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

feat: add --json-file-output option for snyk test #1107

Merged
merged 1 commit into from
May 28, 2020
Merged
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
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.
Copy link
Contributor

Choose a reason for hiding this comment

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

👍


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 {
maxjeffos marked this conversation as resolved.
Show resolved Hide resolved
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