Sachin Chaurasiya

DevSecOps Part 5 of 5 · Software Supply Chain Security

Attestations and Provenance: Binding the SBOM to the Image

Turn the SBOM from part 2 into a Cosign attestation bound to the image digest, verify it and read the predicate back, list what hangs off an image with cosign tree, and see what SLSA build provenance adds.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
7 min read
Difficulty
intermediate

Reviewed Tested with Cosign 3.1.3 (ghcr.io/sigstore/cosign/cosign image), Syft 1.51.1 SBOM (CycloneDX 1.7), registry:2, Docker 29.1

On this page

Overview

Part 2 left the SBOM as a file next to the image. Part 4 signed the image. Neither connects the two: a verifier who has the digest still has to trust that ci-demo-1.4.2.cdx.json describes it and has not been edited. An attestation closes that gap. It is a signed statement whose subject is the image digest and whose predicate is a document about it, the SBOM here, stored in the registry beside the image, verifiable with the same key.

The attestation section was run end to end against the local registry from part 4 with Cosign 3.1.3. The provenance section describes what a CI platform generates and how it is verified; it was not executed here, because build provenance is produced by the build platform, not by a command you run afterwards, and this site’s pipeline does not currently generate it.

Prerequisites

  • The signed image, key pair, signing config and cosign shell function from part 4
  • The SBOM of the same image from part 3 (ci-demo-1.4.2.cdx.json), copied to sbom.cdx.json

Attest the SBOM

cosign attest wraps a predicate document in an in-toto statement, sets the subject to the image digest, signs the whole thing and pushes it to the registry as a referrer of the image. --type cyclonedx sets the predicate type so verifiers know how to read it.

DIGEST=sha256:5743684a366b5d6e91df18c41f9f3a9ceab8452d62b2c3fea07015552ce1b636
cosign attest --key cosign.key --signing-config signing-config.json --allow-http-registry \
  --type cyclonedx --predicate sbom.cdx.json "registry:5000/ci-demo@$DIGEST"
Using payload from: sbom.cdx.json
Signing artifact...

The SBOM is now in the registry, addressed by the image digest, and cannot be altered without invalidating a signature. It is also exactly the SBOM from part 3, which the scan gate ran on, so the scan result and the deployed image now refer to the same inventory.

Verify and read it back

verify-attestation checks the signature, checks that the subject is the digest you asked about, and prints the signed envelope. The predicate is base64 inside it; decode and read:

cosign verify-attestation --key cosign.pub --allow-http-registry --insecure-ignore-tlog \
  --type cyclonedx "registry:5000/ci-demo@$DIGEST" \
| python3 -c '
import json, sys, base64
env = json.loads(sys.stdin.readline())
stmt = json.loads(base64.b64decode(env["payload"]))
print("payloadType:", env["payloadType"])
print("predicateType:", stmt["predicateType"])
print("subject:", stmt["subject"][0]["name"], stmt["subject"][0]["digest"])
print("predicate bomFormat:", stmt["predicate"]["bomFormat"], "components:", len(stmt["predicate"]["components"]))
'
payloadType: application/vnd.in-toto+json
predicateType: https://cyclonedx.org/bom
subject: registry:5000/ci-demo {'sha256': '5743684a366b5d6e91df18c41f9f3a9ceab8452d62b2c3fea07015552ce1b636'}
predicate bomFormat: CycloneDX components: 105

Four facts, each of which a verifier can now rely on: the envelope is an in-toto statement, the predicate is a CycloneDX BOM, the subject is this digest and no other, and the BOM inside is the 105-component inventory of the hardened image (22 packages plus the file evidence Syft records). Feed the decoded predicate to Grype and you have rescanned the deployed image from the registry alone, without pulling it.

--type matters on the verify side too: an image can carry several attestations (an SBOM, a scan result, a provenance statement), and asking for cyclonedx returns only the ones that claim to be one.

What hangs off the image now

cosign tree --allow-http-registry "registry:5000/ci-demo@$DIGEST"
📦 Supply Chain Security Related artifacts for an image: registry:5000/ci-demo@sha256:5743684a…
└── 🔗 https://cyclonedx.org/bom artifacts via OCI referrer: registry:5000/ci-demo@sha256:0e2ac577…
   └── 🍒 sha256:e23892c3…
└── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: registry:5000/ci-demo@sha256:77f56bdd…
   └── 🍒 sha256:a6d9f7e8…

One signature, one SBOM attestation, both stored as OCI referrers of the image manifest. A registry that supports the referrers API (Distribution 2.8+, most managed registries) keeps them with the image through copies and promotions; cosign copy moves image, signature and attestations together.

Build provenance

The SBOM says what is in the image. Provenance says how it was made: which builder, from which source revision, with which parameters, producing which digest. The standard predicate is SLSA provenance, and the property that makes it worth having is who generates it. An attestation the build script signs proves only that the script ran; provenance generated by the build platform (GitLab CI’s artifact attestations, GitHub’s attest-build-provenance, or a SLSA generator running outside the job) proves the platform saw the job build that digest from that commit, which is the SLSA level-2 and level-3 distinction from part 1.

Verifying provenance is the same command with a different type, plus a policy on the contents:

# Configuration only: requires provenance generated by a build platform, which this lab did not produce.
cosign verify-attestation --type slsaprovenance1 \
  --certificate-identity-regexp '^https://gitlab.com/example-group/ci-demo//' \
  --certificate-oidc-issuer https://gitlab.com \
  registry.example.com/ci-demo@sha256:…

The predicate’s buildDefinition.externalParameters then names the repository and ref; a deploy policy can require that the builder is your CI project and the source is your protected branch before the image runs. GitLab generates SLSA provenance for job artifacts when RUNNER_GENERATE_ARTIFACTS_METADATA is set on the runner; attaching it to the image as a Cosign attestation is a cosign attest --type slsaprovenance1 --predicate step in the same job. None of this was executed here.

Placing it in the pipeline

The whole path, as pipeline stages, in the order the artifacts are produced:

build: # docker build; push by tag; resolve and export DIGEST
sbom: # syft registry:$IMAGE@$DIGEST -o cyclonedx-json=sbom.cdx.json
scan: # grype sbom:sbom.cdx.json --fail-on medium --only-fixed
sign: # cosign sign $IMAGE@$DIGEST            (keyless with id_tokens, or a KMS key)
attest: # cosign attest --type cyclonedx --predicate sbom.cdx.json $IMAGE@$DIGEST
deploy: # cosign verify + verify-attestation --type cyclonedx, then deploy $IMAGE@$DIGEST

DIGEST is resolved once, after the push, and every later stage uses it. Signing happens after the scan passes, so a signature means “this digest passed the gate on this date”, which is a stronger claim than “this digest exists”. The DevSecOps Pipeline path has the gate stages; this path adds the last three.

Security Considerations

  • Attestations are readable by anyone who can pull the image, and the SBOM is a map of vulnerable versions. Keep registry access as tight as the image warrants.
  • verify-attestation with a key checks the signature, not the content. Policy on the content (a maximum severity in a scan attestation, an allowed builder in provenance) is a second step, in the deploy job or in an admission policy engine.
  • Removing an attestation from the registry leaves the image valid. A deploy policy that requires an SBOM attestation of a given type is what makes its absence a failure.

Troubleshooting

SymptomCauseFix
verify-attestation finds nothingWrong --type, or the attestation was made for another digestMatch the type used at attest time; cosign tree lists what exists
Predicate is empty or the wrong document--predicate pointed at the wrong fileRe-attest; attestations are additive, so also filter by type when verifying
Attestations disappear after copying the imageThe copy tool ignored referrersUse cosign copy, or a tool that copies referrers
Registry rejects the referrer pushRegistry without OCI referrers/tag-fallback supportUpgrade the registry, or let Cosign fall back to the sha256-… tag scheme

Conclusion

The image now carries its own evidence: a signature that says who vouched for the digest, and an attestation that binds the inventory the scan gate read to that same digest. A verifier with the public key and the digest can check both without trusting the pipeline’s word for it. That closes the chain from part 1: source and dependencies locked, build pinned, image inventoried and scanned, digest signed and attested, deployment verifying before it runs. The existing Trivy article and the SBOM lab are the places to practise the scanning half with a second toolchain.

References

Keep reading