SBOMs at Scale: SPDX, CycloneDX, and Registry Integration
Generating a bill of materials is easy; operating millions of them is not. This post compares SPDX 2.3/3.0 with CycloneDX 1.5/1.6, shows how to produce SBOMs with syft and Trivy, and covers storing them as OCI artifacts in your registry and querying them at scale.
Published on • November 15, 2026
AI Assistant

An SBOM you generate once and never look at is a compliance artifact. An SBOM you can query — “which of our 40,000 images contain libcurl 8.5.0 and which teams own them?” — is a security tool. The difference is not the generation step; it is everything downstream: format choice, where the document lives, and how it is indexed at fleet scale.
The regulatory pressure is real and dated. US Executive Order 14028 (May 2021) directed suppliers to provide SBOMs in a machine-readable format, formalized through NTIA’s minimum-elements work now maintained by CISA. The EU Cyber Resilience Act (Regulation (EU) 2024/2847, in force since 10 December 2024) requires manufacturers to maintain an accurate, machine-readable inventory of software components, with the bulk of obligations applying from 11 December 2027. Both regimes care about the document existing; your architecture has to care about it being findable.
SPDX vs CycloneDX: what actually differs
SPDX is the older standard and the one with an ISO number: ISO/IEC 5962:2021. SPDX 2.3 (the last of the 2.x line) serializes as tag-value, JSON, YAML, or RDF, and models packages, files, snippets, relationships, and license expressions with real precision. SPDX 3.0, released in 2024, restructured the spec around a graph model and a set of profiles — the SPDX project now organizes itself around areas of interest including security, licensing, AI, datasets, build, and supply chain — so you can opt into the parts you need without carrying the whole vocabulary.
CycloneDX, an OWASP project, took the opposite route: start from the security use case and grow outward. Version 1.5 (2023) and 1.6 (2024) are JSON-or-XML documents that treat vulnerability records, VEX statements, and even a cryptographic bill of materials (CBOM) as first-class citizens, alongside the software BOM.
The practical split looks like this:
| Concern | SPDX 2.3 / 3.0 | CycloneDX 1.5 / 1.6 |
|---|---|---|
| Standardization | ISO/IEC 5962:2021 | OWASP project, industry-de facto |
| License analysis | Strong (SPDX license expressions, LicenseRef) | Good, but SPDX expressions are embedded, not native |
| Vulnerability / VEX | Via security profile in 3.0 | Native since 1.x, mature tooling |
| Scope beyond software | AI, dataset, hardware profiles (3.0) | Hardware, SaaS, vuln disclosure BOMs |
| Ecosystem tooling | REUSE, FOSSA, ScanCode, SPDX tools | Dependency Track, cdxgen, Grype/Snyk support |
Rule of thumb: if your consumer is a legal or open-source-compliance team, generate SPDX; if it feeds a vulnerability-management platform, generate CycloneDX. At scale you will generate both — syft can emit either from the same scan, and conversion tools exist for the rest.
Generating SBOMs in the pipeline
Two tools cover most of the ground: syft from Anchore and Trivy from Aqua Security. Both scan image filesystems and package databases (dpkg, rpm, apk, npm, PyPI, Go binaries) rather than trusting build metadata:
# SPDX JSON from a built image
syft registry.example.com/payments-api:1.4.2 -o spdx-json=payments-api.spdx.json
# CycloneDX JSON from the same image with Trivy
trivy image --format cyclonedx --output payments-api.cdx.json \
registry.example.com/payments-api:1.4.2
# Convert between formats when a consumer demands it
syft convert payments-api.cdx.json -o spdx-json=payments-api.spdx.json
Three details decide whether the output is useful six months later:
- Pin the tool version in CI. SBOM content differs between syft releases as new package ecosystems are added; an unpinned generator makes diffs meaningless.
- Record the scan target digest, not the tag. The SBOM must be bound to
sha256:..., or you will eventually attach a BOM to the wrong artifact after a tag is re-pushed. - Scan at build time, not at deploy time. BuildKit makes this nearly free:
docker buildx build --sbom=trueemits an SPDX 2.2 document as part of the build, generated by the same pipeline that produced the image.
Storing SBOMs as OCI artifacts
The most important architectural decision is where the document lives. Browsers of SBOMs scattered across S3 buckets, wikis, and CI artifacts do not scale. Regries do: every image already has an access-controlled, retention-managed, digest-addressed home, and OCI distribution now treats non-image payloads as first-class via the artifact type and referrers mechanisms.
ORAS (OCI Registry As Storage) pushes any file as an artifact and links it to the image it describes:
oras push ghcr.io/acme/sbom:payments-api-1.4.2 \
--artifact-type application/spdx+json \
--annotation org.opencontainers.image.title="payments-api 1.4.2 SBOM" \
--annotation org.opencontainers.image.revision="$(git rev-parse HEAD)" \
payments-api.spdx.json
The referrers API lets a client ask the registry “what is attached to this digest?” and receive SBOMs, signatures, and provenance attestations in one query — no side database needed for lookup by image. Cosign fits the same model: cosign attach sbom stores the document as a Sigstore attachment, and Harbor, GHCR, ECR, and Artifactory all support the pattern with varying degrees of referrers-API completeness (registries without referrers fall back to tag-based discovery).
For provenance, the attestation route matters too: SLSA provenance and in-toto attestations can carry an SPDX or CycloneDX document as a predicate, which lets cosign verify-attestation check the SBOM’s presence and signer in the same command that verifies the signature.
Querying at scale
Ten thousand SBOMs are a pile of JSON. A million is a database problem, and three layers show up in practice:
- Registry-native queries. Referrers give you “all SBOMs for this image.” They cannot answer “all images containing component X.” That is the limit of the storage layer, and it is why indexes exist.
- Graph indexes. GUAC (GraphQL API, a CNCF project) ingests SBOMs, attestations, and vulnerability feeds into a queryable graph: which images depend on this package, which are signed, which have a known CVE. Ingestion is push-based (from CI or a collector), so it composes with either storage route above.
- Quality and drift tooling. An SBOM index is only as good as its inputs.
sbomqsscores documents against the NTIA/CISA minimum elements, and SBOMlyze reviews SBOM changes in pull requests — catching, for example, a dependency that silently vanished from the BOM between releases.
The query that actually pays the bills is the incident-time one: Log4j-style blast radius. With an index, that is one GraphQL or SQL query against component → image → service → owner. Without one, it is a weekend of grep across CI artifacts.
Putting it together
A pipeline that survives contact with an audit looks like this:
- Build with
--sbom=trueor runsyft/trivyin CI against the pushed digest. - Generate both SPDX and CycloneDX when consumers disagree — the marginal cost of a second format from the same scan is seconds.
- Attach to the image with ORAS or cosign so the registry remains the source of truth.
- Push documents (and their attestations) into GUAC or a Dependency Track instance for cross-image querying.
- Fail the build on quality regressions (
sbomqsscore below threshold) rather than discovering a malformed BOM during an incident.
Formats are the easy part. The hard part — and the part regulators are really asking for — is a system where the answer to “what is running, and what is inside it” takes seconds instead of a quarter.
References
- SPDX — System Package Data Exchange (cited source; ISO/IEC 5962:2021)
- NTIA / CISA — The Minimum Elements For a Software Bill of Materials (SBOM)
- The White House — Executive Order 14028: Improving the Nation’s Cybersecurity
- EU Cyber Resilience Act — Regulation (EU) 2024/2847
- anchore/syft · aquasecurity/trivy · oras.land
- CNCF GUAC · OWASP CycloneDX · OWASP Dependency-Track