diff --git a/INSTALL b/INSTALL index dc6e2908c1..f02dd44da3 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 fce812babf..ea2a04db6b 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 4c800d85a4..4a3190eace 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 69f8501f04..e0583b8158 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 0000000000..0f201af053 --- /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 0000000000..ad893e2b6e --- /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()