Pick the format your consumers validate, which in practice means CycloneDX 1.7 or SPDX 2.3, and agree the version, required fields and relationship semantics. Identify packages with a parsed, canonical purl, bytes with a digest, and graph nodes with document-local references, and never let one stand in for another. Add CPE names only where a vulnerability database needs them. Test every conversion by comparing identities and typed dependency edges, not by whether the output parses.
- CycloneDX 1.7 (ECMA-424 2nd edition) and SPDX 3.0.1 are current, but Syft, Trivy and BuildKit still write SPDX 2.3.
- A purl names a package in its ecosystem, a digest names bytes, and a
bom-reforSPDXIDonly links nodes inside one document. - Parse purls before comparing them: npm and PyPI names have type-specific normalization, and qualifiers such as
archare part of the identity. - CPE matching depends on NVD enrichment, which since 2026-04-15 is prompt only for KEV, federal-use and critical-software CVEs.
- The same Syft scan written as CycloneDX and as SPDX differs in its root identity and states dependencies in opposite directions.
- Run
python compare_sboms.py app.cdx.json app.spdx.jsonon every conversion and require exit code 0 or a documented reason for each reported difference. - Validate CycloneDX with
cyclonedx validate --input-file app.cdx.json --input-version v1_7 --fail-on-errors(cyclonedx-cli 0.33) and SPDX 2.3 withpyspdxtools -i app.spdx.json; both exit non-zero on an invalid document. - Parse every purl with a purl library and confirm that it re-serializes to the stored string.
- For each CPE, record whether it came from the NVD CPE dictionary or was guessed by the generator.
- Confirm that the root component in each output names the released artifact by digest.
- Applies to
- CycloneDX 1.7 (ECMA-424 2nd edition, December 2025)
- SPDX 2.3 and 3.0.1; 3.1 is a release candidate
- Package URL ECMA-427 1st edition (December 2025)
SPDX/CycloneDX component identity and interpretation. Cross-format field presence is not completeness or compliance.
- Part of
- Learning path: From source to trusted release (step 2 of 12). Workflow: Respond to a vulnerability
Learning goals and prerequisites
What you will learn
- Compare local references, package URLs and content hashes correctly.
- Detect lossy conversion using typed relationships and variant identities.
- Set a consumer-specific data contract independent of format marketing.
Before you start
Engineers who produce, convert or ingest SBOMs, and security teams who match them against vulnerability data
- Read package names, versions, digests, dependency edges and supplier fields.
Refresh first: Generate an SBOM for the software you actually release
Go to the guided practiceSelecting a format is an interoperability decision. A valid document can still be unusable when a consumer ignores relationship types, drops qualifiers or supports an older version. Specify format, version, serialization, profiles, required fields, relationship semantics and unknown-value behavior. On 2026-10-10 the current versions are CycloneDX 1.7, released 2025-10-21 and standardized as ECMA-424 2nd edition, and SPDX 3.0.1; SPDX 3.1 is a release candidate. Tooling lags the specifications: Syft, Trivy and BuildKit write SPDX 2.3, and npm sbom writes CycloneDX 1.5, so a requirement for SPDX 3 is usually a conversion requirement. A current specification is not evidence that an installed converter or customer portal supports it.
Suppose a buyer accepts CycloneDX JSON with components, package URLs, hashes and dependency edges. The producer also needs an SPDX export for a different customer. The acceptance test is whether the needed facts survive conversion, not whether both files parse. SPDX and CycloneDX use different models; mechanically renaming fields can change meaning. Keep the original document and a conversion-loss report, and negotiate unsupported facts instead of dropping them silently. For this guide the fixture is a normalized comparison table, not a complete instance of either standard.
CISA's 2026 Minimum Elements for an SBOM, published 2026-07-29, replace the 2021 NTIA list and define 17 data fields. The table maps them, with the 2021 name in brackets where one existed, to the fields that carry them. Two gaps matter in practice: SPDX 2.3 has no field for the generation context, and neither SPDX version carries a signature inside the document, so the SBOM Author Signature is a detached signature or attestation. /tools/sbom-checker/ flags missing versions, suppliers and hashes.
| CISA 2026 element (2021 name) | CycloneDX 1.7 | SPDX 2.3 | SPDX 3.0.1 |
|---|---|---|---|
| SBOM Author (Author of SBOM Data) | metadata.manufacturer, or metadata.authors for people | creationInfo.creators entry Organization: or Person: | CreationInfo.createdBy |
| SBOM Author Signature (new) | signature (JSON Signature Format), or a detached signature or attestation | Detached signature or attestation | Detached signature or attestation |
| SBOM Data Format Name and Version (new) | bomFormat and specVersion | spdxVersion, such as SPDX-2.3 | CreationInfo.specVersion and the JSON-LD @context |
| SBOM Generation Context (new) | metadata.lifecycles[].phase | No field; state it in creationInfo.comment | software_sbomType on the software_Sbom |
| SBOM Timestamp (Timestamp) | metadata.timestamp | creationInfo.created | CreationInfo.created |
| SBOM Tool Name and Version (new) | metadata.tools.components[] name and version | creationInfo.creators entry Tool: name-version | CreationInfo.createdUsing (a Tool element) |
| SBOM Version (new) | serialNumber plus version | A new documentNamespace for each revision | No version field; a new spdxId for each revision |
| Component Producer (Supplier Name) | manufacturer, or supplier | originator, or supplier | originatedBy, or suppliedBy |
| Component Name (Component Name) | components[].name | packages[].name | name |
| Component Version (Version of the Component) | components[].version | packages[].versionInfo | software_packageVersion |
| Component Identifiers (Other Unique Identifiers) | purl, cpe, swid, omniborId, swhid | externalRefs[] of type purl, cpe23Type, swh or gitoid | software_packageUrl and externalIdentifier (cpe23, swhid, gitoid) |
| Component Hash Value and Algorithm (new) | hashes[] alg and content | checksums[] algorithm and checksumValue | verifiedUsing: a Hash with algorithm and hashValue |
| Component License (new) | licenses[] with license.id or expression | licenseDeclared and licenseConcluded | hasDeclaredLicense and hasConcludedLicense relationships |
| Component Dependency Relationship (Dependency Relationship) | dependencies[].dependsOn | relationships[] of type DEPENDS_ON, or the inverse DEPENDENCY_OF | Relationship of type dependsOn |
A document-local reference joins nodes within that document. It need not be a globally meaningful package identity. A package URL identifies a package using ecosystem-specific coordinates; a content digest identifies bytes. These identifiers cannot substitute for one another. Two nodes with different local references may describe the same package. Two different builds may share a package coordinate but have different content. A same-name component in another ecosystem is a different package, even if the versions look identical.
The Package URL specification defines the structured identifier and type-specific rules. For an npm scoped package the namespace is encoded, as in pkg:npm/%40cedar/codec@2.0.0. Compare parsed and canonicalized values using the applicable type rules: an npm package name is lowercase, and a PyPI name is lowercased with _ replaced by -, so pkg:pypi/Django_Rest@3.15.0 and pkg:pypi/django-rest@3.15.0 are one package. Do not lowercase all versions, remove qualifiers, or split strings at punctuation without a parser. Distribution, architecture and other qualifiers can distinguish artifacts that an advisory or consumer treats differently. A hash conflict is evidence needing investigation; it is not repaired by choosing the lexically first record.
A package URL names a package the way its ecosystem does, and became ECMA-427 in December 2025; OSV and the GitHub Advisory Database match on the same ecosystem coordinates, and so do scanners applying the VEX statements in /domains/supply-chain/vex/producer-and-consumer-decisions/. A CPE 2.3 name identifies a vendor, product and version from the NVD CPE dictionary, and NVD CVE records match software through CPE configurations. Generators that cannot read a CPE from package metadata guess one: for alpine:3.22, Syft attached 84 candidate CPE names to 17 components, six of them for alpine-baselayout alone. A guessed vendor or product produces false matches and misses, so record where each CPE came from.
CPE coverage now depends on NVD's priorities. Since 2026-04-15 NIST promptly enriches, with product lists and scores, only CVEs in CISA's KEV catalog, CVEs in software used by the federal government and CVEs in critical software as defined by Executive Order 14028; other CVEs are marked "Lowest Priority - not scheduled for immediate enrichment". A scanner that matches only on NVD CPE data can miss new vulnerabilities in ordinary dependencies, so pair purl-based sources with CPE.
| Identifier | What it names | Use it for | Watch for |
|---|---|---|---|
| purl (ECMA-427) | A package in one ecosystem: type, namespace, name, version and qualifiers | Dependency matching against OSV and GitHub advisories | Type-specific normalization; arch and distro qualifiers are part of identity |
| CPE 2.3 | A vendor, product and version from the NVD dictionary | Matching NVD CVE configurations | Guessed names; CVEs that NVD has not enriched |
| SHA-256 digest | The exact bytes of a file or archive | Binding an SBOM or signature to an artifact | Says nothing about which package or version the bytes are |
| SWHID (ISO/IEC 18670) and OmniBOR gitoid | Source files and build artifacts by intrinsic hash | Tracing source and build inputs | Few vulnerability databases index them |
An inventory graph is more than a bag of names. A dependency edge says one node relies on another under the chosen scope; containment says something else. Build-only and runtime relationships cannot be collapsed when a consumer needs runtime exposure. Duplicate coordinates may be legitimate when separate bundled copies or variants exist. The test is whether their identities, locations and relationship meaning remain available.
Unknown and empty are distinct. An explicit empty dependency list can mean the producer assessed that node and found no dependencies; an absent graph may mean relationships were not collected. When a conversion cannot preserve that distinction, consumers may falsely conclude that an application has no transitive dependencies. Mark the result as lossy and preserve the source observation. The same discipline applies to unknown licenses and suppliers: substituting a guessed value improves apparent completeness while reducing truthfulness.
A round trip should compare normalized semantic facts rather than ordering or local identifier spelling. First map local references to stable identity records; then compare component tuples and typed edges. Compare content digests separately from package coordinates so byte changes remain visible. Include duplicate names, namespace encoding, variants, unknown fields and a missing relationship graph in the test corpus. One easy two-package example is insufficient evidence for the difficult cases that real releases contain. Real tools disagree even without a converter: the CycloneDX and SPDX files that Syft 1.54 writes from one scan of alpine:3.22 list the same 16 packages and 25 dependency edges, but only the SPDX file gives the image itself a package URL, and it states every dependency as DEPENDENCY_OF, the inverse direction of CycloneDX dependsOn. A converter that ignores the direction reverses every edge.
The script below performs that comparison for a CycloneDX and an SPDX 2.3 document: it canonicalizes every purl with packageurl-python, maps local references to purls, normalizes edge direction, and exits with status 1 if packages or edges differ. On the Syft pair it reports the image package that exists only in SPDX. The exercise fixture contains five source rows, including two Debian architectures and two copies of the same npm coordinate with different hashes. The candidate converter produces four rows after merging both Debian variants and drops every edge. Even though its output parses and the visible names look reasonable, it fails the buyer's explicit contract. Supporting a richer format has operational costs in parser maintenance and validation. A narrower agreed contract is acceptable when those losses are stated before consumption; it is not acceptable to call an accidental loss equivalent data.
"""Compare package identities and dependency edges between a CycloneDX and an SPDX 2.3 JSON SBOM."""
import json
import sys
from packageurl import PackageURL # pip install packageurl-python
def canonical(purl):
# Parse and re-serialize, so percent-encoding and qualifier order cannot cause a false mismatch.
return PackageURL.from_string(purl).to_string()
def cyclonedx(doc):
ids = {c["bom-ref"]: canonical(c["purl"]) for c in doc.get("components", []) if c.get("purl")}
edges = {(ids[d["ref"]], ids[t]) for d in doc.get("dependencies", []) if d["ref"] in ids
for t in d.get("dependsOn", []) if t in ids}
return set(ids.values()), edges
def spdx(doc):
ids = {p["SPDXID"]: canonical(r["referenceLocator"]) for p in doc.get("packages", [])
for r in p.get("externalRefs", []) if r["referenceType"] == "purl"}
edges = set()
for r in doc.get("relationships", []):
a, b, kind = r["spdxElementId"], r["relatedSpdxElement"], r["relationshipType"]
if a in ids and b in ids and kind == "DEPENDS_ON":
edges.add((ids[a], ids[b]))
elif a in ids and b in ids and kind == "DEPENDENCY_OF":
edges.add((ids[b], ids[a]))
return set(ids.values()), edges
with open(sys.argv[1], encoding="utf-8") as f1, open(sys.argv[2], encoding="utf-8") as f2:
(p1, e1), (p2, e2) = cyclonedx(json.load(f1)), spdx(json.load(f2))
print(f"packages: {len(p1)} CycloneDX, {len(p2)} SPDX; edges: {len(e1)} CycloneDX, {len(e2)} SPDX")
for label, items in [("only in CycloneDX", p1 - p2), ("only in SPDX", p2 - p1),
("edges only in CycloneDX", e1 - e2), ("edges only in SPDX", e2 - e1)]:
for item in sorted(items):
print(f"{label}: {item}")
sys.exit(1 if p1 ^ p2 or e1 ^ e2 else 0)Guided practice
A buyer requires component ecosystem, namespace, version, architecture when present, content hash and runtime dependency edges to survive export.
Supplied scenario material
Source normalized rows (hashes are fictional labels):
r1 | pkg:npm/%40cedar/codec@2.0.0 | H1 | runtime
r2 | pkg:pypi/codec@2.0.0 | H2 | runtime
r3 | pkg:deb/debian/libcodec@2.0.0?arch=amd64 | H3 | runtime
r4 | pkg:deb/debian/libcodec@2.0.0?arch=arm64 | H4 | runtime
r5 | pkg:npm/%40cedar/codec@2.0.0 | H5 | runtime, vendored copy
Edges: app depends on r1,r2,r3,r4; r1 depends on r5.
Candidate export: r1 H1; r2 H2; libcodec 2.0.0 H3 (no architecture or ecosystem); r5 H5. All edges absent.
Candidate parses. Tool reports no conversion warnings.
Contract C2: no loss of any listed identity field, hash, or runtime edge.1. Perform exact identity comparisons
Decide which source pairs share package coordinates and which share bytes.
Expected result and reasoning
r1/r5 share the exact npm coordinate but H1 differs from H5, so byte equality is not established. r1/r2 differ by ecosystem and namespace. r3/r4 differ by architecture. No supplied pair shares a hash.
2. Count semantic losses
Compare source rows and edges with the candidate.
Expected result and reasoning
One component variant was merged away; architecture and ecosystem information for libcodec were lost. All five dependency edges are absent. Four output rows and successful parsing cannot satisfy C2.
3. Explain a likely wrong vulnerability match
Describe the error made by matching only codec plus version.
Expected result and reasoning
Such matching could apply a PyPI advisory to the scoped npm component and could miss the Debian packaging context. The actual match must include the advisory ecosystem and relevant package/distribution identity, not just a display name.
4. Specify the conversion test
Write exact round-trip assertions without depending on local reference names.
Expected result and reasoning
Require five component records with the original coordinate/hash tuples and five typed dependency edges after mapping local refs. Preserve both Debian architectures and both npm byte variants. Unknown relationships must stay unknown, never turn into an asserted empty list.
5. Make the delivery decision
Decide whether to ship this export and propose a bounded alternative.
Expected result and reasoning
Reject the candidate under C2. Send the original supported format or fix the converter and rerun these assertions. A buyer may explicitly accept a narrower contract, but a warning-free tool result is not that acceptance.
The compact table teaches identity and conversion semantics; it is not a complete CycloneDX or SPDX document and does not validate either schema.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- CycloneDX specification overview, version 1.7. Version 1.7 (October 2025).
- SPDX specification 3.0.1
- Package URL specification
- CISA: 2026 Minimum Elements for a Software Bill of Materials. 29 July 2026.
- ECMA-424: CycloneDX Bill of Materials specification. 2nd edition (December 2025).
- ECMA-427: Package-URL (PURL) specification. 1st edition (December 2025).
- SPDX specification 2.3. Version 2.3.1.
- NVD: Official Common Platform Enumeration (CPE) Dictionary
- NIST: NIST updates NVD operations to address record CVE growth (2026-04-15)
- packageurl-python
- CycloneDX CLI