Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/scripts/verify_builder_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/policy-ci.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions .github/workflows/release-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
10 changes: 5 additions & 5 deletions policy/builder-image/POLICY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Policy builder-image v1
# Policy builder-image v2

<!-- Generated from policy.json by policy_tool.py render-md. Do not edit. -->

Expand Down Expand Up @@ -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"
}
```

Expand Down
10 changes: 5 additions & 5 deletions policy/builder-image/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_policy_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ def test_schema1_rejected():


def test_tag_name():
assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v1"
assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v2"
13 changes: 10 additions & 3 deletions tests/test_verify_builder_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ 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 <patrickryankenneth@gmail.com> 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, ""
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[: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, ""

Expand Down Expand Up @@ -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 <evil@example.com> 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)
Expand All @@ -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])
Expand Down
Loading