Skip to content

Commit

Permalink
feat: add --json-file-output option for snyk test
Browse files Browse the repository at this point in the history
  • Loading branch information
maxjeffos committed May 25, 2020
1 parent 6e6e8c5 commit 4de2ebb
Show file tree
Hide file tree
Showing 18 changed files with 900 additions and 109 deletions.
4 changes: 4 additions & 0 deletions help/help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ Options:
Upgradable fails when there is at least one vulnerability that can be upgraded.
Patchable fails when there is at least one vulnerability that can be patched.
If vulnerabilities do not have a fix and this option is being used tests will pass.
--json-file-output=<string>
(test command only)
Save test output in JSON format directly to the specified file, regardless of whether or not you use the `--json` option.
This is especially useful if you want to display the human-readable test output via stdout and at the same time save the JSON format output to a file.

Maven options:
--scan-all-unmanaged
Expand Down
5 changes: 3 additions & 2 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as abbrev from 'abbrev';
import { CommandResult } from './commands/types';

import debugModule = require('debug');
import { parseMode } from './modes';
Expand Down Expand Up @@ -39,7 +40,7 @@ function dashToCamelCase(dash) {
// Last item is ArgsOptions, the rest are strings (positional arguments, e.g. paths)
export type MethodArgs = Array<string | ArgsOptions>;

export type Method = (...args: MethodArgs) => Promise<string>;
export type Method = (...args: MethodArgs) => Promise<CommandResult | string>;

export interface Args {
command: string;
Expand Down Expand Up @@ -148,7 +149,7 @@ export function args(rawArgv: string[]): Args {
argv._.unshift(tmp.shift()!);
}

let method: () => Promise<string> = cli[command];
let method: () => Promise<CommandResult | string> = cli[command];

if (!method) {
// if we failed to find a command, then default to an error
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ async function testAuthComplete(token: string): Promise<{ res; body }> {
});
}

async function auth(apiToken: string, via: AuthCliCommands) {
async function auth(apiToken: string, via: AuthCliCommands): Promise<string> {
let promise;
resetAttempts();
if (apiToken) {
Expand Down
64 changes: 41 additions & 23 deletions src/cli/commands/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../../lib/types';
import { isLocalFolder } from '../../../lib/detect';
import { MethodArgs } from '../../args';
import { TestCommandResult } from '../../commands/types';
import {
GroupedVuln,
LegacyVulnApiResult,
Expand Down Expand Up @@ -56,9 +57,9 @@ const showVulnPathsMapping: Record<string, ShowVulnPaths> = {

// TODO: avoid using `as any` whenever it's possible

async function test(...args: MethodArgs): Promise<string> {
async function test(...args: MethodArgs): Promise<TestCommandResult> {
const resultOptions = [] as any[];
let results = [] as any[];
const results = [] as any[];
let options = ({} as any) as Options & TestOptions;

if (typeof args[args.length - 1] === 'object') {
Expand Down Expand Up @@ -164,25 +165,18 @@ async function test(...args: MethodArgs): Promise<string> {
// resultOptions is now an array of 1 or more options used for
// the tests results is now an array of 1 or more test results
// values depend on `options.json` value - string or object
if (options.json) {
results = results.map((result) => {
// add json for when thrown exception
if (result instanceof Error) {
return {
ok: false,
error: result.message,
path: (result as any).path,
};
}
return result;
});
const errorMappedResults = createErrorMappedResultsForJsonOutput(results);
// backwards compat - strip array IFF only one result
const dataToSend =
errorMappedResults.length === 1
? errorMappedResults[0]
: errorMappedResults;
const stringifiedData = JSON.stringify(dataToSend, null, 2);

// backwards compat - strip array IFF only one result
const dataToSend = results.length === 1 ? results[0] : results;
const stringifiedData = JSON.stringify(dataToSend, null, 2);

if (results.every((res) => res.ok)) {
return stringifiedData;
if (options.json) {
// if all results are ok (.ok == true) then return the json
if (errorMappedResults.every((res) => res.ok)) {
return TestCommandResult.createJsonTestCommandResult(stringifiedData);
}

const err = new Error(stringifiedData) as any;
Expand All @@ -192,7 +186,7 @@ async function test(...args: MethodArgs): Promise<string> {
const fail = shouldFail(vulnerableResults, options.failOn);
if (!fail) {
// return here to prevent failure
return stringifiedData;
return TestCommandResult.createJsonTestCommandResult(stringifiedData);
}
}
err.code = 'VULNS';
Expand All @@ -202,6 +196,7 @@ async function test(...args: MethodArgs): Promise<string> {
}

err.json = stringifiedData;
err.jsonStringifiedResults = stringifiedData;
throw err;
}

Expand Down Expand Up @@ -253,7 +248,10 @@ async function test(...args: MethodArgs): Promise<string> {
if (!fail) {
// return here to prevent throwing failure
response += chalk.bold.green(summaryMessage);
return response;
return TestCommandResult.createHumanReadableTestCommandResult(
response,
stringifiedData,
);
}
}

Expand All @@ -265,11 +263,31 @@ async function test(...args: MethodArgs): Promise<string> {
// first one
error.code = vulnerableResults[0].code || 'VULNS';
error.userMessage = vulnerableResults[0].userMessage;
error.jsonStringifiedResults = stringifiedData;
throw error;
}

response += chalk.bold.green(summaryMessage);
return response;
return TestCommandResult.createHumanReadableTestCommandResult(
response,
stringifiedData,
);
}

function createErrorMappedResultsForJsonOutput(results) {
const errorMappedResults = results.map((result) => {
// add json for when thrown exception
if (result instanceof Error) {
return {
ok: false,
error: result.message,
path: (result as any).path,
};
}
return result;
});

return errorMappedResults;
}

function shouldFail(vulnerableResults: any[], failOn: FailOn) {
Expand Down
57 changes: 57 additions & 0 deletions src/cli/commands/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export class CommandResult {
result: string;
constructor(result: string) {
this.result = result;
}

public toString(): string {
return this.result;
}

public getDisplayResults() {
return this.result;
}
}

export abstract class TestCommandResult extends CommandResult {
protected jsonResult = '';
public getJsonResult(): string {
return this.jsonResult;
}

public static createHumanReadableTestCommandResult(
humanReadableResult: string,
jsonResult: string,
): HumanReadableTestCommandResult {
return new HumanReadableTestCommandResult(humanReadableResult, jsonResult);
}

public static createJsonTestCommandResult(
jsonResult: string,
): JsonTestCommandResult {
return new JsonTestCommandResult(jsonResult);
}
}

class HumanReadableTestCommandResult extends TestCommandResult {
protected jsonResult = '';

constructor(humanReadableResult: string, jsonResult: string) {
super(humanReadableResult);
this.jsonResult = jsonResult;
}

public getJsonResult(): string {
return this.jsonResult;
}
}

class JsonTestCommandResult extends TestCommandResult {
constructor(jsonResult: string) {
super(jsonResult);
}

public getJsonResult(): string {
return this.result;
}
}
86 changes: 85 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as analytics from '../lib/analytics';
import * as alerts from '../lib/alerts';
import * as sln from '../lib/sln';
import { args as argsLib, Args } from './args';
import { CommandResult, TestCommandResult } from './commands/types';
import { copy } from './copy';
import spinner = require('../lib/spinner');
import errors = require('../lib/errors/legacy-errors');
Expand All @@ -26,6 +27,11 @@ import {
import stripAnsi from 'strip-ansi';
import { ExcludeFlagInvalidInputError } from '../lib/errors/exclude-flag-invalid-input';
import { modeValidation } from './modes';
import { JsonFileOutputBadInputError } from '../lib/errors/json-file-output-bad-input-error';
import {
createDirectory,
writeContentsToFileSwallowingErrors,
} from '../lib/json-file-output';

const debug = Debug('snyk');
const EXIT_CODES = {
Expand All @@ -34,13 +40,18 @@ const EXIT_CODES = {
};

async function runCommand(args: Args) {
const result = await args.method(...args.options._);
const commandResult: CommandResult | string = await args.method(
...args.options._,
);

const res = analytics({
args: args.options._,
command: args.command,
org: args.options.org,
});

const result = commandResult.toString();

if (result && !args.options.quiet) {
if (args.options.copy) {
copy(result);
Expand All @@ -50,6 +61,19 @@ async function runCommand(args: Args) {
}
}

// also save the json (in error.json) to file if option is set
if (args.command === 'test') {
const jsonOutputFile = args.options['json-file-output'];
if (jsonOutputFile) {
const jsonOutputFileStr = jsonOutputFile as string;
const fullOutputFilePath = getFullPath(jsonOutputFileStr);
saveJsonResultsToFile(
stripAnsi((commandResult as TestCommandResult).getJsonResult()),
fullOutputFilePath,
);
}
}

return res;
}

Expand Down Expand Up @@ -87,6 +111,16 @@ async function handleError(args, error) {
}
}

// also save the json (in error.json) to file if `--json-file-output` option is set
const jsonOutputFile = args.options['json-file-output'];
if (jsonOutputFile && error.jsonStringifiedResults) {
const fullOutputFilePath = getFullPath(jsonOutputFile);
saveJsonResultsToFile(
stripAnsi(error.jsonStringifiedResults),
fullOutputFilePath,
);
}

const analyticsError = vulnsFound
? {
stack: error.jsonNoVulns,
Expand Down Expand Up @@ -121,6 +155,37 @@ async function handleError(args, error) {
return { res, exitCode };
}

function getFullPath(filepathFragment: string): string {
if (pathLib.isAbsolute(filepathFragment)) {
return filepathFragment;
} else {
const fullPath = pathLib.join(process.cwd(), filepathFragment);
return fullPath;
}
}

function saveJsonResultsToFile(
stringifiedJson: string,
jsonOutputFile: string,
) {
if (!jsonOutputFile) {
console.error('empty jsonOutputFile');
return;
}

if (jsonOutputFile.constructor.name !== String.name) {
console.error('--json-output-file should be a filename path');
return;
}

// create the directory if it doesn't exist
const dirPath = pathLib.dirname(jsonOutputFile);
const createDirSuccess = createDirectory(dirPath);
if (createDirSuccess) {
writeContentsToFileSwallowingErrors(jsonOutputFile, stringifiedJson);
}
}

function checkRuntime() {
if (!runtime.isSupported(process.versions.node)) {
console.error(
Expand Down Expand Up @@ -221,6 +286,25 @@ async function main() {
throw new FileFlagBadInputError();
}

if (args.options['json-file-output'] && args.command !== 'test') {
throw new UnsupportedOptionCombinationError([
args.command,
'json-file-output',
]);
}

const jsonFileOptionSet: boolean = 'json-file-output' in args.options;
if (jsonFileOptionSet) {
const jsonFileOutputValue = args.options['json-file-output'];
if (!jsonFileOutputValue || typeof jsonFileOutputValue !== 'string') {
throw new JsonFileOutputBadInputError();
}
// On Windows, seems like quotes get passed in
if (jsonFileOutputValue === "''" || jsonFileOutputValue === '""') {
throw new JsonFileOutputBadInputError();
}
}

checkPaths(args);

res = await runCommand(args);
Expand Down
13 changes: 13 additions & 0 deletions src/lib/errors/json-file-output-bad-input-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CustomError } from './custom-error';

export class JsonFileOutputBadInputError extends CustomError {
private static ERROR_CODE = 422;
private static ERROR_MESSAGE =
'Empty --json-file-output argument. Did you mean --file=path/to/output-file.json ?';

constructor() {
super(JsonFileOutputBadInputError.ERROR_MESSAGE);
this.code = JsonFileOutputBadInputError.ERROR_CODE;
this.userMessage = JsonFileOutputBadInputError.ERROR_MESSAGE;
}
}
Loading

0 comments on commit 4de2ebb

Please sign in to comment.