Skip to content

SBOMs Explained: SPDX, CycloneDX and Security

Lesson 2 of 10Intermediate14 min readGit Security & DevSecOps · Supply Chain SecurityVerified: SPDX and CycloneDX specification status, anchore/sbom-action v0.24.2, September 2026

An SBOM is an inventory. It lists the components inside a piece of software, their versions and their relationships to one another.

That is the whole concept, and stating it plainly matters because SBOMs attract claims they do not support:

An SBOM inventories software components. It does not prove those components are safe.

An inventory is a prerequisite for assessing safety, and it is not an assessment. The distinction is the same one as between a passenger list and a security screening.

What it contains. Components, versions, identifiers, licences, and the relationships between them — which component depends on which.

Two formats. SPDX, a Linux Foundation project with roots in licence compliance, and CycloneDX, an OWASP project with roots in security. Both are mature, both are standardised, and most tooling supports both.

When to generate it. At build time, from the artifact, so it describes what you actually shipped.

What to do with it. Attach it to the release, ideally as an attestation so it is cryptographically bound to the artifact rather than merely stored beside it.

Why anyone wants one. So that when an advisory lands, “are we affected?” is a query rather than an investigation.

The value is concentrated in one scenario, and it is worth describing concretely because it justifies the whole practice.

A critical vulnerability is announced in a widely-used library. Every organisation in the world asks the same question at the same time: are we affected?

Without SBOMs, that question is answered by a manual audit: which of our services use this, at which version, and is it a direct or transitive dependency? For an organisation with forty services, that is days of work, done under pressure, and the answer for most of that time is “we do not know” — which is operationally identical to “yes”.

With SBOMs for every deployed artifact, it is a query. Minutes, with a definitive answer per artifact, including transitive dependencies nobody would have thought to check.

That is the case. Everything else — compliance, transparency, customer requirements — is real and secondary.

SPDXCycloneDX
GovernanceLinux FoundationOWASP
OriginLicence complianceApplication security
StandardisationISO standardECMA standard
StrengthsLicence expression, legal metadata, provenance relationshipsVulnerability data, services, dependency graphs, breadth of asset types
FormatsJSON, YAML, RDF, tag-valueJSON, XML, Protocol Buffers

Both formats are actively developed and both have moved well beyond their origins — SPDX 3.x extends past software into hardware and services, and CycloneDX covers cryptographic assets and AI models alongside software components.

The practical answer to “which one?” is: whichever your consumer asks for, and both if you have no strong reason. Generation is a build step and most tools emit either format, so producing both costs almost nothing. Converters exist, and they lose fidelity in the fields each format models better — which is an argument for generating natively rather than converting.

GitHub’s own dependency graph export is SPDX, and its SBOM attestations accept either.

This is the decision that determines whether the document is useful, and it is the most common thing to get wrong.

GitHub can export an SBOM from the dependency graph:

Terminal window
{/* Two steps: start the export, then poll for the finished document */}
url=$(gh api /repos/OWNER/REPO/dependency-graph/sbom/generate-report --jq '.url')
gh api "${url}" > repo-sbom.spdx.json

What it describes: the dependencies declared in the repository’s manifests and lock files, plus anything submitted through the dependency submission API.

What it omits: operating system packages in your container, anything vendored, anything downloaded during the build, and anything the parser did not recognise.

Useful, and it is an SBOM of a repository, not of an artifact.

Generated by scanning the built thing:

- name: Generate an SBOM from the built image
uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json

What it describes: what is actually inside the artifact — application dependencies and the operating system packages in the base image, which for many applications is where most of the CVEs are.

An SBOM in a file somewhere is a document. An SBOM bound to the artifact is evidence.

As a release asset. Simple and adequate for many purposes, with one weakness: nothing connects the file to the artifact except that they sit next to each other.

As an attestation. Cryptographically bound to the artifact’s digest, signed, and verifiable:

permissions:
id-token: write
attestations: write
contents: read
steps:
- uses: actions/attest-sbom@v4
with:
subject-path: dist/my-binary
sbom-path: sbom.spdx.json

The verification then answers a stronger question: not “here is an SBOM somebody published” but “here is an SBOM that the build system attests describes this specific artifact”. See Artifact attestations.

The half that organisations skip, and the half where the value is.

Store them where you can query them. An SBOM per deployed artifact, in one place, searchable by component name. This is the “are we affected?” query, and it is the entire justification for generating them.

Scan them. Vulnerability scanners consume SBOMs directly, which means you can assess an artifact’s exposure without rebuilding or re-scanning it:

Terminal window
grype sbom:./sbom.spdx.json

That is a genuinely useful property. A scanner reading a stored SBOM can re-evaluate a year-old artifact against today’s advisory data, without the artifact or its build environment existing any more.

Diff them. Comparing an artifact’s SBOM against its predecessor shows exactly what changed. A patch release that adds forty components is a release worth asking about, and this is one of the few checks that can catch a dependency arriving through a route nobody reviewed.

Verify the licence position. The compliance use, and a real one — an SBOM is the artefact that answers “what licences are we distributing under?” without a manual audit.

Reading an SBOM once removes most of the mystery. The core of an SPDX JSON document is a list of packages and a list of relationships:

{
"spdxVersion": "SPDX-2.3",
"name": "example-application",
"packages": [
{
"SPDXID": "SPDXRef-Package-example-lib",
"name": "example-lib",
"versionInfo": "2.4.1",
"licenseConcluded": "Apache-2.0",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/example-lib@2.4.1"
}
]
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-Package-example-application",
"relatedSpdxElement": "SPDXRef-Package-example-lib",
"relationshipType": "DEPENDS_ON"
}
]
}

Three fields carry most of the weight.

The package URL (purl). pkg:npm/example-lib@2.4.1 is a standard identifier naming the ecosystem, the package and the version. It is what makes automated matching against advisory data possible — a name and version string alone is ambiguous across ecosystems, and purl is not.

An SBOM whose components lack purl identifiers is substantially less useful, because every consumer has to guess at the mapping. When evaluating a generator, this is the field to check.

licenseConcluded. The generator’s determination, as opposed to what a package declares. The distinction matters for compliance: NOASSERTION means the tool could not establish a licence, which is a finding rather than an absence.

relationships. What makes it a graph rather than a list, and what lets a consumer distinguish a direct dependency from something four levels down. A flat list of four hundred components does not tell you which ones you chose.

CycloneDX expresses the same three concepts with different field names — purl, licenses, and a dependencies array — so a consumer written against one format ports to the other without conceptual work.

The realistic version is a step in the release workflow, generating from the artifact rather than the source, and attaching the result.

- name: Build the image
id: build
uses: docker/build-push-action@v7
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Generate the SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attest the SBOM against the image
uses: actions/attest-sbom@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true

The detail worth copying is that both the SBOM generation and the attestation reference the image by digest, taken from the build step’s output — not by tag.

A tag can move between the build and the SBOM generation, which would produce an SBOM describing a different image and an attestation binding it to the wrong subject. Referencing the digest makes those three steps refer to the same bytes by construction.

The query is the point, so the storage has to support it.

The minimum viable arrangement: one SBOM per deployed artifact, in one place, indexed by component name and version. That can be object storage plus a small index, a purpose-built SBOM repository, or an artifact registry that stores attestations alongside images.

What it has to be able to answer:

Which deployed artifacts contain example-lib at any version?
Which contain a version in the affected range?
When were those artifacts deployed, and where?

If your arrangement cannot answer the first question in under a minute, it will not be used during an incident, and generating the documents was preparation for a query nobody can run.

A pattern that works without new infrastructure: attach the SBOM as an attestation, push it to the registry alongside the image, and query the registry. The artifacts and their inventories then live together, and the retention policy is one policy rather than two.

It does not tell you what is vulnerable. It lists components. Joining that against advisory data is a separate step with a separate tool.

It does not tell you what is reachable. A component being present says nothing about whether your code calls the vulnerable part of it.

It does not prove itself accurate. An SBOM is generated by a tool making a best effort. Statically linked libraries, vendored code, dynamically loaded plugins and anything the generator does not understand can be missing. An SBOM is a claim, and its accuracy is a property of the generator.

It does not establish provenance. “What is inside” and “where did this come from” are different questions. Build provenance answers the second.

It does not make anything safe. It is an inventory.

An SBOM is a claim about what is in an artifact, produced by a tool that inspected it. Different tools inspecting the same artifact produce different lists, and the differences are systematic rather than random.

Package manager metadata is reliable. Anything installed through apt, npm, pip or similar leaves a record the generator reads. This is the well-covered case and it is most of the list.

Statically linked code is hard. A Go binary containing five libraries compiled in has, from the filesystem’s point of view, one file. Some generators read Go’s embedded module metadata and get this right; others report one component. The same problem is worse for C and C++, where there is usually no metadata at all.

Vendored source is usually invisible. A library copied into your tree and compiled with your code looks like your code.

Multi-stage builds help and hide. A build stage’s tooling correctly does not appear in the final image’s SBOM. Something copied from a build stage into the final image appears as a file with no package metadata, so it may not appear as a component at all.

Two practical consequences.

Treat an SBOM as a lower bound. It lists what the generator could identify. It does not report what it could not, and there is no field for “I found a binary I could not attribute”.

Compare generators once. Running two different tools against the same artifact and diffing the output is a half-hour exercise that tells you how much your chosen generator misses for your kind of artifact. That number is worth knowing before you rely on the inventory during an incident.

An SBOM plus advisory data produces a list of vulnerabilities present in an artifact. For a container image with a few hundred packages, that list is routinely dozens of entries long, and most of them are not exploitable in that artifact — the vulnerable package is present but the vulnerable path is never reached.

This creates a specific and predictable friction. A customer scans your artifact, gets forty findings, and asks you to explain them. You know that thirty-five are unreachable. There is no field in the SBOM to say so, because the SBOM’s job is inventory rather than assessment.

VEX — Vulnerability Exploitability eXchange — is the class of document designed to carry that statement: for this product, this vulnerability, here is its exploitability status and why. CycloneDX can express VEX data, and OpenVEX is a standalone format for the same purpose.

Two things to understand about it before adopting it.

It is an assertion by the producer. A VEX statement saying “not affected: the vulnerable code is not present in this configuration” is your claim. It is only as good as the analysis behind it, and consumers weigh it by how much they trust you.

It is ongoing work. Every new advisory affecting a component in your artifact is a new statement somebody has to make. An organisation that publishes VEX for one release and not the next has published a snapshot.

For most teams the useful position is: generate SBOMs now, understand that consumers will find unreachable vulnerabilities in them, and treat VEX as the mechanism to reach for when that conversation becomes recurring — rather than as something to adopt speculatively.

Threat. A vulnerable or malicious component is present in a deployed artifact, and nobody establishes that in time — because nobody can enumerate what is in it.

Attack surface. Everything in the artifact, which is a much larger set than everything in the repository: the base image, statically linked libraries, anything the build fetched, anything vendored.

Impact. Almost never the SBOM’s absence directly. It is the response delay: the days spent determining exposure while the answer is “we do not know”, which for anything urgent is treated as “yes”.

Control. An artifact SBOM per deployed artifact, attested to the digest, stored somewhere queryable, and re-scanned as advisory data changes.

Verification. Pick a component you know is in a deployed artifact and query for it. Time the answer. Then pick one you are not sure about — that is the case the inventory exists for, and the one that reveals whether your generator sees what you assumed.

SBOMs increasingly appear in procurement requirements and in regulation covering software sold to governments and to regulated industries. Two things are worth knowing without turning this into a compliance guide.

A requirement to provide an SBOM is not a requirement to act on one. Meeting the letter of a requirement by attaching a generated document to a release is achievable in an afternoon and delivers none of the security value described above. That is not an argument against compliance — it is a warning that satisfying the requirement and getting the benefit are different projects, and only the first has a deadline.

Requirements name formats and fields. Which format, which minimum fields, and how it is delivered vary by regime and change over time. Verify the current requirement rather than assuming the SBOM you already generate satisfies it — the usual gaps are missing purl identifiers, missing supplier fields, and delivering a repository SBOM where an artifact SBOM is required.

The durable position: generate artifact SBOMs, attach them as attestations, and store them queryably. That satisfies most requirements as a side effect of doing the useful thing, which is the right way round.

Publishing a repository SBOM as an artifact SBOM. Different documents; consumers want the second.

Generating and never consuming. The most common outcome, and it is cost without control.

One format because it is the default. Ask consumers what they want. Both is usually cheap.

Generating from source rather than from the built artifact. Misses everything the build adds, including the base image.

Treating it as a vulnerability report. It is the input to one.

Not attaching it to the artifact. A file beside a binary has no verifiable relationship to it.

Assuming completeness. Statically linked, vendored and dynamically loaded components are the usual gaps, and the SBOM does not say what it missed.

Generating once at release and never again. The artifact does not change; the advisory data does. A stored SBOM rescanned monthly is where the ongoing value is.

An SBOM is a parts list. It tells you what is in the box. Whether any of those parts is recalled, whether the box was assembled by who it claims, and whether the parts are wired together safely are three other questions with three other answers.

  • An SBOM inventories components, versions, licences and relationships; it is not an assessment
  • Its primary value is answering “are we affected?” as a query rather than an investigation
  • SPDX and CycloneDX are both mature; generate whichever your consumers want, and both if unsure
  • A repository SBOM describes declared dependencies; an artifact SBOM describes what shipped
  • Consumers almost always want the artifact SBOM, including base image packages
  • Attaching it as an attestation binds it cryptographically to the artifact’s digest
  • Scanners consume SBOMs directly, so a stored SBOM can be re-evaluated against new advisories
  • An SBOM is a claim whose accuracy depends on the generator, and it does not report its own gaps
  • Generation without consumption is the most common and least useful outcome

Use a disposable repository with a small containerised application.

  1. Export the repository SBOM with the gh api command. Count the components.

  2. Build the container image and generate an SBOM from the image. Count again. Predict: how large is the difference?

  3. Look at what is in the second and not the first. Predict: what proportion is the base image?

  4. Scan the artifact SBOM with a vulnerability scanner. Predict: where are most findings — your dependencies, or the base image?

  5. Generate both SPDX and CycloneDX for the same image and compare. Predict: do the component counts match?

  6. Change the base image to a smaller one, rebuild and regenerate. Predict: how does the count change, and how does the vulnerability count change?

  7. Attach the SBOM as an attestation and verify it with gh attestation verify.

  8. Delete the repository.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.