Reproducible Sbom Hashes With Cosign Attestations For Python Wheels
Written by
Vera Crypt
The bug that sent me down this rabbit hole
I once debugged a production incident where everything looked correct: builds were “on CI,” dependencies were “pinned,” and we had an SBOM (software bill of materials). Yet the security team flagged that the SBOM didn’t match the artifact we actually deployed.
What I eventually realized is that many SBOM pipelines produce a document but don’t prove it was generated from the exact bytes of the artifact you shipped. In software supply chain terms, you want a cryptographic link between:
- The artifact bytes (e.g., a Python wheel
.whl) - The SBOM bytes (e.g., a CycloneDX JSON file)
- The identity that created/claims it (signing/attestation)
To make that link real, I implemented a “reproducible SBOM hash” workflow for Python wheels using Cosign attestations. The niche part (and the reason I’m writing this) is that Python wheel metadata can vary in subtle ways, so I focused on generating a deterministic SBOM and checking it against the wheel’s content hash before signing anything.
What I mean by “reproducible SBOM hash”
A plain SBOM file might be byte-for-byte different between two builds even if the dependencies are effectively the same—because of ordering, timestamps, normalized formats, etc.
So I built the workflow around one rule:
The SBOM attestation includes (a) the wheel content digest and (b) the hash of the SBOM file bytes.
During verification, you recompute the wheel digest from the artifact and recompute the SBOM hash from the SBOM file bytes. If either doesn’t match, the attestation is rejected.
That makes the SBOM “tamper-evident” and bound to the exact artifact.
The setup I used (Python wheel + CycloneDX + Cosign)
Tools
- Python (to build the wheel)
- CycloneDX (to generate SBOM; I used the Python package form)
- Cosign (to store attestations in an OCI registry)
Install dependencies:
pip install cyclonedx-bom curl -fsSL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 -o cosign chmod +x cosign
Step 1: Build a wheel deterministically (enough to be checkable)
Python wheel contents include metadata and files. Full bit-for-bit reproducible wheels can be tricky depending on build tooling, but for my use case I needed stable, checkable content hashing. Even if the wheel isn’t perfectly reproducible across machines, the verification step still works because it binds to the actual bytes shipped.
Here’s a tiny example project:
pyproject.toml
[project] name = "supplychain-demo-wheel" version = "0.1.0" dependencies = [ "requests==2.32.3" ]
Build a wheel:
python -m pip install build python -m build --wheel
You’ll get something like:
dist/supplychain_demo_wheel-0.1.0-py3-none-any.whl
Step 2: Generate a CycloneDX SBOM and normalize it
CycloneDX SBOM generation outputs JSON. JSON can differ in whitespace/ordering. To get stable hashing of the SBOM bytes, I normalized the JSON by parsing and re-dumping it with consistent formatting.
Script: generate SBOM + stable bytes hash
sbom_and_hash.py
import hashlib import json import subprocess from pathlib import Path def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def sha256_file(path: Path) -> str: data = path.read_bytes() return sha256_bytes(data) def normalized_json_bytes(path: Path) -> bytes: obj = json.loads(path.read_text(encoding="utf-8")) # Normalize output deterministically return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def main(): whl = Path("dist").glob("*.whl") whl_path = next(whl) out_dir = Path("dist") raw_sbom_path = out_dir / "sbom.cdx.json" # Generate SBOM based on the installed environment requirements. # For wheels, a common pragmatic approach is to generate from the project's dependency lock. # This example uses pip's dependency metadata as the source of truth. # # In a real pipeline, generate SBOM from the exact build environment used for the wheel. subprocess.run([ "cyclonedx-bom", "--output-format", "json", "--output-file", str(raw_sbom_path) ], check=True) # Normalize SBOM JSON to ensure stable hashing of the SBOM "document" norm_sbom_bytes = normalized_json_bytes(raw_sbom_path) norm_sbom_path = out_dir / "sbom.normalized.cdx.json" norm_sbom_path.write_bytes(norm_sbom_bytes) wheel_hash = sha256_file(whl_path) sbom_hash = sha256_bytes(norm_sbom_bytes) print(f"WHEEL_PATH={whl_path}") print(f"WHEEL_SHA256={wheel_hash}") print(f"SBOM_PATH={norm_sbom_path}") print(f"SBOM_SHA256={sbom_hash}") if __name__ == "__main__": main()
Run it:
python sbom_and_hash.py
This prints two critical values:
WHEEL_SHA256SBOM_SHA256
Those values are what I later embedded into the attestation payload.
Why this normalization step matters: without it, two identical SBOM structures can hash differently due to formatting or key ordering. By hashing the normalized JSON bytes, the “SBOM hash” is stable and meaningful.
Step 3: Sign the SBOM hash as a Cosign attestation bound to the wheel
Cosign attestations are metadata objects you can verify later. I used an attestation type that carries custom JSON payload.
Script: create a signed attestation
attest_sbom_hash.py
import json import subprocess from pathlib import Path def run(cmd): subprocess.run(cmd, check=True) def main(): # Read the values from the previous script output by recomputing them here. # Keeping it simple and self-contained for the blog. whl_path = next(Path("dist").glob("*.whl")) # Generate normalized SBOM and hashes (reuse logic from earlier approach) raw_sbom_path = Path("dist") / "sbom.cdx.json" subprocess.run([ "cyclonedx-bom", "--output-format", "json", "--output-file", str(raw_sbom_path) ], check=True) import hashlib import json as _json def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def normalized_json_bytes(path: Path) -> bytes: obj = _json.loads(path.read_text(encoding="utf-8")) return _json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") norm_sbom_bytes = normalized_json_bytes(raw_sbom_path) norm_sbom_path = Path("dist") / "sbom.normalized.cdx.json" norm_sbom_path.write_bytes(norm_sbom_bytes) wheel_sha = sha256_bytes(whl_path.read_bytes()) sbom_sha = sha256_bytes(norm_sbom_bytes) payload = { "artifact": { "type": "python-wheel", "path": str(whl_path), "sha256": wheel_sha }, "sbom": { "format": "cyclonedx-json", "path": str(norm_sbom_path), "sha256": sbom_sha }, "generator": { "tool": "cyclonedx-bom", "normalized": True } } payload_file = Path("dist") / "sbom-attestation-payload.json" payload_file.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") # Attest the SBOM metadata against the wheel artifact digest. # Cosign can attach attestations to an OCI reference. # # For file-based workflows, I packaged the wheel into an OCI artifact # reference via cosign's "attest" with --predicate directly against a ref. # # Here, I’m using an OCI layout reference in a local registry for clarity. # If your environment uses a remote registry, replace the reference. target_ref = "localhost:5000/supplychain-demo/python-wheel:0.1.0" # Create an unsigned OCI artifact for the wheel content reference. # (In a real pipeline you’d push from CI; omitted for brevity.) # # The key part: we attach an attestation to the target_ref and include the hashes. run([ "./cosign", "attest", "--type", "sbom.reproducible-hash.v1", "--predicate", str(payload_file), target_ref ]) if __name__ == "__main__": main()
This script focuses on the attestation payload: it includes both digests.
Important: Cosign attestations attach to an OCI reference. In most real deployments, your wheel and/or container image already has an OCI digest. In my local lab, I used a local registry and an OCI reference for the wheel artifact.
To run:
python attest_sbom_hash.py
Step 4: Verify—what I check and why it closes the “SBOM mismatch” gap
Verification is where the workflow becomes useful. Instead of trusting that “the SBOM came from the build,” I force an equality check between:
- The wheel bytes you provide now
- The SBOM bytes you provide now
- The signed attestation payload hashes
Script: verify the hashes match the attestation payload
verify_attestation.py
import hashlib import json import subprocess from pathlib import Path def sha256_file(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def normalized_json_bytes(path: Path) -> bytes: obj = json.loads(path.read_text(encoding="utf-8")) return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def main(): whl_path = next(Path("dist").glob("*.whl")) raw_sbom_path = Path("dist") / "sbom.cdx.json" norm_sbom_path = Path("dist") / "sbom.normalized.cdx.json" # If you already generated normalized SBOM and it exists, reuse it. if not norm_sbom_path.exists(): norm_sbom_path.write_bytes(normalized_json_bytes(raw_sbom_path)) wheel_sha_local = sha256_file(whl_path) sbom_sha_local = sha256_file(norm_sbom_path) # Fetch the attestation from the target OCI reference. target_ref = "localhost:5000/supplychain-demo/python-wheel:0.1.0" # Cosign verify output is JSON-ish depending on flags; use --output for scripting. cmd = [ "./cosign", "verify-attestation", "--type", "sbom.reproducible-hash.v1", "--certificate-identity-regexp", ".*", "--certificate-oidc-issuer-regexp", ".*", "--output", "json", target_ref ] proc = subprocess.run(cmd, capture_output=True, text=True, check=True) verified = json.loads(proc.stdout) # Cosign returns a list of statements; pick the one that matches the expected predicate structure. # The exact shape can vary; this is a robust approach: scan for our predicate keys. predicate = None for item in (verified.get("attestationStatements", None) or []): p = item.get("predicate", None) if p and "sbom" in p and "artifact" in p: predicate = p break if predicate is None: raise SystemExit("No matching attestation predicate found") wheel_sha_att = predicate["artifact"]["sha256"] sbom_sha_att = predicate["sbom"]["sha256"] if wheel_sha_local != wheel_sha_att: raise SystemExit( f"Wheel hash mismatch!\nLocal: {wheel_sha_local}\nAttested: {wheel_sha_att}" ) if sbom_sha_local != sbom_sha_att: raise SystemExit( f"SBOM hash mismatch!\nLocal: {sbom_sha_local}\nAttested: {sbom_sha_att}" ) print("Verification passed: local wheel and SBOM match attested hashes.") if __name__ == "__main__": main()
Run verification:
python verify_attestation.py
What happens in a failure case
I simulated tampering by changing the SBOM file (even by reformatting JSON) and re-running verification.
- If the SBOM structure is same but bytes differ without normalization, the SBOM hash mismatch triggers.
- If a dependency changes (or SBOM generator runs in a different environment), SBOM normalized bytes change, hash mismatch triggers.
- If the wheel artifact differs from what the attestation claims, wheel hash mismatch triggers.
That means the “SBOM mismatch” incident I saw earlier becomes impossible to miss: the pipeline either verifies or fails.
Step 5: Where this fits into DevSecOps and Zero Trust thinking
In a Zero Trust model (meaning “never trust by default; always verify explicitly”), this pattern is a strong primitive:
- Verification becomes a gate: deployment doesn’t accept an artifact unless attested hashes match.
- Security travels with artifacts: the claim (SBOM digest + wheel digest) is attached to the thing that gets deployed.
- It reduces reliance on human process: it doesn’t matter who pressed which button in CI; the cryptographic link is the source of truth.
In DevSecOps terms, the security step is “just another pipeline check” right alongside tests and packaging—early enough to prevent bad artifacts from ever reaching production.
Closing thoughts
I built this reproducible SBOM hashing + Cosign attestation workflow because I wanted a hard cryptographic guarantee that the SBOM corresponds to the exact Python wheel bytes shipped. By normalizing the SBOM JSON, hashing both the wheel and the normalized SBOM, and embedding those digests into a signed Cosign attestation, I turned a fuzzy “SBOM generated during build” into a precise “SBOM verified against the artifact” check.