---
key: "sbom-formats-identifiers"
title: "Choose SBOM formats and identify components accurately"
url: "https://softwaresecurity.com/domains/supply-chain/sbom/formats-and-identifiers/"
api: "https://softwaresecurity.com/api/v1/articles/sbom-formats-identifiers.json"
domain: "supply-chain"
topic: "Software supply chain"
type: "Guide"
identifiers: ["A03:2025"]
tags: ["SBOM"]
stages: ["release"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Choose SBOM formats and identify components accurately

Separate schema versions, document references and package identities so inventory conversion and advisory matching preserve meaning.

Canonical URL: https://softwaresecurity.com/domains/supply-chain/sbom/formats-and-identifiers/

## In short

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-ref` or `SPDXID` only links nodes inside one document.
- Parse purls before comparing them: npm and PyPI names have type-specific normalization, and qualifiers such as `arch` are 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.

## Verify it

1. Run `python compare_sboms.py app.cdx.json app.spdx.json` on every conversion and require exit code 0 or a documented reason for each reported difference.
2. 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 with `pyspdxtools -i app.spdx.json`; both exit non-zero on an invalid document.
3. Parse every purl with a purl library and confirm that it re-serializes to the stored string.
4. For each CPE, record whether it came from the NVD CPE dictionary or was guessed by the generator.
5. Confirm that the root component in each output names the released artifact by digest.

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

Audience: Engineers who produce, convert or ingest SBOMs, and security teams who match them against vulnerability data

Before you start:

- Read package names, versions, digests, dependency edges and supplier fields.

Time: About 31 minutes: 8 to read and 23 for the exercise

## Treat an SBOM format as an interface contract

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

## Find each minimum element in CycloneDX and SPDX

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

## Separate graph references, package coordinates and bytes

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.

## Use purl for packages and CPE where a database needs it

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 |

## Preserve what an edge actually claims

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.

## Use a fixture designed to expose information loss

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.

```python
"""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 material

```text
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.

Worked answer: 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.

Worked answer: 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.

Worked answer: 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.

Worked answer: 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.

Worked answer: 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.

Deliverable: A semantic conversion report naming the lost variant, fields and five edges, with precise acceptance assertions.

Scope: The compact table teaches identity and conversion semantics; it is not a complete CycloneDX or SPDX document and does not validate either schema.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [CycloneDX specification overview, version 1.7](https://cyclonedx.org/specification/overview/). Version 1.7 (October 2025).
- [SPDX specification 3.0.1](https://spdx.github.io/spdx-spec/v3.0.1/)
- [Package URL specification](https://github.com/package-url/purl-spec)
- [CISA: 2026 Minimum Elements for a Software Bill of Materials](https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom). 29 July 2026.
- [ECMA-424: CycloneDX Bill of Materials specification](https://ecma-international.org/publications-and-standards/standards/ecma-424/). 2nd edition (December 2025).
- [ECMA-427: Package-URL (PURL) specification](https://ecma-international.org/publications-and-standards/standards/ecma-427/). 1st edition (December 2025).
- [SPDX specification 2.3](https://spdx.github.io/spdx-spec/v2.3.1/). Version 2.3.1.
- [NVD: Official Common Platform Enumeration (CPE) Dictionary](https://nvd.nist.gov/products/cpe)
- [NIST: NIST updates NVD operations to address record CVE growth (2026-04-15)](https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth)
- [packageurl-python](https://github.com/package-url/packageurl-python)
- [CycloneDX CLI](https://github.com/CycloneDX/cyclonedx-cli)