From 9e225d68efa32309b7609ce06083acf4d9a839e3 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:34:44 +0200 Subject: [PATCH 01/10] feat(cve-report): add canonical CVE-keyed aggregation --- .github/scripts/cve-aggregate.sh | 135 ++++++++++++++++++ .../fixtures/cve/v5.2-arm64.hardened.json | 3 + .../tests/fixtures/cve/v5.2-arm64.meta.json | 1 + .../tests/fixtures/cve/v5.2-arm64.plain.json | 4 + .../cve/v5.2-kern-amd64.hardened.json | 4 + .../fixtures/cve/v5.2-kern-amd64.meta.json | 1 + .../fixtures/cve/v5.2-kern-amd64.plain.json | 4 + .github/scripts/tests/run.sh | 98 ++++++++++--- 8 files changed, 228 insertions(+), 22 deletions(-) create mode 100755 .github/scripts/cve-aggregate.sh create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-arm64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-arm64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-arm64.plain.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-kern-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-kern-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-kern-amd64.plain.json diff --git a/.github/scripts/cve-aggregate.sh b/.github/scripts/cve-aggregate.sh new file mode 100755 index 0000000..691b5af --- /dev/null +++ b/.github/scripts/cve-aggregate.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Aggregate the release workflow's per-image Trivy scans into one canonical, +# CVE-keyed JSON document -- the single interface every renderer reads. +# +# Usage: cve-aggregate.sh +# /.meta.json {image,variant,arch,plain_digest,hardened_digest} +# /.plain.json full Trivy JSON of the plain image +# /.hardened.json full Trivy JSON of the hardened image (may be absent) +# +# Emits on stdout: +# {generated, images:[{image,variant,arch,plain_digest,hardened_digest, +# hardened_state,counts{},fixable_count}], +# cves:[{id,severity,pkg,status,fix,affects:[image-index,...]}]} +# +# Rows are keyed on (id, pkg, status) -- NOT on id alone. A CVE affecting two +# packages, fixed on one and residual on the other, must stay two entries; and a +# CVE fixed in one image but residual in another must stay two entries. Keying on +# id alone silently merges these and loses rows (see the multipkg regression test). +# +# Deliberately streams per-file rather than slurping: the real data set is ~1 GB of +# Trivy JSON across 50 scans, which `jq -s` cannot hold. +set -euo pipefail + +data_dir="${1:?usage: cve-aggregate.sh }" +timestamp="${2:?usage: cve-aggregate.sh }" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +metas="$(find "$data_dir" -maxdepth 1 -name '*.meta.json' | LC_ALL=C sort)" +if [ -z "$metas" ]; then + jq -n --arg ts "$timestamp" '{generated:$ts, images:[], cves:[]}' + exit 0 +fi + +: > "${work}/images.jsonl" +: > "${work}/rows.tsv" + +idx=0 +while IFS= read -r meta; do + [ -n "$meta" ] || continue + key="$(basename "$meta" .meta.json)" + plain="${data_dir}/${key}.plain.json" + hardened="${data_dir}/${key}.hardened.json" + + # A meta without a plain scan means the scan step dropped this variant; skip it + # rather than emitting an image with no data. + [ -f "$plain" ] || continue + + pd="$(jq -r '.plain_digest' "$meta")" + hd="$(jq -r '.hardened_digest' "$meta")" + + if [ ! -f "$hardened" ] || [ "$hd" = "unpublished" ]; then + state="not-produced" + elif [ "$pd" = "$hd" ]; then + state="identical" + else + state="patched" + fi + + # Row emission. Command substitution (not process substitution) so a jq + # failure aborts under set -e instead of silently yielding zero rows. + if [ "$state" = "not-produced" ]; then + rows="$(jq -r --argjson i "$idx" ' + [.Results[]?.Vulnerabilities[]?] + | unique_by([.VulnerabilityID,.PkgName]) + | .[] + | [$i, "unpatched", .VulnerabilityID, .PkgName, .Severity, + (if (.FixedVersion//"") != "" then "fix " + .FixedVersion + " available" else "no fix" end)] + | @tsv' "$plain")" + else + residual="$(jq -r --argjson i "$idx" ' + [.Results[]?.Vulnerabilities[]?] + | unique_by([.VulnerabilityID,.PkgName]) + | .[] + | [$i, "residual", .VulnerabilityID, .PkgName, .Severity, + (if (.FixedVersion//"") != "" then "fix " + .FixedVersion + " available" else "no fix" end)] + | @tsv' "$hardened")" + # fixed = present in plain, absent from hardened, keyed on id+pkg with EXACT + # array membership (index), never `inside`/`contains` -- those do recursive + # substring matching on string arrays and would match CVE-2024-1 inside + # CVE-2024-12345. + fixed="$(jq -r --argjson i "$idx" --slurpfile h "$hardened" ' + ([$h[0].Results[]?.Vulnerabilities[]? | .VulnerabilityID + " " + .PkgName] | unique) as $hkeys + | [.Results[]?.Vulnerabilities[]?] + | map(select( (.VulnerabilityID + " " + .PkgName) as $k | ($hkeys | index($k)) == null )) + | unique_by([.VulnerabilityID,.PkgName]) + | .[] + | [$i, "fixed", .VulnerabilityID, .PkgName, .Severity, + .InstalledVersion + " → " + (.FixedVersion//"?")] + | @tsv' "$plain")" + rows="$(printf '%s\n%s' "$residual" "$fixed")" + fi + printf '%s\n' "$rows" | grep -v '^$' >> "${work}/rows.tsv" || true + + # fixable_count is measured on the PLAIN scan: how many distinct (CVE,pkg) had an + # upstream fix available at scan time. It is the honest answer to "could Copa have + # done anything here", independent of whether it ran. + fixable="$(jq -r '[.Results[]?.Vulnerabilities[]? | select((.FixedVersion//"") != "")] + | unique_by([.VulnerabilityID,.PkgName]) | length' "$plain")" + + jq -n -c \ + --slurpfile m "$meta" \ + --arg state "$state" \ + --argjson fixable "$fixable" \ + '$m[0] | {image, variant, arch, plain_digest, hardened_digest, + hardened_state: $state, fixable_count: $fixable}' \ + >> "${work}/images.jsonl" + + idx=$((idx + 1)) +done <<< "$metas" + +# Assemble. Severity counts are derived from the emitted rows so the summary can +# never disagree with the tables it links to. +jq -n -r \ + --arg ts "$timestamp" \ + --slurpfile images "${work}/images.jsonl" \ + --rawfile rows "${work}/rows.tsv" \ + ' + ($rows | rtrimstr("\n") | if . == "" then [] else split("\n") end + | map(split("\t") | {i: (.[0]|tonumber), status: .[1], id: .[2], pkg: .[3], severity: .[4], fix: .[5]}) + ) as $r + | ($r | group_by([.id, .pkg, .status]) + | map({ id: .[0].id, severity: .[0].severity, pkg: .[0].pkg, + status: .[0].status, fix: .[0].fix, + affects: (map(.i) | unique) }) + | sort_by([.severity, .id, .pkg]) + ) as $cves + | ($r | group_by(.i) | map({key: (.[0].i|tostring), + value: (group_by(.severity) | map({key: .[0].severity, value: length}) | from_entries)}) + | from_entries) as $counts + | { generated: $ts, + images: ($images | to_entries | map(.value + {counts: ($counts[(.key|tostring)] // {})})), + cves: $cves } + ' diff --git a/.github/scripts/tests/fixtures/cve/v5.2-arm64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-arm64.hardened.json new file mode 100644 index 0000000..f068cbd --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-arm64.hardened.json @@ -0,0 +1,3 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-v5.2-hardened-arm64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-6020","PkgName":"libpam0g","Severity":"HIGH","InstalledVersion":"1.5.3-7","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-arm64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-arm64.meta.json new file mode 100644 index 0000000..1df7c6b --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-arm64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-v5.2","variant":"default","arch":"arm64","plain_digest":"sha256:aa11bb22cc33d05a8c1e6f4b9d3072a5e8c1b6f0d4a7e2c9b5083f1d6a4c7e2b90","hardened_digest":"sha256:bb22cc33dd44a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-arm64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-arm64.plain.json new file mode 100644 index 0000000..b108d05 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-arm64.plain.json @@ -0,0 +1,4 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-v5.2-arm64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2024-45491","PkgName":"libexpat1","Severity":"HIGH","InstalledVersion":"2.6.2-1","FixedVersion":"2.6.2-2+deb13u1"}, +{"VulnerabilityID":"CVE-2025-6020","PkgName":"libpam0g","Severity":"HIGH","InstalledVersion":"1.5.3-7","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.hardened.json new file mode 100644 index 0000000..72cef49 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.hardened.json @@ -0,0 +1,4 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-min-v5.2-hardened-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2026-0001","PkgName":"linux-libc-dev","Severity":"CRITICAL","InstalledVersion":"6.12.5-1","FixedVersion":""}, +{"VulnerabilityID":"CVE-2026-0002","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.meta.json new file mode 100644 index 0000000..6d388b8 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-min-v5.2","variant":"min","arch":"amd64","plain_digest":"sha256:cc33dd44ee55a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37","hardened_digest":"sha256:cc33dd44ee55a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.plain.json new file mode 100644 index 0000000..9610b21 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-kern-amd64.plain.json @@ -0,0 +1,4 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-min-v5.2-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2026-0001","PkgName":"linux-libc-dev","Severity":"CRITICAL","InstalledVersion":"6.12.5-1","FixedVersion":""}, +{"VulnerabilityID":"CVE-2026-0002","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index b5a00da..2ec7357 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -314,29 +314,83 @@ CF="$cf3" RETRY_DELAY=0 RETRY_MAX=2 "$WR" bash -c 'echo $(( $(cat "$CF") + 1 )) "$WR" >/dev/null 2>&1; rc=$? [ "$rc" = 2 ] && echo " ok: no-args -> exit 2 (usage)" || { echo " FAIL: no-args rc $rc (want 2)"; fail=1; } -echo "== generate-cve-report.sh ==" +echo "== cve-aggregate.sh ==" CVE_FIX="${ROOT}/.github/scripts/tests/fixtures/cve" -CVE_OUT="$(bash "${ROOT}/.github/scripts/generate-cve-report.sh" "$CVE_FIX" "2026-07-16 02:41 UTC" 2>/tmp/cve-err)"; CVE_RC=$? -assert_eq "$CVE_RC" "0" "generate-cve-report exits 0" -# digests table: full digest for a published image -assert_contains "$CVE_OUT" "sha256:1f3a9c4e2b7d05a8c1e6f4b9d3072a5e8c1b6f0d4a7e2c9b5083f1d6a4c7e2b90" "full plain digest in digests table" -assert_contains "$CVE_OUT" "sha256:8ad4c17b93e0a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37" "full hardened digest in digests table" -assert_contains "$CVE_OUT" "not published this run" "unpublished hardened shown in digests table" -# fixed row: short PLAIN digest pointer + old->new + fixed status -assert_contains "$CVE_OUT" "| \`1f3a9c4e2b7d\` | CVE-2024-45491 | HIGH | libexpat1 | ✅ fixed · 2.6.2-1 → 2.6.2-2+deb13u1 |" "fixed row rendered with plain short digest and version bump" -# residual row: short HARDENED digest pointer + no fix -assert_contains "$CVE_OUT" "| \`8ad4c17b93e0\` | CVE-2025-6020 | HIGH | libpam0g | ⚠️ residual · no fix |" "residual row rendered with hardened short digest" -# unpublished-hardened image: residual rows use 'unpublished' pointer + unpatched status -# hardened not produced: plain IS still published, so the row points at the PLAIN short digest -assert_contains "$CVE_OUT" "| \`44bec0a1d2e3\` | CVE-2024-7883 | MEDIUM | libxml2 | ⚠️ unpatched · hardened not produced |" "unpublished-hardened variant lists plain CVEs as unpatched (plain digest pointer)" -assert_contains "$CVE_OUT" "Development / rolling tags" "header notes dev exclusion" -# Regression: one CVE id affecting two packages, fixed on one (pkga) and residual on -# the other (pkgb) -- the fixed-set query must key on id+package (exact), not id -# alone, or the pkga row silently vanishes from BOTH tables (see fixture -# v5.2-multipkg-amd64: CVE-2025-9999 present in plain for pkga+pkgb, hardened only -# still has pkgb). -assert_contains "$CVE_OUT" "| \`dd81d549a32e\` | CVE-2025-9999 | HIGH | pkga | ✅ fixed · 1.0 → 1.1 |" "same CVE id fixed on one package (id+package keying)" -assert_contains "$CVE_OUT" "| \`d07739baf7cd\` | CVE-2025-9999 | HIGH | pkgb | ⚠️ residual · no fix |" "same CVE id residual on a different package (id+package keying)" +AGG="$(mktemp)"; tmpdirs+=("$AGG") +"${ROOT}/.github/scripts/cve-aggregate.sh" "$CVE_FIX" "2026-07-29 12:00 UTC" > "$AGG" 2>/tmp/agg-err; AGG_RC=$? +assert_eq "$AGG_RC" "0" "cve-aggregate exits 0" +assert_eq "$(jq -e 'type' "$AGG" 2>/dev/null | tr -d '"')" "object" "cve-aggregate emits a JSON object" +assert_eq "$(jq -r '.generated' "$AGG")" "2026-07-29 12:00 UTC" "timestamp passed through" + +# hardened_state classification, all three branches +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-v5.2" and .arch=="amd64") | .hardened_state' "$AGG")" \ + "patched" "differing digests -> patched" +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-min-v5.2") | .hardened_state' "$AGG")" \ + "identical" "equal plain/hardened digests -> identical" +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-debug-v5.2") | .hardened_state' "$AGG")" \ + "not-produced" "unpublished hardened digest -> not-produced" + +# affects collapsing: one CVE+pkg+status row spans both arches of php8.5-v5.2 +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-6020" and .pkg=="libpam0g")] | length' "$AGG")" \ + "1" "same CVE+pkg+status across two arches is ONE row" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-6020" and .pkg=="libpam0g") | .affects | length' "$AGG")" \ + "2" "that row affects two image entries" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-6020") | .affects | (unique | length)' "$AGG")" \ + "2" "affects has no duplicate indices" + +# every affects index is a valid images[] index +assert_eq "$(jq -r '(.images|length) as $n | [.cves[].affects[] | select(. < 0 or . >= $n)] | length' "$AGG")" \ + "0" "all affects indices are in range" + +# REGRESSION (moved from generate-cve-report.sh): one CVE id on two packages, fixed on +# pkga and residual on pkgb. Keying on id alone merges these and loses a row. +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-9999" and .pkg=="pkga") | .status' "$AGG")" \ + "fixed" "multipkg: CVE-2025-9999 fixed on pkga (id+pkg keying)" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-9999" and .pkg=="pkgb") | .status' "$AGG")" \ + "residual" "multipkg: CVE-2025-9999 residual on pkgb (id+pkg keying)" +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-9999")] | length' "$AGG")" \ + "2" "multipkg: both package rows survive" + +# fixed rows carry the version bump; residual rows carry the fix availability +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2024-45491") | .fix' "$AGG")" \ + "2.6.2-1 → 2.6.2-2+deb13u1" "fixed row records old -> new version" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-6020") | .fix' "$AGG")" \ + "no fix" "residual row with no upstream fix says so" + +# not-produced image yields unpatched rows +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2024-7883") | .status' "$AGG")" \ + "unpatched" "image without hardened scan yields unpatched rows" + +# per-image severity counts and fixable_count +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-min-v5.2") | .counts.CRITICAL' "$AGG")" \ + "1" "min image counts its CRITICAL row" +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-min-v5.2") | .fixable_count' "$AGG")" \ + "0" "min image has nothing fixable" +assert_eq "$(jq -r '.images[] | select(.image=="php8.5-v5.2" and .arch=="amd64") | .fixable_count' "$AGG")" \ + "1" "amd64 default image has one fixable CVE" + +# kernel-header rows are PRESENT in the canonical data (renderers exclude them, not this) +assert_eq "$(jq -r '[.cves[] | select(.pkg=="linux-libc-dev")] | length' "$AGG")" \ + "1" "linux-libc-dev row retained in canonical JSON" + +# empty input is valid, not an error +EMPTY_DIR="$(mktemp -d)"; tmpdirs+=("$EMPTY_DIR") +EMPTY_OUT="$("${ROOT}/.github/scripts/cve-aggregate.sh" "$EMPTY_DIR" "2026-07-29 12:00 UTC")"; EMPTY_RC=$? +assert_eq "$EMPTY_RC" "0" "empty data dir exits 0" +assert_eq "$(printf '%s' "$EMPTY_OUT" | jq -r '.images | length')" "0" "empty data dir -> zero images" +assert_eq "$(printf '%s' "$EMPTY_OUT" | jq -r '.cves | length')" "0" "empty data dir -> zero cves" + +# Malformed Trivy JSON must FAIL CLOSED -- abort non-zero with no output -- rather than +# emit a partial report that silently under-reports CVEs. A truncated or half-written +# scan file is the realistic failure here. +BAD_DIR="$(mktemp -d)"; tmpdirs+=("$BAD_DIR") +cp "${CVE_FIX}/v5.2-amd64.meta.json" "${CVE_FIX}/v5.2-amd64.hardened.json" "$BAD_DIR/" +printf '{"Results":[ THIS IS NOT JSON' > "${BAD_DIR}/v5.2-amd64.plain.json" +BAD_OUT="$(mktemp)"; tmpdirs+=("$BAD_OUT") +"${ROOT}/.github/scripts/cve-aggregate.sh" "$BAD_DIR" "2026-07-29 12:00 UTC" > "$BAD_OUT" 2>/dev/null; BAD_RC=$? +[ "$BAD_RC" != "0" ] && echo " ok: malformed Trivy JSON exits non-zero ($BAD_RC)" \ + || { echo " FAIL: malformed Trivy JSON exited 0 -- would emit a partial report"; fail=1; } +assert_eq "$(wc -c < "$BAD_OUT" | tr -d ' ')" "0" "malformed input produces no partial output" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From bcfad7f89198e6de3bf83c0d76e09a3beedd12af Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:43:03 +0200 Subject: [PATCH 02/10] test(cve-report): cover status-key regression in cve-aggregate.sh Prior fixtures could only detect a group_by key dropping .pkg (multipkg case, distinguished by package name). Add a fixture pair where the same (CVE id, package) is fixed in one image and residual in another, so dropping .status from the (id,pkg,status) row key is now caught too. Verified by mutating group_by([.id,.pkg,.status]) to group_by([.id,.pkg]) and confirming the new assertions fail, then restoring and confirming the suite passes again. --- .../cve/v5.2-statusmix-a-amd64.hardened.json | 1 + .../fixtures/cve/v5.2-statusmix-a-amd64.meta.json | 1 + .../cve/v5.2-statusmix-a-amd64.plain.json | 3 +++ .../cve/v5.2-statusmix-b-amd64.hardened.json | 3 +++ .../fixtures/cve/v5.2-statusmix-b-amd64.meta.json | 1 + .../cve/v5.2-statusmix-b-amd64.plain.json | 3 +++ .github/scripts/tests/run.sh | 15 +++++++++++++++ 7 files changed, 27 insertions(+) create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.plain.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.plain.json diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.hardened.json new file mode 100644 index 0000000..faf3fbd --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.hardened.json @@ -0,0 +1 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-statusmix-a-v5.2-hardened-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.meta.json new file mode 100644 index 0000000..7c27436 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-statusmix-a-v5.2","variant":"default","arch":"amd64","plain_digest":"sha256:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888","hardened_digest":"sha256:bbbb1111cccc2222dddd3333eeee4444ffff5555aaaa6666bbbb7777cccc8888"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.plain.json new file mode 100644 index 0000000..1b3f279 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-a-amd64.plain.json @@ -0,0 +1,3 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-statusmix-a-v5.2-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-7777","PkgName":"libfoo1","Severity":"HIGH","InstalledVersion":"1.0.0","FixedVersion":"1.0.1"} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.hardened.json new file mode 100644 index 0000000..8b17eaa --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.hardened.json @@ -0,0 +1,3 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-statusmix-b-v5.2-hardened-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-7777","PkgName":"libfoo1","Severity":"HIGH","InstalledVersion":"1.0.0","FixedVersion":"1.0.1"} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.meta.json new file mode 100644 index 0000000..8f2ac34 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-statusmix-b-v5.2","variant":"default","arch":"amd64","plain_digest":"sha256:cccc1111dddd2222eeee3333ffff4444aaaa5555bbbb6666cccc7777dddd8888","hardened_digest":"sha256:dddd1111eeee2222ffff3333aaaa4444bbbb5555cccc6666dddd7777eeee8888"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.plain.json new file mode 100644 index 0000000..84d03d9 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-statusmix-b-amd64.plain.json @@ -0,0 +1,3 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-statusmix-b-v5.2-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-7777","PkgName":"libfoo1","Severity":"HIGH","InstalledVersion":"1.0.0","FixedVersion":"1.0.1"} +]}]} diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 2ec7357..ec90b03 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -351,6 +351,21 @@ assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-9999" and .pkg=="pkgb") | .s assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-9999")] | length' "$AGG")" \ "2" "multipkg: both package rows survive" +# REGRESSION (status must be part of the key): the SAME (CVE id, package) pair is +# fixed in one image (statusmix-a: absent from that image's hardened scan) and +# residual in another (statusmix-b: still present in that image's hardened scan). +# Dropping `status` from the group_by key (i.e. grouping on [.id,.pkg] alone) would +# silently collapse these into ONE row and lose a status -- unlike the multipkg +# case above, .pkg alone cannot distinguish these two rows, only .status can. +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-7777" and .pkg=="libfoo1")] | length' "$AGG")" \ + "2" "status-mix: same (id,pkg) fixed in one image + residual in another stays TWO rows" +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-7777" and .pkg=="libfoo1") | .status] | sort | join(",")' "$AGG")" \ + "fixed,residual" "status-mix: both statuses present, not merged into one" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-7777" and .pkg=="libfoo1" and .status=="fixed") | .affects | length' "$AGG")" \ + "1" "status-mix: fixed row affects exactly the image where it was fixed" +assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2025-7777" and .pkg=="libfoo1" and .status=="residual") | .affects | length' "$AGG")" \ + "1" "status-mix: residual row affects exactly the image where it is residual" + # fixed rows carry the version bump; residual rows carry the fix availability assert_eq "$(jq -r '.cves[] | select(.id=="CVE-2024-45491") | .fix' "$AGG")" \ "2.6.2-1 → 2.6.2-2+deb13u1" "fixed row records old -> new version" From 99a59bd5881987c03d46232a94a1c24b09acf48f Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:48:15 +0200 Subject: [PATCH 03/10] feat(cve-report): add summary renderer --- .github/scripts/cve-render-summary.sh | 116 ++++++++++++++++++++++++++ .github/scripts/tests/run.sh | 35 ++++++++ 2 files changed, 151 insertions(+) create mode 100755 .github/scripts/cve-render-summary.sh diff --git a/.github/scripts/cve-render-summary.sh b/.github/scripts/cve-render-summary.sh new file mode 100755 index 0000000..70f27ba --- /dev/null +++ b/.github/scripts/cve-render-summary.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Render the summary section of the known-CVE report from the canonical CVE data. +# Usage: cve-render-summary.sh +# +# This is the part a reader actually reads. It must answer, in the first screenful: +# did hardening achieve anything, how bad is each image, and what is not tabulated. +set -euo pipefail + +data="${1:?usage: cve-render-summary.sh }" + +jq -r ' + def short: if startswith("sha256:") then .[7:19] else . end; + + .images as $imgs + | .cves as $cves + | ($cves | map(select(.pkg == "linux-libc-dev"))) as $kern + | ($cves | map(select(.pkg != "linux-libc-dev"))) as $tab + | ($imgs | map(.hardened_state) | unique) as $states + | ($imgs | map(.fixable_count) | add // 0) as $fixable + | ( + "# Known CVEs & hardening report" + , "" + , "_Generated \(.generated)._" + , "" + , "Per published **stable release image**: known CVEs from a full Trivy scan (all" + , "severities, OS + library packages, unfixable CVEs included). **Development / rolling" + , "tags (`*-dev`) are not covered** -- they are plain-only and never Copa-patched." + , "" + , "## Hardening outcome" + , "" + , ( if $fixable == 0 then + "**No fixable CVE was available upstream for any image in this run.** Debian ships no" + + " fix for any of the \($tab | map(.id) | unique | length) distinct CVEs found, so Copa" + + " had nothing to patch." + else + "**\($fixable) fixable CVE(s)** were available upstream across all images; see the" + + " `fixed` rows in the tables below." + end ) + , ( if ($states | length) == 1 and $states[0] == "identical" then + " Every `-hardened` tag in this run is therefore the **identical image** to its plain" + + " counterpart -- same digest, same contents." + elif ($states | index("not-produced")) then + " Some images have no `-hardened` tag this run (the severity gate failed or hardening" + + " was disabled); their rows are marked `unpatched`." + else "" end ) + , "" + , "**Status legend:** `fixed` = Copa patched it (old → new version) · `residual` = still" + , "present in the hardened image · `unpatched` = no hardened image was produced." + , "" + , "## Severity totals" + , "" + , "| Severity | Distinct CVEs | Tabulated rows |" + , "|----------|---------------|----------------|" + , ( ["CRITICAL","HIGH","MEDIUM","LOW","UNKNOWN"] + | map( . as $s + | ($tab | map(select(.severity == $s))) as $rows + | "| \($s) | \($rows | map(.id) | unique | length) | \($rows | length) |" ) + | join("\n") ) + , "" + , "## Not tabulated" + , "" + , ( if ($kern | length) == 0 then "_Nothing excluded._" + else + "**\($kern | length) `linux-libc-dev` rows (\($kern | map(.id) | unique | length) distinct" + + " CVEs) are excluded from the tables.** These are Linux kernel *header* CVEs. A container" + + " runs on the host kernel, so they are not reachable inside these images. They remain in" + + " the machine-readable `cve-data.json` artifact attached to the release run." + end ) + , "" + , "## CVEs by variant" + , "" + , "Variants differ enormously. This table is the fastest way to see which image flavour" + , "carries the CVE surface, and therefore which one to pick if you do not need its extras." + , "" + , "| Variant | Distinct CVEs | Images |" + , "|---------|---------------|--------|" + , ( ($imgs | to_entries | map({v: .value.variant, i: .key}) + | group_by(.v) + | map({ variant: .[0].v, idxs: map(.i) })) as $byvar + | $byvar + | map( . as $g + | ($tab | map(select(.affects | any(. as $a | $g.idxs | index($a))))) as $rows + | { variant: $g.variant, + cves: ($rows | map(.id) | unique | length), + imgs: ($g.idxs | length) } ) + | sort_by(-.cves) + | map("| `\(.variant)` | \(.cves) | \(.imgs) |") + | join("\n") ) + , "" + , "## Most-affected packages" + , "" + , "| Package | Distinct CVEs | Images affected |" + , "|---------|---------------|-----------------|" + , ( $tab + | group_by(.pkg) + | map({ pkg: .[0].pkg, + cves: (map(.id) | unique | length), + imgs: ([.[].affects[]] | unique | length) }) + | sort_by(-.cves) + | .[0:10] + | map("| `\(.pkg)` | \(.cves) | \(.imgs) |") + | join("\n") ) + , "" + , "## Images" + , "" + , "| Image | Arch | CRIT | HIGH | MED | LOW | UNK | Fixable | Hardening | Plain digest |" + , "|-------|------|------|------|-----|-----|-----|---------|-----------|--------------|" + , ( $imgs + | map( "| `\(.image)` | \(.arch) " + + "| \(.counts.CRITICAL // 0) | \(.counts.HIGH // 0) | \(.counts.MEDIUM // 0) " + + "| \(.counts.LOW // 0) | \(.counts.UNKNOWN // 0) | \(.fixable_count) " + + "| \(.hardened_state) | `\(.plain_digest | short)` |" ) + | join("\n") ) + , "" + ) +' "$data" diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index ec90b03..d444413 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -407,5 +407,40 @@ BAD_OUT="$(mktemp)"; tmpdirs+=("$BAD_OUT") || { echo " FAIL: malformed Trivy JSON exited 0 -- would emit a partial report"; fail=1; } assert_eq "$(wc -c < "$BAD_OUT" | tr -d ' ')" "0" "malformed input produces no partial output" +echo "== cve-render-summary.sh ==" +SUM="$("${ROOT}/.github/scripts/cve-render-summary.sh" "$AGG")"; SUM_RC=$? +assert_eq "$SUM_RC" "0" "cve-render-summary exits 0" +assert_contains "$SUM" "# Known CVEs & hardening report" "summary has the H1 title" +assert_contains "$SUM" "_Generated 2026-07-29 12:00 UTC._" "summary states the generation time" +assert_contains "$SUM" "## Hardening outcome" "summary leads with the hardening outcome" +# fixtures DO have fixable CVEs, so the outcome must report them rather than the no-fix wording +assert_contains "$SUM" "fixable CVE(s)" "fixable CVEs present -> reports the count" +assert_not_contains "$SUM" "No fixable CVE was available upstream" "does not claim zero fixable when there are some" +# a not-produced image exists in the fixtures, so the caveat sentence must fire +assert_contains "$SUM" "Some images have no" "not-produced images are called out" + +assert_contains "$SUM" "## Severity totals" "summary has severity totals" +assert_contains "$SUM" "## Not tabulated" "summary discloses what is excluded" +assert_contains "$SUM" "linux-libc-dev" "kernel-header exclusion named" +assert_contains "$SUM" "not reachable inside these images" "exclusion is justified, not just stated" +assert_contains "$SUM" "## CVEs by variant" "summary breaks CVEs down by variant" +assert_contains "$SUM" "## Most-affected packages" "summary lists worst packages" +assert_contains "$SUM" "## Images" "summary has the per-image table" +# per-image row: min variant, 12-char short digest, identical hardening +assert_contains "$SUM" "| \`php8.5-min-v5.2\` | amd64 " "per-image row present for the min variant" +assert_contains "$SUM" "| identical | \`cc33dd44ee55\` |" "per-image row shows hardening state and short digest" + +# the summary must stay small enough to read +SUM_BYTES=$(printf '%s' "$SUM" | wc -c) +[ "$SUM_BYTES" -lt 10240 ] && echo " ok: summary under 10 KB ($SUM_BYTES bytes)" \ + || { echo " FAIL: summary is $SUM_BYTES bytes, want < 10240"; fail=1; } + +# empty input must render a no-data summary, not crash +EMPTY_AGG="$(mktemp)"; tmpdirs+=("$EMPTY_AGG") +"${ROOT}/.github/scripts/cve-aggregate.sh" "$EMPTY_DIR" "2026-07-29 12:00 UTC" > "$EMPTY_AGG" +ESUM="$("${ROOT}/.github/scripts/cve-render-summary.sh" "$EMPTY_AGG")"; ESUM_RC=$? +assert_eq "$ESUM_RC" "0" "summary of empty data exits 0" +assert_contains "$ESUM" "# Known CVEs & hardening report" "empty summary still has a title" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From c2818a667126b39e7429f7d83817866a15002ea1 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:01:20 +0200 Subject: [PATCH 04/10] test(cve-report): pin computed values in summary sections, not just headings Severity totals, CVEs-by-variant, and Most-affected-packages previously only had heading assertions, so a wrong select/group_by/sort_by could pass silently. Adds assertions on exact computed rows (hand-derived from the fixture Trivy JSON) plus the exact fixable count, and a line-position check for the variant sort order since a multi-line grep -F needle matches by line-OR, not contiguous block. --- .github/scripts/tests/run.sh | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index d444413..1232198 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -430,6 +430,51 @@ assert_contains "$SUM" "## Images" "summary has the per-image table" assert_contains "$SUM" "| \`php8.5-min-v5.2\` | amd64 " "per-image row present for the min variant" assert_contains "$SUM" "| identical | \`cc33dd44ee55\` |" "per-image row shows hardening state and short digest" +# --- Fix round: pin computed values, not just headings --- +# The four checks below target sections whose only prior coverage was a heading grep +# (assert_contains on "## Severity totals" etc.), which a wrong select/group_by/sort_by/ +# aggregation could sail straight through. Expected numbers are hand-derived from the raw +# fixture Trivy JSON (see task-2-report.md fix-round section for the full working), then +# cross-checked against the aggregated JSON's .cves/.images arrays independently of this +# script -- not copied from this script's own output. + +# Hardening outcome: exact fixable count. 5 = sum of fixable_count across all 7 images +# (v5.2-amd64:1, v5.2-arm64:1, debug:0, min:0, multipkg:1, statusmix-a:1, statusmix-b:1). +assert_contains "$SUM" "**5 fixable CVE(s)**" "hardening outcome pins the exact fixable count (5)" + +# Severity totals: HIGH pins 4 distinct CVEs / 6 rows (libexpat1 fixed, libpam0g residual, +# libfoo1 fixed+residual, pkga fixed, pkgb residual -- ids: CVE-2024-45491, CVE-2025-6020, +# CVE-2025-7777, CVE-2025-9999). MEDIUM pins 1/1 (libxml2, CVE-2024-7883). Asserting a SECOND, +# differently-valued severity row (not just HIGH) also catches a regression that hardcodes the +# severity comparison to "HIGH" -- that would leave the HIGH row itself unchanged but corrupt +# every other row, which a HIGH-only assertion would miss. +assert_contains "$SUM" "| HIGH | 4 | 6 |" "severity totals: HIGH row pins distinct/row counts" +assert_contains "$SUM" "| MEDIUM | 1 | 1 |" "severity totals: MEDIUM row pins distinct/row counts (catches a hardcoded-severity regression the HIGH row alone would miss)" + +# CVEs by variant: default pins 4 distinct CVEs / 5 images (the 5 default-variant images: +# v5.2 amd64+arm64, multipkg, statusmix-a, statusmix-b). debug pins 1/1 (libxml2 image only). +assert_contains "$SUM" "| \`default\` | 4 | 5 |" "CVEs by variant: default row pins distinct-CVE/image counts" +assert_contains "$SUM" "| \`debug\` | 1 | 1 |" "CVEs by variant: debug row pins distinct-CVE/image counts" +# Order matters here (descending by CVE count): assert_contains cannot verify adjacency/order +# for a multi-line needle -- grep -F treats a newline in the pattern as an OR of separate +# line-patterns, not a contiguous block match (confirmed empirically) -- so check line +# position directly instead. +variant_default_line="$(printf '%s\n' "$SUM" | grep -nF -- '| `default` | 4 | 5 |' | head -1 | cut -d: -f1)" +variant_debug_line="$(printf '%s\n' "$SUM" | grep -nF -- '| `debug` | 1 | 1 |' | head -1 | cut -d: -f1)" +if [ -n "$variant_default_line" ] && [ -n "$variant_debug_line" ] && [ "$variant_default_line" -lt "$variant_debug_line" ]; then + echo " ok: CVEs by variant: default (4 CVEs) sorts before debug (1 CVE) -- descending order" +else + echo " FAIL: CVEs by variant: expected default row before debug row (default@${variant_default_line:-?}, debug@${variant_debug_line:-?})" + fail=1 +fi + +# Most-affected packages: libexpat1 pins one row whose OWN affects=[0,1] already spans two +# images; libfoo1 pins the union of TWO SEPARATE rows (fixed row affects=[5], residual row +# affects=[6], same CVE-2025-7777) into 2 images -- a regression that fails to union affects +# across rows sharing a package, or mis-keys the grouping, would corrupt one or both of these. +assert_contains "$SUM" "| \`libexpat1\` | 1 | 2 |" "most-affected packages: libexpat1 row pins distinct-CVE/image counts" +assert_contains "$SUM" "| \`libfoo1\` | 1 | 2 |" "most-affected packages: libfoo1 row pins the union of two separate rows' affects into 2 images" + # the summary must stay small enough to read SUM_BYTES=$(printf '%s' "$SUM" | wc -c) [ "$SUM_BYTES" -lt 10240 ] && echo " ok: summary under 10 KB ($SUM_BYTES bytes)" \ From adb7e8513309ae7a8b09b73efb002fcca65321cd Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:09:28 +0200 Subject: [PATCH 05/10] feat(cve-report): add table renderer and render-limit guard --- .github/scripts/cve-render-table.sh | 43 +++++++++++++++++++++++++++++ .github/scripts/cve-size-guard.sh | 25 +++++++++++++++++ .github/scripts/tests/run.sh | 37 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100755 .github/scripts/cve-render-table.sh create mode 100755 .github/scripts/cve-size-guard.sh diff --git a/.github/scripts/cve-render-table.sh b/.github/scripts/cve-render-table.sh new file mode 100755 index 0000000..f732e32 --- /dev/null +++ b/.github/scripts/cve-render-table.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Render one severity slice of the canonical CVE data as a markdown table. +# Usage: cve-render-table.sh [title] +# e.g. cve-render-table.sh cve-data.json CRITICAL,HIGH "Critical & high severity" +# +# Always excludes linux-libc-dev: those are kernel-header CVEs, not reachable in a +# container (the container uses the host kernel). They are ~50% of all rows and the +# summary discloses their count, so nothing is hidden -- only un-tabulated. +set -euo pipefail + +data="${1:?usage: cve-render-table.sh [title]}" +sevs="${2:?usage: cve-render-table.sh [title]}" +title="${3:-}" + +[ -n "$title" ] && printf '## %s\n\n' "$title" + +jq -r --arg sevs "$sevs" ' + ($sevs | split(",")) as $want + # Arch is collapsed (amd64/arm64 findings are near-identical, so listing both + # doubles the cell for no information), and the cell names the affected RELEASES + # rather than every image variant. "20 images · v3.8, v4.2" answers the question a + # reader actually has; spelling out 20 variant names does not, and it pushed the + # LOW table to within 10 percent of the 512 KB GitHub render limit. + | (.images | map(.image)) as $labels + | (.images | map(.image | capture("(?v[0-9]+\\.[0-9]+)$").v // "other")) as $rels + | [ .cves[] | select(.severity as $s | $want | index($s)) | select(.pkg != "linux-libc-dev") ] + | if length == 0 then + "_No CVEs in this severity range._" + else + (["| CVE | Severity | Package | Status | Affects |", + "|-----|----------|---------|--------|---------|"] + + ( sort_by([.severity, .pkg, .id]) + | map( + ([.affects[] | $labels[.]] | unique | length) as $n + | ([.affects[] | $rels[.]] | unique | sort) as $r + | "| [\(.id)](https://nvd.nist.gov/vuln/detail/\(.id)) " + + "| \(.severity) | `\(.pkg)` | \(.status) · \(.fix) " + + "| \($n) image\(if $n == 1 then "" else "s" end) · \($r | join(", ")) |" + ) + ) + ) | join("\n") + end +' "$data" diff --git a/.github/scripts/cve-size-guard.sh b/.github/scripts/cve-size-guard.sh new file mode 100755 index 0000000..7818c4e --- /dev/null +++ b/.github/scripts/cve-size-guard.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Warn when a rendered report file approaches the GitHub markdown render limit. +# Usage: cve-size-guard.sh [limit-bytes] +# +# GitHub refuses to render markdown blobs at or above 512 KB, showing "we can't show +# files that are this big" instead. That is the exact failure this report redesign +# fixed, and CVE counts only grow -- so a silent slide back past the limit must be +# detected. Warns at 90% of the limit to leave room to react. +# +# Always exits 0: the report is transparency, not a gate, and must never fail a +# release whose images already published. +set -euo pipefail + +file="${1:?usage: cve-size-guard.sh [limit-bytes]}" +limit="${2:-524288}" + +[ -f "$file" ] || exit 0 + +size="$(wc -c < "$file" | tr -d ' ')" +threshold=$(( limit * 90 / 100 )) + +if [ "$size" -ge "$threshold" ]; then + printf '::warning::%s is %s bytes, at or above 90%% of the %s-byte GitHub markdown render limit. Split this table further before it stops rendering.\n' \ + "$file" "$size" "$limit" +fi diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 1232198..4e21070 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -487,5 +487,42 @@ ESUM="$("${ROOT}/.github/scripts/cve-render-summary.sh" "$EMPTY_AGG")"; ESUM_RC= assert_eq "$ESUM_RC" "0" "summary of empty data exits 0" assert_contains "$ESUM" "# Known CVEs & hardening report" "empty summary still has a title" +echo "== cve-render-table.sh ==" +TBL="$("${ROOT}/.github/scripts/cve-render-table.sh" "$AGG" CRITICAL,HIGH "Critical & high severity")"; TBL_RC=$? +assert_eq "$TBL_RC" "0" "cve-render-table exits 0" +assert_contains "$TBL" "## Critical & high severity" "optional title rendered as H2" +assert_contains "$TBL" "| CVE | Severity | Package | Status | Affects |" "table header present" +# CVE cell is an NVD link +assert_contains "$TBL" "[CVE-2025-6020](https://nvd.nist.gov/vuln/detail/CVE-2025-6020)" "CVE cell links to NVD" +# affects cell: count of images plus the affected releases, arch collapsed. CVE-2025-6020 +# is in both arches of php8.5-v5.2, which is ONE logical image on release v5.2. +assert_contains "$TBL" "| 1 image · v5.2 |" "affects cell collapses arch to one image and names the release" +# kernel-header rows never appear in a table, even at CRITICAL +assert_not_contains "$TBL" "linux-libc-dev" "linux-libc-dev excluded from tables" +assert_not_contains "$TBL" "CVE-2026-0001" "the CRITICAL kernel-header CVE is excluded" +# severity filter is respected +assert_not_contains "$TBL" "CVE-2024-7883" "MEDIUM CVE absent from a CRITICAL,HIGH table" + +TBL_MED="$("${ROOT}/.github/scripts/cve-render-table.sh" "$AGG" MEDIUM)" +assert_contains "$TBL_MED" "CVE-2024-7883" "MEDIUM CVE present in a MEDIUM table" +assert_not_contains "$TBL_MED" "CVE-2025-6020" "HIGH CVE absent from a MEDIUM table" +assert_not_contains "$TBL_MED" "## " "title omitted when no third argument is given" + +# a severity with no rows renders a placeholder line, not an empty table +TBL_NONE="$("${ROOT}/.github/scripts/cve-render-table.sh" "$AGG" UNKNOWN)" +assert_contains "$TBL_NONE" "_No CVEs in this severity range._" "empty slice renders a placeholder" + +echo "== cve-size-guard.sh ==" +SG_SMALL="$(mktemp)"; tmpdirs+=("$SG_SMALL"); printf 'tiny' > "$SG_SMALL" +sgOut="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_SMALL")"; sgRc=$? +assert_eq "$sgRc" "0" "size guard exits 0 for a small file" +assert_not_contains "$sgOut" "::warning::" "no warning for a small file" + +SG_BIG="$(mktemp)"; tmpdirs+=("$SG_BIG"); head -c 950 /dev/zero | tr '\0' 'x' > "$SG_BIG" +sgOut2="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_BIG" 1000)"; sgRc2=$? +assert_eq "$sgRc2" "0" "size guard exits 0 even when warning (never fails a release)" +assert_contains "$sgOut2" "::warning::" "warns at 95% of the limit" +assert_contains "$sgOut2" "950" "warning names the actual byte size" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From 9b2df106d2999cbbb7b571014dafb1f71da7c372 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:17:19 +0200 Subject: [PATCH 06/10] test(cve-report): pin CVE-2025-6020 row and the 90% size-guard boundary --- .github/scripts/tests/run.sh | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 4e21070..b2ed869 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -495,8 +495,13 @@ assert_contains "$TBL" "| CVE | Severity | Package | Status | Affects |" "table # CVE cell is an NVD link assert_contains "$TBL" "[CVE-2025-6020](https://nvd.nist.gov/vuln/detail/CVE-2025-6020)" "CVE cell links to NVD" # affects cell: count of images plus the affected releases, arch collapsed. CVE-2025-6020 -# is in both arches of php8.5-v5.2, which is ONE logical image on release v5.2. -assert_contains "$TBL" "| 1 image · v5.2 |" "affects cell collapses arch to one image and names the release" +# is in both arches of php8.5-v5.2, which is ONE logical image on release v5.2. Pin the +# COMPLETE row (not just the "| 1 image · v5.2 |" substring): every other row in this +# slice already maps to a single-arch image, so that substring appears elsewhere in the +# table even if the amd64+arm64 collapse for THIS row breaks (a dropped `unique` on the +# label list would turn this exact row into "2 images · v5.2" while the substring alone +# still matched a different, unrelated row). +assert_contains "$TBL" "| [CVE-2025-6020](https://nvd.nist.gov/vuln/detail/CVE-2025-6020) | HIGH | \`libpam0g\` | residual · no fix | 1 image · v5.2 |" "CVE-2025-6020 full row: arch-collapsed to one image on v5.2" # kernel-header rows never appear in a table, even at CRITICAL assert_not_contains "$TBL" "linux-libc-dev" "linux-libc-dev excluded from tables" assert_not_contains "$TBL" "CVE-2026-0001" "the CRITICAL kernel-header CVE is excluded" @@ -518,11 +523,23 @@ sgOut="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_SMALL")"; sgRc=$? assert_eq "$sgRc" "0" "size guard exits 0 for a small file" assert_not_contains "$sgOut" "::warning::" "no warning for a small file" -SG_BIG="$(mktemp)"; tmpdirs+=("$SG_BIG"); head -c 950 /dev/zero | tr '\0' 'x' > "$SG_BIG" -sgOut2="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_BIG" 1000)"; sgRc2=$? -assert_eq "$sgRc2" "0" "size guard exits 0 even when warning (never fails a release)" -assert_contains "$sgOut2" "::warning::" "warns at 95% of the limit" -assert_contains "$sgOut2" "950" "warning names the actual byte size" +# Pin the exact 90% threshold AND the >= boundary, not just "some big file warns". +# limit=1000 -> threshold = 1000*90/100 = 900 exactly (integer division has no +# remainder here, so the boundary is unambiguous). A file of exactly 900 bytes must +# warn -- this pins both the 90% figure and the >= operator (a >= -> > regression +# would stop warning exactly at this boundary). A file of exactly 899 bytes must NOT +# warn -- this pins the threshold from the other side (a lowered threshold, e.g. 50%, +# would incorrectly warn at 899 too, since 899 is nowhere near 500). +SG_AT="$(mktemp)"; tmpdirs+=("$SG_AT"); head -c 900 /dev/zero | tr '\0' 'x' > "$SG_AT" +sgOutAt="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_AT" 1000)"; sgRcAt=$? +assert_eq "$sgRcAt" "0" "size guard exits 0 even when warning (never fails a release)" +assert_contains "$sgOutAt" "::warning::" "warns at exactly the 90% boundary (900 of 1000 bytes)" +assert_contains "$sgOutAt" "900" "warning names the actual byte size" + +SG_BELOW="$(mktemp)"; tmpdirs+=("$SG_BELOW"); head -c 899 /dev/zero | tr '\0' 'x' > "$SG_BELOW" +sgOutBelow="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_BELOW" 1000)"; sgRcBelow=$? +assert_eq "$sgRcBelow" "0" "size guard exits 0 one byte below the threshold" +assert_not_contains "$sgOutBelow" "::warning::" "does not warn one byte below the 90% boundary (899 of 1000 bytes)" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From 93a0155a9d2737c190abeeb373f7f4c296e0369b Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:23:38 +0200 Subject: [PATCH 07/10] feat(cve-report): render severity-split report, fix re-run artifact download --- .github/scripts/generate-cve-report.sh | 104 ------------------------- .github/workflows/release.yml | 64 +++++++++++++-- README.md | 21 +++-- 3 files changed, 71 insertions(+), 118 deletions(-) delete mode 100755 .github/scripts/generate-cve-report.sh diff --git a/.github/scripts/generate-cve-report.sh b/.github/scripts/generate-cve-report.sh deleted file mode 100755 index 8004dd6..0000000 --- a/.github/scripts/generate-cve-report.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# Render docs/known-cves.md from per-image CVE data collected by the release workflow. -# Usage: generate-cve-report.sh -# /.meta.json {image,variant,arch,plain_digest,hardened_digest} -# /.plain.json full Trivy JSON of the plain image -# /.hardened.json full Trivy JSON of the hardened image (may be absent) -set -euo pipefail - -data_dir="${1:?usage: generate-cve-report.sh }" -timestamp="${2:?usage: generate-cve-report.sh }" - -short_digest() { case "$1" in sha256:*) printf '%s' "${1#sha256:}" | cut -c1-12 ;; *) printf '%s' "$1" ;; esac; } -sev_rank() { case "$1" in CRITICAL) echo 0;; HIGH) echo 1;; MEDIUM) echo 2;; LOW) echo 3;; *) echo 4;; esac; } - -cat < cve-data.json + + # Severity split. One flat table of every severity is ~608 KB, over the + # 512 KB GitHub render limit; these three slices are ~226/232/376 KB. + { + .github/scripts/cve-render-summary.sh cve-data.json + .github/scripts/cve-render-table.sh cve-data.json CRITICAL,HIGH "Critical & high severity" + printf '\n\nFull detail for the remaining severities: ' + printf '[MEDIUM](known-cves-medium.md) · [LOW and UNKNOWN](known-cves-low.md).\n' + } > docs/known-cves.md + + { + printf '# Known CVEs — MEDIUM\n\n_Generated %s._ Back to the [summary](known-cves.md).\n\n' "$TS" + .github/scripts/cve-render-table.sh cve-data.json MEDIUM + } > docs/known-cves-medium.md + + { + printf '# Known CVEs — LOW and UNKNOWN\n\n_Generated %s._ Back to the [summary](known-cves.md).\n\n' "$TS" + .github/scripts/cve-render-table.sh cve-data.json LOW,UNKNOWN + } > docs/known-cves-low.md + + for f in docs/known-cves.md docs/known-cves-medium.md docs/known-cves-low.md; do + .github/scripts/cve-size-guard.sh "$f" + done + + # Per-run visibility on the run page itself, whether or not the commit happens. + .github/scripts/cve-render-summary.sh cve-data.json >> "$GITHUB_STEP_SUMMARY" + if [ "${GITHUB_REF_TYPE:-}" != "branch" ]; then - echo "Not a branch run (ref_type=${GITHUB_REF_TYPE:-unknown}); skipping known-cves.md commit." + echo "Not a branch run (ref_type=${GITHUB_REF_TYPE:-unknown}); skipping the report commit." exit 0 fi - TS="$(date -u '+%Y-%m-%d %H:%M UTC')" - .github/scripts/generate-cve-report.sh _cvedata "$TS" > docs/known-cves.md git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add docs/known-cves.md + git add docs/known-cves.md docs/known-cves-medium.md docs/known-cves-low.md if git diff --cached --quiet; then - echo "docs/known-cves.md unchanged." + echo "Known-CVE report unchanged." exit 0 fi git commit -m "docs: update known-CVEs report [skip ci]" git pull --rebase --autostash origin "${GITHUB_REF_NAME}" || true git push origin "HEAD:${GITHUB_REF_NAME}" + + - name: Upload machine-readable CVE data + # The canonical JSON is ~2.8 MB and includes the kernel-header rows the + # committed tables omit. Uploaded, never committed: at ~2.8 MB per release + # it would grow the repository without bound. + if: ${{ always() && hashFiles('cve-data.json') != '' }} + uses: actions/upload-artifact@v7 + with: + name: cve-report-json + path: cve-data.json diff --git a/README.md b/README.md index 3710ad8..e152610 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,21 @@ Attaching is best-effort (it's skipped if the registry rejects OCI referrers), s ## Known CVEs -For every **published stable release image** we publish a per-architecture CVE and patch -report: [`docs/known-cves.md`](docs/known-cves.md). For each image it lists the plain and -Copa-hardened image digests, the CVEs Copa **fixed** (with the library version bump), and -the **residual** known CVEs still present in the hardened image (including CVEs with no -upstream fix yet). It is regenerated on each publish. Development / rolling (`-dev`) tags are -plain-only and are not covered. +For every **published stable release image** we publish a CVE and hardening report, +regenerated on each publish: [`docs/known-cves.md`](docs/known-cves.md). It opens with the +hardening outcome, per-severity totals, a per-variant breakdown (variants differ by more +than an order of magnitude), the worst-affected packages, and a per-image table of severity +counts and digests. Critical and high severity CVEs are listed in full there; the remaining +severities are split into [`docs/known-cves-medium.md`](docs/known-cves-medium.md) and +[`docs/known-cves-low.md`](docs/known-cves-low.md) so every file stays within GitHub's +rendering limit. + +Kernel-header (`linux-libc-dev`) CVEs are excluded from the tables — a container runs on the +host kernel, so they are not reachable inside these images — and the summary states how many +were excluded. The complete machine-readable data, including those rows, is the +`cve-report-json` artifact on the corresponding release workflow run. + +Development / rolling (`-dev`) tags are plain-only and are not covered. ## Container registries Our images are available on both Docker Hub and the GitHub Container Registry, so you can choose the one that best fits your workflow. From 9a716610875c8af0999ce70fa188f9d5c51c1d7a Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:00:13 +0200 Subject: [PATCH 08/10] fix(cve-report): reconcile the Images table with the tabulated counts The Images table rendered per-image severity counts straight from the Trivy scan, so it included the linux-libc-dev rows that every other count in the report excludes. On production data kernel headers are ~50% of all rows and carry CRITICALs, so most Images rows advertised CRIT/HIGH findings with no matching row anywhere in the three report files -- reading as if the report had silently dropped them. Keep `counts` as raw scan totals (they legitimately describe the image as published) and say so: a footnote under the Images table now states that the severity columns and `Fixable` include the un-tabulated kernel rows and therefore intentionally exceed every tabulated count above. Corrected the cve-aggregate.sh comment that claimed these counts "can never disagree with the tables" -- they always did. The kernel-header handling had no discriminating test: two mutations (stop excluding kernel rows from the tabulated set; count every row as a kernel row) each left all 170 assertions green. Pinned the exact `| CRITICAL | 0 | 0 |` severity row and the exact Not-tabulated count sentence; both mutations now fail. Also fixed that sentence's grammar -- it rendered "1 rows (1 distinct CVEs)"; row/CVE plurals and the verb now agree independently. "N fixable CVE(s) ... see the `fixed` rows below" was a sum of per-image counts presented as a CVE count, measured on the full plain scan while Copa only patches CRITICAL/HIGH OS packages -- so it could read "5 fixable CVEs, see the fixed rows" with zero fixed rows. It now names what the number is, states that a CVE is counted once per image, and disclaims the fixed-row equivalence instead of promising rows that may not exist. "Images affected" counted per-arch entries while the detail tables' "Affects" collapses arch: one word, two answers. Renamed both summary columns to "Image builds" with a footnote explaining the relationship, leaving the figures (the correct scale for a per-arch summary) unchanged. Also: the actions/download-artifact#486 github-token fix had landed on only one of the two jobs, so "Re-run failed jobs" still 404s in process-tags. Added the token to both of its download steps plus a minimal `permissions:` block -- audited first: nothing in that job uses GITHUB_TOKEN beyond checkout and the downloads, since both registry logins and the manifest/SBOM pushes go through repository secrets and the docker/oras credential store. 170 -> 183 assertions, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/cve-aggregate.sh | 8 +++- .github/scripts/cve-render-summary.sh | 59 ++++++++++++++++++++++----- .github/scripts/tests/run.sh | 48 ++++++++++++++++++++-- .github/workflows/release.yml | 16 ++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/.github/scripts/cve-aggregate.sh b/.github/scripts/cve-aggregate.sh index 691b5af..aa9ae9c 100755 --- a/.github/scripts/cve-aggregate.sh +++ b/.github/scripts/cve-aggregate.sh @@ -110,8 +110,12 @@ while IFS= read -r meta; do idx=$((idx + 1)) done <<< "$metas" -# Assemble. Severity counts are derived from the emitted rows so the summary can -# never disagree with the tables it links to. +# Assemble. Per-image severity counts are derived from the emitted rows, but from ALL of +# them -- including the linux-libc-dev (kernel-header) rows that every renderer excludes. +# They are therefore RAW scan totals describing the image as published, and are deliberately +# HIGHER than the tabulated severity totals and the detail tables. They are not a cross-check +# on those tables and must not be presented as one: cve-render-summary.sh prints a footnote +# under the Images table saying exactly this. jq -n -r \ --arg ts "$timestamp" \ --slurpfile images "${work}/images.jsonl" \ diff --git a/.github/scripts/cve-render-summary.sh b/.github/scripts/cve-render-summary.sh index 70f27ba..74ecd92 100755 --- a/.github/scripts/cve-render-summary.sh +++ b/.github/scripts/cve-render-summary.sh @@ -14,8 +14,16 @@ jq -r ' .images as $imgs | .cves as $cves | ($cves | map(select(.pkg == "linux-libc-dev"))) as $kern + | ($kern | length) as $kn + | ($kern | map(.id) | unique | length) as $kd | ($cves | map(select(.pkg != "linux-libc-dev"))) as $tab | ($imgs | map(.hardened_state) | unique) as $states + # NOT a CVE count: fixable_count is a per-image figure, so this sum counts a CVE + # once per image it appears in. It is also measured on the full plain scan (all + # severities, OS + library packages) while Copa only patches CRITICAL/HIGH OS + # packages -- so it can be non-zero with zero `fixed` rows. The rendered sentence + # below must therefore describe it as a summed finding count, never as N CVEs, + # and must not promise `fixed` rows that may not exist. | ($imgs | map(.fixable_count) | add // 0) as $fixable | ( "# Known CVEs & hardening report" @@ -33,11 +41,13 @@ jq -r ' + " fix for any of the \($tab | map(.id) | unique | length) distinct CVEs found, so Copa" + " had nothing to patch." else - "**\($fixable) fixable CVE(s)** were available upstream across all images; see the" - + " `fixed` rows in the tables below." + "**\($fixable) fix-available findings** were seen across all images -- the `Fixable`" + + " column of the Images table below, summed, so a CVE present in several images is" + + " counted once per image. Copa patches only CRITICAL/HIGH OS packages, so this is an" + + " upper bound on what could have been patched, not a count of `fixed` rows." end ) , ( if ($states | length) == 1 and $states[0] == "identical" then - " Every `-hardened` tag in this run is therefore the **identical image** to its plain" + " Every `-hardened` tag in this run is the **identical image** to its plain" + " counterpart -- same digest, same contents." elif ($states | index("not-produced")) then " Some images have no `-hardened` tag this run (the severity gate failed or hardening" @@ -59,10 +69,12 @@ jq -r ' , "" , "## Not tabulated" , "" - , ( if ($kern | length) == 0 then "_Nothing excluded._" + , ( if $kn == 0 then "_Nothing excluded._" else - "**\($kern | length) `linux-libc-dev` rows (\($kern | map(.id) | unique | length) distinct" - + " CVEs) are excluded from the tables.** These are Linux kernel *header* CVEs. A container" + "**\($kn) `linux-libc-dev` row\(if $kn == 1 then "" else "s" end) (\($kd) distinct" + + " CVE\(if $kd == 1 then "" else "s" end))" + + " \(if $kn == 1 then "is" else "are" end)" + + " excluded from the tables.** These are Linux kernel *header* CVEs. A container" + " runs on the host kernel, so they are not reachable inside these images. They remain in" + " the machine-readable `cve-data.json` artifact attached to the release run." end ) @@ -72,8 +84,8 @@ jq -r ' , "Variants differ enormously. This table is the fastest way to see which image flavour" , "carries the CVE surface, and therefore which one to pick if you do not need its extras." , "" - , "| Variant | Distinct CVEs | Images |" - , "|---------|---------------|--------|" + , "| Variant | Distinct CVEs | Image builds |" + , "|---------|---------------|--------------|" , ( ($imgs | to_entries | map({v: .value.variant, i: .key}) | group_by(.v) | map({ variant: .[0].v, idxs: map(.i) })) as $byvar @@ -89,8 +101,13 @@ jq -r ' , "" , "## Most-affected packages" , "" - , "| Package | Distinct CVEs | Images affected |" - , "|---------|---------------|-----------------|" + # "Image builds affected", not "Images affected": this counts images[] indices, which + # are per-arch (amd64 and arm64 are two entries), whereas the `Affects` column of the + # detail tables collapses arch and counts logical images. Same data, two scales -- the + # column name and the footnote below keep the difference explicit instead of letting + # one word carry two answers in the same document. + , "| Package | Distinct CVEs | Image builds affected |" + , "|---------|---------------|-----------------------|" , ( $tab | group_by(.pkg) | map({ pkg: .[0].pkg, @@ -101,6 +118,10 @@ jq -r ' | map("| `\(.pkg)` | \(.cves) | \(.imgs) |") | join("\n") ) , "" + , "_\"Image builds\" counts each architecture separately (amd64 and arm64 of one tag are" + , "two builds), so these figures are larger than the arch-collapsed `Affects` column in" + , "the detail tables. Both describe the same rows._" + , "" , "## Images" , "" , "| Image | Arch | CRIT | HIGH | MED | LOW | UNK | Fixable | Hardening | Plain digest |" @@ -112,5 +133,23 @@ jq -r ' + "| \(.hardened_state) | `\(.plain_digest | short)` |" ) | join("\n") ) , "" + # I1: these counts come straight from the Trivy scan of the image, so unlike every + # other figure in this report they still include the un-tabulated linux-libc-dev rows. + # That is deliberate -- they describe the image as published -- but it must be said out + # loud, or a reader chasing a CRIT here finds no matching table row and concludes the + # report dropped findings. + , ( if $kn == 0 then + "_`CRIT`-`UNK` and `Fixable` are raw Trivy totals for the image as published._" + else + "_`CRIT`-`UNK` and `Fixable` above are **raw Trivy totals for the image as" + + " published**: unlike the tabulated counts earlier in this report they still" + + " include the \($kn) un-tabulated `linux-libc-dev`" + + " row\(if $kn == 1 then "" else "s" end), so they intentionally exceed every" + + " tabulated count above. A `CRIT`/`HIGH` here with no matching row in any detail" + + " table is a kernel-header CVE -- see **Not tabulated** above. `Fixable` is also" + + " measured across all severities and both OS and library packages, wider than the" + + " CRITICAL/HIGH OS-package scope Copa patches._" + end ) + , "" ) ' "$data" diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index b2ed869..5d20a3d 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -317,7 +317,8 @@ CF="$cf3" RETRY_DELAY=0 RETRY_MAX=2 "$WR" bash -c 'echo $(( $(cat "$CF") + 1 )) echo "== cve-aggregate.sh ==" CVE_FIX="${ROOT}/.github/scripts/tests/fixtures/cve" AGG="$(mktemp)"; tmpdirs+=("$AGG") -"${ROOT}/.github/scripts/cve-aggregate.sh" "$CVE_FIX" "2026-07-29 12:00 UTC" > "$AGG" 2>/tmp/agg-err; AGG_RC=$? +AGG_ERR="$(mktemp)"; tmpdirs+=("$AGG_ERR") +"${ROOT}/.github/scripts/cve-aggregate.sh" "$CVE_FIX" "2026-07-29 12:00 UTC" > "$AGG" 2>"$AGG_ERR"; AGG_RC=$? assert_eq "$AGG_RC" "0" "cve-aggregate exits 0" assert_eq "$(jq -e 'type' "$AGG" 2>/dev/null | tr -d '"')" "object" "cve-aggregate emits a JSON object" assert_eq "$(jq -r '.generated' "$AGG")" "2026-07-29 12:00 UTC" "timestamp passed through" @@ -414,7 +415,7 @@ assert_contains "$SUM" "# Known CVEs & hardening report" "summary has the H1 tit assert_contains "$SUM" "_Generated 2026-07-29 12:00 UTC._" "summary states the generation time" assert_contains "$SUM" "## Hardening outcome" "summary leads with the hardening outcome" # fixtures DO have fixable CVEs, so the outcome must report them rather than the no-fix wording -assert_contains "$SUM" "fixable CVE(s)" "fixable CVEs present -> reports the count" +assert_contains "$SUM" "fix-available findings" "fixable CVEs present -> reports the count" assert_not_contains "$SUM" "No fixable CVE was available upstream" "does not claim zero fixable when there are some" # a not-produced image exists in the fixtures, so the caveat sentence must fire assert_contains "$SUM" "Some images have no" "not-produced images are called out" @@ -440,7 +441,40 @@ assert_contains "$SUM" "| identical | \`cc33dd44ee55\` |" "per-image row shows h # Hardening outcome: exact fixable count. 5 = sum of fixable_count across all 7 images # (v5.2-amd64:1, v5.2-arm64:1, debug:0, min:0, multipkg:1, statusmix-a:1, statusmix-b:1). -assert_contains "$SUM" "**5 fixable CVE(s)**" "hardening outcome pins the exact fixable count (5)" +# +# M1: the number is a SUM of per-image counts, not a count of CVEs, and it is measured on +# the full plain scan while Copa only patches CRITICAL/HIGH OS packages -- on these very +# fixtures it is 5 while the tables carry 3 `fixed` rows. So the wording is pinned too, not +# just the digit: it must name what the number is ("fix-available findings"), must NOT call +# it N CVEs, and must NOT point the reader at `fixed` rows it cannot guarantee exist. +assert_contains "$SUM" "**5 fix-available findings** were seen across all images" "hardening outcome pins the exact summed fix-available count (5) and describes it as a summed finding count, not a CVE count" +assert_contains "$SUM" "not a count of \`fixed\` rows" "hardening outcome disclaims the fixed-row equivalence (5 summed findings vs 3 fixed rows in these fixtures)" +assert_not_contains "$SUM" "fixable CVE(s)** were available upstream" "hardening outcome no longer presents the multiplied sum as a CVE count" +assert_not_contains "$SUM" "see the \`fixed\` rows in the tables below" "hardening outcome no longer promises fixed rows that a MEDIUM/library-only fix would not produce" +# Cross-check the contradiction this wording exists to prevent: the summed number (5) really +# does differ from the number of `fixed` rows (3) on these fixtures, so any wording that +# equates the two is provably wrong here. +assert_eq "$(jq -r '[.cves[] | select(.status=="fixed")] | length' "$AGG")" "3" "fixtures really do have 3 fixed rows against a summed fix-available count of 5 (the M1 mismatch is live, not hypothetical)" + +# I2(a): the exact CRITICAL severity-totals row. The ONLY CRITICAL in the fixtures is a +# linux-libc-dev row, so a renderer that stops excluding kernel rows turns this into +# "| CRITICAL | 1 | 1 |". Pinning the whole row (both cells) is what makes the exclusion +# testable at all -- the prose assertions above only prove the disclosure text exists. +assert_contains "$SUM" "| CRITICAL | 0 | 0 |" "severity totals: CRITICAL row is exactly 0 distinct / 0 rows (proves kernel-header rows are excluded from the tabulated totals, not merely described)" + +# I2(b): the exact Not-tabulated sentence, count and all -- pins that the kernel selection +# matches ONLY linux-libc-dev (a selection that swept in every row would render 9/7 here), +# and pins the singular wording for count == 1. +assert_contains "$SUM" "**1 \`linux-libc-dev\` row (1 distinct CVE) is excluded from the tables.**" "not-tabulated sentence pins the exact kernel row/CVE counts and reads grammatically at count 1" +assert_not_contains "$SUM" "rows (1 distinct CVEs)" "not-tabulated sentence is not the ungrammatical plural form at count 1" + +# I1: the Images table carries raw scan totals INCLUDING the un-tabulated kernel rows, so it +# legitimately disagrees with every other count here. That must be stated under the table, or +# the min row below (CRIT 1, with no CRITICAL anywhere in the three tables) reads as a +# silently dropped finding. +assert_contains "$SUM" "raw Trivy totals for the image as published" "Images table footnote declares the counts as raw scan totals" +assert_contains "$SUM" "1 un-tabulated \`linux-libc-dev\` row" "Images table footnote names the un-tabulated kernel rows it includes (exact count, singular)" +assert_contains "$SUM" "| \`php8.5-min-v5.2\` | amd64 | 1 | 0 | 0 | 1 | 0 | 0 | identical |" "Images row for min pins the raw CRIT=1 the footnote has to explain (the row the reviewer could not trace to any table)" # Severity totals: HIGH pins 4 distinct CVEs / 6 rows (libexpat1 fixed, libpam0g residual, # libfoo1 fixed+residual, pkga fixed, pkgb residual -- ids: CVE-2024-45491, CVE-2025-6020, @@ -475,6 +509,14 @@ fi assert_contains "$SUM" "| \`libexpat1\` | 1 | 2 |" "most-affected packages: libexpat1 row pins distinct-CVE/image counts" assert_contains "$SUM" "| \`libfoo1\` | 1 | 2 |" "most-affected packages: libfoo1 row pins the union of two separate rows' affects into 2 images" +# M2: these two columns count per-arch image BUILDS, while the detail tables' `Affects` column +# collapses arch. libexpat1 above is the proof: 2 here (v5.2 amd64 + arm64), 1 image in the +# CRITICAL,HIGH table. The headings must not both say "Images" or the same word carries two +# answers in one document. +assert_contains "$SUM" "| Package | Distinct CVEs | Image builds affected |" "most-affected packages heading names the per-arch scale (Image builds), not the ambiguous Images" +assert_contains "$SUM" "| Variant | Distinct CVEs | Image builds |" "CVEs-by-variant heading names the per-arch scale (Image builds), not the ambiguous Images" +assert_contains "$SUM" "counts each architecture separately" "summary explains why its build counts exceed the tables' arch-collapsed Affects column" + # the summary must stay small enough to read SUM_BYTES=$(printf '%s' "$SUM" | wc -c) [ "$SUM_BYTES" -lt 10240 ] && echo " ok: summary under 10 KB ($SUM_BYTES bytes)" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76e1cfe..bd7f98b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -451,6 +451,15 @@ jobs: runs-on: ubuntu-22.04 needs: build-php if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} + # Explicit and minimal. Nothing in this job authenticates with GITHUB_TOKEN except + # checkout (contents:read) and the two downloads below (actions:read) -- both + # registry logins use repository secrets, and merge-manifests.sh/attach-sbom.sh + # talk to Docker Hub and ghcr.io through the docker/oras credential store, not + # through GITHUB_TOKEN. So no packages: or contents: write is needed here. + permissions: + contents: read + # download-artifact needs actions:read to use the public REST API path. + actions: read steps: - name: Check out CI scripts from the workflow ref @@ -477,6 +486,12 @@ jobs: with: path: artifacts pattern: aggregated_tags_* + # Same actions/download-artifact#486 fix as publish-cve-report below: + # without a token the internal artifact API 404s artifacts written by a + # PREVIOUS attempt, so "Re-run failed jobs" can never see them. This + # step is not continue-on-error, so today it fails loudly rather than + # going green empty -- but it still cannot be re-run without the token. + github-token: ${{ github.token }} - name: Download SBOMs uses: actions/download-artifact@v8 @@ -484,6 +499,7 @@ jobs: path: sboms pattern: sboms_* merge-multiple: true + github-token: ${{ github.token }} - name: Process tags run: | From 07df1fb65f5bdab3fb0a305d433e049dde3bd8ca Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:50:02 +0200 Subject: [PATCH 09/10] feat(cve-report): add docs index and pin report branch expectation Prepares the report for GitHub Pages (Deploy from a branch, 5.x /docs): - docs/README.md gives the site root a page. jekyll-readme-index is in the github-pages default plugin set, so it becomes the index of the published site; it also serves as the directory landing page in the repo itself. - Warn when a release is dispatched from a branch other than 5.x. The report covers every stable release regardless of the dispatching branch but is committed to that branch, and Pages serves only one -- so a run from elsewhere would leave the published report silently stale. Warning only: the images are already published and the report must never gate a release. No front matter or link rewriting is needed: github-pages enables jekyll-optional-front-matter (renders .md without front matter) and jekyll-relative-links (rewrites the .md cross-links to their built URLs), and unions those defaults in even when a site supplies its own plugin list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 11 +++++++++++ docs/README.md | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 docs/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd7f98b..09e43b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -582,6 +582,17 @@ jobs: exit 0 fi + # The report covers EVERY published stable release (v2.3 … v5.2) regardless of + # which branch dispatched the run, but it is committed to the dispatching + # branch. GitHub Pages serves one branch, so a run dispatched from anywhere + # other than REPORT_BRANCH updates a copy nobody reads and leaves the published + # site silently stale. Warn loudly rather than fail -- the images are already + # published and the report must never gate a release. + REPORT_BRANCH=5.x + if [ "${GITHUB_REF_NAME}" != "$REPORT_BRANCH" ]; then + echo "::warning::Known-CVE report is being committed to '${GITHUB_REF_NAME}', not '${REPORT_BRANCH}'. If GitHub Pages publishes from ${REPORT_BRANCH}/docs, the published report will not reflect this run." + fi + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add docs/known-cves.md docs/known-cves-medium.md docs/known-cves-low.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1b2a8c8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# Pimcore Docker image reports + +Security and hardening reports for the published `pimcore/pimcore` container images, +regenerated automatically on every release. + +## Known CVEs + +- **[Known CVEs & hardening report](known-cves.md)** — start here. Hardening outcome, + per-severity totals, a per-variant breakdown, the worst-affected packages, per-image + digests, and the full CRITICAL + HIGH listing. +- [MEDIUM severity](known-cves-medium.md) +- [LOW and UNKNOWN severity](known-cves-low.md) + +Kernel-header (`linux-libc-dev`) CVEs are excluded from the tables — a container runs on the +host kernel, so they are not reachable inside these images — and the summary states how many +were excluded. The complete machine-readable dataset, including those rows, is the +`cve-report-json` artifact attached to the corresponding release workflow run. + +Development / rolling (`-dev`) tags are plain-only, are never Copa-patched, and are not +covered by these reports. From 6aa0990f71302ea3a19382b7c67db909c600ddaa Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:15:34 +0200 Subject: [PATCH 10/10] fix(cve-report): key rows on every rendered attribute, correct report prose Four verified findings from the PR #262 review. F1 (latent data corruption): cve-aggregate.sh keyed rows on (id, pkg, status) but rendered .[0].severity and .[0].fix for the whole group, with `affects` listing every image in it. The report aggregates bullseye, bookworm and trixie in one run, and the same (CVE, package, status) legitimately carries a different `fix` across them -- the `fixed` branch renders InstalledVersion + " -> " + FixedVersion and Debian versions differ per release. 2,045 of 8,254 distinct (id, pkg) pairs in the real data already have divergent InstalledVersion. Taking .[0].fix therefore attributes ONE image version pair to ALL affected images. Invisible today only because no CVE has a FixedVersion, so every row renders `no fix`; silently wrong the day Debian ships one. The key now carries every rendered attribute, so rows that genuinely differ stay separate rows with accurate `affects` lists. F2: cve-render-table.sh wrapped every identifier in an NVD link, including the TEMP-* Debian security-tracker placeholders Trivy emits for issues with no CVE assigned yet (360 rows in the real data, 310 of them reaching a rendered table). nvd.nist.gov has no page for those. Only CVE-* is linked now; other identifiers render as inline code. F3: the "Not tabulated" section pointed readers at a `cve-data.json` artifact. The workflow uploads an artifact NAMED cve-report-json that CONTAINS cve-data.json, which is also what both READMEs say. Named accurately now. F4: two places claimed fixable_count is wider than Copa scope because Copa patches only CRITICAL/HIGH OS packages. That is false -- scan-patch-gate.sh:44 scans Copa input with `--pkg-types os --ignore-unfixed` and NO --severity filter, so Copa receives fixable OS vulnerabilities at every severity; the CRITICAL,HIGH threshold applies only to the separate post-patch gate scan. The real reason is package scope: fixable_count is measured on the full scan, which includes library packages. Both statements corrected, and the severity claim explicitly ruled out so it cannot be read back in. Tests: 183 -> 202 assertions, green. F1 and F2 each covered by a new fixture pair in tests/fixtures/cve-edge/ and mutation-proven (reverting either fix fails exactly the new assertions and nothing else). Kept in a separate fixture dir on purpose: adding images to tests/fixtures/cve/ would move every hand-derived number the summary assertions pin, for reasons unrelated to what is under test. No existing assertion needle changed. Failure behaviour untouched -- malformed Trivy JSON still fails closed, everything else stays best-effort. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/cve-aggregate.sh | 22 ++++- .github/scripts/cve-render-summary.sh | 26 ++++-- .github/scripts/cve-render-table.sh | 8 +- .../cve-edge/edge-a-amd64.hardened.json | 4 + .../fixtures/cve-edge/edge-a-amd64.meta.json | 1 + .../fixtures/cve-edge/edge-a-amd64.plain.json | 5 + .../cve-edge/edge-b-amd64.hardened.json | 1 + .../fixtures/cve-edge/edge-b-amd64.meta.json | 1 + .../fixtures/cve-edge/edge-b-amd64.plain.json | 3 + .github/scripts/tests/run.sh | 93 ++++++++++++++++++- 10 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-a-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-a-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-a-amd64.plain.json create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-b-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-b-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve-edge/edge-b-amd64.plain.json diff --git a/.github/scripts/cve-aggregate.sh b/.github/scripts/cve-aggregate.sh index aa9ae9c..0ad4e94 100755 --- a/.github/scripts/cve-aggregate.sh +++ b/.github/scripts/cve-aggregate.sh @@ -12,10 +12,22 @@ # hardened_state,counts{},fixable_count}], # cves:[{id,severity,pkg,status,fix,affects:[image-index,...]}]} # -# Rows are keyed on (id, pkg, status) -- NOT on id alone. A CVE affecting two -# packages, fixed on one and residual on the other, must stay two entries; and a -# CVE fixed in one image but residual in another must stay two entries. Keying on -# id alone silently merges these and loses rows (see the multipkg regression test). +# Rows are keyed on EVERY rendered attribute -- (id, pkg, status, severity, fix) -- +# NOT on id alone. A CVE affecting two packages, fixed on one and residual on the +# other, must stay two entries; and a CVE fixed in one image but residual in another +# must stay two entries. Keying on id alone silently merges these and loses rows (see +# the multipkg regression test). +# +# severity and fix are in the key for the same reason, one step further: the group is +# collapsed into a single row that renders .[0] of each attribute, so any attribute +# NOT in the key gets one member of the group attributed to all of them. This report +# aggregates several Debian bases (bullseye/bookworm/trixie) in one run, and the same +# (id, pkg, status) legitimately carries a different `fix` across them, because the +# `fixed` branch renders InstalledVersion + " -> " + FixedVersion and Debian package +# versions differ per release. Keying on (id, pkg, status) alone therefore attributes +# ONE base version pair to every affected image. That is invisible while no CVE has a +# FixedVersion (every row renders "no fix"), and silently wrong the day one does. +# Rows that genuinely differ stay separate rows with accurate `affects` lists instead. # # Deliberately streams per-file rather than slurping: the real data set is ~1 GB of # Trivy JSON across 50 scans, which `jq -s` cannot hold. @@ -124,7 +136,7 @@ jq -n -r \ ($rows | rtrimstr("\n") | if . == "" then [] else split("\n") end | map(split("\t") | {i: (.[0]|tonumber), status: .[1], id: .[2], pkg: .[3], severity: .[4], fix: .[5]}) ) as $r - | ($r | group_by([.id, .pkg, .status]) + | ($r | group_by([.id, .pkg, .status, .severity, .fix]) | map({ id: .[0].id, severity: .[0].severity, pkg: .[0].pkg, status: .[0].status, fix: .[0].fix, affects: (map(.i) | unique) }) diff --git a/.github/scripts/cve-render-summary.sh b/.github/scripts/cve-render-summary.sh index 74ecd92..75a743c 100755 --- a/.github/scripts/cve-render-summary.sh +++ b/.github/scripts/cve-render-summary.sh @@ -19,11 +19,15 @@ jq -r ' | ($cves | map(select(.pkg != "linux-libc-dev"))) as $tab | ($imgs | map(.hardened_state) | unique) as $states # NOT a CVE count: fixable_count is a per-image figure, so this sum counts a CVE - # once per image it appears in. It is also measured on the full plain scan (all - # severities, OS + library packages) while Copa only patches CRITICAL/HIGH OS - # packages -- so it can be non-zero with zero `fixed` rows. The rendered sentence - # below must therefore describe it as a summed finding count, never as N CVEs, - # and must not promise `fixed` rows that may not exist. + # once per image it appears in. It is also measured on the full plain scan, which + # covers OS AND library packages, while the scan that feeds Copa is restricted to OS + # packages (scan-patch-gate.sh: trivy image --pkg-types os --ignore-unfixed) -- so it + # can be non-zero with zero `fixed` rows. Severity is NOT part of that difference: + # the Copa input scan carries no --severity filter, and the CRITICAL,HIGH threshold + # (GATE_SEVERITY) applies only to the separate post-patch gate scan that decides + # whether the -hardened tag is published. The rendered sentence below must therefore + # describe it as a summed finding count, never as N CVEs, must not promise `fixed` + # rows that may not exist, and must not blame severity for the gap. | ($imgs | map(.fixable_count) | add // 0) as $fixable | ( "# Known CVEs & hardening report" @@ -43,8 +47,10 @@ jq -r ' else "**\($fixable) fix-available findings** were seen across all images -- the `Fixable`" + " column of the Images table below, summed, so a CVE present in several images is" - + " counted once per image. Copa patches only CRITICAL/HIGH OS packages, so this is an" - + " upper bound on what could have been patched, not a count of `fixed` rows." + + " counted once per image. It is measured on the full scan, which covers OS **and" + + " library** packages, while the scan that feeds Copa is restricted to OS packages" + + " -- so this is an upper bound on what could have been patched, not a count of" + + " `fixed` rows." end ) , ( if ($states | length) == 1 and $states[0] == "identical" then " Every `-hardened` tag in this run is the **identical image** to its plain" @@ -76,7 +82,7 @@ jq -r ' + " \(if $kn == 1 then "is" else "are" end)" + " excluded from the tables.** These are Linux kernel *header* CVEs. A container" + " runs on the host kernel, so they are not reachable inside these images. They remain in" - + " the machine-readable `cve-data.json` artifact attached to the release run." + + " `cve-data.json`, uploaded as the `cve-report-json` artifact on the release run." end ) , "" , "## CVEs by variant" @@ -147,8 +153,8 @@ jq -r ' + " row\(if $kn == 1 then "" else "s" end), so they intentionally exceed every" + " tabulated count above. A `CRIT`/`HIGH` here with no matching row in any detail" + " table is a kernel-header CVE -- see **Not tabulated** above. `Fixable` is also" - + " measured across all severities and both OS and library packages, wider than the" - + " CRITICAL/HIGH OS-package scope Copa patches._" + + " measured across both OS and library packages, wider than the OS-packages-only" + + " scope of the scan that feeds Copa -- severity plays no part in that difference._" end ) , "" ) diff --git a/.github/scripts/cve-render-table.sh b/.github/scripts/cve-render-table.sh index f732e32..acaf6b1 100755 --- a/.github/scripts/cve-render-table.sh +++ b/.github/scripts/cve-render-table.sh @@ -15,6 +15,12 @@ title="${3:-}" [ -n "$title" ] && printf '## %s\n\n' "$title" jq -r --arg sevs "$sevs" ' + # Only CVE-* identifiers are linked to NVD. Trivy also reports Debian + # security-tracker placeholders (TEMP-0000000-F7A20F and friends) for issues with no + # CVE assigned yet; NVD has no page for those, so linking them yields a guaranteed + # dead link. They render as inline code instead -- readable, and honest about not + # being resolvable anywhere. + def idcell: if startswith("CVE-") then "[\(.)](https://nvd.nist.gov/vuln/detail/\(.))" else "`\(.)`" end; ($sevs | split(",")) as $want # Arch is collapsed (amd64/arm64 findings are near-identical, so listing both # doubles the cell for no information), and the cell names the affected RELEASES @@ -33,7 +39,7 @@ jq -r --arg sevs "$sevs" ' | map( ([.affects[] | $labels[.]] | unique | length) as $n | ([.affects[] | $rels[.]] | unique | sort) as $r - | "| [\(.id)](https://nvd.nist.gov/vuln/detail/\(.id)) " + | "| \(.id | idcell) " + "| \(.severity) | `\(.pkg)` | \(.status) · \(.fix) " + "| \($n) image\(if $n == 1 then "" else "s" end) · \($r | join(", ")) |" ) diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.hardened.json b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.hardened.json new file mode 100644 index 0000000..ea6c45f --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.hardened.json @@ -0,0 +1,4 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-edge-a-v5.2-hardened-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"TEMP-0000000-F7A20F","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""}, +{"VulnerabilityID":"CVE-2026-0002","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.meta.json b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.meta.json new file mode 100644 index 0000000..a6c83b9 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-edge-a-v5.2","variant":"edge","arch":"amd64","plain_digest":"sha256:ea11ea11ea11b5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37","hardened_digest":"sha256:ea22ea22ea22b5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.plain.json b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.plain.json new file mode 100644 index 0000000..3a84420 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-a-amd64.plain.json @@ -0,0 +1,5 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-edge-a-v5.2-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-8888","PkgName":"libbar1","Severity":"HIGH","InstalledVersion":"1.1.3-4.1","FixedVersion":"1.1.3-4.1+deb12u1"}, +{"VulnerabilityID":"TEMP-0000000-F7A20F","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""}, +{"VulnerabilityID":"CVE-2026-0002","PkgName":"zlib1g","Severity":"LOW","InstalledVersion":"1.3-1","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.hardened.json b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.hardened.json new file mode 100644 index 0000000..356e63d --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.hardened.json @@ -0,0 +1 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-edge-b-v5.2-hardened-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[]}]} diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.meta.json b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.meta.json new file mode 100644 index 0000000..71a538d --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-edge-b-v5.2","variant":"edge","arch":"amd64","plain_digest":"sha256:eb11eb11eb11b5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37","hardened_digest":"sha256:eb22eb22eb22b5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} diff --git a/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.plain.json b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.plain.json new file mode 100644 index 0000000..06cce47 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve-edge/edge-b-amd64.plain.json @@ -0,0 +1,3 @@ +{"ArtifactName":"pimcore/pimcore:php8.5-edge-b-v5.2-amd64","Results":[{"Target":"debian","Class":"os-pkgs","Type":"debian","Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-8888","PkgName":"libbar1","Severity":"HIGH","InstalledVersion":"1.2.1-2","FixedVersion":"1.2.1-2+deb13u1"} +]}]} diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 5d20a3d..f5cd260 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -443,10 +443,11 @@ assert_contains "$SUM" "| identical | \`cc33dd44ee55\` |" "per-image row shows h # (v5.2-amd64:1, v5.2-arm64:1, debug:0, min:0, multipkg:1, statusmix-a:1, statusmix-b:1). # # M1: the number is a SUM of per-image counts, not a count of CVEs, and it is measured on -# the full plain scan while Copa only patches CRITICAL/HIGH OS packages -- on these very -# fixtures it is 5 while the tables carry 3 `fixed` rows. So the wording is pinned too, not -# just the digit: it must name what the number is ("fix-available findings"), must NOT call -# it N CVEs, and must NOT point the reader at `fixed` rows it cannot guarantee exist. +# the full plain scan (OS + library packages) while the scan that feeds Copa is restricted +# to OS packages -- on these very fixtures it is 5 while the tables carry 3 `fixed` rows. So +# the wording is pinned too, not just the digit: it must name what the number is +# ("fix-available findings"), must NOT call it N CVEs, and must NOT point the reader at +# `fixed` rows it cannot guarantee exist. assert_contains "$SUM" "**5 fix-available findings** were seen across all images" "hardening outcome pins the exact summed fix-available count (5) and describes it as a summed finding count, not a CVE count" assert_contains "$SUM" "not a count of \`fixed\` rows" "hardening outcome disclaims the fixed-row equivalence (5 summed findings vs 3 fixed rows in these fixtures)" assert_not_contains "$SUM" "fixable CVE(s)** were available upstream" "hardening outcome no longer presents the multiplied sum as a CVE count" @@ -456,6 +457,18 @@ assert_not_contains "$SUM" "see the \`fixed\` rows in the tables below" "hardeni # equates the two is provably wrong here. assert_eq "$(jq -r '[.cves[] | select(.status=="fixed")] | length' "$AGG")" "3" "fixtures really do have 3 fixed rows against a summed fix-available count of 5 (the M1 mismatch is live, not hypothetical)" +# F4(a): WHY the summed count is only an upper bound. scan-patch-gate.sh scans Copa input +# with `trivy image --pkg-types os --ignore-unfixed` and NO --severity filter, so Copa +# receives fixable OS vulnerabilities at EVERY severity; the CRITICAL,HIGH threshold +# (GATE_SEVERITY) applies only to the separate post-patch gate scan that decides whether the +# -hardened tag ships. The one real difference is package scope: this report scans OS AND +# library packages. The old wording blamed severity and was simply false, so the reason is +# pinned here -- a plausible-sounding but wrong explanation in a security report is worse +# than no explanation, and only an assertion stops it drifting back. +assert_contains "$SUM" "measured on the full scan, which covers OS **and library** packages, while the scan that feeds Copa is restricted to OS packages" "hardening outcome gives the CORRECT reason the summed count is an upper bound (package scope: OS + library vs OS only)" +assert_not_contains "$SUM" "Copa patches only CRITICAL/HIGH OS packages" "hardening outcome no longer claims Copa patches only CRITICAL/HIGH (the Copa input scan carries no --severity filter)" +assert_not_contains "$SUM" "CRITICAL/HIGH OS" "no CRITICAL/HIGH Copa-scope claim survives anywhere in the summary" + # I2(a): the exact CRITICAL severity-totals row. The ONLY CRITICAL in the fixtures is a # linux-libc-dev row, so a renderer that stops excluding kernel rows turns this into # "| CRITICAL | 1 | 1 |". Pinning the whole row (both cells) is what makes the exclusion @@ -468,6 +481,14 @@ assert_contains "$SUM" "| CRITICAL | 0 | 0 |" "severity totals: CRITICAL row is assert_contains "$SUM" "**1 \`linux-libc-dev\` row (1 distinct CVE) is excluded from the tables.**" "not-tabulated sentence pins the exact kernel row/CVE counts and reads grammatically at count 1" assert_not_contains "$SUM" "rows (1 distinct CVEs)" "not-tabulated sentence is not the ungrammatical plural form at count 1" +# F3: the excluded rows are recoverable, and the pointer must name the thing a reader can +# actually download. release.yml uploads an artifact NAMED cve-report-json whose CONTENT is +# cve-data.json; the old text called the artifact itself `cve-data.json`, which matches +# nothing on the run page and disagrees with README.md. Both halves are pinned so neither +# the file name nor the artifact name can drift out again. +assert_contains "$SUM" "They remain in \`cve-data.json\`, uploaded as the \`cve-report-json\` artifact on the release run." "not-tabulated section names the artifact by its real name (cve-report-json) and the file inside it (cve-data.json), consistent with README.md" +assert_not_contains "$SUM" "the machine-readable \`cve-data.json\` artifact" "no artifact called cve-data.json is claimed (no such artifact exists)" + # I1: the Images table carries raw scan totals INCLUDING the un-tabulated kernel rows, so it # legitimately disagrees with every other count here. That must be stated under the table, or # the min row below (CRIT 1, with no CRITICAL anywhere in the three tables) reads as a @@ -476,6 +497,11 @@ assert_contains "$SUM" "raw Trivy totals for the image as published" "Images tab assert_contains "$SUM" "1 un-tabulated \`linux-libc-dev\` row" "Images table footnote names the un-tabulated kernel rows it includes (exact count, singular)" assert_contains "$SUM" "| \`php8.5-min-v5.2\` | amd64 | 1 | 0 | 0 | 1 | 0 | 0 | identical |" "Images row for min pins the raw CRIT=1 the footnote has to explain (the row the reviewer could not trace to any table)" +# F4(b): the SECOND place the old Copa-scope claim appeared. Same correction as F4(a): the +# Fixable column is wider than what Copa can act on because of package scope, not severity. +assert_contains "$SUM" "wider than the OS-packages-only scope of the scan that feeds Copa -- severity plays no part in that difference" "Images footnote gives the CORRECT reason Fixable exceeds what Copa can patch, and explicitly rules out severity" +assert_not_contains "$SUM" "measured across all severities and both OS and library packages, wider than" "Images footnote no longer presents all-severities as a reason Fixable is wider than Copa scope" + # Severity totals: HIGH pins 4 distinct CVEs / 6 rows (libexpat1 fixed, libpam0g residual, # libfoo1 fixed+residual, pkga fixed, pkgb residual -- ids: CVE-2024-45491, CVE-2025-6020, # CVE-2025-7777, CVE-2025-9999). MEDIUM pins 1/1 (libxml2, CVE-2024-7883). Asserting a SECOND, @@ -559,6 +585,65 @@ assert_not_contains "$TBL_MED" "## " "title omitted when no third argument is gi TBL_NONE="$("${ROOT}/.github/scripts/cve-render-table.sh" "$AGG" UNKNOWN)" assert_contains "$TBL_NONE" "_No CVEs in this severity range._" "empty slice renders a placeholder" +echo "== cve edge cases: divergent fix per base, non-CVE identifiers ==" +# A SEPARATE fixture set, deliberately. Both regressions below need image rows the main +# fixture set does not have, and adding them there would move every hand-derived number +# pinned above (the fixable sum, the severity totals, the per-variant counts) for reasons +# unrelated to what is under test. Isolated, each fixture set stays independently derivable. +# +# cve-edge holds two images whose SAME (id, pkg, status, severity) finding carries a +# DIFFERENT `fix` string -- exactly what the three Debian bases in one report run produce, +# since the `fixed` branch renders InstalledVersion + " -> " + FixedVersion and Debian +# versions differ per release. edge-a: libbar1 1.1.3-4.1 -> 1.1.3-4.1+deb12u1. +# edge-b: libbar1 1.2.1-2 -> 1.2.1-2+deb13u1. edge-a also carries a TEMP-* Debian +# security-tracker identifier, which has no NVD page. +CVE_EDGE="${ROOT}/.github/scripts/tests/fixtures/cve-edge" +EDGE="$(mktemp)"; tmpdirs+=("$EDGE") +"${ROOT}/.github/scripts/cve-aggregate.sh" "$CVE_EDGE" "2026-07-29 12:00 UTC" > "$EDGE"; EDGE_RC=$? +assert_eq "$EDGE_RC" "0" "edge-fixture aggregate exits 0" + +# F1: the group key must carry EVERY rendered attribute. A group is collapsed to one row +# rendering .[0] of each field, so an attribute left out of the key gets ONE member of the +# group attributed to all of them. Keying on [.id,.pkg,.status] alone merges the two rows +# below and reports edge-a version pair for edge-b as well. +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-8888")] | length' "$EDGE")" "2" \ + "divergent fix: one (id,pkg,status) with two different fix strings stays TWO rows" +assert_eq "$(jq -r '[.cves[] | select(.id=="CVE-2025-8888") | .fix] | sort | join(" ; ")' "$EDGE")" \ + "1.1.3-4.1 → 1.1.3-4.1+deb12u1 ; 1.2.1-2 → 1.2.1-2+deb13u1" \ + "divergent fix: both version pairs survive verbatim, neither overwritten by the other" +# Each row must point at exactly the ONE image its versions were measured on. Asserted +# through the image NAME rather than the raw affects index, so a reordering of images[] +# cannot let a wrong attribution pass. +assert_eq "$(jq -r '.images as $i | .cves[] | select(.id=="CVE-2025-8888" and .fix=="1.1.3-4.1 → 1.1.3-4.1+deb12u1") | [.affects[] | $i[.].image] | join(",")' "$EDGE")" \ + "php8.5-edge-a-v5.2" "divergent fix: the deb12u1 row affects edge-a and nothing else" +assert_eq "$(jq -r '.images as $i | .cves[] | select(.id=="CVE-2025-8888" and .fix=="1.2.1-2 → 1.2.1-2+deb13u1") | [.affects[] | $i[.].image] | join(",")' "$EDGE")" \ + "php8.5-edge-b-v5.2" "divergent fix: the deb13u1 row affects edge-b and nothing else" + +# The same property at the RENDERED level -- the merge only does harm because these version +# strings reach a reader as fact about a specific image. +EDGE_TBL="$("${ROOT}/.github/scripts/cve-render-table.sh" "$EDGE" CRITICAL,HIGH)" +assert_contains "$EDGE_TBL" "| [CVE-2025-8888](https://nvd.nist.gov/vuln/detail/CVE-2025-8888) | HIGH | \`libbar1\` | fixed · 1.1.3-4.1 → 1.1.3-4.1+deb12u1 | 1 image · v5.2 |" \ + "divergent fix: edge-a version pair renders as its own single-image row" +assert_contains "$EDGE_TBL" "| [CVE-2025-8888](https://nvd.nist.gov/vuln/detail/CVE-2025-8888) | HIGH | \`libbar1\` | fixed · 1.2.1-2 → 1.2.1-2+deb13u1 | 1 image · v5.2 |" \ + "divergent fix: edge-b version pair renders as its own single-image row" +assert_not_contains "$EDGE_TBL" "2 images · v5.2" \ + "divergent fix: no merged two-image row claiming one version pair for both bases" + +# F2: Trivy Debian data carries security-tracker placeholders (TEMP-*) for issues with no CVE +# assigned yet. https://nvd.nist.gov/vuln/detail/TEMP-0000000-F7A20F is a guaranteed 404, so +# only CVE-* identifiers may be linked. +EDGE_LOW="$("${ROOT}/.github/scripts/cve-render-table.sh" "$EDGE" LOW,UNKNOWN)" +assert_contains "$EDGE_LOW" "| \`TEMP-0000000-F7A20F\` | LOW | \`zlib1g\` | residual · no fix | 1 image · v5.2 |" \ + "TEMP-* identifier renders as plain inline code (full row pinned), not as a link" +assert_not_contains "$EDGE_LOW" "nvd.nist.gov/vuln/detail/TEMP" \ + "TEMP-* identifier gets no NVD URL" +assert_not_contains "$EDGE_LOW" "[TEMP-0000000-F7A20F]" \ + "TEMP-* identifier is not wrapped in markdown link syntax at all" +# ...while a real CVE in the SAME table still links. Without this, dropping the NVD link for +# every identifier would satisfy the two assertions above. +assert_contains "$EDGE_LOW" "| [CVE-2026-0002](https://nvd.nist.gov/vuln/detail/CVE-2026-0002) | LOW | \`zlib1g\` | residual · no fix | 1 image · v5.2 |" \ + "a neighbouring CVE-* row in the same table still links to NVD" + echo "== cve-size-guard.sh ==" SG_SMALL="$(mktemp)"; tmpdirs+=("$SG_SMALL"); printf 'tiny' > "$SG_SMALL" sgOut="$("${ROOT}/.github/scripts/cve-size-guard.sh" "$SG_SMALL")"; sgRc=$?