SBOM & provenance attestations
Prove what shipped and how it was built.
Your checkout-api image passed SAST (static application security testing), dependency scanning and container scanning. The sign job stamped it with cosign. You promote it to a customer's cluster and their admission controller throws it straight back: "image has a signature but no SBOM attestation and no build provenance." Two things missing, in other words: a parts list and a build record. The signature proved who pushed the image. It said nothing about what is inside that image, and nothing about how it was built. Those two questions, what and how, are where supply-chain security actually lives. This lesson answers both inside your GitLab pipeline, then checks the answers the same way that admission controller does.
SBOM, provenance, attestation: three different jobs
Pick up a jar of pasta sauce. The back label lists every ingredient by name. That is an SBOM (Software Bill of Materials): a machine-readable inventory of every component, version and licence inside a built artifact, written in one of two standard schemas, CycloneDX or SPDX (Software Package Data Exchange). The same jar also carries a batch code tying it to a factory, a production line and a date. That is provenance: a signed statement of how the artifact was produced, naming the source commit, the builder that ran, the pipeline and the parameters. Both start life as ordinary JSON (JavaScript Object Notation) files that anyone could type up. What makes them hard to fake is an attestation: your SBOM or provenance document (the predicate) sealed inside a DSSE (Dead Simple Signing Envelope) and cryptographically tied to the image digest rather than its tag. Keep the three verbs apart. Signing answers who. The SBOM answers what. Provenance answers how. The payoff shows up on a bad Friday. When the next Log4Shell lands, "which of our running images contain the vulnerable version?" becomes a query over stored SBOMs. Minutes, not a three-day manual audit.
Build the SBOM and seal it, in a real job
This is the step auditors most often find sitting there as a commented-out line. Run it for real. The build job pushes the image and writes down its digest. The attest job scans that exact digest with syft to produce a CycloneDX SBOM, then cosign attest wraps the SBOM as a cyclonedx predicate and signs it keyless. Keyless means there is no long-lived private key sitting anywhere for someone to steal. GitLab mints a short-lived OIDC (OpenID Connect) token through id_tokens with an audience of sigstore, Sigstore's Fulcio hands back a short-lived certificate bound to your pipeline identity, cosign signs with that certificate, and the signature plus certificate land in Rekor, a public append-only log that anyone can read. The rules:if line matters as much as any of it. It pins the whole job to the default branch, so a pipeline running from somebody's fork can never mint an attestation under your identity.
stages: [build, attest]build:stage: buildimage:name: gcr.io/kaniko-project/executor:v1.23.2-debugentrypoint: [""]variables:RUNNER_GENERATE_ARTIFACTS_METADATA: "true" # GitLab's built-in SLSA provenancescript:- /kaniko/executor --context "$CI_PROJECT_DIR"--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"--digest-file digest.txt- echo "DIGEST=$(cat digest.txt)" > build.envartifacts:reports:dotenv: build.env # exports $DIGEST to later jobspaths: [digest.txt]attest-sbom:stage: attestimage: ubuntu:24.04needs: [build]id_tokens:SIGSTORE_ID_TOKEN:aud: sigstorerules:- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'before_script:- apt-get update && apt-get install -y curl- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin- curl -sSfL -o /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64- chmod +x /usr/local/bin/cosignscript:- cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"- syft "$CI_REGISTRY_IMAGE@$DIGEST" -o cyclonedx-json=sbom.cdx.json- cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$CI_REGISTRY_IMAGE@$DIGEST"artifacts:paths: [sbom.cdx.json]
The attest-sbom job log is where you watch it happen. syft catalogues the image, cosign uploads a signed attestation, and a Rekor transparency-log index comes back. That index is the receipt a verifier will ask for later.
$ syft "$CI_REGISTRY_IMAGE@$DIGEST" -o cyclonedx-json=sbom.cdx.json✔ Loaded image registry.gitlab.com/acme/checkout-api@sha256:5f4c…e21a✔ Cataloged contents├── ✔ Packages [214 packages]├── ✔ File digests [1,893 files]└── ✔ Executables [126 executables]$ cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$CI_REGISTRY_IMAGE@$DIGEST"Generating ephemeral keys...Retrieving signed certificate...Issuer: https://gitlab.comSubject: https://gitlab.com/acme/checkout-api//.gitlab-ci.yml@refs/heads/maintlog entry created with index: 148203915Uploading attestation to registry: registry.gitlab.com/acme/checkout-apiJob succeeded (exit code 0)
The provenance GitLab already writes for you
The SBOM covers what is inside the box. Provenance covers where the box came from, and you do not have to write it by hand. Setting RUNNER_GENERATE_ARTIFACTS_METADATA: "true" makes GitLab Runner emit an in-toto Statement (in-toto is the open standard for describing how a piece of software was built) carrying an SLSA (Supply-chain Levels for Software Artifacts) v1.0 provenance predicate next to every artifact upload, saved as artifacts-metadata.json when the artifact has no name of its own. Under buildDefinition you get the buildType, a link to the runner's provenance spec, plus the external and internal run parameters. Under runDetails you get the builder id (which specific runner did the work), the invocation id, and the start and finish times. One detail decides everything downstream: the runner writes this statement unsigned. It is plain metadata rather than a signed DSSE envelope, so anybody who can write the file can forge it. That is why it counts as SLSA Build Level 1 and no higher until cosign signs it. Pull it out of the build job's artifacts.
{"_type": "https://in-toto.io/Statement/v0.1","predicateType": "https://slsa.dev/provenance/v1","subject": [{ "name": "digest.txt","digest": { "sha256": "9b2c1e…f7" } }],"predicate": {"buildDefinition": {"buildType": "https://gitlab.com/gitlab-org/gitlab-runner/-/blob/v17.5.0/PROVENANCE.md","externalParameters": {"source": "https://gitlab.com/acme/checkout-api","entryPoint": "build"},"internalParameters": {"name": "green-4.saas-linux-medium-amd64.runners-manager.gitlab.com/default","executor": "docker+machine","architecture": "amd64","job": "10340684631"},"resolvedDependencies": [{ "uri": "https://gitlab.com/acme/checkout-api","digest": { "sha256": "bdd2ecda9ef57b129c88617a0215afc9fb223521" } }]},"runDetails": {"builder": {"id": "https://gitlab.com/acme/checkout-api/-/runners/12058652","version": { "gitlab-runner": "v17.5.0" }},"metadata": {"invocationId": "10340684631","startedOn": "2026-07-14T09:11:40Z","finishedOn": "2026-07-14T09:13:02Z"}}}}
Check it at the door
An attestation nobody reads is paperwork, so verification is the load-bearing step. cosign verify-attestation re-fetches the attestation for that digest, confirms the Rekor inclusion proof, and then does the part that matters most for keyless signing: it checks that the signing certificate's identity is the pipeline you expected. You pin two claims. One is the OIDC issuer (https://gitlab.com). The other is the subject, which is the exact CI (continuous integration) config file and ref. If an attacker builds a byte-identical image from a different project or a different branch, the signature is perfectly valid and the identity still will not match, so verification fails. That mismatch is the whole point of pinning.
cosign verify-attestation \--type cyclonedx \--certificate-oidc-issuer "https://gitlab.com" \--certificate-identity "https://gitlab.com/acme/checkout-api//.gitlab-ci.yml@refs/heads/main" \"registry.gitlab.com/acme/checkout-api@sha256:5f4c…e21a" \| jq -r '.payload | @base64d | fromjson | .predicateType'
Verification for registry.gitlab.com/acme/checkout-api@sha256:5f4c…e21a --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted CA certificatesCertificate subject: https://gitlab.com/acme/checkout-api//.gitlab-ci.yml@refs/heads/mainCertificate issuer URL: https://gitlab.comhttps://cyclonedx.org/bom$ echo "exit code: $?"exit code: 0
Where this lands on the SLSA ladder
SLSA's Build track is a ladder that grades how hard your build is to tamper with, and the work above maps onto it rung by rung. Level 1 means provenance exists. That is what RUNNER_GENERATE_ARTIFACTS_METADATA buys you, and unsigned metadata is forgeable, so the climb stops there. Level 2 means the provenance is authentic, signed by a hosted build platform. Keyless cosign plus Rekor gives you exactly that, because the signature is tied to GitLab's OIDC identity and written into a log anyone can audit. Level 3 is the hard rung. It wants a hardened, isolated, throwaway builder whose signing identity cannot be reached from user-defined build steps, so even the person who wrote the pipeline cannot forge its provenance. Most GitLab setups reach Level 2 for free and climb toward Level 3 with isolated runners. Know which rung you are aiming for, so your gate asks for evidence that earns trust rather than a file that merely exists.
Every image now leaves your pipeline with a searchable ingredients list and a verifiable birth record, both signed and both pinned to a digest. That still stops nothing on its own, because producing evidence and demanding evidence are two separate jobs. The tool you meet next is the one that demands it. GitOps with Argo CD or Flux puts a policy controller in front of the cluster that re-runs this same verify-attestation call at admission time and refuses to sync any image whose attestations do not check out.
cosign verify-attestation --type cyclonedx against checkout-api:latest, gets exit 0, and admits the image. Why is that still unsafe?--type flag selects it. The format is not what is broken here.--certificate-identity and --certificate-oidc-issuer set, exit 0 does confirm the identity matched. The real flaw is verifying a tag instead of a pinned digest.RUNNER_GENERATE_ARTIFACTS_METADATA: "true" makes GitLab Runner write an in-toto SLSA provenance statement alongside every artifact. On its own, which SLSA Build level does that reach, and what stops it going higher?Try this
Work through “Where this lands on the SLSA ladder” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: attest the digest, never the tag, and never from a fork pipeline. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.