diff --git a/.github/scripts/generate-cve-report.sh b/.github/scripts/generate-cve-report.sh new file mode 100755 index 0000000..8004dd6 --- /dev/null +++ b/.github/scripts/generate-cve-report.sh @@ -0,0 +1,104 @@ +#!/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 < -- 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/fixtures/cve/v5.2-amd64.hardened.json b/.github/scripts/tests/fixtures/cve/v5.2-amd64.hardened.json new file mode 100644 index 0000000..5768277 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-amd64.hardened.json @@ -0,0 +1,3 @@ +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2025-6020","Severity":"HIGH","PkgName":"libpam0g","InstalledVersion":"1.5.3-7","FixedVersion":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json new file mode 100644 index 0000000..454fcbb --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-v5.2","variant":"default","arch":"amd64","plain_digest":"sha256:1f3a9c4e2b7d05a8c1e6f4b9d3072a5e8c1b6f0d4a7e2c9b5083f1d6a4c7e2b90","hardened_digest":"sha256:8ad4c17b93e0a5d2f4681c9b0e3a7d5c2b8f1069a4e7c3b05d9f28a1c6e4b0f37"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-amd64.plain.json new file mode 100644 index 0000000..888ab87 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-amd64.plain.json @@ -0,0 +1,4 @@ +{"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":""} +]}]} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.meta.json b/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.meta.json new file mode 100644 index 0000000..06a38c3 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.meta.json @@ -0,0 +1 @@ +{"image":"php8.5-debug-v5.2","variant":"debug","arch":"amd64","plain_digest":"sha256:44bec0a1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f7081920a3b4c5d6e7","hardened_digest":"unpublished"} diff --git a/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.plain.json b/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.plain.json new file mode 100644 index 0000000..bc609a7 --- /dev/null +++ b/.github/scripts/tests/fixtures/cve/v5.2-debug-amd64.plain.json @@ -0,0 +1,3 @@ +{"Results":[{"Vulnerabilities":[ +{"VulnerabilityID":"CVE-2024-7883","Severity":"MEDIUM","PkgName":"libxml2","InstalledVersion":"2.12.7+dfsg-3","FixedVersion":""} +]}]} 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 effb080..b5a00da 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,54 @@ 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 "== 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" +# 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/.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..dba3ac8 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 @@ -224,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}" @@ -319,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}" @@ -333,6 +340,45 @@ 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 + # 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") + 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}" \ + || { 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}" \ + || { 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") + 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: | @@ -368,6 +414,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 @@ -436,3 +490,51 @@ 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 + # 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 + pattern: cve-report-data_* + - name: Generate and commit docs/known-cves.md + run: | + set -euo pipefail + # 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/ \; + 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}" 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: 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..b95291c --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-known-cves-report.md @@ -0,0 +1,431 @@ +# 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`.