From 6d83dd00248479122e8656cbd530d5023f0a54ec Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Tue, 28 Apr 2026 13:36:30 -0700 Subject: [PATCH 1/5] feat: add CRA SBOM generation (make sbom) Adds `make sbom` producing CycloneDX 1.6 and SPDX 2.3 SBOMs for EU Cyber Resilience Act compliance. Generation is handled by scripts/gen-sbom (Python 3, stdlib only). The script stages a `make install`, hashes the installed libwolfssl.so, generates both formats, then removes the staging directory. pyspdxtools validates the SPDX JSON and converts it to tag-value (.spdx). Output files (all versioned): wolfssl-.cdx.json CycloneDX 1.6 JSON wolfssl-.spdx.json SPDX 2.3 JSON wolfssl-.spdx SPDX 2.3 tag-value SBOMs include: SHA-256 of the library, CPE, PURL, license detected from the LICENSING file, copyright, and build configuration (options.h defines as CDX properties). Optional external dependencies (liboqs, libxmss, liblms, libz) appear as separate components when enabled. Version detection for deps without pkg-config (libxmss, liblms) uses `git describe --tags --always` on the source tree root. configure.ac changes: - AC_SUBST ENABLED_LIBOQS/LIBXMSS/LIBLMS/LIBZ so the dep flags set during ./configure are visible in the generated Makefile - AC_SUBST LIBLMS_ROOT (XMSS_ROOT was already exported by wolfssl) so gen-sbom can locate the source tree for git describe - AC_PATH_PROG([GIT]) to find git robustly at configure time rather than relying on PATH at make sbom time - Initialize LIBLMS_ROOT="" before the liblms detection block, mirroring how XMSS_ROOT is defaulted in the disabled branch Also adds: doc/SBOM.md, INSTALL section 21, README one-liner, install-sbom / uninstall-sbom targets. --- INSTALL | 36 ++++ Makefile.am | 56 +++++++ README.md | 5 + configure.ac | 12 ++ doc/SBOM.md | 107 ++++++++++++ scripts/gen-sbom | 417 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 633 insertions(+) create mode 100644 doc/SBOM.md create mode 100755 scripts/gen-sbom diff --git a/INSTALL b/INSTALL index dc6e2908c1a..f02dd44da3a 100644 --- a/INSTALL +++ b/INSTALL @@ -322,3 +322,39 @@ We also have vcpkg ports for wolftpm, wolfmqtt and curl. Deprecated. wolfSSL now has its own XMMS/XMSS^MT implementation in wolfCrypt. + +21. Generating an SBOM (Software Bill of Materials) + + wolfSSL can generate a Software Bill of Materials for EU Cyber Resilience + Act (CRA) compliance after a normal build and install. + + Prerequisites: + - python3 (detected automatically by configure) + - pyspdxtools (pip install spdx-tools) + + Usage: + + $ ./configure + $ make + $ make sbom + + This produces three files in the build directory: + + wolfssl-.cdx.json CycloneDX 1.6 JSON + wolfssl-.spdx.json SPDX 2.3 JSON + wolfssl-.spdx SPDX 2.3 tag-value (validated by pyspdxtools) + + The SPDX JSON is validated by pyspdxtools before the tag-value file is + written; make sbom fails if validation fails. + + To install the SBOM files to $(datadir)/doc/wolfssl/: + + $ make install-sbom + + To remove installed SBOM files: + + $ make uninstall-sbom + + The generated files are removed by make clean. + + For details on the SBOM contents and CRA context, see doc/SBOM.md. diff --git a/Makefile.am b/Makefile.am index fce812babf5..ea2a04db6b4 100644 --- a/Makefile.am +++ b/Makefile.am @@ -350,3 +350,59 @@ merge-clean: .cu.lo: $(LIBTOOL) --tag=CC --mode=compile $(COMPILE) --compile -o $@ $< -static + +# SBOM generation (CRA compliance) +SBOM_CDX = wolfssl-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = wolfssl-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = wolfssl-$(PACKAGE_VERSION).spdx +sbomdir = $(datadir)/doc/$(PACKAGE) + +.PHONY: sbom install-sbom uninstall-sbom + +sbom: + @if test -z "$(PYTHON3)"; then \ + echo ""; \ + echo "ERROR: 'python3' not found in PATH. Cannot generate SBOM."; \ + echo ""; \ + exit 1; \ + fi + @if test -z "$(PYSPDXTOOLS)"; then \ + echo ""; \ + echo "ERROR: 'pyspdxtools' not found in PATH. Cannot validate SBOM."; \ + echo " Install: pip install spdx-tools"; \ + echo ""; \ + exit 1; \ + fi + rm -rf $(abs_builddir)/_sbom_staging + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging + $(PYTHON3) $(srcdir)/scripts/gen-sbom \ + --name $(PACKAGE) \ + --version $(PACKAGE_VERSION) \ + --license-file $(srcdir)/LICENSING \ + --options-h $(abs_builddir)/wolfssl/options.h \ + --lib $(abs_builddir)/_sbom_staging$(libdir)/libwolfssl.so.$(WOLFSSL_LIBRARY_VERSION_FIRST).$(WOLFSSL_LIBRARY_VERSION_SECOND).$(WOLFSSL_LIBRARY_VERSION_THIRD) \ + --dep-liboqs $(ENABLED_LIBOQS) \ + --dep-libxmss $(ENABLED_LIBXMSS) \ + --dep-libxmss-root '$(XMSS_ROOT)' \ + --dep-liblms $(ENABLED_LIBLMS) \ + --dep-liblms-root '$(LIBLMS_ROOT)' \ + --dep-libz $(ENABLED_LIBZ) \ + --git '$(GIT)' \ + --cdx-out $(abs_builddir)/$(SBOM_CDX) \ + --spdx-out $(abs_builddir)/$(SBOM_SPDX) + rm -rf $(abs_builddir)/_sbom_staging + $(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) + +CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) diff --git a/README.md b/README.md index 4c800d85a40..4a3190eacec 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ applications which have previously used the OpenSSL package. For a complete feature list, see [Chapter 4](https://www.wolfssl.com/docs/wolfssl-manual/ch4/) of the wolfSSL manual. +## SBOM / CRA Compliance + +wolfSSL provides a Software Bill of Materials (SBOM) for EU Cyber Resilience +Act (CRA) compliance via `make sbom`. See `doc/SBOM.md` for details. + ## Notes, Please Read ### Note 1 diff --git a/configure.ac b/configure.ac index 69f8501f04d..e0583b81580 100644 --- a/configure.ac +++ b/configure.ac @@ -1989,6 +1989,7 @@ done # liblms # Get the path to the hash-sigs LMS HSS lib. ENABLED_LIBLMS="no" +LIBLMS_ROOT="" tryliblmsdir="" AC_ARG_WITH([liblms], [AS_HELP_STRING([--with-liblms=PATH],[PATH to hash-sigs LMS/HSS install (default /usr/local) (requires --enable-experimental)!])], @@ -2051,6 +2052,7 @@ AC_ARG_WITH([liblms], AM_CFLAGS="$AM_CFLAGS -DHAVE_LIBLMS" ENABLED_LIBLMS="yes" + LIBLMS_ROOT=$tryliblmsdir ] ) @@ -11756,6 +11758,16 @@ AC_SUBST([WOLFSSL_PREFIX_ABS]) AC_SUBST([WOLFSSL_LIBDIR_ABS]) AC_SUBST([WOLFSSL_INCLUDEDIR_ABS]) +# SBOM generation +AC_PATH_PROG([PYTHON3], [python3]) +AC_PATH_PROG([PYSPDXTOOLS], [pyspdxtools]) +AC_PATH_PROG([GIT], [git]) +AC_SUBST([ENABLED_LIBOQS]) +AC_SUBST([ENABLED_LIBXMSS]) +AC_SUBST([ENABLED_LIBLMS]) +AC_SUBST([ENABLED_LIBZ]) +AC_SUBST([LIBLMS_ROOT]) + # FINAL AC_CONFIG_FILES([stamp-h], [echo timestamp > stamp-h]) AC_CONFIG_FILES([Makefile diff --git a/doc/SBOM.md b/doc/SBOM.md new file mode 100644 index 00000000000..0f201af053b --- /dev/null +++ b/doc/SBOM.md @@ -0,0 +1,107 @@ +# wolfSSL SBOM Generation + +wolfSSL generates a Software Bill of Materials (SBOM) to support compliance +with the EU Cyber Resilience Act (CRA), which requires software products +placed on the EU market to provide a machine-readable SBOM identifying all +software components. + +## Quick Start + +```sh +./configure +make +make sbom +``` + +This requires `python3` and `pyspdxtools` (`pip install spdx-tools`). +Both are detected by `configure`; `make sbom` fails with a clear error +message if either is missing. + +## Output Files + +`make sbom` produces three files in the build directory: + +| File | Format | Standard | Primary use | +|---|---|---|---| +| `wolfssl-.cdx.json` | JSON | CycloneDX 1.6 | Supply-chain tooling, VEX | +| `wolfssl-.spdx.json` | JSON | SPDX 2.3 | Machine processing | +| `wolfssl-.spdx` | Tag-value | SPDX 2.3 | Human review, archival | + +The `.spdx` tag-value file is produced by `pyspdxtools` converting the +`.spdx.json`. If the JSON fails SPDX validation, `make sbom` stops with +a non-zero exit and the tag-value file is not written. + +## Installing the SBOM + +```sh +make install-sbom # installs to $(datadir)/doc/wolfssl/ +make uninstall-sbom # removes the installed files +``` + +The generated files are removed by `make clean`. + +## SBOM Contents + +Both formats contain the same information: + +| Field | Value | +|---|---| +| Name | `wolfssl` | +| Version | from `configure.ac` (`PACKAGE_VERSION`) | +| Type | library | +| Supplier | wolfSSL Inc. | +| License | detected from `LICENSING` file (currently `GPL-3.0-only`) | +| Copyright | `Copyright (C) 2006- wolfSSL Inc.` | +| SHA-256 | hash of the installed `libwolfssl.so.X.Y.Z` | +| CPE | `cpe:2.3:a:wolfssl:wolfssl::*:*:*:*:*:*:*` | +| PURL | `pkg:generic/wolfssl@` | +| Download location | `https://github.com/wolfSSL/wolfssl` | +| Third-party deps | none (wolfssl has no runtime dependencies in a default build) | + +### License detection + +The license SPDX identifier is parsed from the `LICENSING` file at SBOM +generation time, not hardcoded. If the `LICENSING` file cannot be parsed, +`make sbom` warns and uses `NOASSERTION` rather than silently emitting a +wrong value. + +### Dual licensing + +wolfSSL is available under `GPL-3.0-only` for open-source use, with a +commercial license for proprietary products. The SBOM reflects the +open-source license. Commercial licensees should update the `licenseConcluded` +field to `LicenseRef-wolfSSL-Commercial` or their applicable SPDX expression +when distributing under a commercial agreement. + +## Validating the SBOM Manually + +```sh +# Validate SPDX JSON +pyspdxtools --infile wolfssl-.spdx.json + +# Convert to another format (e.g. RDF) +pyspdxtools --infile wolfssl-.spdx.json \ + --outfile wolfssl-.spdx.rdf +``` + +### External dependency version detection + +For dependencies with pkg-config support (`liboqs`, `libz`), the version is +queried via `pkg-config --modversion` at generation time. + +For dependencies without pkg-config (`libxmss`, `liblms`), wolfSSL is typically +built against a source checkout rather than an installed package. The generator +falls back to `git describe --tags --always` on the source tree root (passed via +`configure` as `XMSS_ROOT` / `LIBLMS_ROOT`). If the source tree has no tags, +`git describe` returns the short commit hash, which is recorded as-is. If the +source tree is unavailable or `git` is not found, version is recorded as +`NOASSERTION`. + +## Implementation Notes + +SBOM generation is implemented in `scripts/gen-sbom` (Python 3, stdlib only) +and hooked into the autotools build via `Makefile.am` and `configure.ac`. +The script stages a `make install` into a temporary directory, hashes the +installed library, generates both SBOM formats, then removes the staging +directory. The `pyspdxtools` validation and conversion step runs after +generation and gates the build on SPDX conformance. diff --git a/scripts/gen-sbom b/scripts/gen-sbom new file mode 100755 index 00000000000..ad893e2b6e6 --- /dev/null +++ b/scripts/gen-sbom @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.""" + +import argparse +import hashlib +import json +import re +import subprocess +import sys +import uuid +from datetime import datetime, timezone + + +# Known metadata for optional external dependencies. +# Version is detected at runtime via pkg-config; falls back to None. +DEP_META = { + '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}', + }, + 'libxmss': { + 'name': 'xmss-reference', + 'supplier': 'XMSS reference implementation authors', + 'license': 'CC0-1.0', + 'download': 'https://github.com/XMSS/xmss-reference', + 'pkgconfig': None, + 'purl': lambda v: f'pkg:github/XMSS/xmss-reference@{v}', + }, + 'liblms': { + 'name': 'hash-sigs', + 'supplier': 'Cisco Systems', + 'license': 'MIT', + 'download': 'https://github.com/cisco/hash-sigs', + 'pkgconfig': None, + 'purl': lambda v: f'pkg:github/cisco/hash-sigs@{v}', + }, + 'libz': { + 'name': 'zlib', + 'supplier': 'Jean-loup Gailly and Mark Adler', + 'license': 'Zlib', + 'download': 'https://github.com/madler/zlib', + 'pkgconfig': 'zlib', + 'purl': lambda v: f'pkg:generic/zlib@{v}', + }, +} + + +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: + text = open(license_file).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 + ) + if not m: + print(f"WARNING: no GPL version found in {license_file}", + file=sys.stderr) + return None + + version = m.group(1) + excerpt = text[m.end():m.end() + 100] + if re.search(r'or\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() + + +GIT_BIN = None + + +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 git_describe_version(root, git_bin): + """Return version from git describe --tags --always, or None.""" + if not root or not git_bin: + return None + try: + r = subprocess.run( + [git_bin, '-C', root, 'describe', '--tags', '--always'], + capture_output=True, text=True + ) + if r.returncode == 0: + return r.stdout.strip() + except FileNotFoundError: + pass + return None + + +def dep_version(key): + pkgname = DEP_META[key]['pkgconfig'] + if pkgname: + return pkgconfig_version(pkgname) + git_root = DEP_META[key].get('git_root') + if git_root: + return git_describe_version(git_root, GIT_BIN) + return None + + +def parse_options_h(path): + """Parse wolfssl/options.h and return sorted deduplicated list of + (name, value) pairs for every #define found.""" + try: + text = open(path).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): + defines[m.group(1)] = (m.group(2) or '').strip() + return sorted(defines.items()) + + +def cdx_dep_component(key): + """Return (bom_ref, component_dict) for a CDX dependency component.""" + meta = DEP_META[key] + version = dep_version(key) + bom_ref = str(uuid.uuid4()) + 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): + """Return (spdx_id, package_dict) for an SPDX dependency package.""" + meta = DEP_META[key] + version = dep_version(key) + 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, lib_hash, + timestamp, serial, enabled_deps, build_props): + year = datetime.now(timezone.utc).year + bom_ref = str(uuid.uuid4()) + + dep_bom_refs = [] + components = [] + for key in enabled_deps: + ref, comp = cdx_dep_component(key) + 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 + ] + + 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': 'wolfssl-sbom-gen', + 'version': '1.0' + }] + }, + 'component': { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': supplier}, + 'name': name, + 'version': version, + 'licenses': [{'license': {'id': license_id}}], + 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', + 'purl': f'pkg:generic/{name}@{version}', + 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], + 'externalReferences': [{ + 'type': 'vcs', + 'url': 'https://github.com/wolfSSL/wolfssl' + }], + 'properties': properties, + } + }, + '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, lib_hash, + timestamp, doc_ns_uuid, enabled_deps, build_props): + year = datetime.now(timezone.utc).year + + build_defines = ', '.join(k for k, _ in build_props) + wolfssl_pkg = { + 'SPDXID': 'SPDXRef-Package-wolfssl', + 'name': name, + 'versionInfo': version, + 'supplier': f'Organization: {supplier}', + 'downloadLocation': 'https://github.com/wolfSSL/wolfssl', + '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}', + 'externalRefs': [ + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'cpe23Type', + 'referenceLocator': ( + f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*' + ) + }, + { + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': f'pkg:generic/{name}@{version}' + } + ], + } + + packages = [wolfssl_pkg] + relationships = [{ + 'spdxElementId': 'SPDXRef-DOCUMENT', + 'relatedSpdxElement': 'SPDXRef-Package-wolfssl', + 'relationshipType': 'DESCRIBES', + }] + + for key in enabled_deps: + spdx_id, pkg = spdx_dep_package(key) + packages.append(pkg) + relationships.append({ + 'spdxElementId': 'SPDXRef-Package-wolfssl', + 'relatedSpdxElement': spdx_id, + 'relationshipType': 'DEPENDS_ON', + }) + + return { + 'spdxVersion': 'SPDX-2.3', + 'dataLicense': 'CC0-1.0', + 'SPDXID': 'SPDXRef-DOCUMENT', + 'name': f'{name}-{version}', + 'documentNamespace': ( + f'https://wolfssl.com/sbom/{name}-{version}-{doc_ns_uuid}' + ), + 'creationInfo': { + 'licenseListVersion': '3.28', + 'creators': [ + f'Organization: {supplier}', + 'Tool: wolfssl-sbom-gen-1.0' + ], + 'created': timestamp, + }, + 'packages': packages, + 'relationships': relationships, + } + + +def main(): + parser = argparse.ArgumentParser( + description='Generate CycloneDX and SPDX SBOMs for wolfssl' + ) + 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('--lib', required=True, + help='Path to libwolfssl.so.X.Y.Z for SHA-256 hashing') + parser.add_argument('--license-file', required=True, + help='Path to LICENSING file for SPDX ID detection') + parser.add_argument('--options-h', required=True, + help='Path to wolfssl/options.h for build config') + parser.add_argument('--dep-liboqs', default='no', + help='yes if built with --with-liboqs') + parser.add_argument('--dep-libxmss', default='no', + help='yes if built with --with-libxmss') + parser.add_argument('--dep-libxmss-root', default='', + help='Path to xmss-reference source tree root') + parser.add_argument('--dep-liblms', default='no', + help='yes if built with --with-liblms') + parser.add_argument('--dep-liblms-root', default='', + help='Path to hash-sigs source tree root') + parser.add_argument('--dep-libz', default='no', + help='yes if built with --with-libz') + parser.add_argument('--git', default='', + help='Path to git binary for version detection') + 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() + + global GIT_BIN + GIT_BIN = args.git or None + + if args.dep_libxmss_root: + DEP_META['libxmss']['git_root'] = args.dep_libxmss_root + if args.dep_liblms_root: + DEP_META['liblms']['git_root'] = args.dep_liblms_root + + enabled_deps = [ + key for key, flag in [ + ('liboqs', args.dep_liboqs), + ('libxmss', args.dep_libxmss), + ('liblms', args.dep_liblms), + ('libz', args.dep_libz), + ] + if flag.lower() == 'yes' + ] + + 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' + + build_props = parse_options_h(args.options_h) + lib_hash = sha256_file(args.lib) + timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + serial = str(uuid.uuid4()) + doc_ns_uuid = str(uuid.uuid4()) + + cdx = generate_cdx( + args.name, args.version, args.supplier, + license_id, lib_hash, timestamp, serial, + enabled_deps, build_props, + ) + spdx = generate_spdx( + args.name, args.version, args.supplier, + license_id, lib_hash, timestamp, doc_ns_uuid, + enabled_deps, build_props, + ) + + 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() From e7efa84128e72bd8abf52256bad106cb0b101c7c Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Mon, 22 Jun 2026 12:49:50 -0700 Subject: [PATCH 2/5] feat(sbom): add embedded/custom-builder gen-sbom path Add --user-settings (pcpp/CC -dM -E), --srcs, --srcs-file, and --no-artifact-hash to gen-sbom for IAR/Keil/MPLAB builds. Makes --lib and --options-h optional; validate combinations. Document the three build paths in doc/SBOM.md. --- doc/SBOM.md | 142 ++++++++++++++ scripts/gen-sbom | 502 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 606 insertions(+), 38 deletions(-) diff --git a/doc/SBOM.md b/doc/SBOM.md index 0f201af053b..ed0209141ac 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -97,6 +97,148 @@ falls back to `git describe --tags --always` on the source tree root (passed via source tree is unavailable or `git` is not found, version is recorded as `NOASSERTION`. +## Build System Integration + +`gen-sbom` supports three build paths. All produce the same two output files +(CycloneDX 1.6 JSON and SPDX 2.3 JSON). + +### autotools / cmake + +The standard path. The build system generates `wolfssl/options.h` at +configure time and a compiled library artifact at link time. Both are hashed +to produce a deterministic artifact identity. + +```sh +# autotools +./configure && make +make sbom + +# cmake (out-of-source build) +cmake -B build && cmake --build build +cmake --build build --target sbom +``` + +### Embedded / custom build systems (IAR, Keil, MPLAB, custom Makefile) + +wolfSSL embedded builds configure the library through `user_settings.h` +rather than through autotools or cmake. There is no generated `options.h` +and no standalone compiled library — the wolfSSL source files are compiled +directly into the application firmware image. + +`gen-sbom` handles this with two new argument groups: + +**Configuration source** — exactly one of: + +| Flag | Description | +|------|-------------| +| `--options-h PATH` | Path to generated `wolfssl/options.h` (autotools/cmake only) | +| `--user-settings PATH` | Path to `user_settings.h`; preprocessed with `pcpp` (preferred) or `CC -dM -E` | +| `--user-settings-include DIR` | Add include directory for preprocessing (repeat as needed) | +| `--user-settings-define MACRO` | Pre-define a macro before preprocessing (repeat as needed) | + +**Artifact hash source** — at least one of: + +| Flag | Description | +|------|-------------| +| `--lib PATH` | Compiled library (`libwolfssl.so.X.Y.Z`); SHA-256 is recorded | +| `--srcs FILE [FILE …]` | Compiled source files; deterministic combined SHA-256 is recorded | +| `--srcs-file PATH` | File listing sources, one per line; blank and `#` lines ignored | +| `--no-artifact-hash` | No hash available; placeholder recorded with a wolfSSL contact note | + +#### Example: embedded build, source file list on the command line + +```sh +python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings /path/to/user_settings.h \ + --user-settings-include /path/to/wolfssl \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs wolfcrypt/src/aes.c wolfcrypt/src/sha256.c src/tls.c \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +#### Example: embedded build, source list from a file + +Useful when the source list is too long for the command line, or when the +IDE/build system can generate the list automatically (e.g. from a link map): + +```sh +python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings /path/to/user_settings.h \ + --user-settings-include /path/to/wolfssl \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs-file /path/to/wolfssl-sources.txt \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +`wolfssl-sources.txt` format — one path per line, comments allowed: + +``` +# wolfssl core +/path/to/wolfssl/wolfcrypt/src/aes.c +/path/to/wolfssl/wolfcrypt/src/sha256.c +/path/to/wolfssl/src/tls.c +``` + +#### Source file combined hash + +When `--srcs` or `--srcs-file` is used, `gen-sbom` computes a combined +SHA-256 as follows: + +1. Hash each file individually with SHA-256. +2. Sort the `(path, digest)` pairs by path. +3. Hash the sorted manifest `:\n` lines with SHA-256. + +The result is deterministic regardless of the order paths were listed. +Consumers can re-verify by reprocessing the same source tree. + +The SBOM records `wolfssl:sbom:hash-source=srcs` so downstream tooling +can identify which hash method was used. + +#### No hashable artifact available + +For binary-only distributions, ROM builds, or HSM firmware where neither a +compiled library nor source files are accessible: + +```sh +python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings /path/to/user_settings.h \ + --user-settings-include /path/to/wolfssl \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --no-artifact-hash \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +A placeholder (64 zero digits) is recorded in the hash fields, and a note +directing integrators to contact wolfSSL is embedded in the SBOM properties. +The `wolfssl:sbom:hash-source=none` property signals to downstream tooling +that no real artifact hash was available. + +Contact wolfssl@wolfssl.com to discuss integrity verification options for +your specific build system before using `--no-artifact-hash` in production. + +#### Preprocessor detection for `--user-settings` + +`gen-sbom` tries preprocessors in order: + +1. **pcpp** (`pip install pcpp`): pure Python, host-independent. Preferred + for cross-compilation scenarios where the host compiler would expand host + macros rather than target macros. +2. **`CC -dM -E`**: the C compiler from the `CC` environment variable + (default: `cc`). Set `CC=arm-none-eabi-gcc` (or your target compiler) + for cross builds. + +If both fail, `gen-sbom` exits with a message that names both fallback +commands and the install path for pcpp. + ## Implementation Notes SBOM generation is implemented in `scripts/gen-sbom` (Python 3, stdlib only) diff --git a/scripts/gen-sbom b/scripts/gen-sbom index ad893e2b6e6..7172c6904a7 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -3,7 +3,9 @@ import argparse import hashlib +import io import json +import os import re import subprocess import sys @@ -48,6 +50,19 @@ DEP_META = { }, } +# Sentinel used in SBOM hash fields when --no-artifact-hash is given. +# 64 zeros is an impossible SHA-256 output (no input hashes to all zeros) +# and is visually unambiguous as a placeholder to downstream tooling. +_NO_HASH_SENTINEL = '0' * 64 + +# Note embedded in the SBOM when --no-artifact-hash is used. Points +# integrators toward wolfSSL rather than leaving a silent gap. +_NO_HASH_NOTE = ( + 'No artifact hash recorded for this build. ' + 'Contact wolfssl@wolfssl.com to discuss SBOM and integrity ' + 'verification options for your specific build system.' +) + def detect_license(license_file): """Parse LICENSING file and return an SPDX license ID. @@ -80,13 +95,14 @@ def detect_license(license_file): def sha256_file(path): + """SHA-256 a single file. Exits on read error.""" 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}") + sys.exit(f"ERROR: cannot read file for hashing: {path}: {e}") return h.hexdigest() @@ -133,19 +149,202 @@ def dep_version(key): return None +def _parse_defines_text(text): + """Extract sorted deduplicated #define pairs from preprocessor text. + + Accepts wolfssl/options.h format, 'cc -dM -E' output, and pcpp output. + All three use the same '#define NAME [value]' syntax. + + Returns a sorted list of (name, value_str) pairs. + """ + defines = {} + for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.+))?$', + text, re.MULTILINE): + defines[m.group(1)] = (m.group(2) or '').strip() + return sorted(defines.items()) + + def parse_options_h(path): - """Parse wolfssl/options.h and return sorted deduplicated list of - (name, value) pairs for every #define found.""" + """Parse wolfssl/options.h and return sorted (name, value) define pairs. + + Used by the autotools and cmake build paths where options.h is generated + by configure or cmake during the build. + """ try: text = open(path).read() except OSError as e: print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr) return [] + return _parse_defines_text(text) - defines = {} - for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.+))?$', text, re.MULTILINE): - defines[m.group(1)] = (m.group(2) or '').strip() - return sorted(defines.items()) + +def preprocess_user_settings(path, includes, defines): + """Preprocess user_settings.h and return sorted (name, value) define pairs. + + Used by the embedded / custom-builder path where no wolfssl/options.h + exists because the build system does not run autotools or cmake configure. + wolfSSL embedded builds are configured entirely through user_settings.h, + so preprocessing it gives the same information as options.h would. + + Two preprocessor strategies, tried in order: + + 1. pcpp (pip install pcpp): pure Python, no compiler required. + Preferred for cross-compilation scenarios because it runs on the host + without expanding host-compiler-specific macros. + + 2. CC -dM -E: the C compiler named by the CC environment variable + (default: 'cc'). For cross-compilation builds, set CC to the target + compiler, e.g. CC=arm-none-eabi-gcc, so that target-specific macros + are expanded correctly rather than host macros. + + If both strategies fail the function exits with a clear error that names + the install command for pcpp and the CC variable that controls fallback. + + Args: + path: Path to user_settings.h. + includes: List of include directories (passed as -I to preprocessor). + defines: List of macro names (without -D prefix) to pre-define before + processing, e.g. ['WOLFSSL_USER_SETTINGS']. + """ + # Strategy 1: pcpp + try: + import pcpp # type: ignore[import] + + pp = pcpp.Preprocessor() + # Suppress #line directives; we only care about #define output. + pp.line_directive = None + for inc in includes: + pp.add_path(inc) + for d in defines: + # pcpp.define() takes "MACRO VALUE" (space-separated, no -D). + pp.define(f'{d} 1') + try: + pp.parse(open(path).read(), filename=path) + except OSError as e: + sys.exit(f"ERROR: cannot read user_settings file {path}: {e}") + buf = io.StringIO() + pp.write(buf) + print(f"NOTE: preprocessed {path} using pcpp", file=sys.stderr) + return _parse_defines_text(buf.getvalue()) + except ImportError: + pass # pcpp not installed; fall through to CC + + # Strategy 2: CC -dM -E + cc = os.environ.get('CC', 'cc') + cmd = [cc, '-dM', '-E'] + for d in defines: + cmd.append(f'-D{d}') + for inc in includes: + cmd.append(f'-I{inc}') + cmd.append(path) + print( + f"NOTE: preprocessed {path} using {cc} -dM -E\n" + f" For cross-compilation set CC= so that\n" + f" target-specific macros are expanded, not host macros.\n" + f" To avoid this message install pcpp: pip install pcpp", + file=sys.stderr + ) + try: + r = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError: + sys.exit( + f"ERROR: no preprocessor available.\n" + f" Tried pcpp: not installed (pip install pcpp).\n" + f" Tried {cc}: not found.\n" + f" Install pcpp, or set CC to a working C compiler." + ) + if r.returncode != 0: + sys.exit( + f"ERROR: {cc} -dM -E failed (exit {r.returncode}):\n{r.stderr}" + ) + return _parse_defines_text(r.stdout) + + +def hash_srcs(paths): + """Compute a deterministic combined SHA-256 over a set of source files. + + Each file is hashed individually; the results are sorted by file path and + combined into a single manifest digest: + + manifest = SHA-256 of sorted lines ':\\n' + + Sorting by path makes the digest stable regardless of the order paths were + supplied. This gives an integrity anchor that a consumer can re-verify by + reprocessing the same source tree. + + This is used for embedded / custom-builder SBOMs where no compiled library + artifact (.so, .a, firmware image) is available as a single hash target. + The digest covers exactly the set of .c files compiled into the wolfSSL + build, which is the closest equivalent to hashing the library. + + Args: + paths: Iterable of file path strings (need not be sorted). + + Returns: + (combined_hex, per_file) where per_file is a sorted list of + (path, hex_digest) pairs, one entry per source file. + + Exits with a clear error if any file cannot be read. + """ + per_file = [] + for p in sorted(paths): + h = hashlib.sha256() + try: + with open(p, '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 source file for hashing: {p}: {e}" + ) + per_file.append((p, h.hexdigest())) + + manifest = hashlib.sha256() + for p, digest in per_file: + manifest.update(f"{p}:{digest}\n".encode()) + + return manifest.hexdigest(), per_file + + +def _collect_srcs(srcs_args, srcs_file): + """Merge --srcs and --srcs-file into a deduplicated sorted list of paths. + + --srcs-file format: one file path per line. Blank lines and lines whose + first non-whitespace character is '#' are ignored, so build-system- + generated file lists can include comments. + + Duplicates (from either source) are silently dropped; order is normalised + to sorted order by hash_srcs() so the result is deterministic. + + Exits with a clear error if --srcs-file cannot be read or the combined + list is empty. + """ + paths = list(srcs_args or []) + if srcs_file: + try: + with open(srcs_file) as f: + for line in f: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + paths.append(stripped) + except OSError as e: + sys.exit(f"ERROR: cannot read --srcs-file {srcs_file}: {e}") + + # Deduplicate, preserving first-seen order before hash_srcs sorts. + seen = set() + unique = [] + for p in paths: + if p not in seen: + seen.add(p) + unique.append(p) + + if not unique: + sys.exit( + "ERROR: --srcs / --srcs-file produced an empty source list.\n" + " Pass at least one .c file, or use --no-artifact-hash\n" + " if no hashable artifact is available for this build." + ) + return unique def cdx_dep_component(key): @@ -195,8 +394,21 @@ def spdx_dep_package(key): return spdx_id, pkg -def generate_cdx(name, version, supplier, license_id, lib_hash, - timestamp, serial, enabled_deps, build_props): +def generate_cdx(name, version, supplier, license_id, artifact_hash, + hash_source, timestamp, serial, enabled_deps, build_props): + """Generate a CycloneDX 1.6 SBOM dict. + + Args: + artifact_hash: SHA-256 hex string of the hashed artifact, or + _NO_HASH_SENTINEL when --no-artifact-hash is used. + hash_source: One of: + 'lib' -- artifact_hash is SHA-256 of a compiled + library (libwolfssl.so or similar). + 'srcs' -- artifact_hash is the combined SHA-256 + of the compiled source files (see hash_srcs). + 'none' -- artifact_hash is _NO_HASH_SENTINEL; no + real artifact was available to hash. + """ year = datetime.now(timezone.utc).year bom_ref = str(uuid.uuid4()) @@ -211,6 +423,13 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} for k, v in build_props ] + # Record what was hashed so consumers know how to re-verify. + 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, + }) return { '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', @@ -238,7 +457,7 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', 'purl': f'pkg:generic/{name}@{version}', - 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], + 'hashes': [{'alg': 'SHA-256', 'content': artifact_hash}], 'externalReferences': [{ 'type': 'vcs', 'url': 'https://github.com/wolfSSL/wolfssl' @@ -254,11 +473,30 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, } -def generate_spdx(name, version, supplier, license_id, lib_hash, - timestamp, doc_ns_uuid, enabled_deps, build_props): - year = datetime.now(timezone.utc).year +def generate_spdx(name, version, supplier, license_id, artifact_hash, + hash_source, timestamp, doc_ns_uuid, enabled_deps, + build_props): + """Generate an SPDX 2.3 SBOM dict. + Args: + artifact_hash, hash_source: same semantics as generate_cdx. + """ + year = datetime.now(timezone.utc).year build_defines = ', '.join(k for k, _ in build_props) + + # Build the package comment: always includes the build defines; appends + # context notes when the hash is not a standard library artifact hash. + comment_parts = [f'Build configuration defines: {build_defines}'] + if hash_source == 'srcs': + comment_parts.append( + 'Checksum is a combined SHA-256 over compiled source files ' + '(see wolfssl:sbom:hash-source property in CycloneDX output). ' + 'This build used an embedded or custom build system; no compiled ' + 'library artifact was available to hash.' + ) + elif hash_source == 'none': + comment_parts.append(_NO_HASH_NOTE) + wolfssl_pkg = { 'SPDXID': 'SPDXRef-Package-wolfssl', 'name': name, @@ -266,11 +504,11 @@ def generate_spdx(name, version, supplier, license_id, lib_hash, 'supplier': f'Organization: {supplier}', 'downloadLocation': 'https://github.com/wolfSSL/wolfssl', 'filesAnalyzed': False, - 'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}], + 'checksums': [{'algorithm': 'SHA256', 'checksumValue': artifact_hash}], 'licenseConcluded': license_id, 'licenseDeclared': license_id, 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', - 'comment': f'Build configuration defines: {build_defines}', + 'comment': ' '.join(comment_parts), 'externalRefs': [ { 'referenceCategory': 'SECURITY', @@ -326,38 +564,181 @@ def generate_spdx(name, version, supplier, license_id, lib_hash, def main(): parser = argparse.ArgumentParser( - description='Generate CycloneDX and SPDX SBOMs for wolfssl' + description=( + 'Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.\n\n' + 'Three build-system paths are supported:\n' + ' autotools/cmake: pass --options-h and --lib\n' + ' embedded/custom: pass --user-settings and --srcs/--srcs-file\n' + ' no hashable artifact: pass --user-settings and --no-artifact-hash' + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument('--name', required=True, help='Package name') - parser.add_argument('--version', required=True, help='Package version') + + # -- Identity -------------------------------------------------------- + parser.add_argument('--name', required=True, + help='Package name (e.g. wolfssl)') + parser.add_argument('--version', required=True, + help='Package version string') parser.add_argument('--supplier', default='wolfSSL Inc.', help='Supplier name (default: wolfSSL Inc.)') - parser.add_argument('--lib', required=True, - help='Path to libwolfssl.so.X.Y.Z for SHA-256 hashing') + + # -- Build configuration source (exactly one required) --------------- + cfg_group = parser.add_argument_group( + 'build configuration source (exactly one required)' + ) + cfg_group.add_argument( + '--options-h', + help=( + 'Path to wolfssl/options.h generated by autotools configure or ' + 'cmake. Mutually exclusive with --user-settings.' + ) + ) + cfg_group.add_argument( + '--user-settings', + metavar='PATH', + help=( + 'Path to user_settings.h (or wolfssl/wolfcrypt/settings.h) for ' + 'embedded / custom-builder builds that configure wolfSSL via ' + 'WOLFSSL_USER_SETTINGS rather than autotools or cmake. ' + 'The file is preprocessed using pcpp (preferred) or CC -dM -E ' + '(fallback; set CC= for cross builds). ' + 'Mutually exclusive with --options-h.' + ) + ) + cfg_group.add_argument( + '--user-settings-include', + metavar='DIR', + action='append', + default=[], + help=( + 'Add an include directory for --user-settings preprocessing. ' + 'Repeat for multiple directories. Typically includes the wolfssl ' + 'source root and the directory containing user_settings.h.' + ) + ) + cfg_group.add_argument( + '--user-settings-define', + metavar='MACRO', + action='append', + default=[], + help=( + 'Pre-define a macro before preprocessing --user-settings. ' + 'Repeat for multiple macros. ' + 'At minimum pass WOLFSSL_USER_SETTINGS to activate the ' + 'user_settings.h configuration path in wolfssl headers.' + ) + ) + + # -- Artifact hash source (at least one required) -------------------- + hash_group = parser.add_argument_group( + 'artifact hash source (at least one of --lib / --srcs / ' + '--srcs-file / --no-artifact-hash required)' + ) + hash_group.add_argument( + '--lib', + metavar='PATH', + help=( + 'Path to the compiled wolfssl library (e.g. libwolfssl.so.X.Y.Z ' + 'or libwolfssl.a) for SHA-256 hashing. Used by the autotools and ' + 'cmake paths. May be combined with --srcs/--srcs-file to record ' + 'both a library hash and a source hash.' + ) + ) + hash_group.add_argument( + '--srcs', + metavar='FILE', + nargs='+', + default=[], + help=( + 'One or more compiled wolfssl source files (.c) to hash. ' + 'Used by the embedded path where no compiled library artifact ' + 'exists. A deterministic combined SHA-256 is computed over all ' + 'listed files (see also --srcs-file for longer lists). ' + 'May be repeated or combined with --srcs-file.' + ) + ) + hash_group.add_argument( + '--srcs-file', + metavar='PATH', + help=( + 'Path to a file listing compiled wolfssl source files, one per ' + 'line. Blank lines and lines beginning with "#" are ignored. ' + 'Useful when the source list is too long for the command line, ' + 'or when the build system can generate the list automatically ' + '(e.g. from a link map or IDE project file). ' + 'Combined with any --srcs arguments.' + ) + ) + hash_group.add_argument( + '--no-artifact-hash', + action='store_true', + help=( + 'Emit an SBOM without a real artifact hash. A placeholder ' + '(64 zero digits) is recorded in the hash fields, and a note ' + 'directing integrators to contact wolfSSL is added to the SBOM ' + 'properties. Use this only when no compiled library AND no source ' + 'file list is available (e.g. binary-only distributions, ROM ' + 'builds, or HSM firmware where source files are not accessible). ' + 'Cannot be combined with --lib, --srcs, or --srcs-file.' + ) + ) + + # -- Common ---------------------------------------------------------- parser.add_argument('--license-file', required=True, - help='Path to LICENSING file for SPDX ID detection') - parser.add_argument('--options-h', required=True, - help='Path to wolfssl/options.h for build config') + help='Path to wolfssl LICENSING file for SPDX ID detection') parser.add_argument('--dep-liboqs', default='no', - help='yes if built with --with-liboqs') + help='yes if built with liboqs (Open Quantum Safe)') parser.add_argument('--dep-libxmss', default='no', - help='yes if built with --with-libxmss') + help='yes if built with libxmss') parser.add_argument('--dep-libxmss-root', default='', - help='Path to xmss-reference source tree root') + help='Path to xmss-reference source tree root for version detection') parser.add_argument('--dep-liblms', default='no', - help='yes if built with --with-liblms') + help='yes if built with liblms (hash-sigs)') parser.add_argument('--dep-liblms-root', default='', - help='Path to hash-sigs source tree root') + help='Path to hash-sigs source tree root for version detection') parser.add_argument('--dep-libz', default='no', - help='yes if built with --with-libz') + help='yes if built with libz (zlib)') parser.add_argument('--git', default='', - help='Path to git binary for version detection') + help='Path to git binary for dep version detection (optional)') parser.add_argument('--cdx-out', required=True, - help='Output path for CycloneDX JSON') + help='Output path for CycloneDX 1.6 JSON') parser.add_argument('--spdx-out', required=True, - help='Output path for SPDX JSON') + help='Output path for SPDX 2.3 JSON') + args = parser.parse_args() + # -- Validate argument combinations ---------------------------------- + + # Build-config source: exactly one of --options-h / --user-settings. + if args.options_h and args.user_settings: + parser.error('--options-h and --user-settings are mutually exclusive') + if not args.options_h and not args.user_settings: + parser.error('one of --options-h or --user-settings is required') + + # --user-settings-include/define require --user-settings. + if args.user_settings_include and not args.user_settings: + parser.error('--user-settings-include requires --user-settings') + if args.user_settings_define and not args.user_settings: + parser.error('--user-settings-define requires --user-settings') + + # Artifact hash: --no-artifact-hash cannot coexist with any hash source. + if args.no_artifact_hash and (args.lib or args.srcs or args.srcs_file): + parser.error( + '--no-artifact-hash cannot be combined with ' + '--lib, --srcs, or --srcs-file' + ) + + # At least one artifact hash source must be present. + has_hash_source = bool( + args.lib or args.srcs or args.srcs_file or args.no_artifact_hash + ) + if not has_hash_source: + parser.error( + 'at least one artifact hash source is required: ' + '--lib, --srcs, --srcs-file, or --no-artifact-hash' + ) + + # -- Initialise globals ---------------------------------------------- global GIT_BIN GIT_BIN = args.git or None @@ -376,29 +757,74 @@ def main(): if flag.lower() == 'yes' ] + # -- Build configuration defines ------------------------------------- + if args.options_h: + build_props = parse_options_h(args.options_h) + else: + build_props = preprocess_user_settings( + args.user_settings, + args.user_settings_include, + args.user_settings_define, + ) + + # -- Artifact hash --------------------------------------------------- + if args.no_artifact_hash: + artifact_hash = _NO_HASH_SENTINEL + hash_source = 'none' + print("NOTE: --no-artifact-hash: recording placeholder hash", + file=sys.stderr) + + elif args.lib and not (args.srcs or args.srcs_file): + # Standard library hash (autotools / cmake path). + artifact_hash = sha256_file(args.lib) + hash_source = 'lib' + + elif (args.srcs or args.srcs_file) and not args.lib: + # Source-file combined hash (embedded path). + src_paths = _collect_srcs(args.srcs, args.srcs_file) + artifact_hash, per_file = hash_srcs(src_paths) + hash_source = 'srcs' + print(f"NOTE: hashed {len(per_file)} source file(s) " + f"(combined SHA-256: {artifact_hash[:16]}...)", + file=sys.stderr) + + else: + # Both --lib and --srcs/--srcs-file provided: record the library hash + # (the primary artifact) but note source files were also present. + # The source hash is printed for reference but not embedded in the SBOM + # to avoid two competing hash values in the same hash field. + artifact_hash = sha256_file(args.lib) + hash_source = 'lib' + src_paths = _collect_srcs(args.srcs, args.srcs_file) + _, per_file = hash_srcs(src_paths) + print(f"NOTE: --lib and --srcs both provided; recording library hash. " + f"Source hash ({len(per_file)} files) computed but not embedded.", + file=sys.stderr) + + # -- License --------------------------------------------------------- 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' - build_props = parse_options_h(args.options_h) - lib_hash = sha256_file(args.lib) + # -- Generate -------------------------------------------------------- timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') serial = str(uuid.uuid4()) doc_ns_uuid = str(uuid.uuid4()) cdx = generate_cdx( args.name, args.version, args.supplier, - license_id, lib_hash, timestamp, serial, - enabled_deps, build_props, + license_id, artifact_hash, hash_source, + timestamp, serial, enabled_deps, build_props, ) spdx = generate_spdx( args.name, args.version, args.supplier, - license_id, lib_hash, timestamp, doc_ns_uuid, - enabled_deps, build_props, + license_id, artifact_hash, hash_source, + timestamp, doc_ns_uuid, enabled_deps, build_props, ) + # -- Write ----------------------------------------------------------- try: with open(args.cdx_out, 'w') as f: json.dump(cdx, f, indent=2) From 940e9fca9d972d48ba6f5687570579cd06620c15 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Mon, 22 Jun 2026 13:10:01 -0700 Subject: [PATCH 3/5] docs(sbom): add legal rationale for source-file hashing in embedded builds --- doc/SBOM.md | 15 +++++++++++++-- scripts/gen-sbom | 14 ++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/doc/SBOM.md b/doc/SBOM.md index ed0209141ac..feb0b12114c 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -187,8 +187,19 @@ python3 scripts/gen-sbom \ #### Source file combined hash -When `--srcs` or `--srcs-file` is used, `gen-sbom` computes a combined -SHA-256 as follows: +For embedded builds, hashing the compiled wolfSSL source files is the legally +correct artifact. Neither NTIA minimum elements, SPDX 2.3, nor CycloneDX 1.6 +require a hash — but when one is present it should uniquely identify the +component as delivered. The firmware image is the wrong artifact: that is the +customer's product, not wolfSSL's component, and wolfSSL cannot control what +else goes into it. The wolfSSL source files are the component boundary; their +combined hash is independently verifiable against the public wolfSSL git tree. +The hash is only as good as the list: it must include every wolfSSL `.c` file +on the link line, ideally generated from the actual link step (link map or IDE +project export) rather than constructed by hand. + +When `--srcs` or `--srcs-file` is used, `gen-sbom` computes the combined hash +as follows: 1. Hash each file individually with SHA-256. 2. Sort the `(path, digest)` pairs by path. diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 7172c6904a7..f977b65c0c7 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -272,10 +272,16 @@ def hash_srcs(paths): supplied. This gives an integrity anchor that a consumer can re-verify by reprocessing the same source tree. - This is used for embedded / custom-builder SBOMs where no compiled library - artifact (.so, .a, firmware image) is available as a single hash target. - The digest covers exactly the set of .c files compiled into the wolfSSL - build, which is the closest equivalent to hashing the library. + For embedded builds this is the legally correct artifact to hash. Neither + NTIA minimum elements, SPDX 2.3, nor CycloneDX 1.6 require a hash -- but + when one is present it should uniquely identify the component as delivered. + The firmware image is the wrong artifact: that is the customer's product, + not wolfSSL's component, and wolfSSL cannot control what else goes into it. + The wolfSSL source files are the component boundary; their combined hash is + independently verifiable against the public wolfSSL git tree. The hash is + only as good as the list: it must include every wolfSSL .c file on the link + line, ideally generated from the actual link step (link map or IDE project + export) rather than constructed by hand. Args: paths: Iterable of file path strings (need not be sorted). From f2a77c56e088668ce7291a18bf0a2aac4dd4ed8a Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Mon, 22 Jun 2026 14:20:03 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20pcpp=20parse()=20keyword=20arg:=20fi?= =?UTF-8?q?lename=20=E2=86=92=20source=20(SBOM-a4c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcpp 1.30 Preprocessor.parse() uses source= not filename= for the filename parameter. Wrong keyword caused TypeError crash when taking the pcpp code path. --- scripts/gen-sbom | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gen-sbom b/scripts/gen-sbom index f977b65c0c7..aed95c194b9 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -219,7 +219,7 @@ def preprocess_user_settings(path, includes, defines): # pcpp.define() takes "MACRO VALUE" (space-separated, no -D). pp.define(f'{d} 1') try: - pp.parse(open(path).read(), filename=path) + pp.parse(open(path).read(), source=path) except OSError as e: sys.exit(f"ERROR: cannot read user_settings file {path}: {e}") buf = io.StringIO() From 37630ea84fea6c0996b4fa89a3806e53d65f2266 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Mon, 22 Jun 2026 14:40:33 -0700 Subject: [PATCH 5/5] build(cmake): add sbom/install-sbom/uninstall-sbom targets (SBOM-kyu) Cherry-picked from feat/sbom-cmake (ff31dbb6b). Adds cmake custom targets for SBOM generation alongside the existing autotools/embedded paths. Stub targets fire a clear error when python3 or pyspdxtools are missing instead of silently failing. --- CMakeLists.txt | 169 +++++++++++++++++++++++++++++++++++++ cmake/install-sbom.cmake | 38 +++++++++ cmake/uninstall-sbom.cmake | 29 +++++++ 3 files changed, 236 insertions(+) create mode 100644 cmake/install-sbom.cmake create mode 100644 cmake/uninstall-sbom.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 663c3cf2cc9..b86534da0b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3249,3 +3249,172 @@ if(WOLFSSL_INSTALL) DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wolfssl ) endif() + +#################################################### +# SBOM generation +#################################################### +# +# Targets: +# sbom -- Generate CycloneDX 1.6 + SPDX 2.3 artifacts in CMAKE_BINARY_DIR +# install-sbom -- Copy artifacts to CMAKE_INSTALL_DOCDIR (triggers sbom first) +# uninstall-sbom -- Remove installed artifacts +# +# All three targets are always defined. If required tools were not found at +# configure time the targets fail with a clear error; re-run cmake after +# installing the tools to re-detect them. +# +# Output filenames match the autotools convention exactly so that CRA Kit +# scripts can locate them with the same pattern: +# wolfssl-.cdx.json CycloneDX 1.6 +# wolfssl-.spdx.json SPDX 2.3 JSON (input to pyspdxtools) +# wolfssl-.spdx SPDX 2.3 tag-value (pyspdxtools output) +# +# Equivalent autotools targets: make sbom / make install-sbom / make uninstall-sbom + +find_program(WOLFSSL_SBOM_PYTHON3 + NAMES python3 + DOC "Python 3 interpreter for SBOM generation (scripts/gen-sbom)") +find_program(WOLFSSL_SBOM_PYSPDXTOOLS + NAMES pyspdxtools + DOC "pyspdxtools SPDX validator for SBOM generation (pip install spdx-tools)") +find_program(WOLFSSL_SBOM_GIT + NAMES git + DOC "git binary for version detection by gen-sbom (optional; empty string if absent)") + +# git is optional: gen-sbom accepts --git "" and falls back to other version sources. +# find_program sets the variable to -NOTFOUND when the program is absent; +# normalise that to an empty string so VERBATIM does not pass the literal +# "WOLFSSL_SBOM_GIT-NOTFOUND" string to gen-sbom. +if(NOT WOLFSSL_SBOM_GIT) + set(WOLFSSL_SBOM_GIT "") +endif() + +# SBOM output file paths (build directory, matching autotools naming). +set(WOLFSSL_SBOM_CDX "${CMAKE_BINARY_DIR}/wolfssl-${PROJECT_VERSION}.cdx.json") +set(WOLFSSL_SBOM_SPDX "${CMAKE_BINARY_DIR}/wolfssl-${PROJECT_VERSION}.spdx.json") +set(WOLFSSL_SBOM_TV "${CMAKE_BINARY_DIR}/wolfssl-${PROJECT_VERSION}.spdx") + +# Map CMake feature options to gen-sbom --dep-* flags. +# +# libz: not yet implemented in the CMake build; see "TODO: - LIBZ" comment +# earlier in this file. Hardcoded "no" until a WOLFSSL_LIBZ option is +# added and wired up here. +# +# liblms / libxmss: being removed in wolfssl PR #10292. Mapped here so the +# SBOM remains accurate for builds that still enable them. The +# autotools --dep-libxmss-root and --dep-liblms-root arguments are +# omitted because CMakeLists.txt has no XMSS_ROOT / LIBLMS_ROOT +# equivalents. +set(_wolfssl_sbom_dep_liboqs "$,yes,no>") +set(_wolfssl_sbom_dep_libxmss "$,yes,no>") +set(_wolfssl_sbom_dep_liblms "$,yes,no>") +set(_wolfssl_sbom_dep_libz "no") + +if(NOT WOLFSSL_SBOM_PYTHON3 OR NOT WOLFSSL_SBOM_PYSPDXTOOLS) + # Stub targets: always defined so that cmake --build --target sbom produces a + # clear diagnostic rather than "No rule to make target 'sbom'". + # Re-run cmake after installing the missing tools to enable the real targets. + set(_wolfssl_sbom_missing "") + if(NOT WOLFSSL_SBOM_PYTHON3) + message(STATUS "SBOM: python3 not found -- 'sbom' target disabled") + string(APPEND _wolfssl_sbom_missing " python3") + endif() + if(NOT WOLFSSL_SBOM_PYSPDXTOOLS) + message(STATUS + "SBOM: pyspdxtools not found -- 'sbom' target disabled (pip install spdx-tools)") + string(APPEND _wolfssl_sbom_missing " pyspdxtools") + endif() + + foreach(_wolfssl_sbom_stub IN ITEMS sbom install-sbom uninstall-sbom) + add_custom_target(${_wolfssl_sbom_stub} + COMMAND ${CMAKE_COMMAND} -E echo + "ERROR: Required SBOM tools not found at configure time:${_wolfssl_sbom_missing}" + COMMAND ${CMAKE_COMMAND} -E echo + " Install the missing tools, re-run cmake, then retry." + COMMAND ${CMAKE_COMMAND} -E false + COMMENT "SBOM prerequisites missing -- re-run cmake to re-detect" + ) + endforeach() + unset(_wolfssl_sbom_stub) + unset(_wolfssl_sbom_missing) +else() + # gen-sbom reads wolfssl/options.h for build-configuration #defines. + # That file is produced by configure_file(cmake/options.h.in ...) during + # the cmake configure step, so it is always present before the sbom target + # runs and does not require a separate build step. + # + # --lib uses $ (the built library in the build tree) + # rather than a staging-install path. The autotools target stages an + # install solely to obtain the versioned .so path for SHA-256 hashing; + # the build-tree file is byte-for-byte identical and avoids that overhead. + add_custom_target(sbom + COMMAND "${WOLFSSL_SBOM_PYTHON3}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen-sbom" + --name wolfssl + --version "${PROJECT_VERSION}" + --license-file "${CMAKE_CURRENT_SOURCE_DIR}/LICENSING" + --options-h "${WOLFSSL_OUTPUT_BASE}/wolfssl/options.h" + --lib "$" + --dep-liboqs "${_wolfssl_sbom_dep_liboqs}" + --dep-libxmss "${_wolfssl_sbom_dep_libxmss}" + --dep-liblms "${_wolfssl_sbom_dep_liblms}" + --dep-libz "${_wolfssl_sbom_dep_libz}" + --git "${WOLFSSL_SBOM_GIT}" + --cdx-out "${WOLFSSL_SBOM_CDX}" + --spdx-out "${WOLFSSL_SBOM_SPDX}" + COMMAND "${WOLFSSL_SBOM_PYSPDXTOOLS}" + --infile "${WOLFSSL_SBOM_SPDX}" + --outfile "${WOLFSSL_SBOM_TV}" + COMMENT "Generating wolfSSL SBOM (CycloneDX 1.6 + SPDX 2.3)" + VERBATIM + ) + # wolfssl must be built before gen-sbom can hash $. + add_dependencies(sbom wolfssl) + + # install-sbom: copy SBOM artifacts to the documentation install directory. + # Mirrors autotools: sbomdir = $(datadir)/doc/$(PACKAGE). + # + # Delegates to cmake/install-sbom.cmake so that DESTDIR is read from the + # environment at build time rather than configure time. This matches the + # autotools behaviour: DESTDIR=/staging make install-sbom. + # CMake equivalent: DESTDIR=/staging cmake --build --target install-sbom + add_custom_target(install-sbom + COMMAND ${CMAKE_COMMAND} + "-DWOLFSSL_SBOM_CDX=${WOLFSSL_SBOM_CDX}" + "-DWOLFSSL_SBOM_SPDX=${WOLFSSL_SBOM_SPDX}" + "-DWOLFSSL_SBOM_TV=${WOLFSSL_SBOM_TV}" + "-DWOLFSSL_VERSION=${PROJECT_VERSION}" + "-DWOLFSSL_INSTALL_DOCDIR=${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DOCDIR}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/install-sbom.cmake" + COMMENT "Installing wolfSSL SBOM to ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DOCDIR}" + VERBATIM + ) + # sbom must complete before install-sbom copies its outputs. + add_dependencies(install-sbom sbom) + + # uninstall-sbom: remove installed artifacts. + # Delegates to cmake/uninstall-sbom.cmake for the same DESTDIR reason. + # file(REMOVE ...) in that script is a no-op for absent files, matching + # autotools `rm -f`. + add_custom_target(uninstall-sbom + COMMAND ${CMAKE_COMMAND} + "-DWOLFSSL_VERSION=${PROJECT_VERSION}" + "-DWOLFSSL_INSTALL_DOCDIR=${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DOCDIR}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/uninstall-sbom.cmake" + COMMENT + "Uninstalling wolfSSL SBOM from ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DOCDIR}" + VERBATIM + ) + + # Register SBOM outputs with cmake's clean target so they are removed by + # cmake --build . --target clean alongside compiled artifacts. + set_property(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" APPEND PROPERTY + ADDITIONAL_CLEAN_FILES + "${WOLFSSL_SBOM_CDX}" + "${WOLFSSL_SBOM_SPDX}" + "${WOLFSSL_SBOM_TV}") +endif() + +unset(_wolfssl_sbom_dep_liboqs) +unset(_wolfssl_sbom_dep_libxmss) +unset(_wolfssl_sbom_dep_liblms) +unset(_wolfssl_sbom_dep_libz) diff --git a/cmake/install-sbom.cmake b/cmake/install-sbom.cmake new file mode 100644 index 00000000000..7d1dcc75354 --- /dev/null +++ b/cmake/install-sbom.cmake @@ -0,0 +1,38 @@ +# cmake -P script: install wolfSSL SBOM artifacts. +# Invoked by the install-sbom custom target. Reads DESTDIR from the +# environment at script-execution time (build time), so staging installs +# work correctly: +# +# DESTDIR=/staging cmake --build --target install-sbom +# +# Required -D arguments (passed by add_custom_target in CMakeLists.txt): +# WOLFSSL_SBOM_CDX path to generated CycloneDX JSON +# WOLFSSL_SBOM_SPDX path to generated SPDX JSON +# WOLFSSL_SBOM_TV path to generated SPDX tag-value +# WOLFSSL_VERSION project version string +# WOLFSSL_INSTALL_DOCDIR resolved install doc directory (absolute) + +foreach(_var WOLFSSL_SBOM_CDX WOLFSSL_SBOM_SPDX WOLFSSL_SBOM_TV + WOLFSSL_VERSION WOLFSSL_INSTALL_DOCDIR) + if(NOT DEFINED ${_var}) + message(FATAL_ERROR "install-sbom.cmake: required variable ${_var} not set") + endif() +endforeach() + +# DESTDIR is read from the environment at script-execution time so that +# `DESTDIR=/staging cmake --build . --target install-sbom` works the same +# way as `make install-sbom DESTDIR=/staging` with autotools. +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() + +set(_dest "${_destdir}${WOLFSSL_INSTALL_DOCDIR}") + +file(MAKE_DIRECTORY "${_dest}") +file(COPY "${WOLFSSL_SBOM_CDX}" DESTINATION "${_dest}") +file(COPY "${WOLFSSL_SBOM_SPDX}" DESTINATION "${_dest}") +file(COPY "${WOLFSSL_SBOM_TV}" DESTINATION "${_dest}") + +message(STATUS "Installed wolfSSL SBOM to ${_dest}") diff --git a/cmake/uninstall-sbom.cmake b/cmake/uninstall-sbom.cmake new file mode 100644 index 00000000000..3d6604c34f3 --- /dev/null +++ b/cmake/uninstall-sbom.cmake @@ -0,0 +1,29 @@ +# cmake -P script: uninstall wolfSSL SBOM artifacts. +# Invoked by the uninstall-sbom custom target. Reads DESTDIR from the +# environment at script-execution time (build time). +# +# Required -D arguments (passed by add_custom_target in CMakeLists.txt): +# WOLFSSL_VERSION project version string +# WOLFSSL_INSTALL_DOCDIR resolved install doc directory (absolute) + +foreach(_var WOLFSSL_VERSION WOLFSSL_INSTALL_DOCDIR) + if(NOT DEFINED ${_var}) + message(FATAL_ERROR "uninstall-sbom.cmake: required variable ${_var} not set") + endif() +endforeach() + +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() + +set(_dest "${_destdir}${WOLFSSL_INSTALL_DOCDIR}") + +# file(REMOVE ...) is a no-op for absent files, matching autotools `rm -f`. +file(REMOVE + "${_dest}/wolfssl-${WOLFSSL_VERSION}.cdx.json" + "${_dest}/wolfssl-${WOLFSSL_VERSION}.spdx.json" + "${_dest}/wolfssl-${WOLFSSL_VERSION}.spdx") + +message(STATUS "Uninstalled wolfSSL SBOM from ${_dest}")