From ab6e44c34d4c2be83bcac25e69382183facf2dbb Mon Sep 17 00:00:00 2001 From: Gaurav Chinawale Date: Sat, 31 Aug 2024 15:19:34 +0530 Subject: [PATCH 1/2] feat: Add support for including specific columns in audit report - Implemented the `-i` flag to specify columns to include in the audit report - Now, users can choose to include selected columns like Module, Title, and Severity by using the `-i` option - Example usage: `better-npm-audit audit -i Module,Title,Severity` --- index.ts | 14 ++++++-- package.json | 2 +- src/handlers/handleFinish.ts | 12 +++++-- src/handlers/handleInput.ts | 11 ++++-- src/types/general.d.ts | 1 + src/types/table.d.ts | 2 +- src/utils/print.ts | 12 ++++--- src/utils/vulnerability.ts | 56 ++++++++++++++++++------------ test/handlers/flags.test.ts | 33 ++++++++++++++++++ test/handlers/handleFinish.test.ts | 17 +++++---- test/utils/print.test.ts | 4 +-- 11 files changed, 120 insertions(+), 44 deletions(-) diff --git a/index.ts b/index.ts index f3a19a4..5ab2838 100755 --- a/index.ts +++ b/index.ts @@ -18,9 +18,16 @@ const program = new Command(); * @param {String} auditCommand The NPM audit command to use (with flags) * @param {String} auditLevel The level of vulnerabilities we care about * @param {Array} exceptionIds List of vulnerability IDs to exclude - * @param {Array} modulesToIgnore List of vulnerable modules to ignore in audit results + * @param {Array} modulesToIgnore List of vulnerable modules to ignore in audit results + * @param {Array} columnsToInclude List of columns to include in audit results */ -export function callback(auditCommand: string, auditLevel: AuditLevel, exceptionIds: string[], modulesToIgnore: string[]): void { +export function callback( + auditCommand: string, + auditLevel: AuditLevel, + exceptionIds: string[], + modulesToIgnore: string[], + columnsToInclude: string[], +): void { // Increase the default max buffer size (1 MB) const audit = exec(`${auditCommand} --json`, { maxBuffer: MAX_BUFFER_SIZE }); @@ -33,7 +40,7 @@ export function callback(auditCommand: string, auditLevel: AuditLevel, exception // Once the stdout has completed, process the output if (audit.stderr) { - audit.stderr.on('close', () => handleFinish(jsonBuffer, auditLevel, exceptionIds, modulesToIgnore)); + audit.stderr.on('close', () => handleFinish(jsonBuffer, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude)); // stderr audit.stderr.on('data', console.error); } @@ -49,6 +56,7 @@ program .option('-l, --level ', 'The minimum audit level to validate.') .option('-p, --production', 'Skip checking the devDependencies.') .option('-r, --registry ', 'The npm registry url to use.') + .option('-i, --include-columns ,,..,', 'Columns to include in report.') .action((options: CommandOptions) => handleInput(options, callback)); program.parse(process.argv); diff --git a/package.json b/package.json index dea6165..b14a15a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "lint": "eslint .", "qc": "npm run test && npm run lint", "clean": "rimraf lib", - "prebuild": "npm run qc && npm run clean", + "build": "tsc", "postbuild": "cp README.md lib && chmod +x ./lib/index.js", "publish:live": "npm run build && npm publish ./lib --tag latest", diff --git a/src/handlers/handleFinish.ts b/src/handlers/handleFinish.ts index 8275109..3f4c6ce 100644 --- a/src/handlers/handleFinish.ts +++ b/src/handlers/handleFinish.ts @@ -8,13 +8,21 @@ import { processAuditJson, handleUnusedExceptions } from '../utils/vulnerability * @param {Number} auditLevel The level of vulnerabilities we care about * @param {Array} exceptionIds List of vulnerability IDs to exclude * @param {Array} exceptionModules List of vulnerable modules to ignore in audit results + * @param {Array} columnsToInclude List of columns to include in audit results */ -export default function handleFinish(jsonBuffer: string, auditLevel: AuditLevel, exceptionIds: string[], exceptionModules: string[]): void { +export default function handleFinish( + jsonBuffer: string, + auditLevel: AuditLevel, + exceptionIds: string[], + exceptionModules: string[], + columnsToInclude: string[], +): void { const { unhandledIds, report, failed, unusedExceptionIds, unusedExceptionModules } = processAuditJson( jsonBuffer, auditLevel, exceptionIds, exceptionModules, + columnsToInclude, ); // If unable to process the audit JSON @@ -27,7 +35,7 @@ export default function handleFinish(jsonBuffer: string, auditLevel: AuditLevel, // Print the security report if (report.length) { - printSecurityReport(report); + printSecurityReport(report, columnsToInclude); } // Handle unused exceptions diff --git a/src/handlers/handleInput.ts b/src/handlers/handleInput.ts index e24032f..9219324 100644 --- a/src/handlers/handleInput.ts +++ b/src/handlers/handleInput.ts @@ -22,7 +22,10 @@ function getProductionOnlyOption() { * @param {Object} options User's command options or flags * @param {Function} fn The function to handle the inputs */ -export default function handleInput(options: CommandOptions, fn: (T1: string, T2: AuditLevel, T3: string[], T4: string[]) => void): void { +export default function handleInput( + options: CommandOptions, + fn: (T1: string, T2: AuditLevel, T3: string[], T4: string[], T5: string[]) => void, +): void { // Generate NPM Audit command const auditCommand: string = [ 'npm audit', @@ -45,6 +48,10 @@ export default function handleInput(options: CommandOptions, fn: (T1: string, T2 .filter((each) => each !== ''); const exceptionIds: string[] = getExceptionsIds(nsprc, cmdExceptions); const cmdModuleIgnore: string[] = get(options, 'moduleIgnore', '').split(','); + const cmdIncludeColumns: string[] = get(options, 'includeColumns', '') + .split(',') + .map((each: string) => each.trim()) + .filter((each: string) => !!each); - fn(auditCommand, auditLevel, exceptionIds, cmdModuleIgnore); + fn(auditCommand, auditLevel, exceptionIds, cmdModuleIgnore, cmdIncludeColumns); } diff --git a/src/types/general.d.ts b/src/types/general.d.ts index 5f478a3..e4e2552 100644 --- a/src/types/general.d.ts +++ b/src/types/general.d.ts @@ -6,6 +6,7 @@ export interface CommandOptions { readonly production?: boolean; readonly level?: AuditLevel; readonly registry?: string; + readonly includeColumns?: string; } export interface NpmAuditJson { diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 0040e0a..f974ea3 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -1,2 +1,2 @@ -export type SecurityReportHeader = 'ID' | 'Module' | 'Title' | 'Paths' | 'Sev.' | 'URL' | 'Ex.'; +export type SecurityReportHeader = 'ID' | 'Module' | 'Title' | 'Paths' | 'Severity' | 'URL' | 'Ex.'; export type ExceptionReportHeader = 'ID' | 'Status' | 'Expiry' | 'Notes'; diff --git a/src/utils/print.ts b/src/utils/print.ts index 1c68de9..9f433ac 100644 --- a/src/utils/print.ts +++ b/src/utils/print.ts @@ -2,7 +2,7 @@ import get from 'lodash.get'; import { table, TableUserConfig } from 'table'; import { SecurityReportHeader, ExceptionReportHeader } from 'src/types'; -const SECURITY_REPORT_HEADER: SecurityReportHeader[] = ['ID', 'Module', 'Title', 'Paths', 'Sev.', 'URL', 'Ex.']; +const SECURITY_REPORT_HEADER: SecurityReportHeader[] = ['ID', 'Module', 'Title', 'Paths', 'Severity', 'URL', 'Ex.']; const EXCEPTION_REPORT_HEADER: ExceptionReportHeader[] = ['ID', 'Status', 'Expiry', 'Notes']; // TODO: Add unit tests @@ -35,10 +35,11 @@ export function getColumnWidth(tableData: string[][], columnIndex: number, maxWi /** * Print the security report in a table format - * @param {Array} data Array of arrays - * @return {undefined} Returns void + * @param {Array} data Array of arrays + * @return {undefined} Returns void + * @param {Array} columnsToInclude List of columns to include in audit results */ -export function printSecurityReport(data: string[][]): void { +export function printSecurityReport(data: string[][], columnsToInclude: string[]): void { const configs: TableUserConfig = { singleLine: true, header: { @@ -58,8 +59,9 @@ export function printSecurityReport(data: string[][]): void { }, }, }; + const headers = columnsToInclude.length ? SECURITY_REPORT_HEADER.filter((h) => columnsToInclude.includes(h)) : SECURITY_REPORT_HEADER; - console.info(table([SECURITY_REPORT_HEADER, ...data], configs)); + console.info(table([headers, ...data], configs)); } /** diff --git a/src/utils/vulnerability.ts b/src/utils/vulnerability.ts index 4d82be6..1feddf7 100644 --- a/src/utils/vulnerability.ts +++ b/src/utils/vulnerability.ts @@ -114,6 +114,7 @@ export function validateV7Vulnerability( * @param {String} auditLevel User's target audit level * @param {Array} exceptionIds Exception IDs (ID to be ignored) * @param {Array} exceptionModules Exception modules (modules to be ignored) + * @param {Array} columnsToInclude List of columns to include in audit results * @return {Object} Processed vulnerabilities details */ export function processAuditJson( @@ -121,6 +122,7 @@ export function processAuditJson( auditLevel: AuditLevel = 'info', exceptionIds: string[] = [], exceptionModules: string[] = [], + columnsToInclude: string[] = [], ): ProcessedResult { if (!isJsonString(jsonBuffer)) { return { @@ -156,22 +158,28 @@ export function processAuditJson( acc.unusedExceptionModules = acc.unusedExceptionModules.filter((module) => module !== cur.module_name); } - // Record this vulnerability into the report, and highlight it using yellow color if it's new - acc.report.push([ - color(cur.id.toString(), isExcepted ? '' : 'yellow'), - color(cur.module_name, isExcepted ? '' : 'yellow'), - color(cur.title, isExcepted ? '' : 'yellow'), - color( - trimArray( + const rowData = [ + { key: 'ID', value: cur.id.toString() }, + { key: 'Module', value: cur.module_name }, + { key: 'Title', value: cur.title }, + { + key: 'Paths', + value: trimArray( cur.findings.reduce((a, c) => [...a, ...c.paths] as [], []), MAX_PATHS_SIZE, ).join('\n'), - isExcepted ? '' : 'yellow', - ), - color(cur.severity, isExcepted ? '' : 'yellow', getSeverityBgColor(cur.severity)), - color(cur.url, isExcepted ? '' : 'yellow'), - isExcepted ? 'y' : color('n', 'yellow'), - ]); + }, + { key: 'Severity', value: cur.severity }, + { key: 'URL', value: cur.url }, + { key: 'Ex.', value: isExcepted ? 'y' : 'n' }, + ] + .filter(({ key }) => (columnsToInclude.length ? columnsToInclude.includes(key) : true)) + .map(({ key, value }) => + color(value, isExcepted ? '' : 'yellow', key === 'Severity' ? getSeverityBgColor(cur.severity) : undefined), + ); + + // Record this vulnerability into the report, and highlight it using yellow color if it's new + acc.report.push(rowData); acc.vulnerabilityIds.push(cur.id.toString()); if (!acc.vulnerabilityModules.includes(cur.module_name)) { @@ -224,16 +232,20 @@ export function processAuditJson( acc.unusedExceptionModules = acc.unusedExceptionModules.filter((module) => module !== moduleName); } + const rowData = [ + { key: 'ID', value: String(id) }, + { key: 'Module', value: vul.name }, + { key: 'Title', value: vul.title }, + { key: 'Paths', value: trimArray(get(cur, 'nodes', []).map(shortenNodePath), MAX_PATHS_SIZE).join('\n') }, + { key: 'Severity', value: vul.severity, bgColor: getSeverityBgColor(vul.severity) }, + { key: 'URL', value: vul.url }, + { key: 'Ex.', value: isExcepted ? 'y' : 'n' }, + ] + .filter(({ key }) => (columnsToInclude.length ? columnsToInclude.includes(key) : true)) + .map(({ key, value, bgColor }) => color(value, isExcepted ? '' : 'yellow', key === 'Severity' ? bgColor : undefined)); + // Record this vulnerability into the report, and highlight it using yellow color if it's new - acc.report.push([ - color(String(id), isExcepted ? '' : 'yellow'), - color(vul.name, isExcepted ? '' : 'yellow'), - color(vul.title, isExcepted ? '' : 'yellow'), - color(trimArray(get(cur, 'nodes', []).map(shortenNodePath), MAX_PATHS_SIZE).join('\n'), isExcepted ? '' : 'yellow'), - color(vul.severity, isExcepted ? '' : 'yellow', getSeverityBgColor(vul.severity)), - color(vul.url, isExcepted ? '' : 'yellow'), - isExcepted ? 'y' : color('n', 'yellow'), - ]); + acc.report.push(rowData); acc.vulnerabilityIds.push(String(id)); if (!acc.vulnerabilityModules.includes(moduleName)) { diff --git a/test/handlers/flags.test.ts b/test/handlers/flags.test.ts index 8268b71..ae1b36d 100644 --- a/test/handlers/flags.test.ts +++ b/test/handlers/flags.test.ts @@ -199,6 +199,7 @@ describe('Flags', () => { // with space options.moduleIgnore = 'lodash, moment'; + handleInput(options, callbackStub); expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore)).to.equal(true); @@ -213,4 +214,36 @@ describe('Flags', () => { expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore)).to.equal(true); }); }); + + describe('--include-columns', () => { + it('should be able to pass column names using the command flag smoothly', () => { + const callbackStub = sinon.stub(); + const options = { includeColumns: 'ID,Module' }; + const auditCommand = 'npm audit'; + const auditLevel = 'info'; + const exceptionIds: string[] = []; + const modulesToIgnore: string[] = ['']; + const columnsToInclude = ['ID', 'Module']; + + expect(callbackStub.called).to.equal(false); + handleInput(options, callbackStub); + expect(callbackStub.called).to.equal(true); + expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude)).to.equal(true); + + // with space + options.includeColumns = 'ID, Module'; + handleInput(options, callbackStub); + expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude)).to.equal(true); + + // invalid exceptions + options.includeColumns = 'ID,undefined,Module'; + handleInput(options, callbackStub); + expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude)).to.equal(true); + + // invalid null + options.includeColumns = 'ID,null,Module'; + handleInput(options, callbackStub); + expect(callbackStub.calledWith(auditCommand, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude)).to.equal(true); + }); + }); }); diff --git a/test/handlers/handleFinish.test.ts b/test/handlers/handleFinish.test.ts index 85a56fc..2f306be 100644 --- a/test/handlers/handleFinish.test.ts +++ b/test/handlers/handleFinish.test.ts @@ -14,11 +14,12 @@ describe('Events handling', () => { const auditLevel = 'info'; const exceptionIds: string[] = []; const exceptionModules: string[] = []; + const columnsToInclude: string[] = []; expect(processStub.called).to.equal(false); expect(consoleStub.called).to.equal(false); - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); expect(processStub.called).to.equal(true); expect(processStub.calledWith(1)).to.equal(true); @@ -37,9 +38,10 @@ describe('Events handling', () => { const auditLevel = 'info'; const exceptionIds: string[] = []; const exceptionModules: string[] = []; + const columnsToInclude: string[] = []; expect(consoleStub.called).to.equal(false); - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); expect(processStub.called).to.equal(true); expect(processStub.calledWith(0)).to.equal(true); @@ -58,9 +60,10 @@ describe('Events handling', () => { const auditLevel = 'info'; const exceptionIds = ['975', '985', '1179', '1213', '1500', '1523', '1555', '1556', '1589']; const exceptionModules = ['swagger-ui', 'mem']; + const columnsToInclude: string[] = []; expect(consoleStub.called).to.equal(false); - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); expect(processStub.called).to.equal(true); expect(processStub.calledWith(0)).to.equal(true); @@ -80,12 +83,13 @@ describe('Events handling', () => { const auditLevel = 'info'; const exceptionIds = ['975', '976', '985', '1084', '1179', '1213', '1500', '1523', '1555']; const exceptionModules: string[] = []; + const columnsToInclude: string[] = []; expect(processStub.called).to.equal(false); expect(consoleErrorStub.called).to.equal(false); expect(consoleInfoStub.called).to.equal(false); - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); expect(processStub.called).to.equal(true); expect(consoleErrorStub.called).to.equal(true); @@ -108,13 +112,14 @@ describe('Events handling', () => { const auditLevel = 'info'; let exceptionModules = ['fakeModule1', 'fakeModule2']; let exceptionIds = ['975', '976', '985', '1084', '1179', '1213', '1500', '1523', '1555', '2001']; + const columnsToInclude: string[] = []; expect(processStub.called).to.equal(false); expect(consoleErrorStub.called).to.equal(false); expect(consoleWarnStub.called).to.equal(false); expect(consoleInfoStub.called).to.equal(false); - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); expect(processStub.called).to.equal(true); expect(processStub.calledWith(1)).to.equal(true); @@ -136,7 +141,7 @@ describe('Events handling', () => { // Message for multiple unused exceptions exceptionIds = ['975', '976', '985', '1084', '1179', '1213', '1500', '1523', '1555', '2001', '2002']; exceptionModules = ['fakeModule1']; - handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules); + handleFinish(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude); message = [ '2 of the excluded vulnerabilities did not match any of the found vulnerabilities: 2001, 2002.', 'They can be removed from the .nsprc file or --exclude -x flags.', diff --git a/test/utils/print.test.ts b/test/utils/print.test.ts index 3439dc7..18d5f5e 100644 --- a/test/utils/print.test.ts +++ b/test/utils/print.test.ts @@ -6,11 +6,11 @@ import V7_SECURITY_REPORT_TABLE_DATA from '../__mocks__/v7-security-report-table describe('Print utils', () => { it('v6 security report table visual', () => { - printSecurityReport(V6_SECURITY_REPORT_TABLE_DATA); + printSecurityReport(V6_SECURITY_REPORT_TABLE_DATA, []); }); it('v7 security report table visual', () => { - printSecurityReport(V7_SECURITY_REPORT_TABLE_DATA); + printSecurityReport(V7_SECURITY_REPORT_TABLE_DATA, []); }); it('exception table visual', () => { From a0eb2a1e2e0ee7f57b3c4f2624f3c42477043434 Mon Sep 17 00:00:00 2001 From: Gaurav Chinawale Date: Tue, 3 Sep 2024 11:22:40 +0530 Subject: [PATCH 2/2] chore: apply review changes - Added `-i` flag details to README.md - Retained the `prebuild` command - Performed `npm audit fix` to address vulnerabilities --- README.md | 15 ++++++++------- package-lock.json | 9 +++++---- package.json | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f1b0b56..7d1988c 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,14 @@ npm run audit ## Options -| Flag | Short | Description | -| ----------------- | ----- | ----------------------------------------------------------------------------------------------------- | -| `--exclude` | `-x` | Exceptions or the vulnerabilities ID(s) to exclude; the ID can be the numeric ID, CVE, CWE or GHSA ID | -| `--module-ignore` | `-m` | Names of modules to exclude | -| `--level` | `-l` | The minimum audit level to validate; Same as the original `--audit-level` flag | -| `--production` | `-p` | Skip the `devDependencies` | -| `--registry` | `-r` | The npm registry url to use | +| Flag | Short | Description | +| --------------------| ----- | ----------------------------------------------------------------------------------------------------- | +| `--exclude` | `-x` | Exceptions or the vulnerabilities ID(s) to exclude; the ID can be the numeric ID, CVE, CWE or GHSA ID | +| `--module-ignore` | `-m` | Names of modules to exclude | +| `--level` | `-l` | The minimum audit level to validate; Same as the original `--audit-level` flag | +| `--production` | `-p` | Skip the `devDependencies` | +| `--registry` | `-r` | The npm registry url to use | +| `--include-columns` | `-i` | Columns to include in report |
diff --git a/package-lock.json b/package-lock.json index c77f4f0..8bb5017 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1659,12 +1659,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.4", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT", "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" diff --git a/package.json b/package.json index b14a15a..dea6165 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "lint": "eslint .", "qc": "npm run test && npm run lint", "clean": "rimraf lib", - + "prebuild": "npm run qc && npm run clean", "build": "tsc", "postbuild": "cp README.md lib && chmod +x ./lib/index.js", "publish:live": "npm run build && npm publish ./lib --tag latest",