Skip to content
Stribog

Supply Chain

All writing

SBOM Formats: SPDX, CycloneDX and What Consumers Ingest

SBOM formats are picked per consumer: BSI wants CycloneDX 1.6+ or SPDX 3.0.1+, Syft still defaults SPDX to 2.3, and each scanner reads a different VEX.

Stribog12 min read

"Do you produce an SBOM?" has become a procurement checkbox, and the honest answer is usually yes. The question that decides whether the document is any use is narrower: which format, at which version, read by whom. A regulator's checker, a customer's intake tooling and your own scanner can each reject a perfectly valid SBOM because it is the wrong one for them.

Most comparisons of SPDX and CycloneDX are feature tables. This article maps the formats to the version each real consumer requires today, shows where VEX statements have to live for each scanner to read them, and explains why converting one SBOM into another is the wrong way to satisfy a second reader. Tool behaviour is read at Syft 1.57.1, Grype 0.121.3 and Trivy 0.76.2, the latest releases as of 12 November 2026.

Who Reads Your SBOM, and Which Version Do They Parse?

Start with the regulation, because it is the least specific consumer. The EU Cyber Resilience Act, Annex I Part II point (1), requires manufacturers to draw up an SBOM "in a commonly used and machine-readable format covering at the very least the top-level dependencies". It names no format. Article 13(24) says the Commission may specify the format and elements by implementing act; it sets no deadline, and none has been adopted as of writing. Annex VII places the SBOM inside the technical documentation's vulnerability-handling information. The Regulation applies from 11 December 2027, with Article 14 reporting from 11 September 2026. The CRA article in this series covers the obligation itself.

ENISA's SBOM technical advisory (October 2026) describes SBOMs as "most commonly CycloneDX or SPDX", stored as JSON, XML, YAML or Tag-Value. It is guidance, not an implementing act. The national guidance that does name versions is Germany's BSI TR-03183-2 v2.1.0, dated 20 August 2025:

A newly generated or updated SBOM MUST be in JSON- or XML-format and a valid SBOM according to one of the following specifications in one of the specified versions: CycloneDX, version 1.6 or higher; System Package Data Exchange (SPDX), version 3.0.1 or higher.
BSI TR-03183-2 v2.1.0

That version raised the floors from CycloneDX 1.5 to 1.6 and from SPDX 2.2.1 to 3.0.1. The guideline lets the immediately preceding version be used for six months after a new one is issued; counted from August 2025, that window has closed, and no later version was found as of writing. BSI also goes further than the CRA on depth: recursive dependency resolution "MUST be performed at least for each component included in the scope of delivery", not just the top level.

In the United States, the NTIA 2021 Minimum Elements list seven baseline fields (Supplier, Component Name, Version, Other Unique Identifiers, Dependency Relationship, Author of SBOM Data, Timestamp) and name SPDX, CycloneDX and SWID tags as the formats in use. The federal memos that drove supplier SBOM requests, M-22-18 and M-23-16, were rescinded by OMB M-26-05 on 23 January 2026.

Then the tools. Dependency-Track's documentation (v4.x; the latest release is 5.2.1) describes consuming and producing CycloneDX SBOMs and CycloneDX VEX; it does not document SPDX SBOM ingestion. Grype reads Syft's native JSON as well as CycloneDX and SPDX. Trivy's SBOM-target docs list CycloneDX, SPDX JSON and SPDX tag-value as inputs, and do not mention SPDX 3. The spdx/ntia-conformance-checker (sbomcheck) checks SPDX 2.x and 3.x against NTIA, FSCT3 and BSI TR-03183-2 v2.1.0, and only SPDX. sbomqs scores both formats against BSI, FSCT v3 and NTIA.

Where the Two Data Models Actually Differ

A common shorthand says SPDX is for licences and CycloneDX is for security. Both halves are wrong in ways that matter to an auditor.

  • Licences. CycloneDX's expression field is "a valid SPDX license expression", and a 1.6 licence choice is either a list of licences or exactly one SPDX expression. CycloneDX also records whether a licence is declared or concluded ("Concluded licenses are verified and confirmed"), mirroring SPDX's Declared and Concluded License fields. BSI requires licences to be named by SPDX identifier or expression in either format.
  • Relationships. SPDX 2.3 has a long list of typed relationships, of which DEPENDS_ON is one; CycloneDX's dependencies[] records dependsOn as lists of bom-ref identifiers. SPDX can say more kinds of thing about how two elements relate; CycloneDX says the dependency graph plainly. Neither is a superset of the other.
  • Vulnerability status. This is the real gap. SPDX 2.3's SECURITY external reference offers an advisory type, a pointer to a published advisory, not a structured per-product status. CycloneDX carries a structured analysis per vulnerability. SPDX 3.0.1 closes the gap with a Security profile that expresses VEX as relationship classes such as VexNotAffectedVulnAssessmentRelationship.

So the meaningful split is not SPDX against CycloneDX. It is SPDX 2.x against everything newer, which is why the version question below matters more than the format question.

Versions Are the Constraint Nobody Checks

Read Syft's defaults from its source, not its docs. At v1.57.1, spdxutil/versions.go sets DefaultVersion = "2.3" and cyclonedxutil/versions.go sets DefaultVersion = "1.7". A bare -o spdx-json therefore writes a document that fails BSI's SPDX floor, while a bare -o cyclonedx-json writes a version newer than BSI requires and newer than some readers may parse. SPDX 3.0 must be asked for explicitly (spdx-json@3.0) and exists only as JSON. Both defaults are true at that tag and can change in any later release, which is the argument for pinning.

SPDX 3 is not SPDX 2 with new fields. Its data model is RDF-based and JSON-LD is one serialisation; every JSON-LD document must reference the SPDX global context file. A reader written for SPDX 2.x JSON cannot be assumed to read it. Other generators sit at the same version: kubernetes-sigs/bom writes SPDX 2.3 by default and treats SPDX 3.0.1 JSON-LD as experimental.

The standards labels add one more trap. ISO/IEC 5962:2021 is SPDX 2.2.1, so a contract clause asking for "the ISO standard SBOM format" points at a version BSI no longer accepts. On the CycloneDX side, ECMA-424 1st edition (June 2024) standardises CycloneDX 1.6 and the 2nd edition (December 2025) defines 1.7. When a requirement cites a standard number, translate it to a spec version before you build against it.

VEX: Three Carriers, Three Consumers

A VEX statement says whether, and why, a product is affected by a vulnerability. It only helps if the scanner reading your SBOM reads that carrier. BSI TR-03183-2 v2.1.0 says exploitability "should not be detailed by the SBOM" and points to security advisories such as CSAF, or VEX, so for BSI evidence keep VEX in a separate document.

  • Grype 0.121.3 reads OpenVEX and CSAF. Its --vex processor recognises those two and returns unsupported VEX document format for anything else, so a CycloneDX VEX BOM is not a Grype input.
  • Trivy 0.76.2 reads CycloneDX VEX only when the input SBOM is CycloneDX, and as an independent VEX BOM. "OpenVEX can be used in all Trivy targets, unlike CycloneDX VEX", and CSAF VEX accepts CycloneDX or SPDX input SBOMs.
  • Dependency-Track (v4.x docs) consumes and produces CycloneDX VEX.

The vocabularies map closely but not one-to-one. CycloneDX 1.6 and 1.7 analysis states are resolved, resolved_with_pedigree, exploitable, in_triage, false_positive and not_affected; "Justification should be specified for all not_affected cases", from nine values such as code_not_reachable. In OpenVEX, a not_affected status requires a justification. SPDX 3.0.1 has five VexJustificationType values, including componentNotPresent. CSAF 2.0's VEX profile exists "to state that and why a certain product is, or is not, affected by a vulnerability". Here is one assessment in CycloneDX form, as a standalone VEX BOM that points into the SBOM by BOM-Link:

json
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:0c7f3a52-9d4e-4b1a-8f2e-6a1d5c3b9e70",
  "version": 1,
  "metadata": { "timestamp": "2026-11-12T09:00:00Z" },
  "vulnerabilities": [
    {
      "id": "CVE-2025-60876",
      "source": {
        "name": "NVD",
        "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-60876"
      },
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "The service never invokes busybox wget."
      },
      "affects": [
        {
          "ref": "urn:cdx:9d83a53b-847f-46c4-b1dc-76aa83350c80/1#pkg:apk/alpine/busybox@1.36.1-r31?arch=x86_64&distro=alpine-3.20.10&package-id=eb7a53da2e2526a9"
        }
      ]
    }
  ]
}
payments-api.vex.cdx.json — standalone CycloneDX 1.6 VEX BOM. The affects ref is urn:cdx:<SBOM serialNumber>/<version>#<bom-ref>. The CVE and justification illustrate the shape; yours must come from your own analysis.

The same assessment for Grype is an OpenVEX document. Note the vocabulary shift: CycloneDX's code_not_reachable becomes OpenVEX's vulnerable_code_not_in_execute_path.

json
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/payments-api-2026-11-12",
  "author": "Platform Security <security@example.com>",
  "timestamp": "2026-11-12T09:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2025-60876" },
      "products": [
        {
          "@id": "pkg:oci/payments-api@sha256%3A75331a99a135e9ec87ed7f9116fa2ea4b81ffceccb9dceb83d134d48045071dd?repository_url=registry.example.com/platform/payments-api",
          "subcomponents": [
            { "@id": "pkg:apk/alpine/busybox@1.36.1-r31?arch=x86_64&distro=alpine-3.20.10" }
          ]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "The service never invokes busybox wget."
    }
  ]
}
payments-api.openvex.json — the product is the image by digest, the subcomponent the package. Run grype sbom:app.syft.json --vex payments-api.openvex.json and confirm the match is suppressed.

Grype matches the product against identifiers it derives from the image's tags and repository digests when the scanned source carries image metadata; for other sources it falls back to a pkg:generic name and version. The syft-json record keeps that image metadata, which is one more reason to scan the record rather than an export. Whichever input you use, check that the statement actually suppresses the match before you rely on it.

Why Conversion Is the Wrong Way to Feed a Second Consumer

Once one format exists, converting it into the other looks cheaper than re-running the scan. Syft's own wiki is blunt about it: "This feature is experimental and data might be lost when converting formats." Packages transfer easily; "files and relationships, as well as other information Syft doesn't support, are more likely to be lost". That page was last edited on 1 November 2024 and lists only SPDX 2.2 and CycloneDX 1.4 as conversion formats, so treat it as Syft's stated position rather than a current format list.

protobom states that it ingests modern SPDX and CycloneDX into a neutral protobuf representation "without loss". That is a project claim, and lossless ingest is not lossless output: the target format still needs somewhere to put each field. SPDX 2.3 has no structured slot for a CycloneDX analysis.

There is also a quieter break. A standalone VEX BOM addresses components as urn:cdx:<serialNumber>/<version>#<bom-ref>. If a converted document carries a new serial number or new bom-ref values, every VEX statement pointing into the original no longer resolves against it. Do not take anyone's word on loss in either direction; measure it on your own SBOM:

bash
#!/usr/bin/env bash
# Round-trip a CycloneDX SBOM through SPDX 2.3 and back; print what survived.
set -euo pipefail
in="${1:?usage: $0 app.cdx.json}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

syft convert "$in" -o spdx-json@2.3="$work/rt.spdx.json"
syft convert "$work/rt.spdx.json" -o cyclonedx-json@1.6="$work/rt.cdx.json"

count() {
  jq '{
    serialNumber,
    components: ([.components[]?] | length),
    dependency_edges: ([.dependencies[]?.dependsOn[]?] | length),
    licensed_components: ([.components[]? | select((.licenses // []) | length > 0)] | length)
  }' "$1"
}

echo "before:"; count "$in"
echo "after:";  count "$work/rt.cdx.json"

# A standalone VEX BOM points at urn:cdx:<serial>/<version>#<bom-ref>.
# Count how many original bom-refs still exist after the round trip.
kept="$(comm -12 \
  <(jq -r '.components[]?."bom-ref" // empty' "$in" | sort -u) \
  <(jq -r '.components[]?."bom-ref" // empty' "$work/rt.cdx.json" | sort -u) | wc -l)"
echo "original bom-refs still present: ${kept}"
sbom-roundtrip.sh — prints counts before and after a CycloneDX → SPDX 2.3 → CycloneDX round trip, plus how many original bom-refs survive. A clean result is evidence for that SBOM, not a guarantee for the next.

One Catalog, Several Outputs, One Validator per Consumer

The alternative to conversion is cheap: Syft writes several formats from a single catalog run with repeated -o format@version=file flags. Every output then describes the same scan, at the same moment, of the same digest. Keep the syft-json as the record, because it is the richest form Syft produces and the one you can re-emit from later; the Syft cataloger article covers making that record complete.

bash
#!/usr/bin/env bash
# One catalog run, one record, one output per consumer. Never convert between them.
set -euo pipefail
image="${1:?usage: $0 <registry/repo@sha256:digest> [outdir]}"
out="${2:-sbom}"
mkdir -p "$out"

# Pin every format at an explicit version: Syft's defaults move between releases.
syft scan "registry:${image}" \
  -o syft-json="$out/app.syft.json" \
  -o cyclonedx-json@1.6="$out/app.cdx.json" \
  -o spdx-json@3.0="$out/app.spdx3.json" \
  -o spdx-json@2.3="$out/app.spdx23.json"

jq -r '"CycloneDX " + .specVersion' "$out/app.cdx.json"
jq -r '"SPDX 3 context " + ."@context"' "$out/app.spdx3.json"
jq -r '.spdxVersion' "$out/app.spdx23.json"
sha256sum "$out"/app.* > "$out/SHA256SUMS"
sbom-emit.sh — one Syft run, one archived record, one pinned output per consumer. Address the image by digest so every file names the same artefact.

CycloneDX is pinned at 1.6 because it is BSI's floor and ECMA-424's first edition; move to 1.7 once every reader you feed has been checked against it. The SPDX 3 line prints the document's @context, so you can see it references the SPDX 3.0.1 context BSI's floor asks for. SPDX 2.3 stays only for readers whose documentation lists SPDX 2.x input and nothing newer, such as Trivy's. Then gate each output with the checker its consumer would use:

bash
#!/usr/bin/env bash
# One validator per consumer. Any rejection fails the pipeline.
set -euo pipefail
out="${1:-sbom}"

# CycloneDX: schema-valid at the version promised (BSI floor: 1.6).
cyclonedx validate --input-file "$out/app.cdx.json" \
  --input-version v1_6 --fail-on-errors

# SPDX 3: BSI TR-03183-2 v2.1.0 content requirements. Exits non-zero
# when the document is not conformant.
sbomcheck -s spdx3 -c bsi "$out/app.spdx3.json"

# sbomqs scores rather than gates, so read its JSON. --bsi-v21 pins
# v2.1.0; --bsi follows "latest" and moves when BSI publishes again.
spec_score="$(sbomqs compliance --bsi-v21 --json "$out/app.cdx.json" \
  | jq '[.sections[] | select(.section_id == "4")][0].score')"
if [ "$spec_score" != "10" ]; then
  echo "sbomqs: format/version fails BSI TR-03183-2 v2.1.0 (score ${spec_score})" >&2
  exit 1
fi
sbom-gate.sh — fails the pipeline if any consumer's checker rejects its file. sbomqs is pinned to BSI v2.1.0 with --bsi-v21.

The flag choice matters. In sbomqs, --bsi-v2 checks v2.0.0, whose floors are CycloneDX 1.5 and SPDX 2.2.1, so a gate built on it would pass the SPDX 2.3 file that v2.1.0 rejects. Section 4 of the sbomqs report is the format-and-version check. Expect the content checks in sbomcheck to report fields no scanner can know, such as who created the SBOM; those are inputs to supply, not format errors.

Every consumer gets its own format and version from the same scan. Conversion is the path not taken.

Failure Modes

  • Default drift. An unpinned -o spdx-json or -o cyclonedx-json changes version whenever the generator's default does, and the first sign is a consumer rejecting a file that passed last month.
  • SPDX 3 sent to a 2.x reader. The file is valid and the reader cannot parse it. Trivy's docs do not list SPDX 3 input; test before relying on it.
  • VEX in a carrier the scanner ignores. A CycloneDX VEX BOM handed to Grype is rejected outright; an OpenVEX product identifier that does not match is simply not applied. Either way the findings come back unfiltered and someone re-triages work already done.
  • Converted SBOMs with broken references. A dependency edge an auditor asks about, or a BOM-Link a VEX statement relies on, can vanish in conversion. Emit, do not convert.
  • Licence text instead of identifiers. BSI wants SPDX identifiers or expressions in either format; free-text licence names fail that check.

Exit Ramps and the Long Game

The native record is the exit ramp. Every tool in this pipeline is open source and replaceable: cyclonedx, sbomcheck and sbomqs can each be swapped for another checker, and the outputs re-emitted from the archived record with syft convert, which starts from Syft's own model rather than from another interchange format. Run the same gate on a re-emitted file as on a fresh one. Store the record beside the image in a registry you operate, and sign it with the rest of the supply-chain evidence. The same record feeds the vulnerability-management lifecycle and is easiest to keep accurate for base images you build yourself.

The rules will move again. The CRA's Article 13(24) still allows the Commission to fix a format by implementing act; BSI has raised its floors once already. A team that kept only a converted CycloneDX 1.5 export would have to re-scan every shipped release to meet a new floor. A team that kept the native record re-emits and re-gates. Over the support life of a product, that difference decides whether compliance is a script or a project.

§FAQ/Common questions

Frequently asked

What are the main SBOM formats?

SPDX and CycloneDX. The NTIA 2021 Minimum Elements also name SWID tags, and ENISA's October 2026 advisory describes SBOMs as most commonly CycloneDX or SPDX, serialised as JSON, XML, YAML or Tag-Value.

Which SBOM format does the Cyber Resilience Act require?

None specifically. Annex I asks for a commonly used, machine-readable format covering at least top-level dependencies. Article 13(24) says the Commission may specify a format by implementing act; none had been adopted as of 12 November 2026.

Does Syft produce an SBOM that meets BSI TR-03183-2 v2.1.0?

Not by default for SPDX: Syft 1.57.1 writes SPDX 2.3 unless you request spdx-json@3.0, and BSI requires SPDX 3.0.1 or CycloneDX 1.6 or later. Request the version explicitly, then check content with sbomcheck -c bsi or sbomqs --bsi-v21.

Can Grype read CycloneDX VEX?

No. Grype 0.121.3's --vex processor recognises OpenVEX and CSAF documents and rejects other formats. Trivy reads CycloneDX VEX when the input SBOM is CycloneDX, and Dependency-Track consumes CycloneDX VEX.

Is it safe to convert an SBOM from CycloneDX to SPDX?

Syft calls its conversion experimental and says files and relationships are more likely to be lost than packages. Converted documents can also carry new serial numbers or bom-refs, breaking VEX references. Emit each format from the same scan instead.

sbom formatsspdx vs cyclonedxcyclonedx vexbsi tr-03183-2 sbom formatspdx 3.0 json-ld sbomopenvex csaf vex

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.