From 7f269eecf0081d48fb4177f76eb387120ca0e0d2 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Thu, 25 Jul 2024 17:06:28 -0400 Subject: [PATCH] feat: add output-file option, default to random directory output in temp Signed-off-by: Keith Zantow --- README.md | 3 +- action.yml | 3 ++ dist/index.js | 34 ++++++++++++---------- index.js | 34 ++++++++++++---------- tests/action_args.test.js | 56 +++++++++++++++++++++++++++++++++++-- tests/grype_command.test.js | 16 ++++++++--- 6 files changed, 110 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6c0b562f..c4118efe 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Optionally, change the `fail-build` field to `false` to avoid failing the build The inputs `image`, `path`, and `sbom` are mutually exclusive to specify the source to scan; all the other keys are optional. These are all the available keys to configure this action, along with the defaults: | Input Name | Description | Default Value | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +|---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| | `image` | The image to scan | N/A | | `path` | The file path to scan | N/A | | `sbom` | The SBOM to scan | N/A | @@ -127,6 +127,7 @@ The inputs `image`, `path`, and `sbom` are mutually exclusive to specify the sou | `registry-password` | The registry password to use when authenticating to an external registry | | | `fail-build` | Fail the build if a vulnerability is found with a higher severity. That severity defaults to `medium` and can be set with `severity-cutoff`. | `true` | | `output-format` | Set the output parameter after successful action execution. Valid choices are `json`, `sarif`, and `table`, where `table` output will print to the console instead of generating a file. | `sarif` | +| `output-file` | File to output the Grype scan results to. Defaults to a file in the system temp directory, available in the action outputs | | | `severity-cutoff` | Optionally specify the minimum vulnerability severity to trigger a failure. Valid choices are "negligible", "low", "medium", "high" and "critical". Any vulnerability with a severity less than this value will lead to a "warning" result. Default is "medium". | `medium` | | `only-fixed` | Specify whether to only report vulnerabilities that have a fix available. | `false` | | `add-cpes-if-none` | Specify whether to autogenerate missing CPEs. | `false` | diff --git a/action.yml b/action.yml index 7093e3e7..8583175c 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,9 @@ inputs: description: 'Set the output parameter after successful action execution. Valid choices are "json", "sarif", and "table".' required: false default: "sarif" + output-file: + description: 'The file to output the grype scan results to' + required: false severity-cutoff: description: 'Optionally specify the minimum vulnerability severity to trigger an "error" level ACS result. Valid choices are "negligible", "low", "medium", "high" and "critical". Any vulnerability with a severity less than this value will lead to a "warning" result. Default is "medium".' required: false diff --git a/dist/index.js b/dist/index.js index 264f6cda..dfb3a6b3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -16,6 +16,7 @@ const cache = __nccwpck_require__(7784); const core = __nccwpck_require__(2186); const exec = __nccwpck_require__(1514); const fs = __nccwpck_require__(7147); +const os = __nccwpck_require__(2037); const path = __nccwpck_require__(1017); const stream = __nccwpck_require__(2781); const { GRYPE_VERSION } = __nccwpck_require__(6244); @@ -130,11 +131,13 @@ async function run() { const addCpesIfNone = core.getInput("add-cpes-if-none") || "false"; const byCve = core.getInput("by-cve") || "false"; const vex = core.getInput("vex") || ""; + const outputFile = core.getInput("output-file") || ""; const out = await runScan({ source, failBuild, severityCutoff, onlyFixed, + outputFile, outputFormat, addCpesIfNone, byCve, @@ -153,6 +156,7 @@ async function runScan({ failBuild, severityCutoff, onlyFixed, + outputFile, outputFormat, addCpesIfNone, byCve, @@ -193,6 +197,15 @@ async function runScan({ cmdArgs.push("-o", outputFormat); + // always output to a file, this is read later to print table output + if (!outputFile) { + outputFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "grype-")), + "output", + ); + } + cmdArgs.push("--file", outputFile); + if ( !SEVERITY_LIST.some( (item) => @@ -286,21 +299,14 @@ async function runScan({ core.debug(cmdOutput); } - switch (outputFormat) { - case "sarif": { - const SARIF_FILE = "./results.sarif"; - fs.writeFileSync(SARIF_FILE, cmdOutput); - out.sarif = SARIF_FILE; - break; - } - case "json": { - const REPORT_FILE = "./results.json"; - fs.writeFileSync(REPORT_FILE, cmdOutput); - out.json = REPORT_FILE; - break; + out[outputFormat] = outputFile; + if (outputFormat === "table") { + try { + const report = fs.readFileSync(outputFile); + core.info(report.toString()); + } catch (e) { + core.warning(`error writing table output contents: ${e}`); } - default: // e.g. table - core.info(cmdOutput); } // If there is a non-zero exit status code there are a couple of potential reporting paths diff --git a/index.js b/index.js index ac4f7130..8cf3865c 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const cache = require("@actions/tool-cache"); const core = require("@actions/core"); const exec = require("@actions/exec"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const stream = require("stream"); const { GRYPE_VERSION } = require("./GrypeVersion"); @@ -116,11 +117,13 @@ async function run() { const addCpesIfNone = core.getInput("add-cpes-if-none") || "false"; const byCve = core.getInput("by-cve") || "false"; const vex = core.getInput("vex") || ""; + const outputFile = core.getInput("output-file") || ""; const out = await runScan({ source, failBuild, severityCutoff, onlyFixed, + outputFile, outputFormat, addCpesIfNone, byCve, @@ -139,6 +142,7 @@ async function runScan({ failBuild, severityCutoff, onlyFixed, + outputFile, outputFormat, addCpesIfNone, byCve, @@ -179,6 +183,15 @@ async function runScan({ cmdArgs.push("-o", outputFormat); + // always output to a file, this is read later to print table output + if (!outputFile) { + outputFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "grype-")), + "output", + ); + } + cmdArgs.push("--file", outputFile); + if ( !SEVERITY_LIST.some( (item) => @@ -272,21 +285,14 @@ async function runScan({ core.debug(cmdOutput); } - switch (outputFormat) { - case "sarif": { - const SARIF_FILE = "./results.sarif"; - fs.writeFileSync(SARIF_FILE, cmdOutput); - out.sarif = SARIF_FILE; - break; - } - case "json": { - const REPORT_FILE = "./results.json"; - fs.writeFileSync(REPORT_FILE, cmdOutput); - out.json = REPORT_FILE; - break; + out[outputFormat] = outputFile; + if (outputFormat === "table") { + try { + const report = fs.readFileSync(outputFile); + core.info(report.toString()); + } catch (e) { + core.warning(`error writing table output contents: ${e}`); } - default: // e.g. table - core.info(cmdOutput); } // If there is a non-zero exit status code there are a couple of potential reporting paths diff --git a/tests/action_args.test.js b/tests/action_args.test.js index 5cabc507..68f53f1f 100644 --- a/tests/action_args.test.js +++ b/tests/action_args.test.js @@ -1,6 +1,9 @@ const { run } = require("../index"); const core = require("@actions/core"); const exec = require("@actions/exec"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); jest.setTimeout(90000); // 90 seconds; tests were timing out in CI. https://github.com/anchore/scan-action/pull/249 @@ -13,7 +16,7 @@ describe("Github action args", () => { "output-format": "json", "severity-cutoff": "medium", "add-cpes-if-none": "true", - "vex": "test.vex", + vex: "test.vex", }; const spyInput = jest.spyOn(core, "getInput").mockImplementation((name) => { try { @@ -37,7 +40,7 @@ describe("Github action args", () => { }); expect(outputs["sarif"]).toBeFalsy(); - expect(outputs["json"]).toBe("./results.json"); + expect(outputs["json"]).toBeDefined(); spyInput.mockRestore(); spyOutput.mockRestore(); @@ -73,7 +76,8 @@ describe("Github action args", () => { expect(inputs[name]).toBe(true); }); - expect(outputs["sarif"]).toBe("./results.sarif"); + expect(outputs["json"]).toBeFalsy(); + expect(outputs["sarif"]).toBeDefined(); spyInput.mockRestore(); spyOutput.mockRestore(); @@ -117,12 +121,58 @@ describe("Github action args", () => { expect(outputs["sarif"]).toBeFalsy(); expect(outputs["json"]).toBeFalsy(); + expect(outputs["table"]).toBeDefined(); spyInput.mockRestore(); spyOutput.mockRestore(); spyStdout.mockRestore(); }); + it("runs with output-file", async () => { + const reportFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "my-dir-")), + "my-grype-report.json", + ); + const inputs = { + image: "localhost:5000/match-coverage/debian:latest", + "fail-build": "true", + "output-file": reportFile, + "output-format": "json", + "severity-cutoff": "medium", + "add-cpes-if-none": "true", + }; + const spyInput = jest.spyOn(core, "getInput").mockImplementation((name) => { + try { + return inputs[name]; + } finally { + inputs[name] = true; + } + }); + + const outputs = {}; + const spyOutput = jest + .spyOn(core, "setOutput") + .mockImplementation((name, value) => { + outputs[name] = value; + }); + + await run(); + + Object.keys(inputs).map((name) => { + expect(inputs[name]).toBe(true); + }); + + expect(outputs["sarif"]).toBeFalsy(); + expect(outputs["json"]).toBe(reportFile); + expect(outputs["table"]).toBeFalsy(); + + const report = JSON.parse(fs.readFileSync(reportFile).toString()); + expect(report).toBeDefined(); + + spyInput.mockRestore(); + spyOutput.mockRestore(); + }); + it("runs with environment variables", async () => { const inputs = { path: "tests/fixtures/npm-project", diff --git a/tests/grype_command.test.js b/tests/grype_command.test.js index 70978bbd..b3eddc47 100644 --- a/tests/grype_command.test.js +++ b/tests/grype_command.test.js @@ -30,6 +30,7 @@ describe("Grype command", () => { let cmd = await mockExec({ source: "dir:.", failBuild: "false", + outputFile: "the-output-file", outputFormat: "sarif", severityCutoff: "high", version: "0.6.0", @@ -37,13 +38,16 @@ describe("Grype command", () => { addCpesIfNone: "false", byCve: "false", }); - expect(cmd).toBe(`${cmdPrefix} -o sarif --fail-on high dir:.`); + expect(cmd).toBe( + `${cmdPrefix} -o sarif --file the-output-file --fail-on high dir:.`, + ); }); it("is invoked with values", async () => { let cmd = await mockExec({ source: "asdf", failBuild: "false", + outputFile: "the-output-file", outputFormat: "json", severityCutoff: "low", version: "0.6.0", @@ -51,13 +55,16 @@ describe("Grype command", () => { addCpesIfNone: "false", byCve: "false", }); - expect(cmd).toBe(`${cmdPrefix} -o json --fail-on low asdf`); + expect(cmd).toBe( + `${cmdPrefix} -o json --file the-output-file --fail-on low asdf`, + ); }); it("adds missing CPEs if requested", async () => { let cmd = await mockExec({ source: "asdf", failBuild: "false", + outputFile: "the-output-file", outputFormat: "json", severityCutoff: "low", version: "0.6.0", @@ -66,7 +73,7 @@ describe("Grype command", () => { byCve: "false", }); expect(cmd).toBe( - `${cmdPrefix} -o json --fail-on low --add-cpes-if-none asdf` + `${cmdPrefix} -o json --file the-output-file --fail-on low --add-cpes-if-none asdf`, ); }); @@ -74,6 +81,7 @@ describe("Grype command", () => { let cmd = await mockExec({ source: "asdf", failBuild: "false", + outputFile: "the-output-file", outputFormat: "json", severityCutoff: "low", version: "0.6.0", @@ -83,7 +91,7 @@ describe("Grype command", () => { vex: "test.vex", }); expect(cmd).toBe( - `${cmdPrefix} -o json --fail-on low --add-cpes-if-none --vex test.vex asdf` + `${cmdPrefix} -o json --file the-output-file --fail-on low --add-cpes-if-none --vex test.vex asdf`, ); }); });