feat(cve-scan): add reusable action to scan container images for cves - #213
feat(cve-scan): add reusable action to scan container images for cves#213vcauesantos wants to merge 15 commits into
Conversation
introduces a composite action in loft-sh/github-actions that scans a container image for cves via a swappable scanner adapter (snyk ships first), gates optionally on severity, and reports findings via slack, a markdown report, and sarif. advisory by default (block-on-findings defaults false); a scanner error never fails the job regardless of that setting, distinct from finding cves.
- chmod +x run.sh, process-findings.sh, and snyk.sh — all three were committed non-executable, so action.yml's direct invocation would have failed with permission denied on every run. add a test in each bats file that invokes the real script directly, not via `bash`, so this can't regress invisibly again. - process-findings.sh: check yq's exit code and validate the parsed result is JSON before proceeding. a malformed ignore file previously failed open, silently swallowing every finding with an empty exit 0 instead of the documented exit 2. - snyk.sh: capture stdout and stderr into separate files instead of merging them. snyk's routine stderr warnings were corrupting the json capture, misclassifying successful scans carrying real findings as scanner errors. - validate expires as strict zero-padded YYYY-MM-DD at load time. a sloppy date compared as a plain string against today, silently extending or cutting short a suppression. - fix has-vulnerabilities' description (implements >= severity-threshold, not "above low severity"), and always append the report to $GITHUB_STEP_SUMMARY so sub-threshold findings stay visible even when the default severity-threshold suppresses the slack notification. - drop the private-repo/goprivate/gh-access-token line that lingered in the top-level README after those inputs were removed from action.yml. - fix the action's own readme to reference @cve-scan/v1 (bare tag), matching every sibling readme, instead of @<commit-sha> # cve-scan/v1. - fix image/tag splitting for digest refs and registry:port refs (report labeling only, never used for gating). - remove the stray `set -e` left after three `set +e` blocks; the scripts never enable errexit, so it was silently turning it on for the remainder of run.sh and snyk.sh.
loft-bot
left a comment
There was a problem hiding this comment.
Panel review: a nine-lane pass over the new cve-scan action. This is a non-blocking COMMENT review — nothing here gates the merge button.
Solid, well-documented action with a genuinely swappable adapter seam and a real bats suite. The findings below cluster around one theme: because a scanner error is designed never to fail the job, every prerequisite gap and internal error degrades into a permanently green, permanently non-scanning job. Several of these I reproduced by running the scripts rather than reading them; where that's the case the comment says so.
Blocking concerns
Highest-impact first. Each is a real defect, not a style preference.
src/process-findings.sh:99— the expired-ignore::warning::never reaches the job log. Verified end to end: run the pair with an expired ignore entry and the job log is completely empty whilehigh-count=1shows the suppressed CVE has returned.run.shcaptures the child's whole stdout as akey=valuechannel and re-emits only grepped keys. The one signal telling a human a CVE came back is destroyed. One-word fix (>&2).src/process-findings.sh:121— the jq partition's exit status is unchecked, so the gate silently no-ops. Verified: with a findings JSON the filter can't iterate, the script still exits 0, every count is written blank,scanner-error=false, andblock-on-findings=truedoes not fire. A data error is indistinguishable from a clean scan.action.yml:87-94— thesnykCLI is never installed or version-pinned.yqgets a conditional install step; the scanner the action exists to run gets none, and isn't onubuntu-latest. Exit 127 folds into the silent scanner-error path, so the action reports "scanner error" forever and never scans.src/scanners/snyk.sh:48— no image pull or registry auth. The design ticket for this work explicitly records that Snyk's own GHCR credential has broken repeatedly and that a CI scan should pull the image itself; the manual runbook does exactly that. As written, private-image scanning depends on the very credential this was meant to route around, and fails silently.run.sh:69— theenabledkill switch silently skips on any non-exact value. Verified:TRUE,True,yes,1, and" true"all skip the scan; only lowercasetrueruns it. The examples wire this to a repo variable — a free-text web-UI field. Result is a green check and no annotation. (An empty value is safe —${ENABLED:-true}re-defaults it.)test/run.bats:57-67— the mock only ever emits cleankey=valuelines, so nothing exercises the parent's handling of other child stdout. This is why #1 ships green.test/process_findings.bats:149-165— the severity-rank table is only ever exercised athigh; rankingcriticalbelowlowpasses all 23 tests.test/run.bats:141-153— the gate matrix never variesblock-on-findingswhere it matters; two mutations that break release gating pass the suite.
What was checked
- Correctness: boundary/empty inputs, ignore-file expiry and id/cve matching, the gate decision, temp-file and
traplifecycle. Expiry logic, zero-padding validation, and id/cve matching are correct; the unchecked jq exit above is the real defect. - Security: injection, secrets in logs, authz. Clean on the important ones — untrusted scanner data reaches jq only via
--arg/--argjson, never interpolated into program text, and the CLI invocation uses a proper argv array. Three defense-in-depth notes inline. Permission footprint is genuinely minimal and the SARIF-upload split is the right call. - Test quality: exercised by mutation testing — production code was deliberately broken and the suite re-run to prove what it catches. Four gate-coverage gaps are blocking above.
- Operability: the disabled / scanner-error / config-error / completed taxonomy versus what an operator can actually observe. This is where the action is weakest; see blocking #1, #3, #4, #5 plus inline notes on step-summary and timeouts.
- Architecture: the adapter seam is real and the canonical shape is documented in one place. One leak remains (severity vocabulary) — inline.
- Reuse: compared against
govulncheckand ~30 siblings. The inline output/table/truncation helpers are per-action convention here, not missed reuse; no shared helper exists to call. Two genuine items inline. - Infra/CI: third-party actions correctly SHA-pinned; renovate annotations verified against the actual
renovate.jsonregex; the notify step's compoundif:/ternary verified correct across all reachable output states; emptywebhook-urlalready handled safely byci-test-notify. - Typos/prose: doc-comment count mismatch and the
set +eintent inline. - PR metadata: below.
- Skipped: dead-code-guard (nothing deleted, moved, or renamed — 9 added files plus two append-only edits). The four e2e lanes and gap-analysis (no
e2e*/suite in this repo and no Go unit test, so neither signal is present).
Not re-reported: anything actionlint, zizmor, shellcheck, or check-docs already covers — all five checks are green. The ci-test-notify SHA pin is correct per this repo's convention and is deliberately not flagged.
What the change does well
The scanner seam is honest rather than nominal: the canonical findings shape is documented as an explicit adapter contract, and the Snyk-specific two-array/dedup trap is handled inside the adapter where it belongs instead of leaking into the gating logic.
PR-level notes
- nit — The body says "41 bats tests"; the PR now has 53 (23 + 15 + 15). 41 was the count at the first commit; the second added 12 more. The claim undersells the suite rather than overstating it. Everything else checks out: all five checks green, and
References DEVOPS-1292is the right form since this is Phase 2 and does not close the ticket.
Quality notes (non-blocking)
Low-severity test-assertion gaps, recorded for tracking rather than as inline comments:
test/run.bats:167— onlycritical-countis asserted from the real grep/cut output parsing; hardcodinghigh/medium/low-countto0still passes the suite.test/process_findings.bats:291-297— the Slack summary test checks only truncation, never the embedded per-severity counts; swapping which count fills thecritical=field still passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…canner errors Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'t silently fail Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loft-bot
left a comment
There was a problem hiding this comment.
Panel review: second pass over cve-scan, at 0d0419b. Nine lanes ran. This is a non-blocking COMMENT review — nothing here gates the merge button.
First, the round-one response holds up. I checked all 24 resolved threads against the code rather than the replies, and every one is genuinely fixed; several are fixed better than what was suggested. The two places you pushed back — declining the mock-based test for the swallowed warning because the mock's stdout is swallowed either way, and deferring the severity enum until a second adapter lands — were right, and I'm not re-litigating them. The four things you found that the review missed are all real. None of the findings below is a re-raise.
What most of them have in common: the second commit moved a lot of code, and the new surface it created is where the gaps are. The gate bypass, the four unwritten config-error exits, the enabled gate in the manifest, and the vendor-named scanner-version default all arrived after round one.
Blocking concerns
run.sh:213— the release gate can be made to pass on a real critical finding. The gate greps$GITHUB_OUTPUTback and takestail -n1;process-findings.sh:207writessummary=last, interpolatingtrigger-contextunchecked. Reproduced end to end: withtrigger-context=$'release\nhas-vulnerabilities=false'and one genuine critical finding,block-on-findings: trueexits 0. Round one closed this class forimage-ref;trigger-contextreaches the same sink and is documented as a free-text label, which invites wiring it from event data.src/process-findings.sh:207— the sink side of the same defect. Unvalidatedtrigger-contextin a single-linekey=valueappend forges arbitrary step outputs. Worth closing at both ends: the read-back is fragile regardless of who can write to the file.test/scanners_snyk.bats:173—install_snyk()'s success path has no test. Every case either pre-seedssnykonPATHor drives a failure branch, so the path every fresh runner takes — download, verify,chmod +x, execute — is unexercised, as is the no-published-checksum branch. This is the code that fetches and runs a binary over the network..github/workflows/test-cve-scan.yaml:53— the smoke job short-circuits before it proves anything.enabled: falsereturns atrun.sh's first branch, so 10 of 15 inputs get no manifest-wiring coverage. That's the one layer the bats suite structurally cannot see, and it's why this job was added in round one — so the fix is incomplete rather than wrong.
What was checked
- Correctness: boundary and empty inputs, the errexit behaviour of the
read/here-doc/command-substitution construct, thetrap/trap -pair, the ignore-file expiry and id/cve matching, adapter exit-code classification. The expiry logic, zero-padding validation,SARIF_URIfallback,ENABLED/BLOCK_ON_FINDINGScase loop, andinstall_snyk's dest-reuse andchmodordering were each tested directly and are correct. Two real gaps inline (an unobserved jq exit, duplicate ignore ids collapsing). - Security: injection, secrets in logs, authz, supply chain. Untrusted scanner data reaches jq only via
--arg/--argjson/file, never interpolated into program text; the adapter resolution is enumerable; the permission footprint is minimal and leaving SARIF upload to the caller is the right call. Four findings inline, thetrigger-contextsink being the sharp one. - Test quality: exercised by mutation, with bats 1.14.0 and a real mikefarah/yq installed. All six mutation-catch claims in the PR body hold — I broke each one and confirmed the suite fails. The two bare
! grepnegations are not inert (both are the final statement in their body, so they're enforced), the loop-based tests bind correctly to their iteration, the two-array Snyk dedup trap is covered, and thecurl-badsumstub's checksum format matches a livedownloads.snyk.iofetch. Gaps are coverage, not rigor. - Operability: all four outcomes traced to what a human actually sees. The four early config-error exits write neither a summary nor a Job Summary entry — reproduced, and it contradicts the README's "every outcome writes a Job Summary entry" — plus an unbounded
docker pullwhose job-timeout cancellation escapes the Slackif:, and a scheduled example that contradicts the README's own noise advice. - Architecture: the adapter seam is genuinely real, not nominal. One vendor leak remains in the manifest (
scanner-version) and the validation split now spans two files with two exit-code conventions. Inline. - Reuse: compared against ~28 siblings. The per-action output/summary helpers, the
docker/login-actionSHA, the workflow shape, and the Makefile glob all match existing convention exactly — deliberately not flagged.normalize_flag/to_booland the checksum-verified CLI install are genuinely first-of-their-kind here. One real item (test helpers duplicated three times under two names, where this repo already has ahelpers.bashpattern). - Infra/CI: every third-party pin, the intra-repo
ci-test-notifySHA, and the renovate annotations verified against sibling files and the actualrenovate.json— all correct. The intra-repo SHA pin is this repo's convention and is deliberately not flagged. One finding: the manifest'senabled != 'false'gate disagrees withrun.sh's normalisation. - Typos/prose: four doc-vs-code contradictions, including a documented input that doesn't exist and a README section describing an architecture this PR removed.
- PR metadata: below.
- Skipped:
dead-code-guard(nothing deleted, moved or renamed — 10 added files plus two append-only edits). The four e2e lanes and gap analysis (noe2e*/suite in this repo and no Go unit tests, so neither the e2e nor the unit-crossover signal is present).
Not re-reported: anything actionlint, zizmor, shellcheck, check-docs or validate-renovate already covers — all six checks are green.
What the change does well
The adapter seam earns its keep: the canonical findings shape is documented as an explicit contract, the Snyk-specific two-array/dedup trap is handled inside the adapter where it belongs, and the second commit correctly pushed CLI provisioning down there too rather than into the manifest — so adding a scanner really is close to a one-file change.
PR-level notes
- nit — The body says "111 bats tests"; the suite at this head has 99 (
run.bats36,process_findings.bats36,scanners_snyk.bats22,integration.bats5), confirmed bygrep -c '^@test'andbats --count. 111 was accurate atebde485; the two later commits that cut the action down and dropped five duplicate tests brought it to 99. Stale rather than fabricated, and the claim understates the suite — but this is the second round where the count has drifted, so it may be worth just not putting a number in the body. Everything else checks out: the title and all 12 commit subjects follow the convention,References DEVOPS-1292is the right form since this is Phase 2 and doesn't close the ticket, there's no PR template in this repo to satisfy, theset -euo pipefailclaim is accurate for all three scripts, and the claim aboutgovulncheck/run.sh's inverse errexit trap is correct.
|
|
||
| # --- 2. Validate config before spending a scan ------------------------------- | ||
| # All cheap, none needs the scanner: a typo shouldn't cost a full pull and scan. | ||
|
|
||
| # A container reference never legitimately contains whitespace, and image-ref | ||
| # is the one value here that comes from outside the action. | ||
| case "$IMAGE_REF" in | ||
| '' | *[[:space:]]*) | ||
| echo "::error::image-ref must be a non-empty reference with no whitespace (got '${IMAGE_REF}')" >&2 | ||
| exit 1 | ||
| ;; | ||
| esac | ||
|
|
||
| # Adapters are in-repo and enumerable, so resolve by name rather than | ||
| # interpolating caller input into a path that then gets executed. | ||
| case "$SCANNER" in | ||
| snyk) ADAPTER="${ACTION_PATH}/src/scanners/snyk.sh" ;; | ||
| *) | ||
| echo "::error::unknown scanner '${SCANNER}' — supported: snyk" >&2 | ||
| exit 1 | ||
| ;; | ||
| esac | ||
|
|
||
| if [ -n "$IGNORE_FILE" ] && [ ! -f "$IGNORE_FILE" ]; then | ||
| echo "::error::ignore-file '${IGNORE_FILE}' does not exist" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Every adapter needs the Dockerfile, so this is a caller-input precondition | ||
| # rather than a per-scanner one. A missing `--file=` target is not an error to | ||
| # the scanner: it quietly scans only the base image and skips the embedded | ||
| # binaries this scan exists to cover. | ||
| if [ -n "$DOCKERFILE_PATH" ] && [ ! -f "$DOCKERFILE_PATH" ]; then | ||
| echo "::error::dockerfile-path '${DOCKERFILE_PATH}' does not exist — without it only the base image is scanned and embedded binaries are silently skipped" >&2 | ||
| echo "::error::set dockerfile-path to a file the caller checked out, or to an empty string to scan the image alone deliberately" >&2 | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
consider — These four validation exits write no summary output and no Job Summary entry, unlike the two later config-error exits at lines 173-174 and 197-198 which both call write_output summary + write_step_summary before exiting 1.
Reproduced all four against the real script:
missing-ignore exit=1 GITHUB_OUTPUT=0 bytes STEP_SUMMARY=0 bytes
unknown-scanner exit=1 GITHUB_OUTPUT=0 bytes STEP_SUMMARY=0 bytes
missing-dockerfile exit=1 GITHUB_OUTPUT=0 bytes STEP_SUMMARY=0 bytes
ws-imageref exit=1 GITHUB_OUTPUT=0 bytes STEP_SUMMARY=0 bytes
adapter exit 2 (for contrast)
exit=1 GITHUB_OUTPUT=93 bytes STEP_SUMMARY=142 bytes
Two consequences. The README's "Every outcome writes a Job Summary entry, including the ones that never produce a report" doesn't hold — and these are the config errors most likely to be hit first in Phase 3, since a repo without Dockerfile.release at the default path or a typo'd ignore-file trips them before anything else. And on a schedule/release run the notify step still fires on steps.scan.outcome == 'failure', so Slack posts the CONFIGURATION ERROR line over an empty code block, because summary was never written.
The job does go red and the ::error:: text is on stderr, so nobody is fully blind — that's why this is consider rather than blocking. But the invariant is currently re-implemented per call site, which is what let these four drift. A helper next to finish_with_no_result would make it hold in one place:
finish_with_config_error() {
local detail="$1"
write_output summary "cve-scan — \`${IMAGE_REF}\`: ${detail}"
write_step_summary ":x: **Configuration error** — ${detail}. No scan was performed."
exit 1
}...called from all six config-error exits. Note the four here run before WORKDIR/IMAGE_REF validation completes in one case, so the ws-imageref branch needs its detail string to not re-interpolate the bad value into the summary.
run.bats asserts only exit status and stderr text for these ("a missing ignore-file fails before the scan is spent", "unknown scanner fails the job"), so the gap is untested as well as unwritten.
…ss path and config-error smoke scenario
Summary
cve-scaninloft-sh/github-actions: scans a container image for CVEs via a swappable scanner adapter (Snyk ships first), never hardcoding the vendor in its public contractblock-on-findings: true(advisory by default); a config or setup error always fails it, so a permanently broken scanner can't report "inconclusive" forever while scanning nothingReferences DEVOPS-1292 — this is Phase 2 (the action itself) of that ticket's phased plan. Phase 3 (wiring both triggers — a scheduled
headsweep and a prerelease-filtered release scan — intovcluster,vcluster-pro, andloft-enterprise) is a follow-up, not included here.Test plan
make test-cve-scan— 111 bats tests acrossrun.sh,src/scanners/snyk.sh,src/process-findings.sh, and a newintegration.batsthat wiresrun.shto the realprocess-findings.shcomposite-smokejob drives the action throughuses:withenabled: falseand asserts its outputs, soaction.yml's input→env wiring is exercised in CIshellcheck,actionlintandzizmorclean;make lintandmake check-docscleanenabledkill switch, scanner-name path traversal, andGITHUB_OUTPUTforgery via a newline inimage-refcriticalbelowlow, inverting the SARIF level map, dropping SARIF locations, dropping theownerrequirement, gating the config-error exit onblock-on-findings, and dropping thehas-vulnerabilitieshalf of the gate are each now caughtReview response
Second commit addresses the panel review. All 24 inline comments were verified against the code before acting; none were spurious. Notable deltas from what was suggested:
set +enit and the unchecked jq partition were the same bug. All three scripts now useset -euo pipefail(the repo norm — 34 of 39 action scripts) with genuineset +e/set -ebrackets, which kills the class rather than the instance.govulncheck/run.shhas the inverse latent trap: it starts without errexit and a bracket'sset -eswitches it on mid-script.run.shswallows regardless of the fix — the fix changes the real child, not the mock. Closed instead withintegration.bats, verified to fail before the>&2fix and pass after, while the old unit test reportsokin both cases.enablednow fails safe rather than just normalising case. Only an explicit falsey value skips;flasescans and warns. A kill switch on a security control shouldn't be trippable by a typo in a web-UI field.2). A missing CLI or credential is a provisioning mistake, not an inconclusive scan, and previously had no way to say so — every setup gap folded into the silent scanner-error path. The adapter also provisions its own tool, keepingaction.ymlscanner-agnostic.locations[], and GitHub requires at least one to display a result — so the documented upload example would have shown nothing; and a newline inimage-refdemonstrably forged a secondhas-vulnerabilities=falseafter the genuine one.summarywas documented as empty on a clean scan (it never is); the missing SARIF locations; an emptyscanner-tokengoing silently green, which is the live state today since the secret isn't provisioned yet; and — found while verifying the arch-less tag question — release tags carry avprefix that registry tags don't, soimage-ref: ...:${{ github.event.release.tag_name }}resolves to a tag that doesn't exist. Both READMEs now derive the tag in a step.severity-thresholdvalidation stays inprocess-findings.shrather than being duplicated inrun.sh; only the zero-driftignore-fileexistence check moved up front.ghcr.io/loft-sh/vcluster-pro:headwas confirmed against ghcr.io as a live multi-arch manifest list (linux/amd64,linux/arm64). The versioned target list per repo is recorded on DEVOPS-1292 and remains Phase 3 work.