diff --git a/snykTask/src/__tests__/test-task-args.ts b/snykTask/src/__tests__/test-task-args.ts index 48febfb7..b544e136 100644 --- a/snykTask/src/__tests__/test-task-args.ts +++ b/snykTask/src/__tests__/test-task-args.ts @@ -20,7 +20,7 @@ which I don't want to do. */ import { TaskArgs } from '../task-args'; -import { Severity } from '../task-lib'; +import { Severity, TestType } from '../task-lib'; function defaultTaskArgs(): TaskArgs { return new TaskArgs({ @@ -44,6 +44,23 @@ test('ensure no problems if both targetFile and docker-file-path are both not se expect(fileArg).toBe(''); }); +test('ensure no problems if testType set to code and both targetFile and dockerImageName are both set', () => { + const args = defaultTaskArgs(); + args.testType = TestType.CODE; + args.dockerImageName = 'some-docker-image'; + args.targetFile = 'some-target-file'; + args.dockerfilePath = null as any; + + const fileArg = args.getFileParameter(); + console.log(`fileArg: ${fileArg}`); + if (fileArg == null) { + console.log('fileArg is null'); + } + + expect(args.testType).toBe('code'); + expect(fileArg).toBe(''); +}); + test("if dockerImageName is specified and (dockerfilePath is not specified but targetFile is and does not contain 'Dockerfile') then return empty string", () => { const args = defaultTaskArgs(); args.dockerImageName = 'some-docker-image'; @@ -126,17 +143,27 @@ describe('TaskArgs.setMonitorWhen', () => { args.setMonitorWhen('always'); expect(args.monitorWhen).toBe('always'); + + args.testType = TestType.CODE; + args.setMonitorWhen('always'); + expect(args.monitorWhen).toBe('never'); }); }); describe('TaskArgs.validate', () => { const args = defaultTaskArgs(); - const validSeverityThresholds = [ - Severity.CRITICAL, - Severity.HIGH, - Severity.MEDIUM, - Severity.LOW, + const testTypeSeverityThreshold = [ + [ + TestType.APPLICATION, + [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW], + ], + [TestType.CODE, [Severity.HIGH, Severity.MEDIUM, Severity.LOW]], + [ + TestType.CONTAINER_IMAGE, + [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW], + ], ]; + it('passes validation when correct combination of severity and fail on thresholds', () => { args.severityThreshold = Severity.LOW; args.failOnThreshold = Severity.HIGH; @@ -153,22 +180,51 @@ describe('TaskArgs.validate', () => { args.validate(); }); - it('throws error if invalid severity threshold', () => { + it('throws error if invalid severity threshold for blank testType defaulted to app', () => { expect(() => { args.severityThreshold = 'hey'; args.validate(); }).toThrow( new Error( - "If set, severityThreshold must be 'critical' or 'high' or 'medium' or 'low' (case insensitive). If not set, the default is 'low'.", + "If set, severityThreshold must be one from [critical,high,medium,low] (case insensitive). If not set, the default is 'low'.", ), ); }); - it.each(validSeverityThresholds)( - 'passes validation for ${level}', - (level) => { - args.severityThreshold = level; + it('throws error if invalid severity threshold for container testType', () => { + expect(() => { + args.severityThreshold = 'hey'; + args.testType = TestType.CONTAINER_IMAGE; + args.validate(); + }).toThrow( + new Error( + "If set, severityThreshold must be one from [critical,high,medium,low] (case insensitive). If not set, the default is 'low'.", + ), + ); + }); + + it('throws error if invalid severity threshold for code', () => { + expect(() => { + args.codeSeverityThreshold = 'hey'; + args.testType = TestType.CODE; args.validate(); + }).toThrow( + new Error( + "If set, severityThreshold must be one from [high,medium,low] (case insensitive). If not set, the default is 'low'.", + ), + ); + }); + + it.each(testTypeSeverityThreshold)( + 'passes validation for each test type severity threshold', + (a, b) => { + args.testType = a as TestType; + for (const sev of b) { + args.severityThreshold = sev as Severity; + args.codeSeverityThreshold = sev as Severity; + args.failOnThreshold = sev as Severity; + args.validate(); + } }, ); }); diff --git a/snykTask/src/index.ts b/snykTask/src/index.ts index bc9d3dad..b221ab03 100644 --- a/snykTask/src/index.ts +++ b/snykTask/src/index.ts @@ -30,6 +30,7 @@ import { HTML_ATTACHMENT_TYPE, doVulnerabilitiesExistForFailureThreshold, Severity, + TestType, } from './task-lib'; import * as fs from 'fs'; import * as path from 'path'; @@ -74,6 +75,7 @@ function parseInputArgs(): TaskArgs { failOnIssues: tl.getBoolInput('failOnIssues', true), }); + taskArgs.testType = tl.getInput('testType', false) || TestType.APPLICATION; taskArgs.targetFile = tl.getInput('targetFile', false); taskArgs.dockerImageName = tl.getInput('dockerImageName', false); taskArgs.dockerfilePath = tl.getInput('dockerfilePath', false); @@ -90,6 +92,7 @@ function parseInputArgs(): TaskArgs { tl.getInput('additionalArguments', false) || ''; taskArgs.testDirectory = tl.getInput('testDirectory', false); taskArgs.severityThreshold = tl.getInput('severityThreshold', false); + taskArgs.codeSeverityThreshold = tl.getInput('codeSeverityThreshold', false); taskArgs.failOnThreshold = tl.getInput('failOnThreshold', false) || Severity.LOW; taskArgs.ignoreUnknownCA = tl.getBoolInput('ignoreUnknownCA', false); @@ -104,10 +107,14 @@ function parseInputArgs(): TaskArgs { } const logAllTaskArgs = (taskArgs: TaskArgs) => { + console.log(`taskArgs.testType: ${taskArgs.testType}`); console.log(`taskArgs.targetFile: ${taskArgs.targetFile}`); console.log(`taskArgs.dockerImageName: ${taskArgs.dockerImageName}`); console.log(`taskArgs.dockerfilePath: ${taskArgs.dockerfilePath}`); console.log(`taskArgs.severityThreshold: ${taskArgs.severityThreshold}`); + console.log( + `taskArgs.codeSeverityThreshold: ${taskArgs.codeSeverityThreshold}`, + ); console.log(`taskArgs.failOnThreshold: ${taskArgs.failOnThreshold}`); console.log(`taskArgs.projectName: ${taskArgs.projectName}`); console.log(`taskArgs.organization: ${taskArgs.organization}`); @@ -146,12 +153,20 @@ async function runSnykTest( const snykTestToolRunner = tl .tool(snykPath) + .argIf(taskArgs.testType == TestType.CODE, 'code') + .argIf( + taskArgs.dockerImageName || taskArgs.testType == TestType.CONTAINER_IMAGE, + 'container', + ) .arg('test') .argIf( - taskArgs.severityThreshold, + taskArgs.testType != TestType.CODE && taskArgs.severityThreshold, `--severity-threshold=${taskArgs.severityThreshold}`, ) - .argIf(taskArgs.dockerImageName, `--docker`) + .argIf( + taskArgs.testType == TestType.CODE && taskArgs.codeSeverityThreshold, + `--severity-threshold=${taskArgs.codeSeverityThreshold}`, + ) .argIf(taskArgs.dockerImageName, `${taskArgs.dockerImageName}`) .argIf(fileArg, `--file=${fileArg}`) .argIf(taskArgs.ignoreUnknownCA, `--insecure`) @@ -179,9 +194,34 @@ async function runSnykTest( if (snykTestExitCode >= CLI_EXIT_CODE_INVALID_USE) { code = snykTestExitCode; errorMsg = - 'failing task because `snyk test` was improperly used or had other errors'; + 'failing task because `snyk` was improperly used or had other errors'; } const snykOutput: SnykOutput = { code: code, message: errorMsg }; + + // handle snyk code no-issues-found non-existent json by rerunning --json stdout to piped file + if ( + taskArgs.testType == TestType.CODE && + !fs.existsSync(jsonReportOutputPath) && + snykTestExitCode === CLI_EXIT_CODE_SUCCESS + ) { + const echoToolRunner = tl.tool('echo'); + const snykCodeTestToolRunner = tl + .tool(snykPath) + .arg('code') + .arg('test') + .arg('--json') + .argIf( + taskArgs.codeSeverityThreshold, + `--severity-threshold=${taskArgs.codeSeverityThreshold}`, + ) + .argIf(taskArgs.ignoreUnknownCA, `--insecure`) + .argIf(taskArgs.organization, `--org=${taskArgs.organization}`) + .argIf(taskArgs.projectName, `--project-name=${projectNameArg}`) + .line(taskArgs.additionalArguments) + .pipeExecOutputToTool(echoToolRunner, jsonReportOutputPath); + await snykCodeTestToolRunner.exec(options); + } + removeRegexFromFile( jsonReportOutputPath, regexForRemoveCommandLine, @@ -215,7 +255,7 @@ const runSnykToHTML = async ( if (snykToHTMLExitCode >= CLI_EXIT_CODE_INVALID_USE) { code = snykToHTMLExitCode; errorMsg = - 'failing task because `snyk test` was improperly used or had other errors'; + 'failing task because `snyk` was improperly used or had other errors'; } const snykOutput: SnykOutput = { code: code, message: errorMsg }; removeRegexFromFile( @@ -241,10 +281,14 @@ async function runSnykMonitor( taskVersion, snykToken, ); + // not handling snyk code cli upload which is still a closed beta const snykMonitorToolRunner = tl .tool(snykPath) + .argIf( + taskArgs.dockerImageName || taskArgs.testType == TestType.CONTAINER_IMAGE, + 'container', + ) .arg('monitor') - .argIf(taskArgs.dockerImageName, `--docker`) .argIf(taskArgs.dockerImageName, `${taskArgs.dockerImageName}`) .argIf(fileArg, `--file=${fileArg}`) .argIf(taskArgs.organization, `--org=${taskArgs.organization}`) diff --git a/snykTask/src/task-args.ts b/snykTask/src/task-args.ts index 92bda899..1c303815 100644 --- a/snykTask/src/task-args.ts +++ b/snykTask/src/task-args.ts @@ -15,7 +15,7 @@ */ import * as tl from 'azure-pipelines-task-lib'; -import { Severity } from './task-lib'; +import { Severity, TestType, testTypeSeverityThreshold } from './task-lib'; export type MonitorWhen = 'never' | 'noIssuesFound' | 'always'; class TaskArgs { testType: string | undefined = ''; @@ -35,6 +35,8 @@ class TaskArgs { testDirectory: string | undefined = ''; additionalArguments: string = ''; ignoreUnknownCA: boolean = false; + // Snyk Code severity with its own pickList (no critical) + codeSeverityThreshold: string | undefined = ''; delayAfterReportGenerationSeconds: number = 0; @@ -46,7 +48,10 @@ class TaskArgs { public setMonitorWhen(rawInput?: string) { if (rawInput) { const lowerCaseInput = rawInput.toLowerCase(); - if (lowerCaseInput === 'never' || lowerCaseInput === 'always') { + if (this.testType == TestType.CODE) { + console.log('Snyk Code publishes results using --report workflow'); + this.monitorWhen = 'never'; + } else if (lowerCaseInput === 'never' || lowerCaseInput === 'always') { this.monitorWhen = lowerCaseInput; } else if (lowerCaseInput === 'noissuesfound') { this.monitorWhen = 'noIssuesFound'; @@ -57,8 +62,12 @@ class TaskArgs { } } } + + // disallow snyk code monitor which follows --report workflow public shouldRunMonitor(snykTestSuccess: boolean): boolean { - if (this.monitorWhen === 'always') { + if (this.testType == TestType.CODE) { + return false; + } else if (this.monitorWhen === 'always') { return true; } else if (this.monitorWhen === 'never') { return false; @@ -102,32 +111,34 @@ class TaskArgs { return this.projectName; } + // validate based on testTypeSeverityThreshold applicable thresholds public validate() { + const taskTestType = this.testType || TestType.APPLICATION; + const taskTestTypeThreshold = testTypeSeverityThreshold.get(taskTestType); + if (this.failOnThreshold) { - if (this.isNotValidThreshold(this.failOnThreshold)) { - const errorMsg = `If set, failOnThreshold must be '${Severity.CRITICAL}' or '${Severity.HIGH}' or '${Severity.MEDIUM}' or '${Severity.LOW}' (case insensitive). If not set, the default is '${Severity.LOW}'.`; + if ( + !taskTestTypeThreshold?.includes(this.failOnThreshold.toLowerCase()) + ) { + const errorMsg = `If set, failOnThreshold must be one from [${taskTestTypeThreshold}] (case insensitive). If not set, the default is '${Severity.LOW}'.`; throw new Error(errorMsg); } } - if (this.severityThreshold) { - if (this.isNotValidThreshold(this.severityThreshold)) { - const errorMsg = `If set, severityThreshold must be '${Severity.CRITICAL}' or '${Severity.HIGH}' or '${Severity.MEDIUM}' or '${Severity.LOW}' (case insensitive). If not set, the default is '${Severity.LOW}'.`; - throw new Error(errorMsg); - } + if ( + (this.severityThreshold && + !taskTestTypeThreshold?.includes( + this.severityThreshold.toLowerCase(), + )) || + (this.codeSeverityThreshold && + !taskTestTypeThreshold?.includes( + this.codeSeverityThreshold.toLowerCase(), + )) + ) { + const errorMsg = `If set, severityThreshold must be one from [${taskTestTypeThreshold}] (case insensitive). If not set, the default is '${Severity.LOW}'.`; + throw new Error(errorMsg); } } - - private isNotValidThreshold(threshold: string) { - const severityThresholdLowerCase = threshold.toLowerCase(); - - return ( - severityThresholdLowerCase !== Severity.CRITICAL && - severityThresholdLowerCase !== Severity.HIGH && - severityThresholdLowerCase !== Severity.MEDIUM && - severityThresholdLowerCase !== Severity.LOW - ); - } } export function getAuthToken() { diff --git a/snykTask/src/task-lib.ts b/snykTask/src/task-lib.ts index ed211221..9aef5848 100644 --- a/snykTask/src/task-lib.ts +++ b/snykTask/src/task-lib.ts @@ -110,6 +110,24 @@ export enum Severity { LOW = 'low', } +export enum TestType { + APPLICATION = 'app', + CODE = 'code', + CONTAINER_IMAGE = 'container', +} + +export const testTypeSeverityThreshold = new Map>([ + [ + TestType.APPLICATION, + [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW], + ], + [TestType.CODE, [Severity.HIGH, Severity.MEDIUM, Severity.LOW]], + [ + TestType.CONTAINER_IMAGE, + [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW], + ], +]); + export function getSeverityOrdinal(severity: string): number { switch (severity) { case Severity.CRITICAL: diff --git a/snykTask/task.json b/snykTask/task.json index 104f3c2a..d46e857c 100644 --- a/snykTask/task.json +++ b/snykTask/task.json @@ -42,6 +42,7 @@ "helpMarkDown": "What do you want to test?", "options": { "app": "Application", + "code": "Code", "container": "Container Image" }, "properties": { @@ -87,7 +88,22 @@ "high": "High", "critical": "Critical" }, - "helpMarkDown": "The testing severity threshold. Leave blank for no threshold." + "helpMarkDown": "The testing severity threshold. Leave blank for no threshold.", + "visibleRule": "testType = app || testType = container" + }, + { + "name": "codeSeverityThreshold", + "label": "Code Testing severity threshold", + "type": "pickList", + "required": false, + "defaultValue": "low", + "options": { + "low": "Low (default)", + "medium": "Medium", + "high": "High" + }, + "helpMarkDown": "Snyk Code testing severity threshold. Leave blank for no threshold.", + "visibleRule": "testType = code" }, { "name": "monitorWhen", @@ -100,7 +116,8 @@ }, "required": true, "defaultValue": "always", - "helpMarkDown": "When to run Snyk Monitor" + "helpMarkDown": "When to run Snyk Monitor", + "visibleRule": "testType = app || testType = container" }, { "name": "failOnIssues", diff --git a/ui/enhancer/detect-vulns.ts b/ui/enhancer/detect-vulns.ts index e3103320..43b40dd6 100644 --- a/ui/enhancer/detect-vulns.ts +++ b/ui/enhancer/detect-vulns.ts @@ -19,7 +19,10 @@ export function detectVulns(jsonResults: object | any[]): boolean { return jsonResults.some((result) => !!result.uniqueCount); } - if (jsonResults['uniqueCount'] && jsonResults['uniqueCount'] > 0) { + if ( + (jsonResults['uniqueCount'] && jsonResults['uniqueCount'] > 0) || + (jsonResults['$schema'] && jsonResults['runs'][0]['results'].length > 0) + ) { return true; } diff --git a/ui/enhancer/generate-report-title.ts b/ui/enhancer/generate-report-title.ts index fbfd2b9a..3f7289c8 100644 --- a/ui/enhancer/generate-report-title.ts +++ b/ui/enhancer/generate-report-title.ts @@ -45,7 +45,7 @@ export function generateReportTitle( return titleText; } - // Single project scan results + // Single project scan or Snyk code scan results let titleText = ''; if (jsonResults['docker'] && jsonResults['docker']['baseImage']) { titleText = `Snyk Test for ${ @@ -59,8 +59,17 @@ export function generateReportTitle( } (${formatReportName(attachmentName)})`; } + if (jsonResults['$schema']) { + titleText = `Snyk Code Test for (${formatReportName(attachmentName)})`; + } + if (jsonResults['uniqueCount'] && jsonResults['uniqueCount'] > 0) { titleText += ` | Found ${jsonResults['uniqueCount']} issues`; + } else if ( + jsonResults['$schema'] && + jsonResults['runs'][0]['results'].length > 0 + ) { + titleText += ` | Found ${jsonResults['runs'][0]['results'].length} issues`; } else { titleText += ` | No issues found`; } diff --git a/ui/snyk-report-tab.html b/ui/snyk-report-tab.html index 19f46649..71a1f50b 100644 --- a/ui/snyk-report-tab.html +++ b/ui/snyk-report-tab.html @@ -28,7 +28,8 @@ overflow: auto; } .iframeContainer { - overflow: scroll; + overflow: auto; + min-height: 100%; } .list { line-height: 2.5;