Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support JSON Output #104

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { exec } from 'child_process';
import { AuditLevel, CommandOptions } from 'src/types';

import handleInput from './src/handlers/handleInput';
import type { HandleInputCallback } from './src/handlers/handleInput';
import handleFinish from './src/handlers/handleFinish';

import packageJson from './package.json';
Expand All @@ -20,14 +21,18 @@ const program = new Command();
* @param {Array} exceptionIds List of vulnerability IDs to exclude
* @param {Array} modulesToIgnore List of vulnerable modules to ignore in audit results
* @param {Array} columnsToInclude List of columns to include in audit results
* @param {Boolean} outputJson Output audit report in JSON format
* @param {Array} exceptionsReport Exceptions report
*/
export function callback(
export const callback: HandleInputCallback = (
auditCommand: string,
auditLevel: AuditLevel,
exceptionIds: string[],
modulesToIgnore: string[],
columnsToInclude: string[],
): void {
outputJson: boolean,
exceptionsReport: string[][],
): void => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason functions dont use a single object argument? This could be neater but I noticed all other functions provide individual args like this?

// Increase the default max buffer size (1 MB)
const audit = exec(`${auditCommand} --json`, { maxBuffer: MAX_BUFFER_SIZE });

Expand All @@ -40,11 +45,13 @@ export function callback(

// Once the stdout has completed, process the output
if (audit.stderr) {
audit.stderr.on('close', () => handleFinish(jsonBuffer, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude));
audit.stderr.on('close', () =>
handleFinish(jsonBuffer, auditLevel, exceptionIds, modulesToIgnore, columnsToInclude, outputJson, exceptionsReport),
);
// stderr
audit.stderr.on('data', console.error);
}
}
};

program.name(packageJson.name).version(packageJson.version);

Expand All @@ -57,6 +64,7 @@ program
.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.')
.option('-j, --json', 'Output audit report in JSON format')
.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 @@ -17,7 +17,7 @@
},
"scripts": {
"preaudit": "npm run build",
"audit": "node lib audit -x 1064843,1067245",
"audit": "node lib audit -j -x 1064843,1067245",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temporary for testing

"test": "mocha -r ts-node/register test/**/*.test.ts",
"lint": "eslint .",
"qc": "npm run test && npm run lint",
Expand Down
39 changes: 24 additions & 15 deletions src/handlers/handleFinish.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuditLevel } from 'src/types';
import { printSecurityReport } from '../utils/print';
import { printJsonOutput, printSecurityReport } from '../utils/print';
import { processAuditJson, handleUnusedExceptions } from '../utils/vulnerability';

/**
Expand All @@ -9,21 +9,20 @@ import { processAuditJson, handleUnusedExceptions } from '../utils/vulnerability
* @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
* @param {Boolean} outputJson Output audit report in JSON format
* @param {Array} exceptionsReport List of exceptions
*/
export default function handleFinish(
jsonBuffer: string,
auditLevel: AuditLevel,
exceptionIds: string[],
exceptionModules: string[],
columnsToInclude: string[],
outputJson: boolean,
exceptionsReport: string[][],
): void {
const { unhandledIds, report, failed, unusedExceptionIds, unusedExceptionModules } = processAuditJson(
jsonBuffer,
auditLevel,
exceptionIds,
exceptionModules,
columnsToInclude,
);
const result = processAuditJson(jsonBuffer, auditLevel, exceptionIds, exceptionModules, columnsToInclude, outputJson);
const { unhandledIds, report, failed, unusedExceptionIds, unusedExceptionModules } = result;

// If unable to process the audit JSON
if (failed) {
Expand All @@ -34,21 +33,31 @@ export default function handleFinish(
}

// Print the security report
if (report.length) {
printSecurityReport(report, columnsToInclude);
}
if (outputJson) {
printJsonOutput(result, exceptionsReport);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with json output we only print the json, any other logs should be suppressed so the output can be parsed as json

} else {
if (report.length) {
printSecurityReport(report, columnsToInclude);
}

// Handle unused exceptions
handleUnusedExceptions(unusedExceptionIds, unusedExceptionModules);
// Handle unused exceptions
handleUnusedExceptions(unusedExceptionIds, unusedExceptionModules);
}

// Display the found unhandled vulnerabilities
if (unhandledIds.length) {
console.error(`${unhandledIds.length} vulnerabilities found. Node security advisories: ${unhandledIds.join(', ')}`);
if (!outputJson) {
console.error(`${unhandledIds.length} vulnerabilities found. Node security advisories: ${unhandledIds.join(', ')}`);
}

// Exit failed
process.exit(1);
} else {
// Happy happy, joy joy
console.info('🤝 All good!');
if (!outputJson) {
console.info('🤝 All good!');
}

process.exit(0);
}
}
25 changes: 17 additions & 8 deletions src/handlers/handleInput.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import get from 'lodash.get';
import semver from 'semver';
import { AuditLevel, CommandOptions } from 'src/types';
import type { ProcessedReport } from 'src/types';
import { getNpmVersion } from '../utils/npm';
import { readFile } from '../utils/file';
import { getExceptionsIds } from '../utils/vulnerability';
import { getProcessedExceptions } from '../utils/vulnerability';

export type HandleInputCallback = (
auditCommand: string,
auditLevel: AuditLevel,
exceptionIds: string[],
modulesToIgnore: string[],
columnsToInclude: string[],
outputJson: boolean,
exceptionsReport: string[][],
) => void;

/**
* Get the `npm audit` flag to audit only production dependencies.
* @return {String} The flag.
*/
function getProductionOnlyOption() {
function getProductionOnlyOption(): string {
const npmVersion = getNpmVersion();
if (semver.satisfies(npmVersion, '<=8.13.2')) {
return '--production';
Expand All @@ -22,10 +33,7 @@ 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[], T5: string[]) => void,
): void {
export default function handleInput(options: CommandOptions, fn: HandleInputCallback): void {
// Generate NPM Audit command
const auditCommand: string = [
'npm audit',
Expand All @@ -46,12 +54,13 @@ export default function handleInput(
.split(',')
.map((each) => each.trim())
.filter((each) => each !== '');
const exceptionIds: string[] = getExceptionsIds(nsprc, cmdExceptions);
const outputJson: boolean = get(options, 'json', false);
const { exceptionIds, report: exceptionsReport }: ProcessedReport = getProcessedExceptions(nsprc, cmdExceptions, outputJson);
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, cmdIncludeColumns);
fn(auditCommand, auditLevel, exceptionIds, cmdModuleIgnore, cmdIncludeColumns, outputJson, exceptionsReport);
}
15 changes: 15 additions & 0 deletions src/types/general.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuditLevel, Severity } from './level';
import type { ExceptionReportKey, SecurityReportKey } from './table';

export interface CommandOptions {
readonly exclude?: string;
Expand All @@ -7,6 +8,7 @@ export interface CommandOptions {
readonly level?: AuditLevel;
readonly registry?: string;
readonly includeColumns?: string;
readonly json?: boolean;
}

export interface NpmAuditJson {
Expand Down Expand Up @@ -63,6 +65,19 @@ export interface ProcessedResult {
unusedExceptionModules: string[];
}

export interface JsonOutput {
/** Whether there was a failure and the output is not accurate */
readonly failed: boolean;
/** List of all found vulnerabilities */
readonly vulnerabilitiesReport: Record<SecurityReportKey, string>[];
/** List of unhandled vulnerabilities, ie those that cause the audit to fail */
readonly unhandledVulnerabilityIds: string[];
/** List of all exceptions */
readonly exceptionsReport: Record<ExceptionReportKey, string>[];
/** List of unused exceptions */
readonly unusedExceptionIds: string[];
}

export interface ProcessedReport {
readonly exceptionIds: string[];
readonly report: string[][];
Expand Down
6 changes: 6 additions & 0 deletions src/types/table.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
/** Display names for table columns */
export type SecurityReportHeader = 'ID' | 'Module' | 'Title' | 'Paths' | 'Severity' | 'URL' | 'Ex.';
/** Names for object properties */
export type SecurityReportKey = 'id' | 'module' | 'title' | 'paths' | 'severity' | 'url' | 'isExcepted';
/** Display names for table columns */
export type ExceptionReportHeader = 'ID' | 'Status' | 'Expiry' | 'Notes';
/** Names for object properties */
export type ExceptionReportKey = 'id' | 'status' | 'expiry' | 'notes';
5 changes: 3 additions & 2 deletions src/utils/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ const COLORS = <const>{
* @param {String} message Message
* @param {String} fgColor Foreground color
* @param {String} bgColor Background color
* @param {Boolean} condition Condition to color the message, when false it will return the message without color
* @return {String} Message
*/
export function color(message: string, fgColor?: Color, bgColor?: Color): string {
if ('NO_COLOR' in process.env) {
export function color(message: string, fgColor?: Color, bgColor?: Color, condition = true): string {
if ('NO_COLOR' in process.env || !condition) {
return message;
}

Expand Down
74 changes: 69 additions & 5 deletions src/utils/print.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
/* eslint-disable quote-props */
import get from 'lodash.get';
import { table, TableUserConfig } from 'table';
import { SecurityReportHeader, ExceptionReportHeader } from 'src/types';
import type {
SecurityReportHeader,
ExceptionReportHeader,
ProcessedResult,
JsonOutput,
SecurityReportKey,
ExceptionReportKey,
} from 'src/types';

const SECURITY_REPORT_HEADER: SecurityReportHeader[] = ['ID', 'Module', 'Title', 'Paths', 'Severity', 'URL', 'Ex.'];
const EXCEPTION_REPORT_HEADER: ExceptionReportHeader[] = ['ID', 'Status', 'Expiry', 'Notes'];

const SECURITY_REPORT_HEADER_TO_OBJECT_KEY_MAP: Record<SecurityReportHeader, SecurityReportKey> = {
ID: 'id',
Module: 'module',
Title: 'title',
Paths: 'paths',
Severity: 'severity',
URL: 'url',
'Ex.': 'isExcepted',
};

const EXCEPTION_REPORT_HEADER_TO_OBJECT_KEY_MAP: Record<ExceptionReportHeader, ExceptionReportKey> = {
ID: 'id',
Status: 'status',
Expiry: 'expiry',
Notes: 'notes',
};

const SECURITY_REPORT_KEYS: SecurityReportKey[] = SECURITY_REPORT_HEADER.map((header) => SECURITY_REPORT_HEADER_TO_OBJECT_KEY_MAP[header]);
const EXCEPTION_REPORT_KEYS: ExceptionReportKey[] = EXCEPTION_REPORT_HEADER.map(
(header) => EXCEPTION_REPORT_HEADER_TO_OBJECT_KEY_MAP[header],
);

// TODO: Add unit tests
/**
* Get the column width size for the table
Expand Down Expand Up @@ -66,10 +96,10 @@ export function printSecurityReport(data: string[][], columnsToInclude: string[]

/**
* Print the exception report in a table format
* @param {Array} data Array of arrays
* @return {undefined} Returns void
* @param {Array} exceptionsReport Array of arrays
* @return {undefined} Returns void
*/
export function printExceptionReport(data: string[][]): void {
export function printExceptionReport(exceptionsReport: string[][]): void {
const configs: TableUserConfig = {
singleLine: true,
header: {
Expand All @@ -78,5 +108,39 @@ export function printExceptionReport(data: string[][]): void {
},
};

console.info(table([EXCEPTION_REPORT_HEADER, ...data], configs));
console.info(table([EXCEPTION_REPORT_HEADER, ...exceptionsReport], configs));
}

/**
* Print the JSON output
* @param {Object} result The processed result
* @param {Array} exceptionsReport The exceptions report
* @return {undefined} Returns void
*/
export function printJsonOutput(result: ProcessedResult, exceptionsReport: string[][]): void {
const jsonOutput: JsonOutput = {
failed: result.failed ?? false,
unhandledVulnerabilityIds: result.unhandledIds.filter(Boolean),
vulnerabilitiesReport: convertReportTuplesToObjects(result.report, SECURITY_REPORT_KEYS),
exceptionsReport: convertReportTuplesToObjects(exceptionsReport, EXCEPTION_REPORT_KEYS),
unusedExceptionIds: result.unusedExceptionIds.filter(Boolean),
};

console.info(JSON.stringify(jsonOutput, null, 2));
}

/**
* Convert a given report tuple to an object with the given headers
* @param {Array} report Report where each row is a tuple
* @param {Array} elementKeyNames Array of object key names to use for each tuple element
* @return {Array} Report where each row is an object
*/
function convertReportTuplesToObjects<THeader extends string>(report: string[][], elementKeyNames: THeader[]): Record<THeader, string>[] {
return report.map((rowTuple) =>
rowTuple.reduce((rowObj, colValue, i) => {
const header = elementKeyNames[i];
rowObj[header] = colValue;
return rowObj;
}, {} as Record<string, string>),
);
}
Loading