Skip to content

Commit

Permalink
Merge pull request #1622 from snyk/feat/open-source-sarif
Browse files Browse the repository at this point in the history
feat: add SARIF support for snyk open-source
  • Loading branch information
admons committed Feb 9, 2021
2 parents 15f1728 + a3ec441 commit c7374f5
Show file tree
Hide file tree
Showing 8 changed files with 3,958 additions and 3 deletions.
8 changes: 8 additions & 0 deletions help/commands-docs/_SNYK_COMMAND_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ For advanced usage, we offer language and context specific flags, listed further
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.

- `--sarif`:
Return results in SARIF format.

- `--sarif-file-output`=<OUTPUT_FILE_PATH>:
(only in `test` command)
Save test output in SARIF format directly to the <OUTPUT_FILE_PATH> file, regardless of whether or not you use the `--sarif` option.
This is especially useful if you want to display the human-readable test output via stdout and at the same time save the SARIF format output to a file.

- `--severity-threshold`=low|medium|high:
Only report vulnerabilities of provided level or higher.

Expand Down
11 changes: 8 additions & 3 deletions src/cli/commands/test/formatters/format-test-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { createSarifOutputForContainers } from '../sarif-output';
import { createSarifOutputForIac } from '../iac-output';
import { isNewVuln, isVulnFixable } from '../vuln-helpers';
import { jsonStringifyLargeObject } from '../../../../lib/json';
import { createSarifOutputForOpenSource } from '../open-source-sarif-output';

export function formatJsonOutput(jsonData, options: Options) {
const jsonDataClone = _.cloneDeep(jsonData);
Expand Down Expand Up @@ -63,9 +64,13 @@ export function extractDataToSendFromResults(
let sarifData = {};
let stringifiedSarifData = '';
if (options.sarif || options['sarif-file-output']) {
sarifData = !options.iac
? createSarifOutputForContainers(results)
: createSarifOutputForIac(results);
if (options.iac) {
sarifData = createSarifOutputForIac(results);
} else if (options.docker) {
sarifData = createSarifOutputForContainers(results);
} else {
sarifData = createSarifOutputForOpenSource(results);
}
stringifiedSarifData = jsonStringifyLargeObject(sarifData);
}

Expand Down
138 changes: 138 additions & 0 deletions src/cli/commands/test/open-source-sarif-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as sarif from 'sarif';
import * as _ from 'lodash';
import { upperFirst } from 'lodash';
import {
TestResult,
SEVERITY,
AnnotatedIssue,
} from '../../../lib/snyk-test/legacy';

const LOCK_FILES_TO_MANIFEST_MAP = {
'Gemfile.lock': 'Gemfile',
'package-lock.json': 'package.json',
'yarn.lock': 'package.json',
'Gopkg.lock': 'Gopkg.toml',
'go.sum': 'go.mod',
'composer.lock': 'composer.json',
'Podfile.lock': 'Podfile',
'poetry.lock': 'pyproject.toml',
};

export function createSarifOutputForOpenSource(
testResults: TestResult[],
): sarif.Log {
return {
$schema:
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
version: '2.1.0',
runs: testResults.map(replaceLockfileWithManifest).map((testResult) => ({
tool: {
driver: {
name: 'Snyk Open Source',
rules: getRules(testResult),
},
},
results: getResults(testResult),
})),
};
}

function replaceLockfileWithManifest(testResult: TestResult): TestResult {
let targetFile = testResult.displayTargetFile || '';
for (const [key, replacer] of Object.entries(LOCK_FILES_TO_MANIFEST_MAP)) {
targetFile = targetFile.replace(new RegExp(key, 'g'), replacer);
}
return {
...testResult,
vulnerabilities: testResult.vulnerabilities || [],
displayTargetFile: targetFile,
};
}

export function getRules(testResult: TestResult): sarif.ReportingDescriptor[] {
return _.chain(testResult.vulnerabilities)
.groupBy('id')
.values()
.map(
([vuln, ...moreVulns]): sarif.ReportingDescriptor => {
const cves = vuln.identifiers?.CVE?.join();
return {
id: vuln.id,
shortDescription: {
text: `${upperFirst(vuln.severity)} severity - ${
vuln.title
} vulnerability in ${vuln.packageName}`,
},
fullDescription: {
text: cves
? `(${cves}) ${vuln.name}@${vuln.version}`
: `${vuln.name}@${vuln.version}`,
},
help: {
text: '',
markdown: `* Package Manager: ${testResult.packageManager}
* ${vuln.type === 'license' ? 'Module' : 'Vulnerable module'}: ${vuln.name}
* Introduced through: ${getIntroducedThrough(vuln)}
#### Detailed paths
${[vuln, ...moreVulns]
.map((item) => `* _Introduced through_: ${item.from.join(' › ')}`)
.join('\n')}
${vuln.description}`.replace(/##\s/g, '# '),
},
properties: {
tags: [
'security',
...(vuln.identifiers?.CWE || []),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
testResult.packageManager!,
],
},
};
},
)
.value();
}

export function getResults(testResult): sarif.Result[] {
return testResult.vulnerabilities.map((vuln) => ({
ruleId: vuln.id,
level: getLevel(vuln),
message: {
text: `This file introduces a vulnerable ${vuln.packageName} package with a ${vuln.severity} severity vulnerability.`,
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: testResult.displayTargetFile,
},
region: {
startLine: vuln.lineNumber || 1,
},
},
},
],
}));
}

export function getLevel(vuln: AnnotatedIssue) {
switch (vuln.severity) {
case SEVERITY.HIGH:
return 'error';
case SEVERITY.MEDIUM:
return 'warning';
case SEVERITY.LOW:
default:
return 'note';
}
}

function getIntroducedThrough(vuln: AnnotatedIssue) {
const [firstFrom, secondFrom] = vuln.from || [];

return vuln.from.length > 2
? `${firstFrom}, ${secondFrom} and others`
: vuln.from.length === 2
? `${firstFrom} and ${secondFrom}`
: firstFrom;
}
4 changes: 4 additions & 0 deletions src/lib/snyk-test/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface IssueData {
fixedIn: string[];
legalInstructions?: string;
reachability?: REACHABILITY;
packageManager?: SupportedProjectTypes;
}

export type CallPath = string[];
Expand Down Expand Up @@ -123,6 +124,9 @@ interface AnnotatedIssue extends IssueData {
publicationTime?: string;

reachablePaths?: ReachablePaths;
identifiers?: {
[name: string]: string[];
};
}

// Mixin, to be added to GroupedVuln / AnnotatedIssue
Expand Down
17 changes: 17 additions & 0 deletions test/__snapshots__/sarif.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file Gemfile.lock 1`] = `"Gemfile"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file Gopkg.lock 1`] = `"Gopkg.toml"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file Podfile.lock 1`] = `"Podfile"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file composer.lock 1`] = `"composer.json"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file go.sum 1`] = `"go.mod"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file package-lock.json 1`] = `"package.json"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file poetry.lock 1`] = `"pyproject.toml"`;

exports[`createSarifOutputForOpenSource replace lock-file to manifest-file yarn.lock 1`] = `"package.json"`;
24 changes: 24 additions & 0 deletions test/acceptance/cli-test/cli-test.generic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as sinon from 'sinon';
import * as fs from 'fs';
import * as path from 'path';
import * as needle from 'needle';
import * as Ajv from 'ajv';
import sarifSchema = require('../../support/sarif-schema-2.1.0');
import { AcceptanceTests } from './cli-test.acceptance.test';

// ensure this is required *after* the demo server, since this will
Expand Down Expand Up @@ -356,5 +358,27 @@ export const GenericTests: AcceptanceTests = {
t.match(err.message, 'Internal server error');
}
},

'test --sarif': (params, utils) => async (t) => {
utils.chdirWorkspaces();
try {
const vulns = require('../fixtures/npm-package/test-graph-result.json');
params.server.setNextResponse(vulns);

await params.cli.test('npm-package', { sarif: true });
t.fail('Should fail');
} catch (err) {
const sarifObj = JSON.parse(err.message);
const validate = new Ajv({ allErrors: true }).compile(sarifSchema);
const valid = validate(sarifObj);
if (!valid) {
t.fail(
validate.errors
?.map((e) => `${e.message} - ${JSON.stringify(e.params)}`)
.join(),
);
}
}
},
},
};
111 changes: 111 additions & 0 deletions test/sarif.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { createSarifOutputForOpenSource } from '../src/cli/commands/test/open-source-sarif-output';
import { SEVERITY, TestResult } from '../src/lib/snyk-test/legacy';

describe('createSarifOutputForOpenSource', () => {
it('general', () => {
const testFile = getTestResult();
const sarif = createSarifOutputForOpenSource([testFile]);
expect(sarif.runs).toHaveLength(1);

const [run] = sarif.runs;
expect(run.tool.driver.name).toEqual('Snyk Open Source');
expect(run.tool.driver.rules).toHaveLength(1);
expect(run.results).toHaveLength(1);
});

describe('replace lock-file to manifest-file', () => {
const lockFiles = [
'Gemfile.lock',
'package-lock.json',
'yarn.lock',
'Gopkg.lock',
'go.sum',
'composer.lock',
'Podfile.lock',
'poetry.lock',
];

lockFiles.forEach((lockFileName) =>
it(lockFileName, () => {
const time = Date.now();
const testFile = getTestResult({
displayTargetFile: `${time}/${lockFileName}`,
});
const sarif = createSarifOutputForOpenSource([testFile]);
const uri = sarif.runs?.[0]?.results?.[0].locations?.[0]?.physicalLocation?.artifactLocation?.uri?.replace(
`${time}/`,
'',
);
expect(uri).toMatchSnapshot();
}),
);
});
});

function getTestResult(testResultOverride = {}, vulnOverride = {}): TestResult {
return {
vulnerabilities: [
{
below: '',
credit: ['Unknown'],
description: '## Overview\n',
fixedIn: ['6.12.3'],
id: 'SNYK-JS-AJV-584908',
identifiers: {
CVE: ['CVE-2020-15366'],
CWE: ['CWE-400'],
},
moduleName: 'ajv',
packageManager: 'npm',
packageName: 'ajv',
patches: [],
publicationTime: '2020-07-16T13:58:04Z',
semver: {
vulnerable: ['<6.12.3'],
},
severity: SEVERITY.HIGH,
title: 'Prototype Pollution',
from: [
'PROJECT_NAME@1.0.0',
'jimp@0.2.28',
'request@2.88.2',
'har-validator@5.1.3',
'ajv@6.12.2',
],
upgradePath: [
false,
'jimp@0.2.28',
'request@2.88.2',
'har-validator@5.1.3',
'ajv@6.12.3',
],
isUpgradable: true,
isPatchable: false,
name: 'ajv',
version: '6.12.2',
__filename: 'node_modules/ajv/package.json',
parentDepType: 'prod',
isNew: false,
...vulnOverride,
},
],
ok: false,
dependencyCount: 969,
org: 'ORG',
policy: '',
isPrivate: true,
licensesPolicy: {
severities: {},
orgLicenseRules: {},
},
packageManager: 'npm',
ignoreSettings: null,
summary: '165 vulnerable dependency paths',
filesystemPolicy: false,
uniqueCount: 42,
projectName: 'PROJECT_NAME',
foundProjectCount: 22,
displayTargetFile: 'package.json',
...testResultOverride,
};
}
Loading

0 comments on commit c7374f5

Please sign in to comment.