From 90db1fb3b44f22fdc836e8c78ca6b7f88440a7c3 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:06:16 +0200 Subject: [PATCH 01/10] release: pin Trivy Java DB to ghcr + retry transient Trivy failures Prevents the gcr-mirror 'trivy-java-db 404' that failed a plain-build leg: - Add TRIVY_JAVA_DB_REPOSITORY=ghcr.io/aquasecurity/trivy-java-db:1 (the Java-DB counterpart to the existing TRIVY_DB_REPOSITORY ghcr pin) so the Java DB no longer comes from the flaky gcr mirror. - New with-retry.sh (3 attempts, exponential backoff, retries on any non-zero exit) wrapping the Trivy calls (plain SBOM in release.yml; initial scan, gate scan, hardened SBOM in scan-patch-gate.sh). NOT Copa -- its failures are usually terminal (EOL distros), so retrying wastes minutes. Unit tests cover with-retry (success/retry-then-succeed/exhaust); gate tests run with RETRY_MAX=1 so behaviour is unchanged and fast. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/scan-patch-gate.sh | 7 ++++--- .github/scripts/tests/run.sh | 30 +++++++++++++++++++++++++++++- .github/scripts/with-retry.sh | 27 +++++++++++++++++++++++++++ .github/workflows/release.yml | 3 ++- 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100755 .github/scripts/with-retry.sh diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index b6f4402..5998d41 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -6,6 +6,7 @@ # still ships and other variants continue. Genuine infra errors abort (set -e). set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" # for sibling helpers (with-retry.sh) variant="${1:?usage: scan-patch-gate.sh }" : "${IMAGE_NAME:?}"; : "${ARCH_TAG:?}"; : "${GATE_SEVERITY:?}" STATE_DIR="${STATE_DIR:-.docker-state}" @@ -40,7 +41,7 @@ fail_gate() { # -- record + skip hardened, but let plain ship } echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" -trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ +"${HERE}/with-retry.sh" trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ || fail_gate "Trivy scan of plain image failed" jq empty "$report" 2>/dev/null || fail_gate "Trivy report of plain image is not valid JSON" @@ -73,7 +74,7 @@ if [ "$GATE_SEVERITY" != "NONE" ]; then REPORT_JSON="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.json" REPORT_TXT="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.txt" - trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ + "${HERE}/with-retry.sh" trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ --format json -o "${REPORT_JSON}" "${HARDENED_IMAGE}" \ || fail_gate "Trivy gate scan failed" @@ -100,7 +101,7 @@ fi # Gate passed (or disabled): generate the SBOM first, then publish the markers atomically. HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" -trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" \ +"${HERE}/with-retry.sh" trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" \ || fail_gate "hardened SBOM generation failed" while IFS= read -r plain_tag; do diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index effb080..eb85cc4 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -134,7 +134,10 @@ setup_variant() { # "ghcr.io/pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_tags.txt" } run_gate() { # runs scan-patch-gate.sh in with env already exported - ( cd "$1" && IMAGE_NAME=pimcore/pimcore ARCH_TAG=amd64 \ + # RETRY_MAX=1: the with-retry.sh wrapper around trivy runs the command exactly + # once here (no sleeps, behaviour identical to the unwrapped call). with-retry's + # own retry/backoff is covered by its dedicated tests below. + ( cd "$1" && IMAGE_NAME=pimcore/pimcore ARCH_TAG=amd64 RETRY_MAX=1 \ "${ROOT}/.github/scripts/scan-patch-gate.sh" "$2" ) 2>&1 } @@ -286,5 +289,30 @@ oras_attaches="$(grep -c 'attach' "$logS" 2>/dev/null || true)" assert_contains "$(cat "$logS")" "attach --artifact-type application/spdx+json docker.io/pimcore/pimcore:php8.5-v5.2 sboms/php8.5-v5.2-amd64.spdx.json" "S amd64 SBOM attached to the LOGICAL tag (docker.io-qualified)" assert_contains "$(cat "$logS")" "attach --artifact-type application/spdx+json docker.io/pimcore/pimcore:php8.5-v5.2 sboms/php8.5-v5.2-arm64.spdx.json" "S arm64 SBOM attached to the LOGICAL tag (docker.io-qualified)" +echo "== with-retry.sh ==" +WR="${ROOT}/.github/scripts/with-retry.sh" + +# 1. success on the first try -> exit 0, command run once +cf1="$(mktemp)"; tmpdirs+=("$cf1"); echo 0 > "$cf1" +CF="$cf1" RETRY_DELAY=0 "$WR" bash -c 'echo $(( $(cat "$CF") + 1 )) > "$CF"' ; rc=$? +[ "$rc" = 0 ] && echo " ok: success -> exit 0" || { echo " FAIL: rc $rc"; fail=1; } +[ "$(cat "$cf1")" = 1 ] && echo " ok: ran exactly once" || { echo " FAIL: ran $(cat "$cf1")x"; fail=1; } + +# 2. fails twice then succeeds (max 3) -> overall success, 3 attempts +cf2="$(mktemp)"; tmpdirs+=("$cf2"); echo 0 > "$cf2" +CF="$cf2" RETRY_DELAY=0 RETRY_MAX=3 "$WR" bash -c 'n=$(( $(cat "$CF") + 1 )); echo $n > "$CF"; [ "$n" -ge 3 ]'; rc=$? +[ "$rc" = 0 ] && echo " ok: retries then succeeds -> exit 0" || { echo " FAIL: rc $rc"; fail=1; } +[ "$(cat "$cf2")" = 3 ] && echo " ok: took 3 attempts" || { echo " FAIL: took $(cat "$cf2")"; fail=1; } + +# 3. always fails -> returns the command's last exit code after RETRY_MAX attempts +cf3="$(mktemp)"; tmpdirs+=("$cf3"); echo 0 > "$cf3" +CF="$cf3" RETRY_DELAY=0 RETRY_MAX=2 "$WR" bash -c 'echo $(( $(cat "$CF") + 1 )) > "$CF"; exit 7'; rc=$? +[ "$rc" = 7 ] && echo " ok: returns last exit code (7)" || { echo " FAIL: rc $rc (want 7)"; fail=1; } +[ "$(cat "$cf3")" = 2 ] && echo " ok: tried RETRY_MAX (2) times" || { echo " FAIL: tried $(cat "$cf3")x"; fail=1; } + +# 4. no command given -> usage error (exit 2), not a silent success +"$WR" >/dev/null 2>&1; rc=$? +[ "$rc" = 2 ] && echo " ok: no-args -> exit 2 (usage)" || { echo " FAIL: no-args rc $rc (want 2)"; fail=1; } + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/scripts/with-retry.sh b/.github/scripts/with-retry.sh new file mode 100755 index 0000000..e5cb6e3 --- /dev/null +++ b/.github/scripts/with-retry.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run a command with retries + exponential backoff, for transient CI flakiness +# (e.g. Trivy DB/artifact downloads 404ing from a mirror). Retries on ANY non-zero +# exit. Config via env: RETRY_MAX (default 3), RETRY_DELAY (first backoff seconds, +# default 10; doubles each attempt). Returns the command's last exit code if all +# attempts fail. Deliberately omits `set -e`: the retry loop owns all failure +# handling, so a failing attempt must fall through to the backoff, never abort. +set -uo pipefail + +[ "$#" -gt 0 ] || { echo "usage: with-retry.sh [args...]" >&2; exit 2; } + +max="${RETRY_MAX:-3}" +delay="${RETRY_DELAY:-10}" +attempt=1 + +while true; do + "$@" && exit 0 + rc=$? + if [ "$attempt" -ge "$max" ]; then + echo "::warning::command failed after ${attempt} attempt(s) (exit ${rc}): $*" >&2 + exit "$rc" + fi + echo "attempt ${attempt}/${max} failed (exit ${rc}); retrying in ${delay}s: $*" >&2 + sleep "$delay" + attempt=$((attempt + 1)) + delay=$((delay * 2)) +done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8174283..6627508 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ env: COPA_VERSION: "0.14.1" ORAS_VERSION: "1.2.0" TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" + TRIVY_JAVA_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-java-db:1" jobs: build-php: @@ -195,7 +196,7 @@ jobs: mkdir -p sboms PLAIN_SBOM="sboms/${TAG}.spdx.json" - trivy image --format spdx-json -o "${PLAIN_SBOM}" "${PLAIN_IMAGE}" + _ci/.github/scripts/with-retry.sh trivy image --format spdx-json -o "${PLAIN_SBOM}" "${PLAIN_IMAGE}" echo "${PLAIN_SBOM}" > ".docker-state/${imageVariant}/plain_sbom.txt" done From d76a122afc64799eff5702506c3a4e4d13bdce44 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:33:24 +0200 Subject: [PATCH 02/10] Un-park Known-CVEs report spec + plan (implementing now) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-known-cves-report.md | 410 ++++++++++++++++++ .../2026-07-16-known-cves-report-design.md | 124 ++++++ 2 files changed, 534 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-known-cves-report.md create mode 100644 docs/superpowers/specs/2026-07-16-known-cves-report-design.md diff --git a/docs/superpowers/plans/2026-07-16-known-cves-report.md b/docs/superpowers/plans/2026-07-16-known-cves-report.md new file mode 100644 index 0000000..6d85140 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-known-cves-report.md @@ -0,0 +1,410 @@ +# Known-CVEs / patch report — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On publish, generate and commit `docs/known-cves.md` — per published stable image and arch: plain/hardened digests, CVEs fixed by Copa (old→new version), and residual known CVEs. + +**Architecture:** For each `hardened: true` matrix leg the workflow captures push digests, runs a full Trivy JSON scan of the plain and hardened images, and uploads them + a `meta.json` as a `cve-report-data_*` artifact. A new `publish-cve-report` job downloads all of them, runs `generate-cve-report.sh` (pure parse+render, jq), and commits `docs/known-cves.md`. + +**Tech Stack:** GitHub Actions, Bash 5, Trivy (JSON), jq, git. + +## Global Constraints + +- Scope: **hardened (stable) lines only** (`v1.6, v2.3, v3.8, v4.2, v5.2`); dev/`-dev` lines excluded and noted in the report. +- CVE source: `trivy image --format json` — **all severities, OS + library, NO `--ignore-unfixed`** (unfixable CVEs included). Separate from the Copa/gate scans. +- Per-arch (amd64, arm64) reported separately. +- Report is **two flat tables**: `Image digests` (full 64-char `sha256:` digests) and `CVEs` (one row per CVE; a **12-char** short digest pointer — plain for `fixed`, hardened for `residual`; `unpublished` when hardened not pushed). +- Status: `✅ fixed · ` or `⚠️ residual · `. +- Digests are captured only for images actually pushed (plain needs `PUSH`, hardened needs `PUSH_HARDENED`). +- Registry name verbatim: `pimcore/pimcore`. Every extracted script starts `#!/usr/bin/env bash` + `set -euo pipefail`. +- The report job runs on publish only: `if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}`. + +## File structure + +- `.github/scripts/generate-cve-report.sh` (new) — reads a data dir of `.{meta.json,plain.json,hardened.json}`, prints `known-cves.md` to stdout. Single responsibility: parse + render. +- `.github/scripts/tests/fixtures/cve/` (new) — fixture meta + Trivy JSONs. +- `.github/scripts/tests/run.sh` (modify) — add generate-cve-report assertions. +- `.github/workflows/release.yml` (modify) — digest capture in the two push steps; new `Collect CVE report data` + `Upload CVE report data` steps; new `publish-cve-report` job. +- `README.md` (modify) — add "Known CVEs" section. + +--- + +### Task 1: `generate-cve-report.sh` + tests + +**Files:** +- Create: `.github/scripts/generate-cve-report.sh` +- Create: `.github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json`, `.plain.json`, `.hardened.json`; `.../v5.2-debug-amd64.meta.json`, `.plain.json` (no hardened.json = gate-failed/unpublished case) +- Modify: `.github/scripts/tests/run.sh` + +**Interfaces:** +- Produces: `generate-cve-report.sh ` → Markdown on stdout. Exit 0. Reads `/*.meta.json` (`{image,variant,arch,plain_digest,hardened_digest}`) and, per key, `.plain.json` / `.hardened.json` (Trivy `--format json`). `hardened.json` absent ⇒ that image's CVEs are rendered as `⚠️ unpatched · hardened not produced`. + +- [ ] **Step 1: Write the fixtures** + +`.github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json`: +```json +{"image":"php8.5-v5.2","variant":"default","arch":"amd64","plain_digest":"sha256:1f3a9c4e2b7d05a8c1e6f4b9d3072a5e8c1b6f0d4a7e2c9b5083f1d6a4c7e2b90","hardened_digest":"sha256:8ad4c17b93e0a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} +``` +`.github/scripts/tests/fixtures/cve/v5.2-amd64.plain.json` (a fixable CVE that Copa will remove, plus a residual one that stays): +```json +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2024-45491","Severity":"HIGH","PkgName":"libexpat1","InstalledVersion":"2.6.2-1","FixedVersion":"2.6.2-2+deb13u1"}, +{"VulnerabilityID":"CVE-2025-6020","Severity":"HIGH","PkgName":"libpam0g","InstalledVersion":"1.5.3-7","FixedVersion":""} +]}]} +``` +`.github/scripts/tests/fixtures/cve/v5.2-amd64.hardened.json` (the fixable one is gone; the no-fix one remains): +```json +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-6020","Severity":"HIGH","PkgName":"libpam0g","InstalledVersion":"1.5.3-7","FixedVersion":""} +]}]} +``` +`.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.meta.json` (hardened not published): +```json +{"image":"php8.5-debug-v5.2","variant":"debug","arch":"amd64","plain_digest":"sha256:44bec0a1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f7081920a3b4c5d6e7","hardened_digest":"unpublished"} +``` +`.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.plain.json`: +```json +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2024-7883","Severity":"MEDIUM","PkgName":"libxml2","InstalledVersion":"2.12.7+dfsg-3","FixedVersion":""} +]}]} +``` + +- [ ] **Step 2: Append the failing tests to `run.sh`** (before the final summary lines) + +```bash +echo "== generate-cve-report.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" +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `.github/scripts/tests/run.sh` +Expected: FAIL — `generate-cve-report.sh: No such file or directory` and the new assertions FAIL. + +- [ ] **Step 4: Write `generate-cve-report.sh`** + +```bash +#!/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 </{tag,plain_image,base_tag,version,hardened_image}.txt`. +- Produces: per hardened leg, a `cve-report-data_*` artifact of `.{meta.json,plain.json,hardened.json}` where `` = `${BASE_TAG}-${VERSION}-${ARCH_TAG}`; `meta.image` = `${BASE_TAG}-${VERSION}`. + +- [ ] **Step 1: Capture the plain digest after the plain push** + +In `Push plain images`, inside `if [[ "$PUSH" == "true" ]]; then`, after the `docker push` xargs line, add: +```bash + PLAIN_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$PLAIN_IMAGE" 2>/dev/null | sed 's/.*@//') + echo "${PLAIN_DIGEST:-unpublished}" > ".docker-state/${imageVariant}/plain_digest.txt" +``` + +- [ ] **Step 2: Capture the hardened digest after the hardened push** + +In `Push hardened images`, inside `if [[ "$PUSH_HARDENED" == "true" ]]; then`, after the `docker push` xargs line, add: +```bash + HARDENED_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$HARDENED_IMAGE" 2>/dev/null | sed 's/.*@//') + echo "${HARDENED_DIGEST:-unpublished}" > ".docker-state/${imageVariant}/hardened_digest.txt" +``` + +- [ ] **Step 3: Add the `Collect CVE report data` step** (immediately after `Push hardened images`, before `Clean up images`) + +```yaml + - name: Collect CVE report data + if: ${{ matrix.build.hardened }} + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + run: | + set -euxo pipefail + mkdir -p cve-report-data + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + BASE_TAG=$(< ".docker-state/${imageVariant}/base_tag.txt") + VERSION=$(< ".docker-state/${imageVariant}/version.txt") + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + + # Full CVE scan (all severities, OS+library, includes unfixable) of the plain image. + trivy image --format json -o "cve-report-data/${TAG}.plain.json" "${PLAIN_IMAGE}" + + if [ -f ".docker-state/${imageVariant}/hardened_image.txt" ]; then + HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") + trivy image --format json -o "cve-report-data/${TAG}.hardened.json" "${HARDENED_IMAGE}" + fi + + PLAIN_DIGEST=$(cat ".docker-state/${imageVariant}/plain_digest.txt" 2>/dev/null || echo "unpublished") + HARDENED_DIGEST=$(cat ".docker-state/${imageVariant}/hardened_digest.txt" 2>/dev/null || echo "unpublished") + jq -n \ + --arg image "${BASE_TAG}-${VERSION}" \ + --arg variant "${imageVariant}" \ + --arg arch "${ARCH_TAG}" \ + --arg pd "${PLAIN_DIGEST}" \ + --arg hd "${HARDENED_DIGEST}" \ + '{image:$image, variant:$variant, arch:$arch, plain_digest:$pd, hardened_digest:$hd}' \ + > "cve-report-data/${TAG}.meta.json" + done +``` + +- [ ] **Step 4: Add the `Upload CVE report data` artifact step** (after `Upload SBOMs`) + +```yaml + - name: Upload CVE report data + if: always() + uses: actions/upload-artifact@v7 + with: + name: cve-report-data_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} + path: cve-report-data/ + if-no-files-found: ignore +``` + +- [ ] **Step 5: Lint** + +Run: +```bash +ALINT=$(command -v actionlint || echo /tmp/actionlint) +PATH="/tmp:$PATH" "$ALINT" .github/workflows/release.yml && echo "actionlint clean" +``` +Expected: clean (install actionlint v1.7.7 + shellcheck locally if missing, as in the sibling plan). + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release.yml: capture push digests and collect per-image CVE report data" +``` + +--- + +### Task 3: `publish-cve-report` job (`release.yml`) + +**Files:** +- Modify: `.github/workflows/release.yml` + +**Interfaces:** +- Consumes: `cve-report-data_*` artifacts (Task 2), `.github/scripts/generate-cve-report.sh` (Task 1). +- Produces: a commit of `docs/known-cves.md` on the workflow's branch. + +- [ ] **Step 1: Add the job** (sibling of `process-tags`) + +```yaml + publish-cve-report: + runs-on: ubuntu-22.04 + needs: build-php + if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - name: Download CVE report data + uses: actions/download-artifact@v8 + with: + path: cve-artifacts + pattern: cve-report-data_* + - name: Generate and commit docs/known-cves.md + run: | + set -euo pipefail + mkdir -p _cvedata docs + # Flatten all per-leg artifact dirs into one (filenames are unique per image+arch). + 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." + exit 0 + fi + TS="$(date -u '+%Y-%m-%d %H:%M UTC')" + .github/scripts/generate-cve-report.sh _cvedata "$TS" > docs/known-cves.md + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/known-cves.md + if git diff --cached --quiet; then + echo "docs/known-cves.md 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}" +``` + +- [ ] **Step 2: Lint** + +Run: `PATH="/tmp:$PATH" /tmp/actionlint .github/workflows/release.yml && echo clean` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release.yml: add publish-cve-report job committing docs/known-cves.md" +``` + +--- + +### Task 4: README "Known CVEs" section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add the section** after the `## Hardened images` section (before `## Container registries`) + +```markdown +## 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. +``` + +- [ ] **Step 2: Verify** + +Run: `grep -c "## Known CVEs" README.md` +Expected: `1`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "README: add Known CVEs section linking docs/known-cves.md" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Two flat tables (digests full / CVE short pointer), fixed+residual, per-arch, status format → Task 1 (generator + tests). ✅ +- Full scan (all severities, OS+library, no `--ignore-unfixed`) → Task 2 Step 3. ✅ +- Digests captured per pushed image; `unpublished` when hardened not pushed → Task 2 Steps 1–2 + generator. ✅ +- Stable lines only; dev excluded + noted → `Collect`/`Upload`/`publish-cve-report` are all `matrix.build.hardened` / stable-only; header note in generator + README. ✅ +- Separate dedicated job + file → Task 3. ✅ +- README section → Task 4. ✅ +- `publish-cve-report` condition (always + repo + publish) → Task 3 Step 1. ✅ + +**Placeholder scan:** none — full code in every step. + +**Type/name consistency:** `` = `${BASE_TAG}-${VERSION}-${ARCH_TAG}` (= `tag.txt`) used for `.plain.json`/`.hardened.json`/`.meta.json` in Task 2 and consumed by the generator in Task 1; `meta` fields `image/variant/arch/plain_digest/hardened_digest` written in Task 2 Step 3 match the generator's `jq -r '.image'` etc.; digest state files `plain_digest.txt`/`hardened_digest.txt` written in Task 2 Steps 1–2 and read in Step 3. + +**Known follow-ups (non-blocking):** dev-line coverage; optional severity threshold/filter; sharing the bot-commit machinery with the deferred package-docs job. diff --git a/docs/superpowers/specs/2026-07-16-known-cves-report-design.md b/docs/superpowers/specs/2026-07-16-known-cves-report-design.md new file mode 100644 index 0000000..fcccba3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-known-cves-report-design.md @@ -0,0 +1,124 @@ +# Design: published-image Known-CVEs / patch transparency report + +**Date:** 2026-07-16 +**Status:** **Parked** — design approved and a full implementation plan written +(`docs/superpowers/plans/2026-07-16-known-cves-report.md`); **not yet implemented**. +Pick up by executing that plan. Builds on the `publish_hardened` / push-digest work. +**Branch:** `image_copa` (PR #247) +**Affected files:** `.github/workflows/release.yml`, `.github/scripts/generate-cve-report.sh` +(new), `.github/scripts/tests/` (new tests), `docs/known-cves.md` (new, CI-generated), +`README.md` + +## Problem / goal + +When images are published we want a committed, human-readable transparency report of, per +published image: its known CVEs, the plain image digest, the Copa-hardened image digest, +and which library versions Copa patched (old → new). This lets consumers see exactly what +CVEs a published image carries and what hardening changed. + +## Decisions (confirmed with maintainer, 2026-07-16) + +1. **Scope: hardened (stable) lines only** — `v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2` (the + lines with both a plain and a Copa-hardened flavour). **Dev/rolling lines are excluded** + for now — they are plain-only, never Copa-patched, and churn every run; their absence is + stated in the report and README. (Revisit as a follow-up if dev coverage is wanted.) +2. **Per-arch** — amd64 and arm64 are reported separately (distinct digests, CVE sets, and + patched versions). +3. **CVE set: both fixed and residual** — per image, the CVEs Copa fixed (with library + old → new versions) *and* the CVEs still present in the published hardened image. +4. **Known-CVE completeness: full scan** — the report's CVE lists come from a dedicated + `trivy image --format json` scan with **all severities, OS + library packages, and NO + `--ignore-unfixed`**, so unfixable CVEs (the ones that persist) are shown. This is + separate from the Copa-input and gate scans, which stay `--pkg-types os --ignore-unfixed` + for their own purposes. +5. **Separate dedicated job + file, now** — not folded into the deferred package-docs job. + +## Data collected (added to the hardened path — stable lines only, bounded) + +For each `hardened: true` matrix leg (per arch), per image variant: + +- **Full CVE scan** of the plain image and of the hardened image: + `trivy image --format json -o cve-reports/.plain.json ` and + `... .hardened.json ` — all severities, OS+library, no `--ignore-unfixed`. + Added to `scan-patch-gate.sh` (plain scan before patching, hardened scan after the gate). +- **Patched library versions** are derived later from the existing plain-vs-hardened SBOM + diff (packages whose `versionInfo` changed) — no new scan. +- **Digests** captured immediately after a successful push: + `docker inspect --format '{{index .RepoDigests 0}}' ` → `plain_digest.txt` / + `hardened_digest.txt` in `.docker-state//`. A digest exists only for an image + that was actually pushed (plain requires `PUSH`; hardened requires `PUSH_HARDENED`). +- Everything is uploaded as a per-leg `cve-reports_*` artifact (mirroring the + `trivy-reports_*` / `sboms_*` naming, including `version-override` + `latest-tag` so names + are unique across the matrix). + +## Report generation + +New job **`publish-cve-report`** in `release.yml`: + +- `needs: build-php`; `if: ${{ always() && github.repository == 'pimcore/docker' && + (github.event_name != 'workflow_dispatch' || inputs.publish) }}`; `permissions: + contents: write`. Single job (single writer) so there are no concurrent-commit races. +- Checks out the **default branch**, downloads the `cve-reports_*`, `sboms_*`, and any + digest artifacts, runs `.github/scripts/generate-cve-report.sh`, commits `docs/known-cves.md` + with `GITHUB_TOKEN` (bot commits do not re-trigger workflows), no-op when unchanged, one + `git pull --rebase` retry on push rejection. + +`generate-cve-report.sh` (jq over the Trivy JSON + SPDX SBOMs) produces `docs/known-cves.md` +as **two flat tables** (chosen for scannability over per-image nested sections): + +1. **Image digests** table — one row per image × arch: + `Image | Arch | Plain digest | Hardened digest`. Digests are the **full** 64-char + `sha256:…` (copy-paste-pullable / verifiable). Hardened digest shows _"not published this + run"_ when the hardened image was built + gated but not pushed (e.g. + `publish_hardened=false`). +2. **CVEs** table — one row per (image × arch × CVE): + `Image | Arch | Image digest | CVE | Severity | Package | Status`. + - **Image digest** here is a **short 12-char pointer** (the full value lives in table 1): + the **plain** digest for `fixed` rows (where the CVE was), the **hardened** digest for + `residual` rows (the published image still carrying it), or `unpublished` when hardened + wasn't pushed. + - **Status** merges both CVE classes: `✅ fixed · ` (fixed by Copa; version + bump from the plain-vs-hardened SBOM diff) or `⚠️ residual · ` (still present in the hardened scan). + +Header: a generation timestamp (passed in from the workflow, not computed in-script), the +status legend, the plain/hardened digest-column convention, and a note that dev/rolling +(`*-dev`) lines are excluded (plain-only, never Copa-patched). + +The rendered layout is validated in `scratchpad/known-cves.example.md` (mockup). + +## README + +Add a **"Known CVEs"** section linking to `docs/known-cves.md`, explaining: it lists, per +published stable image and architecture, the known CVEs, the plain and hardened digests, +and the libraries Copa patched; it is regenerated on publish; and it covers **stable +release images only** (dev/`-dev` lines are plain-only and not included). + +## Interactions / edge cases + +- **Publish-gated digests.** The report reflects what was actually published. On + `publish=true, publish_hardened=false`, the hardened digest is absent (hardened not + pushed) — the section still shows the computed fixed/residual CVEs and marks the hardened + digest as not-published. On a non-publish dry-run the job does not run (publish-gated). +- **Copa image source (I4).** The full CVE scans run against the local images (Trivy uses + the Docker daemon), so they are accurate regardless of the buildkitd/registry question + that affects Copa itself. +- **CI cost.** Two extra full Trivy scans per stable image variant × arch (~2 × 5 variants + × 5 lines × 2 arches). Trivy's DB is shared/cached per leg, so each scan is fast; the + cost is bounded to the stable lines (dev excluded). + +## Out of scope (YAGNI) + +- Dev/rolling line CVE coverage (documented exclusion; possible follow-up). +- Signing the report or emitting VEX; the SBOM referrer/attestation work is separate. +- Any change to the gate's `--ignore-unfixed` behaviour (the report uses its own full scan). + +## Testing + +- **Generator:** unit-test `generate-cve-report.sh` against fixture Trivy JSON + SPDX SBOM + pairs (a fixed CVE, a residual CVE, an unfixable CVE, a patched library) and assert the + rendered Markdown tables (fixed vs residual partition, old→new versions, digests, the + "not published this run" hardened case). +- **Workflow:** `actionlint` + `bash -n`; the existing `scripts` job runs the new tests. +- **Live:** a `publish=true` dispatch produces `cve-reports_*` artifacts and the job renders + and commits `docs/known-cves.md`. From 1a26df4d21339092a1f5d4af54517c6a7b929573 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:42:38 +0200 Subject: [PATCH 03/10] Add known-CVEs report generator (fixed/residual, per-arch) with fixture tests --- .github/scripts/generate-cve-report.sh | 83 +++++++++++++++++++ .../fixtures/cve/v5.2-amd64.hardened.json | 3 + .../tests/fixtures/cve/v5.2-amd64.meta.json | 1 + .../tests/fixtures/cve/v5.2-amd64.plain.json | 4 + .../fixtures/cve/v5.2-debug-amd64.meta.json | 1 + .../fixtures/cve/v5.2-debug-amd64.plain.json | 3 + .github/scripts/tests/run.sh | 17 ++++ 7 files changed, 112 insertions(+) create mode 100755 .github/scripts/generate-cve-report.sh create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-amd64.plain.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-debug-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-debug-amd64.plain.json diff --git a/.github/scripts/generate-cve-report.sh b/.github/scripts/generate-cve-report.sh new file mode 100755 index 0000000..de4fccf --- /dev/null +++ b/.github/scripts/generate-cve-report.sh @@ -0,0 +1,83 @@ +#!/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 ==" +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" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From 70592e33d4f9e7f4f4e86731e12f4f7d5ba6e5de Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:00:11 +0200 Subject: [PATCH 04/10] generate-cve-report: key fixed-CVE detection on id+package (exact), surface jq errors --- .github/scripts/generate-cve-report.sh | 29 ++++++++++++++++--- .../cve/v5.2-multipkg-amd64.hardened.json | 3 ++ .../cve/v5.2-multipkg-amd64.meta.json | 1 + .../cve/v5.2-multipkg-amd64.plain.json | 4 +++ .github/scripts/tests/run.sh | 7 +++++ .../plans/2026-07-16-known-cves-report.md | 29 ++++++++++++++++--- 6 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.hardened.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.meta.json create mode 100644 .github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.plain.json diff --git a/.github/scripts/generate-cve-report.sh b/.github/scripts/generate-cve-report.sh index de4fccf..1cc049a 100755 --- a/.github/scripts/generate-cve-report.sh +++ b/.github/scripts/generate-cve-report.sh @@ -61,22 +61,43 @@ for meta in $metas; do rows="" if [ -f "$hardened_json" ]; then # residual = every vuln still in the hardened image + # Capture jq output into a variable first (under set -e) rather than reading + # it via process substitution: `while … done < <(jq …)` hides a jq failure + # from set -e/pipefail entirely -- the loop just reads zero lines and the + # script still exits 0, silently producing an incomplete report on malformed + # Trivy JSON. A plain command-substitution assignment aborts loudly instead. + residual_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json")" while IFS=$'\t' read -r id sev pkg _installed fixed; do [ -z "$id" ] && continue if [ -n "$fixed" ]; then fv="fix ${fixed} available"; else fv="no fix"; fi rows+="$(sev_rank "$sev")1"$'\t'"| \`${image}\` | ${arch} | \`${hd_short}\` | ${id} | ${sev} | ${pkg} | ⚠️ residual · ${fv} |"$'\n' - done < <(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json") - # fixed = in plain, absent from hardened + done <<< "$residual_tsv" + # fixed = in plain, absent from hardened -- keyed on VulnerabilityID+PkgName + # (NUL-joined) with EXACT array membership (index), not id-only `inside`. + # `inside`/`contains` on string arrays is recursive substring matching (e.g. + # ["CVE-2024-1"] | inside(["CVE-2024-12345"]) is true), and keying on id + # alone conflates a CVE across packages: if a CVE affects both pkgA and + # pkgB and Copa fixes only pkgA, the id stays in the hardened id set (via + # pkgB) and the pkgA row would incorrectly be excluded from "fixed" (and + # isn't residual for pkgA either) -- it would vanish from both tables. + fixed_tsv="$(jq -r --slurpfile h "$hardened_json" ' + ([$h[0].Results[]?.Vulnerabilities[]? | .VulnerabilityID + "\u0000" + .PkgName] | unique) as $hkeys + | [.Results[]?.Vulnerabilities[]?] + | map(select( (.VulnerabilityID + "\u0000" + .PkgName) as $key | ($hkeys | index($key)) == null )) + | unique_by(.VulnerabilityID+.PkgName) + | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"?")] | @tsv + ' "$plain_json")" while IFS=$'\t' read -r id sev pkg old new; do [ -z "$id" ] && continue rows+="$(sev_rank "$sev")0"$'\t'"| \`${image}\` | ${arch} | \`${pd_short}\` | ${id} | ${sev} | ${pkg} | ✅ fixed · ${old} → ${new} |"$'\n' - done < <(jq -r --slurpfile h "$hardened_json" '([$h[0].Results[]?.Vulnerabilities[]?.VulnerabilityID]|unique) as $hids | [.Results[]?.Vulnerabilities[]?] | map(select([.VulnerabilityID]|inside($hids)|not)) | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"?")] | @tsv' "$plain_json") + done <<< "$fixed_tsv" else # hardened image was not produced (gate failed) — list the plain CVEs as unpatched + unpatched_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json")" while IFS=$'\t' read -r id sev pkg _installed _fixed; do [ -z "$id" ] && continue rows+="$(sev_rank "$sev")1"$'\t'"| \`${image}\` | ${arch} | \`${pd_short}\` | ${id} | ${sev} | ${pkg} | ⚠️ unpatched · hardened not produced |"$'\n' - done < <(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json") + done <<< "$unpatched_tsv" fi # sort this image's rows by severity then fixed-before-residual, drop the sort key [ -n "$rows" ] && printf '%s' "$rows" | sort -t$'\t' -k1,1 | cut -f2- diff --git a/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.hardened.json new file mode 100644 index 0000000..7c6d291 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.hardened.json @@ -0,0 +1,3 @@ +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-9999","Severity":"HIGH","PkgName":"pkgb","InstalledVersion":"2.0","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.meta.json new file mode 100644 index 0000000..797376e --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-multipkg-v5.2","variant":"default","arch":"amd64","plain_digest":"sha256:dd81d549a32e1c53156f2fc83da5814f0637389fef78ed6e2d27f8f3384fe742","hardened_digest":"sha256:d07739baf7cd22301c30836a09ee577cf7a81b0c2904c8d78ed81779127ba4bf"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.plain.json new file mode 100644 index 0000000..15d1321 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-multipkg-amd64.plain.json @@ -0,0 +1,4 @@ +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-9999","Severity":"HIGH","PkgName":"pkga","InstalledVersion":"1.0","FixedVersion":"1.1"}, +{"VulnerabilityID":"CVE-2025-9999","Severity":"HIGH","PkgName":"pkgb","InstalledVersion":"2.0","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index e7ad789..b5a00da 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -330,6 +330,13 @@ assert_contains "$CVE_OUT" "| \`8ad4c17b93e0\` | CVE-2025-6020 | HIGH | libpam0g # 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)" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/docs/superpowers/plans/2026-07-16-known-cves-report.md b/docs/superpowers/plans/2026-07-16-known-cves-report.md index 6d85140..b95291c 100644 --- a/docs/superpowers/plans/2026-07-16-known-cves-report.md +++ b/docs/superpowers/plans/2026-07-16-known-cves-report.md @@ -161,22 +161,43 @@ for meta in $metas; do rows="" if [ -f "$hardened_json" ]; then # residual = every vuln still in the hardened image + # Capture jq output into a variable first (under set -e) rather than reading + # it via process substitution: `while … done < <(jq …)` hides a jq failure + # from set -e/pipefail entirely -- the loop just reads zero lines and the + # script still exits 0, silently producing an incomplete report on malformed + # Trivy JSON. A plain command-substitution assignment aborts loudly instead. + residual_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json")" while IFS=$'\t' read -r id sev pkg _installed fixed; do [ -z "$id" ] && continue if [ -n "$fixed" ]; then fv="fix ${fixed} available"; else fv="no fix"; fi rows+="$(sev_rank "$sev")1"$'\t'"| \`${image}\` | ${arch} | \`${hd_short}\` | ${id} | ${sev} | ${pkg} | ⚠️ residual · ${fv} |"$'\n' - done < <(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json") - # fixed = in plain, absent from hardened + done <<< "$residual_tsv" + # fixed = in plain, absent from hardened -- keyed on VulnerabilityID+PkgName + # (NUL-joined) with EXACT array membership (index), not id-only `inside`. + # `inside`/`contains` on string arrays is recursive substring matching (e.g. + # ["CVE-2024-1"] | inside(["CVE-2024-12345"]) is true), and keying on id + # alone conflates a CVE across packages: if a CVE affects both pkgA and + # pkgB and Copa fixes only pkgA, the id stays in the hardened id set (via + # pkgB) and the pkgA row would incorrectly be excluded from "fixed" (and + # isn't residual for pkgA either) -- it would vanish from both tables. + fixed_tsv="$(jq -r --slurpfile h "$hardened_json" ' + ([$h[0].Results[]?.Vulnerabilities[]? | .VulnerabilityID + "\u0000" + .PkgName] | unique) as $hkeys + | [.Results[]?.Vulnerabilities[]?] + | map(select( (.VulnerabilityID + "\u0000" + .PkgName) as $key | ($hkeys | index($key)) == null )) + | unique_by(.VulnerabilityID+.PkgName) + | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"?")] | @tsv + ' "$plain_json")" while IFS=$'\t' read -r id sev pkg old new; do [ -z "$id" ] && continue rows+="$(sev_rank "$sev")0"$'\t'"| \`${image}\` | ${arch} | \`${pd_short}\` | ${id} | ${sev} | ${pkg} | ✅ fixed · ${old} → ${new} |"$'\n' - done < <(jq -r --slurpfile h "$hardened_json" '([$h[0].Results[]?.Vulnerabilities[]?.VulnerabilityID]|unique) as $hids | [.Results[]?.Vulnerabilities[]?] | map(select([.VulnerabilityID]|inside($hids)|not)) | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"?")] | @tsv' "$plain_json") + done <<< "$fixed_tsv" else # hardened image was not produced (gate failed) — list the plain CVEs as unpatched + unpatched_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json")" while IFS=$'\t' read -r id sev pkg _installed _fixed; do [ -z "$id" ] && continue rows+="$(sev_rank "$sev")1"$'\t'"| \`${image}\` | ${arch} | \`${pd_short}\` | ${id} | ${sev} | ${pkg} | ⚠️ unpatched · hardened not produced |"$'\n' - done < <(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json") + done <<< "$unpatched_tsv" fi # sort this image's rows by severity then fixed-before-residual, drop the sort key [ -n "$rows" ] && printf '%s' "$rows" | sort -t$'\t' -k1,1 | cut -f2- From 3fb1c9d1d7dd2d909b5730d96f4dc176fd064721 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:05:22 +0200 Subject: [PATCH 05/10] release.yml: capture push digests and collect per-image CVE report data --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6627508..f982195 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,6 +225,9 @@ jobs: if [[ "$PUSH" == "true" ]]; then printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + PLAIN_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$PLAIN_IMAGE" 2>/dev/null | sed 's/.*@//') + echo "${PLAIN_DIGEST:-unpublished}" > ".docker-state/${imageVariant}/plain_digest.txt" + _ci/.github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" @@ -320,6 +323,9 @@ jobs: if [[ "$PUSH_HARDENED" == "true" ]]; then printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + HARDENED_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$HARDENED_IMAGE" 2>/dev/null | sed 's/.*@//') + echo "${HARDENED_DIGEST:-unpublished}" > ".docker-state/${imageVariant}/hardened_digest.txt" + HARDENED_TAG="${HARDENED_IMAGE#"${IMAGE_NAME}":}" _ci/.github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" @@ -334,6 +340,40 @@ jobs: fi done + - name: Collect CVE report data + if: ${{ matrix.build.hardened }} + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + run: | + set -euxo pipefail + mkdir -p cve-report-data + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + BASE_TAG=$(< ".docker-state/${imageVariant}/base_tag.txt") + VERSION=$(< ".docker-state/${imageVariant}/version.txt") + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + + # Full CVE scan (all severities, OS+library, includes unfixable) of the plain image. + _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.plain.json" "${PLAIN_IMAGE}" + + if [ -f ".docker-state/${imageVariant}/hardened_image.txt" ]; then + HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") + _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.hardened.json" "${HARDENED_IMAGE}" + fi + + PLAIN_DIGEST=$(cat ".docker-state/${imageVariant}/plain_digest.txt" 2>/dev/null || echo "unpublished") + HARDENED_DIGEST=$(cat ".docker-state/${imageVariant}/hardened_digest.txt" 2>/dev/null || echo "unpublished") + jq -n \ + --arg image "${BASE_TAG}-${VERSION}" \ + --arg variant "${imageVariant}" \ + --arg arch "${ARCH_TAG}" \ + --arg pd "${PLAIN_DIGEST}" \ + --arg hd "${HARDENED_DIGEST}" \ + '{image:$image, variant:$variant, arch:$arch, plain_digest:$pd, hardened_digest:$hd}' \ + > "cve-report-data/${TAG}.meta.json" + done + - name: Clean up images if: ${{ always() }} run: | @@ -369,6 +409,14 @@ jobs: path: sboms/ if-no-files-found: ignore + - name: Upload CVE report data + if: always() + uses: actions/upload-artifact@v7 + with: + name: cve-report-data_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} + path: cve-report-data/ + if-no-files-found: ignore + - name: Upload aggregated tags if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} uses: actions/upload-artifact@v7 From 5b3cd6d3b4dac594ab6b546d851e0558d6eb22d8 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:09:25 +0200 Subject: [PATCH 06/10] release: make CVE-report Collect non-fatal (consistent with the non-fatal gate) A Trivy scan failure in Collect (after retries) now omits that variant from this run's report with a ::warning::, instead of failing the leg. The images already published; the report is transparency, not a gate -- same best-effort stance as the severity gate. Non-destructive: no rmi, uploads/cleanup stay always(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f982195..2aca67d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,6 +348,9 @@ jobs: set -euxo pipefail mkdir -p cve-report-data mapfile -t imageVariants < .docker-state/variants.txt + # Best-effort, like the severity gate: a scan failure (after retries) omits + # that variant from this run's report and warns, but never fails the leg -- + # the images already published, and the report is transparency, not a gate. for imageVariant in "${imageVariants[@]}"; do TAG=$(< ".docker-state/${imageVariant}/tag.txt") BASE_TAG=$(< ".docker-state/${imageVariant}/base_tag.txt") @@ -355,11 +358,13 @@ jobs: PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") # Full CVE scan (all severities, OS+library, includes unfixable) of the plain image. - _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.plain.json" "${PLAIN_IMAGE}" + _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.plain.json" "${PLAIN_IMAGE}" \ + || { echo "::warning::CVE report: plain scan failed for ${imageVariant}; omitting it from this run's report"; rm -f "cve-report-data/${TAG}.plain.json"; continue; } if [ -f ".docker-state/${imageVariant}/hardened_image.txt" ]; then HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") - _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.hardened.json" "${HARDENED_IMAGE}" + _ci/.github/scripts/with-retry.sh trivy image --format json -o "cve-report-data/${TAG}.hardened.json" "${HARDENED_IMAGE}" \ + || { echo "::warning::CVE report: hardened scan failed for ${imageVariant}; omitting it from this run's report"; rm -f "cve-report-data/${TAG}.plain.json" "cve-report-data/${TAG}.hardened.json"; continue; } fi PLAIN_DIGEST=$(cat ".docker-state/${imageVariant}/plain_digest.txt" 2>/dev/null || echo "unpublished") From ef4a324e984b9bf2b86bb318c6a70b0729937ea6 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:11:18 +0200 Subject: [PATCH 07/10] release.yml: add publish-cve-report job committing docs/known-cves.md --- .github/workflows/release.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aca67d..e1d73b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -490,3 +490,45 @@ jobs: set -uo pipefail find artifacts -type f -name "aggregated_tags.txt" -exec cat {} + > all_aggregated_tags.txt _ci/.github/scripts/merge-manifests.sh + + publish-cve-report: + runs-on: ubuntu-22.04 + needs: build-php + if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - name: Download CVE report data + uses: actions/download-artifact@v8 + with: + path: cve-artifacts + pattern: cve-report-data_* + - name: Generate and commit docs/known-cves.md + run: | + set -euo pipefail + mkdir -p _cvedata docs + # Flatten all per-leg artifact dirs into one (filenames are unique per image+arch). + 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." + exit 0 + fi + if [ "${GITHUB_REF_TYPE:-}" != "branch" ]; then + echo "Not a branch run (ref_type=${GITHUB_REF_TYPE:-unknown}); skipping known-cves.md commit." + exit 0 + fi + TS="$(date -u '+%Y-%m-%d %H:%M UTC')" + .github/scripts/generate-cve-report.sh _cvedata "$TS" > docs/known-cves.md + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/known-cves.md + if git diff --cached --quiet; then + echo "docs/known-cves.md 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}" From 100cf2e01399e77aa61a2f2216c633cfc1106f1c Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:15:38 +0200 Subject: [PATCH 08/10] release: continue-on-error on CVE-report artifact download (reach the no-data skip) If no cve-report-data_* artifacts match (or the download hiccups), the job no longer hard-fails before the 'no data this run -> exit 0' skip. Best-effort, consistent with the non-fatal report collection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1d73b7..128382c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -500,6 +500,10 @@ jobs: 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. + continue-on-error: true uses: actions/download-artifact@v8 with: path: cve-artifacts From 417c3f09813a7c5d4e8b97c8c5cb167aa18a8617 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:17:30 +0200 Subject: [PATCH 09/10] README: add Known CVEs section + SBOM discovery/retrieval examples - Document the CI-generated docs/known-cves.md per-arch CVE/patch report (plain + hardened digests, Copa fixes, residual CVEs), stable-only. - Expand the SBOM paragraph with oras discover/pull examples for both registries (noting the docker.io/ prefix oras requires) and the sboms_* workflow artifact fallback when referrer attach is skipped. --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 8d01244..3710ad8 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,30 @@ php8.5-debug-v5-hardened # same image, all available OS CVE fixes applied **SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM. It is always uploaded as a build artifact, and — where the registry supports OCI referrers — attached to the published image so it is discoverable with `oras discover` on the tag you pull (the multi-arch tag carries a referrer per architecture). +Retrieving the SBOM with [`oras`](https://oras.land/): + +```text +# Discover the SBOM referrer(s) on the tag you pull (one per architecture). +# Note the docker.io/ prefix for Docker Hub — oras (unlike docker) does not +# apply that default, so a bare "pimcore/pimcore:..." ref will not resolve. +oras discover --artifact-type application/spdx+json ghcr.io/pimcore/pimcore:php8.5-v5.2 +oras discover --artifact-type application/spdx+json docker.io/pimcore/pimcore:php8.5-v5.2 + +# Pull the SBOM blob using a referrer digest from the discover output above. +oras pull ghcr.io/pimcore/pimcore@ -o ./sbom +``` + +Attaching is best-effort (it's skipped if the registry rejects OCI referrers), so if `oras discover` comes up empty, fall back to the guaranteed copy: download the `sboms_*` artifact from the corresponding release workflow run. + +## 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. + ## 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. Use either of the following commands: From 9db829217a3ea95f73ccbf4f2f303f179b2cf25a Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:31:21 +0200 Subject: [PATCH 10/10] cve-report: reach no-data skip; collision-safe dedup key; locale-stable sort Final-review (Fable) fixes: - release.yml: mkdir -p now includes cve-artifacts so a zero-artifact run reaches the 'no data -> exit 0' skip instead of failing on the find (download-artifact zero-match doesn't create the dir). [blocking] - generate-cve-report.sh: unique_by([.VulnerabilityID,.PkgName]) (array key) instead of bare string concat, so a digit-boundary id/package collision can't silently drop a CVE row. LC_ALL=C on both sorts for environment-independent byte-determinism. Tests pass; output verified byte-identical across runs; actionlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/generate-cve-report.sh | 10 +++++----- .github/workflows/release.yml | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/scripts/generate-cve-report.sh b/.github/scripts/generate-cve-report.sh index 1cc049a..8004dd6 100755 --- a/.github/scripts/generate-cve-report.sh +++ b/.github/scripts/generate-cve-report.sh @@ -33,7 +33,7 @@ column in the CVE table is a 12-char pointer — the **plain** digest for \`fixe |-------|------|--------------|-----------------| EOF -metas=$(find "$data_dir" -maxdepth 1 -name '*.meta.json' | sort) +metas=$(find "$data_dir" -maxdepth 1 -name '*.meta.json' | LC_ALL=C sort) for meta in $metas; do image=$(jq -r '.image' "$meta"); arch=$(jq -r '.arch' "$meta") @@ -66,7 +66,7 @@ for meta in $metas; do # from set -e/pipefail entirely -- the loop just reads zero lines and the # script still exits 0, silently producing an incomplete report on malformed # Trivy JSON. A plain command-substitution assignment aborts loudly instead. - residual_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json")" + residual_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by([.VulnerabilityID,.PkgName]) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$hardened_json")" while IFS=$'\t' read -r id sev pkg _installed fixed; do [ -z "$id" ] && continue if [ -n "$fixed" ]; then fv="fix ${fixed} available"; else fv="no fix"; fi @@ -84,7 +84,7 @@ for meta in $metas; do ([$h[0].Results[]?.Vulnerabilities[]? | .VulnerabilityID + "\u0000" + .PkgName] | unique) as $hkeys | [.Results[]?.Vulnerabilities[]?] | map(select( (.VulnerabilityID + "\u0000" + .PkgName) as $key | ($hkeys | index($key)) == null )) - | unique_by(.VulnerabilityID+.PkgName) + | unique_by([.VulnerabilityID,.PkgName]) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"?")] | @tsv ' "$plain_json")" while IFS=$'\t' read -r id sev pkg old new; do @@ -93,12 +93,12 @@ for meta in $metas; do done <<< "$fixed_tsv" else # hardened image was not produced (gate failed) — list the plain CVEs as unpatched - unpatched_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by(.VulnerabilityID+.PkgName) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json")" + unpatched_tsv="$(jq -r '[.Results[]?.Vulnerabilities[]?] | unique_by([.VulnerabilityID,.PkgName]) | .[] | [.VulnerabilityID,.Severity,.PkgName,.InstalledVersion,(.FixedVersion//"")] | @tsv' "$plain_json")" while IFS=$'\t' read -r id sev pkg _installed _fixed; do [ -z "$id" ] && continue rows+="$(sev_rank "$sev")1"$'\t'"| \`${image}\` | ${arch} | \`${pd_short}\` | ${id} | ${sev} | ${pkg} | ⚠️ unpatched · hardened not produced |"$'\n' done <<< "$unpatched_tsv" fi # sort this image's rows by severity then fixed-before-residual, drop the sort key - [ -n "$rows" ] && printf '%s' "$rows" | sort -t$'\t' -k1,1 | cut -f2- + [ -n "$rows" ] && printf '%s' "$rows" | LC_ALL=C sort -t$'\t' -k1,1 | cut -f2- done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 128382c..dba3ac8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -511,7 +511,9 @@ jobs: - name: Generate and commit docs/known-cves.md run: | set -euo pipefail - mkdir -p _cvedata docs + # cve-artifacts may not exist if the download matched zero artifacts; + # create it so the find below can't fail before the no-data skip fires. + mkdir -p _cvedata docs cve-artifacts # Flatten all per-leg artifact dirs into one (filenames are unique per image+arch). find cve-artifacts -type f \( -name '*.meta.json' -o -name '*.plain.json' -o -name '*.hardened.json' \) \ -exec cp -n {} _cvedata/ \;