From fa81cb5dfb6f5cfa9416b0c206f342f5d0b3dd90 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sun, 4 Oct 2026 00:02:55 -0500 Subject: [PATCH] policy v2: IMG-8 for GPG tags; policy-ci; release gating --- .github/scripts/verify_builder_policy.py | 15 ++++++-- .github/workflows/policy-ci.yml | 48 ++++++++++++++++++++++++ .github/workflows/release-policy.yml | 17 +++++++++ .github/workflows/release.yml | 10 +---- README.md | 2 +- policy/builder-image/POLICY.md | 10 ++--- policy/builder-image/policy.json | 10 ++--- tests/test_policy_tool.py | 2 +- tests/test_verify_builder_policy.py | 13 +++++-- 9 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/policy-ci.yml diff --git a/.github/scripts/verify_builder_policy.py b/.github/scripts/verify_builder_policy.py index 118cfb9..9d452b1 100644 --- a/.github/scripts/verify_builder_policy.py +++ b/.github/scripts/verify_builder_policy.py @@ -388,10 +388,17 @@ def check_tag_signed(ctx, params): rc, out = ctx.run(["git", "rev-parse", f"{ref}^{{commit}}"]) if rc != 0 or out.strip() != ctx.tag_sha: return [f"{ctx.tag} points at {out.strip()!r}, expected {ctx.tag_sha}"] - rc, _ = ctx.run(["gitsign", "verify-tag", - "--certificate-identity", params["identity"], - "--certificate-oidc-issuer", params["issuer"], ctx.tag]) - return [] if rc == 0 else [f"gitsign verify-tag failed for {ctx.tag} (identity {params['identity']})"] + rc, body = ctx.run(["git", "cat-file", "tag", ref]) + tagger = [l for l in body.splitlines() if l.startswith("tagger ")] + if rc != 0 or not tagger or f"<{params['identity']}>" not in tagger[0]: + return [f"{ctx.tag} tagger is not {params['identity']}"] + rc, obj = ctx.run(["git", "rev-parse", ref]) + if rc != 0: + return [f"cannot resolve tag object for {ctx.tag}"] + rc, out = ctx.run(["gh", "api", f"repos/{params['repo']}/git/tags/{obj.strip()}", + "--jq", r'"\(.verification.verified) \(.verification.reason)"']) + res = out.strip().splitlines()[-1] if out.strip() else "" + return [] if rc == 0 and res == "true valid" else [f"{ctx.tag} not GitHub-verified: {res!r}"] def check_actions_pinned(ctx, params): diff --git a/.github/workflows/policy-ci.yml b/.github/workflows/policy-ci.yml new file mode 100644 index 0000000..f4384c0 --- /dev/null +++ b/.github/workflows/policy-ci.yml @@ -0,0 +1,48 @@ +name: policy-ci + +on: + pull_request: + push: + branches: [main, dev] + +permissions: + contents: read + +jobs: + policy-ci: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Lint policy and POLICY.md + run: | + set -euo pipefail + P=policy/builder-image/policy.json + M=policy/builder-image/POLICY.md + python3 .github/scripts/policy_tool.py --policy "$P" --md "$M" lint + python3 .github/scripts/policy_tool.py --policy "$P" --md "$M" check-md + + - name: release.yml pins match policy.json + run: | + set -euo pipefail + P=policy/builder-image/policy.json + want_sha="$(grep -oP '^\s*POLICY_SHA256:\s*\K[0-9a-f]{64}' .github/workflows/release.yml)" + have_sha="$(sha256sum "$P" | cut -d' ' -f1)" + want_tag="$(grep -oP '^\s*POLICY_TAG:\s*\K\S+' .github/workflows/release.yml)" + have_tag="$(python3 .github/scripts/policy_tool.py --policy "$P" tag)" + [ "$want_sha" = "$have_sha" ] || { echo "::error::POLICY_SHA256 $want_sha != policy.json $have_sha"; exit 1; } + [ "$want_tag" = "$have_tag" ] || { echo "::error::POLICY_TAG $want_tag != $have_tag"; exit 1; } + + - name: Tests (verifier, policy tool, gate script) + run: | + set -euo pipefail + python3 -m pip install pytest + python3 -m pytest -q diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index f071fcb..9bc7d7c 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -39,6 +39,9 @@ jobs: check: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: read + actions: read env: GH_TOKEN: ${{ github.token }} WANT: ${{ inputs.policy_version }} @@ -69,6 +72,20 @@ jobs: fi echo "MODE=$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" mode)" >> "$GITHUB_ENV" + - name: Run the test suite (must pass before any release is drafted) + run: | + set -euo pipefail + python3 -m pip install pytest + python3 -m pytest -q + + - name: Require green policy-ci on this exact commit + run: | + set -euo pipefail + id="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow policy-ci.yml \ + --commit "$GITHUB_SHA" --status success --event push \ + --json databaseId --jq '.[0].databaseId // empty')" + [ -n "$id" ] || { echo "::error::no successful policy-ci run on main for $GITHUB_SHA"; exit 1; } + - name: Refuse if this policy release already exists run: | if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cdde62..34f9541 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,10 @@ 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-v1 + POLICY_TAG: policy-builder-image-v2 # 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 + POLICY_SHA256: cef4bd0d2d8196e6214f25d2f82cfc6f2a78339ceb267c5674fdabcdba2ee25e GITSIGN_REF: dd51a25b4f9fa103ca6f5cdca4183fa15cb3e627 # v0.14.0 jobs: @@ -221,12 +221,6 @@ jobs: 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 diff --git a/README.md b/README.md index 07d35da..43613e5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A zero-trust, SLSA-aligned reference architecture for compiling deterministic, cryptographically attested Python wheels for hardened and air-gapped environments. [![CI & Policy Verifier](https://github.com/patrickryankenneth/python-wheels-builder/actions/workflows/build-environment-image.yml/badge.svg)](https://github.com/patrickryankenneth/python-wheels-builder/actions) -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![SLSA Level 3](https://img.shields.io/badge/SLSA-Level_3_Principles-green.svg)](https://slsa.dev) --- diff --git a/policy/builder-image/POLICY.md b/policy/builder-image/POLICY.md index b613046..07d3252 100644 --- a/policy/builder-image/POLICY.md +++ b/policy/builder-image/POLICY.md @@ -1,4 +1,4 @@ -# Policy builder-image v1 +# Policy builder-image v2 @@ -157,20 +157,20 @@ Parameters: } ``` -### IMG-8 - Release tag is annotated and signed by the named identity +### IMG-8 - Release tag is annotated, signed, and GitHub-verified - Status: `enforced` - Check: `check_tag_signed` -- Implemented by: gitsign; release workflow; verify_builder_policy.py +- Implemented by: GPG-signed tag; release workflow; verify_builder_policy.py (GitHub verification API) -The release tag is annotated, points at the commit the image was built from, and its gitsign signature verifies against this identity and issuer. +The release tag is annotated, points at the commit the image was built from, names this identity as tagger, and GitHub reports its signature as verified (reason valid). The signing key is not pinned: GitHub verifies against a key registered to the tagger's account. Parameters: ```json { "identity": "patrickryankenneth@gmail.com", - "issuer": "https://github.com/login/oauth" + "repo": "patrickryankenneth/python-wheels-builder" } ``` diff --git a/policy/builder-image/policy.json b/policy/builder-image/policy.json index efe7b9e..ca070ea 100644 --- a/policy/builder-image/policy.json +++ b/policy/builder-image/policy.json @@ -12,7 +12,7 @@ "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", + "policy_version": "v2", "rules": [ { "check": "check_image_attested", @@ -135,14 +135,14 @@ { "check": "check_tag_signed", "id": "IMG-8", - "implemented_by": "gitsign; release workflow; verify_builder_policy.py", + "implemented_by": "GPG-signed tag; release workflow; verify_builder_policy.py (GitHub verification API)", "parameters": { "identity": "patrickryankenneth@gmail.com", - "issuer": "https://github.com/login/oauth" + "repo": "patrickryankenneth/python-wheels-builder" }, - "statement": "The release tag is annotated, points at the commit the image was built from, and its gitsign signature verifies against this identity and issuer.", + "statement": "The release tag is annotated, points at the commit the image was built from, names this identity as tagger, and GitHub reports its signature as verified (reason valid). The signing key is not pinned: GitHub verifies against a key registered to the tagger's account.", "status": "enforced", - "title": "Release tag is annotated and signed by the named identity" + "title": "Release tag is annotated, signed, and GitHub-verified" }, { "check": "check_actions_pinned", diff --git a/tests/test_policy_tool.py b/tests/test_policy_tool.py index e55d0e3..3065606 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-v1" \ No newline at end of file + assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v2" \ No newline at end of file diff --git a/tests/test_verify_builder_policy.py b/tests/test_verify_builder_policy.py index b9b0ee3..b191cbd 100644 --- a/tests/test_verify_builder_policy.py +++ b/tests/test_verify_builder_policy.py @@ -16,7 +16,10 @@ def P(rid): return RULES[rid].get("parameters", {}) class FakeRun: """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 __init__(self, fail=(), by_key=None): + self.fail, self.calls, self.by_key = fail, [], by_key or {} + self.tagger = "tagger Patrick 1 +0000" + self.verified = "true valid" def __call__(self, argv): self.calls.append(argv) if any(argv[:len(f)] == list(f) for f in self.fail): return 1, "" @@ -24,7 +27,9 @@ def __call__(self, 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[:3] == ["git", "cat-file", "tag"]: return 0, "object x\ntype commit\n" + self.tagger + "\n" if argv[:2] == ["git", "cat-file"]: return 0, "tag\n" + if argv[:2] == ["gh", "api"]: return 0, self.verified + "\n" if argv[:2] == ["git", "rev-parse"]: return 0, SHA + "\n" return 0, "" @@ -102,7 +107,9 @@ def b_base2(c): c.inputs["aarch64"]["base_image_index_digest"] = "sha256:" + "1" def b_spdx(c): c.spdx["x86_64"]["components"].append({"purl": "pkg:cargo/extra@1.0.0"}) def b_nospdx(c): del c.spdx["aarch64"] def b_vex(c): c.vex["statements"][0]["status"] = "not_affected" -def b_tag(c): c.run = FakeRun(fail=[("gitsign",)]) +def b_tag(c): c.run = FakeRun(fail=[("gh", "api")]) +def b_tag_unverified(c): c.run.verified = "false unsigned" +def b_tagger(c): c.run.tagger = "tagger X 1 +0000" def b_pin(c): (c.root / ".github/workflows/a.yml").write_text(" - uses: actions/checkout@v4\n") def b_assets(c): c.assets.pop() def K(c, arch, ptype): return (c.digests[arch], ptype) @@ -128,7 +135,7 @@ 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], "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]} @pytest.mark.parametrize("check,mutator", [(k, m) for k, ms in BAD.items() for m in ms])