CoursesSecure CI/CD with GitLabSBOM & provenance attestations

SBOM & provenance attestations

Prove what shipped and how it was built.

Advanced12 min · lesson 14 of 17

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.

.gitlab-ci.yml
stages: [build, attest]
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
variables:
RUNNER_GENERATE_ARTIFACTS_METADATA: "true" # GitLab's built-in SLSA provenance
script:
- /kaniko/executor --context "$CI_PROJECT_DIR"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
--digest-file digest.txt
- echo "DIGEST=$(cat digest.txt)" > build.env
artifacts:
reports:
dotenv: build.env # exports $DIGEST to later jobs
paths: [digest.txt]
attest-sbom:
stage: attest
image: ubuntu:24.04
needs: [build]
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
rules:
- 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/cosign
script:
- 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.

Job log · attest-sbom
$ 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.com
Subject: https://gitlab.com/acme/checkout-api//.gitlab-ci.yml@refs/heads/main
tlog entry created with index: 148203915
Uploading attestation to registry: registry.gitlab.com/acme/checkout-api
Job 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.

artifacts-metadata.json (abridged)
{
"_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.

verify (admission gate or MR job)
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'
Output
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 certificates
Certificate subject: https://gitlab.com/acme/checkout-api//.gitlab-ci.yml@refs/heads/main
Certificate issuer URL: https://gitlab.com
https://cyclonedx.org/bom
$ echo "exit code: $?"
exit code: 0
Attest the digest, never the tag, and never from a fork pipeline
cosign binds an attestation to an immutable sha256 digest, but out of habit people verify by tag. If your gate runs verify-attestation against :latest, an attacker who can re-point that tag at an unattested image walks straight through, because the tag you verified is no longer the image you deploy. Resolve to @sha256:… before you attest and before you verify. The second half of this is the fork rule. Keep the attest job on protected refs only, which is what rules:if with the default branch does for you. On a merge-request pipeline coming from a fork, the SIGSTORE_ID_TOKEN subject is the fork's own ref, so an unguarded job hands an outsider a way to mint provenance that looks like yours. Gate on the certificate identity, never on "the signature was valid".

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.

SLSA build levels for a GitLab pipeline
L0: no guarantees
Build & push image
No provenance; you trust whoever pushed
L1: provenance exists
Runner emits metadata
RUNNER_GENERATE_ARTIFACTS_METADATA
Unsigned statement
Describes the build, easy to forge
L2: signed by a hosted builder
cosign attest via OIDC
DSSE-signed to GitLab identity
Rekor transparency log
Tamper-evident, anyone can check
L3: hardened build
Isolated ephemeral runner
Signing identity out of reach of build steps
Non-forgeable provenance
Author cannot fake the builder id

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.

Quick check
01Your admission controller runs cosign verify-attestation --type cyclonedx against checkout-api:latest, gets exit 0, and admits the image. Why is that still unsafe?
Correct — cosign ties attestations to the digest, and a tag is only a mutable pointer. Resolve the tag to its digest and verify that, so the image you checked is the image you run.
Incorrect — No. cosign verifies any predicate type, cyclonedx included, and the --type flag selects it. The format is not what is broken here.
Incorrect — No. The output shows the Rekor inclusion proof being checked. The hole is the mutable tag, not a missing transparency-log check.
Incorrect — No. With --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.
02Setting 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?
Incorrect — No. The runner writes the statement unsigned. Nothing signs it at that step.
Incorrect — No. Level 3 needs a hardened builder whose signing identity is out of reach of the build steps, which a default shared runner is not.
Incorrect — No. The metadata is genuine SLSA provenance, and its existence is exactly what earns Level 1.
Correct — SLSA (Supply-chain Levels for Software Artifacts) Level 1 means provenance exists. Signing it keyless with cosign and recording it in Rekor is what reaches Level 2.
03Six months after shipping, a critical vulnerability is disclosed in a popular library. Every image your pipeline produced carries a signed CycloneDX SBOM (Software Bill of Materials) attestation in the registry. What does that do to the question 'which of our running images contain the vulnerable version?'
Correct — An SBOM is a machine-readable inventory of every component and version, so you answer by searching records you already hold.
Incorrect — No. The inventory already sits in the attestations, so there is nothing to rebuild in order to answer the question.
Incorrect — No. An SBOM records what is inside an image. It changes nothing on its own.
Incorrect — No. The SBOM attestation is already signed and readable. Checking its identity does not mean signing anything again.

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.

Related