diff --git a/README.md b/README.md index 0f74f70e..61b25f09 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ For complete details on each command, refer to the following documents: - [`cache`](./docs/cli/cache.md) - [`extract integrity`](./docs/cli/extract-integrity.md) - [`stats`](./docs/cli/stats.md) +- [re-highlight](./docs/cli/re-highlight.md) Each link provides access to the full documentation for the command, including additional details, options, and usage examples. diff --git a/bin/index.js b/bin/index.js index fa3577fd..b3154c38 100755 --- a/bin/index.js +++ b/bin/index.js @@ -140,9 +140,18 @@ prog .command("stats") .describe(i18n.getTokenSync("cli.commands.stats.desc")) .option("-m, --min", i18n.getTokenSync("cli.commands.stats.option_min"), undefined) + .option("-o, --output", i18n.getTokenSync("cli.commands.option_output"), "nsecure-result") .example("nsecure stats") .action(commands.stats.main); +prog + .command("re-highlight") + .option("-o, --output", i18n.getTokenSync("cli.commands.option_output"), "nsecure-result") + .option("-c, --contacts", i18n.getTokenSync("cli.commands.option_contacts"), []) + .option("-p, --packages", i18n.getTokenSync("cli.commands.option_packages"), []) + .example("nsecure re-highlight -c sindre sindre@gmail.com -c matteo -p lodash@^4.0.0 -p express@^4.18.0") + .action(commands.reHighlight.main); + prog.parse(process.argv); function defaultScannerCommand(name, options = {}) { diff --git a/docs/cli/re-highlight.md b/docs/cli/re-highlight.md new file mode 100644 index 00000000..f99b98e4 --- /dev/null +++ b/docs/cli/re-highlight.md @@ -0,0 +1,19 @@ +## 📝 Command `re-highlight` + +The `re-highlight` command re-highlights the specified contacts and packages of the previous analysis stored in the JSON file at the root of this project. + +## 📜 Syntax + + +```bash +$ nsecure re-highlight -c sindre sindre@gmail.com -c matteo -p lodash@^4.0.0 -p express@^4.18.0 +``` + + +## ⚙️ Available Options + +| Name | Shortcut | Default Value | Description | +| ------------------------- | -------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--output` | `-o` | `nsecure-result` | Specify the output file to read from. | +| `--contacts` | `-c` | `[]` | List of contacts to highlight. | +| `--packages` | `-p` | `[]` | List of packages to highlight. | diff --git a/docs/cli/stats.md b/docs/cli/stats.md index 18c1959b..85225d4e 100644 --- a/docs/cli/stats.md +++ b/docs/cli/stats.md @@ -23,3 +23,4 @@ $ nsecure stats | Name | Shortcut | Default Value | Description | | ----- | -------- | ------------- | -------------------------------------------------------- | | `--min` | `-m` | `undefined` | Filter API calls with execution time above ceiling (ms) | +| `--output` | `-o` | `nsecure-result` | Specify the output file to read from. diff --git a/i18n/arabic.js b/i18n/arabic.js index df7bd5c9..f54d1dda 100644 --- a/i18n/arabic.js +++ b/i18n/arabic.js @@ -103,6 +103,9 @@ const cli = { option_min: "تصفية استدعاءات API ذات وقت التنفيذ أعلى من الحد المحدد (بالمللي ثانية)", minNotANumber: "خطأ: يجب أن يكون --min رقماً.", statsCeiling: tS`عدد استدعاءات API فوق ${0}: ${1}` + }, + reHighlight: { + error: "يجب إجراء فحص قبل إعادة تمييز جهات الاتصال والحزم." } }, startHttp: { diff --git a/i18n/english.js b/i18n/english.js index e4b26546..080567b9 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -109,6 +109,9 @@ const cli = { option_min: "Filter API calls with execution time above the specified ceiling (in ms)", minNotANumber: "Error: --min must be a number.", statsCeiling: tS`API calls count above ${0}: ${1}` + }, + reHighlight: { + error: "A scan must be performed before re-highlighting contacts and packages." } }, startHttp: { diff --git a/i18n/french.js b/i18n/french.js index 09d25908..0e14ae49 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -109,6 +109,9 @@ const cli = { option_min: "Filtrer les appels API avec un temps d'exécution supérieur au plafond spécifié (en ms)", minNotANumber: "Erreur: --min doit être un nombre.", statsCeiling: tS`Nombre d'appels API au-dessus de ${0}: ${1}` + }, + reHighlight: { + error: "Une analyse doit être effectuée avant de remettre en évidence les contacts et les packages." } }, startHttp: { diff --git a/i18n/turkish.js b/i18n/turkish.js index 74a7ba7c..60572c38 100644 --- a/i18n/turkish.js +++ b/i18n/turkish.js @@ -105,6 +105,9 @@ const cli = { option_min: "Belirtilen tavan değerinin (ms cinsinden) üzerinde yürütme süresine sahip API çağrılarını filtrele", minNotANumber: "Hata: --min bir sayı olmalıdır.", statsCeiling: tS`${0} üzerindeki API çağrıları sayısı: ${1}` + }, + reHighlight: { + error: "Kişileri ve paketleri yeniden vurgulamadan önce bir tarama yapılmalıdır." } }, startHttp: { diff --git a/package.json b/package.json index a5e749ac..7c7955d9 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@nodesecure/ossf-scorecard-sdk": "4.0.1", "@nodesecure/rc": "5.6.0", "@nodesecure/report": "4.2.2", - "@nodesecure/scanner": "10.11.0", + "@nodesecure/scanner": "10.12.0", "@nodesecure/server": "1.0.0", "@nodesecure/utils": "^2.2.0", "@nodesecure/vulnera": "3.1.0", diff --git a/src/commands/index.js b/src/commands/index.js index f0ae1341..ef989c2f 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -9,3 +9,4 @@ export * as report from "./report.js"; export * as cache from "./cache.js"; export * as extractIntegrity from "./extract-integrity.js"; export * as stats from "./stats.js"; +export * as reHighlight from "./re-highlight.js"; diff --git a/src/commands/loggers/logger.js b/src/commands/loggers/logger.js index 5d3e6089..6d53bb34 100644 --- a/src/commands/loggers/logger.js +++ b/src/commands/loggers/logger.js @@ -1,4 +1,10 @@ +// Import Node.js Dependencies +import fs from "node:fs"; +import path from "node:path"; + // Import Third-party Dependencies +import semver from "semver"; +import filenamify from "filenamify"; import * as i18n from "@nodesecure/i18n"; import ms from "ms"; @@ -78,3 +84,69 @@ function colorExecutionTime(timeMs) { return kleur.red().bold(formatted); } + +export async function logAndWrite( + /** @type {import("@nodesecure/scanner").Payload} */ + payload, + output = "nsecure-result", + options = {} +) { + const { local = false, showWarnings = true } = options; + + if (payload === null) { + console.log(i18n.getTokenSync("cli.no_dep_to_proceed")); + + return null; + } + + if (showWarnings && payload.warnings.length > 0) { + console.log(`\n ${kleur.yellow().bold("Global Warning:")}\n`); + const logFn = semver.satisfies(payload.scannerVersion, ">=7.0.0") ? + logGlobalWarningsV7 : + logGlobalWarningsV6; + logFn(payload.warnings); + console.log(""); + } + + const ret = JSON.stringify(payload, null, 2); + + if (local) { + // FIXME: would it make more sense to manage this directly within Scanner? + Object.assign(ret, { local }); + } + + const fileName = path.extname(output) === ".json" ? + filenamify(output) : + `${filenamify(output)}.json`; + const filePath = path.join(process.cwd(), fileName); + fs.writeFileSync(filePath, ret); + + console.log(""); + console.log( + kleur.white().bold(i18n.getTokenSync("cli.successfully_written_json", kleur.green().bold(filePath))) + ); + console.log(""); + + return filePath; +} + +function logGlobalWarningsV7( + /** @type {import("@nodesecure/scanner").GlobalWarning[]} */ + warnings +) { + for (const warning of warnings) { + const isTypoSquatting = warning.type === "typo-squatting"; + + const type = kleur[isTypoSquatting ? "cyan" : "yellow"]().bold(`${warning.type}`); + console.log(kleur.gray().bold(`[${type}] ${warning.message}`)); + } +} + +function logGlobalWarningsV6( + /** @type {string[]} */ + warnings +) { + for (const warning of warnings) { + console.log(kleur.yellow().bold(warning)); + } +} diff --git a/src/commands/re-highlight.js b/src/commands/re-highlight.js new file mode 100644 index 00000000..bec46107 --- /dev/null +++ b/src/commands/re-highlight.js @@ -0,0 +1,41 @@ +// Import Third-party Dependencies +import { Extractors } from "@nodesecure/scanner/extractors"; + +// Import Internal Dependencies +import { getScanFromFile } from "../utils/getScanFromFile.js"; +import { logAndWrite, logError } from "./loggers/logger.js"; +import { parseContacts } from "./parsers/contacts.js"; +import { parsePackages } from "./parsers/packages.js"; + +export async function main(options) { + const { getScanResult = getScanFromFile, logger = { + logAndWrite, + logError + }, contacts, packages, output + } = options; + try { + const scanResult = await getScanResult(output); + + const extractor = new Extractors.Payload(scanResult, [ + new Extractors.Probes.HighlightedContacts(parseContacts(contacts)), + new Extractors.Probes.HighlightedPackages(parsePackages(packages)) + ]); + + const { + highlightedPackages, + illuminated + } = extractor.extractAndMerge(); + + await logger.logAndWrite({ + ...scanResult, + highlighted: { + ...scanResult.highlighted, + contacts: illuminated, + packages: highlightedPackages + } + }, output, { showWarnings: false }); + } + catch { + logger.logError("cli.commands.reHighlight.error"); + } +} diff --git a/src/commands/scanner.js b/src/commands/scanner.js index a3fe9f60..6965c242 100644 --- a/src/commands/scanner.js +++ b/src/commands/scanner.js @@ -1,11 +1,8 @@ // Import Node.js Dependencies import fs from "node:fs"; -import path from "node:path"; import events from "node:events"; // Import Third-party Dependencies -import semver from "semver"; -import filenamify from "filenamify"; import { Spinner } from "@topcli/spinner"; import * as i18n from "@nodesecure/i18n"; import * as scanner from "@nodesecure/scanner"; @@ -17,6 +14,7 @@ import * as http from "./http.js"; import { logScannerStat, logScannerError, + logAndWrite, formatMs } from "./loggers/logger.js"; import { parseContacts } from "./parsers/contacts.js"; @@ -275,69 +273,3 @@ function startSpinners() { } }); } - -async function logAndWrite( - /** @type {import("@nodesecure/scanner").Payload} */ - payload, - output = "nsecure-result", - options = {} -) { - const { local = false } = options; - - if (payload === null) { - console.log(i18n.getTokenSync("cli.no_dep_to_proceed")); - - return null; - } - - if (payload.warnings.length > 0) { - console.log(`\n ${kleur.yellow().bold("Global Warning:")}\n`); - const logFn = semver.satisfies(payload.scannerVersion, ">=7.0.0") ? - logGlobalWarningsV7 : - logGlobalWarningsV6; - logFn(payload.warnings); - console.log(""); - } - - const ret = JSON.stringify(payload, null, 2); - - if (local) { - // FIXME: would it make more sense to manage this directly within Scanner? - Object.assign(ret, { local }); - } - - const fileName = path.extname(output) === ".json" ? - filenamify(output) : - `${filenamify(output)}.json`; - const filePath = path.join(process.cwd(), fileName); - fs.writeFileSync(filePath, ret); - - console.log(""); - console.log( - kleur.white().bold(i18n.getTokenSync("cli.successfully_written_json", kleur.green().bold(filePath))) - ); - console.log(""); - - return filePath; -} - -function logGlobalWarningsV7( - /** @type {import("@nodesecure/scanner").GlobalWarning[]} */ - warnings -) { - for (const warning of warnings) { - const isTypoSquatting = warning.type === "typo-squatting"; - - const type = kleur[isTypoSquatting ? "cyan" : "yellow"]().bold(`${warning.type}`); - console.log(kleur.gray().bold(`[${type}] ${warning.message}`)); - } -} - -function logGlobalWarningsV6( - /** @type {string[]} */ - warnings -) { - for (const warning of warnings) { - console.log(kleur.yellow().bold(warning)); - } -} diff --git a/src/commands/stats.js b/src/commands/stats.js index c1e25471..d9ee218b 100644 --- a/src/commands/stats.js +++ b/src/commands/stats.js @@ -1,12 +1,9 @@ -// Import Node.js Dependencies -import { readFile } from "node:fs/promises"; -import path from "node:path"; - // Import Internal Dependencies import { logScannerStat, logScannerError, log, logError, formatMs } from "./loggers/logger.js"; +import { getScanFromFile } from "../utils/getScanFromFile.js"; export async function main(options) { - const { getScanResult = getScanFromFile, min, logger = { + const { getScanResult = getScanFromFile, min, output, logger = { logScannerStat, logScannerError, log, @@ -20,7 +17,7 @@ export async function main(options) { } try { - const scanResult = await getScanResult(); + const scanResult = await getScanResult(output); const { metadata } = scanResult; logger.log("cli.commands.stats.elapsed", formatMs(metadata.executionTime)); @@ -48,10 +45,3 @@ export async function main(options) { logger.logError("cli.commands.stats.error"); } } - -async function getScanFromFile() { - const projectRootDir = path.join(import.meta.dirname, "..", ".."); - const filePath = path.join(projectRootDir, "nsecure-result.json"); - - return JSON.parse(await readFile(filePath, "utf8")); -} diff --git a/src/utils/getScanFromFile.js b/src/utils/getScanFromFile.js new file mode 100644 index 00000000..1fdf0b8f --- /dev/null +++ b/src/utils/getScanFromFile.js @@ -0,0 +1,16 @@ +// Import Node.js Dependencies +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +// Import Third-party Dependencies +import filenamify from "filenamify"; + +export async function getScanFromFile(output = "nsecure-result") { + const fileName = path.extname(output) === ".json" ? + filenamify(output) : + `${filenamify(output)}.json`; + + const filePath = path.join(process.cwd(), fileName); + + return JSON.parse(await readFile(filePath, "utf8")); +} diff --git a/test/commands/re-highlight.test.js b/test/commands/re-highlight.test.js new file mode 100644 index 00000000..802a9910 --- /dev/null +++ b/test/commands/re-highlight.test.js @@ -0,0 +1,85 @@ +// Import Node.js Dependencies +import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, test } from "node:test"; + +// Import Internal Dependencies +import { main } from "../../src/commands/re-highlight.js"; + +describe("re-hightlight", () => { + test("should display an error when there is no scan", async(t) => { + async function getScanResult() { + throw new Error("file does not exist"); + } + + const logger = { + logError: t.mock.fn(), + logAndWrite: t.mock.fn() + }; + + await main({ + getScanResult, + logger, + contacts: ["TJ Holowaychuk"], + packages: ["crc@0.2.0"], + output: "some-file" + }); + + assert.deepEqual(logger.logError.mock.calls[0].arguments, ["cli.commands.reHighlight.error"]); + assert.strictEqual(logger.logAndWrite.mock.callCount(), 0); + }); + test("should rehighlight contacts and packages", async(t) => { + const scanResult = JSON.parse(await readFile(path.join(import.meta.dirname, "..", "fixtures", "result-test1.json"), "utf8")); + + let expectedOutput; + + async function getScanResult(output) { + expectedOutput = output; + + return Promise.resolve(scanResult); + } + + const logger = { + logError: t.mock.fn(), + logAndWrite: t.mock.fn() + }; + + await main({ + getScanResult, + logger, + contacts: ["TJ Holowaychuk"], + packages: ["crc@0.2.0"], + output: "some-file" + }); + + assert.strictEqual(logger.logError.mock.callCount(), 0); + assert.strictEqual(expectedOutput, "some-file"); + assert.deepEqual(logger.logAndWrite.mock.calls[0].arguments, [{ + ...scanResult, + highlighted: { + contacts: [ + { + name: "TJ Holowaychuk", + dependencies: [ + "fresh", + "range-parser", + "commander", + "send", + "bytes", + "pause", + "connect", + "express" + ] + } + + ], + packages: ["crc@0.2.0"], + identifiers: ["example@gmail.com"] + } + }, "some-file", { + showWarnings: false + }]); + }); +}); + diff --git a/test/commands/stats.test.js b/test/commands/stats.test.js index 34e97d70..e8ee7221 100644 --- a/test/commands/stats.test.js +++ b/test/commands/stats.test.js @@ -152,10 +152,14 @@ describe("stats", () => { }, false]); }); - test("should not disply the ceiling log if api calls count and api calls count above min are the same", async(t) => { + test("should not display the ceiling log if api calls count and api calls count above min are the same", async(t) => { const scanResult = JSON.parse(await readFile(path.join(import.meta.dirname, "..", "fixtures", "result-test3.json"), "utf8")); - async function getScanResult() { + let expectedOuput; + + async function getScanResult(output) { + expectedOuput = output; + return Promise.resolve(scanResult); } @@ -169,12 +173,14 @@ describe("stats", () => { await main({ getScanResult, logger, - min: 10 + min: 10, + output: "some-file" }); assert.deepEqual(logger.log.mock.calls[0].arguments, ["cli.commands.stats.elapsed", "771ms"]); assert.deepEqual(logger.log.mock.calls[1].arguments, ["cli.commands.stats.stats", 3]); assert.deepEqual(logger.log.mock.calls[2].arguments, ["cli.commands.stats.errors", 2]); + assert.deepEqual(expectedOuput, "some-file"); }); test("should log error when min parameter is not a number", async(t) => { diff --git a/test/fixtures/result-test1.json b/test/fixtures/result-test1.json index 465573b8..a385ea0a 100644 --- a/test/fixtures/result-test1.json +++ b/test/fixtures/result-test1.json @@ -9,7 +9,20 @@ "vulnerabilityStrategy": "none", "warnings": [], "highlighted": { - "contacts": [] + "contacts": [ + { + "name": "Alex Gorbatchev", + "dependencies": [ + "crc" + ] + } + ], + "packages": [ + "cookie@0.0.4" + ], + "identifiers": [ + "example@gmail.com" + ] }, "dependencies": { "crc": {