Feat/CVE report visibility - #262
Conversation
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.
…eadings 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.
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Redesigns CVE reporting into a scalable aggregation and rendering pipeline with split reports and size monitoring.
Changes:
- Adds canonical CVE aggregation and summary/detail renderers.
- Splits reports by severity and publishes machine-readable data.
- Expands regression fixtures and tests.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents the redesigned reports. |
.github/workflows/release.yml |
Integrates generation, publishing, and artifact handling. |
.github/scripts/cve-aggregate.sh |
Aggregates Trivy data. |
.github/scripts/cve-render-summary.sh |
Renders report summaries. |
.github/scripts/cve-render-table.sh |
Renders severity tables. |
.github/scripts/cve-size-guard.sh |
Warns near markdown limits. |
.github/scripts/generate-cve-report.sh |
Removes the legacy renderer. |
.github/scripts/tests/run.sh |
Tests the new pipeline. |
v5.2-statusmix-a-amd64.*.json |
Tests fixed-status aggregation. |
v5.2-statusmix-b-amd64.*.json |
Tests residual-status aggregation. |
v5.2-kern-amd64.*.json |
Tests kernel-header exclusion. |
v5.2-arm64.*.json |
Tests architecture collapsing. |
Comments suppressed due to low confidence (1)
.github/scripts/cve-render-summary.sh:151
- The footnote repeats the incorrect CRITICAL/HIGH restriction. Copa's input report includes every severity and is restricted only to OS packages (
scan-patch-gate.sh:43-56).
+ " measured across all severities and both OS and library packages, wider than the"
+ " CRITICAL/HIGH OS-package scope Copa patches._"
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if [ ! -f "$hardened" ] || [ "$hd" = "unpublished" ]; then | ||
| state="not-produced" | ||
| elif [ "$pd" = "$hd" ]; then | ||
| state="identical" | ||
| else | ||
| state="patched" | ||
| fi |
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
.github/scripts/cve-aggregate.sh:130
- Grouping only by CVE/package/status merges findings from different releases even when their severity or installed/fixed versions differ, then publishes whichever
.severityand.fixhappened to sort first for every affected image. Preserve those fields in the key (or model them per affected image) so the canonical artifact and rendered status text do not misreport version-specific data.
| ($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) })
.github/scripts/cve-render-summary.sh:151
- The footer repeats the incorrect CRITICAL/HIGH-only Copa scope. The pre-patch report passed to Copa includes fixable OS vulnerabilities at every severity; only library findings are outside that patch input.
+ " 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._"
.github/scripts/cve-render-summary.sh:47
- This states that Copa patches only CRITICAL/HIGH packages, but the patch report in
scan-patch-gate.sh:43-56has no severity filter;GATE_SEVERITYis applied only by the post-patch scan at lines 67-98. Correcting this is important because the generated hardening report currently understates Copa's scope.
"**\($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."
.github/scripts/cve-render-table.sh:36
- Trivy's
VulnerabilityIDcan be a non-CVE identifier such asGHSA-*, especially because these scans include library packages. Sending every identifier to NVD therefore creates broken links; route known advisory namespaces appropriately and leave unknown identifiers as text.
| "| [\(.id)](https://nvd.nist.gov/vuln/detail/\(.id)) "
.github/scripts/cve-aggregate.sh:94
- The trailing
|| truesuppresses every pipeline failure, including a failed append caused by a full disk, so aggregation can silently publish a partial CVE report. Filter blank rows without putting the write behind an unconditional success path.
printf '%s\n' "$rows" | grep -v '^$' >> "${work}/rows.tsv" || true
.github/workflows/release.yml:611
- If aggregation fails, the shell redirection on line 552 still leaves an empty or partial
cve-data.json;hashFilesonly checks existence, andalways()then uploads that invalid file as the canonical artifact. Validate it withjq/a success marker before upload, while still allowing a valid aggregate to be uploaded if only a later render or push operation fails.
if: ${{ always() && hashFiles('cve-data.json') != '' }}
… 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
.github/scripts/cve-aggregate.sh:66
unpublishedmeans “not pushed,” not “not produced.” The workflow writeshardened_digest.txtonly insidePUSH_HARDENED == true(release.yml:323-327), while scheduled/tag runs intentionally leave hardened publishing off but still create and scan the hardened image (release.yml:296-303, 364-367). Those normal runs therefore enter this branch despite having a hardened scan, discard its fixed/residual comparison, and report every plain finding asunpatched. Separate production from publication (use hardened-file presence for row processing, and track the unavailable registry digest independently), with a regression case where a hardened JSON exists and its digest isunpublished.
if [ ! -f "$hardened" ] || [ "$hd" = "unpublished" ]; then
state="not-produced"
.github/scripts/cve-aggregate.sh:106
- The
|| truesuppresses every pipeline failure, not only grep's expected “no non-empty lines” status. If the temp filesystem fills or the append fails, aggregation continues with a truncatedrows.tsvand silently under-reports findings. Check whetherrowsis empty before appending so genuine write failures remain fatal underset -e.
printf '%s\n' "$rows" | grep -v '^$' >> "${work}/rows.tsv" || true
.github/workflows/release.yml:552
- Direct redirection creates
cve-data.jsonbefore aggregation succeeds. On malformed input the script intentionally exits nonzero with no output, but the zero-byte file remains; the lateralways() && hashFiles('cve-data.json')step then uploads it as the canonical artifact. Write to a temporary file and rename only after successful aggregation so failures cannot publish invalid machine-readable data.
TS="$(date -u '+%Y-%m-%d %H:%M UTC')"
.github/scripts/cve-aggregate.sh _cvedata "$TS" > cve-data.json
.github/scripts/cve-render-summary.sh:46
- This zero-fix message attributes every finding to Debian even though the report explicitly includes library-package findings, whose fixes come from their own ecosystems. It can therefore make a false claim in the published security report. Describe the observed Trivy result instead, and limit the Copa conclusion to OS packages.
, ( 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."
.github/scripts/cve-render-summary.sh:73
- The aggregate deliberately retains non-CVE identifiers such as Debian
TEMP-*findings, and the table renderer explicitly supports them, so this calculation is a count of distinct finding IDs—not distinct CVEs. The same mislabel appears in the variant/package sections and can overstate the published CVE totals whenever Trivy emits an unassigned finding. Rename these counts consistently to “Distinct findings” (and adjust related prose/tests).
, "| 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) |" )
This pull request introduces a major redesign of the CVE report generation and rendering scripts for the release workflow. The changes replace the previous monolithic
generate-cve-report.shwith a modular pipeline that aggregates, summarizes, and renders CVE data more efficiently and transparently. The redesign addresses scalability issues with large data sets, improves the usability and clarity of the generated reports, and introduces safeguards against exceeding GitHub's markdown size limits.The most important changes are:
CVE Data Aggregation and Processing Pipeline
.github/scripts/cve-aggregate.shto stream-aggregate per-image Trivy scan results into a single, canonical JSON document keyed by CVE, package, and status, supporting large data sets without excessive memory use..github/scripts/generate-cve-report.shscript, which previously handled both aggregation and rendering in a single pass, in favor of the new modular pipeline.Report Rendering and Presentation
.github/scripts/cve-render-summary.shto generate a concise, reader-focused markdown summary of the CVE findings, including hardening outcomes, severity breakdowns, variant/package impact, and image tables..github/scripts/cve-render-table.shto render detailed markdown tables for selected severity slices, excluding kernel-header CVEs and collapsing architectures for clarity and size efficiency.Reliability and Transparency Improvements
.github/scripts/cve-size-guard.shto warn when a rendered report approaches GitHub's 512 KB markdown render limit, preventing silent failures and ensuring continued transparency.Test Data
These changes collectively modernize the CVE reporting process, making it more maintainable, scalable, and user-friendly.