diff --git a/bin/index.js b/bin/index.js index d230b632..ed27e4f3 100755 --- a/bin/index.js +++ b/bin/index.js @@ -139,6 +139,7 @@ prog prog .command("stats") .describe(i18n.getTokenSync("cli.commands.stats.desc")) + .option("-m, --min", i18n.getTokenSync("cli.commands.stats.option_min"), undefined) .example("nsecure stats") .action(commands.stats.main); diff --git a/docs/cli/stats.md b/docs/cli/stats.md index 27bf143e..18c1959b 100644 --- a/docs/cli/stats.md +++ b/docs/cli/stats.md @@ -17,3 +17,9 @@ The `stats` displays the statistics of the last performed scan such as : ```bash $ nsecure stats ``` + +## ⚙️ Available Options + +| Name | Shortcut | Default Value | Description | +| ----- | -------- | ------------- | -------------------------------------------------------- | +| `--min` | `-m` | `undefined` | Filter API calls with execution time above ceiling (ms) | diff --git a/i18n/arabic.js b/i18n/arabic.js index 25227d35..2ad93550 100644 --- a/i18n/arabic.js +++ b/i18n/arabic.js @@ -94,7 +94,10 @@ const cli = { elapsed: tS`مدة المسح: ${0}`, stats: tS`عدد استدعاءات API: ${0}`, error: "يجب إجراء مسح قبل عرض الإحصائيات.", - errors: tS`عدد الأخطاء: ${0}` + errors: tS`عدد الأخطاء: ${0}`, + option_min: "تصفية استدعاءات API ذات وقت التنفيذ أعلى من الحد المحدد (بالمللي ثانية)", + minNotANumber: "خطأ: يجب أن يكون --min رقماً.", + statsCeiling: tS`عدد استدعاءات API فوق ${0}: ${1}` } }, startHttp: { diff --git a/i18n/english.js b/i18n/english.js index 3ecf258a..cc240af2 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -97,7 +97,10 @@ const cli = { elapsed: tS`Scan duration: ${0}`, stats: tS`API calls count: ${0}`, error: "A scan must be performed before displaying stats.", - errors: tS`Error count: ${0}` + errors: tS`Error count: ${0}`, + 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}` } }, startHttp: { diff --git a/i18n/french.js b/i18n/french.js index 1744b5b2..39176f3e 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -97,7 +97,10 @@ const cli = { elapsed: tS`Durée du scan: ${0}`, stats: tS`Nombre d'appels API: ${0}`, error: "Un scan doit être effectué avant d'afficher les statistiques.", - errors: tS`Nombre d'erreurs: ${0}` + errors: tS`Nombre d'erreurs: ${0}`, + 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}` } }, startHttp: { diff --git a/i18n/turkish.js b/i18n/turkish.js index 636db10c..7e438ec8 100644 --- a/i18n/turkish.js +++ b/i18n/turkish.js @@ -96,7 +96,10 @@ const cli = { elapsed: tS`Tarama süresi: ${0}`, stats: tS`API çağrı sayısı: ${0}`, error: "İstatistikleri görüntülemeden önce bir tarama yapılmalıdır.", - errors: tS`Hata sayısı: ${0}` + errors: tS`Hata sayısı: ${0}`, + 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}` } }, startHttp: { diff --git a/src/commands/stats.js b/src/commands/stats.js index a8cd5051..c1e25471 100644 --- a/src/commands/stats.js +++ b/src/commands/stats.js @@ -6,21 +6,36 @@ import path from "node:path"; import { logScannerStat, logScannerError, log, logError, formatMs } from "./loggers/logger.js"; export async function main(options) { - const { getScanResult = getScanFromFile, logger = { + const { getScanResult = getScanFromFile, min, logger = { logScannerStat, logScannerError, log, logError } } = options; + + if (min !== undefined && typeof min !== "number") { + logger.logError("cli.commands.stats.minNotANumber"); + + return; + } + try { const scanResult = await getScanResult(); const { metadata } = scanResult; logger.log("cli.commands.stats.elapsed", formatMs(metadata.executionTime)); logger.log("cli.commands.stats.stats", metadata.apiCallsCount); - metadata.apiCalls.forEach((call) => { + const apiCallsToLog = min === undefined ? + metadata.apiCalls : metadata.apiCalls.filter(({ executionTime }) => executionTime > min); + + if (typeof min === "number" && apiCallsToLog.length !== metadata.apiCallsCount) { + logger.log("cli.commands.stats.statsCeiling", formatMs(min), apiCallsToLog.length); + } + + apiCallsToLog.forEach((call) => { logger.logScannerStat(call, false); }); + if (metadata.errorCount === 0) { return; } diff --git a/test/commands/stats.test.js b/test/commands/stats.test.js index a6f7ad27..34e97d70 100644 --- a/test/commands/stats.test.js +++ b/test/commands/stats.test.js @@ -115,5 +115,91 @@ describe("stats", () => { }, false]); assert.equal(logger.log.mock.calls[2], undefined); }); + + test("should filter API calls when min parameter is provided", async(t) => { + const scanResult = JSON.parse(await readFile(path.join(import.meta.dirname, "..", "fixtures", "result-test3.json"), "utf8")); + + async function getScanResult() { + return Promise.resolve(scanResult); + } + + const logger = { + logScannerStat: t.mock.fn(), + logScannerError: t.mock.fn(), + log: t.mock.fn(), + logError: t.mock.fn() + }; + + await main({ + getScanResult, + logger, + min: 50 + }); + + 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.statsCeiling", "50ms", 2]); + assert.deepEqual(logger.logScannerStat.mock.calls.length, 2); + assert.deepEqual(logger.logScannerStat.mock.calls[0].arguments, [{ + name: "pacote.extract react@19.2.4", + startedAt: 1774601089529, + executionTime: 83 + }, false]); + assert.deepEqual(logger.logScannerStat.mock.calls[1].arguments, [{ + name: "tarball.scanDirOrArchive react@19.2.4", + startedAt: 1774601089612, + executionTime: 247 + }, false]); + }); + + test("should not disply 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() { + return Promise.resolve(scanResult); + } + + const logger = { + logScannerStat: t.mock.fn(), + logScannerError: t.mock.fn(), + log: t.mock.fn(), + logError: t.mock.fn() + }; + + await main({ + getScanResult, + logger, + min: 10 + }); + + 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]); + }); + + test("should log error when min parameter is not a number", async(t) => { + const scanResult = JSON.parse(await readFile(path.join(import.meta.dirname, "..", "fixtures", "result-test3.json"), "utf8")); + + async function getScanResult() { + return Promise.resolve(scanResult); + } + + const logger = { + logScannerStat: t.mock.fn(), + logScannerError: t.mock.fn(), + log: t.mock.fn(), + logError: t.mock.fn() + }; + + await main({ + getScanResult, + logger, + min: "not-a-number" + }); + + assert.deepEqual(logger.logError.mock.calls[0].arguments, ["cli.commands.stats.minNotANumber"]); + assert.strictEqual(logger.logScannerStat.mock.callCount(), 0); + assert.strictEqual(logger.log.mock.callCount(), 0); + }); });