From 5d0ca9bb4cea0dc517a846a980ca9bd2ac28fe85 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:47:00 +0000 Subject: [PATCH 1/3] Scan published native images and refresh the runtime base (#802) The native release recorded provenance but never inventoried vulnerabilities in the published image. Add a repository-owned scan: scripts/ci/native-image-scan.sh runs the pinned Trivy 0.76.1 index on one platform digest from the registry, and native_scan_receipt.py keeps a compact metadata receipt (digest, scanner and database identity, every HIGH/CRITICAL row with fix status and class, fingerprint). native-release.yml scans both published children after publish. A HIGH or CRITICAL row with a fixed version fails the job; alias evidence now requires both scan jobs, so it holds promotion. Unfixed rows stay inventory. native-image-rescan.yml rescans latest weekly. Scanning published 3.5.0 found CVE-2026-109518 (apt, libapt-pkg6.0 2.6.1, fixed in 2.6.2) in the runtime base, so the base moves to the current Debian 12 bookworm-slim index, which scans clean of fixable rows on both architectures. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image-rescan.yml | 66 +++++++++++ .../workflows/native-promotion-contracts.yml | 4 + .github/workflows/native-release.yml | 62 ++++++++++ Dockerfile.native | 7 +- docs/native-release-promotion.md | 12 +- scripts/ci/check-native-release-tags.sh | 4 +- scripts/ci/native-image-scan.sh | 43 +++++++ scripts/ci/native_alias_evidence.py | 5 + scripts/ci/native_scan_receipt.py | 107 ++++++++++++++++++ .../Unit/Support/native_scan_receipt_test.py | 67 +++++++++++ 10 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/native-image-rescan.yml create mode 100755 scripts/ci/native-image-scan.sh create mode 100644 scripts/ci/native_scan_receipt.py create mode 100644 tests/Unit/Support/native_scan_receipt_test.py diff --git a/.github/workflows/native-image-rescan.yml b/.github/workflows/native-image-rescan.yml new file mode 100644 index 000000000..0697be00c --- /dev/null +++ b/.github/workflows/native-image-rescan.yml @@ -0,0 +1,66 @@ +name: Native image rescan + +# Server #802: rescans the latest stable native image weekly, so advisories +# or fixes published after a release are seen. It reads the image from the +# registry and runs nothing. A HIGH or CRITICAL row with a fixed version +# fails the run; record the disposition on server#802. Unfixed rows are +# inventory in the kept receipts. + +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-image-rescan + cancel-in-progress: false + +env: + GHCR_IMAGE: ghcr.io/durable-workflow/server + +jobs: + rescan: + name: Rescan latest ${{ matrix.platform }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + platform: [linux/amd64, linux/arm64] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Resolve latest to its platform digest + id: resolve + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + docker buildx imagetools inspect "$GHCR_IMAGE:latest" --format '{{json .Manifest}}' > index.json + digest=$(python3 -I -c 'import json, sys + os_name, arch = sys.argv[2].split("/") + index = json.load(open(sys.argv[1])) + found = [m["digest"] for m in index["manifests"] if m.get("platform", {}).get("os") == os_name and m["platform"].get("architecture") == arch] + assert len(found) == 1, found + print(found[0])' index.json "$PLATFORM") + echo "image=$GHCR_IMAGE@$digest" >> "$GITHUB_OUTPUT" + + - name: Scan the latest ${{ matrix.platform }} digest + env: + IMAGE: ${{ steps.resolve.outputs.image }} + PLATFORM: ${{ matrix.platform }} + run: bash scripts/ci/native-image-scan.sh "$IMAGE" "$PLATFORM" native-scan + + - name: Keep the scan receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-rescan-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: native-scan + retention-days: 90 + if-no-files-found: ignore diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml index 54bea0f53..5686170ce 100644 --- a/.github/workflows/native-promotion-contracts.yml +++ b/.github/workflows/native-promotion-contracts.yml @@ -13,6 +13,9 @@ on: - scripts/conformance/server-parity/native-image-corpus.sh - tests/Unit/Support/native_alias_*test.py - tests/Unit/Support/native_qualification_test.py + - scripts/ci/native-image-scan.sh + - scripts/ci/native_scan_receipt.py + - tests/Unit/Support/native_scan_receipt_test.py - .github/workflows/native-*.yml - .github/workflows/published-compose.yml permissions: @@ -29,5 +32,6 @@ jobs: run: | python3 -m unittest discover -s tests/Unit/Support -p 'native_qualification_test.py' -v python3 -m unittest discover -s tests/Unit/Support -p 'native_alias_*test.py' -v + python3 -m unittest discover -s tests/Unit/Support -p 'native_scan_receipt_test.py' -v - name: Preserve release, candidate and monotonic alias contracts run: bash scripts/ci/check-native-release-tags.sh diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 97b4088ab..d0e136833 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -353,5 +353,67 @@ jobs: subject-digest: ${{ steps.index.outputs.digest }} push-to-registry: true + # Server #802: each published platform digest is scanned with the pinned + # Trivy. A HIGH or CRITICAL row with a fixed version fails this job, and + # alias promotion requires every job of this run to succeed, so it holds + # promotion. Unfixed rows are inventory in the kept receipt. + scan-amd64: + name: Scan published linux/amd64 + needs: [build-amd64, publish] + if: github.ref == format('refs/tags/{0}', inputs.tag) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Scan the published linux/amd64 digest + env: + IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-amd64.outputs.digest }} + run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/amd64 native-scan + + - name: Keep the linux/amd64 scan receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-scan-amd64 + path: native-scan + retention-days: 90 + if-no-files-found: ignore + + scan-arm64: + name: Scan published linux/arm64 + needs: [build-arm64, publish] + if: github.ref == format('refs/tags/{0}', inputs.tag) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Scan the published linux/arm64 digest + env: + IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-arm64.outputs.digest }} + run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/arm64 native-scan + + - name: Keep the linux/arm64 scan receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-scan-arm64 + path: native-scan + retention-days: 90 + if-no-files-found: ignore + # Rolling aliases are deliberately absent. Future final tags containing # this source use native-alias-promotion.yml after existing qualification. diff --git a/Dockerfile.native b/Dockerfile.native index 37d897f7e..053ad42e1 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -29,8 +29,11 @@ RUN cargo build --release --locked \ && install -m 0755 target/release/durable-workflow-server /durable-workflow-server # A digest-pinned slim runtime: glibc for the binary, nothing else. TLS uses -# rustls with compiled-in web PKI roots, and SQLite is bundled. -FROM mirror.gcr.io/library/debian@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 +# rustls with compiled-in web PKI roots, and SQLite is bundled. Debian 12 +# bookworm-slim (12.15) as its multi-architecture index, refreshed for +# CVE-2026-109518 (apt 2.6.2), which the release scan found fixable in the +# previous pin (server#802). +FROM mirror.gcr.io/library/debian@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc COPY --from=build /durable-workflow-server /usr/local/bin/durable-workflow-server # The PHP image's paths, so a taken-over installation keeps its volumes: # SQLite in /app/database, and external payload objects under /app/storage, diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md index 36a30e4a2..c9f586016 100644 --- a/docs/native-release-promotion.md +++ b/docs/native-release-promotion.md @@ -11,7 +11,13 @@ any frozen release remain subject to their separate operator and Security gates. `native-release.yml` publishes and attests exact version tags only. It never moves `X.Y`, `X` or `latest`. A final release follows this order: -1. Publish both architecture children and the same index in both registries. +1. Publish both architecture children and the same index in both registries, + then scan each published child with the pinned Trivy (`scan-amd64`, + `scan-arm64`, server#802). A HIGH or CRITICAL row with a fixed version fails + the run, and the manifest builder refuses a release run with a failed job, + so that finding holds promotion. Unfixed rows are inventory in the + `native-scan-*` receipts for Security's disposition on server#802. + `native-image-rescan.yml` rescans `latest` weekly. 2. Run the existing native-image corpus on that exact GHCR index from the release tag, then the existing published-Compose four-cell matrix on that same index from that tag. These are separately admitted invocations, not automatic @@ -51,7 +57,9 @@ hand-written evidence file is needed. Run the commands from a checkout of commit=$(git rev-parse "$tag^{commit}") ``` -2. Publish the tag and wait for the run to succeed. Note its run ID. +2. Publish the tag and wait for the run to succeed, scans included. Note its + run ID. If a scan fails, fix the image (for example, refresh the base digest) + in a new release. ```sh gh workflow run native-release.yml --ref "$tag" -f tag="$tag" -f release_commit="$commit" diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index ac649566e..0e7d4307c 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -660,7 +660,9 @@ tag_gate="github.ref == format('refs/tags/{0}', inputs.tag)" expected_conditions="plan=none build-amd64=$tag_gate build-arm64=$tag_gate -publish=$tag_gate" +publish=$tag_gate +scan-amd64=$tag_gate +scan-arm64=$tag_gate" test "$job_conditions" = "$expected_conditions" \ || fail "native-release.yml job conditions changed; expected: $expected_conditions diff --git a/scripts/ci/native-image-scan.sh b/scripts/ci/native-image-scan.sh new file mode 100755 index 000000000..8a36e3cd0 --- /dev/null +++ b/scripts/ci/native-image-scan.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Scans one native image platform digest with the pinned Trivy and writes a +# compact receipt (server#802). Exits 1 when a HIGH or CRITICAL row has a +# fixed version, which holds promotion; unfixed rows are inventory only. +# +# usage: native-image-scan.sh IMAGE@sha256:DIGEST PLATFORM OUT_DIR +set -euo pipefail + +image=${1:?image by digest} +platform=${2:?platform} +out=${3:?output directory} + +# Trivy 0.76.1's multi-architecture index. Its linux/amd64 manifest is +# sha256:2e6c00d711ab…, the one Security scanned with on server#802. +scanner=docker.io/aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c + +if [[ ! $image =~ ^[a-z0-9./-]+@sha256:[0-9a-f]{64}$ ]]; then + echo "native-image-scan: name the image by digest, not $image" >&2 + exit 2 +fi +case $platform in + linux/amd64 | linux/arm64) ;; + *) echo "native-image-scan: unsupported platform $platform" >&2; exit 2 ;; +esac + +mkdir -p "$out" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/cache" "$work/report" +name=scan-${platform#linux/} + +trivy() { + docker run --rm --name "dw-native-scan-$name-$$" -u "$(id -u):$(id -g)" \ + -v "$work/cache:/cache" -v "$work/report:/report" "$scanner" "$@" +} + +# Read the image from the registry for the one platform; nothing runs it. +trivy image --cache-dir /cache --image-src remote --platform "$platform" \ + --scanners vuln --severity HIGH,CRITICAL --format json --output /report/trivy.json --quiet "$image" +trivy version --cache-dir /cache --format json > "$work/report/version.json" + +python3 -I "$(dirname "$0")/native_scan_receipt.py" "$work/report/trivy.json" "$work/report/version.json" \ + "$image" "$platform" "$scanner" > "$out/$name.json" diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index 21db284b6..0e1c13a85 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -64,6 +64,11 @@ "Attest the index's build provenance in GHCR", "Attest the index's build provenance in Docker Hub", ), + # Server #802: a fixed-version HIGH or CRITICAL finding fails the scan. + **{ + "Scan published " + platform: ("Scan the published " + platform + " digest",) + for platform in PLATFORMS + }, } QUALIFICATION_STEPS = { "corpus": ( diff --git a/scripts/ci/native_scan_receipt.py b/scripts/ci/native_scan_receipt.py new file mode 100644 index 000000000..212d3be24 --- /dev/null +++ b/scripts/ci/native_scan_receipt.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Compact receipt for one Trivy scan of a native image platform digest (#802). + +Reads Trivy's JSON report and `trivy version` JSON and writes a small, +metadata-only receipt: the scanned digest, scanner and database identity, +every HIGH/CRITICAL row with its fix status, and a fingerprint of those rows. +The full report is not kept. + +A row is actionable when Trivy reports a fixed version for it (status +`fixed`). Actionable rows hold promotion: the script exits 1. Unfixed +distribution rows are inventory for Security's reachability disposition on +server#802, not a failure. + +Usage: native_scan_receipt.py REPORT VERSION IMAGE PLATFORM SCANNER_IMAGE +""" +from __future__ import annotations + +import hashlib +import json +import re +import sys +from typing import Any + +SCHEMA = "durable-workflow.native-image-scan/v1" +SEVERITIES = ("CRITICAL", "HIGH") +DIGEST_REF = re.compile(r"[a-z0-9./-]+@sha256:[0-9a-f]{64}\Z") + + +def rows(report: dict[str, Any]) -> list[dict[str, Any]]: + found = [] + for result in report.get("Results") or []: + for vulnerability in result.get("Vulnerabilities") or []: + severity = vulnerability.get("Severity") + if severity not in SEVERITIES: + continue + fixed = vulnerability.get("FixedVersion") or None + status = vulnerability.get("Status") or ("fixed" if fixed else "unknown") + found.append({ + "id": vulnerability["VulnerabilityID"], + "severity": severity, + "package": vulnerability["PkgName"], + "installed": vulnerability.get("InstalledVersion"), + "fixed": fixed, + "status": status, + # os-pkgs: distribution packages; lang-pkgs: Cargo and other + # language dependencies. + "class": result.get("Class"), + "actionable": fixed is not None and status == "fixed", + }) + found.sort(key=lambda row: (SEVERITIES.index(row["severity"]), row["id"], row["package"], row["installed"] or "")) + return found + + +def receipt(report: dict[str, Any], version: dict[str, Any], image: str, platform: str, scanner: str) -> dict[str, Any]: + if not DIGEST_REF.match(image) or not DIGEST_REF.match(scanner): + raise SystemExit("image and scanner must be named by digest") + digests = (report.get("Metadata") or {}).get("RepoDigests") or [] + if image not in digests: + raise SystemExit(f"report does not name the scanned digest {image}") + found = rows(report) + database = version.get("VulnerabilityDB") or {} + normalized = json.dumps([{key: row[key] for key in ("id", "package", "installed", "fixed", "status")} for row in found], + separators=(",", ":"), sort_keys=True) + return { + "schema": SCHEMA, + "image": image, + "platform": platform, + "os": (report.get("Metadata") or {}).get("OS"), + "scanned_at": report.get("CreatedAt"), + "scanner": {"name": "trivy", "version": version.get("Version"), "image": scanner}, + "database": {"version": database.get("Version"), "updated_at": database.get("UpdatedAt"), + "downloaded_at": database.get("DownloadedAt")}, + "counts": { + "critical": sum(row["severity"] == "CRITICAL" for row in found), + "high": sum(row["severity"] == "HIGH" for row in found), + "advisories": len({row["id"] for row in found}), + "actionable": sum(row["actionable"] for row in found), + }, + "fingerprint": "sha256:" + hashlib.sha256(normalized.encode()).hexdigest(), + "findings": found, + } + + +def main(argv: list[str]) -> int: + if len(argv) != 6: + print(__doc__, file=sys.stderr) + return 2 + _, report_path, version_path, image, platform, scanner = argv + with open(report_path, encoding="utf-8") as handle: + report = json.load(handle) + with open(version_path, encoding="utf-8") as handle: + version = json.load(handle) + result = receipt(report, version, image, platform, scanner) + json.dump(result, sys.stdout, indent=1) + sys.stdout.write("\n") + counts = result["counts"] + print(f"{platform}: {counts['critical']} critical, {counts['high']} high, {counts['actionable']} with a fixed version", + file=sys.stderr) + for row in result["findings"]: + if row["actionable"]: + print(f"actionable: {row['id']} {row['package']} {row['installed']} -> {row['fixed']} ({row['severity']})", + file=sys.stderr) + return 1 if counts["actionable"] else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/Unit/Support/native_scan_receipt_test.py b/tests/Unit/Support/native_scan_receipt_test.py new file mode 100644 index 000000000..b1ba0a115 --- /dev/null +++ b/tests/Unit/Support/native_scan_receipt_test.py @@ -0,0 +1,67 @@ +"""Server #802: the native image scan receipt and its promotion rule.""" +from __future__ import annotations + +import pathlib +import sys +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "scripts" / "ci")) + +import native_scan_receipt as R # noqa: E402 + +IMAGE = "ghcr.io/durable-workflow/server@sha256:" + "a" * 64 +SCANNER = "docker.io/aquasec/trivy@sha256:" + "b" * 64 +VERSION = {"Version": "0.76.1", "VulnerabilityDB": {"Version": 2, "UpdatedAt": "2026-11-11T19:04:54Z", + "DownloadedAt": "2026-11-12T01:41:40Z"}} + + +def vulnerability(id_: str, package: str, severity: str, status: str, fixed: str | None = None) -> dict: + row = {"VulnerabilityID": id_, "PkgName": package, "InstalledVersion": "1.0", "Severity": severity, "Status": status} + if fixed: + row["FixedVersion"] = fixed + return row + + +def report(*vulnerabilities: dict, lang: tuple[dict, ...] = ()) -> dict: + return {"CreatedAt": "2026-11-12T01:42:00Z", "Metadata": {"RepoDigests": [IMAGE], "OS": {"Family": "debian", "Name": "12.15"}}, + "Results": [{"Target": IMAGE, "Class": "os-pkgs", "Vulnerabilities": list(vulnerabilities)}, + {"Target": "durable-workflow-server", "Class": "lang-pkgs", "Vulnerabilities": list(lang)}]} + + +class ScanReceipt(unittest.TestCase): + def test_unfixed_inventory_is_kept_and_does_not_hold(self) -> None: + result = R.receipt(report(vulnerability("CVE-1", "zlib1g", "CRITICAL", "will_not_fix"), + vulnerability("CVE-2", "libc6", "HIGH", "affected"), + vulnerability("CVE-3", "bash", "MEDIUM", "fixed", "2.0")), VERSION, IMAGE, "linux/amd64", SCANNER) + self.assertEqual(result["counts"], {"critical": 1, "high": 1, "advisories": 2, "actionable": 0}) + self.assertEqual([row["id"] for row in result["findings"]], ["CVE-1", "CVE-2"]) + self.assertEqual((result["scanner"]["version"], result["database"]["updated_at"]), ("0.76.1", "2026-11-11T19:04:54Z")) + self.assertTrue(result["fingerprint"].startswith("sha256:")) + + def test_a_fixed_high_row_holds_and_names_its_class(self) -> None: + result = R.receipt(report(vulnerability("CVE-4", "apt", "HIGH", "fixed", "2.6.2"), + lang=(vulnerability("RUSTSEC-1", "hyper", "CRITICAL", "fixed", "1.1"),)), + VERSION, IMAGE, "linux/arm64", SCANNER) + self.assertEqual(result["counts"]["actionable"], 2) + self.assertEqual({row["package"]: row["class"] for row in result["findings"]}, {"apt": "os-pkgs", "hyper": "lang-pkgs"}) + + def test_the_fingerprint_ignores_scan_time_and_follows_rows(self) -> None: + rows = (vulnerability("CVE-2", "libc6", "HIGH", "affected"),) + first = R.receipt(report(*rows), VERSION, IMAGE, "linux/amd64", SCANNER) + later = report(*rows) + later["CreatedAt"] = "2026-11-19T00:00:00Z" + self.assertEqual(R.receipt(later, VERSION, IMAGE, "linux/amd64", SCANNER)["fingerprint"], first["fingerprint"]) + changed = R.receipt(report(vulnerability("CVE-2", "libc6", "HIGH", "fixed", "2.1")), VERSION, IMAGE, "linux/amd64", SCANNER) + self.assertNotEqual(changed["fingerprint"], first["fingerprint"]) + + def test_refuses_a_report_for_another_or_unpinned_image(self) -> None: + with self.assertRaises(SystemExit): + R.receipt(report(), VERSION, "ghcr.io/durable-workflow/server:latest", "linux/amd64", SCANNER) + other = report() + other["Metadata"]["RepoDigests"] = ["ghcr.io/durable-workflow/server@sha256:" + "c" * 64] + with self.assertRaises(SystemExit): + R.receipt(other, VERSION, IMAGE, "linux/amd64", SCANNER) + + +if __name__ == "__main__": + unittest.main() From c64e1a7272c40578c58080f2f0609ddb8e236c85 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:04:01 +0000 Subject: [PATCH 2/3] Scope the scanner container and keep paths out of the receipt's argv - The scan container is named from RESOURCE_SCOPE (run, attempt, job), and each scan job removes it in an if: always() step, as the Docker isolation policy requires. - The isolation checker's pinned hash of native_alias_evidence.py moves to the reviewed version that requires the scan jobs. - The weekly rescan resolves latest through the registry API instead of a docker command. - native_scan_receipt.py reads the report on stdin and the version JSON as a value, so no path comes from arguments. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image-rescan.yml | 17 +++---- .../workflows/native-promotion-contracts.yml | 1 + .github/workflows/native-release.yml | 12 +++++ scripts/ci/check-docker-workflow-isolation.py | 2 +- scripts/ci/native-image-scan.sh | 13 +++-- scripts/ci/native_scan_receipt.py | 13 +++-- scripts/ci/resolve_latest_platform_digest.py | 47 +++++++++++++++++++ .../Unit/Support/native_scan_receipt_test.py | 7 ++- 8 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 scripts/ci/resolve_latest_platform_digest.py diff --git a/.github/workflows/native-image-rescan.yml b/.github/workflows/native-image-rescan.yml index 0697be00c..90b36de65 100644 --- a/.github/workflows/native-image-rescan.yml +++ b/.github/workflows/native-image-rescan.yml @@ -26,6 +26,8 @@ jobs: name: Rescan latest ${{ matrix.platform }} runs-on: ubuntu-latest timeout-minutes: 20 + env: + RESOURCE_SCOPE: dw-native-rescan-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index }} strategy: fail-fast: false matrix: @@ -39,16 +41,7 @@ jobs: id: resolve env: PLATFORM: ${{ matrix.platform }} - run: | - set -euo pipefail - docker buildx imagetools inspect "$GHCR_IMAGE:latest" --format '{{json .Manifest}}' > index.json - digest=$(python3 -I -c 'import json, sys - os_name, arch = sys.argv[2].split("/") - index = json.load(open(sys.argv[1])) - found = [m["digest"] for m in index["manifests"] if m.get("platform", {}).get("os") == os_name and m["platform"].get("architecture") == arch] - assert len(found) == 1, found - print(found[0])' index.json "$PLATFORM") - echo "image=$GHCR_IMAGE@$digest" >> "$GITHUB_OUTPUT" + run: echo "image=$(python3 -I scripts/ci/resolve_latest_platform_digest.py "$PLATFORM")" >> "$GITHUB_OUTPUT" - name: Scan the latest ${{ matrix.platform }} digest env: @@ -56,6 +49,10 @@ jobs: PLATFORM: ${{ matrix.platform }} run: bash scripts/ci/native-image-scan.sh "$IMAGE" "$PLATFORM" native-scan + - name: Remove this job's scanner container + if: always() + run: docker rm -f "$RESOURCE_SCOPE-trivy" || true + - name: Keep the scan receipt if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml index 5686170ce..df4c370b2 100644 --- a/.github/workflows/native-promotion-contracts.yml +++ b/.github/workflows/native-promotion-contracts.yml @@ -15,6 +15,7 @@ on: - tests/Unit/Support/native_qualification_test.py - scripts/ci/native-image-scan.sh - scripts/ci/native_scan_receipt.py + - scripts/ci/resolve_latest_platform_digest.py - tests/Unit/Support/native_scan_receipt_test.py - .github/workflows/native-*.yml - .github/workflows/published-compose.yml diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index d0e136833..d711e1a58 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -365,6 +365,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + env: + RESOURCE_SCOPE: dw-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -377,6 +379,10 @@ jobs: IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-amd64.outputs.digest }} run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/amd64 native-scan + - name: Remove this job's scanner container + if: always() + run: docker rm -f "$RESOURCE_SCOPE-trivy" || true + - name: Keep the linux/amd64 scan receipt if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -394,6 +400,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + env: + RESOURCE_SCOPE: dw-native-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -406,6 +414,10 @@ jobs: IMAGE: ${{ env.GHCR_IMAGE }}@${{ needs.build-arm64.outputs.digest }} run: bash scripts/ci/native-image-scan.sh "$IMAGE" linux/arm64 native-scan + - name: Remove this job's scanner container + if: always() + run: docker rm -f "$RESOURCE_SCOPE-trivy" || true + - name: Keep the linux/arm64 scan receipt if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 134480981..f9d4b44d6 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -17,7 +17,7 @@ # explicit model update and countercontrol review before gaining coverage. QUALIFICATION_CLEANUP_SOURCES = { "scripts/ci/native_qualification.py": "775655e8d60ecd9a468970cdc28ea40860e0b1549b75343786837f2a35f37eac", - "scripts/ci/native_alias_evidence.py": "7097eb55cf32b698539860b4a0a1bd010e251baf2def9f9e77f145aab96f60db", + "scripts/ci/native_alias_evidence.py": "2d4a05b812a1a914679ddf017b46de2bcb1dedbbf19c09297002f026f63b50d9", "scripts/ci/native_alias_transport.py": "5a925299f3fc5c58fb937e85de579d3bed70942b19b464aca78a77e6d0d63273", } diff --git a/scripts/ci/native-image-scan.sh b/scripts/ci/native-image-scan.sh index 8a36e3cd0..d75db3559 100755 --- a/scripts/ci/native-image-scan.sh +++ b/scripts/ci/native-image-scan.sh @@ -23,6 +23,13 @@ case $platform in *) echo "native-image-scan: unsupported platform $platform" >&2; exit 2 ;; esac +# The scanner container's name derives from RESOURCE_SCOPE, so the job's +# cleanup step can remove it. +scope_identity="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-native-scan}" +if test -z "${RESOURCE_SCOPE:-}"; then + RESOURCE_SCOPE="dw-native-$scope_identity-$(date -u +%Y%m%d%H%M%S)-$$" +fi + mkdir -p "$out" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT @@ -30,7 +37,7 @@ mkdir -p "$work/cache" "$work/report" name=scan-${platform#linux/} trivy() { - docker run --rm --name "dw-native-scan-$name-$$" -u "$(id -u):$(id -g)" \ + docker run --rm --name "$RESOURCE_SCOPE-trivy" -u "$(id -u):$(id -g)" \ -v "$work/cache:/cache" -v "$work/report:/report" "$scanner" "$@" } @@ -39,5 +46,5 @@ trivy image --cache-dir /cache --image-src remote --platform "$platform" \ --scanners vuln --severity HIGH,CRITICAL --format json --output /report/trivy.json --quiet "$image" trivy version --cache-dir /cache --format json > "$work/report/version.json" -python3 -I "$(dirname "$0")/native_scan_receipt.py" "$work/report/trivy.json" "$work/report/version.json" \ - "$image" "$platform" "$scanner" > "$out/$name.json" +python3 -I "$(dirname "$0")/native_scan_receipt.py" "$(cat "$work/report/version.json")" \ + "$image" "$platform" "$scanner" < "$work/report/trivy.json" > "$out/$name.json" diff --git a/scripts/ci/native_scan_receipt.py b/scripts/ci/native_scan_receipt.py index 212d3be24..3860a9433 100644 --- a/scripts/ci/native_scan_receipt.py +++ b/scripts/ci/native_scan_receipt.py @@ -11,7 +11,8 @@ distribution rows are inventory for Security's reachability disposition on server#802, not a failure. -Usage: native_scan_receipt.py REPORT VERSION IMAGE PLATFORM SCANNER_IMAGE +Usage: native_scan_receipt.py VERSION_JSON IMAGE PLATFORM SCANNER_IMAGE < REPORT +(the Trivy report on standard input, `trivy version --format json` as text) """ from __future__ import annotations @@ -82,14 +83,12 @@ def receipt(report: dict[str, Any], version: dict[str, Any], image: str, platfor def main(argv: list[str]) -> int: - if len(argv) != 6: + if len(argv) != 5: print(__doc__, file=sys.stderr) return 2 - _, report_path, version_path, image, platform, scanner = argv - with open(report_path, encoding="utf-8") as handle: - report = json.load(handle) - with open(version_path, encoding="utf-8") as handle: - version = json.load(handle) + _, version_text, image, platform, scanner = argv + report = json.load(sys.stdin) + version = json.loads(version_text) result = receipt(report, version, image, platform, scanner) json.dump(result, sys.stdout, indent=1) sys.stdout.write("\n") diff --git a/scripts/ci/resolve_latest_platform_digest.py b/scripts/ci/resolve_latest_platform_digest.py new file mode 100644 index 000000000..1d5331353 --- /dev/null +++ b/scripts/ci/resolve_latest_platform_digest.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Resolve ghcr.io/durable-workflow/server:latest to one platform's digest. + +Reads the public registry over HTTPS with an anonymous pull token; nothing is +pulled or run. Prints `ghcr.io/durable-workflow/server@sha256:...`. + +Usage: resolve_latest_platform_digest.py linux/amd64|linux/arm64 +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.request + +REPOSITORY = "durable-workflow/server" +INDEX_TYPES = ("application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json") + + +def get(url: str, headers: dict[str, str]) -> bytes: + if not url.startswith("https://ghcr.io/"): + raise SystemExit(f"refusing {url}") + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as response: + return response.read(1 << 20) + + +def main(argv: list[str]) -> int: + if len(argv) != 2 or argv[1] not in ("linux/amd64", "linux/arm64"): + print(__doc__, file=sys.stderr) + return 2 + os_name, arch = argv[1].split("/") + token = json.loads(get(f"https://ghcr.io/token?scope=repository:{REPOSITORY}:pull", {}))["token"] + index = json.loads(get(f"https://ghcr.io/v2/{REPOSITORY}/manifests/latest", + {"Authorization": f"Bearer {token}", "Accept": ", ".join(INDEX_TYPES)})) + if index.get("mediaType") not in INDEX_TYPES: + raise SystemExit("latest is not a multi-architecture index") + found = [entry["digest"] for entry in index.get("manifests", []) + if entry.get("platform", {}).get("os") == os_name and entry["platform"].get("architecture") == arch] + if len(found) != 1 or not re.fullmatch(r"sha256:[0-9a-f]{64}", found[0]): + raise SystemExit(f"expected one {argv[1]} manifest, found {found}") + print(f"ghcr.io/{REPOSITORY}@{found[0]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/Unit/Support/native_scan_receipt_test.py b/tests/Unit/Support/native_scan_receipt_test.py index b1ba0a115..b2eef1962 100644 --- a/tests/Unit/Support/native_scan_receipt_test.py +++ b/tests/Unit/Support/native_scan_receipt_test.py @@ -54,9 +54,12 @@ def test_the_fingerprint_ignores_scan_time_and_follows_rows(self) -> None: changed = R.receipt(report(vulnerability("CVE-2", "libc6", "HIGH", "fixed", "2.1")), VERSION, IMAGE, "linux/amd64", SCANNER) self.assertNotEqual(changed["fingerprint"], first["fingerprint"]) - def test_refuses_a_report_for_another_or_unpinned_image(self) -> None: + def test_refuses_an_unpinned_image(self) -> None: + unpinned = report() with self.assertRaises(SystemExit): - R.receipt(report(), VERSION, "ghcr.io/durable-workflow/server:latest", "linux/amd64", SCANNER) + R.receipt(unpinned, VERSION, "ghcr.io/durable-workflow/server:latest", "linux/amd64", SCANNER) + + def test_refuses_a_report_for_another_image(self) -> None: other = report() other["Metadata"]["RepoDigests"] = ["ghcr.io/durable-workflow/server@sha256:" + "c" * 64] with self.assertRaises(SystemExit): From 7e6929f283bc4b86fd93db2523e23d3028903c01 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:05:25 +0000 Subject: [PATCH 3/3] Refuse registry redirects when resolving latest Co-Authored-By: Claude Opus 5.5 --- scripts/ci/resolve_latest_platform_digest.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/ci/resolve_latest_platform_digest.py b/scripts/ci/resolve_latest_platform_digest.py index 1d5331353..459210128 100644 --- a/scripts/ci/resolve_latest_platform_digest.py +++ b/scripts/ci/resolve_latest_platform_digest.py @@ -18,10 +18,20 @@ "application/vnd.docker.distribution.manifest.list.v2+json") +class NoRedirect(urllib.request.HTTPRedirectHandler): + """A redirect could leave ghcr.io; refuse it instead of following.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + raise SystemExit(f"refusing redirect to {newurl}") + + +OPENER = urllib.request.build_opener(NoRedirect) + + def get(url: str, headers: dict[str, str]) -> bytes: if not url.startswith("https://ghcr.io/"): raise SystemExit(f"refusing {url}") - with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as response: + with OPENER.open(urllib.request.Request(url, headers=headers), timeout=30) as response: return response.read(1 << 20)