diff --git a/__tests__/helper.ts b/__tests__/helper.ts index 89061f2..80c5b45 100644 --- a/__tests__/helper.ts +++ b/__tests__/helper.ts @@ -1,6 +1,9 @@ import * as fs from 'fs'; +import * as path from 'path'; import { Downloader } from '../src/downloader'; +export const template = path.join(__dirname, '../src/template/default.tpl'); + const downloader = new Downloader(); export function removeTrivyCmd(path: string) { diff --git a/__tests__/inputs.test.ts b/__tests__/inputs.test.ts new file mode 100644 index 0000000..56cbb5a --- /dev/null +++ b/__tests__/inputs.test.ts @@ -0,0 +1,36 @@ +import { Inputs } from '../src/inputs'; +import { template } from './helper'; + +describe('Inputs class Test', () => { + const initEnv = process.env; + + beforeEach(() => { + process.env = { + INPUT_TOKEN: 'xxxxx', + INPUT_IMAGE: 'yyyyy', + ...initEnv + }; + }); + + test('Specify required parameters only', () => { + expect(() => new Inputs()).not.toThrow(); + }); + + test('Specify all parameter', () => { + process.env = { + INPUT_TOKEN: 'xxx', + INPUT_IMAGE: 'yyy', + INPUT_TRIVY_VERSION: '0.18.3', + INPUT_SEVERITY: 'HIGH', + INPUT_VULN_TYPE: 'os', + INPUT_IGNORE_UNFIXED: 'true', + INPUT_TEMPLATE: template, + INPUT_ISSUE_TITLE: 'hello', + INPUT_ISSUE_LABEL: 'world', + INPUT_ISSUE_ASSIGNEE: 'aaaa', + ...initEnv + }; + const inputs = new Inputs(); + expect(() => inputs.validate()).not.toThrow(); + }); +}); diff --git a/__tests__/trivy.test.ts b/__tests__/trivy.test.ts index d482725..386643c 100644 --- a/__tests__/trivy.test.ts +++ b/__tests__/trivy.test.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import { Downloader } from '../src/downloader'; import { scan } from '../src/trivy'; -import { TrivyOption } from '../src/interface'; +import { TrivyCmdOption } from '../src/interface'; import { removeTrivyCmd } from './helper'; const downloader = new Downloader(); @@ -22,7 +22,7 @@ describe('Trivy scan', () => { }); test('with valid option', () => { - const option: TrivyOption = { + const option: TrivyCmdOption = { severity: 'HIGH,CRITICAL', vulnType: 'os,library', ignoreUnfixed: true, @@ -33,7 +33,7 @@ describe('Trivy scan', () => { }); test('without ignoreUnfixed', () => { - const option: TrivyOption = { + const option: TrivyCmdOption = { severity: 'HIGH,CRITICAL', vulnType: 'os,library', ignoreUnfixed: false, @@ -42,28 +42,4 @@ describe('Trivy scan', () => { const result: string = scan(trivyPath, image, option) as string; expect(result.length).toBeGreaterThanOrEqual(1); }); - - test('with invalid severity', () => { - const invalidOption: TrivyOption = { - severity: 'INVALID', - vulnType: 'os,library', - ignoreUnfixed: true, - template - }; - expect(() => { - scan(trivyPath, image, invalidOption); - }).toThrowError('Trivy option error: INVALID is unknown severity'); - }); - - test('with invalid vulnType', () => { - const invalidOption: TrivyOption = { - severity: 'HIGH', - vulnType: 'INVALID', - ignoreUnfixed: true, - template - }; - expect(() => { - scan(trivyPath, image, invalidOption); - }).toThrowError('Trivy option error: INVALID is unknown vuln-type'); - }); }); diff --git a/__tests__/validator.test.ts b/__tests__/validator.test.ts new file mode 100644 index 0000000..16750c2 --- /dev/null +++ b/__tests__/validator.test.ts @@ -0,0 +1,48 @@ +import { TrivyCmdOptionValidator } from '../src/validator'; +import { template } from './helper'; + +describe('TrivyCmdOptionValidator Test', () => { + test('Correct option', () => { + const validator = new TrivyCmdOptionValidator({ + severity: 'HIGH', + vulnType: 'os', + ignoreUnfixed: false, + template + }); + expect(() => validator.validate()).not.toThrow(); + }); + + test('Invalid severity', () => { + const validator = new TrivyCmdOptionValidator({ + severity: '?', + vulnType: 'os', + ignoreUnfixed: false, + template + }); + expect(() => validator.validate()).toThrow( + 'Trivy option error: ? is unknown severity' + ); + }); + + test('Invalid vuln_type', () => { + const validator = new TrivyCmdOptionValidator({ + severity: 'HIGH', + vulnType: '?', + ignoreUnfixed: false, + template + }); + expect(() => validator.validate()).toThrow( + 'Trivy option error: ? is unknown vuln-type' + ); + }); + + test('Invalid template', () => { + const validator = new TrivyCmdOptionValidator({ + severity: 'HIGH', + vulnType: 'os', + ignoreUnfixed: false, + template: '?' + }); + expect(() => validator.validate()).toThrow('Could not find ?'); + }); +}); diff --git a/src/index.ts b/src/index.ts index ad372a9..9e349d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,29 @@ import * as core from '@actions/core'; import { Downloader } from './downloader'; import { GitHub } from './github'; +import { Inputs } from './inputs'; import { scan } from './trivy'; -import { TrivyOption } from './interface'; async function run(): Promise { - const trivyVersion = core.getInput('trivy_version').replace(/^v/, ''); - const image = core.getInput('image') || process.env.IMAGE_NAME; - - if (!image) { - throw new Error('Please specify scan target image name'); - } - - const trivyOption: TrivyOption = { - severity: core.getInput('severity').replace(/\s+/g, ''), - vulnType: core.getInput('vuln_type').replace(/\s+/g, ''), - ignoreUnfixed: core.getInput('ignore_unfixed').toLowerCase() === 'true', - template: core.getInput('template') || `${__dirname}/template/default.tpl`, - }; + const inputs = new Inputs(); + inputs.validate(); const downloader = new Downloader(); - const trivyCmdPath = await downloader.download(trivyVersion); - const result = scan(trivyCmdPath, image, trivyOption); + const trivyCmdPath = await downloader.download(inputs.trivy.version); + const result = scan(trivyCmdPath, inputs.image, inputs.trivy.option); if (!result) { return; } - const issueOption = { - title: core.getInput('issue_title'), - body: result, - labels: core - .getInput('issue_label') - .replace(/\s+/g, '') - .split(','), - assignees: core - .getInput('issue_assignee') - .replace(/\s+/g, '') - .split(','), - }; - const token = core.getInput('token', { required: true }); - const github = new GitHub(token); - const output = await github.createOrUpdateIssue(image, issueOption); + const github = new GitHub(inputs.token); + const issueOption = { body: result, ...inputs.issue }; + const output = await github.createOrUpdateIssue(inputs.image, issueOption); core.setOutput('html_url', output.htmlUrl); core.setOutput('issue_number', output.issueNumber.toString()); - if (core.getInput('fail_on_vulnerabilities') === 'true') { + if (inputs.fail_on_vulnerabilities) { throw new Error('Abnormal termination because vulnerabilities found'); } } diff --git a/src/inputs.ts b/src/inputs.ts new file mode 100644 index 0000000..c1226f8 --- /dev/null +++ b/src/inputs.ts @@ -0,0 +1,52 @@ +import * as core from '@actions/core'; +import { IssueInputs, TrivyInputs } from './interface'; +import { TrivyCmdOptionValidator } from './validator'; + +export class Inputs { + token: string; + image: string; + trivy: TrivyInputs; + issue: IssueInputs; + fail_on_vulnerabilities: boolean; + + constructor() { + this.token = core.getInput('token', { required: true }); + + const image = core.getInput('image') || process.env.IMAGE_NAME; + if (!image) { + throw new Error('Please specify target image'); + } + this.image = image; + + this.trivy = { + version: core.getInput('trivy_version').replace(/^v/, ''), + option: { + severity: core.getInput('severity').replace(/\s+/g, ''), + vulnType: core.getInput('vuln_type').replace(/\s+/g, ''), + ignoreUnfixed: core.getInput('ignore_unfixed').toLowerCase() === 'true', + template: + core.getInput('template') || `${__dirname}/template/default.tpl` + } + }; + + this.issue = { + title: core.getInput('issue_title'), + labels: core + .getInput('issue_label') + .replace(/\s+/g, '') + .split(','), + assignees: core + .getInput('issue_assignee') + .replace(/\s+/g, '') + .split(',') + }; + + this.fail_on_vulnerabilities = + core.getInput('fail_on_vulnerabilities') === 'true'; + } + + validate(): void { + const trivy = new TrivyCmdOptionValidator(this.trivy.option); + trivy.validate(); + } +} diff --git a/src/interface.ts b/src/interface.ts index 86c359b..e399202 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,16 +1,28 @@ -export interface IssueOption { +export interface Validator { + validate(): void; +} + +export interface IssueInputs { title: string; - body: string; labels?: string[]; assignees?: string[]; } +export interface IssueOption extends IssueInputs { + body: string; +} + export interface IssueResponse { issueNumber: number; htmlUrl: string; } -export interface TrivyOption { +export interface TrivyInputs { + version: string; + option: TrivyCmdOption; +} + +export interface TrivyCmdOption { severity: string; vulnType: string; ignoreUnfixed: boolean; diff --git a/src/trivy.ts b/src/trivy.ts index 389b72e..d37b02c 100644 --- a/src/trivy.ts +++ b/src/trivy.ts @@ -1,14 +1,12 @@ import { spawnSync } from 'child_process'; import * as core from '@actions/core'; -import { TrivyOption } from './interface'; +import { TrivyCmdOption } from './interface'; export function scan( trivyPath: string, image: string, - option: TrivyOption + option: TrivyCmdOption ): string | undefined { - validateOption(option); - const args = [ '--severity', option.severity, @@ -44,39 +42,3 @@ export function scan( stderr: ${result.stderr}`); } } - -function validateOption(option: TrivyOption): void { - validateSeverity(option.severity.split(',')); - validateVulnType(option.vulnType.split(',')); -} - -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; -} - -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 { - for (const option of options) { - if (!allowedValue.test(option)) { - return false; - } - } - return true; -} diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..a170b32 --- /dev/null +++ b/src/validator.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import { TrivyCmdOption, Validator } from './interface'; + +export class TrivyCmdOptionValidator implements Validator { + option: TrivyCmdOption; + + constructor(option: TrivyCmdOption) { + this.option = option; + } + + validate(): void { + this.validateSeverity(); + this.validateVulnType(); + this.validateTemplate(); + } + + private validateSeverity(): void { + const severities = this.option.severity.split(','); + const allowedSeverities = /UNKNOWN|LOW|MEDIUM|HIGH|CRITICAL/; + if (!this.validateArrayOption(allowedSeverities, severities)) { + throw new Error( + `Trivy option error: ${severities.join(',')} is unknown severity. + Trivy supports UNKNOWN, LOW, MEDIUM, HIGH and CRITICAL.` + ); + } + } + + private validateVulnType(): void { + const vulnTypes = this.option.vulnType.split(','); + const allowedVulnTypes = /os|library/; + if (!this.validateArrayOption(allowedVulnTypes, vulnTypes)) { + throw new Error( + `Trivy option error: ${vulnTypes.join(',')} is unknown vuln-type. + Trivy supports os and library.` + ); + } + } + + private validateArrayOption( + allowedValue: RegExp, + options: string[] + ): boolean { + for (const option of options) { + if (!allowedValue.test(option)) { + return false; + } + } + return true; + } + + private validateTemplate(): void { + const template = this.option.template; + + const exists = fs.existsSync(template); + if (!exists) { + throw new Error(`Could not find ${template}`); + } + + const isFile = fs.statSync(template).isFile(); + if (!isFile) { + throw new Error(`${template} is not a file`); + } + } +}