diff --git a/.github/scripts/cve-aggregate.sh b/.github/scripts/cve-aggregate.sh new file mode 100755 index 0000000..0ad4e94 --- /dev/null +++ b/.github/scripts/cve-aggregate.sh @@ -0,0 +1,151 @@ +#!/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 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. +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. 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" \ + --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, .severity, .fix]) + | 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/cve-render-summary.sh b/.github/scripts/cve-render-summary.sh new file mode 100755 index 0000000..75a743c --- /dev/null +++ b/.github/scripts/cve-render-summary.sh @@ -0,0 +1,161 @@ +#!/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 + | ($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, 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" + , "" + , "_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) 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. 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" + + " 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 $kn == 0 then "_Nothing excluded._" + else + "**\($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" + + " `cve-data.json`, uploaded as the `cve-report-json` artifact on 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 | Image builds |" + , "|---------|---------------|--------------|" + , ( ($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" + , "" + # "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, + cves: (map(.id) | unique | length), + imgs: ([.[].affects[]] | unique | length) }) + | sort_by(-.cves) + | .[0:10] + | 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 |" + , "|-------|------|------|------|-----|-----|-----|---------|-----------|--------------|" + , ( $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") ) + , "" + # 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 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 ) + , "" + ) +' "$data" diff --git a/.github/scripts/cve-render-table.sh b/.github/scripts/cve-render-table.sh new file mode 100755 index 0000000..acaf6b1 --- /dev/null +++ b/.github/scripts/cve-render-table.sh @@ -0,0 +1,49 @@ +#!/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" ' + # 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 + # 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 | idcell) " + + "| \(.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/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 </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") +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" + +# 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" + +# 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" +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 "== 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" "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" + +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" + +# --- 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). +# +# M1: the number is a SUM of per-image counts, not a count of CVEs, and it is measured on +# 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" +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)" + +# 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 +# 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" + +# 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 +# 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)" + +# 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, +# 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" + +# 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)" \ + || { 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 "== 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. 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" +# 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 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=$? +assert_eq "$sgRc" "0" "size guard exits 0 for a small file" +assert_not_contains "$sgOut" "::warning::" "no warning for a small file" + +# 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dba3ac8..09e43b1 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: | @@ -497,18 +513,28 @@ jobs: if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} permissions: contents: write + # download-artifact needs actions:read to use the public REST API path. + actions: read steps: - uses: actions/checkout@v5 - name: Download CVE report data # Best-effort: if no cve-report-data_* artifacts exist this run (or the # download hiccups), don't fail the job -- the next step's no-data check - # then exits 0 and leaves docs/known-cves.md unchanged. + # then exits 0 and leaves the committed report unchanged. continue-on-error: true uses: actions/download-artifact@v8 with: path: cve-artifacts pattern: cve-report-data_* - - name: Generate and commit docs/known-cves.md + # Without a token the action uses the internal artifact API, whose + # GetSignedArtifactURL rejects artifacts from a PREVIOUS attempt with + # "(404) workflow run not found" (actions/download-artifact#486). On a + # "Re-run failed jobs" that 404s every download; combined with + # continue-on-error above, the job then goes green having produced no + # report at all -- exactly what happened in run 30265129837. The token + # switches it to the public REST API, which handles cross-attempt reads. + github-token: ${{ github.token }} + - name: Generate and commit the known-CVE report run: | set -euo pipefail # cve-artifacts may not exist if the download matched zero artifacts; @@ -518,23 +544,72 @@ jobs: find cve-artifacts -type f \( -name '*.meta.json' -o -name '*.plain.json' -o -name '*.hardened.json' \) \ -exec cp -n {} _cvedata/ \; if [ -z "$(find _cvedata -name '*.meta.json' -print -quit)" ]; then - echo "No CVE report data this run; leaving docs/known-cves.md unchanged." + echo "No CVE report data this run; leaving the committed report unchanged." exit 0 fi + + TS="$(date -u '+%Y-%m-%d %H:%M UTC')" + .github/scripts/cve-aggregate.sh _cvedata "$TS" > 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 + + # 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 + 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. 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.