From 8b809df160bf80921a193b2959955539ef99cef8 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sat, 3 Oct 2026 21:14:07 -0500 Subject: [PATCH] policy: attest builder-inputs, sboms, and vex (IMG-12, IMG-13, IMG-14) --- .github/scripts/verify_builder_policy.py | 89 ++++++ .github/workflows/build-environment-image.yml | 26 +- .github/workflows/release.yml | 262 ++++++++++++++++++ policy/builder-image/POLICY.md | 67 ++++- policy/builder-image/policy.json | 52 +++- tests/test_verify_builder_policy.py | 91 +++++- 6 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/verify_builder_policy.py b/.github/scripts/verify_builder_policy.py index b15c1f9..118cfb9 100644 --- a/.github/scripts/verify_builder_policy.py +++ b/.github/scripts/verify_builder_policy.py @@ -136,6 +136,95 @@ def check_image_attested(ctx, params): return errs +def _digest_ok(ctx, a, errs): + d = ctx.digests.get(a) + if isinstance(d, str) and DIGEST_RE.match(d): + return d + errs.append(f"{a}: image digest missing or malformed: {d!r}") + return None + + +def _attested_predicates(ctx, a, d, params, ptype, source_ref, errs): + """gh attestation verify the image digest for one predicate type; return the list of + verified predicates (empty list if none), or None after recording an error.""" + rc, out = ctx.run([ + "gh", "attestation", "verify", f"oci://{params['image']}@{d}", + "--repo", params["repo"], + "--signer-workflow", params["signer_workflow"], + "--source-ref", source_ref, + "--source-digest", ctx.tag_sha, + "--predicate-type", ptype, + "--format", "json", + ]) + if rc != 0: + errs.append(f"{a}: gh attestation verify failed for {ptype} on {d}") + return None + try: + results = json.loads(out) + except ValueError: + errs.append(f"{a}: gh attestation verify output is not JSON ({ptype})") + return None + preds = [] + for r in results if isinstance(results, list) else []: + stmt = ((r or {}).get("verificationResult") or {}).get("statement") or {} + preds.append(stmt.get("predicate")) + if not preds: + errs.append(f"{a}: no {ptype} attestation returned for {d}") + return None + return preds + + +def _predicate_equals(ctx, a, d, params, ptype, source_ref, doc, label, errs): + if not isinstance(doc, dict): + errs.append(f"{a}: released {label} missing") + return + preds = _attested_predicates(ctx, a, d, params, ptype, source_ref, errs) + if preds is not None and doc not in preds: + errs.append(f"{a}: released {label} differs from the attested predicate on {d}") + + +def check_inputs_attested(ctx, params): + """builder-inputs..json is the predicate of a verified attestation on that arch's + image digest (named workflow, main, tagged commit, named predicate type).""" + errs = [] + for a in ARCHES: + d = _digest_ok(ctx, a, errs) + if d: + _predicate_equals(ctx, a, d, params, params["predicate_type"], params["source_ref"], + ctx.inputs.get(a), "builder-inputs.json", errs) + return errs + + +def check_sbom_attested(ctx, params): + """The released CycloneDX and SPDX SBOMs are the predicates of verified attestations 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 not d: + continue + cdx, spdx = ctx.sboms.get(a), ctx.spdx.get(a) + _predicate_equals(ctx, a, d, params, params["cyclonedx_predicate_type"], sref, cdx, "CycloneDX SBOM", errs) + ver = str((spdx or {}).get("spdxVersion", "")).partition("-")[2] + if not ver: + errs.append(f"{a}: SPDX SBOM missing or has no spdxVersion") + continue + _predicate_equals(ctx, a, d, params, params["spdx_predicate_type_prefix"] + ver, sref, spdx, "SPDX SBOM", errs) + return errs + + +def check_vex_attested(ctx, params): + """The released OpenVEX file is the predicate of a verified attestation on each image digest.""" + 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.vex, "builder.openvex.json", errs) + return errs + + def check_gate_script_hash(ctx, params): import hashlib want = hashlib.sha256(ctx.git_show(params["script_path"])).hexdigest() diff --git a/.github/workflows/build-environment-image.yml b/.github/workflows/build-environment-image.yml index 07c2347..c31a092 100644 --- a/.github/workflows/build-environment-image.yml +++ b/.github/workflows/build-environment-image.yml @@ -14,6 +14,7 @@ on: env: IMAGE: ghcr.io/${{ github.repository_owner }}/python-wheels-builder-alpine CONTEXT: environments/alpine-3.24-musl + INPUTS_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/builder-inputs/v1 # No default permissions; each job asks for exactly what it needs. permissions: {} @@ -106,7 +107,7 @@ jobs: echo "\`$IMAGE@$DIGEST\`" >> "$GITHUB_STEP_SUMMARY" - name: Attest build provenance - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-name: ghcr.io/${{ github.repository_owner }}/python-wheels-builder-alpine subject-digest: ${{ steps.push.outputs.digest }} @@ -176,6 +177,29 @@ jobs: builder_image:$builder_image, source_commit:$commit}' > "builder-inputs.$ARCH.json" cat "builder-inputs.$ARCH.json" + - name: Attest builder inputs + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/python-wheels-builder-alpine + subject-digest: ${{ steps.push.outputs.digest }} + predicate-type: ${{ env.INPUTS_PREDICATE_TYPE }} + predicate-path: builder-inputs.${{ matrix.arch }}.json + push-to-registry: true + + - name: Verify the builder-inputs attestation we just made + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.push.outputs.digest }} + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + gh attestation verify "oci://$IMAGE@$DIGEST" \ + --repo "${{ github.repository }}" \ + --signer-workflow "${{ github.repository }}/.github/workflows/build-environment-image.yml" \ + --predicate-type "$INPUTS_PREDICATE_TYPE" --format json \ + | jq -e --slurpfile want "builder-inputs.$ARCH.json" \ + 'any(.[]; .verificationResult.statement.predicate == $want[0])' > /dev/null + - name: Upload digest and inputs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8cdde62 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,262 @@ +name: release-image + +# Builds a DRAFT release for a builder image from a gitsign-signed tag `v*`. +# +# 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) +# 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. +# +# The tag must point at the commit that built the image. The build workflow is path-filtered, +# so a later commit that touched none of its paths has no build run and the gate refuses it. +# +# Dry run: `gh workflow run release.yml --ref ` (workflow_dispatch on a tag ref). It does +# everything above, using the policy.json in the tagged commit instead of the published release, +# and creates no release. The sha256 check still applies. + +on: + push: + tags: ["v*"] + workflow_dispatch: {} + +permissions: {} + +env: + IMAGE: ghcr.io/patrickryankenneth/python-wheels-builder-alpine + 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-v1 + # 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: 0a54fd52107cf24d9b100c6e0eb98e65fd304b55c4d1c09558ebcaaeaabba7ac + GITSIGN_REF: dd51a25b4f9fa103ca6f5cdca4183fa15cb3e627 # v0.14.0 + +jobs: + gate: + if: github.ref_type == 'tag' + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + outputs: + run_id: ${{ steps.build.outputs.run_id }} + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Find the successful build run on main for exactly this commit + id: build + run: | + set -euo pipefail + id="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build-environment-image.yml \ + --commit "$GITHUB_SHA" --status success --event push \ + --json databaseId,headBranch \ + --jq '[.[] | select(.headBranch == "main")] | .[0].databaseId // empty')" + if [ -z "$id" ]; then + echo "::error::no successful build-environment-image run on main for $GITHUB_SHA. Tag the commit that built the image." + exit 1 + fi + echo "run_id=$id" >> "$GITHUB_OUTPUT" + echo "Build run: $id" >> "$GITHUB_STEP_SUMMARY" + + attest: + needs: gate + name: attest SBOMs and VEX (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + syft_arch: amd64 + syft_sha256: 54a87372498168b2d033e876fd41fa4e8035b872699e525a57046e1f2f09c860 + - arch: aarch64 + runner: ubuntu-24.04-arm + syft_arch: arm64 + syft_sha256: ee6d4566373a05b344bc6b5f1706f14419bf9338ba39ff686e247deefe9b8818 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + actions: read + packages: write + id-token: write + attestations: write + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ needs.gate.outputs.run_id }} + ARCH: ${{ matrix.arch }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Load image digest and builder inputs from the build run + run: | + set -euo pipefail + [[ "$RUN_ID" =~ ^[0-9]+$ ]] || { echo "::error::bad build run id"; exit 1; } + gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" -n "image-digest-$ARCH" -D digests + D="$(tr -d '[:space:]' < "digests/image.digest.$ARCH")" + [[ "$D" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest: $D"; exit 1; } + echo "D=$D" >> "$GITHUB_ENV" + echo "Image digest: $D" + + - name: Verify build provenance (named workflow, main, this commit) + run: | + set -euo pipefail + gh attestation verify "oci://$IMAGE@$D" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/build-environment-image.yml" \ + --source-ref refs/heads/main \ + --source-digest "$GITHUB_SHA" + + - name: Log in to GHCR + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + run: echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + + - name: Install pinned Syft + env: + SYFT_ARCH: ${{ matrix.syft_arch }} + SYFT_SHA256: ${{ matrix.syft_sha256 }} + run: | + set -euo pipefail + f="syft_${SYFT_VERSION}_linux_${SYFT_ARCH}.tar.gz" + curl -fsSL --proto '=https' -o "$f" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/$f" + echo "$SYFT_SHA256 $f" | sha256sum --check --strict - + mkdir -p "$RUNNER_TEMP/syft" + tar -xzf "$f" -C "$RUNNER_TEMP/syft" syft + "$RUNNER_TEMP/syft/syft" version + + - name: Generate SBOMs from the image digest + env: + SYFT_CHECK_FOR_APP_UPDATE: "false" + run: | + set -euo pipefail + mkdir -p out + "$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/ + # 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; } + done + ls -l out + + - name: Attest CycloneDX SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ env.D }} + sbom-path: out/sbom.${{ matrix.arch }}.cdx.json + push-to-registry: true + + - name: Attest SPDX SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ env.D }} + sbom-path: out/sbom.${{ matrix.arch }}.spdx.json + push-to-registry: true + + - name: Attest VEX (OpenVEX predicate) + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ env.D }} + predicate-type: ${{ env.VEX_PREDICATE_TYPE }} + predicate-path: ${{ env.VEX_PATH }} + push-to-registry: true + + - name: Upload SBOMs, digest and inputs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-${{ matrix.arch }} + path: out/ + if-no-files-found: error + + release: + needs: [gate, attest] + runs-on: ubuntu-24.04 + permissions: + contents: write + actions: read + attestations: read + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch tags (keep the annotated tag object) + run: git fetch --force --tags origin + + - name: Load the builder policy and check its hash + run: | + set -euo pipefail + mkdir -p policy-dl + if [ "$GITHUB_EVENT_NAME" = "push" ]; then + gh release download "$POLICY_TAG" --repo "$GITHUB_REPOSITORY" --pattern policy.json --dir policy-dl + gh attestation verify policy-dl/policy.json --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-policy.yml" \ + --source-ref refs/heads/main + else + echo "::notice::dry run: using policy/builder-image/policy.json from the tagged commit" + cp policy/builder-image/policy.json policy-dl/policy.json + fi + echo "$POLICY_SHA256 policy-dl/policy.json" | sha256sum --check --strict - + + - name: Download the attested artifacts from this run + run: | + set -euo pipefail + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" -p 'sbom-*' -D artifacts + ls -R artifacts + + - name: Install gitsign + run: | + set -euo pipefail + go install "github.com/sigstore/gitsign@${GITSIGN_REF}" + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Stage release assets + run: | + 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 + cp "artifacts/sbom-$a/$f" release-assets/ + done + done + cp "$VEX_PATH" release-assets/builder.openvex.json + ls -1 release-assets | sort > assets.txt + cat assets.txt + + - name: Verify against the builder policy + run: | + set -euo pipefail + python3 .github/scripts/verify_builder_policy.py \ + --policy policy-dl/policy.json \ + --tag "$GITHUB_REF_NAME" --tag-sha "$GITHUB_SHA" \ + --artifacts-dir artifacts --vex "$VEX_PATH" \ + --assets-file assets.txt + + - name: Create DRAFT release and verify its asset set (never publishes) + if: github.event_name == 'push' + run: | + set -euo pipefail + gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft --verify-tag \ + --title "Builder image $GITHUB_REF_NAME" \ + --notes "Draft. Verified against policy ${POLICY_TAG} (policy.json sha256 ${POLICY_SHA256}) by release.yml. Verify the image: gh attestation verify oci://${IMAGE}@> --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/build-environment-image.yml, and with --predicate-type for the SBOM and VEX attestations signed by release.yml." + gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" release-assets/* + got="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name] | sort | .[]')" + [ "$got" = "$(cat assets.txt)" ] || { echo "::error::draft asset set differs from the verified set"; exit 1; } + echo "### Draft $GITHUB_REF_NAME created - NOT published" >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/policy/builder-image/POLICY.md b/policy/builder-image/POLICY.md index fc2bf40..b613046 100644 --- a/policy/builder-image/POLICY.md +++ b/policy/builder-image/POLICY.md @@ -142,7 +142,7 @@ Parameters: - Status: `enforced` - Check: `check_sbom_and_vex` -- Implemented by: sbom-image.yml; vex/builder.openvex.json; verify_builder_policy.py +- Implemented by: release.yml (Syft SBOMs); environments/alpine-3.24-musl/vex/builder.openvex.json; verify_builder_policy.py Both architectures have a non-trivial CycloneDX SBOM and an SPDX SBOM that agree on cargo and deb components. The VEX is OpenVEX with valid statuses, and the listed components are marked affected (the maintainer does not claim them unaffected). @@ -224,6 +224,67 @@ Parameters: } ``` +### IMG-12 - Build inputs are attested + +- Status: `enforced` +- Check: `check_inputs_attested` +- Implemented by: build-environment-image.yml (actions/attest, custom predicate on the image digest); verify_builder_policy.py + +For both architectures, the image digest has a second GitHub attestation of the named predicate type, produced by the named workflow from refs/heads/main at exactly the commit the release tag points to, and its predicate is identical to the builder-inputs.json released for that architecture. This binds the recorded inputs to the build that produced the image; it does not make the unpinned inputs trustworthy. + +Parameters: + +```json +{ + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "predicate_type": "https://github.com/patrickryankenneth/python-wheels-builder/predicate/builder-inputs/v1", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/build-environment-image.yml", + "source_ref": "refs/heads/main" +} +``` + +### IMG-13 - SBOMs are attested + +- Status: `enforced` +- Check: `check_sbom_attested` +- Implemented by: release.yml (actions/attest sbom-path on the image digest); verify_builder_policy.py + +For both architectures, the image digest has GitHub attestations for a CycloneDX and an SPDX SBOM, produced by the named workflow from the release tag at exactly the commit the tag points to, and each attested predicate is identical to the SBOM released for that architecture. This shows which workflow produced the SBOMs from the image digest; it does not show that they are complete. + +Parameters: + +```json +{ + "cyclonedx_predicate_type": "https://cyclonedx.org/bom", + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml", + "source_ref_template": "refs/tags/{tag}", + "spdx_predicate_type_prefix": "https://spdx.dev/Document/v" +} +``` + +### IMG-14 - VEX is attested + +- Status: `enforced` +- Check: `check_vex_attested` +- Implemented by: release.yml (actions/attest custom OpenVEX predicate on the image digest); verify_builder_policy.py + +For both architectures, the image digest has a GitHub attestation of the OpenVEX 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 builder.openvex.json. This shows which workflow and commit published the VEX, not that its statements are true. + +Parameters: + +```json +{ + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "predicate_type": "https://openvex.dev/ns/v0.2.0", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml", + "source_ref_template": "refs/tags/{tag}" +} +``` + ### TRU-1 - GitHub-hosted runner - Status: `declared` @@ -266,5 +327,5 @@ Crate tarballs are trusted through the registry's checksums and the locked depen - The sccache build gate covers sccache only, not other tools in the image. - 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 written by the build workflow but is not itself attested or signed; the image attestation binds only the image digest. IMG-6 checks its shape, and IMG-1, IMG-2 and IMG-11 cross-check parts of it against the attestation and git, but its authenticity beyond that rests on the workflow run that produced it. -- The SBOMs and the VEX are not individually attested. They are release assets, and IMG-7 checks that they are well-formed and consistent, not who produced them. +- 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. diff --git a/policy/builder-image/policy.json b/policy/builder-image/policy.json index d3f0a73..efe7b9e 100644 --- a/policy/builder-image/policy.json +++ b/policy/builder-image/policy.json @@ -9,8 +9,8 @@ "The sccache build gate covers sccache only, not other tools in the image.", "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 written by the build workflow but is not itself attested or signed; the image attestation binds only the image digest. IMG-6 checks its shape, and IMG-1, IMG-2 and IMG-11 cross-check parts of it against the attestation and git, but its authenticity beyond that rests on the workflow run that produced it.", - "The SBOMs and the VEX are not individually attested. They are release assets, and IMG-7 checks that they are well-formed and consistent, not who produced them." + "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." ], "policy_version": "v1", "rules": [ @@ -121,7 +121,7 @@ { "check": "check_sbom_and_vex", "id": "IMG-7", - "implemented_by": "sbom-image.yml; vex/builder.openvex.json; verify_builder_policy.py", + "implemented_by": "release.yml (Syft SBOMs); environments/alpine-3.24-musl/vex/builder.openvex.json; verify_builder_policy.py", "parameters": { "min_cargo_components": 1, "vex_must_be_affected": [ @@ -185,6 +185,52 @@ "status": "enforced", "title": "Base image is pinned by digest" }, + { + "check": "check_inputs_attested", + "id": "IMG-12", + "implemented_by": "build-environment-image.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/builder-inputs/v1", + "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 image digest has a second GitHub attestation of the named predicate type, produced by the named workflow from refs/heads/main at exactly the commit the release tag points to, and its predicate is identical to the builder-inputs.json released for that architecture. This binds the recorded inputs to the build that produced the image; it does not make the unpinned inputs trustworthy.", + "status": "enforced", + "title": "Build inputs are attested" + }, + { + "check": "check_sbom_attested", + "id": "IMG-13", + "implemented_by": "release.yml (actions/attest sbom-path on the image digest); verify_builder_policy.py", + "parameters": { + "cyclonedx_predicate_type": "https://cyclonedx.org/bom", + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "repo": "patrickryankenneth/python-wheels-builder", + "signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml", + "source_ref_template": "refs/tags/{tag}", + "spdx_predicate_type_prefix": "https://spdx.dev/Document/v" + }, + "statement": "For both architectures, the image digest has GitHub attestations for a CycloneDX and an SPDX SBOM, produced by the named workflow from the release tag at exactly the commit the tag points to, and each attested predicate is identical to the SBOM released for that architecture. This shows which workflow produced the SBOMs from the image digest; it does not show that they are complete.", + "status": "enforced", + "title": "SBOMs are attested" + }, + { + "check": "check_vex_attested", + "id": "IMG-14", + "implemented_by": "release.yml (actions/attest custom OpenVEX predicate on the image digest); verify_builder_policy.py", + "parameters": { + "image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine", + "predicate_type": "https://openvex.dev/ns/v0.2.0", + "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 OpenVEX 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 builder.openvex.json. This shows which workflow and commit published the VEX, not that its statements are true.", + "status": "enforced", + "title": "VEX is attested" + }, { "evidence": "builder-inputs.json runner object (image_os, image_version, arch, docker_version, buildx_version)", "id": "TRU-1", diff --git a/tests/test_verify_builder_policy.py b/tests/test_verify_builder_policy.py index c374a76..b9b0ee3 100644 --- a/tests/test_verify_builder_policy.py +++ b/tests/test_verify_builder_policy.py @@ -15,10 +15,15 @@ def P(rid): return RULES[rid].get("parameters", {}) class FakeRun: - def __init__(self, fail=()): self.fail, self.calls = fail, [] + """by_key maps (image digest, predicate type) -> the predicate gh would return.""" + def __init__(self, fail=(), by_key=None): self.fail, self.calls, self.by_key = fail, [], by_key or {} def __call__(self, argv): self.calls.append(argv) if any(argv[:len(f)] == list(f) for f in self.fail): return 1, "" + if argv[:2] == ["gh", "attestation"] and "--format" in argv: + dg = next(x for x in argv if x.startswith("oci://")).rpartition("@")[2] + p = self.by_key.get((dg, argv[argv.index("--predicate-type") + 1])) + return 0, json.dumps([] if p is None else [{"verificationResult": {"statement": {"predicate": p}}}]) if argv[:2] == ["git", "cat-file"]: return 0, "tag\n" if argv[:2] == ["git", "rev-parse"]: return 0, SHA + "\n" return 0, "" @@ -53,12 +58,23 @@ def good(tmp_path, run=None): (wf / "a.yml").write_text(f"jobs:\n x:\n steps:\n - uses: actions/checkout@{'b'*40} # v7\n - uses: ./local\n") vex = {"@context": "https://openvex.dev/ns/v0.2.0", "statements": [{"vulnerability": {"name": "X"}, "products": [{"@id": "pkg:cargo/bytes@1.10.1"}], "status": "affected"}]} + ins = {a: inputs(a) for a in v.ARCHES} + digs = {"x86_64": "sha256:" + "c" * 64, "aarch64": "sha256:" + "d" * 64} + cdx = {a: sbom() for a in v.ARCHES} + spdx = {a: {**sbom(), "spdxVersion": "SPDX-2.3"} for a in v.ARCHES} + run = run or FakeRun() + t12, t13, t14 = P("IMG-12")["predicate_type"], P("IMG-13"), P("IMG-14")["predicate_type"] + run.by_key = {} + for a in v.ARCHES: + run.by_key[(digs[a], t12)] = copy.deepcopy(ins[a]) + 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) return v.Ctx(root=tmp_path, tag="v1", tag_sha=SHA, - inputs={a: inputs(a) for a in v.ARCHES}, - digests={a: "sha256:" + "c" * 64 for a in v.ARCHES}, - sboms={a: sbom() for a in v.ARCHES}, spdx={a: sbom() for a in v.ARCHES}, vex=vex, + inputs=ins, digests=digs, + sboms=cdx, spdx=spdx, vex=vex, assets=list(P("IMG-10")["expected_assets"]), - run=run or FakeRun(), git_show=fake_show) + run=run, git_show=fake_show) def test_every_rule_has_a_check_and_a_bad_fixture(): @@ -89,8 +105,27 @@ def b_vex(c): c.vex["statements"][0]["status"] = "not_affected" def b_tag(c): c.run = FakeRun(fail=[("gitsign",)]) def b_pin(c): (c.root / ".github/workflows/a.yml").write_text(" - uses: actions/checkout@v4\n") def b_assets(c): c.assets.pop() - -BAD = {"check_image_attested": [b_attest, b_commit], "check_gate_script_hash": [b_gate], +def K(c, arch, ptype): return (c.digests[arch], ptype) +T12 = lambda: P("IMG-12")["predicate_type"] +def b_ia_mismatch(c): c.run.by_key[K(c, "aarch64", T12())]["rust_version"] = "9.9.9" +def b_ia_none(c): c.run.by_key.pop(K(c, "x86_64", T12())) +def b_ia_nodigest(c): c.digests["x86_64"] = None +def b_ia_swapped(c): + m, x, a = c.run.by_key, K(c, "x86_64", T12()), K(c, "aarch64", T12()) + m[x], m[a] = m[a], m[x] +def b_sb_cdx(c): c.run.by_key[K(c, "aarch64", P("IMG-13")["cyclonedx_predicate_type"])]["components"].append({"purl": "pkg:cargo/extra@1.0.0"}) +def b_sb_spdx_none(c): c.run.by_key.pop(K(c, "x86_64", P("IMG-13")["spdx_predicate_type_prefix"] + "2.3")) +def b_sb_nover(c): del c.spdx["x86_64"]["spdxVersion"] +def b_sb_ver(c): c.spdx["aarch64"]["spdxVersion"] = "SPDX-2.2" +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 + +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], + "check_sbom_attested": [b_attest, b_ia_nodigest, b_sb_cdx, b_sb_spdx_none, b_sb_nover, b_sb_ver, b_sb_nospdx], + "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], "check_actions_pinned": [b_pin], "check_release_assets": [b_assets]} @@ -125,4 +160,44 @@ def test_attest_uses_tag_sha_and_main(tmp_path): v.check_image_attested(c, P("IMG-1")) argv = next(x for x in run.calls if x[:2] == ["gh", "attestation"]) assert argv[argv.index("--source-digest") + 1] == SHA - assert argv[argv.index("--source-ref") + 1] == "refs/heads/main" \ No newline at end of file + assert argv[argv.index("--source-ref") + 1] == "refs/heads/main" + +def test_inputs_attestation_asks_for_the_policy_predicate(tmp_path): + run = FakeRun(); c = good(tmp_path, run) + assert v.check_inputs_attested(c, P("IMG-12")) == [] + argv = next(x for x in run.calls if "--predicate-type" in x) + assert argv[argv.index("--predicate-type") + 1] == P("IMG-12")["predicate_type"] + assert argv[argv.index("--source-digest") + 1] == SHA + assert argv[argv.index("--source-ref") + 1] == "refs/heads/main" + + +def test_build_workflow_attests_the_policy_predicate_type(): + wf = (ROOT / ".github/workflows/build-environment-image.yml").read_text() + assert P("IMG-12")["predicate_type"] in wf + assert "predicate-path: builder-inputs." in wf + + +def test_release_attestations_use_tag_ref_and_release_workflow(tmp_path): + run = FakeRun(); c = good(tmp_path, run) + assert v.check_sbom_attested(c, P("IMG-13")) == [] + assert v.check_vex_attested(c, P("IMG-14")) == [] + calls = [x for x in run.calls if "--predicate-type" in x] + assert len(calls) == 6 # (cdx, spdx, vex) x 2 arches + 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_release_workflow_matches_policy(): + wf = (ROOT / ".github/workflows/release.yml").read_text() + assert hashlib.sha256((ROOT / "policy/builder-image/policy.json").read_bytes()).hexdigest() in wf + assert P("IMG-13")["cyclonedx_predicate_type"] != P("IMG-14")["predicate_type"] + assert P("IMG-14")["predicate_type"] in wf + assert "sbom-path: out/sbom.${{ matrix.arch }}.cdx.json" in wf + assert "sbom-path: out/sbom.${{ matrix.arch }}.spdx.json" in wf + + +def test_repo_workflows_are_pinned(): + import types + assert v.check_actions_pinned(types.SimpleNamespace(root=ROOT), {}) == [] \ No newline at end of file