From add76776b9b262165a05adf343410158f06c9c1a Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sun, 4 Oct 2026 12:53:17 -0500 Subject: [PATCH 1/2] release: run assert-image.sh on each image before attesting --- .github/workflows/pages-deployment.yml | 121 +++++++++++++++++++++++++ .github/workflows/release.yml | 11 ++- 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pages-deployment.yml diff --git a/.github/workflows/pages-deployment.yml b/.github/workflows/pages-deployment.yml new file mode 100644 index 0000000..f9d4022 --- /dev/null +++ b/.github/workflows/pages-deployment.yml @@ -0,0 +1,121 @@ +name: Universal Site Adapter + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read # Secure: No push access required! + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.x" + + - name: Build site payload (_site/) + env: + REPO_NAME: ${{ github.repository }} + run: | + # Pinned by wheel hash for supply-chain integrity. + echo "markdown==3.7 --hash=sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803" > /tmp/reqs.txt + pip install --require-hashes --only-binary=:all: --no-deps -r /tmp/reqs.txt + + python3 - << 'EOF' + import os, shutil, subprocess, markdown + + os.makedirs("_site", exist_ok=True) + repo_name = os.environ.get("REPO_NAME", "Site").split("/")[-1] + tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines() + + # Strict web-asset allowlist (Now updated for Python Indexes and APIs) + WEB_FILES = {"index.html", "favicon.ico", "robots.txt", "sitemap.xml", "CNAME", "404.html"} + WEB_DIRS = {"assets", "css", "js", "images", "img", "fonts", "static", "public", "docs", "media", "simple"} + WEB_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".avif", ".css", ".pdf", ".json", ".whl", ".tar.gz", ".tar.bz2"} + + def is_safe_asset(filepath): + parts = filepath.split('/') + ext = os.path.splitext(filepath)[1].lower() + return (len(parts) == 1 and parts[0] in WEB_FILES) or (parts[0] in WEB_DIRS) or (ext in WEB_EXTS) + + # FIX #1: Sentinel Check Restored to prevent freezing legacy auto-generated repos + has_custom_site = False + if os.path.exists("index.html"): + with open("index.html", "r", encoding="utf-8", errors="ignore") as f: + if "" not in f.read(): + has_custom_site = True + + if has_custom_site: + print("::notice::[CUSTOM SITE] Explicit index.html detected. Packaging strict web assets.") + for f in tracked: + # Never publish CI/CD configs or raw repository metadata + if f.startswith(".github/") or f in {".gitignore", "SECURITY.md", "README.md", "LICENSE"}: + continue + # Publish if it explicitly looks like a web asset or ends in .html + if is_safe_asset(f) or f.endswith(".html"): + dst = os.path.join("_site", f) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(f, dst) + + else: + print("::notice::[README FALLBACK] No custom index.html found. Rendering README.md.") + content = open("README.md", "r", encoding="utf-8").read() if os.path.exists("README.md") else f"# {repo_name}" + body = markdown.markdown(content, extensions=["tables", "fenced_code", "toc"]) + + html = f""" + + {repo_name} + + + {body}""" + + with open("_site/index.html", "w", encoding="utf-8") as out: + out.write(html) + + # FIX #2: Unified safe copy logic ensures root-level images (like ./logo.png) are copied + for f in tracked: + if f.startswith(".github/") or f in {".gitignore", "SECURITY.md", "README.md", "LICENSE", "index.html"}: + continue + if is_safe_asset(f): + dst = os.path.join("_site", f) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(f, dst) + + # Prevent GitHub Pages from stripping folders starting with underscores + open("_site/.nojekyll", "w").close() + EOF + + # FIX #3: Defensive validation restored + - name: Validate build payload + run: | + if [ ! -f "_site/index.html" ]; then + echo "::error::Build failed to generate _site/index.html" + exit 1 + fi + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: _site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d561ad0..486ab7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,9 @@ name: release-image # # What this workflow does, in order: # gate require a successful build-environment-image run on main for exactly this commit -# attest per arch: verify the build provenance, generate SBOMs with pinned Syft, and attest the -# CycloneDX SBOM, the SPDX SBOM and the VEX on the image digest (signer: this workflow) +# attest per arch: verify the build provenance, generate SBOMs with pinned Syft, run +# assert-image.sh against the image and its SBOM (the facts the VEX relies on), and attest +# the CycloneDX SBOM, the SPDX SBOM and the VEX on the image digest (signer: this workflow) # release load the published builder policy, check its sha256 against the value committed # below, run verify_builder_policy.py (one check per enforced rule), and only then # create a DRAFT release. It never publishes: the maintainer reviews and publishes. @@ -148,6 +149,12 @@ jobs: done ls -l out + - name: Assert the image properties the VEX relies on + run: | + set -euo pipefail + echo "$GH_TOKEN" | podman login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + .github/scripts/assert-image.sh "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" | tee -a "$GITHUB_STEP_SUMMARY" + - name: Attest CycloneDX SBOM uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: From 2ed12ba0587ec3deeabfc9ec721e55c307328828 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sun, 4 Oct 2026 14:26:21 -0500 Subject: [PATCH 2/2] policy v2.1: attest image assertion results and build logs --- .github/scripts/policy_tool.py | 4 +- .github/scripts/verify_builder_policy.py | 101 +++++++++++++++++- .github/workflows/build-environment-image.yml | 40 ++++++- .github/workflows/release.yml | 37 +++++-- policy/builder-image/POLICY.md | 74 ++++++++++++- policy/builder-image/policy.json | 61 ++++++++++- tests/test_policy_tool.py | 2 +- tests/test_verify_builder_policy.py | 86 ++++++++++++++- 8 files changed, 382 insertions(+), 23 deletions(-) diff --git a/.github/scripts/policy_tool.py b/.github/scripts/policy_tool.py index c29dcfd..ef98ee1 100644 --- a/.github/scripts/policy_tool.py +++ b/.github/scripts/policy_tool.py @@ -29,7 +29,7 @@ import sys from pathlib import Path -ENFORCED_VERSION_RE = re.compile(r"^v[1-9][0-9]*$") +ENFORCED_VERSION_RE = re.compile(r"^v[1-9][0-9]*(\.[0-9]+)?$") RULE_ID_RE = re.compile(r"^[A-Z]{3,4}-[0-9]+$") CHECK_RE = re.compile(r"^check_[a-z0-9_]+$") SCOPE_RE = re.compile(r"^[a-z][a-z0-9-]*$") @@ -92,7 +92,7 @@ def validate_v2(obj, verifier: Path | None) -> list[str]: errs.append("schema 2 policies must have enforcement_mode 'enforced'") ver = obj["policy_version"] if not (isinstance(ver, str) and ENFORCED_VERSION_RE.match(ver)): - errs.append("policy_version must look like v1, v2, ...") + errs.append("policy_version must look like v1, v2, v2.1, ...") if not _nonempty(obj["summary"]): errs.append("summary must be a non-empty string") if not (isinstance(obj["not_guaranteed"], list) and obj["not_guaranteed"] diff --git a/.github/scripts/verify_builder_policy.py b/.github/scripts/verify_builder_policy.py index 9d452b1..5b0a2bf 100644 --- a/.github/scripts/verify_builder_policy.py +++ b/.github/scripts/verify_builder_policy.py @@ -20,6 +20,8 @@ --tag / --tag-sha the release tag and the commit it must point at """ import argparse +import gzip +import hashlib import json import re import subprocess @@ -35,7 +37,8 @@ class Ctx: def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex, - spdx=None, assets=None, run=None, git_show=None): + spdx=None, assets=None, run=None, git_show=None, + asserts=None, artifacts_dir=None): self.root = Path(root) self.tag = tag self.tag_sha = tag_sha @@ -45,6 +48,8 @@ def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex, self.spdx = spdx or {} # arch -> parsed SPDX SBOM (must agree with CycloneDX) self.vex = vex # parsed OpenVEX self.assets = assets # list[str] | None + self.asserts = asserts or {} # arch -> parsed assert-image..json + self.artifacts_dir = Path(artifacts_dir) if artifacts_dir else None self.run = run or _run self.git_show = git_show or _git_show(self.root, tag_sha, self.run) @@ -101,6 +106,13 @@ def cargo_pairs(sbom): return {(n, v) for t, n, v in purl_set(sbom) if t == "cargo"} +def artifact_path(ctx, arch, name): + """Path of a file in the sbom-/ artifact directory, or None if no directory was given.""" + if ctx.artifacts_dir is None: + return None + return ctx.artifacts_dir / f"sbom-{arch}" / name + + def _per_arch(ctx, getter, label): out, errs = {}, [] for a in ARCHES: @@ -225,6 +237,88 @@ def check_vex_attested(ctx, params): return errs +def check_assert_image_passed(ctx, params): + """assert-image..json (written by release.yml from assert-image.sh) names this image + digest, the tagged commit and the released CycloneDX SBOM, lists exactly the required checks, + and every check is PASS.""" + want = list(params["required_checks"]) + if not want: + return ["policy required_checks is empty"] + errs = [] + for a in ARCHES: + d = _digest_ok(ctx, a, errs) + doc = ctx.asserts.get(a) + if not isinstance(doc, dict): + errs.append(f"{a}: assert-image.{a}.json missing") + continue + if d and doc.get("image") != f"{params['image']}@{d}": + errs.append(f"{a}: assert-image image {doc.get('image')!r} != {params['image']}@{d}") + if doc.get("commit") != ctx.tag_sha: + errs.append(f"{a}: assert-image commit {doc.get('commit')!r} != tag commit {ctx.tag_sha}") + p = artifact_path(ctx, a, f"sbom.{a}.cdx.json") + if p is None or not p.exists(): + errs.append(f"{a}: released CycloneDX SBOM not found, cannot compare sbom_sha256") + elif doc.get("sbom_sha256") != hashlib.sha256(p.read_bytes()).hexdigest(): + errs.append(f"{a}: assert-image sbom_sha256 does not match the released CycloneDX SBOM") + checks = doc.get("checks") + if not (isinstance(checks, list) and all(isinstance(c, dict) for c in checks)): + errs.append(f"{a}: assert-image checks is not a list of objects") + continue + names = [str(c.get("name")) for c in checks] + if sorted(names) != sorted(want): + errs.append(f"{a}: assert-image checks differ from the policy list: " + f"missing {sorted(set(want) - set(names))}, unexpected {sorted(set(names) - set(want))}") + for c in checks: + if c.get("result") != "PASS": + errs.append(f"{a}: assert-image check {c.get('name')!r} result {c.get('result')!r}") + return errs + + +def check_assert_attested(ctx, params): + """assert-image..json is the predicate of a verified attestation on the image digest, + made by the release workflow from the release tag.""" + errs = [] + sref = params["source_ref_template"].format(tag=ctx.tag) + for a in ARCHES: + d = _digest_ok(ctx, a, errs) + if d: + _predicate_equals(ctx, a, d, params, params["predicate_type"], sref, + ctx.asserts.get(a), f"assert-image.{a}.json", errs) + return errs + + +def check_build_log_attested(ctx, params): + """build-log..txt.gz is a non-empty gzip that mentions the image digest, and the file + itself has a verified attestation from the build workflow, from main, at the tagged commit.""" + errs = [] + for a in ARCHES: + d = _digest_ok(ctx, a, errs) + p = artifact_path(ctx, a, f"build-log.{a}.txt.gz") + if p is None or not p.exists(): + errs.append(f"{a}: build-log.{a}.txt.gz missing") + continue + try: + with gzip.open(p, "rt", errors="replace") as fh: + text = fh.read() + except (OSError, EOFError) as e: + errs.append(f"{a}: build log is not a readable gzip: {e}") + continue + if not text.strip(): + errs.append(f"{a}: build log is empty") + elif d and d not in text: + errs.append(f"{a}: build log does not mention image digest {d}") + rc, _ = ctx.run([ + "gh", "attestation", "verify", str(p), + "--repo", params["repo"], + "--signer-workflow", params["signer_workflow"], + "--source-ref", params["source_ref"], + "--source-digest", ctx.tag_sha, + ]) + if rc != 0: + errs.append(f"{a}: gh attestation verify failed for build-log.{a}.txt.gz") + return errs + + def check_gate_script_hash(ctx, params): import hashlib want = hashlib.sha256(ctx.git_show(params["script_path"])).hexdigest() @@ -462,10 +556,13 @@ def f(arch, name): cdx = {x: jload(f(x, f"sbom.{x}.cdx.json")) for x in ARCHES} spdx = {x: jload(f(x, f"sbom.{x}.spdx.json")) for x in ARCHES} assets = a.assets_file.read_text().split() if a.assets_file else None + asserts = {x: jload(f(x, f"assert-image.{x}.json")) for x in ARCHES} return Ctx(root=a.root, tag=a.tag, tag_sha=a.tag_sha, inputs=inputs, digests=digests, sboms={k: v for k, v in cdx.items() if v is not None}, spdx={k: v for k, v in spdx.items() if v is not None}, - vex=jload(a.vex), assets=assets) + vex=jload(a.vex), assets=assets, + asserts={k: v for k, v in asserts.items() if v is not None}, + artifacts_dir=a.artifacts_dir) def main(): diff --git a/.github/workflows/build-environment-image.yml b/.github/workflows/build-environment-image.yml index c31a092..7ccc992 100644 --- a/.github/workflows/build-environment-image.yml +++ b/.github/workflows/build-environment-image.yml @@ -56,11 +56,13 @@ jobs: - name: Build (local, not pushed yet) env: TAG: ${{ steps.tag.outputs.tag }} + ARCH: ${{ matrix.arch }} run: | - docker buildx build --load \ + set -euo pipefail + docker buildx build --load --progress=plain \ -f "$CONTEXT/Containerfile" \ -t "$IMAGE:$TAG" \ - "$CONTEXT" + "$CONTEXT" 2>&1 | tee "build.$ARCH.log" - name: Check apk package list against committed lock (exact match) env: @@ -84,6 +86,7 @@ jobs: echo "::error::Installed apk packages differ from $LOCK. If the change is intended, commit the new list." exit 1 fi + echo "apk package list matches $LOCK" | tee -a "build.$TAG.log" - name: Upload apk list (for first-time lock or review) if: always() @@ -98,10 +101,12 @@ jobs: env: REF: ${{ steps.tag.outputs.tag }} run: | - docker push "$IMAGE:$REF" + set -euo pipefail + docker push "$IMAGE:$REF" 2>&1 | tee -a "build.${{ matrix.arch }}.log" DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$REF" | cut -d@ -f2)" case "$DIGEST" in sha256:????????????????????????????????????????????????????????????????) ;; *) echo "::error::bad digest: $DIGEST"; exit 1;; esac echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "pushed $IMAGE@$DIGEST" | tee -a "build.${{ matrix.arch }}.log" echo "$DIGEST" > "image.digest.${{ matrix.arch }}" echo "### ${{ matrix.arch }} builder digest" >> "$GITHUB_STEP_SUMMARY" echo "\`$IMAGE@$DIGEST\`" >> "$GITHUB_STEP_SUMMARY" @@ -200,10 +205,35 @@ jobs: | jq -e --slurpfile want "builder-inputs.$ARCH.json" \ 'any(.[]; .verificationResult.statement.predicate == $want[0])' > /dev/null - - name: Upload digest and inputs + - name: Package build log + env: + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + [ -s "build.$ARCH.log" ] || { echo "::error::build log is empty"; exit 1; } + gzip -9 -n -c "build.$ARCH.log" > "build-log.$ARCH.txt.gz" + ls -l "build-log.$ARCH.txt.gz" + + - name: Attest build log + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: build-log.${{ matrix.arch }}.txt.gz + + - name: Verify the build-log attestation we just made + env: + GH_TOKEN: ${{ github.token }} + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + gh attestation verify "build-log.$ARCH.txt.gz" \ + --repo "${{ github.repository }}" \ + --signer-workflow "${{ github.repository }}/.github/workflows/build-environment-image.yml" + + - name: Upload digest, inputs and build log uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: image-digest-${{ matrix.arch }} path: | image.digest.${{ matrix.arch }} - builder-inputs.${{ matrix.arch }}.json \ No newline at end of file + builder-inputs.${{ matrix.arch }}.json + build-log.${{ matrix.arch }}.txt.gz \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 486ab7d..249f674 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ name: release-image # gate require a successful build-environment-image run on main for exactly this commit # attest per arch: verify the build provenance, generate SBOMs with pinned Syft, run # assert-image.sh against the image and its SBOM (the facts the VEX relies on), and attest -# the CycloneDX SBOM, the SPDX SBOM and the VEX on the image digest (signer: this workflow) +# the CycloneDX SBOM, the SPDX SBOM, the VEX and the assertion results on the image digest +# (signer: this workflow). The build log from the build run travels with them. # release load the published builder policy, check its sha256 against the value committed # below, run verify_builder_policy.py (one check per enforced rule), and only then # create a DRAFT release. It never publishes: the maintainer reviews and publishes. @@ -30,10 +31,11 @@ env: SYFT_VERSION: "1.54.0" VEX_PATH: environments/alpine-3.24-musl/vex/builder.openvex.json VEX_PREDICATE_TYPE: https://openvex.dev/ns/v0.2.0 - POLICY_TAG: policy-builder-image-v2 + ASSERT_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1 + POLICY_TAG: policy-builder-image-v2.1 # sha256 of policy/builder-image/policy.json. Update this whenever the policy changes # (tests/test_verify_builder_policy.py fails if it drifts). - POLICY_SHA256: cef4bd0d2d8196e6214f25d2f82cfc6f2a78339ceb267c5674fdabcdba2ee25e + POLICY_SHA256: a9d65504d7ebe4bc83269b6d4880877f04aa24e8810d670b580f21f7b4a8e29b jobs: gate: @@ -142,7 +144,7 @@ jobs: "$RUNNER_TEMP/syft/syft" "registry:$IMAGE@$D" \ -o "spdx-json=out/sbom.$ARCH.spdx.json" \ -o "cyclonedx-json=out/sbom.$ARCH.cdx.json" - cp "digests/image.digest.$ARCH" "digests/builder-inputs.$ARCH.json" out/ + cp "digests/image.digest.$ARCH" "digests/builder-inputs.$ARCH.json" "digests/build-log.$ARCH.txt.gz" out/ # actions/attest rejects SBOMs and predicates over 16 MiB; fail here with a clear message. for f in "out/sbom.$ARCH.spdx.json" "out/sbom.$ARCH.cdx.json" "$VEX_PATH"; do [ "$(stat -c %s "$f")" -lt 16777216 ] || { echo "::error::$f is 16 MiB or larger; cannot be attested"; exit 1; } @@ -153,7 +155,21 @@ jobs: run: | set -euo pipefail echo "$GH_TOKEN" | podman login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - .github/scripts/assert-image.sh "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" | tee -a "$GITHUB_STEP_SUMMARY" + .github/scripts/assert-image.sh "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" | tee assert.out | tee -a "$GITHUB_STEP_SUMMARY" + python3 - "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" "$GITHUB_SHA" "out/assert-image.$ARCH.json" <<'PY' + import hashlib, json, re, sys + image, sbom, commit, dest = sys.argv[1:5] + checks = [] + for line in open("assert.out", encoding="utf-8"): + m = re.match(r"^(PASS|FAIL) (.*\S)\s*$", line) + if m: + checks.append({"name": m.group(2), "result": m.group(1)}) + doc = {"image": image, "commit": commit, "checks": checks, + "sbom_sha256": hashlib.sha256(open(sbom, "rb").read()).hexdigest()} + with open(dest, "w") as fh: + json.dump(doc, fh, indent=2, sort_keys=True) + PY + cat "out/assert-image.$ARCH.json" - name: Attest CycloneDX SBOM uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 @@ -180,6 +196,15 @@ jobs: predicate-path: ${{ env.VEX_PATH }} push-to-registry: true + - name: Attest image assertion results + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ env.D }} + predicate-type: ${{ env.ASSERT_PREDICATE_TYPE }} + predicate-path: out/assert-image.${{ matrix.arch }}.json + push-to-registry: true + - name: Upload SBOMs, digest and inputs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -232,7 +257,7 @@ jobs: set -euo pipefail mkdir -p release-assets for a in x86_64 aarch64; do - for f in "builder-inputs.$a.json" "image.digest.$a" "sbom.$a.cdx.json" "sbom.$a.spdx.json"; do + for f in "builder-inputs.$a.json" "image.digest.$a" "sbom.$a.cdx.json" "sbom.$a.spdx.json" "assert-image.$a.json" "build-log.$a.txt.gz"; do cp "artifacts/sbom-$a/$f" release-assets/ done done diff --git a/policy/builder-image/POLICY.md b/policy/builder-image/POLICY.md index 07d3252..710ad8a 100644 --- a/policy/builder-image/POLICY.md +++ b/policy/builder-image/POLICY.md @@ -1,4 +1,4 @@ -# Policy builder-image v2 +# Policy builder-image v2.1 @@ -195,6 +195,10 @@ Parameters: ```json { "expected_assets": [ + "assert-image.aarch64.json", + "assert-image.x86_64.json", + "build-log.aarch64.txt.gz", + "build-log.x86_64.txt.gz", "builder-inputs.aarch64.json", "builder-inputs.x86_64.json", "builder.openvex.json", @@ -317,6 +321,72 @@ Toolchain archives are trusted through rustup's own verification. They are not i Crate tarballs are trusted through the registry's checksums and the locked dependency graph. They are not independently pinned by hash. +### IMG-15 - Image assertions ran and passed + +- Status: `enforced` +- Check: `check_assert_image_passed` +- Implemented by: release.yml runs .github/scripts/assert-image.sh on each image digest before attesting and records the results in assert-image..json; verify_builder_policy.py + +For both architectures, assert-image..json names this image digest, the tagged commit and the sha256 of the released CycloneDX SBOM, lists exactly the required checks, and every check is PASS. The checks are the ones assert-image.sh runs against the image and its SBOM, including the facts the VEX statements rely on. + +Parameters: + +```json +{ + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "required_checks": [ + "control: sccache binary exists in image", + "no sccache-dist binary", + "control: SBOM has >=100 cargo components", + "no network-stack crates in SBOM", + "pinned tar@0.4.40 present", + "pinned bytes@1.10.1 present", + "pinned rand@0.8.5 present", + "SCCACHE_MAX_FRAME_LENGTH set in image", + "server seen on loopback; no other listeners" + ] +} +``` + +### IMG-16 - Image assertion results are attested + +- Status: `enforced` +- Check: `check_assert_attested` +- Implemented by: release.yml (actions/attest, custom predicate on the image digest); verify_builder_policy.py + +For both architectures, the image digest has a GitHub attestation of the assert-image predicate type, produced by the named workflow from the release tag at exactly the commit the tag points to, and its predicate is identical to the released assert-image..json. This shows which workflow and commit produced the results, not that the checks are sufficient. + +Parameters: + +```json +{ + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "predicate_type": "https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml", + "source_ref_template": "refs/tags/{tag}" +} +``` + +### IMG-17 - Build log is attested + +- Status: `enforced` +- Check: `check_build_log_attested` +- Implemented by: build-environment-image.yml (build log, actions/attest with the log file as subject); verify_builder_policy.py + +For both architectures, the release carries build-log..txt.gz, a non-empty gzip that mentions the image digest, and the file itself has a GitHub attestation from the named workflow, from refs/heads/main, at exactly the commit the release tag points to. The log is what the build workflow printed; it lets the build be inspected after Actions logs expire. + +Parameters: + +```json +{ + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/build-environment-image.yml", + "source_ref": "refs/heads/main" +} +``` + ## Not guaranteed - The image is not reproducible, and apk packages, rustup archives and crate tarballs have no content-hash pins. @@ -329,3 +399,5 @@ Crate tarballs are trusted through the registry's checksums and the locked depen - The number of cargo components is recorded in the SBOM and deliberately not a rule. - builder-inputs.json is attested (IMG-12) as produced by the build workflow, but it is the workflow's own record of what ran. The unpinned inputs it records (runner, APK packages, rustup archives, crate tarballs) are recorded, not verified. - The SBOMs and the VEX are attested (IMG-13, IMG-14) as produced by release.yml at the release tag. That shows who produced them, not that they are correct: the SBOMs record what Syft found in the image, and the VEX remains the maintainer's assertion. +- The assert-image results show that the listed checks passed on the image and its SBOM at release time. They check those properties only and are not a security review of the image. +- The build log is the build workflow's own output, attested as produced by that workflow. It is evidence for later inspection, not an independent record, and it is not a complete log of the release workflow run. diff --git a/policy/builder-image/policy.json b/policy/builder-image/policy.json index ca070ea..9859864 100644 --- a/policy/builder-image/policy.json +++ b/policy/builder-image/policy.json @@ -10,9 +10,11 @@ "The local gh guard is a convenience for one maintainer and is not enforcement.", "The number of cargo components is recorded in the SBOM and deliberately not a rule.", "builder-inputs.json is attested (IMG-12) as produced by the build workflow, but it is the workflow's own record of what ran. The unpinned inputs it records (runner, APK packages, rustup archives, crate tarballs) are recorded, not verified.", - "The SBOMs and the VEX are attested (IMG-13, IMG-14) as produced by release.yml at the release tag. That shows who produced them, not that they are correct: the SBOMs record what Syft found in the image, and the VEX remains the maintainer's assertion." + "The SBOMs and the VEX are attested (IMG-13, IMG-14) as produced by release.yml at the release tag. That shows who produced them, not that they are correct: the SBOMs record what Syft found in the image, and the VEX remains the maintainer's assertion.", + "The assert-image results show that the listed checks passed on the image and its SBOM at release time. They check those properties only and are not a security review of the image.", + "The build log is the build workflow's own output, attested as produced by that workflow. It is evidence for later inspection, not an independent record, and it is not a complete log of the release workflow run." ], - "policy_version": "v2", + "policy_version": "v2.1", "rules": [ { "check": "check_image_attested", @@ -159,6 +161,10 @@ "implemented_by": "release workflow; verify_builder_policy.py on the draft release", "parameters": { "expected_assets": [ + "assert-image.aarch64.json", + "assert-image.x86_64.json", + "build-log.aarch64.txt.gz", + "build-log.x86_64.txt.gz", "builder-inputs.aarch64.json", "builder-inputs.x86_64.json", "builder.openvex.json", @@ -262,6 +268,57 @@ "statement": "Crate tarballs are trusted through the registry's checksums and the locked dependency graph. They are not independently pinned by hash.", "status": "declared", "title": "Cargo crate tarballs" + }, + { + "check": "check_assert_image_passed", + "id": "IMG-15", + "implemented_by": "release.yml runs .github/scripts/assert-image.sh on each image digest before attesting and records the results in assert-image..json; verify_builder_policy.py", + "parameters": { + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "required_checks": [ + "control: sccache binary exists in image", + "no sccache-dist binary", + "control: SBOM has >=100 cargo components", + "no network-stack crates in SBOM", + "pinned tar@0.4.40 present", + "pinned bytes@1.10.1 present", + "pinned rand@0.8.5 present", + "SCCACHE_MAX_FRAME_LENGTH set in image", + "server seen on loopback; no other listeners" + ] + }, + "statement": "For both architectures, assert-image..json names this image digest, the tagged commit and the sha256 of the released CycloneDX SBOM, lists exactly the required checks, and every check is PASS. The checks are the ones assert-image.sh runs against the image and its SBOM, including the facts the VEX statements rely on.", + "status": "enforced", + "title": "Image assertions ran and passed" + }, + { + "check": "check_assert_attested", + "id": "IMG-16", + "implemented_by": "release.yml (actions/attest, custom predicate on the image digest); verify_builder_policy.py", + "parameters": { + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "predicate_type": "https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml", + "source_ref_template": "refs/tags/{tag}" + }, + "statement": "For both architectures, the image digest has a GitHub attestation of the assert-image predicate type, produced by the named workflow from the release tag at exactly the commit the tag points to, and its predicate is identical to the released assert-image..json. This shows which workflow and commit produced the results, not that the checks are sufficient.", + "status": "enforced", + "title": "Image assertion results are attested" + }, + { + "check": "check_build_log_attested", + "id": "IMG-17", + "implemented_by": "build-environment-image.yml (build log, actions/attest with the log file as subject); verify_builder_policy.py", + "parameters": { + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/build-environment-image.yml", + "source_ref": "refs/heads/main" + }, + "statement": "For both architectures, the release carries build-log..txt.gz, a non-empty gzip that mentions the image digest, and the file itself has a GitHub attestation from the named workflow, from refs/heads/main, at exactly the commit the release tag points to. The log is what the build workflow printed; it lets the build be inspected after Actions logs expire.", + "status": "enforced", + "title": "Build log is attested" } ], "schema": 2, diff --git a/tests/test_policy_tool.py b/tests/test_policy_tool.py index 3065606..e420f8a 100644 --- a/tests/test_policy_tool.py +++ b/tests/test_policy_tool.py @@ -49,4 +49,4 @@ def test_schema1_rejected(): def test_tag_name(): - assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v2" \ No newline at end of file + assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v2.1" \ No newline at end of file diff --git a/tests/test_verify_builder_policy.py b/tests/test_verify_builder_policy.py index b191cbd..1108d64 100644 --- a/tests/test_verify_builder_policy.py +++ b/tests/test_verify_builder_policy.py @@ -1,4 +1,4 @@ -import copy, hashlib, importlib.util, json +import copy, gzip, hashlib, importlib.util, json from pathlib import Path import pytest @@ -75,11 +75,24 @@ def good(tmp_path, run=None): run.by_key[(digs[a], t13["cyclonedx_predicate_type"])] = copy.deepcopy(cdx[a]) run.by_key[(digs[a], t13["spdx_predicate_type_prefix"] + "2.3")] = copy.deepcopy(spdx[a]) run.by_key[(digs[a], t14)] = copy.deepcopy(vex) + # release-run artifacts: sbom-/ holds the SBOM and the build log; the assert results + # (IMG-15) are checked against both and attested (IMG-16) + img, asserts = P("IMG-15")["image"], {} + for a in v.ARCHES: + d = tmp_path / f"sbom-{a}"; d.mkdir() + raw = json.dumps(cdx[a]).encode() + (d / f"sbom.{a}.cdx.json").write_bytes(raw) + with gzip.open(d / f"build-log.{a}.txt.gz", "wt") as fh: + fh.write(f"building\npushed {img}@{digs[a]}\n") + asserts[a] = {"image": f"{img}@{digs[a]}", "commit": SHA, "sbom_sha256": hashlib.sha256(raw).hexdigest(), + "checks": [{"name": n, "result": "PASS"} for n in P("IMG-15")["required_checks"]]} + run.by_key[(digs[a], P("IMG-16")["predicate_type"])] = copy.deepcopy(asserts[a]) return v.Ctx(root=tmp_path, tag="v1", tag_sha=SHA, inputs=ins, digests=digs, sboms=cdx, spdx=spdx, vex=vex, assets=list(P("IMG-10")["expected_assets"]), - run=run, git_show=fake_show) + run=run, git_show=fake_show, + asserts=asserts, artifacts_dir=tmp_path) def test_every_rule_has_a_check_and_a_bad_fixture(): @@ -128,6 +141,27 @@ def b_sb_nospdx(c): del c.spdx["aarch64"] def b_vx_mismatch(c): c.run.by_key[K(c, "x86_64", P("IMG-14")["predicate_type"])]["statements"][0]["status"] = "not_affected" def b_vx_none(c): c.run.by_key.pop(K(c, "aarch64", P("IMG-14")["predicate_type"])) def b_vx_ctx(c): c.vex = None +T16 = lambda: P("IMG-16")["predicate_type"] +def LOG(c, a): return c.artifacts_dir / f"sbom-{a}" / f"build-log.{a}.txt.gz" +def b_as_fail(c): c.asserts["x86_64"]["checks"][0]["result"] = "FAIL" +def b_as_dropped(c): c.asserts["aarch64"]["checks"].pop() +def b_as_extra(c): c.asserts["aarch64"]["checks"].append({"name": "extra", "result": "PASS"}) +def b_as_sbom(c): c.asserts["x86_64"]["sbom_sha256"] = "0" * 64 +def b_as_commit(c): c.asserts["aarch64"]["commit"] = "d" * 40 +def b_as_image(c): c.asserts["x86_64"]["image"] = P("IMG-15")["image"] + "@sha256:" + "9" * 64 +def b_as_none(c): del c.asserts["aarch64"] +def b_as_swapped(c): c.asserts["x86_64"], c.asserts["aarch64"] = c.asserts["aarch64"], c.asserts["x86_64"] +def b_at_mismatch(c): c.run.by_key[K(c, "aarch64", T16())]["checks"][0]["result"] = "FAIL" +def b_at_none(c): c.run.by_key.pop(K(c, "x86_64", T16())) +def b_at_swapped(c): + m, x, a = c.run.by_key, K(c, "x86_64", T16()), K(c, "aarch64", T16()) + m[x], m[a] = m[a], m[x] +def b_lg_missing(c): LOG(c, "x86_64").unlink() +def b_lg_empty(c): + with gzip.open(LOG(c, "x86_64"), "wt") as fh: fh.write("") +def b_lg_nodigest(c): + with gzip.open(LOG(c, "aarch64"), "wt") as fh: fh.write("built, but no digest recorded\n") +def b_lg_notgzip(c): LOG(c, "x86_64").write_bytes(b"not gzip") BAD = {"check_image_attested": [b_attest, b_commit], "check_inputs_attested": [b_attest, b_ia_mismatch, b_ia_none, b_ia_nodigest, b_ia_swapped], @@ -135,7 +169,10 @@ def b_vx_ctx(c): c.vex = None "check_vex_attested": [b_attest, b_ia_nodigest, b_vx_mismatch, b_vx_none, b_vx_ctx], "check_gate_script_hash": [b_gate], "check_pinned_crates": [b_pins], "check_no_network_crates": [b_net], "check_zero_deb": [b_deb], "check_inputs_recorded": [b_inputs], "check_base_image_pinned": [b_base, b_base2, b_base3, b_base4], "check_sbom_and_vex": [b_vex, b_spdx, b_nospdx], - "check_tag_signed": [b_tag, b_tag_unverified, b_tagger], "check_actions_pinned": [b_pin], "check_release_assets": [b_assets]} + "check_tag_signed": [b_tag, b_tag_unverified, b_tagger], "check_actions_pinned": [b_pin], "check_release_assets": [b_assets], + "check_assert_image_passed": [b_as_fail, b_as_dropped, b_as_extra, b_as_sbom, b_as_commit, b_as_image, b_as_none, b_as_swapped, b_ia_nodigest], + "check_assert_attested": [b_attest, b_ia_nodigest, b_as_none, b_at_mismatch, b_at_none, b_at_swapped], + "check_build_log_attested": [b_attest, b_ia_nodigest, b_lg_missing, b_lg_empty, b_lg_nodigest, b_lg_notgzip]} @pytest.mark.parametrize("check,mutator", [(k, m) for k, ms in BAD.items() for m in ms]) @@ -207,4 +244,45 @@ def test_release_workflow_matches_policy(): def test_repo_workflows_are_pinned(): import types - assert v.check_actions_pinned(types.SimpleNamespace(root=ROOT), {}) == [] \ No newline at end of file + assert v.check_actions_pinned(types.SimpleNamespace(root=ROOT), {}) == [] + +def test_assert_attestation_uses_tag_ref_and_release_workflow(tmp_path): + run = FakeRun(); c = good(tmp_path, run) + assert v.check_assert_attested(c, P("IMG-16")) == [] + calls = [x for x in run.calls if "--predicate-type" in x and x[x.index("--predicate-type") + 1] == T16()] + assert len(calls) == 2 + for argv in calls: + assert argv[argv.index("--source-ref") + 1] == "refs/tags/v1" + assert argv[argv.index("--source-digest") + 1] == SHA + assert argv[argv.index("--signer-workflow") + 1].endswith("/.github/workflows/release.yml") + + +def test_build_log_attestation_pins_build_workflow_main_and_tag_sha(tmp_path): + run = FakeRun(); c = good(tmp_path, run) + assert v.check_build_log_attested(c, P("IMG-17")) == [] + calls = [x for x in run.calls if x[:3] == ["gh", "attestation", "verify"] and x[3].endswith(".txt.gz")] + assert len(calls) == 2 + for argv in calls: + assert argv[argv.index("--source-ref") + 1] == "refs/heads/main" + assert argv[argv.index("--source-digest") + 1] == SHA + assert argv[argv.index("--signer-workflow") + 1].endswith("/.github/workflows/build-environment-image.yml") + + +def test_assert_image_script_checks_match_policy(): + import re + text = (ROOT / ".github/scripts/assert-image.sh").read_text() + assert sorted(re.findall(r'^check "([^"]+)"', text, re.M)) == sorted(P("IMG-15")["required_checks"]) + + +def test_release_workflow_runs_assert_image_and_attests_the_results(): + wf = (ROOT / ".github/workflows/release.yml").read_text() + assert ".github/scripts/assert-image.sh" in wf + assert P("IMG-16")["predicate_type"] in wf + assert "predicate-path: out/assert-image.${{ matrix.arch }}.json" in wf + assert wf.index("assert-image.sh") < wf.index("Attest CycloneDX SBOM") + + +def test_build_workflow_attests_the_build_log(): + wf = (ROOT / ".github/workflows/build-environment-image.yml").read_text() + assert "subject-path: build-log.${{ matrix.arch }}.txt.gz" in wf + assert "build-log.${{ matrix.arch }}.txt.gz" in wf.split("Upload digest, inputs and build log")[1] \ No newline at end of file