From 9380897976ea023fba061e31b3b2a68861b8db4f Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 22 Jul 2026 13:46:43 +0300 Subject: [PATCH 1/3] sbom: generate SBOMs from every wolfBoot build system 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= SIGN= - CMake and the Pico SDK: cmake --build --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= - 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 Co-authored-by: Cursor --- .github/workflows/test-sbom.yml | 147 ++++++++++ .gitignore | 6 + CMakeLists.txt | 5 + Makefile | 81 +++-- README.md | 22 +- cmake/sbom.cmake | 124 ++++++++ docs/SBOM.md | 292 +++++++++++++++++++ tools/scripts/ide-sbom/compdb_sbom.py | 176 +++++++++++ tools/scripts/ide-sbom/iar_sbom.py | 198 +++++++++++++ tools/scripts/ide-sbom/route_through_sbom.sh | 64 ++++ tools/scripts/ide-sbom/validate_sbom.py | 89 ++++++ tools/scripts/ide-sbom/zephyr_sbom.py | 145 +++++++++ tools/scripts/wolfboot-sbom.sh | 287 ++++++++++++++++++ 13 files changed, 1601 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/test-sbom.yml create mode 100644 cmake/sbom.cmake create mode 100644 docs/SBOM.md create mode 100755 tools/scripts/ide-sbom/compdb_sbom.py create mode 100755 tools/scripts/ide-sbom/iar_sbom.py create mode 100755 tools/scripts/ide-sbom/route_through_sbom.sh create mode 100755 tools/scripts/ide-sbom/validate_sbom.py create mode 100755 tools/scripts/ide-sbom/zephyr_sbom.py create mode 100755 tools/scripts/wolfboot-sbom.sh diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml new file mode 100644 index 0000000000..378ff6e2b9 --- /dev/null +++ b/.github/workflows/test-sbom.yml @@ -0,0 +1,147 @@ +name: Wolfboot SBOM Canary + +# Exercises every wolfBoot SBOM route so a change to the build system, the +# shared driver (tools/scripts/wolfboot-sbom.sh) or an IDE extractor cannot +# silently break SBOM generation: +# +# * Make path (methods 1-4) -> make sbom TARGET=sim +# * CMake path (method 5) -> cmake --build --target sbom +# * IAR extractor (method 7) -> tools/scripts/ide-sbom/iar_sbom.py +# * compdb extractor (6, 8-11) -> tools/scripts/ide-sbom/compdb_sbom.py +# * per-HAL SBOM -> make sbom-hal TARGET=sim +# * Zephyr module SBOM -> tools/scripts/ide-sbom/zephyr_sbom.py +# +# Each route must emit a schema-valid CycloneDX 1.6 + SPDX 2.3 document whose +# top-level component is wolfboot. + +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '*' ] + +jobs: + sbom_canary: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Trust workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install tooling + run: | + sudo apt-get update + sudo apt-get install -y cmake python3 build-essential + + # gen-sbom ships with wolfSSL. Use the vendored submodule copy if the + # pinned revision already carries it, otherwise fetch it from wolfSSL + # master so the canary still runs while the submodule bump lands. + - name: Locate gen-sbom + id: gensbom + run: | + if [ -f lib/wolfssl/scripts/gen-sbom ]; then + echo "path=$GITHUB_WORKSPACE/lib/wolfssl/scripts/gen-sbom" >> "$GITHUB_OUTPUT" + else + mkdir -p .sbom-tools + curl -fsSL \ + https://raw.githubusercontent.com/wolfSSL/wolfssl/master/scripts/gen-sbom \ + -o .sbom-tools/gen-sbom + chmod +x .sbom-tools/gen-sbom + echo "path=$GITHUB_WORKSPACE/.sbom-tools/gen-sbom" >> "$GITHUB_OUTPUT" + fi + + # Reproducibility guard: a -D macro carrying an absolute host path (e.g. + # arch.mk's -DPICO_SDK_PATH=$(PICO_SDK_PATH)) must never reach the SBOM. + - name: Path scrub check + run: | + printf 'src/image.c\n' > /tmp/scrub-srcs.txt + sh tools/scripts/wolfboot-sbom.sh \ + --srcs-file /tmp/scrub-srcs.txt \ + --cflags "-DWOLFBOOT_HASH_SHA256 -DPICO_SDK_PATH=/home/ci-secret/pico-sdk" \ + --name wolfboot --version 0.0.0-scrubtest \ + --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + --cdx-out /tmp/scrub.cdx.json --spdx-out /tmp/scrub.spdx.json + if grep -q 'ci-secret' /tmp/scrub.cdx.json /tmp/scrub.spdx.json; then + echo "ERROR: absolute host path leaked into the SBOM (scrub failed)." >&2 + exit 1 + fi + grep -q 'PICO_SDK_PATH' /tmp/scrub.cdx.json || { + echo "ERROR: PICO_SDK_PATH macro was dropped entirely (should be redacted, not removed)." >&2 + exit 1; } + echo "scrub OK: path redacted, macro key preserved" + + - name: Make path - make sbom (sim) + run: | + cp config/examples/sim.config .config + make sbom TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" + python3 tools/scripts/ide-sbom/validate_sbom.py \ + wolfboot-*.cdx.json wolfboot-*.spdx.json + + - name: CMake path - cmake --target sbom (sim) + run: | + rm -rf build-sim + cmake -S . -B build-sim -G "Unix Makefiles" \ + -DWOLFBOOT_TARGET=sim -DARCH=HOST -DSIGN=ED25519 -DHASH=SHA256 \ + -DWOLFBOOT_SECTOR_SIZE=256 -DWOLFBOOT_PARTITION_SIZE=0x6400 \ + -DWOLFBOOT_PARTITION_BOOT_ADDRESS=0x08003000 \ + -DWOLFBOOT_PARTITION_UPDATE_ADDRESS=0x08009400 \ + -DWOLFBOOT_PARTITION_SWAP_ADDRESS=0x0800F800 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DGEN_SBOM="${{ steps.gensbom.outputs.path }}" + cmake --build build-sim --target sbom + python3 tools/scripts/ide-sbom/validate_sbom.py \ + build-sim/wolfboot-*.cdx.json build-sim/wolfboot-*.spdx.json + + - name: IAR extractor (method 7) + run: | + python3 tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp \ + --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + --cdx-out iar.cdx.json --spdx-out iar.spdx.json + python3 tools/scripts/ide-sbom/validate_sbom.py iar.cdx.json iar.spdx.json + + - name: compdb extractor (methods 6, 8-11) + run: | + python3 tools/scripts/ide-sbom/compdb_sbom.py \ + build-sim/compile_commands.json \ + --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + --cdx-out compdb.cdx.json --spdx-out compdb.spdx.json + python3 tools/scripts/ide-sbom/validate_sbom.py \ + compdb.cdx.json compdb.spdx.json + + - name: Per-HAL SBOM (make sbom-hal) + run: | + cp config/examples/sim.config .config + make sbom-hal TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" + python3 tools/scripts/ide-sbom/validate_sbom.py \ + wolfboot-hal-sim-*.cdx.json wolfboot-hal-sim-*.spdx.json + + - name: Zephyr module SBOM + run: | + python3 tools/scripts/ide-sbom/zephyr_sbom.py \ + --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + --cdx-out zephyr.cdx.json --spdx-out zephyr.spdx.json + python3 tools/scripts/ide-sbom/validate_sbom.py \ + zephyr.cdx.json zephyr.spdx.json + + - name: Upload SBOM artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: wolfboot-sboms + path: | + wolfboot-*.cdx.json + wolfboot-*.spdx.json + build-sim/wolfboot-*.cdx.json + build-sim/wolfboot-*.spdx.json + iar.cdx.json + iar.spdx.json + compdb.cdx.json + compdb.spdx.json + zephyr.cdx.json + zephyr.spdx.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index b1bc7fb6ad..ec98e3d355 100644 --- a/.gitignore +++ b/.gitignore @@ -421,3 +421,9 @@ sdcard.img # wolfHSM STM32H5 TZ demo build output port/stmicro/stm32h5-tz-wolfhsm/out/ + +# Generated SBOM artifacts (CycloneDX 1.6 + SPDX 2.3) +wolfboot-*.cdx.json +wolfboot-*.spdx.json +wolfboot-*.spdx +wolfboot-sbom-srcs.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 344cc21d52..ef4ae7297c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1546,4 +1546,9 @@ if(HOST_IS_MSVC) # Some VS2022 helpers "${CMAKE_CURRENT_BINARY_DIR}") endif() # HOST_IS_MSVC VS2022 helpers +#--------------------------------------------------------------------------------------------- +# SBOM generation (CycloneDX 1.6 + SPDX 2.3), shares the engine used by `make sbom` +#--------------------------------------------------------------------------------------------- +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/sbom.cmake) + message(STATUS "End [WOLFBOOT_ROOT]/CmakeLists.txt") diff --git a/Makefile b/Makefile index a011889aac..fc81e7c145 100644 --- a/Makefile +++ b/Makefile @@ -799,10 +799,14 @@ pico-sdk-info: FORCE # recipe echoes the effective target/sign so a default build is visible; pass # them explicitly to get an SBOM that reflects your actual configuration. # -# Extracts the configuration-specific source list from OBJS (which is fully -# assembled by this point — core wolfBoot + wolfcrypt + HAL sources are all -# included), captures the build's -D configuration macros via $(HOSTCC) -dM -E -# on the host, and calls gen-sbom to emit CycloneDX and SPDX output files. +# This is the plain-Make / arch.mk entry point. It also covers every build +# that is really the Makefile with a vendor SDK bolted on via source/include +# paths (MCUXpresso, STM32Cube, PSoC6, Freedom-E-SDK, Vorago) and the IDE +# targets that also have an arch.mk path (TI Hercules, Renesas RX, Zynq). It +# extracts the configuration-specific source list from OBJS (fully assembled by +# this point: core wolfBoot + wolfcrypt + HAL) and passes it, together with the +# build CFLAGS, to the shared SBOM driver (tools/scripts/wolfboot-sbom.sh) which +# is the single engine reused by the CMake and IDE entry points too. # # wolfcrypt sources are compiled directly into the wolfBoot image and are # therefore listed as wolfBoot's own sources, not as a separate component. @@ -821,49 +825,64 @@ GEN_SBOM?=$(WOLFBOOT_LIB_WOLFSSL)/scripts/gen-sbom SBOM_CDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).cdx.json SBOM_SPDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).spdx.json SBOM_PYTHON?=$(or $(CRA_PYTHON),python3) +SBOM_DRIVER:=$(WOLFBOOT_ROOT)/tools/scripts/wolfboot-sbom.sh sbom: - @if [ -z "$(WOLFBOOT_VERSION)" ]; then \ - echo "ERROR: could not read LIBWOLFBOOT_VERSION_STRING from include/wolfboot/version.h" >&2; \ - echo " (check the file exists and its version format is intact)." >&2; \ - exit 1; \ - fi - @if [ ! -f "$(GEN_SBOM)" ]; then \ - echo "ERROR: gen-sbom not found at '$(GEN_SBOM)'." >&2; \ - echo " Initialize the submodule: git submodule update --init lib/wolfssl" >&2; \ - echo " or point GEN_SBOM at a wolfssl tree: make sbom GEN_SBOM=/path/to/wolfssl/scripts/gen-sbom" >&2; \ - exit 1; \ - fi @echo "wolfBoot SBOM: version=$(WOLFBOOT_VERSION) target=$(TARGET) sign=$(SIGN)" - @echo " Outputs: $(SBOM_CDX_OUT) $(SBOM_SPDX_OUT)" $(eval _SBOM_SRCS := $(wildcard $(patsubst %.o,%.c,$(OBJS))) $(wildcard $(patsubst %.o,%.S,$(OBJS)))) @if [ -z "$(_SBOM_SRCS)" ]; then \ echo "ERROR: no source files found in OBJS — check that TARGET and SIGN are correct." >&2; \ exit 1; \ fi @set -e; \ - _dh=$$(mktemp /tmp/wolfboot-sbom-defines.XXXXXX); \ _sf=$$(mktemp /tmp/wolfboot-sbom-srcs.XXXXXX); \ - trap 'rm -f "$$_dh" "$$_sf"' EXIT; \ - _defs=""; \ - for _t in $(CFLAGS); do \ - case "$$_t" in -D*) _defs="$$_defs $$_t" ;; esac; \ - done; \ - $(HOSTCC) -dM -E -DWOLFSSL_USER_SETTINGS $$_defs \ - -x c /dev/null >"$$_dh" 2>/dev/null || \ - { echo "ERROR: '$(HOSTCC) -dM -E' failed; install a host C compiler or set HOSTCC." >&2; exit 1; }; \ + trap 'rm -f "$$_sf"' EXIT; \ printf '%s\n' $(_SBOM_SRCS) >"$$_sf"; \ - $(SBOM_PYTHON) "$(GEN_SBOM)" \ + "$(SBOM_DRIVER)" \ + --srcs-file "$$_sf" \ + --cflags "$(CFLAGS)" \ --name wolfboot \ --version "$(WOLFBOOT_VERSION)" \ - --supplier "wolfSSL Inc." \ --license-file "$(WOLFBOOT_ROOT)/LICENSE" \ - --options-h "$$_dh" \ - --srcs-file "$$_sf" \ + --gen-sbom "$(GEN_SBOM)" \ + --python "$(SBOM_PYTHON)" \ + --hostcc "$(HOSTCC)" \ + --root "$(WOLFBOOT_ROOT)" \ --cdx-out "$(SBOM_CDX_OUT)" \ --spdx-out "$(SBOM_SPDX_OUT)" - @echo "SBOM written: $(SBOM_CDX_OUT) $(SBOM_SPDX_OUT)" + +## Per-HAL SBOM +# Emits a standalone SBOM whose component is the HAL layer for the selected +# TARGET (hal/hal.c, hal/$(TARGET).c, and any target flash/uart/board drivers), +# separate from the full bootloader SBOM. Uses the same build config (CFLAGS) +# so the captured macros match the real build. Run once per TARGET. +SBOM_HAL_CDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).cdx.json +SBOM_HAL_SPDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).spdx.json + +sbom-hal: + @echo "wolfBoot HAL SBOM: version=$(WOLFBOOT_VERSION) target=$(TARGET)" + $(eval _HAL_SRCS := $(filter hal/%,$(patsubst ./%,%,$(wildcard $(patsubst %.o,%.c,$(OBJS)) $(patsubst %.o,%.S,$(OBJS)))))) + @if [ -z "$(_HAL_SRCS)" ]; then \ + echo "ERROR: no HAL sources found in OBJS for TARGET=$(TARGET)." >&2; \ + exit 1; \ + fi + @set -e; \ + _sf=$$(mktemp /tmp/wolfboot-hal-sbom-srcs.XXXXXX); \ + trap 'rm -f "$$_sf"' EXIT; \ + printf '%s\n' $(_HAL_SRCS) >"$$_sf"; \ + "$(SBOM_DRIVER)" \ + --srcs-file "$$_sf" \ + --cflags "$(CFLAGS)" \ + --name "wolfboot-hal-$(TARGET)" \ + --version "$(WOLFBOOT_VERSION)" \ + --license-file "$(WOLFBOOT_ROOT)/LICENSE" \ + --gen-sbom "$(GEN_SBOM)" \ + --python "$(SBOM_PYTHON)" \ + --hostcc "$(HOSTCC)" \ + --root "$(WOLFBOOT_ROOT)" \ + --cdx-out "$(SBOM_HAL_CDX_OUT)" \ + --spdx-out "$(SBOM_HAL_SPDX_OUT)" FORCE: -.PHONY: FORCE clean keytool_check squashelf_check sbom +.PHONY: FORCE clean keytool_check squashelf_check sbom sbom-hal diff --git a/README.md b/README.md index 8682487a6d..360adc1c3e 100644 --- a/README.md +++ b/README.md @@ -142,15 +142,29 @@ make sbom TARGET= SIGN= HASH= `TARGET`, `SIGN`, and `HASH` must match your wolfBoot build configuration (same as a normal `make` invocation), because the SBOM's source set and artifact hash -are configuration-specific. `gen-sbom` lives in the `lib/wolfssl` submodule and -is used automatically; override with `GEN_SBOM=/path/to/wolfssl/scripts/gen-sbom` -if you keep wolfssl elsewhere. +are configuration-specific. `gen-sbom` is part of wolfSSL. The build uses the +copy in the `lib/wolfssl` submodule. If the pinned revision does not include it, +give the path with `GEN_SBOM=/path/to/wolfssl/scripts/gen-sbom`. + +The same SBOM engine is available from every wolfBoot build system, so you get +an identical CycloneDX 1.6 / SPDX 2.3 document however you build: + +| Build system / artifact | How to generate the SBOM | +| --- | --- | +| Make / arch.mk / vendor SDKs | `make sbom TARGET= SIGN=` | +| CMake (and Pico SDK) | `cmake --build --target sbom` | +| IAR Embedded Workbench | `tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp` | +| TI CCS / MPLAB X / Renesas / Xilinx | `tools/scripts/ide-sbom/route_through_sbom.sh --config ...` | +| Any IDE with a compilation database | `tools/scripts/ide-sbom/compdb_sbom.py compile_commands.json` | +| Per-HAL component | `make sbom-hal TARGET=` | +| Zephyr TEE/PSA module | `tools/scripts/ide-sbom/zephyr_sbom.py` | Output files are written to the build directory as `wolfboot-.cdx.json` (CycloneDX 1.6) and `wolfboot-.spdx.json` (SPDX 2.3 JSON), where `` is read from `include/wolfboot/version.h`. -For CRA guidance and worked SBOM examples, see the +See [docs/SBOM.md](./docs/SBOM.md) for the full per-build-system guide. For CRA +guidance and worked SBOM examples, see the [wolfSSL CRA Kit](https://github.com/wolfSSL/wolfssl-examples/tree/master/cra-kit). ## Troubleshooting diff --git a/cmake/sbom.cmake b/cmake/sbom.cmake new file mode 100644 index 0000000000..a309edd972 --- /dev/null +++ b/cmake/sbom.cmake @@ -0,0 +1,124 @@ +# cmake/sbom.cmake - CMake entry point for wolfBoot SBOM generation. +# +# This is the CMake counterpart of the Makefile `sbom` target. It reuses the +# same engine (tools/scripts/wolfboot-sbom.sh -> wolfSSL gen-sbom) so a CMake +# build and a Make build of the same configuration produce byte-comparable +# CycloneDX 1.6 and SPDX 2.3 SBOMs. It covers the CMake presets / dot-config +# builds and the Pico SDK build (which is CMake underneath). +# +# Usage: +# cmake --build --target sbom +# +# Overrides: +# -DGEN_SBOM=/path/to/wolfssl/scripts/gen-sbom (default: lib/wolfssl copy) +# -DHOSTCC=cc host cc for macro capture +# -DSBOM_PYTHON=python3 +# +# NOTE: the driver is a POSIX shell script; on Windows run this target from a +# shell environment (WSL/MSYS/Git-Bash) or use the Makefile path. + +if(NOT DEFINED WOLFBOOT_ROOT) + set(WOLFBOOT_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) +endif() + +set(SBOM_DRIVER ${WOLFBOOT_ROOT}/tools/scripts/wolfboot-sbom.sh) + +# gen-sbom: prefer an explicit override, else the vendored wolfssl submodule. +if(NOT DEFINED GEN_SBOM OR GEN_SBOM STREQUAL "") + set(GEN_SBOM ${WOLFBOOT_ROOT}/lib/wolfssl/scripts/gen-sbom) +endif() +if(NOT DEFINED HOSTCC OR HOSTCC STREQUAL "") + set(HOSTCC cc) +endif() +if(NOT DEFINED SBOM_PYTHON OR SBOM_PYTHON STREQUAL "") + set(SBOM_PYTHON python3) +endif() + +# Read the wolfBoot version string from the header so CMake and Make agree. +set(_sbom_version "") +if(EXISTS ${WOLFBOOT_ROOT}/include/wolfboot/version.h) + file(STRINGS ${WOLFBOOT_ROOT}/include/wolfboot/version.h _sbom_ver_line + REGEX "LIBWOLFBOOT_VERSION_STRING") + if(_sbom_ver_line) + string(REGEX REPLACE ".*LIBWOLFBOOT_VERSION_STRING[ \t]+\"([^\"]*)\".*" + "\\1" _sbom_version "${_sbom_ver_line}") + endif() +endif() +if(_sbom_version STREQUAL "") + message(WARNING "sbom: could not read LIBWOLFBOOT_VERSION_STRING; using 0.0") + set(_sbom_version "0.0") +endif() + +# Collect the compiled-in source set from the wolfBoot library targets. These +# are the same sources arch.mk folds into OBJS: core wolfBoot + HAL + keystore +# + wolfcrypt. +set(_sbom_targets wolfboot wolfboothal) +if(TARGET public_key) + list(APPEND _sbom_targets public_key) +endif() +if(DEFINED WOLFSSL_TGT AND TARGET ${WOLFSSL_TGT}) + list(APPEND _sbom_targets ${WOLFSSL_TGT}) +endif() + +set(_sbom_srcs "") +foreach(_t IN LISTS _sbom_targets) + get_target_property(_t_srcs ${_t} SOURCES) + get_target_property(_t_dir ${_t} SOURCE_DIR) + if(_t_srcs) + foreach(_s IN LISTS _t_srcs) + # Skip generator expressions (e.g. $) which are + # not plain file paths; wolfBoot's targets use plain sources. + # Also skip headers so the SBOM lists only compiled translation + # units, matching the Make path (OBJS -> .c/.S only). + if(NOT _s MATCHES "\\$<" AND _s MATCHES "\\.(c|cc|cpp|cxx|s|S|asm)$") + if(IS_ABSOLUTE "${_s}") + list(APPEND _sbom_srcs "${_s}") + else() + list(APPEND _sbom_srcs "${_t_dir}/${_s}") + endif() + endif() + endforeach() + endif() +endforeach() +list(REMOVE_DUPLICATES _sbom_srcs) + +# Write the source list for the driver (one path per line). +set(_sbom_srcs_file ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-sbom-srcs.txt) +string(REPLACE ";" "\n" _sbom_srcs_nl "${_sbom_srcs}") +file(GENERATE OUTPUT ${_sbom_srcs_file} CONTENT "${_sbom_srcs_nl}\n") + +# Assemble the effective configuration as -D flags. The driver runs these +# through the HOST compiler's -dM -E, exactly like the Make path does with +# CFLAGS, so the captured macro set (and therefore the SBOM) is identical. +set(_sbom_defs ${WOLFBOOT_DEFS} ${WOLFBOOT_DEFS_PUBLIC} ${USER_SETTINGS} ${SIGN_OPTIONS}) +list(REMOVE_DUPLICATES _sbom_defs) +set(_sbom_cflags "") +foreach(_d IN LISTS _sbom_defs) + if(NOT _d STREQUAL "") + string(REGEX REPLACE "^-D" "" _d "${_d}") + set(_sbom_cflags "${_sbom_cflags} -D${_d}") + endif() +endforeach() + +set(_sbom_cdx ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_sbom_version}.cdx.json) +set(_sbom_spdx ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_sbom_version}.spdx.json) + +add_custom_target(sbom + COMMAND ${CMAKE_COMMAND} -E env HOSTCC=${HOSTCC} + ${SBOM_DRIVER} + --srcs-file ${_sbom_srcs_file} + --cflags ${_sbom_cflags} + --name wolfboot + --version ${_sbom_version} + --license-file ${WOLFBOOT_ROOT}/LICENSE + --gen-sbom ${GEN_SBOM} + --python ${SBOM_PYTHON} + --hostcc ${HOSTCC} + --root ${WOLFBOOT_ROOT} + --skip-missing + --cdx-out ${_sbom_cdx} + --spdx-out ${_sbom_spdx} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + VERBATIM + COMMENT "Generating wolfBoot SBOM (CycloneDX 1.6 + SPDX 2.3)" +) diff --git a/docs/SBOM.md b/docs/SBOM.md new file mode 100644 index 0000000000..965b4db5c3 --- /dev/null +++ b/docs/SBOM.md @@ -0,0 +1,292 @@ +# wolfBoot SBOM Generation + +wolfBoot can emit a Software Bill of Materials (SBOM) in **CycloneDX 1.6** and +**SPDX 2.3** JSON for every configuration and every build system it supports. +An SBOM is one of the software-transparency artifacts useful towards EU Cyber +Resilience Act (CRA) obligations; it does not by itself make a product CRA +compliant (that is a system- and process-level determination for the +manufacturer). + +## One engine, many front ends + +There is a single SBOM engine. Every build system feeds it the same two inputs +and gets back the same document: + +``` +build system ─┐ + ├─► tools/scripts/wolfboot-sbom.sh ─► wolfSSL gen-sbom ─► *.cdx.json + *.spdx.json +extractor ─┘ (srcs list + build config) +``` + +* **srcs list** – the source files actually compiled into the image. +* **build config** – the effective `-D` macros, normalized through the *host* + compiler's `-dM -E`. Because macro capture uses the host compiler (never the + cross-compiler), the SBOM is reproducible across toolchains: GCC, Clang/LLVM, + IAR `iccarm`, TI `armcl`, Renesas `ccrx` and Microchip `xc32` all converge to + the same document for the same configuration. + +The pieces: + +| File | Role | +| --- | --- | +| `tools/scripts/wolfboot-sbom.sh` | Canonical driver (srcs + config → gen-sbom). | +| `cmake/sbom.cmake` | CMake `sbom` target. | +| `tools/scripts/ide-sbom/iar_sbom.py` | Extracts srcs + defines from an IAR `.ewp`. | +| `tools/scripts/ide-sbom/compdb_sbom.py` | Extracts srcs + defines from a `compile_commands.json`. | +| `tools/scripts/ide-sbom/zephyr_sbom.py` | Extracts the Zephyr module sources from `zephyr/CMakeLists.txt`. | +| `tools/scripts/ide-sbom/route_through_sbom.sh` | Stages a config and runs `make sbom` for IDE targets that build through the Makefile. | +| `tools/scripts/ide-sbom/validate_sbom.py` | Structural sanity check used by CI. | +| `make sbom-hal` | Standalone SBOM for the HAL of a given target. | + +## Prerequisites + +* `python3` +* A host C compiler. The default is `cc`. To use a different compiler, set + `HOSTCC=...`. +* `gen-sbom`. This tool is part of wolfSSL. The build uses the copy in the + `lib/wolfssl` submodule. The pinned wolfSSL revision does not include + `gen-sbom` yet. Until a wolfSSL update adds it, give the path to a copy. Use + `GEN_SBOM=/path/to/wolfssl/scripts/gen-sbom` for Make and route-through. Use + `-DGEN_SBOM=...` for CMake. Use `--gen-sbom ...` for the Python tools. + +```sh +git submodule update --init lib/wolfssl +``` + +## Limitations + +Obey these limitations when you make an SBOM. + +- `gen-sbom` is necessary. If the build does not find the tool, it stops and + shows an error. Give the path with `GEN_SBOM` or the equivalent option. A + wolfSSL submodule update removes this step. +- A vendor SDK build lists only the source files that are on disk. If the SDK + is not in the source tree, the SBOM does not include the SDK files. The SBOM + always includes the wolfBoot, wolfCrypt, and HAL files. +- The driver is a POSIX shell script. On Windows, run the tools in a POSIX + shell. Use WSL, MSYS, or Git Bash. As an alternative, use the compilation + database tool (`compdb_sbom.py`). + +## Coverage: the 11 build methods + +wolfBoot is built in many ways. Each maps to one of four SBOM routes: + +| # | Build method | SBOM route | +| --- | --- | --- | +| 1 | Plain Make / `arch.mk` | Make target | +| 2 | Make + MCUXpresso SDK | Make target | +| 3 | Make + STM32Cube | Make target | +| 4 | Make + PSoC6 / Freedom-E / Vorago SDKs | Make target | +| 5 | CMake (presets / dot-config) | CMake target | +| 6 | Pico SDK (RP2350) | compdb extractor | +| 7 | IAR Embedded Workbench | IAR extractor | +| 8 | TI Code Composer Studio (Hercules TMS570) | route-through Make (or compdb) | +| 9 | Microchip MPLAB X (SAME51 / PIC32) | route-through Make (or compdb) | +| 10 | Renesas e² studio (RX / RA / RZ) | route-through Make (or compdb) | +| 11 | Xilinx SDK / Vitis (Zynq / ZynqMP) | route-through Make (or compdb) | + +--- + +## Route 1 — Make (methods 1–4) + +The Makefile `sbom` target is the primary entry point. It works for the plain +`arch.mk` build and for every build that is really the Makefile with a vendor +SDK bolted on via source/include paths (MCUXpresso, STM32Cube, PSoC6, +Freedom-E-SDK, Vorago). + +```sh +make sbom TARGET= SIGN= HASH= +``` + +`TARGET`, `SIGN`, and `HASH` come from the same place as a normal build (command +line, environment, or `.config`) and must match the configuration you ship — +the source set and artifact hash are configuration-specific. + +Useful overrides: `HOSTCC`, `GEN_SBOM`, `CRA_PYTHON`. + +wolfcrypt sources are compiled directly into the wolfBoot image, so they are +listed as wolfBoot's own sources rather than as a separate component. + +## Route 2 — CMake (methods 5–6) + +The CMake build exposes an `sbom` target (`cmake/sbom.cmake`) that collects the +compiled source set from the wolfBoot library targets and the effective +configuration from `WOLFBOOT_DEFS` / `USER_SETTINGS`, then calls the shared +driver — producing a document byte-comparable with the Make path. + +```sh +cmake -S . -B build-sim -DWOLFBOOT_TARGET=sim ... # your normal configure +cmake --build build-sim --target sbom +``` + +Outputs land in the build directory. Overrides: `-DGEN_SBOM=...`, `-DHOSTCC=...`, +`-DSBOM_PYTHON=...`. + +> The driver is a POSIX shell script; on Windows run this target from WSL / MSYS +> / Git-Bash, or use the Make path. + +### Pico SDK (method 6) + +The Pico SDK build under `IDE/pico-sdk/rp2350/` is a standalone CMake project +that pulls in the Pico SDK, so it does not include `cmake/sbom.cmake`. Generate +its SBOM from the compilation database (see Route 4): + +```sh +cd IDE/pico-sdk/rp2350/wolfboot +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ... # your normal configure +python3 /tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json +``` + +## Route 3 — IAR extractor (method 7) + +IAR builds happen entirely inside Embedded Workbench and never touch the +Makefile or CMake, so the compiled source set and preprocessor configuration +live in the `.ewp` project file. The extractor reads them out and feeds the +shared driver: + +```sh +tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp +``` + +Options: `--config ` (defaults to the configuration with the most defines, +i.e. the real build config), `--gen-sbom`, `--version`, `--cdx-out`, +`--spdx-out`, and `--print-only` to inspect the extracted sources/defines +without generating. + +Sources listed in the `.ewp` that are generated at build time (e.g. +`keystore.c`) and are not on disk are reported and excluded, matching the Make +path's `$(wildcard)` behavior. + +## Route 4 — route-through & compilation database (methods 8–11) + +### Route-through Make (preferred where a `.config` exists) + +TI CCS (Hercules TMS570), Microchip MPLAB X (SAME51 / PIC32), Renesas RX, and +Xilinx Zynq / ZynqMP all have wolfBoot `config/examples/*.config` targets and +build through the Makefile on the command line. For these, the SBOM is produced +by the same `make sbom` engine; `route_through_sbom.sh` makes that explicit by +staging the config and forwarding the vendor make variables: + +```sh +# TI Hercules (CCS toolchain, built from the command line): +tools/scripts/ide-sbom/route_through_sbom.sh \ + --config config/examples/ti-tms570lc435.config \ + CCS_ROOT=/opt/ti/ccs/tools/compiler/ti-cgt-arm_20.2.7.LTS \ + F021_DIR=/opt/ti/Hercules/F021_Flash_API/02.01.01 + +# Xilinx ZynqMP: +tools/scripts/ide-sbom/route_through_sbom.sh --config config/examples/zynqmp.config + +# Renesas RX72N: +tools/scripts/ide-sbom/route_through_sbom.sh --config config/examples/renesas-rx72n.config + +# Microchip SAME51: +tools/scripts/ide-sbom/route_through_sbom.sh --config config/examples/same51.config +``` + +### Compilation-database extractor (any IDE / toolchain) + +When a target is built *strictly inside* an IDE (e.g. Renesas RA/RZ e² studio, +an MPLAB X GUI build, or a Vitis build) and you want an SBOM of exactly what the +IDE compiled, capture a Clang compilation database and use the universal +extractor. This is toolchain- and IDE-independent: + +```sh +# CMake emits it natively: +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ... + +# Make-based IDE projects (MPLAB X nbproject, CCS, Vitis) via Bear: +bear -- make # produces compile_commands.json + +python3 tools/scripts/ide-sbom/compdb_sbom.py compile_commands.json \ + --exclude 'test-app/' # optional: drop test sources +``` + +The extractor takes the exact file list and `-D` set the compiler saw, so the +SBOM reflects the real IDE build regardless of how sources and defines were +configured in the GUI. + +## Per-artifact SBOMs + +The routes above describe the wolfBoot **bootloader** image. wolfBoot also has +sub-components you may want to inventory separately. Each gets its own SBOM file +whose component is named `wolfboot-`, so nothing collides. + +### Per-HAL SBOM + +The hardware abstraction layer for a target (`hal/hal.c`, `hal/.c`, and +any target flash / UART / board drivers) is already included in the full +bootloader SBOM. To emit it as a **standalone** component — e.g. to track the +board-support portion of the supply chain on its own — use: + +```sh +make sbom-hal TARGET= SIGN= +``` + +This reuses the real build `CFLAGS`, so the captured configuration matches the +bootloader build. Output: `wolfboot-hal--.{cdx,spdx}.json`. +Run it once per target. + +### Zephyr TEE / PSA module + +The `zephyr/` directory is **not** the bootloader — it is a Zephyr module that +compiles a small TEE/PSA non-secure client shim into a Zephyr application +(`zephyr_library_sources(...)`, gated on `CONFIG_WOLFBOOT_TEE`). It is built by +Zephyr/west, so neither the Make nor the CMake SBOM target sees it. The +extractor reads the module's source list straight from `zephyr/CMakeLists.txt` +(staying in sync automatically): + +```sh +tools/scripts/ide-sbom/zephyr_sbom.py +``` + +Output: `wolfboot-zephyr-.{cdx,spdx}.json`. + +Because the module's configuration is Kconfig-driven (`CONFIG_*` symbols) rather +than a `-D` macro set, this is a **source-inventory** SBOM by default (no +build-config macros; the driver's `--source-only` mode). If you have a real +Zephyr build and want the exact compiled configuration, generate the SBOM from +that build's compilation database instead: + +```sh +west build ... -- -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +python3 tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json \ + --include 'zephyr/src/' --name wolfboot-zephyr +``` + +## Output + +Every route writes, into the working/build directory: + +* `wolfboot-.cdx.json` — CycloneDX 1.6 +* `wolfboot-.spdx.json` — SPDX 2.3 + +`` is read from `include/wolfboot/version.h`. These are ignored by +`.gitignore`. + +You can sanity-check any output: + +```sh +python3 tools/scripts/ide-sbom/validate_sbom.py wolfboot-*.cdx.json wolfboot-*.spdx.json +``` + +## Continuous integration + +`.github/workflows/test-sbom.yml` is an SBOM canary that runs the Make, CMake, +IAR, and compilation-database routes on every push/PR and validates each output, +so a change to a build system, the shared driver, or an extractor cannot +silently break SBOM generation. The generated SBOMs are uploaded as build +artifacts. + +## Reproducibility + +`gen-sbom` supports deterministic output (e.g. `SOURCE_DATE_EPOCH` and stable +UUIDs). Combined with host-compiler macro capture, the same wolfBoot +configuration yields the same SBOM regardless of the build system or +cross-toolchain used to produce the firmware. + +The driver also scrubs absolute host paths from the captured macros. For +example, `arch.mk` passes `-DPICO_SDK_PATH=$(PICO_SDK_PATH)`. Without the scrub, +the local path enters the SBOM. This makes the SBOM machine-specific and leaks +the local file system. The driver redacts the path but keeps the macro name, so +the configuration record stays complete. Use `--no-scrub` for debug only. diff --git a/tools/scripts/ide-sbom/compdb_sbom.py b/tools/scripts/ide-sbom/compdb_sbom.py new file mode 100755 index 0000000000..ea9c30c79e --- /dev/null +++ b/tools/scripts/ide-sbom/compdb_sbom.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Generate a wolfBoot SBOM from a Clang compilation database (compile_commands.json). + +This is the universal IDE/toolchain fallback. Any build that can emit a +compilation database gives us an exact, ground-truth list of the files that were +compiled and the -D configuration they were compiled with - independent of the +build system or compiler. That covers cases with no Make/CMake SBOM path: + + * CMake builds (configure with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON) + * TI Code Composer Studio (CCS / armcl, via `bear -- make ...`) + * Microchip MPLAB X (xc32, via `bear -- make ...` on the nbproject make) + * Renesas e2studio / CCRX (via a build wrapper that records commands) + * Xilinx SDK / Vitis (via `bear -- make ...`) + +The extracted sources + defines are handed to the shared driver +(tools/scripts/wolfboot-sbom.sh), so the resulting CycloneDX 1.6 / SPDX 2.3 +SBOM is identical in shape to the Make, CMake and IAR paths. + +Usage: + tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json [options] + +Options: + --include REGEX Only include source files whose absolute path matches REGEX + (repeatable). Default: all C/asm sources. + --exclude REGEX Exclude source files whose absolute path matches REGEX + (repeatable, applied after --include). Handy to drop + test-app/ or unit-test sources. + --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). + --version VER Package version (passed through). + --cdx-out / --spdx-out PATH Output paths (passed through). + --srcs-out PATH Where to write the extracted source list (default: temp). + --print-only Print the extracted sources + defines and exit. +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) +DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') + + +def entry_tokens(entry): + """Return the compiler argument tokens for a compdb entry.""" + if 'arguments' in entry and entry['arguments']: + return list(entry['arguments']) + if 'command' in entry and entry['command']: + try: + return shlex.split(entry['command']) + except ValueError: + return entry['command'].split() + return [] + + +def extract_defines(tokens): + """Return -D define tokens (normalized to bare NAME[=VAL]) from arg tokens.""" + defs = [] + i = 0 + while i < len(tokens): + t = tokens[i] + if t == '-D' and i + 1 < len(tokens): + defs.append(tokens[i + 1]) + i += 2 + continue + if t.startswith('-D'): + defs.append(t[2:]) + i += 1 + return defs + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('compdb', help='Path to compile_commands.json') + ap.add_argument('--include', action='append', default=[]) + ap.add_argument('--exclude', action='append', default=[]) + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--name', default='wolfboot') + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.compdb): + sys.exit(f"ERROR: compilation database not found: {args.compdb}") + with open(args.compdb) as f: + try: + db = json.load(f) + except json.JSONDecodeError as e: + sys.exit(f"ERROR: cannot parse {args.compdb}: {e}") + + inc = [re.compile(p) for p in args.include] + exc = [re.compile(p) for p in args.exclude] + + srcs = [] + defines = set() + seen = set() + for entry in db: + fpath = entry.get('file') + if not fpath: + continue + directory = entry.get('directory', os.getcwd()) + if not os.path.isabs(fpath): + fpath = os.path.join(directory, fpath) + fpath = os.path.normpath(fpath) + if not fpath.lower().endswith(SRC_EXTS): + continue + if inc and not any(r.search(fpath) for r in inc): + continue + if exc and any(r.search(fpath) for r in exc): + continue + tokens = entry_tokens(entry) + for d in extract_defines(tokens): + defines.add(d) + if fpath not in seen and os.path.isfile(fpath): + seen.add(fpath) + srcs.append(fpath) + + if not srcs: + sys.exit("ERROR: no matching source files found in compilation database") + + defines = sorted(defines) + cflags = ' '.join(f'-D{d}' for d in defines) + + if args.print_only: + print(f"# {len(srcs)} sources, {len(defines)} defines") + print("\n[defines]") + for d in defines: + print(f" -D{d}") + print("\n[sources]") + for s in srcs: + print(f" {s}") + return + + srcs_out = args.srcs_out + tmp = None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-compdb-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [DRIVER, '--srcs-file', srcs_out, '--cflags', cflags, + '--name', args.name, '--root', ROOT] + if args.version: + cmd += ['--version', args.version] + if args.gen_sbom: + cmd += ['--gen-sbom', args.gen_sbom] + if args.cdx_out: + cmd += ['--cdx-out', args.cdx_out] + if args.spdx_out: + cmd += ['--spdx-out', args.spdx_out] + + print(f"compdb SBOM: {len(srcs)} sources, {len(defines)} defines") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/scripts/ide-sbom/iar_sbom.py b/tools/scripts/ide-sbom/iar_sbom.py new file mode 100755 index 0000000000..e3bc775d3b --- /dev/null +++ b/tools/scripts/ide-sbom/iar_sbom.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Generate a wolfBoot SBOM from an IAR Embedded Workbench project (.ewp). + +IAR builds happen entirely inside the IDE and never touch the Makefile or CMake, +so the compiled source set and the preprocessor configuration live in the .ewp +project file rather than in OBJS / CFLAGS. This extractor reads them out of the +.ewp and feeds them to the shared wolfBoot SBOM driver +(tools/scripts/wolfboot-sbom.sh), so an IAR-built wolfBoot gets the same +CycloneDX 1.6 + SPDX 2.3 SBOM as a Make- or CMake-built one. + +It parses: + * the C compiler preprocessor defines (ICCARM -> CCDefines entries) + * the compiled source files ( ...), resolving $PROJ_DIR$ + +Usage: + tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp [options] + +Options: + --config NAME IAR build configuration to read (default: the configuration + with the most preprocessor defines, i.e. the real one). + --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). + --version VER Package version (passed through; else driver reads version.h). + --cdx-out PATH / --spdx-out PATH Output paths (passed through). + --srcs-out PATH Where to write the extracted source list + (default: a temp file). + --print-only Print the extracted sources + defines and exit (no SBOM). +""" + +import argparse +import os +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + +# Repo root: tools/scripts/ide-sbom/iar_sbom.py -> up 3. +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) +DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') + + +def resolve_proj_dir(raw, proj_dir): + """Resolve an IAR $PROJ_DIR$-relative, backslash path to an absolute path.""" + p = raw.replace('$PROJ_DIR$', proj_dir) + p = p.replace('\\', '/') + if not os.path.isabs(p): + p = os.path.join(proj_dir, p) + return os.path.normpath(p) + + +def parse_configs(root): + """Return {config_name: [define, ...]} from each configuration's ICCARM + CCDefines option.""" + configs = {} + for cfg in root.findall('configuration'): + name_el = cfg.find('name') + cfg_name = name_el.text if name_el is not None else '(unnamed)' + defines = [] + for settings in cfg.findall('settings'): + sname = settings.find('name') + if sname is None or sname.text != 'ICCARM': + continue + for data in settings.findall('data'): + for option in data.findall('option'): + oname = option.find('name') + if oname is None or oname.text != 'CCDefines': + continue + for state in option.findall('state'): + if state.text: + defines.append(state.text.strip()) + configs[cfg_name] = defines + return configs + + +def collect_sources(root, proj_dir): + """Return (present, missing) absolute paths of compiled source files. + + Files that do not exist on disk are separated out: build-time generated + files such as keystore.c are listed in the .ewp but only materialize during + a build. This mirrors the Make path, where $(wildcard) silently drops + sources that are not present. + """ + srcs = [] + for file_el in root.iter('file'): + name_el = file_el.find('name') + if name_el is None or not name_el.text: + continue + raw = name_el.text.strip() + if not raw.lower().endswith(SRC_EXTS): + continue + srcs.append(resolve_proj_dir(raw, proj_dir)) + # De-dup while preserving order, then split on existence. + seen = set() + present, missing = [], [] + for s in srcs: + if s in seen: + continue + seen.add(s) + (present if os.path.isfile(s) else missing).append(s) + return present, missing + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('ewp', help='Path to the IAR .ewp project file') + ap.add_argument('--config', help='IAR configuration name (default: richest)') + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.ewp): + sys.exit(f"ERROR: .ewp not found: {args.ewp}") + proj_dir = os.path.dirname(os.path.abspath(args.ewp)) + + try: + tree = ET.parse(args.ewp) + except ET.ParseError as e: + sys.exit(f"ERROR: cannot parse {args.ewp}: {e}") + root = tree.getroot() + + configs = parse_configs(root) + if not configs: + sys.exit("ERROR: no with ICCARM CCDefines found in .ewp") + + if args.config: + if args.config not in configs: + sys.exit(f"ERROR: config {args.config!r} not found. " + f"Available: {', '.join(configs)}") + cfg_name = args.config + else: + # Pick the configuration with the most defines (the real build config; + # a Debug config often only carries NDEBUG-style noise). + cfg_name = max(configs, key=lambda k: len(configs[k])) + + defines = configs[cfg_name] + srcs, missing = collect_sources(root, proj_dir) + if not srcs: + sys.exit("ERROR: no existing source files found in .ewp") + if missing: + sys.stderr.write( + "WARNING: %d source(s) listed in the .ewp were not found on disk " + "and are excluded (e.g. build-time generated files like " + "keystore.c):\n" % len(missing)) + for m in missing: + sys.stderr.write(" - %s\n" % m) + + cflags = ' '.join(f'-D{d}' for d in defines) + + if args.print_only: + print(f"# IAR configuration: {cfg_name}") + print(f"# {len(srcs)} sources, {len(defines)} defines") + print("\n[defines]") + for d in defines: + print(f" -D{d}") + print("\n[sources]") + for s in srcs: + print(f" {s}") + return + + srcs_out = args.srcs_out + tmp = None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-iar-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [DRIVER, '--srcs-file', srcs_out, '--cflags', cflags, + '--name', 'wolfboot', '--root', ROOT] + if args.version: + cmd += ['--version', args.version] + if args.gen_sbom: + cmd += ['--gen-sbom', args.gen_sbom] + if args.cdx_out: + cmd += ['--cdx-out', args.cdx_out] + if args.spdx_out: + cmd += ['--spdx-out', args.spdx_out] + + print(f"IAR SBOM: configuration={cfg_name} " + f"({len(srcs)} sources, {len(defines)} defines)") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/scripts/ide-sbom/route_through_sbom.sh b/tools/scripts/ide-sbom/route_through_sbom.sh new file mode 100755 index 0000000000..dced3bb962 --- /dev/null +++ b/tools/scripts/ide-sbom/route_through_sbom.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# route_through_sbom.sh - SBOM for IDE/SDK targets that build through the Makefile. +# +# Several "IDE" ecosystems supported by wolfBoot are, on the command line, just +# the normal Makefile build with a vendor toolchain and a target .config: +# +# * TI Code Composer Studio (Hercules TMS570) -> make CCS_ROOT=.. F021_DIR=.. +# * Xilinx SDK / Vitis (Zynq / ZynqMP) -> make TARGET=zynqmp .. +# * Renesas RX (e2studio) via CCRX -> make TARGET=rx72n RX_GCC..=.. +# * Microchip MPLAB X (nbproject makefiles) -> make (generated makefile) +# +# For those, the SBOM is produced by the same `make sbom` engine as any other +# Make build - this wrapper just makes that explicit and self-documenting by +# staging a config and forwarding the vendor make variables. +# +# Usage: +# route_through_sbom.sh --config config/examples/.config [MAKE_VAR=VAL ...] +# route_through_sbom.sh --no-config [MAKE_VAR=VAL ...] +# +# Everything after the flags is passed verbatim to `make sbom`, so the vendor +# variables (CCS_ROOT, F021_DIR, TARGET, ...) and SBOM overrides (GEN_SBOM, +# HOSTCC, ...) all work. +# +# Examples: +# # TI Hercules (CCS toolchain, built from the command line): +# route_through_sbom.sh --config config/examples/ti-tms570lc435.config \ +# CCS_ROOT=/opt/ti/ccs/tools/compiler/ti-cgt-arm_20.2.7.LTS \ +# F021_DIR=/opt/ti/Hercules/F021_Flash_API/02.01.01 +# +# # Xilinx ZynqMP: +# route_through_sbom.sh --config config/examples/zynqmp.config +set -e + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) + +CONFIG="" +NO_CONFIG=0 +while [ $# -gt 0 ]; do + case "$1" in + --config) CONFIG="$2"; shift 2 ;; + --no-config) NO_CONFIG=1; shift ;; + -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) break ;; + esac +done + +cd "$ROOT" + +if [ "$NO_CONFIG" -ne 1 ]; then + if [ -z "$CONFIG" ]; then + echo "ERROR: pass --config (or --no-config to use the current .config)." >&2 + exit 2 + fi + if [ ! -f "$CONFIG" ]; then + echo "ERROR: config not found: $CONFIG" >&2 + exit 1 + fi + echo "Staging $CONFIG -> .config" + cp "$CONFIG" .config +fi + +echo "Running: make sbom $*" +exec make sbom "$@" diff --git a/tools/scripts/ide-sbom/validate_sbom.py b/tools/scripts/ide-sbom/validate_sbom.py new file mode 100755 index 0000000000..a39e9ec24b --- /dev/null +++ b/tools/scripts/ide-sbom/validate_sbom.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Lightweight structural validator for wolfBoot SBOM output. + +Not a full schema validator - it asserts the essentials that every wolfBoot +SBOM route must satisfy, so CI (and humans) can fail fast on a broken generator: + + CycloneDX (*.cdx.json): + * bomFormat == "CycloneDX" + * specVersion == "1.6" + * metadata.component.name == "wolfboot" + * metadata.component has a non-empty version + * at least one component or source recorded + + SPDX (*.spdx.json): + * spdxVersion starts with "SPDX-2" + * has a name and at least one package + +The file kind is detected by content, so argument order does not matter. + +Usage: + validate_sbom.py FILE [FILE ...] +""" + +import json +import sys + +# Every wolfBoot artifact SBOM names its component in the wolfboot* family: +# wolfboot, wolfboot-hal-, wolfboot-zephyr, ... +EXPECTED_NAME_PREFIX = "wolfboot" + + +def fail(path, msg): + print(f"FAIL [{path}]: {msg}", file=sys.stderr) + sys.exit(1) + + +def validate_cyclonedx(path, d): + if d.get("bomFormat") != "CycloneDX": + fail(path, f"bomFormat != CycloneDX (got {d.get('bomFormat')!r})") + if d.get("specVersion") != "1.6": + fail(path, f"specVersion != 1.6 (got {d.get('specVersion')!r})") + comp = d.get("metadata", {}).get("component", {}) + name = comp.get("name", "") + if not name.startswith(EXPECTED_NAME_PREFIX): + fail(path, f"metadata.component.name does not start with " + f"{EXPECTED_NAME_PREFIX!r} (got {name!r})") + if not comp.get("version"): + fail(path, "metadata.component.version is empty") + # Sources are recorded as sub-components and/or properties; require some. + if not d.get("components") and not comp.get("properties"): + fail(path, "no components or component properties recorded") + print(f"OK [{path}]: CycloneDX 1.6, component " + f"{comp['name']} {comp['version']}") + + +def validate_spdx(path, d): + ver = d.get("spdxVersion", "") + if not ver.startswith("SPDX-2"): + fail(path, f"spdxVersion not SPDX-2.x (got {ver!r})") + if not d.get("name"): + fail(path, "document name is empty") + if not d.get("packages"): + fail(path, "no packages recorded") + print(f"OK [{path}]: {ver}, {len(d['packages'])} package(s)") + + +def main(argv): + if len(argv) < 2: + print(__doc__) + sys.exit(2) + for path in argv[1:]: + try: + with open(path) as f: + d = json.load(f) + except FileNotFoundError: + fail(path, "file not found") + except json.JSONDecodeError as e: + fail(path, f"invalid JSON: {e}") + if "bomFormat" in d or path.endswith(".cdx.json"): + validate_cyclonedx(path, d) + elif "spdxVersion" in d or path.endswith(".spdx.json"): + validate_spdx(path, d) + else: + fail(path, "unrecognized SBOM format (neither CycloneDX nor SPDX)") + print("All SBOMs valid.") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/tools/scripts/ide-sbom/zephyr_sbom.py b/tools/scripts/ide-sbom/zephyr_sbom.py new file mode 100755 index 0000000000..f16d7a882f --- /dev/null +++ b/tools/scripts/ide-sbom/zephyr_sbom.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Generate an SBOM for the wolfBoot Zephyr module (TEE / PSA client). + +The `zephyr/` directory is not the bootloader: it is a Zephyr module that +compiles a small TEE/PSA non-secure client shim *into a Zephyr application* +(`zephyr_library_sources(...)`, gated on CONFIG_WOLFBOOT_TEE). Its source set is +fixed and lives in the module's CMakeLists, but it is built by Zephyr/west - not +by wolfBoot's Makefile or CMakeLists - so neither the Make nor the CMake SBOM +target sees it. + +This extractor reads the module's source list straight out of +`zephyr/CMakeLists.txt` (so it stays in sync automatically) and hands it to the +shared driver as a separate component, `wolfboot-zephyr`. + +The module's configuration is Kconfig-driven (CONFIG_* symbols), not a `-D` +macro set, so by default this produces a source-inventory SBOM (no build-config +macros). If you have a real Zephyr build and want the exact compiled config, +generate the SBOM from that build's compilation database with compdb_sbom.py +instead. + +Usage: + tools/scripts/ide-sbom/zephyr_sbom.py [--cmakelists zephyr/CMakeLists.txt] [options] + +Options: + --cmakelists PATH Module CMakeLists (default: /zephyr/CMakeLists.txt). + --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). + --version VER Package version (passed through). + --cdx-out / --spdx-out PATH Output paths + (default: wolfboot-zephyr-.{cdx,spdx}.json). + --srcs-out PATH Where to write the extracted source list (default: temp). + --print-only Print the extracted sources and exit. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) +DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + + +def parse_library_sources(cmakelists): + """Extract the file list from the zephyr_library_sources(...) block.""" + with open(cmakelists) as f: + text = f.read() + module_dir = os.path.dirname(os.path.abspath(cmakelists)) + + srcs = [] + for m in re.finditer(r'zephyr_library_sources\s*\((.*?)\)', text, re.DOTALL): + for raw in m.group(1).split(): + raw = raw.strip() + if not raw or not raw.lower().endswith(SRC_EXTS): + continue + # Resolve the CMake variables Zephyr uses for the module directory. + p = raw.replace('${CMAKE_CURRENT_LIST_DIR}', module_dir) + p = p.replace('${CMAKE_CURRENT_SOURCE_DIR}', module_dir) + p = p.replace('${WOLFBOOT_MODULE_DIR}', os.path.dirname(module_dir)) + if '${' in p: + # Unknown variable - skip rather than emit a bogus path. + sys.stderr.write(f"WARNING: skipping unresolved source: {raw}\n") + continue + if not os.path.isabs(p): + p = os.path.join(module_dir, p) + srcs.append(os.path.normpath(p)) + + seen = set() + present, missing = [], [] + for s in srcs: + if s in seen: + continue + seen.add(s) + (present if os.path.isfile(s) else missing).append(s) + return present, missing + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--cmakelists', default=os.path.join(ROOT, 'zephyr', 'CMakeLists.txt')) + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.cmakelists): + sys.exit(f"ERROR: CMakeLists not found: {args.cmakelists}") + + srcs, missing = parse_library_sources(args.cmakelists) + if missing: + sys.stderr.write( + "WARNING: %d module source(s) not found on disk (excluded):\n" + % len(missing)) + for m in missing: + sys.stderr.write(" - %s\n" % m) + if not srcs: + sys.exit("ERROR: no wolfBoot Zephyr module sources found in " + + args.cmakelists) + + if args.print_only: + print(f"# wolfboot-zephyr: {len(srcs)} sources") + for s in srcs: + print(f" {s}") + return + + srcs_out = args.srcs_out + tmp = None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-zephyr-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [DRIVER, '--srcs-file', srcs_out, '--source-only', + '--name', 'wolfboot-zephyr', '--root', ROOT] + if args.version: + cmd += ['--version', args.version] + if args.gen_sbom: + cmd += ['--gen-sbom', args.gen_sbom] + if args.cdx_out: + cmd += ['--cdx-out', args.cdx_out] + if args.spdx_out: + cmd += ['--spdx-out', args.spdx_out] + + print(f"Zephyr module SBOM: {len(srcs)} sources (source-inventory)") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/scripts/wolfboot-sbom.sh b/tools/scripts/wolfboot-sbom.sh new file mode 100755 index 0000000000..2dbbed7d5f --- /dev/null +++ b/tools/scripts/wolfboot-sbom.sh @@ -0,0 +1,287 @@ +#!/bin/sh +# wolfboot-sbom.sh - canonical wolfBoot SBOM driver. +# +# One engine, reused by every wolfBoot build system (plain Make/arch.mk, the +# vendor-SDK Make wrappers, CMake, the Pico SDK, and the IDE extractors) so the +# generated CycloneDX 1.6 + SPDX 2.3 SBOM is identical no matter how wolfBoot +# was built. Each build system only has to produce two things and hand them to +# this script: +# +# 1. the list of source files actually compiled into the image (--srcs-file) +# 2. the effective build configuration, either as raw build CFLAGS (--cflags) +# or as a pre-expanded flat #define header (--options-h) +# +# The heavy lifting (config-macro normalization, hashing, schema-shaped output) +# is done by wolfSSL's product-agnostic gen-sbom. The macro capture runs the +# HOST compiler (never the cross-compiler), so the SBOM is byte-reproducible +# across toolchains: gcc, clang/LLVM, IAR, armcl, CCRX and XC32 all converge to +# the same document for the same configuration. +# +# Reproducibility: captured macros are scrubbed of absolute host paths before +# they reach gen-sbom (e.g. -DPICO_SDK_PATH=/home/you/pico-sdk from arch.mk). +# An unscrubbed path makes the SBOM machine-specific and leaks the local file +# system into a published artifact. Use --no-scrub to disable (debug only). +# +# PORTABILITY NOTE +# This script is product-neutral by design (name, version, root, gen-sbom and +# license are all arguments; nothing wolfBoot-specific is hard-coded). The same +# driver can therefore be reused by other products; only the caller passes +# different --root/--name values, and the logic is unchanged. +# +# Exit status is non-zero on any error. +set -e + +usage() { + cat >&2 <<'EOF' +Usage: wolfboot-sbom.sh --srcs-file PATH (--cflags "..." | --options-h PATH) [options] + +Required: + --srcs-file PATH File listing compiled-in sources, one path per line + (blank lines and lines starting with # are ignored). + +Configuration source (exactly one): + --cflags "..." Build CFLAGS. -D tokens are extracted and expanded through + $HOSTCC -dM -E to capture the effective wolfBoot/wolfCrypt + configuration. + --options-h PATH A pre-expanded flat #define header (e.g. output of + `$CC -dM -E`). Used verbatim; HOSTCC is not invoked. + --source-only Produce a source-inventory SBOM with no build-config + macros. Use for artifacts whose configuration is not a + `-D` set (e.g. the Zephyr module, which is Kconfig-driven). + +Options: + --name NAME Package name recorded in the SBOM (default: wolfboot). + --version VER Package version (default: read from + include/wolfboot/version.h under --root). + --license-file P LICENSE file for SPDX id detection (default: /LICENSE). + --cdx-out PATH CycloneDX output (default: -.cdx.json). + --spdx-out PATH SPDX output (default: -.spdx.json). + --gen-sbom PATH Path to wolfSSL scripts/gen-sbom + (default: /lib/wolfssl/scripts/gen-sbom). + --python BIN Python interpreter (default: python3). + --hostcc BIN Host C compiler for macro capture (default: cc). + --root PATH wolfBoot root (default: derived from this script location). + --skip-missing Drop source paths that do not exist on disk (with a + warning) instead of failing. Mirrors the Make path, where + $(wildcard) silently omits not-yet-generated files such as + keystore.c. + --no-scrub Do not redact absolute host paths from the captured macro + header. Debug only; the output is then not reproducible. + -h, --help Show this help. +EOF +} + +# Defaults. +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) +SRCS_FILE="" +CFLAGS_IN="" +OPTIONS_H="" +NAME="wolfboot" +VERSION="" +LICENSE_FILE="" +CDX_OUT="" +SPDX_OUT="" +GEN_SBOM="" +PYTHON="${CRA_PYTHON:-python3}" +HOSTCC="${HOSTCC:-cc}" +SKIP_MISSING=0 +SOURCE_ONLY=0 +SCRUB=1 + +while [ $# -gt 0 ]; do + case "$1" in + --srcs-file) SRCS_FILE="$2"; shift 2 ;; + --cflags) CFLAGS_IN="$2"; shift 2 ;; + --options-h) OPTIONS_H="$2"; shift 2 ;; + --source-only) SOURCE_ONLY=1; shift ;; + --name) NAME="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --license-file) LICENSE_FILE="$2"; shift 2 ;; + --cdx-out) CDX_OUT="$2"; shift 2 ;; + --spdx-out) SPDX_OUT="$2"; shift 2 ;; + --gen-sbom) GEN_SBOM="$2"; shift 2 ;; + --python) PYTHON="$2"; shift 2 ;; + --hostcc) HOSTCC="$2"; shift 2 ;; + --root) ROOT=$(CDPATH= cd -- "$2" && pwd); shift 2 ;; + --skip-missing) SKIP_MISSING=1; shift ;; + --no-scrub) SCRUB=0; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +# Resolve remaining defaults now that --root is known. +[ -n "$LICENSE_FILE" ] || LICENSE_FILE="$ROOT/LICENSE" +[ -n "$GEN_SBOM" ] || GEN_SBOM="$ROOT/lib/wolfssl/scripts/gen-sbom" + +if [ -z "$VERSION" ]; then + VERSION=$(sed -n \ + 's/.*LIBWOLFBOOT_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$ROOT/include/wolfboot/version.h" 2>/dev/null || true) +fi + +# Validate inputs. +if [ -z "$SRCS_FILE" ]; then + echo "ERROR: --srcs-file is required." >&2 + usage + exit 2 +fi +if [ ! -f "$SRCS_FILE" ]; then + echo "ERROR: --srcs-file '$SRCS_FILE' does not exist." >&2 + exit 1 +fi +if [ "$SOURCE_ONLY" -eq 1 ]; then + if [ -n "$CFLAGS_IN" ] || [ -n "$OPTIONS_H" ]; then + echo "ERROR: --source-only cannot be combined with --cflags/--options-h." >&2 + exit 2 + fi +else + if [ -n "$CFLAGS_IN" ] && [ -n "$OPTIONS_H" ]; then + echo "ERROR: pass only one of --cflags or --options-h." >&2 + exit 2 + fi + if [ -z "$CFLAGS_IN" ] && [ -z "$OPTIONS_H" ]; then + echo "ERROR: pass one of --cflags, --options-h, or --source-only." >&2 + exit 2 + fi +fi +if [ -z "$VERSION" ]; then + echo "ERROR: could not determine version; pass --version or check" >&2 + echo " $ROOT/include/wolfboot/version.h" >&2 + exit 1 +fi +if [ ! -f "$GEN_SBOM" ]; then + echo "ERROR: gen-sbom not found at '$GEN_SBOM'." >&2 + echo " Initialize the submodule: git submodule update --init lib/wolfssl" >&2 + echo " or pass --gen-sbom /path/to/wolfssl/scripts/gen-sbom" >&2 + exit 1 +fi + +# Default output names follow the component name so multiple artifacts +# (wolfboot, wolfboot-hal-, wolfboot-zephyr, ...) don't collide. +[ -n "$CDX_OUT" ] || CDX_OUT="$NAME-$VERSION.cdx.json" +[ -n "$SPDX_OUT" ] || SPDX_OUT="$NAME-$VERSION.spdx.json" + +# Temp files we own (cleaned up on exit). The trap captures and re-returns the +# real exit status so cleanup never masks it (a bare "[ -n x ] && rm" as the +# last statement would make a successful run exit non-zero). +_TMP_DH="" +_TMP_SF="" +_TMP_SCRUB="" +cleanup() { + _rc=$? + for _f in "$_TMP_DH" "$_TMP_SF" "$_TMP_SCRUB"; do + [ -n "$_f" ] && rm -f "$_f" + done + return $_rc +} +trap cleanup EXIT INT TERM HUP + +# Redact absolute-path tokens from a flat #define header. A macro whose value +# is (or contains) an absolute path -- e.g. `#define PICO_SDK_PATH /home/x/sdk` +# from arch.mk's -DPICO_SDK_PATH=$(PICO_SDK_PATH) -- would otherwise make the +# SBOM machine-specific and leak the local file system. The redacted marker is +# constant, so the same configuration yields the same SBOM on every host. +# $1 = input header, $2 = output header. +scrub_defines() { + awk ' + /^#define / { + name = $2 + val = "" + for (i = 3; i <= NF; i++) val = (val == "" ? $i : val " " $i) + if (val == "") { print; next } + m = split(val, toks, " ") + nv = "" + for (i = 1; i <= m; i++) { + t = toks[i] + core = t + gsub(/"/, "", core) + # Absolute path: starts with "/" followed by at least one more char. + if (core ~ /^\/[^ ]+/) t = "" + nv = (nv == "" ? t : nv " " t) + } + print "#define " name " " nv + next + } + { print } + ' "$1" > "$2" +} + +# Optionally drop sources that do not exist on disk, matching the Make path's +# $(wildcard) behavior (e.g. keystore.c before it has been generated). +if [ "$SKIP_MISSING" -eq 1 ]; then + _TMP_SF=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-present.XXXXXX") + _dropped=0 + while IFS= read -r _line || [ -n "$_line" ]; do + case "$_line" in + ''|\#*) continue ;; + esac + if [ -f "$_line" ]; then + printf '%s\n' "$_line" >>"$_TMP_SF" + else + echo "WARNING: skipping missing source: $_line" >&2 + _dropped=$((_dropped + 1)) + fi + done < "$SRCS_FILE" + if [ ! -s "$_TMP_SF" ]; then + echo "ERROR: no existing source files remain after --skip-missing." >&2 + exit 1 + fi + [ "$_dropped" -gt 0 ] && echo " (--skip-missing dropped $_dropped file(s))" >&2 + SRCS_FILE="$_TMP_SF" +fi + +if [ "$SOURCE_ONLY" -eq 1 ]; then + # No build-config macros: hand gen-sbom an empty define header. + _TMP_DH=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-defines.XXXXXX") + DEFINES_H="$_TMP_DH" +elif [ -n "$OPTIONS_H" ]; then + if [ ! -f "$OPTIONS_H" ]; then + echo "ERROR: --options-h '$OPTIONS_H' does not exist." >&2 + exit 1 + fi + DEFINES_H="$OPTIONS_H" +else + # Extract -D tokens from CFLAGS and expand through the HOST compiler so the + # captured macro set reflects the effective configuration, independent of + # the (possibly cross) target toolchain. + _defs="" + for _t in $CFLAGS_IN; do + case "$_t" in + -D*) _defs="$_defs $_t" ;; + esac + done + _TMP_DH=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-defines.XXXXXX") + # shellcheck disable=SC2086 + if ! $HOSTCC -dM -E -DWOLFSSL_USER_SETTINGS $_defs -x c /dev/null >"$_TMP_DH" 2>/dev/null; then + echo "ERROR: '$HOSTCC -dM -E' failed; install a host C compiler or set HOSTCC." >&2 + exit 1 + fi + DEFINES_H="$_TMP_DH" +fi + +# Scrub absolute host paths out of the captured macros (unless disabled) so the +# SBOM is reproducible and does not leak the local file system. Applies to both +# the CFLAGS-derived header and a caller-supplied --options-h. +if [ "$SCRUB" -eq 1 ] && [ "$SOURCE_ONLY" -ne 1 ]; then + _TMP_SCRUB=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-scrub.XXXXXX") + scrub_defines "$DEFINES_H" "$_TMP_SCRUB" + DEFINES_H="$_TMP_SCRUB" +fi + +echo "wolfBoot SBOM: name=$NAME version=$VERSION" +echo " sources: $SRCS_FILE" +echo " outputs: $CDX_OUT $SPDX_OUT" + +"$PYTHON" "$GEN_SBOM" \ + --name "$NAME" \ + --version "$VERSION" \ + --supplier "wolfSSL Inc." \ + --license-file "$LICENSE_FILE" \ + --options-h "$DEFINES_H" \ + --srcs-file "$SRCS_FILE" \ + --cdx-out "$CDX_OUT" \ + --spdx-out "$SPDX_OUT" + +echo "SBOM written: $CDX_OUT $SPDX_OUT" From df63948304118fe27d211914d42a8f82727027a8 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 23 Jul 2026 12:05:56 +0300 Subject: [PATCH 2/3] sbom: expand CI to prove toolchain neutrality and cross-target coverage 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 --- .github/workflows/test-sbom.yml | 161 +++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml index 378ff6e2b9..7c179ce480 100644 --- a/.github/workflows/test-sbom.yml +++ b/.github/workflows/test-sbom.yml @@ -13,6 +13,24 @@ name: Wolfboot SBOM Canary # # Each route must emit a schema-valid CycloneDX 1.6 + SPDX 2.3 document whose # top-level component is wolfboot. +# +# The sbom_canary job also guards three properties of the SBOM itself: +# +# * Toolchain neutrality - gcc and clang must give a byte-identical SBOM. +# The driver captures configuration with the host compiler and the source +# list, so the cross-toolchain that builds the firmware does not change the +# SBOM. No per-compiler front end is needed for clang, LLVM or a vendor +# compiler. +# * Reproducibility - the same configuration built from a different absolute +# path gives a byte-identical SBOM. +# * Path scrub - an absolute host path in a -D macro (the synthetic check and +# a real rp2350 build with PICO_SDK_PATH) must not reach the SBOM. +# +# The cross_targets job proves the same source route works for many embedded +# targets with no IDE and no cross-toolchain installed. It runs make sbom for a +# spread of architectures (Arm-M, Arm-A, PowerPC, Renesas RX and RISC-V). The +# driver never calls the cross compiler, so the SBOM is produced for a target +# whose toolchain is absent. on: push: @@ -36,7 +54,7 @@ jobs: - name: Install tooling run: | sudo apt-get update - sudo apt-get install -y cmake python3 build-essential + sudo apt-get install -y cmake python3 build-essential clang # gen-sbom ships with wolfSSL. Use the vendored submodule copy if the # pinned revision already carries it, otherwise fetch it from wolfSSL @@ -128,6 +146,78 @@ jobs: python3 tools/scripts/ide-sbom/validate_sbom.py \ zephyr.cdx.json zephyr.spdx.json + # Toolchain neutrality: the cross-toolchain that builds the firmware must + # not change the SBOM. Capture the same config with gcc and with clang and + # require a byte-identical result. SOURCE_DATE_EPOCH is fixed so the two + # runs are comparable. + - name: Toolchain neutrality (gcc vs clang) + run: | + export SOURCE_DATE_EPOCH=1700000000 + cp config/examples/sim.config .config + rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json + make sbom TARGET=sim HOSTCC=gcc \ + GEN_SBOM="${{ steps.gensbom.outputs.path }}" + mv wolfboot-*.cdx.json /tmp/gcc.cdx.json + mv wolfboot-*.spdx.json /tmp/gcc.spdx.json + make sbom TARGET=sim HOSTCC=clang \ + GEN_SBOM="${{ steps.gensbom.outputs.path }}" + mv wolfboot-*.cdx.json /tmp/clang.cdx.json + mv wolfboot-*.spdx.json /tmp/clang.spdx.json + if ! diff -u /tmp/gcc.cdx.json /tmp/clang.cdx.json \ + || ! diff -u /tmp/gcc.spdx.json /tmp/clang.spdx.json; then + echo "ERROR: gcc and clang produced different SBOMs." >&2 + echo "The SBOM must not depend on the toolchain." >&2 + exit 1 + fi + echo "neutrality OK: gcc and clang SBOMs are byte-identical" + + # Reproducibility: the same configuration built from a different absolute + # path must give a byte-identical SBOM. This catches any absolute build + # path that leaks into the source list or the config record. + - name: Reproducibility (path independence) + run: | + export SOURCE_DATE_EPOCH=1700000000 + cp config/examples/sim.config .config + rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json + make sbom TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" + cp wolfboot-*.cdx.json /tmp/repro-a.cdx.json + rm -rf /tmp/wb-copy && mkdir -p /tmp/wb-copy + cp -a . /tmp/wb-copy/ + git config --global --add safe.directory /tmp/wb-copy + ( cd /tmp/wb-copy \ + && export SOURCE_DATE_EPOCH=1700000000 \ + && rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json \ + && make sbom TARGET=sim \ + GEN_SBOM="${{ steps.gensbom.outputs.path }}" ) + cp /tmp/wb-copy/wolfboot-*.cdx.json /tmp/repro-b.cdx.json + if ! diff -u /tmp/repro-a.cdx.json /tmp/repro-b.cdx.json; then + echo "ERROR: SBOM is not reproducible across build paths." >&2 + exit 1 + fi + echo "reproducibility OK: identical SBOM from two paths" + + # Path scrub in a real build: rp2350 injects -DPICO_SDK_PATH= + # through arch.mk. The absolute path must be redacted while the macro key + # is kept. This exercises the scrub on a genuine build, not a synthetic + # command line. + - name: Path scrub in a real build (rp2350 / PICO_SDK_PATH) + run: | + cp config/examples/rp2350.config .config + rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json + PICO_SDK_PATH=/home/ci-secret/pico-sdk \ + make sbom TARGET=rp2350 \ + GEN_SBOM="${{ steps.gensbom.outputs.path }}" + if grep -q 'ci-secret' wolfboot-*.cdx.json wolfboot-*.spdx.json; then + echo "ERROR: absolute PICO_SDK_PATH leaked in a real rp2350 build." >&2 + exit 1 + fi + grep -q 'PICO_SDK_PATH' wolfboot-*.cdx.json || { + echo "ERROR: PICO_SDK_PATH macro was dropped (should be redacted)." >&2 + exit 1; } + python3 tools/scripts/ide-sbom/validate_sbom.py \ + wolfboot-*.cdx.json wolfboot-*.spdx.json + echo "rp2350 scrub OK: path redacted, macro key preserved" + - name: Upload SBOM artifacts if: always() uses: actions/upload-artifact@v4 @@ -145,3 +235,72 @@ jobs: zephyr.cdx.json zephyr.spdx.json if-no-files-found: warn + + # Proves the source route (make sbom) works for a spread of embedded targets + # with no IDE and no cross-toolchain installed. The driver never calls the + # cross compiler, so every target below produces a valid SBOM on a plain + # runner. This is the "any target, any toolchain, no hardware" guarantee. + cross_targets: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # A spread of architectures whose cross-toolchains are NOT installed: + # stm32h7 Arm Cortex-M7 nrf52840 Arm Cortex-M4 + # imx-rt1060 Arm Cortex-M7 sama5d3 Arm Cortex-A5 + # nxp-t1040 PowerPC e5500 renesas-rx65n Renesas RX + # hifive1 RISC-V + target: + - stm32h7 + - nrf52840 + - imx-rt1060 + - sama5d3 + - nxp-t1040 + - renesas-rx65n + - hifive1 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Trust workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install tooling + run: | + sudo apt-get update + sudo apt-get install -y python3 + + - name: Locate gen-sbom + id: gensbom + run: | + if [ -f lib/wolfssl/scripts/gen-sbom ]; then + echo "path=$GITHUB_WORKSPACE/lib/wolfssl/scripts/gen-sbom" >> "$GITHUB_OUTPUT" + else + mkdir -p .sbom-tools + curl -fsSL \ + https://raw.githubusercontent.com/wolfSSL/wolfssl/master/scripts/gen-sbom \ + -o .sbom-tools/gen-sbom + chmod +x .sbom-tools/gen-sbom + echo "path=$GITHUB_WORKSPACE/.sbom-tools/gen-sbom" >> "$GITHUB_OUTPUT" + fi + + - name: make sbom (no cross-toolchain present) + run: | + cp config/examples/${{ matrix.target }}.config .config + make sbom TARGET=${{ matrix.target }} \ + GEN_SBOM="${{ steps.gensbom.outputs.path }}" + python3 tools/scripts/ide-sbom/validate_sbom.py \ + wolfboot-*.cdx.json wolfboot-*.spdx.json + + - name: Upload SBOM artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: wolfboot-sbom-${{ matrix.target }} + path: | + wolfboot-*.cdx.json + wolfboot-*.spdx.json + if-no-files-found: warn From b99d97648143480aba9d70a7ef456075051c767f Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 23 Jul 2026 16:12:26 +0300 Subject: [PATCH 3/3] Vendor wolfGlass SBOM tooling and switch wolfBoot to it Signed-off-by: Sameeh Jubran --- .github/workflows/test-sbom.yml | 180 ++- .gitignore | 9 + Makefile | 74 +- cmake/sbom.cmake | 134 +- docs/SBOM.md | 90 +- lib/wolfssl | 2 +- tools/sbom/.wolfglass-rev | 1 + tools/sbom/README.md | 70 + tools/sbom/VERSION | 1 + tools/sbom/build/sbom.cmake | 164 ++ tools/sbom/build/sbom.mk | 88 ++ tools/sbom/frontends/compdb_sbom.py | 167 +++ tools/sbom/frontends/iar_sbom.py | 183 +++ tools/sbom/frontends/zephyr_sbom.py | 136 ++ tools/sbom/gen-sbom | 1410 ++++++++++++++++++ tools/sbom/sbom-driver | 24 + tools/sbom/sbom-driver.py | 324 ++++ tools/sbom/sbom.am | 220 +++ tools/sbom/validate_sbom.py | 91 ++ tools/scripts/ide-sbom/compdb_sbom.py | 179 +-- tools/scripts/ide-sbom/iar_sbom.py | 201 +-- tools/scripts/ide-sbom/route_through_sbom.sh | 4 +- tools/scripts/ide-sbom/validate_sbom.py | 92 +- tools/scripts/ide-sbom/zephyr_sbom.py | 150 +- tools/scripts/wolfboot-sbom.sh | 288 +--- 25 files changed, 3162 insertions(+), 1120 deletions(-) create mode 100644 tools/sbom/.wolfglass-rev create mode 100644 tools/sbom/README.md create mode 100644 tools/sbom/VERSION create mode 100644 tools/sbom/build/sbom.cmake create mode 100644 tools/sbom/build/sbom.mk create mode 100755 tools/sbom/frontends/compdb_sbom.py create mode 100755 tools/sbom/frontends/iar_sbom.py create mode 100755 tools/sbom/frontends/zephyr_sbom.py create mode 100755 tools/sbom/gen-sbom create mode 100755 tools/sbom/sbom-driver create mode 100755 tools/sbom/sbom-driver.py create mode 100644 tools/sbom/sbom.am create mode 100755 tools/sbom/validate_sbom.py diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml index 7c179ce480..62e2b4a048 100644 --- a/.github/workflows/test-sbom.yml +++ b/.github/workflows/test-sbom.yml @@ -1,15 +1,15 @@ name: Wolfboot SBOM Canary # Exercises every wolfBoot SBOM route so a change to the build system, the -# shared driver (tools/scripts/wolfboot-sbom.sh) or an IDE extractor cannot +# vendored wolfGlass tooling under tools/sbom/, or an IDE extractor cannot # silently break SBOM generation: # # * Make path (methods 1-4) -> make sbom TARGET=sim # * CMake path (method 5) -> cmake --build --target sbom -# * IAR extractor (method 7) -> tools/scripts/ide-sbom/iar_sbom.py -# * compdb extractor (6, 8-11) -> tools/scripts/ide-sbom/compdb_sbom.py +# * IAR extractor (method 7) -> tools/sbom/frontends/iar_sbom.py +# * compdb extractor (6, 8-11) -> tools/sbom/frontends/compdb_sbom.py # * per-HAL SBOM -> make sbom-hal TARGET=sim -# * Zephyr module SBOM -> tools/scripts/ide-sbom/zephyr_sbom.py +# * Zephyr module SBOM -> tools/sbom/frontends/zephyr_sbom.py # # Each route must emit a schema-valid CycloneDX 1.6 + SPDX 2.3 document whose # top-level component is wolfboot. @@ -56,33 +56,18 @@ jobs: sudo apt-get update sudo apt-get install -y cmake python3 build-essential clang - # gen-sbom ships with wolfSSL. Use the vendored submodule copy if the - # pinned revision already carries it, otherwise fetch it from wolfSSL - # master so the canary still runs while the submodule bump lands. - - name: Locate gen-sbom - id: gensbom - run: | - if [ -f lib/wolfssl/scripts/gen-sbom ]; then - echo "path=$GITHUB_WORKSPACE/lib/wolfssl/scripts/gen-sbom" >> "$GITHUB_OUTPUT" - else - mkdir -p .sbom-tools - curl -fsSL \ - https://raw.githubusercontent.com/wolfSSL/wolfssl/master/scripts/gen-sbom \ - -o .sbom-tools/gen-sbom - chmod +x .sbom-tools/gen-sbom - echo "path=$GITHUB_WORKSPACE/.sbom-tools/gen-sbom" >> "$GITHUB_OUTPUT" - fi + - name: Verify vendored gen-sbom + run: test -f tools/sbom/gen-sbom # Reproducibility guard: a -D macro carrying an absolute host path (e.g. # arch.mk's -DPICO_SDK_PATH=$(PICO_SDK_PATH)) must never reach the SBOM. - name: Path scrub check run: | printf 'src/image.c\n' > /tmp/scrub-srcs.txt - sh tools/scripts/wolfboot-sbom.sh \ + tools/sbom/sbom-driver \ --srcs-file /tmp/scrub-srcs.txt \ --cflags "-DWOLFBOOT_HASH_SHA256 -DPICO_SDK_PATH=/home/ci-secret/pico-sdk" \ --name wolfboot --version 0.0.0-scrubtest \ - --gen-sbom "${{ steps.gensbom.outputs.path }}" \ --cdx-out /tmp/scrub.cdx.json --spdx-out /tmp/scrub.spdx.json if grep -q 'ci-secret' /tmp/scrub.cdx.json /tmp/scrub.spdx.json; then echo "ERROR: absolute host path leaked into the SBOM (scrub failed)." >&2 @@ -96,12 +81,14 @@ jobs: - name: Make path - make sbom (sim) run: | cp config/examples/sim.config .config - make sbom TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" - python3 tools/scripts/ide-sbom/validate_sbom.py \ + make sbom TARGET=sim + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ wolfboot-*.cdx.json wolfboot-*.spdx.json - name: CMake path - cmake --target sbom (sim) run: | + export SOURCE_DATE_EPOCH=1700000000 rm -rf build-sim cmake -S . -B build-sim -G "Unix Makefiles" \ -DWOLFBOOT_TARGET=sim -DARCH=HOST -DSIGN=ED25519 -DHASH=SHA256 \ @@ -109,43 +96,81 @@ jobs: -DWOLFBOOT_PARTITION_BOOT_ADDRESS=0x08003000 \ -DWOLFBOOT_PARTITION_UPDATE_ADDRESS=0x08009400 \ -DWOLFBOOT_PARTITION_SWAP_ADDRESS=0x0800F800 \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DGEN_SBOM="${{ steps.gensbom.outputs.path }}" + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON cmake --build build-sim --target sbom - python3 tools/scripts/ide-sbom/validate_sbom.py \ + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ build-sim/wolfboot-*.cdx.json build-sim/wolfboot-*.spdx.json - name: IAR extractor (method 7) run: | - python3 tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp \ - --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + python3 tools/sbom/frontends/iar_sbom.py IDE/IAR/wolfboot.ewp \ + --name wolfboot \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING \ --cdx-out iar.cdx.json --spdx-out iar.spdx.json - python3 tools/scripts/ide-sbom/validate_sbom.py iar.cdx.json iar.spdx.json + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + iar.cdx.json iar.spdx.json - name: compdb extractor (methods 6, 8-11) run: | - python3 tools/scripts/ide-sbom/compdb_sbom.py \ + python3 tools/sbom/frontends/compdb_sbom.py \ build-sim/compile_commands.json \ - --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + --name wolfboot \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING \ --cdx-out compdb.cdx.json --spdx-out compdb.spdx.json - python3 tools/scripts/ide-sbom/validate_sbom.py \ + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ compdb.cdx.json compdb.spdx.json - name: Per-HAL SBOM (make sbom-hal) run: | cp config/examples/sim.config .config - make sbom-hal TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" - python3 tools/scripts/ide-sbom/validate_sbom.py \ + make sbom-hal TARGET=sim + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot-hal- \ wolfboot-hal-sim-*.cdx.json wolfboot-hal-sim-*.spdx.json - name: Zephyr module SBOM run: | - python3 tools/scripts/ide-sbom/zephyr_sbom.py \ - --gen-sbom "${{ steps.gensbom.outputs.path }}" \ + python3 tools/sbom/frontends/zephyr_sbom.py \ + --driver tools/sbom/sbom-driver \ + --name wolfboot-zephyr \ + --cmakelists zephyr/CMakeLists.txt \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING \ --cdx-out zephyr.cdx.json --spdx-out zephyr.spdx.json - python3 tools/scripts/ide-sbom/validate_sbom.py \ + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot-zephyr \ zephyr.cdx.json zephyr.spdx.json + - name: Make vs CMake equivalence (sim, advisory) + continue-on-error: true + run: | + export SOURCE_DATE_EPOCH=1700000000 + cp config/examples/sim.config .config + rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json + make sbom TARGET=sim + cp wolfboot-*.cdx.json /tmp/make.cdx.json + cp wolfboot-*.spdx.json /tmp/make.spdx.json + rm -rf build-sim-compare + cmake -S . -B build-sim-compare -G "Unix Makefiles" \ + -DWOLFBOOT_TARGET=sim -DARCH=HOST -DSIGN=ED25519 -DHASH=SHA256 \ + -DWOLFBOOT_SECTOR_SIZE=256 -DWOLFBOOT_PARTITION_SIZE=0x6400 \ + -DWOLFBOOT_PARTITION_BOOT_ADDRESS=0x08003000 \ + -DWOLFBOOT_PARTITION_UPDATE_ADDRESS=0x08009400 \ + -DWOLFBOOT_PARTITION_SWAP_ADDRESS=0x0800F800 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + cmake --build build-sim-compare --target sbom + cp build-sim-compare/wolfboot-*.cdx.json /tmp/cmake.cdx.json + cp build-sim-compare/wolfboot-*.spdx.json /tmp/cmake.spdx.json + diff -u /tmp/make.cdx.json /tmp/cmake.cdx.json + diff -u /tmp/make.spdx.json /tmp/cmake.spdx.json + # Toolchain neutrality: the cross-toolchain that builds the firmware must # not change the SBOM. Capture the same config with gcc and with clang and # require a byte-identical result. SOURCE_DATE_EPOCH is fixed so the two @@ -155,12 +180,10 @@ jobs: export SOURCE_DATE_EPOCH=1700000000 cp config/examples/sim.config .config rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json - make sbom TARGET=sim HOSTCC=gcc \ - GEN_SBOM="${{ steps.gensbom.outputs.path }}" + make sbom TARGET=sim HOSTCC=gcc mv wolfboot-*.cdx.json /tmp/gcc.cdx.json mv wolfboot-*.spdx.json /tmp/gcc.spdx.json - make sbom TARGET=sim HOSTCC=clang \ - GEN_SBOM="${{ steps.gensbom.outputs.path }}" + make sbom TARGET=sim HOSTCC=clang mv wolfboot-*.cdx.json /tmp/clang.cdx.json mv wolfboot-*.spdx.json /tmp/clang.spdx.json if ! diff -u /tmp/gcc.cdx.json /tmp/clang.cdx.json \ @@ -179,7 +202,7 @@ jobs: export SOURCE_DATE_EPOCH=1700000000 cp config/examples/sim.config .config rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json - make sbom TARGET=sim GEN_SBOM="${{ steps.gensbom.outputs.path }}" + make sbom TARGET=sim cp wolfboot-*.cdx.json /tmp/repro-a.cdx.json rm -rf /tmp/wb-copy && mkdir -p /tmp/wb-copy cp -a . /tmp/wb-copy/ @@ -187,8 +210,7 @@ jobs: ( cd /tmp/wb-copy \ && export SOURCE_DATE_EPOCH=1700000000 \ && rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json \ - && make sbom TARGET=sim \ - GEN_SBOM="${{ steps.gensbom.outputs.path }}" ) + && make sbom TARGET=sim ) cp /tmp/wb-copy/wolfboot-*.cdx.json /tmp/repro-b.cdx.json if ! diff -u /tmp/repro-a.cdx.json /tmp/repro-b.cdx.json; then echo "ERROR: SBOM is not reproducible across build paths." >&2 @@ -205,8 +227,7 @@ jobs: cp config/examples/rp2350.config .config rm -f wolfboot-*.cdx.json wolfboot-*.spdx.json PICO_SDK_PATH=/home/ci-secret/pico-sdk \ - make sbom TARGET=rp2350 \ - GEN_SBOM="${{ steps.gensbom.outputs.path }}" + make sbom TARGET=rp2350 if grep -q 'ci-secret' wolfboot-*.cdx.json wolfboot-*.spdx.json; then echo "ERROR: absolute PICO_SDK_PATH leaked in a real rp2350 build." >&2 exit 1 @@ -214,7 +235,8 @@ jobs: grep -q 'PICO_SDK_PATH' wolfboot-*.cdx.json || { echo "ERROR: PICO_SDK_PATH macro was dropped (should be redacted)." >&2 exit 1; } - python3 tools/scripts/ide-sbom/validate_sbom.py \ + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ wolfboot-*.cdx.json wolfboot-*.spdx.json echo "rp2350 scrub OK: path redacted, macro key preserved" @@ -273,26 +295,15 @@ jobs: sudo apt-get update sudo apt-get install -y python3 - - name: Locate gen-sbom - id: gensbom - run: | - if [ -f lib/wolfssl/scripts/gen-sbom ]; then - echo "path=$GITHUB_WORKSPACE/lib/wolfssl/scripts/gen-sbom" >> "$GITHUB_OUTPUT" - else - mkdir -p .sbom-tools - curl -fsSL \ - https://raw.githubusercontent.com/wolfSSL/wolfssl/master/scripts/gen-sbom \ - -o .sbom-tools/gen-sbom - chmod +x .sbom-tools/gen-sbom - echo "path=$GITHUB_WORKSPACE/.sbom-tools/gen-sbom" >> "$GITHUB_OUTPUT" - fi + - name: Verify vendored gen-sbom + run: test -f tools/sbom/gen-sbom - name: make sbom (no cross-toolchain present) run: | cp config/examples/${{ matrix.target }}.config .config - make sbom TARGET=${{ matrix.target }} \ - GEN_SBOM="${{ steps.gensbom.outputs.path }}" - python3 tools/scripts/ide-sbom/validate_sbom.py \ + make sbom TARGET=${{ matrix.target }} + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ wolfboot-*.cdx.json wolfboot-*.spdx.json - name: Upload SBOM artifact @@ -304,3 +315,44 @@ jobs: wolfboot-*.cdx.json wolfboot-*.spdx.json if-no-files-found: warn + + windows_sbom: + runs-on: windows-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Native driver path scrub + shell: pwsh + run: | + Set-Content -Path scrub-srcs.txt -Value "src/image.c" + python tools/sbom/sbom-driver.py ` + --srcs-file scrub-srcs.txt ` + --cflags "-DWOLFBOOT_HASH_SHA256 -DSDK_PATH=C:\Users\ci-secret\sdk -DSDK_SHARE=\\server\share\sdk" ` + --name wolfboot ` + --version-file include/wolfboot/version.h ` + --version-macro LIBWOLFBOOT_VERSION_STRING ` + --cdx-out wolfboot-win.cdx.json ` + --spdx-out wolfboot-win.spdx.json + if (Select-String -Path wolfboot-win.cdx.json,wolfboot-win.spdx.json -Pattern 'ci-secret|server\\share' -Quiet) { + throw "Windows absolute path leaked into the SBOM." + } + python tools/sbom/validate_sbom.py --name-prefix wolfboot wolfboot-win.cdx.json wolfboot-win.spdx.json + + - name: Upload Windows SBOM artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: wolfboot-sbom-windows + path: | + wolfboot-win.cdx.json + wolfboot-win.spdx.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index ec98e3d355..28c1e0a9de 100644 --- a/.gitignore +++ b/.gitignore @@ -389,6 +389,11 @@ language.settings.xml /**/build /**/build-** +# Exception: vendored wolfGlass SBOM integration sources. +# This directory contains tracked Make/CMake fragments, not generated output. +!/tools/sbom/build/ +!/tools/sbom/build/** + # User config # See cmake/config_defaults_user.cmake.sample /**/cmake/config_defaults_user.cmake @@ -427,3 +432,7 @@ wolfboot-*.cdx.json wolfboot-*.spdx.json wolfboot-*.spdx wolfboot-sbom-srcs.txt + +# Python cache files +__pycache__/ +*.py[cod] diff --git a/Makefile b/Makefile index fc81e7c145..88304bc957 100644 --- a/Makefile +++ b/Makefile @@ -805,83 +805,47 @@ pico-sdk-info: FORCE # targets that also have an arch.mk path (TI Hercules, Renesas RX, Zynq). It # extracts the configuration-specific source list from OBJS (fully assembled by # this point: core wolfBoot + wolfcrypt + HAL) and passes it, together with the -# build CFLAGS, to the shared SBOM driver (tools/scripts/wolfboot-sbom.sh) which -# is the single engine reused by the CMake and IDE entry points too. +# build CFLAGS, to the vendored wolfGlass driver under tools/sbom/. # # wolfcrypt sources are compiled directly into the wolfBoot image and are # therefore listed as wolfBoot's own sources, not as a separate component. # # Optional make variables: # HOSTCC Host C compiler for macro capture (default: cc) -# GEN_SBOM Path to wolfssl scripts/gen-sbom -# (default: $(WOLFBOOT_LIB_WOLFSSL)/scripts/gen-sbom) +# SBOM_GEN Path to gen-sbom +# (default: tools/sbom/gen-sbom via driver discovery) # CRA_PYTHON Python interpreter (default: python3) HOSTCC?=cc WOLFBOOT_VERSION:=$(shell sed -n \ 's/.*LIBWOLFBOOT_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ include/wolfboot/version.h) -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))) +SBOM_CFLAGS=$(CFLAGS) +SBOM_VERSION=$(WOLFBOOT_VERSION) +SBOM_LICENSE_FILE=$(WOLFBOOT_ROOT)/LICENSE SBOM_CDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).cdx.json SBOM_SPDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).spdx.json -SBOM_PYTHON?=$(or $(CRA_PYTHON),python3) -SBOM_DRIVER:=$(WOLFBOOT_ROOT)/tools/scripts/wolfboot-sbom.sh - -sbom: - @echo "wolfBoot SBOM: version=$(WOLFBOOT_VERSION) target=$(TARGET) sign=$(SIGN)" - $(eval _SBOM_SRCS := $(wildcard $(patsubst %.o,%.c,$(OBJS))) $(wildcard $(patsubst %.o,%.S,$(OBJS)))) - @if [ -z "$(_SBOM_SRCS)" ]; then \ - echo "ERROR: no source files found in OBJS — check that TARGET and SIGN are correct." >&2; \ - exit 1; \ - fi - @set -e; \ - _sf=$$(mktemp /tmp/wolfboot-sbom-srcs.XXXXXX); \ - trap 'rm -f "$$_sf"' EXIT; \ - printf '%s\n' $(_SBOM_SRCS) >"$$_sf"; \ - "$(SBOM_DRIVER)" \ - --srcs-file "$$_sf" \ - --cflags "$(CFLAGS)" \ - --name wolfboot \ - --version "$(WOLFBOOT_VERSION)" \ - --license-file "$(WOLFBOOT_ROOT)/LICENSE" \ - --gen-sbom "$(GEN_SBOM)" \ - --python "$(SBOM_PYTHON)" \ - --hostcc "$(HOSTCC)" \ - --root "$(WOLFBOOT_ROOT)" \ - --cdx-out "$(SBOM_CDX_OUT)" \ - --spdx-out "$(SBOM_SPDX_OUT)" +SBOM_GEN?= + +include tools/sbom/build/sbom.mk ## Per-HAL SBOM # Emits a standalone SBOM whose component is the HAL layer for the selected # TARGET (hal/hal.c, hal/$(TARGET).c, and any target flash/uart/board drivers), # separate from the full bootloader SBOM. Uses the same build config (CFLAGS) # so the captured macros match the real build. Run once per TARGET. +SBOM_HAL_NAME:=wolfboot-hal-$(TARGET) +SBOM_HAL_SRCS=$(filter hal/%,$(patsubst ./%,%,$(wildcard $(patsubst %.o,%.c,$(OBJS)) $(patsubst %.o,%.S,$(OBJS))))) +SBOM_HAL_CFLAGS:=$(CFLAGS) +SBOM_HAL_VERSION:=$(WOLFBOOT_VERSION) +SBOM_HAL_LICENSE_FILE:=$(WOLFBOOT_ROOT)/LICENSE SBOM_HAL_CDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).cdx.json SBOM_HAL_SPDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).spdx.json - -sbom-hal: - @echo "wolfBoot HAL SBOM: version=$(WOLFBOOT_VERSION) target=$(TARGET)" - $(eval _HAL_SRCS := $(filter hal/%,$(patsubst ./%,%,$(wildcard $(patsubst %.o,%.c,$(OBJS)) $(patsubst %.o,%.S,$(OBJS)))))) - @if [ -z "$(_HAL_SRCS)" ]; then \ - echo "ERROR: no HAL sources found in OBJS for TARGET=$(TARGET)." >&2; \ - exit 1; \ - fi - @set -e; \ - _sf=$$(mktemp /tmp/wolfboot-hal-sbom-srcs.XXXXXX); \ - trap 'rm -f "$$_sf"' EXIT; \ - printf '%s\n' $(_HAL_SRCS) >"$$_sf"; \ - "$(SBOM_DRIVER)" \ - --srcs-file "$$_sf" \ - --cflags "$(CFLAGS)" \ - --name "wolfboot-hal-$(TARGET)" \ - --version "$(WOLFBOOT_VERSION)" \ - --license-file "$(WOLFBOOT_ROOT)/LICENSE" \ - --gen-sbom "$(GEN_SBOM)" \ - --python "$(SBOM_PYTHON)" \ - --hostcc "$(HOSTCC)" \ - --root "$(WOLFBOOT_ROOT)" \ - --cdx-out "$(SBOM_HAL_CDX_OUT)" \ - --spdx-out "$(SBOM_HAL_SPDX_OUT)" +SBOM_HAL_GEN:=$(SBOM_GEN) +$(eval $(call wolfglass_sbom_rule,sbom-hal,SBOM_HAL_)) FORCE: diff --git a/cmake/sbom.cmake b/cmake/sbom.cmake index a309edd972..df4b006f32 100644 --- a/cmake/sbom.cmake +++ b/cmake/sbom.cmake @@ -1,57 +1,19 @@ -# cmake/sbom.cmake - CMake entry point for wolfBoot SBOM generation. -# -# This is the CMake counterpart of the Makefile `sbom` target. It reuses the -# same engine (tools/scripts/wolfboot-sbom.sh -> wolfSSL gen-sbom) so a CMake -# build and a Make build of the same configuration produce byte-comparable -# CycloneDX 1.6 and SPDX 2.3 SBOMs. It covers the CMake presets / dot-config -# builds and the Pico SDK build (which is CMake underneath). -# -# Usage: -# cmake --build --target sbom -# -# Overrides: -# -DGEN_SBOM=/path/to/wolfssl/scripts/gen-sbom (default: lib/wolfssl copy) -# -DHOSTCC=cc host cc for macro capture -# -DSBOM_PYTHON=python3 -# -# NOTE: the driver is a POSIX shell script; on Windows run this target from a -# shell environment (WSL/MSYS/Git-Bash) or use the Makefile path. +# cmake/sbom.cmake - wolfBoot wrapper around the vendored wolfGlass CMake helper. if(NOT DEFINED WOLFBOOT_ROOT) set(WOLFBOOT_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) endif() -set(SBOM_DRIVER ${WOLFBOOT_ROOT}/tools/scripts/wolfboot-sbom.sh) +include(${WOLFBOOT_ROOT}/tools/sbom/build/sbom.cmake) -# gen-sbom: prefer an explicit override, else the vendored wolfssl submodule. -if(NOT DEFINED GEN_SBOM OR GEN_SBOM STREQUAL "") - set(GEN_SBOM ${WOLFBOOT_ROOT}/lib/wolfssl/scripts/gen-sbom) -endif() -if(NOT DEFINED HOSTCC OR HOSTCC STREQUAL "") - set(HOSTCC cc) -endif() -if(NOT DEFINED SBOM_PYTHON OR SBOM_PYTHON STREQUAL "") - set(SBOM_PYTHON python3) -endif() - -# Read the wolfBoot version string from the header so CMake and Make agree. -set(_sbom_version "") -if(EXISTS ${WOLFBOOT_ROOT}/include/wolfboot/version.h) - file(STRINGS ${WOLFBOOT_ROOT}/include/wolfboot/version.h _sbom_ver_line - REGEX "LIBWOLFBOOT_VERSION_STRING") - if(_sbom_ver_line) - string(REGEX REPLACE ".*LIBWOLFBOOT_VERSION_STRING[ \t]+\"([^\"]*)\".*" - "\\1" _sbom_version "${_sbom_ver_line}") - endif() -endif() -if(_sbom_version STREQUAL "") - message(WARNING "sbom: could not read LIBWOLFBOOT_VERSION_STRING; using 0.0") - set(_sbom_version "0.0") +file(STRINGS ${WOLFBOOT_ROOT}/include/wolfboot/version.h _wolfboot_ver_line + REGEX "LIBWOLFBOOT_VERSION_STRING") +string(REGEX REPLACE ".*LIBWOLFBOOT_VERSION_STRING[ \t]+\"([^\"]*)\".*" + "\\1" _wolfboot_sbom_version "${_wolfboot_ver_line}") +if(_wolfboot_sbom_version STREQUAL "") + message(FATAL_ERROR "sbom: could not read LIBWOLFBOOT_VERSION_STRING") endif() -# Collect the compiled-in source set from the wolfBoot library targets. These -# are the same sources arch.mk folds into OBJS: core wolfBoot + HAL + keystore -# + wolfcrypt. set(_sbom_targets wolfboot wolfboothal) if(TARGET public_key) list(APPEND _sbom_targets public_key) @@ -60,65 +22,27 @@ if(DEFINED WOLFSSL_TGT AND TARGET ${WOLFSSL_TGT}) list(APPEND _sbom_targets ${WOLFSSL_TGT}) endif() -set(_sbom_srcs "") -foreach(_t IN LISTS _sbom_targets) - get_target_property(_t_srcs ${_t} SOURCES) - get_target_property(_t_dir ${_t} SOURCE_DIR) - if(_t_srcs) - foreach(_s IN LISTS _t_srcs) - # Skip generator expressions (e.g. $) which are - # not plain file paths; wolfBoot's targets use plain sources. - # Also skip headers so the SBOM lists only compiled translation - # units, matching the Make path (OBJS -> .c/.S only). - if(NOT _s MATCHES "\\$<" AND _s MATCHES "\\.(c|cc|cpp|cxx|s|S|asm)$") - if(IS_ABSOLUTE "${_s}") - list(APPEND _sbom_srcs "${_s}") - else() - list(APPEND _sbom_srcs "${_t_dir}/${_s}") - endif() - endif() - endforeach() - endif() -endforeach() -list(REMOVE_DUPLICATES _sbom_srcs) - -# Write the source list for the driver (one path per line). -set(_sbom_srcs_file ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-sbom-srcs.txt) -string(REPLACE ";" "\n" _sbom_srcs_nl "${_sbom_srcs}") -file(GENERATE OUTPUT ${_sbom_srcs_file} CONTENT "${_sbom_srcs_nl}\n") - -# Assemble the effective configuration as -D flags. The driver runs these -# through the HOST compiler's -dM -E, exactly like the Make path does with -# CFLAGS, so the captured macro set (and therefore the SBOM) is identical. set(_sbom_defs ${WOLFBOOT_DEFS} ${WOLFBOOT_DEFS_PUBLIC} ${USER_SETTINGS} ${SIGN_OPTIONS}) -list(REMOVE_DUPLICATES _sbom_defs) -set(_sbom_cflags "") -foreach(_d IN LISTS _sbom_defs) - if(NOT _d STREQUAL "") - string(REGEX REPLACE "^-D" "" _d "${_d}") - set(_sbom_cflags "${_sbom_cflags} -D${_d}") - endif() -endforeach() - -set(_sbom_cdx ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_sbom_version}.cdx.json) -set(_sbom_spdx ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_sbom_version}.spdx.json) -add_custom_target(sbom - COMMAND ${CMAKE_COMMAND} -E env HOSTCC=${HOSTCC} - ${SBOM_DRIVER} - --srcs-file ${_sbom_srcs_file} - --cflags ${_sbom_cflags} - --name wolfboot - --version ${_sbom_version} - --license-file ${WOLFBOOT_ROOT}/LICENSE - --gen-sbom ${GEN_SBOM} - --python ${SBOM_PYTHON} - --hostcc ${HOSTCC} - --root ${WOLFBOOT_ROOT} - --skip-missing - --cdx-out ${_sbom_cdx} - --spdx-out ${_sbom_spdx} - WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} - VERBATIM - COMMENT "Generating wolfBoot SBOM (CycloneDX 1.6 + SPDX 2.3)" +set(_sbom_args + NAME wolfboot + VERSION_FILE ${WOLFBOOT_ROOT}/include/wolfboot/version.h + VERSION_MACRO LIBWOLFBOOT_VERSION_STRING + TARGETS ${_sbom_targets} + DEFS ${_sbom_defs} + LICENSE ${WOLFBOOT_ROOT}/LICENSE + ROOT ${WOLFBOOT_ROOT} + CDX_OUT ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_wolfboot_sbom_version}.cdx.json + SPDX_OUT ${CMAKE_CURRENT_BINARY_DIR}/wolfboot-${_wolfboot_sbom_version}.spdx.json ) + +if(DEFINED SBOM_GEN AND NOT SBOM_GEN STREQUAL "") + list(APPEND _sbom_args SBOM_GEN ${SBOM_GEN}) +elseif(DEFINED GEN_SBOM AND NOT GEN_SBOM STREQUAL "") + list(APPEND _sbom_args SBOM_GEN ${GEN_SBOM}) +endif() +if(DEFINED HOSTCC AND NOT HOSTCC STREQUAL "") + list(APPEND _sbom_args HOSTCC ${HOSTCC}) +endif() + +wolfglass_add_sbom(${_sbom_args}) diff --git a/docs/SBOM.md b/docs/SBOM.md index 965b4db5c3..8f3f3196e9 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -14,7 +14,7 @@ and gets back the same document: ``` build system ─┐ - ├─► tools/scripts/wolfboot-sbom.sh ─► wolfSSL gen-sbom ─► *.cdx.json + *.spdx.json + ├─► tools/sbom/sbom-driver ─► tools/sbom/gen-sbom ─► *.cdx.json + *.spdx.json extractor ─┘ (srcs list + build config) ``` @@ -29,13 +29,14 @@ The pieces: | File | Role | | --- | --- | -| `tools/scripts/wolfboot-sbom.sh` | Canonical driver (srcs + config → gen-sbom). | -| `cmake/sbom.cmake` | CMake `sbom` target. | -| `tools/scripts/ide-sbom/iar_sbom.py` | Extracts srcs + defines from an IAR `.ewp`. | -| `tools/scripts/ide-sbom/compdb_sbom.py` | Extracts srcs + defines from a `compile_commands.json`. | -| `tools/scripts/ide-sbom/zephyr_sbom.py` | Extracts the Zephyr module sources from `zephyr/CMakeLists.txt`. | -| `tools/scripts/ide-sbom/route_through_sbom.sh` | Stages a config and runs `make sbom` for IDE targets that build through the Makefile. | -| `tools/scripts/ide-sbom/validate_sbom.py` | Structural sanity check used by CI. | +| `tools/sbom/sbom-driver` | Vendored wolfGlass driver (srcs + config → gen-sbom). | +| `tools/sbom/gen-sbom` | Vendored SBOM generator. | +| `cmake/sbom.cmake` | wolfBoot wrapper around the vendored CMake helper. | +| `tools/sbom/frontends/iar_sbom.py` | Extracts srcs + defines from an IAR `.ewp`. | +| `tools/sbom/frontends/compdb_sbom.py` | Extracts srcs + defines from a `compile_commands.json`. | +| `tools/sbom/frontends/zephyr_sbom.py` | Extracts the Zephyr module sources from `zephyr/CMakeLists.txt`. | +| `tools/scripts/ide-sbom/route_through_sbom.sh` | wolfBoot-specific staging helper for IDE targets that build through the Makefile. It intentionally stays outside the vendored tree because it encodes wolfBoot config paths and Makefile entry points. | +| `tools/sbom/validate_sbom.py` | Structural sanity check used by CI. | | `make sbom-hal` | Standalone SBOM for the HAL of a given target. | ## Prerequisites @@ -43,28 +44,21 @@ The pieces: * `python3` * A host C compiler. The default is `cc`. To use a different compiler, set `HOSTCC=...`. -* `gen-sbom`. This tool is part of wolfSSL. The build uses the copy in the - `lib/wolfssl` submodule. The pinned wolfSSL revision does not include - `gen-sbom` yet. Until a wolfSSL update adds it, give the path to a copy. Use - `GEN_SBOM=/path/to/wolfssl/scripts/gen-sbom` for Make and route-through. Use - `-DGEN_SBOM=...` for CMake. Use `--gen-sbom ...` for the Python tools. - -```sh -git submodule update --init lib/wolfssl -``` +* The vendored wolfGlass SBOM set under `tools/sbom/`. ## Limitations Obey these limitations when you make an SBOM. -- `gen-sbom` is necessary. If the build does not find the tool, it stops and - shows an error. Give the path with `GEN_SBOM` or the equivalent option. A - wolfSSL submodule update removes this step. +- `gen-sbom` is necessary. wolfBoot vendors it under `tools/sbom/`. If you + override it with `SBOM_GEN`, `-DGEN_SBOM=...`, or `--gen-sbom ...`, the + replacement copy must support the same flags as the vendored one. - A vendor SDK build lists only the source files that are on disk. If the SDK is not in the source tree, the SBOM does not include the SDK files. The SBOM always includes the wolfBoot, wolfCrypt, and HAL files. -- The driver is a POSIX shell script. On Windows, run the tools in a POSIX - shell. Use WSL, MSYS, or Git Bash. As an alternative, use the compilation +- `tools/sbom/sbom-driver` is a thin POSIX launcher for the vendored Python + engine. On Windows, run the launcher from WSL, MSYS, or Git Bash, or call + `tools/sbom/sbom-driver.py` directly. As an alternative, use the compilation database tool (`compdb_sbom.py`). ## Coverage: the 11 build methods @@ -102,7 +96,7 @@ make sbom TARGET= SIGN= HASH= line, environment, or `.config`) and must match the configuration you ship — the source set and artifact hash are configuration-specific. -Useful overrides: `HOSTCC`, `GEN_SBOM`, `CRA_PYTHON`. +Useful overrides: `HOSTCC`, `SBOM_GEN`, `CRA_PYTHON`. wolfcrypt sources are compiled directly into the wolfBoot image, so they are listed as wolfBoot's own sources rather than as a separate component. @@ -111,19 +105,19 @@ listed as wolfBoot's own sources rather than as a separate component. The CMake build exposes an `sbom` target (`cmake/sbom.cmake`) that collects the compiled source set from the wolfBoot library targets and the effective -configuration from `WOLFBOOT_DEFS` / `USER_SETTINGS`, then calls the shared -driver — producing a document byte-comparable with the Make path. +configuration from `WOLFBOOT_DEFS` / `USER_SETTINGS`, then calls the vendored +driver. The intent is to converge with the Make path for the same +configuration; CI compares the two outputs as an advisory check. ```sh cmake -S . -B build-sim -DWOLFBOOT_TARGET=sim ... # your normal configure cmake --build build-sim --target sbom ``` -Outputs land in the build directory. Overrides: `-DGEN_SBOM=...`, `-DHOSTCC=...`, -`-DSBOM_PYTHON=...`. +Outputs land in the build directory. Overrides: `-DGEN_SBOM=...`, `-DHOSTCC=...`. -> The driver is a POSIX shell script; on Windows run this target from WSL / MSYS -> / Git-Bash, or use the Make path. +> `tools/sbom/sbom-driver` is a POSIX launcher; on Windows run this target from +> WSL / MSYS / Git-Bash, or call `tools/sbom/sbom-driver.py` directly. ### Pico SDK (method 6) @@ -134,7 +128,11 @@ its SBOM from the compilation database (see Route 4): ```sh cd IDE/pico-sdk/rp2350/wolfboot cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ... # your normal configure -python3 /tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json +python3 /tools/sbom/frontends/compdb_sbom.py build/compile_commands.json \ + --name wolfboot \ + --driver /tools/sbom/sbom-driver \ + --version-file /include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING ``` ## Route 3 — IAR extractor (method 7) @@ -145,7 +143,11 @@ live in the `.ewp` project file. The extractor reads them out and feeds the shared driver: ```sh -tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp +tools/sbom/frontends/iar_sbom.py IDE/IAR/wolfboot.ewp \ + --name wolfboot \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING ``` Options: `--config ` (defaults to the configuration with the most defines, @@ -198,7 +200,11 @@ cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ... # Make-based IDE projects (MPLAB X nbproject, CCS, Vitis) via Bear: bear -- make # produces compile_commands.json -python3 tools/scripts/ide-sbom/compdb_sbom.py compile_commands.json \ +python3 tools/sbom/frontends/compdb_sbom.py compile_commands.json \ + --name wolfboot \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING \ --exclude 'test-app/' # optional: drop test sources ``` @@ -237,7 +243,12 @@ extractor reads the module's source list straight from `zephyr/CMakeLists.txt` (staying in sync automatically): ```sh -tools/scripts/ide-sbom/zephyr_sbom.py +tools/sbom/frontends/zephyr_sbom.py \ + --name wolfboot-zephyr \ + --cmakelists zephyr/CMakeLists.txt \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING ``` Output: `wolfboot-zephyr-.{cdx,spdx}.json`. @@ -250,7 +261,10 @@ that build's compilation database instead: ```sh west build ... -- -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -python3 tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json \ +python3 tools/sbom/frontends/compdb_sbom.py build/compile_commands.json \ + --driver tools/sbom/sbom-driver \ + --version-file include/wolfboot/version.h \ + --version-macro LIBWOLFBOOT_VERSION_STRING \ --include 'zephyr/src/' --name wolfboot-zephyr ``` @@ -267,7 +281,9 @@ Every route writes, into the working/build directory: You can sanity-check any output: ```sh -python3 tools/scripts/ide-sbom/validate_sbom.py wolfboot-*.cdx.json wolfboot-*.spdx.json +python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + wolfboot-*.cdx.json wolfboot-*.spdx.json ``` ## Continuous integration @@ -275,7 +291,9 @@ python3 tools/scripts/ide-sbom/validate_sbom.py wolfboot-*.cdx.json wolfboot-*.s `.github/workflows/test-sbom.yml` is an SBOM canary that runs the Make, CMake, IAR, and compilation-database routes on every push/PR and validates each output, so a change to a build system, the shared driver, or an extractor cannot -silently break SBOM generation. The generated SBOMs are uploaded as build +silently break SBOM generation. It also diffs Make vs CMake output for the same +sim configuration and runs a native-Windows scrub test against +`tools/sbom/sbom-driver.py`. The generated SBOMs are uploaded as build artifacts. ## Reproducibility diff --git a/lib/wolfssl b/lib/wolfssl index ac01707f55..887f242ee8 160000 --- a/lib/wolfssl +++ b/lib/wolfssl @@ -1 +1 @@ -Subproject commit ac01707f552c611fbd135cc723b2682b3e7f80f2 +Subproject commit 887f242ee8570f7d8403e002c5b2b88929b86544 diff --git a/tools/sbom/.wolfglass-rev b/tools/sbom/.wolfglass-rev new file mode 100644 index 0000000000..1970e7e00a --- /dev/null +++ b/tools/sbom/.wolfglass-rev @@ -0,0 +1 @@ +1f1f7f96254d419e4d41d1b8d8991903456bec22 diff --git a/tools/sbom/README.md b/tools/sbom/README.md new file mode 100644 index 0000000000..8b168b501f --- /dev/null +++ b/tools/sbom/README.md @@ -0,0 +1,70 @@ +# wolfBoot SBOM Toolkit + +This directory is the vendored wolfGlass SBOM layer for wolfBoot. It was synced +into `tools/sbom/` and pinned with `VERSION` and `.wolfglass-rev`. + +For future updates, refresh this directory from wolfGlass with +`tools/wolfglass-sync` rather than editing the shared files ad hoc. + +## Contents + +| File | Role | +|---|---| +| `sbom-driver.py` | The product-neutral SBOM engine (Python). | +| `sbom-driver` | Thin shell wrapper that runs `sbom-driver.py`. | +| `validate_sbom.py` | Structural validator for CI (`--name-prefix`). | +| `frontends/compdb_sbom.py` | Extractor for any `compile_commands.json`. | +| `frontends/iar_sbom.py` | Extractor for an IAR Embedded Workbench `.ewp`. | +| `frontends/zephyr_sbom.py` | Extractor for a Zephyr module `CMakeLists.txt`. | +| `build/sbom.mk` | Shared plain-Make fragment and `wolfglass_sbom_rule` macro. | +| `build/sbom.cmake` | Shared CMake helper: `wolfglass_add_sbom()`. | +| `gen-sbom` | The vendored SBOM generator. | +| `sbom.am` | Shared autotools fragment. | + +## The driver contract + +Every front end produces a composition input and a config input and hands them to +the driver. + +Composition (at least one): + +- `--srcs-file PATH` — the source files compiled into the artifact (tier E). +- `--lib PATH` — the built library to hash (tier R/L/S). +- `--no-artifact-hash` — record the artifact as-built and do not re-hash. Use it + with `--lib` for a FIPS canister or a kernel module. Never substitute a source + list for a certified artifact. + +Config (choose one): + +- `--cflags="..."` — raw CFLAGS; the driver expands the `-D` tokens through the + host compiler. Use the `=` form so a leading-dash value is not read as a flag. +- `--options-h PATH` — a pre-expanded flat `#define` header, used verbatim. +- `--user-settings PATH` — a `user_settings.h`; the generator captures it. +- `--source-only` — no build-config macros (for example a Kconfig-driven build). + +Dependency (for linkers and bindings): `--dep-wolfssl`, `--dep-openssl`, and +`--dep-version` are passed through only when the generator supports them. + +The driver captures macros with the host compiler, so the SBOM is reproducible +across toolchains. It scrubs absolute host paths from the captured macros unless +you pass `--no-scrub`. + +The shared driver is product-neutral and calls the vendored `tools/sbom/gen-sbom` by +default. Pass `--gen-sbom` only when you want to override that copy. + +## The manifest contract + +A product does not copy logic. It describes itself: + +- Make: set `SBOM_NAME`, `SBOM_SRCS`, `SBOM_CFLAGS`, and a version + (`SBOM_VERSION`, or `SBOM_VERSION_FILE` + `SBOM_VERSION_MACRO`), then + `include tools/sbom/build/sbom.mk`. For a second target, instantiate + `$(eval $(call wolfglass_sbom_rule,,))`. +- CMake: `include(tools/sbom/build/sbom.cmake)` and call `wolfglass_add_sbom()` + with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. + `SBOM_GEN` is the canonical generator override; `GEN_SBOM` remains a legacy + alias for compatibility. +- Autotools: set the `SBOM_*` variables and `include tools/sbom/sbom.am`. + +Keep only true product knowledge in the product: the route-through script, the +module extractor, and the HAL source selector. diff --git a/tools/sbom/VERSION b/tools/sbom/VERSION new file mode 100644 index 0000000000..3e56aeef00 --- /dev/null +++ b/tools/sbom/VERSION @@ -0,0 +1 @@ +0.1.0-draft diff --git a/tools/sbom/build/sbom.cmake b/tools/sbom/build/sbom.cmake new file mode 100644 index 0000000000..b1009e989f --- /dev/null +++ b/tools/sbom/build/sbom.cmake @@ -0,0 +1,164 @@ +# sbom.cmake - shared CMake helper for wolfGlass SBOM generation. +# +# This replaces the per-product `add_custom_target(sbom ...)` blocks that today +# are copied and drifting across wolfMQTT, wolfTPM, and wolfBoot. It exposes ONE +# function, wolfglass_add_sbom(), so each product describes itself in a few lines +# and gets an `sbom` target that calls the same driver as the Make and autotools +# paths. +# +# Include this file, then call the function: +# +# include(${CMAKE_CURRENT_SOURCE_DIR}/tools/sbom/build/sbom.cmake) +# wolfglass_add_sbom( +# NAME wolfboot +# VERSION_FILE ${CMAKE_CURRENT_SOURCE_DIR}/include/wolfboot/version.h +# VERSION_MACRO LIBWOLFBOOT_VERSION_STRING +# TARGETS wolfboot wolfboothal +# DEFS ${WOLFBOOT_DEFS} ${USER_SETTINGS} +# LICENSE ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE +# ) +# +# Optional arguments: +# SBOM_GEN Path to gen-sbom (default: driver auto-discovery). +# GEN_SBOM Legacy alias for SBOM_GEN. +# HOSTCC Host C compiler for macro capture (default: cc). +# ROOT Product root (default: CMAKE_CURRENT_SOURCE_DIR). +# TARGET_NAME Name of the custom target (default: sbom). +# CDX_OUT Explicit CycloneDX output path. +# SPDX_OUT Explicit SPDX output path. +# +# NOTE: the driver is invoked as a program; on Windows run the target from a +# shell environment (WSL/MSYS/Git-Bash) or use the Make/autotools path. + +# The shared driver sits one directory above this fragment. +set(_WOLFGLASS_SBOM_DIR ${CMAKE_CURRENT_LIST_DIR}) +get_filename_component(_WOLFGLASS_DRIVER + "${_WOLFGLASS_SBOM_DIR}/../sbom-driver" ABSOLUTE) + +function(wolfglass_add_sbom) + set(_opts NO_ARTIFACT_HASH SOURCE_ONLY) + set(_one NAME TARGET_NAME VERSION VERSION_FILE VERSION_MACRO LICENSE + SBOM_GEN GEN_SBOM HOSTCC ROOT LIB USER_SETTINGS OPTIONS_H + DEP_WOLFSSL DEP_OPENSSL CDX_OUT SPDX_OUT) + set(_multi TARGETS DEFS) + cmake_parse_arguments(SB "${_opts}" "${_one}" "${_multi}" ${ARGN}) + + if(NOT SB_NAME) + message(FATAL_ERROR "wolfglass_add_sbom: NAME is required") + endif() + if(NOT SB_TARGETS AND NOT SB_LIB) + message(FATAL_ERROR "wolfglass_add_sbom: set TARGETS or LIB") + endif() + if(NOT SB_ROOT) + set(SB_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) + endif() + if(NOT SB_LICENSE) + set(SB_LICENSE ${SB_ROOT}/LICENSE) + endif() + if(NOT SB_HOSTCC) + set(SB_HOSTCC cc) + endif() + if(NOT SB_TARGET_NAME) + set(SB_TARGET_NAME sbom) + endif() + if(NOT SB_SBOM_GEN AND SB_GEN_SBOM) + set(SB_SBOM_GEN ${SB_GEN_SBOM}) + endif() + + # Collect the compiled source set from the named targets. Skip generator + # expressions and headers so the list matches the Make path (compiled + # translation units only). + set(_srcs "") + foreach(_t IN LISTS SB_TARGETS) + if(TARGET ${_t}) + get_target_property(_t_srcs ${_t} SOURCES) + get_target_property(_t_dir ${_t} SOURCE_DIR) + if(_t_srcs) + foreach(_s IN LISTS _t_srcs) + if(NOT _s MATCHES "\\$<" AND + _s MATCHES "\\.(c|cc|cpp|cxx|s|S|asm)$") + if(IS_ABSOLUTE "${_s}") + list(APPEND _srcs "${_s}") + else() + list(APPEND _srcs "${_t_dir}/${_s}") + endif() + endif() + endforeach() + endif() + endif() + endforeach() + list(REMOVE_DUPLICATES _srcs) + + set(_cmd ${CMAKE_COMMAND} -E env HOSTCC=${SB_HOSTCC} + ${_WOLFGLASS_DRIVER} + --name ${SB_NAME} + --root ${SB_ROOT} + --license-file ${SB_LICENSE} + --hostcc ${SB_HOSTCC} + --skip-missing) + + # Composition: a source set from the targets and/or a built library. + if(SB_TARGETS) + set(_srcs_file ${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-sbom-srcs.txt) + string(REPLACE ";" "\n" _srcs_nl "${_srcs}") + file(GENERATE OUTPUT ${_srcs_file} CONTENT "${_srcs_nl}\n") + list(APPEND _cmd --srcs-file ${_srcs_file}) + endif() + if(SB_LIB) + list(APPEND _cmd --lib ${SB_LIB}) + endif() + if(SB_NO_ARTIFACT_HASH) + list(APPEND _cmd --no-artifact-hash) + endif() + + # Config: user_settings, a pre-expanded header, source-only, or -D flags. + if(SB_USER_SETTINGS) + list(APPEND _cmd --user-settings ${SB_USER_SETTINGS}) + elseif(SB_OPTIONS_H) + list(APPEND _cmd --options-h ${SB_OPTIONS_H}) + elseif(SB_SOURCE_ONLY) + list(APPEND _cmd --source-only) + else() + set(_cflags "") + list(REMOVE_DUPLICATES SB_DEFS) + foreach(_d IN LISTS SB_DEFS) + if(NOT _d STREQUAL "") + string(REGEX REPLACE "^-D" "" _d "${_d}") + set(_cflags "${_cflags} -D${_d}") + endif() + endforeach() + string(STRIP "${_cflags}" _cflags) + list(APPEND _cmd "--cflags=${_cflags}") + endif() + + if(SB_VERSION) + list(APPEND _cmd --version ${SB_VERSION}) + endif() + if(SB_VERSION_FILE) + list(APPEND _cmd --version-file ${SB_VERSION_FILE}) + endif() + if(SB_VERSION_MACRO) + list(APPEND _cmd --version-macro ${SB_VERSION_MACRO}) + endif() + if(SB_DEP_WOLFSSL) + list(APPEND _cmd --dep-wolfssl ${SB_DEP_WOLFSSL}) + endif() + if(SB_DEP_OPENSSL) + list(APPEND _cmd --dep-openssl ${SB_DEP_OPENSSL}) + endif() + if(SB_SBOM_GEN) + list(APPEND _cmd --gen-sbom ${SB_SBOM_GEN}) + endif() + if(SB_CDX_OUT) + list(APPEND _cmd --cdx-out ${SB_CDX_OUT}) + endif() + if(SB_SPDX_OUT) + list(APPEND _cmd --spdx-out ${SB_SPDX_OUT}) + endif() + + add_custom_target(${SB_TARGET_NAME} + COMMAND ${_cmd} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + VERBATIM + COMMENT "Generating ${SB_NAME} SBOM (CycloneDX 1.6 + SPDX 2.3)") +endfunction() diff --git a/tools/sbom/build/sbom.mk b/tools/sbom/build/sbom.mk new file mode 100644 index 0000000000..a67425f283 --- /dev/null +++ b/tools/sbom/build/sbom.mk @@ -0,0 +1,88 @@ +# sbom.mk - shared plain-Make fragment for wolfGlass SBOM generation. +# +# One driver does the work; each product describes itself with a few variables +# and includes this fragment to get an `sbom` target. It is the plain-Make +# counterpart of sbom.am (autotools) and sbom.cmake (CMake). All three call the +# same driver, so the three build systems emit byte-comparable SBOMs. +# +# The including Makefile MUST set, before `include .../build/sbom.mk`: +# SBOM_NAME Product name recorded in the SBOM (e.g. wolfboot). +# +# Composition - set at least one: +# SBOM_SRCS Source files compiled into the artifact (tier E), e.g.: +# SBOM_SRCS := $(patsubst %.o,%.c,$(OBJS)) +# SBOM_LIB Path to the built library to hash (tier R/L/S). +# +# Config - set one: +# SBOM_CFLAGS Build CFLAGS whose -D tokens describe the config. +# SBOM_OPTIONS_H A pre-expanded flat #define header. +# SBOM_USER_SETTINGS A user_settings.h. +# SBOM_SOURCE_ONLY = 1 Source-inventory SBOM with no build-config macros. +# +# Version - set one: +# SBOM_VERSION Literal version string, OR +# SBOM_VERSION_FILE + Header to read and the macro to read from it, e.g.: +# SBOM_VERSION_MACRO SBOM_VERSION_FILE = include/wolfboot/version.h +# SBOM_VERSION_MACRO = LIBWOLFBOOT_VERSION_STRING +# +# Optional (defaults shown): +# SBOM_ROOT Product root. Default: current directory. +# SBOM_LICENSE_FILE License file. Default: $(SBOM_ROOT)/LICENSE. +# SBOM_GEN Path to gen-sbom. Default: driver auto-discovery. +# GEN_SBOM Legacy alias for SBOM_GEN. +# SBOM_NO_ARTIFACT_HASH = 1 As-built FIPS/kernel: do not re-hash. +# SBOM_DEP_WOLFSSL yes/no - record wolfSSL as a dependency. +# SBOM_DEP_OPENSSL yes/no - record OpenSSL as a dependency. +# HOSTCC Host C compiler for macro capture. Default: cc. +# CRA_PYTHON Python interpreter. Default: python3. +# +# The driver path is derived from this fragment's own location, so a product +# that vendors share/ into tools/sbom/ needs no path configuration. +# +# To instantiate a second target, set another variable prefix and call: +# $(eval $(call wolfglass_sbom_rule,sbom-hal,SBOM_HAL_)) +# using SBOM_HAL_NAME, SBOM_HAL_SRCS, SBOM_HAL_CFLAGS, and so on. + +SBOM_MK_DIR := $(dir $(lastword $(MAKEFILE_LIST))) +SBOM_DRIVER ?= $(abspath $(SBOM_MK_DIR)/../sbom-driver) + +SBOM_ROOT ?= $(CURDIR) +SBOM_LICENSE_FILE ?= $(SBOM_ROOT)/LICENSE +HOSTCC ?= cc + +define wolfglass_sbom_rule +.PHONY: $(1) +$(1): + @test -n "$($(2)NAME)" || { echo "ERROR: set $(2)NAME"; exit 1; } + @test -n "$(strip $($(2)SRCS))$($(2)LIB)" || \ + { echo "ERROR: set $(2)SRCS or $(2)LIB"; exit 1; } + @set -e; \ + if [ -n "$(strip $($(2)SRCS))" ]; then \ + trap 'rm -f "$(CURDIR)/.$(1)-wolfglass-srcs.txt"' EXIT INT TERM HUP; \ + printf '%s\n' $($(2)SRCS) > "$(CURDIR)/.$(1)-wolfglass-srcs.txt"; \ + fi; \ + CRA_PYTHON="$(CRA_PYTHON)" HOSTCC="$(or $($(2)HOSTCC),$(HOSTCC))" \ + "$(or $($(2)DRIVER),$(SBOM_DRIVER))" \ + --name "$($(2)NAME)" \ + --root "$(or $($(2)ROOT),$(SBOM_ROOT))" \ + --license-file "$(or $($(2)LICENSE_FILE),$(SBOM_LICENSE_FILE))" \ + --skip-missing \ + $(if $(strip $($(2)SRCS)),--srcs-file "$(CURDIR)/.$(1)-wolfglass-srcs.txt") \ + $(if $($(2)LIB),--lib "$($(2)LIB)") \ + $(if $(filter 1,$($(2)NO_ARTIFACT_HASH)),--no-artifact-hash) \ + $(if $(filter 1,$($(2)SOURCE_ONLY)),--source-only) \ + $(if $($(2)CFLAGS),--cflags="$($(2)CFLAGS)") \ + $(if $($(2)OPTIONS_H),--options-h "$($(2)OPTIONS_H)") \ + $(if $($(2)USER_SETTINGS),--user-settings "$($(2)USER_SETTINGS)") \ + $(if $($(2)VERSION),--version "$($(2)VERSION)") \ + $(if $($(2)VERSION_FILE),--version-file "$($(2)VERSION_FILE)") \ + $(if $($(2)VERSION_MACRO),--version-macro "$($(2)VERSION_MACRO)") \ + $(if $($(2)DEP_WOLFSSL),--dep-wolfssl "$($(2)DEP_WOLFSSL)") \ + $(if $($(2)DEP_OPENSSL),--dep-openssl "$($(2)DEP_OPENSSL)") \ + $(if $(or $($(2)GEN),$(GEN_SBOM)),--gen-sbom "$(or $($(2)GEN),$(GEN_SBOM))") \ + $(if $($(2)CDX_OUT),--cdx-out "$($(2)CDX_OUT)") \ + $(if $($(2)SPDX_OUT),--spdx-out "$($(2)SPDX_OUT)") +endef + +SBOM_TARGET ?= sbom +$(eval $(call wolfglass_sbom_rule,$(SBOM_TARGET),SBOM_)) diff --git a/tools/sbom/frontends/compdb_sbom.py b/tools/sbom/frontends/compdb_sbom.py new file mode 100755 index 0000000000..6d97428f1c --- /dev/null +++ b/tools/sbom/frontends/compdb_sbom.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Generate an SBOM from a Clang compilation database (compile_commands.json). + +This is the universal IDE/toolchain fallback and is product-neutral. Any build +that can emit a compilation database gives an exact, ground-truth list of the +files that were compiled and the -D configuration they were compiled with, +independent of the build system or compiler. That covers cases with no Make or +CMake SBOM path: + + * CMake builds (-DCMAKE_EXPORT_COMPILE_COMMANDS=ON) + * TI Code Composer Studio (armcl, via `bear -- make ...`) + * Microchip MPLAB X (xc32, via `bear -- make ...`) + * Renesas e2studio / CCRX (via a build wrapper that records commands) + * Xilinx SDK / Vitis (via `bear -- make ...`) + +The extracted sources + defines are handed to the shared driver (sbom-driver), +so the resulting CycloneDX 1.6 / SPDX 2.3 SBOM is identical in shape to the Make, +CMake, and IAR paths. + +Usage: + compdb_sbom.py --name NAME build/compile_commands.json [options] +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +# The shared driver is the sibling of this frontends/ directory. +DEFAULT_DRIVER = os.path.join(os.path.dirname(SCRIPT_DIR), "sbom-driver") + + +def entry_tokens(entry): + if entry.get('arguments'): + return list(entry['arguments']) + if entry.get('command'): + try: + return shlex.split(entry['command']) + except ValueError: + return entry['command'].split() + return [] + + +def extract_defines(tokens): + defs = [] + i = 0 + while i < len(tokens): + t = tokens[i] + if t == '-D' and i + 1 < len(tokens): + defs.append(tokens[i + 1]) + i += 2 + continue + if t.startswith('-D'): + defs.append(t[2:]) + i += 1 + return defs + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('compdb', help='Path to compile_commands.json') + ap.add_argument('--name', required=True, help='Package name for the SBOM.') + ap.add_argument('--include', action='append', default=[]) + ap.add_argument('--exclude', action='append', default=[]) + ap.add_argument('--driver', default=DEFAULT_DRIVER, + help='Path to the shared sbom-driver.') + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--version-file', default=None) + ap.add_argument('--version-macro', default=None) + ap.add_argument('--root', default=None) + ap.add_argument('--license-file', default=None) + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.compdb): + sys.exit(f"ERROR: compilation database not found: {args.compdb}") + with open(args.compdb) as f: + try: + db = json.load(f) + except json.JSONDecodeError as e: + sys.exit(f"ERROR: cannot parse {args.compdb}: {e}") + + inc = [re.compile(p) for p in args.include] + exc = [re.compile(p) for p in args.exclude] + + srcs, defines, seen = [], set(), set() + for entry in db: + fpath = entry.get('file') + if not fpath: + continue + directory = entry.get('directory', os.getcwd()) + if not os.path.isabs(fpath): + fpath = os.path.join(directory, fpath) + fpath = os.path.normpath(fpath) + if not fpath.lower().endswith(SRC_EXTS): + continue + if inc and not any(r.search(fpath) for r in inc): + continue + if exc and any(r.search(fpath) for r in exc): + continue + for d in extract_defines(entry_tokens(entry)): + defines.add(d) + if fpath not in seen and os.path.isfile(fpath): + seen.add(fpath) + srcs.append(fpath) + + if not srcs: + sys.exit("ERROR: no matching source files found in compilation database") + + defines = sorted(defines) + cflags = ' '.join(f'-D{d}' for d in defines) + + if args.print_only: + print(f"# {len(srcs)} sources, {len(defines)} defines") + print("\n[defines]") + for d in defines: + print(f" -D{d}") + print("\n[sources]") + for s in srcs: + print(f" {s}") + return + + srcs_out, tmp = args.srcs_out, None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-compdb-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [args.driver, '--srcs-file', srcs_out, f'--cflags={cflags}', + '--name', args.name] + for flag, val in (('--version', args.version), + ('--version-file', args.version_file), + ('--version-macro', args.version_macro), + ('--root', args.root), + ('--license-file', args.license_file), + ('--gen-sbom', args.gen_sbom), + ('--cdx-out', args.cdx_out), + ('--spdx-out', args.spdx_out)): + if val: + cmd += [flag, val] + + print(f"compdb SBOM: {len(srcs)} sources, {len(defines)} defines") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/sbom/frontends/iar_sbom.py b/tools/sbom/frontends/iar_sbom.py new file mode 100755 index 0000000000..4c877b13b0 --- /dev/null +++ b/tools/sbom/frontends/iar_sbom.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Generate an SBOM from an IAR Embedded Workbench project (.ewp). + +This is the product-neutral form of wolfBoot's iar_sbom.py. IAR builds happen +inside the IDE and never touch a Makefile or CMake, so the compiled source set +and the preprocessor configuration live in the .ewp project file. This extractor +reads them out of the .ewp and feeds them to the shared driver (sbom-driver), so +an IAR-built product gets the same CycloneDX 1.6 + SPDX 2.3 SBOM as a Make- or +CMake-built one. + +It parses: + * the C compiler preprocessor defines (ICCARM -> CCDefines entries) + * the compiled source files ( ...), resolving $PROJ_DIR$ + +Usage: + iar_sbom.py --name NAME path/to/project.ewp [options] +""" + +import argparse +import os +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +# The shared driver is the sibling of this frontends/ directory. +DEFAULT_DRIVER = os.path.join(os.path.dirname(SCRIPT_DIR), "sbom-driver") + + +def resolve_proj_dir(raw, proj_dir): + """Resolve an IAR $PROJ_DIR$-relative, backslash path to an absolute path.""" + p = raw.replace('$PROJ_DIR$', proj_dir).replace('\\', '/') + if not os.path.isabs(p): + p = os.path.join(proj_dir, p) + return os.path.normpath(p) + + +def parse_configs(root): + """Return {config_name: [define, ...]} from each ICCARM CCDefines option.""" + configs = {} + for cfg in root.findall('configuration'): + name_el = cfg.find('name') + cfg_name = name_el.text if name_el is not None else '(unnamed)' + defines = [] + for settings in cfg.findall('settings'): + sname = settings.find('name') + if sname is None or sname.text != 'ICCARM': + continue + for data in settings.findall('data'): + for option in data.findall('option'): + oname = option.find('name') + if oname is None or oname.text != 'CCDefines': + continue + for state in option.findall('state'): + if state.text: + defines.append(state.text.strip()) + configs[cfg_name] = defines + return configs + + +def collect_sources(root, proj_dir): + """Return (present, missing) absolute paths of compiled source files.""" + srcs = [] + for file_el in root.iter('file'): + name_el = file_el.find('name') + if name_el is None or not name_el.text: + continue + raw = name_el.text.strip() + if not raw.lower().endswith(SRC_EXTS): + continue + srcs.append(resolve_proj_dir(raw, proj_dir)) + seen, present, missing = set(), [], [] + for s in srcs: + if s in seen: + continue + seen.add(s) + (present if os.path.isfile(s) else missing).append(s) + return present, missing + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('ewp', help='Path to the IAR .ewp project file') + ap.add_argument('--name', required=True, help='Package name for the SBOM.') + ap.add_argument('--config', help='IAR configuration name (default: richest)') + ap.add_argument('--driver', default=DEFAULT_DRIVER) + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--version-file', default=None) + ap.add_argument('--version-macro', default=None) + ap.add_argument('--root', default=None) + ap.add_argument('--license-file', default=None) + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.ewp): + sys.exit(f"ERROR: .ewp not found: {args.ewp}") + proj_dir = os.path.dirname(os.path.abspath(args.ewp)) + + try: + root = ET.parse(args.ewp).getroot() + except ET.ParseError as e: + sys.exit(f"ERROR: cannot parse {args.ewp}: {e}") + + configs = parse_configs(root) + if not configs: + sys.exit("ERROR: no with ICCARM CCDefines found in .ewp") + + if args.config: + if args.config not in configs: + sys.exit(f"ERROR: config {args.config!r} not found. " + f"Available: {', '.join(configs)}") + cfg_name = args.config + else: + # The configuration with the most defines is the real build config. + cfg_name = max(configs, key=lambda k: len(configs[k])) + + defines = configs[cfg_name] + srcs, missing = collect_sources(root, proj_dir) + if not srcs: + sys.exit("ERROR: no existing source files found in .ewp") + if missing: + sys.stderr.write( + f"WARNING: {len(missing)} source(s) listed in the .ewp were not " + f"found on disk and are excluded (e.g. build-time generated " + f"files):\n") + for m in missing: + sys.stderr.write(f" - {m}\n") + + cflags = ' '.join(f'-D{d}' for d in defines) + + if args.print_only: + print(f"# IAR configuration: {cfg_name}") + print(f"# {len(srcs)} sources, {len(defines)} defines") + print("\n[defines]") + for d in defines: + print(f" -D{d}") + print("\n[sources]") + for s in srcs: + print(f" {s}") + return + + srcs_out, tmp = args.srcs_out, None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-iar-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [args.driver, '--srcs-file', srcs_out, f'--cflags={cflags}', + '--name', args.name] + for flag, val in (('--version', args.version), + ('--version-file', args.version_file), + ('--version-macro', args.version_macro), + ('--root', args.root), + ('--license-file', args.license_file), + ('--gen-sbom', args.gen_sbom), + ('--cdx-out', args.cdx_out), + ('--spdx-out', args.spdx_out)): + if val: + cmd += [flag, val] + + print(f"IAR SBOM: configuration={cfg_name} " + f"({len(srcs)} sources, {len(defines)} defines)") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/sbom/frontends/zephyr_sbom.py b/tools/sbom/frontends/zephyr_sbom.py new file mode 100755 index 0000000000..ef300847b3 --- /dev/null +++ b/tools/sbom/frontends/zephyr_sbom.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Generate a source-inventory SBOM for a Zephyr module. + +This is the product-neutral form of wolfBoot's zephyr_sbom.py. A Zephyr module +compiles its sources into a Zephyr application through `zephyr_library_sources(...)`, +built by Zephyr/west rather than by the product's own Makefile or CMake. So +neither the Make nor the CMake SBOM target sees those sources. + +This extractor reads the module's source list straight out of its CMakeLists (so +it stays in sync automatically) and hands it to the shared driver. + +The module configuration is Kconfig-driven (CONFIG_* symbols), not a `-D` macro +set, so by default this produces a source-inventory SBOM. If you have a real +Zephyr build and want the exact compiled config, generate the SBOM from that +build's compilation database with compdb_sbom.py instead. + +Usage: + zephyr_sbom.py --name NAME --cmakelists path/to/zephyr/CMakeLists.txt [options] +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_DRIVER = os.path.join(os.path.dirname(SCRIPT_DIR), "sbom-driver") + +SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') + + +def parse_library_sources(cmakelists): + """Extract the file list from the zephyr_library_sources(...) blocks.""" + with open(cmakelists) as f: + text = f.read() + module_dir = os.path.dirname(os.path.abspath(cmakelists)) + + srcs = [] + for m in re.finditer(r'zephyr_library_sources\s*\((.*?)\)', text, re.DOTALL): + for raw in m.group(1).split(): + raw = raw.strip() + if not raw or not raw.lower().endswith(SRC_EXTS): + continue + # Resolve the common CMake module-directory variables. + p = raw.replace('${CMAKE_CURRENT_LIST_DIR}', module_dir) + p = p.replace('${CMAKE_CURRENT_SOURCE_DIR}', module_dir) + p = p.replace('${ZEPHYR_CURRENT_MODULE_DIR}', os.path.dirname(module_dir)) + if '${' in p: + sys.stderr.write(f"WARNING: skipping unresolved source: {raw}\n") + continue + if not os.path.isabs(p): + p = os.path.join(module_dir, p) + srcs.append(os.path.normpath(p)) + + seen, present, missing = set(), [], [] + for s in srcs: + if s in seen: + continue + seen.add(s) + (present if os.path.isfile(s) else missing).append(s) + return present, missing + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--name', required=True, help='Package name for the SBOM.') + ap.add_argument('--cmakelists', required=True, + help='Path to the module CMakeLists.txt.') + ap.add_argument('--driver', default=DEFAULT_DRIVER) + ap.add_argument('--gen-sbom', default=None) + ap.add_argument('--version', default=None) + ap.add_argument('--version-file', default=None) + ap.add_argument('--version-macro', default=None) + ap.add_argument('--root', default=None) + ap.add_argument('--license-file', default=None) + ap.add_argument('--cdx-out', default=None) + ap.add_argument('--spdx-out', default=None) + ap.add_argument('--srcs-out', default=None) + ap.add_argument('--print-only', action='store_true') + args = ap.parse_args() + + if not os.path.isfile(args.cmakelists): + sys.exit(f"ERROR: CMakeLists not found: {args.cmakelists}") + + srcs, missing = parse_library_sources(args.cmakelists) + if missing: + sys.stderr.write( + f"WARNING: {len(missing)} module source(s) not found on disk " + f"(excluded):\n") + for m in missing: + sys.stderr.write(f" - {m}\n") + if not srcs: + sys.exit("ERROR: no zephyr_library_sources found in " + args.cmakelists) + + if args.print_only: + print(f"# {args.name}: {len(srcs)} sources") + for s in srcs: + print(f" {s}") + return + + srcs_out, tmp = args.srcs_out, None + if not srcs_out: + fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-zephyr-srcs-', suffix='.txt') + os.close(fd) + tmp = srcs_out + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + + cmd = [args.driver, '--srcs-file', srcs_out, '--source-only', + '--name', args.name] + for flag, val in (('--version', args.version), + ('--version-file', args.version_file), + ('--version-macro', args.version_macro), + ('--root', args.root), + ('--license-file', args.license_file), + ('--gen-sbom', args.gen_sbom), + ('--cdx-out', args.cdx_out), + ('--spdx-out', args.spdx_out)): + if val: + cmd += [flag, val] + + print(f"Zephyr module SBOM: {len(srcs)} sources (source-inventory)") + try: + rc = subprocess.call(cmd) + finally: + if tmp and os.path.exists(tmp): + os.remove(tmp) + sys.exit(rc) + + +if __name__ == '__main__': + main() diff --git a/tools/sbom/gen-sbom b/tools/sbom/gen-sbom new file mode 100755 index 0000000000..f90b3f450e --- /dev/null +++ b/tools/sbom/gen-sbom @@ -0,0 +1,1410 @@ +#!/usr/bin/env python3 +"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.""" + +import argparse +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import uuid +from datetime import datetime, timezone + + +# Tool identification. Bump GEN_SBOM_VERSION whenever the SBOM output +# shape changes in any auditor-visible way (new property, new field, +# semantic change to an existing one) so downstream consumers can pin +# their parser against a known producer. Carried in the CycloneDX +# `metadata.tools.components[].version` and SPDX `creationInfo.creators` +# fields. Reproducibility CI keys on byte-equal SBOMs across re-runs, +# so this constant must change in lockstep with the output it produces. +GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen' +GEN_SBOM_VERSION = '1.2' + +# Placeholder recorded in the component checksum fields when the operator +# passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only +# redistribution) where neither a library archive nor the compiled source +# files are accessible to hash. 64 zero hex digits is an obviously-synthetic +# SHA-256 that can never collide with a real artefact, and the companion +# `wolfssl:sbom:hash-source=none` property plus the note below tell a +# downstream auditor the value is intentional, not a generation bug. +_NO_HASH_SENTINEL = '0' * 64 +_NO_HASH_NOTE = ( + 'No artefact hash was available at SBOM generation time ' + '(--no-artifact-hash). The checksum field is a placeholder, not a real ' + 'SHA-256 of any wolfSSL component. Contact wolfssl@wolfssl.com to ' + 'arrange integrity verification appropriate to this build before relying ' + 'on this SBOM for CRA conformance.' +) + +# Stable namespace for deterministic uuid5 derivation. The seed string is +# an opaque input to uuid5 -- it only needs to be (a) constant across +# releases so the derived UUIDs reproduce byte-for-byte (any consumer +# pinning a wolfSSL SBOM hash would otherwise see a content rotation +# from a seed change alone), and (b) unlikely to collide with another +# project's uuid5 namespace. It is NOT a URL the SBOM resolves to and +# is NOT what we serialize as the SPDX documentNamespace -- that field +# is now `urn:uuid:` (see generate_spdx). The historical +# string is preserved verbatim to keep derived UUIDs (bom-refs, +# serialNumbers, the documentNamespace UUID component) stable across +# the documentNamespace shape change. +SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/') + + +def project_urls(name): + """Canonical wolfSSL GitHub URLs for a project, derived from its package + name. Keeping these name-derived (rather than hardcoded to wolfssl) lets + the same generator emit correct VCS / issue-tracker / advisory / download + URLs for every product in the wolfSSL stack (wolfssl, wolfssh, wolfmqtt, + ...). For name='wolfssl' the result is byte-identical to the historical + hardcoded URLs, so existing wolfSSL SBOMs do not change.""" + base = f'https://github.com/wolfSSL/{name}' + return { + 'vcs': base, + 'issues': f'{base}/issues', + 'advisories': f'{base}/security/advisories', + } + + +def derived_uuid(*parts): + """Deterministic UUID from joined parts under the wolfSSL SBOM namespace. + Re-runs of `make sbom` against the same source produce identical UUIDs, + which is required for reproducible-build-style SBOM hashing. + + Uses NUL as a separator so no aliasing is possible between e.g. + derived_uuid('a/b', 'c') and derived_uuid('a', 'b/c'); NUL cannot + appear in any of the call-site inputs (package name, version, role + label, dep key).""" + return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts))) + + +def build_timestamp(): + """Return (datetime, ISO-8601-Z string) honoring SOURCE_DATE_EPOCH. + Reproducible Builds convention: if the env var is set to a valid + integer, use it as the SBOM creation timestamp instead of wallclock.""" + sde = os.environ.get('SOURCE_DATE_EPOCH', '').strip() + if sde: + try: + dt = datetime.fromtimestamp(int(sde), tz=timezone.utc) + except (ValueError, OverflowError, OSError) as e: + print(f"WARNING: ignoring invalid SOURCE_DATE_EPOCH={sde!r}: {e}", + file=sys.stderr) + dt = datetime.now(timezone.utc) + else: + dt = datetime.now(timezone.utc) + return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ') + + +# Known metadata for optional external dependencies. Version is detected +# at runtime via pkg-config; falls back to None. Each entry must describe +# the *linked artefact* (so vulnerability scanners like OSV / Grype / Trivy +# / Dependency-Track resolve CVEs against the right package). Algorithm +# enablement is captured separately via build_props (HAVE_FALCON, ...). +DEP_META = { + # wolfssl itself, declared as a dependency by downstream wolfSSL-stack + # products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl. Only + # emitted when the caller passes --dep-wolfssl yes; wolfSSL's own + # `make sbom` never enables it (a package is not its own dependency). + # Recording it is what lets a CRA / vulnerability scanner associate + # wolfSSL advisories with a product that embeds wolfSSL. + 'wolfssl': { + 'name': 'wolfssl', + 'supplier': 'wolfSSL Inc.', + # wolfSSL is distributed under GPLv3 (LICENSING: "version 3 (GPLv3)", + # no "or later"), with a commercial option. This matches what + # detect_license() infers for wolfSSL's own main-package SBOM, so a + # downstream product's wolfssl dependency entry and wolfSSL's own + # self-SBOM agree on the licence. + 'license': 'GPL-3.0-only', + 'download': 'https://github.com/wolfSSL/wolfssl', + 'pkgconfig': 'wolfssl', + 'purl': lambda v: f'pkg:github/wolfSSL/wolfssl@v{v}', + }, + # liboqs is the only PQ external dependency wolfSSL still links against + # after upstream PR #10293 collapsed the rest of the PQ surface into + # native wolfCrypt. Today, --enable-falcon strictly implies --with-liboqs + # (configure.ac enforces both directions), so a build that links liboqs + # is precisely a build that exposed Falcon. + 'liboqs': { + 'name': 'liboqs', + 'supplier': 'Open Quantum Safe', + 'license': 'MIT', + 'download': 'https://github.com/open-quantum-safe/liboqs', + 'pkgconfig': 'liboqs', + 'purl': lambda v: f'pkg:github/open-quantum-safe/liboqs@{v}', + }, + 'libz': { + 'name': 'zlib', + 'supplier': 'Jean-loup Gailly and Mark Adler', + 'license': 'Zlib', + 'download': 'https://github.com/madler/zlib', + 'pkgconfig': 'zlib', + # pkg:github resolves in OSV / GHSA / Snyk / Trivy without the + # vendor:product mapping a pkg:generic PURL would force. + 'purl': lambda v: f'pkg:github/madler/zlib@{v}', + }, + # openssl, declared as a dependency by the OpenSSL-compat products + # (wolfProvider, wolfEngine) that link libcrypto/libssl alongside wolfSSL. + # Only emitted when the caller passes --dep-openssl yes. These products + # target the OpenSSL 3.x provider/engine ABI, which is Apache-2.0 (older + # 1.1.x was the SPDX "OpenSSL" licence); Apache-2.0 is therefore the correct + # id for the supported surface. The purl uses OpenSSL 3.x's "openssl-X.Y.Z" + # git tag form so it resolves in OSV / GHSA. + 'openssl': { + 'name': 'openssl', + 'supplier': 'OpenSSL Software Foundation', + 'license': 'Apache-2.0', + 'download': 'https://github.com/openssl/openssl', + 'pkgconfig': 'openssl', + 'purl': lambda v: f'pkg:github/openssl/openssl@openssl-{v}', + }, +} + + +# Matches a single SPDX `LicenseRef-` identifier as defined in SPDX 2.3 +# Annex D ("idstring = 1*(ALPHA / DIGIT / '-' / '.')"). We use this to +# discover custom license refs inside an arbitrary SPDX expression and to +# decide whether a `licenseConcluded` value needs an accompanying +# `hasExtractedLicensingInfos` block. +LICENSEREF_RE = re.compile(r'LicenseRef-[A-Za-z0-9.\-]+') + +# Matches a "simple" SPDX-listed license ID such as `GPL-2.0-or-later` or +# `MIT` (no spaces, no operators, no LicenseRef-). Anything that does not +# match must be expressed via `licenses[].license.name` / `licenses[].expression` +# in CycloneDX, since `license.id` is restricted to the SPDX licence list. +SIMPLE_SPDX_ID_RE = re.compile(r'\A[A-Za-z0-9.+\-]+\Z') + + +def is_simple_spdx_id(value): + return bool(SIMPLE_SPDX_ID_RE.match(value)) and \ + not value.startswith('LicenseRef-') and value != 'NOASSERTION' + + +def extract_license_refs(expr): + """Return a sorted, deduplicated list of LicenseRef-* IDs found in expr.""" + return sorted(set(LICENSEREF_RE.findall(expr or ''))) + + +def load_license_text(path): + """Read the license text file given via --license-text, exit on error.""" + if not path: + return None + try: + with open(path) as f: + return f.read() + except OSError as e: + sys.exit(f"ERROR: cannot read --license-text {path}: {e}") + + +def build_extracted_licensing_infos(license_expr, license_text): + """Return SPDX `hasExtractedLicensingInfos` array for license_expr. + + SPDX 2.3 §10 requires every LicenseRef-* used in `licenseConcluded`/ + `licenseDeclared` to be declared once at document level via + `hasExtractedLicensingInfos`. Returns None when no LicenseRef-* is + present so the caller can omit the field entirely. + + `license_text=None` produces a placeholder entry; main() rejects + that combination upfront, so this fallback is only reachable from + direct programmatic callers (e.g. tests, library reuse). + """ + refs = extract_license_refs(license_expr) + if not refs: + return None + if license_text is None: + license_text = ( + 'NOASSERTION. The text for this LicenseRef has not been ' + 'embedded in the SBOM. Provide it via the gen-sbom ' + '--license-text PATH flag (or `make sbom SBOM_LICENSE_TEXT=...`).' + ) + infos = [] + for ref in refs: + infos.append({ + 'licenseId': ref, + 'extractedText': license_text, + 'name': ref[len('LicenseRef-'):].replace('-', ' ').strip(), + }) + return infos + + +def cdx_license_block(license_expr, license_text): + """Return the CycloneDX `licenses[]` entry for an arbitrary SPDX + expression. CDX 1.6 distinguishes: + * `license.id` - an entry from the SPDX licence list + * `license.name` - a non-listed licence (e.g. a LicenseRef-*) + * `expression` - a compound SPDX expression + Picking the wrong shape causes downstream tooling to reject the SBOM.""" + # NOASSERTION is a reserved SPDX value, not a parseable SPDX expression; + # emit it via license.name so CDX validators don't choke trying to parse + # it as one. + if license_expr == 'NOASSERTION': + return [{'license': {'name': 'NOASSERTION'}}] + if is_simple_spdx_id(license_expr): + return [{'license': {'id': license_expr}}] + refs = extract_license_refs(license_expr) + if len(refs) == 1 and refs[0] == license_expr: + block = {'name': license_expr} + if license_text: + block['text'] = {'contentType': 'text/plain', 'content': license_text} + return [{'license': block}] + return [{'expression': license_expr}] + + +def detect_license(license_file): + """Parse LICENSING file and return an SPDX license ID. + + Looks for 'GNU General Public License version N' and whether + 'or later' / 'or any later version' follows. Returns None and + prints a warning if the file cannot be parsed. + """ + try: + with open(license_file) as f: + text = f.read() + except OSError as e: + print(f"WARNING: cannot read license file {license_file}: {e}", + file=sys.stderr) + return None + + m = re.search( + r'gnu general public license\s+version\s+(\d+)', + text, re.IGNORECASE + ) + or_later_plus = False + if not m: + # Abbreviated form: some wolfSSL-stack LICENSING files (e.g. wolfSSH) + # say "GPLv3" rather than the canonical "GNU General Public License + # version 3", so the long-form regex above misses and detection would + # fall back to NOASSERTION. A trailing "+" (GPLv3+) denotes the + # or-later variant; otherwise fall through to the shared "or later" + # prose check below. + m = re.search(r'\bGPLv(\d+)(\+)?', text, re.IGNORECASE) + if m and m.group(2) == '+': + or_later_plus = True + if not m: + print(f"WARNING: no GPL version found in {license_file}", + file=sys.stderr) + return None + + version = m.group(1) + if or_later_plus: + return f'GPL-{version}.0-or-later' + excerpt = text[m.end():m.end() + 100] + # Match upgrade-permission wording in the 100-byte excerpt that + # follows the version mention. Three FSF-derived shapes: + # * canonical preamble: "or (at your option) any later version" + # * preamble variant: "or (at the licensee's option) any later" + # * compact form: "or later" / "or any later" + # The optional `[^,.;\n]*?\s+` group consumes parenthesised + # asides without crossing sentence boundaries so unrelated + # "or" / "later" mentions in surrounding prose do not match. + if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later', + excerpt, re.IGNORECASE): + return f'GPL-{version}.0-or-later' + return f'GPL-{version}.0-only' + + +def sha256_file(path): + h = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + except OSError as e: + sys.exit(f"ERROR: cannot read library for hashing: {e}") + return h.hexdigest() + + +def sha1_sha256_file(path): + """Return (sha1_hex, sha256_hex) computed in a single pass. + SPDX 2.3 §8.4 requires SHA-1 on every file entry (`packageFileChecksum` + cardinality 1..*, with SHA-1 mandatory). CycloneDX accepts either. + Reading the file twice would double the I/O on builds with many + source files; one pass keeps `make sbom` fast on embedded trees.""" + s1 = hashlib.sha1() + s256 = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + s1.update(chunk) + s256.update(chunk) + except OSError as e: + sys.exit(f"ERROR: cannot read file for hashing: {e}") + return s1.hexdigest(), s256.hexdigest() + + + + +def pkgconfig_version(pkgname): + """Return version string from pkg-config, or None if unavailable.""" + try: + r = subprocess.run( + ['pkg-config', '--modversion', pkgname], + capture_output=True, text=True + ) + if r.returncode == 0: + return r.stdout.strip() + except FileNotFoundError: + pass + return None + + +def dep_version(key, overrides=None): + """Resolve the runtime version of a DEP_META entry. + + Resolution order: + 1. Explicit override from `overrides[key]` (set via the + --dep-version CLI flag). This is the only path that works + for embedded / cross-compile builds where pkg-config is not + available on the host that runs gen-sbom. + 2. `pkg-config --modversion `. Used by the autotools + path on a typical Linux server where the linked dep was + installed via the system package manager. + 3. None. Caller emits NOASSERTION (SPDX) / omits the version + (CycloneDX). + + A previous source-tree fallback that used `git describe` against + `git_root` was removed once libxmss/liblms were dropped upstream; + if a future PQ dep returns to a source-only integration, restore + the fallback here together with a `git_root` field on the DEP_META + entry.""" + if overrides and key in overrides: + return overrides[key] + return pkgconfig_version(DEP_META[key]['pkgconfig']) + + +# Patterns for #define names that pollute the SBOM with build-environment +# noise rather than wolfSSL configuration. Applied identically to +# parse_options_h (no-pcpp / autotools path) and parse_user_settings +# (pcpp embedded path) so both entry points produce semantically +# equivalent build-property sets for the same effective configuration. +# +# Three families are filtered: +# +# 1. Compiler / preprocessor reserved identifiers (`__*`, `_[A-Z]*`). +# ISO C 7.1.3 reserves these for the implementation; clang, gcc, and +# pcpp emit dozens of them (`__VERSION__`, `__SSE2__`, `_LP64`, ...). +# They describe the build *host*, not wolfSSL, and break SBOM +# reproducibility across hosts (same wolfSSL config built on macOS +# clang vs. arm-none-eabi-gcc otherwise produces different SBOMs). +# +# 2. Apple macros (`TARGET_OS_*`, +# `TARGET_IPHONE_*`). The no-pcpp escape hatch +# (`$CC -dM -E -include settings.h`) on macOS transitively pulls in +# macOS system headers and emits this entire family; without the +# filter, a wolfSSL SBOM for an STM32 firmware would falsely +# advertise TARGET_OS_MAC=1 if generated on a Mac. +# +# 3. Header include guards (`*_H` whose token does NOT carry an +# autoconf / wolfSSL configuration prefix). +# wolfssl/options.h itself and many internal wolfSSL headers define +# guards like WOLFSSL_OPTIONS_H, WOLF_CRYPT_SETTINGS_H, and +# WOLFCRYPT_TEST_*_H to prevent double inclusion. Those describe +# *which file was parsed*, not configuration choices. +# +# The carve-out tokens (`HAVE_`, `NO_`, `USE_`) are critical: real +# wolfSSL configuration flags also end in `_H` and would otherwise +# be silently filtered out, falsifying the SBOM for the customers +# who rely on them most: +# +# * `HAVE_*_H` / `WOLFSSL_HAVE_*_H` - autoconf AC_CHECK_HEADER +# results (HAVE_STDINT_H, WOLFSSL_HAVE_ATOMIC_H, +# WOLFSSL_HAVE_ASSERT_H, ...). Gates `#if defined(...)` +# branches in wc_port.h / types.h. +# * `NO_*_H` / `WOLFSSL_NO_*_H` - explicit stdlib / feature +# suppression (NO_STDINT_H, NO_STDLIB_H, NO_LIMITS_H, +# NO_CTYPE_H, NO_STRING_H, NO_STDDEF_H, WOLFSSL_NO_ASSERT_H). +# Set by NETOS / Telit / other RTOS profiles in settings.h to +# replace stdlib headers with vendor headers; gates branches +# in types.h:398 / settings.h:3850 / sp.h:42. +# * `USE_*_H` - build-mode toggles (USE_FLAT_TEST_H, +# USE_FLAT_BENCHMARK_H). Gates which test/benchmark layout +# is compiled in test.c:165 / benchmark.c:219 / server.c:70. +# +# Heuristic limitation: a stray feature flag that ends in `_H` +# without one of those tokens (e.g. WOLFSSL_DEBUG_TRACE_ERROR_CODES_H, +# a debug-only opt-in) would still be filtered. Customers who +# depend on such a flag can either move it to a non-`_H`-suffixed +# name in their user_settings.h, or feed gen-sbom the full +# `$CC -dM -E` dump via --options-h together with a hand-edited +# add-back file. None of the embedded customer profiles in the +# tree (NETOS, Telit, Zephyr, ESP-IDF, GCC-ARM, MDK, IAR, NUTTX) +# use such flags, which is why we accept the heuristic. +_NOISE_MACRO_RE = re.compile( + r'^(?:' + r'__\w+' # compiler/preprocessor reserved + r'|_[A-Z][A-Z0-9_]*' # ISO C reserved (e.g. _LP64) + r'|TARGET_OS_\w+' # Apple TargetConditionals leak + r'|TARGET_IPHONE_\w+' # Apple TargetConditionals leak + r')$' +) + +# Tokens that, when present anywhere in a `*_H` macro name, mark it as +# real wolfSSL / autoconf configuration rather than a header include +# guard. Kept tight on purpose - widening (e.g. adding `DEBUG_` or +# `WOLFSSL_`) would let through real guards like WOLFSSL_OPTIONS_H. +_CONFIG_H_TOKENS = ('HAVE_', 'NO_', 'USE_') + + +def _is_noise_macro(name): + """True if `name` is a build-environment artefact rather than wolfSSL + configuration, and therefore must not appear as a SBOM + `wolfssl:build:*` property. + + Drops three families (see the module-level comment block on + `_NOISE_MACRO_RE` for full rationale): + 1. Compiler / preprocessor reserved (`__*`, `_[A-Z]*`). + 2. Apple (`TARGET_OS_*`, `TARGET_IPHONE_*`). + 3. Header include guards (`*_H` not carrying any of + `_CONFIG_H_TOKENS`). + """ + if _NOISE_MACRO_RE.match(name): + return True + if name.endswith('_H') and not any(t in name for t in _CONFIG_H_TOKENS): + return True + return False + + +def _strip_define_comment(raw): + """Strip trailing C/C++ comment from a #define value while preserving + `/`-bearing characters that appear inside a double-quoted string. + + Earlier versions used `re.split(r'/\\*|//', raw, maxsplit=1)[0]`, which + is unaware of string literals. That regex corrupts autoconf-generated + defines such as + + #define PACKAGE_URL "https://www.wolfssl.com" + #define PACKAGE_BUGREPORT "https://github.com/wolfssl/wolfssl/issues" + + by truncating at the first `//` inside the URL — both end up as + `"https:` in the SBOM build properties, falsely showing PACKAGE_URL + drifting between releases when nothing actually changed. + + Char literals are not handled: autoconf-generated options.h does not + emit them, and pcpp normalises customer user_settings.h before this + helper sees the value, so the only realistic source of `/` in a + #define value is a quoted string.""" + in_str = False + i = 0 + n = len(raw) + while i < n: + c = raw[i] + if in_str: + if c == '\\' and i + 1 < n: + i += 2 + continue + if c == '"': + in_str = False + else: + if c == '"': + in_str = True + elif c == '/' and i + 1 < n and raw[i + 1] in '/*': + return raw[:i] + i += 1 + return raw + + +def parse_options_h(path): + """Parse a flat `#define` header and return a sorted deduplicated + list of (name, value) pairs for every wolfSSL-relevant macro. + + Accepts both autotools-generated `wolfssl/options.h` (curated by + ./configure, contains only wolfSSL macros plus its own header guard) + and raw compiler output from `$CC -dM -E -include settings.h ...` + (the no-pcpp escape hatch documented in doc/SBOM.md § 1.5). The + latter case motivates the `_is_noise_macro` filter: a `clang -dM -E` + dump contains hundreds of compiler internals (`__VERSION__`, + `__SSE2__`, `__INT_FAST32_MAX__`) and Apple system header leaks + (`TARGET_OS_MAC`) that would otherwise drown out the wolfSSL + configuration in the SBOM and break reproducibility across hosts. + + Trailing C/C++ comments on a #define line (`#define HAVE_FOO 42 /* x */` + or `// y`) are stripped; otherwise they would land verbatim in the + SBOM build properties. String literals are preserved intact so that + URLs in PACKAGE_URL / PACKAGE_BUGREPORT are not truncated at the + first `//` (see _strip_define_comment).""" + try: + with open(path) as f: + text = f.read() + except OSError as e: + print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr) + return [] + + defines = {} + for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.*))?$', text, re.MULTILINE): + name = m.group(1) + if _is_noise_macro(name): + continue + raw = (m.group(2) or '') + raw = _strip_define_comment(raw) + defines[name] = raw.strip() + return sorted(defines.items()) + + +def parse_user_settings(settings_h_path, include_dirs, predefines): + """Walk wolfssl/wolfcrypt/settings.h through pcpp and return the same + sorted [(name, value), ...] list shape that parse_options_h() returns. + + The customer's user_settings.h is included transitively via the + standard `#ifdef WOLFSSL_USER_SETTINGS` gate inside settings.h, so the + caller predefines `WOLFSSL_USER_SETTINGS` and adds the directory of + user_settings.h to `include_dirs`. This mirrors the way the C compiler + actually sees the wolfSSL build, so the SBOM build properties reflect + the real compiled configuration rather than just the literal text of + user_settings.h. + + Filters (see `_is_noise_macro` for the shared family list used by + both this function and parse_options_h): + * compiler/preprocessor reserved names (`__*`, `_[A-Z]*`). pcpp's + own internals (__DATE__/__TIME__/__PCPP__/__FILE__) and any host + compiler defines transitively leaking through pcpp's preprocess + would otherwise break reproducibility across build hosts. + * Apple macros (`TARGET_OS_*`, + `TARGET_IPHONE_*`). Defensive: pcpp does not auto-include + system headers, but a customer's user_settings.h may. + * header guards (`*_H` whose token does not carry an autoconf / + wolfSSL config prefix - see _CONFIG_H_TOKENS). wolfSSL's own + settings.h / visibility.h emit guards like + WOLF_CRYPT_SETTINGS_H that describe inclusion, not + configuration; real `_H` configuration flags (NO_STDINT_H, + USE_FLAT_TEST_H, WOLFSSL_NO_ASSERT_H) are preserved. + * function-like macros are dropped (they are API surface, not + build configuration; including their post-expansion body would + also break reproducibility under whitespace/token-render drift). + + pcpp is imported lazily so the autotools path (which uses + parse_options_h) does not require the dependency. + """ + try: + from pcpp import Preprocessor + except ImportError: + sys.exit( + "ERROR: --user-settings requires the 'pcpp' Python preprocessor.\n" + " Install: pip install pcpp\n" + " Or pre-process externally and pass the result via " + "--options-h instead\n" + " (e.g. $CC -dM -E -include wolfssl/wolfcrypt/settings.h " + "-DWOLFSSL_USER_SETTINGS - < /dev/null)." + ) + + pp = Preprocessor() + pp.line_directive = None + for d in include_dirs: + pp.add_path(d) + for predefine in predefines: + # Compiler-style `-D KEY=VALUE` is the universal CLI shape; + # translate to the `"KEY VALUE"` form pcpp.define() expects. + # Bare `-D KEY` (no value) maps to `"KEY"`, also accepted. + spec = predefine.replace('=', ' ', 1) if '=' in predefine else predefine + pp.define(spec) + + try: + with open(settings_h_path) as f: + text = f.read() + except OSError as e: + sys.exit(f"ERROR: cannot read settings.h {settings_h_path}: {e}") + + pp.parse(text, source=settings_h_path) + # pcpp.write() is what actually drives the preprocessor through #if / + # #ifdef resolution and populates pp.macros with the surviving + # defines. The output stream is intentionally discarded - we only + # care about pp.macros - but this call is NOT optional. + sink = io.StringIO() + pp.write(sink) + + # pcpp signals fatal preprocessing problems (an `#error` directive + # firing, an unbalanced `#if`, a missing #include, etc.) by setting + # pp.return_code to non-zero and printing to stderr; it does NOT + # raise. For an SBOM tool whose contract is "this artefact + # faithfully describes the build", a partial macro table produced + # before the failure is the worst possible output - the SBOM would + # silently omit configuration the customer set. Hard-fail instead + # so the build pipeline notices. + if pp.return_code != 0: + sys.exit( + f"ERROR: pcpp failed to preprocess {settings_h_path} " + f"(return_code={pp.return_code}); the resulting SBOM would " + f"be incomplete. Check the pcpp diagnostics printed above " + f"for the offending #error / #include / #if directive." + ) + + defines = {} + for name, macro in pp.macros.items(): + if _is_noise_macro(name): + continue + if macro.arglist is not None: + continue + tokens = macro.value or [] + defines[name] = ' '.join(t.value for t in tokens).strip() + return sorted(defines.items()) + + +def gitoid_blob_sha256(path): + """Compute the OmniBOR / git SHA-256 gitoid for a single file. + + The format is `sha256("blob " + filesize + "\\0" + filecontents)` + which is byte-identical to `git hash-object --object-format=sha256`. + Using the gitoid (rather than a plain SHA-256) lets the source-set + Merkle hash interoperate with bomsh/OmniBOR tooling: a customer can + cross-reference the wolfSSL SBOM's component hash with the entries + in an OmniBOR artifact dependency graph and confirm the same files + on both sides. + + The well-known empty-blob gitoid sha256 is + 473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813 + (regression-tested in scripts/test_gen_sbom.py). + """ + h = hashlib.sha256() + try: + with open(path, 'rb') as f: + # Take the size from the open descriptor (not a prior + # os.path.getsize) so the gitoid header length and the bytes + # hashed below come from the same file, with no TOCTOU window. + size = os.fstat(f.fileno()).st_size + h.update(f'blob {size}\x00'.encode()) + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + except OSError as e: + sys.exit(f"ERROR: cannot read source for hashing: {e}") + return h.hexdigest() + + +def srcs_merkle_hash(src_paths): + """Deterministic SHA-256 over a sorted list of (basename, gitoid) + pairs for the given source files. + + Two customers compiling the same wolfSSL release with the same set + of source files get identical hashes regardless of where their + wolfSSL tree lives on disk, the order they passed --srcs, or the + filesystem they built on. Sorting on basename only (not full path) + is what makes this true; collisions across basenames would matter + in theory but wolfSSL's source layout has unique basenames per file + by construction. + + A one-byte change in any compiled-in source produces a different + hash, which is the property that makes this useful as the SBOM + component checksum for embedded builds with no separate library + archive.""" + seen = set() + entries = [] + for path in src_paths: + name = os.path.basename(path) + if name in seen: + sys.exit( + f"ERROR: duplicate basename in --srcs: {name!r}\n" + f" Source files must have unique basenames so the " + f"Merkle hash is order-independent.") + seen.add(name) + entries.append((name, gitoid_blob_sha256(path))) + entries.sort() + h = hashlib.sha256() + for name, oid in entries: + h.update(f'{name}\x00{oid}\n'.encode()) + return h.hexdigest() + + +def _collect_srcs(srcs_args, srcs_file): + """Merge the --srcs list and the --srcs-file list into one ordered, + path-deduplicated list of source files. + + --srcs-file is the file-driven companion to --srcs: one path per line, + with blank lines and `#` comment lines ignored. It exists because an + embedded link line can run to hundreds of wolfSSL .c files -- more than + fits comfortably on a command line -- and because an IDE / build system + can emit such a list mechanically (from a link map or project export), + which is exactly how a *complete* source set should be produced rather + than hand-curated. + + Identical paths appearing in both inputs are collapsed (first occurrence + wins) so that combining a base --srcs-file with a couple of extra --srcs + overrides does not trip srcs_merkle_hash's duplicate-basename guard on a + file the operator listed twice by accident. Genuine distinct files that + share a basename are still rejected downstream -- that guard is what keeps + the Merkle hash order-independent. + """ + paths = list(srcs_args or []) + if srcs_file: + try: + with open(srcs_file, 'r') as f: + raw_lines = f.read().splitlines() + except OSError as e: + sys.exit(f"ERROR: cannot read --srcs-file {srcs_file!r}: {e}") + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + paths.append(stripped) + + seen = set() + deduped = [] + for p in paths: + if p not in seen: + seen.add(p) + deduped.append(p) + + if not deduped: + sys.exit( + "ERROR: --srcs / --srcs-file produced an empty source list.\n" + " Pass at least one wolfSSL .c file, or use " + "--no-artifact-hash if no hashable artefact exists.") + return deduped + + +def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None): + """Return (bom_ref, component_dict) for a CDX dependency component. + bom_ref is deterministic for reproducibility.""" + meta = DEP_META[key] + version = dep_version(key, dep_version_overrides) + bom_ref = derived_uuid(name, pkg_version, 'dep', key) + comp = { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': meta['supplier']}, + 'name': meta['name'], + 'licenses': [{'license': {'id': meta['license']}}], + 'externalReferences': [{'type': 'vcs', 'url': meta['download']}], + } + if version: + comp['version'] = version + comp['purl'] = meta['purl'](version) + else: + print(f"WARNING: version unknown for {meta['name']}; " + "omitting version and purl", file=sys.stderr) + return bom_ref, comp + + +def spdx_dep_package(key, dep_version_overrides=None): + """Return (spdx_id, package_dict) for an SPDX dependency package.""" + meta = DEP_META[key] + version = dep_version(key, dep_version_overrides) + spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', meta['name']) + pkg = { + 'SPDXID': spdx_id, + 'name': meta['name'], + 'versionInfo': version if version else 'NOASSERTION', + 'supplier': f"Organization: {meta['supplier']}", + 'downloadLocation': meta['download'], + 'filesAnalyzed': False, + 'licenseConcluded': meta['license'], + 'licenseDeclared': meta['license'], + 'copyrightText': 'NOASSERTION', + } + if version: + pkg['externalRefs'] = [{ + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': meta['purl'](version), + }] + return spdx_id, pkg + + +def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, + timestamp, year, serial, enabled_deps, build_props, + dep_version_overrides=None, hash_kind='library-binary', + hash_source='lib', srcs_basenames=None, file_entries=None): + bom_ref = derived_uuid(name, version, 'package') + urls = project_urls(name) + + dep_bom_refs = [] + components = [] + for key in enabled_deps: + ref, comp = cdx_dep_component(name, version, key, dep_version_overrides) + dep_bom_refs.append(ref) + components.append(comp) + + properties = [ + {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} + for k, v in build_props + ] + # Document what the SHA-256 in `hashes` represents, on every entry + # point. Without this property an auditor reading the SBOM has to + # guess whether the SHA-256 is over a library binary, a source-set + # Merkle hash, or something else. Emitting it unconditionally + # turns "what does this hash mean?" from forensic guesswork into + # a single property lookup. + properties.append( + {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind}) + # hash-source is the coarse, stable provenance tag downstream tooling + # keys on: which *input* the checksum came from -- 'lib' (library + # archive), 'srcs' (compiled source set), or 'none' (no hashable + # artefact). hash-kind above carries the finer implementation detail + # (e.g. source-merkle-omnibor); hash-source is the value an integrator + # filters on without needing to know our hashing internals. + properties.append( + {'name': 'wolfssl:sbom:hash-source', 'value': hash_source}) + if hash_source == 'none': + properties.append( + {'name': 'wolfssl:sbom:no-artifact-hash-note', + 'value': _NO_HASH_NOTE}) + if srcs_basenames: + properties.append({ + 'name': 'wolfssl:sbom:source-set', + 'value': ','.join(srcs_basenames), + }) + + main_component = { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': supplier}, + 'name': name, + 'version': version, + 'licenses': cdx_license_block(license_id, license_text), + 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', + 'purl': f'pkg:github/wolfSSL/{name}@v{version}', + 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], + 'externalReferences': [ + {'type': 'vcs', + 'url': urls['vcs']}, + {'type': 'website', + 'url': 'https://www.wolfssl.com/'}, + {'type': 'issue-tracker', + 'url': urls['issues']}, + {'type': 'advisories', + 'url': urls['advisories']}, + {'type': 'security-contact', + 'url': 'https://www.wolfssl.com/.well-known/security.txt'}, + ], + 'properties': properties, + } + # Sub-component file entries (CycloneDX file-typed components nested + # under the library). Autotools paths nest the linked library + # binary so an auditor running a CDX parser can resolve the SHA-256 + # in `hashes` back to a concrete file path; embedded paths skip + # this since the source-set Merkle hash already captures the inputs. + if file_entries: + main_component['components'] = [ + { + 'type': 'file', + 'name': fe['name'], + 'hashes': [ + {'alg': 'SHA-1', 'content': fe['sha1']}, + {'alg': 'SHA-256', 'content': fe['sha256']}, + ], + } + for fe in file_entries + ] + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'serialNumber': f'urn:uuid:{serial}', + 'version': 1, + 'metadata': { + 'timestamp': timestamp, + 'tools': { + 'components': [{ + 'type': 'application', + 'author': 'wolfSSL Inc.', + 'name': GEN_SBOM_TOOL_NAME, + 'version': GEN_SBOM_VERSION, + }] + }, + 'component': main_component, + }, + 'components': components, + 'dependencies': [ + {'ref': bom_ref, 'dependsOn': dep_bom_refs}, + *[{'ref': r, 'dependsOn': []} for r in dep_bom_refs], + ], + } + + +def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, + timestamp, year, doc_ns_uuid, enabled_deps, build_props, + dep_version_overrides=None, hash_kind='library-binary', + hash_source='lib', srcs_basenames=None, + document_namespace=None, file_entries=None): + build_defines = ', '.join(k for k, _ in build_props) + # Hash-kind / source-set / bomsh-traced-binary information used to + # be stuffed into the package `comment` as `key=value` slugs, which + # forced anyone reading the SPDX to grep free-form text. SPDX 2.3 + # §8.5 provides `annotations[]` for exactly this -- structured + # producer notes that validators understand and downstream parsers + # can consume directly. The `comment` field now carries only the + # build-config define list a human reader scans first. + + # Annotations on the wolfssl package: structured producer notes + # that the comment field used to carry as positional `key=value` + # slugs. Covered by the SPDX 2.3 §8.5 schema, so validators see + # them as first-class data instead of opaque text. + annotations = [] + + def _annotate(payload): + annotations.append({ + 'annotationDate': timestamp, + 'annotationType': 'OTHER', + 'annotator': f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}', + 'comment': payload, + }) + + _annotate(f'wolfssl:sbom:hash-kind={hash_kind}') + _annotate(f'wolfssl:sbom:hash-source={hash_source}') + if hash_source == 'none': + _annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}') + if srcs_basenames: + _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) + + urls = project_urls(name) + # Main-package SPDXID derived from --name (sanitised per SPDX 2.3 idstring + # rules) rather than hardcoded to wolfssl, so a wolfSSH/wolfMQTT SBOM does + # not mislabel its own package as wolfssl. For name='wolfssl' the result + # is 'SPDXRef-Package-wolfssl', unchanged from before. + main_spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', name) + + wolfssl_pkg = { + 'SPDXID': main_spdx_id, + 'name': name, + 'versionInfo': version, + 'supplier': f'Organization: {supplier}', + 'downloadLocation': urls['vcs'], + 'filesAnalyzed': False, + 'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}], + 'licenseConcluded': license_id, + 'licenseDeclared': license_id, + 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'comment': f'Build configuration defines: {build_defines}', + 'annotations': annotations, + 'externalRefs': [ + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'cpe23Type', + 'referenceLocator': ( + f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*' + ) + }, + { + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': f'pkg:github/wolfSSL/{name}@v{version}', + }, + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'advisory', + 'referenceLocator': urls['advisories'], + }, + ], + } + + # No SPDX `files[]` / `hasFiles[]` inventory. spdx-tools (the + # validator the autotools `make sbom` recipe runs) treats any + # `hasFiles` linkage as an implicit CONTAINS relationship, and + # SPDX 2.3 forbids package elements when `filesAnalyzed` is False. + # Flipping `filesAnalyzed` to True is not honest for wolfSSL: the + # package contains hundreds of source/header files, of which we + # only enumerate the linked binary, and `packageVerificationCode` + # under §8.10 requires every file in the package to be hashed. + # The CycloneDX side (which is more permissive about file + # sub-components) carries the linked-binary inventory; the SPDX + # side relies on the package-level SHA-256 plus the + # `wolfssl:sbom:hash-kind` annotation to identify the artefact. + # `file_entries` is accepted for parameter symmetry with + # generate_cdx but ignored here; if a future SPDX 2.4 / 3.0 model + # makes file inventory cleanly compatible with `filesAnalyzed: + # False`, this is the place to add it back. + del file_entries # unused on the SPDX side; see comment above. + + packages = [wolfssl_pkg] + relationships = [{ + 'spdxElementId': 'SPDXRef-DOCUMENT', + 'relatedSpdxElement': main_spdx_id, + 'relationshipType': 'DESCRIBES', + }] + + for key in enabled_deps: + spdx_id, pkg = spdx_dep_package(key, dep_version_overrides) + packages.append(pkg) + relationships.append({ + 'spdxElementId': main_spdx_id, + 'relatedSpdxElement': spdx_id, + 'relationshipType': 'DEPENDS_ON', + }) + + # SPDX 2.3 §6.5: documentNamespace must be a unique URI; it is NOT + # required to resolve to anything. Default to `urn:uuid:` + # rather than a `https://wolfssl.com/sbom/...` URL the project does + # not actually host -- emitting an unresolvable URL misleads any + # downstream tool that follows it. Downstream packagers who DO host + # a per-version mirror can override via `--document-namespace` + # (Makefile.am: SBOM_DOCUMENT_NAMESPACE). + doc_namespace = document_namespace or f'urn:uuid:{doc_ns_uuid}' + doc = { + 'spdxVersion': 'SPDX-2.3', + 'dataLicense': 'CC0-1.0', + 'SPDXID': 'SPDXRef-DOCUMENT', + 'name': f'{name}-{version}', + 'documentNamespace': doc_namespace, + 'creationInfo': { + 'creators': [ + f'Organization: {supplier}', + f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}', + ], + 'created': timestamp, + }, + 'packages': packages, + 'relationships': relationships, + } + + extracted = build_extracted_licensing_infos(license_id, license_text) + if extracted: + doc['hasExtractedLicensingInfos'] = extracted + + return doc + + +def _parse_dep_version_overrides(spec_list): + """Parse repeated --dep-version KEY=VERSION flags into a dict. + Rejects unknown keys early so a typo (e.g. --dep-version libssl=…) + does not silently produce an SBOM that omits the dep version.""" + overrides = {} + for spec in spec_list: + if '=' not in spec: + sys.exit( + f"ERROR: --dep-version expects KEY=VERSION, got {spec!r}") + key, _, value = spec.partition('=') + if key not in DEP_META: + sys.exit( + f"ERROR: --dep-version key {key!r} is not a known wolfSSL " + f"dependency. Known keys: {', '.join(sorted(DEP_META))}.") + overrides[key] = value + return overrides + + +def _resolve_dep_versions(enabled_deps, overrides): + """Resolve each enabled dependency's version exactly once, mutating and + returning `overrides` so both the CDX and SPDX emitters reuse the same + value instead of each re-invoking pkg-config. Caching the result + (including None) means a later dep_version() lookup short-circuits on the + membership check rather than re-shelling to `pkg-config --modversion`, so + a default --with-libz --with-liboqs build calls pkg-config once per dep + (not once per dep per output format) and the two documents can never + disagree if pkg-config output were ever non-deterministic.""" + for key in enabled_deps: + if key not in overrides: + overrides[key] = dep_version(key, overrides) + return overrides + + +def main(): + parser = argparse.ArgumentParser( + description='Generate CycloneDX and SPDX SBOMs for wolfssl. ' + 'Supports two entry-point shapes: the autotools / ' + 'library-binary form (--options-h + --lib) used by ' + '`make sbom`, and the standalone embedded form ' + '(--user-settings + --srcs) used by customers who ' + 'build with their own Makefile / IDE and never run ' + './configure.' + ) + parser.add_argument('--name', required=True, help='Package name') + parser.add_argument('--version', required=True, help='Package version') + parser.add_argument('--supplier', default='wolfSSL Inc.', + help='Supplier name (default: wolfSSL Inc.)') + parser.add_argument('--license-file', required=True, + help='Path to LICENSING file for SPDX ID detection') + parser.add_argument('--license-override', default='', + help='Override the detected SPDX license expression ' + '(e.g. LicenseRef-wolfSSL-Commercial). Useful ' + 'for commercial licensees regenerating the SBOM ' + 'for their own product.') + parser.add_argument('--license-text', default='', + help='Path to a plain-text licence file whose ' + 'contents are embedded in the SBOM as the ' + '`extractedText` for any LicenseRef-* used in ' + '`--license-override`. Required by SPDX 2.3 ' + 'validators (e.g. pyspdxtools) for any custom ' + 'licence reference.') + # Build-configuration source: pick exactly one. + parser.add_argument('--options-h', + help='Path to wolfssl/options.h for build config ' + '(autotools entry point). The file is read ' + 'as a flat list of #define directives; pre-' + 'processed `$CC -dM -E -include settings.h` ' + 'output works equivalently.') + parser.add_argument('--user-settings', + help='Path to wolfssl/wolfcrypt/settings.h to walk ' + 'through pcpp (embedded entry point). Combine ' + 'with --user-settings-include to point at the ' + 'directory containing user_settings.h, and ' + '`--user-settings-define WOLFSSL_USER_SETTINGS` ' + 'to enable the user_settings.h inclusion gate.') + parser.add_argument('--user-settings-include', action='append', default=[], + metavar='DIR', + help='Add an include path for --user-settings ' + 'preprocessing (repeatable). Equivalent to -I ' + 'on the compiler command line.') + parser.add_argument('--user-settings-define', action='append', default=[], + metavar='NAME[=VALUE]', + help='Predefine a macro for --user-settings ' + 'preprocessing (repeatable). Equivalent to -D ' + 'on the compiler command line. At minimum ' + 'pass `WOLFSSL_USER_SETTINGS` so settings.h ' + 'pulls in user_settings.h.') + # Component checksum source: pick exactly one. + parser.add_argument('--lib', + help='Path to the wolfSSL library artifact ' + '(shared or static) for SHA-256 hashing ' + '(autotools entry point).') + parser.add_argument('--srcs', nargs='+', default=None, + help='wolfSSL source files compiled into the ' + 'firmware (embedded entry point). Their ' + 'OmniBOR-compatible gitoid Merkle hash is ' + 'used as the SBOM component checksum ' + 'instead of --lib. May be combined with ' + '--srcs-file.') + parser.add_argument('--srcs-file', default=None, metavar='PATH', + help='Path to a file listing wolfSSL source files, ' + 'one per line (blank lines and lines starting ' + 'with `#` are ignored). The file-driven ' + 'companion to --srcs for link lines too long ' + 'for the command line, or lists emitted ' + 'mechanically by an IDE / build system (link ' + 'map, project export). Merged with --srcs and ' + 'hashed the same way.') + parser.add_argument('--no-artifact-hash', action='store_true', + help='Record a placeholder component checksum when ' + 'no hashable artefact exists (ROM image, HSM ' + 'firmware, binary-only redistribution). Emits ' + 'wolfssl:sbom:hash-source=none and a note ' + 'directing integrators to contact wolfSSL. ' + 'Mutually exclusive with --lib / --srcs / ' + '--srcs-file.') + parser.add_argument('--dep-wolfssl', default='no', + help='yes to record wolfssl as a dependency component ' + '(for downstream wolfSSL-stack products such as ' + 'wolfSSH / wolfMQTT that link libwolfssl). ' + 'wolfSSL\'s own SBOM leaves this off. Combine ' + 'with --dep-version wolfssl=X.Y.Z on hosts ' + 'without wolfssl.pc.') + parser.add_argument('--dep-openssl', default='no', + help='yes to record openssl as a dependency component ' + '(for OpenSSL-compat products such as wolfProvider ' + '/ wolfEngine that link libcrypto/libssl). Combine ' + 'with --dep-version openssl=X.Y.Z on hosts without ' + 'openssl.pc.') + parser.add_argument('--dep-libz', default='no', + help='yes if built with --with-libz') + parser.add_argument('--dep-liboqs', default='no', + help='yes if built with --with-liboqs (the package ' + 'wolfSSL links against; --enable-falcon implies ' + 'this in any legal configuration)') + parser.add_argument('--dep-version', action='append', default=[], + metavar='KEY=VERSION', + help='Override pkg-config version detection for a ' + 'dependency (repeatable). KEY is one of: ' + + ', '.join(sorted(DEP_META)) + '. Required ' + 'on hosts without pkg-config (typical embedded ' + 'cross-compile setups).') + parser.add_argument('--document-namespace', default='', + metavar='URI', + help='Override SPDX documentNamespace. Default ' + 'is a deterministic urn:uuid derived from ' + '--name and --version. Set to a URI you ' + 'actually host (e.g. ' + 'https://example.com/sbom/wolfssl-X.Y.Z.spdx.json) ' + 'when re-publishing the SBOM under your own ' + 'distribution. SPDX 2.3 §6.5 requires only ' + 'uniqueness, not resolvability.') + parser.add_argument('--cdx-out', required=True, + help='Output path for CycloneDX JSON') + parser.add_argument('--spdx-out', required=True, + help='Output path for SPDX JSON') + args = parser.parse_args() + + # Mutual exclusion + at-least-one validation for the two entry-point + # shapes. Surfacing this here keeps argparse's --required machinery + # simple and produces a friendlier error than argparse's auto-text. + if bool(args.options_h) == bool(args.user_settings): + sys.exit( + "ERROR: pass exactly one of --options-h or --user-settings.\n" + " --options-h: autotools entry point (a flat #define file " + "such as wolfssl/options.h).\n" + " --user-settings: embedded entry point (path to " + "wolfssl/wolfcrypt/settings.h, with --user-settings-include " + "pointing at the directory containing user_settings.h).") + srcs_provided = bool(args.srcs) or bool(args.srcs_file) + hash_sources = [bool(args.lib), srcs_provided, bool(args.no_artifact_hash)] + if sum(hash_sources) != 1: + sys.exit( + "ERROR: pass exactly one component-checksum source.\n" + " --lib: hash a built library artefact (.so/.a/.dylib).\n" + " --srcs / --srcs-file: hash the wolfSSL source files " + "compiled into your firmware (OmniBOR gitoid Merkle hash).\n" + " --no-artifact-hash: record a placeholder when no " + "hashable artefact exists (ROM/HSM/binary-only).") + + # SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI + # per RFC 3986. `make sbom` runs pyspdxtools afterwards and would + # catch a malformed value, but the standalone entry point has no + # validation gate -- a typo in SBOM_DOCUMENT_NAMESPACE / a packager + # passing a relative path would otherwise land malformed SPDX in + # downstream artefacts. An absolute URI per RFC 3986 §3 has a + # non-empty scheme; urlparse extracts that. + if args.document_namespace: + from urllib.parse import urlparse + scheme = urlparse(args.document_namespace).scheme + if not scheme: + sys.exit( + f"ERROR: --document-namespace {args.document_namespace!r} " + "is not an absolute URI (SPDX 2.3 §6.5 requires RFC 3986 " + "absolute URI form). Expected e.g. " + "https://example.com/sbom/wolfssl-X.Y.Z.spdx.json or " + "urn:uuid:00000000-0000-0000-0000-000000000000.") + + enabled_deps = [ + key for key, flag in [ + ('wolfssl', args.dep_wolfssl), + ('openssl', args.dep_openssl), + ('libz', args.dep_libz), + ('liboqs', args.dep_liboqs), + ] + if flag.lower() == 'yes' + ] + dep_version_overrides = _parse_dep_version_overrides(args.dep_version) + # Resolve each enabled dependency's version once, here, and feed the + # result to both the CDX and SPDX emitters via the overrides map (see + # _resolve_dep_versions for the once-per-dep pkg-config rationale). + _resolve_dep_versions(enabled_deps, dep_version_overrides) + + if args.license_override: + license_id = args.license_override + else: + license_id = detect_license(args.license_file) + if license_id is None: + print("WARNING: license could not be determined; using NOASSERTION", + file=sys.stderr) + license_id = 'NOASSERTION' + + license_text = load_license_text(args.license_text) + if extract_license_refs(license_id) and license_text is None: + sys.exit( + "ERROR: --license-override contains a LicenseRef-* identifier " + "but --license-text was not provided.\n" + " SPDX 2.3 requires the licence text to be embedded in " + "hasExtractedLicensingInfos for any LicenseRef-* used in " + "licenseConcluded/licenseDeclared.\n" + " Re-run with --license-text PATH (or " + "`make sbom SBOM_LICENSE_TEXT=PATH`)." + ) + + if args.options_h: + build_props = parse_options_h(args.options_h) + else: + build_props = parse_user_settings( + args.user_settings, + args.user_settings_include, + args.user_settings_define, + ) + + file_entries = None + if args.lib: + # Refuse the empty-file SHA-256 as a component checksum. A + # build that points --lib at /dev/null, a stub touch(1)'d + # placeholder, or an empty .a that failed to ar-create would + # otherwise emit a valid-looking SBOM whose hash matches no + # compiled wolfSSL artefact ever shipped. The SBOM passes + # both spec validators -- nothing else catches it. + try: + lib_size = os.path.getsize(args.lib) + except OSError as e: + sys.exit(f"ERROR: cannot stat --lib {args.lib!r}: {e}") + if lib_size == 0: + sys.exit( + f"ERROR: --lib {args.lib!r} is empty (0 bytes); refusing " + "to emit an SBOM with the empty-file SHA-256 as the " + "component checksum. Verify your build produced a " + "real library artefact.") + lib_sha1, lib_hash = sha1_sha256_file(args.lib) + hash_kind = 'library-binary' + hash_source = 'lib' + srcs_basenames = None + # Single SPDX file entry / CycloneDX file sub-component for + # the linked library, so the SBOM names the artefact whose + # SHA-256 it is reporting (rather than only carrying the hash + # in `checksums[]`). Auditors and downstream tooling can + # then cross-reference the binary by its canonical filename + # without out-of-band knowledge of the build layout. + file_entries = [{ + 'name': os.path.basename(args.lib), + 'sha1': lib_sha1, + 'sha256': lib_hash, + }] + elif args.no_artifact_hash: + # No hashable artefact available (ROM image, HSM firmware, + # binary-only redistribution). Record an obviously-synthetic + # placeholder rather than a real SHA-256, flagged by both the + # hash-source property and the contact note so a downstream + # auditor cannot mistake it for a genuine artefact digest. + print( + "NOTE: --no-artifact-hash: recording a placeholder component " + "checksum (no library or source set to hash). Contact " + "wolfssl@wolfssl.com for integrity verification options.", + file=sys.stderr) + lib_hash = _NO_HASH_SENTINEL + hash_kind = 'none' + hash_source = 'none' + srcs_basenames = None + else: + # --srcs / --srcs-file is the embedded entry point. Zero-byte + # files in the set are uncommon but not necessarily wrong (a + # cross-compile toolchain may stub a per-target source with + # touch); warn rather than fail so the customer can decide + # whether the gitoid for an empty blob is what they want + # recorded. + srcs = _collect_srcs(args.srcs, args.srcs_file) + zero_byte_srcs = [ + p for p in srcs if os.path.isfile(p) and os.path.getsize(p) == 0 + ] + if zero_byte_srcs: + print( + "WARNING: zero-byte source files in --srcs (gitoid will " + "be the well-known empty-blob hash for these): " + + ', '.join(zero_byte_srcs), + file=sys.stderr) + lib_hash = srcs_merkle_hash(srcs) + hash_kind = 'source-merkle-omnibor' + hash_source = 'srcs' + srcs_basenames = sorted({os.path.basename(p) for p in srcs}) + + dt, timestamp = build_timestamp() + year = dt.year + serial = derived_uuid(args.name, args.version, 'serial') + doc_ns_uuid = derived_uuid(args.name, args.version, 'document') + + cdx = generate_cdx( + args.name, args.version, args.supplier, + license_id, license_text, lib_hash, timestamp, year, serial, + enabled_deps, build_props, + dep_version_overrides=dep_version_overrides, + hash_kind=hash_kind, hash_source=hash_source, + srcs_basenames=srcs_basenames, + file_entries=file_entries, + ) + spdx = generate_spdx( + args.name, args.version, args.supplier, + license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, + enabled_deps, build_props, + dep_version_overrides=dep_version_overrides, + hash_kind=hash_kind, hash_source=hash_source, + srcs_basenames=srcs_basenames, + document_namespace=(args.document_namespace or None), + file_entries=file_entries, + ) + + try: + with open(args.cdx_out, 'w') as f: + json.dump(cdx, f, indent=2) + f.write('\n') + with open(args.spdx_out, 'w') as f: + json.dump(spdx, f, indent=2) + f.write('\n') + except OSError as e: + sys.exit(f"ERROR: cannot write SBOM output: {e}") + + print(f"Generated: {args.cdx_out}") + print(f"Generated: {args.spdx_out}") + + +if __name__ == '__main__': + main() diff --git a/tools/sbom/sbom-driver b/tools/sbom/sbom-driver new file mode 100755 index 0000000000..d2322cf627 --- /dev/null +++ b/tools/sbom/sbom-driver @@ -0,0 +1,24 @@ +#!/bin/sh +# wolfGlass SBOM driver - thin shell wrapper. +# +# The engine is Python (sbom-driver.py) for Windows and air-gap parity. This +# wrapper lets a Make or shell caller invoke it as a plain command. It picks a +# Python interpreter (CRA_PYTHON, then python3, then python) and forwards all +# arguments unchanged. +set -e + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DRIVER_PY="$SCRIPT_DIR/sbom-driver.py" + +if [ -n "${CRA_PYTHON:-}" ]; then + PY="$CRA_PYTHON" +elif command -v python3 >/dev/null 2>&1; then + PY=python3 +elif command -v python >/dev/null 2>&1; then + PY=python +else + echo "ERROR: no python3 found; set CRA_PYTHON." >&2 + exit 1 +fi + +exec "$PY" "$DRIVER_PY" "$@" diff --git a/tools/sbom/sbom-driver.py b/tools/sbom/sbom-driver.py new file mode 100755 index 0000000000..818a834a83 --- /dev/null +++ b/tools/sbom/sbom-driver.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""wolfGlass SBOM driver - the product-neutral SBOM engine. + +This is the generalized form of wolfBoot's tools/scripts/wolfboot-sbom.sh. It is +product-neutral: the name, version, root, generator, and license are all +arguments. Nothing product-specific is hard-coded. Any product front end +(autotools, CMake, plain Make, IAR, or a compilation database) produces the +inputs and hands them to this driver. + +The driver covers every product tier: + + * Library (tier R/L/S): hash the built library with --lib. + * Source-embedded (tier E, e.g. wolfBoot): hash a source set with --srcs-file. + * As-built / FIPS / kernel module: --lib with --no-artifact-hash on the + as-built artifact. Never substitute a source list for a certified artifact. + * Linker / binding: record a declared dependency with --dep-wolfssl and + friends (feature-detected against the generator). + +Config (choose one): + --cflags "..." Raw CFLAGS. -D tokens are expanded through the host + compiler and scrubbed of absolute paths. + --options-h PATH A pre-expanded flat #define header, used verbatim (scrubbed). + --user-settings P A user_settings.h. Passed to the generator, which captures it. + --source-only No build-config macros (e.g. a Kconfig-driven build). + +The macro capture runs the HOST compiler (never the cross compiler), so the SBOM +is byte-reproducible across toolchains: gcc, clang, IAR, armcl, ccrx, and xc32 +all converge to the same document for the same config. + +Reproducibility: captured macros are scrubbed of absolute host paths before they +reach gen-sbom (for example -DPICO_SDK_PATH=/home/you/pico-sdk from arch.mk). Use +--no-scrub to disable (debug only). +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + +_ABS_PATH_POSIX = re.compile(r'^/[^ ]+') +_ABS_PATH_WINDOWS_DRIVE = re.compile(r'^[A-Za-z]:[\\/]') +_ABS_PATH_WINDOWS_UNC = re.compile(r'^\\\\') +_DEFINE = re.compile(r'^#define\s+(\S+)\s*(.*)$') + + +def is_absolute_path_token(token): + """Return True when a macro token looks like an absolute host path.""" + return bool( + _ABS_PATH_POSIX.match(token) or + _ABS_PATH_WINDOWS_DRIVE.match(token) or + _ABS_PATH_WINDOWS_UNC.match(token) + ) + + +def scrub_defines(text): + """Redact absolute-path tokens from a flat #define header text. + + A macro value that is (or contains) an absolute path would otherwise make + the SBOM machine-specific and leak the local file system. The macro name is + kept, so the configuration record stays complete. + """ + out = [] + for line in text.splitlines(): + m = _DEFINE.match(line) + if not m: + out.append(line) + continue + name, val = m.group(1), m.group(2) + if val == "": + out.append(line) + continue + toks = [] + for t in val.split(" "): + core = t.replace('"', '') + toks.append("" if is_absolute_path_token(core) else t) + out.append("#define " + name + " " + " ".join(toks)) + return "\n".join(out) + "\n" + + +def read_version(version_file, version_macro): + """Read a version string from a header, e.g. LIBWOLFBOOT_VERSION_STRING.""" + if not version_file or not os.path.isfile(version_file): + return "" + pat = re.compile(re.escape(version_macro) + r'\s*"([^"]*)"') + with open(version_file, encoding="utf-8", errors="replace") as f: + for line in f: + m = pat.search(line) + if m: + return m.group(1) + return "" + + +def find_gen_sbom(explicit): + """Locate gen-sbom: explicit arg, a sibling vendored copy, then WOLFSSL_DIR.""" + if explicit: + return explicit + sibling = os.path.join(SCRIPT_DIR, "gen-sbom") + if os.path.isfile(sibling): + return sibling + wolfssl_dir = os.environ.get("WOLFSSL_DIR") + if wolfssl_dir: + cand = os.path.join(wolfssl_dir, "scripts", "gen-sbom") + if os.path.isfile(cand): + return cand + return sibling # report the sibling path in the error message + + +def gen_sbom_supports(python, gen_sbom, flag): + """Return True if the generator advertises a flag in its --help.""" + try: + res = subprocess.run([python, gen_sbom, "--help"], + capture_output=True, text=True, check=False) + except OSError: + return False + return flag in (res.stdout + res.stderr) + + +def capture_macros(hostcc, cflags): + """Expand the -D tokens of CFLAGS through the host compiler's -dM -E.""" + defs = [t for t in cflags.split() if t.startswith("-D")] + cmd = [hostcc, "-dM", "-E", "-DWOLFSSL_USER_SETTINGS", *defs, + "-x", "c", os.devnull] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError): + sys.exit(f"ERROR: '{hostcc} -dM -E' failed; install a host C compiler " + f"or set --hostcc.") + return res.stdout + + +def load_srcs(srcs_file, skip_missing): + """Read the source list; optionally drop paths that do not exist.""" + kept, dropped = [], 0 + with open(srcs_file, encoding="utf-8", errors="replace") as f: + for line in f: + s = line.strip() + 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) + dropped += 1 + continue + kept.append(s) + if not kept: + sys.exit("ERROR: no source files remain for the SBOM.") + if dropped: + print(f" (--skip-missing dropped {dropped} file(s))", file=sys.stderr) + return kept + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + + # Composition (at least one of --lib / --srcs-file; --source-only needs srcs). + ap.add_argument("--lib", default="", + help="Path to the built library to hash (tier R/L/S).") + ap.add_argument("--srcs-file", default="", + help="File listing compiled-in sources (tier E).") + ap.add_argument("--no-artifact-hash", action="store_true", + help="Do not recompute the artifact hash. For as-built FIPS " + "or kernel modules.") + + # Config (choose one). + grp = ap.add_mutually_exclusive_group() + grp.add_argument("--cflags", default="", + help="Build CFLAGS; -D tokens expanded through the host cc.") + grp.add_argument("--options-h", default="", + help="A pre-expanded flat #define header, used verbatim.") + grp.add_argument("--user-settings", default="", + help="A user_settings.h; passed to the generator.") + grp.add_argument("--source-only", action="store_true", + help="Source-inventory SBOM with no build-config macros.") + + # Identity. + ap.add_argument("--name", required=True, help="Package name in the SBOM.") + ap.add_argument("--version", default="") + ap.add_argument("--version-file", default="") + ap.add_argument("--version-macro", default="") + ap.add_argument("--supplier", default="wolfSSL Inc.") + ap.add_argument("--license-file", default="") + ap.add_argument("--license-override", default="") + ap.add_argument("--license-text", default="") + + # Dependencies (feature-detected against the generator). + ap.add_argument("--dep-wolfssl", default="") + ap.add_argument("--dep-openssl", default="") + ap.add_argument("--dep-version", action="append", default=[], + help="Dependency version, e.g. wolfssl=5.9.2 (repeatable).") + + # Outputs and environment. + ap.add_argument("--cdx-out", default="") + ap.add_argument("--spdx-out", default="") + ap.add_argument("--gen-sbom", default="") + ap.add_argument("--python", default=sys.executable) + ap.add_argument("--hostcc", default=os.environ.get("HOSTCC", "cc")) + ap.add_argument("--root", default="") + ap.add_argument("--skip-missing", action="store_true") + ap.add_argument("--no-scrub", action="store_true") + args = ap.parse_args() + + root = os.path.abspath(args.root) if args.root else os.getcwd() + license_file = args.license_file or os.path.join(root, "LICENSE") + + version = args.version or read_version(args.version_file, args.version_macro) + if not version: + sys.exit("ERROR: could not determine version; pass --version or " + "--version-file with --version-macro.") + + if not args.lib and not args.srcs_file: + sys.exit("ERROR: pass at least one of --lib or --srcs-file.") + if args.source_only and not args.srcs_file: + sys.exit("ERROR: --source-only needs --srcs-file.") + if args.srcs_file and not os.path.isfile(args.srcs_file): + sys.exit(f"ERROR: --srcs-file '{args.srcs_file}' does not exist.") + if args.lib and not os.path.isfile(args.lib): + sys.exit(f"ERROR: --lib '{args.lib}' does not exist.") + + gen_sbom = find_gen_sbom(args.gen_sbom) + if not os.path.isfile(gen_sbom): + sys.exit(f"ERROR: gen-sbom not found at '{gen_sbom}'. Pass --gen-sbom " + f"/path/to/wolfssl/scripts/gen-sbom or set WOLFSSL_DIR.") + + cdx_out = args.cdx_out or f"{args.name}-{version}.cdx.json" + spdx_out = args.spdx_out or f"{args.name}-{version}.spdx.json" + + tmp_files = [] + try: + cmd = [args.python, gen_sbom, + "--name", args.name, + "--version", version, + "--supplier", args.supplier, + "--license-file", license_file, + "--cdx-out", cdx_out, + "--spdx-out", spdx_out] + + # Composition. + if args.srcs_file: + srcs = load_srcs(args.srcs_file, args.skip_missing) + fd, srcs_path = tempfile.mkstemp(prefix="wolfglass-srcs-", suffix=".txt") + os.close(fd) + tmp_files.append(srcs_path) + with open(srcs_path, "w", encoding="utf-8") as f: + f.write("\n".join(srcs) + "\n") + cmd += ["--srcs-file", srcs_path] + src_count = len(srcs) + else: + src_count = 0 + if args.lib: + cmd += ["--lib", args.lib] + if args.no_artifact_hash: + cmd += ["--no-artifact-hash"] + + # Config. + if args.user_settings: + if not os.path.isfile(args.user_settings): + sys.exit(f"ERROR: --user-settings '{args.user_settings}' " + f"does not exist.") + cmd += ["--user-settings", args.user_settings] + else: + if args.source_only: + defines_text = "" + elif args.options_h: + if not os.path.isfile(args.options_h): + sys.exit(f"ERROR: --options-h '{args.options_h}' does not exist.") + with open(args.options_h, encoding="utf-8", errors="replace") as f: + defines_text = f.read() + elif args.cflags: + defines_text = capture_macros(args.hostcc, args.cflags) + else: + sys.exit("ERROR: pass one of --cflags, --options-h, " + "--user-settings, or --source-only.") + if not args.no_scrub and not args.source_only: + defines_text = scrub_defines(defines_text) + fd, defines_path = tempfile.mkstemp(prefix="wolfglass-defs-", suffix=".h") + os.close(fd) + tmp_files.append(defines_path) + with open(defines_path, "w", encoding="utf-8") as f: + f.write(defines_text) + cmd += ["--options-h", defines_path] + + # License overrides. + if args.license_override: + cmd += ["--license-override", args.license_override] + if args.license_text: + cmd += ["--license-text", args.license_text] + + # Dependencies, only if the generator supports them. + if args.dep_wolfssl and gen_sbom_supports(args.python, gen_sbom, "--dep-wolfssl"): + cmd += ["--dep-wolfssl", args.dep_wolfssl] + if args.dep_openssl and gen_sbom_supports(args.python, gen_sbom, "--dep-openssl"): + cmd += ["--dep-openssl", args.dep_openssl] + if args.dep_version and gen_sbom_supports(args.python, gen_sbom, "--dep-version"): + for dv in args.dep_version: + cmd += ["--dep-version", dv] + + print(f"SBOM: name={args.name} version={version}") + if args.lib: + print(f" library: {args.lib}" + + (" (as-built, no re-hash)" if args.no_artifact_hash else "")) + if src_count: + print(f" sources: {src_count} file(s)") + print(f" outputs: {cdx_out} {spdx_out}") + + rc = subprocess.call(cmd) + finally: + for f in tmp_files: + try: + os.remove(f) + except OSError: + pass + + if rc == 0: + print(f"SBOM written: {cdx_out} {spdx_out}") + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/tools/sbom/sbom.am b/tools/sbom/sbom.am new file mode 100644 index 0000000000..644434dd80 --- /dev/null +++ b/tools/sbom/sbom.am @@ -0,0 +1,220 @@ +# sbom.am - shared Automake recipe for CRA-compliant SBOM generation. +# +# One generator (gen-sbom) does the work; each product just describes itself and +# includes this fragment. It is deliberately product-agnostic: a Makefile.am +# sets a few variables (below) and does `include tools/sbom/sbom.am` to get the +# `sbom`, `install-sbom` and `uninstall-sbom` targets. +# +# This is the canonical copy in wolfGlass. Product repositories vendor it +# together with the sibling `gen-sbom`, so the SBOM path works offline with no +# build-time dependency on a separate wolfSSL checkout. +# +# --------------------------------------------------------------------------- +# The including Makefile.am MUST set, before `include scripts/sbom.am`: +# SBOM_PKGNAME Product name recorded in the SBOM (e.g. wolfssh). Drives +# the output filenames and gen-sbom --name. +# SBOM_LICENSE_FILE Path to the product's LICENSING file +# (e.g. $(srcdir)/LICENSING). +# +# Optional (defaults shown): +# SBOM_OPTIONS_H Path to a product-generated options header (e.g. +# wolfMQTT's $(builddir)/wolfmqtt/options.h) that records +# the enabled build macros. Set this for products whose +# feature flags are NOT in config.h (no AC_DEFINE); when +# unset the recipe derives the macros from the compiler + +# config.h. Default: unset. +# SBOM_ARTIFACT lib | bin - which build output to hash. Default: lib. +# SBOM_LIB_STEM Library basename w/o extension. Default: lib$(SBOM_PKGNAME). +# SBOM_BIN_NAME Program name when SBOM_ARTIFACT = bin. Default: $(SBOM_PKGNAME). +# SBOM_DEP_WOLFSSL yes | no - record wolfSSL as a dependency. Default: no. +# SBOM_DEP_OPENSSL yes | no - record OpenSSL as a dependency (wolfProvider / +# wolfEngine). Default: no. +# SBOM_LICENSE_OVERRIDE SPDX expression to record instead of the licence +# detected from SBOM_LICENSE_FILE. +# SBOM_LICENSE_TEXT Path to licence text for any LicenseRef-* used in +# SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). +# SBOM_WOLFSSL_VERSION Version recorded for the wolfSSL dependency; +# auto-detected from WOLFSSL_DIR/wolfssl/version.h when unset. +# SBOM_OPENSSL_VERSION Version recorded for the OpenSSL dependency; +# gen-sbom resolves it via pkg-config when unset. +# SBOM_CONFIG_H Path to the configure-generated config header to +# force-include when capturing the configured build +# macros. Products whose AC_CONFIG_HEADERS lives in a +# subdirectory MUST override this so config.h defines are +# captured (e.g. wolfEngine: $(abs_builddir)/include/config.h; +# wolfCLU: $(abs_builddir)/src/config.h). +# Default: $(abs_builddir)/config.h. +# +# The wolfSSL/OpenSSL dependency flags are feature-detected against gen-sbom +# --help, so a product wired for them still produces a valid SBOM (with a NOTE) +# against a gen-sbom that predates the flag. +# +# gen-sbom is located next to this fragment in the vendored `tools/sbom/` +# directory unless SBOM_GEN is overridden. GEN_SBOM is accepted as a legacy +# alias. python3, pyspdxtools and git come from configure (AC_PATH_PROG); git +# is used only to derive SOURCE_DATE_EPOCH. +# +# NOTE: this fragment requires GNU make. It uses GNU conditional assignment +# (?=) and the GNU make functions $(wildcard), $(if), $(firstword) and +# $(addprefix); under a non-GNU make the SBOM targets will not work. +# --------------------------------------------------------------------------- + +SBOM_ARTIFACT ?= lib +SBOM_LIB_STEM ?= lib$(SBOM_PKGNAME) +SBOM_BIN_NAME ?= $(SBOM_PKGNAME) +SBOM_DEP_WOLFSSL ?= no +SBOM_DEP_OPENSSL ?= no +SBOM_CONFIG_H ?= $(abs_builddir)/config.h +SBOM_AM_DIR ?= $(dir $(lastword $(MAKEFILE_LIST))) + +SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx +# Use Automake's $(docdir) so a user's --docdir override is honoured (this +# equals $(datadir)/doc/$(PACKAGE) by default). +sbomdir = $(docdir) + +# Prefer the vendored sibling copy. Callers may override SBOM_GEN explicitly. +SBOM_GEN := $(or $(SBOM_GEN),$(GEN_SBOM),$(abspath $(SBOM_AM_DIR)/gen-sbom)) + +# Library artifact search order (versioned first) covering ELF, Mach-O and PE. +# Windows import libs (.lib) come with and without the "lib" prefix. +SBOM_LIB_GLOBS = \ + $(SBOM_LIB_STEM).so.[0-9]* \ + $(SBOM_LIB_STEM).so \ + $(SBOM_LIB_STEM).[0-9]*.dylib \ + $(SBOM_LIB_STEM).dylib \ + $(SBOM_LIB_STEM).dll \ + $(SBOM_LIB_STEM).dll.a \ + $(SBOM_LIB_STEM).lib \ + $(SBOM_PKGNAME).lib \ + $(SBOM_LIB_STEM).a + +# Automake requires CLEANFILES to be initialised with `=` before `+=`; the +# including Makefile.am must declare `CLEANFILES =` (typically in its primaries +# init block) before `include scripts/sbom.am`. +CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) + +.PHONY: sbom install-sbom uninstall-sbom + +# Stage a `make install` into a private tree, discover the installed artifact +# (shared/static library or program; ELF/Mach-O/PE), hash it, capture the +# configured build macros (from SBOM_OPTIONS_H if set, else AM_CPPFLAGS/ +# AM_CFLAGS/CFLAGS + config.h; some products carry their feature -D flags in +# AM_CFLAGS rather than AM_CPPFLAGS, and some outside config.h entirely), +# generate SPDX+CDX, validate +# the SPDX, then convert to tag-value. The staging tree and temp defines file +# are removed unconditionally via `trap`, even on failure. SOURCE_DATE_EPOCH is +# honoured for reproducible output (defaults to the last git commit time). +sbom: + @test -n "$(PYTHON3)" || { \ + echo "ERROR: 'python3' not found in PATH. Cannot generate SBOM."; \ + exit 1; } + @test -n "$(PYSPDXTOOLS)" || { \ + echo "ERROR: 'pyspdxtools' not found (pip install spdx-tools)."; \ + exit 1; } + @test -f "$(SBOM_GEN)" || { \ + echo "ERROR: gen-sbom not found at $(SBOM_GEN)."; \ + echo " Vendor tools/sbom/gen-sbom or override SBOM_GEN=/path/to/gen-sbom"; \ + exit 1; } + @rm -rf $(abs_builddir)/_sbom_staging + @set -e; \ + _defines=`mktemp $(abs_builddir)/_sbom_defines.XXXXXX`; \ + trap 'rm -rf $(abs_builddir)/_sbom_staging "$$_defines"' EXIT INT TERM HUP; \ + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ + sbom_art=""; \ + if test "$(SBOM_ARTIFACT)" = bin; then \ + for art in \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)" \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)".exe; do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + else \ + for art in \ + $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(SBOM_LIB_GLOBS)) \ + $(addprefix "$(abs_builddir)/_sbom_staging$(bindir)"/,$(SBOM_LIB_STEM).dll $(SBOM_PKGNAME).dll); do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + fi; \ + if test -z "$$sbom_art"; then \ + echo ""; \ + echo "ERROR: no installed $(SBOM_PKGNAME) artifact found for SBOM."; \ + echo " (configure with --enable-shared or --enable-static)"; \ + echo ""; \ + exit 1; \ + fi; \ + echo "SBOM: hashing $$sbom_art"; \ + opts_h="$(SBOM_OPTIONS_H)"; \ + if test -z "$$opts_h"; then \ + opts_h="$$_defines"; \ + $(CC) -dM -E $(DEFAULT_INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) \ + $(AM_CFLAGS) $(CFLAGS) \ + $(if $(wildcard $(SBOM_CONFIG_H)),-include $(SBOM_CONFIG_H)) \ + -x c /dev/null > "$$_defines"; \ + fi; \ + if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ + $(GIT) -C "$(srcdir)" rev-parse --git-dir >/dev/null 2>&1; then \ + sde=`$(GIT) -C "$(srcdir)" log -1 --format=%ct 2>/dev/null`; \ + if test -n "$$sde"; then SOURCE_DATE_EPOCH="$$sde"; export SOURCE_DATE_EPOCH; fi; \ + fi; \ + dep_args=""; \ + if test "$(SBOM_DEP_WOLFSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-wolfssl'; then \ + dep_args="$$dep_args --dep-wolfssl yes"; \ + wv="$(SBOM_WOLFSSL_VERSION)"; \ + if test -z "$$wv" && test -n "$(WOLFSSL_DIR)" && test -f "$(WOLFSSL_DIR)/wolfssl/version.h"; then \ + wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$(WOLFSSL_DIR)/wolfssl/version.h"`; \ + fi; \ + if test -n "$$wv"; then \ + dep_args="$$dep_args --dep-version wolfssl=$$wv"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-wolfssl support, so the SBOM"; \ + echo " will not list wolfssl as a dependency component."; \ + fi; \ + fi; \ + if test "$(SBOM_DEP_OPENSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-openssl'; then \ + dep_args="$$dep_args --dep-openssl yes"; \ + if test -n "$(SBOM_OPENSSL_VERSION)"; then \ + dep_args="$$dep_args --dep-version openssl=$(SBOM_OPENSSL_VERSION)"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-openssl support; openssl will"; \ + echo " not be listed as a dependency component."; \ + fi; \ + fi; \ + $(PYTHON3) "$(SBOM_GEN)" \ + --name $(SBOM_PKGNAME) \ + --version $(PACKAGE_VERSION) \ + --supplier "wolfSSL Inc." \ + --license-file $(SBOM_LICENSE_FILE) \ + --options-h "$$opts_h" \ + --lib "$$sbom_art" \ + $$dep_args \ + $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ + $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ + --cdx-out $(abs_builddir)/$(SBOM_CDX) \ + --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ + $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ + --outfile $(abs_builddir)/$(SBOM_SPDX_TV) + +install-sbom: sbom + $(MKDIR_P) $(DESTDIR)$(sbomdir) + $(INSTALL_DATA) $(SBOM_CDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX_TV) $(DESTDIR)$(sbomdir)/ + +uninstall-sbom: + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_CDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX_TV) + +# SBOM install is intentionally opt-in (`make install-sbom`), so `make install` +# does NOT place SBOM files. uninstall-sbom is still chained into the standard +# `make uninstall` via uninstall-hook so a prior `make install-sbom` is cleaned +# up; it uses `rm -f`, so it is a harmless no-op when no SBOM was installed. +uninstall-hook: uninstall-sbom diff --git a/tools/sbom/validate_sbom.py b/tools/sbom/validate_sbom.py new file mode 100755 index 0000000000..b0fa22f42e --- /dev/null +++ b/tools/sbom/validate_sbom.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Structural validator for wolfGlass SBOM output. + +This is the product-neutral form of wolfBoot's validate_sbom.py. It asserts the +essentials that every SBOM route must satisfy, so CI (and humans) can fail fast +on a broken generator. It is not a full schema validator. + + CycloneDX (*.cdx.json): + * bomFormat == "CycloneDX" + * specVersion == "1.6" + * metadata.component.name starts with --name-prefix (if given) + * metadata.component has a non-empty version + * at least one component or component property recorded + + SPDX (*.spdx.json): + * spdxVersion starts with "SPDX-2" + * has a name and at least one package + +The file kind is detected by content, so argument order does not matter. + +Usage: + validate_sbom.py [--name-prefix PREFIX] FILE [FILE ...] +""" + +import argparse +import json +import sys + + +def fail(path, msg): + print(f"FAIL [{path}]: {msg}", file=sys.stderr) + sys.exit(1) + + +def validate_cyclonedx(path, d, name_prefix): + if d.get("bomFormat") != "CycloneDX": + fail(path, f"bomFormat != CycloneDX (got {d.get('bomFormat')!r})") + if d.get("specVersion") != "1.6": + fail(path, f"specVersion != 1.6 (got {d.get('specVersion')!r})") + comp = d.get("metadata", {}).get("component", {}) + name = comp.get("name", "") + if name_prefix and not name.startswith(name_prefix): + fail(path, f"metadata.component.name does not start with " + f"{name_prefix!r} (got {name!r})") + if not comp.get("version"): + fail(path, "metadata.component.version is empty") + if not d.get("components") and not comp.get("properties"): + fail(path, "no components or component properties recorded") + print(f"OK [{path}]: CycloneDX 1.6, component " + f"{comp.get('name')} {comp.get('version')}") + + +def validate_spdx(path, d): + ver = d.get("spdxVersion", "") + if not ver.startswith("SPDX-2"): + fail(path, f"spdxVersion not SPDX-2.x (got {ver!r})") + if not d.get("name"): + fail(path, "document name is empty") + if not d.get("packages"): + fail(path, "no packages recorded") + print(f"OK [{path}]: {ver}, {len(d['packages'])} package(s)") + + +def main(argv): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--name-prefix", default="", + help="Require metadata.component.name to start with this.") + ap.add_argument("files", nargs="+") + args = ap.parse_args(argv[1:]) + + for path in args.files: + try: + with open(path) as f: + d = json.load(f) + except FileNotFoundError: + fail(path, "file not found") + except json.JSONDecodeError as e: + fail(path, f"invalid JSON: {e}") + if "bomFormat" in d or path.endswith(".cdx.json"): + validate_cyclonedx(path, d, args.name_prefix) + elif "spdxVersion" in d or path.endswith(".spdx.json"): + validate_spdx(path, d) + else: + fail(path, "unrecognized SBOM format (neither CycloneDX nor SPDX)") + print("All SBOMs valid.") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/tools/scripts/ide-sbom/compdb_sbom.py b/tools/scripts/ide-sbom/compdb_sbom.py index ea9c30c79e..76045d6a54 100755 --- a/tools/scripts/ide-sbom/compdb_sbom.py +++ b/tools/scripts/ide-sbom/compdb_sbom.py @@ -1,176 +1,23 @@ #!/usr/bin/env python3 -"""Generate a wolfBoot SBOM from a Clang compilation database (compile_commands.json). +"""Compatibility wrapper for the vendored wolfGlass compdb frontend.""" -This is the universal IDE/toolchain fallback. Any build that can emit a -compilation database gives us an exact, ground-truth list of the files that were -compiled and the -D configuration they were compiled with - independent of the -build system or compiler. That covers cases with no Make/CMake SBOM path: - - * CMake builds (configure with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON) - * TI Code Composer Studio (CCS / armcl, via `bear -- make ...`) - * Microchip MPLAB X (xc32, via `bear -- make ...` on the nbproject make) - * Renesas e2studio / CCRX (via a build wrapper that records commands) - * Xilinx SDK / Vitis (via `bear -- make ...`) - -The extracted sources + defines are handed to the shared driver -(tools/scripts/wolfboot-sbom.sh), so the resulting CycloneDX 1.6 / SPDX 2.3 -SBOM is identical in shape to the Make, CMake and IAR paths. - -Usage: - tools/scripts/ide-sbom/compdb_sbom.py build/compile_commands.json [options] - -Options: - --include REGEX Only include source files whose absolute path matches REGEX - (repeatable). Default: all C/asm sources. - --exclude REGEX Exclude source files whose absolute path matches REGEX - (repeatable, applied after --include). Handy to drop - test-app/ or unit-test sources. - --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). - --version VER Package version (passed through). - --cdx-out / --spdx-out PATH Output paths (passed through). - --srcs-out PATH Where to write the extracted source list (default: temp). - --print-only Print the extracted sources + defines and exit. -""" - -import argparse -import json import os -import re -import shlex -import subprocess import sys -import tempfile - -SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) -DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') - - -def entry_tokens(entry): - """Return the compiler argument tokens for a compdb entry.""" - if 'arguments' in entry and entry['arguments']: - return list(entry['arguments']) - if 'command' in entry and entry['command']: - try: - return shlex.split(entry['command']) - except ValueError: - return entry['command'].split() - return [] - - -def extract_defines(tokens): - """Return -D define tokens (normalized to bare NAME[=VAL]) from arg tokens.""" - defs = [] - i = 0 - while i < len(tokens): - t = tokens[i] - if t == '-D' and i + 1 < len(tokens): - defs.append(tokens[i + 1]) - i += 2 - continue - if t.startswith('-D'): - defs.append(t[2:]) - i += 1 - return defs - - -def main(): - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument('compdb', help='Path to compile_commands.json') - ap.add_argument('--include', action='append', default=[]) - ap.add_argument('--exclude', action='append', default=[]) - ap.add_argument('--gen-sbom', default=None) - ap.add_argument('--version', default=None) - ap.add_argument('--name', default='wolfboot') - ap.add_argument('--cdx-out', default=None) - ap.add_argument('--spdx-out', default=None) - ap.add_argument('--srcs-out', default=None) - ap.add_argument('--print-only', action='store_true') - args = ap.parse_args() - - if not os.path.isfile(args.compdb): - sys.exit(f"ERROR: compilation database not found: {args.compdb}") - with open(args.compdb) as f: - try: - db = json.load(f) - except json.JSONDecodeError as e: - sys.exit(f"ERROR: cannot parse {args.compdb}: {e}") - - inc = [re.compile(p) for p in args.include] - exc = [re.compile(p) for p in args.exclude] - - srcs = [] - defines = set() - seen = set() - for entry in db: - fpath = entry.get('file') - if not fpath: - continue - directory = entry.get('directory', os.getcwd()) - if not os.path.isabs(fpath): - fpath = os.path.join(directory, fpath) - fpath = os.path.normpath(fpath) - if not fpath.lower().endswith(SRC_EXTS): - continue - if inc and not any(r.search(fpath) for r in inc): - continue - if exc and any(r.search(fpath) for r in exc): - continue - tokens = entry_tokens(entry) - for d in extract_defines(tokens): - defines.add(d) - if fpath not in seen and os.path.isfile(fpath): - seen.add(fpath) - srcs.append(fpath) - - if not srcs: - sys.exit("ERROR: no matching source files found in compilation database") - - defines = sorted(defines) - cflags = ' '.join(f'-D{d}' for d in defines) - - if args.print_only: - print(f"# {len(srcs)} sources, {len(defines)} defines") - print("\n[defines]") - for d in defines: - print(f" -D{d}") - print("\n[sources]") - for s in srcs: - print(f" {s}") - return - - srcs_out = args.srcs_out - tmp = None - if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-compdb-srcs-', suffix='.txt') - os.close(fd) - tmp = srcs_out - with open(srcs_out, 'w') as f: - f.write('\n'.join(srcs) + '\n') +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "compdb_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) - cmd = [DRIVER, '--srcs-file', srcs_out, '--cflags', cflags, - '--name', args.name, '--root', ROOT] - if args.version: - cmd += ['--version', args.version] - if args.gen_sbom: - cmd += ['--gen-sbom', args.gen_sbom] - if args.cdx_out: - cmd += ['--cdx-out', args.cdx_out] - if args.spdx_out: - cmd += ['--spdx-out', args.spdx_out] - print(f"compdb SBOM: {len(srcs)} sources, {len(defines)} defines") - try: - rc = subprocess.call(cmd) - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - sys.exit(rc) +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] -if __name__ == '__main__': - main() +args = sys.argv[1:] +args = with_default(args, "--name", "wolfboot") +args = with_default(args, "--version-file", + os.path.join(ROOT, "include", "wolfboot", "version.h")) +args = with_default(args, "--version-macro", "LIBWOLFBOOT_VERSION_STRING") +os.execv(sys.executable, [sys.executable, TARGET, *args]) diff --git a/tools/scripts/ide-sbom/iar_sbom.py b/tools/scripts/ide-sbom/iar_sbom.py index e3bc775d3b..14a19bf1be 100755 --- a/tools/scripts/ide-sbom/iar_sbom.py +++ b/tools/scripts/ide-sbom/iar_sbom.py @@ -1,198 +1,23 @@ #!/usr/bin/env python3 -"""Generate a wolfBoot SBOM from an IAR Embedded Workbench project (.ewp). +"""Compatibility wrapper for the vendored wolfGlass IAR frontend.""" -IAR builds happen entirely inside the IDE and never touch the Makefile or CMake, -so the compiled source set and the preprocessor configuration live in the .ewp -project file rather than in OBJS / CFLAGS. This extractor reads them out of the -.ewp and feeds them to the shared wolfBoot SBOM driver -(tools/scripts/wolfboot-sbom.sh), so an IAR-built wolfBoot gets the same -CycloneDX 1.6 + SPDX 2.3 SBOM as a Make- or CMake-built one. - -It parses: - * the C compiler preprocessor defines (ICCARM -> CCDefines entries) - * the compiled source files ( ...), resolving $PROJ_DIR$ - -Usage: - tools/scripts/ide-sbom/iar_sbom.py IDE/IAR/wolfboot.ewp [options] - -Options: - --config NAME IAR build configuration to read (default: the configuration - with the most preprocessor defines, i.e. the real one). - --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). - --version VER Package version (passed through; else driver reads version.h). - --cdx-out PATH / --spdx-out PATH Output paths (passed through). - --srcs-out PATH Where to write the extracted source list - (default: a temp file). - --print-only Print the extracted sources + defines and exit (no SBOM). -""" - -import argparse import os -import subprocess import sys -import tempfile -import xml.etree.ElementTree as ET - -SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') -# Repo root: tools/scripts/ide-sbom/iar_sbom.py -> up 3. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) -DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') - - -def resolve_proj_dir(raw, proj_dir): - """Resolve an IAR $PROJ_DIR$-relative, backslash path to an absolute path.""" - p = raw.replace('$PROJ_DIR$', proj_dir) - p = p.replace('\\', '/') - if not os.path.isabs(p): - p = os.path.join(proj_dir, p) - return os.path.normpath(p) - - -def parse_configs(root): - """Return {config_name: [define, ...]} from each configuration's ICCARM - CCDefines option.""" - configs = {} - for cfg in root.findall('configuration'): - name_el = cfg.find('name') - cfg_name = name_el.text if name_el is not None else '(unnamed)' - defines = [] - for settings in cfg.findall('settings'): - sname = settings.find('name') - if sname is None or sname.text != 'ICCARM': - continue - for data in settings.findall('data'): - for option in data.findall('option'): - oname = option.find('name') - if oname is None or oname.text != 'CCDefines': - continue - for state in option.findall('state'): - if state.text: - defines.append(state.text.strip()) - configs[cfg_name] = defines - return configs - - -def collect_sources(root, proj_dir): - """Return (present, missing) absolute paths of compiled source files. - - Files that do not exist on disk are separated out: build-time generated - files such as keystore.c are listed in the .ewp but only materialize during - a build. This mirrors the Make path, where $(wildcard) silently drops - sources that are not present. - """ - srcs = [] - for file_el in root.iter('file'): - name_el = file_el.find('name') - if name_el is None or not name_el.text: - continue - raw = name_el.text.strip() - if not raw.lower().endswith(SRC_EXTS): - continue - srcs.append(resolve_proj_dir(raw, proj_dir)) - # De-dup while preserving order, then split on existence. - seen = set() - present, missing = [], [] - for s in srcs: - if s in seen: - continue - seen.add(s) - (present if os.path.isfile(s) else missing).append(s) - return present, missing - - -def main(): - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument('ewp', help='Path to the IAR .ewp project file') - ap.add_argument('--config', help='IAR configuration name (default: richest)') - ap.add_argument('--gen-sbom', default=None) - ap.add_argument('--version', default=None) - ap.add_argument('--cdx-out', default=None) - ap.add_argument('--spdx-out', default=None) - ap.add_argument('--srcs-out', default=None) - ap.add_argument('--print-only', action='store_true') - args = ap.parse_args() - - if not os.path.isfile(args.ewp): - sys.exit(f"ERROR: .ewp not found: {args.ewp}") - proj_dir = os.path.dirname(os.path.abspath(args.ewp)) - - try: - tree = ET.parse(args.ewp) - except ET.ParseError as e: - sys.exit(f"ERROR: cannot parse {args.ewp}: {e}") - root = tree.getroot() - - configs = parse_configs(root) - if not configs: - sys.exit("ERROR: no with ICCARM CCDefines found in .ewp") - - if args.config: - if args.config not in configs: - sys.exit(f"ERROR: config {args.config!r} not found. " - f"Available: {', '.join(configs)}") - cfg_name = args.config - else: - # Pick the configuration with the most defines (the real build config; - # a Debug config often only carries NDEBUG-style noise). - cfg_name = max(configs, key=lambda k: len(configs[k])) - - defines = configs[cfg_name] - srcs, missing = collect_sources(root, proj_dir) - if not srcs: - sys.exit("ERROR: no existing source files found in .ewp") - if missing: - sys.stderr.write( - "WARNING: %d source(s) listed in the .ewp were not found on disk " - "and are excluded (e.g. build-time generated files like " - "keystore.c):\n" % len(missing)) - for m in missing: - sys.stderr.write(" - %s\n" % m) - - cflags = ' '.join(f'-D{d}' for d in defines) - - if args.print_only: - print(f"# IAR configuration: {cfg_name}") - print(f"# {len(srcs)} sources, {len(defines)} defines") - print("\n[defines]") - for d in defines: - print(f" -D{d}") - print("\n[sources]") - for s in srcs: - print(f" {s}") - return - - srcs_out = args.srcs_out - tmp = None - if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-iar-srcs-', suffix='.txt') - os.close(fd) - tmp = srcs_out - with open(srcs_out, 'w') as f: - f.write('\n'.join(srcs) + '\n') +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "iar_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) - cmd = [DRIVER, '--srcs-file', srcs_out, '--cflags', cflags, - '--name', 'wolfboot', '--root', ROOT] - if args.version: - cmd += ['--version', args.version] - if args.gen_sbom: - cmd += ['--gen-sbom', args.gen_sbom] - if args.cdx_out: - cmd += ['--cdx-out', args.cdx_out] - if args.spdx_out: - cmd += ['--spdx-out', args.spdx_out] - print(f"IAR SBOM: configuration={cfg_name} " - f"({len(srcs)} sources, {len(defines)} defines)") - try: - rc = subprocess.call(cmd) - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - sys.exit(rc) +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] -if __name__ == '__main__': - main() +args = sys.argv[1:] +args = with_default(args, "--name", "wolfboot") +args = with_default(args, "--version-file", + os.path.join(ROOT, "include", "wolfboot", "version.h")) +args = with_default(args, "--version-macro", "LIBWOLFBOOT_VERSION_STRING") +os.execv(sys.executable, [sys.executable, TARGET, *args]) diff --git a/tools/scripts/ide-sbom/route_through_sbom.sh b/tools/scripts/ide-sbom/route_through_sbom.sh index dced3bb962..437a13168d 100755 --- a/tools/scripts/ide-sbom/route_through_sbom.sh +++ b/tools/scripts/ide-sbom/route_through_sbom.sh @@ -18,8 +18,8 @@ # route_through_sbom.sh --no-config [MAKE_VAR=VAL ...] # # Everything after the flags is passed verbatim to `make sbom`, so the vendor -# variables (CCS_ROOT, F021_DIR, TARGET, ...) and SBOM overrides (GEN_SBOM, -# HOSTCC, ...) all work. +# variables (CCS_ROOT, F021_DIR, TARGET, ...) and SBOM overrides (SBOM_GEN, +# legacy GEN_SBOM, HOSTCC, ...) all work. # # Examples: # # TI Hercules (CCS toolchain, built from the command line): diff --git a/tools/scripts/ide-sbom/validate_sbom.py b/tools/scripts/ide-sbom/validate_sbom.py index a39e9ec24b..73ee41cc4b 100755 --- a/tools/scripts/ide-sbom/validate_sbom.py +++ b/tools/scripts/ide-sbom/validate_sbom.py @@ -1,89 +1,13 @@ #!/usr/bin/env python3 -"""Lightweight structural validator for wolfBoot SBOM output. +"""Compatibility wrapper for the vendored wolfGlass SBOM validator.""" -Not a full schema validator - it asserts the essentials that every wolfBoot -SBOM route must satisfy, so CI (and humans) can fail fast on a broken generator: - - CycloneDX (*.cdx.json): - * bomFormat == "CycloneDX" - * specVersion == "1.6" - * metadata.component.name == "wolfboot" - * metadata.component has a non-empty version - * at least one component or source recorded - - SPDX (*.spdx.json): - * spdxVersion starts with "SPDX-2" - * has a name and at least one package - -The file kind is detected by content, so argument order does not matter. - -Usage: - validate_sbom.py FILE [FILE ...] -""" - -import json +import os import sys -# Every wolfBoot artifact SBOM names its component in the wolfboot* family: -# wolfboot, wolfboot-hal-, wolfboot-zephyr, ... -EXPECTED_NAME_PREFIX = "wolfboot" - - -def fail(path, msg): - print(f"FAIL [{path}]: {msg}", file=sys.stderr) - sys.exit(1) - - -def validate_cyclonedx(path, d): - if d.get("bomFormat") != "CycloneDX": - fail(path, f"bomFormat != CycloneDX (got {d.get('bomFormat')!r})") - if d.get("specVersion") != "1.6": - fail(path, f"specVersion != 1.6 (got {d.get('specVersion')!r})") - comp = d.get("metadata", {}).get("component", {}) - name = comp.get("name", "") - if not name.startswith(EXPECTED_NAME_PREFIX): - fail(path, f"metadata.component.name does not start with " - f"{EXPECTED_NAME_PREFIX!r} (got {name!r})") - if not comp.get("version"): - fail(path, "metadata.component.version is empty") - # Sources are recorded as sub-components and/or properties; require some. - if not d.get("components") and not comp.get("properties"): - fail(path, "no components or component properties recorded") - print(f"OK [{path}]: CycloneDX 1.6, component " - f"{comp['name']} {comp['version']}") - - -def validate_spdx(path, d): - ver = d.get("spdxVersion", "") - if not ver.startswith("SPDX-2"): - fail(path, f"spdxVersion not SPDX-2.x (got {ver!r})") - if not d.get("name"): - fail(path, "document name is empty") - if not d.get("packages"): - fail(path, "no packages recorded") - print(f"OK [{path}]: {ver}, {len(d['packages'])} package(s)") - - -def main(argv): - if len(argv) < 2: - print(__doc__) - sys.exit(2) - for path in argv[1:]: - try: - with open(path) as f: - d = json.load(f) - except FileNotFoundError: - fail(path, "file not found") - except json.JSONDecodeError as e: - fail(path, f"invalid JSON: {e}") - if "bomFormat" in d or path.endswith(".cdx.json"): - validate_cyclonedx(path, d) - elif "spdxVersion" in d or path.endswith(".spdx.json"): - validate_spdx(path, d) - else: - fail(path, "unrecognized SBOM format (neither CycloneDX nor SPDX)") - print("All SBOMs valid.") - +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "validate_sbom.py") -if __name__ == "__main__": - main(sys.argv) +args = sys.argv[1:] +if "--name-prefix" not in args: + args = ["--name-prefix", "wolfboot", *args] +os.execv(sys.executable, [sys.executable, TARGET, *args]) diff --git a/tools/scripts/ide-sbom/zephyr_sbom.py b/tools/scripts/ide-sbom/zephyr_sbom.py index f16d7a882f..aaaa91b7dd 100755 --- a/tools/scripts/ide-sbom/zephyr_sbom.py +++ b/tools/scripts/ide-sbom/zephyr_sbom.py @@ -1,145 +1,25 @@ #!/usr/bin/env python3 -"""Generate an SBOM for the wolfBoot Zephyr module (TEE / PSA client). +"""Compatibility wrapper for the vendored wolfGlass Zephyr frontend.""" -The `zephyr/` directory is not the bootloader: it is a Zephyr module that -compiles a small TEE/PSA non-secure client shim *into a Zephyr application* -(`zephyr_library_sources(...)`, gated on CONFIG_WOLFBOOT_TEE). Its source set is -fixed and lives in the module's CMakeLists, but it is built by Zephyr/west - not -by wolfBoot's Makefile or CMakeLists - so neither the Make nor the CMake SBOM -target sees it. - -This extractor reads the module's source list straight out of -`zephyr/CMakeLists.txt` (so it stays in sync automatically) and hands it to the -shared driver as a separate component, `wolfboot-zephyr`. - -The module's configuration is Kconfig-driven (CONFIG_* symbols), not a `-D` -macro set, so by default this produces a source-inventory SBOM (no build-config -macros). If you have a real Zephyr build and want the exact compiled config, -generate the SBOM from that build's compilation database with compdb_sbom.py -instead. - -Usage: - tools/scripts/ide-sbom/zephyr_sbom.py [--cmakelists zephyr/CMakeLists.txt] [options] - -Options: - --cmakelists PATH Module CMakeLists (default: /zephyr/CMakeLists.txt). - --gen-sbom PATH Path to wolfSSL scripts/gen-sbom (passed through). - --version VER Package version (passed through). - --cdx-out / --spdx-out PATH Output paths - (default: wolfboot-zephyr-.{cdx,spdx}.json). - --srcs-out PATH Where to write the extracted source list (default: temp). - --print-only Print the extracted sources and exit. -""" - -import argparse import os -import re -import subprocess import sys -import tempfile SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..', '..')) -DRIVER = os.path.join(ROOT, 'tools', 'scripts', 'wolfboot-sbom.sh') - -SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') - - -def parse_library_sources(cmakelists): - """Extract the file list from the zephyr_library_sources(...) block.""" - with open(cmakelists) as f: - text = f.read() - module_dir = os.path.dirname(os.path.abspath(cmakelists)) - - srcs = [] - for m in re.finditer(r'zephyr_library_sources\s*\((.*?)\)', text, re.DOTALL): - for raw in m.group(1).split(): - raw = raw.strip() - if not raw or not raw.lower().endswith(SRC_EXTS): - continue - # Resolve the CMake variables Zephyr uses for the module directory. - p = raw.replace('${CMAKE_CURRENT_LIST_DIR}', module_dir) - p = p.replace('${CMAKE_CURRENT_SOURCE_DIR}', module_dir) - p = p.replace('${WOLFBOOT_MODULE_DIR}', os.path.dirname(module_dir)) - if '${' in p: - # Unknown variable - skip rather than emit a bogus path. - sys.stderr.write(f"WARNING: skipping unresolved source: {raw}\n") - continue - if not os.path.isabs(p): - p = os.path.join(module_dir, p) - srcs.append(os.path.normpath(p)) - - seen = set() - present, missing = [], [] - for s in srcs: - if s in seen: - continue - seen.add(s) - (present if os.path.isfile(s) else missing).append(s) - return present, missing - - -def main(): - ap = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument('--cmakelists', default=os.path.join(ROOT, 'zephyr', 'CMakeLists.txt')) - ap.add_argument('--gen-sbom', default=None) - ap.add_argument('--version', default=None) - ap.add_argument('--cdx-out', default=None) - ap.add_argument('--spdx-out', default=None) - ap.add_argument('--srcs-out', default=None) - ap.add_argument('--print-only', action='store_true') - args = ap.parse_args() - - if not os.path.isfile(args.cmakelists): - sys.exit(f"ERROR: CMakeLists not found: {args.cmakelists}") - - srcs, missing = parse_library_sources(args.cmakelists) - if missing: - sys.stderr.write( - "WARNING: %d module source(s) not found on disk (excluded):\n" - % len(missing)) - for m in missing: - sys.stderr.write(" - %s\n" % m) - if not srcs: - sys.exit("ERROR: no wolfBoot Zephyr module sources found in " - + args.cmakelists) - - if args.print_only: - print(f"# wolfboot-zephyr: {len(srcs)} sources") - for s in srcs: - print(f" {s}") - return - - srcs_out = args.srcs_out - tmp = None - if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfboot-zephyr-srcs-', suffix='.txt') - os.close(fd) - tmp = srcs_out - with open(srcs_out, 'w') as f: - f.write('\n'.join(srcs) + '\n') +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "zephyr_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) - cmd = [DRIVER, '--srcs-file', srcs_out, '--source-only', - '--name', 'wolfboot-zephyr', '--root', ROOT] - if args.version: - cmd += ['--version', args.version] - if args.gen_sbom: - cmd += ['--gen-sbom', args.gen_sbom] - if args.cdx_out: - cmd += ['--cdx-out', args.cdx_out] - if args.spdx_out: - cmd += ['--spdx-out', args.spdx_out] - print(f"Zephyr module SBOM: {len(srcs)} sources (source-inventory)") - try: - rc = subprocess.call(cmd) - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - sys.exit(rc) +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] -if __name__ == '__main__': - main() +args = sys.argv[1:] +args = with_default(args, "--name", "wolfboot-zephyr") +args = with_default(args, "--cmakelists", + os.path.join(ROOT, "zephyr", "CMakeLists.txt")) +args = with_default(args, "--version-file", + os.path.join(ROOT, "include", "wolfboot", "version.h")) +args = with_default(args, "--version-macro", "LIBWOLFBOOT_VERSION_STRING") +os.execv(sys.executable, [sys.executable, TARGET, *args]) diff --git a/tools/scripts/wolfboot-sbom.sh b/tools/scripts/wolfboot-sbom.sh index 2dbbed7d5f..fe342700c6 100755 --- a/tools/scripts/wolfboot-sbom.sh +++ b/tools/scripts/wolfboot-sbom.sh @@ -1,287 +1,7 @@ #!/bin/sh -# wolfboot-sbom.sh - canonical wolfBoot SBOM driver. -# -# One engine, reused by every wolfBoot build system (plain Make/arch.mk, the -# vendor-SDK Make wrappers, CMake, the Pico SDK, and the IDE extractors) so the -# generated CycloneDX 1.6 + SPDX 2.3 SBOM is identical no matter how wolfBoot -# was built. Each build system only has to produce two things and hand them to -# this script: -# -# 1. the list of source files actually compiled into the image (--srcs-file) -# 2. the effective build configuration, either as raw build CFLAGS (--cflags) -# or as a pre-expanded flat #define header (--options-h) -# -# The heavy lifting (config-macro normalization, hashing, schema-shaped output) -# is done by wolfSSL's product-agnostic gen-sbom. The macro capture runs the -# HOST compiler (never the cross-compiler), so the SBOM is byte-reproducible -# across toolchains: gcc, clang/LLVM, IAR, armcl, CCRX and XC32 all converge to -# the same document for the same configuration. -# -# Reproducibility: captured macros are scrubbed of absolute host paths before -# they reach gen-sbom (e.g. -DPICO_SDK_PATH=/home/you/pico-sdk from arch.mk). -# An unscrubbed path makes the SBOM machine-specific and leaks the local file -# system into a published artifact. Use --no-scrub to disable (debug only). -# -# PORTABILITY NOTE -# This script is product-neutral by design (name, version, root, gen-sbom and -# license are all arguments; nothing wolfBoot-specific is hard-coded). The same -# driver can therefore be reused by other products; only the caller passes -# different --root/--name values, and the logic is unchanged. -# -# Exit status is non-zero on any error. -set -e - -usage() { - cat >&2 <<'EOF' -Usage: wolfboot-sbom.sh --srcs-file PATH (--cflags "..." | --options-h PATH) [options] - -Required: - --srcs-file PATH File listing compiled-in sources, one path per line - (blank lines and lines starting with # are ignored). - -Configuration source (exactly one): - --cflags "..." Build CFLAGS. -D tokens are extracted and expanded through - $HOSTCC -dM -E to capture the effective wolfBoot/wolfCrypt - configuration. - --options-h PATH A pre-expanded flat #define header (e.g. output of - `$CC -dM -E`). Used verbatim; HOSTCC is not invoked. - --source-only Produce a source-inventory SBOM with no build-config - macros. Use for artifacts whose configuration is not a - `-D` set (e.g. the Zephyr module, which is Kconfig-driven). - -Options: - --name NAME Package name recorded in the SBOM (default: wolfboot). - --version VER Package version (default: read from - include/wolfboot/version.h under --root). - --license-file P LICENSE file for SPDX id detection (default: /LICENSE). - --cdx-out PATH CycloneDX output (default: -.cdx.json). - --spdx-out PATH SPDX output (default: -.spdx.json). - --gen-sbom PATH Path to wolfSSL scripts/gen-sbom - (default: /lib/wolfssl/scripts/gen-sbom). - --python BIN Python interpreter (default: python3). - --hostcc BIN Host C compiler for macro capture (default: cc). - --root PATH wolfBoot root (default: derived from this script location). - --skip-missing Drop source paths that do not exist on disk (with a - warning) instead of failing. Mirrors the Make path, where - $(wildcard) silently omits not-yet-generated files such as - keystore.c. - --no-scrub Do not redact absolute host paths from the captured macro - header. Debug only; the output is then not reproducible. - -h, --help Show this help. -EOF -} +# Compatibility wrapper. wolfBoot now vendors the canonical driver from +# wolfGlass under tools/sbom/. -# Defaults. +set -e SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) -SRCS_FILE="" -CFLAGS_IN="" -OPTIONS_H="" -NAME="wolfboot" -VERSION="" -LICENSE_FILE="" -CDX_OUT="" -SPDX_OUT="" -GEN_SBOM="" -PYTHON="${CRA_PYTHON:-python3}" -HOSTCC="${HOSTCC:-cc}" -SKIP_MISSING=0 -SOURCE_ONLY=0 -SCRUB=1 - -while [ $# -gt 0 ]; do - case "$1" in - --srcs-file) SRCS_FILE="$2"; shift 2 ;; - --cflags) CFLAGS_IN="$2"; shift 2 ;; - --options-h) OPTIONS_H="$2"; shift 2 ;; - --source-only) SOURCE_ONLY=1; shift ;; - --name) NAME="$2"; shift 2 ;; - --version) VERSION="$2"; shift 2 ;; - --license-file) LICENSE_FILE="$2"; shift 2 ;; - --cdx-out) CDX_OUT="$2"; shift 2 ;; - --spdx-out) SPDX_OUT="$2"; shift 2 ;; - --gen-sbom) GEN_SBOM="$2"; shift 2 ;; - --python) PYTHON="$2"; shift 2 ;; - --hostcc) HOSTCC="$2"; shift 2 ;; - --root) ROOT=$(CDPATH= cd -- "$2" && pwd); shift 2 ;; - --skip-missing) SKIP_MISSING=1; shift ;; - --no-scrub) SCRUB=0; shift ;; - -h|--help) usage; exit 0 ;; - *) echo "ERROR: unknown argument: $1" >&2; usage; exit 2 ;; - esac -done - -# Resolve remaining defaults now that --root is known. -[ -n "$LICENSE_FILE" ] || LICENSE_FILE="$ROOT/LICENSE" -[ -n "$GEN_SBOM" ] || GEN_SBOM="$ROOT/lib/wolfssl/scripts/gen-sbom" - -if [ -z "$VERSION" ]; then - VERSION=$(sed -n \ - 's/.*LIBWOLFBOOT_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ - "$ROOT/include/wolfboot/version.h" 2>/dev/null || true) -fi - -# Validate inputs. -if [ -z "$SRCS_FILE" ]; then - echo "ERROR: --srcs-file is required." >&2 - usage - exit 2 -fi -if [ ! -f "$SRCS_FILE" ]; then - echo "ERROR: --srcs-file '$SRCS_FILE' does not exist." >&2 - exit 1 -fi -if [ "$SOURCE_ONLY" -eq 1 ]; then - if [ -n "$CFLAGS_IN" ] || [ -n "$OPTIONS_H" ]; then - echo "ERROR: --source-only cannot be combined with --cflags/--options-h." >&2 - exit 2 - fi -else - if [ -n "$CFLAGS_IN" ] && [ -n "$OPTIONS_H" ]; then - echo "ERROR: pass only one of --cflags or --options-h." >&2 - exit 2 - fi - if [ -z "$CFLAGS_IN" ] && [ -z "$OPTIONS_H" ]; then - echo "ERROR: pass one of --cflags, --options-h, or --source-only." >&2 - exit 2 - fi -fi -if [ -z "$VERSION" ]; then - echo "ERROR: could not determine version; pass --version or check" >&2 - echo " $ROOT/include/wolfboot/version.h" >&2 - exit 1 -fi -if [ ! -f "$GEN_SBOM" ]; then - echo "ERROR: gen-sbom not found at '$GEN_SBOM'." >&2 - echo " Initialize the submodule: git submodule update --init lib/wolfssl" >&2 - echo " or pass --gen-sbom /path/to/wolfssl/scripts/gen-sbom" >&2 - exit 1 -fi - -# Default output names follow the component name so multiple artifacts -# (wolfboot, wolfboot-hal-, wolfboot-zephyr, ...) don't collide. -[ -n "$CDX_OUT" ] || CDX_OUT="$NAME-$VERSION.cdx.json" -[ -n "$SPDX_OUT" ] || SPDX_OUT="$NAME-$VERSION.spdx.json" - -# Temp files we own (cleaned up on exit). The trap captures and re-returns the -# real exit status so cleanup never masks it (a bare "[ -n x ] && rm" as the -# last statement would make a successful run exit non-zero). -_TMP_DH="" -_TMP_SF="" -_TMP_SCRUB="" -cleanup() { - _rc=$? - for _f in "$_TMP_DH" "$_TMP_SF" "$_TMP_SCRUB"; do - [ -n "$_f" ] && rm -f "$_f" - done - return $_rc -} -trap cleanup EXIT INT TERM HUP - -# Redact absolute-path tokens from a flat #define header. A macro whose value -# is (or contains) an absolute path -- e.g. `#define PICO_SDK_PATH /home/x/sdk` -# from arch.mk's -DPICO_SDK_PATH=$(PICO_SDK_PATH) -- would otherwise make the -# SBOM machine-specific and leak the local file system. The redacted marker is -# constant, so the same configuration yields the same SBOM on every host. -# $1 = input header, $2 = output header. -scrub_defines() { - awk ' - /^#define / { - name = $2 - val = "" - for (i = 3; i <= NF; i++) val = (val == "" ? $i : val " " $i) - if (val == "") { print; next } - m = split(val, toks, " ") - nv = "" - for (i = 1; i <= m; i++) { - t = toks[i] - core = t - gsub(/"/, "", core) - # Absolute path: starts with "/" followed by at least one more char. - if (core ~ /^\/[^ ]+/) t = "" - nv = (nv == "" ? t : nv " " t) - } - print "#define " name " " nv - next - } - { print } - ' "$1" > "$2" -} - -# Optionally drop sources that do not exist on disk, matching the Make path's -# $(wildcard) behavior (e.g. keystore.c before it has been generated). -if [ "$SKIP_MISSING" -eq 1 ]; then - _TMP_SF=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-present.XXXXXX") - _dropped=0 - while IFS= read -r _line || [ -n "$_line" ]; do - case "$_line" in - ''|\#*) continue ;; - esac - if [ -f "$_line" ]; then - printf '%s\n' "$_line" >>"$_TMP_SF" - else - echo "WARNING: skipping missing source: $_line" >&2 - _dropped=$((_dropped + 1)) - fi - done < "$SRCS_FILE" - if [ ! -s "$_TMP_SF" ]; then - echo "ERROR: no existing source files remain after --skip-missing." >&2 - exit 1 - fi - [ "$_dropped" -gt 0 ] && echo " (--skip-missing dropped $_dropped file(s))" >&2 - SRCS_FILE="$_TMP_SF" -fi - -if [ "$SOURCE_ONLY" -eq 1 ]; then - # No build-config macros: hand gen-sbom an empty define header. - _TMP_DH=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-defines.XXXXXX") - DEFINES_H="$_TMP_DH" -elif [ -n "$OPTIONS_H" ]; then - if [ ! -f "$OPTIONS_H" ]; then - echo "ERROR: --options-h '$OPTIONS_H' does not exist." >&2 - exit 1 - fi - DEFINES_H="$OPTIONS_H" -else - # Extract -D tokens from CFLAGS and expand through the HOST compiler so the - # captured macro set reflects the effective configuration, independent of - # the (possibly cross) target toolchain. - _defs="" - for _t in $CFLAGS_IN; do - case "$_t" in - -D*) _defs="$_defs $_t" ;; - esac - done - _TMP_DH=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-defines.XXXXXX") - # shellcheck disable=SC2086 - if ! $HOSTCC -dM -E -DWOLFSSL_USER_SETTINGS $_defs -x c /dev/null >"$_TMP_DH" 2>/dev/null; then - echo "ERROR: '$HOSTCC -dM -E' failed; install a host C compiler or set HOSTCC." >&2 - exit 1 - fi - DEFINES_H="$_TMP_DH" -fi - -# Scrub absolute host paths out of the captured macros (unless disabled) so the -# SBOM is reproducible and does not leak the local file system. Applies to both -# the CFLAGS-derived header and a caller-supplied --options-h. -if [ "$SCRUB" -eq 1 ] && [ "$SOURCE_ONLY" -ne 1 ]; then - _TMP_SCRUB=$(mktemp "${TMPDIR:-/tmp}/wolfboot-sbom-scrub.XXXXXX") - scrub_defines "$DEFINES_H" "$_TMP_SCRUB" - DEFINES_H="$_TMP_SCRUB" -fi - -echo "wolfBoot SBOM: name=$NAME version=$VERSION" -echo " sources: $SRCS_FILE" -echo " outputs: $CDX_OUT $SPDX_OUT" - -"$PYTHON" "$GEN_SBOM" \ - --name "$NAME" \ - --version "$VERSION" \ - --supplier "wolfSSL Inc." \ - --license-file "$LICENSE_FILE" \ - --options-h "$DEFINES_H" \ - --srcs-file "$SRCS_FILE" \ - --cdx-out "$CDX_OUT" \ - --spdx-out "$SPDX_OUT" - -echo "SBOM written: $CDX_OUT $SPDX_OUT" +exec "$SCRIPT_DIR/../sbom/sbom-driver" "$@"