Git, CI/CD & GitOpsLIII · Container CIContainer CI
SBOM generation in CI
What you'll learn
- Explain what an SBOM is and why it belongs in the artifact chain
- Generate a syft SPDX SBOM from a directory in CI
- Choose between SPDX and CycloneDX based on toolchain compatibility
- Recognise the limits of an SBOM (it lists components; it does not verify them)
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
An SBOM is a list of components in a software artifact. For a container image, those components are the packages - OS packages, language libraries, application modules - that the image contains. The SBOM is one of the artifacts LIII-05 binds to the image (the others are the vulnerability report and the provenance, both of which a future lesson in Part LIII attaches).
Why an SBOM is part of the chain
The chain from LIII-01 has four observation points: checkout, build, test/scan, registry. The SBOM is generated at scan time, after the build, and is the inventory that downstream policy and audit consumers will read. Five reasons it belongs in the build chain rather than as an afterthought:
- Vulnerability remediation. When a CVE is published, the team asks “where are we exposed?”. The answer requires knowing which images contain which package versions. An SBOM per image is the data structure that question answers.
- License compliance. Many organisations have policy on OSS licences. An SBOM per image is the input to license-policy enforcement.
- Audit. A regulator or customer asking “what was in this image at the moment you deployed it?” needs the SBOM from the moment, not a regenerate-from-source guess later.
- Reproducibility. A SBOM is the inventory counterpart of the provenance attestation: provenance says what was built, SBOM says what was in it.
- Deprecation. When a package is archived upstream, the team needs to find every image that still references it.
The SBOM must be generated against the final image, with all its layers resolved. A SBOM generated from source misses the OS packages; a SBOM generated from the build context misses the base image’s contributions.
SPDX versus CycloneDX
Two formats dominate. They are not interchangeable; they model different things in different ways:
| SPDX | CycloneDX | |
|---|---|---|
| Origin | Linux Foundation | OWASP / CycloneDX working group |
| Model | License-centric | Component-graph-centric |
| Strength | Comprehensive license metadata; tooling built around SPDX IDs | Components with hashes, dependencies, vulnerabilities, services |
| Weakness | Heavy XML/JSON, complex data model | Less mature license expression tooling |
| Tooling | syft (-o spdx-json), in-toto SPDX predicates | syft (-o cyclonedx-json), trivy, cdxgen |
The practical decision:
- Choose SPDX if the downstream consumer is license-policy tooling, government compliance, or a vendor product keyed to SPDX identifiers.
- Choose CycloneDX if the downstream consumer is a vulnerability management platform (Dependency-Track, Mend, Snyk) that understands CycloneDX’s component graph natively.
Pick one. Converting between formats loses information because the models do not align; running both SBOM generators and storing both is fine but storing one and converting at consumer time is not.
flowchart LR
A["Final image"] --> B["syft"]
A --> C["cyclonedx-bom"]
B --> D["SPDX JSON"]
C --> E["CycloneDX JSON"]
D --> F["Attestation (sign)"]
E --> F
syft in CI
The dominant SBOM generator is anchore/syft. It understands
several ecosystems (Debian/Ubuntu, RPM, Alpine apk, npm, pip,
gem, go modules, cargo, maven, …) and can scan a directory, an
image, or an archive:
syft dir:. -o spdx-json > sbom.spdx.json
The -o spdx-json flag selects SPDX JSON output. Swap to
-o cyclonedx-json for CycloneDX JSON. The dir:. argument
tells syft to scan the current directory; swap to
registry:ghcr.io/org/app:$COMMIT_SHA for an image scan
post-push.
A CI job that runs syft against the source after the build:
- name: Generate SBOM
run: |
syft dir:$GITHUB_WORKSPACE -o spdx-json > sbom.spdx.json
syft dir:$GITHUB_WORKSPACE -o cyclonedx-json > sbom.cdx.json
- name: Upload SBOM as artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: |
sbom.spdx.json
sbom.cdx.json
The SBOM is uploaded as a CI artifact, then attached to the image as an attestation in the signing step (LIII-06). The chain is: build produces digest, syft produces SBOM, cosign attaches SBOM to the digest, registry stores the signed attestation.
cyclonedx-bom
The CycloneDX project ships its own tool for environments where
syft is not appropriate. cyclonedx-bom is a Go binary that
supports scanning and conversion:
cyclonedx-bom scan --input src --output sbom.cdx.json
For most teams, syft is the lower-friction choice: wider ecosystem support, single binary, simpler CI integration. CycloneDX-bom makes sense when syft cannot model a niche format (specific Maven plugin outputs, certain build-tool outputs) or when the team wants to validate against the official CycloneDX schema before upload.
What an SBOM cannot do
Three limits worth naming explicitly:
- An SBOM lists components; it does not verify them. A package at version 1.2.3 in the SBOM may be the upstream package or may be a recompiled binary that happens to identify itself as 1.2.3. Verification comes from attestation and signing (LIII-06), not from the SBOM.
- An SBOM does not capture runtime behaviour. A package loaded dynamically, an interpreter invoked at runtime, or a configuration file fetched at boot may all be missing from an SBOM that scans the image layers but not the process tree.
- An SBOM has no exploitability information. A CVE matched against an SBOM is a match, not an exploit. The package may not be loaded, may not be reachable, may not be on a vulnerable code path. That distinction is the job of a vuln scanner, not the SBOM.
Production discipline
- Pick one format as the source of truth. Mixing SPDX and CycloneDX without a designated primary format creates ambiguity when the two disagree, which they will.
- Generate against the final image, never against source only. Source-level SBOMs miss OS packages; that is the CVE hot zone.
- Attach the SBOM to the image as an attestation. A SBOM sitting in CI artifact storage is a SBOM that gets lost when the artifact retention expires. The signed attestation on the registry digest is durable.
- Refresh the SBOM on rebuild, not on policy change. A SBOM is a property of the artifact; replace the SBOM only when the artifact is replaced.
Cross-course references
- Container Security for Production Sysadmins - Part VI (SBOM-driven compliance) covers the consumer side: how a vulnerability management platform consumes the SBOM and turns it into a CVE match.
- Software Composition Analysis - this part’s SBOM lesson is the data structure that SCA tools require; the analysis itself lives outside the lessons in this course.
Quiz
Knowledge check · 4 questions
Q1. A team must pick one SBOM format as the source of truth. Their downstream vulnerability management platform ingests CycloneDX natively and they need a format that models dependency graphs. Which format should they pick?
Q2. Generating a SBOM with syft dir:. against the source tree of an application produces an SBOM that includes the OS packages inherited from the final image's base layer.
Q3. Name two capabilities an SBOM does not provide that downstream consumers must obtain from elsewhere.
Q4. A regulator asks for the SBOM of an image that was deployed six months ago. Diagnose what the team can and cannot produce.
A production image was built six months ago with syft dir:. generating sbom.spdx.json, which was uploaded as a CI artifact. The image was tagged app:1.4.0 and deployed. The CI artifact retention is 30 days, so the SBOM file is gone. The team asks syft to regenerate against the source at the same commit; the regenerated SBOM differs from what would have been generated six months ago because a transitive Go dependency was updated.
Passing score: 75%. Answers are checked in this browser.