From 3beb88d503d33cba875f99daca0a853b2d4926a1 Mon Sep 17 00:00:00 2001 From: homoluctus Date: Fri, 4 Jun 2021 02:42:27 +0900 Subject: [PATCH 1/5] chore(prettier): trailingComma is none --- .prettierrc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.prettierrc.yml b/.prettierrc.yml index cd617f9..785d76b 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -1,5 +1,5 @@ semi: true singleQuote: true -trailingComma: es5 +trailingComma: none parser": typescript bracketSpacing: true \ No newline at end of file From 5e2b7342e6b3ba6d8695cbaf697eedb164b8f86c Mon Sep 17 00:00:00 2001 From: homoluctus Date: Fri, 4 Jun 2021 02:43:47 +0900 Subject: [PATCH 2/5] chore: Add trivy template --- src/template/default.tpl | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/template/default.tpl diff --git a/src/template/default.tpl b/src/template/default.tpl new file mode 100644 index 0000000..38ac5c9 --- /dev/null +++ b/src/template/default.tpl @@ -0,0 +1,32 @@ +

{{- escapeXML ( index . 0 ).Target }} - Trivy Report - {{ getCurrentTime }}

+ +{{- range . }} + + {{- if (eq (len .Vulnerabilities) 0) }} + + {{- else }} + + + + + + + + + {{- range .Vulnerabilities }} + + + + + + + + + {{- end }} + {{- end }} +{{- end }} +
{{ escapeXML .Type }}
No Vulnerabilities found
PackageVulnerability IDSeverityInstalled VersionFixed VersionLinks
{{ escapeXML .PkgName }}{{ escapeXML .VulnerabilityID }}{{ escapeXML .Vulnerability.Severity }}{{ escapeXML .InstalledVersion }}{{ escapeXML .FixedVersion }}
\ No newline at end of file From 325d3db31cfe7b87cc51759978351822c97242b8 Mon Sep 17 00:00:00 2001 From: homoluctus Date: Fri, 4 Jun 2021 02:44:19 +0900 Subject: [PATCH 3/5] feat: Support trivy template option --- src/index.ts | 29 +++------ src/interface.ts | 18 +----- src/trivy.ts | 158 +++++++++++++++++++---------------------------- src/utils.ts | 3 - 4 files changed, 73 insertions(+), 135 deletions(-) delete mode 100644 src/utils.ts diff --git a/src/index.ts b/src/index.ts index 41b9e43..ad372a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,12 @@ import * as core from '@actions/core'; import { Downloader } from './downloader'; import { GitHub } from './github'; -import { Trivy } from './trivy'; -import { TrivyOption, IssueOption, Vulnerability } from './interface'; +import { scan } from './trivy'; +import { TrivyOption } from './interface'; -async function run() { +async function run(): Promise { const trivyVersion = core.getInput('trivy_version').replace(/^v/, ''); const image = core.getInput('image') || process.env.IMAGE_NAME; - const issueFlag = core.getInput('issue').toLowerCase() == 'true'; if (!image) { throw new Error('Please specify scan target image name'); @@ -17,32 +16,20 @@ async function run() { severity: core.getInput('severity').replace(/\s+/g, ''), vulnType: core.getInput('vuln_type').replace(/\s+/g, ''), ignoreUnfixed: core.getInput('ignore_unfixed').toLowerCase() === 'true', - format: issueFlag ? 'json' : 'table', + template: core.getInput('template') || `${__dirname}/template/default.tpl`, }; const downloader = new Downloader(); const trivyCmdPath = await downloader.download(trivyVersion); + const result = scan(trivyCmdPath, image, trivyOption); - const trivy = new Trivy(); - const result = trivy.scan(trivyCmdPath, image, trivyOption); - - if (!issueFlag) { - core.info(`Not create a issue because issue parameter is false. - Vulnerabilities: ${result}`); - return; - } - - const issueContent = trivy.parse(image, result as Vulnerability[]); - if (issueContent === '') { - core.info( - 'Vulnerabilities were not found.\nYour maintenance looks good 👍' - ); + if (!result) { return; } const issueOption = { title: core.getInput('issue_title'), - body: issueContent, + body: result, labels: core .getInput('issue_label') .replace(/\s+/g, '') @@ -60,7 +47,7 @@ async function run() { core.setOutput('issue_number', output.issueNumber.toString()); if (core.getInput('fail_on_vulnerabilities') === 'true') { - throw new Error(`Vulnerabilities found.\n${issueContent}`); + throw new Error('Abnormal termination because vulnerabilities found'); } } diff --git a/src/interface.ts b/src/interface.ts index 267bbf8..86c359b 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -14,21 +14,5 @@ export interface TrivyOption { severity: string; vulnType: string; ignoreUnfixed: boolean; - format: string; -} - -export interface Vulnerability { - Target: string; - Vulnerabilities: CVE[] | null; -} - -interface CVE { - VulnerabilityID: string; - PkgName: string; - InstalledVersion: string; - FixedVersion: string; - Title?: string; - Description: string; - Severity: string; - References: string[]; + template: string; } diff --git a/src/trivy.ts b/src/trivy.ts index 7eb6fa3..389b72e 100644 --- a/src/trivy.ts +++ b/src/trivy.ts @@ -1,105 +1,75 @@ -import { spawnSync, SpawnSyncReturns } from 'child_process'; -import { TrivyOption, Vulnerability } from './interface'; -import { isIterable } from './utils'; - -export class Trivy { - public scan( - trivyPath: string, - image: string, - option: TrivyOption - ): Vulnerability[] | string { - this.validateOption(option); - - const args: string[] = [ - '--severity', - option.severity, - '--vuln-type', - option.vulnType, - '--format', - option.format, - '--quiet', - '--no-progress', - ]; - - if (option.ignoreUnfixed) args.push('--ignore-unfixed'); - args.push(image); - - const result: SpawnSyncReturns = spawnSync(trivyPath, args, { - encoding: 'utf-8', - }); - - if (result.stdout && result.stdout.length > 0) { - const vulnerabilities: Vulnerability[] | string = - option.format === 'json' ? JSON.parse(result.stdout) : result.stdout; - if (vulnerabilities.length > 0) { - return vulnerabilities; +import { spawnSync } from 'child_process'; +import * as core from '@actions/core'; +import { TrivyOption } from './interface'; + +export function scan( + trivyPath: string, + image: string, + option: TrivyOption +): string | undefined { + validateOption(option); + + const args = [ + '--severity', + option.severity, + '--vuln-type', + option.vulnType, + '--format', + 'template', + '--template', + option.template, + '--quiet', + '--no-progress', + '--exit-code', + '255' + ]; + + if (option.ignoreUnfixed) args.push('--ignore-unfixed'); + args.push(image); + + const result = spawnSync(trivyPath, args, { encoding: 'utf-8' }); + switch (result.status) { + case 0: + core.info(`Vulnerabilities were not found. + Your maintenance looks good 👍`); + case 255: + if (result.stdout && result.stdout.length > 0) { + core.info('Vulnerabilities found !!!'); + return result.stdout; } - } - - throw new Error(`Failed vulnerability scan using Trivy. + default: + throw new Error(`Failed to execute Trivy command. + exit code: ${result.status} stdout: ${result.stdout} - stderr: ${result.stderr} - error: ${result.error} - `); - } - - public parse(image: string, vulnerabilities: Vulnerability[]): string { - let issueContent: string = ''; - - for (const vuln of vulnerabilities) { - if (vuln.Vulnerabilities === null) continue; - - issueContent += `## ${vuln.Target}\n`; - let vulnTable: string = '|Title|Severity|CVE|Package Name|'; - vulnTable += 'Installed Version|Fixed Version|References|\n'; - vulnTable += '|:--:|:--:|:--:|:--:|:--:|:--:|:--|\n'; - - for (const cve of vuln.Vulnerabilities) { - vulnTable += `|${cve.Title || 'N/A'}|${cve.Severity || 'N/A'}`; - vulnTable += `|${cve.VulnerabilityID || 'N/A'}|${cve.PkgName || 'N/A'}`; - vulnTable += `|${cve.InstalledVersion || 'N/A'}|${cve.FixedVersion || - 'N/A'}|`; - - const references = cve.References; - if (!isIterable(references)) continue; - for (const reference of references) { - vulnTable += `${reference || 'N/A'}
`; - } - - vulnTable.replace(/
$/, '|\n'); - } - issueContent += `${vulnTable}\n\n`; - } - - return issueContent ? `_(image scanned: \`${image}\`)_\n\n${issueContent}` : issueContent; + stderr: ${result.stderr}`); } +} - private validateOption(option: TrivyOption): void { - this.validateSeverity(option.severity.split(',')); - this.validateVulnType(option.vulnType.split(',')); - } +function validateOption(option: TrivyOption): void { + validateSeverity(option.severity.split(',')); + validateVulnType(option.vulnType.split(',')); +} - private validateSeverity(severities: string[]): boolean { - const allowedSeverities = /UNKNOWN|LOW|MEDIUM|HIGH|CRITICAL/; - if (!validateArrayOption(allowedSeverities, severities)) { - throw new Error( - `Trivy option error: ${severities.join(',')} is unknown severity. - Trivy supports UNKNOWN, LOW, MEDIUM, HIGH and CRITICAL.` - ); - } - return true; +function validateSeverity(severities: string[]): boolean { + const allowedSeverities = /UNKNOWN|LOW|MEDIUM|HIGH|CRITICAL/; + if (!validateArrayOption(allowedSeverities, severities)) { + throw new Error( + `Trivy option error: ${severities.join(',')} is unknown severity. + Trivy supports UNKNOWN, LOW, MEDIUM, HIGH and CRITICAL.` + ); } + return true; +} - private validateVulnType(vulnTypes: string[]): boolean { - const allowedVulnTypes = /os|library/; - if (!validateArrayOption(allowedVulnTypes, vulnTypes)) { - throw new Error( - `Trivy option error: ${vulnTypes.join(',')} is unknown vuln-type. - Trivy supports os and library.` - ); - } - return true; +function validateVulnType(vulnTypes: string[]): boolean { + const allowedVulnTypes = /os|library/; + if (!validateArrayOption(allowedVulnTypes, vulnTypes)) { + throw new Error( + `Trivy option error: ${vulnTypes.join(',')} is unknown vuln-type. + Trivy supports os and library.` + ); } + return true; } function validateArrayOption(allowedValue: RegExp, options: string[]): boolean { diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 924c442..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isIterable(obj: Object): Boolean { - return obj != null && typeof obj[Symbol.iterator] === 'function'; -} From 0d9e3c8f037b5664124a540e42a6975b6c31deac Mon Sep 17 00:00:00 2001 From: homoluctus Date: Fri, 4 Jun 2021 02:44:44 +0900 Subject: [PATCH 4/5] test: Modify to support trivy template option --- __tests__/downloader.test.ts | 20 ++++ __tests__/trivy.test.ts | 201 +++-------------------------------- __tests__/utils.test.ts | 12 --- 3 files changed, 33 insertions(+), 200 deletions(-) delete mode 100644 __tests__/utils.test.ts diff --git a/__tests__/downloader.test.ts b/__tests__/downloader.test.ts index c7c8a8e..a55030e 100644 --- a/__tests__/downloader.test.ts +++ b/__tests__/downloader.test.ts @@ -97,3 +97,23 @@ describe('Trivy command', () => { expect(result).toBeFalsy(); }); }); + +describe('Exists trivy command', () => { + beforeAll(() => { + fs.writeFileSync('./trivy', ''); + }); + + afterAll(() => { + removeTrivyCmd('.'); + }); + + test('exists', () => { + const result = downloader.trivyExists('.'); + expect(result).toBeTruthy(); + }); + + test('does not exist', () => { + const result = downloader.trivyExists('src'); + expect(result).toBeFalsy(); + }); +}); diff --git a/__tests__/trivy.test.ts b/__tests__/trivy.test.ts index 08a9476..d482725 100644 --- a/__tests__/trivy.test.ts +++ b/__tests__/trivy.test.ts @@ -1,35 +1,15 @@ -import * as fs from 'fs'; +import * as path from 'path'; import { Downloader } from '../src/downloader'; -import { Trivy } from '../src/trivy'; -import { Vulnerability, TrivyOption } from '../src/interface'; +import { scan } from '../src/trivy'; +import { TrivyOption } from '../src/interface'; import { removeTrivyCmd } from './helper'; const downloader = new Downloader(); -const trivy = new Trivy(); - -describe('Trivy command', () => { - beforeAll(() => { - fs.writeFileSync('./trivy', ''); - }); - - afterAll(() => { - removeTrivyCmd('.'); - }); - - test('exists', () => { - const result = downloader.trivyExists('.'); - expect(result).toBeTruthy(); - }); - - test('does not exist', () => { - const result = downloader.trivyExists('src'); - expect(result).toBeFalsy(); - }); -}); +const template = path.join(__dirname, '../src/template/default.tpl'); describe('Trivy scan', () => { let trivyPath: string; - const image: string = 'alpine:3.10'; + const image = 'knqyf263/vuln-image'; beforeAll(async () => { trivyPath = !downloader.trivyExists(__dirname) @@ -46,15 +26,10 @@ describe('Trivy scan', () => { severity: 'HIGH,CRITICAL', vulnType: 'os,library', ignoreUnfixed: true, - format: 'json', + template }; - const result: Vulnerability[] | string = trivy.scan( - trivyPath, - image, - option - ); + const result = scan(trivyPath, image, option) as string; expect(result.length).toBeGreaterThanOrEqual(1); - expect(result).toBeInstanceOf(Object); }); test('without ignoreUnfixed', () => { @@ -62,31 +37,10 @@ describe('Trivy scan', () => { severity: 'HIGH,CRITICAL', vulnType: 'os,library', ignoreUnfixed: false, - format: 'json', + template }; - const result: Vulnerability[] | string = trivy.scan( - trivyPath, - image, - option - ); + const result: string = scan(trivyPath, image, option) as string; expect(result.length).toBeGreaterThanOrEqual(1); - expect(result).toBeInstanceOf(Object); - }); - - test('with table format', () => { - const option: TrivyOption = { - severity: 'HIGH,CRITICAL', - vulnType: 'os,library', - ignoreUnfixed: false, - format: 'table', - }; - const result: Vulnerability[] | string = trivy.scan( - trivyPath, - image, - option - ); - expect(result.length).toBeGreaterThanOrEqual(1); - expect(result).toMatch(/alpine:3\.10/); }); test('with invalid severity', () => { @@ -94,10 +48,10 @@ describe('Trivy scan', () => { severity: 'INVALID', vulnType: 'os,library', ignoreUnfixed: true, - format: 'json', + template }; expect(() => { - trivy.scan(trivyPath, image, invalidOption); + scan(trivyPath, image, invalidOption); }).toThrowError('Trivy option error: INVALID is unknown severity'); }); @@ -106,139 +60,10 @@ describe('Trivy scan', () => { severity: 'HIGH', vulnType: 'INVALID', ignoreUnfixed: true, - format: 'json', + template }; expect(() => { - trivy.scan(trivyPath, image, invalidOption); + scan(trivyPath, image, invalidOption); }).toThrowError('Trivy option error: INVALID is unknown vuln-type'); }); }); - -describe('Parse', () => { - const image: string = 'alpine:3.10'; - - test('the result without vulnerabilities', () => { - const vulnerabilities: Vulnerability[] = [ - { - Target: 'alpine:3.10 (alpine 3.10.3)', - Vulnerabilities: null, - }, - ]; - const result = trivy.parse(image, vulnerabilities); - expect(result).toBe(''); - }); - - test('the result including vulnerabilities', () => { - const vulnerabilities: Vulnerability[] = [ - { - Target: 'alpine:3.9 (alpine 3.9.4)', - Vulnerabilities: [ - { - VulnerabilityID: 'CVE-2019-14697', - PkgName: 'musl', - InstalledVersion: '1.1.20-r4', - FixedVersion: '1.1.20-r5', - Description: - "musl libc through 1.1.23 has an x87 floating-point stack adjustment imbalance, related to the math/i386/ directory. In some cases, use of this library could introduce out-of-bounds writes that are not present in an application's source code.", - Severity: 'HIGH', - References: [ - 'http://www.openwall.com/lists/oss-security/2019/08/06/4', - 'https://www.openwall.com/lists/musl/2019/08/06/1', - ], - }, - { - VulnerabilityID: 'CVE-2019-1549', - PkgName: 'openssl', - InstalledVersion: '1.1.1b-r1', - FixedVersion: '1.1.1d-r0', - Title: 'openssl: information disclosure in fork()', - Description: - 'OpenSSL 1.1.1 introduced a rewritten random number generator (RNG). This was intended to include protection in the event of a fork() system call in order to ensure that the parent and child processes did not share the same RNG state. However this protection was not being used in the default case. A partial mitigation for this issue is that the output from a high precision timer is mixed into the RNG state so the likelihood of a parent and child process sharing state is significantly reduced. If an application already calls OPENSSL_init_crypto() explicitly using OPENSSL_INIT_ATFORK then this problem does not occur at all. Fixed in OpenSSL 1.1.1d (Affected 1.1.1-1.1.1c).', - Severity: 'MEDIUM', - References: [ - 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-1549', - 'https://git.openssl.org/gitweb/?p=openssl.git;a=commitdiff;h=1b0fe00e2704b5e20334a16d3c9099d1ba2ef1be', - 'https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/GY6SNRJP2S7Y42GIIDO3HXPNMDYN2U3A/', - 'https://security.netapp.com/advisory/ntap-20190919-0002/', - 'https://support.f5.com/csp/article/K44070243', - 'https://www.openssl.org/news/secadv/20190910.txt', - ], - }, - ], - }, - ]; - const result = trivy.parse(image, vulnerabilities); - expect(result).toMatch( - /\|Title\|Severity\|CVE\|Package Name\|Installed Version\|Fixed Version\|References\|/ - ); - expect(result).toContain(image); - }); -}); - -describe('Validate trivy option', () => { - test('with a valid severity', () => { - const options: string[] = ['HIGH']; - const result = trivy['validateSeverity'](options); - expect(result).toBeTruthy(); - }); - - test('with two valid severities', () => { - const options: string[] = ['HIGH', 'CRITICAL']; - const result = trivy['validateSeverity'](options); - expect(result).toBeTruthy(); - }); - - test('with an invalid severity', () => { - const options: string[] = ['INVALID']; - expect(() => { - trivy['validateSeverity'](options); - }).toThrowError('Trivy option error: INVALID is unknown severity'); - }); - - test('with two invalid severities', () => { - const options: string[] = ['INVALID', 'ERROR']; - expect(() => { - trivy['validateSeverity'](options); - }).toThrowError('Trivy option error: INVALID,ERROR is unknown severity'); - }); - - test('with an invalid and a valid severities', () => { - const options: string[] = ['INVALID', 'HIGH']; - expect(() => { - trivy['validateSeverity'](options); - }).toThrowError('Trivy option error: INVALID,HIGH is unknown severity'); - }); - - test('with a valid vuln-type', () => { - const options: string[] = ['os']; - const result = trivy['validateVulnType'](options); - expect(result).toBeTruthy(); - }); - - test('with two valid vuln-types', () => { - const options: string[] = ['os', 'library']; - const result = trivy['validateVulnType'](options); - expect(result).toBeTruthy(); - }); - - test('with an invalid vuln-type', () => { - const options: string[] = ['INVALID']; - expect(() => { - trivy['validateVulnType'](options); - }).toThrowError('Trivy option error: INVALID is unknown vuln-type'); - }); - - test('with two invalid vuln-types', () => { - const options: string[] = ['INVALID', 'ERROR']; - expect(() => { - trivy['validateVulnType'](options); - }).toThrowError('Trivy option error: INVALID,ERROR is unknown vuln-type'); - }); - - test('with a valid and an invalid vuln-types', () => { - const options: string[] = ['INVALID', 'os']; - expect(() => { - trivy['validateVulnType'](options); - }).toThrowError('Trivy option error: INVALID,os is unknown vuln-type'); - }); -}); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts deleted file mode 100644 index e74a0c0..0000000 --- a/__tests__/utils.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isIterable } from '../src/utils'; - -describe('isIterable', () => { - test.each([ - ['test', true], - [[], true], - [['this', 'is', 'test'], true], - [{ id: 'test' }, false], - ])('input %s', (obj, expected) => { - expect(isIterable(obj)).toBe(expected); - }); -}); From 63e138ac2d55d550fd934c76843472d496f9300e Mon Sep 17 00:00:00 2001 From: homoluctus Date: Fri, 4 Jun 2021 02:45:24 +0900 Subject: [PATCH 5/5] chore: Modify action metadata to support template option --- README.md | 14 ++++++-------- action.yml | 9 ++++----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 949e031..ffd3df1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ If vulnerabilities are found by Trivy, it creates the following GitHub Issue. ![image](./assets/img/issue.png) -## Usage - -### Inputs +## Inputs |Parameter|Required|Default Value|Description| |:--:|:--:|:--:|:--| @@ -22,21 +20,21 @@ If vulnerabilities are found by Trivy, it creates the following GitHub Issue. |severity|False|HIGH,CRITICAL|Severities of vulnerabilities (separated by commma)| |vuln_type|False|os,library|Scan target are os and / or library (separated by commma)| |ignore_unfixed|False|false|Ignore unfixed vulnerabilities
Please specify `true` or `false`| -|issue|False|true|Decide whether creating issue when vulnerabilities are found by trivy.
Please specify `true` or `false`| -|token|True if issue parameter is true else False|N/A|GitHub Access Token.
${{ secrets.GITHUB_TOKEN }} is recommended.| +|template|False|N/A|Trivy --template option
By default, it uses src/template/default.tpl which is based on [contrib/html.tpl](https://github.com/aquasecurity/trivy/blob/main/contrib/html.tpl)
reference: [Report Formats - Trivy](https://aquasecurity.github.io/trivy/v0.18.3/examples/report/#template)| +|token|True|N/A|GitHub Access Token.
${{ secrets.GITHUB_TOKEN }} is recommended.| |issue_title|False|Security Alert|Issue title| |issue_label|False|trivy,vulnerability|Issue label (separated by commma)| |issue_assignee|False|N/A|Issue assignee (separated by commma)| |fail_on_vulnerabilities|False|false|Whether the action should fail if any vulnerabilities were found.| -### Outputs +## Outputs |Parameter|Description| |:--:|:--| |html_url|The URL to view the issue| |issue_number|The created issue number| -## Example Workflow +## Example Detect your docker image vulnerability everyday at 9:00 (UTC). @@ -55,7 +53,7 @@ jobs: - name: Pull docker image run: docker pull sample - - uses: lazy-actions/gitrivy@main + - uses: lazy-actions/gitrivy@v2 with: token: ${{ secrets.GITHUB_TOKEN }} image: sample diff --git a/action.yml b/action.yml index 50904bc..9aadc2f 100644 --- a/action.yml +++ b/action.yml @@ -21,13 +21,12 @@ inputs: description: 'Ignore unfixed vulnerabilities [true, false]' default: 'false' required: false - issue: - description: 'Decide whether to create a issue when vulnerabilities are found [true, false]' - default: 'true' - required: false + template: + description: 'Trivy --template option' + required: false token: description: 'GitHub access token used to create a issue' - required: false + required: true issue_title: description: 'Issue title' default: 'Security Alert'