BOM-Link is a CycloneDX URN, written urn:cdx:serialNumber/version#bom-ref, that points at another BOM document or at one component, service or vulnerability inside it. Use it when a release ships several BOMs (SBOM, VEX, CBOM, AIBOM, HBOM) and each needs to reference the others by identity rather than by copying content.
The rest of this post covers what causes trouble in practice: where the schema accepts a BOM-Link and where it does not, how a VEX in one file targets a library in another, why a regenerated SBOM breaks every inbound link, and how to package and check a linked set. Every JSON example validates against CycloneDX 1.6, and we checked the same files against 1.7.
Key takeaways
- A BOM-Link has three parts: the target BOM's serial number (a UUID), its version (an integer from 1) and an optional
bom-refafter#. Without the fragment it names a document; with it, one element. - The
cdxURN namespace was registered with IANA on 19 March 2022. Typed BOM-Link definitions first appear in the CycloneDX 1.5 schema (26 June 2023) and are unchanged in 1.6 and 1.7. - BOM-Links are accepted in
externalReferences[].url,vulnerabilities[].affects[].ref,annotations[].subjects,compositions[].assembliesand a few other fields, but not independencies. - A malformed BOM-Link in
affects[].refstill passes schema validation. You need a separate resolution check; one is included below. - BOM-Link is identity, not location. Pair it with a download location and hash, or a channel such as OCI referrers or a TEA collection.
- Regenerated serial numbers and version bumps are what break links. Write down which one your pipeline does on a rebuild and which on a correction.
What is CycloneDX BOM-Link?
The BOM-Link capability page describes it as a way to "reference components, services, or vulnerabilities in BOMs from other systems or other BOMs." The goal is modularity: keep software inventory, cryptographic assets, model metadata and vulnerability analysis in separate BOMs and connect them. That also lets you share one document without exposing the others, which matters when a CBOM or HBOM is more sensitive than the SBOM it relates to.
The page states that BOM-Link is a formally registered URN governed by IANA and compliant with RFC 8141. The IANA registration for the cdx namespace, filed on behalf of the OWASP CycloneDX project, is dated 2022-03-19. It says the URN lets a BOM reference an upstream BOM for a component instead of embedding it, for performance on constrained devices or when a supplier lacks authority to share the upstream content.
The syntax, field by field
The capability page gives the syntax as urn:cdx:serialNumber/version#bom-ref and these examples:
urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1
urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1#componentA
The first names a whole document. The second names the element whose bom-ref is componentA inside it.
| Part | Where it comes from | Rules | Required |
|---|---|---|---|
urn:cdx: | Fixed prefix | Registered URN namespace | Yes |
| serial number | The target BOM's serialNumber, without urn:uuid: | A UUID; IANA says version 4; the schema pattern allows lowercase hex only | Yes |
| version | The target BOM's version | Integer of 1 or more; default 1 | Yes |
#bom-ref | The bom-ref of an object in the target | Any non-empty string; it is a URI fragment | No |
The CycloneDX 1.6 schema in the specification repository enforces this with two patterns:
bomLinkDocumentType: ^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*$
bomLinkElementType: ^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*#.+$
Two details trip people up. The hex must be lowercase, so a UUID from a tool that prints uppercase fails. And version 0 is invalid.
The IANA registration also carries a privacy note. Fragments travel with every linking document, so a bom-ref such as internal-hsm-key-rotation-service leaks a name. It recommends UUIDs as fragments where confidentiality matters.
When was BOM-Link added?
| Date | Event | Source |
|---|---|---|
| 2022-03-19 | cdx URN namespace registered with IANA | IANA URN registry |
| 2023-06-26 | CycloneDX 1.5 released; first schema to define bomLink, bomLinkDocumentType and bomLinkElementType | Specification repository |
| 2024-04-09 | CycloneDX 1.6 released; BOM-Link definitions unchanged | Specification repository |
| 2025-10-21 | CycloneDX 1.7 released; BOM-Link definitions unchanged | CycloneDX specification overview |
| 2025-12-10 | ECMA-424 listed as published on the CycloneDX overview page | CycloneDX specification overview |
We checked the schema files directly: 1.4 has no BOM-Link definitions and no urn:cdx pattern; 1.5, 1.6 and 1.7 each have the same three. A BOM-Link in a 1.4 externalReferences[].url still passes, because that field accepts any IRI, but nothing tells a 1.4 consumer it is a link.
Where the schema accepts a BOM-Link
This table tells you which relationships you can express across documents. We walked every $ref to the BOM-Link definitions in the 1.6 and 1.7 schemas.
| Field | Accepts | What it lets you say across documents |
|---|---|---|
externalReferences[].url (BOM, component, service and elsewhere) | Document or element link, or any IRI | "The BOM for this thing is that one" (type bom), or "the VEX is there" (type exploitability-statement) |
vulnerabilities[].affects[].ref | Element link or local bom-ref | "This analysis applies to that component in that SBOM" |
annotations[].subjects[] | Element link or local bom-ref | "This note is about these objects," local and remote mixed |
compositions[].assemblies[] | Element link or local bom-ref | Completeness statements about assemblies held elsewhere |
services[].data[].source[] and destination[] | Element link or URL style reference | Data flows between services described in different BOMs |
modelCard.modelParameters.datasets[].ref | Element link or local bom-ref | "This model was trained on a dataset described in another BOM" |
resourceReferenceChoice.ref (formulation and others) | Element link or local bom-ref | Build steps that consume resources described elsewhere |
componentIdentityEvidence.tools[] | Element link or local bom-ref | The tool behind identity evidence, described elsewhere |
dependencies[].ref and dependsOn[] | Local bom-ref only | Nothing. The dependency graph is per document |
The last row surprises people. You cannot draw a dependency edge into a supplier's SBOM with a BOM-Link, because dependsOn items are typed as same-document references. Instead, keep a local stub component with an externalReferences entry of type bom pointing at the upstream BOM, use compositions, or merge the documents.
BOM-Link vs externalReferences vs nested BOMs
These mechanisms overlap. BOM-Link does not replace externalReferences; often it is the value you put inside one.
Identity versus location
An externalReferences entry has a type, a url, an optional comment and optional hashes. Since 1.5 the url accepts a normal IRI or a BOM-Link, and the schema's description of the field names BOM-Link as a registered URN for referencing a whole BOM or any object in one.
- An HTTPS URL is a location: where to fetch bytes today. It does not say which document version those bytes are, and they can change.
- A BOM-Link is an identity: one exact BOM version, and optionally one element. It does not say where to fetch it.
The strongest pattern uses both. Add two bom references, one holding the BOM-Link and one holding an HTTPS URL with a SHA-256 hash. The consumer resolves the identity against what it holds, and falls back to the location, verifying the hash, when it does not.
Nested and merged BOMs
A CycloneDX component can carry child components in its own components array, so a subsystem's inventory can sit inside its parent. The CycloneDX CLI has a merge command with a hierarchical mode that builds such a document from several inputs; its README notes that each input must describe its subject in the metadata component.
Nesting gives you one self contained file that is easy to consume and sign. It also throws away the separation BOM-Link exists to keep: one serial number, one version, one access policy. If the CBOM part is restricted and the SBOM part is public, a merged file forces you to pick one.
Comparison table
| Approach | What travels | Version pinned | Targets one element | Separate access control | Works in dependencies | Good for |
|---|---|---|---|---|---|---|
| Nested components or hierarchical merge | Full content in one file | By the merged BOM's version | Yes, by local bom-ref | No | Yes | Small products, one audience, offline use |
externalReferences with HTTPS URL and hash | A pointer to bytes | Only through the hash | No | Yes | No | Telling a consumer where to download |
externalReferences with document BOM-Link | An identity | Yes | No | Yes | No | "The BOM for this component is that exact document" |
Element BOM-Link in affects, subjects, assemblies and similar | Identity of one object | Yes | Yes | Yes | No | VEX, annotations, CBOM and AIBOM cross references |
For nesting and resolution strategies, read nested SBOMs and external references and how to resolve them. For how bom-ref, serialNumber and version behave inside one document, see CycloneDX bom-ref, serial number and version explained. This post stays on links between documents.
How SPDX handles the same problem
SPDX 3.0.1 gives every element an IRI as its spdxId, and an SpdxDocument declares elements it uses but does not define in an ExternalMap. The ExternalMap class has a required externalSpdxId and optional locationHint, verifiedUsing (integrity methods) and definingArtifact. SPDX bundles identity, location and integrity in one structure; CycloneDX keeps identity in the BOM-Link and leaves location and integrity to other fields. Converting links between the two is not mechanical.
How a VEX document points at a component in a separate SBOM
The CycloneDX VEX capability page describes VEX as conveying whether a vulnerability in a component can be exploited in the context of the product. The product context lives in the SBOM; the judgment lives in the VEX. They change at different speeds: the SBOM is fixed at build time, while the VEX is revised every time a new vulnerability is analysed.
A CycloneDX VEX is a BOM whose main content is the vulnerabilities array. Each vulnerability has affects entries, and when the VEX is a separate document, each entry's ref is an element BOM-Link:
"affects": [
{ "ref": "urn:cdx:edb88733-49ea-4636-a5d4-52848b5f50f5/1#pkg:npm/lodash@4.17.20" }
]
Read left to right: the SBOM with serial edb88733... at version 1, then the element whose bom-ref is pkg:npm/lodash@4.17.20. A consumer holding that SBOM attaches the analysis to that exact component, not to every lodash 4.17.20 it has seen.
Matching by package URL alone, which matching VEX statements to SBOM components by purl covers in depth, works across products but cannot tell two copies of a package apart. A BOM-Link says "this copy, in this release." Using a purl as the bom-ref, as the example below does, gives you both: a consumer that cannot resolve the link can still fall back to purl matching.
One schema rule to respect: the refType definition says a local bom-ref should not start with urn:cdx:, so it cannot be confused with a link. A comment in the schema notes that enforcing this would be a breaking change, so nothing enforces it today.
Worked example: one release, four linked documents
Here is a minimal set for one release of payments-api 2.4.0, which contains a JavaScript library, an OpenSSL build and a fraud scoring model.
| Document | Serial number | Links out to | Linked to from |
|---|---|---|---|
| SBOM | edb88733-49ea-4636-a5d4-52848b5f50f5 | CBOM (from OpenSSL), AIBOM (from the model), latest VEX by HTTPS | VEX, CBOM, AIBOM |
| VEX | cc66b67a-deb9-4757-9714-b49a578c1e74 | SBOM element pkg:npm/lodash@4.17.20 | Found by location |
| CBOM | 671fa157-5f1a-44f9-b0da-ed150c5c2cb9 | SBOM element pkg:generic/openssl@3.0.13, twice | SBOM |
| AIBOM (ML-BOM) | 07cd7882-893a-46f5-a259-1faa9cd46fe3 | SBOM element model-fraud-scorer-1.3.0 | SBOM |
Notice what is not linked. The SBOM points at a stable HTTPS location for the latest VEX, not at a VEX serial and version. The SBOM is frozen at build time and the VEX will gain versions, so a BOM-Link from SBOM to VEX would be stale after the first revision.
All four files validate against bom-1.6.schema.json. With specVersion changed to 1.7, all four also validate against bom-1.7.schema.json. The CVE in the VEX is a real, public lodash advisory used only to show structure; the not_affected analysis is invented for the example.
Document 1: the SBOM
{
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:edb88733-49ea-4636-a5d4-52848b5f50f5",
"version": 1,
"metadata": {
"timestamp": "2026-10-20T09:00:00Z",
"component": {
"type": "application",
"bom-ref": "pkg:oci/payments-api@2.4.0",
"name": "payments-api",
"version": "2.4.0"
}
},
"components": [
{
"type": "library",
"bom-ref": "pkg:npm/lodash@4.17.20",
"name": "lodash",
"version": "4.17.20",
"purl": "pkg:npm/lodash@4.17.20"
},
{
"type": "library",
"bom-ref": "pkg:generic/openssl@3.0.13",
"name": "openssl",
"version": "3.0.13",
"purl": "pkg:generic/openssl@3.0.13",
"externalReferences": [
{
"type": "bom",
"url": "urn:cdx:671fa157-5f1a-44f9-b0da-ed150c5c2cb9/1",
"comment": "CBOM for the cryptography this library provides"
}
]
},
{
"type": "machine-learning-model",
"bom-ref": "model-fraud-scorer-1.3.0",
"name": "fraud-scorer",
"version": "1.3.0",
"externalReferences": [
{
"type": "bom",
"url": "urn:cdx:07cd7882-893a-46f5-a259-1faa9cd46fe3/1",
"comment": "ML-BOM (AIBOM) for this model"
}
]
}
],
"externalReferences": [
{
"type": "exploitability-statement",
"url": "https://example.com/vex/payments-api/2.4.0/latest.cdx.json",
"comment": "Stable location of the newest VEX for this release"
}
],
"dependencies": [
{
"ref": "pkg:oci/payments-api@2.4.0",
"dependsOn": ["pkg:npm/lodash@4.17.20", "pkg:generic/openssl@3.0.13", "model-fraud-scorer-1.3.0"]
},
{ "ref": "pkg:npm/lodash@4.17.20", "dependsOn": [] },
{ "ref": "pkg:generic/openssl@3.0.13", "dependsOn": [] },
{ "ref": "model-fraud-scorer-1.3.0", "dependsOn": [] }
]
}
The SBOM uses document links (no fragment). The schema describes the bom reference type as covering any bill of materials, naming SBOM, OBOM, HBOM and SaaSBOM as examples, so one type serves every BOM kind.
Document 2: the VEX
{
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:cc66b67a-deb9-4757-9714-b49a578c1e74",
"version": 1,
"metadata": {
"timestamp": "2026-10-21T14:30:00Z",
"component": {
"type": "application",
"bom-ref": "payments-api-2.4.0",
"name": "payments-api",
"version": "2.4.0"
}
},
"vulnerabilities": [
{
"bom-ref": "vex-cve-2021-23337",
"id": "CVE-2021-23337",
"source": {
"name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23337"
},
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "payments-api never calls _.template; the vulnerable function is not on any execution path."
},
"affects": [
{
"ref": "urn:cdx:edb88733-49ea-4636-a5d4-52848b5f50f5/1#pkg:npm/lodash@4.17.20"
}
]
}
]
}
The VEX's own metadata.component helps a human reader and a consumer that cannot resolve the link. The affects entry is the machine binding.
Document 3: the CBOM
The CBOM capability page covers algorithms, keys, certificates and their relationships to software components. This CBOM describes one algorithm the OpenSSL build provides and ties it back to the SBOM in two places.
{
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:671fa157-5f1a-44f9-b0da-ed150c5c2cb9",
"version": 1,
"metadata": {
"timestamp": "2026-10-20T09:05:00Z",
"component": {
"type": "library",
"bom-ref": "openssl-3.0.13",
"name": "openssl",
"version": "3.0.13",
"externalReferences": [
{
"type": "bom",
"url": "urn:cdx:edb88733-49ea-4636-a5d4-52848b5f50f5/1#pkg:generic/openssl@3.0.13",
"comment": "The SBOM entry this CBOM describes"
}
]
}
},
"components": [
{
"type": "cryptographic-asset",
"bom-ref": "crypto-aes-256-gcm",
"name": "AES-256-GCM",
"cryptoProperties": {
"assetType": "algorithm",
"algorithmProperties": {
"primitive": "ae",
"mode": "gcm",
"parameterSetIdentifier": "256",
"cryptoFunctions": ["encrypt", "decrypt"],
"classicalSecurityLevel": 256,
"nistQuantumSecurityLevel": 5
},
"oid": "2.16.840.1.101.3.4.1.46"
}
}
],
"annotations": [
{
"subjects": [
"crypto-aes-256-gcm",
"urn:cdx:edb88733-49ea-4636-a5d4-52848b5f50f5/1#pkg:generic/openssl@3.0.13"
],
"annotator": { "organization": { "name": "Example Corp crypto inventory" } },
"timestamp": "2026-10-20T09:05:00Z",
"text": "AES-256-GCM is provided by the OpenSSL build listed in the release SBOM."
}
]
}
The annotation's subjects mixes a local bom-ref with a remote element link, which is the closest CycloneDX gets to a cross document relationship statement. CycloneDX 1.6 added a provides field to dependencies for "this component implements that crypto asset," but like dependsOn it only accepts same-document references, so a separate CBOM has to use annotations or external references.
Document 4: the AIBOM
CycloneDX calls this an ML-BOM; the ML-BOM capability page covers models, datasets and provenance.
{
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:07cd7882-893a-46f5-a259-1faa9cd46fe3",
"version": 1,
"metadata": {
"timestamp": "2026-10-20T09:10:00Z",
"component": {
"type": "machine-learning-model",
"bom-ref": "fraud-scorer-1.3.0",
"name": "fraud-scorer",
"version": "1.3.0",
"externalReferences": [
{
"type": "bom",
"url": "urn:cdx:edb88733-49ea-4636-a5d4-52848b5f50f5/1#model-fraud-scorer-1.3.0",
"comment": "Where this model sits in the release SBOM"
}
]
}
},
"components": [
{
"type": "machine-learning-model",
"bom-ref": "fraud-scorer-weights",
"name": "fraud-scorer",
"version": "1.3.0",
"modelCard": {
"modelParameters": {
"task": "classification",
"datasets": [ { "ref": "dataset-transactions-2025q4" } ]
}
}
},
{
"type": "data",
"bom-ref": "dataset-transactions-2025q4",
"name": "transactions-2025q4",
"data": [
{
"type": "dataset",
"name": "transactions-2025q4",
"classification": "confidential"
}
]
}
]
}
The dataset reference is local here. Because datasets[].ref accepts an element BOM-Link, a model card can instead point at a dataset in a separate data BOM, which keeps a confidential dataset description out of the AIBOM you share with customers.
Where an HBOM fits
An HBOM for the appliance that runs payments-api would have its own serial, referenced from a device component or the SBOM's metadata component with a bom external reference. For HBOM structure, see the HBOM complete guide, how to create an HBOM in CycloneDX and HBOM vs SBOM.
Step by step: build, validate and resolve a linked BOM set
Step 1: decide your serial number policy first
The 1.6 schema recommends a unique serial number for every BOM generated, even when contents have not changed. It recommends incrementing version by 1 whenever an existing BOM is modified, and says a system holding several BOMs with one serial should use the most recent. The IANA registration agrees: a revised BOM keeps its serial and increments the version.
| Operation | Serial number | Version | Links to the old document |
|---|---|---|---|
| New build | New UUID | 1 | Unaffected; they describe a different build |
| Same build, BOM regenerated by a new tool run | New UUID, per schema guidance | 1 | All break |
| Correction to a published BOM | Same | Previous plus 1 | Still resolve to the old version; consumers can follow forward |
A common source of breakage is a release job that regenerates the SBOM on every rerun, issuing a new serial each time, while the VEX team keeps linking to the first one. Decide which operation a rerun performs.
Step 2: make bom-refs stable and safe
A fragment is only as stable as the bom-ref it names. Derive bom-ref values from inputs that do not change between runs for the same build (a purl for packages; a name and version you control for models, datasets and crypto assets). Keep them URI safe: spaces, # and non ASCII characters need percent encoding. Use UUIDs for anything sensitive.
Step 3: generate lowercase serial numbers
# macOS and Linux
echo "urn:uuid:$(uuidgen | tr 'A-F' 'a-f')"
# Python, any platform
python3 -c 'import uuid; print(f"urn:uuid:{uuid.uuid4()}")'
Step 4: validate each document with the CycloneDX CLI
The CycloneDX CLI README documents a validate command that takes an input file, an input format, an input spec version (v1_0 to v1_7, default v1.7) and a fail on errors switch that returns a non zero exit code. It also documents a Homebrew tap and a Docker image.
brew install cyclonedx/cyclonedx/cyclonedx-cli
for f in release-boms/*.cdx.json; do
cyclonedx-cli validate --input-file "$f" --input-version v1_6 --fail-on-errors || exit 1
done
Schema validation is necessary but not sufficient for links. We replaced the VEX link with urn:cdx:EDB88733/0#pkg:npm/lodash@4.17.20 (uppercase, truncated UUID, version zero) and the file still validated against 1.6. The reason: affects[].ref accepts either a BOM-Link element or a plain bom-ref, and a plain bom-ref can be any non-empty string. externalReferences[].url behaves the same way, since it accepts any IRI.
Step 5: resolve every link against the set you ship
This script indexes every BOM in a directory by serial and version, then checks every string starting with urn:cdx:. It reports malformed links, links to documents not in the set, links to missing bom-ref values, and links to an older version when a newer one is present. It needs Python 3.9 or later.
#!/usr/bin/env python3
"""Resolve every CycloneDX BOM-Link found in a directory of BOMs."""
import json, re, sys, pathlib
LINK = re.compile(r"^urn:cdx:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/([1-9][0-9]*)(?:#(.+))?$")
def bom_refs(node, found):
if isinstance(node, dict):
if "bom-ref" in node:
found.add(node["bom-ref"])
for v in node.values():
bom_refs(v, found)
elif isinstance(node, list):
for v in node:
bom_refs(v, found)
return found
def links(node, path="$"):
if isinstance(node, dict):
for k, v in node.items():
yield from links(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
yield from links(v, f"{path}[{i}]")
elif isinstance(node, str) and node.startswith("urn:cdx:"):
yield path, node
index, latest = {}, {}
files = sorted(pathlib.Path(sys.argv[1]).glob("*.json"))
for f in files:
doc = json.loads(f.read_text())
serial = doc.get("serialNumber", "").removeprefix("urn:uuid:")
version = int(doc.get("version", 1))
index[(serial, version)] = (f.name, bom_refs(doc, set()))
latest[serial] = max(version, latest.get(serial, 0))
failures = 0
for f in files:
doc = json.loads(f.read_text())
for path, value in links(doc):
m = LINK.match(value)
if not m:
print(f"MALFORMED {f.name} {path}: {value}"); failures += 1; continue
serial, version, ref = m.group(1), int(m.group(2)), m.group(3)
target = index.get((serial, version))
if target is None:
print(f"MISSING {f.name} {path}: no BOM {serial} version {version}"); failures += 1; continue
if ref is not None and ref not in target[1]:
print(f"NO-REF {f.name} {path}: {target[0]} has no bom-ref '{ref}'"); failures += 1; continue
note = f" (newer version {latest[serial]} exists)" if latest[serial] > version else ""
print(f"OK {f.name} {path} -> {target[0]}{'#' + ref if ref else ''}{note}")
sys.exit(1 if failures else 0)
Against the four example files it resolves all six links and exits 0. Then we simulated the most common failure: we regenerated the SBOM with a fresh serial and added the broken VEX from step 4. The script exits 1:
MISSING aibom.cdx.json $.metadata.component.externalReferences[0].url: no BOM edb88733-49ea-4636-a5d4-52848b5f50f5 version 1
MISSING cbom.cdx.json $.metadata.component.externalReferences[0].url: no BOM edb88733-49ea-4636-a5d4-52848b5f50f5 version 1
MISSING cbom.cdx.json $.annotations[0].subjects[1]: no BOM edb88733-49ea-4636-a5d4-52848b5f50f5 version 1
OK sbom.cdx.json $.components[1].externalReferences[0].url -> cbom.cdx.json
OK sbom.cdx.json $.components[2].externalReferences[0].url -> aibom.cdx.json
MALFORMED vex-bad.json $.vulnerabilities[0].affects[0].ref: urn:cdx:EDB88733/0#pkg:npm/lodash@4.17.20
MISSING vex.cdx.json $.vulnerabilities[0].affects[0].ref: no BOM edb88733-49ea-4636-a5d4-52848b5f50f5 version 1
Note the asymmetry. The SBOM's outbound links still resolve, so a check that only looks at the SBOM passes, while every inbound link is gone.
Step 6: run both checks in CI
name: release-bom-links
on:
push:
paths: ["release-boms/**"]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Schema validation against CycloneDX 1.6
run: |
for f in release-boms/*.cdx.json; do
docker run --rm -v "$PWD:/work" -w /work cyclonedx/cyclonedx-cli \
validate --input-file "$f" --input-version v1_6 --fail-on-errors
done
- name: Resolve every BOM-Link in the release set
run: python3 scripts/resolve_bomlinks.py release-boms
Schema validation catches structural errors; the resolver catches what schema validation lets through.
Packaging a release's whole BOM set
Links name documents; they do not deliver them. CISA's SBOM Sharing Lifecycle Report (April 2023) splits sharing into Discovery (how a consumer learns an SBOM exists and how to get it), Access (who may view or use it) and Transport (how it moves). BOM-Link lets a consumer confirm that what it discovered and received is the exact document another document refers to. There are three common ways to ship the set.
Option 1: one directory with a manifest
mkdir -p dist/payments-api-2.4.0-boms
cp sbom.cdx.json vex.cdx.json cbom.cdx.json aibom.cdx.json dist/payments-api-2.4.0-boms/
cd dist/payments-api-2.4.0-boms
python3 ../../scripts/resolve_bomlinks.py . && sha256sum *.cdx.json > SHA256SUMS
cd .. && tar -czf payments-api-2.4.0-boms.tar.gz payments-api-2.4.0-boms
On macOS, use shasum -a 256. This suits audits and procurement handoffs, but the VEX inside a frozen archive goes stale. Ship the VEX separately, or reissue the archive under a new name when it changes.
Option 2: OCI referrers
For container releases, each BOM can be pushed to the registry as an artifact whose subject is the image digest. A consumer asks the registry for everything that refers to the digest and gets the SBOM, VEX, CBOM, AIBOM and their signatures. The mechanics are in the OCI referrers API explained.
Referrers answer "which documents are attached to this digest?" BOM-Links answer "which document, and which element, does this statement mean?" Use both. A VEX revision becomes a new referrer next to the old one, and the consumer takes the highest CycloneDX version for that serial.
Option 3: a Transparency Exchange API collection
The Transparency Exchange API (TEA) is a format agnostic API for publishing and retrieving transparency artefacts, worked on in Ecma TC54 task group 1 with the aim of standardisation. Its README says it is at Beta 2, focused on the consumer side, with publisher API work to follow the 1.0 release. Treat it as emerging.
A Transparency Exchange Identifier (TEI), a URL of the form tei://domain/type/identifier, resolves through DNS based discovery to a Product Release, and each release has a TEA Collection: a versioned list of artifacts. The collection specification is strict about change:
- A collection keeps its UUID across versions; its version increments by 1 when content changes, and old versions stay unchanged.
- Each artifact revision is immutable; any change to the document or its published URLs creates a new revision.
- Artifact type
BOMcovers every kind of bill of materials, andVULNERABILITIEScovers VDR and VEX. - Each new collection version carries an
updateReason.VEX_UPDATEDis separate fromARTIFACT_UPDATEDand takes precedence when several changes happen together, so a client can alert differently on a VEX change than on a corrected SBOM.
In that model, the SBOM, CBOM and AIBOM are long lived artifacts, the VEX gains revisions, and the BOM-Links inside the VEX keep pointing at the SBOM serial and version the collection already holds. For adoption timing, read do you need the Transparency Exchange API yet?; for channels compared, see SBOM distribution patterns in 2026.
Which packaging to choose
| Situation | Package | Why |
|---|---|---|
| One-off audit or procurement request | Archive with SHA256SUMS | Simple, offline, easy to sign as one file |
| Container releases pulled from your registry | OCI referrers per document | Documents travel with the digest; VEX revisions append cleanly |
| Many customers, frequent VEX updates, automated consumers | TEA collection, or a portal while TEA matures | Versioned collections, explicit update reasons, discovery by TEI |
| Restricted CBOM or HBOM, public SBOM | Any of the above, restricted documents under separate access | The public SBOM can name the restricted document without revealing it |
How consumers resolve links at scale
With hundreds of products and several suppliers each, you hold tens of thousands of BOMs. Resolution needs an index:
- Index on ingest by
(serial, version), storing thebom-refset, the product or digest described and the origin. - Keep every version. Overwriting version 1 with version 2 breaks every link to version 1.
- Resolve exact, then forward. If a newer version of the serial exists and still contains the linked
bom-ref, carrying the statement forward follows the schema's "use the most recent" guidance. If the element is gone, flag the statement for review. - Fall back by purl, and label it. When the serial is unknown, a purl
bom-refcan still match the product's current SBOM; mark it as a weaker match. - Queue unresolved links and retry when new BOMs arrive from that supplier.
What breaks: version bumps and regenerated serial numbers
- Regenerated SBOMs. A rerun issues a new serial and replaces the stored SBOM, orphaning every inbound link, as the resolver output above shows. Either reuse the stored SBOM on reruns or re-link dependent documents whenever you regenerate.
- Renamed elements in a correction. A supplier fixes a typo and also changes a
bom-ref. Version 2 is right, but links to the old fragment now point at nothing. Treat publishedbom-refvalues as a public interface. - Consumers that never follow forward. A VEX links to version 1; the supplier publishes version 2. Exact-match-only consumers never apply the analysis to version 2. Agree on behaviour with suppliers.
- Uppercase or short UUIDs. They pass schema validation in
affectsandexternalReferencesand never resolve. - Links attempted in
dependencies. Validation rejects them, and the hurried fix of deleting the edge silently drops a dependency. Use a stub component with an external reference. - Mixed spec versions. A 1.6 VEX can link to a 1.4 SBOM, since the link needs only serial and version. Validate each document against its own
specVersion, not the linking document's.
Where Safeguard fits
Safeguard's fit here is real but partial. SBOM generation and analysis is generally available and produces CycloneDX (JSON and XML) and SPDX (JSON, XML and RDF), with more than 30 export formats and continuous scanning on push, schedule or release; that covers the SBOM in the worked example. CSAF and VEX upload is generally available. AI-BOM discovery, which inventories AI assets across code, cloud, SaaS and vector databases and supports CycloneDX ML-BOM and the SPDX AI profile, is in early access. Attestation and signing is generally available (Sigstore keyless, in-toto, SLSA, X.509), so you can sign each document or the archive. Third party risk management, generally available, lets you request SBOMs from vendors, and the portal for publishing and sharing SBOMs is generally available, with Trust Center itself in early access.
Safeguard does not generate CBOMs or a cryptographic inventory, does not generate HBOMs and does not run a Transparency Exchange API server. Its offering documentation does not describe cross document BOM-Link resolution, so treat the resolver step above as yours to run in CI.
Common mistakes
- Treating schema validation as link validation.
- Letting the SBOM tool issue a new serial on every rerun of the same release.
- Linking the SBOM to one specific VEX version instead of the VEX location.
- Using
bom-refvalues that change between runs, such as freshly generated UUIDs. - Putting sensitive names in fragments.
- Writing local
bom-refvalues that start withurn:cdx:. - Overwriting older BOM versions in storage.
- Merging documents to dodge link problems, then sharing the merged file with an audience that should only see part of it.
FAQ
What is a BOM-Link in CycloneDX?
A URN in the IANA registered cdx namespace that identifies one version of a CycloneDX BOM, and optionally one object in it: urn:cdx: plus the serial number UUID, a slash, the version and, optionally, # and a bom-ref.
Which CycloneDX version added BOM-Link?
The namespace was registered with IANA on 19 March 2022. The schema first defines BOM-Link types in CycloneDX 1.5, released 26 June 2023; 1.6 and 1.7 keep them.
Is BOM-Link the same as externalReferences?
No. externalReferences is a field; BOM-Link is a kind of value. A bom external reference can hold an HTTPS URL (location) or a BOM-Link (identity), and fields such as affects[].ref accept BOM-Links without being external references.
How does a VEX reference a component in a separate SBOM?
With an element BOM-Link in vulnerabilities[].affects[].ref: the SBOM's serial and version, then # and the component's bom-ref.
Can I use a BOM-Link in the dependencies section?
No. dependsOn and provides only accept references within the same document. Use a stub component with a bom external reference, a composition or a merged document.
What happens to BOM-Links when I regenerate an SBOM?
If the new SBOM gets a new serial, every link to the old one stops resolving. For a correction, keep the serial, increment the version and keep bom-ref values unchanged.
Does SPDX have an equivalent of BOM-Link?
SPDX 3.0.1 identifies elements by IRI and declares externally defined ones in an ExternalMap, which can also carry a location hint and integrity methods.
Do I need the Transparency Exchange API to use BOM-Links?
No. BOM-Links work with any delivery method. TEA adds discovery, versioned collections and update reasons, and it is still in beta.
Fix it without waiting for upstream
A linked BOM set tells you precisely which component in which release a vulnerability touches, and a VEX tells you whether it matters. Neither one ships the fixed build, so the work of getting a patched dependency into production still has to happen, and that is where the following applies.
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
See how Safeguard would help
These demos do not show BOM-Link itself. They show the step after a linked SBOM and VEX say a component needs to change.
A real Safeguard CLI session: after install, safeguard opens the interactive terminal, you ask it to "fix the lodash CVE in this repo," and it reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing. It then shows /permissions, /model and /scan. In the worked example, that is the path from the lodash entry in the SBOM to a fixed build, after which you issue a new SBOM and VEX.
Click through it at your own pace, or open it full screen.
This demo sets up the Safeguard runner on your own machine, approves it with its device code and then scans from the terminal. Local scanning fits when a release's BOM set has to be produced and checked inside your own network before anything is shared.
Click through it at your own pace, or open it full screen.
This one connects Claude to the Safeguard MCP server with OAuth and shows how to choose which tools Claude can use. Once connected, you can ask about your projects and their vulnerabilities from a chat, a quick way to see where a component appears before you look for the matching VEX.
Click through it at your own pace, or open it full screen.
To try the CLI on your own repository, download Safeguard and follow the CLI documentation.
Quick check
A VEX links to version 1 of an SBOM, and the supplier publishes version 2 with the same bom-ref. Does the statement still apply?
The link still resolves exactly to version 1. Because the schema guidance says to use the most recent version of a serial, a consumer can reasonably carry the statement forward to version 2 while the same bom-ref exists there. If the bom-ref was renamed, the statement needs review.
Why does a malformed BOM-Link in vulnerabilities[].affects[].ref pass schema validation?
That field accepts either a BOM-Link element or a plain bom-ref, and a plain bom-ref can be any non-empty string. Only a resolution check against the documents you hold catches it.
Sources
- CycloneDX: BOM-Link capability
- IANA: URN namespace registration for cdx
- CycloneDX specification repository (JSON schemas 1.4 to 1.7)
- CycloneDX 1.5 release
- CycloneDX 1.6 release
- CycloneDX specification overview
- CycloneDX: VEX capability
- CycloneDX: CBOM capability
- CycloneDX: ML-BOM capability
- CycloneDX CLI README
- Transparency Exchange API README
- Transparency Exchange API: TEA Collection object
- Transparency Exchange API: discovery and TEI
- CISA: SBOM Sharing Lifecycle Report (April 2023)
- SPDX 3.0.1: ExternalMap class
Last reviewed: 23 October 2026. Check the CycloneDX specification and the Transparency Exchange API repository for changes after this date.