diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 0000000000..84aed2a217 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,515 @@ +name: SBOM Tests + +# START OF COMMON SECTION +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '*' ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +# END OF COMMON SECTION + +jobs: + # Tier 1 - pure-Python unit tests for scripts/gen-sbom. + # No build, no autotools, no external deps. Runs in seconds and is the + # cheapest gate for licence/UUID/timestamp logic regressions. + unit: + name: gen-sbom unit tests + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Syntax check + run: python3 -m py_compile scripts/gen-sbom + + - name: Unit tests + run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_sbom.py -v + + # Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert + # everything an external auditor or vulnerability scanner relies on. + integration: + name: SBOM integration (linux) + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + # Pin tool versions; drift in any of these silently changes what + # "valid" means and produces mystery CI failures. + - name: Install SBOM validators + run: | + python3 -m pip install --user --upgrade pip + python3 -m pip install --user \ + 'spdx-tools==0.8.*' \ + 'ntia-conformance-checker==5.*' \ + 'cyclonedx-bom==7.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # Test fixture for the LicenseRef-+text matrix step. Using a fixture + # rather than $PWD/COPYING decouples the test from upstream file + # naming and makes the assertion exact ('FIXTURE LICENCE BODY'). + - name: Create license-text fixture + run: echo 'FIXTURE LICENCE BODY' > /tmp/sbom-fixture-licence.txt + + - name: Configure wolfSSL (shared + static) + run: autoreconf -ivf && ./configure --enable-shared --enable-static + + - name: Build + generate SBOM (default GPL) + run: make sbom + + # ---- Format-level validators ----------------------------------------- + + - name: SPDX 2.3 - NTIA Minimum Elements (2021) + # Already validated structurally by pyspdxtools inside `make sbom`. + # NTIA conformance is the additional contract auditors rely on. + run: ntia-checker -c ntia wolfssl-*.spdx.json + + - name: CycloneDX 1.6 - JSON schema validation + run: | + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + for path in glob.glob('wolfssl-*.cdx.json'): + with open(path) as f: + errors = v.validate_str(f.read()) + if errors: + print(f"INVALID: {path}: {errors}", file=sys.stderr) + sys.exit(1) + print(f"OK: {path}") + PY + + # ---- Artefact-integrity assertions ---------------------------------- + + - name: Library hash matches the SBOM + # `make sbom` cleans its private staging tree on exit, so we install + # to an independent prefix and re-hash the resulting library. + # Search order matches gen-sbom's so we hash the same artefact. + run: | + rm -rf /tmp/_inst + make install DESTDIR=/tmp/_inst >/dev/null + LIB="" + for cand in /tmp/_inst/usr/local/lib/libwolfssl.so.[0-9]* \ + /tmp/_inst/usr/local/lib/libwolfssl.so \ + /tmp/_inst/usr/local/lib/libwolfssl.a; do + if [ -f "$cand" ]; then LIB="$cand"; break; fi + done + test -n "$LIB" || (echo "no installed library found"; exit 1) + EXPECTED=$(python3 -c " + import hashlib, sys + h = hashlib.sha256() + with open(sys.argv[1], 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + print(h.hexdigest())" "$LIB") + ACTUAL=$(python3 -c " + import json, glob + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + p = [x for x in d['packages'] if x['name'] == 'wolfssl'][0] + print(p['checksums'][0]['checksumValue'])") + test "$EXPECTED" = "$ACTUAL" || \ + { echo "hash mismatch: expected=$EXPECTED actual=$ACTUAL"; exit 1; } + + - name: CPE 2.3 and PURL identifiers well-formed + # A typo in supplier or product name silently breaks every + # downstream OSV / Trivy / Grype scan. + run: | + python3 - <<'PY' + import glob, json, re, sys + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + refs = {r['referenceType']: r['referenceLocator'] + for r in d['packages'][0]['externalRefs']} + assert re.match(r'cpe:2\.3:a:wolfssl:wolfssl:[\d.]+:', refs['cpe23Type']), refs + assert re.match(r'pkg:generic/wolfssl@[\d.]+', refs['purl']), refs + print('identifiers ok:', refs) + PY + + # ---- Reproducibility ------------------------------------------------- + + - name: Reproducibility under SOURCE_DATE_EPOCH + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom + sha256sum wolfssl-*.cdx.json wolfssl-*.spdx.json > /tmp/a.sums + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom + sha256sum wolfssl-*.cdx.json wolfssl-*.spdx.json > /tmp/b.sums + diff /tmp/a.sums /tmp/b.sums + + # ---- Licence-override matrix ---------------------------------------- + + - name: License matrix - default GPL + # Detected from LICENSING. The current upstream file reads + # "GNU General Public License version 3" without "or later", so + # detect_license returns GPL-3.0-only. If LICENSING is updated to + # add "or any later version", switch this assertion to + # GPL-3.0-or-later. + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + make sbom + python3 - <<'PY' + import glob, json + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + assert d['packages'][0]['licenseConcluded'].startswith('GPL-3.0-'), \ + d['packages'][0]['licenseConcluded'] + assert 'hasExtractedLicensingInfos' not in d + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + cdx = json.load(f) + lic = cdx['metadata']['component']['licenses'] + assert lic == [{'license': {'id': d['packages'][0]['licenseConcluded']}}], lic + print('default GPL: ok ->', lic) + PY + + - name: License matrix - LicenseRef + text + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + make sbom \ + SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ + SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt + python3 - <<'PY' + import glob, json + with open('/tmp/sbom-fixture-licence.txt') as f: + expected = f.read() + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + infos = d['hasExtractedLicensingInfos'] + assert len(infos) == 1 + assert infos[0]['licenseId'] == 'LicenseRef-wolfSSL-Commercial' + assert infos[0]['extractedText'] == expected + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + cdx = json.load(f) + lic = cdx['metadata']['component']['licenses'][0]['license'] + assert lic['name'] == 'LicenseRef-wolfSSL-Commercial' + assert lic['text']['content'] == expected + print('LicenseRef + text: ok') + PY + # The output of this run must still pass NTIA and CDX validators. + ntia-checker -c ntia wolfssl-*.spdx.json + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + errs = v.validate_str(f.read()) + sys.exit(1 if errs else 0) + PY + + - name: License matrix - LicenseRef without text must FAIL + # gen-sbom must refuse to emit a SBOM that names a LicenseRef-* + # but doesn't embed its text - that combo is invalid per SPDX 2.3 + # and any "successfully generated" output would mislead auditors. + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + if make sbom SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ + 2>/tmp/err; then + echo "FAIL: gen-sbom should have refused this configuration" + exit 1 + fi + grep -q 'license-text was not provided' /tmp/err || \ + { echo "FAIL: error message missing actionable hint"; \ + cat /tmp/err; exit 1; } + if ls wolfssl-*.spdx.json >/dev/null 2>&1; then + echo "FAIL: SBOM file should not exist after refusal" + exit 1 + fi + + - name: License matrix - compound expression + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + make sbom \ + SBOM_LICENSE_OVERRIDE='GPL-3.0-only OR LicenseRef-wolfSSL-Commercial' \ + SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt + python3 - <<'PY' + import glob, json + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + assert len(d['hasExtractedLicensingInfos']) == 1 + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + cdx = json.load(f) + entry = cdx['metadata']['component']['licenses'][0] + assert 'expression' in entry, entry + print('compound expression: ok') + PY + + - name: License matrix - simple SPDX override + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + make sbom SBOM_LICENSE_OVERRIDE=Apache-2.0 + python3 - <<'PY' + import glob, json + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + assert 'hasExtractedLicensingInfos' not in d + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + cdx = json.load(f) + lic = cdx['metadata']['component']['licenses'][0]['license'] + assert lic == {'id': 'Apache-2.0'}, lic + print('simple SPDX override: ok') + PY + + # ---- liboqs / Falcon dep entry --------------------------------------- + # Without this, every code path that emits a dep package - pkg-config + # lookup, supplier/purl/license construction, deterministic UUID + # derivation for deps - is uncovered by CI. A future rename or shape + # break in DEP_META['liboqs'] would silently land. + + - name: Install liboqs (provides liboqs.pc for --with-liboqs) + run: sudo apt-get update && sudo apt-get install -y liboqs-dev + + - name: Configure with --with-liboqs --enable-falcon + run: | + make distclean + autoreconf -ivf + ./configure --enable-shared --enable-experimental \ + --with-liboqs --enable-falcon + + - name: Build + generate SBOM with liboqs enabled + run: make sbom + + - name: liboqs dep entry resolves to a CVE-trackable identifier + # The point of recording liboqs (rather than `falcon`) is that + # OSV / Grype / Trivy / Dependency-Track key vulnerability + # records off purl + name. These assertions guard the contract + # that pulled the entry away from the algorithm name. + run: | + python3 - <<'PY' + import glob, json, re, sys + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + pkgs = {p['name']: p for p in d['packages']} + assert 'liboqs' in pkgs, list(pkgs) + assert 'falcon' not in pkgs, "algorithm name leaked as a dep package" + liboqs = pkgs['liboqs'] + assert liboqs['supplier'] == 'Organization: Open Quantum Safe', \ + liboqs['supplier'] + refs = {r['referenceType']: r['referenceLocator'] + for r in liboqs.get('externalRefs', [])} + assert 'purl' in refs, refs + assert re.match(r'pkg:github/open-quantum-safe/liboqs@', refs['purl']), \ + refs['purl'] + # Algorithm enablement must still be visible via build_props + # (parsed from options.h), not via the dep entry. + props = {p['name']: p['value'] + for p in d['packages'][0].get('annotations', []) + if p.get('annotationType') == 'OTHER'} + # CycloneDX side: same package + version present. + with open(glob.glob('wolfssl-*.cdx.json')[0]) as f: + cdx = json.load(f) + deps = {c['name']: c for c in cdx.get('components', [])} + assert 'liboqs' in deps, list(deps) + print('liboqs dep entry: ok ->', refs['purl']) + PY + + - name: HAVE_FALCON algorithm flag is captured as a build property + # Algorithm visibility moved out of the dep entry; this verifies + # it is still preserved (just somewhere honest). + run: | + python3 - <<'PY' + import glob, json + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + wolf = [p for p in d['packages'] if p['name'] == 'wolfssl'][0] + props = {p['name']: p['value'] + for p in wolf.get('annotations', []) + if p.get('annotationType') == 'OTHER'} + # Build props can land as annotations or as a 'attributionTexts' + # block depending on SPDX version; check both. + combined = json.dumps(d) + assert 'HAVE_FALCON' in combined, \ + "HAVE_FALCON missing from SBOM build properties" + print('HAVE_FALCON build prop: present') + PY + + - name: Restore default build for remaining steps + run: | + make distclean + autoreconf -ivf + ./configure --enable-shared --enable-static + + # ---- Distribution + install hooks ----------------------------------- + + - name: Tarball roundtrip (make dist -> ./configure -> make sbom) + # If a future change adds a new helper file but forgets EXTRA_DIST, + # the tarball will not contain it and this step fails. + run: | + rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx + make dist + mkdir /tmp/tb + tar -xzf wolfssl-*.tar.gz -C /tmp/tb + cd /tmp/tb/wolfssl-* + ./configure --enable-shared + make sbom + + - name: Install-sbom / uninstall hook + # `install-sbom` is a separate target (intentional - SBOM generation + # has heavy deps like pyspdxtools that we do not want firing on + # every `make install`). `make uninstall` runs uninstall-hook, + # which removes both regular and SBOM artefacts idempotently. + run: | + rm -rf /tmp/_inst2 + make install DESTDIR=/tmp/_inst2 >/dev/null + make install-sbom DESTDIR=/tmp/_inst2 + ls /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx.json \ + /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.cdx.json \ + /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx + make uninstall DESTDIR=/tmp/_inst2 + if ls /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx.json \ + 2>/dev/null; then + echo "uninstall-hook did not remove SBOM artefacts" + exit 1 + fi + + # Tier 2 (macOS) - smoke test that gen-sbom finds .dylib artefacts and + # that the autotools target works on Mach-O. Linux already exercises + # the heavy validation matrix; this job is intentionally minimal so the + # macOS runner minutes go to portability coverage, not duplicated checks. + integration-macos: + name: SBOM integration (macos) + if: github.repository_owner == 'wolfssl' + runs-on: macos-latest + needs: unit + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install build deps and SBOM validators + run: | + brew install autoconf automake libtool + python3 -m pip install --user --break-system-packages \ + 'spdx-tools==0.8.*' + # Resolve the actual scripts dir for the python that ran pip, + # rather than guessing a glob like `~/Library/Python/*/bin`. + # `posix_user` is the install scheme `pip install --user` wrote + # to, so this matches even when the runner's selected python + # changes between minor versions / homebrew vs system. + python3 -c \ + 'import sysconfig; print(sysconfig.get_path("scripts","posix_user"))' \ + >> "$GITHUB_PATH" + + - name: Configure wolfSSL (shared) + run: autoreconf -ivf && ./configure --enable-shared + + - name: Build + generate SBOM (verifies .dylib detection) + run: make sbom + + - name: SBOM hashed a real .dylib + run: | + python3 - <<'PY' + import glob, json, re + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) + checksum = d['packages'][0]['checksums'][0]['checksumValue'] + assert re.fullmatch(r'[0-9a-f]{64}', checksum), checksum + print('macOS SBOM checksum well-formed:', checksum) + PY + + # Tier 2 (bomsh) - exercises the `make bomsh` target which traces a + # full clean rebuild under bomtrace3 (patched strace, Linux-only) and + # produces an OmniBOR artifact dependency graph. Without this job + # the entire bomsh recipe and its SPDX enrichment step would only be + # exercised by hand; a regression in either would silently land. + bomsh: + name: bomsh integration (linux) + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install build deps + SBOM validators + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf automake libtool \ + python3 python3-pip git + python3 -m pip install --user --upgrade pip + python3 -m pip install --user 'spdx-tools==0.8.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install bomsh toolchain (bomtrace3 + helper scripts) + # Bomsh is not packaged; build bomtrace3 (patched strace) from + # source and install the python helpers system-wide so configure's + # AC_PATH_PROG can find them. + run: | + git clone --depth=1 https://github.com/omnibor/bomsh /tmp/bomsh + # bomtrace3 build: docker/devcontainer-only Makefile in upstream; + # use the embedded build script if present, else fall back to + # the strace patch path. + cd /tmp/bomsh + if [ -d .devcontainer/bomtrace3 ]; then + make -C .devcontainer/bomtrace3 + sudo install -m 755 .devcontainer/bomtrace3/bomtrace3 \ + /usr/local/bin/ + else + echo "bomsh repo layout changed; please update CI" + exit 1 + fi + sudo install -m 755 scripts/bomsh_create_bom.py /usr/local/bin/ + sudo install -m 755 scripts/bomsh_sbom.py /usr/local/bin/ + bomtrace3 --version || true + which bomsh_create_bom.py bomsh_sbom.py + + - name: Configure wolfSSL + run: autoreconf -ivf && ./configure --enable-shared + + - name: Generate SPDX (input to bomsh enrichment) + run: make sbom + + - name: Run make bomsh + run: make bomsh + + - name: OmniBOR artifact graph produced + # bomsh writes the artifact dependency graph under omnibor/. + # Empty/missing graph means bomtrace3 silently failed to trace. + run: | + test -d omnibor + test "$(find omnibor -type f | wc -l)" -gt 0 + echo "omnibor/ contents:" + find omnibor -maxdepth 3 -type f | head -20 + + - name: Enriched SPDX has PERSISTENT-ID gitoid externalRef + # The whole point of `make bomsh` over `make sbom` is the + # bridge between component identity (SPDX package) and build + # provenance (OmniBOR gitoid). If the enrichment step ran but + # produced an SPDX without the gitoid ref, the bridge is broken. + run: | + ls omnibor.wolfssl-*.spdx.json + python3 - <<'PY' + import glob, json, sys + path = glob.glob('omnibor.wolfssl-*.spdx.json')[0] + with open(path) as f: + d = json.load(f) + gitoid_refs = [] + for pkg in d.get('packages', []): + for ref in pkg.get('externalRefs', []): + if (ref.get('referenceCategory') == 'PERSISTENT-ID' + or ref.get('referenceType') == 'gitoid'): + gitoid_refs.append(ref) + assert gitoid_refs, \ + f'no PERSISTENT-ID gitoid externalRef in {path}' + print(f'bomsh enrichment ok: {len(gitoid_refs)} gitoid refs') + PY + + - name: make clean removes all bomsh + sbom artefacts + # Regression guard: if a future change adds an output to either + # recipe but forgets CLEANFILES, this will catch it. + run: | + make clean + if ls wolfssl-*.spdx.json wolfssl-*.cdx.json \ + omnibor.wolfssl-*.spdx.json 2>/dev/null; then + echo "make clean did not remove SBOM/bomsh artefacts" + exit 1 + fi + test ! -d omnibor || (echo "omnibor/ not cleaned"; exit 1) diff --git a/INSTALL b/INSTALL index 058b5a1edf..17e37f56db 100644 --- a/INSTALL +++ b/INSTALL @@ -313,3 +313,76 @@ We also have vcpkg ports for wolftpm, wolfmqtt and curl. Docker container, use `make rpm-docker`. In both cases the resulting packages are placed in the root directory of the project. + +19. 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. + +20. Generating OmniBOR build artifact graph (Bomsh) + + wolfSSL supports generating an OmniBOR artifact dependency graph using + the Bomsh project (https://github.com/omnibor/bomsh). OmniBOR provides + cryptographic traceability from every binary artifact back to the exact + source files that produced it. + + Prerequisites: + - bomtrace3 (build from https://github.com/omnibor/bomsh) + - bomsh_create_bom.py (from the bomsh scripts/ directory, in PATH) + - bomsh_sbom.py (optional; from bomsh scripts/, for SPDX enrichment) + + Both bomtrace3 and the Python scripts are detected by configure. + make bomsh fails with a clear error message if either required tool + is missing. + + Usage: + + $ ./configure + $ make + $ make bomsh + + This performs a clean rebuild of wolfSSL under bomtrace3 tracing, + then produces an OmniBOR artifact graph in omnibor/ in the build + directory. If bomsh_sbom.py is available and a wolfssl-.spdx.json + exists (from 'make sbom'), it also produces an OmniBOR-enriched SPDX + document omnibor.wolfssl-.spdx.json. + + To install: + + $ make install-bomsh # installs omnibor/ to $(datadir)/doc/wolfssl/ + $ make uninstall-bomsh # removes installed files + + The generated files are removed by make clean. + + See doc/SBOM.md for full details. diff --git a/Makefile.am b/Makefile.am index fce812babf..04fa6b7fbe 100644 --- a/Makefile.am +++ b/Makefile.am @@ -350,3 +350,195 @@ 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) + +# Shared-library / Mach-O basenames in priority order (versioned first). +# Both `sbom:` and `bomsh:` glob for these under their own search prefixes; +# adding a new platform-specific dynamic-library extension here updates +# both targets at once. Static (.a) and Windows (.dll/.lib) variants are +# listed inline at each call-site because their ordering and prefixes +# differ between the install tree and the build tree. +WOLFSSL_LIB_DSO_BASENAMES = \ + libwolfssl.so.[0-9]* \ + libwolfssl.so \ + libwolfssl.[0-9]*.dylib \ + libwolfssl.dylib + +.PHONY: sbom install-sbom uninstall-sbom + +# Stage a `make install` into a private tree, discover the installed library +# artifact (shared or static, ELF/Mach-O/PE), hash it, generate SPDX+CDX, +# validate the SPDX, then convert to tag-value. The staging tree is removed +# unconditionally via `trap`, even if any step fails. Honors SOURCE_DATE_EPOCH +# for reproducible builds (set by the recipe to `git log -1 --format=%ct` when +# unset and a git tree is available). +# +# User-overridable variables: +# SBOM_LICENSE_OVERRIDE SPDX expression to use instead of the GPL ID +# parsed from LICENSING (e.g. for commercial +# licensees: LicenseRef-wolfSSL-Commercial). +# SBOM_LICENSE_TEXT Path to the actual licence text for any +# LicenseRef-* in SBOM_LICENSE_OVERRIDE. Required +# for SPDX 2.3 conformance whenever a custom +# LicenseRef is in use; `make sbom` exits with an +# error if it is missing. +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 + @set -e; \ + trap 'rm -rf $(abs_builddir)/_sbom_staging' EXIT INT TERM HUP; \ + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ + sbom_lib=""; \ + for lib in \ + $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.dll \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.dll.a \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.lib \ + "$(abs_builddir)/_sbom_staging$(libdir)"/wolfssl.lib \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.a; do \ + if test -f "$$lib"; then sbom_lib="$$lib"; break; fi; \ + done; \ + if test -z "$$sbom_lib"; then \ + echo ""; \ + echo "ERROR: No installed wolfSSL library artifact found for SBOM."; \ + echo " Searched in $(abs_builddir)/_sbom_staging$(libdir)"; \ + echo " (configure with --enable-shared or --enable-static)"; \ + echo ""; \ + exit 1; \ + fi; \ + echo "SBOM: hashing $$sbom_lib"; \ + if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ + $(GIT) -C "$(srcdir)" rev-parse --git-dir >/dev/null 2>&1; then \ + SOURCE_DATE_EPOCH=`$(GIT) -C "$(srcdir)" log -1 --format=%ct 2>/dev/null || echo`; \ + export SOURCE_DATE_EPOCH; \ + fi; \ + $(PYTHON3) $(srcdir)/scripts/gen-sbom \ + --name $(PACKAGE) \ + --version $(PACKAGE_VERSION) \ + --license-file $(srcdir)/LICENSING \ + --license-override '$(SBOM_LICENSE_OVERRIDE)' \ + --license-text '$(SBOM_LICENSE_TEXT)' \ + --options-h $(abs_builddir)/wolfssl/options.h \ + --lib "$$sbom_lib" \ + --dep-libz $(ENABLED_LIBZ) \ + --dep-liboqs $(ENABLED_LIBOQS) \ + --cdx-out $(abs_builddir)/$(SBOM_CDX) \ + --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ + $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ + --outfile $(abs_builddir)/$(SBOM_SPDX_TV) + +install-sbom: sbom + $(MKDIR_P) $(DESTDIR)$(sbomdir) + $(INSTALL_DATA) $(SBOM_CDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX_TV) $(DESTDIR)$(sbomdir)/ + +uninstall-sbom: + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_CDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX_TV) + +CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) + +# Bomsh (OmniBOR build artifact tracing + SBOM enrichment) +BOMSH_RAWLOG_BASE = $(abs_builddir)/bomsh_raw_logfile +BOMSH_RAWLOG = $(BOMSH_RAWLOG_BASE).sha1 +BOMSH_CONF = $(abs_builddir)/_bomsh.conf +BOMSH_OMNIBORDIR = $(abs_builddir)/omnibor +BOMSH_SPDX_OUT = omnibor.wolfssl-$(PACKAGE_VERSION).spdx.json +bomshdir = $(datadir)/doc/$(PACKAGE) + +.PHONY: bomsh install-bomsh uninstall-bomsh + +# Self-contained: the traced rebuild also regenerates the SBOM, so users +# can run `make bomsh` directly without first running `make sbom`. This is +# also what makes the combined workflow correct: `make sbom` writes the SPDX, +# but `make bomsh` issues `make clean` (which removes it via CLEANFILES), so +# the only reliable way to enrich is to regenerate after the traced build. +bomsh: + @if test -z "$(BOMTRACE3)"; then \ + echo ""; \ + echo "ERROR: 'bomtrace3' not found in PATH. Cannot generate OmniBOR data."; \ + echo " Build bomtrace3 from: https://github.com/omnibor/bomsh"; \ + echo ""; \ + exit 1; \ + fi + @if test -z "$(BOMSH_CREATE_BOM)"; then \ + echo ""; \ + echo "ERROR: 'bomsh_create_bom.py' not found in PATH. Cannot process OmniBOR data."; \ + echo " Install from: https://github.com/omnibor/bomsh"; \ + echo ""; \ + exit 1; \ + fi + $(MAKE) clean + @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' + $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) + $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' + $(MAKE) sbom + @if test -z "$(BOMSH_SBOM)"; then \ + echo "NOTE: bomsh_sbom.py not in PATH; skipping SPDX enrichment."; \ + echo " The OmniBOR graph in $(BOMSH_OMNIBORDIR) is still produced."; \ + exit 0; \ + fi; \ + bomsh_artifact=""; \ + for lib in \ + $(addprefix $(abs_builddir)/src/.libs/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ + $(abs_builddir)/src/.libs/libwolfssl.a \ + $(abs_builddir)/src/libwolfssl.a; do \ + if test -f "$$lib"; then bomsh_artifact="$$lib"; break; fi; \ + done; \ + if test -z "$$bomsh_artifact"; then \ + echo "NOTE: no built libwolfssl artifact found in $(abs_builddir)/src/.libs/"; \ + echo " OmniBOR graph produced; SPDX enrichment skipped."; \ + exit 0; \ + fi; \ + echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ + $(BOMSH_SBOM) \ + -b '$(BOMSH_OMNIBORDIR)' \ + -i '$(abs_builddir)/$(SBOM_SPDX)' \ + -f "$$bomsh_artifact" \ + -s spdx-json \ + -O '$(abs_builddir)' + +install-bomsh: bomsh + $(MKDIR_P) '$(DESTDIR)$(bomshdir)/omnibor' + @if test -d '$(BOMSH_OMNIBORDIR)'; then \ + (cd '$(BOMSH_OMNIBORDIR)' && tar cf - .) | \ + (cd '$(DESTDIR)$(bomshdir)/omnibor' && tar xf -); \ + fi + @if test -f '$(abs_builddir)/$(BOMSH_SPDX_OUT)'; then \ + $(INSTALL_DATA) '$(abs_builddir)/$(BOMSH_SPDX_OUT)' '$(DESTDIR)$(bomshdir)/'; \ + fi + +uninstall-bomsh: + -rm -rf '$(DESTDIR)$(bomshdir)/omnibor' + -rm -f '$(DESTDIR)$(bomshdir)/$(BOMSH_SPDX_OUT)' + +CLEANFILES += $(BOMSH_RAWLOG) $(BOMSH_RAWLOG_BASE).sha256 $(BOMSH_CONF) $(BOMSH_SPDX_OUT) + +# Hook SBOM/Bomsh cleanup into `make uninstall` so packagers don't leave +# stale artefacts behind after install-sbom/install-bomsh. rm -f is +# idempotent, so this is safe whether or not those targets were ever run. +uninstall-hook: + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_CDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX_TV) + -rm -rf $(DESTDIR)$(bomshdir)/omnibor + -rm -f $(DESTDIR)$(bomshdir)/$(BOMSH_SPDX_OUT) diff --git a/README.md b/README.md index ae1f22a08c..a51a2d0b8e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ 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. + +## OmniBOR / Bomsh + +wolfSSL supports generating an OmniBOR artifact dependency graph via +`make bomsh`, providing cryptographic traceability from the installed +library back to every source file that produced it. See `doc/SBOM.md` +for details. + ## Notes, Please Read ### Note 1 diff --git a/configure.ac b/configure.ac index 786514eea3..dd0cc0b0cc 100644 --- a/configure.ac +++ b/configure.ac @@ -12178,6 +12178,18 @@ 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_LIBZ]) +AC_SUBST([ENABLED_LIBOQS]) + +# Bomsh (OmniBOR build artifact tracing + SBOM enrichment) +AC_PATH_PROG([BOMTRACE3], [bomtrace3]) +AC_PATH_PROG([BOMSH_CREATE_BOM], [bomsh_create_bom.py]) +AC_PATH_PROG([BOMSH_SBOM], [bomsh_sbom.py]) + # FINAL AC_CONFIG_FILES([stamp-h], [echo timestamp > stamp-h]) AC_CONFIG_FILES([Makefile diff --git a/doc/CRA.md b/doc/CRA.md new file mode 100644 index 0000000000..d477d3a1f0 --- /dev/null +++ b/doc/CRA.md @@ -0,0 +1,270 @@ +# wolfSSL and the EU Cyber Resilience Act + +This guide is for product teams that ship a product containing wolfSSL and +need to satisfy EU Cyber Resilience Act (CRA) obligations related to software +component transparency and build traceability. + +## Background + +The CRA requires manufacturers of products with digital elements placed on +the EU market to identify and document the software components in those +products. The practical requirement is a machine-readable Software Bill of +Materials (SBOM) covering all open-source and third-party components, +following the NTIA minimum element guidelines. + +wolfSSL provides two complementary artefacts to help you meet this +requirement: + +| Artefact | Produced by | What it answers | +|---|---|---| +| SBOM (SPDX 2.3 + CycloneDX 1.6) | `make sbom` | *What* is in wolfSSL (identity, license, CPE, PURL, checksum) | +| OmniBOR artifact graph | `make bomsh` | *How* wolfSSL was built (cryptographic source-to-binary traceability) | + +For most CRA use cases the SBOM alone is sufficient. The OmniBOR graph +provides a deeper audit trail if your compliance posture requires it. + +## Quick Start + +```sh +./configure +make +make sbom # produces wolfssl-.spdx.json, .cdx.json, .spdx +make bomsh # optional: produces omnibor/ + OmniBOR-enriched SPDX +``` + +See `doc/SBOM.md` for prerequisites and full details on both targets. + +## What wolfSSL Provides + +After `make sbom`: + +``` +wolfssl-.spdx.json SPDX 2.3 JSON (machine processing) +wolfssl-.cdx.json CycloneDX 1.6 JSON (supply-chain tooling, VEX) +wolfssl-.spdx SPDX 2.3 tag-value (human review, archival) +``` + +After `make bomsh` (with `make sbom` already run): + +``` +omnibor/ OmniBOR artifact dependency graph +omnibor.wolfssl-.spdx.json SPDX enriched with PERSISTENT-ID gitoid +``` + +## Integrating wolfSSL into Your Product SBOM + +Your product SBOM needs to list wolfSSL as a component. The two standard +approaches are to reference wolfSSL's SBOM document from yours, or to copy +the wolfSSL package entry directly into your document. + +### SPDX: external document reference (recommended) + +Reference wolfSSL's SPDX document from your product's SPDX document using +`externalDocumentRefs`. This keeps the documents separate and lets wolfSSL's +SBOM stand as an independently verifiable artefact. + +```json +{ + "externalDocumentRefs": [ + { + "externalDocumentId": "DocumentRef-wolfssl", + "spdxDocument": "https://wolfssl.com/sbom/wolfssl-.spdx.json", + "checksum": { + "algorithm": "SHA256", + "checksumValue": "" + } + } + ] +} +``` + +Then express the dependency in your `relationships` section: + +```json +{ + "spdxElementId": "SPDXRef-Package-YourProduct", + "relatedSpdxElement": "DocumentRef-wolfssl:SPDXRef-Package-wolfssl", + "relationshipType": "DYNAMIC_LINK" +} +``` + +Use `STATIC_LINK` if you link wolfSSL statically, `DYNAMIC_LINK` if you +use the shared library, or `CONTAINS` if you redistribute the source. + +Alternatively, copy the wolfSSL package entry from its SPDX document +directly into your own SPDX document and add the `DYNAMIC_LINK` / +`STATIC_LINK` relationship to your product package. + +### CycloneDX: component reference + +Include wolfSSL as a component in your CycloneDX BOM, referencing the +wolfSSL CycloneDX document via an external reference of type `bom`: + +```json +{ + "type": "library", + "name": "wolfssl", + "version": "", + "purl": "pkg:generic/wolfssl@", + "cpe": "cpe:2.3:a:wolfssl:wolfssl::*:*:*:*:*:*:*", + "licenses": [{ "license": { "id": "GPL-3.0-only" } }], + "externalReferences": [ + { + "type": "bom", + "url": "https://wolfssl.com/sbom/wolfssl-.cdx.json", + "hashes": [ + { + "alg": "SHA-256", + "content": "" + } + ] + } + ] +} +``` + +## Commercial License Users + +wolfSSL's published SBOM records `licenseConcluded: GPL-3.0-only`, which +reflects the open-source license. If you are distributing a product under a +wolfSSL commercial license, you have two options: + +### Option 1: regenerate the SBOM with your license expression + +Pass `SBOM_LICENSE_OVERRIDE` to `make sbom` to bake your SPDX expression +directly into the artefact (preferred — survives re-runs, no manual editing): + +```sh +make sbom \ + SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ + SBOM_LICENSE_TEXT=/path/to/wolfssl-commercial-license.txt +``` + +`SBOM_LICENSE_TEXT` is **required** whenever `SBOM_LICENSE_OVERRIDE` uses a +custom `LicenseRef-*` identifier. SPDX 2.3 §10.1 requires the actual licence +text to be embedded in `hasExtractedLicensingInfos` for any LicenseRef used in +the document; conformant validators (e.g. `pyspdxtools`, `ntia-conformance-checker`) +will reject the SBOM otherwise. The file should contain the plain-text +licence agreement you received from wolfSSL. + +If `SBOM_LICENSE_OVERRIDE` is set to a `LicenseRef-*` and `SBOM_LICENSE_TEXT` +is missing, `make sbom` exits with an error rather than emit an invalid SBOM +that might end up in front of a regulator. + +For a stock SPDX-listed identifier (`Apache-2.0`, `MIT`, etc.) the +`SBOM_LICENSE_TEXT` argument is unnecessary because validators already know +the canonical text. + +Or invoke the generator directly with `--license-override` / +`--license-text` if you are producing the SBOM outside the standard make +target. + +### Option 2: update your product SBOM's reference to wolfSSL + +Leave the upstream SBOM file alone and override `licenseConcluded` on the +wolfSSL package entry in *your* product SBOM: + +```json +"licenseConcluded": "LicenseRef-wolfSSL-Commercial" +``` + +Do not modify the wolfSSL-published SBOM file in place; either regenerate it +with the override (Option 1) or override at the consumer level (Option 2). + +## Reproducible SBOMs + +The generator honors `SOURCE_DATE_EPOCH` for the SBOM creation timestamp and +uses deterministic UUIDs derived from the package name and version, so two +runs of `make sbom` against the same source tree, library binary, and build +options produce byte-identical `.spdx.json` and `.cdx.json` files. This +matters for downstream attestation pipelines that hash SBOMs as part of a +provenance chain. + +`make sbom` will derive `SOURCE_DATE_EPOCH` from `git log -1 --format=%ct` if +you do not set it explicitly and the wolfSSL source tree is a git checkout. + +## Build Provenance (OmniBOR) + +The CRA also encourages transparency about *how* software is built, not just +*what* it contains. Running `make bomsh` after `make sbom` produces an +OmniBOR artifact dependency graph and an enriched SPDX document: + +``` +omnibor.wolfssl-.spdx.json +``` + +This file is identical to `wolfssl-.spdx.json` except it adds a +`PERSISTENT-ID gitoid` entry to the wolfSSL package's `externalRefs`: + +```json +{ + "referenceCategory": "PERSISTENT-ID", + "referenceType": "gitoid", + "referenceLocator": "gitoid:blob:sha1:" +} +``` + +The `gitoid` is the entry point into the OmniBOR Merkle DAG stored in +`omnibor/`. A CRA auditor or supply-chain tool can follow that identifier +through the graph to verify that a specific `libwolfssl.so` binary was +produced from a specific, unmodified set of source files. + +Use `omnibor.wolfssl-.spdx.json` in place of the plain SPDX file +when you want to include this traceability claim in your product SBOM. + +## What to Give Your Auditor + +For a CRA conformity assessment, provide: + +| File | Purpose | +|---|---| +| `wolfssl-.spdx.json` | Machine-readable component identity (SPDX 2.3) | +| `wolfssl-.cdx.json` | Machine-readable component identity (CycloneDX 1.6) | +| `wolfssl-.spdx` | Human-readable tag-value form | +| `omnibor/` + `omnibor.wolfssl-.spdx.json` | Build traceability (optional, if bomsh was run) | + +If you have a product-level SBOM that references wolfSSL via +`ExternalDocumentRef` (SPDX) or a `bom` external reference (CycloneDX), +include that product SBOM alongside the wolfSSL artefacts. + +## Further Reading + +### wolfSSL documentation + +- `doc/SBOM.md` — unified reference covering SBOM generation, OmniBOR/Bomsh + build provenance, combined workflow, output formats, and implementation notes + +### OpenSSF guidance + +- [CRA Brief Guide for OSS Developers](https://best.openssf.org/CRA-Brief-Guide-for-OSS-Developers.html) + — Clarifies when the CRA applies to open source projects and + maintainers, and what obligations fall on manufacturers integrating + OSS components into commercial products (i.e., you, if you ship a + product containing wolfSSL). + +- [SBOM in Compliance](https://sbom-catalog.openssf.org/sbom-compliance.html) + — OpenSSF SBOM Everywhere SIG survey of the global regulatory + landscape: CRA, NTIA minimum elements, US EO 14028, Germany TR-03183, + and others. Useful for understanding how wolfSSL's SBOM artefacts map + to each framework. + +- [Getting Started with SBOMs](https://sbom-catalog.openssf.org/getting-started) + — OpenSSF SBOM Everywhere SIG guidance on SBOM generation approaches + (build-integrated vs. separate tooling), phase selection, and + publication. wolfSSL's `make sbom` follows the build-integrated + approach recommended here. + +- [OpenSSF CRA Policy Hub](https://openssf.org/category/policy/cra/) + — Ongoing OpenSSF coverage of CRA developments, implementation + guidance, and community responses. + +- [SBOM Everywhere Wiki](https://sbom-catalog.openssf.org/) + — OpenSSF SIG home: tooling catalog, working group resources, naming + conventions, and cross-format guidance for SPDX and CycloneDX. + +### Standards + +- SPDX 2.3 specification: https://spdx.github.io/spdx-spec/v2.3/ +- CycloneDX 1.6 specification: https://cyclonedx.org/specification/overview/ +- NTIA minimum elements for an SBOM: + https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom diff --git a/doc/SBOM.md b/doc/SBOM.md new file mode 100644 index 0000000000..90c343e2f5 --- /dev/null +++ b/doc/SBOM.md @@ -0,0 +1,295 @@ +# wolfSSL SBOM and Build Provenance + +wolfSSL provides two complementary artefacts for software supply chain +transparency: + +| Artefact | Target | Answers | +|---|---|---| +| SBOM (SPDX 2.3 + CycloneDX 1.6) | `make sbom` | *What* wolfSSL is: component identity, license, checksums, CPE, PURL | +| OmniBOR artifact graph | `make bomsh` | *How* wolfSSL was built: cryptographic source-to-binary traceability | + +Together they provide full coverage for the EU Cyber Resilience Act (CRA) +and similar supply chain transparency requirements. Each target is +independently useful; running both produces an enriched SPDX document that +bridges the two artefacts with a single `PERSISTENT-ID gitoid` reference. + +## Quick Start + +### Component identity only + +```sh +./configure +make +make sbom +``` + +Requires `python3` and `pyspdxtools` (`pip install spdx-tools`). + +### Full coverage: component identity + build provenance + +```sh +./configure +make +make sbom +make bomsh +``` + +Additionally requires `bomtrace3` and `bomsh_create_bom.py` in `PATH`. +See [Prerequisites for make bomsh](#prerequisites-for-make-bomsh) below. + +All tools are detected by `configure`; either target fails with a clear +error message if a required tool is missing. + +--- + +## make sbom + +### 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. + +### 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 default SBOM reflects the +open-source license. Commercial licensees should regenerate the SBOM with +`--license-override` set to their applicable SPDX expression — the generator +exposes this directly: + +```sh +python3 scripts/gen-sbom \ + --license-override LicenseRef-wolfSSL-Commercial \ + --license-text /path/to/wolfssl-commercial-license.txt \ + ... other flags ... +``` + +`--license-text` is **required** whenever `--license-override` is a custom +`LicenseRef-*`: SPDX 2.3 mandates that any LicenseRef in `licenseConcluded` +or `licenseDeclared` be backed by a `hasExtractedLicensingInfos` entry that +embeds the actual licence text. Running without it is a configuration +error and the generator exits non-zero rather than emit a misleading SBOM +that auditors might then circulate. + +For an SPDX-listed override (`Apache-2.0`, `MIT`, etc.), `--license-text` +is unnecessary because validators already know the canonical text. + +`make sbom` plumbs both knobs through the matching make variables: + +```sh +make sbom \ + SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ + SBOM_LICENSE_TEXT=/path/to/wolfssl-commercial-license.txt +``` + +#### External dependency version detection + +The optional external dependencies wolfSSL can link against (`libz` and +`liboqs`) are both installed packages and are queried via +`pkg-config --modversion` at SBOM generation time. The SBOM records each +linked library by its package name (`zlib`, `liboqs`) so that downstream +vulnerability scanners (OSV, Grype, Trivy, Dependency-Track) match CVEs +against the right component. Algorithm enablement (e.g. Falcon, which is +reachable only via liboqs) is captured separately as build properties +(`wolfssl:build:HAVE_FALCON` etc.) parsed from `wolfssl/options.h`. + +If pkg-config does not report a version (the package is not installed, or +its `.pc` file is missing): + +- SPDX records `versionInfo: NOASSERTION` and emits no `purl` external ref. +- CycloneDX omits the `version` and `purl` fields entirely and the generator + prints a warning to stderr. + +### 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 +``` + +### 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`. + +### 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. + +--- + +## make bomsh + +`make bomsh` uses the [Bomsh](https://github.com/omnibor/bomsh) project to +trace the wolfSSL build under `bomtrace3` (a patched `strace`) and produce +an OmniBOR artifact dependency graph: a content-addressed Merkle DAG mapping +every built binary back to the exact set of source files that produced it. + +### Prerequisites for make bomsh + +| Tool | Required | Where to get it | +|---|---|---| +| `bomtrace3` | yes | Build from source: [omnibor/bomsh](https://github.com/omnibor/bomsh) | +| `bomsh_create_bom.py` | yes | `scripts/` directory of the bomsh repo, placed in `PATH` | +| `bomsh_sbom.py` | no | Same; needed only for SPDX enrichment step | + +`bomtrace3` is a patched `strace` — it is a userspace binary and requires no +kernel modifications. It uses the standard `ptrace()` syscall available on +any stock Linux kernel. The only environments where it may be unavailable +are containers running with a hardened seccomp profile or systems with +`kernel.yama.ptrace_scope=3`. + +#### Building bomtrace3 + +```sh +git clone https://github.com/omnibor/bomsh +git clone https://github.com/strace/strace strace3 +cd strace3 +patch -p1 < ../bomsh/.devcontainer/patches/bomtrace3.patch +cp ../bomsh/.devcontainer/src/*.[hc] src/ +./bootstrap && ./configure && make +cp src/strace ~/.local/bin/bomtrace3 +``` + +Place `bomsh_create_bom.py` (and optionally `bomsh_sbom.py`) from the bomsh +`scripts/` directory somewhere in `PATH`. + +### What make bomsh does + +1. Writes a build-local `_bomsh.conf` redirecting the raw logfile out of + `/tmp/` to the build directory (avoids collisions between concurrent + builds). +2. Runs `make clean` to ensure a full rebuild. This is necessary because + `bomtrace3` intercepts syscalls live during compilation and cannot + post-process an already-built tree. +3. Runs `bomtrace3 -c _bomsh.conf make` — rebuilds wolfSSL under strace + tracing, recording every compiler invocation with its inputs and outputs. +4. Runs `bomsh_create_bom.py` to process the raw logfile and produce the + OmniBOR artifact graph in `omnibor/`. +5. If `bomsh_sbom.py` is available **and** `wolfssl-.spdx.json` + exists (from `make sbom`), annotates that SPDX document with OmniBOR + `ExternalRef` identifiers, producing `omnibor.wolfssl-.spdx.json`. + +### Output files + +| Path | Description | +|---|---| +| `omnibor/objects/` | OmniBOR artifact objects (SHA-1 content-addressed dependency graph) | +| `omnibor/metadata/bomsh/` | Bomsh build metadata | +| `omnibor.wolfssl-.spdx.json` | SPDX 2.3 JSON enriched with OmniBOR `ExternalRef` (produced only when both `bomsh_sbom.py` and `wolfssl-.spdx.json` are present) | + +The `PERSISTENT-ID gitoid` entry added to the enriched SPDX looks like: + +```json +{ + "referenceCategory": "PERSISTENT-ID", + "referenceType": "gitoid", + "referenceLocator": "gitoid:blob:sha1:" +} +``` + +This sits alongside the existing CPE and PURL `externalRefs` on the wolfSSL +package entry and is the key into the OmniBOR Merkle DAG in `omnibor/`. + +### Installing + +```sh +make install-bomsh # installs omnibor/ and enriched SPDX to $(datadir)/doc/wolfssl/ +make uninstall-bomsh # removes installed files +``` + +The generated files are removed by `make clean`. + +### Implementation notes + +`make bomsh` runs a full clean rebuild under `bomtrace3` on every invocation. +The ~20% runtime overhead of `bomtrace3` means the rebuild takes roughly +1.2× the normal build time. + +The raw logfile (`bomsh_raw_logfile.sha1`) and conf file (`_bomsh.conf`) +are written to the build directory and removed by `make clean`. The +`omnibor/` tree is also removed by `make clean`. + +--- + +## Combined workflow + +Running both targets produces the complete set of supply chain transparency +artefacts. `make bomsh` automatically enriches the SPDX document from +`make sbom` if it is present; there is no need to pass any extra flags. + +```sh +./configure +make +make sbom # component identity +make bomsh # build provenance + enriched SPDX +``` + +All output files: + +| File | From | Description | +|---|---|---| +| `wolfssl-.cdx.json` | `make sbom` | CycloneDX 1.6 component SBOM | +| `wolfssl-.spdx.json` | `make sbom` | SPDX 2.3 JSON component SBOM | +| `wolfssl-.spdx` | `make sbom` | SPDX 2.3 tag-value, validated | +| `omnibor/` | `make bomsh` | OmniBOR artifact dependency graph | +| `omnibor.wolfssl-.spdx.json` | `make bomsh` | SPDX 2.3 JSON enriched with OmniBOR gitoid | + +The enriched SPDX is the document to hand to a CRA auditor or downstream +consumer when you want both component identity and build traceability in one +file. + +--- + +## Using wolfSSL's artefacts in a product + +If you are shipping a product that includes wolfSSL and need to satisfy CRA +obligations, see `doc/CRA.md` for guidance on integrating these artefacts +into your product SBOM and what to provide to a conformity assessor. diff --git a/doc/include.am b/doc/include.am index 92f2c5b66b..13bb8bf9ed 100644 --- a/doc/include.am +++ b/doc/include.am @@ -3,7 +3,9 @@ # All paths should be given relative to the root dist_doc_DATA+= doc/README.txt \ - doc/QUIC.md + doc/QUIC.md \ + doc/SBOM.md \ + doc/CRA.md dox-pdf: @@ -21,3 +23,4 @@ clean-local: -rm -rf doc/html/ -rm -f doc/refman.pdf -rm -f doc/doxygen_warnings + -rm -rf $(BOMSH_OMNIBORDIR) diff --git a/scripts/gen-sbom b/scripts/gen-sbom new file mode 100755 index 0000000000..9538025213 --- /dev/null +++ b/scripts/gen-sbom @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.""" + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import uuid +from datetime import datetime, timezone + + +# Stable namespace for deterministic uuid5 derivation. Anchored under +# wolfssl.com so collisions with other projects' SBOM UUIDs are not a concern. +SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/') + + +def derived_uuid(*parts): + """Deterministic UUID from joined parts under the wolfSSL SBOM namespace. + Re-runs of `make sbom` against the same source produce identical UUIDs, + which is required for reproducible-build-style SBOM hashing. + + Uses NUL as a separator so no aliasing is possible between e.g. + derived_uuid('a/b', 'c') and derived_uuid('a', 'b/c'); NUL cannot + appear in any of the call-site inputs (package name, version, role + label, dep key).""" + return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts))) + + +def build_timestamp(): + """Return (datetime, ISO-8601-Z string) honoring SOURCE_DATE_EPOCH. + Reproducible Builds convention: if the env var is set to a valid + integer, use it as the SBOM creation timestamp instead of wallclock.""" + sde = os.environ.get('SOURCE_DATE_EPOCH', '').strip() + if sde: + try: + dt = datetime.fromtimestamp(int(sde), tz=timezone.utc) + except (ValueError, OverflowError, OSError) as e: + print(f"WARNING: ignoring invalid SOURCE_DATE_EPOCH={sde!r}: {e}", + file=sys.stderr) + dt = datetime.now(timezone.utc) + else: + dt = datetime.now(timezone.utc) + return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ') + + +# Known metadata for optional external dependencies. Version is detected +# at runtime via pkg-config; falls back to None. Each entry must describe +# the *linked artefact* (so vulnerability scanners like OSV / Grype / Trivy +# / Dependency-Track resolve CVEs against the right package). Algorithm +# enablement is captured separately via build_props (HAVE_FALCON, ...). +DEP_META = { + # liboqs is the only PQ external dependency wolfSSL still links against + # after upstream PR #10293 collapsed the rest of the PQ surface into + # native wolfCrypt. Today, --enable-falcon strictly implies --with-liboqs + # (configure.ac enforces both directions), so a build that links liboqs + # is precisely a build that exposed Falcon. + 'liboqs': { + 'name': 'liboqs', + 'supplier': 'Open Quantum Safe', + 'license': 'MIT', + 'download': 'https://github.com/open-quantum-safe/liboqs', + 'pkgconfig': 'liboqs', + 'purl': lambda v: f'pkg:github/open-quantum-safe/liboqs@{v}', + }, + 'libz': { + 'name': 'zlib', + 'supplier': 'Jean-loup Gailly and Mark Adler', + 'license': 'Zlib', + 'download': 'https://github.com/madler/zlib', + 'pkgconfig': 'zlib', + 'purl': lambda v: f'pkg:generic/zlib@{v}', + }, +} + + +# Matches a single SPDX `LicenseRef-` identifier as defined in SPDX 2.3 +# Annex D ("idstring = 1*(ALPHA / DIGIT / '-' / '.')"). We use this to +# discover custom license refs inside an arbitrary SPDX expression and to +# decide whether a `licenseConcluded` value needs an accompanying +# `hasExtractedLicensingInfos` block. +LICENSEREF_RE = re.compile(r'LicenseRef-[A-Za-z0-9.\-]+') + +# Matches a "simple" SPDX-listed license ID such as `GPL-2.0-or-later` or +# `MIT` (no spaces, no operators, no LicenseRef-). Anything that does not +# match must be expressed via `licenses[].license.name` / `licenses[].expression` +# in CycloneDX, since `license.id` is restricted to the SPDX licence list. +SIMPLE_SPDX_ID_RE = re.compile(r'\A[A-Za-z0-9.+\-]+\Z') + + +def is_simple_spdx_id(value): + return bool(SIMPLE_SPDX_ID_RE.match(value)) and \ + not value.startswith('LicenseRef-') and value != 'NOASSERTION' + + +def extract_license_refs(expr): + """Return a sorted, deduplicated list of LicenseRef-* IDs found in expr.""" + return sorted(set(LICENSEREF_RE.findall(expr or ''))) + + +def load_license_text(path): + """Read the license text file given via --license-text, exit on error.""" + if not path: + return None + try: + with open(path) as f: + return f.read() + except OSError as e: + sys.exit(f"ERROR: cannot read --license-text {path}: {e}") + + +def build_extracted_licensing_infos(license_expr, license_text): + """Return SPDX `hasExtractedLicensingInfos` array for license_expr. + + SPDX 2.3 §10 requires every LicenseRef-* used in `licenseConcluded`/ + `licenseDeclared` to be declared once at document level via + `hasExtractedLicensingInfos`. Returns None when no LicenseRef-* is + present so the caller can omit the field entirely. + + `license_text=None` produces a placeholder entry; main() rejects + that combination upfront, so this fallback is only reachable from + direct programmatic callers (e.g. tests, library reuse). + """ + refs = extract_license_refs(license_expr) + if not refs: + return None + if license_text is None: + license_text = ( + 'NOASSERTION. The text for this LicenseRef has not been ' + 'embedded in the SBOM. Provide it via the gen-sbom ' + '--license-text PATH flag (or `make sbom SBOM_LICENSE_TEXT=...`).' + ) + infos = [] + for ref in refs: + infos.append({ + 'licenseId': ref, + 'extractedText': license_text, + 'name': ref[len('LicenseRef-'):].replace('-', ' ').strip(), + }) + return infos + + +def cdx_license_block(license_expr, license_text): + """Return the CycloneDX `licenses[]` entry for an arbitrary SPDX + expression. CDX 1.6 distinguishes: + * `license.id` - an entry from the SPDX licence list + * `license.name` - a non-listed licence (e.g. a LicenseRef-*) + * `expression` - a compound SPDX expression + Picking the wrong shape causes downstream tooling to reject the SBOM.""" + # NOASSERTION is a reserved SPDX value, not a parseable SPDX expression; + # emit it via license.name so CDX validators don't choke trying to parse + # it as one. + if license_expr == 'NOASSERTION': + return [{'license': {'name': 'NOASSERTION'}}] + if is_simple_spdx_id(license_expr): + return [{'license': {'id': license_expr}}] + refs = extract_license_refs(license_expr) + if len(refs) == 1 and refs[0] == license_expr: + block = {'name': license_expr} + if license_text: + block['text'] = {'contentType': 'text/plain', 'content': license_text} + return [{'license': block}] + return [{'expression': license_expr}] + + +def detect_license(license_file): + """Parse LICENSING file and return an SPDX license ID. + + Looks for 'GNU General Public License version N' and whether + 'or later' / 'or any later version' follows. Returns None and + prints a warning if the file cannot be parsed. + """ + try: + with open(license_file) as f: + text = f.read() + except OSError as e: + print(f"WARNING: cannot read license file {license_file}: {e}", + file=sys.stderr) + return None + + m = re.search( + r'gnu general public license\s+version\s+(\d+)', + text, re.IGNORECASE + ) + 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() + + +def pkgconfig_version(pkgname): + """Return version string from pkg-config, or None if unavailable.""" + try: + r = subprocess.run( + ['pkg-config', '--modversion', pkgname], + capture_output=True, text=True + ) + if r.returncode == 0: + return r.stdout.strip() + except FileNotFoundError: + pass + return None + + +def dep_version(key): + """Resolve the runtime version of a DEP_META entry. + + Every live entry exposes a `pkgconfig` package name; if pkg-config + cannot answer (package missing or `.pc` not on PKG_CONFIG_PATH) we + return None and the caller emits NOASSERTION (SPDX) / omits the + version (CycloneDX). A previous source-tree fallback that used + `git describe` against `git_root` was removed once libxmss/liblms + were dropped upstream; if a future PQ dep returns to a source-only + integration, restore the fallback here together with a `git_root` + field on the DEP_META entry.""" + return pkgconfig_version(DEP_META[key]['pkgconfig']) + + +def parse_options_h(path): + """Parse wolfssl/options.h and return sorted deduplicated list of + (name, value) pairs for every #define found. + + Trailing C/C++ comments on a #define line (`#define HAVE_FOO 42 /* x */` + or `// y`) are stripped; otherwise they would land verbatim in the + SBOM build properties.""" + try: + with open(path) as f: + text = f.read() + except OSError as e: + print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr) + return [] + + defines = {} + for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.*))?$', text, re.MULTILINE): + raw = (m.group(2) or '') + raw = re.split(r'/\*|//', raw, maxsplit=1)[0] + defines[m.group(1)] = raw.strip() + return sorted(defines.items()) + + +def cdx_dep_component(name, pkg_version, key): + """Return (bom_ref, component_dict) for a CDX dependency component. + bom_ref is deterministic for reproducibility.""" + meta = DEP_META[key] + version = dep_version(key) + bom_ref = derived_uuid(name, pkg_version, 'dep', key) + comp = { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': meta['supplier']}, + 'name': meta['name'], + 'licenses': [{'license': {'id': meta['license']}}], + 'externalReferences': [{'type': 'vcs', 'url': meta['download']}], + } + if version: + comp['version'] = version + comp['purl'] = meta['purl'](version) + else: + print(f"WARNING: version unknown for {meta['name']}; " + "omitting version and purl", file=sys.stderr) + return bom_ref, comp + + +def spdx_dep_package(key): + """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, license_text, lib_hash, + timestamp, year, serial, enabled_deps, build_props): + bom_ref = derived_uuid(name, version, 'package') + + dep_bom_refs = [] + components = [] + for key in enabled_deps: + ref, comp = cdx_dep_component(name, version, 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': cdx_license_block(license_id, license_text), + 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', + 'purl': f'pkg: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, license_text, lib_hash, + timestamp, year, doc_ns_uuid, enabled_deps, build_props): + 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', + }) + + doc = { + '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': { + 'creators': [ + f'Organization: {supplier}', + 'Tool: wolfssl-sbom-gen-1.0' + ], + 'created': timestamp, + }, + 'packages': packages, + 'relationships': relationships, + } + + extracted = build_extracted_licensing_infos(license_id, license_text) + if extracted: + doc['hasExtractedLicensingInfos'] = extracted + + return doc + + +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 the wolfSSL library artifact ' + '(shared or static) for SHA-256 hashing') + parser.add_argument('--license-file', required=True, + help='Path to LICENSING file for SPDX ID detection') + parser.add_argument('--license-override', default='', + help='Override the detected SPDX license expression ' + '(e.g. LicenseRef-wolfSSL-Commercial). Useful ' + 'for commercial licensees regenerating the SBOM ' + 'for their own product.') + parser.add_argument('--license-text', default='', + help='Path to a plain-text licence file whose ' + 'contents are embedded in the SBOM as the ' + '`extractedText` for any LicenseRef-* used in ' + '`--license-override`. Required by SPDX 2.3 ' + 'validators (e.g. pyspdxtools) for any custom ' + 'licence reference.') + parser.add_argument('--options-h', required=True, + help='Path to wolfssl/options.h for build config') + parser.add_argument('--dep-libz', default='no', + help='yes if built with --with-libz') + parser.add_argument('--dep-liboqs', default='no', + help='yes if built with --with-liboqs (the package ' + 'wolfSSL links against; --enable-falcon implies ' + 'this in any legal configuration)') + parser.add_argument('--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() + + enabled_deps = [ + key for key, flag in [ + ('libz', args.dep_libz), + ('liboqs', args.dep_liboqs), + ] + if flag.lower() == 'yes' + ] + + if args.license_override: + license_id = args.license_override + else: + license_id = detect_license(args.license_file) + if license_id is None: + print("WARNING: license could not be determined; using NOASSERTION", + file=sys.stderr) + license_id = 'NOASSERTION' + + license_text = load_license_text(args.license_text) + if extract_license_refs(license_id) and license_text is None: + sys.exit( + "ERROR: --license-override contains a LicenseRef-* identifier " + "but --license-text was not provided.\n" + " SPDX 2.3 requires the licence text to be embedded in " + "hasExtractedLicensingInfos for any LicenseRef-* used in " + "licenseConcluded/licenseDeclared.\n" + " Re-run with --license-text PATH (or " + "`make sbom SBOM_LICENSE_TEXT=PATH`)." + ) + + build_props = parse_options_h(args.options_h) + lib_hash = sha256_file(args.lib) + dt, timestamp = build_timestamp() + year = dt.year + serial = derived_uuid(args.name, args.version, 'serial') + doc_ns_uuid = derived_uuid(args.name, args.version, 'document') + + cdx = generate_cdx( + args.name, args.version, args.supplier, + license_id, license_text, lib_hash, timestamp, year, serial, + enabled_deps, build_props, + ) + spdx = generate_spdx( + args.name, args.version, args.supplier, + license_id, license_text, lib_hash, timestamp, year, 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() diff --git a/scripts/include.am b/scripts/include.am index f7a0bb37c8..4f7d9df219 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -155,3 +155,12 @@ EXTRA_DIST += scripts/bench/bench_functions.sh EXTRA_DIST += scripts/benchmark_compare.sh EXTRA_DIST += scripts/user_settings_asm.sh + +# SBOM generator (invoked from `make sbom` in the top-level Makefile.am). +# Must be in the dist tarball, otherwise `make dist && cd && +# ./configure && make sbom` fails for downstream consumers. +EXTRA_DIST += scripts/gen-sbom + +# SBOM generator unit tests. Shipped so downstream consumers building +# from a release tarball can re-run the regression suite. +EXTRA_DIST += scripts/test_gen_sbom.py diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py new file mode 100644 index 0000000000..8de58a9169 --- /dev/null +++ b/scripts/test_gen_sbom.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Unit tests for the helpers in scripts/gen-sbom. + +Run from the repo root: + + python3 -m unittest scripts/test_gen_sbom.py + +These tests cover the pure logic in gen-sbom (license expression handling, +deterministic UUID derivation, SOURCE_DATE_EPOCH timestamp parsing). They +intentionally avoid touching the filesystem-heavy paths (sha256_file, +parse_options_h, pkg-config) which are exercised end-to-end by the +integration tests in .github/workflows/sbom.yml. +""" + +import importlib.util +import os +import pathlib +import tempfile +import unittest +import uuid +from datetime import datetime, timedelta, timezone +from importlib.machinery import SourceFileLoader + + +def _load_gen_sbom(): + """Load gen-sbom (no .py extension) as a module under the name 'gs'. + spec_from_file_location infers the loader from the suffix; gen-sbom has + none, so we hand it a SourceFileLoader explicitly.""" + here = pathlib.Path(__file__).resolve().parent + target = here / 'gen-sbom' + if not target.is_file(): + raise FileNotFoundError( + f"expected gen-sbom alongside this test file at {target}" + ) + loader = SourceFileLoader('gs', str(target)) + spec = importlib.util.spec_from_loader('gs', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +gs = _load_gen_sbom() + + +class TestIsSimpleSpdxId(unittest.TestCase): + def test_listed_ids_are_simple(self): + for spdx in ('Apache-2.0', 'MIT', 'GPL-3.0-or-later', + 'GPL-2.0-only', 'BSD-3-Clause', 'CC0-1.0', 'Zlib'): + self.assertTrue(gs.is_simple_spdx_id(spdx), + f"{spdx!r} should be simple") + + def test_license_refs_are_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('LicenseRef-wolfSSL-Commercial')) + self.assertFalse(gs.is_simple_spdx_id('LicenseRef-Foo')) + + def test_compound_expressions_are_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('GPL-3.0-only OR MIT')) + self.assertFalse(gs.is_simple_spdx_id( + 'Apache-2.0 AND LicenseRef-Foo')) + self.assertFalse(gs.is_simple_spdx_id('(MIT OR Apache-2.0)')) + + def test_noassertion_is_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('NOASSERTION')) + + +class TestExtractLicenseRefs(unittest.TestCase): + def test_no_refs(self): + self.assertEqual(gs.extract_license_refs('Apache-2.0'), []) + self.assertEqual(gs.extract_license_refs('GPL-3.0-only OR MIT'), []) + self.assertEqual(gs.extract_license_refs(''), []) + self.assertEqual(gs.extract_license_refs(None), []) + + def test_single_ref(self): + self.assertEqual( + gs.extract_license_refs('LicenseRef-X'), ['LicenseRef-X']) + self.assertEqual( + gs.extract_license_refs('LicenseRef-wolfSSL-Commercial'), + ['LicenseRef-wolfSSL-Commercial']) + + def test_multiple_refs_are_sorted_and_deduped(self): + self.assertEqual( + gs.extract_license_refs( + 'Apache-2.0 OR LicenseRef-B AND LicenseRef-A'), + ['LicenseRef-A', 'LicenseRef-B']) + self.assertEqual( + gs.extract_license_refs( + 'LicenseRef-X OR LicenseRef-X AND LicenseRef-X'), + ['LicenseRef-X']) + + +class TestCdxLicenseBlock(unittest.TestCase): + def test_listed_id_uses_id_form(self): + self.assertEqual( + gs.cdx_license_block('Apache-2.0', None), + [{'license': {'id': 'Apache-2.0'}}]) + self.assertEqual( + gs.cdx_license_block('GPL-3.0-or-later', None), + [{'license': {'id': 'GPL-3.0-or-later'}}]) + + def test_single_ref_with_text_uses_name_and_text(self): + block = gs.cdx_license_block('LicenseRef-Foo', 'BODY') + self.assertEqual(len(block), 1) + lic = block[0]['license'] + self.assertEqual(lic['name'], 'LicenseRef-Foo') + self.assertEqual(lic['text']['content'], 'BODY') + self.assertEqual(lic['text']['contentType'], 'text/plain') + self.assertNotIn('id', lic) + + def test_single_ref_without_text_omits_text_field(self): + block = gs.cdx_license_block('LicenseRef-Foo', None) + lic = block[0]['license'] + self.assertEqual(lic['name'], 'LicenseRef-Foo') + self.assertNotIn('text', lic) + + def test_compound_uses_expression(self): + # Per CDX 1.6 schema, compound SPDX expressions go into `expression`. + # We must NOT use `id` (only listed IDs allowed) nor `name` (single + # licence only). + self.assertEqual( + gs.cdx_license_block('GPL-3.0-only OR LicenseRef-Foo', 'X'), + [{'expression': 'GPL-3.0-only OR LicenseRef-Foo'}]) + self.assertEqual( + gs.cdx_license_block('GPL-3.0-only AND MIT', None), + [{'expression': 'GPL-3.0-only AND MIT'}]) + + def test_noassertion_uses_name_not_expression(self): + # NOASSERTION is a reserved SPDX literal, not a parseable SPDX + # expression - shoving it into `expression` makes some CDX + # validators choke when they try to parse it. + self.assertEqual( + gs.cdx_license_block('NOASSERTION', None), + [{'license': {'name': 'NOASSERTION'}}]) + self.assertEqual( + gs.cdx_license_block('NOASSERTION', 'ignored'), + [{'license': {'name': 'NOASSERTION'}}]) + + +class TestBuildExtractedLicensingInfos(unittest.TestCase): + def test_no_refs_returns_none(self): + self.assertIsNone( + gs.build_extracted_licensing_infos('Apache-2.0', None)) + self.assertIsNone( + gs.build_extracted_licensing_infos('GPL-3.0-only AND MIT', None)) + + def test_single_ref_with_text(self): + infos = gs.build_extracted_licensing_infos( + 'LicenseRef-wolfSSL-Commercial', 'BODY') + self.assertEqual(len(infos), 1) + self.assertEqual(infos[0]['licenseId'], + 'LicenseRef-wolfSSL-Commercial') + self.assertEqual(infos[0]['extractedText'], 'BODY') + self.assertIn('name', infos[0]) + + def test_placeholder_when_text_missing(self): + infos = gs.build_extracted_licensing_infos('LicenseRef-X', None) + self.assertEqual(len(infos), 1) + # Placeholder must mention how to fix it so reviewers/auditors who + # inspect the SBOM know what's wrong. + text = infos[0]['extractedText'] + self.assertIn('--license-text', text) + + def test_multiple_refs_each_get_entry(self): + infos = gs.build_extracted_licensing_infos( + 'LicenseRef-A OR LicenseRef-B', 'BODY') + self.assertEqual( + sorted(i['licenseId'] for i in infos), + ['LicenseRef-A', 'LicenseRef-B']) + for i in infos: + self.assertEqual(i['extractedText'], 'BODY') + + +class TestDerivedUuid(unittest.TestCase): + def test_deterministic(self): + a = gs.derived_uuid('wolfssl', '5.9.1', 'package') + b = gs.derived_uuid('wolfssl', '5.9.1', 'package') + self.assertEqual(a, b) + + def test_different_inputs_diverge(self): + self.assertNotEqual( + gs.derived_uuid('wolfssl', '5.9.1', 'package'), + gs.derived_uuid('wolfssl', '5.9.2', 'package')) + self.assertNotEqual( + gs.derived_uuid('wolfssl', '5.9.1', 'package'), + gs.derived_uuid('wolfssl', '5.9.1', 'serial')) + + def test_returns_valid_uuid_string(self): + s = gs.derived_uuid('a', 'b') + # Will raise if not a valid UUID. + parsed = uuid.UUID(s) + self.assertEqual(str(parsed), s) + + def test_separator_does_not_alias_inputs(self): + # If the helper joined parts on a printable character (e.g. '/'), + # then ('a/b', 'c') would collide with ('a', 'b/c'). NUL is not + # representable in any of the call-site inputs, so the join must + # be unambiguous. Regression guard for that contract. + self.assertNotEqual( + gs.derived_uuid('a/b', 'c'), + gs.derived_uuid('a', 'b/c')) + self.assertNotEqual( + gs.derived_uuid('a-b', 'c'), + gs.derived_uuid('a', 'b-c')) + + +class TestBuildTimestamp(unittest.TestCase): + def setUp(self): + self._saved = os.environ.get('SOURCE_DATE_EPOCH') + + def tearDown(self): + if self._saved is None: + os.environ.pop('SOURCE_DATE_EPOCH', None) + else: + os.environ['SOURCE_DATE_EPOCH'] = self._saved + + def test_honors_source_date_epoch(self): + os.environ['SOURCE_DATE_EPOCH'] = '1700000000' + dt, ts = gs.build_timestamp() + self.assertEqual(dt.year, 2023) + self.assertEqual(ts, '2023-11-14T22:13:20Z') + + def test_two_calls_with_same_sde_match(self): + os.environ['SOURCE_DATE_EPOCH'] = '1700000000' + _, t1 = gs.build_timestamp() + _, t2 = gs.build_timestamp() + self.assertEqual(t1, t2) + + def test_invalid_sde_falls_back_to_now(self): + os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' + dt, ts = gs.build_timestamp() + # Shape check. + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + # Freshness check: regression guard against a future change that + # accidentally hard-codes the fallback (e.g. epoch zero). Five + # seconds is generous for a unit test on slow runners. + self.assertLess( + abs(dt - datetime.now(tz=timezone.utc)), + timedelta(seconds=5)) + + def test_no_sde_is_current_utc(self): + os.environ.pop('SOURCE_DATE_EPOCH', None) + dt, ts = gs.build_timestamp() + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + self.assertLess( + abs(dt - datetime.now(tz=timezone.utc)), + timedelta(seconds=5)) + + +class TestLoadLicenseText(unittest.TestCase): + def test_empty_path_returns_none(self): + self.assertIsNone(gs.load_license_text('')) + self.assertIsNone(gs.load_license_text(None)) + + def test_real_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write('LICENCE BODY\n') + path = f.name + try: + self.assertEqual(gs.load_license_text(path), 'LICENCE BODY\n') + finally: + os.unlink(path) + + def test_missing_file_exits(self): + with self.assertRaises(SystemExit): + gs.load_license_text('/no/such/path/please.txt') + + +class TestSha256File(unittest.TestCase): + def test_real_file_hashes_to_known_value(self): + # Empty file's SHA-256 is well-known; sanity-checks the chunked + # read path produces the same digest as a one-shot hash. + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + path = f.name + try: + empty_sha256 = ('e3b0c44298fc1c149afbf4c8996fb924' + '27ae41e4649b934ca495991b7852b855') + self.assertEqual(gs.sha256_file(path), empty_sha256) + finally: + os.unlink(path) + + def test_missing_file_exits_cleanly(self): + # Regression guard: gen-sbom must surface a missing --lib path as + # a clean non-zero exit, not an unhandled OSError, so `make sbom` + # fails fast with a useful message instead of a Python traceback. + with self.assertRaises(SystemExit): + gs.sha256_file('/no/such/library/please.so') + + +class TestParseOptionsH(unittest.TestCase): + def _parse(self, body): + with tempfile.NamedTemporaryFile('w', suffix='.h', + delete=False) as f: + f.write(body) + path = f.name + try: + return gs.parse_options_h(path) + finally: + os.unlink(path) + + def test_parses_defines_sorted_and_deduped(self): + pairs = self._parse( + "/* fake options.h */\n" + "#define HAVE_BAR\n" + "#define HAVE_AAA 1\n" + "#define HAVE_FOO 42\n" + ) + names = [k for k, _ in pairs] + self.assertEqual(names, sorted(set(names))) + self.assertEqual(dict(pairs)['HAVE_AAA'], '1') + self.assertEqual(dict(pairs)['HAVE_FOO'], '42') + self.assertEqual(dict(pairs)['HAVE_BAR'], '') + + def test_strips_trailing_block_comment(self): + # Regression: an earlier version captured the comment text into + # the value, polluting the SBOM build properties. + pairs = dict(self._parse("#define HAVE_FOO 42 /* always */\n")) + self.assertEqual(pairs['HAVE_FOO'], '42') + + def test_strips_trailing_line_comment(self): + pairs = dict(self._parse("#define HAVE_FOO 42 // always\n")) + self.assertEqual(pairs['HAVE_FOO'], '42') + + def test_strips_comment_from_valueless_define(self): + pairs = dict(self._parse("#define HAVE_BAR /* set elsewhere */\n")) + self.assertEqual(pairs['HAVE_BAR'], '') + + def test_dedup_keeps_last_assignment(self): + # Last assignment wins (matches C preprocessor semantics for + # duplicate #defines after redefinition). + pairs = dict(self._parse( + "#define HAVE_X 1\n" + "#define HAVE_X 2\n" + )) + self.assertEqual(pairs['HAVE_X'], '2') + + +class TestDepMetaShape(unittest.TestCase): + """Lock down the dep-tracking surface so renames/removals don't + silently regress vulnerability-scanner identifiers in the SBOM. + + These guard against: + * an external dep being added without a CVE-resolvable identifier + * a future PR re-introducing the `falcon`/`libxmss`/`liblms` + keys after they were intentionally removed.""" + + def test_only_libz_and_liboqs_are_tracked(self): + self.assertEqual(set(gs.DEP_META.keys()), {'libz', 'liboqs'}) + + def test_liboqs_entry_describes_the_linked_artefact(self): + liboqs = gs.DEP_META['liboqs'] + self.assertEqual(liboqs['name'], 'liboqs') + self.assertEqual(liboqs['supplier'], 'Open Quantum Safe') + self.assertEqual(liboqs['pkgconfig'], 'liboqs') + self.assertEqual( + liboqs['purl']('0.10.0'), + 'pkg:github/open-quantum-safe/liboqs@0.10.0') + + def test_no_stale_dep_keys(self): + # `falcon` is an algorithm, not a linked package; it must not + # appear as a dep entry (algorithm enablement lives in + # build_props parsed from options.h). `libxmss` and `liblms` + # were removed upstream; their re-appearance here would + # silently emit unresolvable identifiers in the SBOM. + for stale in ('falcon', 'libxmss', 'liblms', 'xmss', 'lms'): + self.assertNotIn(stale, gs.DEP_META) + + +class TestEnabledDepsCli(unittest.TestCase): + """End-to-end test of the argparse plumbing for --dep-* flags. + + Runs gen-sbom in a child process so we exercise the real argparse + config rather than a re-imported module.""" + + def _run(self, *argv): + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = here / 'gen-sbom' + return subprocess.run( + ['python3', str(script), *argv], + capture_output=True, text=True + ) + + def test_dep_liboqs_is_accepted(self): + result = self._run('--help') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--dep-liboqs', result.stdout) + self.assertIn('--dep-libz', result.stdout) + + def test_removed_flags_are_rejected(self): + # Each of these was either renamed (--dep-falcon -> --dep-liboqs) + # or removed entirely (--dep-libxmss/--dep-liblms with upstream + # removal of the libraries). argparse should reject them as + # unrecognised, not silently accept them. We pass the full set + # of required args (against /dev/null sentinels) so argparse + # progresses to the unknown-flag check; we never want + # gen-sbom to actually generate anything in this test. + required = [ + '--name', 'wolfssl', + '--version', '0.0.0-test', + '--lib', '/dev/null', + '--license-file', '/dev/null', + '--options-h', '/dev/null', + '--cdx-out', '/dev/null', + '--spdx-out', '/dev/null', + ] + for stale_flag in ('--dep-falcon', '--dep-libxmss', '--dep-liblms', + '--dep-libxmss-root', '--dep-liblms-root', + '--git'): + result = self._run(*required, stale_flag, 'no') + self.assertNotEqual(result.returncode, 0, + f"{stale_flag!r} unexpectedly accepted") + self.assertIn('unrecognized arguments', result.stderr, + f"{stale_flag!r}: {result.stderr!r}") + + +if __name__ == '__main__': + unittest.main(verbosity=2)