diff --git a/Makefile b/Makefile index 17a3e956..def92231 100644 --- a/Makefile +++ b/Makefile @@ -901,8 +901,31 @@ SBOM_ROOT:=$(WOLFBOOT_ROOT) SBOM_NAME:=wolfboot SBOM_SRCS=$(wildcard $(patsubst %.o,%.c,$(OBJS))) $(wildcard $(patsubst %.o,%.S,$(OBJS))) SBOM_CFLAGS=$(CFLAGS) +# wolfBoot's wolfCrypt configuration is derived, not literal: include/user_settings.h +# turns WOLFBOOT_SIGN_ECC256 into HAVE_ECC, HAVE_ECC256, ECC_TIMING_RESISTANT and +# the rest. Capturing CFLAGS alone would record the -D set and none of what it +# selects, so the SBOM would report a bootloader with no signature algorithm. +SBOM_SETTINGS_H:=$(WOLFBOOT_LIB_WOLFSSL)/wolfssl/wolfcrypt/settings.h +SBOM_INCLUDE_DIRS:=$(WOLFBOOT_ROOT)/include $(WOLFBOOT_LIB_WOLFSSL) +# user_settings.h includes the generated target.h, so it must exist before the +# capture runs. It also carries the flash layout the SBOM records. +SBOM_PREREQS:=include/target.h +# wolfCrypt sources are compiled into the image rather than linked, so they are +# listed as wolfBoot's own sources. Declaring wolfssl as a dependency component +# as well is what lets a scanner match wolfSSL advisories against this firmware. +SBOM_DEP_WOLFSSL?=yes +SBOM_WOLFSSL_VERSION?=$(shell sed -n \ + 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + $(WOLFBOOT_LIB_WOLFSSL)/wolfssl/version.h 2>/dev/null | head -1) SBOM_VERSION=$(WOLFBOOT_VERSION) SBOM_LICENSE_FILE=$(WOLFBOOT_ROOT)/LICENSE +# wolfBoot is a bootloader flashed as an image, not a library linked into one. +SBOM_COMPONENT_TYPE?=firmware +# LICENSE is the verbatim GPLv3, which says nothing about how wolfBoot licenses +# under it, so inference falls back to GPL-3.0-only and understates the grant. +# Every GPL-headered source says "either version 3 ... or (at your option) any +# later version". +SBOM_LICENSE_OVERRIDE?=GPL-3.0-or-later SBOM_CDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).cdx.json SBOM_SPDX_OUT:=wolfboot-$(WOLFBOOT_VERSION).spdx.json SBOM_GEN?= @@ -916,9 +939,15 @@ include tools/sbom/build/sbom.mk # so the captured macros match the real build. Run once per TARGET. SBOM_HAL_NAME:=wolfboot-hal-$(TARGET) SBOM_HAL_SRCS=$(filter hal/%,$(patsubst ./%,%,$(wildcard $(patsubst %.o,%.c,$(OBJS)) $(patsubst %.o,%.S,$(OBJS))))) -SBOM_HAL_CFLAGS:=$(CFLAGS) +SBOM_HAL_CFLAGS=$(CFLAGS) +SBOM_HAL_SETTINGS_H:=$(SBOM_SETTINGS_H) +SBOM_HAL_INCLUDE_DIRS:=$(SBOM_INCLUDE_DIRS) +SBOM_HAL_PREREQS:=$(SBOM_PREREQS) SBOM_HAL_VERSION:=$(WOLFBOOT_VERSION) SBOM_HAL_LICENSE_FILE:=$(WOLFBOOT_ROOT)/LICENSE +# Same sources, same grant: without this the HAL SBOM would infer +# GPL-3.0-only and contradict the bootloader SBOM built from the same tree. +SBOM_HAL_LICENSE_OVERRIDE:=$(SBOM_LICENSE_OVERRIDE) SBOM_HAL_CDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).cdx.json SBOM_HAL_SPDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).spdx.json SBOM_HAL_GEN:=$(SBOM_GEN) diff --git a/docs/SBOM.md b/docs/SBOM.md index 8f3f3196..1837cb45 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -99,7 +99,32 @@ the source set and artifact hash are configuration-specific. Useful overrides: `HOSTCC`, `SBOM_GEN`, `CRA_PYTHON`. wolfcrypt sources are compiled directly into the wolfBoot image, so they are -listed as wolfBoot's own sources rather than as a separate component. +listed as wolfBoot's own sources rather than as a separate component. wolfSSL +is *also* recorded as a dependency component (`SBOM_DEP_WOLFSSL`, on by +default) with the submodule's version, which is what lets a scanner match +wolfSSL advisories against this firmware. + +### How the configuration is captured + +wolfBoot's configuration is derived rather than literal: `include/user_settings.h` +turns `WOLFBOOT_SIGN_ECC256` into `HAVE_ECC`, `HAVE_ECC256`, +`ECC_TIMING_RESISTANT` and the rest. So the capture preprocesses the real +settings header with the real `-D` set, via `SBOM_SETTINGS_H` and +`SBOM_INCLUDE_DIRS`: + +``` +cc -dM -E $(CFLAGS -D and -I tokens) -include wolfssl/wolfcrypt/settings.h +``` + +Capturing only the `-D` tokens (against an empty translation unit) records +nothing that the header derives, and capturing only the header (with no `-D` +set) evaluates every `#if` against an empty configuration. Either omission +produces a well-formed SBOM describing a build nobody made — a signing +bootloader with no signature algorithm, in wolfBoot's case. Both halves are +required, and the capture aborts rather than emitting a partial dump. + +`include/target.h` is generated and `#include`d by `user_settings.h`, so the +`sbom` target builds it first (`SBOM_PREREQS`). ## Route 2 — CMake (methods 5–6) diff --git a/tools/sbom/.wolfglass-rev b/tools/sbom/.wolfglass-rev index 315e0a35..dfbbd2f3 100644 --- a/tools/sbom/.wolfglass-rev +++ b/tools/sbom/.wolfglass-rev @@ -1 +1 @@ -9bdf5b7133c603659ea34b9a552c0bf2a962a8c0 +a54cde180e09b4158634cc3de99e1adef1ffbc9d diff --git a/tools/sbom/README.md b/tools/sbom/README.md index 3f0ecfc8..eb6bc407 100644 --- a/tools/sbom/README.md +++ b/tools/sbom/README.md @@ -58,6 +58,11 @@ A product does not copy logic. It describes itself: (`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,,))`. + If the product's configuration lives in a `user_settings.h`, also set + `SBOM_SETTINGS_H` (and `SBOM_INCLUDE_DIRS` if that header needs paths the + CFLAGS do not already carry). `SBOM_CFLAGS` alone records the literal `-D` + set and nothing it derives, which for a gated header means the SBOM + describes a configuration nobody built. - 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 diff --git a/tools/sbom/build/sbom.mk b/tools/sbom/build/sbom.mk index b426cb38..188b1250 100644 --- a/tools/sbom/build/sbom.mk +++ b/tools/sbom/build/sbom.mk @@ -15,16 +15,25 @@ # # 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. +# The driver keeps the -D tokens and forwards -I/-isystem; +# all other flags are dropped. # 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. # +# Config, strongly recommended alongside SBOM_CFLAGS: +# SBOM_SETTINGS_H Settings header -include'd during the capture, so the +# macros it derives from the -D set are recorded too. +# Any product whose configuration lives in a +# user_settings.h MUST set this. Without it the capture +# reads an empty translation unit and records only the +# literal -D list, which for a gated header (wolfBoot's +# user_settings.h derives HAVE_ECC from +# WOLFBOOT_SIGN_ECC256) silently omits the entire +# derived configuration. +# SBOM_INCLUDE_DIRS Extra -I paths for the capture, space-separated, so +# SBOM_SETTINGS_H can resolve the headers it pulls in. +# # Version - set one: # SBOM_VERSION Literal version string, OR # SBOM_VERSION_FILE + Header to read and the macro to read from it, e.g.: @@ -32,8 +41,26 @@ # SBOM_VERSION_MACRO = LIBWOLFBOOT_VERSION_STRING # # Optional (defaults shown): +# SBOM_PREREQS Targets to build before the SBOM runs. Use this for +# generated headers the capture needs: wolfBoot's +# include/target.h carries the flash layout and is +# #include'd by its user_settings.h, so the capture +# cannot preprocess without it. # SBOM_ROOT Product root. Default: current directory. # SBOM_LICENSE_FILE License file. Default: $(SBOM_ROOT)/LICENSE. +# SBOM_COMPONENT_TYPE What kind of artifact this is: CycloneDX +# component.type, mirrored to SPDX +# primaryPackagePurpose. Default: library. A +# bootloader or a flashed image should say firmware. +# SBOM_LICENSE_OVERRIDE SPDX expression recorded instead of the one inferred +# from SBOM_LICENSE_FILE. Needed whenever that file is +# the *full* licence text rather than a licensing +# statement: the GPL text never states whether the +# project grants "or any later version", so inference +# cannot distinguish GPL-3.0-only from +# GPL-3.0-or-later and defaults to -only. +# SBOM_LICENSE_TEXT Plain-text licence embedded for a LicenseRef-* used +# in SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). # 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. @@ -62,6 +89,15 @@ # $$name becomes $n + ame (empty single-letter Make var) after that double # expansion; $$$$name survives as $name for the shell. # +# That same double expansion is why SBOM_CFLAGS is referenced as +# $$($(2)CFLAGS) rather than $($(2)CFLAGS). A single $ interpolates the CFLAGS +# *value* into the rule text at eval time, and the recipe expansion then +# re-scans it. Any `$` surviving in that value is reinterpreted as a Make +# reference: wolfBoot passes -DBOOTLOADER_PARTITION_SIZE=$$(( A - B )), whose +# `$((` was read as a variable named `( A - B )`, expanded to nothing, and +# left the SBOM recording the literal value `)`. Deferring with $$( ) hands +# the arithmetic to the shell, which is what the compile recipes already do. +# # 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. @@ -75,7 +111,7 @@ HOSTCC ?= cc define wolfglass_sbom_rule .PHONY: $(1) -$(1): +$(1): $($(2)PREREQS) @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; } @@ -111,9 +147,14 @@ $(1): $(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)CFLAGS),--cflags="$$($(2)CFLAGS)") \ + $(if $($(2)SETTINGS_H),--settings-h "$($(2)SETTINGS_H)") \ + $(foreach d,$($(2)INCLUDE_DIRS),--include-dir "$(d)") \ $(if $($(2)OPTIONS_H),--options-h "$($(2)OPTIONS_H)") \ $(if $($(2)USER_SETTINGS),--user-settings "$($(2)USER_SETTINGS)") \ + $(if $($(2)COMPONENT_TYPE),--component-type "$($(2)COMPONENT_TYPE)") \ + $(if $($(2)LICENSE_OVERRIDE),--license-override '$($(2)LICENSE_OVERRIDE)') \ + $(if $($(2)LICENSE_TEXT),--license-text "$($(2)LICENSE_TEXT)") \ $(if $($(2)VERSION),--version "$($(2)VERSION)") \ $(if $($(2)VERSION_FILE),--version-file "$($(2)VERSION_FILE)") \ $(if $($(2)VERSION_MACRO),--version-macro "$($(2)VERSION_MACRO)") \ diff --git a/tools/sbom/gen-sbom b/tools/sbom/gen-sbom index f90b3f45..74e76450 100755 --- a/tools/sbom/gen-sbom +++ b/tools/sbom/gen-sbom @@ -20,8 +20,17 @@ from datetime import datetime, timezone # `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. +# +# The CLI counts as auditor-visible: '1.2' shipped in two incompatible +# shapes because dropping --dep-liboqs did not bump it, leaving vendored +# copies indistinguishable by the only identifier the SBOM records. +# +# 1.3 Valueless '#define X' records an empty value instead of '1'. +# Warns when the licence file is the full GPL text, where the +# -only/-or-later distinction cannot be inferred. +# 1.2 Dropped --dep-liboqs (unversioned; see above). GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen' -GEN_SBOM_VERSION = '1.2' +GEN_SBOM_VERSION = '1.3' # Placeholder recorded in the component checksum fields when the operator # passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only @@ -122,19 +131,6 @@ DEP_META = { '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', @@ -252,6 +248,22 @@ def cdx_license_block(license_expr, license_text): return [{'expression': license_expr}] +# Section headings that appear only in the verbatim GNU licence text, never +# in a short per-project licensing statement such as wolfSSL's LICENSING. +_FULL_LICENSE_MARKERS = ( + 'terms and conditions for copying, distribution and modification', + 'terms and conditions', + 'how to apply these terms to your new programs', +) + + +def _is_full_license_text(text): + """True when the licence file is the verbatim GNU licence rather than a + statement about how the project licenses under it.""" + low = text.lower() + return sum(marker in low for marker in _FULL_LICENSE_MARKERS) >= 2 + + def detect_license(license_file): """Parse LICENSING file and return an SPDX license ID. @@ -302,6 +314,24 @@ def detect_license(license_file): if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later', excerpt, re.IGNORECASE): return f'GPL-{version}.0-or-later' + if _is_full_license_text(text): + # The verbatim GPL is the licence itself, not a statement about how + # this project licenses under it. Whether the project grants "or any + # later version" appears only in the per-file headers, so -only here + # is a guess that silently narrows the grant. wolfBoot ships the full + # GPLv3 as LICENSE while every source header says "either version 3 + # ... or (at your option) any later version", i.e. GPL-3.0-or-later. + print( + f"WARNING: {license_file} is the full GPL text, not a licensing " + f"statement.\n" + f" It cannot say whether this project grants " + f"'or any later version', so\n" + f" GPL-{version}.0-only is assumed and may understate the " + f"grant. Confirm against\n" + f" your source headers and pass --license-override " + f"GPL-{version}.0-or-later if so\n" + f" (Make: SBOM_LICENSE_OVERRIDE).", + file=sys.stderr) return f'GPL-{version}.0-only' @@ -799,10 +829,26 @@ def spdx_dep_package(key, dep_version_overrides=None): return spdx_id, pkg +# CycloneDX 1.6 component.type -> SPDX 2.3 primaryPackagePurpose, so the two +# documents agree on what kind of artifact this is. A bootloader described as +# a 'library' misfiles the product for anyone triaging by artifact class, which +# under IEC 62443 is the difference between a component and the firmware it +# boots. Only the values a wolfSSL-stack product can legitimately be. +SPDX_PACKAGE_PURPOSE = { + 'library': 'LIBRARY', + 'firmware': 'FIRMWARE', + 'application': 'APPLICATION', + 'framework': 'FRAMEWORK', + 'device': 'DEVICE', + 'file': 'FILE', +} + + 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): + hash_source='lib', srcs_basenames=None, file_entries=None, + component_type='library'): bom_ref = derived_uuid(name, version, 'package') urls = project_urls(name) @@ -813,8 +859,14 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, dep_bom_refs.append(ref) components.append(comp) + # A valueless `#define X` is recorded with an empty value, not '1'. + # Coercing to '1' made the two indistinguishable and produced actively + # misleading entries where the macro names a quantity: wolfBoot's + # target.h emits `#define WOLFBOOT_LOAD_ADDRESS` with nothing after it + # when the target does not set a load address, which an auditor then read + # as the address literally being 1. properties = [ - {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} + {'name': f'wolfssl:build:{k}', 'value': v} for k, v in build_props ] # Document what the SHA-256 in `hashes` represents, on every entry @@ -845,7 +897,7 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, main_component = { 'bom-ref': bom_ref, - 'type': 'library', + 'type': component_type, 'supplier': {'name': supplier}, 'name': name, 'version': version, @@ -916,7 +968,8 @@ 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): + document_namespace=None, file_entries=None, + component_type='library'): 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 @@ -965,6 +1018,8 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'licenseConcluded': license_id, 'licenseDeclared': license_id, 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'primaryPackagePurpose': SPDX_PACKAGE_PURPOSE.get( + component_type, 'LIBRARY'), 'comment': f'Build configuration defines: {build_defines}', 'annotations': annotations, 'externalRefs': [ @@ -1078,7 +1133,7 @@ def _resolve_dep_versions(enabled_deps, overrides): 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 + a default --with-libz 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: @@ -1101,6 +1156,12 @@ def main(): 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('--component-type', default='library', + choices=sorted(SPDX_PACKAGE_PURPOSE), + help='What kind of artifact this is: CycloneDX ' + 'component.type, mirrored to SPDX ' + 'primaryPackagePurpose. Use firmware for a ' + 'bootloader such as wolfBoot (default: library)') parser.add_argument('--license-file', required=True, help='Path to LICENSING file for SPDX ID detection') parser.add_argument('--license-override', default='', @@ -1185,10 +1246,6 @@ def main(): '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 ' @@ -1257,7 +1314,6 @@ def main(): ('wolfssl', args.dep_wolfssl), ('openssl', args.dep_openssl), ('libz', args.dep_libz), - ('liboqs', args.dep_liboqs), ] if flag.lower() == 'yes' ] @@ -1380,6 +1436,7 @@ def main(): hash_kind=hash_kind, hash_source=hash_source, srcs_basenames=srcs_basenames, file_entries=file_entries, + component_type=args.component_type, ) spdx = generate_spdx( args.name, args.version, args.supplier, @@ -1390,6 +1447,7 @@ def main(): srcs_basenames=srcs_basenames, document_namespace=(args.document_namespace or None), file_entries=file_entries, + component_type=args.component_type, ) try: diff --git a/tools/sbom/sbom-driver.py b/tools/sbom/sbom-driver.py index a20270e0..411b9965 100755 --- a/tools/sbom/sbom-driver.py +++ b/tools/sbom/sbom-driver.py @@ -18,7 +18,10 @@ The driver covers every product tier: Config (choose one): --cflags "..." Raw CFLAGS. -D tokens are expanded through the host - compiler and scrubbed of absolute paths. + compiler and scrubbed of absolute paths. Pair with + --settings-h so the capture also sees the header those + -D tokens are interpreted by; without it the dump is the + -D list alone and every derived macro is missing. --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). @@ -119,22 +122,81 @@ def gen_sbom_supports(python, gen_sbom, flag): 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. +def split_cflags(cflags): + """Split a CFLAGS string on whitespace. - 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``. + Deliberately a plain split rather than shlex: --cflags arrives from a Make + recipe, so the shell has already removed one layer of quoting. Re-lexing it + as shell words would strip a second layer, and shlex's POSIX escape + handling would eat the backslashes out of Windows paths -- + -DPICO_SDK_PATH=C:\\Users\\ci\\sdk becomes C:Userscisdk, which is both + wrong and no longer matched by the absolute-path scrub, so a mangled host + path would leak into the SBOM. """ - defs = [t for t in cflags.split() if t.startswith("-D")] - cmd = [hostcc, "-dM", "-E", "-DWOLFSSL_USER_SETTINGS", *defs, - "-x", "c", os.devnull] + return cflags.split() + + +def capture_macros(hostcc, cflags, settings_h="", include_dirs=()): + """Expand a build configuration through the host compiler's -dM -E. + + The capture must see BOTH inputs that determine the configuration: + + * the ``-D`` tokens from CFLAGS, which select features + (``-DWOLFBOOT_SIGN_ECC256``), and + * the settings header those tokens are interpreted by, reached via + ``-include``. + + Feeding only one of them yields a confident, well-formed, wrong document. + With no ``-include``, the compiler reads an empty translation unit and the + dump is just the ``-D`` list plus compiler built-ins, so every macro the + settings header derives is missing. With no ``-D`` tokens, every ``#if`` + in that header is evaluated against an empty configuration and the dump + describes a product nobody built -- wolfBoot's user_settings.h gates + HAVE_ECC on WOLFBOOT_SIGN_ECC256, so a capture missing the latter silently + reports a secure bootloader with no signature algorithm. + + ``-I`` and ``-isystem`` from CFLAGS are forwarded so the ``-include`` + header can resolve the product headers it pulls in (user_settings.h, + target.h). Every other flag is dropped: they describe code generation, + not configuration, and cross-toolchain flags would break the host cc. + """ + toks = split_cflags(cflags) + defs, incs = [], [] + i = 0 + while i < len(toks): + t = toks[i] + if t.startswith("-D"): + defs.append(t) + elif t in ("-I", "-isystem", "-include") and i + 1 < len(toks): + # Separated form: keep -I/-isystem, but never a second -include + # (the settings header below is the only one we want). + if t != "-include": + incs += [t, toks[i + 1]] + i += 1 + elif t.startswith("-I"): + incs.append(t) + i += 1 + + for d in include_dirs: + incs += ["-I", d] + + cmd = [hostcc, "-dM", "-E", "-DWOLFSSL_USER_SETTINGS", *incs, *defs] + if settings_h: + cmd += ["-include", settings_h] + cmd += ["-x", "c", os.devnull] try: res = subprocess.run(cmd, capture_output=True, text=True, check=True) - except (OSError, subprocess.CalledProcessError): + except OSError: sys.exit(f"ERROR: '{hostcc} -dM -E' failed; install a host C compiler " f"or set --hostcc.") + except subprocess.CalledProcessError as e: + detail = (e.stderr or "").strip() + hint = "" + if settings_h: + hint = (f"\n The capture includes '{settings_h}'. Check that " + f"--include-dir covers the headers it pulls in.") + sys.exit(f"ERROR: '{hostcc} -dM -E' failed while capturing the build " + f"configuration.{hint}\n{detail}") return res.stdout @@ -176,6 +238,16 @@ def main(): grp = ap.add_mutually_exclusive_group() grp.add_argument("--cflags", default="", help="Build CFLAGS; -D tokens expanded through the host cc.") + ap.add_argument("--settings-h", default="", + help="Settings header -include'd during the --cflags " + "capture, so macros the header derives from the -D " + "set are recorded. Required for any product whose " + "configuration lives in a user_settings.h.") + ap.add_argument("--include-dir", action="append", default=[], + metavar="DIR", + help="Extra -I path for the --cflags capture (repeatable). " + "-I/-isystem already present in CFLAGS are forwarded " + "automatically.") grp.add_argument("--options-h", default="", help="A pre-expanded flat #define header, used verbatim.") grp.add_argument("--user-settings", default="", @@ -189,6 +261,11 @@ def main(): ap.add_argument("--version-file", default="") ap.add_argument("--version-macro", default="") ap.add_argument("--supplier", default="wolfSSL Inc.") + ap.add_argument("--component-type", default="", + help="CycloneDX component.type, mirrored to SPDX " + "primaryPackagePurpose. Use firmware for a " + "bootloader. Passed through only if the generator " + "supports it.") ap.add_argument("--license-file", default="") ap.add_argument("--license-override", default="") ap.add_argument("--license-text", default="") @@ -263,6 +340,12 @@ def main(): cmd += ["--no-artifact-hash"] # Config. + if args.settings_h and not args.cflags: + # --settings-h only feeds the --cflags capture. Ignoring it in + # silence is how a product ends up believing it recorded a + # configuration it never captured. + print("WARNING: --settings-h is only used with --cflags; " + "ignoring it here.", file=sys.stderr) if args.user_settings: if not os.path.isfile(args.user_settings): sys.exit(f"ERROR: --user-settings '{args.user_settings}' " @@ -277,7 +360,13 @@ def main(): 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) + if args.settings_h and not os.path.isfile(args.settings_h): + sys.exit(f"ERROR: --settings-h '{args.settings_h}' " + f"does not exist.") + defines_text = capture_macros( + args.hostcc, args.cflags, + settings_h=args.settings_h, + include_dirs=args.include_dir) else: sys.exit("ERROR: pass one of --cflags, --options-h, " "--user-settings, or --source-only.") @@ -296,6 +385,10 @@ def main(): if args.license_text: cmd += ["--license-text", args.license_text] + if args.component_type and gen_sbom_supports( + args.python, gen_sbom, "--component-type"): + cmd += ["--component-type", args.component_type] + # 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]