The shortest path to an SBOM in GitLab CI is a job that runs Syft against your freshly built container image, emits CycloneDX JSON, and registers it with artifacts:reports:cyclonedx — about twelve lines of YAML. Everything past that is refinement: scanning the SBOM instead of re-scanning the image, signing it as an attestation, and deciding what to do about source-versus-container drift. Here is the whole pipeline, in the order you should build it.
The minimal job
Assume a standard docker-build stage that pushes $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA. Add:
sbom:
stage: test
image:
name: anchore/syft:v1.0.1
entrypoint: [""]
script:
- /syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
-o cyclonedx-json=sbom.cdx.json
-o spdx-json=sbom.spdx.json
artifacts:
reports:
cyclonedx: sbom.cdx.json
paths:
- sbom.cdx.json
- sbom.spdx.json
expire_in: 1 year
Two things worth noticing. The reports:cyclonedx declaration is not decorative — GitLab ingests the file into the dependency list (Ultimate tier), so components become queryable in the UI rather than trapped in a JSON blob. And emitting both CycloneDX and SPDX costs one extra flag now but saves an argument later; customers and regulators are split on format, so produce both while the build context is hot.
Pin the Syft version. SBOM generators change their component-detection logic between releases, and an unpinned generator gives you diffs that look like supply chain changes but are actually tool changes.
If you are on Ultimate, mind the overlap
GitLab's own Dependency Scanning (the Gemnasium analyzers) has emitted CycloneDX reports since 15.7, per lockfile it understands. That covers declared application dependencies well, but a container SBOM from Syft additionally captures OS packages (apk, deb, rpm), language runtimes, and binaries that never appear in a lockfile — which is exactly where things like an outdated openssl hide. The pragmatic setup is both: keep Dependency-Scanning.gitlab-ci.yml included for MR widgets, and generate the image-level SBOM as the artifact of record for releases.
Scan the SBOM, not the image, in the next stage
Once the SBOM exists, downstream jobs should consume it instead of re-analyzing the image. It is faster (no image pull, no filesystem walk) and it guarantees every tool reasons about the same inventory:
vuln-scan:
stage: test
needs: ["sbom"]
image:
name: anchore/grype:v0.74.7
entrypoint: [""]
script:
- /grype sbom:./sbom.cdx.json --fail-on high
--fail-on high blocks the pipeline on high and critical findings. Start with --fail-on critical if the first run produces a wall of red — a gate the team immediately mutes protects nothing. Grype honors a .grype.yaml ignore file, which is where accepted risks go, with a comment and an owner, under code review like everything else.
Sign the SBOM and attach it to the image
An unsigned SBOM sitting in a job artifact is a claim; a signed attestation attached to the image digest is evidence. Cosign with GitLab's OIDC-backed keyless signing:
sbom-attest:
stage: release
needs: ["sbom"]
image: alpine:3.19
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- apk add --no-cache cosign crane
- DIGEST=$(crane digest $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA)
- cosign attest --yes
--predicate sbom.cdx.json
--type cyclonedx
$CI_REGISTRY_IMAGE@$DIGEST
Note the digest resolution step: always attest the digest, never the tag. Tags move; the attestation must bind to the exact bytes you shipped. Verification on the consuming side — admission controllers, customer audits — is covered in our piece on Cosign verification policies in production.
Source SBOM vs. container SBOM
Run Syft in dir: mode against the repo and you get the declared inventory; run it against the image and you get the shipped one. They will differ, and the differences are informative: build-time-only tools, OS packages, binaries copied in multi-stage builds, and occasionally a dependency your build injects that no lockfile mentions. Generating both and diffing them (syft dir:. -o cyclonedx-json=source.cdx.json, then compare component lists) is a cheap tripwire for build-process tampering — the class of problem where the artifact stops matching the source.
At one or two services, eyeballing that diff works. At fifty services with per-commit SBOMs, you have a management problem, not a generation problem — storage, versioning, cross-service queries, and drift alerts are what SBOM Studio exists for; Safeguard ingests exactly the CycloneDX artifacts this pipeline produces. For the underlying concepts, the Academy has a self-paced SBOM track.
Frequently asked questions
Should I generate the SBOM from the repo or from the image?
From the image, if you pick only one — it reflects what actually ships, including OS packages and binaries no lockfile declares. Generate both when you can, because the diff between them is itself a useful integrity signal.
CycloneDX or SPDX?
Emit both; it is one extra output flag. CycloneDX tends to dominate security tooling and vulnerability workflows, SPDX shows up more in license compliance and procurement contexts. Committing to one format in 2024 is an unforced error.
How long should SBOM artifacts be retained?
Match your artifact retention to your support window — if customers run a release for three years, keep its SBOM for three years. expire_in: 1 year is a floor for CI artifacts; the SBOM of record for releases belongs in longer-term storage, ideally attached to the image as an attestation.
Does an SBOM slow the pipeline down noticeably?
Syft against an already-pulled image typically runs in seconds to low tens of seconds for normal service images. Scanning the SBOM with Grype instead of re-scanning the image usually makes the overall pipeline faster than a naive scan-everything setup.