Skip to content

Commit

Permalink
feat: Add support for including specific columns in audit report
Browse files Browse the repository at this point in the history
- 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`
  • Loading branch information
Gaurav Chinawale committed Aug 31, 2024
1 parent adf6857 commit ab6e44c
Show file tree
Hide file tree
Showing 11 changed files with 120 additions and 44 deletions.
14 changes: 11 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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);
}
Expand All @@ -49,6 +56,7 @@ program
.option('-l, --level <auditLevel>', 'The minimum audit level to validate.')
.option('-p, --production', 'Skip checking the devDependencies.')
.option('-r, --registry <url>', 'The npm registry url to use.')
.option('-i, --include-columns <columnName1>,<columnName2>,..,<columnNameN>', 'Columns to include in report.')
.action((options: CommandOptions) => handleInput(options, callback));

program.parse(process.argv);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions src/handlers/handleFinish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/handlers/handleInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions src/types/general.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface CommandOptions {
readonly production?: boolean;
readonly level?: AuditLevel;
readonly registry?: string;
readonly includeColumns?: string;
}

export interface NpmAuditJson {
Expand Down
2 changes: 1 addition & 1 deletion src/types/table.d.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 7 additions & 5 deletions src/utils/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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));
}

/**
Expand Down
56 changes: 34 additions & 22 deletions src/utils/vulnerability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ 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(
jsonBuffer = '',
auditLevel: AuditLevel = 'info',
exceptionIds: string[] = [],
exceptionModules: string[] = [],
columnsToInclude: string[] = [],
): ProcessedResult {
if (!isJsonString(jsonBuffer)) {
return {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
33 changes: 33 additions & 0 deletions test/handlers/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
});
});
});
17 changes: 11 additions & 6 deletions test/handlers/handleFinish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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.',
Expand Down
4 changes: 2 additions & 2 deletions test/utils/print.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down

0 comments on commit ab6e44c

Please sign in to comment.