Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
31227f8
feat(cve-scan): add reusable action to scan container images for cves
vcauesantos Aug 5, 2026
edbf292
fix(cve-scan): address review findings
vcauesantos Aug 5, 2026
ebde485
fix(cve-scan): stop setup and data errors degrading to a green non-scan
vcauesantos Aug 6, 2026
d64d626
docs(cve-scan): record the verified image targets and the v-prefix trap
vcauesantos Aug 6, 2026
3cef3c9
test(cve-scan): drop five tests duplicating existing coverage
vcauesantos Aug 6, 2026
25e8163
refactor(cve-scan): cut the action down after a simplification pass
vcauesantos Aug 6, 2026
0e9d46a
fix(cve-scan): notify slack on config errors, not just findings and s…
vcauesantos Aug 6, 2026
181391d
docs(cve-scan): correct why the action pulls the image locally
vcauesantos Aug 6, 2026
e8c76ff
fix(cve-scan): default registry-username so a password-only login can…
vcauesantos Aug 6, 2026
045e770
fix(cve-scan): only notify slack on schedule and release events
vcauesantos Aug 6, 2026
ad4d238
fix(cve-scan): honor documented flag spellings, fix scanner-name test…
vcauesantos Aug 7, 2026
0d0419b
fix(cve-scan): drop invalid secrets expression from action.yml descri…
vcauesantos Aug 7, 2026
0cfd9cb
fix(cve-scan): close trigger-context gate bypass, cover install succe…
vcauesantos Aug 8, 2026
a2c0a2a
fix(cve-scan): fix template-injection finding and two doc nits
vcauesantos Aug 8, 2026
fa97ec7
docs(cve-scan): fix stale docs, imprecise error text, and docker-logi…
vcauesantos Aug 10, 2026
29650b5
fix(cve-scan): fail closed on unverifiable installs and ambiguous sup…
vcauesantos Aug 10, 2026
fe2f785
refactor(cve-scan): delegate suppression and per-finding reporting to…
vcauesantos Aug 10, 2026
bf18fd8
refactor(cve-scan): drop dead fixtures, dedup test setup, fix dockerf…
vcauesantos Aug 10, 2026
001f3db
Merge remote-tracking branch 'origin/main' into devops-1292/cve-scan-…
vcauesantos Aug 10, 2026
3ab9de7
fix(cve-scan): make dockerfile-path optional, it does not affect what…
vcauesantos Aug 10, 2026
6c54623
refactor(cve-scan): drop trigger-context, the image ref already says …
vcauesantos Aug 10, 2026
d15c1e1
fix(cve-scan): strip null security-severity so github accepts snyk's …
vcauesantos Aug 10, 2026
18dd42f
fix(cve-scan): fill null security-severity rather than deleting it
vcauesantos Aug 10, 2026
a6e705f
fix(cve-scan): security-severity is a string, not a number - snyk emi…
vcauesantos Aug 10, 2026
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
253 changes: 253 additions & 0 deletions .github/actions/cve-scan/README.md

Large diffs are not rendered by default.

173 changes: 173 additions & 0 deletions .github/actions/cve-scan/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: 'cve-scan'
description: 'Scan a container image 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
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
default: ""
scanner:
description: "Which scanner adapter to run. Selects `src/scanners/<scanner>.sh`. The action's inputs/outputs never change when this does."
required: false
default: "snyk"
scanner-token:
description: "Credential the selected adapter needs, if any. Named generically on purpose — its meaning depends on `scanner`, so swapping tools repoints the secret a caller maps in here without renaming this input."
required: false
default: ""
scanner-version:
description: "Version of the scanner CLI to install when it isn't already on the runner. Pinned so results are reproducible; the adapter installs and checksum-verifies it. Renovate keeps the default current."
required: false
# renovate: datasource=github-releases depName=snyk/cli
default: v1.1306.3
Comment thread
vcauesantos marked this conversation as resolved.
registry:
description: "Registry host to authenticate against, e.g. `ghcr.io`. Only used when `registry-password` is set."
required: false
default: "ghcr.io"
registry-username:
description: "Username for the registry holding `image-ref`. Defaults to the triggering actor, which is what GHCR expects. GHCR only checks the token, but docker/login-action requires a username value to be present — same constraint promote-release documents."
required: false
default: ${{ github.actor }}
registry-password:
description: "Password or token for the registry holding `image-ref`. For GHCR pass the caller's `secrets.GITHUB_TOKEN` with `packages: read`. Masked in the log before use."
Comment thread
vcauesantos marked this conversation as resolved.
required: false
default: ""
severity-threshold:
description: "Minimum severity that counts toward gating: `critical`, `high`, `medium`, or `low`."
required: false
default: "high"
enabled:
description: "Kill switch. Only an explicit falsey value (`false`/`no`/`0`/`off`, any case) skips the scan, and the skip is annotated rather than silent. An unrecognised value scans anyway and warns — resolving toward scanning so a typo in a repo variable can't quietly disable a security control."
required: false
default: "true"
block-on-findings:
description: "When `true` (or `yes`/`1`/`on`), findings at or above `severity-threshold` fail the job. When `false` (default) or unrecognised, findings are always reported but never fail the job. A scanner error never fails the job either way; a config error always does — see README."
required: false
default: "false"
notify:
description: "Send a Slack notification on findings, a scanner error, or a config error. Only fires on `schedule` and `release` events — a `workflow_dispatch` or PR run never notifies, so testing the action can't spam the channel. The Job Summary is written either way."
required: false
default: "true"
slack-webhook-url:
description: "Slack incoming webhook URL for the ci-test-notify action. Required when `notify: true`."
required: false
default: ""

outputs:
has-vulnerabilities:
description: "`true` if any finding at or above `severity-threshold` was reported. Findings below the threshold are still counted in the per-severity outputs and the report; they just don't set this to `true` or drive the Slack notification."
value: ${{ steps.scan.outputs.has-vulnerabilities }}
critical-count:
description: "Critical-severity finding count."
value: ${{ steps.scan.outputs.critical-count }}
high-count:
description: "High-severity finding count."
value: ${{ steps.scan.outputs.high-count }}
medium-count:
description: "Medium-severity finding count."
value: ${{ steps.scan.outputs.medium-count }}
low-count:
description: "Low-severity finding count."
value: ${{ steps.scan.outputs.low-count }}
scanner-error:
description: "`true` if the scan could not complete (timeout, registry failure, unparseable output). Distinct from finding CVEs — never fails the job regardless of `block-on-findings`. Also distinct from a *setup* error (missing CLI or credential), which fails the job and leaves this unset — config errors write no result outputs at all."
value: ${{ steps.scan.outputs.scanner-error }}
report-path:
description: "Path to a short markdown summary — the per-severity counts and what was scanned. Per-finding detail is in `sarif-path`, not here."
value: ${{ steps.scan.outputs.report-path }}
summary:
description: "Slack-ready text summary, distinct from `report-path` (the full file). Always set: a completed scan reports the per-severity counts even when they are all zero, and a skipped or inconclusive run says which it was."
value: ${{ steps.scan.outputs.summary }}
sarif-path:
description: "Path to the SARIF file the scanner emitted, or empty if it produced none. Upload it with `github/codeql-action/upload-sarif` from the caller workflow to get per-finding detail in the Security tab — this action does not upload it itself, to keep `security-events: write` out of its own permission footprint."
value: ${{ steps.scan.outputs.sarif-path }}

runs:
using: "composite"
steps:
# docker/login-action only calls core.setSecret on its multi-registry
# `registry-auth` input, not the single-registry form used below, so
# masking here is what backs this input's documented guarantee. GitHub's
# automatic masking covers only an exact literal from the `secrets.`
# context, which misses a caller who composes or re-encodes the credential.
- name: Mask the registry password
if: inputs.registry-password != ''
shell: bash
env:
REGISTRY_PASSWORD: ${{ inputs.registry-password }}
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)
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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
# follows. See README, "Three failure modes".
continue-on-error: true
with:
registry: ${{ inputs.registry }}
username: ${{ inputs.registry-username }}
password: ${{ inputs.registry-password }}

- name: Scan image for vulnerabilities
id: scan
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
IMAGE_REF: ${{ inputs.image-ref }}
DOCKERFILE_PATH: ${{ inputs.dockerfile-path }}
SCANNER: ${{ inputs.scanner }}
SCANNER_TOKEN: ${{ inputs.scanner-token }}
SCANNER_VERSION: ${{ inputs.scanner-version }}
SEVERITY_THRESHOLD: ${{ inputs.severity-threshold }}
ENABLED: ${{ inputs.enabled }}
BLOCK_ON_FINDINGS: ${{ inputs.block-on-findings }}
run: ${{ github.action_path }}/run.sh

- name: Send Slack notification
# Two gates, for two different reasons.
#
# Event allowlist: only the two triggers this action is built for can
# post. A `workflow_dispatch` run is someone testing, and a channel that
# gets a message every time an engineer pokes the action is a channel
# people learn to ignore — which costs more than the alert is worth.
# `govulncheck` restricts to `schedule` for the same reason; the release
# event is added here because the prerelease scan is the case this action
# exists for. Deliberately an allowlist, not a denylist: a new trigger
# should have to opt in rather than start posting by surprise.
#
# Outcome gate: `outcome == 'failure'` is the third case. A config error
# (bad severity-threshold, a scanner that couldn't
# be provisioned) fails the job but sets neither `scanner-error` nor
# `has-vulnerabilities`, so without it that outcome reddened the run and
# told nobody — despite being the most actionable of the three.
if: >-
always() &&
inputs.notify == 'true' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

considernotify is compared as an exact lowercase string, while its two sibling switches are not. Two lanes raised this independently.

enabled and block-on-findings both go through normalize_flag + to_bool in run.sh, so they accept TRUE, True, yes, 1, on and tolerate stray whitespace — added deliberately last round because the documented examples wire them from repo variables, a free-text web-UI field. notify never reaches run.sh at all; it is only ever read by this if:, so it gets none of that treatment, and neither its description nor the README says so.

A caller who silences one repo by wiring notify: ${{ vars.CVE_SCAN_NOTIFY }} the same way the examples wire the other two, and types TRUE, loses every future Slack notification permanently. Because the gate is pure YAML, no script runs, so nothing can annotate the mismatch — there is no signal anywhere. That blast radius is the whole paging channel, which is why it is worth deciding on separately from the registry-login enabled comparison you already looked at.

GitHub expressions cannot case-fold, so there are two honest options. The cheap one is to make the manifest's contract match its behaviour — state in the notify description that it must be exactly true, and stop implying it behaves like the other switches. The thorough one is to have run.sh normalise it alongside the other two, emit it as a notify-effective step output, and gate this step on steps.scan.outputs.notify-effective == 'true' — which also gets the unrecognised-value warning for free.

(github.event_name == 'schedule' || github.event_name == 'release') &&
(steps.scan.outputs.has-vulnerabilities == 'true' ||
steps.scan.outputs.scanner-error == 'true' ||
steps.scan.outcome == 'failure')
Comment on lines +150 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider — The Slack gate has no arm for a cancelled scan step, so the most likely unattended-cron failure produces no page and no Job Summary entry.

docker pull on line 152 is completely unbounded; only the snyk invocation inside the adapter carries its own 900s timeout. A composite action cannot set timeout-minutes on its own steps, which is why the README tells callers to set it on the job — and the README's own example uses timeout-minutes: 20, of which the adapter alone can reserve 15, leaving little headroom for an unbounded pull plus the CLI install plus the login.

When that job timeout fires, steps.scan.outcome is cancelled, which matches none of the three arms here (has-vulnerabilities, scanner-error, outcome == 'failure'). The step is killed mid-flight, so none of run.sh's finish_with_* helpers run either — no summary, and no Job Summary entry, which contradicts the README's "Every outcome writes a Job Summary entry".

The result is a scheduled security scan that silently stops scanning, which is the outcome this action's whole error taxonomy exists to prevent. It is a fifth state the README does not name.

      if: >-
        always() &&
        inputs.notify == 'true' &&
        (github.event_name == 'schedule' || github.event_name == 'release') &&
        (steps.scan.outputs.has-vulnerabilities == 'true' ||
         steps.scan.outputs.scanner-error == 'true' ||
         steps.scan.outcome == 'failure' ||
         steps.scan.outcome == 'cancelled')

…with a matching arm in the details: ternary so a timeout reads as a timeout rather than as a config error. Bounding the docker pull with timeout, mirroring the adapter's SCAN_TIMEOUT, is the better half of the fix: it turns a hang into a classified scanner error before the job timeout can fire at all.

This becomes blocking once the Phase 3 callers land, because at that point a hung daily scan across three repos is invisible to everyone.

uses: loft-sh/github-actions/.github/actions/ci-test-notify@85d7023c5749421d369f59430c7849f2d00ad694 # ci-test-notify/v1
with:
test-name: "cve-scan: ${{ inputs.image-ref }}"
# Every branch of the `if:` above is a failure, so the status is a
# literal rather than a ternary that could only ever pick one value.
status: failure
# All three outcomes share that status, so the distinction has to live
# in the text: "the scanner broke", "we shipped critical CVEs" and "the
# config is wrong" are very different calls to action for whoever is on
# call, and someone skimming Slack by colour can't tell them apart.
# Ordered so a blocked scan with findings reads as findings, not as a
# config error — it fails the job too.
details: |
${{ steps.scan.outputs.scanner-error == 'true' && 'SCANNER ERROR — no scan was performed, nothing was verified' || (steps.scan.outputs.has-vulnerabilities == 'true' && 'FINDINGS at or above the severity threshold' || 'CONFIGURATION ERROR — cve-scan could not run. An authoring or provisioning mistake, not a scan result; see the job log.') }}
```
${{ steps.scan.outputs.summary }}
```
webhook-url: ${{ inputs.slack-webhook-url }}
Loading
Loading