From 07d08d5995108385825d40d20705f404ba85ffcd Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Tue, 11 Aug 2026 12:46:31 -0700 Subject: [PATCH] feat: add SBOM generation for autotools and cmake Add `sbom`, `install-sbom` and `uninstall-sbom` targets to both build systems, producing CycloneDX and SPDX output for CRA compliance. The recipe is the canonical scripts/sbom.am fragment from wolfSSL (wolfSSL/wolfssl#10343), vendored byte-identical; Makefile.am only declares the wolfMQTT-specific inputs. Feature macros come from the generated wolfmqtt/options.h via SBOM_OPTIONS_H rather than config.h, since wolfMQTT uses no AC_DEFINE. Both paths stage an install to hash the real installed library, record wolfSSL as a dependency component, validate the SPDX with pyspdxtools, and are exercised in CI. Requires a wolfssl source tree via WOLFSSL_DIR for scripts/gen-sbom, plus python3 and spdx-tools on the build host. --- .github/workflows/sbom.yml | 267 +++++++++++++++++++++++++++++++++++++ .gitignore | 5 + CMakeLists.txt | 175 ++++++++++++++++++++++++ Makefile.am | 24 ++++ README.md | 45 +++++++ configure.ac | 10 ++ scripts/sbom.am | 229 +++++++++++++++++++++++++++++++ 7 files changed, 755 insertions(+) create mode 100644 .github/workflows/sbom.yml create mode 100644 scripts/sbom.am diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 000000000..d704a3720 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,267 @@ +name: SBOM Test + +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '**' ] + workflow_dispatch: + inputs: + wolfssl_ref: + description: 'wolfssl git ref that provides scripts/gen-sbom' + default: 'master' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# This workflow only reads the repo and uploads artefacts; no API writes. +permissions: + contents: read + +jobs: + sbom: + name: wolfMQTT SBOM generation (linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout wolfmqtt + uses: actions/checkout@v4 + with: + path: wolfmqtt + + # wolfMQTT links wolfSSL for TLS, so its SBOM records wolfSSL as a + # dependency. wolfSSL is built + installed here so wolfMQTT has a library + # to link, and the same source tree (scripts/gen-sbom + wolfssl/version.h) + # is passed to `make sbom` via WOLFSSL_DIR -- so the recorded wolfSSL + # dependency version matches the linked one. scripts/gen-sbom lives on + # wolfssl master (wolfSSL/wolfssl#10343); the wolfssl_ref input overrides + # it for testing against a different ref. + - name: Checkout wolfssl (gen-sbom + library source) + uses: actions/checkout@v4 + with: + repository: wolfSSL/wolfssl + ref: ${{ github.event.inputs.wolfssl_ref || 'master' }} + path: wolfssl + + - name: Install build tooling and SBOM validator (pyspdxtools) + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf automake libtool \ + pkg-config + python3 -m pip install --user 'spdx-tools==0.8.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Build and install wolfssl + working-directory: wolfssl + run: | + autoreconf -ivf + ./configure --enable-all \ + --prefix="$GITHUB_WORKSPACE/wolfssl-install" + make -j"$(nproc)" + make install + + # gen-sbom lives in wolfssl and may not be on the checked-out ref yet (the + # wolfSSL SBOM change can land separately). Gate on its presence and on + # --dep-wolfssl so this workflow is safe against a gen-sbom that predates + # it. + - name: Detect gen-sbom availability and capabilities + id: gate + run: | + GS="$GITHUB_WORKSPACE/wolfssl/scripts/gen-sbom" + if [ ! -f "$GS" ]; then + echo "have=no" >> "$GITHUB_OUTPUT" + echo "::notice::wolfssl scripts/gen-sbom not present on this ref; skipping SBOM generation." + exit 0 + fi + echo "have=yes" >> "$GITHUB_OUTPUT" + if python3 "$GS" --help 2>/dev/null | grep -q -- '--dep-wolfssl'; then + echo "dep_wolfssl=yes" >> "$GITHUB_OUTPUT" + else + echo "dep_wolfssl=no" >> "$GITHUB_OUTPUT" + echo "::notice::gen-sbom on this ref has no --dep-wolfssl; the wolfssl dependency + name-derived identity assertions will be skipped." + fi + + - name: Configure and build wolfmqtt + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: | + autoreconf -ivf + ./configure --enable-tls \ + --with-libwolfssl-prefix="$GITHUB_WORKSPACE/wolfssl-install" + make -j"$(nproc)" + + - name: Generate SBOM + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: make sbom WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + + - name: Outputs exist and SPDX validates + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: | + ls wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json wolfmqtt-*.spdx + pyspdxtools --infile wolfmqtt-*.spdx.json + + - name: CycloneDX identity, licence, and captured options + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: | + python3 - <<'PY' + import glob, json + cdx = json.load(open(glob.glob('wolfmqtt-*.cdx.json')[0])) + assert cdx['bomFormat'] == 'CycloneDX', cdx.get('bomFormat') + assert cdx['specVersion'] == '1.6', cdx.get('specVersion') + m = cdx['metadata']['component'] + assert m['name'] == 'wolfmqtt', m['name'] + assert m['purl'].startswith('pkg:github/wolfSSL/wolfmqtt@'), m['purl'] + # Default override must land as GPL-3.0-or-later (matches source headers). + ids = [l.get('license', {}).get('id') for l in m.get('licenses', [])] + assert 'GPL-3.0-or-later' in ids, ids + # Identity is the hashed library artifact. + assert {h['alg'] for h in m.get('hashes', [])}, 'no component hash' + # SBOM_OPTIONS_H must have been parsed: wolfMQTT stores its feature + # macros in wolfmqtt/options.h (no config.h AC_DEFINE), so the SBOM's + # build properties must be populated. + props = [p for p in m.get('properties', []) + if p.get('name', '').startswith('wolfssl:build:')] + assert props, 'no wolfssl:build:* properties captured from options.h' + print('CDX ok:', m['name'], m['purl'], ids, f'{len(props)} build props') + PY + + - name: Reproducible across two runs (SOURCE_DATE_EPOCH) + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: | + rm -f wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json wolfmqtt-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom \ + WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + sha256sum wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json > /tmp/a.sums + rm -f wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json wolfmqtt-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom \ + WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + sha256sum wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json > /tmp/b.sums + diff /tmp/a.sums /tmp/b.sums + + - name: wolfssl recorded as a dependency + if: steps.gate.outputs.have == 'yes' && steps.gate.outputs.dep_wolfssl == 'yes' + working-directory: wolfmqtt + run: | + python3 - <<'PY' + import glob, json + d = json.load(open(glob.glob('wolfmqtt-*.spdx.json')[0])) + assert 'wolfssl' in {p['name'] for p in d['packages']}, \ + [p['name'] for p in d['packages']] + rels = [(r['spdxElementId'], r['relationshipType'], + r['relatedSpdxElement']) for r in d['relationships']] + assert ('SPDXRef-Package-wolfmqtt', 'DEPENDS_ON', + 'SPDXRef-Package-wolfssl') in rels, rels + print('wolfssl dependency ok') + PY + + - name: Upload SBOM artefacts + if: always() && steps.gate.outputs.have == 'yes' + uses: actions/upload-artifact@v4 + with: + name: wolfmqtt-sbom-${{ github.sha }} + path: | + wolfmqtt/wolfmqtt-*.cdx.json + wolfmqtt/wolfmqtt-*.spdx.json + wolfmqtt/wolfmqtt-*.spdx + if-no-files-found: warn + retention-days: 90 + + # The CMake `sbom` custom target is a separate implementation from the + # autotools recipe above; build it too so a regression in either path + # fails CI. The reproducibility and CycloneDX assertions live in the + # autotools job; the wolfssl dependency is asserted in both. + sbom-cmake: + name: wolfMQTT SBOM generation (cmake) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout wolfmqtt + uses: actions/checkout@v4 + with: + path: wolfmqtt + + - name: Checkout wolfssl (gen-sbom + library source) + uses: actions/checkout@v4 + with: + repository: wolfSSL/wolfssl + ref: ${{ github.event.inputs.wolfssl_ref || 'master' }} + path: wolfssl + + - name: Install build tooling and SBOM validator (pyspdxtools) + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf automake libtool \ + pkg-config cmake + python3 -m pip install --user 'spdx-tools==0.8.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Build and install wolfssl + working-directory: wolfssl + run: | + autoreconf -ivf + ./configure --enable-all \ + --prefix="$GITHUB_WORKSPACE/wolfssl-install" + make -j"$(nproc)" + make install + + - name: Detect gen-sbom availability + id: gate + run: | + GS="$GITHUB_WORKSPACE/wolfssl/scripts/gen-sbom" + if [ ! -f "$GS" ]; then + echo "have=no" >> "$GITHUB_OUTPUT" + echo "::notice::wolfssl scripts/gen-sbom not present on this ref; skipping CMake SBOM job." + exit 0 + fi + echo "have=yes" >> "$GITHUB_OUTPUT" + if python3 "$GS" --help 2>/dev/null | grep -q -- '--dep-wolfssl'; then + echo "dep_wolfssl=yes" >> "$GITHUB_OUTPUT" + else + echo "dep_wolfssl=no" >> "$GITHUB_OUTPUT" + echo "::notice::gen-sbom on this ref has no --dep-wolfssl; the wolfssl dependency assertion will be skipped." + fi + + - name: Configure and build wolfmqtt (cmake) + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: | + cmake -B build \ + -DWITH_WOLFSSL="$GITHUB_WORKSPACE/wolfssl-install" \ + -DWOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + cmake --build build -j"$(nproc)" + + - name: Generate SBOM via CMake target + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt + run: cmake --build build --target sbom + + - name: Outputs exist and SPDX validates + if: steps.gate.outputs.have == 'yes' + working-directory: wolfmqtt/build + run: | + ls wolfmqtt-*.cdx.json wolfmqtt-*.spdx.json wolfmqtt-*.spdx + pyspdxtools --infile wolfmqtt-*.spdx.json + + # The CMake target passes --dep-wolfssl independently of scripts/sbom.am, + # so assert it here too; file existence alone would pass on an SBOM that + # silently dropped the dependency component. + - name: wolfssl recorded as a dependency (cmake) + if: steps.gate.outputs.have == 'yes' && steps.gate.outputs.dep_wolfssl == 'yes' + working-directory: wolfmqtt/build + run: | + python3 - <<'PY' + import glob, json + d = json.load(open(glob.glob('wolfmqtt-*.spdx.json')[0])) + assert 'wolfssl' in {p['name'] for p in d['packages']}, \ + [p['name'] for p in d['packages']] + rels = [(r['spdxElementId'], r['relationshipType'], + r['relatedSpdxElement']) for r in d['relationships']] + assert ('SPDXRef-Package-wolfmqtt', 'DEPENDS_ON', + 'SPDXRef-Package-wolfssl') in rels, rels + print('cmake wolfssl dependency ok') + PY diff --git a/.gitignore b/.gitignore index 78c66be31..ac794ed1a 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,8 @@ tests/test_broker_connect tests/test_mqtt_sn tests/test_mqtt_sn_client tests/unit_tests + +# SBOM output (make sbom) +wolfmqtt-*.cdx.json +wolfmqtt-*.spdx +wolfmqtt-*.spdx.json diff --git a/CMakeLists.txt b/CMakeLists.txt index dd3155511..63328b678 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -418,3 +418,178 @@ message("\tMultithread: ${ENABLE_MULTITHREAD}") message("\tCurl: ${ENABLE_CURL}") message("\tBroker: ${WOLFMQTT_BROKER}") message("-----------------------------------------------") + +# ── SBOM generation target ──────────────────────────────────────────────────── +# +# Usage: +# cmake -B build -DWOLFSSL_DIR=/path/to/wolfssl/source . +# cmake --build build +# cmake --build build --target sbom +# +# WOLFSSL_DIR must point to a wolfssl source tree containing scripts/gen-sbom +# (feat/sbom-embedded branch). wolfSSL is NOT required to be installed for +# SBOM generation — only the source tree is needed to locate gen-sbom. +# +# Outputs in build directory: +# wolfmqtt-.cdx.json +# wolfmqtt-.spdx.json +# wolfmqtt-.spdx +# +# This mirrors the autotools `make sbom` target: same package metadata, licence, +# option fingerprint and wolfSSL dependency component. The hashed artifact is +# whatever that build system actually installs, so the recorded file name and +# checksum legitimately differ between the two (autotools installs the +# SONAME-versioned library, cmake an unversioned one). + +# Location of the wolfssl source tree that ships scripts/gen-sbom. A cache PATH +# (not a plain var) so it can be set on the command line with -DWOLFSSL_DIR=... +# and persists across reconfigures. +set(WOLFSSL_DIR "" CACHE PATH + "Path to wolfssl source tree containing scripts/gen-sbom (for the sbom target)") + +# Derive the SBOM version from wolfmqtt/version.h, NOT from PROJECT_VERSION. +# version.h is the single source of truth shared with the autotools build +# (PACKAGE_VERSION is generated from it); project(VERSION ...) is hand-maintained +# and can drift, which would make the cmake SBOM disagree with the autotools one. +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/wolfmqtt/version.h" WOLFMQTT_VERSION_LINE + REGEX "^#define[ \t]+LIBWOLFMQTT_VERSION_STRING[ \t]+\"") +string(REGEX REPLACE "^.*\"([^\"]+)\".*$" "\\1" + WOLFMQTT_SBOM_VERSION "${WOLFMQTT_VERSION_LINE}") + +set(SBOM_CDX "wolfmqtt-${WOLFMQTT_SBOM_VERSION}.cdx.json") +set(SBOM_SPDX "wolfmqtt-${WOLFMQTT_SBOM_VERSION}.spdx.json") +set(SBOM_SPDX_TV "wolfmqtt-${WOLFMQTT_SBOM_VERSION}.spdx") + +# gen-sbom runs under python3; pyspdxtools validates the SPDX tag-value output. +# Both are required — fail at configure time so the user fixes their environment +# before building rather than hitting a confusing failure mid-`--target sbom`. +find_program(PYTHON3_EXECUTABLE python3) +find_program(PYSPDXTOOLS_EXECUTABLE pyspdxtools) + +# WOLFSSL_DIR (a wolfssl source tree with scripts/gen-sbom) is required only to +# actually build the sbom target. CI and default builds never set it, so checking +# it at configure time would abort every build. Mirror the autotools `make sbom` +# recipe: configure always succeeds; if WOLFSSL_DIR is unset, define a stub sbom +# target that fails clearly at BUILD time. An empty WOLFSSL_DIR is the common +# first-run mistake. +if(WOLFSSL_DIR STREQUAL "") + add_custom_target(sbom + COMMAND ${CMAKE_COMMAND} -E echo + "ERROR: WOLFSSL_DIR is not set. Cannot locate gen-sbom." + COMMAND ${CMAKE_COMMAND} -E echo + " Reconfigure with: cmake -B build -DWOLFSSL_DIR=/path/to/wolfssl ." + COMMAND ${CMAKE_COMMAND} -E false + COMMENT "SBOM target requires -DWOLFSSL_DIR") + return() +endif() + +# WOLFSSL_DIR is set: the operator opted into SBOM generation. Validate the rest +# of the environment now so they get immediate, actionable feedback. +if(NOT EXISTS "${WOLFSSL_DIR}/scripts/gen-sbom") + message(FATAL_ERROR + "gen-sbom not found at ${WOLFSSL_DIR}/scripts/gen-sbom.\n" + " WOLFSSL_DIR must point to a wolfssl source tree (feat/sbom-embedded).") +endif() +if(NOT PYTHON3_EXECUTABLE) + message(FATAL_ERROR "python3 not found in PATH. Cannot generate SBOM.") +endif() +if(NOT PYSPDXTOOLS_EXECUTABLE) + message(FATAL_ERROR + "pyspdxtools not found in PATH. Cannot validate SBOM.\n" + " Install with: pip install spdx-tools") +endif() + +# Staging dir for `cmake --install` so gen-sbom hashes the as-installed +# library without polluting the system or requiring root. +set(SBOM_STAGING "${CMAKE_BINARY_DIR}/_sbom_staging") + +# Subdirectory of the staging prefix that holds the installed library. The +# install(TARGETS) rule above sends LIBRARY/ARCHIVE to lib and RUNTIME to bin, +# and a shared build on the Windows-family toolchains (MSVC, MinGW, Cygwin) is +# the only case whose artifact - the DLL - is a RUNTIME output. +# CMAKE_IMPORT_LIBRARY_SUFFIX is set exactly on those platforms. The file name +# itself comes from $, so .so/.dylib/.dll/.a/.lib +# are all handled without hardcoding a suffix. +if(BUILD_SHARED_LIBS AND CMAKE_IMPORT_LIBRARY_SUFFIX) + set(SBOM_ARTIFACT_DIR "bin") +else() + set(SBOM_ARTIFACT_DIR "lib") +endif() + +# wolfMQTT links wolfSSL for TLS, so the SBOM records it as a dependency +# component -- the same thing SBOM_DEP_WOLFSSL = yes does in Makefile.am. +# gen-sbom only learned --dep-wolfssl in wolfSSL/wolfssl#10343, so probe for it +# the way scripts/sbom.am does: an older gen-sbom still emits a valid SBOM +# (just without the dependency) instead of dying on an unknown flag. +execute_process( + COMMAND ${PYTHON3_EXECUTABLE} ${WOLFSSL_DIR}/scripts/gen-sbom --help + OUTPUT_VARIABLE SBOM_GEN_HELP + ERROR_VARIABLE SBOM_GEN_HELP + RESULT_VARIABLE SBOM_GEN_HELP_RC) +set(SBOM_DEP_ARGS "") +if(SBOM_GEN_HELP_RC EQUAL 0 AND SBOM_GEN_HELP MATCHES "--dep-wolfssl") + list(APPEND SBOM_DEP_ARGS --dep-wolfssl yes) + # Record the version of the wolfSSL that WOLFSSL_DIR points at, matching the + # version scrape in scripts/sbom.am. + if(EXISTS "${WOLFSSL_DIR}/wolfssl/version.h") + file(STRINGS "${WOLFSSL_DIR}/wolfssl/version.h" WOLFSSL_VERSION_LINE + REGEX "^#define[ \t]+LIBWOLFSSL_VERSION_STRING[ \t]+\"") + string(REGEX REPLACE "^.*\"([^\"]+)\".*$" "\\1" + WOLFSSL_SBOM_VERSION "${WOLFSSL_VERSION_LINE}") + if(NOT WOLFSSL_SBOM_VERSION STREQUAL "") + list(APPEND SBOM_DEP_ARGS --dep-version wolfssl=${WOLFSSL_SBOM_VERSION}) + endif() + endif() +else() + message(STATUS + "gen-sbom has no --dep-wolfssl support, so the SBOM will not list " + "wolfssl as a dependency component. That support is added by " + "wolfSSL/wolfssl#10343. The generated SBOM is valid either way.") +endif() + +add_custom_target(sbom + # Install into the staging dir so the SBOM hashes the real installed + # artifact rather than a build-tree intermediate. + # --config is required for multi-config generators (Visual Studio, Ninja + # Multi-Config): without it the install script falls back to "Release" and + # cannot find a library built in any other configuration. It expands to an + # empty (harmless) argument under single-config generators. + COMMAND ${CMAKE_COMMAND} --install ${CMAKE_BINARY_DIR} + --config $ + --prefix ${SBOM_STAGING}/usr/local + + # The generated options.h is passed to gen-sbom directly, the same input + # the autotools `make sbom` feeds it, so both build systems record an + # identical compile-time option fingerprint. (No shell redirection here: + # add_custom_target COMMANDs do not run through a shell under VERBATIM.) + COMMAND ${PYTHON3_EXECUTABLE} ${WOLFSSL_DIR}/scripts/gen-sbom + --name wolfmqtt + --version ${WOLFMQTT_SBOM_VERSION} + --supplier "wolfSSL Inc." + --license-file ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE + # Match the autotools default (SBOM_LICENSE_OVERRIDE in + # scripts/sbom.am): source headers say GPLv3 or later, which + # LICENSE-file detection alone records as GPL-3.0-only. + --license-override GPL-3.0-or-later + ${SBOM_DEP_ARGS} + --options-h ${WOLFMQTT_OUTPUT_BASE}/wolfmqtt/options.h + --lib ${SBOM_STAGING}/usr/local/${SBOM_ARTIFACT_DIR}/$ + --cdx-out ${CMAKE_BINARY_DIR}/${SBOM_CDX} + --spdx-out ${CMAKE_BINARY_DIR}/${SBOM_SPDX} + + # Validate the SPDX output and emit the tag-value (.spdx) rendering. + COMMAND ${PYSPDXTOOLS_EXECUTABLE} + --infile ${CMAKE_BINARY_DIR}/${SBOM_SPDX} + --outfile ${CMAKE_BINARY_DIR}/${SBOM_SPDX_TV} + + # Remove the staging tree; keep only the three SBOM files. + # (-E remove_directory, not -E rm: the latter needs CMake >= 3.17 and + # this project's minimum is 3.16.) + COMMAND ${CMAKE_COMMAND} -E remove_directory ${SBOM_STAGING} + + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Generating SBOM for wolfMQTT ${WOLFMQTT_SBOM_VERSION}" + VERBATIM) + +# The library must exist before we install/hash it. +add_dependencies(sbom wolfmqtt) diff --git a/Makefile.am b/Makefile.am index dce54740d..07f251e53 100644 --- a/Makefile.am +++ b/Makefile.am @@ -74,6 +74,30 @@ test: check DISTCLEANFILES+= wolfmqtt-config +# SBOM generation (CRA compliance). The recipe is shared across the wolfSSL +# stack's autotools products in scripts/sbom.am; wolfMQTT just declares what it +# is (a libwolfmqtt library that links wolfSSL for TLS) and includes it. +# WOLFSSL_DIR must point to a wolfssl source tree containing scripts/gen-sbom. +SBOM_PKGNAME = wolfmqtt +SBOM_LICENSE_FILE = $(srcdir)/LICENSE +SBOM_DEP_WOLFSSL = yes + +# wolfMQTT records its feature macros in its own generated options header (it +# uses no AC_DEFINE / config.h defines), so point gen-sbom at that header rather +# than at the compiler-derived defaults. +SBOM_OPTIONS_H = $(abs_builddir)/wolfmqtt/options.h + +# wolfMQTT is GPLv3-or-later (per the per-file source headers: "either version 3 +# of the License, or (at your option) any later version") or commercial. Pin +# the header-accurate SPDX id here so the SBOM is correct regardless of the +# gen-sbom version's licence detection; commercial licensees can override it +# (e.g. LicenseRef-wolfSSL-Commercial). +SBOM_LICENSE_OVERRIDE ?= GPL-3.0-or-later + +EXTRA_DIST += scripts/sbom.am + +include scripts/sbom.am + clean-local: -rm -rf tests/fuzz/corpus @if test "$(abs_srcdir)" != "$(abs_builddir)"; then \ diff --git a/README.md b/README.md index 055ad649a..e33c3ec17 100644 --- a/README.md +++ b/README.md @@ -571,3 +571,48 @@ You can test the wolfMQTT client against public brokers supporting websockets: * HiveMQ secure websockets `./examples/websocket/websocket_client -h broker.hivemq.com -p8884 -t` + +## SBOM / EU CRA Compliance + +wolfMQTT generates a Software Bill of Materials (SBOM) in CycloneDX 1.6 and +SPDX 2.3 formats to support compliance with the EU Cyber Resilience Act (CRA). +The SBOM records the configured build options (from `wolfmqtt/options.h`), +hashes the built `libwolfmqtt` library artifact (shared or static; ELF, Mach-O, +or PE), and (with a sufficiently new `gen-sbom`) lists wolfSSL as a dependency +so vulnerability scanners can associate wolfSSL advisories with a TLS-enabled +wolfMQTT deployment. Output is reproducible: set `SOURCE_DATE_EPOCH` (or build +from a git checkout, which uses the last commit time) and repeated runs are +byte-identical. + +```sh +make sbom WOLFSSL_DIR=/path/to/wolfssl +``` + +Requires `python3` and `pyspdxtools` (`pip install spdx-tools`). `WOLFSSL_DIR` +must point to a wolfssl source tree containing `scripts/gen-sbom` (branch +`feat/sbom-embedded`, or `master` once wolfSSL/wolfssl#10343 merges). + +Output: `wolfmqtt-.cdx.json`, `wolfmqtt-.spdx.json`, `wolfmqtt-.spdx` + +Optional overrides: + +- `SBOM_LICENSE_OVERRIDE` - SPDX expression to use instead of the licence + parsed from `LICENSE` (e.g. `LicenseRef-wolfSSL-Commercial` for commercial + licensees). Defaults to `GPL-3.0-or-later` (the per-file header licence). +- `SBOM_LICENSE_TEXT` - path to the licence text for any `LicenseRef-*` used in + `SBOM_LICENSE_OVERRIDE` (required by SPDX 2.3). +- `SBOM_WOLFSSL_VERSION` - version recorded for the wolfSSL dependency; + auto-detected from `WOLFSSL_DIR/wolfssl/version.h` (or wolfSSL's `pkg-config` + entry) when unset. + +```sh +make install-sbom # installs to $(datadir)/doc/wolfmqtt/ +make uninstall-sbom +``` + +Note: recording wolfSSL as a dependency and emitting wolfMQTT-specific project +URLs require the `gen-sbom` from wolfSSL/wolfssl#10343. Against an older +`gen-sbom`, `make sbom` still succeeds and produces a valid SBOM, but omits the +wolfSSL dependency entry and inherits wolfSSL's project URLs. + +For further CRA guidance see [wolfssl/doc/CRA.md](https://github.com/wolfSSL/wolfssl/blob/master/doc/CRA.md). diff --git a/configure.ac b/configure.ac index 5eff2f53c..c58146439 100644 --- a/configure.ac +++ b/configure.ac @@ -571,6 +571,16 @@ AC_SUBST([AM_CPPFLAGS]) AC_SUBST([AM_CFLAGS]) AC_SUBST([AM_LDFLAGS]) +# Tools used by the SBOM targets (see scripts/sbom.am `make sbom`). GIT is used +# only to derive SOURCE_DATE_EPOCH for reproducible SBOM output; all three are +# optional and the target reports a clear error when a required one is missing. +AC_PATH_PROG([PYTHON3], [python3]) +AC_PATH_PROG([PYSPDXTOOLS], [pyspdxtools]) +AC_PATH_PROG([GIT], [git]) +AC_SUBST([PYTHON3]) +AC_SUBST([PYSPDXTOOLS]) +AC_SUBST([GIT]) + # FINAL AC_CONFIG_FILES([Makefile]) AC_CONFIG_FILES([wolfmqtt/version.h]) diff --git a/scripts/sbom.am b/scripts/sbom.am new file mode 100644 index 000000000..509735985 --- /dev/null +++ b/scripts/sbom.am @@ -0,0 +1,229 @@ +# scripts/sbom.am - shared Automake recipe for CRA-compliant SBOM generation. +# +# One generator (gen-sbom) does the work; each product just describes itself and +# includes this fragment. It is deliberately product-agnostic: a Makefile.am +# sets a few variables (below) and does `include scripts/sbom.am` to get the +# `sbom`, `install-sbom` and `uninstall-sbom` targets. +# +# This is the canonical copy (wolfSSL repository, scripts/sbom.am); product +# repositories vendor a copy of it and must be kept in sync with this file. +# gen-sbom is taken from a vendored scripts/gen-sbom if a product ships one +# (used automatically), otherwise from a wolfSSL source tree via WOLFSSL_DIR. +# Products such as wolfSSH use the WOLFSSL_DIR route; vendoring gen-sbom for +# fully offline tarball builds can be added later with no change here. +# +# --------------------------------------------------------------------------- +# The including Makefile.am MUST set, before `include scripts/sbom.am`: +# SBOM_PKGNAME Product name recorded in the SBOM (e.g. wolfssh). Drives +# the output filenames and gen-sbom --name. +# SBOM_LICENSE_FILE Path to the product's LICENSING file +# (e.g. $(srcdir)/LICENSING). +# +# Optional (defaults shown): +# SBOM_OPTIONS_H Path to a product-generated options header (e.g. +# wolfMQTT's $(builddir)/wolfmqtt/options.h) that records +# the enabled build macros. Set this for products whose +# feature flags are NOT in config.h (no AC_DEFINE); when +# unset the recipe derives the macros from the compiler + +# config.h. Default: unset. +# SBOM_ARTIFACT lib | bin - which build output to hash. Default: lib. +# SBOM_LIB_STEM Library basename w/o extension. Default: lib$(SBOM_PKGNAME). +# SBOM_BIN_NAME Program name when SBOM_ARTIFACT = bin. Default: $(SBOM_PKGNAME). +# SBOM_DEP_WOLFSSL yes | no - record wolfSSL as a dependency. Default: no. +# SBOM_DEP_OPENSSL yes | no - record OpenSSL as a dependency (wolfProvider / +# wolfEngine). Default: no. +# SBOM_LICENSE_OVERRIDE SPDX expression to record instead of the licence +# detected from SBOM_LICENSE_FILE. +# SBOM_LICENSE_TEXT Path to licence text for any LicenseRef-* used in +# SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). +# SBOM_WOLFSSL_VERSION Version recorded for the wolfSSL dependency; +# auto-detected from WOLFSSL_DIR/wolfssl/version.h when unset. +# SBOM_OPENSSL_VERSION Version recorded for the OpenSSL dependency; +# gen-sbom resolves it via pkg-config when unset. +# SBOM_CONFIG_H Path to the configure-generated config header to +# force-include when capturing the configured build +# macros. Products whose AC_CONFIG_HEADERS lives in a +# subdirectory MUST override this so config.h defines are +# captured (e.g. wolfEngine: $(abs_builddir)/include/config.h; +# wolfCLU: $(abs_builddir)/src/config.h). +# Default: $(abs_builddir)/config.h. +# +# The wolfSSL/OpenSSL dependency flags are feature-detected against gen-sbom +# --help, so a product wired for them still produces a valid SBOM (with a NOTE) +# against a gen-sbom that predates the flag. +# +# gen-sbom is located at $(srcdir)/scripts/gen-sbom if vendored, else at +# $(WOLFSSL_DIR)/scripts/gen-sbom. python3, pyspdxtools and git come from +# configure (AC_PATH_PROG); git is used only to derive SOURCE_DATE_EPOCH. +# +# NOTE: this fragment requires GNU make. It uses GNU conditional assignment +# (?=) and the GNU make functions $(wildcard), $(if), $(firstword) and +# $(addprefix); under a non-GNU make the SBOM targets will not work. +# --------------------------------------------------------------------------- + +SBOM_ARTIFACT ?= lib +SBOM_LIB_STEM ?= lib$(SBOM_PKGNAME) +SBOM_BIN_NAME ?= $(SBOM_PKGNAME) +SBOM_DEP_WOLFSSL ?= no +SBOM_DEP_OPENSSL ?= no +SBOM_CONFIG_H ?= $(abs_builddir)/config.h + +SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx +# Use Automake's $(docdir) so a user's --docdir override is honoured (this +# equals $(datadir)/doc/$(PACKAGE) by default). +sbomdir = $(docdir) + +# Prefer a vendored gen-sbom; fall back to an external wolfSSL source tree. +# The fallback is $(wildcard)-guarded and only consulted when WOLFSSL_DIR is +# set, so an unset WOLFSSL_DIR leaves SBOM_GEN empty (and the sbom recipe's +# `test -f` prints the "set WOLFSSL_DIR" error) rather than resolving to an +# absolute /scripts/gen-sbom that could run an unrelated host script. +SBOM_GEN = $(firstword $(wildcard $(srcdir)/scripts/gen-sbom) \ + $(if $(WOLFSSL_DIR),$(wildcard $(WOLFSSL_DIR)/scripts/gen-sbom))) + +# Library artifact search order (versioned first) covering ELF, Mach-O and PE. +# Windows import libs (.lib) come with and without the "lib" prefix. +SBOM_LIB_GLOBS = \ + $(SBOM_LIB_STEM).so.[0-9]* \ + $(SBOM_LIB_STEM).so \ + $(SBOM_LIB_STEM).[0-9]*.dylib \ + $(SBOM_LIB_STEM).dylib \ + $(SBOM_LIB_STEM).dll \ + $(SBOM_LIB_STEM).dll.a \ + $(SBOM_LIB_STEM).lib \ + $(SBOM_PKGNAME).lib \ + $(SBOM_LIB_STEM).a + +# Automake requires CLEANFILES to be initialised with `=` before `+=`; the +# including Makefile.am must declare `CLEANFILES =` (typically in its primaries +# init block) before `include scripts/sbom.am`. +CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) + +.PHONY: sbom install-sbom uninstall-sbom + +# Stage a `make install` into a private tree, discover the installed artifact +# (shared/static library or program; ELF/Mach-O/PE), hash it, capture the +# configured build macros (from SBOM_OPTIONS_H if set, else AM_CPPFLAGS/ +# AM_CFLAGS/CFLAGS + config.h; some products carry their feature -D flags in +# AM_CFLAGS rather than AM_CPPFLAGS, and some outside config.h entirely), +# generate SPDX+CDX, validate +# the SPDX, then convert to tag-value. The staging tree and temp defines file +# are removed unconditionally via `trap`, even on failure. SOURCE_DATE_EPOCH is +# honoured for reproducible output (defaults to the last git commit time). +sbom: + @test -n "$(PYTHON3)" || { \ + echo "ERROR: 'python3' not found in PATH. Cannot generate SBOM."; \ + exit 1; } + @test -n "$(PYSPDXTOOLS)" || { \ + echo "ERROR: 'pyspdxtools' not found (pip install spdx-tools)."; \ + exit 1; } + @test -f "$(SBOM_GEN)" || { \ + echo "ERROR: gen-sbom not found. Vendor scripts/gen-sbom, or re-run:"; \ + echo " make sbom WOLFSSL_DIR=/path/to/wolfssl"; \ + exit 1; } + @rm -rf $(abs_builddir)/_sbom_staging + @set -e; \ + _defines=`mktemp $(abs_builddir)/_sbom_defines.XXXXXX`; \ + trap 'rm -rf $(abs_builddir)/_sbom_staging "$$_defines"' EXIT INT TERM HUP; \ + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ + sbom_art=""; \ + if test "$(SBOM_ARTIFACT)" = bin; then \ + for art in \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)" \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)".exe; do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + else \ + for art in \ + $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(SBOM_LIB_GLOBS)) \ + $(addprefix "$(abs_builddir)/_sbom_staging$(bindir)"/,$(SBOM_LIB_STEM).dll $(SBOM_PKGNAME).dll); do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + fi; \ + if test -z "$$sbom_art"; then \ + echo ""; \ + echo "ERROR: no installed $(SBOM_PKGNAME) artifact found for SBOM."; \ + echo " (configure with --enable-shared or --enable-static)"; \ + echo ""; \ + exit 1; \ + fi; \ + echo "SBOM: hashing $$sbom_art"; \ + opts_h="$(SBOM_OPTIONS_H)"; \ + if test -z "$$opts_h"; then \ + opts_h="$$_defines"; \ + $(CC) -dM -E $(DEFAULT_INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) \ + $(AM_CFLAGS) $(CFLAGS) \ + $(if $(wildcard $(SBOM_CONFIG_H)),-include $(SBOM_CONFIG_H)) \ + -x c /dev/null > "$$_defines"; \ + fi; \ + if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ + $(GIT) -C "$(srcdir)" rev-parse --git-dir >/dev/null 2>&1; then \ + sde=`$(GIT) -C "$(srcdir)" log -1 --format=%ct 2>/dev/null`; \ + if test -n "$$sde"; then SOURCE_DATE_EPOCH="$$sde"; export SOURCE_DATE_EPOCH; fi; \ + fi; \ + dep_args=""; \ + if test "$(SBOM_DEP_WOLFSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-wolfssl'; then \ + dep_args="$$dep_args --dep-wolfssl yes"; \ + wv="$(SBOM_WOLFSSL_VERSION)"; \ + if test -z "$$wv" && test -f "$(WOLFSSL_DIR)/wolfssl/version.h"; then \ + wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$(WOLFSSL_DIR)/wolfssl/version.h"`; \ + fi; \ + if test -n "$$wv"; then \ + dep_args="$$dep_args --dep-version wolfssl=$$wv"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-wolfssl support, so the SBOM"; \ + echo " will not list wolfssl as a dependency component. That"; \ + echo " support is added by wolfSSL/wolfssl#10343; until it merges"; \ + echo " to wolfssl master, point WOLFSSL_DIR at that PR's branch"; \ + echo " to enable it. The generated SBOM is valid either way."; \ + fi; \ + fi; \ + if test "$(SBOM_DEP_OPENSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-openssl'; then \ + dep_args="$$dep_args --dep-openssl yes"; \ + if test -n "$(SBOM_OPENSSL_VERSION)"; then \ + dep_args="$$dep_args --dep-version openssl=$(SBOM_OPENSSL_VERSION)"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-openssl support; openssl will"; \ + echo " not be listed as a dependency component."; \ + fi; \ + fi; \ + $(PYTHON3) "$(SBOM_GEN)" \ + --name $(SBOM_PKGNAME) \ + --version $(PACKAGE_VERSION) \ + --supplier "wolfSSL Inc." \ + --license-file $(SBOM_LICENSE_FILE) \ + --options-h "$$opts_h" \ + --lib "$$sbom_art" \ + $$dep_args \ + $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ + $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ + --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) + +# SBOM install is intentionally opt-in (`make install-sbom`), so `make install` +# does NOT place SBOM files. uninstall-sbom is still chained into the standard +# `make uninstall` via uninstall-hook so a prior `make install-sbom` is cleaned +# up; it uses `rm -f`, so it is a harmless no-op when no SBOM was installed. +uninstall-hook: uninstall-sbom