All writing
Updated 7 min readNaren

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:

bom.json
{
  "$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.

bom-with-vex.json
{
  "$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.

A source tree feeds cdxgen and a container image feeds Syft, both producing bom.json, which is validated and then uploaded to Dependency-Track, joined against a vulnerability database by purl, and each match either suppressed by a VEX not_affected statement or raised as an alert. no yes yes no Source tree cdxgen Container image Syft bom.json Schema valid? Back to the developer Dependency-Track Vulnerability database VEX says not_affected? Suppressed, with a justification Alert the team

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.