diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml new file mode 100644 index 000000000..287a5f470 --- /dev/null +++ b/.github/workflows/test-sbom.yml @@ -0,0 +1,85 @@ +name: wolfHSM SBOM Canary + +# Core-target only for this pass. posix-server / posix-client / whnvmtool +# SBOM targets are tracked as follow-up and are not exercised here. +# +# Guards: +# * make sbom with an explicit WOLFHSM_CFG_DIR +# * schema-valid CycloneDX 1.6 + SPDX 2.3 whose top-level component is wolfhsm +# * toolchain neutrality: HOSTCC=gcc vs HOSTCC=clang must be byte-identical +# +# No Make-vs-CMake step: wolfHSM has no CMake build. + +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '*' ] + +jobs: + sbom_canary: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - name: Trust workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Checkout wolfssl (sibling for include path) + uses: actions/checkout@v4 + with: + repository: wolfSSL/wolfssl + path: wolfssl + + - name: Install tooling + run: | + sudo apt-get update + sudo apt-get install -y python3 build-essential clang + + - name: Verify vendored gen-sbom + run: test -f tools/sbom/gen-sbom + + - name: make sbom (core) + run: | + WOLFSSL_DIR=./wolfssl WOLFHSM_CFG_DIR=test/config make sbom + python3 tools/sbom/validate_sbom.py --name-prefix wolfhsm \ + --min-properties 50 --require-dep-version wolfssl \ + wolfhsm-*.cdx.json wolfhsm-*.spdx.json + + - name: Toolchain neutrality (gcc vs clang) + run: | + export SOURCE_DATE_EPOCH=1700000000 + export WOLFSSL_DIR=./wolfssl + export WOLFHSM_CFG_DIR=test/config + rm -f wolfhsm-*.cdx.json wolfhsm-*.spdx.json .sbom-wolfhsm-defines.h + make sbom HOSTCC=gcc + cp wolfhsm-*.cdx.json /tmp/gcc.cdx.json + cp wolfhsm-*.spdx.json /tmp/gcc.spdx.json + rm -f wolfhsm-*.cdx.json wolfhsm-*.spdx.json .sbom-wolfhsm-defines.h + make sbom HOSTCC=clang + cp wolfhsm-*.cdx.json /tmp/clang.cdx.json + cp wolfhsm-*.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 host toolchain." >&2 + exit 1 + fi + # Content sanity: empty --cflags captures also agree across + # toolchains; refuse to call that a pass. + python3 tools/sbom/validate_sbom.py --name-prefix wolfhsm \ + --min-properties 50 --require-dep-version wolfssl \ + /tmp/gcc.cdx.json /tmp/gcc.spdx.json + echo "neutrality OK: gcc and clang SBOMs are byte-identical" + + - name: Upload SBOM artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: wolfhsm-sboms + path: | + wolfhsm-*.cdx.json + wolfhsm-*.spdx.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index bc08749e1..164e94884 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .DS_Store +# Test/benchmark object dirs. Negate tools/sbom/build/ — bare Build/ +# also matches that path on case-insensitive filesystems (macOS). Build/ +!tools/sbom/build/ +!tools/sbom/build/** *.o *.a *.la @@ -24,3 +28,10 @@ scan_out/* # Test output test-suite.log + +# SBOM outputs and wolfGlass temp source lists +*.cdx.json +*.spdx.json +*.spdx +.*-wolfglass-srcs.txt +.sbom-wolfhsm-defines.h diff --git a/Makefile b/Makefile index 6d4763e3f..0c339778e 100644 --- a/Makefile +++ b/Makefile @@ -56,3 +56,70 @@ clean: make -C benchmark clean make -C tools clean make -C examples clean + +# ---- SBOM generation (vendored wolfGlass driver) ---- +# Version comes from ChangeLog.md: there is no release version macro in the +# public headers (WOLFHSM_CFG_INFOVERSION is a protocol info string, not the +# product release). Parsing ChangeLog.md is a known fragility to fix later. +# +# Config capture MUST use SBOM_OPTIONS_H, not SBOM_CFLAGS. The driver's +# --cflags path keeps only -D tokens and drops -I / -include, so feeding +# `-include wolfhsm/wh_settings.h` via SBOM_CFLAGS produced an empty SBOM +# (two raw -D flags). Capture the expanded header the same way PR #414 did. +WOLFSSL_DIR ?= ../wolfssl +HOSTCC ?= cc +SBOM_NAME := wolfhsm +SBOM_ROOT := $(CURDIR) +SBOM_SRCS := $(sort $(wildcard src/*.c)) +SBOM_VERSION := $(shell sed -n 's/^. wolfHSM Release v//p' ChangeLog.md | head -1 | cut -d' ' -f1) +SBOM_LICENSE_FILE := $(CURDIR)/LICENSING +SBOM_DEP_WOLFSSL ?= yes +SBOM_OPTIONS_H := $(CURDIR)/.sbom-wolfhsm-defines.h + +# Enforce only when an SBOM goal was actually requested. A bare $(error) here +# is evaluated while the Makefile is read, so it aborts every goal in the tree +# — `make scan`, `make clean`, `make test` — not just the SBOM ones. +ifneq (,$(filter sbom sbom-defines,$(MAKECMDGOALS))) +ifeq ($(origin WOLFHSM_CFG_DIR),undefined) +$(error WOLFHSM_CFG_DIR is required — point it at the directory \ +holding the wolfhsm_cfg.h/user_settings.h your build uses; do \ +not assume test/config is a release configuration) +endif +endif + +include tools/sbom/build/sbom.mk + +# Keep only project configuration macros. A raw -dM dump also carries the host +# compiler's own builtins, and gen-sbom's scrub drops __-prefixed names but not +# system macros whose *values* name them (ATOMIC_BOOL_LOCK_FREE expands to +# __GCC_ATOMIC_BOOL_LOCK_FREE under gcc, __CLANG_ATOMIC_BOOL_LOCK_FREE under +# clang), which made the SBOM toolchain-dependent. +# +# Object-like macros only: the trailing [[:space:]] excludes function-like ones, +# whose bodies differ cosmetically between the two preprocessors ("idx ##VAR" +# vs "idx##VAR"). A macro body is an implementation detail, not build config. +# +# The \# is required: an unescaped # would start a Make comment and silently +# truncate this pattern to "^", which matches every line and filters nothing. +SBOM_DEFINE_RE := ^\#define (WOLFHSM|WOLFSSL|WOLFCRYPT|WOLF|WC_|HAVE_|NO_|OPENSSL|USE_|SIZEOF_|FP_|TFM_|SP_|ECC_|RSA_|AES_|SHA|CURVE|ED25519|ED448|HKDF|KEEP_|SMALL_|BIG_|NDEBUG|DEBUG)[A-Za-z0-9_]*[[:space:]] + +# Always re-capture: a stale dump would hide config changes. The driver's +# --cflags path cannot do this — it drops -I/-include. +.PHONY: sbom-defines +sbom-defines: + @echo "SBOM: capturing config via $(HOSTCC) -dM -E -include wolfhsm/wh_settings.h" + @$(HOSTCC) -dM -E -DWOLFHSM_CFG -DWOLFSSL_USER_SETTINGS \ + -I. -I$(WOLFHSM_CFG_DIR) -I$(WOLFSSL_DIR) \ + -include wolfhsm/wh_settings.h -x c /dev/null \ + | LC_ALL=C grep -E '$(SBOM_DEFINE_RE)' \ + | LC_ALL=C sort > $(SBOM_OPTIONS_H) + @if [ ! -s $(SBOM_OPTIONS_H) ]; then \ + echo "ERROR: captured no configuration macros from" >&2; \ + echo " WOLFHSM_CFG_DIR=$(WOLFHSM_CFG_DIR)" >&2; \ + echo " Check that it holds the wolfhsm_cfg.h/user_settings.h" >&2; \ + echo " your build uses." >&2; \ + rm -f $(SBOM_OPTIONS_H); \ + exit 1; \ + fi + +sbom: sbom-defines diff --git a/README.md b/README.md index 653da9105..1e336dc87 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,4 @@ please refer to the following resources. - [wolfHSM Manual](https://www.wolfssl.com/documentation/manuals/wolfhsm/index.html) - [wolfHSM API Reference](https://www.wolfssl.com/documentation/manuals/wolfhsm/appendix01.html) - [wolfHSM Examples](https://github.com/wolfSSL/wolfHSM/tree/main/examples) +- [wolfHSM SBOM Generation](docs/SBOM.md) — `make sbom`, CycloneDX/SPDX output, and EU CRA notes diff --git a/docs/SBOM-FOLLOWUPS.md b/docs/SBOM-FOLLOWUPS.md new file mode 100644 index 000000000..b7b6c69a3 --- /dev/null +++ b/docs/SBOM-FOLLOWUPS.md @@ -0,0 +1,71 @@ +# SBOM follow-up issues (drafts) + +These were prepared for `wolfSSL/wolfHSM` as Step 8 of the wolfGlass +migration. They were not filed automatically — create them when ready +(for example with `gh issue create --repo wolfSSL/wolfHSM`). + +--- + +## 1. Add wolfGlass sbom target to examples/posix/wh_posix_server/Makefile + +Follow-up from the wolfGlass SBOM migration (PR #414 lineage): add a +`sbom` Make target to `examples/posix/wh_posix_server/Makefile` using +the vendored `tools/sbom/build/sbom.mk` driver (same pattern as the +core root `Makefile`). + +Core-only pass covers `src/*.c` via root `make sbom`. posix-server is +intentionally not marked ✅ until both (a) the target runs locally and +(b) CI covers it. + +See `docs/SBOM.md` coverage table. + +--- + +## 2. Add wolfGlass sbom target to examples/posix/wh_posix_client/Makefile + +Same as (1) for `examples/posix/wh_posix_client/Makefile`. + +--- + +## 3. Add wolfGlass sbom target to tools/whnvmtool/Makefile + +Same as (1) for `tools/whnvmtool/Makefile`. + +--- + +## 4. Confirm whether restricted vendor ports need out-of-tree SBOM support + +Ask port owners directly (do not infer from public stubs): + +- `port/infineon/tc3xx` +- `port/renesas/rh850f1km` +- `port/stmicro/spc58nn` +- `port/stmicro/SR6` +- `port/microchip/pic32cz` +- `port/ti/tda4vh` + +Build files are not public. If a private port has a real build, it may +still need an out-of-tree wolfGlass front end. Owners: reply yes/no + +front end (Make / CMake / IAR / compdb / other) per port. + +--- + +## 5. Replace the SBOM_DEFINE_RE prefix allowlist with a sourced macro list + +`SBOM_DEFINE_RE` in the root `Makefile` filters the `-dM` dump to macro +names matching a hand-maintained prefix allowlist. This is what makes the +SBOM toolchain-neutral (a raw dump embeds `__GCC_*` vs `__CLANG_*`), but it +has two known costs: + +* A future configuration macro with a novel prefix is dropped silently. + The `--min-properties 50` canary threshold will not notice one missing + macro. +* Alignment/codegen helpers that happen to match (`ALIGN16`, + `ASSERT_SAVED_VECTOR_REGISTERS`) are excluded by design — they are code + helpers, not build configuration — but that boundary is a judgement call + encoded in a regex rather than declared anywhere. + +Better: derive the recorded set from the settings headers themselves +(the macros `wh_settings.h` and `user_settings.h` actually define), so the +list tracks the configuration surface instead of a prefix guess. This +likely belongs in the shared wolfGlass driver, not per-product Makefiles. diff --git a/docs/SBOM.md b/docs/SBOM.md new file mode 100644 index 000000000..26ceb20cb --- /dev/null +++ b/docs/SBOM.md @@ -0,0 +1,113 @@ +# wolfHSM SBOM Generation + +wolfHSM can emit a Software Bill of Materials (SBOM) in **CycloneDX 1.6** and +**SPDX 2.3** JSON. 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. + +## One engine + +There is a single SBOM engine. The Make front end feeds it the source list and +the build configuration, then calls the vendored wolfGlass driver: + +``` +make sbom ─► tools/sbom/sbom-driver ─► tools/sbom/gen-sbom ─► *.cdx.json + *.spdx.json +``` + +| File | Role | +| --- | --- | +| `tools/sbom/sbom-driver` | Vendored wolfGlass driver (srcs + config → gen-sbom). | +| `tools/sbom/gen-sbom` | Vendored SBOM generator. | +| `tools/sbom/build/sbom.mk` | Shared plain-Make fragment. | +| `tools/sbom/validate_sbom.py` | Structural sanity check used by CI. | + +## Prerequisites + +* `python3` +* A host C compiler (`HOSTCC`, default `cc`) +* The vendored wolfGlass SBOM set under `tools/sbom/` +* `WOLFHSM_CFG_DIR` — **required**. Point it at the directory that holds the + `wolfhsm_cfg.h` and `user_settings.h` your build uses. There is no default; + `test/config` is a test harness config, not a release configuration. +* `WOLFSSL_DIR` — path to a wolfssl source tree used on the include path + (default `../wolfssl`) + +## Generate the core SBOM + +```sh +make sbom WOLFSSL_DIR=../wolfssl WOLFHSM_CFG_DIR=/path/to/your/config +``` + +Outputs: `wolfhsm-.cdx.json` and `wolfhsm-.spdx.json`. + +The Make target first runs `$(HOSTCC) -dM -E ... -include wolfhsm/wh_settings.h` +and feeds that dump to the driver as `--options-h` (`SBOM_OPTIONS_H`). Do +**not** use `SBOM_CFLAGS` for this product: the driver's `--cflags` path keeps +only `-D` tokens and drops `-I` / `-include`, which yields an empty config +record (two raw defines) instead of the real `WOLFHSM_CFG_*` / wolfSSL option +set. + +That dump is filtered to project configuration macros (`SBOM_DEFINE_RE` in the +root `Makefile`) before it reaches the driver, and only object-like macros are +kept. A raw `-dM` dump also carries the host compiler's own builtins, which +makes the SBOM depend on whether you built with gcc or clang — the SBOM records +*your build configuration*, not your compiler's internals. The +`make sbom HOSTCC=gcc` vs `HOSTCC=clang` byte-identity check in +`.github/workflows/test-sbom.yml` guards this. + +Version is parsed from `ChangeLog.md` (for example `# wolfHSM Release v1.4.0`). +There is no product release version macro in the public headers today +(`WOLFHSM_CFG_INFOVERSION` is a protocol info string). Relying on ChangeLog +parsing is a known fragility to replace with a header macro later. + +Useful overrides: `HOSTCC`, `SBOM_GEN`, `CRA_PYTHON`, `SBOM_DEP_WOLFSSL=no` +(for a `WOLFHSM_CFG_NO_CRYPTO` build). With `SBOM_DEP_WOLFSSL=yes` (default), +`sbom.mk` reads `$(WOLFSSL_DIR)/wolfssl/version.h` for the dependency version. + +Validate locally (also guards empty captures and missing dep versions): + +```sh +python3 tools/sbom/validate_sbom.py --name-prefix wolfhsm \ + --min-properties 50 --require-dep-version wolfssl \ + wolfhsm-*.cdx.json wolfhsm-*.spdx.json +``` + +## Coverage + +| Target | Status | How | Notes | +| --- | --- | --- | --- | +| Core library (`src/*.c`) | Covered | `make sbom` | Config via `WOLFHSM_CFG_DIR` + `wh_settings.h` | +| `examples/posix/wh_posix_server` | **Not yet** | follow-up | Needs its own `sbom` target in that Makefile | +| `examples/posix/wh_posix_client` | **Not yet** | follow-up | Needs its own `sbom` target in that Makefile | +| `tools/whnvmtool` | **Not yet** | follow-up | Needs its own `sbom` target in that Makefile | +| `port/posix` (HAL sources alone) | **Not yet** | follow-up | May be folded into posix example targets | +| `port/infineon/tc3xx` | Out of scope | — | Restricted vendor stub; build files not public | +| `port/renesas/rh850f1km` | Out of scope | — | Restricted vendor stub; build files not public | +| `port/stmicro/spc58nn` | Out of scope | — | Restricted vendor stub; build files not public | +| `port/stmicro/SR6` | Out of scope | — | Restricted vendor stub; build files not public | +| `port/microchip/pic32cz` | Out of scope | — | Restricted vendor stub; build files not public | +| `port/ti/tda4vh` | Out of scope | — | Restricted vendor stub; build files not public | + +Restricted ports: ask port owners whether private IDE/build tooling should get +out-of-tree SBOM support. Do not infer from the public stubs. Draft issue text +for the three Makefile follow-ups and the port-owner question lives in +[SBOM-FOLLOWUPS.md](SBOM-FOLLOWUPS.md) (not filed yet). + +wolfHSM has no CMake, autotools, or public IAR/compdb front end in this tree. +Do not invent a CMake SBOM path for parity with other products. + +## CI + +`.github/workflows/test-sbom.yml` runs the core canary: `make sbom` against +`test/config`, validates both documents with `--min-properties 50` and +`--require-dep-version wolfssl` (so an empty `--cflags` capture cannot pass), +and checks toolchain neutrality (`HOSTCC=gcc` vs `HOSTCC=clang` with a fixed +`SOURCE_DATE_EPOCH`). + +A coverage cell is only ✅ when (a) the target runs locally and (b) CI is green +for it. Do not mark ✅ on “should work” alone. + +## Further reading + +* [wolfssl/doc/CRA.md](https://github.com/wolfSSL/wolfssl/blob/master/doc/CRA.md) +* Vendored toolkit pin: `tools/sbom/VERSION` and `tools/sbom/.wolfglass-rev` diff --git a/tools/sbom/.wolfglass-rev b/tools/sbom/.wolfglass-rev new file mode 100644 index 000000000..315e0a350 --- /dev/null +++ b/tools/sbom/.wolfglass-rev @@ -0,0 +1 @@ +9bdf5b7133c603659ea34b9a552c0bf2a962a8c0 diff --git a/tools/sbom/README.md b/tools/sbom/README.md new file mode 100644 index 000000000..3f0ecfc80 --- /dev/null +++ b/tools/sbom/README.md @@ -0,0 +1,68 @@ +# share/ + +This is the only vendorable set. Use `tools/wolfglass-sync` to copy these files +into a product at `tools/sbom/`, together with the pin files (`VERSION` and +`.wolfglass-rev`). Do not copy the `share/` folder name; copy the files. + +## 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 `share/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 000000000..3e56aeef0 --- /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 000000000..b1009e989 --- /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 000000000..b426cb38e --- /dev/null +++ b/tools/sbom/build/sbom.mk @@ -0,0 +1,129 @@ +# 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. +# NOTE: the driver keeps ONLY -D tokens from SBOM_CFLAGS. +# -I / -include / other flags are dropped. Products whose +# config comes from -include'ing a settings header (e.g. +# wolfHSM's wh_settings.h) MUST capture with +# `$(HOSTCC) -dM -E ... -include ...` themselves and pass +# the dump via SBOM_OPTIONS_H, not SBOM_CFLAGS. +# SBOM_OPTIONS_H A pre-expanded flat #define header (verbatim, scrubbed). +# 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. +# SBOM_WOLFSSL_VERSION Explicit wolfSSL version for --dep-version. When +# unset and SBOM_DEP_WOLFSSL=yes, falls back to +# $(WOLFSSL_DIR)/wolfssl/version.h +# (LIBWOLFSSL_VERSION_STRING), matching sbom.am. +# SBOM_DEP_VERSION Extra --dep-version KEY=VER tokens (space-separated). +# 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. +# +# Source-list staging uses $(CURDIR)/.-wolfglass-srcs.txt (not mktemp). +# GNU Make expands $${TMPDIR:-/tmp} as an empty Make variable named +# "TMPDIR:-/tmp", which produced "/wolfglass-srcs.XXXXXX" and broke every +# host. The CURDIR file is .gitignore'd; avoid parallel make -j of the *same* +# SBOM target (two recipes would share one staging file). Distinct targets +# (sbom vs sbom-hal) use distinct filenames via $(1). +# +# Shell variables inside wolfglass_sbom_rule need $$$$name (not $$name): +# $(call)/$(eval) expands the define once, then the recipe expands again. +# $$name becomes $n + ame (empty single-letter Make var) after that double +# expansion; $$$$name survives as $name for the shell. +# +# 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; \ + dep_ver=""; \ + if [ -n "$($(2)DEP_VERSION)" ]; then \ + for dv in $($(2)DEP_VERSION); do \ + dep_ver="$$$$dep_ver --dep-version $$$$dv"; \ + done; \ + fi; \ + if [ "$($(2)DEP_WOLFSSL)" = "yes" ] || [ "$($(2)DEP_WOLFSSL)" = "1" ]; then \ + wv="$($(2)WOLFSSL_VERSION)"; \ + if [ -z "$$$$wv" ] && [ -n "$(WOLFSSL_DIR)" ] && \ + [ -f "$(WOLFSSL_DIR)/wolfssl/version.h" ]; then \ + wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$(WOLFSSL_DIR)/wolfssl/version.h" | head -1`; \ + fi; \ + if [ -n "$$$$wv" ]; then \ + dep_ver="$$$$dep_ver --dep-version wolfssl=$$$$wv"; \ + fi; \ + 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)") \ + $$$$dep_ver \ + $(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 000000000..6d97428f1 --- /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 000000000..4c877b13b --- /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 000000000..ef300847b --- /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 000000000..f90b3f450 --- /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 000000000..d2322cf62 --- /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 000000000..a20270e01 --- /dev/null +++ b/tools/sbom/sbom-driver.py @@ -0,0 +1,330 @@ +#!/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. + + Only tokens that start with ``-D`` are kept. ``-I``, ``-include``, and + every other flag are dropped. Products whose configuration is expressed + by ``-include``'ing a settings header must capture that header themselves + (``hostcc -dM -E ... -include ...``) and pass the dump via ``--options-h``. + """ + 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 000000000..644434dd8 --- /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 000000000..b35076c6e --- /dev/null +++ b/tools/sbom/validate_sbom.py @@ -0,0 +1,129 @@ +#!/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 + * optional --min-properties N on metadata.component.properties + * optional --require-dep-version NAME: a components[] entry with that + name must exist and carry a non-empty version + + SPDX (*.spdx.json): + * spdxVersion starts with "SPDX-2" + * has a name and at least one package + * optional --require-dep-version NAME: a packages[] entry whose name + contains NAME must carry a non-empty versionInfo + +The file kind is detected by content, so argument order does not matter. + +Usage: + validate_sbom.py [--name-prefix PREFIX] [--min-properties N] + [--require-dep-version NAME] 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, min_properties, require_deps): + 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") + props = comp.get("properties") or [] + if not d.get("components") and not props: + fail(path, "no components or component properties recorded") + if min_properties is not None and len(props) < min_properties: + fail(path, f"metadata.component.properties has {len(props)} entries, " + f"need at least {min_properties} (config capture likely " + f"empty — check --options-h vs --cflags)") + for dep_name in require_deps: + matches = [c for c in (d.get("components") or []) + if c.get("name") == dep_name] + if not matches: + fail(path, f"required dependency component {dep_name!r} missing") + if not matches[0].get("version"): + fail(path, f"dependency component {dep_name!r} has no version " + f"(pass --dep-version or set WOLFSSL_DIR)") + print(f"OK [{path}]: CycloneDX 1.6, component " + f"{comp.get('name')} {comp.get('version')}, " + f"{len(props)} properties") + + +def validate_spdx(path, d, require_deps): + 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") + pkgs = d.get("packages") or [] + if not pkgs: + fail(path, "no packages recorded") + for dep_name in require_deps: + matches = [p for p in pkgs + if dep_name.lower() in (p.get("name") or "").lower()] + if not matches: + fail(path, f"required dependency package matching {dep_name!r} " + f"missing") + if not matches[0].get("versionInfo"): + fail(path, f"dependency package {matches[0].get('name')!r} has " + f"no versionInfo") + print(f"OK [{path}]: {ver}, {len(pkgs)} 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("--min-properties", type=int, default=None, + help="Require at least N CycloneDX component properties " + "(guards empty --cflags captures).") + ap.add_argument("--require-dep-version", action="append", default=[], + metavar="NAME", + help="Require a dependency component/package NAME with " + "a non-empty version (repeatable).") + 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, + args.min_properties, args.require_dep_version) + elif "spdxVersion" in d or path.endswith(".spdx.json"): + validate_spdx(path, d, args.require_dep_version) + else: + fail(path, "unrecognized SBOM format (neither CycloneDX nor SPDX)") + print("All SBOMs valid.") + + +if __name__ == "__main__": + main(sys.argv)