From 00c89d31a1aa7239b980d50e1f56fca62d2fd687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 22 Oct 2026 17:31:15 +0200 Subject: [PATCH] feat(sbom): add --macro-prefix to record only a product's own macros A product that shares one user_settings.h with wolfSSL, such as wolfCert, captures every wolfSSL macro along with its own, so its SBOM recorded wolfSSL's configuration as the product's build properties. gen-sbom now takes --macro-prefix PREFIX (repeatable) and records only the captured macros whose name starts with one of the prefixes. It applies to --options-h and --user-settings alike. --crypto-only auto still reads the full capture, since that decision depends on wolfSSL's macros, so the recorded subset basis can rest on a macro the document filtered out. The prefixes are recorded in the document (a wolfssl:sbom:macro-prefix property in CycloneDX, an annotation in SPDX) and folded into the configuration identity only when set, so an unscoped SBOM keeps its serialNumber. A prefix that matches none of the captured macros, or a prefix given when nothing was captured, prints a warning. An empty prefix, one with surrounding whitespace, or one holding a comma is an error, since the recorded value is a comma-joined list. The driver forwards --macro-prefix only when gen-sbom advertises it and prints a note when it has to drop it, as sbom.am does. The CMake helper takes MACRO_PREFIX, sbom.mk and sbom.am take SBOM_MACRO_PREFIX. sbom.am also gains SBOM_USER_SETTINGS (with SBOM_USER_SETTINGS_INCLUDES and SBOM_USER_SETTINGS_DEFINES), so an autotools product configured by a user_settings.h can use the same capture as the CMake and Make paths. Tests cover the filter and its warnings in test_gen_sbom.py, the identity fold, the driver forwarding, and the wiring in each build fragment: CMake and sbom.mk through their generated driver command, sbom.am through the autotools fixture. --- README.md | 2 +- share/README.md | 10 ++- share/build/sbom.cmake | 7 +- share/build/sbom.mk | 3 + share/gen-sbom | 67 +++++++++++++- share/sbom-driver.py | 13 +++ share/sbom.am | 71 ++++++++++++--- tests/test_gen_sbom.py | 149 +++++++++++++++++++++++++++++++ tests/test_sbom.py | 52 +++++++++++ tests/test_sbom_autotools.py | 35 +++++++- tests/test_sbom_cmake_install.py | 15 ++++ tests/test_sbom_identity.py | 6 ++ 12 files changed, 409 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 145cd41..cee858a 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ system it ships) while all of them converge on the same engine. | Frontend | Build system | What it extracts | |---|---|---| -| `sbom.am` | Autotools | Stages a private `make install`, discovers the installed library/binary, hashes it; config from `AM_CPPFLAGS`/`config.h`. | +| `sbom.am` | Autotools | Stages a private `make install`, discovers the installed library/binary, hashes it; config from `AM_CPPFLAGS`/`config.h`, or `SBOM_USER_SETTINGS`. | | `build/sbom.cmake` | CMake | Same shape, called as `wolfssl_compliance_tooling_add_sbom()` with `NAME`/`TARGETS`/`DEFS`. Also adds `install-sbom` and `uninstall-sbom`. | | `build/sbom.mk` | Plain Make | Product sets `SBOM_NAME`/`SBOM_SRCS`/`SBOM_CFLAGS` (plus `SBOM_SETTINGS_H` when a `user_settings.h` derives the config), includes the fragment. | | `frontends/compdb_sbom.py` | Any `compile_commands.json` | The universal fallback — TI CCS, MPLAB X, Renesas e2studio, or anything wrappable with `bear -- make ...`. | diff --git a/share/README.md b/share/README.md index 9313c1a..fcc2604 100644 --- a/share/README.md +++ b/share/README.md @@ -41,9 +41,17 @@ Config (choose one): - `--options-h PATH` — a pre-expanded flat `#define` header, used verbatim. - `--user-settings PATH` — path to `wolfssl/wolfcrypt/settings.h`. Also pass `--user-settings-include DIR` (the directory that contains `user_settings.h`) - and `--user-settings-define WOLFSSL_USER_SETTINGS`. + and `--user-settings-define WOLFSSL_USER_SETTINGS`. The autotools fragment + takes `SBOM_USER_SETTINGS`, `SBOM_USER_SETTINGS_INCLUDES` and + `SBOM_USER_SETTINGS_DEFINES`. - `--source-only` — no build-config macros (for example a Kconfig-driven build). +A product that shares one `user_settings.h` with wolfSSL captures wolfSSL's +macros along with its own. Pass `--macro-prefix PREFIX` (repeatable; Make and +autotools `SBOM_MACRO_PREFIX`, CMake `MACRO_PREFIX`) to record only the macros +whose name starts with that prefix, for example `WOLFCERT_`. `--crypto-only auto` +still reads the full capture. + Dependency (for linkers and bindings): `--dep-wolfssl`, `--dep-openssl`, and `--dep-version` are passed through only when the generator supports them. diff --git a/share/build/sbom.cmake b/share/build/sbom.cmake index ae9b309..c9e79c6 100644 --- a/share/build/sbom.cmake +++ b/share/build/sbom.cmake @@ -67,6 +67,8 @@ # them must contain user_settings.h. # USER_SETTINGS_DEFINE ... Predefines for that preprocess. # Include WOLFSSL_USER_SETTINGS. +# MACRO_PREFIX ... Record only the captured macros whose name +# starts with one of these prefixes, e.g. WOLFCERT_. # TV_OUT SPDX tag-value file, when a later step writes one. # INSTALL_DIR Doc directory for install-sbom. Default: # CMAKE_INSTALL_FULL_DOCDIR, else @@ -111,7 +113,7 @@ function(wolfssl_compliance_tooling_add_sbom) LICENSE_OVERRIDE LICENSE_TEXT INSTALL_DIR INSTALL_DEPENDS INSTALL_TARGET UNINSTALL_TARGET) set(_multi TARGETS DEFS DEP_VERSION INCLUDE_DIRS - USER_SETTINGS_INCLUDE USER_SETTINGS_DEFINE) + USER_SETTINGS_INCLUDE USER_SETTINGS_DEFINE MACRO_PREFIX) cmake_parse_arguments(SB "${_opts}" "${_one}" "${_multi}" ${ARGN}) if(NOT SB_NAME) @@ -274,6 +276,9 @@ function(wolfssl_compliance_tooling_add_sbom) if(NOT "${SB_CRYPTO_ONLY}" STREQUAL "") list(APPEND _cmd --crypto-only "${SB_CRYPTO_ONLY}") endif() + foreach(_pfx IN LISTS SB_MACRO_PREFIX) + list(APPEND _cmd --macro-prefix ${_pfx}) + endforeach() foreach(_dv IN LISTS SB_DEP_VERSION) list(APPEND _cmd --dep-version ${_dv}) endforeach() diff --git a/share/build/sbom.mk b/share/build/sbom.mk index 79882d4..0b69de4 100644 --- a/share/build/sbom.mk +++ b/share/build/sbom.mk @@ -29,6 +29,8 @@ # Predefines for that preprocess, space-separated. # Include WOLFSSL_USER_SETTINGS. # SBOM_SOURCE_ONLY = 1 Source-inventory SBOM with no build-config macros. +# SBOM_MACRO_PREFIX Record only the captured macros whose name starts +# with one of these prefixes, space-separated. # # Config, strongly recommended alongside SBOM_CFLAGS: # SBOM_SETTINGS_H Settings header -include'd during the capture, so the @@ -214,6 +216,7 @@ $(1): $($(2)PREREQS) $(if $($(2)DEP_LIBZ),--dep-libz "$($(2)DEP_LIBZ)") \ $(if $($(2)DOCUMENT_NAMESPACE),--document-namespace "$($(2)DOCUMENT_NAMESPACE)") \ $(if $($(2)CRYPTO_ONLY),--crypto-only "$($(2)CRYPTO_ONLY)") \ + $(foreach p,$($(2)MACRO_PREFIX),--macro-prefix "$(p)") \ $$$$dep_ver \ $(if $(or $($(2)GEN),$(GEN_SBOM)),--gen-sbom "$(or $($(2)GEN),$(GEN_SBOM))") \ $(if $($(2)CDX_OUT),--cdx-out "$($(2)CDX_OUT)") \ diff --git a/share/gen-sbom b/share/gen-sbom index 312f074..d4c136d 100755 --- a/share/gen-sbom +++ b/share/gen-sbom @@ -248,7 +248,8 @@ def resolve_crypto_only(mode, build_props): """Return (is_crypto_only, basis) for --crypto-only auto|yes|no. basis is 'declared' when the operator stated it, 'captured' when it was - read out of the configuration macros this SBOM records, and 'unknown' + read out of the captured configuration macros (before any --macro-prefix + filter, so the deciding macro may not be recorded), and 'unknown' when there are no captured macros to read (a --source-only front end such as Zephyr, where nothing in the inputs can settle the question). @@ -283,7 +284,8 @@ def config_identity(lib_hash, license_id, build_props, enabled_deps, dep_versions, *, supplier=None, component_type=None, license_text=None, hash_kind=None, hash_source=None, file_names=None, wolfssl_subset=None, subset_basis=None, - copyright_start=DEFAULT_COPYRIGHT_START): + copyright_start=DEFAULT_COPYRIGHT_START, + macro_prefixes=None): """Stable digest of the build configuration behind one name+version. Folded into the SBOM serialNumber / SPDX documentNamespace so two builds @@ -333,6 +335,8 @@ def config_identity(lib_hash, license_id, build_props, enabled_deps, # Folded only when set, so default documents keep their serialNumber. if copyright_start != DEFAULT_COPYRIGHT_START: _field('copyright_start', str(copyright_start)) + if macro_prefixes: + _field('macro_prefix', ','.join(macro_prefixes)) return h.hexdigest() @@ -1007,6 +1011,15 @@ def parse_user_settings(settings_h_path, include_dirs, predefines): return sorted(defines.items()) +def filter_macro_prefix(build_props, prefixes): + """Keep only the (name, value) pairs whose name starts with a prefix. + + An empty prefix list keeps everything.""" + if not prefixes: + return build_props + return [(k, v) for k, v in build_props if k.startswith(tuple(prefixes))] + + def gitoid_blob_sha256(path): """Compute the OmniBOR / git SHA-256 gitoid for a single file. @@ -1208,7 +1221,8 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, dep_version_overrides=None, hash_kind='library-binary', hash_source='lib', srcs_basenames=None, file_entries=None, component_type='library', wolfssl_subset=None, - subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START): + subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START, + macro_prefixes=None): bom_ref = derived_uuid(name, version, 'package') urls = project_urls(name) @@ -1242,6 +1256,11 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, {'name': f'wolfssl:build:{k}', 'value': v} for k, v in build_props ] + if macro_prefixes: + properties.append({ + 'name': 'wolfssl:sbom:macro-prefix', + 'value': ','.join(macro_prefixes), + }) # 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 @@ -1380,7 +1399,8 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, hash_source='lib', srcs_basenames=None, document_namespace=None, file_entries=None, component_type='library', wolfssl_subset=None, - subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START): + subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START, + macro_prefixes=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 @@ -1410,6 +1430,8 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, _annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}') if srcs_basenames: _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) + if macro_prefixes: + _annotate('wolfssl:sbom:macro-prefix=' + ','.join(macro_prefixes)) if wolfssl_subset: _annotate(f'wolfssl:sbom:wolfssl-subset={wolfssl_subset}') _annotate('wolfssl:sbom:wolfssl-subset-basis=' @@ -1703,6 +1725,15 @@ def main(): 'on the compiler command line. At minimum ' 'pass `WOLFSSL_USER_SETTINGS` so settings.h ' 'pulls in user_settings.h.') + parser.add_argument('--macro-prefix', action='append', default=[], + metavar='PREFIX', + help='Record only the build-configuration macros ' + 'whose name starts with PREFIX (repeatable), ' + 'e.g. WOLFCERT_ for a product that shares one ' + 'user_settings.h with wolfSSL. Applies to ' + '--options-h and --user-settings alike. ' + '--crypto-only auto still reads the full ' + 'capture.') # Component checksum source: pick exactly one. parser.add_argument('--lib', help='Path to the wolfSSL library artifact ' @@ -1809,6 +1840,14 @@ def main(): "compiled into your firmware (OmniBOR gitoid Merkle hash).\n" " --no-artifact-hash: record a placeholder when no " "hashable artefact exists (ROM/HSM/binary-only).") + if any(not pfx.strip() for pfx in args.macro_prefix): + sys.exit("ERROR: --macro-prefix must not be empty.") + if any(pfx != pfx.strip() for pfx in args.macro_prefix): + sys.exit("ERROR: --macro-prefix must not start or end with " + "whitespace.") + if any(',' in pfx for pfx in args.macro_prefix): + sys.exit("ERROR: --macro-prefix takes one prefix; repeat the option " + "for more.") # SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI # per RFC 3986. `make sbom` runs pyspdxtools afterwards and would @@ -1881,6 +1920,23 @@ def main(): crypto_only, subset_basis = resolve_crypto_only(args.crypto_only, build_props) wolfssl_subset = WOLFSSL_SUBSET_CRYPTO_ONLY if crypto_only else None + macro_prefixes = sorted(set(args.macro_prefix)) + captured = build_props + build_props = filter_macro_prefix(build_props, macro_prefixes) + if macro_prefixes and not captured: + print(f"WARNING: --macro-prefix {','.join(macro_prefixes)} has no " + "captured macros to filter.", file=sys.stderr) + elif macro_prefixes and not build_props: + print( + f"WARNING: --macro-prefix {','.join(macro_prefixes)} matched none " + f"of the {len(captured)} captured macros; the SBOM records no " + "build configuration.", + file=sys.stderr) + elif captured: + for pfx in macro_prefixes: + if not filter_macro_prefix(captured, [pfx]): + print(f"WARNING: --macro-prefix {pfx} matched none of the " + f"{len(captured)} captured macros.", file=sys.stderr) if subset_basis == 'unknown' and 'wolfssl' in enabled_deps: # A front end that captures no macros (--source-only) cannot answer # the question either way. Say so rather than defaulting silently to @@ -1981,6 +2037,7 @@ def main(): wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, copyright_start=args.copyright_start, + macro_prefixes=macro_prefixes, ) serial = derived_uuid(args.name, args.version, 'serial', config_id) doc_ns_uuid = derived_uuid(args.name, args.version, 'document', config_id) @@ -1997,6 +2054,7 @@ def main(): wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, copyright_start=args.copyright_start, + macro_prefixes=macro_prefixes, ) spdx = generate_spdx( args.name, args.version, args.supplier, @@ -2011,6 +2069,7 @@ def main(): wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, copyright_start=args.copyright_start, + macro_prefixes=macro_prefixes, ) try: diff --git a/share/sbom-driver.py b/share/sbom-driver.py index 8837615..b568841 100755 --- a/share/sbom-driver.py +++ b/share/sbom-driver.py @@ -304,6 +304,11 @@ def main(): "user_settings.h.") grp.add_argument("--source-only", action="store_true", help="Source-inventory SBOM with no build-config macros.") + ap.add_argument("--macro-prefix", action="append", default=[], + metavar="PREFIX", + help="Record only configuration macros whose name starts " + "with PREFIX (repeatable). Passed through only if " + "the generator supports it.") # Identity. ap.add_argument("--name", required=True, help="Package name in the SBOM.") @@ -486,6 +491,14 @@ def main(): if args.dep_version and gen_sbom_supports(args.python, gen_sbom, "--dep-version"): for dv in args.dep_version: cmd += ["--dep-version", dv] + if args.macro_prefix: + if gen_sbom_supports(args.python, gen_sbom, "--macro-prefix"): + for prefix in args.macro_prefix: + cmd += ["--macro-prefix", prefix] + else: + print("NOTE: this gen-sbom has no --macro-prefix support; " + "every captured macro will be recorded.", + file=sys.stderr) if args.crypto_only and gen_sbom_supports( args.python, gen_sbom, "--crypto-only"): cmd += ["--crypto-only", args.crypto_only] diff --git a/share/sbom.am b/share/sbom.am index c54906d..11474a2 100644 --- a/share/sbom.am +++ b/share/sbom.am @@ -23,6 +23,17 @@ # 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_USER_SETTINGS Path to wolfssl/wolfcrypt/settings.h, for a product +# configured by a user_settings.h instead of configure. +# gen-sbom walks it through pcpp. Also set +# SBOM_USER_SETTINGS_INCLUDES (one directory must hold +# user_settings.h) and SBOM_USER_SETTINGS_DEFINES +# (include WOLFSSL_USER_SETTINGS), both space-separated. +# Mutually exclusive with SBOM_OPTIONS_H. Default: unset. +# SBOM_MACRO_PREFIX Space-separated macro name prefixes. When set, only +# the captured macros starting with one of them are +# recorded, e.g. WOLFCERT_ for a product that shares its +# user_settings.h with wolfSSL. 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). @@ -85,8 +96,8 @@ # (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. +# (?=) and the GNU make functions $(wildcard), $(if), $(firstword), $(strip) +# and $(addprefix); under a non-GNU make the SBOM targets will not work. # --------------------------------------------------------------------------- SBOM_ARTIFACT ?= lib @@ -144,15 +155,15 @@ CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) # 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 the -D/-U/-I tokens -# of AM_CPPFLAGS/AM_CFLAGS/CFLAGS + config.h, expanded through the HOST compiler -# SBOM_HOSTCC so the SBOM is reproducible across cross toolchains; 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). +# configured build macros (from SBOM_USER_SETTINGS or SBOM_OPTIONS_H if set, +# else the -D/-U/-I tokens of AM_CPPFLAGS/AM_CFLAGS/CFLAGS + config.h, +# expanded through the HOST compiler SBOM_HOSTCC so the SBOM is reproducible +# across cross toolchains; 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."; \ @@ -202,7 +213,29 @@ sbom: fi; \ echo "SBOM: hashing $$sbom_art"; \ opts_h="$(SBOM_OPTIONS_H)"; \ - if test -z "$$opts_h"; then \ + set --; \ + if test -n "$(SBOM_USER_SETTINGS)"; then \ + if test -n "$$opts_h"; then \ + echo "ERROR: set SBOM_USER_SETTINGS or SBOM_OPTIONS_H, not both."; \ + exit 1; \ + fi; \ + if test -z "$(strip $(SBOM_USER_SETTINGS_INCLUDES))"; then \ + echo "ERROR: SBOM_USER_SETTINGS needs SBOM_USER_SETTINGS_INCLUDES."; \ + exit 1; \ + fi; \ + case " $(SBOM_USER_SETTINGS_DEFINES) " in \ + *" WOLFSSL_USER_SETTINGS "*|*" WOLFSSL_USER_SETTINGS="*) ;; \ + *) echo "ERROR: SBOM_USER_SETTINGS_DEFINES must include WOLFSSL_USER_SETTINGS."; \ + exit 1;; \ + esac; \ + set -- --user-settings "$(SBOM_USER_SETTINGS)"; \ + for d in $(SBOM_USER_SETTINGS_INCLUDES); do \ + set -- "$$@" --user-settings-include "$$d"; \ + done; \ + for d in $(SBOM_USER_SETTINGS_DEFINES); do \ + set -- "$$@" --user-settings-define "$$d"; \ + done; \ + elif test -z "$$opts_h"; then \ opts_h="$$_defines"; \ sbom_cpp=""; \ for f in $(AM_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS); do \ @@ -212,6 +245,18 @@ sbom: $(if $(wildcard $(SBOM_CONFIG_H)),-include $(SBOM_CONFIG_H)) \ -x c /dev/null > "$$_defines"; \ fi; \ + if test $$# -eq 0; then set -- --options-h "$$opts_h"; fi; \ + if test -n "$(SBOM_MACRO_PREFIX)"; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--macro-prefix'; then \ + for p in $(SBOM_MACRO_PREFIX); do \ + set -- "$$@" --macro-prefix "$$p"; \ + done; \ + else \ + echo "NOTE: this gen-sbom has no --macro-prefix support; every"; \ + echo " captured macro will be recorded."; \ + fi; \ + 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`; \ @@ -291,7 +336,7 @@ sbom: --version $(PACKAGE_VERSION) \ --supplier "wolfSSL Inc." \ --license-file $(SBOM_LICENSE_FILE) \ - --options-h "$$opts_h" \ + "$$@" \ --lib "$$sbom_art" \ $$dep_args \ $${ns_arg:+$$ns_arg '$(SBOM_DOCUMENT_NAMESPACE)'} \ diff --git a/tests/test_gen_sbom.py b/tests/test_gen_sbom.py index acc08dc..164f69d 100644 --- a/tests/test_gen_sbom.py +++ b/tests/test_gen_sbom.py @@ -2999,6 +2999,155 @@ def test_unknown_product_omits_cpe(self): self.assertIsNone(gs.product_cpe_status('wolftpm')) +class TestMacroPrefix(unittest.TestCase): + """--macro-prefix scopes the recorded configuration to one product.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = self._tmp.name + self.opts_h = self._write( + 'options.h', + '#define HAVE_AES 1\n' + '#define WOLFCRYPT_ONLY\n' + '#define WOLFCERT_SCEP 1\n' + '#define WOLFCERT_EST 1\n') + self.lib = self._write('libx.a', '!\n') + self.lic = self._write('LICENSING', 'Licensed under GPLv3.\n') + + def tearDown(self): + self._tmp.cleanup() + + def _write(self, name, body): + path = os.path.join(self.dir, name) + with open(path, 'w') as f: + f.write(body) + return path + + def _run(self, *argv, config=None): + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = here.parent / 'share' / 'gen-sbom' + return subprocess.run( + ['python3', str(script), '--name', 'wolfcert', '--version', '1.0', + '--license-file', self.lic, '--lib', self.lib, + *(config or ['--options-h', self.opts_h]), + '--cdx-out', os.path.join(self.dir, 'out.cdx.json'), + '--spdx-out', os.path.join(self.dir, 'out.spdx.json'), *argv], + capture_output=True, text=True) + + def _generate(self, *argv, config=None): + proc = self._run(*argv, config=config) + self.assertEqual(proc.returncode, 0, proc.stderr) + with open(os.path.join(self.dir, 'out.cdx.json')) as f: + cdx_doc = json.load(f) + with open(os.path.join(self.dir, 'out.spdx.json')) as f: + spdx_doc = json.load(f) + props = {p['name']: p['value'] + for p in cdx_doc['metadata']['component']['properties']} + return props, spdx_doc, proc + + @staticmethod + def _build(props): + return sorted(k[len('wolfssl:build:'):] for k in props + if k.startswith('wolfssl:build:')) + + def test_filter_helper(self): + pairs = [('HAVE_AES', '1'), ('WOLFCERT_A', ''), ('WOLFSSL_X', '2')] + self.assertEqual(gs.filter_macro_prefix(pairs, []), pairs) + self.assertEqual(gs.filter_macro_prefix(pairs, ['WOLFCERT_']), + [('WOLFCERT_A', '')]) + self.assertEqual( + gs.filter_macro_prefix(pairs, ['WOLFCERT_', 'HAVE_']), + [('HAVE_AES', '1'), ('WOLFCERT_A', '')]) + + def test_default_records_everything(self): + props, spdx, _ = self._generate() + self.assertEqual(self._build(props), [ + 'HAVE_AES', 'WOLFCERT_EST', 'WOLFCERT_SCEP', 'WOLFCRYPT_ONLY']) + self.assertNotIn('wolfssl:sbom:macro-prefix', props) + self.assertNotIn('macro-prefix', json.dumps(spdx)) + + def test_options_h_filtered_and_scope_recorded(self): + props, spdx, _ = self._generate('--macro-prefix', 'WOLFCERT_') + self.assertEqual(self._build(props), ['WOLFCERT_EST', 'WOLFCERT_SCEP']) + self.assertEqual(props['wolfssl:sbom:macro-prefix'], 'WOLFCERT_') + pkg = spdx['packages'][0] + self.assertEqual(pkg['comment'], + 'Build configuration defines: WOLFCERT_EST, ' + 'WOLFCERT_SCEP') + notes = [a['comment'] for a in pkg['annotations']] + self.assertIn('wolfssl:sbom:macro-prefix=WOLFCERT_', notes) + + def test_crypto_only_reads_the_unfiltered_capture(self): + props, _, _ = self._generate('--macro-prefix', 'WOLFCERT_') + self.assertEqual(props['wolfssl:sbom:wolfssl-subset'], + gs.WOLFSSL_SUBSET_CRYPTO_ONLY) + self.assertEqual(props['wolfssl:sbom:wolfssl-subset-basis'], + 'captured') + + def test_multiple_prefixes_sorted_and_deduplicated(self): + props, _, _ = self._generate('--macro-prefix', 'WOLFCERT_', + '--macro-prefix', 'HAVE_', + '--macro-prefix', 'WOLFCERT_') + self.assertEqual(self._build(props), + ['HAVE_AES', 'WOLFCERT_EST', 'WOLFCERT_SCEP']) + self.assertEqual(props['wolfssl:sbom:macro-prefix'], + 'HAVE_,WOLFCERT_') + + def test_no_match_warns(self): + props, _, proc = self._generate('--macro-prefix', 'WOLFSSH_') + self.assertEqual(self._build(props), []) + self.assertIn('matched none of the 4 captured macros', proc.stderr) + + def test_partial_miss_warns_per_prefix(self): + props, _, proc = self._generate('--macro-prefix', 'HAVE_', + '--macro-prefix', 'WOLFSSH_') + self.assertNotEqual(self._build(props), []) + self.assertIn('--macro-prefix WOLFSSH_ matched none', proc.stderr) + self.assertNotIn('--macro-prefix HAVE_ matched none', proc.stderr) + + def test_empty_capture_warns(self): + empty = self._write('empty.h', '') + _, _, proc = self._generate('--macro-prefix', 'WOLFCERT_', + config=['--options-h', empty]) + self.assertIn('has no captured macros to filter', proc.stderr) + + def test_padded_prefix_rejected(self): + proc = self._run('--macro-prefix', ' HAVE_') + self.assertNotEqual(proc.returncode, 0) + self.assertIn('whitespace', proc.stderr) + + def test_comma_in_prefix_rejected(self): + proc = self._run('--macro-prefix', 'HAVE_,WOLFCERT_') + self.assertNotEqual(proc.returncode, 0) + self.assertIn('takes one prefix', proc.stderr) + + def test_empty_prefix_rejected(self): + proc = self._run('--macro-prefix', '') + self.assertNotEqual(proc.returncode, 0) + self.assertIn('--macro-prefix must not be empty', proc.stderr) + + def test_user_settings_filtered(self): + try: + import pcpp # noqa: F401 + except ImportError: + self.fail('pcpp is required for the --user-settings path; ' + "install with 'python3 -m pip install --user pcpp'.") + settings = self._write( + 'settings.h', + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n') + self._write('user_settings.h', + '#define WOLFCERT_SCEP 1\n#define HAVE_ECC 1\n') + props, _, _ = self._generate( + '--macro-prefix', 'WOLFCERT_', + config=['--user-settings', settings, + '--user-settings-include', self.dir, + '--user-settings-define', 'WOLFSSL_USER_SETTINGS']) + self.assertEqual(self._build(props), ['WOLFCERT_SCEP']) + + class TestResolveCryptoOnly(unittest.TestCase): """--crypto-only auto reads the macro; explicit values win.""" diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 8ac9285..39841ca 100755 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -20,6 +20,7 @@ import importlib.util import json import os +import shutil import subprocess import sys import tempfile @@ -487,6 +488,57 @@ def integration_tests(gen_sbom): check("WOLFSSL_USER_SETTINGS" in argv, "WOLFSSL_USER_SETTINGS is forwarded") + r = run_driver(d, gen_sbom, [ + "--lib", lib, "--cflags=-DHAVE_AES -DWOLFCERT_SCEP", + "--macro-prefix", "WOLFCERT_", + "--cdx-out", "pfx.cdx.json", "--spdx-out", "pfx.spdx.json"]) + check(r.returncode == 0, "macro prefix generates\n" + r.stderr) + pfx = os.path.join(d, "pfx.cdx.json") + if r.returncode == 0 and os.path.isfile(pfx): + props = {p["name"] for p in json.load( + open(pfx, encoding="utf-8"))["metadata"]["component"] + ["properties"]} + check("wolfssl:build:WOLFCERT_SCEP" in props, + "prefixed macro is recorded") + check("wolfssl:build:HAVE_AES" not in props, + "unprefixed macro is dropped") + if os.path.isfile(argv_path): + os.remove(argv_path) + r = run_driver(d, stub, [ + "--lib", lib, "--cflags=-DHAVE_AES", "--macro-prefix", "WOLFCERT_", + "--cdx-out", "stub2.cdx.json", "--spdx-out", "stub2.spdx.json"], + env=env) + argv = [] + if os.path.isfile(argv_path): + argv = open(argv_path, encoding="utf-8").read().splitlines() + check(r.returncode == 0 and "--macro-prefix" not in argv, + "--macro-prefix is not forwarded to a generator without it") + check("no --macro-prefix support" in r.stderr, + "dropping --macro-prefix prints a note") + + make = shutil.which("make") + if make: + mk_argv = os.path.join(d, "mk-argv.txt") + mk_driver = os.path.join(d, "mk-driver") + with open(mk_driver, "w") as f: + f.write(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{mk_argv}'\n") + os.chmod(mk_driver, 0o755) + with open(os.path.join(d, "Makefile"), "w") as f: + f.write("SBOM_NAME = t\nSBOM_VERSION = 1\n" + f"SBOM_LIB = {lib}\nSBOM_DRIVER = {mk_driver}\n" + "SBOM_MACRO_PREFIX = WOLFCERT_ HAVE_\n" + f"include {os.path.join(SHARE, 'build', 'sbom.mk')}\n") + r = subprocess.run([make, "-C", d, "sbom"], + capture_output=True, text=True) + argv = [] + if os.path.isfile(mk_argv): + argv = open(mk_argv, encoding="utf-8").read().splitlines() + pairs = list(zip(argv, argv[1:])) + check(r.returncode == 0 + and ("--macro-prefix", "WOLFCERT_") in pairs + and ("--macro-prefix", "HAVE_") in pairs, + "sbom.mk forwards SBOM_MACRO_PREFIX\n" + r.stderr) + def main(): ap = argparse.ArgumentParser() diff --git a/tests/test_sbom_autotools.py b/tests/test_sbom_autotools.py index e8acb7c..1f395db 100644 --- a/tests/test_sbom_autotools.py +++ b/tests/test_sbom_autotools.py @@ -72,7 +72,7 @@ def _run(self, cmd, cwd, env): self.assertEqual(r.returncode, 0, f"{' '.join(cmd)} failed\n{r.stdout}\n{r.stderr}") - def test_sbom_hash_matches_installed_library(self): + def _configure_fixture(self): src = os.path.join(self.tmp, "src") os.makedirs(os.path.join(src, "m4")) for name, text in (("configure.ac", CONFIGURE_AC), @@ -89,6 +89,10 @@ def test_sbom_hash_matches_installed_library(self): self._run(["autoreconf", "-fi"], src, env) self._run(["./configure", "--prefix=/usr/local"], src, env) self._run(["make"], src, env) + return src, env + + def test_sbom_hash_matches_installed_library(self): + src, env = self._configure_fixture() self._run(["make", "sbom"], src, env) destdir = os.path.join(self.tmp, "destdir") self._run(["make", "install", "DESTDIR=" + destdir], src, env) @@ -118,5 +122,34 @@ def test_sbom_hash_matches_installed_library(self): self.assertEqual(spdx_hashes.get("SHA256"), want) + def test_macro_prefix_and_user_settings_checks(self): + src, env = self._configure_fixture() + self._run(["make", "sbom", "SBOM_MACRO_PREFIX=HAVE_ STDC_"], src, env) + with open(os.path.join(src, "fixture-1.0.0.cdx.json"), + encoding="utf-8") as f: + props = {p["name"]: p["value"] for p in + json.load(f)["metadata"]["component"]["properties"]} + self.assertEqual(props.get("wolfssl:sbom:macro-prefix"), + "HAVE_,STDC_") + build = [k for k in props if k.startswith("wolfssl:build:")] + self.assertTrue(build) + self.assertTrue(all(k[len("wolfssl:build:"):].startswith( + ("HAVE_", "STDC_")) for k in build), build) + + for args, error in ( + (["SBOM_USER_SETTINGS=s.h", "SBOM_OPTIONS_H=o.h"], + "not both"), + (["SBOM_USER_SETTINGS=s.h"], + "needs SBOM_USER_SETTINGS_INCLUDES"), + (["SBOM_USER_SETTINGS=s.h", "SBOM_USER_SETTINGS_INCLUDES=.", + "SBOM_USER_SETTINGS_DEFINES=HAVE_X"], + "must include WOLFSSL_USER_SETTINGS")): + with self.subTest(args=args): + r = subprocess.run(["make", "sbom", *args], cwd=src, env=env, + capture_output=True, text=True) + self.assertNotEqual(r.returncode, 0) + self.assertIn(error, r.stdout + r.stderr) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sbom_cmake_install.py b/tests/test_sbom_cmake_install.py index 240a7dd..77b883f 100644 --- a/tests/test_sbom_cmake_install.py +++ b/tests/test_sbom_cmake_install.py @@ -326,6 +326,21 @@ def test_second_call_keeps_its_install_target_name(self): self.assertIn("install-sbom-extra", text) self.assertIn("uninstall-sbom-extra", text) + def test_macro_prefix_reaches_the_driver(self): + build = self._configure(f""" +cmake_minimum_required(VERSION 3.16) +project(sbomtest NONE) +include("{BUILD / "sbom.cmake"}") +wolfssl_compliance_tooling_add_sbom( + NAME demo + VERSION 1 + LIB ${{CMAKE_CURRENT_BINARY_DIR}}/libdemo.a + OPTIONS_H ${{CMAKE_CURRENT_SOURCE_DIR}}/opts.h + LICENSE ${{CMAKE_CURRENT_SOURCE_DIR}}/LICENSE + MACRO_PREFIX WOLFCERT_ HAVE_) +""") + text = self._build_text(build) + self.assertIn("--macro-prefix WOLFCERT_ --macro-prefix HAVE_", text) if __name__ == "__main__": unittest.main() diff --git a/tests/test_sbom_identity.py b/tests/test_sbom_identity.py index ab0eb9b..3090234 100644 --- a/tests/test_sbom_identity.py +++ b/tests/test_sbom_identity.py @@ -83,6 +83,7 @@ def _load_gen_sbom(): "--dep-libz", "--crypto-only", "--dep-version", + "--macro-prefix", }) IDENTITY_EXEMPT = { @@ -183,6 +184,10 @@ def test_differs_on_subset_and_basis(self): self.assertNotEqual(self._id(subset_basis="declared"), self._id(subset_basis="captured")) + def test_macro_prefix(self): + self.assertEqual(self._id(), self._id(macro_prefixes=[])) + self.assertNotEqual(self._id(), self._id(macro_prefixes=["HAVE_"])) + def test_fields_do_not_alias(self): # Tagged, NUL-delimited framing: a value moved from one field to # another must not produce the same digest. @@ -414,6 +419,7 @@ def _identity_rows(f): {"--dep-wolfssl": "yes"}, {"--dep-wolfssl": "yes", "--dep-version": "wolfssl=9.9.9"}, base), + ("--macro-prefix", {}, {"--macro-prefix": ["HAVE_"]}, base), ]