diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 00000000..97e8f4c7 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,161 @@ +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' + # TODO: switch back to 'master' once wolfSSL/wolfssl#10343 merges. + default: 'refs/pull/10343/head' + +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: wolfCLU SBOM generation (linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout wolfclu + uses: actions/checkout@v4 + with: + path: wolfclu + + # wolfssl is built + installed so wolfCLU has a library to link, and its + # source tree (scripts/gen-sbom + wolfssl/version.h) is passed to + # `make sbom` via WOLFSSL_DIR. gen-sbom is not yet on wolfssl master, so + # default to the open PR head that carries it (wolfSSL/wolfssl#10343) so CI + # actually exercises `make sbom` instead of silently skipping. TODO: + # switch the fallback back to 'master' once #10343 merges. + - name: Checkout wolfssl (gen-sbom + library source) + uses: actions/checkout@v4 + with: + repository: wolfSSL/wolfssl + ref: ${{ github.event.inputs.wolfssl_ref || 'refs/pull/10343/head' }} + path: wolfssl + + - name: Install SBOM validator (pyspdxtools) + run: | + python3 -m pip install --user 'spdx-tools==0.8.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # --enable-all gives wolfCLU the OpenSSL-compat / cert-req APIs it links. + - 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 so this + # workflow is safe even if the ref is changed to one without 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 wolfclu + if: steps.gate.outputs.have == 'yes' + working-directory: wolfclu + run: | + autoreconf -ivf + ./configure --with-wolfssl="$GITHUB_WORKSPACE/wolfssl-install" + make -j"$(nproc)" + + - name: Generate SBOM + if: steps.gate.outputs.have == 'yes' + working-directory: wolfclu + run: make sbom WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + + - name: Outputs exist and SPDX validates + if: steps.gate.outputs.have == 'yes' + working-directory: wolfclu + run: | + ls wolfclu-*.cdx.json wolfclu-*.spdx.json wolfclu-*.spdx + pyspdxtools --infile wolfclu-*.spdx.json + + - name: CycloneDX identity and licence + if: steps.gate.outputs.have == 'yes' + working-directory: wolfclu + run: | + python3 - <<'PY' + import glob, json + cdx = json.load(open(glob.glob('wolfclu-*.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'] == 'wolfclu', m['name'] + assert m['purl'].startswith('pkg:github/wolfSSL/wolfclu@'), 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 + # Embedded identity is the hashed binary artifact. + assert {h['alg'] for h in m.get('hashes', [])}, 'no component hash' + print('CDX ok:', m['name'], m['purl'], ids) + PY + + - name: Reproducible across two runs (SOURCE_DATE_EPOCH) + if: steps.gate.outputs.have == 'yes' + working-directory: wolfclu + run: | + rm -f wolfclu-*.cdx.json wolfclu-*.spdx.json wolfclu-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom \ + WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + sha256sum wolfclu-*.cdx.json wolfclu-*.spdx.json > /tmp/a.sums + rm -f wolfclu-*.cdx.json wolfclu-*.spdx.json wolfclu-*.spdx + SOURCE_DATE_EPOCH=1700000000 make sbom \ + WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" + sha256sum wolfclu-*.cdx.json wolfclu-*.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: wolfclu + run: | + python3 - <<'PY' + import glob, json + d = json.load(open(glob.glob('wolfclu-*.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-wolfclu', '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: wolfclu-sbom-${{ github.sha }} + path: | + wolfclu/wolfclu-*.cdx.json + wolfclu/wolfclu-*.spdx.json + wolfclu/wolfclu-*.spdx + if-no-files-found: warn + retention-days: 90 diff --git a/Makefile.am b/Makefile.am index 6ef17b86..67e95741 100644 --- a/Makefile.am +++ b/Makefile.am @@ -150,7 +150,6 @@ if ENABLE_MANPAGES # -n keeps the output byte-reproducible (no embedded filename/mtime). MAN_GZ = $(man_MANS:.1=.1.gz) -.PHONY: manpages-gz manpages-gz: $(MAN_GZ) manpages/%.1.gz: $(srcdir)/manpages/%.1 @@ -163,6 +162,28 @@ endif test: check #DISTCLEANFILES+= wolfssl-config +# manpages-gz is only a target under ENABLE_MANPAGES, but declare it .PHONY +# unconditionally so it merges with the unconditional .PHONY in scripts/sbom.am +# (a conditional + unconditional .PHONY pair trips automake -Werror). +.PHONY: manpages-gz + +# SBOM generation (CRA compliance). The recipe is shared across the wolfSSL +# stack's autotools products in scripts/sbom.am; wolfCLU just declares what it +# is (a CLI program that links wolfSSL) and includes it. WOLFSSL_DIR must point +# to a wolfssl source tree containing scripts/gen-sbom. +SBOM_PKGNAME = wolfclu +SBOM_LICENSE_FILE = $(srcdir)/COPYING +SBOM_ARTIFACT = bin +SBOM_BIN_NAME = wolfssl +SBOM_DEP_WOLFSSL = yes + +# wolfCLU is GPLv3-or-later (see source headers) or commercial. Pin the SPDX id +# so the SBOM is correct regardless of the gen-sbom version's licence detection; +# commercial licensees can override (e.g. LicenseRef-wolfSSL-Commercial). +SBOM_LICENSE_OVERRIDE ?= GPL-3.0-or-later + +include scripts/sbom.am + maintainer-clean-local: -rm Makefile.in diff --git a/README.md b/README.md index 270f3e54..e7010dbd 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,51 @@ wolfssl ocsp \ The `-index` file uses OpenSSL's CA index format. +## SBOM / EU CRA Compliance + +wolfCLU 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, hashes the built `wolfssl` +command-line binary (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 wolfCLU 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: `wolfclu-.cdx.json`, `wolfclu-.spdx.json`, `wolfclu-.spdx` + +Optional overrides: + +- `SBOM_LICENSE_OVERRIDE` - SPDX expression to use instead of the licence + parsed from `COPYING` (defaults to `GPL-3.0-or-later`; e.g. + `LicenseRef-wolfSSL-Commercial` for commercial licensees). +- `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` when unset. + +```sh +make install-sbom # installs to $(datadir)/doc/wolfclu/ +make uninstall-sbom +``` + +The SBOM recipe is the shared `scripts/sbom.am` fragment used across the +wolfSSL stack's autotools products; wolfCLU only declares that it is a CLI +program linking wolfSSL. Recording wolfSSL as a dependency and emitting +wolfCLU-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). + ## Contacts Please contact support@wolfssl.com with any questions or comments. diff --git a/configure.ac b/configure.ac index b125a366..8943af19 100644 --- a/configure.ac +++ b/configure.ac @@ -59,6 +59,13 @@ AM_PATH_PYTHON([3.6],, [:]) AC_SUBST([PYTHON]) AM_CONDITIONAL([HAVE_PYTHON], [test "$PYTHON" != ":"]) +# 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]) + # Checks for headers/libraries AC_CHECK_HEADERS([sys/time.h string.h termios.h unistd.h]) AC_CHECK_SIZEOF(long long, 8) diff --git a/scripts/sbom.am b/scripts/sbom.am new file mode 100644 index 00000000..c3844fd2 --- /dev/null +++ b/scripts/sbom.am @@ -0,0 +1,196 @@ +# 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. +# +# The canonical copy lives in the wolfSSL repository (scripts/sbom.am); keep +# product copies in sync. 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. wolfSSH uses 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_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. +# +# 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. +# --------------------------------------------------------------------------- + +SBOM_ARTIFACT ?= lib +SBOM_LIB_STEM ?= lib$(SBOM_PKGNAME) +SBOM_BIN_NAME ?= $(SBOM_PKGNAME) +SBOM_DEP_WOLFSSL ?= no +SBOM_DEP_OPENSSL ?= no + +SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx +sbomdir = $(datadir)/doc/$(PACKAGE) + +# 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 (AM_CPPFLAGS + config.h), 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"; \ + $(CC) -dM -E $(DEFAULT_INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) \ + $(if $(wildcard $(abs_builddir)/config.h),-include $(abs_builddir)/config.h) \ + -x c /dev/null > "$$_defines"; \ + 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[ \t]*"\([^"]*\)".*/\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 "$$_defines" \ + --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) + +uninstall-hook: uninstall-sbom