Skip to content
Draft
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
49 changes: 44 additions & 5 deletions .github/actions/cve-scan/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# cve-scan

Scans a container image for CVEs with a swappable scanner backend (Snyk ships first), gates optionally on severity, and reports via Slack, a short markdown summary, and the scanner's own SARIF for the Security tab.
Scans a container image, or a dependency manifest such as a library's `go.mod`, for CVEs with a swappable scanner backend (Snyk ships first), gates optionally on severity, and reports via Slack, a short markdown summary, and the scanner's own SARIF for the Security tab.

Three failure modes are handled differently, on purpose:

| Outcome | Fails the job? | What it means |
|---------|----------------|---------------|
| **Findings** | Only when `block-on-findings: true` | CVEs at or above `severity-threshold`. The default is advisory (report only), matching the "non-gating in the first iteration" requirement this action was built against. |
| **Scanner error** | Never | The scan couldn't complete — registry failure, timeout, unparseable output. A flaky scanner must never block a release. Sets `scanner-error: true`. |
| **Config error** | Always, regardless of `block-on-findings` | An authoring or provisioning mistake: an unrecognised `severity-threshold`, an invalid `image-ref` or `scanner`, or a scanner that can't be set up (no CLI, no credential). Not a scan result, so it is deliberately *not* reported as an inconclusive scan — a permanently broken setup that reports "inconclusive" forever pages nobody. |
| **Config error** | Always, regardless of `block-on-findings` | An authoring or provisioning mistake: an unrecognised `severity-threshold`, an invalid `image-ref`, `source-path` or `scanner`, or a scanner that can't be set up (no CLI, no credential). Not a scan result, so it is deliberately *not* reported as an inconclusive scan — a permanently broken setup that reports "inconclusive" forever pages nobody. |

If you are wiring this into a release workflow on the advertised advisory-only posture, the third row is the surprising one: a typo'd threshold reds the job even with `block-on-findings: false`.

Expand All @@ -33,7 +33,7 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu
| block-on-findings | string | false | `"false"` | When `true` (or `yes`/`1`/`on`), findings at or <br>above `severity-threshold` fail the job. When <br>`false` (default) or unrecognised, findings are <br>always reported but never fail the <br>job. A scanner error never fails <br>the job either way; a config <br>error always does — see README. |
| dockerfile-path | string | false | | Optional path to the Dockerfile that <br>built the image, passed to the <br>scanner as `--file`. This buys base-image <br>remediation advice only; it does not <br>change which vulnerabilities are found, since <br>application dependencies are scanned by default. <br>Leave empty when scanning a published <br>image with no checkout. If set, <br>it must exist. |
| enabled | string | false | `"true"` | Kill switch. Only an explicit falsey <br>value (`false`/`no`/`0`/`off`, any case) skips the scan, and <br>the skip is annotated rather than <br>silent. An unrecognised value scans anyway <br>and warns — resolving toward scanning <br>so a typo in a repo <br>variable can't quietly disable a security <br>control. |
| image-ref | string | true | | Full registry reference to scan, e.g. <br>`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. |
| image-ref | string | false | | Full registry reference to scan, e.g. <br>`ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. Set this or <br>`source-path`, not both. |
| notify | string | false | `"true"` | Send a Slack notification on findings, <br>a scanner error, or a config <br>error. Accepts the same tolerant spellings <br>as `enabled`/`block-on-findings` (`yes`/`1`/`on`, `no`/`0`/`off`, any case); an unrecognised value <br>still notifies and warns. Also gated <br>by `notify-events`, which by default keeps <br>a `workflow_dispatch` or PR run silent <br>so testing the action can't spam <br>the channel. The Job Summary is <br>written either way. |
| notify-events | string | false | `"schedule,release"` | Comma-separated allowlist of `github.event_name` values permitted <br>to notify. Case- and whitespace-insensitive, and <br>entries match whole, so `release` does <br>not match `prerelease`. An empty value <br>warns and falls back to the <br>default. Note a reusable workflow sees <br>the *caller's* event name, so a <br>`workflow_call` callee must allow whatever triggered <br>the caller. |
| notify-findings | string | false | `"true"` | Post findings to Slack. `false` (or `no`/`0`/`off`) <br>keeps scanner and config errors posting <br>under `notify` but drops the findings <br>post, unless `block-on-findings` is on. An <br>unrecognised value still posts and warns. |
Expand All @@ -45,6 +45,7 @@ Be deliberate about `severity-threshold` on the scheduled sweep. Real images usu
| scanner-version | string | false | | Version of the scanner CLI to <br>install when it isn't already on <br>the runner. Empty installs the version <br>pinned in the adapter script, where <br>Renovate bumps it. The adapter checksum-verifies <br>whatever it installs. |
| severity-threshold | string | false | `"high"` | Minimum severity that counts toward gating: <br>`critical`, `high`, `medium`, or `low`. |
| slack-webhook-url | string | false | | Slack incoming webhook URL for the <br>ci-test-notify action. Required when `notify: true`. |
| source-path | string | false | | Dependency manifest to scan instead of <br>an image, relative to the workspace, <br>e.g. `go.mod`. For a library that <br>ships no image. The caller checks <br>out the code and sets up <br>its toolchain first (for Go, `actions/setup-go`), because the <br>scanner resolves dependencies with it. Skips <br>the registry login and the pull. <br>Set this or `image-ref`, not both. |

<!-- AUTO-DOC-INPUT:END -->

Expand Down Expand Up @@ -201,6 +202,44 @@ Uploading SARIF to the Security tab (caller's choice — kept out of this action

The Snyk adapter namespaces each SARIF run with the image repository while preserving Snyk's run suffix. This keeps shared modules distinct across image repositories on the same ref. The repository comes from the caller's `image-ref`, so changing registry aliases starts a new Code Scanning category.

## Scanning a library (source mode)

A Go library ships no image, so there is nothing for `image-ref` to point at. Set `source-path` to its `go.mod` instead. The action then skips the registry login and the pull, and the Snyk adapter runs `snyk test --file=<source-path>` in place of `snyk container test`. Gating, the report, Slack and SARIF work the same way; the summaries say `Source:` and name `<owner>/<repo>/<source-path>`.

Set exactly one of `image-ref` and `source-path`. Both, or neither, is a config error. `source-path` must be a relative path to a file in the checkout, and `dockerfile-path` must stay empty.

Snyk resolves Go modules with the Go toolchain, so check out the code and set up Go first:

```yaml
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@<commit-sha> # vX.Y.Z
with:
persist-credentials: false
- uses: actions/setup-go@<commit-sha> # vX.Y.Z
with:
go-version-file: go.mod
cache: false
- id: scan
uses: loft-sh/github-actions/.github/actions/cve-scan@cve-scan/v1
with:
source-path: go.mod
scanner-token: ${{ secrets.SNYK_TOKEN }}
slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL_CI_SECURITY_SCANS }}
- uses: github/codeql-action/upload-sarif@<commit-sha> # vX.Y.Z
if: always() && steps.scan.outputs.sarif-path != ''
with:
sarif_file: ${{ steps.scan.outputs.sarif-path }}
```

`snyk test` lists a vulnerability once per dependency path, so source mode counts each one once. Its SARIF runs are keyed `Snyk/OpenSource/<source-path>/...`, so they never share a Code Scanning category with an image scan. A source scan sees module versions only: a library does not choose the Go standard library its consumers build with, so no standard library findings are expected.

## Suppressing a finding

Use Snyk's own [`.snyk` policy file](https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues). Snyk reads it from the working directory during the scan, so suppressed findings never reach this action and no input is needed:
Expand All @@ -219,7 +258,7 @@ Note `.snyk` does not *require* a reason or expiry the way a bespoke format coul

## Scanner adapters

`scanner` selects `src/scanners/<scanner>.sh`. Given `IMAGE_REF`, `DOCKERFILE_PATH`, `SCANNER_TOKEN` and `SCANNER_VERSION` in its environment, an adapter writes the canonical findings shape to the path in `FINDINGS_JSON` and exits:
`scanner` selects `src/scanners/<scanner>.sh`. Given `IMAGE_REF` or `SOURCE_PATH` (exactly one), `DOCKERFILE_PATH`, `SCANNER_TOKEN` and `SCANNER_VERSION` in its environment, an adapter writes the canonical findings shape to the path in `FINDINGS_JSON` and exits:

| Exit | Meaning | Effect on the job |
|------|---------|-------------------|
Expand All @@ -229,7 +268,7 @@ Note `.snyk` does not *require* a reason or expiry the way a bespoke format coul

The `2` case is why a missing prerequisite doesn't disappear: folded in with the inconclusive codes, an unusable scanner would report "scanner error" on every scheduled run forever while scanning nothing, and nothing about that outcome pages anyone.

An adapter also **provisions its own tool** — deliberately the adapter's job and not a step in `action.yml`, which keeps vendor specifics out of the manifest. The image is already pulled locally by the time an adapter runs, so an adapter can assume the layers are available.
An adapter also **provisions its own tool** — deliberately the adapter's job and not a step in `action.yml`, which keeps vendor specifics out of the manifest. In image mode the image is already pulled locally by the time an adapter runs, so an adapter can assume the layers are available.

Everything downstream (gating, the report, the summary) reads only this shape, never a tool's native output:

Expand Down
18 changes: 12 additions & 6 deletions .github/actions/cve-scan/action.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
name: 'cve-scan'
description: 'Scan a container image for CVEs with a swappable scanner backend, gate optionally on severity, and report findings.'
description: 'Scan a container image or a dependency manifest for CVEs with a swappable scanner backend, gate optionally on severity, and report findings.'
branding:
icon: "shield"
color: "red"

inputs:
image-ref:
description: "Full registry reference to scan, e.g. `ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`."
required: true
description: "Full registry reference to scan, e.g. `ghcr.io/loft-sh/vcluster-pro:head` or `ghcr.io/loft-sh/vcluster-pro:v0.38.0-rc.1`. Set this or `source-path`, not both."
required: false
default: ""
source-path:
description: "Dependency manifest to scan instead of an image, relative to the workspace, e.g. `go.mod`. For a library that ships no image. The caller checks out the code and sets up its toolchain first (for Go, `actions/setup-go`), because the scanner resolves dependencies with it. Skips the registry login and the pull. Set this or `image-ref`, not both."
required: false
default: ""
dockerfile-path:
description: "Optional path to the Dockerfile that built the image, passed to the scanner as `--file`. This buys base-image remediation advice only; it does not change which vulnerabilities are found, since application dependencies are scanned by default. Leave empty when scanning a published image with no checkout. If set, it must exist."
required: false
Expand Down Expand Up @@ -121,7 +126,7 @@ runs:
run: echo "::add-mask::${REGISTRY_PASSWORD}"

- name: Log in to the image registry
if: inputs.registry-password != '' && !contains(fromJSON('["false","no","0","off"]'), inputs.enabled)
if: inputs.registry-password != '' && inputs.source-path == '' && !contains(fromJSON('["false","no","0","off"]'), inputs.enabled)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
# A registry that can't be reached is inconclusive, not a finding, so this
# must not fail the caller's job — run.sh classifies the pull failure that
Expand All @@ -132,12 +137,13 @@ runs:
username: ${{ inputs.registry-username }}
password: ${{ inputs.registry-password }}

- name: Scan image for vulnerabilities
- name: Scan for vulnerabilities
id: scan
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
IMAGE_REF: ${{ inputs.image-ref }}
SOURCE_PATH: ${{ inputs.source-path }}
DOCKERFILE_PATH: ${{ inputs.dockerfile-path }}
SCANNER: ${{ inputs.scanner }}
SCANNER_TOKEN: ${{ inputs.scanner-token }}
Expand Down Expand Up @@ -206,7 +212,7 @@ runs:
# Summary is written either way, so nothing is lost when this is skipped.
continue-on-error: true
with:
test-name: "cve-scan: ${{ inputs.image-ref }}"
test-name: "cve-scan: ${{ inputs.image-ref || format('{0}/{1}', github.repository, inputs.source-path) }}"
# Every branch of this `if:` is a failure, so the status is a
# literal rather than a ternary that could only ever pick one value.
status: failure
Expand Down
Loading
Loading