From west spdx to a CRA-Compliant Zephyr SBOM
Zephyr's west spdx misses CPE/PURL identifiers and binary blobs needed for CRA vulnerability scanning. Full SBOM enrichment tutorial.
Zephyr has a built-in SBOM generation command: west spdx. Run it after a build, and you'll get four SPDX files describing your application, Zephyr kernel, and build environment. It's one of the few RTOSes with any native SBOM tooling at all.
The problem is that the output is nearly useless for CRA compliance out of the box.
The generated SPDX files lack CPE and PURL identifiers, so vulnerability scanners can't match components to known CVEs. Subsystem components like the Bluetooth stack, MQTT library, and cJSON parser don't appear as discrete packages — they're compiled into the Zephyr kernel blob. Vendor HAL binary blobs are opaque. And the output is SPDX-only, with no CycloneDX option.
This tutorial covers what west spdx gives you, what it doesn't, and the step-by-step enrichment workflow to produce an SBOM that actually satisfies CRA Annex I Part II. For broader SBOM context, see our SBOM guide for firmware. For the full Zephyr CRA picture, see our Zephyr CRA compliance guide.
What west spdx Gives You (and What It Doesn't)
The west spdx command was added in Zephyr 3.x. It hooks into the CMake build system and generates SPDX 2.3 documents based on the source files and libraries that were actually compiled.
What it does well:
- Enumerates source files that went into the build (not the full manifest — only what was compiled)
- Produces valid SPDX 2.3 documents
- Generates four separate SPDX files: application, Zephyr kernel, build environment, and an overall document
- Uses build-system data, so it reflects your actual configuration (Kconfig-dependent)
What it doesn't do:
- No CPE or PURL identifiers on any package — vulnerability scanners get nothing to match against
- No version information derived from upstream sources — packages are identified by local path only
- Subsystem components (Bluetooth, networking, USB) are rolled into the Zephyr kernel package, not listed individually
- Vendor HAL modules (hal_stm32, hal_nordic, hal_nxp) appear as packages but with no upstream version or vulnerability identifiers
- SPDX output only — no CycloneDX option
- MCUboot (if used) is a separate build and isn't captured in the application's SPDX output
Step by Step: Generating SPDX from a Zephyr Build
Start with a clean build. The west spdx command operates on the build directory, so it needs a completed build to analyse.
# Clean build for your board
west build -p always -b nrf52840dk/nrf52840 -- -DCONFIG_BOOTLOADER_MCUBOOT=y
# Generate SPDX documents
west spdx -d build -n "https://your-company.com/spdx"
The -n flag sets the SPDX namespace URI. Use your company's domain — this becomes the document namespace in the SPDX output.
This produces four files in build/spdx/:
| File | Contents |
|---|---|
app.spdx | Your application source files and libraries |
zephyr.spdx | Zephyr kernel and all compiled subsystems |
build.spdx | Build tools (compiler, linker) |
sdk.spdx | Relationships between the above documents |
Open zephyr.spdx and you'll see packages for Zephyr modules that were compiled. But try running a vulnerability scanner against it:
# This will find almost nothing — no CPE/PURL to match against
grype sbom:build/spdx/zephyr.spdx
Zero or near-zero results. Not because your firmware has no vulnerabilities, but because the scanner has no identifiers to look up.
The 5 Gaps That Make Raw Output Useless for CRA
Gap 1: No CPE/PURL Identifiers
This is the critical gap. The NTIA minimum SBOM elements (referenced by ENISA guidance) require "other unique identifiers" — specifically CPE or PURL — so that consumers of the SBOM can correlate components to vulnerability databases like NVD and OSV.
The west spdx output identifies packages by their local filesystem path (/home/user/zephyrproject/modules/crypto/mbedtls). No vulnerability scanner can do anything with this.
Gap 2: Invisible Subsystem Components
When you enable Bluetooth in Zephyr (CONFIG_BT=y), the Bluetooth stack source files get compiled into the Zephyr kernel. In the SPDX output, these files appear under the main Zephyr package — there's no separate "Bluetooth Host Stack" package entry with its own version and identifiers.
The same applies to the networking stack (TCP/IP, MQTT, CoAP), USB, cJSON, and other subsystems. They're compiled-in library code, not discrete packages from the build system's perspective.
For CRA compliance, these are distinct components with distinct vulnerability histories and they need their own SBOM entries.
Gap 3: Vendor HAL Binary Blobs
Zephyr's HAL modules (hal_stm32, hal_nordic, hal_nxp, hal_espressif) contain vendor-provided code that often includes pre-compiled binary blobs — particularly for radio stacks (BLE SoftDevice on Nordic, WiFi firmware on ESP32).
The west spdx output will list the HAL module as a package, but:
- The binary blob contents are completely opaque
- No sub-components are enumerated
- No vendor-provided SBOM is included
- Vulnerability status of the binary code is unknown
Gap 4: SPDX Only — No CycloneDX
Some organisations and tools prefer CycloneDX, and some supply chain requirements specify it. The west spdx command only outputs SPDX. Converting SPDX to CycloneDX is possible but lossy.
Gap 5: MCUboot Is a Separate Build
If you're using MCUboot (and for CRA secure boot compliance, you should be — see our secure boot guide), it's built as a separate application. Running west spdx on your main application build doesn't capture MCUboot's dependencies. You need to generate and merge a separate SBOM for the bootloader.
Additionally, MCUboot has limited NVD coverage. Searching the NVD for "MCUboot" returns few or no results, even though security issues have been found and fixed. Your vulnerability scanning process needs to account for this.
Enriching the SBOM: Adding CPE/PURL Identifiers
This is the hardest part and where no existing tooling helps you. You need to manually map each component to its upstream identity and add CPE/PURL identifiers.
Identifying Your Components
First, extract the package list from the SPDX output and map each to its upstream source:
| Local package (from west spdx) | Upstream component | CPE | PURL |
|---|---|---|---|
modules/crypto/mbedtls | Mbed TLS 3.6.2 | cpe:2.3:a:arm:mbed_tls:3.6.2:*:*:*:*:*:*:* | pkg:github/Mbed-TLS/mbedtls@v3.6.2 |
modules/lib/cjson | cJSON 1.7.17 | cpe:2.3:a:cjson_project:cjson:1.7.17:*:*:*:*:*:*:* | pkg:github/DaveGamble/cJSON@v1.7.17 |
bootloader/mcuboot | MCUboot 2.1.0 | cpe:2.3:a:mcuboot:mcuboot:2.1.0:*:*:*:*:*:*:* | pkg:github/mcu-tools/mcuboot@v2.1.0 |
modules/hal/nordic | nRF Connect SDK HAL | — | pkg:github/zephyrproject-rtos/hal_nordic@<commit> |
Get the exact versions from your west.yml manifest:
# Check exact module revisions pinned in your manifest
west list --format "{name} {revision} {url}"
Adding Identifiers to SPDX
SPDX 2.3 supports external references on packages. For each package that's missing identifiers, add ExternalRef entries:
ExternalRef: SECURITY cpe23Type cpe:2.3:a:arm:mbed_tls:3.6.2:*:*:*:*:*:*:*
ExternalRef: PACKAGE-MANAGER purl pkg:github/Mbed-TLS/mbedtls@v3.6.2
You can script this. A basic approach:
#!/usr/bin/env python3
"""Add CPE/PURL identifiers to west spdx output."""
# Mapping of Zephyr module paths to upstream identifiers
COMPONENT_MAP = {
"mbedtls": {
"cpe": "cpe:2.3:a:arm:mbed_tls:{version}:*:*:*:*:*:*:*",
"purl": "pkg:github/Mbed-TLS/mbedtls@v{version}",
},
"mcuboot": {
"cpe": "cpe:2.3:a:mcuboot:mcuboot:{version}:*:*:*:*:*:*:*",
"purl": "pkg:github/mcu-tools/mcuboot@v{version}",
},
"cjson": {
"cpe": "cpe:2.3:a:cjson_project:cjson:{version}:*:*:*:*:*:*:*",
"purl": "pkg:github/DaveGamble/cJSON@v{version}",
},
"littlefs": {
"cpe": "cpe:2.3:a:littlefs_project:littlefs:{version}:*:*:*:*:*:*:*",
"purl": "pkg:github/littlefs-project/littlefs@v{version}",
},
}
def enrich_spdx(spdx_path, versions):
"""Insert ExternalRef lines after matching PackageName entries."""
with open(spdx_path, "r") as f:
lines = f.readlines()
enriched = []
for line in lines:
enriched.append(line)
if line.startswith("PackageName:"):
pkg_name = line.split(":", 1)[1].strip().lower()
for key, refs in COMPONENT_MAP.items():
if key in pkg_name and key in versions:
ver = versions[key]
enriched.append(
f'ExternalRef: SECURITY cpe23Type {refs["cpe"].format(version=ver)}\n'
)
enriched.append(
f'ExternalRef: PACKAGE-MANAGER purl {refs["purl"].format(version=ver)}\n'
)
with open(spdx_path, "w") as f:
f.writelines(enriched)
This is a starting point — you'll need to extend the COMPONENT_MAP for every third-party module in your build.
Documenting Vendor HAL Binary Blobs
Vendor HAL modules require special treatment. You can't enumerate the contents of a pre-compiled binary blob, but CRA still requires you to document what's in your firmware.
For each binary blob, add a package entry with NOASSERTION for fields you genuinely cannot determine:
PackageName: Nordic SoftDevice S140
SPDXID: SPDXRef-softdevice-s140
PackageVersion: 7.3.0
PackageSupplier: Organization: Nordic Semiconductor
PackageDownloadLocation: NOASSERTION
FilesAnalyzed: false
PackageVerificationCode: NOASSERTION (binary blob — source not available)
PackageLicenseConcluded: LicenseRef-Nordic-Proprietary
PackageLicenseDeclared: LicenseRef-Nordic-Proprietary
PackageCopyrightText: Copyright Nordic Semiconductor ASA
ExternalRef: PACKAGE-MANAGER purl pkg:generic/nordic/softdevice-s140@7.3.0
PackageComment: Pre-compiled binary blob provided by Nordic Semiconductor.
Source code not available. Vulnerability assessment depends on vendor advisories.
Key points for binary blob documentation:
- Set
FilesAnalyzed: false— you can't inspect the source - Use
NOASSERTIONfor verification codes and download locations you don't have - Add a
PackageCommentexplaining why information is limited - Use
pkg:generic/PURL type for vendor-specific packages without a standard package manager - Track the vendor's security advisories separately — NVD won't cover these
Request SBOMs from your silicon vendors. More vendors are providing them (Nordic, NXP, and STMicroelectronics all have SBOM initiatives), but you may need to ask.
Adding Invisible Components
Zephyr subsystems that are compiled in but don't appear as discrete SPDX packages need to be added manually. Which ones depend on your Kconfig — check your build's .config file.
Common invisible components to add:
| Kconfig option | Component to add | How to determine version |
|---|---|---|
CONFIG_BT=y | Zephyr Bluetooth Host Stack | Same as your Zephyr version |
CONFIG_BT_CTLR=y | Zephyr Bluetooth Controller | Same as your Zephyr version |
CONFIG_MQTT_LIB=y | Zephyr MQTT Library | Same as your Zephyr version |
CONFIG_NET_TCP=y | Zephyr TCP/IP Stack | Same as your Zephyr version |
CONFIG_HTTP_CLIENT=y | Zephyr HTTP Client | Same as your Zephyr version |
CONFIG_CJSON_LIB=y | cJSON | Check modules/lib/cjson revision |
CONFIG_LWM2M=y | Zephyr LwM2M Engine | Same as your Zephyr version |
CONFIG_COAP=y | Zephyr CoAP Library | Same as your Zephyr version |
CONFIG_USB_DEVICE_STACK=y | Zephyr USB Device Stack | Same as your Zephyr version |
Script the detection from your build configuration:
# Extract enabled subsystems from the build config
grep -E "^CONFIG_(BT|MQTT|NET_TCP|HTTP|CJSON|LWM2M|COAP|USB_DEVICE)=" \
build/zephyr/.config
For each enabled subsystem, add a package entry to your SPDX document with:
- The Zephyr version as the package version (these are part of the Zephyr source tree)
- A relationship to the main Zephyr kernel package (
CONTAINSorDEPENDS_ON) - A PURL pointing to the Zephyr project with the subsystem path
PackageName: Zephyr Bluetooth Host Stack
SPDXID: SPDXRef-zephyr-bluetooth
PackageVersion: 3.7.0
PackageSupplier: Organization: Zephyr Project
PackageDownloadLocation: https://github.com/zephyrproject-rtos/zephyr
FilesAnalyzed: false
PackageLicenseConcluded: Apache-2.0
PackageLicenseDeclared: Apache-2.0
PackageCopyrightText: Copyright Zephyr Project Contributors
ExternalRef: PACKAGE-MANAGER purl pkg:github/zephyrproject-rtos/zephyr@v3.7.0#subsys/bluetooth
Relationship: SPDXRef-zephyr-kernel CONTAINS SPDXRef-zephyr-bluetooth
Merging the 4 SPDX Files into One Document
For submission to market surveillance authorities and for tooling compatibility, you'll want a single SPDX document rather than four separate files. The west spdx sdk.spdx file provides relationships between the documents, but many tools expect a single file.
Use the SPDX tools Python library:
pip install spdx-tools
# Validate individual files first
pyspdx-tv parse build/spdx/app.spdx
pyspdx-tv parse build/spdx/zephyr.spdx
For merging, write a script that:
- Reads all four SPDX documents
- Assigns unique SPDX IDs across documents (prefix with document origin to avoid collisions)
- Combines all packages, files, and relationships into a single document
- Updates the document namespace and creation info
Alternatively, use SPDX JSON format (west spdx -d build --output-format json) if your toolchain handles JSON better — the merge is simpler with JSON than with SPDX tag-value format.
Converting to CycloneDX
If your supply chain requires CycloneDX, you can convert the enriched SPDX output. Be aware that the conversion is lossy.
# Install the CycloneDX CLI tool
npm install -g @cyclonedx/cyclonedx-cli
# Convert SPDX to CycloneDX
cyclonedx-cli convert \
--input-file build/spdx/merged.spdx \
--input-format spdxjson \
--output-file firmware-sbom.cdx.json \
--output-format json
What gets lost in conversion:
- SPDX file-level entries (CycloneDX focuses on components, not individual source files)
- Some relationship types that don't have CycloneDX equivalents
- SPDX-specific annotations and review information
What converts cleanly:
- Package names, versions, and suppliers
- CPE and PURL identifiers (this is why enrichment matters — identifiers survive conversion)
- License information
- Dependency relationships (mapped to CycloneDX dependency graph)
If you need both formats, generate the enriched SPDX as your source of truth and derive CycloneDX from it. Don't maintain two parallel SBOMs.
Running Vulnerability Scanners Against the Enriched SBOM
With CPE/PURL identifiers in place, vulnerability scanners can actually do their job.
OSV-Scanner
# OSV-Scanner works well with SPDX
osv-scanner --sbom build/spdx/merged-enriched.spdx
OSV-Scanner queries the OSV database, which aggregates vulnerabilities from multiple sources including NVD, GitHub Advisories, and project-specific databases.
Grype
# Grype supports both SPDX and CycloneDX
grype sbom:build/spdx/merged-enriched.spdx
# Or against the CycloneDX version
grype sbom:firmware-sbom.cdx.json
Interpreting Results
Expect false positives. Embedded firmware often uses stripped-down configurations of libraries. A vulnerability in Mbed TLS's X.509 parsing doesn't affect your build if you've disabled X.509 entirely via Kconfig.
This is where VEX (Vulnerability Exploitability eXchange) documents come in. For each scanner result, determine:
- Affected: The vulnerable code path exists in your build and is reachable
- Not affected: The vulnerable code is compiled out (Kconfig), not reachable, or mitigated by other controls
- Under investigation: You need to analyse further
Document your VEX assessments — CRA Article 14 requires you to track and communicate vulnerability status. See our vulnerability reporting guide for the full process.
CI/CD Integration: SBOM on Every Build
Generate the SBOM as part of your CI pipeline, not as a manual step. This ensures every release has a corresponding SBOM and prevents drift between what's documented and what's shipped.
# Example GitHub Actions workflow
name: Firmware Build + SBOM
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/zephyrproject-rtos/ci:v0.27
steps:
- uses: actions/checkout@v4
- name: West init and update
run: |
west init -l .
west update
- name: Build firmware
run: west build -p always -b ${{ env.BOARD }}
- name: Generate SPDX
run: west spdx -d build -n "https://your-company.com/spdx/${{ github.ref_name }}"
- name: Enrich SBOM
run: python scripts/enrich-sbom.py build/spdx/ --manifest west.yml
- name: Scan for vulnerabilities
run: |
osv-scanner --sbom build/spdx/merged-enriched.spdx
- name: Upload SBOM artifacts
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.ref_name }}
path: |
build/spdx/merged-enriched.spdx
firmware-sbom.cdx.json
Tag the SBOM with the release version. When a market surveillance authority requests documentation, you need to produce the SBOM that matches the specific firmware version deployed.
Nordic west ncs-sbom Comparison
If you're using the nRF Connect SDK (which is built on Zephyr), Nordic provides west ncs-sbom — a separate SBOM tool with different goals.
| Feature | west spdx (Zephyr) | west ncs-sbom (Nordic) |
|---|---|---|
| Primary focus | Build-accurate source enumeration | License compliance |
| Output formats | SPDX 2.3 tag-value/JSON | SPDX, custom report |
| CPE/PURL identifiers | No | No |
| Binary blob handling | Listed but opaque | Better Nordic-specific coverage |
| Subsystem enumeration | Rolled into kernel | Similar limitation |
| CRA vulnerability scanning | Not useful without enrichment | Not useful without enrichment |
Neither tool solves the CRA SBOM problem on its own. Both require the enrichment workflow described in this tutorial. The west ncs-sbom tool is better for license compliance documentation but has the same gaps for security-focused SBOM use.
Checklist: From west spdx to CRA-Compliant SBOM
Generation
-
west spdxruns on every release build - SPDX namespace uses your company's domain
- MCUboot SBOM generated separately and merged
Enrichment
- Every package has a CPE identifier (or documented reason for omission)
- Every package has a PURL identifier
- Package versions match upstream release versions (not just commit hashes)
- Supplier information populated for all packages
Invisible components
- Bluetooth stack listed as separate component (if
CONFIG_BT=y) - MQTT/CoAP/HTTP libraries listed (if enabled)
- cJSON listed separately (if
CONFIG_CJSON_LIB=y) - USB stack listed (if
CONFIG_USB_DEVICE_STACK=y) - All Kconfig-enabled subsystems audited
Binary blobs
- Vendor HAL modules documented with available version info
- Pre-compiled radio stacks (SoftDevice, WiFi firmware) listed
-
FilesAnalyzed: falseset for opaque binaries - Vendor SBOM requested from silicon supplier
Vulnerability scanning
- Scanner runs against enriched SBOM (not raw
west spdxoutput) - False positives triaged via VEX assessment
- MCUboot vulnerabilities tracked via project advisories (not just NVD)
- Scan results archived per release
Format and delivery
- Four SPDX files merged into single document
- CycloneDX version generated if required by supply chain
- SBOM included in Annex VII technical documentation
- SBOM versioned and archived alongside firmware releases
For a broader view of Zephyr CRA compliance beyond SBOM, see our complete Zephyr CRA guide. For the Annex I essential requirements checklist, see the full mapping of every requirement to implementation actions.
Based on Zephyr Project documentation (v3.7+), SPDX 2.3 specification, CycloneDX 1.7 specification, NTIA minimum SBOM elements (2021), and Regulation EU 2024/2847 Annex I Part II. This does not constitute legal advice.
Sources
- Regulation (EU) 2024/2847 — Cyber Resilience Act (full text)
- Zephyr Project — west spdx command
- Zephyr Project — Practical SBOM Management with Zephyr and SPDX
- SPDX 2.3 Specification
- OWASP CycloneDX Specification
- NTIA — Minimum Elements for a Software Bill of Materials (2021)
- Nordic Semiconductor — west ncs-sbom
- OSV-Scanner — Vulnerability scanning
- Grype — Vulnerability scanner for container images and filesystems
Check your CRA compliance status
Answer 7 questions about your embedded product and get a personalized gap analysis — with your CRA classification, key deadlines, and specific action items.
Start free assessment →