From e499b3ca3e36f8bbc524fe3f7372c01ac44530c9 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sat, 3 Oct 2026 19:11:12 -0500 Subject: [PATCH] policy: builder-image v1 policy, verifier, release workflow; bump checkout --- .github/scripts/policy_tool.py | 283 ++++++++++++ .github/scripts/verify_builder_policy.py | 407 ++++++++++++++++++ .github/workflows/build-environment-image.yml | 2 +- .github/workflows/merge-bot.yml | 4 +- .github/workflows/release-policy.yml | 225 ++++++++++ policy/builder-image/POLICY.md | 270 ++++++++++++ policy/builder-image/policy.json | 224 ++++++++++ tests/test_policy_tool.py | 52 +++ tests/test_verify_builder_policy.py | 128 ++++++ 9 files changed, 1592 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/policy_tool.py create mode 100644 .github/scripts/verify_builder_policy.py create mode 100644 .github/workflows/release-policy.yml create mode 100644 policy/builder-image/POLICY.md create mode 100644 policy/builder-image/policy.json create mode 100644 tests/test_policy_tool.py create mode 100644 tests/test_verify_builder_policy.py diff --git a/.github/scripts/policy_tool.py b/.github/scripts/policy_tool.py new file mode 100644 index 0000000..c29dcfd --- /dev/null +++ b/.github/scripts/policy_tool.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +""" +policy_tool.py - lint, render, hash and stage a policy.json. + +policy.json is the source of truth. Its sha256 is taken over the exact file +bytes, so it equals the digest GitHub shows for the release asset. Lint +requires the file to already be in canonical form (indent=2, sorted keys, +trailing newline) so the same policy always hashes the same. + +POLICY.md is generated from policy.json; `check-md` fails on drift. + +Every rule is either + enforced - names a `check_*` function in the verifier script; lint fails if + the function does not exist, and fails if a check_* function + exists that no rule uses. + declared - a trust root: evidence is recorded, nothing is verified. It must + say what evidence in `evidence`, and may not carry a `check`. + +Subcommands: lint | fmt | render-md | check-md | hash | version | mode | + tag | stage +Pure stdlib. Prints ::error:: lines so failures show up in Actions logs. +""" +import argparse +import ast +import hashlib +import json +import re +import shutil +import sys +from pathlib import Path + +ENFORCED_VERSION_RE = re.compile(r"^v[1-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-]*$") +STATUSES_V2 = ("enforced", "declared") +RULE_FIELDS_V2 = {"id", "title", "statement", "status", "implemented_by", + "check", "parameters", "evidence"} +TOP_FIELDS_V2 = {"schema", "scope", "policy_version", "enforcement_mode", + "summary", "rules", "not_guaranteed"} +DEFAULT_VERIFIER = ".github/scripts/verify_builder_policy.py" +V2_COMMENT = "" + + +def die(msg: str) -> None: + print(f"::error::{msg}") + sys.exit(1) + + +def canonical(obj) -> bytes: + return (json.dumps(obj, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def load(path: Path): + if not path.exists(): + die(f"{path} not found") + raw = path.read_bytes() + try: + return raw, json.loads(raw) + except json.JSONDecodeError as e: + die(f"{path} is not valid JSON: {e}") + + +def verifier_checks(path: Path) -> set[str] | None: + """Top-level check_* function names defined in the verifier, or None if absent.""" + if not path.exists(): + return None + tree = ast.parse(path.read_text(), filename=str(path)) + return {n.name for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name.startswith("check_")} + + +def _nonempty(v) -> bool: + return isinstance(v, str) and bool(v.strip()) + + +def validate_v2(obj, verifier: Path | None) -> list[str]: + errs: list[str] = [] + extra, missing = set(obj) - TOP_FIELDS_V2, TOP_FIELDS_V2 - set(obj) + if extra: + errs.append(f"unknown top-level fields: {sorted(extra)}") + if missing: + errs.append(f"missing top-level fields: {sorted(missing)}") + return errs + if not (isinstance(obj["scope"], str) and SCOPE_RE.match(obj["scope"])): + errs.append("scope must be lowercase words joined by '-', e.g. builder-image") + if obj["enforcement_mode"] != "enforced": + 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, ...") + 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"] + and all(_nonempty(s) for s in obj["not_guaranteed"])): + errs.append("not_guaranteed must be a non-empty list of non-empty strings") + rules = obj["rules"] + if not (isinstance(rules, list) and rules): + errs.append("rules must be a non-empty list") + return errs + seen: set[str] = set() + used: set[str] = set() + for i, r in enumerate(rules): + where = f"rules[{i}]" + if not isinstance(r, dict): + errs.append(f"{where} must be an object") + continue + unknown = set(r) - RULE_FIELDS_V2 + if unknown: + errs.append(f"{where} unknown fields: {sorted(unknown)}") + for f in ("id", "title", "statement", "status", "implemented_by"): + if not _nonempty(r.get(f)): + errs.append(f"{where}.{f} must be a non-empty string") + rid = r.get("id", "") + if not RULE_ID_RE.match(rid or ""): + errs.append(f"{where}.id {rid!r} must look like IMG-1") + if rid in seen: + errs.append(f"duplicate rule id {rid}") + seen.add(rid) + status = r.get("status") + if status not in STATUSES_V2: + errs.append(f"{where}.status must be one of {STATUSES_V2}") + if "parameters" in r and not isinstance(r["parameters"], dict): + errs.append(f"{where}.parameters must be an object") + if status == "enforced": + chk = r.get("check") + if not (isinstance(chk, str) and CHECK_RE.match(chk)): + errs.append(f"{where}: enforced rule {rid} needs a `check` like check_something") + else: + used.add(chk) + if "evidence" in r: + errs.append(f"{where}: enforced rule {rid} must not carry `evidence`") + elif status == "declared": + for f in ("check", "parameters"): + if f in r: + errs.append(f"{where}: declared rule {rid} must not carry `{f}` (a declared rule verifies nothing)") + if not _nonempty(r.get("evidence")): + errs.append(f"{where}: declared rule {rid} must say what evidence is recorded") + if not used: + errs.append("a schema 2 policy needs at least one enforced rule") + if verifier is not None: + funcs = verifier_checks(verifier) + if funcs is None: + errs.append(f"verifier {verifier} not found") + else: + for c in sorted(used - funcs): + errs.append(f"rule check {c} is not defined in {verifier}") + for c in sorted(funcs - used): + errs.append(f"{verifier} defines {c} but no enforced rule uses it") + return errs + + +def validate(obj, verifier: Path | None = None) -> list[str]: + if not isinstance(obj, dict): + return ["top level must be an object"] + if obj.get("schema") != 2: + return ["schema must be 2"] + return validate_v2(obj, verifier) + + +def _render_v2(obj) -> str: + out = [ + f"# Policy {obj['scope']} {obj['policy_version']}", + "", + V2_COMMENT, + "", + f"- Scope: `{obj['scope']}`", + f"- Enforcement mode: **{obj['enforcement_mode']}**", + "- Hash: sha256 of the exact `policy.json` bytes, published as `policy.json.sha256` and as the release asset digest. " + "This file cannot contain its own hash.", + "", + obj["summary"], + "", + "## Rules", + "", + ] + for r in obj["rules"]: + out += [f"### {r['id']} - {r['title']}", "", f"- Status: `{r['status']}`"] + if r["status"] == "enforced": + out.append(f"- Check: `{r['check']}`") + out.append(f"- Implemented by: {r['implemented_by']}") + if r["status"] == "declared": + out.append(f"- Evidence recorded, not verified: {r['evidence']}") + out += ["", r["statement"], ""] + if r.get("parameters"): + out += ["Parameters:", "", "```json", + json.dumps(r["parameters"], indent=2, sort_keys=True), "```", ""] + out += ["## Not guaranteed", ""] + out += [f"- {s}" for s in obj["not_guaranteed"]] + return "\n".join(out) + "\n" + + +def render(obj) -> str: + return _render_v2(obj) + + +def cmd_lint(a) -> None: + raw, obj = load(a.policy) + errs = validate(obj, a.verifier) + if not errs and raw != canonical(obj): + errs.append(f"{a.policy} is not canonical - run: python3 .github/scripts/policy_tool.py --policy {a.policy} fmt") + for e in errs: + print(f"::error::{e}") + if errs: + sys.exit(1) + print(f"{a.policy} OK sha256={sha256_bytes(raw)}") + + +def cmd_fmt(a) -> None: + _, obj = load(a.policy) + a.policy.write_bytes(canonical(obj)) + print(f"rewrote {a.policy}") + + +def cmd_render_md(a) -> None: + _, obj = load(a.policy) + a.md.write_text(render(obj)) + print(f"wrote {a.md}") + + +def cmd_check_md(a) -> None: + _, obj = load(a.policy) + if not a.md.exists() or a.md.read_text() != render(obj): + die(f"{a.md} is out of date - run: python3 .github/scripts/policy_tool.py --policy {a.policy} --md {a.md} render-md") + print(f"{a.md} matches {a.policy}") + + +def cmd_hash(a) -> None: + raw, _ = load(a.policy) + print(sha256_bytes(raw)) + + +def cmd_version(a) -> None: + _, obj = load(a.policy) + print(obj["policy_version"]) + + +def cmd_mode(a) -> None: + _, obj = load(a.policy) + print(obj["enforcement_mode"]) + + +def cmd_tag(a) -> None: + """Git tag / release name: policy--.""" + _, obj = load(a.policy) + print(f"policy-{obj['scope']}-{obj['policy_version']}") + + +def cmd_stage(a) -> None: + cmd_lint(a) + cmd_check_md(a) + raw, _ = load(a.policy) + a.outdir.mkdir(parents=True, exist_ok=True) + shutil.copy2(a.policy, a.outdir / "policy.json") + shutil.copy2(a.md, a.outdir / "POLICY.md") + (a.outdir / "policy.json.sha256").write_text(f"{sha256_bytes(raw)} policy.json\n") + print(f"staged policy.json, POLICY.md, policy.json.sha256 in {a.outdir}") + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--policy", type=Path, default=Path("policy/policy.json")) + p.add_argument("--md", type=Path, default=Path("policy/POLICY.md")) + p.add_argument("--verifier", type=Path, default=Path(DEFAULT_VERIFIER), + help="verifier script whose check_* functions schema 2 rules must match exactly") + sub = p.add_subparsers(dest="cmd", required=True) + for name, fn in (("lint", cmd_lint), ("fmt", cmd_fmt), ("render-md", cmd_render_md), + ("check-md", cmd_check_md), ("hash", cmd_hash), ("version", cmd_version), + ("mode", cmd_mode), ("tag", cmd_tag)): + sub.add_parser(name).set_defaults(fn=fn) + s = sub.add_parser("stage") + s.add_argument("outdir", type=Path) + s.set_defaults(fn=cmd_stage) + a = p.parse_args() + a.fn(a) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/scripts/verify_builder_policy.py b/.github/scripts/verify_builder_policy.py new file mode 100644 index 0000000..b15c1f9 --- /dev/null +++ b/.github/scripts/verify_builder_policy.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +verify_builder_policy.py - checks a builder-image release against policy.json. + +Every enforced rule in the policy names one `check_*` function here +(policy_tool.py lint keeps the two in sync in both directions). A check +takes (ctx, params) and returns a list of failure strings; empty means pass. +`params` is the rule's `parameters` object from the policy, so the numbers +and lists the policy publishes are the ones enforced. + +Fail closed: a check that raises, a missing input, or an unknown check name +is a failure, never a skip. + +Inputs (all on disk). --artifacts-dir is the download of the SBOM workflow run; +it holds one `sbom-/` directory per architecture containing + builder-inputs..json, image.digest., + sbom..cdx.json (CycloneDX) and sbom..spdx.json (SPDX). + --vex the OpenVEX file (vex/builder.openvex.json) + --assets-file newline-separated names of the draft release's assets (IMG-10) + --tag / --tag-sha the release tag and the commit it must point at +""" +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import unquote + +ARCHES = ("x86_64", "aarch64") +DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +SHA40_RE = re.compile(r"^[0-9a-f]{40}$") +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*['\"]?([^\s'\"#]+)") + + +class Ctx: + def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex, + spdx=None, assets=None, run=None, git_show=None): + self.root = Path(root) + self.tag = tag + self.tag_sha = tag_sha + self.inputs = inputs # arch -> builder-inputs dict + self.digests = digests # arch -> "sha256:..." + self.sboms = sboms # arch -> parsed CycloneDX SBOM (primary) + self.spdx = spdx or {} # arch -> parsed SPDX SBOM (must agree with CycloneDX) + self.vex = vex # parsed OpenVEX + self.assets = assets # list[str] | None + self.run = run or _run + self.git_show = git_show or _git_show(self.root, tag_sha, self.run) + + +def _run(argv): + r = subprocess.run(argv, text=True, capture_output=True) + return r.returncode, r.stdout + + +def _git_show(root, sha, _run_unused): + def show(path): + r = subprocess.run(["git", "-C", str(root), "show", f"{sha}:{path}"], capture_output=True) + if r.returncode != 0: + raise RuntimeError(f"git show {sha}:{path} failed") + return r.stdout + return show + + +# ---------- SBOM helpers ---------- + +def _walk(o): + if isinstance(o, dict): + p = o.get("purl") + if isinstance(p, str): + yield p + if o.get("referenceType") == "purl" and isinstance(o.get("referenceLocator"), str): + yield o["referenceLocator"] + for v in o.values(): + yield from _walk(v) + elif isinstance(o, list): + for v in o: + yield from _walk(v) + + +def parse_purl(p): + """pkg:type/[namespace/]name@version[?q][#s] -> (type, name, version|None)""" + if not p.startswith("pkg:"): + return None + body = re.split(r"[?#]", p[4:], maxsplit=1)[0] + typ, _, rest = body.partition("/") + if not rest: + return None + name, at, ver = rest.rpartition("@") + if not at: + return typ, unquote(rest), None + return typ, unquote(name), unquote(ver) + + +def purl_set(sbom): + return {t for t in (parse_purl(p) for p in set(_walk(sbom))) if t} + + +def cargo_pairs(sbom): + return {(n, v) for t, n, v in purl_set(sbom) if t == "cargo"} + + +def _per_arch(ctx, getter, label): + out, errs = {}, [] + for a in ARCHES: + v = getter(a) + if v is None: + errs.append(f"{label} missing for {a}") + else: + out[a] = v + return out, errs + + +# ---------- checks (one per enforced rule) ---------- + +def check_image_attested(ctx, params): + errs = [] + for a in ARCHES: + d = ctx.digests.get(a) + if not (isinstance(d, str) and DIGEST_RE.match(d)): + errs.append(f"{a}: image digest missing or malformed: {d!r}") + continue + sc = (ctx.inputs.get(a) or {}).get("source_commit") + if sc != ctx.tag_sha: + errs.append(f"{a}: builder-inputs source_commit {sc!r} != tag commit {ctx.tag_sha}") + rc, _ = ctx.run([ + "gh", "attestation", "verify", f"oci://{params['image']}@{d}", + "--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 {d}") + return errs + + +def check_gate_script_hash(ctx, params): + import hashlib + want = hashlib.sha256(ctx.git_show(params["script_path"])).hexdigest() + errs = [] + for a in ARCHES: + got = (ctx.inputs.get(a) or {}).get("assert_script_sha256") + if got != want: + errs.append(f"{a}: assert_script_sha256 {got!r} != sha256 of {params['script_path']} at {ctx.tag_sha[:12]} ({want})") + return errs + + +def check_pinned_crates(ctx, params): + sboms, errs = _per_arch(ctx, ctx.sboms.get, "SBOM") + for a, s in sboms.items(): + pairs = cargo_pairs(s) + for crate, ver in params["crates"].items(): + versions = {v for n, v in pairs if n == crate} + if ver not in versions: + errs.append(f"{a}: {crate} {ver} not in SBOM (found {sorted(versions) or 'none'})") + elif versions - {ver}: + errs.append(f"{a}: {crate} also present at {sorted(versions - {ver})}") + return errs + + +def check_no_network_crates(ctx, params): + deny = set(params["denylist"]) + if not deny: + return ["policy denylist is empty"] + sboms, errs = _per_arch(ctx, ctx.sboms.get, "SBOM") + for a, s in sboms.items(): + hit = sorted({n for n, _ in cargo_pairs(s)} & deny) + if hit: + errs.append(f"{a}: denylisted crates in SBOM: {hit}") + return errs + + +def check_zero_deb(ctx, params): + sboms, errs = _per_arch(ctx, ctx.sboms.get, "SBOM") + for a, s in sboms.items(): + n = sum(1 for t, _, _ in purl_set(s) if t == "deb") + if n: + errs.append(f"{a}: {n} deb components in SBOM") + return errs + + +def check_inputs_recorded(ctx, params): + errs = [] + for a in ARCHES: + inp = ctx.inputs.get(a) + if not isinstance(inp, dict): + errs.append(f"{a}: builder-inputs missing") + continue + for f in params["required_fields"]: + v = inp.get(f) + if not (isinstance(v, str) and v.strip()): + errs.append(f"{a}: builder-inputs.{f} missing or empty") + runner = inp.get("runner") + if not isinstance(runner, dict): + errs.append(f"{a}: builder-inputs.runner is not an object") + continue + for f in params["runner_fields"]: + v = runner.get(f) + if not (isinstance(v, str) and v.strip()): + errs.append(f"{a}: runner.{f} missing or empty") + want = params["runner_arch"].get(a) + if runner.get("arch") != want: + errs.append(f"{a}: runner.arch {runner.get('arch')!r} != {want!r}") + return errs + + +FROM_RE = re.compile(r"^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?", re.IGNORECASE) + + +def check_base_image_pinned(ctx, params): + """The Containerfile at the tagged commit pins every external FROM by digest, and the + digest it pins is the one builder-inputs recorded for both architectures.""" + errs, recorded = [], set() + for a in ARCHES: + inp = ctx.inputs.get(a) or {} + for name in ("base_image_index_digest", "base_platform_manifest_digest"): + d = inp.get(name) + if not (isinstance(d, str) and DIGEST_RE.match(d)): + errs.append(f"{a}: {name} missing or not a sha256 digest: {d!r}") + if isinstance(inp.get("base_image_index_digest"), str): + recorded.add(inp["base_image_index_digest"]) + if len(recorded) > 1: + errs.append(f"arches were built from different base image indexes: {sorted(recorded)}") + text = ctx.git_show(params["containerfile_path"]).decode() + stages, external = set(), [] + for line in text.splitlines(): + m = FROM_RE.match(line) + if not m: + continue + image, alias = m.group(1), m.group(2) + if image not in stages and image.lower() != "scratch": + external.append(image) + if alias: + stages.add(alias) + if not external: + return errs + [f"no external FROM found in {params['containerfile_path']}"] + pinned = set() + for image in external: + name, at, digest = image.partition("@") + if "$" in image: + errs.append(f"FROM {image} uses a variable; the pin cannot be verified") + elif not (at and DIGEST_RE.match(digest)): + errs.append(f"FROM {image} is not pinned by sha256 digest") + else: + pinned.add(digest) + for a in ARCHES: + ref = (ctx.inputs.get(a) or {}).get("base_image_ref") + if ref is not None and ref not in {i.partition("@")[0] for i in external}: + errs.append(f"{a}: base_image_ref {ref!r} is not a FROM in the Containerfile") + for d in recorded: + if d not in pinned: + errs.append(f"recorded base index digest {d} is not the digest the Containerfile pins") + return sorted(set(errs), key=errs.index) + + +def check_sbom_and_vex(ctx, params): + sboms, errs = _per_arch(ctx, ctx.sboms.get, "SBOM") + for a, s in sboms.items(): + n = len(cargo_pairs(s)) + if n < params["min_cargo_components"]: + errs.append(f"{a}: SBOM has {n} cargo components, need >= {params['min_cargo_components']} (empty or wrong SBOM?)") + sp = ctx.spdx.get(a) + if sp is None: + errs.append(f"SPDX SBOM missing for {a}") + else: + keep = lambda x: {t for t in purl_set(x) if t[0] in ("cargo", "deb")} + only_cdx, only_spdx = keep(s) - keep(sp), keep(sp) - keep(s) + if only_cdx or only_spdx: + errs.append(f"{a}: CycloneDX and SPDX disagree on cargo/deb components " + f"({len(only_cdx)} only in CycloneDX, {len(only_spdx)} only in SPDX)") + vex = ctx.vex + if not isinstance(vex, dict): + return errs + ["VEX missing or not an object"] + if not str(vex.get("@context", "")).startswith("https://openvex.dev/ns"): + errs.append("VEX @context is not an OpenVEX namespace") + stmts = vex.get("statements") + if not (isinstance(stmts, list) and stmts): + return errs + ["VEX has no statements"] + for i, st in enumerate(stmts): + if st.get("status") not in ("not_affected", "affected", "fixed", "under_investigation"): + errs.append(f"VEX statement {i}: invalid status {st.get('status')!r}") + for purl in params["vex_must_be_affected"]: + hits = [st for st in stmts if purl in json.dumps(st)] + if not hits: + errs.append(f"VEX has no statement for {purl}") + for st in hits: + if st.get("status") != "affected": + errs.append(f"VEX marks {purl} {st.get('status')!r}; policy requires 'affected'") + return errs + + +def check_tag_signed(ctx, params): + ref = f"refs/tags/{ctx.tag}" + rc, out = ctx.run(["git", "cat-file", "-t", ref]) + if rc != 0 or out.strip() != "tag": + return [f"{ctx.tag} is not an annotated tag"] + 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']})"] + + +def check_actions_pinned(ctx, params): + errs, seen = [], 0 + wf = ctx.root / ".github" / "workflows" + files = sorted(list(wf.glob("*.yml")) + list(wf.glob("*.yaml"))) + if not files: + return ["no workflow files found"] + for f in files: + for n, line in enumerate(f.read_text().splitlines(), 1): + m = USES_RE.match(line) + if not m: + continue + ref = m.group(1) + if ref.startswith("./"): + continue + seen += 1 + if ref.startswith("docker://"): + ok = "@sha256:" in ref + else: + ok = bool(SHA40_RE.match(ref.rpartition("@")[2])) and "@" in ref + if not ok: + errs.append(f"{f.name}:{n}: not pinned to a full SHA: {ref}") + if seen == 0: + errs.append("found no `uses:` lines at all (parser broken?)") + return errs + + +def check_release_assets(ctx, params): + if ctx.assets is None: + return ["asset list not provided"] + got, want = sorted(ctx.assets), sorted(params["expected_assets"]) + if got == want: + return [] + return [f"asset set mismatch: missing {sorted(set(want) - set(got))}, unexpected {sorted(set(got) - set(want))}"] + + +# ---------- driver ---------- + +def run_rule(rule, ctx): + fn = globals().get(rule["check"]) + if not callable(fn): + return [f"check {rule['check']} not found"] + try: + return fn(ctx, rule.get("parameters", {})) + except Exception as e: # fail closed + return [f"{rule['check']} raised {type(e).__name__}: {e}"] + + +def load_ctx(a): + def jload(p): + return json.loads(Path(p).read_text()) if Path(p).exists() else None + + def f(arch, name): + return a.artifacts_dir / f"sbom-{arch}" / name + inputs = {x: jload(f(x, f"builder-inputs.{x}.json")) for x in ARCHES} + digests = {} + for x in ARCHES: + p = f(x, f"image.digest.{x}") + digests[x] = p.read_text().strip() if p.exists() else None + 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 + 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) + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--policy", type=Path, required=True) + p.add_argument("--root", type=Path, default=Path(".")) + p.add_argument("--tag", required=True) + p.add_argument("--tag-sha", required=True) + p.add_argument("--artifacts-dir", type=Path, required=True) + p.add_argument("--vex", type=Path, required=True) + p.add_argument("--assets-file", type=Path) + p.add_argument("--only", help="run a single rule id") + a = p.parse_args() + policy = json.loads(a.policy.read_text()) + ctx = load_ctx(a) + failed = 0 + for rule in policy["rules"]: + if a.only and rule["id"] != a.only: + continue + if rule["status"] == "declared": + print(f"DECLARED {rule['id']} {rule['title']} (trust root, not verified)") + continue + errs = run_rule(rule, ctx) + if errs: + failed += 1 + print(f"FAIL {rule['id']} {rule['title']}") + for e in errs: + print(f"::error::{rule['id']}: {e}") + else: + print(f"PASS {rule['id']} {rule['title']}") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/workflows/build-environment-image.yml b/.github/workflows/build-environment-image.yml index 0c91cb9..07c2347 100644 --- a/.github/workflows/build-environment-image.yml +++ b/.github/workflows/build-environment-image.yml @@ -37,7 +37,7 @@ jobs: attestations: write steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Compute recipe tag id: tag diff --git a/.github/workflows/merge-bot.yml b/.github/workflows/merge-bot.yml index a770d9a..6f91c2c 100644 --- a/.github/workflows/merge-bot.yml +++ b/.github/workflows/merge-bot.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} @@ -48,7 +48,7 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} steps: - name: Checkout full history at PR head - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 0000000..ddd7e99 --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,225 @@ +name: release-policy + +# Freezes policy/builder-image/policy.json as an immutable release +# `policy-builder-image-` (name comes from `policy_tool.py tag`). +# +# Trust model (who vouches for what): +# commit GPG-signed by the author -> who wrote the policy +# tag gitsign (Sigstore OIDC) annotated -> who approved this exact commit as a release +# build this workflow + GitHub attestation -> these bytes were produced by this workflow +# release SHA256SUMS.sigstore.json, keyless cosign signed ON THE MAINTAINER'S +# WORKSTATION (sign-release.sh) -> a human approved these exact bytes +# +# This workflow NEVER publishes. It stops at a draft release. The maintainer +# downloads the draft, verifies it, signs SHA256SUMS, uploads the bundle and +# publishes by hand (immutable releases: nothing can be swapped afterwards). +# +# Manual only, from main, behind the `policy-release` environment. +# Enforced (vN) policies need a pre-existing gitsign-signed annotated tag +# `policy-builder-image-vN` pointing at the build SHA, verified here against the identity +# pinned in repository variables POLICY_SIGNER_IDENTITY / POLICY_SIGNER_ISSUER. +# Descriptive (legacy-*) policies need no tag and carry no provenance +# attestation, so they cannot be mistaken for an enforcement contract. + +on: + workflow_dispatch: + inputs: + policy_version: + description: "Policy version to freeze; must equal policy_version in policy/policy.json (e.g. v1)" + required: true + +permissions: + contents: read + +env: + POLICY_FILE: policy/builder-image/policy.json + POLICY_MD: policy/builder-image/POLICY.md + +jobs: + check: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + WANT: ${{ inputs.policy_version }} + SIGNER_IDENTITY: ${{ vars.POLICY_SIGNER_IDENTITY }} + SIGNER_ISSUER: ${{ vars.POLICY_SIGNER_ISSUER }} + GITSIGN_REF: dd51a25b4f9fa103ca6f5cdca4183fa15cb3e627 # v0.14.0 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Lint policy and generated POLICY.md + run: | + set -euo pipefail + python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" --md "$POLICY_MD" lint + python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" --md "$POLICY_MD" check-md + have="$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" version)" + echo "TAG=$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" tag)" >> "$GITHUB_ENV" + if [ "$have" != "$WANT" ]; then + echo "::error::input policy_version '$WANT' != policy.json policy_version '$have'" + exit 1 + fi + echo "MODE=$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" mode)" >> "$GITHUB_ENV" + + - name: Refuse if this policy release already exists + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::release ${TAG} already exists - policies are never overwritten, bump policy_version" + exit 1 + fi + + - name: Require annotated tag on the build commit (enforced only) + if: env.MODE == 'enforced' + run: | + set -euo pipefail + tag="$TAG" + git fetch --force --tags origin + if ! git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "::error::tag ${tag} not found - create it locally with your gitsign tag alias and push it first" + exit 1 + fi + if [ "$(git cat-file -t "refs/tags/${tag}")" != "tag" ]; then + echo "::error::${tag} is a lightweight tag - it must be an annotated, signed tag" + exit 1 + fi + commit="$(git rev-parse "refs/tags/${tag}^{commit}")" + if [ "$commit" != "$GITHUB_SHA" ]; then + echo "::error::tag ${tag} points at ${commit}, but this run builds ${GITHUB_SHA}" + exit 1 + fi + + - name: Verify tag gitsign signature against pinned identity (enforced only) + if: env.MODE == 'enforced' + run: | + set -euo pipefail + if [ -z "${SIGNER_IDENTITY:-}" ] || [ -z "${SIGNER_ISSUER:-}" ]; then + echo "::error::set repository variables POLICY_SIGNER_IDENTITY and POLICY_SIGNER_ISSUER (Settings -> Secrets and variables -> Actions -> Variables)" + exit 1 + fi + # Go is preinstalled on ubuntu-latest runners. + go install "github.com/sigstore/gitsign@${GITSIGN_REF}" + export PATH="$(go env GOPATH)/bin:$PATH" + gitsign verify-tag \ + --certificate-identity "$SIGNER_IDENTITY" \ + --certificate-oidc-issuer "$SIGNER_ISSUER" \ + "$TAG" + + draft: + needs: check + runs-on: ubuntu-latest + environment: policy-release + permissions: + contents: write + id-token: write + attestations: write + env: + GH_TOKEN: ${{ github.token }} + WANT: ${{ inputs.policy_version }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Stage release assets + run: | + set -euo pipefail + python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" --md "$POLICY_MD" stage policy-release + echo "MODE=$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" mode)" >> "$GITHUB_ENV" + echo "TAG=$(python3 .github/scripts/policy_tool.py --policy "$POLICY_FILE" tag)" >> "$GITHUB_ENV" + + - name: Re-check tag still points at the build commit (enforced only) + if: env.MODE == 'enforced' + run: | + set -euo pipefail + git fetch --force --tags origin + commit="$(git rev-parse "refs/tags/${TAG}^{commit}")" + [ "$commit" = "$GITHUB_SHA" ] || { echo "::error::tag moved: $commit != $GITHUB_SHA"; exit 1; } + + - name: Attest build provenance (policy.json) + if: env.MODE == 'enforced' + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: policy-release/policy.json + + - name: Verify attestation and attach bundle + if: env.MODE == 'enforced' + run: | + set -euo pipefail + signer="${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml" + ok=0 + for i in 1 2 3 4 5 6; do + if gh attestation verify policy-release/policy.json --repo "$GITHUB_REPOSITORY" --signer-workflow "$signer" --source-ref refs/heads/main --source-digest "$GITHUB_SHA"; then + ok=1; break + fi + echo "attestation not visible yet (try $i), waiting"; sleep 10 + done + [ "$ok" = 1 ] || { echo "::error::could not verify policy.json attestation"; exit 1; } + digest="$(sha256sum policy-release/policy.json | cut -d' ' -f1)" + gh attestation download policy-release/policy.json --repo "$GITHUB_REPOSITORY" + # gh names the bundle sha256:.jsonl in the cwd + mv "sha256:${digest}.jsonl" policy-release/policy.json.attestations.jsonl + + - name: Create DRAFT release, upload, verify asset set (never publishes) + run: | + set -euo pipefail + tag="$TAG" + files=(policy-release/policy.json policy-release/POLICY.md policy-release/policy.json.sha256) + want="POLICY.md,policy.json,policy.json.sha256" + if [ "$MODE" = "enforced" ]; then + files+=(policy-release/policy.json.attestations.jsonl) + want="POLICY.md,policy.json,policy.json.attestations.jsonl,policy.json.sha256" + notes="Frozen enforcing policy ${WANT} (sha256 in policy.json.sha256). Verify: gh attestation verify policy.json --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml ; then verify SHA256SUMS with cosign verify-blob --bundle SHA256SUMS.sigstore.json (identity and issuer are pinned in the repo variables)." + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --draft --verify-tag \ + --title "Policy builder-image ${WANT}" --notes "$notes" + else + notes="Historical description ${WANT} (sha256 in policy.json.sha256). Describes what earlier releases did. Not consumed by builds, not an enforcement contract, and not evidence that any policy was enforced." + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --draft --target "$GITHUB_SHA" \ + --title "Policy builder-image ${WANT}" --notes "$notes" + fi + gh release upload "$tag" --repo "$GITHUB_REPOSITORY" "${files[@]}" + got="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name] | sort | join(",")')" + if [ "$got" != "$want" ]; then + echo "::error::asset set mismatch: got '$got', want '$want'" + exit 1 + fi + + - name: Re-download draft assets and compare + run: | + set -euo pipefail + tag="$TAG" + mkdir -p drafted + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir drafted + cmp drafted/policy.json policy-release/policy.json + cmp drafted/POLICY.md policy-release/POLICY.md + (cd drafted && sha256sum -c policy.json.sha256) + if [ "$MODE" = "enforced" ]; then + gh attestation verify drafted/policy.json --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release-policy.yml" \ + --source-ref refs/heads/main --source-digest "$GITHUB_SHA" + fi + { + echo "### Draft ${TAG} is ready - NOT published" + echo + echo "sha256 \`$(sha256sum drafted/policy.json | cut -d' ' -f1)\`" + echo + echo "On your workstation (needs cosign, gh, and a browser for the Sigstore login):" + echo + echo '```' + echo "export SIGNER_IDENTITY= SIGNER_ISSUER=" + echo "sign-release.sh ${TAG}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/policy/builder-image/POLICY.md b/policy/builder-image/POLICY.md new file mode 100644 index 0000000..fc2bf40 --- /dev/null +++ b/policy/builder-image/POLICY.md @@ -0,0 +1,270 @@ +# Policy builder-image v1 + + + +- Scope: `builder-image` +- Enforcement mode: **enforced** +- Hash: sha256 of the exact `policy.json` bytes, published as `policy.json.sha256` and as the release asset digest. This file cannot contain its own hash. + +Enforcing policy for the Alpine/musl builder image (ghcr.io/patrickryankenneth/python-wheels-builder-alpine). The release workflow loads this file, checks its sha256, runs one verifier function per enforced rule, and refuses to produce a release if any fails. Declared rules are trust roots: evidence is recorded and nothing is verified. Changing a promise means publishing a new version. + +## Rules + +### IMG-1 - Built by the named workflow from main at the tagged commit + +- Status: `enforced` +- Check: `check_image_attested` +- Implemented by: build-environment-image.yml + GitHub build-provenance attestation; verify_builder_policy.py + +For both architectures, the image digest has a GitHub attestation produced by the named workflow, from refs/heads/main, at exactly the commit the release tag points to, and builder-inputs.json records that same commit as source_commit. + +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" +} +``` + +### IMG-2 - The build gate ran, at this version + +- Status: `enforced` +- Check: `check_gate_script_hash` +- Implemented by: Containerfile (gate runs in the build); builder-inputs.json assert_script_sha256; verify_builder_policy.py + +builder-inputs.json records the sha256 of the sccache build gate script, and it equals the sha256 of that script at the tagged commit. The Containerfile runs the gate during the build and the build fails if it fails. + +Parameters: + +```json +{ + "script_path": "environments/alpine-3.24-musl/assert-sccache-build.sh" +} +``` + +### IMG-3 - Pinned crates are exact + +- Status: `enforced` +- Check: `check_pinned_crates` +- Implemented by: assert-sccache-build.sh (build); verify_builder_policy.py against each SBOM + +Each architecture's SBOM lists exactly these crate versions and no other version of these crates. + +Parameters: + +```json +{ + "crates": { + "bytes": "1.10.1", + "rand": "0.8.5", + "tar": "0.4.40" + } +} +``` + +### IMG-4 - No network-stack crates + +- Status: `enforced` +- Check: `check_no_network_crates` +- Implemented by: assert-sccache-build.sh (build); verify_builder_policy.py against each SBOM + +Neither architecture's SBOM contains any crate on this denylist. The list is the one the build gate (assert-sccache-build.sh FORBIDDEN_CRATES) rejects in the dependency tree. + +Parameters: + +```json +{ + "denylist": [ + "hyper-rustls", + "opendal", + "openssl", + "reqwest", + "rustls", + "rustls-webpki" + ] +} +``` + +### IMG-5 - Zero deb components + +- Status: `enforced` +- Check: `check_zero_deb` +- Implemented by: verify_builder_policy.py against each SBOM + +Neither architecture's SBOM contains a deb component. + +### IMG-6 - Build inputs are recorded + +- Status: `enforced` +- Check: `check_inputs_recorded` +- Implemented by: build-environment-image.yml writes builder-inputs..json; verify_builder_policy.py + +For both architectures, builder-inputs.json has every required field as a non-empty string, a runner object with every runner field non-empty, and a runner.arch matching the architecture. This records the unpinned inputs; recording them does not pin them. + +Parameters: + +```json +{ + "required_fields": [ + "apk_lock_sha256", + "assert_script_sha256", + "base_image_index_digest", + "base_image_ref", + "base_platform", + "base_platform_manifest_digest", + "builder_image", + "cargo_auditable_version", + "requirements_builder_sha256", + "rust_version", + "rustup_sha256", + "rustup_version", + "sccache_version", + "source_commit" + ], + "runner_arch": { + "aarch64": "ARM64", + "x86_64": "X64" + }, + "runner_fields": [ + "arch", + "buildx_version", + "docker_version", + "image_os", + "image_version" + ] +} +``` + +### IMG-7 - SBOM and VEX are present and well-formed + +- Status: `enforced` +- Check: `check_sbom_and_vex` +- Implemented by: sbom-image.yml; 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). + +Parameters: + +```json +{ + "min_cargo_components": 1, + "vex_must_be_affected": [ + "pkg:cargo/bytes@1.10.1" + ] +} +``` + +### IMG-8 - Release tag is annotated and signed by the named identity + +- Status: `enforced` +- Check: `check_tag_signed` +- Implemented by: gitsign; release workflow; verify_builder_policy.py + +The release tag is annotated, points at the commit the image was built from, and its gitsign signature verifies against this identity and issuer. + +Parameters: + +```json +{ + "identity": "patrickryankenneth@gmail.com", + "issuer": "https://github.com/login/oauth" +} +``` + +### IMG-9 - Workflow actions are pinned to commit SHAs + +- Status: `enforced` +- Check: `check_actions_pinned` +- Implemented by: verify_builder_policy.py over .github/workflows at the tagged commit + +Every non-local `uses:` in every workflow at the tagged commit is pinned to a full 40-character commit SHA (docker:// references to a sha256 digest). This replaces the legacy declared-only ACT-1. + +### IMG-10 - Release carries exactly the expected assets + +- Status: `enforced` +- Check: `check_release_assets` +- Implemented by: release workflow; verify_builder_policy.py on the draft release + +The draft release's asset set equals this list. SHA256SUMS and its Sigstore bundle are added by the maintainer when signing, after this check, and are checked by the publish guard. + +Parameters: + +```json +{ + "expected_assets": [ + "builder-inputs.aarch64.json", + "builder-inputs.x86_64.json", + "builder.openvex.json", + "image.digest.aarch64", + "image.digest.x86_64", + "sbom.aarch64.cdx.json", + "sbom.aarch64.spdx.json", + "sbom.x86_64.cdx.json", + "sbom.x86_64.spdx.json" + ] +} +``` + +### IMG-11 - Base image is pinned by digest + +- Status: `enforced` +- Check: `check_base_image_pinned` +- Implemented by: Containerfile FROM line; builder-inputs.json; verify_builder_policy.py reads the Containerfile at the tagged commit + +Every external FROM in the Containerfile at the tagged commit is pinned to a sha256 digest, and the digest it pins is the base image index digest recorded in builder-inputs.json for both architectures. Both architectures were built from the same index. This is a content-addressed input. + +Parameters: + +```json +{ + "containerfile_path": "environments/alpine-3.24-musl/Containerfile" +} +``` + +### TRU-1 - GitHub-hosted runner + +- Status: `declared` +- Implemented by: GitHub-hosted runner images +- Evidence recorded, not verified: builder-inputs.json runner object (image_os, image_version, arch, docker_version, buildx_version) + +The runner image is trusted, not pinned. Its identity is recorded; it is not verified against a known-good value. + +### TRU-2 - Alpine APK packages + +- Status: `declared` +- Implemented by: Alpine repository signatures +- Evidence recorded, not verified: apk.lock. (name+version) and the SBOM + +Packages are trusted through Alpine's repository signatures. The lock records names and versions, not content hashes. + +### TRU-3 - Rust toolchain archives + +- Status: `declared` +- Implemented by: rustup manifest and archive verification +- Evidence recorded, not verified: exact rust and rustup versions, rustup-init sha256 + +Toolchain archives are trusted through rustup's own verification. They are not independently pinned by hash. + +### TRU-4 - Cargo crate tarballs + +- Status: `declared` +- Implemented by: registry integrity checks and cargo --locked +- Evidence recorded, not verified: exact versions in the SBOM and .deps.txt + +Crate tarballs are trusted through the registry's checksums and the locked dependency graph. They are not independently pinned by hash. + +## Not guaranteed + +- The image is not reproducible, and apk packages, rustup archives and crate tarballs have no content-hash pins. +- The runner image is unpinned; its identity is recorded, not checked. +- An attestation shows where and how the image was built, not that its contents are free of vulnerabilities. No vulnerability scan is part of this policy. +- The VEX is the maintainer's assertion, not an independent finding. +- Enforcement happens at release time, not at pull time. Consumers must verify the attestation and this policy themselves. +- 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. diff --git a/policy/builder-image/policy.json b/policy/builder-image/policy.json new file mode 100644 index 0000000..d3f0a73 --- /dev/null +++ b/policy/builder-image/policy.json @@ -0,0 +1,224 @@ +{ + "enforcement_mode": "enforced", + "not_guaranteed": [ + "The image is not reproducible, and apk packages, rustup archives and crate tarballs have no content-hash pins.", + "The runner image is unpinned; its identity is recorded, not checked.", + "An attestation shows where and how the image was built, not that its contents are free of vulnerabilities. No vulnerability scan is part of this policy.", + "The VEX is the maintainer's assertion, not an independent finding.", + "Enforcement happens at release time, not at pull time. Consumers must verify the attestation and this policy themselves.", + "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." + ], + "policy_version": "v1", + "rules": [ + { + "check": "check_image_attested", + "id": "IMG-1", + "implemented_by": "build-environment-image.yml + GitHub build-provenance attestation; 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 image digest has a GitHub attestation produced by the named workflow, from refs/heads/main, at exactly the commit the release tag points to, and builder-inputs.json records that same commit as source_commit.", + "status": "enforced", + "title": "Built by the named workflow from main at the tagged commit" + }, + { + "check": "check_gate_script_hash", + "id": "IMG-2", + "implemented_by": "Containerfile (gate runs in the build); builder-inputs.json assert_script_sha256; verify_builder_policy.py", + "parameters": { + "script_path": "environments/alpine-3.24-musl/assert-sccache-build.sh" + }, + "statement": "builder-inputs.json records the sha256 of the sccache build gate script, and it equals the sha256 of that script at the tagged commit. The Containerfile runs the gate during the build and the build fails if it fails.", + "status": "enforced", + "title": "The build gate ran, at this version" + }, + { + "check": "check_pinned_crates", + "id": "IMG-3", + "implemented_by": "assert-sccache-build.sh (build); verify_builder_policy.py against each SBOM", + "parameters": { + "crates": { + "bytes": "1.10.1", + "rand": "0.8.5", + "tar": "0.4.40" + } + }, + "statement": "Each architecture's SBOM lists exactly these crate versions and no other version of these crates.", + "status": "enforced", + "title": "Pinned crates are exact" + }, + { + "check": "check_no_network_crates", + "id": "IMG-4", + "implemented_by": "assert-sccache-build.sh (build); verify_builder_policy.py against each SBOM", + "parameters": { + "denylist": [ + "hyper-rustls", + "opendal", + "openssl", + "reqwest", + "rustls", + "rustls-webpki" + ] + }, + "statement": "Neither architecture's SBOM contains any crate on this denylist. The list is the one the build gate (assert-sccache-build.sh FORBIDDEN_CRATES) rejects in the dependency tree.", + "status": "enforced", + "title": "No network-stack crates" + }, + { + "check": "check_zero_deb", + "id": "IMG-5", + "implemented_by": "verify_builder_policy.py against each SBOM", + "parameters": {}, + "statement": "Neither architecture's SBOM contains a deb component.", + "status": "enforced", + "title": "Zero deb components" + }, + { + "check": "check_inputs_recorded", + "id": "IMG-6", + "implemented_by": "build-environment-image.yml writes builder-inputs..json; verify_builder_policy.py", + "parameters": { + "required_fields": [ + "apk_lock_sha256", + "assert_script_sha256", + "base_image_index_digest", + "base_image_ref", + "base_platform", + "base_platform_manifest_digest", + "builder_image", + "cargo_auditable_version", + "requirements_builder_sha256", + "rust_version", + "rustup_sha256", + "rustup_version", + "sccache_version", + "source_commit" + ], + "runner_arch": { + "aarch64": "ARM64", + "x86_64": "X64" + }, + "runner_fields": [ + "arch", + "buildx_version", + "docker_version", + "image_os", + "image_version" + ] + }, + "statement": "For both architectures, builder-inputs.json has every required field as a non-empty string, a runner object with every runner field non-empty, and a runner.arch matching the architecture. This records the unpinned inputs; recording them does not pin them.", + "status": "enforced", + "title": "Build inputs are recorded" + }, + { + "check": "check_sbom_and_vex", + "id": "IMG-7", + "implemented_by": "sbom-image.yml; vex/builder.openvex.json; verify_builder_policy.py", + "parameters": { + "min_cargo_components": 1, + "vex_must_be_affected": [ + "pkg:cargo/bytes@1.10.1" + ] + }, + "statement": "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).", + "status": "enforced", + "title": "SBOM and VEX are present and well-formed" + }, + { + "check": "check_tag_signed", + "id": "IMG-8", + "implemented_by": "gitsign; release workflow; verify_builder_policy.py", + "parameters": { + "identity": "patrickryankenneth@gmail.com", + "issuer": "https://github.com/login/oauth" + }, + "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.", + "status": "enforced", + "title": "Release tag is annotated and signed by the named identity" + }, + { + "check": "check_actions_pinned", + "id": "IMG-9", + "implemented_by": "verify_builder_policy.py over .github/workflows at the tagged commit", + "parameters": {}, + "statement": "Every non-local `uses:` in every workflow at the tagged commit is pinned to a full 40-character commit SHA (docker:// references to a sha256 digest). This replaces the legacy declared-only ACT-1.", + "status": "enforced", + "title": "Workflow actions are pinned to commit SHAs" + }, + { + "check": "check_release_assets", + "id": "IMG-10", + "implemented_by": "release workflow; verify_builder_policy.py on the draft release", + "parameters": { + "expected_assets": [ + "builder-inputs.aarch64.json", + "builder-inputs.x86_64.json", + "builder.openvex.json", + "image.digest.aarch64", + "image.digest.x86_64", + "sbom.aarch64.cdx.json", + "sbom.aarch64.spdx.json", + "sbom.x86_64.cdx.json", + "sbom.x86_64.spdx.json" + ] + }, + "statement": "The draft release's asset set equals this list. SHA256SUMS and its Sigstore bundle are added by the maintainer when signing, after this check, and are checked by the publish guard.", + "status": "enforced", + "title": "Release carries exactly the expected assets" + }, + { + "check": "check_base_image_pinned", + "id": "IMG-11", + "implemented_by": "Containerfile FROM line; builder-inputs.json; verify_builder_policy.py reads the Containerfile at the tagged commit", + "parameters": { + "containerfile_path": "environments/alpine-3.24-musl/Containerfile" + }, + "statement": "Every external FROM in the Containerfile at the tagged commit is pinned to a sha256 digest, and the digest it pins is the base image index digest recorded in builder-inputs.json for both architectures. Both architectures were built from the same index. This is a content-addressed input.", + "status": "enforced", + "title": "Base image is pinned by digest" + }, + { + "evidence": "builder-inputs.json runner object (image_os, image_version, arch, docker_version, buildx_version)", + "id": "TRU-1", + "implemented_by": "GitHub-hosted runner images", + "statement": "The runner image is trusted, not pinned. Its identity is recorded; it is not verified against a known-good value.", + "status": "declared", + "title": "GitHub-hosted runner" + }, + { + "evidence": "apk.lock. (name+version) and the SBOM", + "id": "TRU-2", + "implemented_by": "Alpine repository signatures", + "statement": "Packages are trusted through Alpine's repository signatures. The lock records names and versions, not content hashes.", + "status": "declared", + "title": "Alpine APK packages" + }, + { + "evidence": "exact rust and rustup versions, rustup-init sha256", + "id": "TRU-3", + "implemented_by": "rustup manifest and archive verification", + "statement": "Toolchain archives are trusted through rustup's own verification. They are not independently pinned by hash.", + "status": "declared", + "title": "Rust toolchain archives" + }, + { + "evidence": "exact versions in the SBOM and .deps.txt", + "id": "TRU-4", + "implemented_by": "registry integrity checks and cargo --locked", + "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" + } + ], + "schema": 2, + "scope": "builder-image", + "summary": "Enforcing policy for the Alpine/musl builder image (ghcr.io/patrickryankenneth/python-wheels-builder-alpine). The release workflow loads this file, checks its sha256, runs one verifier function per enforced rule, and refuses to produce a release if any fails. Declared rules are trust roots: evidence is recorded and nothing is verified. Changing a promise means publishing a new version." +} diff --git a/tests/test_policy_tool.py b/tests/test_policy_tool.py new file mode 100644 index 0000000..e55d0e3 --- /dev/null +++ b/tests/test_policy_tool.py @@ -0,0 +1,52 @@ +import copy, importlib.util, json +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("policy_tool", ROOT / ".github/scripts/policy_tool.py") +pt = importlib.util.module_from_spec(spec); spec.loader.exec_module(pt) +POLICY = json.loads((ROOT / "policy/builder-image/policy.json").read_text()) +VERIFIER = ROOT / ".github/scripts/verify_builder_policy.py" + + +def errs(p): return pt.validate(p, VERIFIER) + + +def test_shipped_policy_is_valid_and_canonical(): + raw = (ROOT / "policy/builder-image/policy.json").read_bytes() + assert errs(POLICY) == [] and raw == pt.canonical(POLICY) + md = (ROOT / "policy/builder-image/POLICY.md").read_text() + assert md == pt.render(POLICY) + + +def mut(fn): + p = copy.deepcopy(POLICY); fn(p); return p + + +def rule(p, rid): return next(r for r in p["rules"] if r["id"] == rid) + + +@pytest.mark.parametrize("name,fn", [ + ("enforced without check", lambda p: rule(p, "IMG-1").pop("check")), + ("check names missing function", lambda p: rule(p, "IMG-1").update(check="check_nope")), + ("declared with check", lambda p: rule(p, "TRU-1").update(check="check_zero_deb")), + ("declared without evidence", lambda p: rule(p, "TRU-1").pop("evidence")), + ("enforced with evidence", lambda p: rule(p, "IMG-5").update(evidence="x")), + ("orphan verifier function", lambda p: p["rules"].remove(rule(p, "IMG-5"))), + ("descriptive mode", lambda p: p.update(enforcement_mode="descriptive")), + ("bad scope", lambda p: p.update(scope="Builder Image")), + ("unknown field", lambda p: rule(p, "IMG-1").update(extra=1)), + ("duplicate id", lambda p: rule(p, "IMG-2").update(id="IMG-1")), + ("legacy status", lambda p: rule(p, "IMG-1").update(status="performed-at-build")), + ("empty not_guaranteed", lambda p: p.update(not_guaranteed=[])), +]) +def test_bad_policy_rejected(name, fn): + assert errs(mut(fn)), name + + +def test_schema1_rejected(): + assert errs(mut(lambda p: p.update(schema=1))) + + +def test_tag_name(): + assert f"policy-{POLICY['scope']}-{POLICY['policy_version']}" == "policy-builder-image-v1" \ No newline at end of file diff --git a/tests/test_verify_builder_policy.py b/tests/test_verify_builder_policy.py new file mode 100644 index 0000000..c374a76 --- /dev/null +++ b/tests/test_verify_builder_policy.py @@ -0,0 +1,128 @@ +import copy, hashlib, importlib.util, json +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("vbp", ROOT / ".github/scripts/verify_builder_policy.py") +v = importlib.util.module_from_spec(spec); spec.loader.exec_module(v) +POLICY = json.loads((ROOT / "policy/builder-image/policy.json").read_text()) +RULES = {r["id"]: r for r in POLICY["rules"] if r["status"] == "enforced"} +SHA = "a" * 40 +SCRIPT = b"#!/bin/sh\necho gate\n" + + +def P(rid): return RULES[rid].get("parameters", {}) + + +class FakeRun: + def __init__(self, fail=()): self.fail, self.calls = fail, [] + 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] == ["git", "cat-file"]: return 0, "tag\n" + if argv[:2] == ["git", "rev-parse"]: return 0, SHA + "\n" + return 0, "" + + +def sbom(): + crates = dict(P("IMG-3")["crates"]); crates["serde"] = "1.0.0" + return {"components": [{"purl": f"pkg:cargo/{n}@{x}"} for n, x in crates.items()]} + + +def inputs(a): + d = {f: "x" for f in P("IMG-6")["required_fields"]} + d.update({"source_commit": SHA, "assert_script_sha256": hashlib.sha256(SCRIPT).hexdigest(), + "base_image_index_digest": "sha256:" + "e" * 64, + "base_platform_manifest_digest": "sha256:" + "f" * 64, + "base_image_ref": "docker.io/library/alpine:3.24"}) + return {**d, + "runner": {"image_os": "ubuntu24", "image_version": "1", "arch": P("IMG-6")["runner_arch"][a], + "docker_version": "28", "buildx_version": "v0"}} + + +CONTAINERFILE = ("# base\nFROM docker.io/library/alpine:3.24@sha256:" + "e" * 64 + " AS base\n" + "FROM base AS final\nRUN true\n").encode() + + +def fake_show(path): + return CONTAINERFILE if path.endswith("Containerfile") else SCRIPT + + +def good(tmp_path, run=None): + wf = tmp_path / ".github/workflows"; wf.mkdir(parents=True) + (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"}]} + 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, + assets=list(P("IMG-10")["expected_assets"]), + run=run or FakeRun(), git_show=fake_show) + + +def test_every_rule_has_a_check_and_a_bad_fixture(): + assert set(BAD) == {r["check"] for r in RULES.values()} + assert {r["check"] for r in RULES.values()} == {n for n in dir(v) if n.startswith("check_")} + + +@pytest.mark.parametrize("rid", sorted(RULES)) +def test_good_fixture_passes(tmp_path, rid): + assert v.run_rule(RULES[rid], good(tmp_path)) == [] + + +def b_attest(c): c.run = FakeRun(fail=[("gh", "attestation")]) +def b_commit(c): c.inputs["x86_64"]["source_commit"] = "d" * 40 +def b_gate(c): c.inputs["aarch64"]["assert_script_sha256"] = "0" * 64 +def b_pins(c): c.sboms["x86_64"]["components"][0]["purl"] = "pkg:cargo/bytes@9.9.9" +def b_net(c): c.sboms["aarch64"]["components"].append({"purl": f"pkg:cargo/{P('IMG-4')['denylist'][0]}@1.0.0"}) +def b_deb(c): c.sboms["x86_64"]["components"].append({"purl": "pkg:deb/debian/libc6@2.36"}) +def b_inputs(c): del c.inputs["aarch64"]["runner"]["docker_version"] +def b_base(c): c.git_show = lambda p: b"FROM docker.io/library/alpine:3.24\n" if p.endswith("Containerfile") else SCRIPT +def b_base3(c): + for a in v.ARCHES: c.inputs[a]["base_image_index_digest"] = "sha256:" + "1" * 64 +def b_base4(c): c.git_show = lambda p: b"ARG B=x\nFROM $B\n" if p.endswith("Containerfile") else SCRIPT +def b_base2(c): c.inputs["aarch64"]["base_image_index_digest"] = "sha256:" + "1" * 64 +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_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], + "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]} + + +@pytest.mark.parametrize("check,mutator", [(k, m) for k, ms in BAD.items() for m in ms]) +def test_bad_fixture_fails(tmp_path, check, mutator): + c = good(tmp_path); mutator(c) + rule = next(r for r in RULES.values() if r["check"] == check) + assert v.run_rule(rule, c), f"{check} accepted {mutator.__name__}" + + +def test_empty_sbom_fails_closed(tmp_path): + c = good(tmp_path); c.sboms["x86_64"] = {} + assert v.run_rule(RULES["IMG-7"], c) + + +def test_missing_sbom_fails_closed(tmp_path): + c = good(tmp_path); del c.sboms["aarch64"] + for rid in ("IMG-3", "IMG-4", "IMG-5", "IMG-7"): + assert v.run_rule(RULES[rid], c), rid + + +def test_check_that_raises_is_a_failure(tmp_path): + c = good(tmp_path); c.inputs = None + assert v.run_rule(RULES["IMG-6"], c) + assert v.run_rule({"check": "check_nope"}, c) + + +def test_attest_uses_tag_sha_and_main(tmp_path): + run = FakeRun(); c = good(tmp_path, run) + 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