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
8 changes: 8 additions & 0 deletions .github/scripts/assert-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ no_netstack() { for c in opendal reqwest rustls openssl hyper-rustls; do [ "$(
pinned() { [ "$(count_purl "pkg:cargo/$1")" -ge 1 ]; }
frame_env() { podman image inspect "$IMG" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -q '^SCCACHE_MAX_FRAME_LENGTH='; }
listen_ok() { podman run --rm -v "$HERE/listen-check.sh:/listen-check.sh:ro" --entrypoint sh "$IMG" /listen-check.sh; }
no_readelf_objdump() { podman run --rm --entrypoint sh "$IMG" -c '! find / -xdev ! -type d \( -name "*readelf*" -o -name "*objdump*" \) | grep -q .'; }
no_perl() { podman run --rm --entrypoint sh "$IMG" -c '! command -v perl >/dev/null 2>&1'; }
no_pip() { podman run --rm --entrypoint sh "$IMG" -c '! python3 -m pip --version >/dev/null 2>&1'; }
no_xcoff() { podman run --rm --entrypoint sh "$IMG" -c 'for t in ld nm objcopy strip; do $t --help 2>&1 | grep -i "supported targets" | grep -qiE "xcoff|aix|rs6000" && exit 1; done; exit 0'; }

check "control: sccache binary exists in image" have_sccache
check "no sccache-dist binary" no_dist_bin
Expand All @@ -23,4 +27,8 @@ check "pinned bytes@1.10.1 present" pinned bytes@1.10.1
check "pinned rand@0.8.5 present" pinned rand@0.8.5
check "SCCACHE_MAX_FRAME_LENGTH set in image" frame_env
check "server seen on loopback; no other listeners" listen_ok
check "no readelf or objdump in image" no_readelf_objdump
check "no perl in image" no_perl
check "no pip in image" no_pip
check "no XCOFF/AIX target in binutils" no_xcoff
exit $fail
5 changes: 3 additions & 2 deletions .github/scripts/check-vex-coverage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# usage: check-vex-coverage.py SCAN.json SBOM.json VEX.json OUT.json
import json, sys, datetime as dt
import hashlib, json, sys, datetime as dt

MAX_DB_AGE_DAYS, MIN_COMPONENTS = 3, 100

Expand All @@ -27,6 +27,7 @@ def ok_status(s, severity):

def main(scan_p, sbom_p, vex_p, out_p):
scan, sbom, vex = (json.load(open(p)) for p in (scan_p, sbom_p, vex_p))
sha = {k: hashlib.sha256(open(q, 'rb').read()).hexdigest() for k, q in (('scan', scan_p), ('sbom', sbom_p), ('vex', vex_p))}
problems, covered, uncovered, seen = [], [], [], set()
n = len(sbom.get("components", []))
if n < MIN_COMPONENTS:
Expand Down Expand Up @@ -55,7 +56,7 @@ def main(scan_p, sbom_p, vex_p, out_p):
json.dump({"verdict": verdict, "scanner": desc.get("name"), "scanner_version": desc.get("version"),
"db_built": built, "sbom_components": n, "findings": len(covered) + len(uncovered),
"covered": covered, "uncovered": uncovered, "problems": problems,
"vex_statements_without_finding": unused}, open(out_p, "w"), indent=2, sort_keys=True)
"vex_statements_without_finding": unused, "input_sha256": sha}, open(out_p, "w"), indent=2, sort_keys=True)
print(f"{verdict}: {len(covered)} covered, {len(uncovered)} uncovered, problems={problems}")
return 0 if verdict == "pass" else 1

Expand Down
79 changes: 78 additions & 1 deletion .github/scripts/verify_builder_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
class Ctx:
def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex,
spdx=None, assets=None, run=None, git_show=None,
asserts=None, artifacts_dir=None):
asserts=None, artifacts_dir=None, coverage=None):
self.root = Path(root)
self.tag = tag
self.tag_sha = tag_sha
Expand All @@ -49,6 +49,7 @@ def __init__(self, *, root, tag, tag_sha, inputs, digests, sboms, vex,
self.vex = vex # parsed OpenVEX
self.assets = assets # list[str] | None
self.asserts = asserts or {} # arch -> parsed assert-image.<arch>.json
self.coverage = coverage or {} # arch -> parsed vex-coverage.<arch>.json
self.artifacts_dir = Path(artifacts_dir) if artifacts_dir else None
self.run = run or _run
self.git_show = git_show or _git_show(self.root, tag_sha, self.run)
Expand Down Expand Up @@ -330,6 +331,80 @@ def check_gate_script_hash(ctx, params):
return errs


def check_input_hashes(ctx, params):
"""builder-inputs records the sha256 of each input file, and it equals the sha256 of that
file at the tagged commit, for both architectures."""
errs = []
for field, tpl in params["files"].items():
for a in ARCHES:
path = tpl.format(arch=a)
want = hashlib.sha256(ctx.git_show(path)).hexdigest()
got = (ctx.inputs.get(a) or {}).get(field)
if got != want:
errs.append(f"{a}: {field} {got!r} != sha256 of {path} at {ctx.tag_sha[:12]} ({want})")
return errs


def check_coverage_report(ctx, params):
"""vex-coverage.<arch>.json (written by check-vex-coverage.py in release.yml) says pass, is
internally consistent, and is tied by sha256 to the released CycloneDX SBOM, the OpenVEX file
at the tagged commit and the vulnerability scan stored with the release run."""
errs = []
vex_want = hashlib.sha256(ctx.git_show(params["vex_path"])).hexdigest()
for a in ARCHES:
doc = ctx.coverage.get(a)
if not isinstance(doc, dict):
errs.append(f"{a}: vex-coverage.{a}.json missing")
continue
if doc.get("verdict") != "pass":
errs.append(f"{a}: coverage verdict {doc.get('verdict')!r}, expected 'pass'")
if doc.get("scanner") != params["scanner"]:
errs.append(f"{a}: coverage scanner {doc.get('scanner')!r} != {params['scanner']!r}")
for f in ("scanner_version", "db_built"):
val = doc.get(f)
if not (isinstance(val, str) and val.strip()):
errs.append(f"{a}: coverage report {f} missing or empty")
if doc.get("problems") != []:
errs.append(f"{a}: coverage report lists problems: {doc.get('problems')!r}")
covered, uncovered = doc.get("covered"), doc.get("uncovered")
if uncovered != []:
errs.append(f"{a}: coverage report has uncovered findings: {uncovered!r}")
if not isinstance(covered, list):
errs.append(f"{a}: coverage report covered is not a list")
elif doc.get("findings") != len(covered):
errs.append(f"{a}: coverage findings {doc.get('findings')!r} != {len(covered)} covered findings")
sb = ctx.sboms.get(a)
n = len(sb.get("components", [])) if isinstance(sb, dict) else None
if n is None or doc.get("sbom_components") != n:
errs.append(f"{a}: coverage sbom_components {doc.get('sbom_components')!r} != {n!r} components in the released SBOM")
hashes = doc.get("input_sha256")
if not isinstance(hashes, dict):
errs.append(f"{a}: coverage report has no input_sha256")
continue
for key, name in (("scan", f"vuln-scan.{a}.json"), ("sbom", f"sbom.{a}.cdx.json")):
p = artifact_path(ctx, a, name)
if p is None or not p.exists():
errs.append(f"{a}: {name} not found, cannot compare input_sha256.{key}")
elif hashes.get(key) != hashlib.sha256(p.read_bytes()).hexdigest():
errs.append(f"{a}: coverage input_sha256.{key} does not match {name}")
if hashes.get("vex") != vex_want:
errs.append(f"{a}: coverage input_sha256.vex does not match {params['vex_path']} at {ctx.tag_sha[:12]}")
return errs


def check_coverage_attested(ctx, params):
"""vex-coverage.<arch>.json is the predicate of a verified attestation on the image digest,
made by the release workflow from the release tag."""
errs = []
sref = params["source_ref_template"].format(tag=ctx.tag)
for a in ARCHES:
d = _digest_ok(ctx, a, errs)
if d:
_predicate_equals(ctx, a, d, params, params["predicate_type"], sref,
ctx.coverage.get(a), f"vex-coverage.{a}.json", errs)
return errs


def check_pinned_crates(ctx, params):
sboms, errs = _per_arch(ctx, ctx.sboms.get, "SBOM")
for a, s in sboms.items():
Expand Down Expand Up @@ -557,11 +632,13 @@ def f(arch, name):
spdx = {x: jload(f(x, f"sbom.{x}.spdx.json")) for x in ARCHES}
assets = a.assets_file.read_text().split() if a.assets_file else None
asserts = {x: jload(f(x, f"assert-image.{x}.json")) for x in ARCHES}
coverage = {x: jload(f(x, f"vex-coverage.{x}.json")) for x in ARCHES}
return Ctx(root=a.root, tag=a.tag, tag_sha=a.tag_sha, inputs=inputs, digests=digests,
sboms={k: v for k, v in cdx.items() if v is not None},
spdx={k: v for k, v in spdx.items() if v is not None},
vex=jload(a.vex), assets=assets,
asserts={k: v for k, v in asserts.items() if v is not None},
coverage={k: v for k, v in coverage.items() if v is not None},
artifacts_dir=a.artifacts_dir)


Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name: release-image
# gate require a successful build-environment-image run on main for exactly this commit
# attest per arch: verify the build provenance, generate SBOMs with pinned Syft, run
# assert-image.sh against the image and its SBOM (the facts the VEX relies on), and attest
# the CycloneDX SBOM, the SPDX SBOM, the VEX and the assertion results on the image digest
# the CycloneDX SBOM, the SPDX SBOM, the VEX, the assertion results and the VEX coverage report on the image digest
# (signer: this workflow). The build log from the build run travels with them.
# release load the published builder policy, check its sha256 against the value committed
# below, run verify_builder_policy.py (one check per enforced rule), and only then
Expand Down Expand Up @@ -34,10 +34,11 @@ env:
VEX_PATH: environments/alpine-3.24-musl/vex/builder.openvex.json
VEX_PREDICATE_TYPE: https://openvex.dev/ns/v0.2.0
ASSERT_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1
POLICY_TAG: policy-builder-image-v2.1
COVERAGE_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/vex-coverage/v1
POLICY_TAG: policy-builder-image-v2.2
# 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: a9d65504d7ebe4bc83269b6d4880877f04aa24e8810d670b580f21f7b4a8e29b
POLICY_SHA256: 3f41bc1fdba9b96de0dc3f7cf9c30c5fe40b1df8880cdaae1dc75ce2c0b56055

jobs:
gate:
Expand Down Expand Up @@ -240,6 +241,15 @@ jobs:
predicate-path: out/assert-image.${{ matrix.arch }}.json
push-to-registry: true

- name: Attest VEX coverage report
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
predicate-type: ${{ env.COVERAGE_PREDICATE_TYPE }}
predicate-path: out/vex-coverage.${{ matrix.arch }}.json
push-to-registry: true

- name: Upload SBOMs, digest and inputs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
Expand Down Expand Up @@ -292,7 +302,7 @@ jobs:
set -euo pipefail
mkdir -p release-assets
for a in x86_64 aarch64; do
for f in "builder-inputs.$a.json" "image.digest.$a" "sbom.$a.cdx.json" "sbom.$a.spdx.json" "assert-image.$a.json" "build-log.$a.txt.gz"; do
for f in "builder-inputs.$a.json" "image.digest.$a" "sbom.$a.cdx.json" "sbom.$a.spdx.json" "assert-image.$a.json" "build-log.$a.txt.gz" "vex-coverage.$a.json"; do
cp "artifacts/sbom-$a/$f" release-assets/
done
done
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
__pycache__/
.pytest_cache/
out/
*.pem
68 changes: 65 additions & 3 deletions policy/builder-image/POLICY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Policy builder-image v2.1
# Policy builder-image v2.2

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

Expand Down Expand Up @@ -207,7 +207,9 @@ Parameters:
"sbom.aarch64.cdx.json",
"sbom.aarch64.spdx.json",
"sbom.x86_64.cdx.json",
"sbom.x86_64.spdx.json"
"sbom.x86_64.spdx.json",
"vex-coverage.aarch64.json",
"vex-coverage.x86_64.json"
]
}
```
Expand Down Expand Up @@ -343,7 +345,11 @@ Parameters:
"pinned bytes@1.10.1 present",
"pinned rand@0.8.5 present",
"SCCACHE_MAX_FRAME_LENGTH set in image",
"server seen on loopback; no other listeners"
"server seen on loopback; no other listeners",
"no readelf or objdump in image",
"no perl in image",
"no pip in image",
"no XCOFF/AIX target in binutils"
]
}
```
Expand Down Expand Up @@ -387,6 +393,62 @@ Parameters:
}
```

### IMG-18 - Locked inputs match the committed files

- Status: `enforced`
- Check: `check_input_hashes`
- Implemented by: build-environment-image.yml writes the hashes to builder-inputs.json; verify_builder_policy.py compares them to the files at the tagged commit

For both architectures, builder-inputs.json records the sha256 of apk.lock.<arch> and of requirements-builder.txt, and each equals the sha256 of that file at the tagged commit. The image was therefore built against the package list and Python requirements committed at the release tag.

Parameters:

```json
{
"files": {
"apk_lock_sha256": "environments/alpine-3.24-musl/apk.lock.{arch}",
"requirements_builder_sha256": "environments/alpine-3.24-musl/requirements-builder.txt"
}
}
```

### IMG-19 - VEX coverage report passes

- Status: `enforced`
- Check: `check_coverage_report`
- Implemented by: release.yml runs check-vex-coverage.py (Grype scan of the image against the SBOM and VEX) and writes vex-coverage.<arch>.json; verify_builder_policy.py checks the report against the released files

For both architectures, the release carries vex-coverage.<arch>.json with verdict pass: no problems, no vulnerability finding without a matching VEX statement, and a finding count equal to the covered findings. Its recorded sha256 values match the released CycloneDX SBOM, the OpenVEX file at the tagged commit and the vulnerability scan stored with the release run's artifacts. The scan is not a release asset and is not attested, so its hash only ties the report to that run's files. The report shows that every finding Grype reported at release time is fixed or has a VEX statement; it says nothing about vulnerabilities Grype does not know of.

Parameters:

```json
{
"scanner": "grype",
"vex_path": "environments/alpine-3.24-musl/vex/builder.openvex.json"
}
```

### IMG-20 - VEX coverage report is attested

- Status: `enforced`
- Check: `check_coverage_attested`
- Implemented by: release.yml attests vex-coverage.<arch>.json on the image digest; verify_builder_policy.py compares the released file to the attested predicate

For both architectures, vex-coverage.<arch>.json is the predicate of a GitHub attestation on the image digest, with the predicate type named in the parameters, from the release workflow, from the release tag, at the tagged commit.

Parameters:

```json
{
"image": "ghcr.io/patrickryankenneth/python-wheels-builder-alpine",
"predicate_type": "https://github.com/patrickryankenneth/python-wheels-builder/predicate/vex-coverage/v1",
"repo": "patrickryankenneth/python-wheels-builder",
"signer_workflow": "patrickryankenneth/python-wheels-builder/.github/workflows/release.yml",
"source_ref_template": "refs/tags/{tag}"
}
```

## Not guaranteed

- The image is not reproducible, and apk packages, rustup archives and crate tarballs have no content-hash pins.
Expand Down
Loading
Loading