SCADABLE

CycloneDX or SPDX for embedded firmware, a decision matrix for ESP-IDF, Yocto, and custom Rust gateways

Most embedded teams end up generating SBOMs in both formats. The question is which one to emit at build time and how to normalize for everything downstream. With per-stack recommendations and a normalization-layer sketch.


The first time someone asks "should we use CycloneDX or SPDX for our firmware SBOM," the temptation is to answer the question. Don't. The honest answer is that you will end up generating both, because every tool in the embedded build chain has already picked one for you, and the only durable move is to canonicalize internally and emit either format on demand.

This post is the decision matrix we wish we had at this fork: what each format is, what the major embedded build systems emit today, the one place the two genuinely diverge (VEX), and the normalization layer that makes the whole question stop mattering.

The two formats in 200 words

CycloneDX is an OWASP Foundation project. It started in 2017 inside the application security world and was designed from day one for vulnerability and supply-chain analysis. Governance sits under the OWASP umbrella with a working group cadence. Format flavors: JSON (the dominant one), XML, and Protobuf. The CycloneDX spec lives at cyclonedx.org.

SPDX is a Linux Foundation project, ISO/IEC 5962:2021. It started in 2010 inside the open-source license-compliance world. Governance sits under the LF with a formal standards process. Format flavors: JSON, YAML, RDF, and the original tag-value text format. SPDX 3.0 (2024) added a profile model that brings security and AI-bill-of-materials surfaces closer to CycloneDX parity. The spec lives at spdx.dev.

Both formats can describe the same firmware, license its components, and identify CVEs. The differences that matter for embedded teams are not in the data model. They are in which tools emit which format by default, and in how each format handles VEX (vulnerability exploitability exchange).

What ESP-IDF emits today

If you build firmware with ESP-IDF, your SBOM tool is esp-idf-sbom, which Espressif ships and documents. It emits SPDX 2.2. That choice was practical: SPDX has the deeper license-compliance ecosystem, and ESP-IDF pulls in a long tail of permissively licensed components where license clarity is the first question to answer.

Out of the box you get a tag-value or JSON SPDX document with each component pinned to its version, license, and (where available) a PURL identifier. The tool also annotates known CVEs from the NVD. If your downstream tooling speaks SPDX, the path of least resistance is to use what Espressif gives you.

What Yocto emits

Yocto has shipped the create-spdx class for years, for the same historical reason ESP-IDF did: license compliance is the primary use case for a Yocto-based product, and SPDX has been the LF-blessed format for license metadata for over a decade.

Anything that consumes SPDX (FOSSology, Tern, the Linux Foundation's own dashboards) reads the Yocto output without translation. SPDX 3.0 support is rolling in via create-spdx-3.0, which gives you the security and build profiles alongside the license data.

What Syft emits on a Rust binary

Now point Anchore's Syft at a stripped Rust binary or a Cargo workspace. Default output: CycloneDX 1.5 JSON. You can flag it to emit SPDX, but the default is CycloneDX because the application-security tool ecosystem (Snyk, Dependency-Track, Trivy) standardized on it years ago.

This is the structural reason a custom Rust gateway lands on CycloneDX: the developer-facing tools that scan binaries during CI are CycloneDX-native. Same story for npm, Go modules, Python wheels, and most container images scanned by trivy or grype.

VEX, the CRA-relevant difference

Here is the one place the two formats genuinely diverge for regulated embedded products.

VEX (Vulnerability Exploitability Exchange) is the answer to "yes, this CVE is in our SBOM, but we are not vulnerable because we never call the affected function." Without VEX, every CVE in your SBOM looks like a live incident to a downstream scanner. With VEX, you mark CVE-2024-XXXX as not_affected with a justification and a code reference, and the scanner respects it.

CycloneDX has native VEX. One document carries components and vulnerability statuses, one schema, signed once. SPDX 3.0 supports VEX through a security profile, but the tooling is younger and the canonical pattern is to ship VEX as a separate document referencing the SBOM.

If your product falls under the EU Cyber Resilience Act, the German BSI TR-03183, or any near-future regulation that asks "for every CVE, what is your status," CycloneDX gives you the shorter path today. SPDX-native teams get there via the 3.0 security profile, just with more moving parts.

Side-by-side decision matrix

DimensionCycloneDXSPDX
StewardOWASP FoundationLinux Foundation, ISO/IEC 5962
First release20172010
Primary originAppSec / vulnerability scanningOpen-source license compliance
Format flavorsJSON, XML, ProtobufJSON, YAML, RDF, tag-value
ESP-IDF defaultNoYes (esp-idf-sbom)
Yocto defaultNoYes (create-spdx)
Syft defaultYes (1.5)Available via flag
npm / Cargo ecosystem defaultCycloneDXAvailable
VEX supportNative, in same documentVia SPDX 3.0 security profile or sidecar
AI/ML BOM (AIBOM)ML-BOM extension (mature)AI profile in SPDX 3.0 (newer)
License-compliance tool depthGoodBest in class
Vulnerability tool depthBest in classGood

For a neutral third-party comparison, HeroDevs has a readable side-by-side, and Sbomify maintains a working translation layer between the two.

The normalization layer

Once you accept that you will see SBOMs in both formats coming from different parts of your build chain, the design move is obvious: stop arguing about the wire format and pick a normalized internal record. Here is the rough shape of one we have used.

{
  "component_id": "purl:pkg:cargo/[email protected]",
  "name": "serde",
  "version": "1.0.197",
  "license_spdx": "MIT OR Apache-2.0",
  "supplier": "serde-rs",
  "hashes": { "sha256": "..." },
  "source_sbom": {
    "format": "cyclonedx-1.5",
    "ingested_at": "2026-05-09T14:22:00Z",
    "build_id": "gateway-linux-v0.18.4-aarch64"
  },
  "vulns": [
    {
      "cve": "CVE-2024-XXXX",
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "evidence_ref": "git:abc1234#src/parser.rs:L42"
    }
  ]
}

The internal record is keyed by PURL (package URL) where one exists, falling back to a content hash where one doesn't. The original SBOM is preserved in object storage so you can always re-derive. License is stored as an SPDX expression because that is the durable canonical form, regardless of which format the SBOM came in. VEX status is stored as a structured field, not parsed on demand.

With this layer in place, "emit SPDX" and "emit CycloneDX" become export functions, not architectural decisions. A customer's procurement portal asks for SPDX 2.3 JSON; you serialize. A vulnerability scanner wants CycloneDX 1.5; you serialize. The SBOM you regenerate at incident-response time is the merged view across every build that shipped that component, not a single point-in-time file someone forgot to refresh.

Per-stack recommendations

ESP-IDF. Generate SPDX with esp-idf-sbom. Ingest into your normalization layer. Re-emit CycloneDX on demand for downstream tools that need it. Do not fight Espressif's default; the build-time integration is the value.

Yocto. Same answer. SPDX at build time via the create-spdx class, normalize, re-emit. Yocto's SPDX output is the most mature in the embedded world and the LF tooling around it is deep.

Custom Rust gateway. Generate CycloneDX with Syft (or cargo-sbom if you prefer Cargo-native). Ingest, normalize, re-emit SPDX for license-compliance audits if and when asked. The CycloneDX-native VEX support is the deciding factor here, especially if you are touching the EU CRA timeline.

Arduino / PlatformIO. The rough one. No first-party SBOM generator is broadly adopted. Pragmatic move: run Syft against the final binary plus a hand-curated dependencies.yaml for the libraries the build pulls in. Output CycloneDX. Document the precision gap honestly in your security posture file.

Mixed fleet. If you ship more than one stack (ESP gateway plus Linux gateway plus a managed service backend), the normalization layer stops being optional. Store both formats, expose both via API, and let the consumer pick. Tag every record with the source build so an incident-response query can answer "which firmware versions on which devices contain openssl 3.0.7" without a rebuild.

The framing that holds up

The format question feels central because it's the one with the most public debate. It isn't. The central decision is whether you treat SBOMs as an artifact (one file per build, archived, mostly forgotten) or as a queryable inventory (canonical record per component, indexed across every build, joinable against your fleet table).

Artifact-camp teams hunt through .spdx.json files in S3 when an auditor asks a question. Inventory-camp teams answer in a single SQL query. The format you pick at build time only matters if you're in the first camp. Build the normalization layer and the format becomes an export concern.

Talk to a founder

If you're staring at this decision and the deeper question is really "how do I plug the SBOM into my fleet posture," let's skip the format debate and talk about the layer above. Twenty minutes, no slides.

Book a quick chat with Ali