diff --git a/src/index.ts b/src/index.ts index 76c7443..5963cee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { CheckovCollector, Logger } from './lib'; +import { CheckovCollector, Logger, TrivyCollector } from './lib'; import fs from 'fs'; import { argv } from 'yargs'; @@ -8,6 +8,7 @@ const logger = new Logger(); const collectors = { checkov: CheckovCollector, + trivy: TrivyCollector, }; /* eslint-disable complexity */ @@ -23,10 +24,10 @@ function parseOptions(): any { } if (!collectors[tool]) { - logger.error(`The specified tool "${ tool }" is not supported.`, true); + logger.error(`The specified tool "${tool}" is not supported.`, true); logger.info('Specify one of the following supported tools for instrumentation:'); for (const key in collectors) { - logger.info(` • ${ key }`); + logger.info(` • ${key}`); } process.exit(1); } @@ -38,7 +39,7 @@ function parseOptions(): any { } else { logger.error('The supplied --path argument does not exist.', true); - logger.info(`${ args.path } could not be found.`); + logger.info(`${args.path} could not be found.`); process.exit(1); } } @@ -77,15 +78,17 @@ async function main(): Promise { const collector = new collectors[options.tool](logger); console.log(); - logger.info(`Running tool "${ options.tool }"...`, true); + logger.info(`Running tool "${options.tool}"...`, true); + const passing = await collector.exec({ cwd: options.path, quiet: options.quiet, }); + if (!passing && !options.softFail) { - logger.error(`One or more ${ options.tool } checks failed or errored.`); + logger.error(`One or more ${options.tool} checks failed or errored.`); process.exit(2); } } diff --git a/src/lib/analysis-collector-base.ts b/src/lib/analysis-collector-base.ts index d702f6d..9170103 100644 --- a/src/lib/analysis-collector-base.ts +++ b/src/lib/analysis-collector-base.ts @@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid'; import cp from 'child_process'; import chalk from 'chalk'; import * as axios from 'axios'; +import { argv } from 'yargs'; export abstract class AnalysisCollectorBase { @@ -18,6 +19,8 @@ export abstract class AnalysisCollectorBase { public _request = axios.default; + public _argv = argv; + public constructor(public toolId: string, public logger: Logger) { this.traceId = uuid(); this.timestamp = new Date(); @@ -46,7 +49,12 @@ export abstract class AnalysisCollectorBase { await this.postResults(results, finalOptions); } - return !results.filter((r) => r.checkResult === CheckResult.FAIL || r.checkResult === CheckResult.ERRORED); + const failingStatuses = [ + CheckResult.FAIL, + CheckResult.ERRORED, + ]; + + return !results.some((r) => failingStatuses.includes(r.checkResult)); } public detectApiToken(): void { diff --git a/src/lib/index.ts b/src/lib/index.ts index 3c3b8c9..260a07f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -3,4 +3,5 @@ export { IResult } from './result.interface'; export { ResultsParserDelegate } from './results-parser-delegate'; export * from './checkov'; +export * from './trivy'; export * from './utils'; diff --git a/src/lib/result.interface.ts b/src/lib/result.interface.ts index 2f31cd2..0a640b9 100644 --- a/src/lib/result.interface.ts +++ b/src/lib/result.interface.ts @@ -67,4 +67,23 @@ export interface IResult { * The raw response from the queried API. */ responseBody?: string; + + // VULNERABILITY BASED RESULT FIELDS + // The following optional fields only apply to tools that return vulnerabilities. + + /** + * The raw response from the queried API. + */ + vulnerabilityId?: string; + + /** + * The name of the package in which the vulnerability was found. + */ + packageName?: string; + + /** + * The version of the package in which the vulnerability was found. + */ + packageVersion?: string; + } diff --git a/src/lib/trivy/index.ts b/src/lib/trivy/index.ts new file mode 100644 index 0000000..3b2de11 --- /dev/null +++ b/src/lib/trivy/index.ts @@ -0,0 +1 @@ +export { TrivyCollector } from './trivy-collector'; diff --git a/src/lib/trivy/trivy-collector.spec.ts b/src/lib/trivy/trivy-collector.spec.ts new file mode 100644 index 0000000..d2871e5 --- /dev/null +++ b/src/lib/trivy/trivy-collector.spec.ts @@ -0,0 +1,120 @@ +import { Logger } from '../utils'; +import { createLoggerFixture } from '../test/logger.fixture'; +import { TrivyCollector } from './trivy-collector'; +import { CheckResult } from '../check-result'; + +describe('TrivyCollector', () => { + + let collector: TrivyCollector; + let logger: Logger; + + beforeEach(() => { + logger = createLoggerFixture(); + collector = new TrivyCollector(logger); + + spyOn(collector, 'spawn').and.returnValue(new Promise((resolve) => { + resolve('TEST_OUTPUT'); + })); + }); + + describe('constructor()', () => { + it('should set the tool ID', () => { + expect(collector.toolId).toEqual('trivy'); + }); + }); + + describe('getToolVersion()', () => { + it('should call trivy with the --version flag', async () => { + const result = await collector.getToolVersion({}); + expect(result).toEqual('TEST_OUTPUT'); + expect(collector.spawn).toHaveBeenCalledTimes(1); + expect(collector.spawn).toHaveBeenCalledWith('trivy', ['--version'], {}); + }); + }); + + describe('getResults()', () => { + it('should call trivy with preset options', async () => { + collector._argv = { + 'image-name': 'TEST_IMAGE', + } as any; + spyOn(collector, 'parseResults').and.returnValue('TEST_RESULTS' as any); + const result = await collector.getResults({}); + expect(result).toEqual('TEST_RESULTS' as any); + + const expectedArgs = [ + '--quiet', + 'image', + '--security-checks', + 'vuln,config', + '--exit-code', + '0', + '-f', + 'json', + '--light', + 'TEST_IMAGE', + ]; + expect(collector.spawn).toHaveBeenCalledTimes(1); + expect(collector.spawn).toHaveBeenCalledWith('trivy', expectedArgs, {}); + + expect(collector.parseResults).toHaveBeenCalledTimes(1); + expect(collector.parseResults).toHaveBeenCalledWith('TEST_OUTPUT'); + }); + it('should error when image name is not specified', async () => { + await expectAsync(collector.getResults({})) + .toBeRejectedWith(new Error('You must specify an --image-name argument.')); + }); + + }); + + describe('parseResults()', () => { + + let raw: string; + + beforeEach(()=> { + /* eslint-disable @typescript-eslint/naming-convention */ + raw = JSON.stringify([{ + Vulnerabilities: [{ + VulnerabilityID: 'TEST_VULNERABILITY_ID', + PkgName: 'TEST_PACKAGE_NAME', + InstalledVersion: 'TEST_INSTALLED_VERSION', + Layer: { + Digest: 'TEST_DIGEST', + }, + }], + }]); + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + it('should report passing when the result contains no vulnerabilities', () => { + raw = JSON.stringify([{}]); + const result = collector.parseResults(raw)[0]; + expect(result.checkResult).toEqual(CheckResult.PASS); + }); + + it('should report failing when no vulnerabilities were found', () => { + const result = collector.parseResults(raw)[0]; + expect(result.checkResult).toEqual(CheckResult.FAIL); + }); + + it('should set the checkname to the vulnerabilityID', () => { + const result = collector.parseResults(raw)[0]; + expect(result.checkName).toEqual('TEST_VULNERABILITY_ID'); + }); + + it('should set the packageName to PkgName', () => { + const result = collector.parseResults(raw)[0]; + expect(result.packageName).toEqual('TEST_PACKAGE_NAME'); + }); + + it('should set the packageVersion to InstalledVersion', () => { + const result = collector.parseResults(raw)[0]; + expect(result.packageVersion).toEqual('TEST_INSTALLED_VERSION'); + }); + + it('should set the resourceId to Layer.Digest', () => { + const result = collector.parseResults(raw)[0]; + expect(result.resourceId).toEqual('TEST_DIGEST'); + }); + }); + +}); diff --git a/src/lib/trivy/trivy-collector.ts b/src/lib/trivy/trivy-collector.ts new file mode 100644 index 0000000..a98ac73 --- /dev/null +++ b/src/lib/trivy/trivy-collector.ts @@ -0,0 +1,74 @@ +import { AnalysisCollectorBase } from '../analysis-collector-base'; +import { CheckResult } from '../check-result'; +import { IResult } from '../result.interface'; +import { Logger } from '../utils'; + +export class TrivyCollector extends AnalysisCollectorBase { + + public constructor(logger: Logger) { + super('trivy', logger); + } + + public override async getToolVersion(options: any): Promise { + const args = ['--version']; + return await this.spawn('trivy', args, options); + } + + public override async getResults(options: any): Promise { + const imageName = this._argv['image-name']; + if (!imageName) { + throw new Error('You must specify an --image-name argument.'); + } + + const args = ['--quiet', 'image', '--security-checks', 'vuln,config', '--exit-code', '0', '-f', 'json', '--light', imageName]; + const output = await this.spawn('trivy', args, options); + + + this.logger.debug(JSON.stringify(output, null, 2)); + + + return this.parseResults(output); + } + + public parseResults(output: string): IResult[] { + const parsed = JSON.parse(output); + + const results = []; + + for (const set of parsed) { + if (!set.Vulnerabilities) { + const result: IResult = { + checkId: 'vulnerabilities', + checkName: 'No vulnerabilities found.', + checkType: 'container-layer', + checkResult: CheckResult.PASS, + resourceId: set.Target, + vulnerabilityId: set.VulnerabilityID, + packageName: set.PkgName, + packageVersion: set.InstalledVersion, + }; + + results.push(result); + continue; + } + + results.push(...set.Vulnerabilities.map((v) => { + const result: IResult = { + checkId: 'vulnerabilities', + checkName: v.VulnerabilityID, + checkType: 'container-layer', + checkResult: CheckResult.FAIL, + resourceId: v.Layer.Digest, + vulnerabilityId: v.VulnerabilityID, + packageName: v.PkgName, + packageVersion: v.InstalledVersion, + }; + + return result; + })); + + } + return results; + } + +}