Attesting builds with SLSA provenance in CI
Generate an SBOM and signed SLSA provenance for every build, attach them with cosign, and prove exactly where an artifact came from when auditors ask.
An SBOM in an S3 bucket answers questions after the fact. An SBOM attached to the image digest as a signed attestation, with a provenance statement next to it, lets the cluster refuse to run anything that lacks them, which turns two JSON files into a control. The mechanics are three commands and one policy; the parts that need care are which identity signs, which predicate type each file gets, and a caveat from the Sigstore documentation about how big an attestation should be.
Produce: the builder can make both
build:stage: buildimage: docker:28services: [docker:28-dind]script:- docker buildx create --use # the classic image store cannot hold attestations- docker buildx build --push--provenance=mode=max --sbom=true--tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .- DIGEST=$(docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" --format '{{json .Manifest.Digest}}' | tr -d '"')- echo "DIGEST=$DIGEST" > build.envartifacts:reports: { dotenv: build.env }
BuildKit attaches a minimal provenance statement to every image it builds; --provenance=mode=max records the full build definition and materials, and --sbom=true adds an SBOM generated during the build, both as in-toto statements in the image index. That is the cheapest route to having the two documents at all, and signing the index digest with cosign covers everything the index references. The Syft route produces a richer SBOM in the format of your choice and is the one to use when the SBOM also has to be archived and queried on its own, as in the SBOM reference; the two are not exclusive.
Attest: predicate types are part of the contract
#!/usr/bin/env bashset -euo pipefailIMAGE="$CI_REGISTRY_IMAGE@$DIGEST"cosign sign --yes "$IMAGE" # the signature: "this digest, from this workflow"syft "$IMAGE" -o cyclonedx-json=sbom.cdx.json -qcosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$IMAGE" # the inventory, as a CycloneDX predicate# provenance: a SLSA v1 statement produced by the builder or the platformcosign attest --yes --type slsaprovenance1 --predicate provenance.json "$IMAGE"
--type names the predicate, and the verifier asks for attestations by that name, so cyclonedx for a CycloneDX file and slsaprovenance1 for a SLSA v1 statement are not decoration: a Kyverno rule that requires a cyclonedx attestation ignores the same SBOM attached as custom. The Sigstore documentation adds a caveat worth reading before the SBOM gets large: with an SBOM predicate type, the whole SBOM is inside the signed bundle, and every verification downloads all of it. For a small service that is fine; for an image with thousands of components, the documented alternative is to push the SBOM as its own OCI artifact, sign that, and attest a small custom predicate carrying its digest.
Where the provenance file comes from depends on the platform. BuildKit's statement can be extracted from the index with docker buildx imagetools inspect. GitLab's runner generates a SLSA level 2 provenance statement for job artifacts, and the GitLab SLSA CI/CD component signs it; GitHub's attest-build-provenance action produces and stores one for a container digest. Whichever produces it, the statement's subject must be the image digest, or the attestation describes something other than what the cluster is about to run.
Verify: identity first, then content
cosign verify-attestation --type cyclonedx "$IMAGE" \ --certificate-oidc-issuer https://gitlab.acme.dev \ --certificate-identity-regexp "^https://gitlab.acme.dev/shop/api//.gitlab-ci.yml@refs/tags/v" \ | jq -r ".payload | @base64d | fromjson | .predicate.components | length"Verification for registry.acme.dev/shop/api@sha256:9f2a1c… -- - The cosign claims were validated - Existence of the claims in the transparency log was verified offline - The code-signing certificate was verified using trusted certificate authority certificates230identity pinned to one project and one workflow on a version tag; then the SBOM itself is readable from the payloadThe two certificate flags are the decision. Without them a keyless verification accepts any Fulcio certificate, which means any public CI job anywhere; with them it accepts one pipeline definition in one project on one ref pattern. The same flags apply to cosign verify for the signature and to verify-attestation for each predicate type, and the payload of a verified attestation is the SBOM or provenance itself, base64 inside a DSSE envelope. A deploy job can therefore check content as well as identity: a provenance whose builder.id is the expected runner, an SBOM whose component count is not zero.
verifyImages:- imageReferences: ["registry.acme.dev/shop/*"]failureAction: Enforceattestations:- type: https://cyclonedx.org/bom # the cyclonedx predicate typeattestors:- entries:- keyless:issuer: https://gitlab.acme.devsubjectRegExp: "^https://gitlab.acme.dev/shop/api//.gitlab-ci.yml@refs/tags/v"conditions:- all:- key: "{{ components | length(@) }}"operator: GreaterThanvalue: 0- type: https://slsa.dev/provenance/v1attestors:- entries:- keyless:issuer: https://gitlab.acme.devsubjectRegExp: "^https://gitlab.acme.dev/shop/api//.gitlab-ci.yml@refs/tags/v"
The signature and the identity pinning are the same ones keyless signing with Kyverno sets up for images alone; attestations add the second question, what is inside and how it was built, on top of the first. The policy engine that asks it can equally be Gatekeeper with an external data provider, and the choice follows whichever the cluster already runs.
Go deeper in a courseSoftware supply chain securitySBOMs, SLSA provenance, keyless signing and admission verification from commit to cluster.View course