sbom: generate SBOMs from every wolfBoot build system - #830
Conversation
wolfBoot ships as source. Users build it in many ways. Before this change, only the plain Make build could make an SBOM. So a user could not make an SBOM for the build that the user runs. This change adds one shared engine (tools/scripts/wolfboot-sbom.sh, which calls wolfSSL gen-sbom) and a front end for each build system. Every build makes a CycloneDX 1.6 and SPDX 2.3 document. The engine captures the configuration with the host compiler, so the SBOM is the same for GCC, Clang, LLVM, IAR, armcl, CCRX, and XC32. Routes: - Make, arch.mk, and vendor SDKs: make sbom TARGET=<t> SIGN=<a> - CMake and the Pico SDK: cmake --build <dir> --target sbom - IAR Embedded Workbench: ide-sbom/iar_sbom.py - Any IDE with a compilation database: ide-sbom/compdb_sbom.py - TI CCS, MPLAB X, Renesas, Xilinx: ide-sbom/route_through_sbom.sh - Per-HAL component: make sbom-hal TARGET=<t> - Zephyr module: ide-sbom/zephyr_sbom.py Make the SBOM reproducible. The captured macros can hold an absolute host path. For example, arch.mk passes -DPICO_SDK_PATH=$(PICO_SDK_PATH). The driver now redacts each absolute path but keeps the macro name, so the configuration record stays complete. Add --no-scrub for debug. Add a validator (ide-sbom/validate_sbom.py) and a CI canary (.github/workflows/test-sbom.yml) that runs and validates every route. The canary also checks that no host path leaks into the SBOM. Add docs/SBOM.md. The tools are product-neutral by design, so they can be shared across wolfSSL products later without logic changes. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com> Co-authored-by: Cursor <cursoragent@cursor.com>
04988d9 to
9380897
Compare
The SBOM canary now guards the properties customers rely on, not only
that each route runs.
sbom_canary job adds three checks:
* Toolchain neutrality - build the same sim config with gcc and with
clang and require a byte-identical CycloneDX and SPDX result. The
driver captures configuration with the host compiler and a source
list, so the cross-toolchain that builds the firmware does not change
the SBOM. clang, LLVM and vendor compilers need no separate front end.
* Reproducibility - build the same config from a second absolute path
and require an identical SBOM, so no build path leaks into the output.
* Path scrub in a real build - build rp2350 with an absolute
PICO_SDK_PATH and assert the path is redacted while the macro key is
kept. This exercises the scrub through arch.mk, not a synthetic line.
New cross_targets job runs make sbom for a spread of architectures with
no IDE and no cross-toolchain installed: stm32h7, nrf52840, imx-rt1060
and sama5d3 (Arm), nxp-t1040 (PowerPC), renesas-rx65n (Renesas RX) and
hifive1 (RISC-V). The driver never calls the cross compiler, so each
target produces a valid SBOM on a plain runner. This proves the "any
target, any toolchain, no hardware" guarantee.
Every produced document is checked with validate_sbom.py and uploaded as
a build artifact.
Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
e16d1a6 to
df63948
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #830
No scan targets match the changed files in this PR. Review skipped.
7f9fa51 to
23cc8a5
Compare
Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
23cc8a5 to
b99d976
Compare
Update: re-vendor wolfGlass tip
|
Sync tools/sbom to wolfGlass 9bdf5b7: document --cflags -D-only behavior, add WOLFSSL_DIR/version.h --dep-version fallback with correct $$$$ expansion, and teach validate_sbom.py --min-properties. CI Make path checks now require a non-empty property set so empty captures cannot pass. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
2a55db2 to
e77f127
Compare
Re-vendor wolfGlass a54cde1 and declare what the capture needs. wolfBoot's wolfCrypt configuration is derived rather than literal: include/user_settings.h turns WOLFBOOT_SIGN_ECC256 into HAVE_ECC, HAVE_ECC256, ECC_TIMING_RESISTANT and the rest. Capturing CFLAGS alone recorded the -D set and none of what it selects, so the SBOM described a signing bootloader with no signature algorithm. Point SBOM_SETTINGS_H at the wolfCrypt settings header, give it SBOM_INCLUDE_DIRS, and make include/target.h a prerequisite since user_settings.h includes it and it carries the flash layout. For an stm32u5 ECC256 build the configuration record now holds 151 defines, including WOLFBOOT_SIGN_ECC256, HAVE_ECC, HAVE_ECC256, WOLFBOOT_HASH_SHA256 and IMAGE_HEADER_SIZE=256, none of which appeared before. BOOTLOADER_PARTITION_SIZE is 65536 rather than the literal `)` that Make's double expansion had left in its place. Declare wolfSSL as a dependency component by default, pinned to the submodule's version. wolfCrypt is compiled into the image rather than linked, so its sources are listed as wolfBoot's own; recording the dependency as well is what lets a scanner match wolfSSL advisories against this firmware. Record the product as firmware rather than a library, in CycloneDX component.type and SPDX primaryPackagePurpose alike. Override the licence to GPL-3.0-or-later: LICENSE is the verbatim GPLv3 and says nothing about how wolfBoot licenses under it, while all 252 GPL-headered sources say "either version 3 ... or (at your option) any later version". The per-HAL SBOM inherits the override so the two documents cannot contradict each other. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
Re-vendor the wolfGlass tip that gives every dependency component a CPE alongside its PURL, and that emits pkg:github identifiers a consumer can actually resolve. The wolfSSL entry in a wolfBoot SBOM previously carried a PURL only, and that PURL pointed at a git tag which does not exist (wolfSSL releases are tagged `-stable`), so an integrator monitoring NVD could not match wolfSSL advisories against this firmware. The CMake route also described a different product than the Make route: it never declared the wolfSSL dependency, so its `components` array was empty, and it recorded wolfBoot as a GPL-3.0-only library rather than a GPL-3.0-or-later firmware image. Both routes now emit the same component identity for the same configuration. wolfCrypt stays inventoried as wolfBoot's own sources. It is not a separate NVD product; its advisories are published against wolfssl:wolfssl, so the wolfSSL identifiers cover that code. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
The bootloader is built WOLFCRYPT_ONLY: include/user_settings.h defines it for every configuration except a wolfHSM server with certificate-chain verification, so the image carries the crypto subset of wolfSSL and no TLS. The SBOM said nothing about that, leaving an integrator to triage TLS advisories against a bootloader that cannot run TLS. The re-vendored generator reads the macro out of the captured configuration and records the subset; the wolfssl component stays, because it is the identifier NVD maps wolfSSL advisories to and the wolfcrypt one has no CVEs mapped to it at all. wolfcrypt is now nested inside wolfssl rather than beside it, which is where it ships from. wolfBoot also stopped emitting a CPE. NVD has no wolfssl:wolfboot entry, and an unlisted CPE is indistinguishable to a scanner from a listed one with no advisories; the submitted identifier is recorded as wolfssl:sbom:cpe-requested until the dictionary request is published. The CMake route passed its -D set with no settings header, so it recorded none of the configuration user_settings.h derives from that set -- a different document than `make sbom` produced from the same tree, and one that could not see WOLFCRYPT_ONLY. It now passes the same SETTINGS_H and INCLUDE_DIRS the Makefile does. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
Re-vendor wolfGlass tip where PRODUCT_CPE marks wolfboot registered (NVD dictionary entries created 2026-08-10). Docs drop the "no CPE yet" wording. The main package now carries cpe:2.3:a:wolfssl:wolfboot:<ver>. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
|
@sameehj please rebase on latest master |
danielinux
left a comment
There was a problem hiding this comment.
Reviewed against head aafafcb4. Built SBOMs locally for sim, stm32h7 and sim-tpm with SOURCE_DATE_EPOCH=1700000000 and compared the outputs.
The architecture is the right one. Generating per build from OBJS rather than shipping a canned release SBOM is correct for wolfBoot, since every integrator's image is a different composition and a static document would be wrong for all of them. Three things here are clear improvements over the previous recipe:
- The config capture is now accurate. The old
make sbomran$(HOSTCC) -dM -Eon the bare-Dlist with no-include, soinclude/user_settings.hwas never evaluated andHAVE_ECC(derived fromWOLFBOOT_SIGN_ECC256) was absent. The SBOM described a secure bootloader with no signature algorithm.SBOM_SETTINGS_Hfixes that: asimbuild now records 230 config macros. - The absolute path scrub keeps
-DPICO_SDK_PATH=<abs path>out of the document while preserving the macro name, and CI asserts both halves on a real rp2350 build rather than only on a synthetic command line. - The
registered/pendingsplit inPRODUCT_CPEis the right discipline. I checked the NVD API:cpe:2.3:a:wolfssl:wolfboot:2.9.0:*:*:*:*:*:*:*is in the dictionary (created 2026-08-10, 33 entries), so the emitted CPE resolves.
Comments inline. Of those, the lib/wolfssl change and the SBOM identity collision are the two I would want resolved before this merges; the $(wildcard) drop and the missing sibling-library components are the two that decide whether the output actually satisfies "top-level dependencies".
| @@ -1 +1 @@ | |||
| Subproject commit ac01707f552c611fbd135cc723b2682b3e7f80f2 | |||
| Subproject commit 887f242ee8570f7d8403e002c5b2b88929b86544 | |||
There was a problem hiding this comment.
This sets lib/wolfssl to 887f242e (2026-05-25). master is currently at 5418d6cf (2026-07-16), and the GitHub compare API reports master 1122 commits ahead of 887f242e with 0 behind, so merging as-is rolls the submodule back by about seven weeks.
It also looks unnecessary now that gen-sbom is vendored under tools/sbom/. I checked the pinned tree and scripts/gen-sbom is not present in it, so nothing in this PR reads the generator from the submodule.
Suggest dropping the submodule change from the PR.
| GEN_SBOM?=$(WOLFBOOT_LIB_WOLFSSL)/scripts/gen-sbom | ||
| SBOM_ROOT:=$(WOLFBOOT_ROOT) | ||
| SBOM_NAME:=wolfboot | ||
| SBOM_SRCS=$(wildcard $(patsubst %.o,%.c,$(OBJS))) $(wildcard $(patsubst %.o,%.S,$(OBJS))) |
There was a problem hiding this comment.
$(wildcard) removes sources that are not on disk before the driver ever sees them, so a missing submodule or SDK silently shrinks the SBOM with no diagnostic anywhere.
Measured with config/examples/sim-tpm.config, same TARGET, same config, twice:
| sources | wolfTPM files | diagnostic | validate_sbom.py |
|
|---|---|---|---|---|
without lib/wolfTPM checked out |
21 | 0 | none | All SBOMs valid. |
with lib/wolfTPM checked out |
29 | 8 | none | All SBOMs valid. |
Both runs write a file named wolfboot-2.9.0.cdx.json carrying the same serialNumber, so the two documents are not distinguishable by identity either (see the gen-sbom comment on that).
The driver already has a warning for exactly this case at tools/sbom/sbom-driver.py:212, but --skip-missing never fires on the Make path because $(wildcard) has already filtered the list. That is the path every embedded user takes.
An object that maps to no source on disk is the signal. Something like:
SBOM_SRCS=$(wildcard $(patsubst %.o,%.c,$(OBJS))) $(wildcard $(patsubst %.o,%.S,$(OBJS)))
# Every object must map to a source that exists. If one does not, a submodule or
# vendor SDK is absent and the SBOM would under-report the image it describes.
SBOM_SRCS_MISSING=$(filter-out $(basename $(SBOM_SRCS)),$(basename $(OBJS)))then fail the recipe when SBOM_SRCS_MISSING is non-empty unless the caller sets SBOM_ALLOW_MISSING=1, and in that case record the dropped count as a wolfssl:sbom:sources-missing property so the omission is visible in the document rather than only on a terminal nobody kept.
| if not s or s.startswith("#"): | ||
| continue | ||
| if skip_missing and not os.path.isfile(s): | ||
| print(f"WARNING: skipping missing source: {s}", file=sys.stderr) |
There was a problem hiding this comment.
This warning is unreachable from make sbom, which is the primary path for every embedded target. Makefile:826 filters the source list through $(wildcard) first, so load_srcs() only ever sees files that exist and --skip-missing never drops anything. Detail in the Makefile comment.
| serial = derived_uuid(args.name, args.version, 'serial') | ||
| doc_ns_uuid = derived_uuid(args.name, args.version, 'document') |
There was a problem hiding this comment.
serialNumber and the SPDX documentNamespace are derived from name and version only, so every wolfBoot configuration at a given version produces the same document identity.
sim vs stm32h7, both 2.9.0, SOURCE_DATE_EPOCH pinned:
sim serial urn:uuid:6dc51759-ade2-5967-b392-2f23ed0eb4df
h7 serial urn:uuid:6dc51759-ade2-5967-b392-2f23ed0eb4df
sim SPDX ns urn:uuid:54060fbb-3fa0-5085-96ef-cc44675172e2
h7 SPDX ns urn:uuid:54060fbb-3fa0-5085-96ef-cc44675172e2
identical namespace: True
identical content: False
The two documents have different source sets (18 vs 28 files) and different component hashes. CycloneDX requires serialNumber to be unique per BOM, and SPDX 2.3 section 6.5 requires the same of documentNamespace. Dependency-Track keys projects on serialNumber, so ingesting a second target overwrites the first rather than adding it. wolfBoot has on the order of 100 target configurations, so this is the normal case rather than a corner.
The composition hash is already computed above and is the right discriminator:
| serial = derived_uuid(args.name, args.version, 'serial') | |
| doc_ns_uuid = derived_uuid(args.name, args.version, 'document') | |
| serial = derived_uuid(args.name, args.version, 'serial', lib_hash) | |
| doc_ns_uuid = derived_uuid(args.name, args.version, 'document', lib_hash) |
The default output filenames collide for the same reason: both targets write wolfboot-2.9.0.cdx.json. SBOM_CDX_OUT / SBOM_SPDX_OUT in the Makefile want a config discriminator too, for example wolfboot-$(TARGET)-$(SIGN)-$(WOLFBOOT_VERSION).cdx.json.
| # the integrator of a product that embeds wolfSSL. Each `cpe` value must be | ||
| # the vendor:product pair NVD actually registers for that dependency; never | ||
| # synthesize one. | ||
| DEP_META = { |
There was a problem hiding this comment.
DEP_META covers wolfssl, wolfcrypt, libz and openssl, and the driver exposes only --dep-wolfssl, --dep-wolfcrypt and --dep-openssl. wolfBoot has six submodules: wolfssl, wolfTPM, wolfPKCS11, wolfHSM, wolfPSA and wolfHAL. The five that are not wolfssl can never be emitted as components.
Built config/examples/sim-tpm.config with lib/wolfTPM checked out:
- the captured config records
WOLFBOOT_TPM=1,WOLFTPM_SWTPM=1,WOLFTPM2_MAX_BUFFER=1500, so the document knows perfectly well that a TPM stack is compiled in tpm2.c,tpm2_wrap.c,tpm2_tis.c,tpm2_packet.c,tpm2_param_enc.c,tpm2_swtpm.c,tpm2_util.candtpm2_crypto.care folded into wolfBoot's own source setcomponents[]iswolfssl -> wolfcryptonly. No wolfTPM component, no version, no CPE, no PURL.
A wolfTPM advisory cannot match that document. The same holds for wolfHSM and wolfPKCS11 builds. This is the specific thing CRA Annex I Part II(1) asks for ("covering at the very least the top-level dependencies of the product"), so it is worth closing rather than deferring.
The detection signal is already present. Suggested shape: make DEP_META a table keyed by name with the same fields it has now, add a generic --dep NAME alongside the existing --dep-version NAME=VER, and have sbom.mk declare the component whenever the corresponding macro (WOLFBOOT_TPM, WOLFHSM_CLIENT, WOLFBOOT_PKCS11 and so on) appears in the captured config.
Two related exclusions are worth stating explicitly in the docs/SBOM.md Limitations section rather than leaving implicit: the compiler runtime (libgcc, newlib) is linked into every image and appears nowhere in the document, and vendor SDK sources are absent whenever the SDK lives outside the source tree.
| if srcs_basenames: | ||
| properties.append({ | ||
| 'name': 'wolfssl:sbom:source-set', | ||
| 'value': ','.join(srcs_basenames), | ||
| }) |
There was a problem hiding this comment.
This property is the only file-level record in the document, and it is a comma joined list of basenames: no path, no per file hash, no per file license. SPDX comes out with files: 0 and filesAnalyzed: false.
Two consequences. You cannot answer "which wolfssl file revisions are in this image", which is the question an incident response actually asks. And the basename collapse is ambiguous wherever a name repeats: sha256.c in a wolfBoot SBOM could be lib/wolfssl/wolfcrypt/src/sha256.c or a HAL file. The Merkle root cannot be decomposed to check one file.
The inputs already exist. srcs_merkle_hash computes a gitoid per source, and generate_cdx already has a file_entries path (line 1174) that the --lib route uses. Emitting SPDX files[] with repo-relative fileName plus SHA-256 and CONTAINS relationships, and reusing file_entries for the source route, closes this cheaply.
Related: srcs_merkle_hash calls sys.exit on any duplicate basename, so a target that pairs a vendor flash.c with another flash.c fails SBOM generation outright rather than degrading. Sorting on the repo-relative path instead of the basename would remove both the ambiguity and the hard failure.
| The `wolfssl` component stays in the document regardless. Dropping it would | ||
| read as more precise and would take the scan from every wolfSSL advisory to | ||
| none. Narrow the TLS-only CVEs with a VEX statement instead, which is the | ||
| mechanism designed to say "present but not exploitable here". |
There was a problem hiding this comment.
This paragraph and the matching comment in gen-sbom (resolve_crypto_only) both direct the reader to narrow TLS-only wolfSSL CVEs with a VEX statement, but nothing in this PR produces one and there is no target that does.
include/user_settings.h sets WOLFCRYPT_ONLY for every configuration except a wolfHSM server with certificate chain verification, so essentially every wolfBoot user's scanner will report wolfSSL TLS advisories against an image that contains no TLS. Recommending the remedy without shipping it leaves that noise in place for everyone.
This looks like the highest value follow-up and a small one: a make vex emitting a CycloneDX VEX with not_affected / code_not_present for the TLS-only CVEs, keyed off the WOLFCRYPT_ONLY the document already captures. Worth filing as a follow-up issue even if it stays out of this PR.
| @@ -0,0 +1 @@ | |||
| 1bfcf4f1a293ba09f0ff6d67904dca09ee8eb6d7 | |||
There was a problem hiding this comment.
tools/sbom/gen-sbom is a 1803 line copy pinned here by a bare SHA, with nothing checking that the copy still matches the pin. It will drift from wolfGlass and from wolfSSL's own scripts/gen-sbom without anything noticing, and a locally patched copy would be invisible.
Worth a step in test-sbom.yml that fetches this revision and diffs it against tools/sbom/, so a stale or modified vendored copy fails CI rather than quietly producing a different document from the one wolfSSL ships.
Description
wolfBoot ships as source. Users build it in many ways. Each user needs an
SBOM for the build that the user makes. This PR makes wolfBoot produce a
CycloneDX 1.6 and SPDX 2.3 SBOM from every build system, and it makes the
SBOM reproducible.
One engine does the work. Each build system gives the engine two inputs:
the compiled source list and the build configuration. The engine captures
the configuration with the host compiler, so the SBOM is the same for GCC,
Clang, LLVM, IAR, armcl, CCRX, and XC32.
What is new
tools/scripts/wolfboot-sbom.sh.make sbom.cmake --build --target sbom.ide-sbom/iar_sbom.py.ide-sbom/compdb_sbom.py.ide-sbom/route_through_sbom.sh.make sbom-hal.ide-sbom/zephyr_sbom.py.ide-sbom/validate_sbom.py..github/workflows/test-sbom.yml.docs/SBOM.mdanddocs/SBOM-WOLFGLASS.md.Reproducibility fix
The driver captures build macros with the host compiler. Some macros hold
an absolute host path. For example,
arch.mkpasses-DPICO_SDK_PATH=$(PICO_SDK_PATH). Before this change, the path enteredthe SBOM. The SBOM was then different on each machine, and it leaked the
local file system.
The driver now redacts each absolute path in the captured macros. It keeps
the macro name, so the configuration record stays complete. The CI canary
asserts that no path leaks and that the macro name stays. Use
--no-scrubfor debug only.
Toolchain support
The SBOM content does not depend on the cross-compiler. The config uses
the host compiler. The composition is a source list. So Clang, LLVM,
Renesas CCRX, TI armcl, and IAR all give the same SBOM. A new toolchain
needs no work. A new IDE that emits a compilation database needs no work.
Relation to wolfGlass
The tools are product-neutral. They are self-contained in wolfBoot on
purpose. They are the reference implementation of the shared SBOM layer
for every wolfSSL product (wolfSSL, wolfHSM, and others).
docs/ SBOM-WOLFGLASS.mdgives the file-by-file migration map and the list ofchanges that wolfGlass must cover. No wolfBoot logic changes when the
tools move.
Prerequisite
The tools need
gen-sbomfrom wolfSSL (lib/wolfssl/scripts/gen-sbom).If the pinned submodule does not carry it, pass a path with
GEN_SBOM=...or the
--gen-sbomoption. The CI canary fetches it from wolfSSL masteras a fallback.
Testing
make sbom TARGET=simproduces a valid SBOM. The validator passes.make sbom-hal TARGET=simproduces a valid per-HAL SBOM.cmake --build <dir> --target sbomproduces a valid SBOM.the macro name stays.
Notes
sources, not as a separate component.
Git Bash. As an alternative, use
compdb_sbom.py.