From fb9a45b214dcf88bab779ec91b681541a1060745 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 18 Aug 2026 06:29:37 +0200 Subject: [PATCH 1/3] sec(ci): scan the Go binaries in the shipped image, not just the source Every scanner in this pipeline reads the repository: govulncheck runs in source mode over the six modules, Trivy runs with scan-type fs and config. docker-build built the image, ran --version against it, and threw it away. A vulnerable binary baked into the artifact was unreachable by construction, which is how two consecutive rounds of CVE fixes went green while the image stayed vulnerable: #1832 bumped the toolchain CI reads but not the one the image built on, and #1833's first fix cleaned /app/cudly while /usr/local/bin/migrate stayed an upstream prebuilt release carrying go1.25.4 and 69 advisories, executed on every container start by entrypoint.sh. scripts/scan-shipped-image.sh exports the built image's filesystem, asks `go version` about every regular file in it, and runs govulncheck in binary mode over each Go binary it finds. It deliberately takes no list of paths: a third binary added later is exactly what must not become invisible again. /app/cudly must be among the binaries found, so an enumeration that reads the wrong filesystem fails rather than reporting a clean run it never did. It fails on any advisory with a published fixed version, at any severity, and tolerates (while still printing) advisories with no published fix. Today /app/cudly carries exactly one of the latter, GO-2026-5932, whose introduced version is "0" with no fix now or ever; gating on it would make the check permanently red with no available action. That is a property of the finding, not an allowlist of ids: an advisory becomes gating the moment upstream publishes a fix, and nothing here needs editing when the set changes. The steps live in docker-build rather than a new job so the image is not built a third time; docker-build is already in ci-success's needs, and has no job-level `if:`, so the gate cannot pass by being skipped. `load: true` exports the built tag into the local image store so the scan inspects the artifact this job produced rather than a rebuild of it. scripts/test-scan-shipped-image.sh runs in the same job and asserts both directions of the verdict against recorded govulncheck output, so a scanner that can no longer fail cannot ship as coverage. Its fixtures are real records: the prebuilt migrate binary that shipped before #1833, /app/cudly as built on main, and the empty output of migrate as built on main. ci-success now allowlists on success instead of denylisting 'failure' and 'cancelled'. A job that never dispatched reports 'skipped', which the old form counted as a pass, so a gate could satisfy the summary by not running. Its "all checks passed" summary line no longer posts unconditionally, where it previously claimed success on a failed run. Not covered: OS package CVEs in the base image. govulncheck only knows Go modules and the standard library. A Trivy scan-type: image step would cover them, but the pinned alpine:3.21.3 runtime base already carries fixable CRITICAL/HIGH openssl, musl and zlib advisories, so that gate would land red; it needs a base bump first and is tracked separately. Closes #1836 --- .github/workflows/ci.yml | 53 +++- scripts/scan-shipped-image.sh | 267 ++++++++++++++++++ scripts/test-scan-shipped-image.sh | 121 ++++++++ .../scan-shipped-image/malformed.jsonl | 1 + .../scan-shipped-image/no-findings.jsonl | 0 .../stale-toolchain-binary.jsonl | 26 ++ .../scan-shipped-image/unfixable-only.jsonl | 15 + 7 files changed, 476 insertions(+), 7 deletions(-) create mode 100755 scripts/scan-shipped-image.sh create mode 100755 scripts/test-scan-shipped-image.sh create mode 100644 scripts/testdata/scan-shipped-image/malformed.jsonl create mode 100644 scripts/testdata/scan-shipped-image/no-findings.jsonl create mode 100644 scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl create mode 100644 scripts/testdata/scan-shipped-image/unfixable-only.jsonl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14b31a9cd..57a5de163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,10 @@ jobs: with: context: . push: false + # Export the built image into the local docker image store so the + # scan step below inspects the artifact this job just produced, + # rather than rebuilding one and hoping it is the same. + load: true tags: cudly:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max @@ -352,6 +356,33 @@ jobs: docker run --rm cudly:test /app/cudly --version || true docker run --rm cudly:test /app/cudly --help || true + # Nothing else in the pipeline looks at the artifact: govulncheck runs in + # source mode, and Trivy runs with scan-type fs and config, all against + # the repository. A vulnerable binary baked into the image was invisible + # by construction, which is how #1833 shipped twice (issue #1836). These + # steps live in this job rather than a new one so the image is scanned + # without being built a third time; docker-build is already in + # ci-success's needs, so the gate is wired. + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Install govulncheck + run: | + # Same pin as the security-scan job: a govulncheck release with new + # detection logic must not silently change this gate's verdict + # between PRs. + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + + - name: Run image scan self-tests + # Asserts both directions of the verdict against recorded govulncheck + # output, so a scanner that can no longer fail cannot ship as coverage. + run: bash scripts/test-scan-shipped-image.sh + + - name: Scan the shipped image for Go advisories + run: bash scripts/scan-shipped-image.sh cudly:${{ github.sha }} + # Terraform validation for all environments terraform-validate: name: Validate Terraform (${{ matrix.cloud }}) @@ -779,19 +810,27 @@ jobs: steps: - name: Check all jobs + # Allowlist on success instead of denylisting 'failure' and + # 'cancelled'. A job that never dispatched reports 'skipped', and the + # denylist form reported that as a pass -- so a gate could satisfy this + # summary by not running at all, which is the same shape as the scanner + # gap in #1836. Anything that is not exactly 'success' now fails, and + # names itself in the log. + env: + NEEDS_JSON: ${{ toJSON(needs) }} run: | - if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then - echo "One or more CI jobs failed" - exit 1 - fi - if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then - echo "One or more CI jobs were cancelled" + # jq is preinstalled on the GitHub Ubuntu runner image (no new deps). + not_success="$(jq -r 'to_entries[] + | select(.value.result != "success") + | " \(.key): \(.value.result)"' <<<"$NEEDS_JSON")" + if [[ -n "$not_success" ]]; then + echo "::error::not every required CI job succeeded" + echo "$not_success" exit 1 fi echo "All CI checks passed!" - name: Post status - if: always() run: | echo "## CI Status" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/scripts/scan-shipped-image.sh b/scripts/scan-shipped-image.sh new file mode 100755 index 000000000..21a79bf13 --- /dev/null +++ b/scripts/scan-shipped-image.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# scan-shipped-image.sh +# +# Scans the Go binaries inside a built container image, rather than the source +# tree they were built from. +# +# Every scanner in this repo's CI reads the repository: govulncheck runs in +# source mode over the six modules, Trivy runs with scan-type fs and config. +# None of them can see the artifact. Twice in a row a toolchain/CVE fix went +# green while the image still shipped the vulnerability (#1829/#1832 bumped the +# toolchain CI reads but not the one the image built on; #1833's first fix +# cleaned /app/cudly while /usr/local/bin/migrate stayed an upstream prebuilt +# release carrying go1.25.4 and 69 advisories, executed on every container start +# by scripts/entrypoint.sh with DB_AUTO_MIGRATE=true). That is issue #1836. +# +# WHAT IT SCANS. Every Go binary in the image, discovered by exporting the +# image filesystem and asking `go version` about every regular file in it. +# It deliberately does not take a list of paths: a third binary added later is +# exactly the thing that must not become invisible again. As a tripwire against +# a silently empty enumeration, /app/cudly must be among the binaries found. +# +# WHAT IT FAILS ON. Any advisory that has a published fixed version, in any of +# those binaries, at any severity. That is the whole class that actually +# occurred: a binary built on a superseded Go toolchain carries stdlib +# advisories fixed in a later patch release, and a stale third-party binary +# carries module advisories fixed in a later release. Both are actionable by +# rebuilding or bumping. +# +# WHAT IT TOLERATES, AND WHY. An advisory with no published fix. Today +# /app/cudly carries exactly one, GO-2026-5932 (golang.org/x/crypto/openpgp is +# unmaintained), introduced at "0" with no fixed version now or ever, and +# unreachable in this binary. Failing on it would make the gate permanently red +# with no available action, which is how gates get disabled. Tolerated +# advisories are still printed on every run, so they cannot go unnoticed. This +# is a property of the finding, not an allowlist of advisory IDs: nothing here +# has to be edited when the set of unfixable advisories changes, and an +# advisory becomes gating the moment upstream publishes a fix. +# +# WHAT IT DOES NOT COVER. OS package CVEs in the base image (musl, openssl, +# zlib, curl). govulncheck only knows about Go modules and the Go standard +# library. A Trivy scan-type: image step would cover those; it is not added +# here because the pinned alpine:3.21.3 runtime base already carries fixable +# CRITICAL/HIGH openssl, musl and zlib advisories, so such a gate would land +# red. Tracked separately. +# +# Exit 0 = every Go binary found, and no advisory with a published fix. +# Exit 1 = at least one fixable advisory, or the enumeration came back empty. +# Exit 2 = usage error or a missing prerequisite. +# +# Usage: +# scripts/scan-shipped-image.sh IMAGE_REF +# +# Requires: docker, go, govulncheck, jq, tar. + +set -euo pipefail + +# The image is built by the Dockerfile's final stage, so this path is a build +# invariant, not a guess. Its absence means the enumeration below silently read +# the wrong filesystem, which is the failure mode that makes a scanner report a +# clean run it never performed. +readonly REQUIRED_BINARY="/app/cudly" + +# classify_findings JSON_FILE LABEL +# +# Reads a `govulncheck -format json` stream and reports one line per advisory, +# split by whether upstream has published a fix. Returns 0 when every advisory +# is unfixable (including when there are none), 1 when at least one is fixable. +# +# Sourceable so scripts/test-scan-shipped-image.sh can exercise the verdict +# against recorded govulncheck output without docker or a built image. +classify_findings() { + if [[ $# -ne 2 ]]; then + echo "ERROR: classify_findings needs JSON_FILE and LABEL" >&2 + return 2 + fi + local json="$1" label="$2" + + if [[ ! -f "$json" ]]; then + echo "ERROR: no govulncheck output at $json" >&2 + return 2 + fi + + # The stream is concatenated JSON objects, hence -s. One advisory produces + # several finding records (one per trace), so group by OSV id and keep the + # first published fixed version seen for it. + # + # Every jq result is checked. errexit is suppressed inside a function whose + # status is being tested by the caller, so an unchecked failure here would + # leave the counts empty, make the comparisons below error out, and land in + # the "clean" branch -- a scan reporting a verdict it never computed. + local summary + if ! summary="$(jq -s ' + [ .[] | select(has("finding")) | .finding ] + | group_by(.osv) + | map({ + osv: .[0].osv, + fixed: (map(.fixed_version // empty) | first), + modules: (map(.trace[0].module // "unknown") | unique | join(", ")) + }) + | sort_by(.osv) + ' "$json")"; then + echo "ERROR: could not parse govulncheck output at $json" >&2 + return 2 + fi + + local fixable unfixable + if ! fixable="$(jq -r '[.[] | select(.fixed != null)] | length' <<<"$summary")" || + ! unfixable="$(jq -r '[.[] | select(.fixed == null)] | length' <<<"$summary")"; then + echo "ERROR: could not summarise the advisories in $json" >&2 + return 2 + fi + if ! [[ "$fixable" =~ ^[0-9]+$ && "$unfixable" =~ ^[0-9]+$ ]]; then + echo "ERROR: unexpected advisory counts from $json" \ + "(fixable='$fixable', unfixable='$unfixable')" >&2 + return 2 + fi + + if [[ "$fixable" -gt 0 ]]; then + jq -r '.[] | select(.fixed != null) + | " FIXABLE \(.osv) in \(.modules) fixed in \(.fixed)"' <<<"$summary" + fi + if [[ "$unfixable" -gt 0 ]]; then + jq -r '.[] | select(.fixed == null) + | " no fix \(.osv) in \(.modules) (tolerated: no published fix)"' <<<"$summary" + fi + + if [[ "$fixable" -gt 0 ]]; then + echo " => $label: $fixable fixable advisory/advisories, $unfixable without a fix" + return 1 + fi + echo " => $label: clean ($unfixable advisory/advisories without a published fix)" + return 0 +} + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: $1 is required but not on PATH" >&2 + exit 2 + fi +} + +# Set by main() before anything that can fail, and only read by cleanup(). +WORKDIR="" +CONTAINER_ID="" + +cleanup() { + if [[ -n "$CONTAINER_ID" ]]; then + docker rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true + fi + # Created by mktemp -d in this run, so nothing pre-existing is removed. + if [[ -n "$WORKDIR" ]]; then + rm -rf "$WORKDIR" + fi +} + +main() { + if [[ $# -ne 1 ]]; then + echo "Usage: $0 IMAGE_REF" >&2 + exit 2 + fi + local image="$1" + + require_tool docker + require_tool go + require_tool govulncheck + require_tool jq + require_tool tar + + WORKDIR="$(mktemp -d)" + trap cleanup EXIT + local workdir="$WORKDIR" + + local rootfs="$workdir/rootfs" + mkdir -p "$rootfs" + + echo "==> exporting the filesystem of $image" + CONTAINER_ID="$(docker create "$image")" + # /dev, /proc and /sys carry entries an unprivileged extract cannot recreate. + docker export "$CONTAINER_ID" | + tar -x -C "$rootfs" --exclude='dev/*' --exclude='proc/*' --exclude='sys/*' + docker rm -f "$CONTAINER_ID" >/dev/null + CONTAINER_ID="" + + # `go version FILE...` prints ": go" for each Go binary and + # reports everything else on stderr, exiting non-zero because most files in + # an image are not Go binaries. Its exit status is therefore not a verdict; + # the empty-result check below is. + # + # Every regular file is offered, not only the executable ones: a Go binary + # shipped without its execute bit, or chmod'd at runtime, is still a Go + # binary that runs. Asking about all 1500-odd files in this image costs under + # a second, so there is nothing to buy by narrowing the question. + local file_list="$workdir/files.nul" + find "$rootfs" -type f -print0 >"$file_list" + local raw="$workdir/go-version.txt" + if ! xargs -0 go version <"$file_list" >"$raw" 2>/dev/null; then + : # expected, see above + fi + + local binaries="$workdir/go-binaries.tsv" + awk ' + /: go[0-9]/ { + i = index($0, ": go") + printf "%s\t%s\n", substr($0, 1, i - 1), substr($0, i + 2) + } + ' "$raw" | sort >"$binaries" + + if [[ ! -s "$binaries" ]]; then + echo "ERROR: no Go binary found in $image." >&2 + echo " An image that ships no Go binary is not this project's image," >&2 + echo " so treat this as a broken scan, not a clean one." >&2 + exit 1 + fi + + if ! awk -v want="$rootfs$REQUIRED_BINARY" -F'\t' '$1 == want { found = 1 } END { exit !found }' "$binaries"; then + echo "ERROR: $REQUIRED_BINARY is not among the Go binaries found in $image:" >&2 + sed "s|^$rootfs| |" "$binaries" >&2 + echo " The enumeration read something other than the shipped image." >&2 + exit 1 + fi + + local count + count="$(wc -l <"$binaries" | tr -d ' ')" + echo "==> $count Go binary/binaries in $image" + + local status=0 + local binpath toolchain rel out rc + while IFS=$'\t' read -r binpath toolchain; do + rel="${binpath#"$rootfs"}" + echo " -> $rel (built with $toolchain)" + out="$workdir/govulncheck$(printf '%s' "$rel" | tr '/' '-').json" + set +e + govulncheck -mode=binary -format json "$binpath" >"$out" 2>"$out.err" + rc=$? + set -e + # 0 is what JSON output returns even with findings; 3 is govulncheck's + # documented "vulnerabilities found". Anything else is a scan error, and a + # scan that did not run must not be reported as a binary with no findings. + if [[ "$rc" -ne 0 && "$rc" -ne 3 ]]; then + echo "ERROR: govulncheck failed on $rel (exit $rc)" >&2 + cat "$out.err" >&2 + status=1 + continue + fi + if ! classify_findings "$out" "$rel"; then + status=1 + fi + done <"$binaries" + + if [[ "$status" -ne 0 ]]; then + echo "" >&2 + echo "FAILED: the shipped image carries at least one advisory with a published fix." >&2 + echo " Rebuild the image on the current toolchain, or bump the dependency /" >&2 + echo " third-party binary the advisory names above. A source-mode scan of" >&2 + echo " this repository cannot see any of it." >&2 + exit 1 + fi + + echo "" + echo "OK: $count Go binary/binaries scanned in $image; no advisory with a published fix." + echo " Advisories without a fix are listed above and are not gated; OS package CVEs" + echo " in the base image are out of scope for govulncheck (see the header)." +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/test-scan-shipped-image.sh b/scripts/test-scan-shipped-image.sh new file mode 100755 index 000000000..2b1f6696e --- /dev/null +++ b/scripts/test-scan-shipped-image.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# test-scan-shipped-image.sh +# +# Exercises the verdict half of scan-shipped-image.sh in BOTH directions, +# against recorded govulncheck output, with no docker and no built image. +# +# The scan itself only has value if it can go red. A scanner that always +# passes reads as coverage while providing none, which is the failure this +# whole check exists to close (issue #1836), so the failing cases here are the +# important ones. +# +# The fixtures under testdata/scan-shipped-image/ are real, unmodified +# `govulncheck -mode=binary -format json` finding records: +# +# stale-toolchain-binary.jsonl the prebuilt golang-migrate v4.19.1 release +# (go1.25.4) that the image shipped before +# #1833, narrowed to six advisories so the +# fixture stays reviewable: four with a +# published fix, two without. +# unfixable-only.jsonl /app/cudly as built on main: one advisory, +# GO-2026-5932, with no fixed version. +# no-findings.jsonl /usr/local/bin/migrate as built on main: +# nothing at all. +# +# malformed.jsonl is the one hand-written fixture: it stands in for output that +# exists but cannot be parsed, which must not read as a clean binary. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURES="${SCRIPT_DIR}/testdata/scan-shipped-image" + +# shellcheck source=scripts/scan-shipped-image.sh +source "${SCRIPT_DIR}/scan-shipped-image.sh" + +pass=0 +fail=0 + +# run_case LABEL EXPECTED_EXIT FIXTURE [EXPECTED_SUBSTRING...] +# +# The substrings matter as much as the exit code: a classifier that returns the +# right verdict from the wrong reading (every advisory lumped as fixable, say) +# would still exit 1 on the failing fixture. +run_case() { + local label="$1" expected_exit="$2" fixture="$3" + shift 3 + + local actual_exit=0 output + output="$(classify_findings "$fixture" "$label" 2>&1)" || actual_exit=$? + + local ok=1 + if [[ "$actual_exit" -ne "$expected_exit" ]]; then + echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)" + ok=0 + fi + local want + for want in "$@"; do + if [[ "$output" != *"$want"* ]]; then + echo "FAIL: $label (output does not contain: $want)" + ok=0 + fi + done + + if [[ "$ok" -eq 1 ]]; then + echo "PASS: $label" + pass=$((pass + 1)) + else + echo "----- output -----" + echo "$output" + echo "------------------" + fail=$((fail + 1)) + fi +} + +# Positive direction: a binary built on a superseded toolchain and carrying +# stale dependencies must fail, and the stdlib advisories must be among the +# reasons. This is the shape that shipped in #1833 and again in #1835. +run_case "stale-toolchain binary fails, and names its fixable advisories" 1 \ + "${FIXTURES}/stale-toolchain-binary.jsonl" \ + "4 fixable advisory/advisories, 2 without a fix" \ + "FIXABLE GO-2026-6218 in stdlib fixed in v1.25.13" \ + "FIXABLE GO-2026-4771 in github.com/jackc/pgx/v5 fixed in v5.9.0" + +# The same fixture's unfixable advisories must be reported as tolerated rather +# than counted towards the failure, or the "fail only on what is fixable" +# policy silently becomes "fail on everything". +run_case "unfixable advisories in a failing binary are tolerated, not counted" 1 \ + "${FIXTURES}/stale-toolchain-binary.jsonl" \ + "no fix GO-2022-0635 in github.com/aws/aws-sdk-go (tolerated: no published fix)" \ + "no fix GO-2026-4518" + +# Negative direction: today's /app/cudly. GO-2026-5932 has no fixed version now +# or ever, so gating on it would make this check permanently red with no +# available action. It must still be printed. +run_case "an advisory with no published fix does not fail the scan" 0 \ + "${FIXTURES}/unfixable-only.jsonl" \ + "clean (1 advisory/advisories without a published fix)" \ + "no fix GO-2026-5932 in golang.org/x/crypto" + +run_case "a binary with no findings passes" 0 \ + "${FIXTURES}/no-findings.jsonl" \ + "clean (0 advisory/advisories without a published fix)" + +# A missing file is exit 2 (could not check), distinct from exit 0 (checked, +# nothing gating). govulncheck failing to write its output must never read as a +# clean binary. +run_case "a missing govulncheck output is exit 2, not a clean verdict" 2 \ + "${FIXTURES}/does-not-exist.jsonl" + +# Same reasoning for output that exists but cannot be read. errexit is +# suppressed inside a function whose status the caller is testing, so an +# unparseable stream must be rejected explicitly rather than falling through to +# the "no fixable advisories" branch with empty counts. +run_case "unparseable govulncheck output is exit 2, not a clean verdict" 2 \ + "${FIXTURES}/malformed.jsonl" + +echo "" +echo "Results: ${pass} passed, ${fail} failed." +[[ "$fail" -eq 0 ]] diff --git a/scripts/testdata/scan-shipped-image/malformed.jsonl b/scripts/testdata/scan-shipped-image/malformed.jsonl new file mode 100644 index 000000000..4a43b169c --- /dev/null +++ b/scripts/testdata/scan-shipped-image/malformed.jsonl @@ -0,0 +1 @@ +this is not JSON at all {{{ diff --git a/scripts/testdata/scan-shipped-image/no-findings.jsonl b/scripts/testdata/scan-shipped-image/no-findings.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl b/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl new file mode 100644 index 000000000..7d7ecaf81 --- /dev/null +++ b/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl @@ -0,0 +1,26 @@ +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6"}]}} +{"finding":{"osv":"GO-2026-4518","trace":[{"module":"github.com/jackc/pgproto3/v2","version":"v2.3.3"}]}} +{"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4"}]}} +{"finding":{"osv":"GO-2026-4394","fixed_version":"v1.40.0","trace":[{"module":"go.opentelemetry.io/otel/sdk","version":"v1.36.0"}]}} +{"finding":{"osv":"GO-2026-4864","fixed_version":"v1.25.9","trace":[{"module":"stdlib","version":"v1.25.4"}]}} +{"finding":{"osv":"GO-2026-6218","fixed_version":"v1.25.13","trace":[{"module":"stdlib","version":"v1.25.4"}]}} +{"finding":{"osv":"GO-2026-6218","fixed_version":"v1.25.13","trace":[{"module":"stdlib","version":"v1.25.4","package":"net/url"}]}} +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6","package":"github.com/aws/aws-sdk-go/service/s3/s3crypto"}]}} +{"finding":{"osv":"GO-2026-4394","fixed_version":"v1.40.0","trace":[{"module":"go.opentelemetry.io/otel/sdk","version":"v1.36.0","package":"go.opentelemetry.io/otel/sdk/resource"}]}} +{"finding":{"osv":"GO-2026-4864","fixed_version":"v1.25.9","trace":[{"module":"stdlib","version":"v1.25.4","package":"internal/syscall/unix"}]}} +{"finding":{"osv":"GO-2026-4518","trace":[{"module":"github.com/jackc/pgproto3/v2","version":"v2.3.3","package":"github.com/jackc/pgproto3/v2"}]}} +{"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4","package":"github.com/jackc/pgx/v5/pgproto3"}]}} +{"finding":{"osv":"GO-2026-6218","fixed_version":"v1.25.13","trace":[{"module":"stdlib","version":"v1.25.4","package":"net/url","function":"Parse","receiver":"URL"}]}} +{"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4","package":"github.com/jackc/pgx/v5/pgproto3","function":"Receive","receiver":"Backend"}]}} +{"finding":{"osv":"GO-2026-6218","fixed_version":"v1.25.13","trace":[{"module":"stdlib","version":"v1.25.4","package":"net/url","function":"ResolveReference","receiver":"URL"}]}} +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6","package":"github.com/aws/aws-sdk-go/service/s3/s3crypto","function":"NewEncryptionClient"}]}} +{"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4","package":"github.com/jackc/pgx/v5/pgproto3","function":"Receive","receiver":"Backend"}]}} +{"finding":{"osv":"GO-2026-4518","trace":[{"module":"github.com/jackc/pgproto3/v2","version":"v2.3.3","package":"github.com/jackc/pgproto3/v2","function":"Decode","receiver":"DataRow"}]}} +{"finding":{"osv":"GO-2026-6218","fixed_version":"v1.25.13","trace":[{"module":"stdlib","version":"v1.25.4","package":"net/url","function":"Parse","receiver":"URL"}]}} +{"finding":{"osv":"GO-2026-4394","fixed_version":"v1.40.0","trace":[{"module":"go.opentelemetry.io/otel/sdk","version":"v1.36.0","package":"go.opentelemetry.io/otel/sdk/resource","function":"New"}]}} +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6","package":"github.com/aws/aws-sdk-go/service/s3/s3crypto","function":"NewDecryptionClient"}]}} +{"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4","package":"github.com/jackc/pgx/v5/pgproto3","function":"Decode","receiver":"Bind"}]}} +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6","package":"github.com/aws/aws-sdk-go/service/s3/s3crypto","function":"NewEncryptionClient"}]}} +{"finding":{"osv":"GO-2026-4864","fixed_version":"v1.25.9","trace":[{"module":"stdlib","version":"v1.25.4","package":"internal/syscall/unix","function":"Fchmodat"}]}} +{"finding":{"osv":"GO-2026-4518","trace":[{"module":"github.com/jackc/pgproto3/v2","version":"v2.3.3","package":"github.com/jackc/pgproto3/v2","function":"Receive","receiver":"Frontend"}]}} +{"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6","package":"github.com/aws/aws-sdk-go/service/s3/s3crypto","function":"NewDecryptionClient"}]}} diff --git a/scripts/testdata/scan-shipped-image/unfixable-only.jsonl b/scripts/testdata/scan-shipped-image/unfixable-only.jsonl new file mode 100644 index 000000000..471db694a --- /dev/null +++ b/scripts/testdata/scan-shipped-image/unfixable-only.jsonl @@ -0,0 +1,15 @@ +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/clearsign"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/errors"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/elgamal"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/s2k"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/packet"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/armor"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/packet","function":"org/x/crypto/openpgp/packet/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/armor","function":"org/x/crypto/openpgp/armor/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/clearsign","function":"org/x/crypto/openpgp/clearsign/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/errors","function":"org/x/crypto/openpgp/errors/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/elgamal","function":"org/x/crypto/openpgp/elgamal/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/s2k","function":"org/x/crypto/openpgp/s2k/*","receiver":"golang"}]}} +{"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp","function":"org/x/crypto/openpgp/*","receiver":"golang"}]}} From 9eb662022f1c422bb19e4b42729de9f5a03d80e0 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 18 Aug 2026 06:52:46 +0200 Subject: [PATCH 2/3] fix(ci): reject a degenerate govulncheck stream instead of calling it clean classify_findings guarded only against a jq parse error, and none of the inputs that matter is one. An empty stream, a lone `{}`, and a stream carrying no finding records are all well-formed JSON that the filter turns into `[]` with jq exiting 0. `[]` then read as zero findings, which read as a clean binary. So a govulncheck invocation that silently produced nothing certified the image as clean, which is the failure shape this whole check exists to close, one level up. Reproduced before fixing: empty stream jq_rc=0 result=[] {} jq_rc=0 result=[] {"osv":{"id":"X"}} jq_rc=0 result=[] Require the stream's first message to carry config.protocol_version, which every real govulncheck run emits, and return 2 when it does not. The jq chain is total (`objects`/`strings` yield nothing rather than erroring on the wrong type), so a first message of any other shape is rejected rather than throwing. A top-level `[]` is a genuine parse error and does not exercise this path, so it is not used as the regression case. The recorded fixtures now open with the config message govulncheck really emitted, so they stay representative of a real stream. no-findings.jsonl becomes a config-only stream, which is what a genuinely clean run looks like and is exactly what distinguishes it from the two new degenerate fixtures. The suite goes from 6 cases to 8, all passing, and a fourth mutation (guard removed) turns it red. Refs #1836 --- scripts/scan-shipped-image.sh | 28 ++++++++++++++++++- scripts/test-scan-shipped-image.sh | 27 +++++++++++++++--- .../scan-shipped-image/empty-stream.jsonl | 0 .../scan-shipped-image/no-findings.jsonl | 1 + .../scan-shipped-image/not-a-report.jsonl | 1 + .../stale-toolchain-binary.jsonl | 1 + .../scan-shipped-image/unfixable-only.jsonl | 1 + 7 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 scripts/testdata/scan-shipped-image/empty-stream.jsonl create mode 100644 scripts/testdata/scan-shipped-image/not-a-report.jsonl diff --git a/scripts/scan-shipped-image.sh b/scripts/scan-shipped-image.sh index 21a79bf13..1f0e455a4 100755 --- a/scripts/scan-shipped-image.sh +++ b/scripts/scan-shipped-image.sh @@ -64,7 +64,8 @@ readonly REQUIRED_BINARY="/app/cudly" # # Reads a `govulncheck -format json` stream and reports one line per advisory, # split by whether upstream has published a fix. Returns 0 when every advisory -# is unfixable (including when there are none), 1 when at least one is fixable. +# is unfixable (including when there are none), 1 when at least one is fixable, +# and 2 when the stream is not a govulncheck report it can draw a verdict from. # # Sourceable so scripts/test-scan-shipped-image.sh can exercise the verdict # against recorded govulncheck output without docker or a built image. @@ -80,6 +81,31 @@ classify_findings() { return 2 fi + # Establish that this is a govulncheck report at all, before reading a + # verdict out of it. An empty stream, a lone `{}`, or a stream carrying no + # finding records is well-formed JSON that yields zero findings, which the + # summary below would classify as a clean binary: a govulncheck invocation + # that silently produced nothing would certify the image. Checking for a + # parse error does not cover it, because none of those IS a parse error. + # + # Every real run opens with a config message, so require its + # protocol_version. The chain is total (`objects`/`strings` yield nothing + # rather than erroring on the wrong type), so a stream whose first message is + # some other shape lands on "" and is rejected rather than throwing. + local protocol + if ! protocol="$(jq -s -r ' + (.[0] | objects | .config | objects | .protocol_version | strings) // "" + ' "$json")"; then + echo "ERROR: could not parse govulncheck output at $json" >&2 + return 2 + fi + if [[ -z "$protocol" ]]; then + echo "ERROR: $json is not a govulncheck report: its first message carries" >&2 + echo " no config.protocol_version. An empty, truncated or otherwise" >&2 + echo " degenerate stream must not read as a binary with no findings." >&2 + return 2 + fi + # The stream is concatenated JSON objects, hence -s. One advisory produces # several finding records (one per trace), so group by OSV id and keep the # first published fixed version seen for it. diff --git a/scripts/test-scan-shipped-image.sh b/scripts/test-scan-shipped-image.sh index 2b1f6696e..05ae9aa36 100755 --- a/scripts/test-scan-shipped-image.sh +++ b/scripts/test-scan-shipped-image.sh @@ -20,10 +20,17 @@ # unfixable-only.jsonl /app/cudly as built on main: one advisory, # GO-2026-5932, with no fixed version. # no-findings.jsonl /usr/local/bin/migrate as built on main: -# nothing at all. +# nothing beyond the config message, which is +# what a genuinely clean run looks like. # -# malformed.jsonl is the one hand-written fixture: it stands in for output that -# exists but cannot be parsed, which must not read as a clean binary. +# Each of those opens with the config message govulncheck really emitted. +# +# The remaining three are hand-written and stand in for output that exists but +# cannot be drawn a verdict from: malformed.jsonl is unparseable, while +# not-a-report.jsonl and empty-stream.jsonl are well-formed JSON carrying zero +# findings. The last two are the ones that matter: they used to classify as a +# clean binary, so a govulncheck invocation that silently produced nothing +# certified the image. # # Exits 0 when all cases pass; exits 1 on any failure. @@ -99,7 +106,7 @@ run_case "an advisory with no published fix does not fail the scan" 0 \ "clean (1 advisory/advisories without a published fix)" \ "no fix GO-2026-5932 in golang.org/x/crypto" -run_case "a binary with no findings passes" 0 \ +run_case "a real run that found nothing passes" 0 \ "${FIXTURES}/no-findings.jsonl" \ "clean (0 advisory/advisories without a published fix)" @@ -116,6 +123,18 @@ run_case "a missing govulncheck output is exit 2, not a clean verdict" 2 \ run_case "unparseable govulncheck output is exit 2, not a clean verdict" 2 \ "${FIXTURES}/malformed.jsonl" +# The two cases a parse-error check does NOT cover. Both are valid JSON that +# yields zero findings, so both used to be indistinguishable from a clean +# binary. Distinguishing them from no-findings.jsonl above is the entire point: +# a clean run still carries the config message, these do not. +run_case "a well-formed stream that is not a report is exit 2" 2 \ + "${FIXTURES}/not-a-report.jsonl" \ + "no config.protocol_version" + +run_case "an empty stream is exit 2, not a clean verdict" 2 \ + "${FIXTURES}/empty-stream.jsonl" \ + "no config.protocol_version" + echo "" echo "Results: ${pass} passed, ${fail} failed." [[ "$fail" -eq 0 ]] diff --git a/scripts/testdata/scan-shipped-image/empty-stream.jsonl b/scripts/testdata/scan-shipped-image/empty-stream.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/testdata/scan-shipped-image/no-findings.jsonl b/scripts/testdata/scan-shipped-image/no-findings.jsonl index e69de29bb..c0053cfd2 100644 --- a/scripts/testdata/scan-shipped-image/no-findings.jsonl +++ b/scripts/testdata/scan-shipped-image/no-findings.jsonl @@ -0,0 +1 @@ +{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","db_last_modified":"2026-08-14T16:22:54Z","scan_level":"symbol","scan_mode":"binary"}} diff --git a/scripts/testdata/scan-shipped-image/not-a-report.jsonl b/scripts/testdata/scan-shipped-image/not-a-report.jsonl new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/scripts/testdata/scan-shipped-image/not-a-report.jsonl @@ -0,0 +1 @@ +{} diff --git a/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl b/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl index 7d7ecaf81..07caa0236 100644 --- a/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl +++ b/scripts/testdata/scan-shipped-image/stale-toolchain-binary.jsonl @@ -1,3 +1,4 @@ +{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","db_last_modified":"2026-08-14T16:22:54Z","scan_level":"symbol","scan_mode":"binary"}} {"finding":{"osv":"GO-2022-0635","trace":[{"module":"github.com/aws/aws-sdk-go","version":"v1.49.6"}]}} {"finding":{"osv":"GO-2026-4518","trace":[{"module":"github.com/jackc/pgproto3/v2","version":"v2.3.3"}]}} {"finding":{"osv":"GO-2026-4771","fixed_version":"v5.9.0","trace":[{"module":"github.com/jackc/pgx/v5","version":"v5.5.4"}]}} diff --git a/scripts/testdata/scan-shipped-image/unfixable-only.jsonl b/scripts/testdata/scan-shipped-image/unfixable-only.jsonl index 471db694a..88025c616 100644 --- a/scripts/testdata/scan-shipped-image/unfixable-only.jsonl +++ b/scripts/testdata/scan-shipped-image/unfixable-only.jsonl @@ -1,3 +1,4 @@ +{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","db_last_modified":"2026-08-14T16:22:54Z","scan_level":"symbol","scan_mode":"binary"}} {"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0"}]}} {"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/clearsign"}]}} {"finding":{"osv":"GO-2026-5932","trace":[{"module":"golang.org/x/crypto","version":"v0.53.0","package":"golang.org/x/crypto/openpgp/errors"}]}} From 09482b350b08c389fe74ea5add536cc6b5683c09 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 18 Aug 2026 07:20:52 +0200 Subject: [PATCH 3/3] fix(ci): pin the govulncheck schema version instead of only requiring one The report guard accepted any non-empty config.protocol_version, and non-empty is not a restriction. A future govulncheck emitting a different schema satisfies a presence check, flows through the same `select(has("finding"))` filter, produces zero findings and classifies as a clean binary. That is the same fail-open one version bump away, and it arrives silently on a tool upgrade rather than through a code change anyone reviews. Compare protocol_version to "v1.0.0" exactly, the value govulncheck v1.1.4 emits. The extraction chain stays total, so a first message of any other shape still lands on "" and is rejected rather than throwing. The error reports what it found against what it expected and names the pinned govulncheck version, so whoever hits it after an upgrade knows the parser needs revalidating rather than that the scan is broken. Both version constants carry a pointer to the govulncheck pin in ci.yml they must move with. New fixture wrong-protocol-version.jsonl is the real config message with only protocol_version changed to v2.0.0. It carries no finding records this parser can see, so before this change it classified as clean; it now exits 2. The suite goes from 8 cases to 9, all passing, and the mutation harness grows a fifth case asserting that relaxing the comparison back to a presence check turns the suite red. Refs #1836 --- scripts/scan-shipped-image.sh | 34 ++++++++++++++---- scripts/test-scan-shipped-image.sh | 36 +++++++++++++------ .../wrong-protocol-version.jsonl | 1 + 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 scripts/testdata/scan-shipped-image/wrong-protocol-version.jsonl diff --git a/scripts/scan-shipped-image.sh b/scripts/scan-shipped-image.sh index 1f0e455a4..7f5df9cb4 100755 --- a/scripts/scan-shipped-image.sh +++ b/scripts/scan-shipped-image.sh @@ -60,6 +60,19 @@ set -euo pipefail # clean run it never performed. readonly REQUIRED_BINARY="/app/cudly" +# The JSON schema version govulncheck emits, and the version of govulncheck +# this parser was written against. Pinned to an exact value rather than merely +# required to be present: a future govulncheck emitting a different schema +# would satisfy a non-empty check, flow through the `select(has("finding"))` +# filter, produce zero findings and classify as clean. That is the same +# fail-open one version bump away, and it would arrive silently on a tool +# upgrade rather than through a code change anyone reviews. Keep +# PINNED_GOVULNCHECK_VERSION in step with the `go install +# golang.org/x/vuln/cmd/govulncheck@...` pin in .github/workflows/ci.yml, and +# revalidate this parser against the new schema when bumping either. +readonly EXPECTED_PROTOCOL_VERSION="v1.0.0" +readonly PINNED_GOVULNCHECK_VERSION="v1.1.4" + # classify_findings JSON_FILE LABEL # # Reads a `govulncheck -format json` stream and reports one line per advisory, @@ -89,9 +102,10 @@ classify_findings() { # parse error does not cover it, because none of those IS a parse error. # # Every real run opens with a config message, so require its - # protocol_version. The chain is total (`objects`/`strings` yield nothing - # rather than erroring on the wrong type), so a stream whose first message is - # some other shape lands on "" and is rejected rather than throwing. + # protocol_version, and require it to be exactly the schema this parser + # reads. The chain is total (`objects`/`strings` yield nothing rather than + # erroring on the wrong type), so a stream whose first message is some other + # shape lands on "" and is rejected rather than throwing. local protocol if ! protocol="$(jq -s -r ' (.[0] | objects | .config | objects | .protocol_version | strings) // "" @@ -99,10 +113,16 @@ classify_findings() { echo "ERROR: could not parse govulncheck output at $json" >&2 return 2 fi - if [[ -z "$protocol" ]]; then - echo "ERROR: $json is not a govulncheck report: its first message carries" >&2 - echo " no config.protocol_version. An empty, truncated or otherwise" >&2 - echo " degenerate stream must not read as a binary with no findings." >&2 + if [[ "$protocol" != "$EXPECTED_PROTOCOL_VERSION" ]]; then + { + echo "ERROR: $json does not carry the govulncheck report schema this parser reads." + echo " expected config.protocol_version = \"$EXPECTED_PROTOCOL_VERSION\"" \ + "(as emitted by govulncheck $PINNED_GOVULNCHECK_VERSION)" + echo " found: \"$protocol\"" + echo " An empty, truncated or newer-schema stream must not read as a binary" + echo " with no findings. If govulncheck was upgraded, revalidate this parser" + echo " against the new schema rather than relaxing this check." + } >&2 return 2 fi diff --git a/scripts/test-scan-shipped-image.sh b/scripts/test-scan-shipped-image.sh index 05ae9aa36..2dc51681a 100755 --- a/scripts/test-scan-shipped-image.sh +++ b/scripts/test-scan-shipped-image.sh @@ -25,12 +25,13 @@ # # Each of those opens with the config message govulncheck really emitted. # -# The remaining three are hand-written and stand in for output that exists but +# The remaining four are hand-written and stand in for output that exists but # cannot be drawn a verdict from: malformed.jsonl is unparseable, while -# not-a-report.jsonl and empty-stream.jsonl are well-formed JSON carrying zero -# findings. The last two are the ones that matter: they used to classify as a -# clean binary, so a govulncheck invocation that silently produced nothing -# certified the image. +# not-a-report.jsonl, empty-stream.jsonl and wrong-protocol-version.jsonl are +# well-formed JSON carrying zero findings this parser can see. Those three are +# the ones that matter, because each used to classify as a clean binary: an +# invocation that silently produced nothing, or a future govulncheck emitting a +# schema this parser was not written against, would certify the image. # # Exits 0 when all cases pass; exits 1 on any failure. @@ -123,17 +124,30 @@ run_case "a missing govulncheck output is exit 2, not a clean verdict" 2 \ run_case "unparseable govulncheck output is exit 2, not a clean verdict" 2 \ "${FIXTURES}/malformed.jsonl" -# The two cases a parse-error check does NOT cover. Both are valid JSON that -# yields zero findings, so both used to be indistinguishable from a clean -# binary. Distinguishing them from no-findings.jsonl above is the entire point: -# a clean run still carries the config message, these do not. +# The cases a parse-error check does NOT cover. All are valid JSON that yields +# zero findings, so all used to be indistinguishable from a clean binary. +# Distinguishing them from no-findings.jsonl above is the entire point: a clean +# run carries the config message this parser was written against, these do not. run_case "a well-formed stream that is not a report is exit 2" 2 \ "${FIXTURES}/not-a-report.jsonl" \ - "no config.protocol_version" + 'expected config.protocol_version = "v1.0.0"' \ + 'found: ""' run_case "an empty stream is exit 2, not a clean verdict" 2 \ "${FIXTURES}/empty-stream.jsonl" \ - "no config.protocol_version" + 'expected config.protocol_version = "v1.0.0"' \ + 'found: ""' + +# Non-empty is not a restriction. A govulncheck emitting a schema this parser +# was not written against would satisfy a presence check, yield zero findings +# through the same filter, and read as clean, arriving silently on a tool +# upgrade rather than through a reviewed code change. The version is compared +# to its expected value, not merely asserted to exist. +run_case "a stream from an unexpected schema version is exit 2" 2 \ + "${FIXTURES}/wrong-protocol-version.jsonl" \ + 'expected config.protocol_version = "v1.0.0"' \ + 'found: "v2.0.0"' \ + "govulncheck v1.1.4" echo "" echo "Results: ${pass} passed, ${fail} failed." diff --git a/scripts/testdata/scan-shipped-image/wrong-protocol-version.jsonl b/scripts/testdata/scan-shipped-image/wrong-protocol-version.jsonl new file mode 100644 index 000000000..62211e597 --- /dev/null +++ b/scripts/testdata/scan-shipped-image/wrong-protocol-version.jsonl @@ -0,0 +1 @@ +{"config":{"protocol_version":"v2.0.0","scanner_name":"govulncheck","scanner_version":"v1.1.4","db":"https://vuln.go.dev","db_last_modified":"2026-08-14T16:22:54Z","scan_level":"symbol","scan_mode":"binary"}}