Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/anchore/scan-action/blob/main/LICENSE)
[![Join our Discourse](https://img.shields.io/badge/Discourse-Join-blue?logo=discourse)](https://anchore.com/discourse)


This is a GitHub Action for invoking the [Grype](https://github.com/anchore/grype) scanner and returning the vulnerabilities found,
and optionally fail if a vulnerability is found with a configurable severity level.

Expand Down Expand Up @@ -118,12 +117,23 @@ Optionally, change the `fail-build` field to `false` to avoid failing the build
fail-build: false
```

To post the scan results as a comment on the pull request (a single comment is updated across runs), set `pr-comment: true` and pass a `github-token` with the `pull-requests: write` permission. This requires `output-format: sarif` (the default):

```yaml
- name: Scan image
uses: anchore/scan-action@v7
with:
image: "localbuild/testimage:latest"
pr-comment: true
github-token: ${{ github.token }}
```

### Action Inputs

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 |
Expand All @@ -140,11 +150,13 @@ The inputs `image`, `path`, and `sbom` are mutually exclusive to specify the sou
| `cache-db` | Cache the Grype DB in GitHub action cache and restore before checking for updates | `false` |
| `grype-version` | An optional Grype version to download, defaults to the pinned version in [GrypeVersion.js](GrypeVersion.js). | |
| `config` | Optional Grype configuration files (newline-separated). Setting this will disable auto-detection of configuration files (e.g. .grype.yaml) - only the specified files will be loaded.. | |
| `pr-comment` | Post (or update) a comment with the scan results on the pull request that triggered the workflow. Requires `github-token` and a `pull_request` event. A single comment is reused across runs. | `false` |
| `github-token` | Token used to create or update the pull request comment when `pr-comment` is `true`. Typically set to `${{ github.token }}`. Requires the `pull-requests: write` permission. | |

### Action Outputs

| Output Name | Description | Type |
|------------------|--------------------------------------------------------------------------------|--------|
| ---------------- | ------------------------------------------------------------------------------ | ------ |
| `sarif` | Path to the SARIF report file, if `output-format` is `sarif` | string |
| `json` | Path to the report file , if `output-format` is `json` | string |
| `cyclonedx-xml` | Path to the CycloneDX report file, if `output-format` is `cyclonedx` | string |
Expand Down Expand Up @@ -218,7 +230,7 @@ A sub-action to [download Grype](download-grype/action.yml) and optionally cache
Input parameters:

| Parameter | Description | Default |
|-----------------|--------------------------------------------------------------------------------------------------------------|---------|
| --------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
| `grype-version` | An optional Grype version to download, defaults to the pinned version in [GrypeVersion.js](GrypeVersion.js). | |
| `cache-db` | Cache the Grype DB in GitHub action cache and restore before checking for updates | `false` |

Expand Down
199 changes: 198 additions & 1 deletion action.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ async function run() {
const configFile = core.getInput("config") || "";
const cacheDb = core.getInput("cache-db") || "false";
const outputFile = core.getInput("output-file") || "";
const prComment = core.getInput("pr-comment") || "false";
const githubToken = core.getInput("github-token") || "";
const out = await runScan({
source,
failBuild,
Expand All @@ -148,6 +150,8 @@ async function run() {
vex,
configFile,
cacheDb,
prComment,
githubToken,
});
Object.keys(out).map((key) => {
core.setOutput(key, out[key]);
Expand Down Expand Up @@ -299,6 +303,8 @@ async function runScan({
vex,
configFile,
cacheDb = "false",
prComment = "false",
githubToken = "",
}) {
const out = {};

Expand Down Expand Up @@ -341,6 +347,7 @@ async function runScan({
addCpesIfNone = addCpesIfNone.toLowerCase() === "true";
byCve = byCve.toLowerCase() === "true";
cacheDb = cacheDb.toLowerCase() === "true" && cache.isFeatureAvailable();
prComment = prComment.toLowerCase() === "true";

cmdArgs.push("-o", outputFormat);

Expand Down Expand Up @@ -450,7 +457,197 @@ async function runScan({
core.setFailed("grype had a non-zero exit status when running");
}
}

// Optionally post the results as a pull request comment. This is best-effort:
// a failure to comment must never fail the scan itself.
if (prComment) {
try {
if (outputFormat !== "sarif") {
core.warning(
"pr-comment requires output-format 'sarif'; skipping comment",
);
} else {
const sarif = JSON.parse(fs.readFileSync(outputFile, "utf8"));
const body = buildPrCommentBody(parseSarifForComment(sarif));
await postPrComment({ token: githubToken, body });
}
} catch (e) {
core.warning(`unable to post pull request comment: ${e.message}`);
}
}

return out;
}

export { run, runScan, installGrype, grypeVersion, updateDbWithCache };
// Marker used to find and update this action's own PR comment across runs,
// instead of posting a new comment each time.
const PR_COMMENT_MARKER = "<!-- anchore/scan-action pr-comment -->";

const SEVERITY_ORDER = [
"critical",
"high",
"medium",
"low",
"negligible",
"unknown",
];

// Grype writes a "key: value" block into each SARIF rule's help.text; pull the
// fields we display out of it.
function parseGrypeHelpText(text) {
const fields = {};
for (const rawLine of (text || "").split("\n")) {
const line = rawLine.trim();
const vuln = line.match(/^Vulnerability\s+(.+)$/);
if (vuln) {
fields.Vulnerability = vuln[1].trim();
continue;
}
const kv = line.match(/^([A-Za-z][A-Za-z ]*):\s*(.*)$/);
if (kv) {
fields[kv[1].trim()] = kv[2].trim();
}
}
return fields;
}

// Turn a grype SARIF report into a list of vulnerabilities for the comment.
function parseSarifForComment(sarif) {
const run = sarif && sarif.runs && sarif.runs[0];
if (!run || !Array.isArray(run.results)) {
return [];
}
const rules = {};
for (const rule of (run.tool && run.tool.driver && run.tool.driver.rules) ||
[]) {
rules[rule.id] = rule;
}
return run.results.map((result) => {
const rule = rules[result.ruleId] || {};
const fields = parseGrypeHelpText(rule.help && rule.help.text);
return {
id: fields.Vulnerability || result.ruleId || "",
severity: (fields.Severity || "unknown").toLowerCase(),
package: fields.Package || "",
version: fields.Version || "",
fix: fields["Fix Version"] || "",
link: rule.helpUri || "",
};
});
}

// Build the markdown body of the PR comment from the parsed vulnerabilities.
function buildPrCommentBody(vulnerabilities) {
if (vulnerabilities.length === 0) {
return `${PR_COMMENT_MARKER}\n## Grype scan results\n\nNo vulnerabilities found.`;
}

const counts = {};
for (const v of vulnerabilities) {
counts[v.severity] = (counts[v.severity] || 0) + 1;
}
const rank = (s) => {
const i = SEVERITY_ORDER.indexOf(s);
return i === -1 ? SEVERITY_ORDER.length : i;
};
const summary = SEVERITY_ORDER.filter((s) => counts[s])
.map((s) => `${counts[s]} ${s}`)
.join(", ");
const rows = [...vulnerabilities]
.sort((a, b) => rank(a.severity) - rank(b.severity))
.map((v) => {
const id = v.link ? `[${v.id}](${v.link})` : v.id;
return `| ${v.severity} | ${v.package} | ${v.version} | ${v.fix} | ${id} |`;
});

return [
PR_COMMENT_MARKER,
"## Grype scan results",
"",
`Found ${vulnerabilities.length} vulnerabilities (${summary}).`,
"",
"| Severity | Package | Version | Fix | Vulnerability |",
"| --- | --- | --- | --- | --- |",
...rows,
].join("\n");
}

// Minimal GitHub REST call using the built-in fetch, so the action does not
// need an additional dependency.
async function githubApiRequest(token, method, apiPath, body) {
const response = await fetch(`https://api.github.com${apiPath}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "anchore-scan-action",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(
`GitHub API ${method} ${apiPath} failed: ${response.status}`,
);
}
return response.json();
}

// Create a new PR comment, or update the existing one this action posted.
async function postPrComment({
token,
body,
env = process.env,
api = githubApiRequest,
}) {
const eventName = env.GITHUB_EVENT_NAME;
if (eventName !== "pull_request" && eventName !== "pull_request_target") {
core.info(`pr-comment: not a pull request event (${eventName}), skipping`);
return;
}
if (!token) {
core.warning("pr-comment: no github-token provided, skipping");
return;
}

const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, "utf8"));
const prNumber =
(event.pull_request && event.pull_request.number) || event.number;
const [owner, repo] = env.GITHUB_REPOSITORY.split("/");

const comments = await api(
token,
"GET",
`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`,
);
const existing = (comments || []).find(
(c) => c.body && c.body.includes(PR_COMMENT_MARKER),
);

if (existing) {
await api(
token,
"PATCH",
`/repos/${owner}/${repo}/issues/comments/${existing.id}`,
{ body },
);
} else {
await api(
token,
"POST",
`/repos/${owner}/${repo}/issues/${prNumber}/comments`,
{ body },
);
}
}

export {
run,
runScan,
installGrype,
grypeVersion,
updateDbWithCache,
parseSarifForComment,
buildPrCommentBody,
postPrComment,
};
7 changes: 7 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ inputs:
cache-db:
description: "Cache the Grype DB in GitHub action cache and restore before checking for updates"
required: false
pr-comment:
description: "Set to true to post (or update) a comment with the scan results on the pull request that triggered the workflow. Requires 'github-token' and a pull_request event. Default is false."
required: false
default: "false"
github-token:
description: "Token used to create or update the pull request comment when 'pr-comment' is true. Typically set to ${{ github.token }}. Requires 'pull-requests: write' permission."
required: false
outputs:
sarif:
description: "Path to a SARIF report file for the scan"
Expand Down
Loading