Safeguard
Supply Chain Security•

BOM-Link Explained: Link an SBOM, VEX, CBOM and AIBOM for One Release

BOM-Link is a CycloneDX URN that points at another BOM, or one element in it, by serial number and version. Here is how to link one release's BOM set.

Safeguard Security Team
33 min read

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-ref after #. Without the fragment it names a document; with it, one element.
  • The cdx URN 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[].assemblies and a few other fields, but not in dependencies.
  • A malformed BOM-Link in affects[].ref still 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.

PartWhere it comes fromRulesRequired
urn:cdx:Fixed prefixRegistered URN namespaceYes
serial numberThe target BOM's serialNumber, without urn:uuid:A UUID; IANA says version 4; the schema pattern allows lowercase hex onlyYes
versionThe target BOM's versionInteger of 1 or more; default 1Yes
#bom-refThe bom-ref of an object in the targetAny non-empty string; it is a URI fragmentNo

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?

DateEventSource
2022-03-19cdx URN namespace registered with IANAIANA URN registry
2023-06-26CycloneDX 1.5 released; first schema to define bomLink, bomLinkDocumentType and bomLinkElementTypeSpecification repository
2024-04-09CycloneDX 1.6 released; BOM-Link definitions unchangedSpecification repository
2025-10-21CycloneDX 1.7 released; BOM-Link definitions unchangedCycloneDX specification overview
2025-12-10ECMA-424 listed as published on the CycloneDX overview pageCycloneDX 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.

FieldAcceptsWhat 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[].refElement 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-refCompleteness statements about assemblies held elsewhere
services[].data[].source[] and destination[]Element link or URL style referenceData flows between services described in different BOMs
modelCard.modelParameters.datasets[].refElement 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-refBuild steps that consume resources described elsewhere
componentIdentityEvidence.tools[]Element link or local bom-refThe tool behind identity evidence, described elsewhere
dependencies[].ref and dependsOn[]Local bom-ref onlyNothing. 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

ApproachWhat travelsVersion pinnedTargets one elementSeparate access controlWorks in dependenciesGood for
Nested components or hierarchical mergeFull content in one fileBy the merged BOM's versionYes, by local bom-refNoYesSmall products, one audience, offline use
externalReferences with HTTPS URL and hashA pointer to bytesOnly through the hashNoYesNoTelling a consumer where to download
externalReferences with document BOM-LinkAn identityYesNoYesNo"The BOM for this component is that exact document"
Element BOM-Link in affects, subjects, assemblies and similarIdentity of one objectYesYesYesNoVEX, 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.

DocumentSerial numberLinks out toLinked to from
SBOMedb88733-49ea-4636-a5d4-52848b5f50f5CBOM (from OpenSSL), AIBOM (from the model), latest VEX by HTTPSVEX, CBOM, AIBOM
VEXcc66b67a-deb9-4757-9714-b49a578c1e74SBOM element pkg:npm/lodash@4.17.20Found by location
CBOM671fa157-5f1a-44f9-b0da-ed150c5c2cb9SBOM element pkg:generic/openssl@3.0.13, twiceSBOM
AIBOM (ML-BOM)07cd7882-893a-46f5-a259-1faa9cd46fe3SBOM element model-fraud-scorer-1.3.0SBOM

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.

OperationSerial numberVersionLinks to the old document
New buildNew UUID1Unaffected; they describe a different build
Same build, BOM regenerated by a new tool runNew UUID, per schema guidance1All break
Correction to a published BOMSamePrevious plus 1Still 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 BOM covers every kind of bill of materials, and VULNERABILITIES covers VDR and VEX.
  • Each new collection version carries an updateReason. VEX_UPDATED is separate from ARTIFACT_UPDATED and 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

SituationPackageWhy
One-off audit or procurement requestArchive with SHA256SUMSSimple, offline, easy to sign as one file
Container releases pulled from your registryOCI referrers per documentDocuments travel with the digest; VEX revisions append cleanly
Many customers, frequent VEX updates, automated consumersTEA collection, or a portal while TEA maturesVersioned collections, explicit update reasons, discovery by TEI
Restricted CBOM or HBOM, public SBOMAny of the above, restricted documents under separate accessThe 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:

  1. Index on ingest by (serial, version), storing the bom-ref set, the product or digest described and the origin.
  2. Keep every version. Overwriting version 1 with version 2 breaks every link to version 1.
  3. 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.
  4. Fall back by purl, and label it. When the serial is unknown, a purl bom-ref can still match the product's current SBOM; mark it as a weaker match.
  5. 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 published bom-ref values 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 affects and externalReferences and 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-ref values that change between runs, such as freshly generated UUIDs.
  • Putting sensitive names in fragments.
  • Writing local bom-ref values that start with urn: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 situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull 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

Last reviewed: 23 October 2026. Check the CycloneDX specification and the Transparency Exchange API repository for changes after this date.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.