Skip to content

Feat/CVE report visibility - #262

Merged
bluvulture merged 10 commits into
5.xfrom
feat/cve-report-visibility
Jul 29, 2026
Merged

Feat/CVE report visibility#262
bluvulture merged 10 commits into
5.xfrom
feat/cve-report-visibility

Conversation

@bluvulture

Copy link
Copy Markdown
Contributor

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.sh with 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

  • Introduced .github/scripts/cve-aggregate.sh to 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.
  • Removed the old .github/scripts/generate-cve-report.sh script, which previously handled both aggregation and rendering in a single pass, in favor of the new modular pipeline.

Report Rendering and Presentation

  • Added .github/scripts/cve-render-summary.sh to generate a concise, reader-focused markdown summary of the CVE findings, including hardening outcomes, severity breakdowns, variant/package impact, and image tables.
  • Added .github/scripts/cve-render-table.sh to render detailed markdown tables for selected severity slices, excluding kernel-header CVEs and collapsing architectures for clarity and size efficiency.

Reliability and Transparency Improvements

  • Added .github/scripts/cve-size-guard.sh to warn when a rendered report approaches GitHub's 512 KB markdown render limit, preventing silent failures and ensuring continued transparency.

Test Data

  • Added fixture files for test coverage, including representative Trivy scan outputs and image metadata for the new pipeline. [1] [2] [3]

These changes collectively modernize the CVE reporting process, making it more maintainable, scalable, and user-friendly.

bluvulture and others added 8 commits July 29, 2026 10:34
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/scripts/cve-aggregate.sh
Comment on lines +53 to +59
if [ ! -f "$hardened" ] || [ "$hd" = "unpublished" ]; then
state="not-produced"
elif [ "$pd" = "$hd" ]; then
state="identical"
else
state="patched"
fi
Comment thread .github/scripts/cve-render-table.sh Outdated
Comment thread .github/scripts/cve-render-summary.sh Outdated
Comment thread .github/scripts/cve-aggregate.sh Outdated
Comment thread .github/scripts/cve-render-summary.sh Outdated
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>
Copilot AI review requested due to automatic review settings July 29, 2026 12:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .severity and .fix happened 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-56 has no severity filter; GATE_SEVERITY is 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 VulnerabilityID can be a non-CVE identifier such as GHSA-*, 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 || true suppresses 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; hashFiles only checks existence, and always() then uploads that invalid file as the canonical artifact. Validate it with jq/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>
Copilot AI review requested due to automatic review settings July 29, 2026 13:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • unpublished means “not pushed,” not “not produced.” The workflow writes hardened_digest.txt only inside PUSH_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 as unpatched. 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 is unpublished.
    if [ ! -f "$hardened" ] || [ "$hd" = "unpublished" ]; then
        state="not-produced"

.github/scripts/cve-aggregate.sh:106

  • The || true suppresses 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 truncated rows.tsv and silently under-reports findings. Check whether rows is empty before appending so genuine write failures remain fatal under set -e.
    printf '%s\n' "$rows" | grep -v '^$' >> "${work}/rows.tsv" || true

.github/workflows/release.yml:552

  • Direct redirection creates cve-data.json before aggregation succeeds. On malformed input the script intentionally exits nonzero with no output, but the zero-byte file remains; the later always() && 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) |" )

@bluvulture
bluvulture merged commit 54f1625 into 5.x Jul 29, 2026
8 checks passed
@bluvulture
bluvulture deleted the feat/cve-report-visibility branch July 29, 2026 13:27
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 29, 2026
@bluvulture
bluvulture restored the feat/cve-report-visibility branch July 29, 2026 13:33
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants