diff --git a/.github/workflows/native-image-rescan.yml b/.github/workflows/native-image-rescan.yml new file mode 100644 index 000000000..90b36de65 --- /dev/null +++ b/.github/workflows/native-image-rescan.yml @@ -0,0 +1,63 @@ +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 + env: + RESOURCE_SCOPE: dw-native-rescan-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index }} + 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: echo "image=$(python3 -I scripts/ci/resolve_latest_platform_digest.py "$PLATFORM")" >> "$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: 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 + 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..df4c370b2 100644 --- a/.github/workflows/native-promotion-contracts.yml +++ b/.github/workflows/native-promotion-contracts.yml @@ -13,6 +13,10 @@ 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 + - scripts/ci/resolve_latest_platform_digest.py + - tests/Unit/Support/native_scan_receipt_test.py - .github/workflows/native-*.yml - .github/workflows/published-compose.yml permissions: @@ -29,5 +33,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..d711e1a58 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -353,5 +353,79 @@ 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 + 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 + 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: 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 + 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 + 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 + 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: 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 + 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-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/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..d75db3559 --- /dev/null +++ b/scripts/ci/native-image-scan.sh @@ -0,0 +1,50 @@ +#!/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 + +# 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 +mkdir -p "$work/cache" "$work/report" +name=scan-${platform#linux/} + +trivy() { + docker run --rm --name "$RESOURCE_SCOPE-trivy" -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" "$(cat "$work/report/version.json")" \ + "$image" "$platform" "$scanner" < "$work/report/trivy.json" > "$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..3860a9433 --- /dev/null +++ b/scripts/ci/native_scan_receipt.py @@ -0,0 +1,106 @@ +#!/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 VERSION_JSON IMAGE PLATFORM SCANNER_IMAGE < REPORT +(the Trivy report on standard input, `trivy version --format json` as text) +""" +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) != 5: + print(__doc__, file=sys.stderr) + return 2 + _, 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") + 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/scripts/ci/resolve_latest_platform_digest.py b/scripts/ci/resolve_latest_platform_digest.py new file mode 100644 index 000000000..459210128 --- /dev/null +++ b/scripts/ci/resolve_latest_platform_digest.py @@ -0,0 +1,57 @@ +#!/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") + + +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 OPENER.open(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 new file mode 100644 index 000000000..b2eef1962 --- /dev/null +++ b/tests/Unit/Support/native_scan_receipt_test.py @@ -0,0 +1,70 @@ +"""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_an_unpinned_image(self) -> None: + unpinned = report() + with self.assertRaises(SystemExit): + 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): + R.receipt(other, VERSION, IMAGE, "linux/amd64", SCANNER) + + +if __name__ == "__main__": + unittest.main()