Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/scripts/cve-aggregate.sh
Original file line number Diff line number Diff line change
@@ -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 <data-dir> <timestamp>
# <data-dir>/<key>.meta.json {image,variant,arch,plain_digest,hardened_digest}
# <data-dir>/<key>.plain.json full Trivy JSON of the plain image
# <data-dir>/<key>.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 <data-dir> <timestamp>}"
timestamp="${2:?usage: cve-aggregate.sh <data-dir> <timestamp>}"

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
Comment thread
bluvulture marked this conversation as resolved.

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
Comment on lines +65 to +71

# 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 }
'
161 changes: 161 additions & 0 deletions .github/scripts/cve-render-summary.sh
Original file line number Diff line number Diff line change
@@ -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 <cve-data.json>
#
# 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 <cve-data.json>}"

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"
49 changes: 49 additions & 0 deletions .github/scripts/cve-render-table.sh
Original file line number Diff line number Diff line change
@@ -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 <cve-data.json> <severity-csv> [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 <cve-data.json> <severity-csv> [title]}"
sevs="${2:?usage: cve-render-table.sh <cve-data.json> <severity-csv> [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>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"
25 changes: 25 additions & 0 deletions .github/scripts/cve-size-guard.sh
Original file line number Diff line number Diff line change
@@ -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 <file> [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 <file> [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
Loading
Loading