CycloneDX, the security-first bill of materials
How CycloneDX models components, dependencies and vulnerabilities, and when to pick it over SPDX.
A CycloneDX file can carry a sentence like this one: this service ships lodash 4.17.20, the
public catalogue of known software flaws lists CVE-2021-23337 against that version, and the flaw
does not reach us because we never call _.template. That last clause does not live in a
comment or a ticket. It lives in a field, with a value from a fixed list, and the next tool
along can act on it.
Every bill-of-materials format records what is inside a build. The claim that a known flaw does not reach you is what CycloneDX adds, and it should decide whether you pick this format.
A software bill of materials, an SBOM, is a machine-readable list of every component inside a piece of software. That includes the dependencies you never chose directly, the ones your own dependencies pulled in.
The general case for keeping one, and the work it does not do for you, is what an SBOM is for.
CycloneDX is one of the two formats that list is normally written in; the other is SPDX, the Software Package Data Exchange.
What the standard actually is
CycloneDX started at OWASP and is no longer only an OWASP document. In June 2024 the international standards body Ecma International ratified CycloneDX 1.6 as ECMA-424, first edition.
CycloneDX 1.7 followed on 21 October 2025 and became the second edition in December 2025, developed jointly by the OWASP Foundation and Ecma's Technical Committee 54.
If a compliance conversation needs a document number, that is the number. The project's 1.7 announcement puts support at over 250 tools.
Documents come in three encodings, not the two that most write-ups mention: JSON
(application/vnd.cyclonedx+json), XML (application/vnd.cyclonedx+xml) and Protocol Buffers
(application/x.vnd.cyclonedx+protobuf).
The project calls its files BOMs, not SBOMs, and means it.
The same schema describes software and six other kinds of inventory: hardware (HBOM), hosted services you depend on (SaaSBOM), operations (OBOM), machine learning models (ML-BOM), cryptography (CBOM) and manufacturing (MBOM).
The shape of a document
A complete, valid 1.7 document for a Node service with one dependency:
{
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:3e67140e-77be-43a5-a4ee-9c9a3fee51e1",
"version": 1,
"metadata": {
"timestamp": "2026-09-08T12:00:00Z",
"tools": {
"components": [
{
"type": "application",
"group": "@cdxgen",
"name": "cdxgen",
"version": "13.1.0"
}
]
},
"component": {
"bom-ref": "pkg:npm/auth-service@2.1.0",
"type": "application",
"name": "auth-service",
"version": "2.1.0"
}
},
"components": [
{
"bom-ref": "pkg:npm/jsonwebtoken@9.0.2",
"type": "library",
"name": "jsonwebtoken",
"version": "9.0.2",
"purl": "pkg:npm/jsonwebtoken@9.0.2",
"licenses": [
{ "license": { "id": "MIT" } }
]
}
],
"dependencies": [
{
"ref": "pkg:npm/auth-service@2.1.0",
"dependsOn": ["pkg:npm/jsonwebtoken@9.0.2"]
},
{
"ref": "pkg:npm/jsonwebtoken@9.0.2",
"dependsOn": []
}
]
}metadata.component is the thing the BOM is about; components is what it contains, and the
field names do not tell you which is which.
serialNumber identifies the document rather than the software, so every BOM you emit needs a
fresh UUID, a random identifier long enough that no two documents collide.
metadata.tools appears here in the object form holding components; the bare array of
{vendor, name, version} most examples still use is deprecated in 1.7, with the legacy vendor
becoming group.
The library component carries a purl, a Package URL: one string naming a package unambiguously
across packaging ecosystems, shaped pkg:type/namespace/name@version.
pkg:npm/jsonwebtoken@9.0.2 means version 9.0.2 of jsonwebtoken from npm and nothing else. purl
is its own standard, ECMA-427.
The field is optional, and metadata.component above does without one. It is still the field
that lets a scanner match a component against a vulnerability database, because a name and a
version alone are ambiguous across registries.
bom-ref is what makes the dependencies array work. The schema types a dependency's ref as
refLinkType, which means it can only point at a bom-ref declared somewhere in the same
document.
Nothing infers that link for you: a purl is not a bom-ref until you declare it as one.
The idiomatic convention is to use the purl string as the bom-ref value, which the project's
own fixtures do, and it makes the two easy to conflate.
A broken graph validates clean
Point a ref at a bom-ref you never declared and the document is still schema-valid, because
ref is only a string. cyclonedx validate passes. Consumers resolve the reference to nothing
and drop the edge, so the dependency tree flattens into a list and you can no longer trace a
transitive path through it.
The part worth choosing it for
The public catalogue of known software flaws is the CVE list, short for Common Vulnerabilities
and Exposures. CVE-2021-23337 is one entry in it. A scanner joining your purls against that
catalogue produces matches.
A match says only that you ship a version the catalogue lists against a flaw; it does not say the flaw affects you.
What a security team needs to know is whether the flawed code can be reached in your product.
VEX records exactly that: Vulnerability Exploitability eXchange, a statement that you ship the vulnerable library, and here is whether it can be exploited.
CycloneDX puts that statement in the same file as the inventory, in a vulnerabilities array.
{
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:8b0e0f1c-2f47-4d1a-9a3b-6d5c4e2f1a09",
"version": 1,
"metadata": {
"timestamp": "2026-09-08T12:00:00Z",
"component": {
"bom-ref": "pkg:npm/auth-service@2.1.0",
"type": "application",
"name": "auth-service",
"version": "2.1.0"
}
},
"components": [
{
"bom-ref": "pkg:npm/lodash@4.17.20",
"type": "library",
"name": "lodash",
"version": "4.17.20",
"purl": "pkg:npm/lodash@4.17.20"
}
],
"vulnerabilities": [
{
"bom-ref": "vuln-lodash-cve-2021-23337",
"id": "CVE-2021-23337",
"source": {
"name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23337"
},
"affects": [
{ "ref": "pkg:npm/lodash@4.17.20" }
],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "The vulnerability is in lodash's _.template function. This service imports lodash but never calls _.template, so the vulnerable code path cannot be reached.",
"response": ["will_not_fix"]
}
}
]
}affects.ref points at a declared bom-ref, so the statement binds to one component in this
document rather than to lodash in general. The justification is code_not_reachable.
Reachability is whether your code ever calls the vulnerable function. CVE-2021-23337 lives in
lodash's _.template, and this service imports lodash but never calls _.template.
detail is free text for whoever audits the claim later.
The justification vocabulary the schema allows is closed: nine values, among them
code_not_present, code_not_reachable, protected_at_runtime and requires_configuration. A
scanner cannot act on a note in a ticket saying nobody here thinks this one applies. It can act
on code_not_reachable, because that value comes from a list it already knows.
The suppression then travels with the artifact as data.
Nothing verifies the claim, and that is the limit of the mechanism.
not_affected is an assertion by whoever generated the document, and a wrong one leaves you
worse off than having no BOM at all, because you have told your tooling to stop reporting a live
exposure.
All anyone auditing a justification has to go on is detail, and detail is a free-text
string.
Who writes these, and who reads them
Writing a BOM and reading one are separate jobs, and most tools do only one. A list of tool names does not tell you which.
- cdxgen and Syft generate. cdxgen 13.1.0 reads a source tree; Syft 1.51.1 reads a container image or a filesystem.
- Dependency-Track consumes only. It ingests BOMs and tracks vulnerabilities against them over time; 5.1.0, released 27 August 2026, added CycloneDX 1.7 ingestion. The commercial products sold as application security posture management are consumers too.
- Trivy and Grype do both. Either will read a BOM to scan it, or scan an image and write one out. Trivy's CycloneDX output turns vulnerability scanning off unless you ask for it back, which catches people expecting findings in the file.
- Snyk generates, on paper. Snyk's own CLI documentation puts BOM output behind an Enterprise plan and a recent CLI release, and tops it out at CycloneDX 1.6. Calling that native support oversells a paid feature.
cdxgen moved to its own npm scope and GitHub organisation at version 13, and the old package name still resolves to a frozen 12.8.4, so a document that looks like current cdxgen output can be two major versions behind.
A deeper BOM out of cdxgen covers which package to install, and what reading a source tree rather than a built image changes.
In the diagram below, two different generators feed one document, and the two lookups against that document stay separate: the vulnerability database decides what matches, and the VEX statement decides whether a human hears about it.
Version skew is not hypothetical
Scan the same project with cdxgen and with Syft today and you get two different spec versions.
cdxgen defaults to 1.7 and accepts 1.6, 1.7 and 2.0 through --spec-version.
Syft's cyclonedx-json covers 1.2 through 1.6 and defaults to the newest it supports, so you
get 1.6. Neither tool is wrong. They disagree.
That matters because of one line in every CycloneDX JSON schema: "additionalProperties": false
at the top level. A strict 1.4 validator does not ignore fields it has never heard of; it
rejects the document.
A newer BOM arriving at an older consumer is a hard failure, not a lossy read.
The answer is a deliberate downgrade. The cyclonedx command shipped by the cyclonedx-cli
project has a convert subcommand that rewrites a document to an older spec version. It drops
fields that exist only in newer versions on the way.
Downgrading means losing those fields, so find out which ones you lost.
Two ways validation lies to you
The $schema value is http://cyclonedx.org/schema/bom-1.7.schema.json. The filename contains
.schema.json, and the shorter form older examples carry,
http://cyclonedx.org/schema/bom-1.5.json, 404s. An editor or CI step that resolves it either
errors or skips validation silently. The http is correct rather than a typo: a JSON Schema
$id is an identifier, not a download link.
The other is exit codes: cyclonedx validate reports problems while still exiting 0 unless you
pass --fail-on-errors, so a CI step built on the plain command stays green over an invalid
document.
When to pick it, and when to pick SPDX
Both formats list the same components, and the comparison usually ends in a shrug.
Pick CycloneDX when something downstream has to decide whether to wake a human.
The vulnerabilities array, with its closed justification vocabulary, is the one capability
here that changes what a pipeline can do rather than how it writes things down.
If exploitability tracking is why you are producing a BOM at all, that settles it.
The non-software inventories work the same way: if you need one document type covering a firmware image, a set of hosted services and a model, CycloneDX has HBOM, SaaSBOM and ML-BOM.
Pick SPDX when licence compliance rather than exploitability is what the document is for.
The split is not clean, and the tell is in the first document above: CycloneDX writes licences
with an id field, {"license": {"id": "MIT"}}, because a security-first format still has to
record them.
SPDX's document model is the other half of this pair.
And when a contract or the consumer's own documentation names a format, that is the answer. Dependency-Track ingests CycloneDX, so if that is where your BOMs go, produce CycloneDX and spend the argument elsewhere.
The uncomfortable part of that position is the version skew above.
CycloneDX is a ratified international standard whose two mainstream generators disagree about
which spec version to emit, and the field that makes it worth choosing is the one nothing can
check. Neither is a reason to pick the other format. They are reasons to pin --spec-version in
your pipeline and to treat every not_affected you write as a claim somebody will eventually
ask you to defend.