diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml new file mode 100644 index 0000000000..62e2b4a048 --- /dev/null +++ b/.github/workflows/test-sbom.yml @@ -0,0 +1,358 @@ +name: Wolfboot SBOM Canary + +# Exercises every wolfBoot SBOM route so a change to the build system, the +# 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/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/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. +# +# 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: + 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 clang + + - 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 + 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 \ + --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 + 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 \ + -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 --target sbom + 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/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/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + iar.cdx.json iar.spdx.json + + - name: compdb extractor (methods 6, 8-11) + run: | + python3 tools/sbom/frontends/compdb_sbom.py \ + build-sim/compile_commands.json \ + --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/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 + 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/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/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 + # 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 + mv wolfboot-*.cdx.json /tmp/gcc.cdx.json + mv wolfboot-*.spdx.json /tmp/gcc.spdx.json + 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 \ + || ! 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 + 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 ) + 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 + 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/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + 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 + 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 + + # 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: 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 }} + python3 tools/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + 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 + + 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 b1bc7fb6ad..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 @@ -421,3 +426,13 @@ 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 + +# Python cache files +__pycache__/ +*.py[cod] 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..88304bc957 100644 --- a/Makefile +++ b/Makefile @@ -799,71 +799,54 @@ 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 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: - @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; }; \ - printf '%s\n' $(_SBOM_SRCS) >"$$_sf"; \ - $(SBOM_PYTHON) "$(GEN_SBOM)" \ - --name wolfboot \ - --version "$(WOLFBOOT_VERSION)" \ - --supplier "wolfSSL Inc." \ - --license-file "$(WOLFBOOT_ROOT)/LICENSE" \ - --options-h "$$_dh" \ - --srcs-file "$$_sf" \ - --cdx-out "$(SBOM_CDX_OUT)" \ - --spdx-out "$(SBOM_SPDX_OUT)" - @echo "SBOM written: $(SBOM_CDX_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_GEN:=$(SBOM_GEN) +$(eval $(call wolfglass_sbom_rule,sbom-hal,SBOM_HAL_)) 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..df4b006f32 --- /dev/null +++ b/cmake/sbom.cmake @@ -0,0 +1,48 @@ +# cmake/sbom.cmake - wolfBoot wrapper around the vendored wolfGlass CMake helper. + +if(NOT DEFINED WOLFBOOT_ROOT) + set(WOLFBOOT_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) +endif() + +include(${WOLFBOOT_ROOT}/tools/sbom/build/sbom.cmake) + +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() + +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_defs ${WOLFBOOT_DEFS} ${WOLFBOOT_DEFS_PUBLIC} ${USER_SETTINGS} ${SIGN_OPTIONS}) + +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 new file mode 100644 index 0000000000..8f3f3196e9 --- /dev/null +++ b/docs/SBOM.md @@ -0,0 +1,310 @@ +# 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/sbom/sbom-driver ─► tools/sbom/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/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 + +* `python3` +* A host C compiler. The default is `cc`. To use a different compiler, set + `HOSTCC=...`. +* The vendored wolfGlass SBOM set under `tools/sbom/`. + +## Limitations + +Obey these limitations when you make an SBOM. + +- `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. +- `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 + +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`, `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. + +## 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 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=...`. + +> `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) + +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/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) + +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/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, +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/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 +``` + +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/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`. + +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/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 +``` + +## 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/sbom/validate_sbom.py \ + --name-prefix wolfboot \ + 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. 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 + +`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/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 new file mode 100755 index 0000000000..76045d6a54 --- /dev/null +++ b/tools/scripts/ide-sbom/compdb_sbom.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the vendored wolfGlass compdb frontend.""" + +import os +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "compdb_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) + + +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] + + +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 new file mode 100755 index 0000000000..14a19bf1be --- /dev/null +++ b/tools/scripts/ide-sbom/iar_sbom.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the vendored wolfGlass IAR frontend.""" + +import os +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "iar_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) + + +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] + + +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 new file mode 100755 index 0000000000..437a13168d --- /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 (SBOM_GEN, +# legacy 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..73ee41cc4b --- /dev/null +++ b/tools/scripts/ide-sbom/validate_sbom.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the vendored wolfGlass SBOM validator.""" + +import os +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "validate_sbom.py") + +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 new file mode 100755 index 0000000000..aaaa91b7dd --- /dev/null +++ b/tools/scripts/ide-sbom/zephyr_sbom.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the vendored wolfGlass Zephyr frontend.""" + +import os +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(SCRIPT_DIR, "..", "..", "sbom", "frontends", "zephyr_sbom.py") +ROOT = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..")) + + +def with_default(argv, flag, value): + if flag in argv: + return argv + return [*argv, flag, value] + + +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 new file mode 100755 index 0000000000..fe342700c6 --- /dev/null +++ b/tools/scripts/wolfboot-sbom.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Compatibility wrapper. wolfBoot now vendors the canonical driver from +# wolfGlass under tools/sbom/. + +set -e +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$SCRIPT_DIR/../sbom/sbom-driver" "$@"