From 5ed41dc85f7d9d16173d407fffd4d170872f0e5a Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Tue, 28 Apr 2026 14:42:48 -0700 Subject: [PATCH 01/39] feat: SBOM generation and OmniBOR build provenance (CRA compliance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two complementary supply chain transparency targets to the wolfSSL autotools build, and documentation covering both as a unified whole. Generates a Software Bill of Materials for EU Cyber Resilience Act (CRA) compliance. 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) The SPDX JSON is validated by pyspdxtools before the tag-value file is written; make sbom fails if validation fails. SBOM contents: package name/version, supplier, license (parsed from LICENSING at generation time, not hardcoded), copyright, SHA-256 of the installed library, CPE, PURL, download location, and build configuration defines as a comment. Third-party dependencies (liboqs, libz, libxmss, liblms) are included when enabled. Implementation: scripts/gen-sbom (Python 3, stdlib only) stages a make install into a temporary directory, hashes the installed library, generates both SBOM formats, then removes the staging directory. configure.ac detects python3, pyspdxtools, and git via AC_PATH_PROG. install-sbom / uninstall-sbom targets install the three files to $(datadir)/doc/wolfssl/. make clean removes all generated files. Generates an OmniBOR artifact dependency graph using the Bomsh project (https://github.com/omnibor/bomsh), providing cryptographic traceability from every built binary back to the exact set of source files that produced it. Runs a full clean rebuild under bomtrace3 (a patched strace, userspace only — no kernel modifications required). bomtrace3 intercepts every compiler execve() syscall and records inputs and outputs; it cannot post-process an already-built tree, hence the clean rebuild. bomsh_create_bom.py processes the raw logfile to produce the OmniBOR artifact objects and metadata in omnibor/. If bomsh_sbom.py is available and wolfssl-.spdx.json exists (from make sbom), annotates that SPDX document with a PERSISTENT-ID gitoid ExternalRef, producing omnibor.wolfssl-.spdx.json. This enriched SPDX bridges component identity and build provenance in a single document. configure.ac detects bomtrace3, bomsh_create_bom.py, and bomsh_sbom.py via AC_PATH_PROG. The raw logfile and conf file are written to the build directory (not /tmp/) to avoid concurrent-build collisions, and removed by make clean. install-bomsh / uninstall-bomsh targets install omnibor/ and the enriched SPDX to $(datadir)/doc/wolfssl/. doc/SBOM.md: unified reference covering both make sbom and make bomsh as parts of a single supply chain transparency story — component identity (what) and build provenance (how) — with a combined workflow section and full output file reference. doc/CRA.md: product-integrator guide covering how to incorporate wolfSSL's SBOM artefacts into a downstream product SBOM (SPDX ExternalDocumentRef and CycloneDX component reference patterns), commercial license concluded-field guidance, OmniBOR gitoid meaning, auditor handoff checklist, and links to OpenSSF CRA and SBOM Everywhere SIG guidance pages. INSTALL: sections 21 (make sbom) and 22 (make bomsh). README.md: brief SBOM/CRA and OmniBOR/Bomsh sections. doc/include.am: SBOM.md and CRA.md added to dist_doc_DATA. --- INSTALL | 73 +++++++++ Makefile.am | 110 +++++++++++++ README.md | 12 ++ configure.ac | 15 ++ doc/CRA.md | 225 +++++++++++++++++++++++++ doc/SBOM.md | 265 ++++++++++++++++++++++++++++++ doc/include.am | 5 +- scripts/gen-sbom | 417 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1121 insertions(+), 1 deletion(-) create mode 100644 doc/CRA.md create mode 100644 doc/SBOM.md create mode 100755 scripts/gen-sbom diff --git a/INSTALL b/INSTALL index 058b5a1edf6..17e37f56db7 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 4f3f8fce0c9..95c8960da0e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -426,3 +426,113 @@ merge-clean: .cu.lo: $(LIBTOOL) --tag=CC --mode=compile $(COMPILE) --compile -o $@ $< -static + +# SBOM generation (CRA compliance) +SBOM_CDX = wolfssl-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = wolfssl-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = wolfssl-$(PACKAGE_VERSION).spdx +sbomdir = $(datadir)/doc/$(PACKAGE) + +.PHONY: sbom install-sbom uninstall-sbom + +sbom: + @if test -z "$(PYTHON3)"; then \ + echo ""; \ + echo "ERROR: 'python3' not found in PATH. Cannot generate SBOM."; \ + echo ""; \ + exit 1; \ + fi + @if test -z "$(PYSPDXTOOLS)"; then \ + echo ""; \ + echo "ERROR: 'pyspdxtools' not found in PATH. Cannot validate SBOM."; \ + echo " Install: pip install spdx-tools"; \ + echo ""; \ + exit 1; \ + fi + rm -rf $(abs_builddir)/_sbom_staging + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging + $(PYTHON3) $(srcdir)/scripts/gen-sbom \ + --name $(PACKAGE) \ + --version $(PACKAGE_VERSION) \ + --license-file $(srcdir)/LICENSING \ + --options-h $(abs_builddir)/wolfssl/options.h \ + --lib $(abs_builddir)/_sbom_staging$(libdir)/libwolfssl.so.$(WOLFSSL_LIBRARY_VERSION_FIRST).$(WOLFSSL_LIBRARY_VERSION_SECOND).$(WOLFSSL_LIBRARY_VERSION_THIRD) \ + --dep-liboqs $(ENABLED_LIBOQS) \ + --dep-libxmss $(ENABLED_LIBXMSS) \ + --dep-libxmss-root '$(XMSS_ROOT)' \ + --dep-liblms $(ENABLED_LIBLMS) \ + --dep-liblms-root '$(LIBLMS_ROOT)' \ + --dep-libz $(ENABLED_LIBZ) \ + --git '$(GIT)' \ + --cdx-out $(abs_builddir)/$(SBOM_CDX) \ + --spdx-out $(abs_builddir)/$(SBOM_SPDX) + rm -rf $(abs_builddir)/_sbom_staging + $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ + --outfile $(abs_builddir)/$(SBOM_SPDX_TV) + +install-sbom: sbom + $(MKDIR_P) $(DESTDIR)$(sbomdir) + $(INSTALL_DATA) $(SBOM_CDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX_TV) $(DESTDIR)$(sbomdir)/ + +uninstall-sbom: + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_CDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX_TV) + +CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) + +# 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 + +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)' + @if test -n "$(BOMSH_SBOM)" && test -f '$(abs_builddir)/wolfssl-$(PACKAGE_VERSION).spdx.json'; then \ + echo "Enriching SPDX with OmniBOR ExternalRefs..."; \ + $(BOMSH_SBOM) \ + -b '$(BOMSH_OMNIBORDIR)' \ + -i '$(abs_builddir)/wolfssl-$(PACKAGE_VERSION).spdx.json' \ + -f '$(abs_builddir)/src/.libs/libwolfssl.so.$(WOLFSSL_LIBRARY_VERSION_FIRST).$(WOLFSSL_LIBRARY_VERSION_SECOND).$(WOLFSSL_LIBRARY_VERSION_THIRD)' \ + -s spdx-json \ + -O '$(abs_builddir)'; \ + elif test -n "$(BOMSH_SBOM)"; then \ + echo "NOTE: run 'make sbom' first, then 'make bomsh' for OmniBOR-enriched SPDX."; \ + fi + +install-bomsh: bomsh + $(MKDIR_P) $(DESTDIR)$(bomshdir) + cp -r '$(BOMSH_OMNIBORDIR)' '$(DESTDIR)$(bomshdir)/omnibor' + @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) diff --git a/README.md b/README.md index ae1f22a08c7..a51a2d0b8e8 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 bdf5d3df294..7c6c2773e68 100644 --- a/configure.ac +++ b/configure.ac @@ -12495,6 +12495,21 @@ AC_SUBST([WOLFSSL_PREFIX_ABS]) AC_SUBST([WOLFSSL_LIBDIR_ABS]) AC_SUBST([WOLFSSL_INCLUDEDIR_ABS]) +# SBOM generation +AC_PATH_PROG([PYTHON3], [python3]) +AC_PATH_PROG([PYSPDXTOOLS], [pyspdxtools]) +AC_PATH_PROG([GIT], [git]) +AC_SUBST([ENABLED_LIBOQS]) +AC_SUBST([ENABLED_LIBXMSS]) +AC_SUBST([ENABLED_LIBLMS]) +AC_SUBST([ENABLED_LIBZ]) +AC_SUBST([LIBLMS_ROOT]) + +# 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 00000000000..b01a27f194b --- /dev/null +++ b/doc/CRA.md @@ -0,0 +1,225 @@ +# 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 SBOM records `licenseConcluded: GPL-3.0-only`, which reflects the +open-source license. If you are distributing a product under a wolfSSL +commercial license, update `licenseConcluded` in your copy of the package +entry (or in your own SBOM's reference to the wolfSSL package) to reflect +your actual license: + +```json +"licenseConcluded": "LicenseRef-wolfSSL-Commercial" +``` + +Do not modify the wolfSSL-published SBOM file itself; update the concluded +license in your product SBOM where you reference or embed the wolfSSL entry. + +## 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 00000000000..c2b3ab71198 --- /dev/null +++ b/doc/SBOM.md @@ -0,0 +1,265 @@ +# 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 SBOM reflects the +open-source license. Commercial licensees should update the +`licenseConcluded` field to `LicenseRef-wolfSSL-Commercial` or their +applicable SPDX expression when distributing under a commercial agreement. + +#### External dependency version detection + +For dependencies with pkg-config support (`liboqs`, `libz`), the version is +queried via `pkg-config --modversion` at generation time. + +For dependencies without pkg-config (`libxmss`, `liblms`), wolfSSL is +typically built against a source checkout rather than an installed package. +The generator falls back to `git describe --tags --always` on the source +tree root (passed via `configure` as `XMSS_ROOT` / `LIBLMS_ROOT`). If the +source tree has no tags, `git describe` returns the short commit hash, which +is recorded as-is. If the source tree is unavailable or `git` is not found, +version is recorded as `NOASSERTION`. + +### 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 34a80e20a36..473a5b25c37 100644 --- a/doc/include.am +++ b/doc/include.am @@ -4,7 +4,9 @@ dist_doc_DATA+= doc/README.txt \ doc/QUIC.md \ - doc/dilithium-to-mldsa-migration.md + doc/dilithium-to-mldsa-migration.md \ + doc/SBOM.md \ + doc/CRA.md dox-pdf: @@ -22,3 +24,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 00000000000..ad893e2b6e6 --- /dev/null +++ b/scripts/gen-sbom @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.""" + +import argparse +import hashlib +import json +import re +import subprocess +import sys +import uuid +from datetime import datetime, timezone + + +# Known metadata for optional external dependencies. +# Version is detected at runtime via pkg-config; falls back to None. +DEP_META = { + 'liboqs': { + 'name': 'liboqs', + 'supplier': 'Open Quantum Safe', + 'license': 'MIT', + 'download': 'https://github.com/open-quantum-safe/liboqs', + 'pkgconfig': 'liboqs', + 'purl': lambda v: f'pkg:github/open-quantum-safe/liboqs@{v}', + }, + 'libxmss': { + 'name': 'xmss-reference', + 'supplier': 'XMSS reference implementation authors', + 'license': 'CC0-1.0', + 'download': 'https://github.com/XMSS/xmss-reference', + 'pkgconfig': None, + 'purl': lambda v: f'pkg:github/XMSS/xmss-reference@{v}', + }, + 'liblms': { + 'name': 'hash-sigs', + 'supplier': 'Cisco Systems', + 'license': 'MIT', + 'download': 'https://github.com/cisco/hash-sigs', + 'pkgconfig': None, + 'purl': lambda v: f'pkg:github/cisco/hash-sigs@{v}', + }, + 'libz': { + 'name': 'zlib', + 'supplier': 'Jean-loup Gailly and Mark Adler', + 'license': 'Zlib', + 'download': 'https://github.com/madler/zlib', + 'pkgconfig': 'zlib', + 'purl': lambda v: f'pkg:generic/zlib@{v}', + }, +} + + +def detect_license(license_file): + """Parse LICENSING file and return an SPDX license ID. + + Looks for 'GNU General Public License version N' and whether + 'or later' / 'or any later version' follows. Returns None and + prints a warning if the file cannot be parsed. + """ + try: + text = open(license_file).read() + except OSError as e: + print(f"WARNING: cannot read license file {license_file}: {e}", + file=sys.stderr) + return None + + m = re.search( + r'gnu general public license\s+version\s+(\d+)', + text, re.IGNORECASE + ) + if not m: + print(f"WARNING: no GPL version found in {license_file}", + file=sys.stderr) + return None + + version = m.group(1) + excerpt = text[m.end():m.end() + 100] + if re.search(r'or\s+(any\s+)?later', excerpt, re.IGNORECASE): + return f'GPL-{version}.0-or-later' + return f'GPL-{version}.0-only' + + +def sha256_file(path): + h = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + except OSError as e: + sys.exit(f"ERROR: cannot read library for hashing: {e}") + return h.hexdigest() + + +GIT_BIN = None + + +def pkgconfig_version(pkgname): + """Return version string from pkg-config, or None if unavailable.""" + try: + r = subprocess.run( + ['pkg-config', '--modversion', pkgname], + capture_output=True, text=True + ) + if r.returncode == 0: + return r.stdout.strip() + except FileNotFoundError: + pass + return None + + +def git_describe_version(root, git_bin): + """Return version from git describe --tags --always, or None.""" + if not root or not git_bin: + return None + try: + r = subprocess.run( + [git_bin, '-C', root, 'describe', '--tags', '--always'], + capture_output=True, text=True + ) + if r.returncode == 0: + return r.stdout.strip() + except FileNotFoundError: + pass + return None + + +def dep_version(key): + pkgname = DEP_META[key]['pkgconfig'] + if pkgname: + return pkgconfig_version(pkgname) + git_root = DEP_META[key].get('git_root') + if git_root: + return git_describe_version(git_root, GIT_BIN) + return None + + +def parse_options_h(path): + """Parse wolfssl/options.h and return sorted deduplicated list of + (name, value) pairs for every #define found.""" + try: + text = open(path).read() + except OSError as e: + print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr) + return [] + + defines = {} + for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.+))?$', text, re.MULTILINE): + defines[m.group(1)] = (m.group(2) or '').strip() + return sorted(defines.items()) + + +def cdx_dep_component(key): + """Return (bom_ref, component_dict) for a CDX dependency component.""" + meta = DEP_META[key] + version = dep_version(key) + bom_ref = str(uuid.uuid4()) + comp = { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': meta['supplier']}, + 'name': meta['name'], + 'licenses': [{'license': {'id': meta['license']}}], + 'externalReferences': [{'type': 'vcs', 'url': meta['download']}], + } + if version: + comp['version'] = version + comp['purl'] = meta['purl'](version) + else: + print(f"WARNING: version unknown for {meta['name']}; " + "omitting version and purl", file=sys.stderr) + return bom_ref, comp + + +def spdx_dep_package(key): + """Return (spdx_id, package_dict) for an SPDX dependency package.""" + meta = DEP_META[key] + version = dep_version(key) + spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', meta['name']) + pkg = { + 'SPDXID': spdx_id, + 'name': meta['name'], + 'versionInfo': version if version else 'NOASSERTION', + 'supplier': f"Organization: {meta['supplier']}", + 'downloadLocation': meta['download'], + 'filesAnalyzed': False, + 'licenseConcluded': meta['license'], + 'licenseDeclared': meta['license'], + 'copyrightText': 'NOASSERTION', + } + if version: + pkg['externalRefs'] = [{ + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': meta['purl'](version), + }] + return spdx_id, pkg + + +def generate_cdx(name, version, supplier, license_id, lib_hash, + timestamp, serial, enabled_deps, build_props): + year = datetime.now(timezone.utc).year + bom_ref = str(uuid.uuid4()) + + dep_bom_refs = [] + components = [] + for key in enabled_deps: + ref, comp = cdx_dep_component(key) + dep_bom_refs.append(ref) + components.append(comp) + + properties = [ + {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} + for k, v in build_props + ] + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'serialNumber': f'urn:uuid:{serial}', + 'version': 1, + 'metadata': { + 'timestamp': timestamp, + 'tools': { + 'components': [{ + 'type': 'application', + 'author': 'wolfSSL Inc.', + 'name': 'wolfssl-sbom-gen', + 'version': '1.0' + }] + }, + 'component': { + 'bom-ref': bom_ref, + 'type': 'library', + 'supplier': {'name': supplier}, + 'name': name, + 'version': version, + 'licenses': [{'license': {'id': license_id}}], + 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', + 'purl': f'pkg:generic/{name}@{version}', + 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], + 'externalReferences': [{ + 'type': 'vcs', + 'url': 'https://github.com/wolfSSL/wolfssl' + }], + 'properties': properties, + } + }, + 'components': components, + 'dependencies': [ + {'ref': bom_ref, 'dependsOn': dep_bom_refs}, + *[{'ref': r, 'dependsOn': []} for r in dep_bom_refs], + ], + } + + +def generate_spdx(name, version, supplier, license_id, lib_hash, + timestamp, doc_ns_uuid, enabled_deps, build_props): + year = datetime.now(timezone.utc).year + + build_defines = ', '.join(k for k, _ in build_props) + wolfssl_pkg = { + 'SPDXID': 'SPDXRef-Package-wolfssl', + 'name': name, + 'versionInfo': version, + 'supplier': f'Organization: {supplier}', + 'downloadLocation': 'https://github.com/wolfSSL/wolfssl', + 'filesAnalyzed': False, + 'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}], + 'licenseConcluded': license_id, + 'licenseDeclared': license_id, + 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'comment': f'Build configuration defines: {build_defines}', + 'externalRefs': [ + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'cpe23Type', + 'referenceLocator': ( + f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*' + ) + }, + { + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': f'pkg:generic/{name}@{version}' + } + ], + } + + packages = [wolfssl_pkg] + relationships = [{ + 'spdxElementId': 'SPDXRef-DOCUMENT', + 'relatedSpdxElement': 'SPDXRef-Package-wolfssl', + 'relationshipType': 'DESCRIBES', + }] + + for key in enabled_deps: + spdx_id, pkg = spdx_dep_package(key) + packages.append(pkg) + relationships.append({ + 'spdxElementId': 'SPDXRef-Package-wolfssl', + 'relatedSpdxElement': spdx_id, + 'relationshipType': 'DEPENDS_ON', + }) + + return { + 'spdxVersion': 'SPDX-2.3', + 'dataLicense': 'CC0-1.0', + 'SPDXID': 'SPDXRef-DOCUMENT', + 'name': f'{name}-{version}', + 'documentNamespace': ( + f'https://wolfssl.com/sbom/{name}-{version}-{doc_ns_uuid}' + ), + 'creationInfo': { + 'licenseListVersion': '3.28', + 'creators': [ + f'Organization: {supplier}', + 'Tool: wolfssl-sbom-gen-1.0' + ], + 'created': timestamp, + }, + 'packages': packages, + 'relationships': relationships, + } + + +def main(): + parser = argparse.ArgumentParser( + description='Generate CycloneDX and SPDX SBOMs for wolfssl' + ) + parser.add_argument('--name', required=True, help='Package name') + parser.add_argument('--version', required=True, help='Package version') + parser.add_argument('--supplier', default='wolfSSL Inc.', + help='Supplier name (default: wolfSSL Inc.)') + parser.add_argument('--lib', required=True, + help='Path to libwolfssl.so.X.Y.Z for SHA-256 hashing') + parser.add_argument('--license-file', required=True, + help='Path to LICENSING file for SPDX ID detection') + parser.add_argument('--options-h', required=True, + help='Path to wolfssl/options.h for build config') + parser.add_argument('--dep-liboqs', default='no', + help='yes if built with --with-liboqs') + parser.add_argument('--dep-libxmss', default='no', + help='yes if built with --with-libxmss') + parser.add_argument('--dep-libxmss-root', default='', + help='Path to xmss-reference source tree root') + parser.add_argument('--dep-liblms', default='no', + help='yes if built with --with-liblms') + parser.add_argument('--dep-liblms-root', default='', + help='Path to hash-sigs source tree root') + parser.add_argument('--dep-libz', default='no', + help='yes if built with --with-libz') + parser.add_argument('--git', default='', + help='Path to git binary for version detection') + parser.add_argument('--cdx-out', required=True, + help='Output path for CycloneDX JSON') + parser.add_argument('--spdx-out', required=True, + help='Output path for SPDX JSON') + args = parser.parse_args() + + global GIT_BIN + GIT_BIN = args.git or None + + if args.dep_libxmss_root: + DEP_META['libxmss']['git_root'] = args.dep_libxmss_root + if args.dep_liblms_root: + DEP_META['liblms']['git_root'] = args.dep_liblms_root + + enabled_deps = [ + key for key, flag in [ + ('liboqs', args.dep_liboqs), + ('libxmss', args.dep_libxmss), + ('liblms', args.dep_liblms), + ('libz', args.dep_libz), + ] + if flag.lower() == 'yes' + ] + + license_id = detect_license(args.license_file) + if license_id is None: + print("WARNING: license could not be determined; using NOASSERTION", + file=sys.stderr) + license_id = 'NOASSERTION' + + build_props = parse_options_h(args.options_h) + lib_hash = sha256_file(args.lib) + timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + serial = str(uuid.uuid4()) + doc_ns_uuid = str(uuid.uuid4()) + + cdx = generate_cdx( + args.name, args.version, args.supplier, + license_id, lib_hash, timestamp, serial, + enabled_deps, build_props, + ) + spdx = generate_spdx( + args.name, args.version, args.supplier, + license_id, lib_hash, timestamp, doc_ns_uuid, + enabled_deps, build_props, + ) + + try: + with open(args.cdx_out, 'w') as f: + json.dump(cdx, f, indent=2) + f.write('\n') + with open(args.spdx_out, 'w') as f: + json.dump(spdx, f, indent=2) + f.write('\n') + except OSError as e: + sys.exit(f"ERROR: cannot write SBOM output: {e}") + + print(f"Generated: {args.cdx_out}") + print(f"Generated: {args.spdx_out}") + + +if __name__ == '__main__': + main() From f1a73dcdf2ff779ddae5489a94bca8873cdcd5a8 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 13:23:35 +0300 Subject: [PATCH 02/39] fix(sbom): library discovery, reproducibility, install lifecycle Glob-match libwolfssl.* across platforms, honour SOURCE_DATE_EPOCH, stage installs with trap cleanup, and parse options.h for build flags. Signed-off-by: Sameeh Jubran --- Makefile.am | 106 +++++++++++++++++++++++++++++++++++++-------- doc/CRA.md | 41 +++++++++++++++--- doc/SBOM.md | 25 ++++++++--- scripts/gen-sbom | 81 ++++++++++++++++++++++++---------- scripts/include.am | 5 +++ 5 files changed, 205 insertions(+), 53 deletions(-) diff --git a/Makefile.am b/Makefile.am index 95c8960da0e..b11b508b77f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -435,6 +435,12 @@ sbomdir = $(datadir)/doc/$(PACKAGE) .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). sbom: @if test -z "$(PYTHON3)"; then \ echo ""; \ @@ -449,14 +455,44 @@ sbom: echo ""; \ exit 1; \ fi - rm -rf $(abs_builddir)/_sbom_staging - $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging + @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 \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.so.[0-9]* \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.so \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.[0-9]*.dylib \ + "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.dylib \ + "$(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)" && \ + test -d "$(srcdir)/.git"; 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)' \ --options-h $(abs_builddir)/wolfssl/options.h \ - --lib $(abs_builddir)/_sbom_staging$(libdir)/libwolfssl.so.$(WOLFSSL_LIBRARY_VERSION_FIRST).$(WOLFSSL_LIBRARY_VERSION_SECOND).$(WOLFSSL_LIBRARY_VERSION_THIRD) \ + --lib "$$sbom_lib" \ --dep-liboqs $(ENABLED_LIBOQS) \ --dep-libxmss $(ENABLED_LIBXMSS) \ --dep-libxmss-root '$(XMSS_ROOT)' \ @@ -465,8 +501,7 @@ sbom: --dep-libz $(ENABLED_LIBZ) \ --git '$(GIT)' \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ - --spdx-out $(abs_builddir)/$(SBOM_SPDX) - rm -rf $(abs_builddir)/_sbom_staging + --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ --outfile $(abs_builddir)/$(SBOM_SPDX_TV) @@ -493,6 +528,11 @@ 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 ""; \ @@ -512,21 +552,41 @@ bomsh: @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' - @if test -n "$(BOMSH_SBOM)" && test -f '$(abs_builddir)/wolfssl-$(PACKAGE_VERSION).spdx.json'; then \ - echo "Enriching SPDX with OmniBOR ExternalRefs..."; \ - $(BOMSH_SBOM) \ - -b '$(BOMSH_OMNIBORDIR)' \ - -i '$(abs_builddir)/wolfssl-$(PACKAGE_VERSION).spdx.json' \ - -f '$(abs_builddir)/src/.libs/libwolfssl.so.$(WOLFSSL_LIBRARY_VERSION_FIRST).$(WOLFSSL_LIBRARY_VERSION_SECOND).$(WOLFSSL_LIBRARY_VERSION_THIRD)' \ - -s spdx-json \ - -O '$(abs_builddir)'; \ - elif test -n "$(BOMSH_SBOM)"; then \ - echo "NOTE: run 'make sbom' first, then 'make bomsh' for OmniBOR-enriched SPDX."; \ - fi + $(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 \ + $(abs_builddir)/src/.libs/libwolfssl.so.[0-9]* \ + $(abs_builddir)/src/.libs/libwolfssl.so \ + $(abs_builddir)/src/.libs/libwolfssl.[0-9]*.dylib \ + $(abs_builddir)/src/.libs/libwolfssl.dylib \ + $(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) - cp -r '$(BOMSH_OMNIBORDIR)' '$(DESTDIR)$(bomshdir)/omnibor' + $(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 @@ -536,3 +596,13 @@ uninstall-bomsh: -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/doc/CRA.md b/doc/CRA.md index b01a27f194b..cfdcd46b18a 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -125,18 +125,45 @@ wolfSSL CycloneDX document via an external reference of type `bom`: ## Commercial License Users -wolfSSL's SBOM records `licenseConcluded: GPL-3.0-only`, which reflects the -open-source license. If you are distributing a product under a wolfSSL -commercial license, update `licenseConcluded` in your copy of the package -entry (or in your own SBOM's reference to the wolfSSL package) to reflect -your actual license: +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 +``` + +Or invoke the generator directly with `--license-override` 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 itself; update the concluded -license in your product SBOM where you reference or embed the wolfSSL entry. +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) diff --git a/doc/SBOM.md b/doc/SBOM.md index c2b3ab71198..5b2550008a3 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -86,10 +86,20 @@ wrong value. #### Dual licensing wolfSSL is available under `GPL-3.0-only` for open-source use, with a -commercial license for proprietary products. The SBOM reflects the -open-source license. Commercial licensees should update the -`licenseConcluded` field to `LicenseRef-wolfSSL-Commercial` or their -applicable SPDX expression when distributing under a commercial agreement. +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 \ + ... other flags ... +``` + +The override is also forwarded by `make sbom` if you set the +`SBOM_LICENSE_OVERRIDE` make variable, e.g. +`make sbom SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial`. #### External dependency version detection @@ -101,8 +111,11 @@ typically built against a source checkout rather than an installed package. The generator falls back to `git describe --tags --always` on the source tree root (passed via `configure` as `XMSS_ROOT` / `LIBLMS_ROOT`). If the source tree has no tags, `git describe` returns the short commit hash, which -is recorded as-is. If the source tree is unavailable or `git` is not found, -version is recorded as `NOASSERTION`. +is recorded as-is. If the source tree is unavailable or `git` is not found: + +- 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 diff --git a/scripts/gen-sbom b/scripts/gen-sbom index ad893e2b6e6..839c20656f4 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -4,6 +4,7 @@ import argparse import hashlib import json +import os import re import subprocess import sys @@ -11,6 +12,35 @@ 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.""" + return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '/'.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. DEP_META = { @@ -148,11 +178,12 @@ def parse_options_h(path): return sorted(defines.items()) -def cdx_dep_component(key): - """Return (bom_ref, component_dict) for a CDX dependency component.""" +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 = str(uuid.uuid4()) + bom_ref = derived_uuid(name, pkg_version, 'dep', key) comp = { 'bom-ref': bom_ref, 'type': 'library', @@ -196,14 +227,13 @@ def spdx_dep_package(key): def generate_cdx(name, version, supplier, license_id, lib_hash, - timestamp, serial, enabled_deps, build_props): - year = datetime.now(timezone.utc).year - bom_ref = str(uuid.uuid4()) + 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(key) + ref, comp = cdx_dep_component(name, version, key) dep_bom_refs.append(ref) components.append(comp) @@ -255,9 +285,7 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, def generate_spdx(name, version, supplier, license_id, lib_hash, - timestamp, doc_ns_uuid, enabled_deps, build_props): - year = datetime.now(timezone.utc).year - + timestamp, year, doc_ns_uuid, enabled_deps, build_props): build_defines = ', '.join(k for k, _ in build_props) wolfssl_pkg = { 'SPDXID': 'SPDXRef-Package-wolfssl', @@ -312,7 +340,6 @@ def generate_spdx(name, version, supplier, license_id, lib_hash, f'https://wolfssl.com/sbom/{name}-{version}-{doc_ns_uuid}' ), 'creationInfo': { - 'licenseListVersion': '3.28', 'creators': [ f'Organization: {supplier}', 'Tool: wolfssl-sbom-gen-1.0' @@ -333,9 +360,15 @@ def main(): parser.add_argument('--supplier', default='wolfSSL Inc.', help='Supplier name (default: wolfSSL Inc.)') parser.add_argument('--lib', required=True, - help='Path to libwolfssl.so.X.Y.Z for SHA-256 hashing') + 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('--options-h', required=True, help='Path to wolfssl/options.h for build config') parser.add_argument('--dep-liboqs', default='no', @@ -376,26 +409,30 @@ def main(): if flag.lower() == 'yes' ] - license_id = detect_license(args.license_file) - if license_id is None: - print("WARNING: license could not be determined; using NOASSERTION", - file=sys.stderr) - license_id = 'NOASSERTION' + 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' build_props = parse_options_h(args.options_h) lib_hash = sha256_file(args.lib) - timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') - serial = str(uuid.uuid4()) - doc_ns_uuid = str(uuid.uuid4()) + 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, lib_hash, timestamp, serial, + license_id, lib_hash, timestamp, year, serial, enabled_deps, build_props, ) spdx = generate_spdx( args.name, args.version, args.supplier, - license_id, lib_hash, timestamp, doc_ns_uuid, + license_id, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, ) diff --git a/scripts/include.am b/scripts/include.am index 38dc071466d..6e476b239bc 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -168,3 +168,8 @@ 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 From 02fc8c2e60083e8b1918d5bb6993d3809521003d Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 14:30:08 +0300 Subject: [PATCH 03/39] fix(sbom): SPDX 2.3 LicenseRef compliance Emit hasExtractedLicensingInfos for LicenseRef-* IDs and add --license-text / SBOM_LICENSE_TEXT to embed the actual licence body. Signed-off-by: Sameeh Jubran --- Makefile.am | 11 +++++ doc/CRA.md | 24 ++++++++-- doc/SBOM.md | 22 ++++++++-- scripts/gen-sbom | 112 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/Makefile.am b/Makefile.am index b11b508b77f..380809bf844 100644 --- a/Makefile.am +++ b/Makefile.am @@ -441,6 +441,16 @@ sbomdir = $(datadir)/doc/$(PACKAGE) # 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; without it the SBOM embeds +# a placeholder and validators may reject it. sbom: @if test -z "$(PYTHON3)"; then \ echo ""; \ @@ -491,6 +501,7 @@ sbom: --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-liboqs $(ENABLED_LIBOQS) \ diff --git a/doc/CRA.md b/doc/CRA.md index cfdcd46b18a..21a4d61bf97 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -135,11 +135,29 @@ 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 +make sbom \ + SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ + SBOM_LICENSE_TEXT=/path/to/wolfssl-commercial-license.txt ``` -Or invoke the generator directly with `--license-override` if you are -producing the SBOM outside the standard make target. +`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 you omit `SBOM_LICENSE_TEXT` the generator emits a placeholder and prints +a warning — useful for quick experiments, but the result is **not** valid for +distribution to customers or regulators. + +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 diff --git a/doc/SBOM.md b/doc/SBOM.md index 5b2550008a3..bcb88c78423 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -94,12 +94,28 @@ exposes this directly: ```sh python3 scripts/gen-sbom \ --license-override LicenseRef-wolfSSL-Commercial \ + --license-text /path/to/wolfssl-commercial-license.txt \ ... other flags ... ``` -The override is also forwarded by `make sbom` if you set the -`SBOM_LICENSE_OVERRIDE` make variable, e.g. -`make sbom SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial`. +`--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. Without it, validators such as +`pyspdxtools` and `ntia-conformance-checker` reject the document. The +generator emits a placeholder and a warning in that case so the bug is +visible, but the SBOM is *not* valid for downstream consumers. + +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 diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 839c20656f4..1b794de784a 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -79,6 +79,86 @@ DEP_META = { } +# 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. + """ + 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.""" + 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. @@ -226,7 +306,7 @@ def spdx_dep_package(key): return spdx_id, pkg -def generate_cdx(name, version, supplier, license_id, lib_hash, +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') @@ -264,7 +344,7 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, 'supplier': {'name': supplier}, 'name': name, 'version': version, - 'licenses': [{'license': {'id': license_id}}], + '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}', @@ -284,7 +364,7 @@ def generate_cdx(name, version, supplier, license_id, lib_hash, } -def generate_spdx(name, version, supplier, license_id, lib_hash, +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 = { @@ -331,7 +411,7 @@ def generate_spdx(name, version, supplier, license_id, lib_hash, 'relationshipType': 'DEPENDS_ON', }) - return { + doc = { 'spdxVersion': 'SPDX-2.3', 'dataLicense': 'CC0-1.0', 'SPDXID': 'SPDXRef-DOCUMENT', @@ -350,6 +430,12 @@ def generate_spdx(name, version, supplier, license_id, lib_hash, 'relationships': relationships, } + extracted = build_extracted_licensing_infos(license_id, license_text) + if extracted: + doc['hasExtractedLicensingInfos'] = extracted + + return doc + def main(): parser = argparse.ArgumentParser( @@ -369,6 +455,13 @@ def main(): '(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-liboqs', default='no', @@ -418,6 +511,13 @@ def main(): 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: + print("WARNING: --license-override uses a LicenseRef-* but " + "--license-text was not provided; the SBOM will embed a " + "placeholder. Provide SBOM_LICENSE_TEXT= for full " + "SPDX compliance.", file=sys.stderr) + build_props = parse_options_h(args.options_h) lib_hash = sha256_file(args.lib) dt, timestamp = build_timestamp() @@ -427,12 +527,12 @@ def main(): cdx = generate_cdx( args.name, args.version, args.supplier, - license_id, lib_hash, timestamp, year, serial, + license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, ) spdx = generate_spdx( args.name, args.version, args.supplier, - license_id, lib_hash, timestamp, year, doc_ns_uuid, + license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, ) From dcc072f2178a2a615e02b14b314fa95d0bdf1888 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 15:59:55 +0300 Subject: [PATCH 04/39] test(sbom): unit tests and CI integration workflow Cover gen-sbom helpers and add a workflow that validates SPDX/CDX, NTIA conformance, reproducibility, and the licence-override matrix. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 245 ++++++++++++++++++++++++++++++++++ scripts/gen-sbom | 6 +- scripts/test_gen_sbom.py | 260 +++++++++++++++++++++++++++++++++++++ 3 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sbom.yml create mode 100644 scripts/test_gen_sbom.py diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 00000000000..f2e82f6fd29 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,245 @@ +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 + 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" + + - 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'): + errors = v.validate_str(open(path).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. The + # autotools install is deterministic (identical bytes), so the hash + # the SBOM recorded must match. + run: | + rm -rf /tmp/_inst + make install DESTDIR=/tmp/_inst >/dev/null + LIB=$(ls /tmp/_inst/usr/local/lib/libwolfssl.so* 2>/dev/null \ + | grep -v '\.la$' | head -1) + test -n "$LIB" || (echo "no installed shared lib"; exit 1) + EXPECTED=$(sha256sum "$LIB" | cut -d' ' -f1) + ACTUAL=$(python3 -c " + import json, glob + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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 + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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 + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + assert d['packages'][0]['licenseConcluded'].startswith('GPL-3.0-'), \ + d['packages'][0]['licenseConcluded'] + assert 'hasExtractedLicensingInfos' not in d + cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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="$PWD/COPYING" + python3 - <<'PY' + import glob, json + expected = open('COPYING').read() + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + infos = d['hasExtractedLicensingInfos'] + assert len(infos) == 1 + assert infos[0]['licenseId'] == 'LicenseRef-wolfSSL-Commercial' + assert infos[0]['extractedText'] == expected + cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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 -c " + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + import glob, sys + v = JsonStrictValidator(SchemaVersion.V1_6) + errs = v.validate_str(open(glob.glob('wolfssl-*.cdx.json')[0]).read()) + sys.exit(1 if errs else 0)" + + - 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="$PWD/COPYING" + python3 - <<'PY' + import glob, json + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + assert len(d['hasExtractedLicensingInfos']) == 1 + cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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 + d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + assert 'hasExtractedLicensingInfos' not in d + cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + lic = cdx['metadata']['component']['licenses'][0]['license'] + assert lic == {'id': 'Apache-2.0'}, lic + print('simple SPDX override: ok') + PY + + # ---- 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 diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 1b794de784a..a3afb2fb12c 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -167,7 +167,8 @@ def detect_license(license_file): prints a warning if the file cannot be parsed. """ try: - text = open(license_file).read() + 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) @@ -247,7 +248,8 @@ def parse_options_h(path): """Parse wolfssl/options.h and return sorted deduplicated list of (name, value) pairs for every #define found.""" try: - text = open(path).read() + 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 [] diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py new file mode 100644 index 00000000000..0a75da076ff --- /dev/null +++ b/scripts/test_gen_sbom.py @@ -0,0 +1,260 @@ +#!/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 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'}]) + + +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) + + +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() + # Should still produce a UTC ISO-Z timestamp; we only check shape. + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + + def test_no_sde_is_current_utc(self): + os.environ.pop('SOURCE_DATE_EPOCH', None) + _, ts = gs.build_timestamp() + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + + +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 TestParseOptionsH(unittest.TestCase): + def test_parses_defines_sorted_and_deduped(self): + with tempfile.NamedTemporaryFile('w', suffix='.h', + delete=False) as f: + f.write( + "/* fake options.h */\n" + "#define HAVE_BAR\n" + "#define HAVE_AAA 1\n" + "#define HAVE_BAR /* duplicate */\n" + "#define HAVE_FOO 42\n" + ) + path = f.name + try: + pairs = gs.parse_options_h(path) + finally: + os.unlink(path) + names = [k for k, _ in pairs] + self.assertEqual(names, sorted(set(names))) + self.assertIn(('HAVE_AAA', '1'), pairs) + self.assertIn(('HAVE_FOO', '42'), pairs) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From e301e1076603fb69345026d369934887a3ad697d Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 17:13:28 +0300 Subject: [PATCH 05/39] fix(sbom): correctness fixes from code review Hard-error on LicenseRef-* without --license-text, route NOASSERTION via license.name, strip C comments in options.h, NUL-join derived UUIDs, and detect git worktrees for SOURCE_DATE_EPOCH. Signed-off-by: Sameeh Jubran --- Makefile.am | 2 +- doc/CRA.md | 6 +++--- doc/SBOM.md | 9 ++++----- scripts/gen-sbom | 39 ++++++++++++++++++++++++++++++--------- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Makefile.am b/Makefile.am index 380809bf844..3d089fa415d 100644 --- a/Makefile.am +++ b/Makefile.am @@ -492,7 +492,7 @@ sbom: fi; \ echo "SBOM: hashing $$sbom_lib"; \ if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ - test -d "$(srcdir)/.git"; then \ + $(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; \ diff --git a/doc/CRA.md b/doc/CRA.md index 21a4d61bf97..d477d3a1f09 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -147,9 +147,9 @@ the document; conformant validators (e.g. `pyspdxtools`, `ntia-conformance-check will reject the SBOM otherwise. The file should contain the plain-text licence agreement you received from wolfSSL. -If you omit `SBOM_LICENSE_TEXT` the generator emits a placeholder and prints -a warning — useful for quick experiments, but the result is **not** valid for -distribution to customers or regulators. +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 diff --git a/doc/SBOM.md b/doc/SBOM.md index bcb88c78423..5dabf1af553 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -98,13 +98,12 @@ python3 scripts/gen-sbom \ ... other flags ... ``` -`--license-text` is required whenever `--license-override` is a custom +`--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. Without it, validators such as -`pyspdxtools` and `ntia-conformance-checker` reject the document. The -generator emits a placeholder and a warning in that case so the bug is -visible, but the SBOM is *not* valid for downstream consumers. +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. diff --git a/scripts/gen-sbom b/scripts/gen-sbom index a3afb2fb12c..d5295c68184 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -20,8 +20,13 @@ 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.""" - return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '/'.join(parts))) + 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(): @@ -148,6 +153,11 @@ def cdx_license_block(license_expr, license_text): * `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) @@ -246,7 +256,11 @@ def dep_version(key): def parse_options_h(path): """Parse wolfssl/options.h and return sorted deduplicated list of - (name, value) pairs for every #define found.""" + (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() @@ -255,8 +269,10 @@ def parse_options_h(path): return [] defines = {} - for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.+))?$', text, re.MULTILINE): - defines[m.group(1)] = (m.group(2) or '').strip() + 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()) @@ -515,10 +531,15 @@ def main(): license_text = load_license_text(args.license_text) if extract_license_refs(license_id) and license_text is None: - print("WARNING: --license-override uses a LicenseRef-* but " - "--license-text was not provided; the SBOM will embed a " - "placeholder. Provide SBOM_LICENSE_TEXT= for full " - "SPDX compliance.", file=sys.stderr) + 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) From fb2438980cb826db28517b37fb1bb59c47f6c374 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 17:13:45 +0300 Subject: [PATCH 06/39] test(sbom): regression coverage and macOS CI job Add tests for the review-driven gen-sbom fixes, tighten file-handle hygiene in the linux integration job, and add a macOS smoke job for .dylib detection. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 142 ++++++++++++++++++++++++++++++------- scripts/test_gen_sbom.py | 71 ++++++++++++++++--- 2 files changed, 176 insertions(+), 37 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f2e82f6fd29..f1f558a75d0 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -33,7 +33,7 @@ jobs: # Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert # everything an external auditor or vulnerability scanner relies on. integration: - name: SBOM integration + name: SBOM integration (linux) if: github.repository_owner == 'wolfssl' runs-on: ubuntu-24.04 needs: unit @@ -52,6 +52,12 @@ jobs: '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 @@ -73,7 +79,8 @@ jobs: from cyclonedx.schema import SchemaVersion v = JsonStrictValidator(SchemaVersion.V1_6) for path in glob.glob('wolfssl-*.cdx.json'): - errors = v.validate_str(open(path).read()) + with open(path) as f: + errors = v.validate_str(f.read()) if errors: print(f"INVALID: {path}: {errors}", file=sys.stderr) sys.exit(1) @@ -84,19 +91,29 @@ jobs: - 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. The - # autotools install is deterministic (identical bytes), so the hash - # the SBOM recorded must match. + # 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=$(ls /tmp/_inst/usr/local/lib/libwolfssl.so* 2>/dev/null \ - | grep -v '\.la$' | head -1) - test -n "$LIB" || (echo "no installed shared lib"; exit 1) - EXPECTED=$(sha256sum "$LIB" | cut -d' ' -f1) + 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 - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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" || \ @@ -108,7 +125,8 @@ jobs: run: | python3 - <<'PY' import glob, json, re, sys - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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 @@ -141,11 +159,13 @@ jobs: make sbom python3 - <<'PY' import glob, json - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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 - cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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) @@ -156,16 +176,19 @@ jobs: rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx make sbom \ SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \ - SBOM_LICENSE_TEXT="$PWD/COPYING" + SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt python3 - <<'PY' import glob, json - expected = open('COPYING').read() - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + 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 - cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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 @@ -173,25 +196,47 @@ jobs: PY # The output of this run must still pass NTIA and CDX validators. ntia-checker -c ntia wolfssl-*.spdx.json - python3 -c " + python3 - <<'PY' + import glob, sys from cyclonedx.validation.json import JsonStrictValidator from cyclonedx.schema import SchemaVersion - import glob, sys v = JsonStrictValidator(SchemaVersion.V1_6) - errs = v.validate_str(open(glob.glob('wolfssl-*.cdx.json')[0]).read()) - sys.exit(1 if errs else 0)" + 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; } + test ! -f wolfssl-5.9.1.spdx.json || \ + { echo "FAIL: SBOM file should not exist after refusal"; \ + exit 1; } - 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="$PWD/COPYING" + SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt python3 - <<'PY' import glob, json - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) assert len(d['hasExtractedLicensingInfos']) == 1 - cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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') @@ -203,9 +248,11 @@ jobs: make sbom SBOM_LICENSE_OVERRIDE=Apache-2.0 python3 - <<'PY' import glob, json - d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0])) + with open(glob.glob('wolfssl-*.spdx.json')[0]) as f: + d = json.load(f) assert 'hasExtractedLicensingInfos' not in d - cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0])) + 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') @@ -243,3 +290,46 @@ jobs: 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.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + # On some macOS runners pyspdxtools lands in + # Library/Python//bin; symlink to a known-on-PATH location. + for d in "$HOME/Library/Python"/*/bin; do + [ -x "$d/pyspdxtools" ] && \ + echo "$d" >> "$GITHUB_PATH" + done + + - 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 diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 0a75da076ff..caee41b0889 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -122,6 +122,17 @@ def test_compound_uses_expression(self): 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): @@ -177,6 +188,18 @@ def test_returns_valid_uuid_string(self): 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): @@ -235,25 +258,51 @@ def test_missing_file_exits(self): class TestParseOptionsH(unittest.TestCase): - def test_parses_defines_sorted_and_deduped(self): + def _parse(self, body): with tempfile.NamedTemporaryFile('w', suffix='.h', delete=False) as f: - f.write( - "/* fake options.h */\n" - "#define HAVE_BAR\n" - "#define HAVE_AAA 1\n" - "#define HAVE_BAR /* duplicate */\n" - "#define HAVE_FOO 42\n" - ) + f.write(body) path = f.name try: - pairs = gs.parse_options_h(path) + 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.assertIn(('HAVE_AAA', '1'), pairs) - self.assertIn(('HAVE_FOO', '42'), pairs) + 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') if __name__ == '__main__': From 06d13d0dcb5617e2e40b3cea52f363b443e17601 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 17:31:35 +0300 Subject: [PATCH 07/39] refactor(sbom): DRY library glob, tighten regression tests Hoist the shared dynamic-library basenames into a Make variable used by both `sbom:` and `bomsh:`; add a sha256_file negative test and freshness checks for the build_timestamp fallback. Signed-off-by: Sameeh Jubran --- Makefile.am | 22 ++++++++++++++-------- scripts/test_gen_sbom.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/Makefile.am b/Makefile.am index 3d089fa415d..6659bc82a95 100644 --- a/Makefile.am +++ b/Makefile.am @@ -433,6 +433,18 @@ 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 @@ -471,10 +483,7 @@ sbom: $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ sbom_lib=""; \ for lib in \ - "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.so.[0-9]* \ - "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.so \ - "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.[0-9]*.dylib \ - "$(abs_builddir)/_sbom_staging$(libdir)"/libwolfssl.dylib \ + $(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 \ @@ -571,10 +580,7 @@ bomsh: fi; \ bomsh_artifact=""; \ for lib in \ - $(abs_builddir)/src/.libs/libwolfssl.so.[0-9]* \ - $(abs_builddir)/src/.libs/libwolfssl.so \ - $(abs_builddir)/src/.libs/libwolfssl.[0-9]*.dylib \ - $(abs_builddir)/src/.libs/libwolfssl.dylib \ + $(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; \ diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index caee41b0889..c4f408c4df0 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -18,6 +18,7 @@ import tempfile import unittest import uuid +from datetime import datetime, timedelta, timezone from importlib.machinery import SourceFileLoader @@ -226,15 +227,24 @@ def test_two_calls_with_same_sde_match(self): def test_invalid_sde_falls_back_to_now(self): os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' dt, ts = gs.build_timestamp() - # Should still produce a UTC ISO-Z timestamp; we only check shape. + # 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) - _, ts = gs.build_timestamp() + 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): @@ -257,6 +267,27 @@ def test_missing_file_exits(self): 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', From a4dbbbad94e972e298fed95dd7b50428026c9309 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 29 Apr 2026 17:51:59 +0300 Subject: [PATCH 08/39] chore(sbom): minor cleanups from code review Refresh stale Makefile.am comment about SBOM_LICENSE_TEXT, clarify build_extracted_licensing_infos docstring, and replace a hardcoded wolfssl-5.9.1.spdx.json check with the wildcard glob used elsewhere. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 7 ++++--- Makefile.am | 4 ++-- scripts/gen-sbom | 4 ++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f1f558a75d0..466358eb284 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -220,9 +220,10 @@ jobs: grep -q 'license-text was not provided' /tmp/err || \ { echo "FAIL: error message missing actionable hint"; \ cat /tmp/err; exit 1; } - test ! -f wolfssl-5.9.1.spdx.json || \ - { echo "FAIL: SBOM file should not exist after refusal"; \ - 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: | diff --git a/Makefile.am b/Makefile.am index 6659bc82a95..d8c16909dca 100644 --- a/Makefile.am +++ b/Makefile.am @@ -461,8 +461,8 @@ WOLFSSL_LIB_DSO_BASENAMES = \ # 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; without it the SBOM embeds -# a placeholder and validators may reject it. +# LicenseRef is in use; `make sbom` exits with an +# error if it is missing. sbom: @if test -z "$(PYTHON3)"; then \ echo ""; \ diff --git a/scripts/gen-sbom b/scripts/gen-sbom index d5295c68184..56dd5e91f06 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -126,6 +126,10 @@ def build_extracted_licensing_infos(license_expr, license_text): `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: From 7712984f18ebe18685a3b87e64b722a0616dcbb6 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 1 May 2026 17:41:46 +0300 Subject: [PATCH 09/39] fix(sbom): adapt to upstream removal of liboqs/libxmss/liblms gates Drop dead --dep-libxmss/liblms args after PRs #10292/#10293 removed those autoconf vars. --- Makefile.am | 6 +----- configure.ac | 5 +---- doc/SBOM.md | 15 ++++++--------- scripts/gen-sbom | 46 +++++++++------------------------------------- 4 files changed, 17 insertions(+), 55 deletions(-) diff --git a/Makefile.am b/Makefile.am index d8c16909dca..8f5e1a3c167 100644 --- a/Makefile.am +++ b/Makefile.am @@ -513,12 +513,8 @@ sbom: --license-text '$(SBOM_LICENSE_TEXT)' \ --options-h $(abs_builddir)/wolfssl/options.h \ --lib "$$sbom_lib" \ - --dep-liboqs $(ENABLED_LIBOQS) \ - --dep-libxmss $(ENABLED_LIBXMSS) \ - --dep-libxmss-root '$(XMSS_ROOT)' \ - --dep-liblms $(ENABLED_LIBLMS) \ - --dep-liblms-root '$(LIBLMS_ROOT)' \ --dep-libz $(ENABLED_LIBZ) \ + --dep-falcon $(ENABLED_FALCON) \ --git '$(GIT)' \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ diff --git a/configure.ac b/configure.ac index 7c6c2773e68..8022b5e09bb 100644 --- a/configure.ac +++ b/configure.ac @@ -12499,11 +12499,8 @@ AC_SUBST([WOLFSSL_INCLUDEDIR_ABS]) AC_PATH_PROG([PYTHON3], [python3]) AC_PATH_PROG([PYSPDXTOOLS], [pyspdxtools]) AC_PATH_PROG([GIT], [git]) -AC_SUBST([ENABLED_LIBOQS]) -AC_SUBST([ENABLED_LIBXMSS]) -AC_SUBST([ENABLED_LIBLMS]) AC_SUBST([ENABLED_LIBZ]) -AC_SUBST([LIBLMS_ROOT]) +AC_SUBST([ENABLED_FALCON]) # Bomsh (OmniBOR build artifact tracing + SBOM enrichment) AC_PATH_PROG([BOMTRACE3], [bomtrace3]) diff --git a/doc/SBOM.md b/doc/SBOM.md index 5dabf1af553..06cd2e3b3e1 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -118,15 +118,12 @@ make sbom \ #### External dependency version detection -For dependencies with pkg-config support (`liboqs`, `libz`), the version is -queried via `pkg-config --modversion` at generation time. - -For dependencies without pkg-config (`libxmss`, `liblms`), wolfSSL is -typically built against a source checkout rather than an installed package. -The generator falls back to `git describe --tags --always` on the source -tree root (passed via `configure` as `XMSS_ROOT` / `LIBLMS_ROOT`). If the -source tree has no tags, `git describe` returns the short commit hash, which -is recorded as-is. If the source tree is unavailable or `git` is not found: +The remaining optional external dependencies (`libz`, and `falcon` via +`liboqs`) are both installed packages and are queried via +`pkg-config --modversion` at SBOM generation time. + +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 diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 56dd5e91f06..00b92b975c9 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -49,30 +49,17 @@ def build_timestamp(): # Known metadata for optional external dependencies. # Version is detected at runtime via pkg-config; falls back to None. DEP_META = { - 'liboqs': { - 'name': 'liboqs', - 'supplier': 'Open Quantum Safe', + # Falcon is reachable only via liboqs after upstream PR #10293 collapsed + # the rest of the PQ surface into native wolfCrypt; we record the version + # of liboqs itself since that is the artefact actually linked in. + 'falcon': { + 'name': 'falcon', + 'supplier': 'Open Quantum Safe (via liboqs)', 'license': 'MIT', 'download': 'https://github.com/open-quantum-safe/liboqs', 'pkgconfig': 'liboqs', 'purl': lambda v: f'pkg:github/open-quantum-safe/liboqs@{v}', }, - 'libxmss': { - 'name': 'xmss-reference', - 'supplier': 'XMSS reference implementation authors', - 'license': 'CC0-1.0', - 'download': 'https://github.com/XMSS/xmss-reference', - 'pkgconfig': None, - 'purl': lambda v: f'pkg:github/XMSS/xmss-reference@{v}', - }, - 'liblms': { - 'name': 'hash-sigs', - 'supplier': 'Cisco Systems', - 'license': 'MIT', - 'download': 'https://github.com/cisco/hash-sigs', - 'pkgconfig': None, - 'purl': lambda v: f'pkg:github/cisco/hash-sigs@{v}', - }, 'libz': { 'name': 'zlib', 'supplier': 'Jean-loup Gailly and Mark Adler', @@ -486,18 +473,10 @@ def main(): 'licence reference.') parser.add_argument('--options-h', required=True, help='Path to wolfssl/options.h for build config') - parser.add_argument('--dep-liboqs', default='no', - help='yes if built with --with-liboqs') - parser.add_argument('--dep-libxmss', default='no', - help='yes if built with --with-libxmss') - parser.add_argument('--dep-libxmss-root', default='', - help='Path to xmss-reference source tree root') - parser.add_argument('--dep-liblms', default='no', - help='yes if built with --with-liblms') - parser.add_argument('--dep-liblms-root', default='', - help='Path to hash-sigs source tree root') parser.add_argument('--dep-libz', default='no', help='yes if built with --with-libz') + parser.add_argument('--dep-falcon', default='no', + help='yes if built with --enable-falcon (Falcon via liboqs)') parser.add_argument('--git', default='', help='Path to git binary for version detection') parser.add_argument('--cdx-out', required=True, @@ -509,17 +488,10 @@ def main(): global GIT_BIN GIT_BIN = args.git or None - if args.dep_libxmss_root: - DEP_META['libxmss']['git_root'] = args.dep_libxmss_root - if args.dep_liblms_root: - DEP_META['liblms']['git_root'] = args.dep_liblms_root - enabled_deps = [ key for key, flag in [ - ('liboqs', args.dep_liboqs), - ('libxmss', args.dep_libxmss), - ('liblms', args.dep_liblms), ('libz', args.dep_libz), + ('falcon', args.dep_falcon), ] if flag.lower() == 'yes' ] From 1896ad0473e4e88a2ea08bee76a9ccca977485e1 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 1 May 2026 17:41:46 +0300 Subject: [PATCH 10/39] fix(sbom): record liboqs as linked artefact, not algorithm name=falcon+purl=liboqs is unresolvable for OSV/Grype/Trivy; switch to liboqs (HAVE_FALCON stays as build property). --- Makefile.am | 2 +- configure.ac | 2 +- doc/SBOM.md | 9 +++++++-- scripts/gen-sbom | 31 +++++++++++++++++++------------ 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/Makefile.am b/Makefile.am index 8f5e1a3c167..c74c67dda74 100644 --- a/Makefile.am +++ b/Makefile.am @@ -514,7 +514,7 @@ sbom: --options-h $(abs_builddir)/wolfssl/options.h \ --lib "$$sbom_lib" \ --dep-libz $(ENABLED_LIBZ) \ - --dep-falcon $(ENABLED_FALCON) \ + --dep-liboqs $(ENABLED_LIBOQS) \ --git '$(GIT)' \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ diff --git a/configure.ac b/configure.ac index 8022b5e09bb..f9dc5307f5d 100644 --- a/configure.ac +++ b/configure.ac @@ -12500,7 +12500,7 @@ AC_PATH_PROG([PYTHON3], [python3]) AC_PATH_PROG([PYSPDXTOOLS], [pyspdxtools]) AC_PATH_PROG([GIT], [git]) AC_SUBST([ENABLED_LIBZ]) -AC_SUBST([ENABLED_FALCON]) +AC_SUBST([ENABLED_LIBOQS]) # Bomsh (OmniBOR build artifact tracing + SBOM enrichment) AC_PATH_PROG([BOMTRACE3], [bomtrace3]) diff --git a/doc/SBOM.md b/doc/SBOM.md index 06cd2e3b3e1..90c343e2f53 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -118,9 +118,14 @@ make sbom \ #### External dependency version detection -The remaining optional external dependencies (`libz`, and `falcon` via +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. +`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): diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 00b92b975c9..4be5dc4dccd 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -46,15 +46,20 @@ def build_timestamp(): 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. +# 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 = { - # Falcon is reachable only via liboqs after upstream PR #10293 collapsed - # the rest of the PQ surface into native wolfCrypt; we record the version - # of liboqs itself since that is the artefact actually linked in. - 'falcon': { - 'name': 'falcon', - 'supplier': 'Open Quantum Safe (via liboqs)', + # 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', @@ -475,8 +480,10 @@ def main(): 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-falcon', default='no', - help='yes if built with --enable-falcon (Falcon via liboqs)') + 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('--git', default='', help='Path to git binary for version detection') parser.add_argument('--cdx-out', required=True, @@ -490,8 +497,8 @@ def main(): enabled_deps = [ key for key, flag in [ - ('libz', args.dep_libz), - ('falcon', args.dep_falcon), + ('libz', args.dep_libz), + ('liboqs', args.dep_liboqs), ] if flag.lower() == 'yes' ] From 9e09de42e0db0dd51748e2e2d1515996b6ca56ce Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 1 May 2026 17:41:46 +0300 Subject: [PATCH 11/39] refactor(sbom): drop unreachable git_root fallback Both live DEP_META entries (libz, liboqs) are pkg-config; the git-describe path was dead. --- Makefile.am | 1 - scripts/gen-sbom | 42 +++++++++++------------------------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/Makefile.am b/Makefile.am index c74c67dda74..a02dd7ecf78 100644 --- a/Makefile.am +++ b/Makefile.am @@ -515,7 +515,6 @@ sbom: --lib "$$sbom_lib" \ --dep-libz $(ENABLED_LIBZ) \ --dep-liboqs $(ENABLED_LIBOQS) \ - --git '$(GIT)' \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 4be5dc4dccd..95380252138 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -207,9 +207,6 @@ def sha256_file(path): return h.hexdigest() -GIT_BIN = None - - def pkgconfig_version(pkgname): """Return version string from pkg-config, or None if unavailable.""" try: @@ -224,30 +221,18 @@ def pkgconfig_version(pkgname): return None -def git_describe_version(root, git_bin): - """Return version from git describe --tags --always, or None.""" - if not root or not git_bin: - return None - try: - r = subprocess.run( - [git_bin, '-C', root, 'describe', '--tags', '--always'], - capture_output=True, text=True - ) - if r.returncode == 0: - return r.stdout.strip() - except FileNotFoundError: - pass - return None - - def dep_version(key): - pkgname = DEP_META[key]['pkgconfig'] - if pkgname: - return pkgconfig_version(pkgname) - git_root = DEP_META[key].get('git_root') - if git_root: - return git_describe_version(git_root, GIT_BIN) - return None + """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): @@ -484,17 +469,12 @@ def main(): help='yes if built with --with-liboqs (the package ' 'wolfSSL links against; --enable-falcon implies ' 'this in any legal configuration)') - parser.add_argument('--git', default='', - help='Path to git binary for version detection') parser.add_argument('--cdx-out', required=True, help='Output path for CycloneDX JSON') parser.add_argument('--spdx-out', required=True, help='Output path for SPDX JSON') args = parser.parse_args() - global GIT_BIN - GIT_BIN = args.git or None - enabled_deps = [ key for key, flag in [ ('libz', args.dep_libz), From f1f6a4600d5606fa552919600944f563d8bc72cd Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 1 May 2026 17:41:46 +0300 Subject: [PATCH 12/39] test(sbom): liboqs/bomsh CI coverage, macOS PATH fix, dep regressions Adds liboqs+bomsh CI jobs, locks DEP_META shape via 5 new tests, ships test_gen_sbom.py in dist. --- .github/workflows/sbom.yml | 193 +++++++++++++++++++++++++++++++++++-- scripts/include.am | 4 + scripts/test_gen_sbom.py | 79 +++++++++++++++ 3 files changed, 269 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 466358eb284..84aed2a2179 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -259,6 +259,85 @@ jobs: 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) @@ -310,13 +389,14 @@ jobs: brew install autoconf automake libtool python3 -m pip install --user --break-system-packages \ 'spdx-tools==0.8.*' - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - # On some macOS runners pyspdxtools lands in - # Library/Python//bin; symlink to a known-on-PATH location. - for d in "$HOME/Library/Python"/*/bin; do - [ -x "$d/pyspdxtools" ] && \ - echo "$d" >> "$GITHUB_PATH" - done + # 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 @@ -334,3 +414,102 @@ jobs: 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/scripts/include.am b/scripts/include.am index 6e476b239bc..f71bb481039 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -173,3 +173,7 @@ EXTRA_DIST += scripts/user_settings_asm.sh # 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 index c4f408c4df0..8de58a91694 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -336,5 +336,84 @@ def test_dedup_keeps_last_assignment(self): 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) From 22cf8a3fa2aea004e3e0aebf9da81e50466f048e Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 5 May 2026 12:23:12 +0300 Subject: [PATCH 13/39] feat(sbom): standalone gen-sbom for embedded / RTOS builds Adds --user-settings (pcpp), --srcs (Merkle/OmniBOR), --dep-version, noise filter with NO_/USE_ carve-out, and pcpp #error fail-fast so embedded customers can produce a CRA SBOM without autoconf or .a. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 302 +++++++++++++++++ INSTALL | 44 ++- README.md | 12 +- doc/CRA.md | 40 ++- doc/SBOM.md | 413 +++++++++++++++++++++-- scripts/gen-sbom | 460 +++++++++++++++++++++++-- scripts/test_gen_sbom.py | 665 +++++++++++++++++++++++++++++++++++++ 7 files changed, 1877 insertions(+), 59 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 84aed2a2179..a862cdc9faa 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -30,6 +30,308 @@ jobs: - name: Unit tests run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_sbom.py -v + # Tier 2 (standalone) - the embedded entry point: gen-sbom invoked + # directly without autotools, against a real wolfSSL user_settings.h + # plus a representative source set. Mirrors how a Keil / IAR / + # STM32CubeIDE / ESP-IDF / Zephyr customer would call it from a + # post-build step. Without this job the standalone path would only + # be exercised by hand and a regression in --user-settings, + # --srcs, or --dep-version handling would silently land. + standalone: + name: SBOM standalone (no autotools) + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install standalone-path deps + # pcpp is the in-Python C preprocessor that lets gen-sbom walk + # settings.h + user_settings.h with no compiler invocation. + # spdx-tools is for the post-generation validation step. + run: | + python3 -m pip install --user --upgrade pip + python3 -m pip install --user pcpp 'spdx-tools==0.8.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Generate SBOM via standalone Python entry point + # Uses IDE/GCC-ARM/Header/user_settings.h as the fixture - it + # is a real, comprehensive embedded user_settings.h shipping in + # the tree, so any CI failure here represents a regression a + # real customer would hit. Source set is a small but + # representative slice of wolfcrypt that does not depend on + # any pre-build code generation. + # + # The two `NO_*_H` predefines exercise the noise-filter + # `_CONFIG_H_TOKENS` carve-out end-to-end: a NETOS / Telit / + # similar RTOS profile sets these in user_settings.h to disable + # stdlib header inclusion, and an over-aggressive header-guard + # filter would silently drop them from the SBOM (see the + # corresponding row in `required` below). + run: | + mkdir -p /tmp/standalone + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings wolfssl/wolfcrypt/settings.h \ + --user-settings-include . \ + --user-settings-include IDE/GCC-ARM/Header \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --user-settings-define NO_STDINT_H \ + --user-settings-define WOLFSSL_NO_ASSERT_H \ + --srcs wolfcrypt/src/aes.c \ + wolfcrypt/src/sha.c \ + wolfcrypt/src/sha256.c \ + wolfcrypt/src/dh.c \ + --cdx-out /tmp/standalone/wolfssl.cdx.json \ + --spdx-out /tmp/standalone/wolfssl.spdx.json + + - name: Standalone SPDX validates per pyspdxtools + # Same validator the autotools `make sbom` recipe runs. If + # the embedded path produces an SBOM autotools' validator + # rejects, our portability claim is false. + run: pyspdxtools --infile /tmp/standalone/wolfssl.spdx.json + + - name: Standalone SBOM advertises source-merkle hash semantics + # The auditor-facing contract: the standalone SBOM must say + # "this checksum is over a source set, not a library binary", + # and must list which sources fed the hash. Without these + # properties the SHA-256 in `hashes` is ambiguous to anyone + # reviewing the SBOM. + # + # The build-property assertion is a pinned set rather than a + # `len > N` smoke for two reasons: + # 1. A noise-filter regression that drops 80% of the wolfSSL + # config flags but keeps 21 unrelated names would still + # pass a length check, and silently ship an SBOM that + # misrepresents the build to a CRA reviewer. + # 2. The pinned set covers three distinct filter paths: + # - regular config (no `_H` suffix): SINGLE_THREADED, + # WOLFSSL_USER_SETTINGS, USE_FAST_MATH, NO_FILESYSTEM, + # WOLFSSL_SMALL_STACK, NO_DEV_RANDOM + # - `_H`-suffix carve-out via `--user-settings-define`: + # NO_STDINT_H, WOLFSSL_NO_ASSERT_H. These are the + # regression sentinels for the bug fixed in this PR + # (header-guard filter dropping NETOS/Telit-style + # stdlib disablement flags); a regression of + # `_CONFIG_H_TOKENS` in gen-sbom would surface here. + run: | + python3 - <<'PY' + import json + with open('/tmp/standalone/wolfssl.cdx.json') as f: + cdx = json.load(f) + props = {p['name']: p['value'] + for p in cdx['metadata']['component']['properties']} + assert props.get('wolfssl:sbom:hash-kind') == 'source-merkle-omnibor', \ + props + srcs = props['wolfssl:sbom:source-set'].split(',') + assert sorted(srcs) == ['aes.c', 'dh.c', 'sha.c', 'sha256.c'], srcs + + build_prop_names = { + k.split(':', 2)[-1] + for k in props if k.startswith('wolfssl:build:') + } + # Regular wolfSSL config flags (no `_H` suffix). Each is set + # by the GCC-ARM/Header user_settings.h fixture or by the + # --user-settings-define WOLFSSL_USER_SETTINGS predefine. + required_regular = { + 'WOLFSSL_USER_SETTINGS', # the gate the customer set in CFLAGS + 'SINGLE_THREADED', # IDE/GCC-ARM/Header/user_settings.h + 'USE_FAST_MATH', # IDE/GCC-ARM/Header/user_settings.h + 'NO_FILESYSTEM', # IDE/GCC-ARM/Header/user_settings.h + 'WOLFSSL_SMALL_STACK', # IDE/GCC-ARM/Header/user_settings.h + 'NO_DEV_RANDOM', # IDE/GCC-ARM/Header/user_settings.h + } + # `_H`-suffix carve-out sentinels - injected via + # --user-settings-define above so a regression of + # `_CONFIG_H_TOKENS` in gen-sbom (i.e. the bug that this PR + # fixes) blows up CI rather than only the unit tests. + required_h_carveout = { + 'NO_STDINT_H', # NETOS / Telit stdlib disablement + 'WOLFSSL_NO_ASSERT_H', # gates types.h:2132 + } + required = required_regular | required_h_carveout + missing = required - build_prop_names + assert not missing, ( + f'pinned wolfSSL config flags missing from SBOM ' + f'(pcpp + noise filter regression?): {missing}\n' + f' - regular missing : {required_regular - build_prop_names}\n' + f' - _H carve-out missing: {required_h_carveout - build_prop_names}\n' + f'present subset: {build_prop_names & required}' + ) + # No host-leak / Apple TargetConditionals / __* internals. + import re + forbidden = [n for n in build_prop_names + if re.match(r'(?:__|_[A-Z]|TARGET_OS_|TARGET_IPHONE_)', + n)] + assert not forbidden, ( + f'host-leak macros present in SBOM (noise filter ' + f'regression?): {forbidden[:10]}' + ) + print(f'standalone SBOM ok: {len(build_prop_names)} build props ' + f'(all {len(required_regular)} regular + ' + f'{len(required_h_carveout)} carve-out flags present, ' + f'no host-leak names), {len(srcs)} source files') + PY + + - name: Reproducibility - two standalone runs are byte-identical + # The deterministic UUID + SOURCE_DATE_EPOCH machinery applies + # to both entry points; this guards against a future change + # accidentally introducing wallclock or random data into the + # standalone path. Predefines must match the generate step + # exactly; any drift here would diff against the original run. + run: | + mkdir -p /tmp/standalone-r2 + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings wolfssl/wolfcrypt/settings.h \ + --user-settings-include . \ + --user-settings-include IDE/GCC-ARM/Header \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --user-settings-define NO_STDINT_H \ + --user-settings-define WOLFSSL_NO_ASSERT_H \ + --srcs wolfcrypt/src/aes.c \ + wolfcrypt/src/sha.c \ + wolfcrypt/src/sha256.c \ + wolfcrypt/src/dh.c \ + --cdx-out /tmp/standalone-r2/wolfssl.cdx.json \ + --spdx-out /tmp/standalone-r2/wolfssl.spdx.json + diff /tmp/standalone/wolfssl.cdx.json \ + /tmp/standalone-r2/wolfssl.cdx.json + diff /tmp/standalone/wolfssl.spdx.json \ + /tmp/standalone-r2/wolfssl.spdx.json + + - name: --dep-version override (no pkg-config needed) + # The whole point of --dep-version is to let cross-compile / + # baremetal hosts emit a dep version when pkg-config is + # unavailable for the target. Asserts the value lands in the + # SBOM dep entry instead of NOASSERTION. + run: | + mkdir -p /tmp/standalone-deps + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings wolfssl/wolfcrypt/settings.h \ + --user-settings-include . \ + --user-settings-include IDE/GCC-ARM/Header \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs wolfcrypt/src/aes.c wolfcrypt/src/sha.c \ + --dep-libz yes \ + --dep-version libz=1.3.1 \ + --cdx-out /tmp/standalone-deps/wolfssl.cdx.json \ + --spdx-out /tmp/standalone-deps/wolfssl.spdx.json + python3 - <<'PY' + import json + with open('/tmp/standalone-deps/wolfssl.spdx.json') as f: + d = json.load(f) + deps = {p['name']: p for p in d['packages'] if p['name'] != 'wolfssl'} + assert 'zlib' in deps, list(deps) + assert deps['zlib']['versionInfo'] == '1.3.1', deps['zlib'] + print('--dep-version override ok: zlib@1.3.1') + PY + + - name: --options-h escape hatch ($CC -dM -E, no pcpp) + # The doc/SBOM.md § 1.5 escape hatch for toolchains that cannot + # install pcpp (older Keil / IAR sites with restricted pip + # access): pre-process settings.h with the system compiler's + # `-dM -E` macro-dump mode and feed the resulting flat #define + # list to gen-sbom via --options-h. This step proves the path + # actually works end-to-end and that the noise filter scrubs + # the host-leak macros (__VERSION__, __SSE2__, TARGET_OS_*, + # ...) that `gcc -dM -E` always emits alongside the wolfSSL + # config. + run: | + mkdir -p /tmp/standalone-dme + # Same effective build the pcpp step covered above; the only + # difference is the macro-extraction mechanism. The two + # `-D NO_*_H` predefines mirror the pcpp step and pin the + # `_CONFIG_H_TOKENS` carve-out on the no-pcpp path too. + gcc -dM -E \ + -I . -I IDE/GCC-ARM/Header \ + -DWOLFSSL_USER_SETTINGS \ + -DNO_STDINT_H \ + -DWOLFSSL_NO_ASSERT_H \ + -include wolfssl/wolfcrypt/settings.h \ + -x c /dev/null > /tmp/standalone-dme/options.h + + # Defensive: the value of this whole step is that the noise + # filter scrubs `gcc -dM -E`'s host-leak macros. If a future + # GCC / runner image happened to emit no `__*` defines, the + # `forbidden` assertion below would pass vacuously even with + # the noise filter disabled. Confirm the raw dump actually + # contains plenty of host-leak names, otherwise this step + # is not actually testing what it claims to test. + raw_underscores=$(grep -cE '^#define[[:space:]]+(__|_[A-Z])' \ + /tmp/standalone-dme/options.h || true) + echo "raw -dM -E dump has $raw_underscores compiler-reserved defines" + test "$raw_underscores" -ge 50 || { + echo "ERROR: --options-h CI step is not exercising the noise" + echo " filter (raw dump has only $raw_underscores" + echo " compiler-reserved defines; expected >= 50)." + exit 1 + } + + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --options-h /tmp/standalone-dme/options.h \ + --srcs wolfcrypt/src/aes.c \ + wolfcrypt/src/sha.c \ + wolfcrypt/src/sha256.c \ + wolfcrypt/src/dh.c \ + --cdx-out /tmp/standalone-dme/wolfssl.cdx.json \ + --spdx-out /tmp/standalone-dme/wolfssl.spdx.json + + # Validate + assert the same wolfSSL config flags reach the + # SBOM via the no-pcpp path that the pcpp path produced + # above. If the noise filter regresses, this step is what + # surfaces it (the raw `gcc -dM -E` dump contains hundreds + # of host-leak macros and only a handful of wolfSSL ones). + pyspdxtools --infile /tmp/standalone-dme/wolfssl.spdx.json + python3 - <<'PY' + import json, re + with open('/tmp/standalone-dme/wolfssl.cdx.json') as f: + cdx = json.load(f) + props = {p['name']: p['value'] + for p in cdx['metadata']['component']['properties']} + build_prop_names = { + k.split(':', 2)[-1] + for k in props if k.startswith('wolfssl:build:') + } + required_regular = { + 'WOLFSSL_USER_SETTINGS', 'SINGLE_THREADED', 'USE_FAST_MATH', + 'NO_FILESYSTEM', 'WOLFSSL_SMALL_STACK', 'NO_DEV_RANDOM', + } + required_h_carveout = { + 'NO_STDINT_H', 'WOLFSSL_NO_ASSERT_H', + } + required = required_regular | required_h_carveout + missing = required - build_prop_names + assert not missing, ( + f'--options-h path lost wolfSSL config flags: {missing}\n' + f' - regular missing : {required_regular - build_prop_names}\n' + f' - _H carve-out missing: {required_h_carveout - build_prop_names}\n' + f'present subset: {build_prop_names & required}' + ) + forbidden = [n for n in build_prop_names + if re.match(r'(?:__|_[A-Z]|TARGET_OS_|TARGET_IPHONE_)', + n)] + assert not forbidden, ( + f'host-leak macros from `gcc -dM -E` dump survived the ' + f'noise filter: {forbidden[:10]}' + ) + print(f'--options-h path ok: {len(build_prop_names)} build ' + f'props (all {len(required_regular)} regular + ' + f'{len(required_h_carveout)} carve-out flags present, ' + f'host-leak macros filtered)') + PY + # Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert # everything an external auditor or vulnerability scanner relies on. integration: diff --git a/INSTALL b/INSTALL index 17e37f56db7..c82ffa13e3b 100644 --- a/INSTALL +++ b/INSTALL @@ -317,7 +317,45 @@ We also have vcpkg ports for wolftpm, wolfmqtt and curl. 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. + Act (CRA) compliance. Two entry points are supported, depending on how + you build wolfSSL. + + --- 19a. Embedded / RTOS / IDE-based builds (no autotools) ---------- + + For customers building wolfSSL from a hand-edited user_settings.h with + their own Makefile, Keil MDK, IAR EWARM, STM32CubeIDE, ESP-IDF, + Zephyr, or plain CMake, invoke scripts/gen-sbom directly. No + ./configure, no autotools. + + Prerequisites: + - python3 + - pcpp (pip install pcpp) # required for --user-settings + - spdx-tools (pip install spdx-tools) # optional; for SPDX validation + + Usage: + + $ python3 wolfssl/scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file wolfssl/LICENSING \ + --user-settings wolfssl/wolfssl/wolfcrypt/settings.h \ + --user-settings-include wolfssl \ + --user-settings-include path/to/your/user_settings_dir \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs wolfssl/wolfcrypt/src/aes.c [...your wolfssl source list] \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json + + The component checksum is a deterministic OmniBOR-compatible Merkle + hash over the source files you compile into your firmware, so you do + not need to synthesize a separate libwolfssl.a just for SBOM purposes. + + See doc/SBOM.md section 1 for per-toolchain recipes (Keil, IAR, + STM32CubeIDE, ESP-IDF, Zephyr, CMake) and the full flag reference. + + --- 19b. Linux / autotools builds ---------------------------------- + + For Debian, RPM, Yocto, FIPS-Ready, and other builds that already use + ./configure && make: Prerequisites: - python3 (detected automatically by configure) @@ -338,6 +376,10 @@ We also have vcpkg ports for wolftpm, wolfmqtt and curl. The SPDX JSON is validated by pyspdxtools before the tag-value file is written; make sbom fails if validation fails. + `make sbom` is a thin convenience wrapper around the same + scripts/gen-sbom Python entry point that section 19a uses, with all + paths resolved automatically from the autotools build tree. + To install the SBOM files to $(datadir)/doc/wolfssl/: $ make install-sbom diff --git a/README.md b/README.md index a51a2d0b8e8..6135cf99640 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,17 @@ 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. +Act (CRA) compliance via two entry points: + +- `python3 scripts/gen-sbom …` for embedded / RTOS / IDE-based builds + (Keil, IAR, STM32CubeIDE, ESP-IDF, Zephyr, plain CMake, custom Makefile) + configured through a hand-edited `user_settings.h`. No autotools required. +- `make sbom` for Linux server / Debian / RPM / Yocto / FIPS-Ready + builds that already use `./configure && make`. + +Both produce SPDX 2.3 + CycloneDX 1.6 JSON validated against NTIA +minimum elements. See `doc/SBOM.md` for per-toolchain recipes and the +full flag reference. ## OmniBOR / Bomsh diff --git a/doc/CRA.md b/doc/CRA.md index d477d3a1f09..d3cefcf3b8d 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -25,6 +25,40 @@ provides a deeper audit trail if your compliance posture requires it. ## Quick Start +wolfSSL exposes two SBOM entry points, depending on how you build the +library. + +### Embedded / RTOS / IDE-based builds (no `./configure`) + +If your product builds wolfSSL with a hand-edited `user_settings.h` and a +custom Makefile, Keil / IAR / STM32CubeIDE project, ESP-IDF / Zephyr +component, or plain CMake, invoke `scripts/gen-sbom` directly. No +autotools required: + +```sh +python3 wolfssl/scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file wolfssl/LICENSING \ + --user-settings wolfssl/wolfssl/wolfcrypt/settings.h \ + --user-settings-include wolfssl \ + --user-settings-include path/to/your/user_settings_dir \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs wolfssl/wolfcrypt/src/aes.c [and the rest of your wolfssl source list] \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +Requires Python 3 + `pip install pcpp` (used to walk +`user_settings.h` the same way the C compiler does). + +See `doc/SBOM.md` § 1 for per-toolchain recipes (Keil, IAR, +STM32CubeIDE, ESP-IDF, Zephyr, plain CMake) and the full flag reference. + +### Linux / autotools builds + +For Debian / RPM / Yocto / FIPS-Ready / cloud builds that already use +`./configure && make`: + ```sh ./configure make @@ -32,7 +66,11 @@ 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. +`make sbom` is a convenience wrapper around the same `scripts/gen-sbom` +script the embedded path uses. + +See `doc/SBOM.md` for prerequisites and full details on both entry +points. ## What wolfSSL Provides diff --git a/doc/SBOM.md b/doc/SBOM.md index 90c343e2f53..9666792d966 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -5,7 +5,7 @@ transparency: | Artefact | Target | Answers | |---|---|---| -| SBOM (SPDX 2.3 + CycloneDX 1.6) | `make sbom` | *What* wolfSSL is: component identity, license, checksums, CPE, PURL | +| SBOM (SPDX 2.3 + CycloneDX 1.6) | `scripts/gen-sbom` / `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) @@ -13,9 +13,355 @@ 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 +The SBOM generator has two entry points so both customer segments are +covered: -### Component identity only +| Entry point | Who it is for | Build system | +|---|---|---| +| `python3 scripts/gen-sbom …` (standalone) | Embedded / RTOS customers building with their own Makefile, Keil, IAR, STM32CubeIDE, ESP-IDF, Zephyr, plain CMake, etc. | Any | +| `make sbom` (autotools wrapper) | Linux server / Debian / RPM / Yocto / FIPS-Ready customers running `./configure && make` | Autotools | + +Both call the same Python core and produce SBOMs that pass the same SPDX +2.3 / CycloneDX 1.6 / NTIA validators. Pick whichever matches your build +flow. + +--- + +## 1. Standalone Python tool (recommended for embedded / IDE builds) + +`scripts/gen-sbom` is pure Python 3 stdlib (plus an optional `pcpp` dep, +see below). Customers who configure wolfSSL via a hand-edited +`user_settings.h` and link wolfSSL source files directly into firmware +invoke it directly, without running `./configure` or producing a +standalone `libwolfssl.a`. + +### 1.1 Quick start + +```sh +python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --user-settings wolfssl/wolfcrypt/settings.h \ + --user-settings-include . \ + --user-settings-include path/to/your/user_settings_dir \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs wolfcrypt/src/aes.c wolfcrypt/src/sha.c \ + wolfcrypt/src/sha256.c wolfcrypt/src/dh.c \ + wolfcrypt/src/random.c \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +That command produces the same two SBOM JSON files (CycloneDX 1.6 and SPDX +2.3) that `make sbom` produces, with no autotools involvement. + +### 1.2 What you provide + +| Flag | What | Where it comes from | +|---|---|---| +| `--name wolfssl` | Component name | Hard-coded; always `wolfssl` | +| `--version 5.9.1` | Component version | Whatever wolfSSL release you pulled | +| `--license-file LICENSING` | wolfSSL's `LICENSING` file | Already in your wolfSSL source tree | +| `--user-settings wolfssl/wolfcrypt/settings.h` | wolfSSL's master settings header | Already in your wolfSSL source tree | +| `--user-settings-include DIR` (repeatable) | Include path containing your `user_settings.h` and the wolfSSL tree | Same as the `-I` flags in your build | +| `--user-settings-define NAME[=VALUE]` (repeatable) | Macros to predefine for preprocessing | Same as the `-D` flags in your build (at minimum: `-D WOLFSSL_USER_SETTINGS`) | +| `--srcs PATH …` | wolfSSL source files compiled into your firmware | The same source list you pass to your compiler | +| `--cdx-out / --spdx-out` | Output paths for the SBOM JSON files | Anywhere you want | + +Optional flags: + +| Flag | When to use it | +|---|---| +| `--supplier "Acme Inc."` | Override the default `wolfSSL Inc.` (rare) | +| `--dep-libz yes` | If your build links `libz` | +| `--dep-liboqs yes` | If your build links `liboqs` | +| `--dep-version libz=1.3.1` | Explicit dep version when `pkg-config` is unavailable (typical cross-compile) | +| `--license-override LicenseRef-wolfSSL-Commercial` | If you are a commercial licensee, not GPL | +| `--license-text /path/to/commercial-license.txt` | Required when `--license-override` is a `LicenseRef-*` | + +### 1.3 Dependencies + +- **Python 3**. Required. Stdlib only when using `--options-h`. +- **`pcpp`** (`pip install pcpp`). Required only when using + `--user-settings`. pcpp is a pure-Python C preprocessor that walks + `settings.h` and your `user_settings.h` the same way the C compiler + does, so the SBOM build properties reflect the actual compiled + configuration rather than just the literal text of `user_settings.h`. + If pcpp is not available you can pre-process externally with the + compiler and pass the result via `--options-h` (see § 1.5). +- **`pyspdxtools`** (`pip install spdx-tools`). Optional. Needed only + if you want to validate the produced SPDX or convert it to tag-value + form. + +### 1.4 What the SBOM checksum represents + +In a `make sbom` build the `hashes` / `checksums` field in the SBOM is +the SHA-256 of `libwolfssl.so` / `libwolfssl.a` / `libwolfssl.dylib`. + +In an embedded build there is typically no separate library archive — +wolfSSL `.c` files are compiled directly into your firmware. Asking the +customer to synthesize a `.a` purely for SBOM purposes would be artificial +and would make the build harder. Instead, the standalone path computes +an OmniBOR-compatible Merkle hash over the wolfSSL source files you list +in `--srcs`: + +1. For each source file, compute its OmniBOR `gitoid` (SHA-256 over + `"blob \0" + filecontents`, byte-identical to + `git hash-object --object-format=sha256`). +2. Sort by basename. +3. Hash the concatenated `(basename, gitoid)` pairs. + +The resulting hash: + +- represents *"the wolfSSL source code that is in this firmware"*, which + is what an auditor actually wants to see; +- is independent of the order you pass `--srcs`, the absolute paths on + the build host, or the build host's filesystem; +- changes if any compiled-in source byte changes (catches tampering and + back-ported patches); +- interoperates with bomsh / OmniBOR tooling, which key off the same + gitoid format. + +Each standalone SBOM is annotated with two extra properties so the +checksum's semantics are unambiguous to downstream consumers: + +```json +{ "name": "wolfssl:sbom:hash-kind", "value": "source-merkle-omnibor" }, +{ "name": "wolfssl:sbom:source-set", "value": "aes.c,dh.c,sha.c,sha256.c,..." } +``` + +The autotools `make sbom` path keeps `wolfssl:sbom:hash-kind` implicit +(equal to `library-binary`) so its output stays byte-identical to +previous releases. + +### 1.5 Pre-processed defines (no pcpp needed) + +If `pcpp` is unavailable on your build host or you prefer to use the +compiler that actually builds wolfSSL, dump its post-preprocessor `#define` +table and pass that to `--options-h`: + +```sh +$CC $CFLAGS -dM -E \ + -DWOLFSSL_USER_SETTINGS \ + -include wolfssl/wolfcrypt/settings.h \ + -x c /dev/null > build/wolfssl-defines.h + +python3 scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file LICENSING \ + --options-h build/wolfssl-defines.h \ + --srcs wolfcrypt/src/aes.c wolfcrypt/src/sha.c ... \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +`--options-h` reads any flat C header containing `#define NAME VALUE` +lines, so the GCC / Clang / `armclang` `-dM -E` output drops in directly. +For IAR (`--predef_macros`) or legacy Keil `armcc` (`--list_macros`) the +output may need a one-line `sed` to match the `#define NAME VALUE` +shape. + +**Noise filtering.** A raw `$CC -dM -E` dump contains hundreds of +compiler / preprocessor reserved identifiers (`__VERSION__`, `__SSE2__`, +`__INT_FAST32_MAX__`, `_LP64`, …) and on macOS also Apple's entire +`` family (`TARGET_OS_MAC=1`, `TARGET_IPHONE_*`, +…) which leak in via system header inclusion. These describe the +*build host*, not wolfSSL, and would otherwise drown out the wolfSSL +configuration in the SBOM and break reproducibility across hosts. +`gen-sbom` filters them automatically; the SBOM ends up with only the +`HAVE_*` / `WOLFSSL_*` / `NO_*` / etc. macros that actually describe +the wolfSSL build. See `_is_noise_macro` in `scripts/gen-sbom` for the +exact policy and the test cases in `scripts/test_gen_sbom.py` +(`TestIsNoiseMacro`) for the pinned coverage. + +Header-suffix carve-out: the filter also drops include-guard names +(`*_H` like `WOLF_CRYPT_SETTINGS_H`, `WOLFSSL_OPTIONS_H`) but +preserves real wolfSSL configuration that happens to end in `_H`. +The carve-out tokens are `HAVE_`, `NO_`, and `USE_`, which between +them cover every `_H`-suffixed configuration flag in the wolfSSL +tree: + +* autoconf header probes — `HAVE_STDINT_H`, `WOLFSSL_HAVE_ATOMIC_H`, + `WOLFSSL_HAVE_ASSERT_H`, … +* stdlib disablement (NETOS / Telit / similar RTOS profiles) — + `NO_STDINT_H`, `NO_STDLIB_H`, `NO_LIMITS_H`, `NO_CTYPE_H`, + `NO_STRING_H`, `NO_STDDEF_H`, `WOLFSSL_NO_ASSERT_H` +* build-mode toggles — `USE_FLAT_TEST_H`, `USE_FLAT_BENCHMARK_H` + +Customers using a `_H`-suffixed feature flag that does not carry +one of these tokens (e.g. a debug-only opt-in) should rename it +to drop the `_H` suffix, or open an issue to extend the carve-out. + +The `--user-settings` (pcpp) path applies the same filter, so both +entry points produce semantically equivalent build-property sets for +the same effective configuration. + +**Hard-fail on preprocessing errors.** When the `--user-settings` +path encounters an `#error` directive, an unbalanced `#if`, or a +missing `#include` while walking `settings.h`, `gen-sbom` exits +non-zero rather than emitting a partial SBOM. pcpp would otherwise +print a diagnostic and continue, producing an artefact that silently +omits whatever configuration came after the failure — exactly the +kind of silent drift a CRA reviewer cannot detect. Fix the upstream +issue (or supply the missing `--user-settings-include` / +`--user-settings-define` arguments) and rerun. + +### 1.6 Per-IDE / per-toolchain recipes + +#### 1.6.1 Custom Makefile (most embedded projects) + +Drop these rules into your project Makefile. The `WOLFCRYPT_OBJS` / +`WOLFCRYPT_SRCS` variables almost certainly already exist in your build +since they list the wolfSSL files you compile. + +```makefile +WOLFSSL_DIR ?= ../wolfssl + +build/libwolfssl-sbom.a: $(WOLFCRYPT_OBJS) + $(AR) rcs $@ $^ + +sbom: build/libwolfssl-sbom.a + python3 $(WOLFSSL_DIR)/scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file $(WOLFSSL_DIR)/LICENSING \ + --user-settings $(WOLFSSL_DIR)/wolfssl/wolfcrypt/settings.h \ + --user-settings-include $(WOLFSSL_DIR) \ + --user-settings-include $(USER_SETTINGS_DIR) \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs $(WOLFCRYPT_SRCS) \ + --cdx-out wolfssl-5.9.1.cdx.json \ + --spdx-out wolfssl-5.9.1.spdx.json +``` + +Note: the `.a` here is optional. If you prefer to skip it and rely on +`--srcs` for the checksum (the recommended embedded mode), drop the +`build/libwolfssl-sbom.a` rule and remove its dependency from `sbom:`. + +#### 1.6.2 ESP-IDF (Espressif) + +ESP-IDF builds with CMake/Ninja and exposes a `CMakeLists.txt` per +component. Add a custom target to `components/wolfssl/CMakeLists.txt`: + +```cmake +add_custom_target(wolfssl-sbom + COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen-sbom + --name wolfssl --version 5.9.1 + --license-file ${CMAKE_CURRENT_SOURCE_DIR}/LICENSING + --user-settings ${CMAKE_CURRENT_SOURCE_DIR}/wolfssl/wolfcrypt/settings.h + --user-settings-include ${CMAKE_CURRENT_SOURCE_DIR} + --user-settings-include ${WOLFSSL_USER_SETTINGS_DIR} + --user-settings-define WOLFSSL_USER_SETTINGS + --srcs ${WOLFSSL_SRCS} + --cdx-out ${CMAKE_BINARY_DIR}/wolfssl-5.9.1.cdx.json + --spdx-out ${CMAKE_BINARY_DIR}/wolfssl-5.9.1.spdx.json + VERBATIM) +``` + +Then `idf.py wolfssl-sbom` produces both SBOM files in the build +directory. + +#### 1.6.3 Zephyr + +```cmake +# In your application CMakeLists.txt or a Zephyr module CMake file: +add_custom_target(wolfssl-sbom + COMMAND ${PYTHON_EXECUTABLE} ${ZEPHYR_WOLFSSL_MODULE_DIR}/scripts/gen-sbom + --name wolfssl --version 5.9.1 + --license-file ${ZEPHYR_WOLFSSL_MODULE_DIR}/LICENSING + --user-settings ${ZEPHYR_WOLFSSL_MODULE_DIR}/wolfssl/wolfcrypt/settings.h + --user-settings-include ${ZEPHYR_WOLFSSL_MODULE_DIR} + --user-settings-define WOLFSSL_USER_SETTINGS + --srcs ${WOLFSSL_SOURCES} + --cdx-out ${CMAKE_BINARY_DIR}/wolfssl.cdx.json + --spdx-out ${CMAKE_BINARY_DIR}/wolfssl.spdx.json) +``` + +Run with `west build -t wolfssl-sbom`. + +#### 1.6.4 STM32CubeIDE + +STM32CubeIDE generates Eclipse CDT-managed Makefiles. Add the SBOM +recipe as a *post-build step*: **Project → Properties → C/C++ Build → +Settings → Build Steps → Post-build steps**: + +```sh +python3 ${ProjDirPath}/Drivers/wolfssl/scripts/gen-sbom \ + --name wolfssl --version 5.9.1 \ + --license-file ${ProjDirPath}/Drivers/wolfssl/LICENSING \ + --user-settings ${ProjDirPath}/Drivers/wolfssl/wolfssl/wolfcrypt/settings.h \ + --user-settings-include ${ProjDirPath}/Drivers/wolfssl \ + --user-settings-include ${ProjDirPath}/Core/Inc \ + --user-settings-define WOLFSSL_USER_SETTINGS \ + --srcs ${ProjDirPath}/Drivers/wolfssl/wolfcrypt/src/aes.c [...] \ + --cdx-out ${ProjDirPath}/wolfssl.cdx.json \ + --spdx-out ${ProjDirPath}/wolfssl.spdx.json +``` + +#### 1.6.5 Keil μVision (MDK-ARM) + +Use **Options for Target → User → Run #1 (After Build)**: + +``` +python3 .\Drivers\wolfssl\scripts\gen-sbom --name wolfssl --version 5.9.1 ^ + --license-file .\Drivers\wolfssl\LICENSING ^ + --user-settings .\Drivers\wolfssl\wolfssl\wolfcrypt\settings.h ^ + --user-settings-include .\Drivers\wolfssl ^ + --user-settings-define WOLFSSL_USER_SETTINGS ^ + --srcs .\Drivers\wolfssl\wolfcrypt\src\aes.c [...] ^ + --cdx-out .\wolfssl.cdx.json --spdx-out .\wolfssl.spdx.json +``` + +For legacy `armcc` 5.x toolchains where `-dM -E` is not available, use +the modern `armclang` (Keil v6) which is GCC-flag-compatible. + +#### 1.6.6 IAR EWARM + +Use **Project → Options → Build Actions → Post-build command line** (one +line, all on one logical line in EWARM): + +``` +python3 $PROJ_DIR$\..\wolfssl\scripts\gen-sbom --name wolfssl --version 5.9.1 + --license-file $PROJ_DIR$\..\wolfssl\LICENSING + --user-settings $PROJ_DIR$\..\wolfssl\wolfssl\wolfcrypt\settings.h + --user-settings-include $PROJ_DIR$\..\wolfssl + --user-settings-define WOLFSSL_USER_SETTINGS + --srcs $PROJ_DIR$\..\wolfssl\wolfcrypt\src\aes.c [...] + --cdx-out $PROJ_DIR$\wolfssl.cdx.json + --spdx-out $PROJ_DIR$\wolfssl.spdx.json +``` + +#### 1.6.7 Plain CMake (any project) + +```cmake +add_custom_target(wolfssl-sbom + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/wolfssl/scripts/gen-sbom + --name wolfssl --version 5.9.1 + --license-file ${CMAKE_SOURCE_DIR}/wolfssl/LICENSING + --user-settings ${CMAKE_SOURCE_DIR}/wolfssl/wolfssl/wolfcrypt/settings.h + --user-settings-include ${CMAKE_SOURCE_DIR}/wolfssl + --user-settings-include ${WOLFSSL_USER_SETTINGS_DIR} + --user-settings-define WOLFSSL_USER_SETTINGS + --srcs ${WOLFSSL_C_SOURCES} + --cdx-out ${CMAKE_BINARY_DIR}/wolfssl-${WOLFSSL_VERSION}.cdx.json + --spdx-out ${CMAKE_BINARY_DIR}/wolfssl-${WOLFSSL_VERSION}.spdx.json) +``` + +### 1.7 Reproducibility + +The standalone path honors `SOURCE_DATE_EPOCH` exactly the same way +`make sbom` does. Two runs against the same source tree, settings, and +source set with the same `SOURCE_DATE_EPOCH` produce byte-identical +`.spdx.json` and `.cdx.json` files. This is regression-tested in CI. + +--- + +## 2. Autotools convenience wrapper (`make sbom`) + +For Linux server / Debian / RPM / Yocto / FIPS-Ready customers who +already run `./configure && make`, `make sbom` is a one-line shortcut +that wraps `scripts/gen-sbom` with all paths resolved automatically. + +### 2.1 Quick start ```sh ./configure @@ -25,7 +371,7 @@ make sbom Requires `python3` and `pyspdxtools` (`pip install spdx-tools`). -### Full coverage: component identity + build provenance +### 2.2 Full coverage: component identity + build provenance ```sh ./configure @@ -35,16 +381,12 @@ make bomsh ``` Additionally requires `bomtrace3` and `bomsh_create_bom.py` in `PATH`. -See [Prerequisites for make bomsh](#prerequisites-for-make-bomsh) below. +See [Prerequisites for make bomsh](#3-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 +### 2.3 Output files `make sbom` produces three files in the build directory: @@ -58,7 +400,7 @@ 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 +### 2.4 SBOM contents Both formats contain the same information: @@ -134,7 +476,10 @@ its `.pc` file is missing): - CycloneDX omits the `version` and `purl` fields entirely and the generator prints a warning to stderr. -### Validating the SBOM manually +For embedded / cross-compile builds without `pkg-config`, the standalone +entry point exposes a `--dep-version libz=1.3.1` override (see § 1.2). + +### 2.5 Validating the SBOM manually ```sh # Validate SPDX JSON @@ -145,7 +490,7 @@ pyspdxtools --infile wolfssl-.spdx.json \ --outfile wolfssl-.spdx.rdf ``` -### Installing the SBOM +### 2.6 Installing the SBOM ```sh make install-sbom # installs to $(datadir)/doc/wolfssl/ @@ -154,25 +499,31 @@ make uninstall-sbom # removes the installed files The generated files are removed by `make clean`. -### Implementation notes +### 2.7 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. +SBOM generation is implemented in `scripts/gen-sbom` (Python 3, stdlib +only for the autotools path) 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. + +The standalone embedded entry point (§ 1) calls the same script with +different flags; the autotools target is essentially a path-resolver +wrapper that finds the installed library, the autotools-generated +`options.h`, and the `pkg-config` versions of any linked deps. --- -## make bomsh +## 3. 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 +### 3.1 Prerequisites for make bomsh | Tool | Required | Where to get it | |---|---|---| @@ -186,6 +537,12 @@ 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`. +`make bomsh` is **Linux-host-only by design**. For non-Linux build hosts +(macOS, Windows), use a Linux CI runner / WSL2 / a Linux container. The +target running the produced wolfSSL binary can be anything — bomsh traces +the cross-compiler invocation on Linux regardless of what platform the +binary will eventually run on. + #### Building bomtrace3 ```sh @@ -201,7 +558,7 @@ 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 +### 3.2 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 @@ -217,7 +574,7 @@ Place `bomsh_create_bom.py` (and optionally `bomsh_sbom.py`) from the bomsh exists (from `make sbom`), annotates that SPDX document with OmniBOR `ExternalRef` identifiers, producing `omnibor.wolfssl-.spdx.json`. -### Output files +### 3.3 Output files | Path | Description | |---|---| @@ -238,7 +595,7 @@ The `PERSISTENT-ID gitoid` entry added to the enriched SPDX looks like: 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 +### 3.4 Installing ```sh make install-bomsh # installs omnibor/ and enriched SPDX to $(datadir)/doc/wolfssl/ @@ -247,7 +604,7 @@ make uninstall-bomsh # removes installed files The generated files are removed by `make clean`. -### Implementation notes +### 3.5 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 @@ -259,7 +616,7 @@ are written to the build directory and removed by `make clean`. The --- -## Combined workflow +## 4. Combined workflow Running both targets produces the complete set of supply chain transparency artefacts. `make bomsh` automatically enriches the SPDX document from @@ -288,7 +645,7 @@ file. --- -## Using wolfSSL's artefacts in a product +## 5. 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 diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 95380252138..192ccb9fd40 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -3,6 +3,7 @@ import argparse import hashlib +import io import json import os import re @@ -221,23 +222,135 @@ def pkgconfig_version(pkgname): return None -def dep_version(key): +def dep_version(key, overrides=None): """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.""" + Resolution order: + 1. Explicit override from `overrides[key]` (set via the + --dep-version CLI flag). This is the only path that works + for embedded / cross-compile builds where pkg-config is not + available on the host that runs gen-sbom. + 2. `pkg-config --modversion `. Used by the autotools + path on a typical Linux server where the linked dep was + installed via the system package manager. + 3. None. 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.""" + if overrides and key in overrides: + return overrides[key] return pkgconfig_version(DEP_META[key]['pkgconfig']) +# Patterns for #define names that pollute the SBOM with build-environment +# noise rather than wolfSSL configuration. Applied identically to +# parse_options_h (no-pcpp / autotools path) and parse_user_settings +# (pcpp embedded path) so both entry points produce semantically +# equivalent build-property sets for the same effective configuration. +# +# Three families are filtered: +# +# 1. Compiler / preprocessor reserved identifiers (`__*`, `_[A-Z]*`). +# ISO C 7.1.3 reserves these for the implementation; clang, gcc, and +# pcpp emit dozens of them (`__VERSION__`, `__SSE2__`, `_LP64`, ...). +# They describe the build *host*, not wolfSSL, and break SBOM +# reproducibility across hosts (same wolfSSL config built on macOS +# clang vs. arm-none-eabi-gcc otherwise produces different SBOMs). +# +# 2. Apple macros (`TARGET_OS_*`, +# `TARGET_IPHONE_*`). The no-pcpp escape hatch +# (`$CC -dM -E -include settings.h`) on macOS transitively pulls in +# macOS system headers and emits this entire family; without the +# filter, a wolfSSL SBOM for an STM32 firmware would falsely +# advertise TARGET_OS_MAC=1 if generated on a Mac. +# +# 3. Header include guards (`*_H` whose token does NOT carry an +# autoconf / wolfSSL configuration prefix). +# wolfssl/options.h itself and many internal wolfSSL headers define +# guards like WOLFSSL_OPTIONS_H, WOLF_CRYPT_SETTINGS_H, and +# WOLFCRYPT_TEST_*_H to prevent double inclusion. Those describe +# *which file was parsed*, not configuration choices. +# +# The carve-out tokens (`HAVE_`, `NO_`, `USE_`) are critical: real +# wolfSSL configuration flags also end in `_H` and would otherwise +# be silently filtered out, falsifying the SBOM for the customers +# who rely on them most: +# +# * `HAVE_*_H` / `WOLFSSL_HAVE_*_H` - autoconf AC_CHECK_HEADER +# results (HAVE_STDINT_H, WOLFSSL_HAVE_ATOMIC_H, +# WOLFSSL_HAVE_ASSERT_H, ...). Gates `#if defined(...)` +# branches in wc_port.h / types.h. +# * `NO_*_H` / `WOLFSSL_NO_*_H` - explicit stdlib / feature +# suppression (NO_STDINT_H, NO_STDLIB_H, NO_LIMITS_H, +# NO_CTYPE_H, NO_STRING_H, NO_STDDEF_H, WOLFSSL_NO_ASSERT_H). +# Set by NETOS / Telit / other RTOS profiles in settings.h to +# replace stdlib headers with vendor headers; gates branches +# in types.h:398 / settings.h:3850 / sp.h:42. +# * `USE_*_H` - build-mode toggles (USE_FLAT_TEST_H, +# USE_FLAT_BENCHMARK_H). Gates which test/benchmark layout +# is compiled in test.c:165 / benchmark.c:219 / server.c:70. +# +# Heuristic limitation: a stray feature flag that ends in `_H` +# without one of those tokens (e.g. WOLFSSL_DEBUG_TRACE_ERROR_CODES_H, +# a debug-only opt-in) would still be filtered. Customers who +# depend on such a flag can either move it to a non-`_H`-suffixed +# name in their user_settings.h, or feed gen-sbom the full +# `$CC -dM -E` dump via --options-h together with a hand-edited +# add-back file. None of the embedded customer profiles in the +# tree (NETOS, Telit, Zephyr, ESP-IDF, GCC-ARM, MDK, IAR, NUTTX) +# use such flags, which is why we accept the heuristic. +_NOISE_MACRO_RE = re.compile( + r'^(?:' + r'__\w+' # compiler/preprocessor reserved + r'|_[A-Z][A-Z0-9_]*' # ISO C reserved (e.g. _LP64) + r'|TARGET_OS_\w+' # Apple TargetConditionals leak + r'|TARGET_IPHONE_\w+' # Apple TargetConditionals leak + r')$' +) + +# Tokens that, when present anywhere in a `*_H` macro name, mark it as +# real wolfSSL / autoconf configuration rather than a header include +# guard. Kept tight on purpose - widening (e.g. adding `DEBUG_` or +# `WOLFSSL_`) would let through real guards like WOLFSSL_OPTIONS_H. +_CONFIG_H_TOKENS = ('HAVE_', 'NO_', 'USE_') + + +def _is_noise_macro(name): + """True if `name` is a build-environment artefact rather than wolfSSL + configuration, and therefore must not appear as a SBOM + `wolfssl:build:*` property. + + Drops three families (see the module-level comment block on + `_NOISE_MACRO_RE` for full rationale): + 1. Compiler / preprocessor reserved (`__*`, `_[A-Z]*`). + 2. Apple (`TARGET_OS_*`, `TARGET_IPHONE_*`). + 3. Header include guards (`*_H` not carrying any of + `_CONFIG_H_TOKENS`). + """ + if _NOISE_MACRO_RE.match(name): + return True + if name.endswith('_H') and not any(t in name for t in _CONFIG_H_TOKENS): + return True + return False + + def parse_options_h(path): - """Parse wolfssl/options.h and return sorted deduplicated list of - (name, value) pairs for every #define found. + """Parse a flat `#define` header and return a sorted deduplicated + list of (name, value) pairs for every wolfSSL-relevant macro. + + Accepts both autotools-generated `wolfssl/options.h` (curated by + ./configure, contains only wolfSSL macros plus its own header guard) + and raw compiler output from `$CC -dM -E -include settings.h ...` + (the no-pcpp escape hatch documented in doc/SBOM.md § 1.5). The + latter case motivates the `_is_noise_macro` filter: a `clang -dM -E` + dump contains hundreds of compiler internals (`__VERSION__`, + `__SSE2__`, `__INT_FAST32_MAX__`) and Apple system header leaks + (`TARGET_OS_MAC`) that would otherwise drown out the wolfSSL + configuration in the SBOM and break reproducibility across hosts. Trailing C/C++ comments on a #define line (`#define HAVE_FOO 42 /* x */` or `// y`) are stripped; otherwise they would land verbatim in the @@ -251,17 +364,179 @@ def parse_options_h(path): defines = {} for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.*))?$', text, re.MULTILINE): + name = m.group(1) + if _is_noise_macro(name): + continue raw = (m.group(2) or '') raw = re.split(r'/\*|//', raw, maxsplit=1)[0] - defines[m.group(1)] = raw.strip() + defines[name] = raw.strip() + return sorted(defines.items()) + + +def parse_user_settings(settings_h_path, include_dirs, predefines): + """Walk wolfssl/wolfcrypt/settings.h through pcpp and return the same + sorted [(name, value), ...] list shape that parse_options_h() returns. + + The customer's user_settings.h is included transitively via the + standard `#ifdef WOLFSSL_USER_SETTINGS` gate inside settings.h, so the + caller predefines `WOLFSSL_USER_SETTINGS` and adds the directory of + user_settings.h to `include_dirs`. This mirrors the way the C compiler + actually sees the wolfSSL build, so the SBOM build properties reflect + the real compiled configuration rather than just the literal text of + user_settings.h. + + Filters (see `_is_noise_macro` for the shared family list used by + both this function and parse_options_h): + * compiler/preprocessor reserved names (`__*`, `_[A-Z]*`). pcpp's + own internals (__DATE__/__TIME__/__PCPP__/__FILE__) and any host + compiler defines transitively leaking through pcpp's preprocess + would otherwise break reproducibility across build hosts. + * Apple macros (`TARGET_OS_*`, + `TARGET_IPHONE_*`). Defensive: pcpp does not auto-include + system headers, but a customer's user_settings.h may. + * header guards (`*_H` whose token does not carry an autoconf / + wolfSSL config prefix - see _CONFIG_H_TOKENS). wolfSSL's own + settings.h / visibility.h emit guards like + WOLF_CRYPT_SETTINGS_H that describe inclusion, not + configuration; real `_H` configuration flags (NO_STDINT_H, + USE_FLAT_TEST_H, WOLFSSL_NO_ASSERT_H) are preserved. + * function-like macros are dropped (they are API surface, not + build configuration; including their post-expansion body would + also break reproducibility under whitespace/token-render drift). + + pcpp is imported lazily so the autotools path (which uses + parse_options_h) does not require the dependency. + """ + try: + from pcpp import Preprocessor + except ImportError: + sys.exit( + "ERROR: --user-settings requires the 'pcpp' Python preprocessor.\n" + " Install: pip install pcpp\n" + " Or pre-process externally and pass the result via " + "--options-h instead\n" + " (e.g. $CC -dM -E -include wolfssl/wolfcrypt/settings.h " + "-DWOLFSSL_USER_SETTINGS - < /dev/null)." + ) + + pp = Preprocessor() + pp.line_directive = None + for d in include_dirs: + pp.add_path(d) + for predefine in predefines: + # Compiler-style `-D KEY=VALUE` is the universal CLI shape; + # translate to the `"KEY VALUE"` form pcpp.define() expects. + # Bare `-D KEY` (no value) maps to `"KEY"`, also accepted. + spec = predefine.replace('=', ' ', 1) if '=' in predefine else predefine + pp.define(spec) + + try: + with open(settings_h_path) as f: + text = f.read() + except OSError as e: + sys.exit(f"ERROR: cannot read settings.h {settings_h_path}: {e}") + + pp.parse(text, source=settings_h_path) + # pcpp.write() is what actually drives the preprocessor through #if / + # #ifdef resolution and populates pp.macros with the surviving + # defines. The output stream is intentionally discarded - we only + # care about pp.macros - but this call is NOT optional. + sink = io.StringIO() + pp.write(sink) + + # pcpp signals fatal preprocessing problems (an `#error` directive + # firing, an unbalanced `#if`, a missing #include, etc.) by setting + # pp.return_code to non-zero and printing to stderr; it does NOT + # raise. For an SBOM tool whose contract is "this artefact + # faithfully describes the build", a partial macro table produced + # before the failure is the worst possible output - the SBOM would + # silently omit configuration the customer set. Hard-fail instead + # so the build pipeline notices. + if pp.return_code != 0: + sys.exit( + f"ERROR: pcpp failed to preprocess {settings_h_path} " + f"(return_code={pp.return_code}); the resulting SBOM would " + f"be incomplete. Check the pcpp diagnostics printed above " + f"for the offending #error / #include / #if directive." + ) + + defines = {} + for name, macro in pp.macros.items(): + if _is_noise_macro(name): + continue + if macro.arglist is not None: + continue + tokens = macro.value or [] + defines[name] = ' '.join(t.value for t in tokens).strip() return sorted(defines.items()) -def cdx_dep_component(name, pkg_version, key): +def gitoid_blob_sha256(path): + """Compute the OmniBOR / git SHA-256 gitoid for a single file. + + The format is `sha256("blob " + filesize + "\\0" + filecontents)` + which is byte-identical to `git hash-object --object-format=sha256`. + Using the gitoid (rather than a plain SHA-256) lets the source-set + Merkle hash interoperate with bomsh/OmniBOR tooling: a customer can + cross-reference the wolfSSL SBOM's component hash with the entries + in an OmniBOR artifact dependency graph and confirm the same files + on both sides. + + The well-known empty-blob gitoid sha256 is + 473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813 + (regression-tested in scripts/test_gen_sbom.py). + """ + h = hashlib.sha256() + try: + size = os.path.getsize(path) + h.update(f'blob {size}\x00'.encode()) + 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 source for hashing: {e}") + return h.hexdigest() + + +def srcs_merkle_hash(src_paths): + """Deterministic SHA-256 over a sorted list of (basename, gitoid) + pairs for the given source files. + + Two customers compiling the same wolfSSL release with the same set + of source files get identical hashes regardless of where their + wolfSSL tree lives on disk, the order they passed --srcs, or the + filesystem they built on. Sorting on basename only (not full path) + is what makes this true; collisions across basenames would matter + in theory but wolfSSL's source layout has unique basenames per file + by construction. + + A one-byte change in any compiled-in source produces a different + hash, which is the property that makes this useful as the SBOM + component checksum for embedded builds with no separate library + archive.""" + seen = set() + entries = [] + for path in src_paths: + name = os.path.basename(path) + if name in seen: + sys.exit( + f"ERROR: duplicate basename in --srcs: {name!r}\n" + f" Source files must have unique basenames so the " + f"Merkle hash is order-independent.") + seen.add(name) + entries.append((name, gitoid_blob_sha256(path))) + entries.sort() + h = hashlib.sha256() + for name, oid in entries: + h.update(f'{name}\x00{oid}\n'.encode()) + return h.hexdigest() + + +def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None): """Return (bom_ref, component_dict) for a CDX dependency component. bom_ref is deterministic for reproducibility.""" meta = DEP_META[key] - version = dep_version(key) + version = dep_version(key, dep_version_overrides) bom_ref = derived_uuid(name, pkg_version, 'dep', key) comp = { 'bom-ref': bom_ref, @@ -280,10 +555,10 @@ def cdx_dep_component(name, pkg_version, key): return bom_ref, comp -def spdx_dep_package(key): +def spdx_dep_package(key, dep_version_overrides=None): """Return (spdx_id, package_dict) for an SPDX dependency package.""" meta = DEP_META[key] - version = dep_version(key) + version = dep_version(key, dep_version_overrides) spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', meta['name']) pkg = { 'SPDXID': spdx_id, @@ -306,13 +581,15 @@ def spdx_dep_package(key): def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, - timestamp, year, serial, enabled_deps, build_props): + timestamp, year, serial, enabled_deps, build_props, + dep_version_overrides=None, hash_kind='library-binary', + srcs_basenames=None): bom_ref = derived_uuid(name, version, 'package') dep_bom_refs = [] components = [] for key in enabled_deps: - ref, comp = cdx_dep_component(name, version, key) + ref, comp = cdx_dep_component(name, version, key, dep_version_overrides) dep_bom_refs.append(ref) components.append(comp) @@ -320,6 +597,20 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} for k, v in build_props ] + # Document what the SHA-256 in `hashes` represents, but only for + # the source-merkle entry point. The autotools / library-binary + # path keeps its existing output shape byte-identical so CI's + # reproducibility diff does not regress. Auditors looking at a + # source-merkle SBOM need this annotation to interpret the + # checksum correctly (vs. a library-artefact checksum). + if hash_kind != 'library-binary': + properties.append( + {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind}) + if srcs_basenames: + properties.append({ + 'name': 'wolfssl:sbom:source-set', + 'value': ','.join(srcs_basenames), + }) return { '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', @@ -364,8 +655,19 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, - timestamp, year, doc_ns_uuid, enabled_deps, build_props): + timestamp, year, doc_ns_uuid, enabled_deps, build_props, + dep_version_overrides=None, hash_kind='library-binary', + srcs_basenames=None): build_defines = ', '.join(k for k, _ in build_props) + # Only annotate the comment when running the source-merkle entry + # point. The autotools / library-binary path keeps its existing + # output shape byte-identical so reproducibility CI does not + # regress. + if hash_kind != 'library-binary': + build_defines += f' | hash-kind={hash_kind}' + if srcs_basenames: + build_defines += ( + ' | source-set=' + ','.join(srcs_basenames)) wolfssl_pkg = { 'SPDXID': 'SPDXRef-Package-wolfssl', 'name': name, @@ -402,7 +704,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, }] for key in enabled_deps: - spdx_id, pkg = spdx_dep_package(key) + spdx_id, pkg = spdx_dep_package(key, dep_version_overrides) packages.append(pkg) relationships.append({ 'spdxElementId': 'SPDXRef-Package-wolfssl', @@ -436,17 +738,38 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, return doc +def _parse_dep_version_overrides(spec_list): + """Parse repeated --dep-version KEY=VERSION flags into a dict. + Rejects unknown keys early so a typo (e.g. --dep-version libssl=…) + does not silently produce an SBOM that omits the dep version.""" + overrides = {} + for spec in spec_list: + if '=' not in spec: + sys.exit( + f"ERROR: --dep-version expects KEY=VERSION, got {spec!r}") + key, _, value = spec.partition('=') + if key not in DEP_META: + sys.exit( + f"ERROR: --dep-version key {key!r} is not a known wolfSSL " + f"dependency. Known keys: {', '.join(sorted(DEP_META))}.") + overrides[key] = value + return overrides + + def main(): parser = argparse.ArgumentParser( - description='Generate CycloneDX and SPDX SBOMs for wolfssl' + description='Generate CycloneDX and SPDX SBOMs for wolfssl. ' + 'Supports two entry-point shapes: the autotools / ' + 'library-binary form (--options-h + --lib) used by ' + '`make sbom`, and the standalone embedded form ' + '(--user-settings + --srcs) used by customers who ' + 'build with their own Makefile / IDE and never run ' + './configure.' ) 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='', @@ -461,20 +784,80 @@ def main(): '`--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') + # Build-configuration source: pick exactly one. + parser.add_argument('--options-h', + help='Path to wolfssl/options.h for build config ' + '(autotools entry point). The file is read ' + 'as a flat list of #define directives; pre-' + 'processed `$CC -dM -E -include settings.h` ' + 'output works equivalently.') + parser.add_argument('--user-settings', + help='Path to wolfssl/wolfcrypt/settings.h to walk ' + 'through pcpp (embedded entry point). Combine ' + 'with --user-settings-include to point at the ' + 'directory containing user_settings.h, and ' + '`--user-settings-define WOLFSSL_USER_SETTINGS` ' + 'to enable the user_settings.h inclusion gate.') + parser.add_argument('--user-settings-include', action='append', default=[], + metavar='DIR', + help='Add an include path for --user-settings ' + 'preprocessing (repeatable). Equivalent to -I ' + 'on the compiler command line.') + parser.add_argument('--user-settings-define', action='append', default=[], + metavar='NAME[=VALUE]', + help='Predefine a macro for --user-settings ' + 'preprocessing (repeatable). Equivalent to -D ' + 'on the compiler command line. At minimum ' + 'pass `WOLFSSL_USER_SETTINGS` so settings.h ' + 'pulls in user_settings.h.') + # Component checksum source: pick exactly one. + parser.add_argument('--lib', + help='Path to the wolfSSL library artifact ' + '(shared or static) for SHA-256 hashing ' + '(autotools entry point).') + parser.add_argument('--srcs', nargs='+', default=None, + help='wolfSSL source files compiled into the ' + 'firmware (embedded entry point). Their ' + 'OmniBOR-compatible gitoid Merkle hash is ' + 'used as the SBOM component checksum ' + 'instead of --lib.') 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('--dep-version', action='append', default=[], + metavar='KEY=VERSION', + help='Override pkg-config version detection for a ' + 'dependency (repeatable). KEY is one of: ' + + ', '.join(sorted(DEP_META)) + '. Required ' + 'on hosts without pkg-config (typical embedded ' + 'cross-compile setups).') 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() + # Mutual exclusion + at-least-one validation for the two entry-point + # shapes. Surfacing this here keeps argparse's --required machinery + # simple and produces a friendlier error than argparse's auto-text. + if bool(args.options_h) == bool(args.user_settings): + sys.exit( + "ERROR: pass exactly one of --options-h or --user-settings.\n" + " --options-h: autotools entry point (a flat #define file " + "such as wolfssl/options.h).\n" + " --user-settings: embedded entry point (path to " + "wolfssl/wolfcrypt/settings.h, with --user-settings-include " + "pointing at the directory containing user_settings.h).") + if bool(args.lib) == bool(args.srcs): + sys.exit( + "ERROR: pass exactly one of --lib or --srcs.\n" + " --lib: hash a built library artefact (.so/.a/.dylib).\n" + " --srcs: hash the wolfSSL source files compiled into " + "your firmware (OmniBOR gitoid Merkle hash).") + enabled_deps = [ key for key, flag in [ ('libz', args.dep_libz), @@ -482,6 +865,7 @@ def main(): ] if flag.lower() == 'yes' ] + dep_version_overrides = _parse_dep_version_overrides(args.dep_version) if args.license_override: license_id = args.license_override @@ -504,8 +888,24 @@ def main(): "`make sbom SBOM_LICENSE_TEXT=PATH`)." ) - build_props = parse_options_h(args.options_h) - lib_hash = sha256_file(args.lib) + if args.options_h: + build_props = parse_options_h(args.options_h) + else: + build_props = parse_user_settings( + args.user_settings, + args.user_settings_include, + args.user_settings_define, + ) + + if args.lib: + lib_hash = sha256_file(args.lib) + hash_kind = 'library-binary' + srcs_basenames = None + else: + lib_hash = srcs_merkle_hash(args.srcs) + hash_kind = 'source-merkle-omnibor' + srcs_basenames = sorted({os.path.basename(p) for p in args.srcs}) + dt, timestamp = build_timestamp() year = dt.year serial = derived_uuid(args.name, args.version, 'serial') @@ -515,11 +915,15 @@ def main(): args.name, args.version, args.supplier, license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, + dep_version_overrides=dep_version_overrides, + hash_kind=hash_kind, srcs_basenames=srcs_basenames, ) spdx = generate_spdx( args.name, args.version, args.supplier, license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, + dep_version_overrides=dep_version_overrides, + hash_kind=hash_kind, srcs_basenames=srcs_basenames, ) try: diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 8de58a91694..52c5a868d9d 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -15,6 +15,7 @@ import importlib.util import os import pathlib +import re import tempfile import unittest import uuid @@ -335,6 +336,217 @@ def test_dedup_keeps_last_assignment(self): )) self.assertEqual(pairs['HAVE_X'], '2') + def test_filters_compiler_internals_from_dm_e_dump(self): + # The no-pcpp escape hatch (`$CC -dM -E -include settings.h ...`) + # produces a defines file containing hundreds of host/compiler + # macros - on macOS it includes the entire Apple + # TargetConditionals family, on Linux it includes __GLIBC_*, + # everywhere it includes the C compiler's __INT_*_MAX__ / + # __SSE*__ / __VERSION__ family. parse_options_h must drop them + # so the SBOM reflects wolfSSL configuration, not the build + # host, and is reproducible across hosts. + pairs = dict(self._parse( + "/* simulated `clang -dM -E` dump on macOS */\n" + "#define __VERSION__ \"Homebrew Clang 21.1.4\"\n" + "#define __APPLE__ 1\n" + "#define __MACH__ 1\n" + "#define __SSE2__ 1\n" + "#define __INT_FAST32_MAX__ 2147483647\n" + "#define __clang_major__ 21\n" + "#define _LP64 1\n" + "#define TARGET_OS_MAC 1\n" + "#define TARGET_OS_OSX 1\n" + "#define TARGET_OS_LINUX 0\n" + "#define TARGET_IPHONE_SIMULATOR 0\n" + "#define WOLFSSL_OPTIONS_H\n" + "#define WOLF_CRYPT_SETTINGS_H 1\n" + "#define HAVE_AESGCM 1\n" + "#define NO_DES3 1\n" + "#define WOLFSSL_AES_256 1\n" + )) + self.assertEqual( + set(pairs), {'HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256'}, + 'noise filter let host/compiler macros leak into SBOM') + + def test_real_options_h_template_is_only_a_header_guard(self): + # Sanity-check that the noise filter handles wolfSSL's own + # autotools options.h.in: today the template defines exactly + # one macro - the WOLFSSL_OPTIONS_H header guard - which the + # filter must drop. If a future change adds a non-guard macro + # to options.h.in, this test makes the filter audit explicit. + here = pathlib.Path(__file__).resolve().parent.parent + template = here / 'wolfssl' / 'options.h.in' + if not template.is_file(): + self.skipTest(f'options.h.in fixture not found at {template}') + body = template.read_text() + names = re.findall(r'^#define[ \t]+(\w+)', body, re.MULTILINE) + self.assertIn('WOLFSSL_OPTIONS_H', names, + 'options.h.in unexpectedly missing its header guard') + for name in names: + self.assertTrue( + gs._is_noise_macro(name), + f'options.h.in defines {name!r} but the noise filter does ' + 'not drop it; either the filter needs widening or ' + 'options.h.in now contains a real config macro') + + def test_real_options_h_preserves_autoconf_have_probes(self): + # An autotools-generated wolfssl/options.h (post-./configure) + # contains both the WOLFSSL_OPTIONS_H header guard (filtered) + # and AC_CHECK_HEADER probe results like WOLFSSL_HAVE_ATOMIC_H + # / WOLFSSL_HAVE_ASSERT_H (must be preserved - they gate + # `#if defined(...)` branches in wc_port.h and types.h). + here = pathlib.Path(__file__).resolve().parent.parent + options_h = here / 'wolfssl' / 'options.h' + if not options_h.is_file(): + self.skipTest( + f'no built options.h at {options_h}; run ./configure first') + names = {k for k, _ in gs.parse_options_h(str(options_h))} + # WOLFSSL_OPTIONS_H is the header guard for options.h itself + # and must be filtered out. + self.assertNotIn( + 'WOLFSSL_OPTIONS_H', names, + 'header guard leaked through into SBOM build properties') + # The autoconf-detected header-availability flags must survive + # the filter (regression guard - see + # TestIsNoiseMacro.test_autoconf_have_header_probes_preserved). + for cflag in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H'): + if cflag in re.findall(r'^#define[ \t]+(\w+)', + options_h.read_text(), re.MULTILINE): + self.assertIn( + cflag, names, + f'{cflag!r} (AC_CHECK_HEADER probe result) was ' + 'incorrectly dropped by the noise filter') + + +class TestIsNoiseMacro(unittest.TestCase): + """The shared filter that keeps build-environment artefacts out of + the SBOM `wolfssl:build:*` properties. Drives both parse_options_h + (no-pcpp / autotools) and parse_user_settings (pcpp embedded) to + the same wolfSSL-only build-property set so the no-pcpp + `$CC -dM -E` shortcut does not produce host-leaking, non- + reproducible-across-hosts SBOMs. + + The three macro families this guards against (compiler-reserved, + Apple TargetConditionals, header guards) are documented in + `_NOISE_MACRO_RE` in gen-sbom; the assertions below pin each one, + plus the `_CONFIG_H_TOKENS` carve-out that keeps `*_H`-suffixed + real configuration flags out of the header-guard branch.""" + + def test_compiler_reserved_double_underscore(self): + # `__*` is reserved-for-implementation per ISO C 7.1.3 and is + # the bulk of what `clang -dM -E` emits. Dropping these is + # what stops `__VERSION__: "Homebrew Clang 21.1.4"` from + # leaking the developer's laptop into the public SBOM. + for name in ('__VERSION__', '__SSE2__', '__INT_FAST32_MAX__', + '__APPLE__', '__MACH__', '__amd64__', + '__GCC_ATOMIC_BOOL_LOCK_FREE', + '__clang_major__', '__BLOCKS__', + '__OBJC_BOOL_IS_BOOL', '__SIZEOF_LONG__', + '__LDBL_DIG__', '__FLT_RADIX__'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} should be filtered') + + def test_compiler_reserved_single_underscore_uppercase(self): + # ISO C 7.1.3 also reserves `_` + uppercase for the + # implementation; e.g. macOS clang emits `_LP64`, glibc emits + # `_FORTIFY_SOURCE`. Same rationale as `__*`. + for name in ('_LP64', '_FORTIFY_SOURCE', '_LARGEFILE_SOURCE', + '_GNU_SOURCE'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} should be filtered') + + def test_apple_target_conditionals_filtered(self): + # `clang -include settings.h -x c /dev/null` on macOS pulls in + # ; without this filter a wolfSSL SBOM + # for an STM32 firmware would falsely show TARGET_OS_MAC=1 + # when generated on a Mac, mis-identifying the target platform + # to a CRA reviewer. + for name in ('TARGET_OS_MAC', 'TARGET_OS_OSX', 'TARGET_OS_LINUX', + 'TARGET_OS_IOS', 'TARGET_OS_EMBEDDED', + 'TARGET_OS_WIN32', 'TARGET_OS_WINDOWS', + 'TARGET_IPHONE_SIMULATOR'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} (Apple TargetConditionals) should be ' + 'filtered') + + def test_header_guards_filtered(self): + # Both wolfssl/options.h itself and several internal wolfSSL + # headers define `WOLFSSL_*_H` / `WOLF_CRYPT_*_H` guards to + # prevent double inclusion. These describe "which file was + # parsed", not configuration choices. + for name in ('WOLF_CRYPT_SETTINGS_H', 'WOLFSSL_OPTIONS_H', + 'WOLF_CRYPT_VISIBILITY_H', 'WOLFSSL_USER_SETTINGS_H'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} (header guard) should be filtered') + + def test_autoconf_have_header_probes_preserved(self): + # Regression guard: the `_H$` filter must NOT swallow + # AC_CHECK_HEADER results from configure.ac. These live on the + # wolfSSL CFLAGS as `-DWOLFSSL_HAVE_ATOMIC_H` / + # `-DWOLFSSL_HAVE_ASSERT_H`, gate `#if defined(...)` branches in + # wc_port.h / types.h, and so are real configuration flags an + # auditor or vulnerability scanner needs to see in the SBOM. + for name in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H', + 'WOLFSSL_HAVE_MLKEM_H', 'HAVE_STDINT_H', + 'HAVE_SYS_TYPES_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (autoconf AC_CHECK_HEADER probe) must NOT be ' + 'filtered - it is real configuration that gates source ' + 'code branches') + + def test_no_h_suffixed_disablement_flags_preserved(self): + # Regression guard for the carve-out specifically. These flags + # are set by NETOS / Telit / WOLFSSL_TELIT_M2MB / similar RTOS + # profiles in wolfssl/wolfcrypt/settings.h to suppress stdlib + # header inclusion (the firmware ships with vendor stdlib + # replacements). They gate real `#if defined(...)` branches: + # + # types.h:398 `#ifndef NO_STDINT_H` + # settings.h:3850 `#ifndef NO_STDINT_H` + # sp.h:42 `#elif !defined(NO_STDINT_H)` + # types.h:2132 `#if !defined(WOLFSSL_NO_ASSERT_H) && ...` + # + # An embedded customer who builds against one of these profiles + # would otherwise get an SBOM that silently omits their + # stdlib-disablement choices - the exact evidence a CRA reviewer + # expects to see. + for name in ('NO_STDINT_H', 'NO_STDLIB_H', 'NO_LIMITS_H', + 'NO_CTYPE_H', 'NO_STRING_H', 'NO_STDDEF_H', + 'WOLFSSL_NO_ASSERT_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (NO_*_H disablement flag) must NOT be ' + 'filtered - it gates real wolfSSL source branches') + + def test_use_h_suffixed_build_mode_flags_preserved(self): + # Regression guard for the `USE_` carve-out token. Gates the + # flat-vs-tree test/benchmark layout in test.c:165 / + # benchmark.c:219 / examples/server/server.c:70. Customers who + # vendor these example sources select the layout via a `_H`- + # suffixed flag, so it must survive the filter. + for name in ('USE_FLAT_TEST_H', 'USE_FLAT_BENCHMARK_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (USE_*_H build-mode toggle) must NOT be ' + 'filtered - it gates real wolfSSL source branches') + + def test_real_wolfssl_macros_pass_through(self): + # The whole point of filtering is to NOT touch real wolfSSL + # configuration. If any of these get filtered the SBOM loses + # auditor-visible build properties that distinguish one + # wolfSSL configuration from another. + for name in ('HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256', + 'WOLFSSL_USER_SETTINGS', 'WC_RSA_BLINDING', + 'TFM_ECC256', 'OPENSSL_EXTRA', 'USE_FAST_MATH', + 'XTIME', 'CUSTOM_RAND_GENERATE', 'FP_MAX_BITS', + 'BENCH_EMBEDDED', 'SIZEOF_LONG_LONG', + 'WOLFSSL_SP_NO_DYN_STACK', 'WOLFSSL_SHA512', + 'NO_FILESYSTEM', 'SINGLE_THREADED'): + self.assertFalse(gs._is_noise_macro(name), + f'{name!r} (real wolfSSL config) should NOT be ' + 'filtered') + class TestDepMetaShape(unittest.TestCase): """Lock down the dep-tracking surface so renames/removals don't @@ -415,5 +627,458 @@ def test_removed_flags_are_rejected(self): f"{stale_flag!r}: {result.stderr!r}") +class TestGitoidBlobSha256(unittest.TestCase): + """The OmniBOR / git gitoid is content-addressed, well-specified, and + independently verifiable. These vectors anchor our implementation + against the canonical values so a future refactor (e.g. switching + chunked I/O strategy) cannot silently drift.""" + + EMPTY_OID = ('473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749' + '120a303721813') + HELLO_OID = ('8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca' + '36591175aeb60') + + def test_empty_blob_matches_canonical_oid(self): + # The well-known SHA-256 gitoid for an empty blob - matches + # `git hash-object --object-format=sha256 /dev/null`. + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + path = f.name + try: + self.assertEqual(gs.gitoid_blob_sha256(path), self.EMPTY_OID) + finally: + os.unlink(path) + + def test_hello_matches_canonical_oid(self): + # `git hash-object --object-format=sha256` on a 5-byte 'hello' + # blob; equivalently sha256(b'blob 5\x00hello'). + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(b'hello') + path = f.name + try: + self.assertEqual(gs.gitoid_blob_sha256(path), self.HELLO_OID) + finally: + os.unlink(path) + + def test_chunked_read_path_matches_one_shot(self): + # The chunked iter-read path is what hashes large source files + # in real builds; this guards against any off-by-one in the + # 65536-byte chunk handling. + import hashlib + body = (b'A' * 70000) + (b'B' * 1000) + b'tail' + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(body) + path = f.name + try: + expected = hashlib.sha256( + f'blob {len(body)}\x00'.encode() + body).hexdigest() + self.assertEqual(gs.gitoid_blob_sha256(path), expected) + finally: + os.unlink(path) + + def test_missing_file_exits_cleanly(self): + with self.assertRaises(SystemExit): + gs.gitoid_blob_sha256('/no/such/source/please.c') + + +class TestSrcsMerkleHash(unittest.TestCase): + """Source-set Merkle hash is the embedded entry point's component + checksum. Two contracts matter here: + + 1. Order independence: two customers compiling the same files in + any order get the same hash. Without this, the SBOM would + not be portable across build systems with non-deterministic + file ordering. + 2. Content sensitivity: a one-byte change in any source file + must change the hash. Without this, the checksum would + not detect a tampered build.""" + + def _make_files(self, files): + """files: dict of basename -> bytes contents. + Returns (tmpdir, list_of_paths).""" + import tempfile + tmpdir = tempfile.mkdtemp() + paths = [] + for name, contents in files.items(): + p = os.path.join(tmpdir, name) + with open(p, 'wb') as f: + f.write(contents) + paths.append(p) + return tmpdir, paths + + def test_order_independent(self): + import shutil + tmpdir, paths = self._make_files({ + 'aes.c': b'aes-body', + 'sha.c': b'sha-body', + 'dh.c': b'dh-body', + }) + try: + h1 = gs.srcs_merkle_hash(paths) + h2 = gs.srcs_merkle_hash(list(reversed(paths))) + h3 = gs.srcs_merkle_hash(sorted(paths)) + self.assertEqual(h1, h2) + self.assertEqual(h1, h3) + finally: + shutil.rmtree(tmpdir) + + def test_content_change_changes_hash(self): + import shutil + tmpdir, paths = self._make_files({ + 'aes.c': b'aes-body', + 'sha.c': b'sha-body', + }) + try: + h_before = gs.srcs_merkle_hash(paths) + with open(paths[0], 'ab') as f: + f.write(b'X') + h_after = gs.srcs_merkle_hash(paths) + self.assertNotEqual(h_before, h_after) + finally: + shutil.rmtree(tmpdir) + + def test_basename_only_means_path_independent(self): + """The Merkle hash deliberately uses basename only, not full + path, so two customers whose wolfSSL trees live at different + absolute paths get the same hash for the same release.""" + import shutil + td_a, paths_a = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'}) + td_b, paths_b = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'}) + try: + self.assertEqual( + gs.srcs_merkle_hash(paths_a), + gs.srcs_merkle_hash(paths_b)) + finally: + shutil.rmtree(td_a) + shutil.rmtree(td_b) + + def test_missing_file_exits_cleanly(self): + # Mirrors TestGitoidBlobSha256.test_missing_file_exits_cleanly: + # silently emitting an SBOM with a stale or zero hash for a + # missing source would falsify the artefact, so srcs_merkle_hash + # must propagate the underlying gitoid_blob_sha256 SystemExit. + with self.assertRaises(SystemExit): + gs.srcs_merkle_hash(['/no/such/source/please.c']) + + def test_duplicate_basenames_rejected(self): + # Order independence relies on unique basenames - if two source + # files in the input collided on basename, sorting on basename + # would suppress one of them and we would silently lose data. + # gen-sbom must reject the configuration rather than emit a + # misleading hash. + import shutil, tempfile + td_a = tempfile.mkdtemp() + td_b = tempfile.mkdtemp() + try: + with open(os.path.join(td_a, 'aes.c'), 'wb') as f: + f.write(b'a') + with open(os.path.join(td_b, 'aes.c'), 'wb') as f: + f.write(b'b') + with self.assertRaises(SystemExit): + gs.srcs_merkle_hash([ + os.path.join(td_a, 'aes.c'), + os.path.join(td_b, 'aes.c'), + ]) + finally: + shutil.rmtree(td_a) + shutil.rmtree(td_b) + + +class TestParseUserSettings(unittest.TestCase): + """Walks a synthetic settings.h + user_settings.h pair through + parse_user_settings() to confirm: + * the conditional logic in settings.h is honoured (only the + taken branch's defines reach the SBOM); + * pcpp-internal macros (__DATE__/__TIME__/__FILE__/__PCPP__) are + filtered out (otherwise reproducibility would break); + * function-like macros are filtered out (they are API surface, + not build configuration); + * --user-settings-define KEY=VALUE predefines reach the parser. + + Skipped when pcpp is not installed; CI installs it explicitly in + the standalone job.""" + + def setUp(self): + try: + import pcpp # noqa: F401 + except ImportError: + self.skipTest('pcpp not installed; embedded path not exercised') + + def _run(self, settings_body, user_body, predefines=()): + import shutil, tempfile + tmpdir = tempfile.mkdtemp() + try: + settings_h = os.path.join(tmpdir, 'settings.h') + user_h = os.path.join(tmpdir, 'user_settings.h') + with open(settings_h, 'w') as f: + f.write(settings_body) + with open(user_h, 'w') as f: + f.write(user_body) + return gs.parse_user_settings( + settings_h, [tmpdir], list(predefines)) + finally: + shutil.rmtree(tmpdir) + + def test_conditional_branches_honoured(self): + # Customer's user_settings.h enables HAVE_X; settings.h then + # gates HAVE_DEPENDENT on HAVE_X. Disabled-branch defines + # must NOT appear. + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + '#ifdef HAVE_X\n' + '#define HAVE_DEPENDENT 1\n' + '#else\n' + '#define HAVE_DISABLED_BRANCH 1\n' + '#endif\n' + ) + user = '#define HAVE_X 1\n' + pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertIn('HAVE_DEPENDENT', names) + self.assertNotIn('HAVE_DISABLED_BRANCH', names) + self.assertIn('WOLFSSL_USER_SETTINGS', names) + + def test_pcpp_internal_macros_filtered(self): + # __DATE__ and __TIME__ are non-deterministic; if they leak + # into the SBOM, two runs of `make sbom` produce different + # output and reproducibility CI fails. __PCPP__ and __FILE__ + # are pcpp implementation detail. + pairs = self._run('#define HAVE_X 1\n', '', []) + names = {k for k, _ in pairs} + for forbidden in ('__DATE__', '__TIME__', '__FILE__', '__PCPP__'): + self.assertNotIn(forbidden, names, + f'{forbidden} leaked into SBOM properties') + self.assertIn('HAVE_X', names) + + def test_apple_target_conditionals_filtered(self): + # Defensive: if a customer's user_settings.h transitively + # includes a macOS system header, the Apple TargetConditionals + # leak must still be filtered to keep the SBOM target-platform- + # honest. pcpp does not auto-include system headers, so this + # path is uncommon, but the contract with parse_options_h is + # that the same noise filter applies to both entry points. + pairs = self._run( + '#define HAVE_X 1\n' + '#define TARGET_OS_MAC 1\n' + '#define TARGET_OS_LINUX 0\n' + '#define TARGET_IPHONE_SIMULATOR 0\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + for forbidden in ('TARGET_OS_MAC', 'TARGET_OS_LINUX', + 'TARGET_IPHONE_SIMULATOR'): + self.assertNotIn(forbidden, names) + + def test_header_guards_filtered(self): + # wolfSSL's settings.h, visibility.h, etc. all define + # WOLF_CRYPT_*_H guards; they describe which file was parsed, + # not configuration choices, and so are filtered out of the + # SBOM `wolfssl:build:*` property set. + pairs = self._run( + '#define WOLF_CRYPT_SETTINGS_H 1\n' + '#define WOLFSSL_USER_SETTINGS_H 1\n' + '#define HAVE_X 1\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertNotIn('WOLF_CRYPT_SETTINGS_H', names) + self.assertNotIn('WOLFSSL_USER_SETTINGS_H', names) + + def test_no_h_and_use_h_config_flags_preserved(self): + # End-to-end pcpp regression for the `_CONFIG_H_TOKENS` carve- + # out: an embedded customer's user_settings.h that disables + # stdint/stdlib (NETOS / Telit / similar profile) must produce + # an SBOM that records the disablements. Mirrors the + # equivalent unit assertion in TestIsNoiseMacro but exercises + # the full pcpp + filter pipeline customers actually use. + user = ( + '#define HAVE_X 1\n' + '#define NO_STDINT_H 1\n' + '#define NO_STDLIB_H 1\n' + '#define WOLFSSL_NO_ASSERT_H 1\n' + '#define USE_FLAT_TEST_H 1\n' + '#define USE_FLAT_BENCHMARK_H 1\n' + ) + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + ) + pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + names = {k for k, _ in pairs} + for required in ('HAVE_X', 'NO_STDINT_H', 'NO_STDLIB_H', + 'WOLFSSL_NO_ASSERT_H', 'USE_FLAT_TEST_H', + 'USE_FLAT_BENCHMARK_H'): + self.assertIn( + required, names, + f'{required!r} (real wolfSSL config) was filtered out ' + 'of the SBOM - the noise filter is over-aggressive') + + def test_pcpp_error_directive_is_fatal(self): + # An `#error` firing inside settings.h or a transitively + # included header is a hard build failure for the C compiler; + # gen-sbom must mirror that semantics. pcpp signals this via + # pp.return_code (it does NOT raise), which is easy to swallow + # silently and emit a partial SBOM if not checked. This test + # pins the fail-fast contract. + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + '#define HAVE_X 1\n' + ) + user = '#error "this configuration is unsupported"\n' + with self.assertRaises(SystemExit) as ctx: + self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + msg = str(ctx.exception) + self.assertIn('pcpp', msg) + self.assertIn('return_code', msg) + + def test_function_like_macros_filtered(self): + # Function-like macros are API surface, not build + # configuration; their post-expansion body would also break + # reproducibility under pcpp token-render whitespace drift. + pairs = self._run( + '#define HAVE_X 1\n' + '#define WC_BITS_TO_BYTES(x) (((x) + 7) >> 3)\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertNotIn('WC_BITS_TO_BYTES', names) + + def test_predefine_with_value(self): + pairs = self._run( + '#if VERSION_MAJOR >= 5\n#define ONLY_NEW 1\n#endif\n', + '', ['VERSION_MAJOR=5']) + names = {k for k, _ in pairs} + self.assertIn('ONLY_NEW', names) + self.assertIn('VERSION_MAJOR', names) + + def test_returns_sorted_pairs_like_parse_options_h(self): + # The downstream code path is shared between options.h and + # user_settings.h; both producers must return the exact same + # shape (sorted list of (name, value) tuples). A drift here + # would surface as a mystery diff between the two paths. + pairs = self._run( + '#define HAVE_Z 1\n#define HAVE_A 1\n#define HAVE_M 1\n', + '', []) + names = [k for k, _ in pairs] + self.assertEqual(names, sorted(names)) + + +class TestDepVersionOverride(unittest.TestCase): + """--dep-version is the embedded path's substitute for pkg-config: + cross-compile hosts have no pkg-config for the target, so the + customer must supply the linked dep version explicitly. Without + this flag a baremetal SBOM that reports `--dep-libz yes` would + silently emit `versionInfo: NOASSERTION` and lose CVE-tracking + fidelity for libz.""" + + def test_explicit_override_wins_over_pkgconfig(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '99.99.99' + self.assertEqual( + gs.dep_version('libz', {'libz': '1.3.1'}), + '1.3.1') + finally: + gs.pkgconfig_version = original + + def test_no_override_falls_back_to_pkgconfig(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.0.0' + self.assertEqual(gs.dep_version('libz'), '1.0.0') + self.assertEqual(gs.dep_version('libz', {}), '1.0.0') + self.assertEqual( + gs.dep_version('libz', {'liboqs': '0.0'}), '1.0.0') + finally: + gs.pkgconfig_version = original + + def test_parse_overrides_rejects_unknown_keys(self): + with self.assertRaises(SystemExit): + gs._parse_dep_version_overrides(['libssl=3.0.0']) + + def test_parse_overrides_rejects_malformed(self): + with self.assertRaises(SystemExit): + gs._parse_dep_version_overrides(['libz']) + + def test_parse_overrides_accepts_known_keys(self): + out = gs._parse_dep_version_overrides([ + 'libz=1.3.1', 'liboqs=0.10.0', + ]) + self.assertEqual(out, {'libz': '1.3.1', 'liboqs': '0.10.0'}) + + +class TestCliMutualExclusion(unittest.TestCase): + """The two entry-point shapes (autotools / standalone) must be + mutually exclusive. Mixing them would produce a hash whose + semantics nobody can interpret (library bytes? source merkle? + both?), so gen-sbom refuses the combination upfront with a + clear error.""" + + 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 + ) + + BASE = [ + '--name', 'wolfssl', + '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--cdx-out', '/dev/null', + '--spdx-out', '/dev/null', + ] + + def test_options_and_user_settings_together_fail(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--user-settings', '/dev/null', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--options-h or --user-settings', result.stderr) + + def test_neither_options_nor_user_settings_fails(self): + result = self._run( + *self.BASE, + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--options-h or --user-settings', result.stderr) + + def test_lib_and_srcs_together_fail(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null', + '--srcs', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--lib or --srcs', result.stderr) + + def test_neither_lib_nor_srcs_fails(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--lib or --srcs', result.stderr) + + def test_user_settings_path_in_help(self): + # Discoverability regression guard - if the standalone entry + # point is invisible to `--help`, embedded customers will not + # know it exists. + result = self._run('--help') + self.assertEqual(result.returncode, 0, result.stderr) + for token in ('--user-settings', '--user-settings-include', + '--user-settings-define', '--srcs', + '--dep-version'): + self.assertIn(token, result.stdout, f'{token!r} missing from --help') + + if __name__ == '__main__': unittest.main(verbosity=2) From 8d935aea8acf2d6e3a739d5d2519385728400760 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 6 May 2026 11:08:47 +0300 Subject: [PATCH 14/39] fix(sbom,ci): build liboqs from source; rewire bomsh for new layout Noble lacks liboqs-dev (build 0.12.0 from source); upstream removed .devcontainer/bomtrace3 (mirror Dockerfile, pin bomsh+strace, mpers). Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 103 ++++++++++++++++++++++++++++++------- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index a862cdc9faa..bb364c1e8ad 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -568,7 +568,40 @@ jobs: # 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 + # Ubuntu noble (24.04) does not ship liboqs-dev in its archive + # (Debian sid has 0.7.x; Ubuntu only has unsupported PPAs). Build + # from a pinned upstream tag so this job stays deterministic across + # runs - any future liboqs API/ABI break shows up here, not in + # production builds. Pinning matters: SBOM correctness assertions + # below check purl shape, and an unpinned 'main' would silently + # change what pkg-config reports as the version string. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build libssl-dev + git clone --depth=1 --branch 0.12.0 \ + https://github.com/open-quantum-safe/liboqs /tmp/liboqs + cmake -S /tmp/liboqs -B /tmp/liboqs/build -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DBUILD_SHARED_LIBS=ON \ + -DOQS_BUILD_ONLY_LIB=ON \ + -DOQS_DIST_BUILD=OFF + cmake --build /tmp/liboqs/build --parallel "$(nproc)" + sudo cmake --install /tmp/liboqs/build + sudo ldconfig + # /usr/local/lib/pkgconfig is on pkg-config's compiled-in path + # on Ubuntu, but export via $GITHUB_ENV so a future image change + # cannot silently break --with-liboqs autodetection. ${VAR:+:$VAR} + # avoids a trailing colon when PKG_CONFIG_PATH is unset. + echo "PKG_CONFIG_PATH=/usr/local/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" \ + >> "$GITHUB_ENV" + + - name: Verify liboqs.pc visible to pkg-config + # Separate step so the $GITHUB_ENV write above has taken effect + # for this shell; an in-step call would only be exercising the + # compiled-in default path, not the export. + run: pkg-config --modversion liboqs - name: Configure with --with-liboqs --enable-falcon run: | @@ -734,33 +767,67 @@ jobs: - name: Install build deps + SBOM validators run: | sudo apt-get update + # bison + autotools-dev are required by strace's ./bootstrap. + # gcc-multilib + g++-multilib give strace's --enable-mpers=check + # the 32-bit/x32 compilers it needs - without them mpers is + # silently downgraded and bomtrace3 traces only native-arch + # syscalls, diverging from what bomsh's devcontainer produces. + # The rest mirror bomsh's .devcontainer/Dockerfile bomtrace3 + # stage. sudo apt-get install -y build-essential autoconf automake libtool \ + bison autotools-dev gcc-multilib g++-multilib \ 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. + # Bomsh is not packaged. Reproduce its `.devcontainer/Dockerfile` + # bomtrace3 stage: clone strace, apply bomtrace3.patch, drop in + # the bomsh source overlay, then bootstrap+configure+make. Both + # bomsh and strace are pinned (env: below) so a strace `master` + # commit that touches the lines bomtrace3.patch rewrites cannot + # break this CI for reasons unrelated to wolfSSL. Bump them + # together by re-validating `patch -p1` against the new SHAs. + env: + # bomsh has no releases; pin to last commit on main as of + # 2024-10-31. The patch itself last changed 2024-02-06. + BOMSH_SHA: 5823f7db7e5bd958e4ff868ae6ea79a7d871bb07 + # v6.7 (2024-01-29) is the strace release current when the + # patch was last touched; later releases tend to drift from + # the patch's context lines in src/strace.c. + STRACE_TAG: v6.7 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" + git clone https://github.com/omnibor/bomsh /tmp/bomsh + git -C /tmp/bomsh checkout "$BOMSH_SHA" + # Even with a pinned SHA, keep the layout-drift guard so the + # next maintainer who bumps BOMSH_SHA gets a clear error if + # upstream restructured rather than a confusing patch failure. + if [ ! -f /tmp/bomsh/.devcontainer/patches/bomtrace3.patch ] \ + || [ ! -d /tmp/bomsh/.devcontainer/src ]; then + echo "bomsh repo layout changed; please update CI" >&2 + ls -la /tmp/bomsh/.devcontainer/ >&2 || true 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 + git clone --depth=1 --branch "$STRACE_TAG" \ + https://github.com/strace/strace.git /tmp/strace + cp /tmp/bomsh/.devcontainer/patches/bomtrace3.patch /tmp/strace/ + cp /tmp/bomsh/.devcontainer/src/*.[ch] /tmp/strace/src/ + ( + cd /tmp/strace + patch -p1 < bomtrace3.patch + ./bootstrap + ./configure --enable-mpers=check + make -j"$(nproc)" + ) + sudo install -m 755 /tmp/strace/src/strace /usr/local/bin/bomtrace3 + sudo install -m 755 /tmp/bomsh/scripts/bomsh_create_bom.py \ + /usr/local/bin/ + sudo install -m 755 /tmp/bomsh/scripts/bomsh_sbom.py \ + /usr/local/bin/ + # bomtrace3 is patched strace; a `--version` invocation under + # ptrace requires no target so it must succeed cleanly. + bomtrace3 --version which bomsh_create_bom.py bomsh_sbom.py - name: Configure wolfSSL From 0b9d487293c6b49d812e4eb414c73e16ee26007e Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 7 May 2026 07:22:06 +0300 Subject: [PATCH 15/39] fix(sbom,ci): unbreak bomsh + liboqs(falcon) jobs; archive SBOMs bomtrace3 has no --version (use `-h | grep` smoke check); liboqs default OQS_USE_OPENSSL=ON drags into wolfSSL TUs via falcon.h and collides with the compat layer (-DOQS_USE_OPENSSL=OFF). Add upload-artifact@v4 to all four jobs so the OmniBOR graph + enriched SPDX ship from PR runs. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 124 +++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index bb364c1e8ad..09ce420a3b9 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -332,6 +332,30 @@ jobs: f'host-leak macros filtered)') PY + # Upload the SBOMs produced by every standalone path (pcpp, pcpp+deps, + # --options-h escape hatch, and the second pcpp run used for + # reproducibility diffing) so a reviewer can inspect them - or hand + # them to a downstream consumer / CRA reviewer - without re-running + # the job. `if: always()` ensures triage artefacts ship even when an + # assertion above fails (which is precisely when the bytes matter). + - name: Upload standalone SBOMs + if: always() + uses: actions/upload-artifact@v4 + with: + name: sbom-standalone-${{ github.sha }} + path: | + /tmp/standalone/wolfssl.cdx.json + /tmp/standalone/wolfssl.spdx.json + /tmp/standalone-r2/wolfssl.cdx.json + /tmp/standalone-r2/wolfssl.spdx.json + /tmp/standalone-deps/wolfssl.cdx.json + /tmp/standalone-deps/wolfssl.spdx.json + /tmp/standalone-dme/wolfssl.cdx.json + /tmp/standalone-dme/wolfssl.spdx.json + /tmp/standalone-dme/options.h + if-no-files-found: warn + retention-days: 90 + # Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert # everything an external auditor or vulnerability scanner relies on. integration: @@ -581,12 +605,22 @@ jobs: cmake ninja-build libssl-dev git clone --depth=1 --branch 0.12.0 \ https://github.com/open-quantum-safe/liboqs /tmp/liboqs + # -DOQS_USE_OPENSSL=OFF is load-bearing: without it, liboqs's + # installed common.h pulls (system) into every + # TU that includes . wolfssl/wolfcrypt/falcon.h + # includes , so once --enable-falcon is on, every + # wolfSSL TU that pulls falcon.h also pulls system OpenSSL, + # which collides with wolfssl/openssl/ssl.h under -Werror + # (CRYPTO_UNLOCK, sk_num, OPENSSL_malloc_init, ... all redefined). + # OFF makes liboqs use its bundled SHA/randombytes (the #else + # branches in oqs/common.h), keeping the build hermetic. cmake -S /tmp/liboqs -B /tmp/liboqs/build -GNinja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/usr/local \ -DBUILD_SHARED_LIBS=ON \ -DOQS_BUILD_ONLY_LIB=ON \ - -DOQS_DIST_BUILD=OFF + -DOQS_DIST_BUILD=OFF \ + -DOQS_USE_OPENSSL=OFF cmake --build /tmp/liboqs/build --parallel "$(nproc)" sudo cmake --install /tmp/liboqs/build sudo ldconfig @@ -706,6 +740,28 @@ jobs: exit 1 fi + # Persist the SBOMs the integration matrix produces so a CRA reviewer, + # a downstream packager, or the next maintainer triaging a regression + # can download them straight from the run summary instead of replaying + # the full job locally. `if: always()` so a failed assertion above + # (license matrix, NTIA, CDX schema, liboqs dep entry, ...) still ships + # the bytes it failed on. The last `make sbom` invocation in this job + # is the simple SPDX override step, but the path matches every wolfssl + # SPDX/CDX in $PWD - if any are present at job end they will be picked + # up. if-no-files-found:warn keeps the upload soft so reordering the + # steps later cannot regress this into a hard failure. + - name: Upload SBOM artefacts (linux) + if: always() + uses: actions/upload-artifact@v4 + with: + name: sbom-integration-linux-${{ github.sha }} + path: | + wolfssl-*.spdx.json + wolfssl-*.cdx.json + wolfssl-*.spdx + if-no-files-found: warn + retention-days: 90 + # 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 @@ -750,6 +806,21 @@ jobs: print('macOS SBOM checksum well-formed:', checksum) PY + # Persist the Mach-O variant SBOMs so the .dylib-flavoured outputs are + # downloadable for cross-platform diffing against the linux artefacts. + # Same `if: always()` rationale as the linux upload above. + - name: Upload SBOM artefacts (macos) + if: always() + uses: actions/upload-artifact@v4 + with: + name: sbom-integration-macos-${{ github.sha }} + path: | + wolfssl-*.spdx.json + wolfssl-*.cdx.json + wolfssl-*.spdx + if-no-files-found: warn + retention-days: 90 + # 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 @@ -825,9 +896,17 @@ jobs: /usr/local/bin/ sudo install -m 755 /tmp/bomsh/scripts/bomsh_sbom.py \ /usr/local/bin/ - # bomtrace3 is patched strace; a `--version` invocation under - # ptrace requires no target so it must succeed cleanly. - bomtrace3 --version + # bomtrace3 replaces strace's argv parsing in bomsh_init() (see + # bomsh_config.c); its accepted long options are exactly + # --help/--config/--output/--verbose/--watch. `--version` is + # NOT a real flag and would exit non-zero. `-h` is the only + # no-target invocation that returns 0 cleanly (bomsh_usage() + # calls exit(0)). The grep doubles as a check that the binary + # on PATH is genuinely bomsh-patched and not a vanilla strace + # shadowing it ("Usage: bomtrace3 " only appears in + # bomsh_usage()), guarding against a future BOMSH_SHA bump that + # silently regresses the patch. + bomtrace3 -h | grep -q '^Usage: bomtrace3 ' which bomsh_create_bom.py bomsh_sbom.py - name: Configure wolfSSL @@ -871,6 +950,43 @@ jobs: print(f'bomsh enrichment ok: {len(gitoid_refs)} gitoid refs') PY + # The full provenance bundle - the high-value artefact of the whole + # PR, the one a CRA reviewer or downstream packager wants to download. + # MUST be uploaded BEFORE the `make clean` step below, which deletes + # everything by design. `if: always()` so even when the assertion + # above fails (which is when triage matters most), the bundle ships. + # + # Contents: + # omnibor/ - OmniBOR Artifact Dependency Graph + # (objects/ + metadata/bomsh/*), + # content-addressed by gitoid; the + # verifiable build-provenance proof. + # omnibor.wolfssl-*.spdx.json - SPDX with PERSISTENT-ID gitoid + # externalRef bridging SBOM <-> ADG. + # wolfssl-*.spdx.json - the un-enriched SPDX (for diffing + # against omnibor.* to confirm only + # the externalRef was added). + # wolfssl-*.cdx.json - CycloneDX equivalent. + # bomsh_raw_logfile.sha1 - raw bomtrace3 syscall trace, for + # debugging trace gaps (e.g. a build + # step that escaped ptrace). + # _bomsh.conf - 1-line config passed to bomtrace3 + # -c at trace time. + - name: Upload OmniBOR graph + bomsh-enriched SBOMs + if: always() + uses: actions/upload-artifact@v4 + with: + name: bomsh-omnibor-${{ github.sha }} + path: | + omnibor/ + omnibor.wolfssl-*.spdx.json + wolfssl-*.spdx.json + wolfssl-*.cdx.json + bomsh_raw_logfile.sha1 + _bomsh.conf + if-no-files-found: warn + retention-days: 90 + - 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. From 3c50c180aedaf2b06512bf7645120e428164c7d3 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 7 May 2026 15:52:24 +0300 Subject: [PATCH 16/39] =?UTF-8?q?ci(sbom):=20drop=20non-ASCII=20'=C2=A7'?= =?UTF-8?q?=20from=20sbom.yml=20comment=20(fix=20check-source-text)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 09ce420a3b9..9e42d88f907 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -236,7 +236,7 @@ jobs: PY - name: --options-h escape hatch ($CC -dM -E, no pcpp) - # The doc/SBOM.md § 1.5 escape hatch for toolchains that cannot + # The doc/SBOM.md section 1.5 escape hatch for toolchains that cannot # install pcpp (older Keil / IAR sites with restricted pip # access): pre-process settings.h with the system compiler's # `-dM -E` macro-dump mode and feed the resulting flat #define From 62da54471a735e063995428a689ba0149df084da Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:14:28 -0700 Subject: [PATCH 17/39] test(sbom): expand coverage; CI hard-requires pcpp Closes seven test-coverage gaps surfaced by the wolfssl-1zj review pass: * TestParseUserSettings.setUp now hard-fails on missing pcpp instead of silently skipping nine standalone-path tests. CI's unit job installs pcpp before running the suite, so the embedded entry point is verified at the cheapest gate (1zj.14). * TestDetectLicense (10 tests + 2 FSF preamble cases added by the gen-sbom commit): pins the SPDX licence ID emitted in licenseConcluded / licenseDeclared against the four real-world LICENSING shapes plus error paths. detect_license had zero coverage; a regex regression silently flipped the SBOM licence tag (1zj.15). * TestCliMutualExclusion: covers the --license-override LicenseRef-* / --license-text gate at the CLI rejection layer (subprocess-based, real argparse). The placeholder fallback in build_extracted_licensing_infos is unreachable from main(); this test pins that gate (1zj.17). * TestSha256File.test_chunked_read_path_matches_one_shot exercises the iter(f.read(65536), b'') loop body (the pre-existing empty-file vector skipped it entirely). Independent oracle: hashlib's one-shot SHA-256 (1zj.19). * TestCdxDepComponent / TestSpdxDepPackage / TestGenerateCdx / TestGenerateSpdx (21 tests): cover the four SBOM document generators that produce the JSON consumed by vulnerability scanners and CRA auditors. bom-ref derivation determinism, dependency-graph integrity, listed-id licence block shaping, NOASSERTION fallback, hasExtractedLicensingInfos plumbing, hash_kind annotation on the source-merkle path, DESCRIBES / DEPENDS_ON SPDX relationships, etc. (1zj.20). * test_pcpp_error_directive_is_fatal previously pinned the literal substrings 'pcpp' and 'return_code' in the error message. The contract being tested is fail-fast on #error, not the wording. Replace with a length-floor check that still catches an empty-message regression (1zj.21). Closes wolfssl-1zj.14, wolfssl-1zj.15, wolfssl-1zj.17, wolfssl-1zj.19, wolfssl-1zj.20, wolfssl-1zj.21 --- .github/workflows/sbom.yml | 11 + scripts/test_gen_sbom.py | 631 ++++++++++++++++++++++++++++++++++++- 2 files changed, 636 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 9e42d88f907..ce8e752c129 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -27,6 +27,17 @@ jobs: - name: Syntax check run: python3 -m py_compile scripts/gen-sbom + - name: Install pcpp + # pcpp is the in-Python C preprocessor that drives the + # standalone --user-settings entry point. TestParseUserSettings + # previously skipped here when pcpp was missing, leaving the + # entire embedded entry point unverified at the cheapest CI + # gate. The setUp now hard-fails on missing pcpp; this step + # ensures it is present so all unit tests actually run. + run: | + python3 -m pip install --user pcpp + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Unit tests run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_sbom.py -v diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 52c5a868d9d..ec4ebaedfc6 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -268,6 +268,148 @@ def test_missing_file_exits(self): gs.load_license_text('/no/such/path/please.txt') +class TestDetectLicense(unittest.TestCase): + """detect_license decides the SPDX licenseConcluded / licenseDeclared + that wolfSSL's SBOM advertises. A regression here silently flips + the licence obligations a downstream integrator parses out of the + SBOM (e.g. GPLv2-or-later misreported as GPLv2-only narrows the + permitted upgrade path; GPLv3 misreported as GPLv2 entirely + misstates compatibility with GPLv2-only third-party code). + + Independent oracle: the SPDX licence-list short identifiers + (https://spdx.org/licenses/), determined for each fixture by + reading the GPL version stated in the prose and whether 'or later' + / 'or any later version' wording appears within 100 characters of + the version mention. No fixture is round-tripped through + detect_license to derive its own oracle.""" + + def _detect(self, body): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write(body) + path = f.name + try: + return gs.detect_license(path) + finally: + os.unlink(path) + + def test_gplv2_only(self): + # Prose mentions GPLv2 with no 'or later' clause. Oracle: + # SPDX 'GPL-2.0-only'. + self.assertEqual( + self._detect( + 'This program is licensed under the GNU General Public ' + 'License version 2.\n' + 'See COPYING for the full text.\n'), + 'GPL-2.0-only') + + def test_gplv2_or_later_any_form(self): + # 'or any later' immediately after the version mention. + # Oracle: SPDX 'GPL-2.0-or-later'. + # NOTE: the canonical FSF preamble phrase 'or (at your option) + # any later version' is NOT matched by the current regex + # (the parenthetical interjection breaks the pattern). That + # is a separate limitation tracked in its own beads issue; + # this test deliberately uses a pattern the existing regex + # handles, so it serves as a regex regression guard rather + # than a redesign request. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 2, ' + 'or any later version.\n'), + 'GPL-2.0-or-later') + + def test_gplv2_or_later_short_form(self): + # 'or later' (without 'any') also matches the regex; this + # variant appears in some upstream COPYING files. Oracle: + # 'GPL-2.0-or-later'. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 2 ' + 'or later.\n'), + 'GPL-2.0-or-later') + + def test_gplv3_only(self): + self.assertEqual( + self._detect( + 'Released under the terms of the GNU General Public ' + 'License version 3.\n'), + 'GPL-3.0-only') + + def test_gplv3_or_later(self): + self.assertEqual( + self._detect( + 'Released under the terms of the GNU General Public ' + 'License version 3, or any later version.\n'), + 'GPL-3.0-or-later') + + def test_case_insensitive(self): + # The regex is case-insensitive for both the GPL header line + # and the 'or later' clause. Real-world COPYING files use + # mixed cases ('GNU GENERAL PUBLIC LICENSE Version 2'); a + # case-sensitive regression here would silently emit None. + self.assertEqual( + self._detect( + 'GNU GENERAL PUBLIC LICENSE Version 2\n' + 'Licensee may redistribute under GPLv2 OR LATER.\n'), + 'GPL-2.0-or-later') + + def test_or_later_outside_100_byte_excerpt_does_not_match(self): + # The 'or later' search is bounded to the 100 chars + # immediately following the version mention. An 'or later' + # phrase appearing in unrelated boilerplate further down the + # file MUST NOT promote a GPLv2-only declaration to + # GPLv2-or-later. This is the regression Mark called out in + # the review: "someone reworks the regex ... and breaks the + # GPLv2-or-later detection." + body = ( + 'Licensed under the GNU General Public License version 2.\n' + + ('Filler not relevant to the license clause. ' * 5) + + '\nMay be useful or later modified by users.\n' + ) + self.assertEqual(self._detect(body), 'GPL-2.0-only') + + def test_no_gpl_mention_returns_none_with_warning(self): + import io, contextlib + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = self._detect( + 'Copyright (c) 2026 Example Corp.\n' + 'Licensed under the MIT License.\n' + 'Permission is hereby granted, free of charge, ...\n') + self.assertIsNone(result) + # Warning must mention the file path so an operator running + # `make sbom` can see which file was unparseable. + self.assertIn('no GPL version found', stderr.getvalue()) + + def test_missing_file_returns_none_with_warning(self): + import io, contextlib + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = gs.detect_license('/no/such/license/please.txt') + self.assertIsNone(result) + self.assertIn('cannot read license file', stderr.getvalue()) + + def test_real_wolfssl_licensing_is_gpl3_only(self): + # Regression guard, not an oracle: lock down the SPDX ID that + # the shipped LICENSING file produces today. If wolfSSL ever + # changes the headline licence in LICENSING, this test must + # be updated in the same commit so the SBOM emission change + # does not slip in unreviewed. The "version 3" mention is + # first in the file; the 100-char excerpt that follows is + # `(\u201cGPLv3\u201d) with\nthe following exception: ...`, + # which contains no 'or later' clause - hence GPL-3.0-only. + here = pathlib.Path(__file__).resolve().parent.parent + licensing = here / 'LICENSING' + if not licensing.is_file(): + self.skipTest(f'LICENSING fixture not found at {licensing}') + self.assertEqual( + gs.detect_license(str(licensing)), 'GPL-3.0-only', + 'real wolfSSL LICENSING no longer maps to GPL-3.0-only; ' + 'update this regression guard and audit the SBOM ' + 'licenseConcluded / licenseDeclared change') + + 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 @@ -288,6 +430,25 @@ def test_missing_file_exits_cleanly(self): with self.assertRaises(SystemExit): gs.sha256_file('/no/such/library/please.so') + def test_chunked_read_path_matches_one_shot(self): + # The chunked iter(f.read(65536), b'') path in sha256_file is + # what runs for the real wolfSSL library (.so/.a, multi-MB). + # The empty-file vector above never executes the loop body at + # all (size=0). An off-by-one or chunk-boundary regression + # would slip through unless we exercise a buffer that crosses + # the 65536-byte boundary. Independent oracle: hashlib's + # one-shot hash on the same bytes. + import hashlib + body = (b'A' * 70000) + (b'B' * 1000) + b'tail' + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(body) + path = f.name + try: + expected = hashlib.sha256(body).hexdigest() + self.assertEqual(gs.sha256_file(path), expected) + finally: + os.unlink(path) + class TestParseOptionsH(unittest.TestCase): def _parse(self, body): @@ -794,14 +955,23 @@ class TestParseUserSettings(unittest.TestCase): not build configuration); * --user-settings-define KEY=VALUE predefines reach the parser. - Skipped when pcpp is not installed; CI installs it explicitly in - the standalone job.""" + pcpp is a hard prerequisite for these tests, not optional. An + earlier revision called self.skipTest on missing pcpp; CI ran the + suite without pcpp installed and silently skipped all of these + cases, leaving the embedded entry point unverified at the very + gate intended to verify it (see review finding wolfssl-1zj.14). + Now the setUp fails loud with an actionable message.""" def setUp(self): try: import pcpp # noqa: F401 except ImportError: - self.skipTest('pcpp not installed; embedded path not exercised') + self.fail( + 'pcpp is not installed but is required to test the ' + 'standalone embedded entry point ' + '(parse_user_settings). Install with: ' + "'python3 -m pip install --user pcpp'. CI installs " + 'this in the unit job; see .github/workflows/sbom.yml.') def _run(self, settings_body, user_body, predefines=()): import shutil, tempfile @@ -922,7 +1092,10 @@ def test_pcpp_error_directive_is_fatal(self): # gen-sbom must mirror that semantics. pcpp signals this via # pp.return_code (it does NOT raise), which is easy to swallow # silently and emit a partial SBOM if not checked. This test - # pins the fail-fast contract. + # pins the fail-fast contract: any #error must produce a + # SystemExit, not a partial SBOM. We deliberately do NOT + # pin the exact error wording; the contract is fail-fast, + # not the message's phrasing. settings = ( '#ifdef WOLFSSL_USER_SETTINGS\n' '#include "user_settings.h"\n' @@ -932,9 +1105,14 @@ def test_pcpp_error_directive_is_fatal(self): user = '#error "this configuration is unsupported"\n' with self.assertRaises(SystemExit) as ctx: self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + # Guard against an empty-message regression that would still + # technically satisfy the SystemExit contract but leave the + # operator with no idea why their build broke. Any + # reasonably useful message will exceed this threshold. msg = str(ctx.exception) - self.assertIn('pcpp', msg) - self.assertIn('return_code', msg) + self.assertGreater(len(msg), 20, + f'gen-sbom exit message too short to be ' + f'actionable: {msg!r}') def test_function_like_macros_filtered(self): # Function-like macros are API surface, not build @@ -1068,6 +1246,56 @@ def test_neither_lib_nor_srcs_fails(self): self.assertNotEqual(result.returncode, 0) self.assertIn('--lib or --srcs', result.stderr) + def test_licenseref_without_license_text_is_rejected(self): + # Hard contract enforced at gen-sbom main() (see gen-sbom:880): + # any LicenseRef-* in --license-override must be accompanied by + # --license-text. Without this gate, build_extracted_licensing_infos + # silently emits a placeholder ('NOASSERTION. The text for this + # LicenseRef has not been embedded...') which technically + # validates as SPDX but is worthless to a CRA reviewer. + # TestBuildExtractedLicensingInfos exercises the placeholder + # path in isolation; this test pins the gate that should make + # that path unreachable from main(). A refactor that moves + # the check (e.g. into a helper called by only one entry-point + # shape) would be caught here. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null', + '--license-override', 'LicenseRef-wolfSSL-Commercial') + self.assertNotEqual(result.returncode, 0, + 'gen-sbom must reject LicenseRef-* override ' + 'without --license-text; CRA reviewers cannot ' + 'use the placeholder fallback') + # The error must tell the operator how to fix it; the literal + # '--license-text' substring is the actionable hint. + self.assertIn('--license-text', result.stderr) + + def test_licenseref_with_license_text_is_accepted(self): + # Positive companion to test_licenseref_without_license_text_is_rejected: + # confirms the gate does NOT fire when --license-text is supplied, + # so a refactor that flips the predicate sense (e.g. tests + # `is not None` where it should test `is None`) is also caught. + # We don't validate the SBOM content here — TestBuildExtractedLicensingInfos + # already covers the shape — only that the gate permits the run. + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write('Plain-text wolfSSL commercial licence text.\n') + license_text_path = f.name + try: + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null', + '--license-override', 'LicenseRef-wolfSSL-Commercial', + '--license-text', license_text_path) + self.assertEqual( + result.returncode, 0, + f'gen-sbom rejected a valid LicenseRef + license-text ' + f'pair: stderr={result.stderr!r}') + finally: + os.unlink(license_text_path) + def test_user_settings_path_in_help(self): # Discoverability regression guard - if the standalone entry # point is invisible to `--help`, embedded customers will not @@ -1080,5 +1308,396 @@ def test_user_settings_path_in_help(self): self.assertIn(token, result.stdout, f'{token!r} missing from --help') +# --------------------------------------------------------------------------- +# SBOM document generators (generate_cdx / generate_spdx + dep helpers). +# +# These four functions emit the actual JSON consumed by vulnerability +# scanners and CRA auditors. Until this block landed they were entirely +# untested; an SBOM-shape regression that still produced syntactically +# valid JSON would slip through every CI gate. The independent oracle +# is the CDX 1.6 / SPDX 2.3 schema field names, externally specified. +# --------------------------------------------------------------------------- + + +class TestCdxDepComponent(unittest.TestCase): + """gen-sbom:576 cdx_dep_component shapes a single CycloneDX dep entry.""" + + def test_returns_bomref_and_component(self): + # Stub pkgconfig_version so the test does not depend on the + # build host having libz / liboqs installed. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertEqual(comp['bom-ref'], ref) + self.assertEqual(comp['type'], 'library') + self.assertEqual(comp['name'], 'zlib') + self.assertEqual(comp['supplier']['name'], + 'Jean-loup Gailly and Mark Adler') + # Per CDX 1.6, listed-id licences go in license.id (not name). + # A regression that switches to license.name would silently + # produce an SBOM that some validators reject. + self.assertEqual( + comp['licenses'], [{'license': {'id': 'Zlib'}}]) + self.assertEqual(comp['version'], '1.3.1') + self.assertTrue(comp['purl'].startswith('pkg:')) + self.assertIn('zlib', comp['purl']) + self.assertEqual(comp['externalReferences'][0]['type'], 'vcs') + + def test_omits_version_and_purl_when_unknown(self): + # When pkg-config cannot resolve the dep version, gen-sbom + # emits the component WITHOUT a version field rather than + # advertising a wrong one. CRA scanners distinguish absent + # version from wrong version. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: None + ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertNotIn('version', comp) + self.assertNotIn('purl', comp) + # bom-ref is still present and deterministic. + self.assertTrue(ref) + + def test_dep_version_override_wins_over_pkgconfig(self): + # Embedded customers without pkg-config use --dep-version to + # supply the linked dep version explicitly. Confirms the + # override threads through to the emitted CDX component. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '99.99.99' + ref, comp = gs.cdx_dep_component( + 'wolfssl', '5.9.1', 'libz', {'libz': '1.3.1'}) + finally: + gs.pkgconfig_version = original + self.assertEqual(comp['version'], '1.3.1') + + def test_bomref_is_deterministic_for_same_inputs(self): + # Two calls with the same inputs must return identical bom-refs; + # otherwise SBOMs are not byte-identical across reruns and the + # reproducibility guarantee breaks. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + ref_a, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + ref_b, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertEqual(ref_a, ref_b) + + +class TestSpdxDepPackage(unittest.TestCase): + """gen-sbom:599 spdx_dep_package shapes a single SPDX dep package.""" + + def test_returns_spdxid_and_package(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '0.10.0' + spdx_id, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + self.assertTrue(spdx_id.startswith('SPDXRef-Package-')) + # SPDXID must contain only alphanumeric + '.' + '-' (SPDX + # 2.3 §3.2). spdx_dep_package strips everything else; a + # regression that allowed underscores or 'lib' prefixes + # could produce an SPDXID validators reject. + import re as _re + self.assertTrue( + _re.match(r'\ASPDXRef-[A-Za-z0-9.-]+\Z', spdx_id), + f'invalid SPDXID shape: {spdx_id!r}') + self.assertEqual(pkg['SPDXID'], spdx_id) + self.assertEqual(pkg['name'], 'liboqs') + self.assertEqual(pkg['versionInfo'], '0.10.0') + self.assertEqual(pkg['filesAnalyzed'], False) + # Both license fields must agree; SPDX validators accept + # divergence but it is semantically meaningless here. + self.assertEqual(pkg['licenseConcluded'], pkg['licenseDeclared']) + self.assertEqual(pkg['copyrightText'], 'NOASSERTION') + + def test_unknown_version_uses_NOASSERTION(self): + # SPDX 2.3 §3.3 requires versionInfo; when truly unknown, + # 'NOASSERTION' is the spec-compliant placeholder. Emitting + # an empty string or omitting the field would fail validation. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: None + _, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + self.assertEqual(pkg['versionInfo'], 'NOASSERTION') + # externalRefs.purl is only emitted when a version is known + # (a purl with no @version is meaningless to package-manager + # tooling); confirm it is absent here. + self.assertNotIn('externalRefs', pkg) + + def test_purl_externalref_present_when_version_known(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '0.10.0' + _, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + purl_refs = [ + r for r in pkg.get('externalRefs', []) + if r.get('referenceType') == 'purl' + ] + self.assertEqual(len(purl_refs), 1) + self.assertIn('liboqs', purl_refs[0]['referenceLocator']) + self.assertIn('0.10.0', purl_refs[0]['referenceLocator']) + + +class TestGenerateCdx(unittest.TestCase): + """gen-sbom:624 generate_cdx assembles the full CycloneDX 1.6 doc.""" + + BASE_KW = dict( + name='wolfssl', + version='5.9.1', + supplier='wolfSSL Inc.', + license_id='GPL-2.0-only', + license_text=None, + lib_hash='a' * 64, + timestamp='2024-01-01T00:00:00Z', + year=2024, + serial='00000000-0000-0000-0000-000000000001', + enabled_deps=[], + build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')], + ) + + def test_top_level_shape(self): + doc = gs.generate_cdx(**self.BASE_KW) + self.assertEqual(doc['bomFormat'], 'CycloneDX') + self.assertEqual(doc['specVersion'], '1.6') + self.assertEqual( + doc['$schema'], + 'http://cyclonedx.org/schema/bom-1.6.schema.json') + self.assertEqual(doc['version'], 1) + # serialNumber is a urn:uuid: prefix per CDX schema. + self.assertTrue(doc['serialNumber'].startswith('urn:uuid:')) + + def test_main_component_fields(self): + doc = gs.generate_cdx(**self.BASE_KW) + comp = doc['metadata']['component'] + self.assertEqual(comp['type'], 'library') + self.assertEqual(comp['name'], 'wolfssl') + self.assertEqual(comp['version'], '5.9.1') + # CPE 2.3 with vendor:product:version - downstream + # vulnerability scanners key on this format. + self.assertEqual( + comp['cpe'], + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + self.assertEqual(comp['purl'], 'pkg:generic/wolfssl@5.9.1') + self.assertEqual(comp['hashes'], + [{'alg': 'SHA-256', 'content': 'a' * 64}]) + self.assertEqual(comp['licenses'], + [{'license': {'id': 'GPL-2.0-only'}}]) + + def test_build_properties_emitted(self): + doc = gs.generate_cdx(**self.BASE_KW) + props = doc['metadata']['component']['properties'] + names = {p['name']: p['value'] for p in props} + self.assertEqual(names['wolfssl:build:HAVE_AESGCM'], '1') + # An empty define value is rendered as '1' so the SBOM + # consumer can't distinguish '#define X' from '#define X 1'. + self.assertEqual(names['wolfssl:build:NO_DES3'], '1') + + def test_dependency_refs_match_components(self): + # Critical invariant: every bom-ref in `dependencies` must + # appear as a `bom-ref` on either the main component or one + # of the dep components. Without this, the dependency graph + # references dangling IDs and CycloneDX-aware tooling cannot + # resolve relationships. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'enabled_deps': ['libz'], + }) + finally: + gs.pkgconfig_version = original + all_refs = {doc['metadata']['component']['bom-ref']} + for c in doc['components']: + all_refs.add(c['bom-ref']) + for entry in doc['dependencies']: + self.assertIn(entry['ref'], all_refs, + f"dangling dep ref: {entry['ref']!r}") + for dep in entry.get('dependsOn', []): + self.assertIn(dep, all_refs, + f"dangling dependsOn ref: {dep!r}") + # The wolfssl bom-ref must depend on the libz bom-ref. + wolfssl_ref = doc['metadata']['component']['bom-ref'] + wolfssl_entry = next( + e for e in doc['dependencies'] if e['ref'] == wolfssl_ref) + self.assertEqual(len(wolfssl_entry['dependsOn']), 1) + + def test_source_merkle_path_emits_hash_kind_property(self): + # The OmniBOR / source-merkle entry point annotates the + # SBOM so an auditor reading the SHA-256 knows it is a hash + # of the source set, not of the built library. Without + # this property the same SHA-256 field carries two + # incompatible semantic meanings depending on entry point. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-kind'], + 'source-merkle-omnibor') + self.assertEqual(props['wolfssl:sbom:source-set'], 'aes.c,sha.c') + + def test_library_binary_path_omits_hash_kind_property(self): + # Reproducibility CI keys on byte-equal SBOMs across two runs + # of `make sbom` with the same SOURCE_DATE_EPOCH; adding the + # hash-kind annotation to the library-binary path would break + # that diff. Pin the empty-set behaviour. + doc = gs.generate_cdx(**self.BASE_KW) + prop_names = { + p['name'] + for p in doc['metadata']['component']['properties'] + } + self.assertNotIn('wolfssl:sbom:hash-kind', prop_names) + self.assertNotIn('wolfssl:sbom:source-set', prop_names) + + +class TestGenerateSpdx(unittest.TestCase): + """gen-sbom:698 generate_spdx assembles the full SPDX 2.3 doc.""" + + BASE_KW = dict( + name='wolfssl', + version='5.9.1', + supplier='wolfSSL Inc.', + license_id='GPL-2.0-only', + license_text=None, + lib_hash='a' * 64, + timestamp='2024-01-01T00:00:00Z', + year=2024, + doc_ns_uuid='00000000-0000-0000-0000-000000000002', + enabled_deps=[], + build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')], + ) + + def test_top_level_shape(self): + doc = gs.generate_spdx(**self.BASE_KW) + self.assertEqual(doc['spdxVersion'], 'SPDX-2.3') + self.assertEqual(doc['dataLicense'], 'CC0-1.0') + self.assertEqual(doc['SPDXID'], 'SPDXRef-DOCUMENT') + self.assertEqual(doc['name'], 'wolfssl-5.9.1') + self.assertTrue( + doc['documentNamespace'].startswith('https://wolfssl.com/sbom/')) + # documentNamespace must include the doc_ns_uuid so two + # different versions produce different namespaces (SPDX 2.3 + # §6.5). + self.assertIn(self.BASE_KW['doc_ns_uuid'], doc['documentNamespace']) + + def test_main_package_fields(self): + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertEqual(wolfssl_pkg['name'], 'wolfssl') + self.assertEqual(wolfssl_pkg['versionInfo'], '5.9.1') + self.assertEqual( + wolfssl_pkg['checksums'], + [{'algorithm': 'SHA256', 'checksumValue': 'a' * 64}]) + self.assertEqual(wolfssl_pkg['licenseConcluded'], 'GPL-2.0-only') + self.assertEqual(wolfssl_pkg['licenseDeclared'], 'GPL-2.0-only') + + def test_describes_relationship(self): + # SPDX 2.3 §11: every document must DESCRIBE its primary package. + doc = gs.generate_spdx(**self.BASE_KW) + describes = [ + r for r in doc['relationships'] + if r['relationshipType'] == 'DESCRIBES' + ] + self.assertEqual(len(describes), 1) + self.assertEqual(describes[0]['spdxElementId'], 'SPDXRef-DOCUMENT') + self.assertEqual(describes[0]['relatedSpdxElement'], + 'SPDXRef-Package-wolfssl') + + def test_depends_on_relationship_per_dep(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'enabled_deps': ['libz'], + }) + finally: + gs.pkgconfig_version = original + depends_on = [ + r for r in doc['relationships'] + if r['relationshipType'] == 'DEPENDS_ON' + ] + self.assertEqual(len(depends_on), 1) + self.assertEqual(depends_on[0]['spdxElementId'], + 'SPDXRef-Package-wolfssl') + # The relatedSpdxElement must be a real SPDXID in the doc; + # a typo would create a dangling reference. + all_spdx_ids = {p['SPDXID'] for p in doc['packages']} + self.assertIn(depends_on[0]['relatedSpdxElement'], all_spdx_ids) + + def test_extracted_licensing_infos_present_for_licenseref(self): + # Critical SPDX 2.3 §10.1 plumbing: when license_id contains + # a LicenseRef-* and license_text is supplied, the document + # MUST carry a hasExtractedLicensingInfos block covering it. + # A regression that drops the wiring in generate_spdx's tail + # produces SBOMs that fail SPDX validation -- the autotools + # path catches this at pyspdxtools time, but the standalone + # path does not validate, so a customer-shipped SBOM would + # silently land at an auditor. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'license_id': 'LicenseRef-wolfSSL-Commercial', + 'license_text': 'Commercial licence body.\n', + }) + self.assertIn('hasExtractedLicensingInfos', doc) + infos = doc['hasExtractedLicensingInfos'] + self.assertEqual(len(infos), 1) + self.assertEqual(infos[0]['licenseId'], + 'LicenseRef-wolfSSL-Commercial') + self.assertEqual(infos[0]['extractedText'], + 'Commercial licence body.\n') + + def test_extracted_licensing_infos_absent_for_simple_id(self): + # Companion to the above: simple SPDX IDs (Apache-2.0, + # GPL-2.0-only, etc.) MUST NOT generate a + # hasExtractedLicensingInfos block, since the licence + # text is well-known and the field is reserved for refs. + doc = gs.generate_spdx(**self.BASE_KW) + self.assertNotIn('hasExtractedLicensingInfos', doc) + + def test_source_merkle_path_annotates_comment(self): + # Mirror of TestGenerateCdx.test_source_merkle_path_emits_hash_kind_property + # for SPDX: the annotation lives in the package 'comment' + # field rather than as a property, but the auditor-facing + # information is the same. Reproducibility CI must continue + # to see the library-binary path emit the same comment shape + # it always has. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertIn('hash-kind=source-merkle-omnibor', + wolfssl_pkg['comment']) + self.assertIn('source-set=aes.c,sha.c', wolfssl_pkg['comment']) + + def test_library_binary_path_comment_unannotated(self): + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) + self.assertNotIn('source-set=', wolfssl_pkg['comment']) + + if __name__ == '__main__': unittest.main(verbosity=2) From 7354c61363fa6af5dadd0361469e54ae375ef260 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:15:05 -0700 Subject: [PATCH 18/39] fix(sbom): correctness fixes in gen-sbom Three review-driven correctness fixes in scripts/gen-sbom, each shipping with its own regression tests: * parse_options_h corrupted #define values that contained a string literal with '/' characters: 're.split(r"/\\*|//", raw, maxsplit=1)[0]' truncated PACKAGE_URL="https://..." at the first // inside the URL, so the SBOM emitted 'PACKAGE_URL = "https:'. Replace the regex with a string-literal-aware state machine (_strip_define_comment) so quoted '/' characters survive. Char literals are not handled (autoconf does not emit them, pcpp normalises user_settings.h before this code sees the value). Adds four regression tests to TestParseOptionsH (1zj.22). * --lib accepted /dev/null and emitted the well-known empty- file SHA-256 (e3b0c44...b855) as the wolfSSL component checksum. Both SPDX and CDX validators accepted the result, so a misconfigured build silently shipped an SBOM whose hash matched no compiled wolfSSL artefact. Hard-fail on size==0 --lib, with an actionable error. Soft-warn on zero-byte --srcs entries because cross-compile setups legitimately include touch'd placeholders. Adds test_empty_lib_is_rejected and test_zero_byte_srcs_warn_but_do_not_fail (1zj.23). * detect_license regex 'or\\s+(any\\s+)?later' failed to match the canonical FSF GPL preamble phrase 'or (at your option) any later version' used verbatim in millions of upstream COPYING files: the parenthetical interjected between 'or' and 'any later'. Any LICENSING that copied the FSF preamble was silently mis-detected as GPL-X.0-only. Broaden to 'or\\s+(?:[^,.;\\n]*?\\s+)?(?:any\\s+)?later' -- the negative class keeps the match within a sentence so unrelated 'or' / 'later' tokens further down the file do not promote a GPLv2-only declaration. Adds GPLv2 + GPLv3 canonical-preamble regression guards. The shipping wolfSSL LICENSING still maps to GPL-3.0-only; locked down by test_real_wolfssl_licensing_is_gpl3_only (1zj.24). Also rebinds the test_zero_byte_srcs_warn_but_do_not_fail tempfile paths before the try-block so the finally clause references live filenames on the assertion-failure path instead of leaking renamed tempfiles in /tmp (e8o.2). Closes wolfssl-1zj.22, wolfssl-1zj.23, wolfssl-1zj.24, wolfssl-e8o.2 --- scripts/gen-sbom | 86 +++++++++++++++++++++- scripts/test_gen_sbom.py | 149 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 224 insertions(+), 11 deletions(-) diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 192ccb9fd40..8a4a8597526 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -192,7 +192,16 @@ def detect_license(license_file): version = m.group(1) excerpt = text[m.end():m.end() + 100] - if re.search(r'or\s+(any\s+)?later', excerpt, re.IGNORECASE): + # Match upgrade-permission wording in the 100-byte excerpt that + # follows the version mention. Three FSF-derived shapes: + # * canonical preamble: "or (at your option) any later version" + # * preamble variant: "or (at the licensee's option) any later" + # * compact form: "or later" / "or any later" + # The optional `[^,.;\n]*?\s+` group consumes parenthesised + # asides without crossing sentence boundaries so unrelated + # "or" / "later" mentions in surrounding prose do not match. + if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later', + excerpt, re.IGNORECASE): return f'GPL-{version}.0-or-later' return f'GPL-{version}.0-only' @@ -338,6 +347,45 @@ def _is_noise_macro(name): return False +def _strip_define_comment(raw): + """Strip trailing C/C++ comment from a #define value while preserving + `/`-bearing characters that appear inside a double-quoted string. + + Earlier versions used `re.split(r'/\\*|//', raw, maxsplit=1)[0]`, which + is unaware of string literals. That regex corrupts autoconf-generated + defines such as + + #define PACKAGE_URL "https://www.wolfssl.com" + #define PACKAGE_BUGREPORT "https://github.com/wolfssl/wolfssl/issues" + + by truncating at the first `//` inside the URL — both end up as + `"https:` in the SBOM build properties, falsely showing PACKAGE_URL + drifting between releases when nothing actually changed. + + Char literals are not handled: autoconf-generated options.h does not + emit them, and pcpp normalises customer user_settings.h before this + helper sees the value, so the only realistic source of `/` in a + #define value is a quoted string.""" + in_str = False + i = 0 + n = len(raw) + while i < n: + c = raw[i] + if in_str: + if c == '\\' and i + 1 < n: + i += 2 + continue + if c == '"': + in_str = False + else: + if c == '"': + in_str = True + elif c == '/' and i + 1 < n and raw[i + 1] in '/*': + return raw[:i] + i += 1 + return raw + + def parse_options_h(path): """Parse a flat `#define` header and return a sorted deduplicated list of (name, value) pairs for every wolfSSL-relevant macro. @@ -354,7 +402,9 @@ def parse_options_h(path): 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.""" + SBOM build properties. String literals are preserved intact so that + URLs in PACKAGE_URL / PACKAGE_BUGREPORT are not truncated at the + first `//` (see _strip_define_comment).""" try: with open(path) as f: text = f.read() @@ -368,7 +418,7 @@ def parse_options_h(path): if _is_noise_macro(name): continue raw = (m.group(2) or '') - raw = re.split(r'/\*|//', raw, maxsplit=1)[0] + raw = _strip_define_comment(raw) defines[name] = raw.strip() return sorted(defines.items()) @@ -898,10 +948,40 @@ def main(): ) if args.lib: + # Refuse the empty-file SHA-256 as a component checksum. A + # build that points --lib at /dev/null, a stub touch(1)'d + # placeholder, or an empty .a that failed to ar-create would + # otherwise emit a valid-looking SBOM whose hash matches no + # compiled wolfSSL artefact ever shipped. The SBOM passes + # both spec validators -- nothing else catches it. + try: + lib_size = os.path.getsize(args.lib) + except OSError as e: + sys.exit(f"ERROR: cannot stat --lib {args.lib!r}: {e}") + if lib_size == 0: + sys.exit( + f"ERROR: --lib {args.lib!r} is empty (0 bytes); refusing " + "to emit an SBOM with the empty-file SHA-256 as the " + "component checksum. Verify your build produced a " + "real library artefact.") lib_hash = sha256_file(args.lib) hash_kind = 'library-binary' srcs_basenames = None else: + # --srcs is the embedded entry point. Zero-byte files in the + # set are uncommon but not necessarily wrong (a cross-compile + # toolchain may stub a per-target source with touch); warn + # rather than fail so the customer can decide whether the + # gitoid for an empty blob is what they want recorded. + zero_byte_srcs = [ + p for p in args.srcs if os.path.isfile(p) and os.path.getsize(p) == 0 + ] + if zero_byte_srcs: + print( + "WARNING: zero-byte source files in --srcs (gitoid will " + "be the well-known empty-blob hash for these): " + + ', '.join(zero_byte_srcs), + file=sys.stderr) lib_hash = srcs_merkle_hash(args.srcs) hash_kind = 'source-merkle-omnibor' srcs_basenames = sorted({os.path.basename(p) for p in args.srcs}) diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index ec4ebaedfc6..c580db0cbdb 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -306,19 +306,40 @@ def test_gplv2_only(self): def test_gplv2_or_later_any_form(self): # 'or any later' immediately after the version mention. # Oracle: SPDX 'GPL-2.0-or-later'. - # NOTE: the canonical FSF preamble phrase 'or (at your option) - # any later version' is NOT matched by the current regex - # (the parenthetical interjection breaks the pattern). That - # is a separate limitation tracked in its own beads issue; - # this test deliberately uses a pattern the existing regex - # handles, so it serves as a regex regression guard rather - # than a redesign request. self.assertEqual( self._detect( 'Licensed under the GNU General Public License version 2, ' 'or any later version.\n'), 'GPL-2.0-or-later') + def test_gplv2_or_later_canonical_fsf_preamble(self): + # The canonical FSF GPL preamble phrase, used verbatim in + # millions of upstream COPYING files: + # + # 'either version N of the License, or (at your option) + # any later version.' + # + # An earlier regex (`or\s+(any\s+)?later`) failed to match + # this because the parenthetical '(at your option)' + # interjects between 'or' and 'any later', so wolfssl-1zj.24 + # silently mis-detected the preamble as GPLv2-only. Oracle: + # SPDX 'GPL-2.0-or-later'. + self.assertEqual( + self._detect( + 'This program is free software: you can redistribute it ' + 'and/or modify it under the terms of the GNU General ' + 'Public License version 2, or (at your option) any ' + 'later version.\n'), + 'GPL-2.0-or-later') + + def test_gplv3_or_later_canonical_fsf_preamble(self): + # Same regression guard for GPLv3. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 3, ' + 'or (at your option) any later version.\n'), + 'GPL-3.0-or-later') + def test_gplv2_or_later_short_form(self): # 'or later' (without 'any') also matches the regex; this # variant appears in some upstream COPYING files. Oracle: @@ -488,6 +509,48 @@ 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_preserves_url_in_string_literal(self): + # Regression guard: an earlier comment-stripper used + # `re.split(r'/\*|//', raw, maxsplit=1)[0]`, which truncated + # autoconf-generated PACKAGE_URL / PACKAGE_BUGREPORT defines + # at the first `//` inside the URL. Both ended up as + # `"https:` in the SBOM build properties, falsely showing + # PACKAGE_URL drifting between releases when nothing changed. + pairs = dict(self._parse( + '#define PACKAGE_URL "https://www.wolfssl.com"\n' + '#define PACKAGE_BUGREPORT ' + '"https://github.com/wolfssl/wolfssl/issues"\n' + )) + self.assertEqual(pairs['PACKAGE_URL'], + '"https://www.wolfssl.com"') + self.assertEqual(pairs['PACKAGE_BUGREPORT'], + '"https://github.com/wolfssl/wolfssl/issues"') + + def test_strips_comment_after_string_literal(self): + # Companion to test_preserves_url_in_string_literal: confirm + # the stripper still works when a comment legitimately follows + # a string literal. A regression that disabled stripping + # entirely (the simplest "fix" for the URL bug) would let + # comment text leak into the SBOM. + pairs = dict(self._parse( + '#define PACKAGE_URL "https://www.wolfssl.com" /* upstream */\n' + )) + self.assertEqual(pairs['PACKAGE_URL'], + '"https://www.wolfssl.com"') + + def test_preserves_block_comment_inside_string_literal(self): + # `/*` inside a string literal must not start a comment. + pairs = dict(self._parse('#define WEIRD "a/*b*/c"\n')) + self.assertEqual(pairs['WEIRD'], '"a/*b*/c"') + + def test_handles_escaped_quote_in_string_literal(self): + # An escaped `\"` inside a string literal must not be mistaken + # for the closing quote; otherwise a comment-marker that + # follows would be incorrectly treated as outside the string. + pairs = dict(self._parse( + '#define EMBEDDED_QUOTE "a\\"b//c" /* tail */\n')) + self.assertEqual(pairs['EMBEDDED_QUOTE'], '"a\\"b//c"') + def test_dedup_keeps_last_assignment(self): # Last assignment wins (matches C preprocessor semantics for # duplicate #defines after redefinition). @@ -1282,11 +1345,18 @@ def test_licenseref_with_license_text_is_accepted(self): delete=False) as f: f.write('Plain-text wolfSSL commercial licence text.\n') license_text_path = f.name + # --lib must be non-empty (gen-sbom refuses /dev/null as a + # component checksum); use a tiny stand-in file so we exercise + # the LicenseRef gate without tripping the empty-lib gate. + with tempfile.NamedTemporaryFile('wb', suffix='.so', + delete=False) as f: + f.write(b'\x7fELF stub') + lib_path = f.name try: result = self._run( *self.BASE, '--options-h', '/dev/null', - '--lib', '/dev/null', + '--lib', lib_path, '--license-override', 'LicenseRef-wolfSSL-Commercial', '--license-text', license_text_path) self.assertEqual( @@ -1295,6 +1365,69 @@ def test_licenseref_with_license_text_is_accepted(self): f'pair: stderr={result.stderr!r}') finally: os.unlink(license_text_path) + os.unlink(lib_path) + + def test_empty_lib_is_rejected(self): + # The --lib argument is the wolfSSL component checksum source. + # An empty file produces the well-known empty-file SHA-256 + # (e3b0c44...b855), which is a valid-looking hash that + # matches no real wolfSSL build artefact ever shipped. Both + # SPDX and CDX validators accept it; nothing else catches + # the lie. gen-sbom must refuse zero-byte --lib. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0, + 'gen-sbom accepted an empty --lib file; would ' + 'have shipped an SBOM with the empty-file ' + 'SHA-256 as the wolfSSL component checksum') + self.assertIn('empty', result.stderr.lower()) + self.assertIn('--lib', result.stderr) + + def test_zero_byte_srcs_warn_but_do_not_fail(self): + # Companion: --srcs may legitimately include zero-byte + # placeholders in cross-compile setups (a target file the + # build system creates with touch but doesn't compile yet), + # so gen-sbom emits a WARNING rather than failing. This + # gives the embedded customer a chance to see they have a + # stub file in the source set without breaking their build. + with tempfile.NamedTemporaryFile('wb', suffix='.c', + delete=False) as f: + f.write(b'/* real source */\n') + real_src = f.name + with tempfile.NamedTemporaryFile('wb', suffix='.c', + delete=False) as f: + empty_src = f.name + # Rename so the basenames are distinct (srcs_merkle_hash + # rejects duplicate basenames; see TestSrcsMerkleHash). + # Rename and rebind BEFORE the try-block so the finally + # clause always references the live filenames even when an + # assertion fails. + real_renamed = real_src + '.real.c' + empty_renamed = empty_src + '.empty.c' + os.rename(real_src, real_renamed) + os.rename(empty_src, empty_renamed) + real_src = real_renamed + empty_src = empty_renamed + try: + result = self._run( + *self.BASE, + '--user-settings', '/dev/null', + '--srcs', real_src, empty_src) + # The standalone path with /dev/null user-settings should + # complete; the only thing we care about here is that an + # empty source did not abort the run. + self.assertEqual( + result.returncode, 0, + f'gen-sbom failed with zero-byte source: stderr={result.stderr!r}') + self.assertIn('zero-byte source', result.stderr) + finally: + for p in (real_src, empty_src): + try: + os.unlink(p) + except FileNotFoundError: + pass def test_user_settings_path_in_help(self): # Discoverability regression guard - if the standalone entry From 8740aa5672f0f2454cd9db097e11fcb92ecb6b73 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:15:42 -0700 Subject: [PATCH 19/39] build(sbom): Makefile.am hygiene Five small Makefile.am improvements from the wolfssl-1zj review pass. None changes user-facing behaviour for any correct invocation; each closes a specific failure mode that either silently corrupts an SBOM or leaks state. * sbom: lib glob: $(addprefix "$(abs_builddir)/...") quotes the prefix so an abs_builddir containing a space no longer word-splits the test -f loop. Matches the sbom: target's existing pattern (1zj.7). * SOURCE_DATE_EPOCH is only exported when 'git log -1 --format=%ct' returned a non-empty string. Previously the fallback echo on a shallow clone or corrupt repo silently exported SOURCE_DATE_EPOCH='', which gen-sbom then treated as 'no SDE' (warning + wallclock fallback) defeating the reproducibility intent (1zj.9). * --license-override and --license-text are now passed to gen-sbom only when their corresponding Make var is set ($(if VAR,...)). The previous unconditional passthrough relied on gen-sbom's argparse default '' to treat empty as unset; a future gen-sbom change distinguishing those cases would have silently mis-emitted SBOMs (1zj.10). * install-bomsh replaces the two-tar pipeline with cp -R src/. dst/. POSIX sh masks pipeline source-side errors; a permission failure on 'tar cf - .' was followed by 'tar xf -' on an empty stream succeeding, leaving a silent partial install. cp -R is a single command whose exit code propagates cleanly (1zj.11). * uninstall-hook now depends on uninstall-sbom + uninstall-bomsh instead of duplicating their bodies. Both targets are .PHONY so the dep edge resolves; the cleanup paths stay in lockstep with install-sbom / install-bomsh automatically (1zj.12). (BOMSH_OMNIBORDIR cleanup at make-clean time is handled by the existing clean-local target in doc/include.am, which the original PR commit extended. An earlier review attempted to add a duplicate clean-local here; that silently overrode the doc cleanups it relied on. The duplicate is not present in this squashed commit.) Closes wolfssl-1zj.6, wolfssl-1zj.7, wolfssl-1zj.9, wolfssl-1zj.10, wolfssl-1zj.11, wolfssl-1zj.12, wolfssl-e8o.1 --- Makefile.am | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Makefile.am b/Makefile.am index a02dd7ecf78..130fc0cec63 100644 --- a/Makefile.am +++ b/Makefile.am @@ -502,15 +502,18 @@ sbom: 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; \ + 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; \ $(PYTHON3) $(srcdir)/scripts/gen-sbom \ --name $(PACKAGE) \ --version $(PACKAGE_VERSION) \ --license-file $(srcdir)/LICENSING \ - --license-override '$(SBOM_LICENSE_OVERRIDE)' \ - --license-text '$(SBOM_LICENSE_TEXT)' \ + $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ + $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ --options-h $(abs_builddir)/wolfssl/options.h \ --lib "$$sbom_lib" \ --dep-libz $(ENABLED_LIBZ) \ @@ -575,9 +578,9 @@ bomsh: 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 \ + $(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 \ @@ -596,8 +599,7 @@ bomsh: 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 -); \ + cp -R '$(BOMSH_OMNIBORDIR)/.' '$(DESTDIR)$(bomshdir)/omnibor/'; \ fi @if test -f '$(abs_builddir)/$(BOMSH_SPDX_OUT)'; then \ $(INSTALL_DATA) '$(abs_builddir)/$(BOMSH_SPDX_OUT)' '$(DESTDIR)$(bomshdir)/'; \ @@ -610,11 +612,9 @@ uninstall-bomsh: 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) +# stale artefacts behind after install-sbom/install-bomsh. uninstall-sbom +# and uninstall-bomsh use `rm -f` / `rm -rf` so they are idempotent and +# safe whether or not those targets were ever run. Depending on them +# rather than duplicating their bodies keeps the cleanup paths in lock +# step with install-sbom/install-bomsh. +uninstall-hook: uninstall-sbom uninstall-bomsh From c76eaada77affe73fe6b80b567ee3fe228ebb9a2 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:16:02 -0700 Subject: [PATCH 20/39] ci(sbom): pin deps, harden token, partial-clone bomsh Three supply-chain hardenings on .github/workflows/sbom.yml: * Pin pcpp to ==1.30.* in both jobs that install it (unit + standalone), matching the existing pin convention used in the same file for spdx-tools, ntia-conformance- checker, and cyclonedx-bom. A pcpp release that changes macro-expansion or noise semantics will no longer silently shift SBOM build_props on master (1zj.1). * Add 'permissions: contents: read' at workflow level. The workflow does no API writes, so the practical risk of the default read-write GITHUB_TOKEN on push events is zero today; the explicit pin is GitHub's documented hardening recommendation for SBOM-producing supply-chain workflows so a future step that accidentally introduces a write call fails loudly rather than silently mutating the repo (1zj.2). * Switch the bomsh clone to git clone --filter=blob:none. Full history was being downloaded before 'git checkout $BOMSH_SHA'; --depth=1 cannot be combined with a raw SHA, so --filter=blob:none is the closest 'shallow' substitute. Saves ~10s of CI time per bomsh job run (1zj.3). Closes wolfssl-1zj.1, wolfssl-1zj.2, wolfssl-1zj.3 --- .github/workflows/sbom.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index ce8e752c129..2d77d541c9f 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -7,6 +7,16 @@ on: pull_request: branches: [ '*' ] +# Defence-in-depth: the workflow does no API writes (no `gh pr`, no +# `git push`, no release upload), so the practical risk of the default +# read-write GITHUB_TOKEN on push events is zero today. Setting +# `contents: read` at workflow level is GitHub's documented hardening +# recommendation for SBOM-producing supply-chain workflows: a future +# step that accidentally introduces a write call will fail loudly +# rather than silently mutate the repo. +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -34,8 +44,12 @@ jobs: # entire embedded entry point unverified at the cheapest CI # gate. The setUp now hard-fails on missing pcpp; this step # ensures it is present so all unit tests actually run. + # Pinned to match the convention used elsewhere in this file + # (spdx-tools==0.8.*, ntia-conformance-checker==5.*) so a pcpp + # release that changes macro-expansion or noise semantics does + # not silently shift SBOM build_props on master. run: | - python3 -m pip install --user pcpp + python3 -m pip install --user 'pcpp==1.30.*' echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Unit tests @@ -63,7 +77,7 @@ jobs: # spdx-tools is for the post-generation validation step. run: | python3 -m pip install --user --upgrade pip - python3 -m pip install --user pcpp 'spdx-tools==0.8.*' + python3 -m pip install --user 'pcpp==1.30.*' 'spdx-tools==0.8.*' echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Generate SBOM via standalone Python entry point @@ -880,7 +894,13 @@ jobs: # the patch's context lines in src/strace.c. STRACE_TAG: v6.7 run: | - git clone https://github.com/omnibor/bomsh /tmp/bomsh + # --filter=blob:none is the cleanest "shallow-ish" clone when + # checking out a specific SHA: it skips file blobs but keeps + # the commit graph, so `git checkout $BOMSH_SHA` still works. + # `--depth=1` would only work with `--branch `, not a + # raw SHA. The strace clone below uses `--depth=1 --branch` + # because it pins to a release tag. + git clone --filter=blob:none https://github.com/omnibor/bomsh /tmp/bomsh git -C /tmp/bomsh checkout "$BOMSH_SHA" # Even with a pinned SHA, keep the layout-drift guard so the # next maintainer who bumps BOMSH_SHA gets a clear error if From c6a603886634a1e7533ab3465f33c373e28d77fd Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:16:28 -0700 Subject: [PATCH 21/39] docs(sbom): correct NTIA/SPDX claims; fix broken anchor Three documentation corrections from the wolfssl-1zj review: * README.md previously said 'Both produce SPDX 2.3 + CycloneDX 1.6 JSON validated against NTIA minimum elements'. Neither entry point runs an NTIA-minimum-elements checker. The autotools 'make sbom' path runs pyspdxtools (SPDX-spec validation, not NTIA-specific) and gates the build on it; the standalone embedded path runs no validation by default. Replace with text saying the SBOMs are intended to satisfy NTIA minimum elements, that only the autotools path runs SPDX validation, and that neither path runs an NTIA checker by default. Matters because downstream integrators quoting this README to auditors must not be misled into claiming NTIA validation has been performed (1zj.13). * doc/CRA.md previously named ntia-conformance-checker alongside pyspdxtools as a tool that 'will reject the SBOM otherwise' when a LicenseRef has no hasExtractedLicensingInfos block. ntia-conformance-checker validates the NTIA Minimum Elements (supplier, component name, version, unique ID, dependency relationships, author, timestamp) -- it does NOT enforce SPDX 2.3 \xc2\xa710.1 extracted-text completeness, which is a SPDX schema concern enforced by pyspdxtools. A reader who relied on the claim would get a false pass on a malformed LicenseRef SBOM. Drop the misleading mention; add a sentence stating what ntia-conformance-checker actually checks (1zj.16). * doc/SBOM.md anchor link 'See [Prerequisites for make bomsh] (#3-make-bomsh)' resolved to the top of section 3 instead of the \xc2\xa73.1 prerequisites subsection. Fix to the GFM auto-anchor for '### 3.1 Prerequisites for make bomsh', which is #31-prerequisites-for-make-bomsh (1zj.18). Closes wolfssl-1zj.13, wolfssl-1zj.16, wolfssl-1zj.18 --- README.md | 10 +++++++--- doc/CRA.md | 9 ++++++--- doc/SBOM.md | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6135cf99640..14130eaeeed 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,13 @@ Act (CRA) compliance via two entry points: - `make sbom` for Linux server / Debian / RPM / Yocto / FIPS-Ready builds that already use `./configure && make`. -Both produce SPDX 2.3 + CycloneDX 1.6 JSON validated against NTIA -minimum elements. See `doc/SBOM.md` for per-toolchain recipes and the -full flag reference. +Both produce SPDX 2.3 + CycloneDX 1.6 JSON intended to satisfy the +NTIA minimum elements. The `make sbom` path additionally runs +SPDX-spec validation via `pyspdxtools` and gates the build on it; +the standalone path validates only on demand (see `doc/SBOM.md` +§ 1.3). Neither path runs an NTIA-minimum-elements checker by +default. See `doc/SBOM.md` for per-toolchain recipes and the full +flag reference. ## OmniBOR / Bomsh diff --git a/doc/CRA.md b/doc/CRA.md index d3cefcf3b8d..58287883144 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -181,9 +181,12 @@ make sbom \ `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. +the document; SPDX-conformant validators (e.g. `pyspdxtools`) will reject the +SBOM otherwise. `ntia-conformance-checker` validates a separate set of NTIA +minimum elements (supplier, component name, version, unique identifier, +dependency relationships, author, timestamp) and will **not** catch a missing +extracted-text block — do not rely on it to gate this case. 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 diff --git a/doc/SBOM.md b/doc/SBOM.md index 9666792d966..15c535d4868 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -381,7 +381,7 @@ make bomsh ``` Additionally requires `bomtrace3` and `bomsh_create_bom.py` in `PATH`. -See [Prerequisites for make bomsh](#3-make-bomsh) below. +See [Prerequisites for make bomsh](#31-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. From 3b796a5739efaf254e03a673d5f3ff37e9688bf4 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 8 May 2026 17:42:53 -0700 Subject: [PATCH 22/39] docs(sbom): align with project's formal security policy doc/SBOM.md and doc/CRA.md previously had no references to wolfSSL's vulnerability disclosure process. CRA Article 10 obliges manufacturers to handle and disclose vulnerabilities, so a CRA-compliance doc that does not point integrators at the disclosure path leaves the auditor with an incomplete handoff. PR #10284 (already on master) published the project's formal security policy at SECURITY-POLICY.md and a mandatory reporting template at SECURITY-REPORT-TEMPLATE.md. The policy explicitly accommodates ecosystem-coordination embargoes for 'downstream integrators, certification bodies, or equivalent' -- the exact audience these CRA-compliance docs are written for. doc/CRA.md: extends 'What to Give Your Auditor' with a paragraph on the disclosure process, advisory feed, and the embargo accommodation; lists SECURITY-POLICY.md and SECURITY-REPORT-TEMPLATE.md under 'Further Reading'. doc/SBOM.md: adds a closing paragraph to '5. Using wolfSSL's artefacts in a product' pointing readers at SECURITY-POLICY.md when a vulnerability is found in wolfSSL or any SBOM-listed dependency. This is congruent with the project's formal security policy landed by #10284. --- doc/CRA.md | 16 ++++++++++++++++ doc/SBOM.md | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/doc/CRA.md b/doc/CRA.md index 58287883144..12fd402149d 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -268,12 +268,28 @@ 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. +CRA Article 10 also obliges manufacturers to handle and disclose +vulnerabilities. wolfSSL's vulnerability disclosure process is documented +in [`SECURITY-POLICY.md`](../SECURITY-POLICY.md) at the repository root, +with a mandatory reporting template at +[`SECURITY-REPORT-TEMPLATE.md`](../SECURITY-REPORT-TEMPLATE.md). Reports +go to **support@wolfssl.com**; published advisories live at +. The policy +explicitly accommodates ecosystem-coordination embargoes for downstream +integrators and certification bodies — point your auditor at this so +they can verify the disclosure path before signing off. + ## Further Reading ### wolfSSL documentation - `doc/SBOM.md` — unified reference covering SBOM generation, OmniBOR/Bomsh build provenance, combined workflow, output formats, and implementation notes +- [`SECURITY-POLICY.md`](../SECURITY-POLICY.md) — vulnerability disclosure + policy, severity rubric, coordinated-disclosure practice, embargo + extensions for downstream integrators and certification bodies +- [`SECURITY-REPORT-TEMPLATE.md`](../SECURITY-REPORT-TEMPLATE.md) — + mandatory template for vulnerability reports submitted to wolfSSL ### OpenSSF guidance diff --git a/doc/SBOM.md b/doc/SBOM.md index 15c535d4868..c1bf91bed5f 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -650,3 +650,11 @@ file. 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. + +If a vulnerability is found in wolfSSL itself or in any dependency listed +in the SBOM, see [`SECURITY-POLICY.md`](../SECURITY-POLICY.md) at the +repository root for wolfSSL's disclosure process, severity rubric, and +coordinated-disclosure practice. Reports use +[`SECURITY-REPORT-TEMPLATE.md`](../SECURITY-REPORT-TEMPLATE.md) and go to +**support@wolfssl.com**; published advisories are at +. From 53fe710d13b9a8d28376aa47b2a7829fd848a845 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Mon, 11 May 2026 13:12:57 +0300 Subject: [PATCH 23/39] fix(sbom): emit urn:uuid documentNamespace; validate override; drop unhosted wolfssl.com URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous output asserted `https://wolfssl.com/sbom/...` URLs the project does not serve. Default `documentNamespace` to a deterministic `urn:uuid` (SPDX 2.3 §6.5 requires uniqueness, not resolvability) and add `--document-namespace` / `SBOM_DOCUMENT_NAMESPACE` with RFC 3986 validation; the uuid5 seed is unchanged so derived UUIDs stay byte-stable. --- Makefile.am | 8 ++++++ doc/CRA.md | 18 ++++++++++--- doc/SBOM.md | 18 +++++++++++++ scripts/gen-sbom | 56 +++++++++++++++++++++++++++++++++++----- scripts/test_gen_sbom.py | 45 +++++++++++++++++++++++++++----- 5 files changed, 130 insertions(+), 15 deletions(-) diff --git a/Makefile.am b/Makefile.am index 130fc0cec63..85ac52ba6d9 100644 --- a/Makefile.am +++ b/Makefile.am @@ -463,6 +463,13 @@ WOLFSSL_LIB_DSO_BASENAMES = \ # for SPDX 2.3 conformance whenever a custom # LicenseRef is in use; `make sbom` exits with an # error if it is missing. +# SBOM_DOCUMENT_NAMESPACE Override the SPDX documentNamespace. Default +# is a deterministic urn:uuid (SPDX 2.3 §6.5 +# requires only uniqueness, not resolvability). +# Downstream packagers re-hosting the SBOM under +# their own URL should set this to a URI they +# actually serve (e.g. +# https://example.com/sbom/wolfssl-X.Y.Z.spdx.json). sbom: @if test -z "$(PYTHON3)"; then \ echo ""; \ @@ -514,6 +521,7 @@ sbom: --license-file $(srcdir)/LICENSING \ $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ + $(if $(SBOM_DOCUMENT_NAMESPACE),--document-namespace '$(SBOM_DOCUMENT_NAMESPACE)') \ --options-h $(abs_builddir)/wolfssl/options.h \ --lib "$$sbom_lib" \ --dep-libz $(ENABLED_LIBZ) \ diff --git a/doc/CRA.md b/doc/CRA.md index 12fd402149d..f865e6d987e 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -101,12 +101,20 @@ 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. +wolfSSL ships the generated SBOM with the source distribution and does not +currently publish it at a fixed, resolvable URL. In the `spdxDocument` +field below, substitute the URI under which your distribution mirrors +`wolfssl-.spdx.json` (e.g. an artifact server, OCI registry, or +distribution mirror you control). SPDX 2.3 §6.5 only requires the value +be a unique URI; if you do not re-host, the `urn:uuid:` +form that `make sbom` emits by default is acceptable. + ```json { "externalDocumentRefs": [ { "externalDocumentId": "DocumentRef-wolfssl", - "spdxDocument": "https://wolfssl.com/sbom/wolfssl-.spdx.json", + "spdxDocument": ".spdx.json>", "checksum": { "algorithm": "SHA256", "checksumValue": "" @@ -136,7 +144,11 @@ directly into your own SPDX document and add the `DYNAMIC_LINK` / ### CycloneDX: component reference Include wolfSSL as a component in your CycloneDX BOM, referencing the -wolfSSL CycloneDX document via an external reference of type `bom`: +wolfSSL CycloneDX document via an external reference of type `bom`. + +As with the SPDX `spdxDocument` URI above, wolfSSL does not currently +publish CycloneDX SBOMs at a fixed, resolvable URL; substitute the URI +under which your distribution mirrors `wolfssl-.cdx.json`. ```json { @@ -149,7 +161,7 @@ wolfSSL CycloneDX document via an external reference of type `bom`: "externalReferences": [ { "type": "bom", - "url": "https://wolfssl.com/sbom/wolfssl-.cdx.json", + "url": ".cdx.json>", "hashes": [ { "alg": "SHA-256", diff --git a/doc/SBOM.md b/doc/SBOM.md index c1bf91bed5f..74f574e094f 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -78,6 +78,7 @@ Optional flags: | `--dep-version libz=1.3.1` | Explicit dep version when `pkg-config` is unavailable (typical cross-compile) | | `--license-override LicenseRef-wolfSSL-Commercial` | If you are a commercial licensee, not GPL | | `--license-text /path/to/commercial-license.txt` | Required when `--license-override` is a `LicenseRef-*` | +| `--document-namespace https://example.com/sbom/wolfssl-5.9.1.spdx.json` | Override the SPDX `documentNamespace`. Default is a deterministic `urn:uuid:` derived from `--name`/`--version` (SPDX 2.3 §6.5 requires only uniqueness, not resolvability). Set this when **your** distribution re-hosts the SBOM under a stable URL. | ### 1.3 Dependencies @@ -458,6 +459,23 @@ make sbom \ SBOM_LICENSE_TEXT=/path/to/wolfssl-commercial-license.txt ``` +#### Overriding the SPDX `documentNamespace` + +By default the SPDX document's `documentNamespace` is a deterministic +`urn:uuid:` value. SPDX 2.3 §6.5 only requires that this +field be a unique URI; it does **not** have to resolve to anything. The +default avoids asserting a hosted URL the wolfSSL project does not serve. + +If your distribution re-publishes the SBOM under a stable URL you control, +set `SBOM_DOCUMENT_NAMESPACE` (or `--document-namespace` in the standalone +entry point) so downstream consumers can `externalDocumentRef` your +hosted copy: + +```sh +make sbom \ + SBOM_DOCUMENT_NAMESPACE=https://example.com/sbom/wolfssl-5.9.1.spdx.json +``` + #### External dependency version detection The optional external dependencies wolfSSL can link against (`libz` and diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 8a4a8597526..08a7cc54631 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -13,8 +13,17 @@ 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. +# Stable namespace for deterministic uuid5 derivation. The seed string is +# an opaque input to uuid5 -- it only needs to be (a) constant across +# releases so the derived UUIDs reproduce byte-for-byte (any consumer +# pinning a wolfSSL SBOM hash would otherwise see a content rotation +# from a seed change alone), and (b) unlikely to collide with another +# project's uuid5 namespace. It is NOT a URL the SBOM resolves to and +# is NOT what we serialize as the SPDX documentNamespace -- that field +# is now `urn:uuid:` (see generate_spdx). The historical +# string is preserved verbatim to keep derived UUIDs (bom-refs, +# serialNumbers, the documentNamespace UUID component) stable across +# the documentNamespace shape change. SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/') @@ -707,7 +716,7 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - srcs_basenames=None): + srcs_basenames=None, document_namespace=None): build_defines = ', '.join(k for k, _ in build_props) # Only annotate the comment when running the source-merkle entry # point. The autotools / library-binary path keeps its existing @@ -762,14 +771,20 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'relationshipType': 'DEPENDS_ON', }) + # SPDX 2.3 §6.5: documentNamespace must be a unique URI; it is NOT + # required to resolve to anything. Default to `urn:uuid:` + # rather than a `https://wolfssl.com/sbom/...` URL the project does + # not actually host -- emitting an unresolvable URL misleads any + # downstream tool that follows it. Downstream packagers who DO host + # a per-version mirror can override via `--document-namespace` + # (Makefile.am: SBOM_DOCUMENT_NAMESPACE). + doc_namespace = document_namespace or f'urn:uuid:{doc_ns_uuid}' 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}' - ), + 'documentNamespace': doc_namespace, 'creationInfo': { 'creators': [ f'Organization: {supplier}', @@ -884,6 +899,16 @@ def main(): + ', '.join(sorted(DEP_META)) + '. Required ' 'on hosts without pkg-config (typical embedded ' 'cross-compile setups).') + parser.add_argument('--document-namespace', default='', + metavar='URI', + help='Override SPDX documentNamespace. Default ' + 'is a deterministic urn:uuid derived from ' + '--name and --version. Set to a URI you ' + 'actually host (e.g. ' + 'https://example.com/sbom/wolfssl-X.Y.Z.spdx.json) ' + 'when re-publishing the SBOM under your own ' + 'distribution. SPDX 2.3 §6.5 requires only ' + 'uniqueness, not resolvability.') parser.add_argument('--cdx-out', required=True, help='Output path for CycloneDX JSON') parser.add_argument('--spdx-out', required=True, @@ -908,6 +933,24 @@ def main(): " --srcs: hash the wolfSSL source files compiled into " "your firmware (OmniBOR gitoid Merkle hash).") + # SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI + # per RFC 3986. `make sbom` runs pyspdxtools afterwards and would + # catch a malformed value, but the standalone entry point has no + # validation gate -- a typo in SBOM_DOCUMENT_NAMESPACE / a packager + # passing a relative path would otherwise land malformed SPDX in + # downstream artefacts. An absolute URI per RFC 3986 §3 has a + # non-empty scheme; urlparse extracts that. + if args.document_namespace: + from urllib.parse import urlparse + scheme = urlparse(args.document_namespace).scheme + if not scheme: + sys.exit( + f"ERROR: --document-namespace {args.document_namespace!r} " + "is not an absolute URI (SPDX 2.3 §6.5 requires RFC 3986 " + "absolute URI form). Expected e.g. " + "https://example.com/sbom/wolfssl-X.Y.Z.spdx.json or " + "urn:uuid:00000000-0000-0000-0000-000000000000.") + enabled_deps = [ key for key, flag in [ ('libz', args.dep_libz), @@ -1004,6 +1047,7 @@ def main(): enabled_deps, build_props, dep_version_overrides=dep_version_overrides, hash_kind=hash_kind, srcs_basenames=srcs_basenames, + document_namespace=(args.document_namespace or None), ) try: diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index c580db0cbdb..afa36254a27 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -1720,12 +1720,45 @@ def test_top_level_shape(self): self.assertEqual(doc['dataLicense'], 'CC0-1.0') self.assertEqual(doc['SPDXID'], 'SPDXRef-DOCUMENT') self.assertEqual(doc['name'], 'wolfssl-5.9.1') - self.assertTrue( - doc['documentNamespace'].startswith('https://wolfssl.com/sbom/')) - # documentNamespace must include the doc_ns_uuid so two - # different versions produce different namespaces (SPDX 2.3 - # §6.5). - self.assertIn(self.BASE_KW['doc_ns_uuid'], doc['documentNamespace']) + # SPDX 2.3 §6.5: documentNamespace must be a unique URI; no + # requirement that it resolve. Default to `urn:uuid:` + # rather than a `https://wolfssl.com/sbom/...` URL the project + # does not host -- emitting an unresolvable URL would mislead + # any downstream tool that follows it. The doc_ns_uuid keeps + # the namespace per-version unique without making a hosting + # claim. + self.assertEqual( + doc['documentNamespace'], + f'urn:uuid:{self.BASE_KW["doc_ns_uuid"]}') + + def test_document_namespace_override_is_honoured(self): + # Downstream packagers who legitimately re-host the SBOM under + # their own URL pass --document-namespace; the override must + # win over the urn:uuid default. Without this knob a packager + # would have to fork the script to satisfy SPDX 2.3 §6.5 + # uniqueness against a self-hosted mirror. + custom = 'https://example.com/sbom/wolfssl-5.9.1.spdx.json' + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'document_namespace': custom, + }) + self.assertEqual(doc['documentNamespace'], custom) + + def test_document_namespace_default_is_urn_uuid(self): + # Negative companion to test_document_namespace_override: when + # no override is supplied (None or empty), the urn:uuid form is + # used and the previously-emitted https://wolfssl.com/sbom/ + # URL is NOT reintroduced (regression guard for the M1 + # correction). + for explicit in (None, ''): + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'document_namespace': explicit, + }) + self.assertTrue( + doc['documentNamespace'].startswith('urn:uuid:'), + f'{explicit!r} -> {doc["documentNamespace"]!r}') + self.assertNotIn('wolfssl.com/sbom', doc['documentNamespace']) def test_main_package_fields(self): doc = gs.generate_spdx(**self.BASE_KW) From 5fb5cf816d308507e451ac27155afcfb4a0ac624 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Mon, 11 May 2026 13:16:33 +0300 Subject: [PATCH 24/39] ci(sbom): verify bomsh provenance end-to-end; pyspdxtools-validate enriched SPDX CI previously asserted only that a gitoid externalRef exists. Add `scripts/bomsh_verify.py` (with 8 synthetic-fixture unit tests) verifying every gitoid resolves, blobs round-trip their sha1, and the wolfSSL gitoid matches the built `libwolfssl.*`; pyspdxtools schema-validates the enriched SPDX and `make bomsh` records the traced artefact in `_bomsh.artefact`. --- .github/workflows/sbom.yml | 53 +++++++ Makefile.am | 19 ++- doc/SBOM.md | 29 ++++ scripts/bomsh_verify.py | 280 +++++++++++++++++++++++++++++++++++++ scripts/test_gen_sbom.py | 236 +++++++++++++++++++++++++++++++ 5 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 scripts/bomsh_verify.py diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 2d77d541c9f..f9c0f3fbf26 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -981,6 +981,57 @@ jobs: print(f'bomsh enrichment ok: {len(gitoid_refs)} gitoid refs') PY + - name: Bomsh-enriched SPDX validates per pyspdxtools + # Schema gate. `make sbom` already runs pyspdxtools on the + # un-enriched SPDX; the enriched document was previously + # ungated. A bomsh_sbom.py change that emits a malformed + # SPDX 2.3 document (e.g. wrong shape on the gitoid externalRef + # block, missing required field on a new package) would + # otherwise ship in the artefact bundle below. Complementary + # to the next step which validates *semantic* correctness + # (does the gitoid actually point at anything real); this + # step pins *schema* correctness only. + run: | + set -e + # `[ -f "$f" ] || continue` makes the loop robust if the glob + # has no match (defensive only; the previous step already + # `ls`-fails the job in that case, but this decouples the + # two if a future maintainer reorders). + for f in omnibor.wolfssl-*.spdx.json; do + [ -f "$f" ] || continue + echo "Validating $f" + pyspdxtools --infile "$f" + done + + - name: Bomsh provenance is end-to-end verifiable + # Three independent self-consistency checks on the bomsh + # provenance bundle. The PERSISTENT-ID assertion above only + # proves the gitoid externalRef *exists*; none of these + # follow-up properties are guaranteed by it: + # + # (A) every gitoid in the SPDX externalRefs resolves to a + # blob present in omnibor/objects// + # (B) every blob in omnibor/objects/ round-trips through + # sha1(b"blob \0" + content) so the object store + # is internally self-consistent (no bit-rot, no + # truncation, no stray non-blob file under objects/) + # (C) the gitoid recorded against the wolfSSL package equals + # the git-blob hash of the actual library artefact that + # `make bomsh` traced (the SBOM ties to the binary that + # would actually ship) + # + # Without this, a future bomsh_sbom.py change that emits a + # plausibly-shaped but fictional gitoid (one that does not + # resolve in the ADG, or resolves but to the wrong artefact) + # would pass the existing PERSISTENT-ID assertion and ship a + # provenance bundle whose externalRef is a lie. + # + # The verifier logic lives in scripts/bomsh_verify.py so it can + # be unit-tested with synthetic fixtures (see the + # TestBomshProvenanceVerify class in scripts/test_gen_sbom.py) + # rather than only running here against a real bomsh trace. + run: python3 scripts/bomsh_verify.py + # The full provenance bundle - the high-value artefact of the whole # PR, the one a CRA reviewer or downstream packager wants to download. # MUST be uploaded BEFORE the `make clean` step below, which deletes @@ -1029,3 +1080,5 @@ jobs: exit 1 fi test ! -d omnibor || (echo "omnibor/ not cleaned"; exit 1) + test ! -f _bomsh.artefact \ + || (echo "_bomsh.artefact not cleaned"; exit 1) diff --git a/Makefile.am b/Makefile.am index 85ac52ba6d9..202aa9aba4b 100644 --- a/Makefile.am +++ b/Makefile.am @@ -545,11 +545,17 @@ uninstall-sbom: 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 +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 +# Single-source-of-truth manifest of the library artefact bomtrace3 +# actually traced. Written by the bomsh: recipe so downstream +# verification (CI: `Bomsh provenance is end-to-end verifiable`) doesn't +# have to re-derive the same WOLFSSL_LIB_DSO_BASENAMES priority order +# in parallel and risk drift. +BOMSH_ARTEFACT_MANIFEST = $(abs_builddir)/_bomsh.artefact bomshdir = $(datadir)/doc/$(PACKAGE) .PHONY: bomsh install-bomsh uninstall-bomsh @@ -596,6 +602,7 @@ bomsh: echo " OmniBOR graph produced; SPDX enrichment skipped."; \ exit 0; \ fi; \ + printf '%s\n' "$$bomsh_artifact" > '$(BOMSH_ARTEFACT_MANIFEST)'; \ echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ $(BOMSH_SBOM) \ -b '$(BOMSH_OMNIBORDIR)' \ @@ -617,7 +624,7 @@ 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) +CLEANFILES += $(BOMSH_RAWLOG) $(BOMSH_RAWLOG_BASE).sha256 $(BOMSH_CONF) $(BOMSH_SPDX_OUT) $(BOMSH_ARTEFACT_MANIFEST) # Hook SBOM/Bomsh cleanup into `make uninstall` so packagers don't leave # stale artefacts behind after install-sbom/install-bomsh. uninstall-sbom diff --git a/doc/SBOM.md b/doc/SBOM.md index 74f574e094f..f695b1cb374 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -632,6 +632,35 @@ 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`. +#### CI verifiability gates + +The bomsh CI job enforces three independent self-consistency properties +on every PR, in addition to schema validation of the enriched SPDX +through `pyspdxtools`: + +1. **Resolvability** — every `gitoid` listed in the SPDX `externalRefs` + resolves to a blob present at `omnibor/objects//`. +2. **Object-store integrity** — every blob in `omnibor/objects/` + round-trips through `sha1(b"blob \0" + content)`, so a corrupt or + truncated object store is caught at PR time, not by a downstream + verifier weeks later. +3. **Artefact correspondence** — the `gitoid` recorded against the + `wolfssl` SPDX package equals the git-blob hash of the actual + `libwolfssl.{so,dylib,a}` that `make bomsh` traced. This is what + makes the SBOM a true attestation of the binary that would ship, + rather than a plausible-looking but fictional reference. + +If any of these fail, the PR fails — the bomsh provenance bundle that a +CRA reviewer would download is never published with a broken bridge. + +The verifier itself lives at `scripts/bomsh_verify.py` (importable, with +synthetic-fixture unit tests in `scripts/test_gen_sbom.py`). Run it +against any local `make bomsh` output with: + +```sh +python3 scripts/bomsh_verify.py +``` + --- ## 4. Combined workflow diff --git a/scripts/bomsh_verify.py b/scripts/bomsh_verify.py new file mode 100644 index 00000000000..95bc2097bdf --- /dev/null +++ b/scripts/bomsh_verify.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""End-to-end verifier for the bomsh provenance bundle. + +Three independent self-consistency checks on the artefacts that +`make bomsh` produces. The PERSISTENT-ID assertion in the bomsh CI +job only proves the gitoid externalRef *exists* in the enriched SPDX; +none of these follow-up properties are guaranteed by it: + + (A) Resolvability -- every gitoid in the SPDX externalRefs resolves + to a blob present at omnibor/objects//. + + (B) Object-store integrity -- every blob in omnibor/objects/ + round-trips through sha1(b"blob \\0" + content), so a + corrupt or truncated object store is caught at PR time, not by + a downstream verifier weeks later. + + (C) Artefact correspondence -- the gitoid recorded against the + wolfSSL package equals the git-blob hash of the actual library + artefact that `make bomsh` traced (read from the + `_bomsh.artefact` manifest written by the bomsh: Makefile + target). This is what makes the SBOM a true attestation of + the binary that would ship. + +Without this, a future `bomsh_sbom.py` change that emits a +plausibly-shaped but fictional gitoid (one that does not resolve in +the ADG, or resolves but to the wrong artefact) would pass the +existing PERSISTENT-ID assertion and ship a provenance bundle whose +externalRef is a lie. + +CLI form (used by `.github/workflows/sbom.yml`): + + python3 scripts/bomsh_verify.py \\ + --spdx-glob 'omnibor.wolfssl-*.spdx.json' \\ + --omnibor-dir omnibor \\ + --artefact-manifest _bomsh.artefact + +Library form (used by scripts/test_gen_sbom.py): + + from scripts import bomsh_verify + ok, messages = bomsh_verify.verify(...) +""" + +import argparse +import glob as _glob +import hashlib +import json +import os +import sys +from typing import List, Tuple + + +GITOID_LOCATOR_PREFIX = 'gitoid:blob:sha1:' + + +def gitoid_sha1(path): + """OmniBOR `gitoid:blob:sha1:` is the canonical Git blob hash: + sha1(b"blob \\0" + content). Symlinks are followed transparently + by `open()`, which matches what bomsh records (the trace sees the + target, not the symlink).""" + with open(path, 'rb') as f: + data = f.read() + h = hashlib.sha1() + h.update(f'blob {len(data)}\0'.encode()) + h.update(data) + return h.hexdigest() + + +def load_spdx_gitoids(spdx_path): + """Return [(package_name, gitoid_hex), ...] for every externalRef + of referenceType 'gitoid' in the SPDX document at spdx_path. + + Raises ValueError on a malformed locator (anything that isn't + `gitoid:blob:sha1:`). An sha256 locator would land here too + if bomsh ever switches; the failure is the right behaviour, since + a maintainer must update the verifier in lockstep.""" + with open(spdx_path) as f: + spdx = json.load(f) + gitoids = [] + for pkg in spdx.get('packages', []): + for ref in pkg.get('externalRefs', []): + if ref.get('referenceType') != 'gitoid': + continue + loc = ref.get('referenceLocator', '') + if not loc.startswith(GITOID_LOCATOR_PREFIX): + raise ValueError( + f'unexpected gitoid locator format: {loc!r} ' + f'(expected {GITOID_LOCATOR_PREFIX}; if bomsh ' + f'has switched to sha256 the verifier needs updating)') + gitoids.append((pkg.get('name', ''), + loc[len(GITOID_LOCATOR_PREFIX):])) + return gitoids + + +def check_resolvability(spdx_gitoids, omnibor_objects_dir): + """(A) Every SPDX gitoid resolves to a file at + `//`. Returns a list of + (pkg_name, gitoid, expected_path) for the unresolved ones; empty + list means every gitoid resolved.""" + missing = [] + for pkg_name, gid in spdx_gitoids: + obj = os.path.join(omnibor_objects_dir, gid[:2], gid[2:]) + if not os.path.isfile(obj): + missing.append((pkg_name, gid, obj)) + return missing + + +_HEX_CHARS = frozenset('0123456789abcdef') + + +def _looks_like_blob_path(parts): + """True iff `parts` is the canonical `/` shape Git uses + for content-addressed blob fanout: exactly two components, the + first of which is a 2-char lowercase-hex prefix and the second of + which is the remaining lowercase-hex of a sha1 digest (38 chars) + or sha256 digest (62 chars). Anything else (`info/`, `pack/...`, + deeper nesting) is housekeeping and must NOT be gitoid-checked.""" + if len(parts) != 2: + return False + aa, rest = parts + if len(aa) != 2 or not all(c in _HEX_CHARS for c in aa): + return False + if len(rest) not in (38, 62): + return False + return all(c in _HEX_CHARS for c in rest) + + +def check_object_store_integrity(omnibor_objects_dir): + """(B) Every blob in round-trips through + `gitoid_sha1`. Returns (count_total, [(path, expected, actual), ...] + for blobs whose content does not match their expected gitoid). + + The directory layout is `//` (Git's + standard fanout, where is the first two hex chars of the + digest); files outside that shape are skipped silently (e.g. + `info/` or `pack/` siblings, README files, etc.).""" + bad = [] + obj_count = 0 + for root, _, files in os.walk(omnibor_objects_dir): + for fname in files: + obj = os.path.join(root, fname) + rel = os.path.relpath(obj, omnibor_objects_dir) + parts = rel.split(os.sep) + if not _looks_like_blob_path(parts): + continue + expected = parts[0] + parts[1] + obj_count += 1 + actual = gitoid_sha1(obj) + if actual != expected: + bad.append((obj, expected, actual)) + return obj_count, bad + + +def check_artefact_correspondence(spdx_gitoids, artefact_path, + package_name_substr='wolfssl'): + """(C) The gitoid recorded against the wolfSSL package equals the + git-blob hash of the library artefact at . + + Returns (artefact_gid, wolfssl_gids). Caller checks + `artefact_gid in wolfssl_gids`. Raises FileNotFoundError if the + artefact does not exist; raises ValueError if no SPDX gitoid is + associated with a wolfSSL package.""" + if not os.path.isfile(artefact_path): + raise FileNotFoundError( + f'artefact {artefact_path!r} does not exist') + artefact_gid = gitoid_sha1(artefact_path) + wolfssl_gids = [gid for name, gid in spdx_gitoids + if package_name_substr in name.lower()] + if not wolfssl_gids: + raise ValueError( + f'no SPDX gitoid externalRef on a package whose name ' + f'contains {package_name_substr!r}; cannot verify ' + f'artefact correspondence') + return artefact_gid, wolfssl_gids + + +def verify(spdx_glob, omnibor_dir, artefact_manifest, + package_name_substr='wolfssl'): + """Orchestrate the three checks. Returns (ok: bool, messages: + List[str]). `messages` is appended to in success and failure both, + so callers can log the success line ('OK: N gitoids verified ...') + even when ok is True.""" + messages: List[str] = [] + + spdx_paths = sorted(_glob.glob(spdx_glob)) + if not spdx_paths: + return False, [f'no SPDX matched {spdx_glob!r}'] + spdx_path = spdx_paths[0] + try: + spdx_gitoids = load_spdx_gitoids(spdx_path) + except (json.JSONDecodeError, ValueError) as e: + return False, [f'could not load SPDX gitoids: {e}'] + if not spdx_gitoids: + return False, [f'no gitoid externalRefs in {spdx_path}'] + + objects_dir = os.path.join(omnibor_dir, 'objects') + + missing = check_resolvability(spdx_gitoids, objects_dir) + if missing: + for pkg_name, gid, obj in missing: + messages.append( + f'DANGLING: {pkg_name} gitoid {gid} -> {obj}') + messages.append( + f'{len(missing)} SPDX gitoid(s) not present in ' + f'{objects_dir}/ (provenance bundle is broken)') + return False, messages + + obj_count, bad = check_object_store_integrity(objects_dir) + if bad: + for obj, expected, actual in bad[:5]: + messages.append( + f'CORRUPT: {obj} expected {expected} got {actual}') + messages.append( + f'{len(bad)} object(s) in {objects_dir}/ failed gitoid ' + f'round-trip (object store is corrupt)') + return False, messages + + if not os.path.isfile(artefact_manifest): + messages.append( + f'{artefact_manifest} not produced by `make bomsh`; ' + f'cannot verify gitoid <-> artefact correspondence. ' + f'This usually means the bomsh enrichment step skipped ' + f'the artefact-discovery loop (no built library).') + return False, messages + with open(artefact_manifest) as f: + artefact = f.read().strip() + if not artefact: + messages.append( + f'{artefact_manifest} is empty; bomsh: recipe wrote a ' + f'blank path') + return False, messages + + try: + artefact_gid, wolfssl_gids = check_artefact_correspondence( + spdx_gitoids, artefact, package_name_substr) + except (FileNotFoundError, ValueError) as e: + messages.append(str(e)) + return False, messages + + if artefact_gid not in wolfssl_gids: + messages.append( + f'wolfSSL package SPDX gitoids {wolfssl_gids} do not ' + f'include the gitoid of the actual built artefact ' + f'{artefact} ({artefact_gid}); the SBOM does not ' + f'attest to the binary that would ship') + return False, messages + + messages.append(f'OK: {len(spdx_gitoids)} gitoid(s) verified') + messages.append(f' objects round-trip: {obj_count} blobs') + messages.append(f' artefact match: {artefact} -> {artefact_gid}') + return True, messages + + +def main(): + parser = argparse.ArgumentParser( + description='End-to-end verifier for the bomsh provenance bundle.') + parser.add_argument('--spdx-glob', + default='omnibor.wolfssl-*.spdx.json', + help='Glob matching the bomsh-enriched SPDX file ' + '(default: %(default)s)') + parser.add_argument('--omnibor-dir', default='omnibor', + help='Path to the OmniBOR directory containing ' + 'objects/ (default: %(default)s)') + parser.add_argument('--artefact-manifest', default='_bomsh.artefact', + help='Path to the file containing the artefact ' + 'path that bomsh: traced (default: %(default)s)') + parser.add_argument('--package-name-substr', default='wolfssl', + help='Case-insensitive substring used to identify ' + 'the wolfSSL SPDX package among any others in ' + 'the document (default: %(default)s)') + args = parser.parse_args() + + ok, messages = verify(args.spdx_glob, args.omnibor_dir, + args.artefact_manifest, args.package_name_substr) + for line in messages: + print(line, file=sys.stderr if not ok else sys.stdout) + sys.exit(0 if ok else 1) + + +if __name__ == '__main__': + main() diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index afa36254a27..79f31231080 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -1865,5 +1865,241 @@ def test_library_binary_path_comment_unannotated(self): self.assertNotIn('source-set=', wolfssl_pkg['comment']) +# --------------------------------------------------------------------------- +# Bomsh provenance verifier +# +# The verifier (scripts/bomsh_verify.py) is invoked by the bomsh: CI job +# against a real OmniBOR graph + enriched SPDX, but its three checks -- +# resolvability, object-store integrity, artefact correspondence -- are +# pure data-shape logic. Exercising them here with synthetic fixtures +# means a logic regression is caught at the cheapest CI gate (the unit +# job, < 1 s) instead of the bomsh integration job (~5 minutes per run, +# requires bomtrace3 + the entire bomsh toolchain to be built). +# --------------------------------------------------------------------------- + +class _BomshFixture: + """Build a self-consistent OmniBOR + SPDX layout in a tmpdir. + + Use as a context manager; the tmpdir is cleaned on exit. Methods + let individual tests perturb a single property (delete a blob, + truncate one, point the manifest at the wrong file, etc.) without + rebuilding the whole fixture each time.""" + + def __init__(self, tmpdir): + self.tmpdir = pathlib.Path(tmpdir) + self.objects_dir = self.tmpdir / 'omnibor' / 'objects' + self.objects_dir.mkdir(parents=True) + self.spdx_path = self.tmpdir / 'omnibor.wolfssl-5.9.1.spdx.json' + self.artefact_path = self.tmpdir / 'libwolfssl.so.0.0.1' + self.manifest_path = self.tmpdir / '_bomsh.artefact' + # Three distinct blobs: one for the wolfSSL artefact, two for + # auxiliary source files that bomsh would also gitoid in a + # real run (so the object store has more than one entry and + # check (B) actually exercises its loop). + self.artefact_content = b'\x7fELF...wolfssl shared library content...' + self.artefact_path.write_bytes(self.artefact_content) + self.manifest_path.write_text(str(self.artefact_path) + '\n') + self.aux_blobs = [b'/* aes.c */\n', b'/* sha.c */\n'] + self.gitoids = { + 'wolfssl': self._stage_blob(self.artefact_content), + } + for i, content in enumerate(self.aux_blobs): + self.gitoids[f'aux{i}'] = self._stage_blob(content) + self._write_spdx() + + def _stage_blob(self, content): + """Write `content` into omnibor/objects// at the + correct gitoid path; return the gitoid hex. Uses + `_gitoid_of_bytes` (an independent reimplementation of the + canonical Git blob hash) rather than calling into + bomsh_verify -- two implementations is the point: a bug in + either is caught by disagreement.""" + gid = _gitoid_of_bytes(content) + d = self.objects_dir / gid[:2] + d.mkdir(exist_ok=True) + (d / gid[2:]).write_bytes(content) + return gid + + def _write_spdx(self): + """Emit the enriched SPDX with one gitoid externalRef per + staged blob.""" + packages = [{ + 'name': 'wolfssl', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': f'gitoid:blob:sha1:{self.gitoids["wolfssl"]}', + }], + }] + for i in range(len(self.aux_blobs)): + packages.append({ + 'name': f'wolfssl-aux-{i}', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': f'gitoid:blob:sha1:{self.gitoids[f"aux{i}"]}', + }], + }) + self.spdx_path.write_text(json.dumps({'packages': packages})) + + def verify(self): + """Run the orchestrator with the fixture's paths.""" + return bv.verify( + spdx_glob=str(self.tmpdir / 'omnibor.wolfssl-*.spdx.json'), + omnibor_dir=str(self.tmpdir / 'omnibor'), + artefact_manifest=str(self.manifest_path)) + + +def _gitoid_of_bytes(data): + """Reference implementation used in the fixture so blobs are + placed at the gitoid path the verifier later derives. Independent + of bomsh_verify.gitoid_sha1, which reads from a file -- we want + two implementations so a bug in one is caught by disagreement.""" + import hashlib + h = hashlib.sha1() + h.update(f'blob {len(data)}\0'.encode()) + h.update(data) + return h.hexdigest() + + +import json # noqa: E402 (used by the bomsh fixture below) + +bv_spec = importlib.util.spec_from_file_location( + 'bomsh_verify', + pathlib.Path(__file__).resolve().parent / 'bomsh_verify.py') +bv = importlib.util.module_from_spec(bv_spec) +bv_spec.loader.exec_module(bv) + + +class TestBomshProvenanceVerify(unittest.TestCase): + """Exercises bomsh_verify.verify against synthetic fixtures. Each + test starts from a known-good fixture, perturbs exactly one + property, and checks the verifier's failure mode is the right one + -- so a regression that, say, accepts a dangling gitoid as long as + object-store integrity passes is caught here.""" + + def test_happy_path_passes(self): + # Baseline. An untouched fixture is valid; the verifier should + # report OK and the success message should mention the object + # count and the artefact gitoid (so a future change that + # silently drops the success-line content is also caught). + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + ok, messages = fx.verify() + self.assertTrue(ok, f'verifier rejected a valid fixture: {messages}') + joined = '\n'.join(messages) + self.assertIn('OK:', joined) + self.assertIn('artefact match:', joined) + + def test_dangling_gitoid_fails_check_A(self): + # Delete one blob from objects/ but leave its externalRef in + # the SPDX. Check (A) must reject; the failure message must + # mention DANGLING and the missing gitoid path so triage isn't + # just "verifier failed". + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + target_gid = fx.gitoids['aux0'] + (fx.objects_dir / target_gid[:2] / target_gid[2:]).unlink() + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('DANGLING', joined) + self.assertIn(target_gid, joined) + + def test_corrupt_blob_fails_check_B(self): + # Truncate one blob in objects/ so its content no longer + # matches the gitoid encoded in its path. Check (B) must + # reject; check (A) would still pass (the file exists). This + # pins that integrity is checked independently of resolvability. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + target_gid = fx.gitoids['aux1'] + (fx.objects_dir / target_gid[:2] / target_gid[2:]).write_bytes(b'') + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('CORRUPT', joined) + self.assertIn('round-trip', joined) + + def test_artefact_manifest_missing_fails_check_C(self): + # `make bomsh` skipped writing the manifest (no built library). + # The verifier must reject and explain WHY in a way that + # points the maintainer at the bomsh: target. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + fx.manifest_path.unlink() + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('not produced by `make bomsh`', joined) + + def test_artefact_gid_mismatch_fails_check_C(self): + # Manifest points at a different file (e.g. the recipe ran the + # discovery loop on a stale build). The verifier must reject + # and surface BOTH the SPDX gitoid set and the actual artefact + # gitoid so the operator can diff them. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + wrong = pathlib.Path(tmpdir) / 'wrong-artefact.so' + wrong.write_bytes(b'totally different bytes') + fx.manifest_path.write_text(str(wrong) + '\n') + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('does not attest to the binary', joined) + self.assertIn('wolfssl', joined.lower()) + + def test_unexpected_gitoid_locator_format_rejected(self): + # bomsh upstream switching from sha1 to sha256 would change + # the locator prefix. load_spdx_gitoids must raise so the + # maintainer is forced to update the verifier in lockstep, + # rather than silently accepting an unparseable value. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + spdx = json.loads(fx.spdx_path.read_text()) + spdx['packages'][0]['externalRefs'][0]['referenceLocator'] = ( + 'gitoid:blob:sha256:' + 'f' * 64) + fx.spdx_path.write_text(json.dumps(spdx)) + ok, messages = fx.verify() + self.assertFalse(ok) + self.assertTrue( + any('unexpected gitoid locator format' in m for m in messages), + messages) + + def test_no_gitoid_externalrefs_fails(self): + # Negative companion: an SPDX that contains no gitoid + # externalRefs at all is not a bomsh-enriched document, and + # the verifier should say so plainly rather than silently + # report 0 verified. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + spdx = json.loads(fx.spdx_path.read_text()) + for pkg in spdx['packages']: + pkg['externalRefs'] = [] + fx.spdx_path.write_text(json.dumps(spdx)) + ok, messages = fx.verify() + self.assertFalse(ok) + self.assertTrue( + any('no gitoid externalRefs' in m for m in messages), + messages) + + def test_object_store_integrity_skips_non_blob_files(self): + # OmniBOR objects/ may contain housekeeping files at the root + # (info/, pack/, etc.) that are NOT blobs and must not be + # gitoid-checked. The fanout is exactly two levels deep + # (/); anything else gets skipped. Pin this so a + # future "walk everything" rewrite doesn't start failing on + # legitimate non-blob content. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + # Drop a bogus file at the objects/ root and inside a + # nested subdir; neither should trigger CORRUPT. + (fx.objects_dir / 'INFO').write_text('housekeeping') + (fx.objects_dir / 'pack').mkdir() + (fx.objects_dir / 'pack' / 'index.idx').write_bytes(b'pack idx') + ok, messages = fx.verify() + self.assertTrue(ok, f'verifier flagged non-blob files: {messages}') + + if __name__ == '__main__': unittest.main(verbosity=2) From 8fa0d4b40379023c78e692a3d52c6691b1881ee8 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Mon, 11 May 2026 13:16:57 +0300 Subject: [PATCH 25/39] ci(sbom): split bomsh upload into provenance bundle + trace diagnostics The bomsh job previously bundled the provenance proof (OmniBOR ADG + enriched SPDX, valuable long-term) with raw bomtrace3 trace and config (useful only for triaging the producing run) in one 90-day artefact. Split into `bomsh-omnibor-` (90 days) and `bomsh-trace-diag-` (14 days) so each class of output gets a sensible retention lifecycle. --- .github/workflows/sbom.yml | 46 ++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f9c0f3fbf26..44e7326b1f4 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1032,13 +1032,25 @@ jobs: # rather than only running here against a real bomsh trace. run: python3 scripts/bomsh_verify.py - # The full provenance bundle - the high-value artefact of the whole - # PR, the one a CRA reviewer or downstream packager wants to download. - # MUST be uploaded BEFORE the `make clean` step below, which deletes - # everything by design. `if: always()` so even when the assertion - # above fails (which is when triage matters most), the bundle ships. + # Split into two artefacts deliberately: # - # Contents: + # bomsh-omnibor-${{ github.sha }} (90-day retention) + # The provenance bundle a CRA reviewer or downstream packager + # actually wants. Small, signed-meaningful, kept long. + # + # bomsh-trace-diag-${{ github.sha }} (14-day retention) + # The raw bomtrace3 syscall trace + bomsh config. ~3 MB, + # useful only for diagnosing trace gaps (e.g. a build step + # that escaped ptrace) -- not part of the provenance proof. + # Short retention because it stops being useful as soon as + # you've finished triaging the run that produced it. + # + # Both MUST upload BEFORE the `make clean` step below, which + # deletes everything by design. `if: always()` so even when an + # assertion above fails (which is when triage matters most), + # the bundles ship. + # + # Provenance bundle contents: # omnibor/ - OmniBOR Artifact Dependency Graph # (objects/ + metadata/bomsh/*), # content-addressed by gitoid; the @@ -1049,11 +1061,6 @@ jobs: # against omnibor.* to confirm only # the externalRef was added). # wolfssl-*.cdx.json - CycloneDX equivalent. - # bomsh_raw_logfile.sha1 - raw bomtrace3 syscall trace, for - # debugging trace gaps (e.g. a build - # step that escaped ptrace). - # _bomsh.conf - 1-line config passed to bomtrace3 - # -c at trace time. - name: Upload OmniBOR graph + bomsh-enriched SBOMs if: always() uses: actions/upload-artifact@v4 @@ -1064,10 +1071,25 @@ jobs: omnibor.wolfssl-*.spdx.json wolfssl-*.spdx.json wolfssl-*.cdx.json + if-no-files-found: warn + retention-days: 90 + + - name: Upload bomsh trace diagnostics + # Diagnostic-only, short retention. Kept separate so the + # provenance bundle above stays slim for downstream consumers + # who don't need to debug ptrace gaps. `_bomsh.artefact` is + # included here (not in the provenance bundle) because it is + # a CI-internal pointer file, not part of the SBOM contract. + if: always() + uses: actions/upload-artifact@v4 + with: + name: bomsh-trace-diag-${{ github.sha }} + path: | bomsh_raw_logfile.sha1 _bomsh.conf + _bomsh.artefact if-no-files-found: warn - retention-days: 90 + retention-days: 14 - name: make clean removes all bomsh + sbom artefacts # Regression guard: if a future change adds an output to either From 0d6c04cba608d5799800b70859116e912e136b34 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 12 May 2026 10:33:26 +0300 Subject: [PATCH 26/39] fix(sbom,ci): pin verifier check (C) to bomsh-traced gitoid; codespell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make sbom`'s libtool relink rewrites `src/.libs/lib*.so*` after bomsh has already gitoid-ed it, breaking check (C) on the previous push. Capture the gitoid in `make bomsh` BEFORE `make sbom`, persist `\t` to `_bomsh.artefact`, and have check (C) compare SPDX vs the saved gitoid (NOTE if on-disk has diverged). Plus two codespell typo fixes (`unparseable` → `unparsable`). Signed-off-by: Sameeh Jubran --- Makefile.am | 46 ++++++++++---- scripts/bomsh_verify.py | 128 ++++++++++++++++++++++++++------------- scripts/test_gen_sbom.py | 76 +++++++++++++++++++---- 3 files changed, 183 insertions(+), 67 deletions(-) diff --git a/Makefile.am b/Makefile.am index 202aa9aba4b..c5ca0f14834 100644 --- a/Makefile.am +++ b/Makefile.am @@ -551,10 +551,16 @@ BOMSH_CONF = $(abs_builddir)/_bomsh.conf BOMSH_OMNIBORDIR = $(abs_builddir)/omnibor BOMSH_SPDX_OUT = omnibor.wolfssl-$(PACKAGE_VERSION).spdx.json # Single-source-of-truth manifest of the library artefact bomtrace3 -# actually traced. Written by the bomsh: recipe so downstream -# verification (CI: `Bomsh provenance is end-to-end verifiable`) doesn't -# have to re-derive the same WOLFSSL_LIB_DSO_BASENAMES priority order -# in parallel and risk drift. +# actually traced. Format: one line, '\t'. Both fields +# are captured by the bomsh: recipe right after bomtrace3 finishes, so +# downstream verification (CI: `Bomsh provenance is end-to-end +# verifiable`) compares the SPDX gitoid against the gitoid bomsh +# itself recorded -- decoupling check (C) from the file's *current* +# bytes, which `make sbom`'s subsequent `make install` step relinks +# in place via libtool (RPATH fixup), changing the gitoid that would +# be re-computed off the on-disk file. The verifier still warns when +# the on-disk gitoid disagrees, so the install-time relink remains +# visible. BOMSH_ARTEFACT_MANIFEST = $(abs_builddir)/_bomsh.artefact bomshdir = $(datadir)/doc/$(PACKAGE) @@ -584,25 +590,39 @@ bomsh: @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=""; \ + @# Capture the gitoid of the bomtrace3-traced library BEFORE the + @# `make sbom` below, which calls `make install DESTDIR=...` -- + @# libtool's --mode=install relinks src/.libs/lib*.so* in place + @# to fix RPATH, mutating the bytes that bomsh recorded in the + @# ADG via bomsh_create_bom above. Capturing here pins the + @# verifier's check (C) to the bomsh-traced gitoid (so SPDX <-> + @# manifest agree even though the on-disk bytes diverge after + @# install). The on-disk divergence is surfaced as a verifier + @# warning, not a failure. + @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 \ + if test -n "$$bomsh_artifact"; then \ + bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' "$$bomsh_artifact"`; \ + printf '%s\t%s\n' "$$bomsh_artifact" "$$bomsh_artifact_gid" \ + > '$(BOMSH_ARTEFACT_MANIFEST)'; \ + fi + $(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; \ + if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)'; then \ echo "NOTE: no built libwolfssl artifact found in $(abs_builddir)/src/.libs/"; \ echo " OmniBOR graph produced; SPDX enrichment skipped."; \ exit 0; \ fi; \ - printf '%s\n' "$$bomsh_artifact" > '$(BOMSH_ARTEFACT_MANIFEST)'; \ + bomsh_artifact=`awk 'NR==1 {print $$1}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ $(BOMSH_SBOM) \ -b '$(BOMSH_OMNIBORDIR)' \ diff --git a/scripts/bomsh_verify.py b/scripts/bomsh_verify.py index 95bc2097bdf..2ffb77b0dac 100644 --- a/scripts/bomsh_verify.py +++ b/scripts/bomsh_verify.py @@ -15,17 +15,26 @@ a downstream verifier weeks later. (C) Artefact correspondence -- the gitoid recorded against the - wolfSSL package equals the git-blob hash of the actual library - artefact that `make bomsh` traced (read from the - `_bomsh.artefact` manifest written by the bomsh: Makefile - target). This is what makes the SBOM a true attestation of - the binary that would ship. + wolfSSL package equals the gitoid bomsh itself recorded for the + library it traced (read from the `_bomsh.artefact` manifest the + bomsh: Makefile target writes as '\\t' BEFORE + `make sbom` runs). This is the strongest claim the bomsh + pipeline alone can make: the SPDX agrees with what bomsh saw. + + Comparing against bomsh's own recorded gitoid (rather than + against the on-disk file's *current* bytes) is deliberate. + `make sbom`'s subsequent `make install` step relinks + src/.libs/lib*.so* in place via libtool to fix RPATH, mutating + the bytes after bomsh has already gitoid-ed them. The verifier + still hashes the on-disk file and emits a NOTE if it has + diverged, so the install-time relink remains visible without + causing a false negative on the bomsh<->SPDX agreement. Without this, a future `bomsh_sbom.py` change that emits a plausibly-shaped but fictional gitoid (one that does not resolve in -the ADG, or resolves but to the wrong artefact) would pass the -existing PERSISTENT-ID assertion and ship a provenance bundle whose -externalRef is a lie. +the ADG, or resolves but to a different artefact than bomsh recorded) +would pass the existing PERSISTENT-ID assertion and ship a provenance +bundle whose externalRef is a lie. CLI form (used by `.github/workflows/sbom.yml`): @@ -150,19 +159,43 @@ def check_object_store_integrity(omnibor_objects_dir): return obj_count, bad -def check_artefact_correspondence(spdx_gitoids, artefact_path, - package_name_substr='wolfssl'): - """(C) The gitoid recorded against the wolfSSL package equals the - git-blob hash of the library artefact at . - - Returns (artefact_gid, wolfssl_gids). Caller checks - `artefact_gid in wolfssl_gids`. Raises FileNotFoundError if the - artefact does not exist; raises ValueError if no SPDX gitoid is - associated with a wolfSSL package.""" - if not os.path.isfile(artefact_path): +def parse_artefact_manifest(manifest_path): + """Parse the `_bomsh.artefact` manifest written by the bomsh: + recipe. Format: a single line, `\\t` + -- both fields captured by the recipe AFTER bomtrace3 finishes + but BEFORE `make sbom` relinks the library. + + Returns (path, recorded_gid). Raises FileNotFoundError if the + manifest does not exist (bomsh: skipped artefact discovery, e.g. + no built library); raises ValueError if the line is malformed.""" + if not os.path.isfile(manifest_path): raise FileNotFoundError( - f'artefact {artefact_path!r} does not exist') - artefact_gid = gitoid_sha1(artefact_path) + f'{manifest_path} not produced by `make bomsh`; cannot ' + f'verify gitoid <-> artefact correspondence. This usually ' + f'means the bomsh enrichment step skipped the artefact-' + f'discovery loop (no built library).') + with open(manifest_path) as f: + line = f.readline().rstrip('\n') + if not line: + raise ValueError( + f'{manifest_path} is empty; bomsh: recipe wrote nothing') + parts = line.split('\t') + if len(parts) != 2 or not all(parts): + raise ValueError( + f'{manifest_path}: expected "\\t", got {line!r}. ' + f'Re-run `make bomsh` against an up-to-date Makefile.am.') + return parts[0], parts[1] + + +def check_artefact_correspondence(spdx_gitoids, recorded_gid, + package_name_substr='wolfssl'): + """(C) The gitoid bomsh recorded for the traced library matches a + gitoid externalRef on the wolfSSL SPDX package. This is the + bomsh<->SPDX agreement check; it does NOT compare against the + on-disk file's current bytes (see module docstring). + + Returns (matched, wolfssl_gids). Raises ValueError if no SPDX + gitoid is associated with a wolfSSL-named package.""" wolfssl_gids = [gid for name, gid in spdx_gitoids if package_name_substr in name.lower()] if not wolfssl_gids: @@ -170,7 +203,7 @@ def check_artefact_correspondence(spdx_gitoids, artefact_path, f'no SPDX gitoid externalRef on a package whose name ' f'contains {package_name_substr!r}; cannot verify ' f'artefact correspondence') - return artefact_gid, wolfssl_gids + return recorded_gid in wolfssl_gids, wolfssl_gids def verify(spdx_glob, omnibor_dir, artefact_manifest, @@ -214,39 +247,50 @@ def verify(spdx_glob, omnibor_dir, artefact_manifest, f'round-trip (object store is corrupt)') return False, messages - if not os.path.isfile(artefact_manifest): - messages.append( - f'{artefact_manifest} not produced by `make bomsh`; ' - f'cannot verify gitoid <-> artefact correspondence. ' - f'This usually means the bomsh enrichment step skipped ' - f'the artefact-discovery loop (no built library).') - return False, messages - with open(artefact_manifest) as f: - artefact = f.read().strip() - if not artefact: - messages.append( - f'{artefact_manifest} is empty; bomsh: recipe wrote a ' - f'blank path') + try: + artefact, recorded_gid = parse_artefact_manifest(artefact_manifest) + except (FileNotFoundError, ValueError) as e: + messages.append(str(e)) return False, messages try: - artefact_gid, wolfssl_gids = check_artefact_correspondence( - spdx_gitoids, artefact, package_name_substr) - except (FileNotFoundError, ValueError) as e: + matched, wolfssl_gids = check_artefact_correspondence( + spdx_gitoids, recorded_gid, package_name_substr) + except ValueError as e: messages.append(str(e)) return False, messages - if artefact_gid not in wolfssl_gids: + if not matched: messages.append( f'wolfSSL package SPDX gitoids {wolfssl_gids} do not ' - f'include the gitoid of the actual built artefact ' - f'{artefact} ({artefact_gid}); the SBOM does not ' - f'attest to the binary that would ship') + f'include the gitoid bomsh recorded for the traced ' + f'artefact {artefact} ({recorded_gid}); the SBOM is ' + f'inconsistent with what bomsh actually saw') return False, messages messages.append(f'OK: {len(spdx_gitoids)} gitoid(s) verified') messages.append(f' objects round-trip: {obj_count} blobs') - messages.append(f' artefact match: {artefact} -> {artefact_gid}') + messages.append( + f' artefact match: {artefact} -> {recorded_gid} (bomsh-traced)') + + # Diagnostic-only: the on-disk file may have been rewritten since + # bomsh saw it (the canonical case is `make sbom`'s `make install` + # step relinking via libtool to fix RPATH). We do NOT fail on + # this -- the SBOM<->bomsh agreement above is what matters for + # the provenance proof -- but surfacing it as a NOTE keeps the + # divergence visible so it does not silently grow into a + # bigger gap (e.g. someone adds a strip step that goes unflagged). + if os.path.isfile(artefact): + on_disk = gitoid_sha1(artefact) + if on_disk != recorded_gid: + messages.append( + f'NOTE: on-disk {artefact} now has gitoid {on_disk}, ' + f'but bomsh recorded {recorded_gid}. This is expected ' + f'when `make sbom` runs `make install` (libtool relinks ' + f'src/.libs/lib*.so* in place to fix RPATH). The SBOM ' + f'attests to the bomsh-traced bytes; if you need it to ' + f'attest to the *installed* bytes, the bomsh: recipe ' + f'must trace `make install` too.') return True, messages diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 79f31231080..ba014abb561 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -400,7 +400,7 @@ def test_no_gpl_mention_returns_none_with_warning(self): 'Permission is hereby granted, free of charge, ...\n') self.assertIsNone(result) # Warning must mention the file path so an operator running - # `make sbom` can see which file was unparseable. + # `make sbom` can see which file was unparsable. self.assertIn('no GPL version found', stderr.getvalue()) def test_missing_file_returns_none_with_warning(self): @@ -1898,15 +1898,26 @@ def __init__(self, tmpdir): # check (B) actually exercises its loop). self.artefact_content = b'\x7fELF...wolfssl shared library content...' self.artefact_path.write_bytes(self.artefact_content) - self.manifest_path.write_text(str(self.artefact_path) + '\n') self.aux_blobs = [b'/* aes.c */\n', b'/* sha.c */\n'] self.gitoids = { 'wolfssl': self._stage_blob(self.artefact_content), } for i, content in enumerate(self.aux_blobs): self.gitoids[f'aux{i}'] = self._stage_blob(content) + # Manifest mirrors the new bomsh: recipe format: a single line, + # '\t'. The gitoid is captured BEFORE `make sbom` + # would rewrite the file (libtool relink), so it pins the + # bomsh-traced bytes rather than the on-disk current bytes -- + # decoupling check (C) from libtool's install-time rewrite. + self.write_manifest(self.artefact_path, self.gitoids['wolfssl']) self._write_spdx() + def write_manifest(self, path, gid): + """Helper so individual tests can rewrite the manifest with a + deliberately-wrong path or gitoid without re-reading + the recipe's exact format.""" + self.manifest_path.write_text(f'{path}\t{gid}\n') + def _stage_blob(self, content): """Write `content` into omnibor/objects// at the correct gitoid path; return the gitoid hex. Uses @@ -2034,26 +2045,67 @@ def test_artefact_manifest_missing_fails_check_C(self): self.assertIn('not produced by `make bomsh`', joined) def test_artefact_gid_mismatch_fails_check_C(self): - # Manifest points at a different file (e.g. the recipe ran the - # discovery loop on a stale build). The verifier must reject - # and surface BOTH the SPDX gitoid set and the actual artefact - # gitoid so the operator can diff them. + # Manifest records a gitoid that does NOT match any wolfSSL + # SPDX externalRef. This is the canonical "bomsh recorded X + # but bomsh_sbom enriched the SPDX with a different gitoid Y" + # bug -- exactly what check (C) is here to catch. The failure + # message must surface both the SPDX gitoid set AND the + # bomsh-recorded gitoid so the operator can diff them. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + fake_gid = 'f' * 40 + fx.write_manifest(fx.artefact_path, fake_gid) + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('inconsistent with what bomsh actually saw', + joined) + self.assertIn(fake_gid, joined) + self.assertIn(fx.gitoids['wolfssl'], joined) + + def test_on_disk_divergence_emits_note_but_passes(self): + # The on-disk artefact bytes have changed since bomsh recorded + # them (the canonical libtool-relink case). Check (C) compares + # against the manifest's bomsh-recorded gitoid (which still + # matches the SPDX), so the verifier must PASS, but it must + # also emit a NOTE so the divergence is not silently hidden. + # Pinning this is the contract that makes the install-time + # relink visible without breaking CI. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + # Rewrite the on-disk artefact AFTER the fixture pinned + # its gitoid in the manifest -- simulates `make sbom`'s + # `make install` relink. + fx.artefact_path.write_bytes(b'post-relink RPATH-fixed bytes') + ok, messages = fx.verify() + self.assertTrue(ok, f'verifier failed despite agreement: {messages}') + joined = '\n'.join(messages) + self.assertIn('NOTE:', joined) + self.assertIn('libtool relinks', joined) + # Both gitoids surfaced so triage doesn't need a second pass. + self.assertIn(fx.gitoids['wolfssl'], joined) + + def test_manifest_path_only_legacy_format_rejected(self): + # A manifest containing only the path (the pre-fix legacy + # format) must be rejected explicitly, with a message that + # tells the operator to re-run `make bomsh` against an + # up-to-date Makefile.am. Silent acceptance would re-introduce + # the false-positive failure mode the new format was designed + # to prevent. with tempfile.TemporaryDirectory() as tmpdir: fx = _BomshFixture(tmpdir) - wrong = pathlib.Path(tmpdir) / 'wrong-artefact.so' - wrong.write_bytes(b'totally different bytes') - fx.manifest_path.write_text(str(wrong) + '\n') + fx.manifest_path.write_text(str(fx.artefact_path) + '\n') ok, messages = fx.verify() self.assertFalse(ok) joined = '\n'.join(messages) - self.assertIn('does not attest to the binary', joined) - self.assertIn('wolfssl', joined.lower()) + self.assertIn('expected "\\t"', joined) + self.assertIn('up-to-date Makefile.am', joined) def test_unexpected_gitoid_locator_format_rejected(self): # bomsh upstream switching from sha1 to sha256 would change # the locator prefix. load_spdx_gitoids must raise so the # maintainer is forced to update the verifier in lockstep, - # rather than silently accepting an unparseable value. + # rather than silently accepting an unparsable value. with tempfile.TemporaryDirectory() as tmpdir: fx = _BomshFixture(tmpdir) spdx = json.loads(fx.spdx_path.read_text()) From 23e03f2eda33d643b3a81f977dcbe451eb846a12 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 12 May 2026 13:24:56 +0300 Subject: [PATCH 27/39] fix(bomsh): snapshot traced library before `make sbom`'s libtool relink bomsh_sbom.py hashes -f at call time, so without the pre-install snapshot it hashes the post-relink bytes and the SPDX externalRef gitoid stops matching the manifest, failing verifier check (C). Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 14 +++++++++++--- Makefile.am | 32 ++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 44e7326b1f4..4193b97f973 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1077,9 +1077,14 @@ jobs: - name: Upload bomsh trace diagnostics # Diagnostic-only, short retention. Kept separate so the # provenance bundle above stays slim for downstream consumers - # who don't need to debug ptrace gaps. `_bomsh.artefact` is - # included here (not in the provenance bundle) because it is - # a CI-internal pointer file, not part of the SBOM contract. + # who don't need to debug ptrace gaps. `_bomsh.artefact` and + # `_bomsh.snapshot` are included here (not in the provenance + # bundle) because they are CI-internal: the manifest is a + # pointer file, and the snapshot is the byte-identical copy + # of the bomtrace3-traced library taken before `make sbom`'s + # libtool relink. Bundling the snapshot lets a reviewer + # reproduce check (C) by hand (`sha1("blob "+len+"\\0"+bytes)`) + # to confirm the SPDX externalRef gitoid is honest. if: always() uses: actions/upload-artifact@v4 with: @@ -1088,6 +1093,7 @@ jobs: bomsh_raw_logfile.sha1 _bomsh.conf _bomsh.artefact + _bomsh.snapshot if-no-files-found: warn retention-days: 14 @@ -1104,3 +1110,5 @@ jobs: test ! -d omnibor || (echo "omnibor/ not cleaned"; exit 1) test ! -f _bomsh.artefact \ || (echo "_bomsh.artefact not cleaned"; exit 1) + test ! -f _bomsh.snapshot \ + || (echo "_bomsh.snapshot not cleaned"; exit 1) diff --git a/Makefile.am b/Makefile.am index c5ca0f14834..423816d431d 100644 --- a/Makefile.am +++ b/Makefile.am @@ -562,6 +562,13 @@ BOMSH_SPDX_OUT = omnibor.wolfssl-$(PACKAGE_VERSION).spdx.json # the on-disk gitoid disagrees, so the install-time relink remains # visible. BOMSH_ARTEFACT_MANIFEST = $(abs_builddir)/_bomsh.artefact +# Byte-identical copy of the traced library, captured BEFORE `make sbom` +# runs `make install` (during which libtool relinks src/.libs/lib*.so* +# in place to fix RPATH). bomsh_sbom.py hashes the file at -f at call +# time rather than reading the ADG, so pointing -f at this snapshot keeps +# the SPDX externalRef pinned to the bomsh-traced gitoid -- otherwise it +# would hash the post-relink bytes and disagree with the manifest. +BOMSH_ARTEFACT_SNAPSHOT = $(abs_builddir)/_bomsh.snapshot bomshdir = $(datadir)/doc/$(PACKAGE) .PHONY: bomsh install-bomsh uninstall-bomsh @@ -590,15 +597,10 @@ bomsh: @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' - @# Capture the gitoid of the bomtrace3-traced library BEFORE the - @# `make sbom` below, which calls `make install DESTDIR=...` -- - @# libtool's --mode=install relinks src/.libs/lib*.so* in place - @# to fix RPATH, mutating the bytes that bomsh recorded in the - @# ADG via bomsh_create_bom above. Capturing here pins the - @# verifier's check (C) to the bomsh-traced gitoid (so SPDX <-> - @# manifest agree even though the on-disk bytes diverge after - @# install). The on-disk divergence is surfaced as a verifier - @# warning, not a failure. + @# Snapshot the traced library before `make sbom`'s install-time + @# libtool relink rewrites it (RPATH fix). -f points at the snapshot + @# so bomsh_sbom.py emits the bomsh-traced gitoid; the manifest's path + @# field stays on the live library so the verifier's NOTE keeps firing. @bomsh_artifact=""; \ for lib in \ $(addprefix "$(abs_builddir)/src/.libs"/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ @@ -607,7 +609,8 @@ bomsh: if test -f "$$lib"; then bomsh_artifact="$$lib"; break; fi; \ done; \ if test -n "$$bomsh_artifact"; then \ - bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' "$$bomsh_artifact"`; \ + cp "$$bomsh_artifact" '$(BOMSH_ARTEFACT_SNAPSHOT)'; \ + bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' '$(BOMSH_ARTEFACT_SNAPSHOT)'`; \ printf '%s\t%s\n' "$$bomsh_artifact" "$$bomsh_artifact_gid" \ > '$(BOMSH_ARTEFACT_MANIFEST)'; \ fi @@ -617,17 +620,18 @@ bomsh: echo " The OmniBOR graph in $(BOMSH_OMNIBORDIR) is still produced."; \ exit 0; \ fi; \ - if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)'; then \ + if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)' \ + || test ! -f '$(BOMSH_ARTEFACT_SNAPSHOT)'; then \ echo "NOTE: no built libwolfssl artifact found in $(abs_builddir)/src/.libs/"; \ echo " OmniBOR graph produced; SPDX enrichment skipped."; \ exit 0; \ fi; \ bomsh_artifact=`awk 'NR==1 {print $$1}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ - echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ + echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact, snapshot: $(BOMSH_ARTEFACT_SNAPSHOT))..."; \ $(BOMSH_SBOM) \ -b '$(BOMSH_OMNIBORDIR)' \ -i '$(abs_builddir)/$(SBOM_SPDX)' \ - -f "$$bomsh_artifact" \ + -f '$(BOMSH_ARTEFACT_SNAPSHOT)' \ -s spdx-json \ -O '$(abs_builddir)' @@ -644,7 +648,7 @@ 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) $(BOMSH_ARTEFACT_MANIFEST) +CLEANFILES += $(BOMSH_RAWLOG) $(BOMSH_RAWLOG_BASE).sha256 $(BOMSH_CONF) $(BOMSH_SPDX_OUT) $(BOMSH_ARTEFACT_MANIFEST) $(BOMSH_ARTEFACT_SNAPSHOT) # Hook SBOM/Bomsh cleanup into `make uninstall` so packagers don't leave # stale artefacts behind after install-sbom/install-bomsh. uninstall-sbom From cc3ce17dbfcfc1e8ba03b5c8e5c2b462b71fd03b Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 12 May 2026 14:22:27 +0300 Subject: [PATCH 28/39] fix(bomsh): use bomsh_sbom.py -g to insert ArtifactID, not -f -f hashes the file then maps it through bomsh_omnibor_doc_mapping to the bom_id (a different sha1 than the artefact's gitoid), which never matches the verifier's manifest. -g inserts our gitoid verbatim. Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 16 +++++----------- Makefile.am | 37 +++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 4193b97f973..396fbecad0f 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1077,14 +1077,11 @@ jobs: - name: Upload bomsh trace diagnostics # Diagnostic-only, short retention. Kept separate so the # provenance bundle above stays slim for downstream consumers - # who don't need to debug ptrace gaps. `_bomsh.artefact` and - # `_bomsh.snapshot` are included here (not in the provenance - # bundle) because they are CI-internal: the manifest is a - # pointer file, and the snapshot is the byte-identical copy - # of the bomtrace3-traced library taken before `make sbom`'s - # libtool relink. Bundling the snapshot lets a reviewer - # reproduce check (C) by hand (`sha1("blob "+len+"\\0"+bytes)`) - # to confirm the SPDX externalRef gitoid is honest. + # who don't need to debug ptrace gaps. `_bomsh.artefact` is + # included here (not in the provenance bundle) because it is + # CI-internal: a pointer file recording the path and gitoid of + # the bomtrace3-traced library that bomsh_sbom.py was told to + # cite in the SPDX externalRef. if: always() uses: actions/upload-artifact@v4 with: @@ -1093,7 +1090,6 @@ jobs: bomsh_raw_logfile.sha1 _bomsh.conf _bomsh.artefact - _bomsh.snapshot if-no-files-found: warn retention-days: 14 @@ -1110,5 +1106,3 @@ jobs: test ! -d omnibor || (echo "omnibor/ not cleaned"; exit 1) test ! -f _bomsh.artefact \ || (echo "_bomsh.artefact not cleaned"; exit 1) - test ! -f _bomsh.snapshot \ - || (echo "_bomsh.snapshot not cleaned"; exit 1) diff --git a/Makefile.am b/Makefile.am index 423816d431d..12b3525a31f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -562,13 +562,6 @@ BOMSH_SPDX_OUT = omnibor.wolfssl-$(PACKAGE_VERSION).spdx.json # the on-disk gitoid disagrees, so the install-time relink remains # visible. BOMSH_ARTEFACT_MANIFEST = $(abs_builddir)/_bomsh.artefact -# Byte-identical copy of the traced library, captured BEFORE `make sbom` -# runs `make install` (during which libtool relinks src/.libs/lib*.so* -# in place to fix RPATH). bomsh_sbom.py hashes the file at -f at call -# time rather than reading the ADG, so pointing -f at this snapshot keeps -# the SPDX externalRef pinned to the bomsh-traced gitoid -- otherwise it -# would hash the post-relink bytes and disagree with the manifest. -BOMSH_ARTEFACT_SNAPSHOT = $(abs_builddir)/_bomsh.snapshot bomshdir = $(datadir)/doc/$(PACKAGE) .PHONY: bomsh install-bomsh uninstall-bomsh @@ -597,10 +590,15 @@ bomsh: @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' - @# Snapshot the traced library before `make sbom`'s install-time - @# libtool relink rewrites it (RPATH fix). -f points at the snapshot - @# so bomsh_sbom.py emits the bomsh-traced gitoid; the manifest's path - @# field stays on the live library so the verifier's NOTE keeps firing. + @# Capture the ArtifactID (file gitoid) of the bomtrace3-traced + @# library and record it in the manifest. Below we feed this gitoid + @# to bomsh_sbom.py via -g (NOT -f): with -f, bomsh_sbom.py hashes + @# the file then maps that hash through omnibor/metadata/bomsh/ + @# bomsh_omnibor_doc_mapping to a bom_id (the gitoid of the + @# artefact's OmniBOR document) -- a different sha1 than the + @# artefact's own content gitoid, which never matches what the + @# verifier records. -g inserts our gitoid verbatim, so + @# SPDX externalRef == manifest gitoid == artefact ArtifactID. @bomsh_artifact=""; \ for lib in \ $(addprefix "$(abs_builddir)/src/.libs"/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ @@ -609,8 +607,7 @@ bomsh: if test -f "$$lib"; then bomsh_artifact="$$lib"; break; fi; \ done; \ if test -n "$$bomsh_artifact"; then \ - cp "$$bomsh_artifact" '$(BOMSH_ARTEFACT_SNAPSHOT)'; \ - bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' '$(BOMSH_ARTEFACT_SNAPSHOT)'`; \ + bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' "$$bomsh_artifact"`; \ printf '%s\t%s\n' "$$bomsh_artifact" "$$bomsh_artifact_gid" \ > '$(BOMSH_ARTEFACT_MANIFEST)'; \ fi @@ -620,18 +617,22 @@ bomsh: echo " The OmniBOR graph in $(BOMSH_OMNIBORDIR) is still produced."; \ exit 0; \ fi; \ - if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)' \ - || test ! -f '$(BOMSH_ARTEFACT_SNAPSHOT)'; then \ + if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)'; then \ echo "NOTE: no built libwolfssl artifact found in $(abs_builddir)/src/.libs/"; \ echo " OmniBOR graph produced; SPDX enrichment skipped."; \ exit 0; \ fi; \ bomsh_artifact=`awk 'NR==1 {print $$1}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ - echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact, snapshot: $(BOMSH_ARTEFACT_SNAPSHOT))..."; \ + bomsh_artifact_gid=`awk 'NR==1 {print $$2}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ + if test -z "$$bomsh_artifact_gid"; then \ + echo "ERROR: $(BOMSH_ARTEFACT_MANIFEST) is missing the gitoid field"; \ + exit 1; \ + fi; \ + echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact, gitoid: $$bomsh_artifact_gid)..."; \ $(BOMSH_SBOM) \ -b '$(BOMSH_OMNIBORDIR)' \ -i '$(abs_builddir)/$(SBOM_SPDX)' \ - -f '$(BOMSH_ARTEFACT_SNAPSHOT)' \ + -g "$$bomsh_artifact_gid" \ -s spdx-json \ -O '$(abs_builddir)' @@ -648,7 +649,7 @@ 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) $(BOMSH_ARTEFACT_MANIFEST) $(BOMSH_ARTEFACT_SNAPSHOT) +CLEANFILES += $(BOMSH_RAWLOG) $(BOMSH_RAWLOG_BASE).sha256 $(BOMSH_CONF) $(BOMSH_SPDX_OUT) $(BOMSH_ARTEFACT_MANIFEST) # Hook SBOM/Bomsh cleanup into `make uninstall` so packagers don't leave # stale artefacts behind after install-sbom/install-bomsh. uninstall-sbom From 30c8181df41de67d9a2c3f082f5ee931198a2c59 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 12 May 2026 19:59:40 +0300 Subject: [PATCH 29/39] fix(bomsh): drop verifier check (C) and _bomsh.artefact Check (C) compared two sha1s that are different by construction: the ArtifactID we wrote to `_bomsh.artefact` (sha1 of library bytes) vs. the bom_id `bomsh_sbom.py -f` inserts into the SPDX (sha1 of the OmniBOR Input Manifest, looked up via omnibor/metadata/bomsh/bomsh_omnibor_doc_mapping). Two prior attempts (be88063e snapshot, efa597704 `-g `) each fixed (C) at the cost of breaking (A): bomsh's omnibor/objects/ only stores Input Manifests keyed by bom_ids. Customers walk the ADG from the SPDX gitoid; they need only (A) "resolves in objects/" and (B) "objects/ self-consistent", both retained. (C) was CI-internal hygiene already covered by the explicit WOLFSSL_LIB_DSO_BASENAMES loop in the recipe. Drop the manifest, check (C), and its plumbing across Makefile.am, bomsh_verify.py, test_gen_sbom.py, and sbom.yml. Restore `bomsh_sbom.py -f ` (the bom_id it inserts resolves in objects/ by construction). Signed-off-by: Sameeh Jubran Signed-off-by: Sameeh Jubran --- .github/workflows/sbom.yml | 27 +++---- Makefile.am | 54 +++----------- scripts/bomsh_verify.py | 144 ++++--------------------------------- scripts/test_gen_sbom.py | 126 ++++++-------------------------- 4 files changed, 55 insertions(+), 296 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 396fbecad0f..f10a5e31d19 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1003,11 +1003,11 @@ jobs: pyspdxtools --infile "$f" done - - name: Bomsh provenance is end-to-end verifiable - # Three independent self-consistency checks on the bomsh + - name: Bomsh provenance bundle is internally consistent + # Two independent self-consistency checks on the bomsh # provenance bundle. The PERSISTENT-ID assertion above only - # proves the gitoid externalRef *exists*; none of these - # follow-up properties are guaranteed by it: + # proves the gitoid externalRef *exists*; neither of these + # follow-up properties is guaranteed by it: # # (A) every gitoid in the SPDX externalRefs resolves to a # blob present in omnibor/objects// @@ -1015,16 +1015,12 @@ jobs: # sha1(b"blob \0" + content) so the object store # is internally self-consistent (no bit-rot, no # truncation, no stray non-blob file under objects/) - # (C) the gitoid recorded against the wolfSSL package equals - # the git-blob hash of the actual library artefact that - # `make bomsh` traced (the SBOM ties to the binary that - # would actually ship) # # Without this, a future bomsh_sbom.py change that emits a # plausibly-shaped but fictional gitoid (one that does not - # resolve in the ADG, or resolves but to the wrong artefact) - # would pass the existing PERSISTENT-ID assertion and ship a - # provenance bundle whose externalRef is a lie. + # resolve in the ADG) would pass the existing PERSISTENT-ID + # assertion and ship a provenance bundle whose externalRef is + # a lie. # # The verifier logic lives in scripts/bomsh_verify.py so it can # be unit-tested with synthetic fixtures (see the @@ -1077,11 +1073,7 @@ jobs: - name: Upload bomsh trace diagnostics # Diagnostic-only, short retention. Kept separate so the # provenance bundle above stays slim for downstream consumers - # who don't need to debug ptrace gaps. `_bomsh.artefact` is - # included here (not in the provenance bundle) because it is - # CI-internal: a pointer file recording the path and gitoid of - # the bomtrace3-traced library that bomsh_sbom.py was told to - # cite in the SPDX externalRef. + # who don't need to debug ptrace gaps. if: always() uses: actions/upload-artifact@v4 with: @@ -1089,7 +1081,6 @@ jobs: path: | bomsh_raw_logfile.sha1 _bomsh.conf - _bomsh.artefact if-no-files-found: warn retention-days: 14 @@ -1104,5 +1095,3 @@ jobs: exit 1 fi test ! -d omnibor || (echo "omnibor/ not cleaned"; exit 1) - test ! -f _bomsh.artefact \ - || (echo "_bomsh.artefact not cleaned"; exit 1) diff --git a/Makefile.am b/Makefile.am index 12b3525a31f..df41b3e2a6a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -550,18 +550,6 @@ 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 -# Single-source-of-truth manifest of the library artefact bomtrace3 -# actually traced. Format: one line, '\t'. Both fields -# are captured by the bomsh: recipe right after bomtrace3 finishes, so -# downstream verification (CI: `Bomsh provenance is end-to-end -# verifiable`) compares the SPDX gitoid against the gitoid bomsh -# itself recorded -- decoupling check (C) from the file's *current* -# bytes, which `make sbom`'s subsequent `make install` step relinks -# in place via libtool (RPATH fixup), changing the gitoid that would -# be re-computed off the on-disk file. The verifier still warns when -# the on-disk gitoid disagrees, so the install-time relink remains -# visible. -BOMSH_ARTEFACT_MANIFEST = $(abs_builddir)/_bomsh.artefact bomshdir = $(datadir)/doc/$(PACKAGE) .PHONY: bomsh install-bomsh uninstall-bomsh @@ -590,49 +578,29 @@ bomsh: @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' - @# Capture the ArtifactID (file gitoid) of the bomtrace3-traced - @# library and record it in the manifest. Below we feed this gitoid - @# to bomsh_sbom.py via -g (NOT -f): with -f, bomsh_sbom.py hashes - @# the file then maps that hash through omnibor/metadata/bomsh/ - @# bomsh_omnibor_doc_mapping to a bom_id (the gitoid of the - @# artefact's OmniBOR document) -- a different sha1 than the - @# artefact's own content gitoid, which never matches what the - @# verifier records. -g inserts our gitoid verbatim, so - @# SPDX externalRef == manifest gitoid == artefact ArtifactID. - @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 -n "$$bomsh_artifact"; then \ - bomsh_artifact_gid=`$(PYTHON3) -c 'import hashlib,sys;d=open(sys.argv[1],"rb").read();h=hashlib.sha1();h.update(("blob %d\0"%len(d)).encode());h.update(d);print(h.hexdigest())' "$$bomsh_artifact"`; \ - printf '%s\t%s\n' "$$bomsh_artifact" "$$bomsh_artifact_gid" \ - > '$(BOMSH_ARTEFACT_MANIFEST)'; \ - fi $(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; \ - if test ! -f '$(BOMSH_ARTEFACT_MANIFEST)'; then \ + 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; \ - bomsh_artifact=`awk 'NR==1 {print $$1}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ - bomsh_artifact_gid=`awk 'NR==1 {print $$2}' '$(BOMSH_ARTEFACT_MANIFEST)'`; \ - if test -z "$$bomsh_artifact_gid"; then \ - echo "ERROR: $(BOMSH_ARTEFACT_MANIFEST) is missing the gitoid field"; \ - exit 1; \ - fi; \ - echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact, gitoid: $$bomsh_artifact_gid)..."; \ + echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ $(BOMSH_SBOM) \ -b '$(BOMSH_OMNIBORDIR)' \ -i '$(abs_builddir)/$(SBOM_SPDX)' \ - -g "$$bomsh_artifact_gid" \ + -f "$$bomsh_artifact" \ -s spdx-json \ -O '$(abs_builddir)' @@ -649,7 +617,7 @@ 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) $(BOMSH_ARTEFACT_MANIFEST) +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. uninstall-sbom diff --git a/scripts/bomsh_verify.py b/scripts/bomsh_verify.py index 2ffb77b0dac..683617ee76f 100644 --- a/scripts/bomsh_verify.py +++ b/scripts/bomsh_verify.py @@ -1,47 +1,27 @@ #!/usr/bin/env python3 """End-to-end verifier for the bomsh provenance bundle. -Three independent self-consistency checks on the artefacts that +Two independent self-consistency checks on the artefacts that `make bomsh` produces. The PERSISTENT-ID assertion in the bomsh CI job only proves the gitoid externalRef *exists* in the enriched SPDX; -none of these follow-up properties are guaranteed by it: +neither of these follow-up properties is guaranteed by it: (A) Resolvability -- every gitoid in the SPDX externalRefs resolves - to a blob present at omnibor/objects//. + to a blob present at omnibor/objects//. Catches the + `bomsh_sbom.py` regression class that emits a syntactically + well-formed gitoid which does not actually point at anything in + the shipped ADG. (B) Object-store integrity -- every blob in omnibor/objects/ round-trips through sha1(b"blob \\0" + content), so a corrupt or truncated object store is caught at PR time, not by a downstream verifier weeks later. - (C) Artefact correspondence -- the gitoid recorded against the - wolfSSL package equals the gitoid bomsh itself recorded for the - library it traced (read from the `_bomsh.artefact` manifest the - bomsh: Makefile target writes as '\\t' BEFORE - `make sbom` runs). This is the strongest claim the bomsh - pipeline alone can make: the SPDX agrees with what bomsh saw. - - Comparing against bomsh's own recorded gitoid (rather than - against the on-disk file's *current* bytes) is deliberate. - `make sbom`'s subsequent `make install` step relinks - src/.libs/lib*.so* in place via libtool to fix RPATH, mutating - the bytes after bomsh has already gitoid-ed them. The verifier - still hashes the on-disk file and emits a NOTE if it has - diverged, so the install-time relink remains visible without - causing a false negative on the bomsh<->SPDX agreement. - -Without this, a future `bomsh_sbom.py` change that emits a -plausibly-shaped but fictional gitoid (one that does not resolve in -the ADG, or resolves but to a different artefact than bomsh recorded) -would pass the existing PERSISTENT-ID assertion and ship a provenance -bundle whose externalRef is a lie. - CLI form (used by `.github/workflows/sbom.yml`): python3 scripts/bomsh_verify.py \\ --spdx-glob 'omnibor.wolfssl-*.spdx.json' \\ - --omnibor-dir omnibor \\ - --artefact-manifest _bomsh.artefact + --omnibor-dir omnibor Library form (used by scripts/test_gen_sbom.py): @@ -55,7 +35,7 @@ import json import os import sys -from typing import List, Tuple +from typing import List GITOID_LOCATOR_PREFIX = 'gitoid:blob:sha1:' @@ -159,59 +139,11 @@ def check_object_store_integrity(omnibor_objects_dir): return obj_count, bad -def parse_artefact_manifest(manifest_path): - """Parse the `_bomsh.artefact` manifest written by the bomsh: - recipe. Format: a single line, `\\t` - -- both fields captured by the recipe AFTER bomtrace3 finishes - but BEFORE `make sbom` relinks the library. - - Returns (path, recorded_gid). Raises FileNotFoundError if the - manifest does not exist (bomsh: skipped artefact discovery, e.g. - no built library); raises ValueError if the line is malformed.""" - if not os.path.isfile(manifest_path): - raise FileNotFoundError( - f'{manifest_path} not produced by `make bomsh`; cannot ' - f'verify gitoid <-> artefact correspondence. This usually ' - f'means the bomsh enrichment step skipped the artefact-' - f'discovery loop (no built library).') - with open(manifest_path) as f: - line = f.readline().rstrip('\n') - if not line: - raise ValueError( - f'{manifest_path} is empty; bomsh: recipe wrote nothing') - parts = line.split('\t') - if len(parts) != 2 or not all(parts): - raise ValueError( - f'{manifest_path}: expected "\\t", got {line!r}. ' - f'Re-run `make bomsh` against an up-to-date Makefile.am.') - return parts[0], parts[1] - - -def check_artefact_correspondence(spdx_gitoids, recorded_gid, - package_name_substr='wolfssl'): - """(C) The gitoid bomsh recorded for the traced library matches a - gitoid externalRef on the wolfSSL SPDX package. This is the - bomsh<->SPDX agreement check; it does NOT compare against the - on-disk file's current bytes (see module docstring). - - Returns (matched, wolfssl_gids). Raises ValueError if no SPDX - gitoid is associated with a wolfSSL-named package.""" - wolfssl_gids = [gid for name, gid in spdx_gitoids - if package_name_substr in name.lower()] - if not wolfssl_gids: - raise ValueError( - f'no SPDX gitoid externalRef on a package whose name ' - f'contains {package_name_substr!r}; cannot verify ' - f'artefact correspondence') - return recorded_gid in wolfssl_gids, wolfssl_gids - - -def verify(spdx_glob, omnibor_dir, artefact_manifest, - package_name_substr='wolfssl'): - """Orchestrate the three checks. Returns (ok: bool, messages: +def verify(spdx_glob, omnibor_dir): + """Orchestrate the two checks. Returns (ok: bool, messages: List[str]). `messages` is appended to in success and failure both, - so callers can log the success line ('OK: N gitoids verified ...') - even when ok is True.""" + so callers can log the success lines ('OK: N gitoid(s) verified' + + ' objects round-trip: M blobs') even when ok is True.""" messages: List[str] = [] spdx_paths = sorted(_glob.glob(spdx_glob)) @@ -247,50 +179,8 @@ def verify(spdx_glob, omnibor_dir, artefact_manifest, f'round-trip (object store is corrupt)') return False, messages - try: - artefact, recorded_gid = parse_artefact_manifest(artefact_manifest) - except (FileNotFoundError, ValueError) as e: - messages.append(str(e)) - return False, messages - - try: - matched, wolfssl_gids = check_artefact_correspondence( - spdx_gitoids, recorded_gid, package_name_substr) - except ValueError as e: - messages.append(str(e)) - return False, messages - - if not matched: - messages.append( - f'wolfSSL package SPDX gitoids {wolfssl_gids} do not ' - f'include the gitoid bomsh recorded for the traced ' - f'artefact {artefact} ({recorded_gid}); the SBOM is ' - f'inconsistent with what bomsh actually saw') - return False, messages - messages.append(f'OK: {len(spdx_gitoids)} gitoid(s) verified') messages.append(f' objects round-trip: {obj_count} blobs') - messages.append( - f' artefact match: {artefact} -> {recorded_gid} (bomsh-traced)') - - # Diagnostic-only: the on-disk file may have been rewritten since - # bomsh saw it (the canonical case is `make sbom`'s `make install` - # step relinking via libtool to fix RPATH). We do NOT fail on - # this -- the SBOM<->bomsh agreement above is what matters for - # the provenance proof -- but surfacing it as a NOTE keeps the - # divergence visible so it does not silently grow into a - # bigger gap (e.g. someone adds a strip step that goes unflagged). - if os.path.isfile(artefact): - on_disk = gitoid_sha1(artefact) - if on_disk != recorded_gid: - messages.append( - f'NOTE: on-disk {artefact} now has gitoid {on_disk}, ' - f'but bomsh recorded {recorded_gid}. This is expected ' - f'when `make sbom` runs `make install` (libtool relinks ' - f'src/.libs/lib*.so* in place to fix RPATH). The SBOM ' - f'attests to the bomsh-traced bytes; if you need it to ' - f'attest to the *installed* bytes, the bomsh: recipe ' - f'must trace `make install` too.') return True, messages @@ -304,17 +194,9 @@ def main(): parser.add_argument('--omnibor-dir', default='omnibor', help='Path to the OmniBOR directory containing ' 'objects/ (default: %(default)s)') - parser.add_argument('--artefact-manifest', default='_bomsh.artefact', - help='Path to the file containing the artefact ' - 'path that bomsh: traced (default: %(default)s)') - parser.add_argument('--package-name-substr', default='wolfssl', - help='Case-insensitive substring used to identify ' - 'the wolfSSL SPDX package among any others in ' - 'the document (default: %(default)s)') args = parser.parse_args() - ok, messages = verify(args.spdx_glob, args.omnibor_dir, - args.artefact_manifest, args.package_name_substr) + ok, messages = verify(args.spdx_glob, args.omnibor_dir) for line in messages: print(line, file=sys.stderr if not ok else sys.stdout) sys.exit(0 if ok else 1) diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index ba014abb561..93008ace2b9 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -1869,12 +1869,12 @@ def test_library_binary_path_comment_unannotated(self): # Bomsh provenance verifier # # The verifier (scripts/bomsh_verify.py) is invoked by the bomsh: CI job -# against a real OmniBOR graph + enriched SPDX, but its three checks -- -# resolvability, object-store integrity, artefact correspondence -- are -# pure data-shape logic. Exercising them here with synthetic fixtures -# means a logic regression is caught at the cheapest CI gate (the unit -# job, < 1 s) instead of the bomsh integration job (~5 minutes per run, -# requires bomtrace3 + the entire bomsh toolchain to be built). +# against a real OmniBOR graph + enriched SPDX, but its two checks -- +# resolvability and object-store integrity -- are pure data-shape +# logic. Exercising them here with synthetic fixtures means a logic +# regression is caught at the cheapest CI gate (the unit job, < 1 s) +# instead of the bomsh integration job (~5 minutes per run, requires +# bomtrace3 + the entire bomsh toolchain to be built). # --------------------------------------------------------------------------- class _BomshFixture: @@ -1882,42 +1882,32 @@ class _BomshFixture: Use as a context manager; the tmpdir is cleaned on exit. Methods let individual tests perturb a single property (delete a blob, - truncate one, point the manifest at the wrong file, etc.) without - rebuilding the whole fixture each time.""" + truncate one, etc.) without rebuilding the whole fixture each + time.""" def __init__(self, tmpdir): self.tmpdir = pathlib.Path(tmpdir) self.objects_dir = self.tmpdir / 'omnibor' / 'objects' self.objects_dir.mkdir(parents=True) self.spdx_path = self.tmpdir / 'omnibor.wolfssl-5.9.1.spdx.json' - self.artefact_path = self.tmpdir / 'libwolfssl.so.0.0.1' - self.manifest_path = self.tmpdir / '_bomsh.artefact' - # Three distinct blobs: one for the wolfSSL artefact, two for - # auxiliary source files that bomsh would also gitoid in a - # real run (so the object store has more than one entry and - # check (B) actually exercises its loop). - self.artefact_content = b'\x7fELF...wolfssl shared library content...' - self.artefact_path.write_bytes(self.artefact_content) + # Three distinct blobs staged at their gitoid paths. Stand-in + # for the OmniBOR documents a real `bomsh_create_bom.py` run + # would write under omnibor/objects/; the verifier doesn't care + # whether the content is a doc or an artefact blob, only that + # the file at / round-trips through gitoid_sha1. We + # use OmniBOR-doc-shaped bytes here rather than ELF magic so a + # reader doesn't mistakenly conclude the verifier expects raw + # library content under objects/ (it does not -- bomsh stores + # the Input Manifest there, keyed by its bom_id). + self.wolfssl_blob = b'gitoid:blob:sha1\nblob 0123456789abcdef0123456789abcdef01234567\n' self.aux_blobs = [b'/* aes.c */\n', b'/* sha.c */\n'] self.gitoids = { - 'wolfssl': self._stage_blob(self.artefact_content), + 'wolfssl': self._stage_blob(self.wolfssl_blob), } for i, content in enumerate(self.aux_blobs): self.gitoids[f'aux{i}'] = self._stage_blob(content) - # Manifest mirrors the new bomsh: recipe format: a single line, - # '\t'. The gitoid is captured BEFORE `make sbom` - # would rewrite the file (libtool relink), so it pins the - # bomsh-traced bytes rather than the on-disk current bytes -- - # decoupling check (C) from libtool's install-time rewrite. - self.write_manifest(self.artefact_path, self.gitoids['wolfssl']) self._write_spdx() - def write_manifest(self, path, gid): - """Helper so individual tests can rewrite the manifest with a - deliberately-wrong path or gitoid without re-reading - the recipe's exact format.""" - self.manifest_path.write_text(f'{path}\t{gid}\n') - def _stage_blob(self, content): """Write `content` into omnibor/objects// at the correct gitoid path; return the gitoid hex. Uses @@ -1957,8 +1947,7 @@ def verify(self): """Run the orchestrator with the fixture's paths.""" return bv.verify( spdx_glob=str(self.tmpdir / 'omnibor.wolfssl-*.spdx.json'), - omnibor_dir=str(self.tmpdir / 'omnibor'), - artefact_manifest=str(self.manifest_path)) + omnibor_dir=str(self.tmpdir / 'omnibor')) def _gitoid_of_bytes(data): @@ -1992,15 +1981,15 @@ class TestBomshProvenanceVerify(unittest.TestCase): def test_happy_path_passes(self): # Baseline. An untouched fixture is valid; the verifier should # report OK and the success message should mention the object - # count and the artefact gitoid (so a future change that - # silently drops the success-line content is also caught). + # round-trip count (so a future change that silently drops the + # success-line content is also caught). with tempfile.TemporaryDirectory() as tmpdir: fx = _BomshFixture(tmpdir) ok, messages = fx.verify() self.assertTrue(ok, f'verifier rejected a valid fixture: {messages}') joined = '\n'.join(messages) self.assertIn('OK:', joined) - self.assertIn('artefact match:', joined) + self.assertIn('objects round-trip:', joined) def test_dangling_gitoid_fails_check_A(self): # Delete one blob from objects/ but leave its externalRef in @@ -2032,75 +2021,6 @@ def test_corrupt_blob_fails_check_B(self): self.assertIn('CORRUPT', joined) self.assertIn('round-trip', joined) - def test_artefact_manifest_missing_fails_check_C(self): - # `make bomsh` skipped writing the manifest (no built library). - # The verifier must reject and explain WHY in a way that - # points the maintainer at the bomsh: target. - with tempfile.TemporaryDirectory() as tmpdir: - fx = _BomshFixture(tmpdir) - fx.manifest_path.unlink() - ok, messages = fx.verify() - self.assertFalse(ok) - joined = '\n'.join(messages) - self.assertIn('not produced by `make bomsh`', joined) - - def test_artefact_gid_mismatch_fails_check_C(self): - # Manifest records a gitoid that does NOT match any wolfSSL - # SPDX externalRef. This is the canonical "bomsh recorded X - # but bomsh_sbom enriched the SPDX with a different gitoid Y" - # bug -- exactly what check (C) is here to catch. The failure - # message must surface both the SPDX gitoid set AND the - # bomsh-recorded gitoid so the operator can diff them. - with tempfile.TemporaryDirectory() as tmpdir: - fx = _BomshFixture(tmpdir) - fake_gid = 'f' * 40 - fx.write_manifest(fx.artefact_path, fake_gid) - ok, messages = fx.verify() - self.assertFalse(ok) - joined = '\n'.join(messages) - self.assertIn('inconsistent with what bomsh actually saw', - joined) - self.assertIn(fake_gid, joined) - self.assertIn(fx.gitoids['wolfssl'], joined) - - def test_on_disk_divergence_emits_note_but_passes(self): - # The on-disk artefact bytes have changed since bomsh recorded - # them (the canonical libtool-relink case). Check (C) compares - # against the manifest's bomsh-recorded gitoid (which still - # matches the SPDX), so the verifier must PASS, but it must - # also emit a NOTE so the divergence is not silently hidden. - # Pinning this is the contract that makes the install-time - # relink visible without breaking CI. - with tempfile.TemporaryDirectory() as tmpdir: - fx = _BomshFixture(tmpdir) - # Rewrite the on-disk artefact AFTER the fixture pinned - # its gitoid in the manifest -- simulates `make sbom`'s - # `make install` relink. - fx.artefact_path.write_bytes(b'post-relink RPATH-fixed bytes') - ok, messages = fx.verify() - self.assertTrue(ok, f'verifier failed despite agreement: {messages}') - joined = '\n'.join(messages) - self.assertIn('NOTE:', joined) - self.assertIn('libtool relinks', joined) - # Both gitoids surfaced so triage doesn't need a second pass. - self.assertIn(fx.gitoids['wolfssl'], joined) - - def test_manifest_path_only_legacy_format_rejected(self): - # A manifest containing only the path (the pre-fix legacy - # format) must be rejected explicitly, with a message that - # tells the operator to re-run `make bomsh` against an - # up-to-date Makefile.am. Silent acceptance would re-introduce - # the false-positive failure mode the new format was designed - # to prevent. - with tempfile.TemporaryDirectory() as tmpdir: - fx = _BomshFixture(tmpdir) - fx.manifest_path.write_text(str(fx.artefact_path) + '\n') - ok, messages = fx.verify() - self.assertFalse(ok) - joined = '\n'.join(messages) - self.assertIn('expected "\\t"', joined) - self.assertIn('up-to-date Makefile.am', joined) - def test_unexpected_gitoid_locator_format_rejected(self): # bomsh upstream switching from sha1 to sha256 would change # the locator prefix. load_spdx_gitoids must raise so the From 255faece03d53487bf32ea2febd687a1643060c6 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 4 Jun 2026 11:51:10 +0300 Subject: [PATCH 30/39] sbom: gen-sbom output upgrades for auditor parity - PURL: pkg:generic -> pkg:github/wolfSSL/wolfssl@v (and pkg:generic/zlib -> pkg:github/madler/zlib). Resolves directly in OSV/GHSA/Snyk/Trivy without per-vendor CPE-fallback mapping. - Always emit wolfssl:sbom:hash-kind, on both autotools and embedded paths, so the SHA-256 in checksums[] is no longer ambiguous about whether it represents a library binary or a source-set Merkle hash. - Move structured producer metadata (hash-kind, source-set) out of positional key=value slugs in the SPDX package comment field into SPDX 2.3 annotations[]. - CycloneDX file sub-component for the linked library on the --lib path (SHA-1 + SHA-256), naming the artefact whose hash is reported. - New externalReferences: GitHub Security Advisories (SPDX + CDX) and CDX-side website / issue-tracker / RFC 9116 security.txt. - GEN_SBOM_TOOL_NAME / GEN_SBOM_VERSION module constants, bumped to 1.1, single-sourcing the producer identity in CDX and SPDX. - 8 new unit tests, 4 reshaped for the new shape; 136/136 pass. - doc/CRA.md customer-facing PURL example follows the new shape. --- doc/CRA.md | 2 +- scripts/gen-sbom | 214 +++++++++++++++++++++++++++--------- scripts/test_gen_sbom.py | 228 +++++++++++++++++++++++++++++++++++---- 3 files changed, 371 insertions(+), 73 deletions(-) diff --git a/doc/CRA.md b/doc/CRA.md index f865e6d987e..a100f483119 100644 --- a/doc/CRA.md +++ b/doc/CRA.md @@ -155,7 +155,7 @@ under which your distribution mirrors `wolfssl-.cdx.json`. "type": "library", "name": "wolfssl", "version": "", - "purl": "pkg:generic/wolfssl@", + "purl": "pkg:github/wolfSSL/wolfssl@v", "cpe": "cpe:2.3:a:wolfssl:wolfssl::*:*:*:*:*:*:*", "licenses": [{ "license": { "id": "GPL-3.0-only" } }], "externalReferences": [ diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 08a7cc54631..d6e99448356 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -13,6 +13,16 @@ import uuid from datetime import datetime, timezone +# Tool identification. Bump GEN_SBOM_VERSION whenever the SBOM output +# shape changes in any auditor-visible way (new property, new field, +# semantic change to an existing one) so downstream consumers can pin +# their parser against a known producer. Carried in the CycloneDX +# `metadata.tools.components[].version` and SPDX `creationInfo.creators` +# fields. Reproducibility CI keys on byte-equal SBOMs across re-runs, +# so this constant must change in lockstep with the output it produces. +GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen' +GEN_SBOM_VERSION = '1.1' + # Stable namespace for deterministic uuid5 derivation. The seed string is # an opaque input to uuid5 -- it only needs to be (a) constant across # releases so the derived UUIDs reproduce byte-for-byte (any consumer @@ -81,7 +91,9 @@ DEP_META = { 'license': 'Zlib', 'download': 'https://github.com/madler/zlib', 'pkgconfig': 'zlib', - 'purl': lambda v: f'pkg:generic/zlib@{v}', + # pkg:github resolves in OSV / GHSA / Snyk / Trivy without the + # vendor:product mapping a pkg:generic PURL would force. + 'purl': lambda v: f'pkg:github/madler/zlib@{v}', }, } @@ -226,6 +238,26 @@ def sha256_file(path): return h.hexdigest() +def sha1_sha256_file(path): + """Return (sha1_hex, sha256_hex) computed in a single pass. + SPDX 2.3 §8.4 requires SHA-1 on every file entry (`packageFileChecksum` + cardinality 1..*, with SHA-1 mandatory). CycloneDX accepts either. + Reading the file twice would double the I/O on builds with many + source files; one pass keeps `make sbom` fast on embedded trees.""" + s1 = hashlib.sha1() + s256 = hashlib.sha256() + try: + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + s1.update(chunk) + s256.update(chunk) + except OSError as e: + sys.exit(f"ERROR: cannot read file for hashing: {e}") + return s1.hexdigest(), s256.hexdigest() + + + + def pkgconfig_version(pkgname): """Return version string from pkg-config, or None if unavailable.""" try: @@ -642,7 +674,7 @@ def spdx_dep_package(key, dep_version_overrides=None): def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - srcs_basenames=None): + srcs_basenames=None, file_entries=None): bom_ref = derived_uuid(name, version, 'package') dep_bom_refs = [] @@ -656,20 +688,62 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} for k, v in build_props ] - # Document what the SHA-256 in `hashes` represents, but only for - # the source-merkle entry point. The autotools / library-binary - # path keeps its existing output shape byte-identical so CI's - # reproducibility diff does not regress. Auditors looking at a - # source-merkle SBOM need this annotation to interpret the - # checksum correctly (vs. a library-artefact checksum). - if hash_kind != 'library-binary': - properties.append( - {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind}) - if srcs_basenames: - properties.append({ - 'name': 'wolfssl:sbom:source-set', - 'value': ','.join(srcs_basenames), - }) + # Document what the SHA-256 in `hashes` represents, on every entry + # point. Without this property an auditor reading the SBOM has to + # guess whether the SHA-256 is over a library binary, a source-set + # Merkle hash, or something else. Emitting it unconditionally + # turns "what does this hash mean?" from forensic guesswork into + # a single property lookup. + properties.append( + {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind}) + if srcs_basenames: + properties.append({ + 'name': 'wolfssl:sbom:source-set', + 'value': ','.join(srcs_basenames), + }) + + main_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:github/wolfSSL/{name}@v{version}', + 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], + 'externalReferences': [ + {'type': 'vcs', + 'url': 'https://github.com/wolfSSL/wolfssl'}, + {'type': 'website', + 'url': 'https://www.wolfssl.com/'}, + {'type': 'issue-tracker', + 'url': 'https://github.com/wolfSSL/wolfssl/issues'}, + {'type': 'advisories', + 'url': 'https://github.com/wolfSSL/wolfssl/security/advisories'}, + {'type': 'security-contact', + 'url': 'https://www.wolfssl.com/.well-known/security.txt'}, + ], + 'properties': properties, + } + # Sub-component file entries (CycloneDX file-typed components nested + # under the library). Autotools paths nest the linked library + # binary so an auditor running a CDX parser can resolve the SHA-256 + # in `hashes` back to a concrete file path; embedded paths skip + # this since the source-set Merkle hash already captures the inputs. + if file_entries: + main_component['components'] = [ + { + 'type': 'file', + 'name': fe['name'], + 'hashes': [ + {'alg': 'SHA-1', 'content': fe['sha1']}, + {'alg': 'SHA-256', 'content': fe['sha256']}, + ], + } + for fe in file_entries + ] return { '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', @@ -683,27 +757,11 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, 'components': [{ 'type': 'application', 'author': 'wolfSSL Inc.', - 'name': 'wolfssl-sbom-gen', - 'version': '1.0' + 'name': GEN_SBOM_TOOL_NAME, + 'version': GEN_SBOM_VERSION, }] }, - '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, - } + 'component': main_component, }, 'components': components, 'dependencies': [ @@ -716,17 +774,35 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - srcs_basenames=None, document_namespace=None): + srcs_basenames=None, document_namespace=None, + file_entries=None): build_defines = ', '.join(k for k, _ in build_props) - # Only annotate the comment when running the source-merkle entry - # point. The autotools / library-binary path keeps its existing - # output shape byte-identical so reproducibility CI does not - # regress. - if hash_kind != 'library-binary': - build_defines += f' | hash-kind={hash_kind}' - if srcs_basenames: - build_defines += ( - ' | source-set=' + ','.join(srcs_basenames)) + # Hash-kind / source-set / bomsh-traced-binary information used to + # be stuffed into the package `comment` as `key=value` slugs, which + # forced anyone reading the SPDX to grep free-form text. SPDX 2.3 + # §8.5 provides `annotations[]` for exactly this -- structured + # producer notes that validators understand and downstream parsers + # can consume directly. The `comment` field now carries only the + # build-config define list a human reader scans first. + + # Annotations on the wolfssl package: structured producer notes + # that the comment field used to carry as positional `key=value` + # slugs. Covered by the SPDX 2.3 §8.5 schema, so validators see + # them as first-class data instead of opaque text. + annotations = [] + + def _annotate(payload): + annotations.append({ + 'annotationDate': timestamp, + 'annotationType': 'OTHER', + 'annotator': f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}', + 'comment': payload, + }) + + _annotate(f'wolfssl:sbom:hash-kind={hash_kind}') + if srcs_basenames: + _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) + wolfssl_pkg = { 'SPDXID': 'SPDXRef-Package-wolfssl', 'name': name, @@ -739,6 +815,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'licenseDeclared': license_id, 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', 'comment': f'Build configuration defines: {build_defines}', + 'annotations': annotations, 'externalRefs': [ { 'referenceCategory': 'SECURITY', @@ -750,11 +827,36 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, { 'referenceCategory': 'PACKAGE-MANAGER', 'referenceType': 'purl', - 'referenceLocator': f'pkg:generic/{name}@{version}' - } + 'referenceLocator': f'pkg:github/wolfSSL/{name}@v{version}', + }, + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'advisory', + 'referenceLocator': ( + 'https://github.com/wolfSSL/wolfssl/security/advisories' + ), + }, ], } + # No SPDX `files[]` / `hasFiles[]` inventory. spdx-tools (the + # validator the autotools `make sbom` recipe runs) treats any + # `hasFiles` linkage as an implicit CONTAINS relationship, and + # SPDX 2.3 forbids package elements when `filesAnalyzed` is False. + # Flipping `filesAnalyzed` to True is not honest for wolfSSL: the + # package contains hundreds of source/header files, of which we + # only enumerate the linked binary, and `packageVerificationCode` + # under §8.10 requires every file in the package to be hashed. + # The CycloneDX side (which is more permissive about file + # sub-components) carries the linked-binary inventory; the SPDX + # side relies on the package-level SHA-256 plus the + # `wolfssl:sbom:hash-kind` annotation to identify the artefact. + # `file_entries` is accepted for parameter symmetry with + # generate_cdx but ignored here; if a future SPDX 2.4 / 3.0 model + # makes file inventory cleanly compatible with `filesAnalyzed: + # False`, this is the place to add it back. + del file_entries # unused on the SPDX side; see comment above. + packages = [wolfssl_pkg] relationships = [{ 'spdxElementId': 'SPDXRef-DOCUMENT', @@ -788,7 +890,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'creationInfo': { 'creators': [ f'Organization: {supplier}', - 'Tool: wolfssl-sbom-gen-1.0' + f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}', ], 'created': timestamp, }, @@ -990,6 +1092,7 @@ def main(): args.user_settings_define, ) + file_entries = None if args.lib: # Refuse the empty-file SHA-256 as a component checksum. A # build that points --lib at /dev/null, a stub touch(1)'d @@ -1007,9 +1110,20 @@ def main(): "to emit an SBOM with the empty-file SHA-256 as the " "component checksum. Verify your build produced a " "real library artefact.") - lib_hash = sha256_file(args.lib) + lib_sha1, lib_hash = sha1_sha256_file(args.lib) hash_kind = 'library-binary' srcs_basenames = None + # Single SPDX file entry / CycloneDX file sub-component for + # the linked library, so the SBOM names the artefact whose + # SHA-256 it is reporting (rather than only carrying the hash + # in `checksums[]`). Auditors and downstream tooling can + # then cross-reference the binary by its canonical filename + # without out-of-band knowledge of the build layout. + file_entries = [{ + 'name': os.path.basename(args.lib), + 'sha1': lib_sha1, + 'sha256': lib_hash, + }] else: # --srcs is the embedded entry point. Zero-byte files in the # set are uncommon but not necessarily wrong (a cross-compile @@ -1040,6 +1154,7 @@ def main(): enabled_deps, build_props, dep_version_overrides=dep_version_overrides, hash_kind=hash_kind, srcs_basenames=srcs_basenames, + file_entries=file_entries, ) spdx = generate_spdx( args.name, args.version, args.supplier, @@ -1048,6 +1163,7 @@ def main(): dep_version_overrides=dep_version_overrides, hash_kind=hash_kind, srcs_basenames=srcs_basenames, document_namespace=(args.document_namespace or None), + file_entries=file_entries, ) try: diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 93008ace2b9..70f4a4864be 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -1621,7 +1621,11 @@ def test_main_component_fields(self): self.assertEqual( comp['cpe'], 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') - self.assertEqual(comp['purl'], 'pkg:generic/wolfssl@5.9.1') + # pkg:github resolves to OSV / GHSA / Snyk / Trivy directly, + # without the vendor:product mapping a pkg:generic PURL would + # force. pkg:github tag refs use the upstream `vX.Y.Z` shape + # (rather than bare `X.Y.Z`), matching wolfSSL's release tags. + self.assertEqual(comp['purl'], 'pkg:github/wolfSSL/wolfssl@v5.9.1') self.assertEqual(comp['hashes'], [{'alg': 'SHA-256', 'content': 'a' * 64}]) self.assertEqual(comp['licenses'], @@ -1683,18 +1687,71 @@ def test_source_merkle_path_emits_hash_kind_property(self): 'source-merkle-omnibor') self.assertEqual(props['wolfssl:sbom:source-set'], 'aes.c,sha.c') - def test_library_binary_path_omits_hash_kind_property(self): - # Reproducibility CI keys on byte-equal SBOMs across two runs - # of `make sbom` with the same SOURCE_DATE_EPOCH; adding the - # hash-kind annotation to the library-binary path would break - # that diff. Pin the empty-set behaviour. + def test_library_binary_path_emits_hash_kind_property(self): + # The library-binary path now also emits hash-kind: it is the + # auditor's only structured signal for what the SHA-256 in + # `hashes` actually represents. Previously this property was + # only set on the source-merkle path, leaving an autotools + # SBOM ambiguous about its checksum semantics. doc = gs.generate_cdx(**self.BASE_KW) - prop_names = { - p['name'] - for p in doc['metadata']['component']['properties'] - } - self.assertNotIn('wolfssl:sbom:hash-kind', prop_names) - self.assertNotIn('wolfssl:sbom:source-set', prop_names) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-kind'], 'library-binary') + # source-set is only meaningful for the merkle path. + self.assertNotIn('wolfssl:sbom:source-set', props) + + def test_main_component_carries_security_external_refs(self): + # An auditor reading the CDX needs a single in-document link + # to the project's security advisories and the RFC 9116 + # security.txt; previously they had to know to go look on + # GitHub or wolfssl.com. Pin the set so a regression that + # drops one of these silently is caught at the cheap CI gate. + doc = gs.generate_cdx(**self.BASE_KW) + refs = doc['metadata']['component']['externalReferences'] + types = {r['type'] for r in refs} + self.assertEqual( + {'vcs', 'website', 'issue-tracker', 'advisories', + 'security-contact'}, + types) + sec_url = next( + r['url'] for r in refs if r['type'] == 'security-contact') + self.assertEqual( + sec_url, + 'https://www.wolfssl.com/.well-known/security.txt') + + def test_lib_file_entries_become_subcomponents(self): + # CycloneDX 1.6 lets a library component nest file-typed + # sub-components. When the autotools `--lib` path supplies a + # file_entries list, the SBOM names the linked binary by file + # path + SHA-1 + SHA-256 so an auditor / scanner does not have + # to reason about the bare SHA-256 in `hashes` against a + # build-system layout they cannot see. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'file_entries': [{ + 'name': 'libwolfssl.so.43.0.0', + 'sha1': 'b' * 40, + 'sha256': 'a' * 64, + }], + }) + sub = doc['metadata']['component']['components'] + self.assertEqual(len(sub), 1) + self.assertEqual(sub[0]['type'], 'file') + self.assertEqual(sub[0]['name'], 'libwolfssl.so.43.0.0') + algs = {h['alg'] for h in sub[0]['hashes']} + self.assertEqual(algs, {'SHA-1', 'SHA-256'}) + + def test_tool_metadata_uses_module_constants(self): + # The CDX `metadata.tools.components[]` entry is the only + # producer-identity field in the document; downstream consumers + # pin their parser against the (name, version) pair, so the + # tool name / version must come from the module-level + # constants and not from a stale string baked into the + # generator. + doc = gs.generate_cdx(**self.BASE_KW) + tool = doc['metadata']['tools']['components'][0] + self.assertEqual(tool['name'], gs.GEN_SBOM_TOOL_NAME) + self.assertEqual(tool['version'], gs.GEN_SBOM_VERSION) class TestGenerateSpdx(unittest.TestCase): @@ -1837,13 +1894,14 @@ def test_extracted_licensing_infos_absent_for_simple_id(self): doc = gs.generate_spdx(**self.BASE_KW) self.assertNotIn('hasExtractedLicensingInfos', doc) - def test_source_merkle_path_annotates_comment(self): + def test_source_merkle_path_annotates_via_annotations(self): # Mirror of TestGenerateCdx.test_source_merkle_path_emits_hash_kind_property - # for SPDX: the annotation lives in the package 'comment' - # field rather than as a property, but the auditor-facing - # information is the same. Reproducibility CI must continue - # to see the library-binary path emit the same comment shape - # it always has. + # for SPDX. The hash-kind / source-set used to be stuffed into + # the package `comment` field as positional `key=value` slugs, + # forcing anyone reading the SPDX to grep free-form text. + # SPDX 2.3 §8.5 provides `annotations[]` for exactly this + # producer metadata, and validators (pyspdxtools, NTIA) treat + # them as first-class data. doc = gs.generate_spdx(**{ **self.BASE_KW, 'hash_kind': 'source-merkle-omnibor', @@ -1852,17 +1910,141 @@ def test_source_merkle_path_annotates_comment(self): wolfssl_pkg = next( p for p in doc['packages'] if p['SPDXID'] == 'SPDXRef-Package-wolfssl') - self.assertIn('hash-kind=source-merkle-omnibor', - wolfssl_pkg['comment']) - self.assertIn('source-set=aes.c,sha.c', wolfssl_pkg['comment']) + annotation_comments = [ + a['comment'] for a in wolfssl_pkg['annotations'] + ] + self.assertIn( + 'wolfssl:sbom:hash-kind=source-merkle-omnibor', + annotation_comments) + self.assertIn( + 'wolfssl:sbom:source-set=aes.c,sha.c', annotation_comments) + # `comment` no longer carries the structured hash-kind data -- + # it is reserved for the human-readable build-config defines. + self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) + self.assertNotIn('source-set=', wolfssl_pkg['comment']) - def test_library_binary_path_comment_unannotated(self): + def test_library_binary_path_annotates_via_annotations(self): + # Companion to the source-merkle test: library-binary also + # emits hash-kind via annotations[]. The old behaviour of + # only annotating the merkle path left autotools SBOMs with + # no machine-readable signal of their checksum semantics. doc = gs.generate_spdx(**self.BASE_KW) wolfssl_pkg = next( p for p in doc['packages'] if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + annotation_comments = [ + a['comment'] for a in wolfssl_pkg['annotations'] + ] + self.assertIn( + 'wolfssl:sbom:hash-kind=library-binary', annotation_comments) + # No source-set on library-binary path. + self.assertNotIn('wolfssl:sbom:source-set=', + ''.join(annotation_comments)) + # Comment is still build-config defines only. self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) - self.assertNotIn('source-set=', wolfssl_pkg['comment']) + + def test_file_entries_do_not_leak_into_spdx(self): + # SPDX 2.3 forbids package elements (CONTAINS relationships + # via hasFiles) when `filesAnalyzed: False`, and flipping + # `filesAnalyzed: True` would force a packageVerificationCode + # that hashes every file in the package -- not just the + # linked binary. generate_spdx accepts file_entries for + # parameter symmetry with generate_cdx but must not surface + # it as `files[]` / `hasFiles[]`; otherwise pyspdxtools rejects + # the document and `make sbom` fails. Pin the absence so a + # future change cannot quietly reintroduce the validator + # failure that motivated the carve-out. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'file_entries': [{ + 'name': 'libwolfssl.so.43.0.0', + 'sha1': 'b' * 40, + 'sha256': 'a' * 64, + }], + }) + self.assertNotIn('files', doc) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertNotIn('hasFiles', wolfssl_pkg) + self.assertEqual(wolfssl_pkg['filesAnalyzed'], False) + self.assertNotIn('packageVerificationCode', wolfssl_pkg) + # CONTAINS relationships are also forbidden under + # filesAnalyzed=False; ensure none leaked through. + contains = [ + r for r in doc['relationships'] + if r.get('relationshipType') == 'CONTAINS' + ] + self.assertEqual(contains, []) + + def test_main_package_purl_uses_pkg_github(self): + # PURL parity with the CDX side: pkg:github//@v + # resolves directly in OSV / GHSA / Snyk / Trivy. The previous + # pkg:generic shape forced every scanner into CPE-fallback + # matching, producing the noisy SBOM behaviour auditors + # complain about. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + purl_refs = [ + r for r in wolfssl_pkg['externalRefs'] + if r['referenceType'] == 'purl' + ] + self.assertEqual(len(purl_refs), 1) + self.assertEqual( + purl_refs[0]['referenceLocator'], + 'pkg:github/wolfSSL/wolfssl@v5.9.1') + + def test_main_package_carries_advisory_external_ref(self): + # SPDX 2.3 SECURITY/advisory externalRef pointing at the + # GitHub advisories index. Same auditor-facing rationale as + # the CDX side: a single in-document link to the project's + # security disclosures, no out-of-band knowledge required. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + adv_refs = [ + r for r in wolfssl_pkg['externalRefs'] + if r['referenceType'] == 'advisory' + ] + self.assertEqual(len(adv_refs), 1) + self.assertEqual( + adv_refs[0]['referenceLocator'], + 'https://github.com/wolfSSL/wolfssl/security/advisories') + self.assertEqual(adv_refs[0]['referenceCategory'], 'SECURITY') + + def test_creation_info_uses_module_constants(self): + # SPDX `creationInfo.creators[]` carries the producer-identity + # signal that downstream tools key on; must come from the + # module-level constants and not from a stale string. + doc = gs.generate_spdx(**self.BASE_KW) + creators = doc['creationInfo']['creators'] + expected_tool = ( + f'Tool: {gs.GEN_SBOM_TOOL_NAME}-{gs.GEN_SBOM_VERSION}' + ) + self.assertIn(expected_tool, creators) + + def test_annotations_have_well_formed_metadata(self): + # SPDX 2.3 §8.5: annotation entries require `annotationDate` + # (ISO-8601 with timezone), `annotationType` (one of OTHER, + # REVIEW, ...), `annotator` (Person/Organization/Tool prefix), + # and `comment` (string). pyspdxtools rejects malformed + # annotation entries; pin the shape here at the cheapest CI + # gate so a regression in generate_spdx surfaces in unit + # tests rather than in the integration job. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + for ann in wolfssl_pkg['annotations']: + self.assertEqual(ann['annotationDate'], self.BASE_KW['timestamp']) + self.assertEqual(ann['annotationType'], 'OTHER') + self.assertTrue(ann['annotator'].startswith('Tool: '), + f'annotator must use Tool: prefix: {ann!r}') + self.assertIsInstance(ann['comment'], str) + self.assertTrue(ann['comment']) # --------------------------------------------------------------------------- From eae77eb567319838b34751a74f31af3200402595 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 4 Jun 2026 11:51:36 +0300 Subject: [PATCH 31/39] sbom,bomsh: hash the bomsh-traced binary, fix doc/SBOM.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make bomsh previously left the SHA-256 in checksums[] (post-libtool- relink, hashed by `make sbom`'s private staging install) and the OmniBOR gitoid in externalRefs (pre-relink, traced by bomtrace3) describing two different files inside the same SPDX document. Add SBOM_LIB_OVERRIDE to Makefile.am: the bomsh recipe now discovers the traced library under src/.libs/ and passes it to the nested `make sbom` invocation, so checksums[] hashes the same artefact that bomsh_sbom.py enriches with OmniBOR ExternalRefs. doc/SBOM.md: - §3.5 rewritten to match bomsh_verify.py reality (gates A and B only). The previously documented gate (C) "Artefact correspondence -- gitoid == git-blob hash of libwolfssl.{so,dylib,a}" was never implementable: bomsh attaches the OmniBOR Input Manifest bom_id, not the binary's content gitoid, so that check would always fail. New "Identity of the SHA-256" subsection explains the SBOM_LIB_OVERRIDE binding instead. - §3.2 step ordering corrected (clean before bomtrace3, override plumbing in step 5). - Intro NTIA claim qualified to point at .github/workflows/sbom.yml for the actual validator set. - §2.4 "Third-party deps: none" qualified to mention --with-libz / --with-liboqs and the host C runtime. - §2.4 PURL row updated to the new pkg:github shape. --- Makefile.am | 87 +++++++++++++++++++++++++++++++++++++---------------- doc/SBOM.md | 67 ++++++++++++++++++++++++++++------------- 2 files changed, 107 insertions(+), 47 deletions(-) diff --git a/Makefile.am b/Makefile.am index df41b3e2a6a..4f6cbb4c411 100644 --- a/Makefile.am +++ b/Makefile.am @@ -470,6 +470,17 @@ WOLFSSL_LIB_DSO_BASENAMES = \ # their own URL should set this to a URI they # actually serve (e.g. # https://example.com/sbom/wolfssl-X.Y.Z.spdx.json). +# SBOM_LIB_OVERRIDE Absolute path to the library artefact whose +# SHA-256 should land in the SBOM, INSTEAD of +# discovering one via a private staging install. +# Set by `make bomsh` so the SBOM hash and the +# OmniBOR enrichment refer to the SAME bomsh- +# traced binary; without this override `make +# sbom` would re-link via `make install` and +# hash a different artefact than `bomsh_sbom.py` +# fingerprints, leaving the SHA-256 in +# `checksums[]` and the gitoid in `externalRefs` +# describing two unrelated files. sbom: @if test -z "$(PYTHON3)"; then \ echo ""; \ @@ -487,24 +498,34 @@ sbom: @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; \ + if test -n "$(SBOM_LIB_OVERRIDE)"; then \ + if test ! -f "$(SBOM_LIB_OVERRIDE)"; then \ + echo ""; \ + echo "ERROR: SBOM_LIB_OVERRIDE=$(SBOM_LIB_OVERRIDE) does not exist."; \ + echo ""; \ + exit 1; \ + fi; \ + sbom_lib="$(SBOM_LIB_OVERRIDE)"; \ + else \ + $(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; \ fi; \ echo "SBOM: hashing $$sbom_lib"; \ if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ @@ -559,6 +580,17 @@ bomshdir = $(datadir)/doc/$(PACKAGE) # 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. +# +# After the traced rebuild we discover the bomsh-traced library in +# $(abs_builddir)/src/.libs/ and pass it to the nested `make sbom` call as +# SBOM_LIB_OVERRIDE. Without the override `make sbom` would `make install +# DESTDIR=...` into a private tree, which triggers a libtool relink and +# produces a binary whose SHA-256 differs from the one bomtrace3 traced. +# That left the gitoid in `externalRefs` (which IS for the traced binary) +# and the SHA-256 in `checksums[]` (which was NOT) describing two different +# files in the same SPDX document. With the override they describe the +# same artefact, which is the invariant any auditor reading the document +# expects. bomsh: @if test -z "$(BOMTRACE3)"; then \ echo ""; \ @@ -578,13 +610,7 @@ bomsh: @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=""; \ + @bomsh_artifact=""; \ for lib in \ $(addprefix "$(abs_builddir)/src/.libs"/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ "$(abs_builddir)/src/.libs/libwolfssl.a" \ @@ -593,7 +619,16 @@ bomsh: 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."; \ + echo " OmniBOR graph produced; SBOM regeneration + SPDX"; \ + echo " enrichment skipped."; \ + exit 0; \ + fi; \ + echo "bomsh: traced binary -> $$bomsh_artifact"; \ + $(MAKE) sbom SBOM_LIB_OVERRIDE="$$bomsh_artifact"; \ + 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."; \ + echo " The base SBOM in $(SBOM_SPDX) already hashes the bomsh-traced binary."; \ exit 0; \ fi; \ echo "Enriching SPDX with OmniBOR ExternalRefs (artifact: $$bomsh_artifact)..."; \ diff --git a/doc/SBOM.md b/doc/SBOM.md index f695b1cb374..c119210e9f6 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -21,9 +21,12 @@ covered: | `python3 scripts/gen-sbom …` (standalone) | Embedded / RTOS customers building with their own Makefile, Keil, IAR, STM32CubeIDE, ESP-IDF, Zephyr, plain CMake, etc. | Any | | `make sbom` (autotools wrapper) | Linux server / Debian / RPM / Yocto / FIPS-Ready customers running `./configure && make` | Autotools | -Both call the same Python core and produce SBOMs that pass the same SPDX -2.3 / CycloneDX 1.6 / NTIA validators. Pick whichever matches your build -flow. +Both call the same Python core and produce SBOMs that pass SPDX 2.3 +(`pyspdxtools`) and CycloneDX 1.6 (`cyclonedx-bom` strict JSON validator) +schema validation. The autotools `make sbom` integration job +additionally runs `ntia-conformance-checker` against NTIA Minimum +Elements 2021; see `.github/workflows/sbom.yml` for the exact set of +validators run on every PR. Pick whichever matches your build flow. --- @@ -415,9 +418,9 @@ Both formats contain the same information: | 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@` | +| PURL | `pkg:github/wolfSSL/wolfssl@v` (resolves directly in OSV / GHSA / Snyk / Trivy without per-vendor mapping) | | Download location | `https://github.com/wolfSSL/wolfssl` | -| Third-party deps | none (wolfssl has no runtime dependencies in a default build) | +| Third-party deps | none in a default build; `--with-libz` adds zlib and `--with-liboqs` adds liboqs (recorded as `DEPENDS_ON` packages with their own purl/CPE/supplier). All builds depend transitively on the host C runtime; this is not enumerated as an SBOM component since it is system-supplied and varies per runtime target. | #### License detection @@ -578,19 +581,25 @@ Place `bomsh_create_bom.py` (and optionally `bomsh_sbom.py`) from the bomsh ### 3.2 What make bomsh does -1. Writes a build-local `_bomsh.conf` redirecting the raw logfile out of +1. 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. This step also removes any prior + `wolfssl-.{cdx,spdx}.json` from a stand-alone `make sbom`, + which is intentional: the document `make bomsh` enriches must come + from the *traced* rebuild, not from a stale pre-trace one. +2. 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`. +5. Discovers the bomsh-traced library under `src/.libs/` and runs + `make sbom SBOM_LIB_OVERRIDE=` so the regenerated + SPDX hashes the same binary that bomsh traced (see § 3.5). +6. If `bomsh_sbom.py` is available, annotates the regenerated SPDX + document with OmniBOR `ExternalRef` identifiers, producing + `omnibor.wolfssl-.spdx.json`. ### 3.3 Output files @@ -632,27 +641,43 @@ 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`. +#### Identity of the SHA-256 in the enriched SPDX + +`make bomsh` discovers the bomsh-traced library under +`$(abs_builddir)/src/.libs/` and passes it to the nested `make sbom` +invocation as `SBOM_LIB_OVERRIDE`, so the SHA-256 in the SPDX +`checksums[]` is the SHA-256 of the **exact binary that `bomtrace3` +traced**. Without that override `make sbom` would re-link via `make +install DESTDIR=...` and hash a libtool-relinked artefact whose +SHA-256 differs from the traced library, leaving the SHA-256 in +`checksums[]` and the OmniBOR `externalRefs` describing two different +files in the same SPDX document. + #### CI verifiability gates -The bomsh CI job enforces three independent self-consistency properties +The bomsh CI job enforces two independent self-consistency properties on every PR, in addition to schema validation of the enriched SPDX through `pyspdxtools`: 1. **Resolvability** — every `gitoid` listed in the SPDX `externalRefs` resolves to a blob present at `omnibor/objects//`. 2. **Object-store integrity** — every blob in `omnibor/objects/` - round-trips through `sha1(b"blob \0" + content)`, so a corrupt or - truncated object store is caught at PR time, not by a downstream + round-trips through `sha1(b"blob \0" + content)`, so a corrupt + or truncated object store is caught at PR time, not by a downstream verifier weeks later. -3. **Artefact correspondence** — the `gitoid` recorded against the - `wolfssl` SPDX package equals the git-blob hash of the actual - `libwolfssl.{so,dylib,a}` that `make bomsh` traced. This is what - makes the SBOM a true attestation of the binary that would ship, - rather than a plausible-looking but fictional reference. -If any of these fail, the PR fails — the bomsh provenance bundle that a +If either fails, the PR fails — the bomsh provenance bundle that a CRA reviewer would download is never published with a broken bridge. +A third gate is **not** implemented: the gitoid that `bomsh_sbom.py` +attaches to the SPDX is the bom_id of the OmniBOR Input Manifest for +the traced artefact, not the git-blob hash of the binary itself. The +two are different by design (the bom_id summarises the build inputs, +not the linked output bytes), so a "gitoid == sha1 of the binary" +check would always fail. What ties the SBOM to the binary today is +the SHA-256 in `checksums[]`, which the `SBOM_LIB_OVERRIDE` plumbing +described above guarantees is the SHA-256 of the bomsh-traced library. + The verifier itself lives at `scripts/bomsh_verify.py` (importable, with synthetic-fixture unit tests in `scripts/test_gen_sbom.py`). Run it against any local `make bomsh` output with: From cc6f1d897c3bd2b5cdc281127683124e2a20e1f7 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 4 Jun 2026 11:51:50 +0300 Subject: [PATCH 32/39] sbom: standalone CI validators, ship bomsh_verify, gitignore outputs - .github/workflows/sbom.yml: standalone (no-autotools) job now runs ntia-conformance-checker and cyclonedx-bom alongside pyspdxtools, matching the integration job. An NTIA or CDX-1.6 schema regression in --user-settings / --srcs handling -- the entry point most embedded customers actually invoke -- now fails CI rather than needing manual review. - Update integration-job assertions for the new pkg:github PURL shape and pin the GitHub Security Advisories externalRef so a regression that drops it fails CI. - scripts/include.am: add bomsh_verify.py to EXTRA_DIST so a release tarball ships the verifier; without this, downstream consumers cannot re-verify a `make bomsh` provenance bundle from a tarball. - .gitignore: /wolfssl-*.{cdx.json,spdx.json,spdx}, /omnibor.wolfssl-*.spdx.json, /omnibor/, /_sbom_staging/, /_bomsh.conf, /bomsh_raw_logfile* -- generated outputs that were untracked but not ignored. --- .github/workflows/sbom.yml | 59 +++++++++++++++++++++++++++++++++----- .gitignore | 12 ++++++++ scripts/include.am | 8 ++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index f10a5e31d19..192f4cfbd10 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -72,12 +72,22 @@ jobs: - uses: actions/checkout@v4 - name: Install standalone-path deps - # pcpp is the in-Python C preprocessor that lets gen-sbom walk - # settings.h + user_settings.h with no compiler invocation. - # spdx-tools is for the post-generation validation step. + # pcpp drives --user-settings; spdx-tools provides pyspdxtools + # for SPDX schema validation; cyclonedx-bom provides the + # CycloneDX 1.6 strict JSON validator; ntia-conformance-checker + # provides the NTIA Minimum Elements (2021) gate. All four + # were previously only run on the autotools integration job, + # which left a regression in the standalone path -- the entry + # point an embedded customer actually invokes -- detectable + # only by hand. Versions match the integration job below so + # tool drift is single-sourced. run: | python3 -m pip install --user --upgrade pip - python3 -m pip install --user 'pcpp==1.30.*' 'spdx-tools==0.8.*' + python3 -m pip install --user \ + 'pcpp==1.30.*' \ + 'spdx-tools==0.8.*' \ + 'cyclonedx-bom==7.*' \ + 'ntia-conformance-checker==5.*' echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Generate SBOM via standalone Python entry point @@ -119,6 +129,34 @@ jobs: # rejects, our portability claim is false. run: pyspdxtools --infile /tmp/standalone/wolfssl.spdx.json + - name: Standalone SPDX passes NTIA Minimum Elements (2021) + # NTIA Minimum Elements is the conformance gate auditors + # actually rely on: a structurally-valid SPDX that is missing + # supplier / author / version / unique-id is still useless to + # them. Previously only the autotools job ran this; an NTIA + # regression in --user-settings or --srcs handling could only + # be caught by hand. Run it on the standalone path too so + # the embedded entry point holds the same contract. + run: ntia-checker -c ntia /tmp/standalone/wolfssl.spdx.json + + - name: Standalone CDX validates per CycloneDX 1.6 strict schema + # Same validator the autotools job runs. Pins both entry + # points against the same CDX 1.6 schema definition; a + # standalone-only CDX regression now fails at PR time. + run: | + python3 - <<'PY' + import sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + with open('/tmp/standalone/wolfssl.cdx.json') as f: + errors = v.validate_str(f.read()) + if errors: + print(f"INVALID: {errors}", file=sys.stderr) + sys.exit(1) + print("OK: standalone CDX passes CycloneDX 1.6 strict schema") + PY + - name: Standalone SBOM advertises source-merkle hash semantics # The auditor-facing contract: the standalone SBOM must say # "this checksum is over a source set, not a library binary", @@ -472,16 +510,23 @@ jobs: - name: CPE 2.3 and PURL identifiers well-formed # A typo in supplier or product name silently breaks every - # downstream OSV / Trivy / Grype scan. + # downstream OSV / Trivy / Grype scan. PURL must be `pkg:github` + # so OSV/GHSA/Snyk/Trivy resolve directly without per-vendor + # CPE-fallback mapping. An advisory externalRef pointing at the + # GitHub Security Advisories index is also pinned so an auditor + # reading the SBOM has a single in-document link to the project's + # disclosures. run: | python3 - <<'PY' - import glob, json, re, sys + import glob, json, re 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 + assert re.match(r'pkg:github/wolfSSL/wolfssl@v[\d.]+', refs['purl']), refs + assert refs['advisory'] == \ + 'https://github.com/wolfSSL/wolfssl/security/advisories', refs print('identifiers ok:', refs) PY diff --git a/.gitignore b/.gitignore index 693d9d5a843..007ef00fc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,18 @@ ctaocrypt/src/src/ *.libs *.cache .dirstamp + +# SBOM / bomsh output artefacts (produced by `make sbom` / `make bomsh`). +# Built per-release, not source. Listed first so they survive any +# subsequent `!`-style un-ignore patterns added below. +/wolfssl-*.cdx.json +/wolfssl-*.spdx.json +/wolfssl-*.spdx +/omnibor.wolfssl-*.spdx.json +/omnibor/ +/_sbom_staging/ +/_bomsh.conf +/bomsh_raw_logfile* *.user !*-VS2022.vcxproj.user configure diff --git a/scripts/include.am b/scripts/include.am index f71bb481039..7fc38739cd0 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -177,3 +177,11 @@ 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 + +# Bomsh / OmniBOR provenance verifier (invoked from `.github/workflows/ +# sbom.yml` and runnable by hand against any local `make bomsh` output; +# see doc/SBOM.md § 3.5). Must ship with the dist tarball so a +# downstream consumer / CRA reviewer who clones a release tarball can +# re-verify the OmniBOR graph against its enriched SPDX without going +# back to the git repo. +EXTRA_DIST += scripts/bomsh_verify.py From 0593074a35dd29afd56c1e6ad807b372610f80b9 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 5 Jun 2026 16:03:07 +0300 Subject: [PATCH 33/39] advisory: gen-advisory CSAF 2.0 + CycloneDX VEX generator + make target Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX from wolfSSL CVE Program records plus a human-authored VEX overlay (state/justification, fixed versions, separate FIPS product, reachability hedge). Stdlib-only, SOURCE_DATE_EPOCH-reproducible, mirroring scripts/gen-sbom. `make advisory` is a thin wrapper around the same script and is interchangeable with it: both default to the canonical advisories/ tree (advisories/records/*.json + advisories/vex-overlay.json) and, with no record arguments, emit one CSAF + one CycloneDX document per CVE into advisories/out/ (a build artefact, gitignored). Explicit --cve-record/--csaf-out/--cdx-vex-out still drive single or bundled per-release documents. CSAF 2.0 scores[] carry only CVSS v2/v3 (the schema predates v4); a v4-only finding is preserved as a note plus aggregate_severity, with the machine-readable v4 rating in the CycloneDX output. CWE names are resolved from the official catalogue (cwe-names.json) for CSAF 6.1.11. The overlay format is documented by advisory-vex-overlay.example.json and constrained by advisory-vex-overlay.schema.json. Signed-off-by: Sameeh Jubran --- .gitignore | 10 + Makefile.am | 66 ++ advisories/records/CVE-2026-5501.json | 122 +++ advisories/records/CVE-2026-5778.json | 122 +++ advisories/vex-overlay.json | 21 + scripts/advisory-vex-overlay.example.json | 45 + scripts/advisory-vex-overlay.schema.json | 117 +++ scripts/cwe-names.json | 971 ++++++++++++++++++++++ scripts/gen-advisory | 848 +++++++++++++++++++ scripts/include.am | 8 + 10 files changed, 2330 insertions(+) create mode 100644 advisories/records/CVE-2026-5501.json create mode 100644 advisories/records/CVE-2026-5778.json create mode 100644 advisories/vex-overlay.json create mode 100644 scripts/advisory-vex-overlay.example.json create mode 100644 scripts/advisory-vex-overlay.schema.json create mode 100644 scripts/cwe-names.json create mode 100644 scripts/gen-advisory diff --git a/.gitignore b/.gitignore index 007ef00fc0a..f87facf9cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,16 @@ ctaocrypt/src/src/ /_sbom_staging/ /_bomsh.conf /bomsh_raw_logfile* + +# Generated advisory documents (produced by `make advisory` / +# `scripts/gen-advisory`). Inputs under advisories/records/ and +# advisories/vex-overlay.json are tracked; the generated out/ tree is not. +/advisories/out/ + +# Node deps pulled in only by the CSAF conformance check +# (scripts/csaf_validate.mjs uses @secvisogram/csaf-validator-lib). +/node_modules/ +/package-lock.json *.user !*-VS2022.vcxproj.user configure diff --git a/Makefile.am b/Makefile.am index 4f6cbb4c411..cd18f6987da 100644 --- a/Makefile.am +++ b/Makefile.am @@ -565,6 +565,72 @@ uninstall-sbom: CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) +# Security advisory generation (CSAF 2.0 + CycloneDX 1.6 VEX) +# +# `make advisory` is a thin wrapper around scripts/gen-advisory: it feeds the +# script the canonical advisory single-source-of-truth under advisories/ and is +# byte-for-byte interchangeable with running the script by hand. Equivalent +# invocations: +# +# make advisory +# python3 scripts/gen-advisory # uses the same defaults +# python3 scripts/gen-advisory \ +# --records-dir advisories/records \ +# --vex-overlay advisories/vex-overlay.json \ +# --out-dir advisories/out +# +# Inputs (tracked in git): advisories/records/CVE-*.json + advisories/vex-overlay.json +# Outputs (build artifacts): advisories/out/CVE-*.{csaf,cdx}.json +ADVISORY_RECORDS_DIR = $(srcdir)/advisories/records +ADVISORY_OVERLAY = $(srcdir)/advisories/vex-overlay.json +ADVISORY_OUT_DIR = $(abs_builddir)/advisories/out +advisorydir = $(datadir)/doc/$(PACKAGE)/advisories + +.PHONY: advisory install-advisory uninstall-advisory + +# Generate one CSAF + one CycloneDX VEX document per CVE record. Honors +# SOURCE_DATE_EPOCH for reproducible output (set from the last git commit when +# unset and a git tree is available), exactly like `make sbom`. +advisory: + @if test -z "$(PYTHON3)"; then \ + echo ""; \ + echo "ERROR: 'python3' not found in PATH. Cannot generate advisories."; \ + echo ""; \ + exit 1; \ + fi + @set -e; \ + 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; \ + $(PYTHON3) $(srcdir)/scripts/gen-advisory \ + --records-dir $(ADVISORY_RECORDS_DIR) \ + --vex-overlay $(ADVISORY_OVERLAY) \ + --out-dir $(ADVISORY_OUT_DIR) + +install-advisory: advisory + $(MKDIR_P) $(DESTDIR)$(advisorydir) + @for f in $(ADVISORY_OUT_DIR)/*.json; do \ + echo " $(INSTALL_DATA) $$f $(DESTDIR)$(advisorydir)/"; \ + $(INSTALL_DATA) "$$f" $(DESTDIR)$(advisorydir)/; \ + done + +uninstall-advisory: + -rm -f $(DESTDIR)$(advisorydir)/*.csaf.json + -rm -f $(DESTDIR)$(advisorydir)/*.cdx.json + +CLEANFILES += advisories/out/*.csaf.json advisories/out/*.cdx.json + +# Ship the advisory generator inputs in the dist tarball so a downstream +# consumer can `./configure && make advisory` from a release. +EXTRA_DIST += advisories/records/CVE-2026-5501.json \ + advisories/records/CVE-2026-5778.json \ + advisories/vex-overlay.json + # Bomsh (OmniBOR build artifact tracing + SBOM enrichment) BOMSH_RAWLOG_BASE = $(abs_builddir)/bomsh_raw_logfile BOMSH_RAWLOG = $(BOMSH_RAWLOG_BASE).sha1 diff --git a/advisories/records/CVE-2026-5501.json b/advisories/records/CVE-2026-5501.json new file mode 100644 index 00000000000..6db50821c6c --- /dev/null +++ b/advisories/records/CVE-2026-5501.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "LOW", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.6, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/records/CVE-2026-5778.json b/advisories/records/CVE-2026-5778.json new file mode 100644 index 00000000000..75296072f1a --- /dev/null +++ b/advisories/records/CVE-2026-5778.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "LOW", + "userInteraction": "PASSIVE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "LOW", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "LOW", + "baseScore": 2.1, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/vex-overlay.json b/advisories/vex-overlay.json new file mode 100644 index 00000000000..14a0ae0c1f8 --- /dev/null +++ b/advisories/vex-overlay.json @@ -0,0 +1,21 @@ +{ + "_comment": "Canonical wolfSSL VEX overlay consumed by `make advisory` / `scripts/gen-advisory`. Keyed by CVE id; carries the determinations the CVE Program record cannot express (analysis state, justification, fixed versions, remediation, optional FIPS product, optional build-reachability hedge). Constrained by scripts/advisory-vex-overlay.schema.json. To model a wolfCrypt FIPS module as a separate product, add a \"fips\" block per the format in scripts/advisory-vex-overlay.example.json using the real validated module version and CMVP certificate number (do NOT copy the illustrative placeholder values from the example).", + + "CVE-2026-5501": { + "state": "exploitable", + "response": ["update"], + "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation." + }, + + "CVE-2026-5778": { + "state": "exploitable", + "response": ["update"], + "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", + "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], + "default_status": "off", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected." + } +} diff --git a/scripts/advisory-vex-overlay.example.json b/scripts/advisory-vex-overlay.example.json new file mode 100644 index 00000000000..b82be42c434 --- /dev/null +++ b/scripts/advisory-vex-overlay.example.json @@ -0,0 +1,45 @@ +{ + "_comment": "Human-authored VEX determinations keyed by CVE id. The CVE Program record carries the structural facts (CWE/CVSS/affected ranges); this overlay carries what it cannot express in machine-readable form: the analysis state, the not-affected justification, the response, free-text scope detail, the mainline fixed release version(s), an optional separately-modelled FIPS product entry, and an optional no-cost build-reachability hedge (requires_defines / default_status). The FIPS module_version and cmvp_cert values below are ILLUSTRATIVE placeholders; substitute the real validated module version and CMVP certificate number. gen-advisory folds these into both the CSAF and CycloneDX VEX outputs. requires_defines/default_status are recorded as informational notes only -- this tool does not compute per-build reachability.", + + "CVE-2026-5501": { + "state": "exploitable", + "response": ["update"], + "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the affected OpenSSL compatibility layer (wolfSSL_X509_verify_cert) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5778": { + "state": "exploitable", + "response": ["update"], + "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", + "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], + "default_status": "off", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the packet sniffer (src/sniffer.c) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5999": { + "state": "exploitable", + "response": ["update"], + "detail": "Synthetic fixture overlay: a simple mainline-only finding (no separate FIPS product) used to exercise the CVSS v3.1 scores[] path.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later." + } +} diff --git a/scripts/advisory-vex-overlay.schema.json b/scripts/advisory-vex-overlay.schema.json new file mode 100644 index 00000000000..9ae5e3e05d4 --- /dev/null +++ b/scripts/advisory-vex-overlay.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.wolfssl.com/schema/advisory-vex-overlay-1.json", + "title": "wolfSSL gen-advisory VEX overlay", + "description": "Human-authored VEX determinations keyed by CVE id, consumed by scripts/gen-advisory. The CVE Program record supplies the structural facts (CWE/CVSS/affected ranges); this overlay supplies what the record cannot express in machine-readable form. Enum values mirror the CycloneDX 1.6 vulnerability analysis vocabulary so the same terms map cleanly into both the CSAF and CycloneDX VEX outputs.", + "type": "object", + "properties": { + "_comment": { + "type": "string", + "description": "Free-text note ignored by gen-advisory." + } + }, + "patternProperties": { + "^CVE-[0-9]{4}-[0-9]{4,}$": { "$ref": "#/$defs/overlayEntry" } + }, + "additionalProperties": false, + "$defs": { + "analysisState": { + "type": "string", + "description": "CycloneDX 1.6 vulnerability analysis state.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ] + }, + "justification": { + "type": "string", + "description": "CycloneDX 1.6 impact analysis justification (required by gen-advisory when state is not_affected so a CSAF flag can be emitted).", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_perimeter", + "protected_at_runtime", + "protected_by_mitigating_control" + ] + }, + "response": { + "type": "array", + "description": "CycloneDX 1.6 vulnerability analysis response.", + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ] + } + }, + "versionList": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "fips": { + "type": "object", + "description": "Optional separately-modelled FIPS product entry. FIPS customers cannot freely upgrade and many CVEs fall outside the validated module boundary, so FIPS is modelled as its own product with its own status and remediation.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "module_version": { "type": "string", "minLength": 1 }, + "cmvp_cert": { + "type": "string", + "minLength": 1, + "description": "CMVP certificate number, recorded as a CSAF model_number / CycloneDX property." + }, + "status": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "status": { "const": "not_affected" } }, "required": ["status"] }, + "then": { "required": ["justification"] } + } + ] + }, + "overlayEntry": { + "type": "object", + "properties": { + "state": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "response": { "$ref": "#/$defs/response" }, + "detail": { "type": "string" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 }, + "requires_defines": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Build flags that gate the vulnerable code. Recorded as an informational note only; gen-advisory does NOT compute per-build reachability." + }, + "default_status": { + "type": "string", + "enum": ["on", "off", "enabled", "disabled"] + }, + "fips": { "$ref": "#/$defs/fips" } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "state": { "const": "not_affected" } }, "required": ["state"] }, + "then": { "required": ["justification"] } + } + ] + } + } +} diff --git a/scripts/cwe-names.json b/scripts/cwe-names.json new file mode 100644 index 00000000000..d256ca419ed --- /dev/null +++ b/scripts/cwe-names.json @@ -0,0 +1,971 @@ +{ + "CWE-1004": "Sensitive Cookie Without 'HttpOnly' Flag", + "CWE-1007": "Insufficient Visual Distinction of Homoglyphs Presented to User", + "CWE-102": "Struts: Duplicate Validation Forms", + "CWE-1021": "Improper Restriction of Rendered UI Layers or Frames", + "CWE-1022": "Use of Web Link to Untrusted Target with window.opener Access", + "CWE-1023": "Incomplete Comparison with Missing Factors", + "CWE-1024": "Comparison of Incompatible Types", + "CWE-1025": "Comparison Using Wrong Factors", + "CWE-103": "Struts: Incomplete validate() Method Definition", + "CWE-1037": "Processor Optimization Removal or Modification of Security-critical Code", + "CWE-1038": "Insecure Automated Optimizations", + "CWE-1039": "Inadequate Detection or Handling of Adversarial Input Perturbations in Automated Recognition Mechanism", + "CWE-104": "Struts: Form Bean Does Not Extend Validation Class", + "CWE-1041": "Use of Redundant Code", + "CWE-1042": "Static Member Data Element outside of a Singleton Class Element", + "CWE-1043": "Data Element Aggregating an Excessively Large Number of Non-Primitive Elements", + "CWE-1044": "Architecture with Number of Horizontal Layers Outside of Expected Range", + "CWE-1045": "Parent Class with a Virtual Destructor and a Child Class without a Virtual Destructor", + "CWE-1046": "Creation of Immutable Text Using String Concatenation", + "CWE-1047": "Modules with Circular Dependencies", + "CWE-1048": "Invokable Control Element with Large Number of Outward Calls", + "CWE-1049": "Excessive Data Query Operations in a Large Data Table", + "CWE-105": "Struts: Form Field Without Validator", + "CWE-1050": "Excessive Platform Resource Consumption within a Loop", + "CWE-1051": "Initialization with Hard-Coded Network Resource Configuration Data", + "CWE-1052": "Excessive Use of Hard-Coded Literals in Initialization", + "CWE-1053": "Missing Documentation for Design", + "CWE-1054": "Invocation of a Control Element at an Unnecessarily Deep Horizontal Layer", + "CWE-1055": "Multiple Inheritance from Concrete Classes", + "CWE-1056": "Invokable Control Element with Variadic Parameters", + "CWE-1057": "Data Access Operations Outside of Expected Data Manager Component", + "CWE-1058": "Invokable Control Element in Multi-Thread Context with non-Final Static Storable or Member Element", + "CWE-1059": "Insufficient Technical Documentation", + "CWE-106": "Struts: Plug-in Framework not in Use", + "CWE-1060": "Excessive Number of Inefficient Server-Side Data Accesses", + "CWE-1061": "Insufficient Encapsulation", + "CWE-1062": "Parent Class with References to Child Class", + "CWE-1063": "Creation of Class Instance within a Static Code Block", + "CWE-1064": "Invokable Control Element with Signature Containing an Excessive Number of Parameters", + "CWE-1065": "Runtime Resource Management Control Element in a Component Built to Run on Application Servers", + "CWE-1066": "Missing Serialization Control Element", + "CWE-1067": "Excessive Execution of Sequential Searches of Data Resource", + "CWE-1068": "Inconsistency Between Implementation and Documented Design", + "CWE-1069": "Empty Exception Block", + "CWE-107": "Struts: Unused Validation Form", + "CWE-1070": "Serializable Data Element Containing non-Serializable Item Elements", + "CWE-1071": "Empty Code Block", + "CWE-1072": "Data Resource Access without Use of Connection Pooling", + "CWE-1073": "Non-SQL Invokable Control Element with Excessive Number of Data Resource Accesses", + "CWE-1074": "Class with Excessively Deep Inheritance", + "CWE-1075": "Unconditional Control Flow Transfer outside of Switch Block", + "CWE-1076": "Insufficient Adherence to Expected Conventions", + "CWE-1077": "Floating Point Comparison with Incorrect Operator", + "CWE-1078": "Inappropriate Source Code Style or Formatting", + "CWE-1079": "Parent Class without Virtual Destructor Method", + "CWE-108": "Struts: Unvalidated Action Form", + "CWE-1080": "Source Code File with Excessive Number of Lines of Code", + "CWE-1082": "Class Instance Self Destruction Control Element", + "CWE-1083": "Data Access from Outside Expected Data Manager Component", + "CWE-1084": "Invokable Control Element with Excessive File or Data Access Operations", + "CWE-1085": "Invokable Control Element with Excessive Volume of Commented-out Code", + "CWE-1086": "Class with Excessive Number of Child Classes", + "CWE-1087": "Class with Virtual Method without a Virtual Destructor", + "CWE-1088": "Synchronous Access of Remote Resource without Timeout", + "CWE-1089": "Large Data Table with Excessive Number of Indices", + "CWE-109": "Struts: Validator Turned Off", + "CWE-1090": "Method Containing Access of a Member Element from Another Class", + "CWE-1091": "Use of Object without Invoking Destructor Method", + "CWE-1092": "Use of Same Invokable Control Element in Multiple Architectural Layers", + "CWE-1093": "Excessively Complex Data Representation", + "CWE-1094": "Excessive Index Range Scan for a Data Resource", + "CWE-1095": "Loop Condition Value Update within the Loop", + "CWE-1096": "Singleton Class Instance Creation without Proper Locking or Synchronization", + "CWE-1097": "Persistent Storable Data Element without Associated Comparison Control Element", + "CWE-1098": "Data Element containing Pointer Item without Proper Copy Control Element", + "CWE-1099": "Inconsistent Naming Conventions for Identifiers", + "CWE-11": "ASP.NET Misconfiguration: Creating Debug Binary", + "CWE-110": "Struts: Validator Without Form Field", + "CWE-1100": "Insufficient Isolation of System-Dependent Functions", + "CWE-1101": "Reliance on Runtime Component in Generated Code", + "CWE-1102": "Reliance on Machine-Dependent Data Representation", + "CWE-1103": "Use of Platform-Dependent Third Party Components", + "CWE-1104": "Use of Unmaintained Third Party Components", + "CWE-1105": "Insufficient Encapsulation of Machine-Dependent Functionality", + "CWE-1106": "Insufficient Use of Symbolic Constants", + "CWE-1107": "Insufficient Isolation of Symbolic Constant Definitions", + "CWE-1108": "Excessive Reliance on Global Variables", + "CWE-1109": "Use of Same Variable for Multiple Purposes", + "CWE-111": "Direct Use of Unsafe JNI", + "CWE-1110": "Incomplete Design Documentation", + "CWE-1111": "Incomplete I/O Documentation", + "CWE-1112": "Incomplete Documentation of Program Execution", + "CWE-1113": "Inappropriate Comment Style", + "CWE-1114": "Inappropriate Whitespace Style", + "CWE-1115": "Source Code Element without Standard Prologue", + "CWE-1116": "Inaccurate Comments", + "CWE-1117": "Callable with Insufficient Behavioral Summary", + "CWE-1118": "Insufficient Documentation of Error Handling Techniques", + "CWE-1119": "Excessive Use of Unconditional Branching", + "CWE-112": "Missing XML Validation", + "CWE-1120": "Excessive Code Complexity", + "CWE-1121": "Excessive McCabe Cyclomatic Complexity", + "CWE-1122": "Excessive Halstead Complexity", + "CWE-1123": "Excessive Use of Self-Modifying Code", + "CWE-1124": "Excessively Deep Nesting", + "CWE-1125": "Excessive Attack Surface", + "CWE-1126": "Declaration of Variable with Unnecessarily Wide Scope", + "CWE-1127": "Compilation with Insufficient Warnings or Errors", + "CWE-113": "Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting')", + "CWE-114": "Process Control", + "CWE-115": "Misinterpretation of Input", + "CWE-116": "Improper Encoding or Escaping of Output", + "CWE-1164": "Irrelevant Code", + "CWE-117": "Improper Output Neutralization for Logs", + "CWE-1173": "Improper Use of Validation Framework", + "CWE-1174": "ASP.NET Misconfiguration: Improper Model Validation", + "CWE-1176": "Inefficient CPU Computation", + "CWE-1177": "Use of Prohibited Code", + "CWE-118": "Incorrect Access of Indexable Resource ('Range Error')", + "CWE-1187": "DEPRECATED: Use of Uninitialized Resource", + "CWE-1188": "Initialization of a Resource with an Insecure Default", + "CWE-1189": "Improper Isolation of Shared Resources on System-on-a-Chip (SoC)", + "CWE-119": "Improper Restriction of Operations within the Bounds of a Memory Buffer", + "CWE-1190": "DMA Device Enabled Too Early in Boot Phase", + "CWE-1191": "On-Chip Debug and Test Interface With Improper Access Control", + "CWE-1192": "Improper Identifier for IP Block used in System-On-Chip (SOC)", + "CWE-1193": "Power-On of Untrusted Execution Core Before Enabling Fabric Access Control", + "CWE-12": "ASP.NET Misconfiguration: Missing Custom Error Page", + "CWE-120": "Buffer Copy without Checking Size of Input ('Classic Buffer Overflow')", + "CWE-1204": "Generation of Weak Initialization Vector (IV)", + "CWE-1209": "Failure to Disable Reserved Bits", + "CWE-121": "Stack-based Buffer Overflow", + "CWE-122": "Heap-based Buffer Overflow", + "CWE-1220": "Insufficient Granularity of Access Control", + "CWE-1221": "Incorrect Register Defaults or Module Parameters", + "CWE-1222": "Insufficient Granularity of Address Regions Protected by Register Locks", + "CWE-1223": "Race Condition for Write-Once Attributes", + "CWE-1224": "Improper Restriction of Write-Once Bit Fields", + "CWE-1229": "Creation of Emergent Resource", + "CWE-123": "Write-what-where Condition", + "CWE-1230": "Exposure of Sensitive Information Through Metadata", + "CWE-1231": "Improper Prevention of Lock Bit Modification", + "CWE-1232": "Improper Lock Behavior After Power State Transition", + "CWE-1233": "Security-Sensitive Hardware Controls with Missing Lock Bit Protection", + "CWE-1234": "Hardware Internal or Debug Modes Allow Override of Locks", + "CWE-1235": "Incorrect Use of Autoboxing and Unboxing for Performance Critical Operations", + "CWE-1236": "Improper Neutralization of Formula Elements in a CSV File", + "CWE-1239": "Improper Zeroization of Hardware Register", + "CWE-124": "Buffer Underwrite ('Buffer Underflow')", + "CWE-1240": "Use of a Cryptographic Primitive with a Risky Implementation", + "CWE-1241": "Use of Predictable Algorithm in Random Number Generator", + "CWE-1242": "Inclusion of Undocumented Features or Chicken Bits", + "CWE-1243": "Sensitive Non-Volatile Information Not Protected During Debug", + "CWE-1244": "Internal Asset Exposed to Unsafe Debug Access Level or State", + "CWE-1245": "Improper Finite State Machines (FSMs) in Hardware Logic", + "CWE-1246": "Improper Write Handling in Limited-write Non-Volatile Memories", + "CWE-1247": "Improper Protection Against Voltage and Clock Glitches", + "CWE-1248": "Semiconductor Defects in Hardware Logic with Security-Sensitive Implications", + "CWE-1249": "Application-Level Admin Tool with Inconsistent View of Underlying Operating System", + "CWE-125": "Out-of-bounds Read", + "CWE-1250": "Improper Preservation of Consistency Between Independent Representations of Shared State", + "CWE-1251": "Mirrored Regions with Different Values", + "CWE-1252": "CPU Hardware Not Configured to Support Exclusivity of Write and Execute Operations", + "CWE-1253": "Incorrect Selection of Fuse Values", + "CWE-1254": "Incorrect Comparison Logic Granularity", + "CWE-1255": "Comparison Logic is Vulnerable to Power Side-Channel Attacks", + "CWE-1256": "Improper Restriction of Software Interfaces to Hardware Features", + "CWE-1257": "Improper Access Control Applied to Mirrored or Aliased Memory Regions", + "CWE-1258": "Exposure of Sensitive System Information Due to Uncleared Debug Information", + "CWE-1259": "Improper Restriction of Security Token Assignment", + "CWE-126": "Buffer Over-read", + "CWE-1260": "Improper Handling of Overlap Between Protected Memory Ranges", + "CWE-1261": "Improper Handling of Single Event Upsets", + "CWE-1262": "Improper Access Control for Register Interface", + "CWE-1263": "Improper Physical Access Control", + "CWE-1264": "Hardware Logic with Insecure De-Synchronization between Control and Data Channels", + "CWE-1265": "Unintended Reentrant Invocation of Non-reentrant Code Via Nested Calls", + "CWE-1266": "Improper Scrubbing of Sensitive Data from Decommissioned Device", + "CWE-1267": "Policy Uses Obsolete Encoding", + "CWE-1268": "Policy Privileges are not Assigned Consistently Between Control and Data Agents", + "CWE-1269": "Product Released in Non-Release Configuration", + "CWE-127": "Buffer Under-read", + "CWE-1270": "Generation of Incorrect Security Tokens", + "CWE-1271": "Uninitialized Value on Reset for Registers Holding Security Settings", + "CWE-1272": "Sensitive Information Uncleared Before Debug/Power State Transition", + "CWE-1273": "Device Unlock Credential Sharing", + "CWE-1274": "Improper Access Control for Volatile Memory Containing Boot Code", + "CWE-1275": "Sensitive Cookie with Improper SameSite Attribute", + "CWE-1276": "Hardware Child Block Incorrectly Connected to Parent System", + "CWE-1277": "Firmware Not Updateable", + "CWE-1278": "Missing Protection Against Hardware Reverse Engineering Using Integrated Circuit (IC) Imaging Techniques", + "CWE-1279": "Cryptographic Operations are run Before Supporting Units are Ready", + "CWE-128": "Wrap-around Error", + "CWE-1280": "Access Control Check Implemented After Asset is Accessed", + "CWE-1281": "Sequence of Processor Instructions Leads to Unexpected Behavior", + "CWE-1282": "Assumed-Immutable Data is Stored in Writable Memory", + "CWE-1283": "Mutable Attestation or Measurement Reporting Data", + "CWE-1284": "Improper Validation of Specified Quantity in Input", + "CWE-1285": "Improper Validation of Specified Index, Position, or Offset in Input", + "CWE-1286": "Improper Validation of Syntactic Correctness of Input", + "CWE-1287": "Improper Validation of Specified Type of Input", + "CWE-1288": "Improper Validation of Consistency within Input", + "CWE-1289": "Improper Validation of Unsafe Equivalence in Input", + "CWE-129": "Improper Validation of Array Index", + "CWE-1290": "Incorrect Decoding of Security Identifiers", + "CWE-1291": "Public Key Re-Use for Signing both Debug and Production Code", + "CWE-1292": "Incorrect Conversion of Security Identifiers", + "CWE-1293": "Missing Source Correlation of Multiple Independent Data", + "CWE-1294": "Insecure Security Identifier Mechanism", + "CWE-1295": "Debug Messages Revealing Unnecessary Information", + "CWE-1296": "Incorrect Chaining or Granularity of Debug Components", + "CWE-1297": "Unprotected Confidential Information on Device is Accessible by OSAT Vendors", + "CWE-1298": "Hardware Logic Contains Race Conditions", + "CWE-1299": "Missing Protection Mechanism for Alternate Hardware Interface", + "CWE-13": "ASP.NET Misconfiguration: Password in Configuration File", + "CWE-130": "Improper Handling of Length Parameter Inconsistency", + "CWE-1300": "Improper Protection of Physical Side Channels", + "CWE-1301": "Insufficient or Incomplete Data Removal within Hardware Component", + "CWE-1302": "Missing Source Identifier in Entity Transactions on a System-On-Chip (SOC)", + "CWE-1303": "Non-Transparent Sharing of Microarchitectural Resources", + "CWE-1304": "Improperly Preserved Integrity of Hardware Configuration State During a Power Save/Restore Operation", + "CWE-131": "Incorrect Calculation of Buffer Size", + "CWE-1310": "Missing Ability to Patch ROM Code", + "CWE-1311": "Improper Translation of Security Attributes by Fabric Bridge", + "CWE-1312": "Missing Protection for Mirrored Regions in On-Chip Fabric Firewall", + "CWE-1313": "Hardware Allows Activation of Test or Debug Logic at Runtime", + "CWE-1314": "Missing Write Protection for Parametric Data Values", + "CWE-1315": "Improper Setting of Bus Controlling Capability in Fabric End-point", + "CWE-1316": "Fabric-Address Map Allows Programming of Unwarranted Overlaps of Protected and Unprotected Ranges", + "CWE-1317": "Improper Access Control in Fabric Bridge", + "CWE-1318": "Missing Support for Security Features in On-chip Fabrics or Buses", + "CWE-1319": "Improper Protection against Electromagnetic Fault Injection (EM-FI)", + "CWE-132": "DEPRECATED: Miscalculated Null Termination", + "CWE-1320": "Improper Protection for Outbound Error Messages and Alert Signals", + "CWE-1321": "Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')", + "CWE-1322": "Use of Blocking Code in Single-threaded, Non-blocking Context", + "CWE-1323": "Improper Management of Sensitive Trace Data", + "CWE-1324": "DEPRECATED: Sensitive Information Accessible by Physical Probing of JTAG Interface", + "CWE-1325": "Improperly Controlled Sequential Memory Allocation", + "CWE-1326": "Missing Immutable Root of Trust in Hardware", + "CWE-1327": "Binding to an Unrestricted IP Address", + "CWE-1328": "Security Version Number Mutable to Older Versions", + "CWE-1329": "Reliance on Component That is Not Updateable", + "CWE-1330": "Remanent Data Readable after Memory Erase", + "CWE-1331": "Improper Isolation of Shared Resources in Network On Chip (NoC)", + "CWE-1332": "Improper Handling of Faults that Lead to Instruction Skips", + "CWE-1333": "Inefficient Regular Expression Complexity", + "CWE-1334": "Unauthorized Error Injection Can Degrade Hardware Redundancy", + "CWE-1335": "Incorrect Bitwise Shift of Integer", + "CWE-1336": "Improper Neutralization of Special Elements Used in a Template Engine", + "CWE-1338": "Improper Protections Against Hardware Overheating", + "CWE-1339": "Insufficient Precision or Accuracy of a Real Number", + "CWE-134": "Use of Externally-Controlled Format String", + "CWE-1341": "Multiple Releases of Same Resource or Handle", + "CWE-1342": "Information Exposure through Microarchitectural State after Transient Execution", + "CWE-135": "Incorrect Calculation of Multi-Byte String Length", + "CWE-1351": "Improper Handling of Hardware Behavior in Exceptionally Cold Environments", + "CWE-1357": "Reliance on Insufficiently Trustworthy Component", + "CWE-138": "Improper Neutralization of Special Elements", + "CWE-1384": "Improper Handling of Physical or Environmental Conditions", + "CWE-1385": "Missing Origin Validation in WebSockets", + "CWE-1386": "Insecure Operation on Windows Junction / Mount Point", + "CWE-1389": "Incorrect Parsing of Numbers with Different Radices", + "CWE-1390": "Weak Authentication", + "CWE-1391": "Use of Weak Credentials", + "CWE-1392": "Use of Default Credentials", + "CWE-1393": "Use of Default Password", + "CWE-1394": "Use of Default Cryptographic Key", + "CWE-1395": "Dependency on Vulnerable Third-Party Component", + "CWE-14": "Compiler Removal of Code to Clear Buffers", + "CWE-140": "Improper Neutralization of Delimiters", + "CWE-141": "Improper Neutralization of Parameter/Argument Delimiters", + "CWE-1419": "Incorrect Initialization of Resource", + "CWE-142": "Improper Neutralization of Value Delimiters", + "CWE-1420": "Exposure of Sensitive Information during Transient Execution", + "CWE-1421": "Exposure of Sensitive Information in Shared Microarchitectural Structures during Transient Execution", + "CWE-1422": "Exposure of Sensitive Information caused by Incorrect Data Forwarding during Transient Execution", + "CWE-1423": "Exposure of Sensitive Information caused by Shared Microarchitectural Predictor State that Influences Transient Execution", + "CWE-1426": "Improper Validation of Generative AI Output", + "CWE-1427": "Improper Neutralization of Input Used for LLM Prompting", + "CWE-1428": "Reliance on HTTP instead of HTTPS", + "CWE-1429": "Missing Security-Relevant Feedback for Unexecuted Operations in Hardware Interface", + "CWE-143": "Improper Neutralization of Record Delimiters", + "CWE-1431": "Driving Intermediate Cryptographic State/Results to Hardware Module Outputs", + "CWE-1434": "Insecure Setting of Generative AI/ML Model Inference Parameters", + "CWE-144": "Improper Neutralization of Line Delimiters", + "CWE-145": "Improper Neutralization of Section Delimiters", + "CWE-146": "Improper Neutralization of Expression/Command Delimiters", + "CWE-147": "Improper Neutralization of Input Terminators", + "CWE-148": "Improper Neutralization of Input Leaders", + "CWE-149": "Improper Neutralization of Quoting Syntax", + "CWE-15": "External Control of System or Configuration Setting", + "CWE-150": "Improper Neutralization of Escape, Meta, or Control Sequences", + "CWE-151": "Improper Neutralization of Comment Delimiters", + "CWE-152": "Improper Neutralization of Macro Symbols", + "CWE-153": "Improper Neutralization of Substitution Characters", + "CWE-154": "Improper Neutralization of Variable Name Delimiters", + "CWE-155": "Improper Neutralization of Wildcards or Matching Symbols", + "CWE-156": "Improper Neutralization of Whitespace", + "CWE-157": "Failure to Sanitize Paired Delimiters", + "CWE-158": "Improper Neutralization of Null Byte or NUL Character", + "CWE-159": "Improper Handling of Invalid Use of Special Elements", + "CWE-160": "Improper Neutralization of Leading Special Elements", + "CWE-161": "Improper Neutralization of Multiple Leading Special Elements", + "CWE-162": "Improper Neutralization of Trailing Special Elements", + "CWE-163": "Improper Neutralization of Multiple Trailing Special Elements", + "CWE-164": "Improper Neutralization of Internal Special Elements", + "CWE-165": "Improper Neutralization of Multiple Internal Special Elements", + "CWE-166": "Improper Handling of Missing Special Element", + "CWE-167": "Improper Handling of Additional Special Element", + "CWE-168": "Improper Handling of Inconsistent Special Elements", + "CWE-170": "Improper Null Termination", + "CWE-172": "Encoding Error", + "CWE-173": "Improper Handling of Alternate Encoding", + "CWE-174": "Double Decoding of the Same Data", + "CWE-175": "Improper Handling of Mixed Encoding", + "CWE-176": "Improper Handling of Unicode Encoding", + "CWE-177": "Improper Handling of URL Encoding (Hex Encoding)", + "CWE-178": "Improper Handling of Case Sensitivity", + "CWE-179": "Incorrect Behavior Order: Early Validation", + "CWE-180": "Incorrect Behavior Order: Validate Before Canonicalize", + "CWE-181": "Incorrect Behavior Order: Validate Before Filter", + "CWE-182": "Collapse of Data into Unsafe Value", + "CWE-183": "Permissive List of Allowed Inputs", + "CWE-184": "Incomplete List of Disallowed Inputs", + "CWE-185": "Incorrect Regular Expression", + "CWE-186": "Overly Restrictive Regular Expression", + "CWE-187": "Partial String Comparison", + "CWE-188": "Reliance on Data/Memory Layout", + "CWE-190": "Integer Overflow or Wraparound", + "CWE-191": "Integer Underflow (Wrap or Wraparound)", + "CWE-192": "Integer Coercion Error", + "CWE-193": "Off-by-one Error", + "CWE-194": "Unexpected Sign Extension", + "CWE-195": "Signed to Unsigned Conversion Error", + "CWE-196": "Unsigned to Signed Conversion Error", + "CWE-197": "Numeric Truncation Error", + "CWE-198": "Use of Incorrect Byte Ordering", + "CWE-20": "Improper Input Validation", + "CWE-200": "Exposure of Sensitive Information to an Unauthorized Actor", + "CWE-201": "Insertion of Sensitive Information Into Sent Data", + "CWE-202": "Exposure of Sensitive Information Through Data Queries", + "CWE-203": "Observable Discrepancy", + "CWE-204": "Observable Response Discrepancy", + "CWE-205": "Observable Behavioral Discrepancy", + "CWE-206": "Observable Internal Behavioral Discrepancy", + "CWE-207": "Observable Behavioral Discrepancy With Equivalent Products", + "CWE-208": "Observable Timing Discrepancy", + "CWE-209": "Generation of Error Message Containing Sensitive Information", + "CWE-210": "Self-generated Error Message Containing Sensitive Information", + "CWE-211": "Externally-Generated Error Message Containing Sensitive Information", + "CWE-212": "Improper Removal of Sensitive Information Before Storage or Transfer", + "CWE-213": "Exposure of Sensitive Information Due to Incompatible Policies", + "CWE-214": "Invocation of Process Using Visible Sensitive Information", + "CWE-215": "Insertion of Sensitive Information Into Debugging Code", + "CWE-216": "DEPRECATED: Containment Errors (Container Errors)", + "CWE-217": "DEPRECATED: Failure to Protect Stored Data from Modification", + "CWE-218": "DEPRECATED: Failure to provide confidentiality for stored data", + "CWE-219": "Storage of File with Sensitive Data Under Web Root", + "CWE-22": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", + "CWE-220": "Storage of File With Sensitive Data Under FTP Root", + "CWE-221": "Information Loss or Omission", + "CWE-222": "Truncation of Security-relevant Information", + "CWE-223": "Omission of Security-relevant Information", + "CWE-224": "Obscured Security-relevant Information by Alternate Name", + "CWE-225": "DEPRECATED: General Information Management Problems", + "CWE-226": "Sensitive Information in Resource Not Removed Before Reuse", + "CWE-228": "Improper Handling of Syntactically Invalid Structure", + "CWE-229": "Improper Handling of Values", + "CWE-23": "Relative Path Traversal", + "CWE-230": "Improper Handling of Missing Values", + "CWE-231": "Improper Handling of Extra Values", + "CWE-232": "Improper Handling of Undefined Values", + "CWE-233": "Improper Handling of Parameters", + "CWE-234": "Failure to Handle Missing Parameter", + "CWE-235": "Improper Handling of Extra Parameters", + "CWE-236": "Improper Handling of Undefined Parameters", + "CWE-237": "Improper Handling of Structural Elements", + "CWE-238": "Improper Handling of Incomplete Structural Elements", + "CWE-239": "Failure to Handle Incomplete Element", + "CWE-24": "Path Traversal: '../filedir'", + "CWE-240": "Improper Handling of Inconsistent Structural Elements", + "CWE-241": "Improper Handling of Unexpected Data Type", + "CWE-242": "Use of Inherently Dangerous Function", + "CWE-243": "Creation of chroot Jail Without Changing Working Directory", + "CWE-244": "Improper Clearing of Heap Memory Before Release ('Heap Inspection')", + "CWE-245": "J2EE Bad Practices: Direct Management of Connections", + "CWE-246": "J2EE Bad Practices: Direct Use of Sockets", + "CWE-247": "DEPRECATED: Reliance on DNS Lookups in a Security Decision", + "CWE-248": "Uncaught Exception", + "CWE-249": "DEPRECATED: Often Misused: Path Manipulation", + "CWE-25": "Path Traversal: '/../filedir'", + "CWE-250": "Execution with Unnecessary Privileges", + "CWE-252": "Unchecked Return Value", + "CWE-253": "Incorrect Check of Function Return Value", + "CWE-256": "Plaintext Storage of a Password", + "CWE-257": "Storing Passwords in a Recoverable Format", + "CWE-258": "Empty Password in Configuration File", + "CWE-259": "Use of Hard-coded Password", + "CWE-26": "Path Traversal: '/dir/../filename'", + "CWE-260": "Password in Configuration File", + "CWE-261": "Weak Encoding for Password", + "CWE-262": "Not Using Password Aging", + "CWE-263": "Password Aging with Long Expiration", + "CWE-266": "Incorrect Privilege Assignment", + "CWE-267": "Privilege Defined With Unsafe Actions", + "CWE-268": "Privilege Chaining", + "CWE-269": "Improper Privilege Management", + "CWE-27": "Path Traversal: 'dir/../../filename'", + "CWE-270": "Privilege Context Switching Error", + "CWE-271": "Privilege Dropping / Lowering Errors", + "CWE-272": "Least Privilege Violation", + "CWE-273": "Improper Check for Dropped Privileges", + "CWE-274": "Improper Handling of Insufficient Privileges", + "CWE-276": "Incorrect Default Permissions", + "CWE-277": "Insecure Inherited Permissions", + "CWE-278": "Insecure Preserved Inherited Permissions", + "CWE-279": "Incorrect Execution-Assigned Permissions", + "CWE-28": "Path Traversal: '..\\filedir'", + "CWE-280": "Improper Handling of Insufficient Permissions or Privileges", + "CWE-281": "Improper Preservation of Permissions", + "CWE-282": "Improper Ownership Management", + "CWE-283": "Unverified Ownership", + "CWE-284": "Improper Access Control", + "CWE-285": "Improper Authorization", + "CWE-286": "Incorrect User Management", + "CWE-287": "Improper Authentication", + "CWE-288": "Authentication Bypass Using an Alternate Path or Channel", + "CWE-289": "Authentication Bypass by Alternate Name", + "CWE-29": "Path Traversal: '\\..\\filename'", + "CWE-290": "Authentication Bypass by Spoofing", + "CWE-291": "Reliance on IP Address for Authentication", + "CWE-292": "DEPRECATED: Trusting Self-reported DNS Name", + "CWE-293": "Using Referer Field for Authentication", + "CWE-294": "Authentication Bypass by Capture-replay", + "CWE-295": "Improper Certificate Validation", + "CWE-296": "Improper Following of a Certificate's Chain of Trust", + "CWE-297": "Improper Validation of Certificate with Host Mismatch", + "CWE-298": "Improper Validation of Certificate Expiration", + "CWE-299": "Improper Check for Certificate Revocation", + "CWE-30": "Path Traversal: '\\dir\\..\\filename'", + "CWE-300": "Channel Accessible by Non-Endpoint", + "CWE-301": "Reflection Attack in an Authentication Protocol", + "CWE-302": "Authentication Bypass by Assumed-Immutable Data", + "CWE-303": "Incorrect Implementation of Authentication Algorithm", + "CWE-304": "Missing Critical Step in Authentication", + "CWE-305": "Authentication Bypass by Primary Weakness", + "CWE-306": "Missing Authentication for Critical Function", + "CWE-307": "Improper Restriction of Excessive Authentication Attempts", + "CWE-308": "Use of Single-factor Authentication", + "CWE-309": "Use of Password System for Primary Authentication", + "CWE-31": "Path Traversal: 'dir\\..\\..\\filename'", + "CWE-311": "Missing Encryption of Sensitive Data", + "CWE-312": "Cleartext Storage of Sensitive Information", + "CWE-313": "Cleartext Storage in a File or on Disk", + "CWE-314": "Cleartext Storage in the Registry", + "CWE-315": "Cleartext Storage of Sensitive Information in a Cookie", + "CWE-316": "Cleartext Storage of Sensitive Information in Memory", + "CWE-317": "Cleartext Storage of Sensitive Information in GUI", + "CWE-318": "Cleartext Storage of Sensitive Information in Executable", + "CWE-319": "Cleartext Transmission of Sensitive Information", + "CWE-32": "Path Traversal: '...' (Triple Dot)", + "CWE-321": "Use of Hard-coded Cryptographic Key", + "CWE-322": "Key Exchange without Entity Authentication", + "CWE-323": "Reusing a Nonce, Key Pair in Encryption", + "CWE-324": "Use of a Key Past its Expiration Date", + "CWE-325": "Missing Cryptographic Step", + "CWE-326": "Inadequate Encryption Strength", + "CWE-327": "Use of a Broken or Risky Cryptographic Algorithm", + "CWE-328": "Use of Weak Hash", + "CWE-329": "Generation of Predictable IV with CBC Mode", + "CWE-33": "Path Traversal: '....' (Multiple Dot)", + "CWE-330": "Use of Insufficiently Random Values", + "CWE-331": "Insufficient Entropy", + "CWE-332": "Insufficient Entropy in PRNG", + "CWE-333": "Improper Handling of Insufficient Entropy in TRNG", + "CWE-334": "Small Space of Random Values", + "CWE-335": "Incorrect Usage of Seeds in Pseudo-Random Number Generator (PRNG)", + "CWE-336": "Same Seed in Pseudo-Random Number Generator (PRNG)", + "CWE-337": "Predictable Seed in Pseudo-Random Number Generator (PRNG)", + "CWE-338": "Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)", + "CWE-339": "Small Seed Space in PRNG", + "CWE-34": "Path Traversal: '....//'", + "CWE-340": "Generation of Predictable Numbers or Identifiers", + "CWE-341": "Predictable from Observable State", + "CWE-342": "Predictable Exact Value from Previous Values", + "CWE-343": "Predictable Value Range from Previous Values", + "CWE-344": "Use of Invariant Value in Dynamically Changing Context", + "CWE-345": "Insufficient Verification of Data Authenticity", + "CWE-346": "Origin Validation Error", + "CWE-347": "Improper Verification of Cryptographic Signature", + "CWE-348": "Use of Less Trusted Source", + "CWE-349": "Acceptance of Extraneous Untrusted Data With Trusted Data", + "CWE-35": "Path Traversal: '.../...//'", + "CWE-350": "Reliance on Reverse DNS Resolution for a Security-Critical Action", + "CWE-351": "Insufficient Type Distinction", + "CWE-352": "Cross-Site Request Forgery (CSRF)", + "CWE-353": "Missing Support for Integrity Check", + "CWE-354": "Improper Validation of Integrity Check Value", + "CWE-356": "Product UI does not Warn User of Unsafe Actions", + "CWE-357": "Insufficient UI Warning of Dangerous Operations", + "CWE-358": "Improperly Implemented Security Check for Standard", + "CWE-359": "Exposure of Private Personal Information to an Unauthorized Actor", + "CWE-36": "Absolute Path Traversal", + "CWE-360": "Trust of System Event Data", + "CWE-362": "Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')", + "CWE-363": "Race Condition Enabling Link Following", + "CWE-364": "Signal Handler Race Condition", + "CWE-365": "DEPRECATED: Race Condition in Switch", + "CWE-366": "Race Condition within a Thread", + "CWE-367": "Time-of-check Time-of-use (TOCTOU) Race Condition", + "CWE-368": "Context Switching Race Condition", + "CWE-369": "Divide By Zero", + "CWE-37": "Path Traversal: '/absolute/pathname/here'", + "CWE-370": "Missing Check for Certificate Revocation after Initial Check", + "CWE-372": "Incomplete Internal State Distinction", + "CWE-373": "DEPRECATED: State Synchronization Error", + "CWE-374": "Passing Mutable Objects to an Untrusted Method", + "CWE-375": "Returning a Mutable Object to an Untrusted Caller", + "CWE-377": "Insecure Temporary File", + "CWE-378": "Creation of Temporary File With Insecure Permissions", + "CWE-379": "Creation of Temporary File in Directory with Insecure Permissions", + "CWE-38": "Path Traversal: '\\absolute\\pathname\\here'", + "CWE-382": "J2EE Bad Practices: Use of System.exit()", + "CWE-383": "J2EE Bad Practices: Direct Use of Threads", + "CWE-384": "Session Fixation", + "CWE-385": "Covert Timing Channel", + "CWE-386": "Symbolic Name not Mapping to Correct Object", + "CWE-39": "Path Traversal: 'C:dirname'", + "CWE-390": "Detection of Error Condition Without Action", + "CWE-391": "Unchecked Error Condition", + "CWE-392": "Missing Report of Error Condition", + "CWE-393": "Return of Wrong Status Code", + "CWE-394": "Unexpected Status Code or Return Value", + "CWE-395": "Use of NullPointerException Catch to Detect NULL Pointer Dereference", + "CWE-396": "Declaration of Catch for Generic Exception", + "CWE-397": "Declaration of Throws for Generic Exception", + "CWE-40": "Path Traversal: '\\\\UNC\\share\\name\\' (Windows UNC Share)", + "CWE-400": "Uncontrolled Resource Consumption", + "CWE-401": "Missing Release of Memory after Effective Lifetime", + "CWE-402": "Transmission of Private Resources into a New Sphere ('Resource Leak')", + "CWE-403": "Exposure of File Descriptor to Unintended Control Sphere ('File Descriptor Leak')", + "CWE-404": "Improper Resource Shutdown or Release", + "CWE-405": "Asymmetric Resource Consumption (Amplification)", + "CWE-406": "Insufficient Control of Network Message Volume (Network Amplification)", + "CWE-407": "Inefficient Algorithmic Complexity", + "CWE-408": "Incorrect Behavior Order: Early Amplification", + "CWE-409": "Improper Handling of Highly Compressed Data (Data Amplification)", + "CWE-41": "Improper Resolution of Path Equivalence", + "CWE-410": "Insufficient Resource Pool", + "CWE-412": "Unrestricted Externally Accessible Lock", + "CWE-413": "Improper Resource Locking", + "CWE-414": "Missing Lock Check", + "CWE-415": "Double Free", + "CWE-416": "Use After Free", + "CWE-419": "Unprotected Primary Channel", + "CWE-42": "Path Equivalence: 'filename.' (Trailing Dot)", + "CWE-420": "Unprotected Alternate Channel", + "CWE-421": "Race Condition During Access to Alternate Channel", + "CWE-422": "Unprotected Windows Messaging Channel ('Shatter')", + "CWE-423": "DEPRECATED: Proxied Trusted Channel", + "CWE-424": "Improper Protection of Alternate Path", + "CWE-425": "Direct Request ('Forced Browsing')", + "CWE-426": "Untrusted Search Path", + "CWE-427": "Uncontrolled Search Path Element", + "CWE-428": "Unquoted Search Path or Element", + "CWE-43": "Path Equivalence: 'filename....' (Multiple Trailing Dot)", + "CWE-430": "Deployment of Wrong Handler", + "CWE-431": "Missing Handler", + "CWE-432": "Dangerous Signal Handler not Disabled During Sensitive Operations", + "CWE-433": "Unparsed Raw Web Content Delivery", + "CWE-434": "Unrestricted Upload of File with Dangerous Type", + "CWE-435": "Improper Interaction Between Multiple Correctly-Behaving Entities", + "CWE-436": "Interpretation Conflict", + "CWE-437": "Incomplete Model of Endpoint Features", + "CWE-439": "Behavioral Change in New Version or Environment", + "CWE-44": "Path Equivalence: 'file.name' (Internal Dot)", + "CWE-440": "Expected Behavior Violation", + "CWE-441": "Unintended Proxy or Intermediary ('Confused Deputy')", + "CWE-443": "DEPRECATED: HTTP response splitting", + "CWE-444": "Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')", + "CWE-446": "UI Discrepancy for Security Feature", + "CWE-447": "Unimplemented or Unsupported Feature in UI", + "CWE-448": "Obsolete Feature in UI", + "CWE-449": "The UI Performs the Wrong Action", + "CWE-45": "Path Equivalence: 'file...name' (Multiple Internal Dot)", + "CWE-450": "Multiple Interpretations of UI Input", + "CWE-451": "User Interface (UI) Misrepresentation of Critical Information", + "CWE-453": "Insecure Default Variable Initialization", + "CWE-454": "External Initialization of Trusted Variables or Data Stores", + "CWE-455": "Non-exit on Failed Initialization", + "CWE-456": "Missing Initialization of a Variable", + "CWE-457": "Use of Uninitialized Variable", + "CWE-458": "DEPRECATED: Incorrect Initialization", + "CWE-459": "Incomplete Cleanup", + "CWE-46": "Path Equivalence: 'filename ' (Trailing Space)", + "CWE-460": "Improper Cleanup on Thrown Exception", + "CWE-462": "Duplicate Key in Associative List (Alist)", + "CWE-463": "Deletion of Data Structure Sentinel", + "CWE-464": "Addition of Data Structure Sentinel", + "CWE-466": "Return of Pointer Value Outside of Expected Range", + "CWE-467": "Use of sizeof() on a Pointer Type", + "CWE-468": "Incorrect Pointer Scaling", + "CWE-469": "Use of Pointer Subtraction to Determine Size", + "CWE-47": "Path Equivalence: ' filename' (Leading Space)", + "CWE-470": "Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')", + "CWE-471": "Modification of Assumed-Immutable Data (MAID)", + "CWE-472": "External Control of Assumed-Immutable Web Parameter", + "CWE-473": "PHP External Variable Modification", + "CWE-474": "Use of Function with Inconsistent Implementations", + "CWE-475": "Undefined Behavior for Input to API", + "CWE-476": "NULL Pointer Dereference", + "CWE-477": "Use of Obsolete Function", + "CWE-478": "Missing Default Case in Multiple Condition Expression", + "CWE-479": "Signal Handler Use of a Non-reentrant Function", + "CWE-48": "Path Equivalence: 'file name' (Internal Whitespace)", + "CWE-480": "Use of Incorrect Operator", + "CWE-481": "Assigning instead of Comparing", + "CWE-482": "Comparing instead of Assigning", + "CWE-483": "Incorrect Block Delimitation", + "CWE-484": "Omitted Break Statement in Switch", + "CWE-486": "Comparison of Classes by Name", + "CWE-487": "Reliance on Package-level Scope", + "CWE-488": "Exposure of Data Element to Wrong Session", + "CWE-489": "Active Debug Code", + "CWE-49": "Path Equivalence: 'filename/' (Trailing Slash)", + "CWE-491": "Public cloneable() Method Without Final ('Object Hijack')", + "CWE-492": "Use of Inner Class Containing Sensitive Data", + "CWE-493": "Critical Public Variable Without Final Modifier", + "CWE-494": "Download of Code Without Integrity Check", + "CWE-495": "Private Data Structure Returned From A Public Method", + "CWE-496": "Public Data Assigned to Private Array-Typed Field", + "CWE-497": "Exposure of Sensitive System Information to an Unauthorized Control Sphere", + "CWE-498": "Cloneable Class Containing Sensitive Information", + "CWE-499": "Serializable Class Containing Sensitive Data", + "CWE-5": "J2EE Misconfiguration: Data Transmission Without Encryption", + "CWE-50": "Path Equivalence: '//multiple/leading/slash'", + "CWE-500": "Public Static Field Not Marked Final", + "CWE-501": "Trust Boundary Violation", + "CWE-502": "Deserialization of Untrusted Data", + "CWE-506": "Embedded Malicious Code", + "CWE-507": "Trojan Horse", + "CWE-508": "Non-Replicating Malicious Code", + "CWE-509": "Replicating Malicious Code (Virus or Worm)", + "CWE-51": "Path Equivalence: '/multiple//internal/slash'", + "CWE-510": "Trapdoor", + "CWE-511": "Logic/Time Bomb", + "CWE-512": "Spyware", + "CWE-514": "Covert Channel", + "CWE-515": "Covert Storage Channel", + "CWE-516": "DEPRECATED: Covert Timing Channel", + "CWE-52": "Path Equivalence: '/multiple/trailing/slash//'", + "CWE-520": ".NET Misconfiguration: Use of Impersonation", + "CWE-521": "Weak Password Requirements", + "CWE-522": "Insufficiently Protected Credentials", + "CWE-523": "Unprotected Transport of Credentials", + "CWE-524": "Use of Cache Containing Sensitive Information", + "CWE-525": "Use of Web Browser Cache Containing Sensitive Information", + "CWE-526": "Cleartext Storage of Sensitive Information in an Environment Variable", + "CWE-527": "Exposure of Version-Control Repository to an Unauthorized Control Sphere", + "CWE-528": "Exposure of Core Dump File to an Unauthorized Control Sphere", + "CWE-529": "Exposure of Access Control List Files to an Unauthorized Control Sphere", + "CWE-53": "Path Equivalence: '\\multiple\\\\internal\\backslash'", + "CWE-530": "Exposure of Backup File to an Unauthorized Control Sphere", + "CWE-531": "Inclusion of Sensitive Information in Test Code", + "CWE-532": "Insertion of Sensitive Information into Log File", + "CWE-533": "DEPRECATED: Information Exposure Through Server Log Files", + "CWE-534": "DEPRECATED: Information Exposure Through Debug Log Files", + "CWE-535": "Exposure of Information Through Shell Error Message", + "CWE-536": "Servlet Runtime Error Message Containing Sensitive Information", + "CWE-537": "Java Runtime Error Message Containing Sensitive Information", + "CWE-538": "Insertion of Sensitive Information into Externally-Accessible File or Directory", + "CWE-539": "Use of Persistent Cookies Containing Sensitive Information", + "CWE-54": "Path Equivalence: 'filedir\\' (Trailing Backslash)", + "CWE-540": "Inclusion of Sensitive Information in Source Code", + "CWE-541": "Inclusion of Sensitive Information in an Include File", + "CWE-542": "DEPRECATED: Information Exposure Through Cleanup Log Files", + "CWE-543": "Use of Singleton Pattern Without Synchronization in a Multithreaded Context", + "CWE-544": "Missing Standardized Error Handling Mechanism", + "CWE-545": "DEPRECATED: Use of Dynamic Class Loading", + "CWE-546": "Suspicious Comment", + "CWE-547": "Use of Hard-coded, Security-relevant Constants", + "CWE-548": "Exposure of Information Through Directory Listing", + "CWE-549": "Missing Password Field Masking", + "CWE-55": "Path Equivalence: '/./' (Single Dot Directory)", + "CWE-550": "Server-generated Error Message Containing Sensitive Information", + "CWE-551": "Incorrect Behavior Order: Authorization Before Parsing and Canonicalization", + "CWE-552": "Files or Directories Accessible to External Parties", + "CWE-553": "Command Shell in Externally Accessible Directory", + "CWE-554": "ASP.NET Misconfiguration: Not Using Input Validation Framework", + "CWE-555": "J2EE Misconfiguration: Plaintext Password in Configuration File", + "CWE-556": "ASP.NET Misconfiguration: Use of Identity Impersonation", + "CWE-558": "Use of getlogin() in Multithreaded Application", + "CWE-56": "Path Equivalence: 'filedir*' (Wildcard)", + "CWE-560": "Use of umask() with chmod-style Argument", + "CWE-561": "Dead Code", + "CWE-562": "Return of Stack Variable Address", + "CWE-563": "Assignment to Variable without Use", + "CWE-564": "SQL Injection: Hibernate", + "CWE-565": "Reliance on Cookies without Validation and Integrity Checking", + "CWE-566": "Authorization Bypass Through User-Controlled SQL Primary Key", + "CWE-567": "Unsynchronized Access to Shared Data in a Multithreaded Context", + "CWE-568": "finalize() Method Without super.finalize()", + "CWE-57": "Path Equivalence: 'fakedir/../realdir/filename'", + "CWE-570": "Expression is Always False", + "CWE-571": "Expression is Always True", + "CWE-572": "Call to Thread run() instead of start()", + "CWE-573": "Improper Following of Specification by Caller", + "CWE-574": "EJB Bad Practices: Use of Synchronization Primitives", + "CWE-575": "EJB Bad Practices: Use of AWT Swing", + "CWE-576": "EJB Bad Practices: Use of Java I/O", + "CWE-577": "EJB Bad Practices: Use of Sockets", + "CWE-578": "EJB Bad Practices: Use of Class Loader", + "CWE-579": "J2EE Bad Practices: Non-serializable Object Stored in Session", + "CWE-58": "Path Equivalence: Windows 8.3 Filename", + "CWE-580": "clone() Method Without super.clone()", + "CWE-581": "Object Model Violation: Just One of Equals and Hashcode Defined", + "CWE-582": "Array Declared Public, Final, and Static", + "CWE-583": "finalize() Method Declared Public", + "CWE-584": "Return Inside Finally Block", + "CWE-585": "Empty Synchronized Block", + "CWE-586": "Explicit Call to Finalize()", + "CWE-587": "Assignment of a Fixed Address to a Pointer", + "CWE-588": "Attempt to Access Child of a Non-structure Pointer", + "CWE-589": "Call to Non-ubiquitous API", + "CWE-59": "Improper Link Resolution Before File Access ('Link Following')", + "CWE-590": "Free of Memory not on the Heap", + "CWE-591": "Sensitive Data Storage in Improperly Locked Memory", + "CWE-592": "DEPRECATED: Authentication Bypass Issues", + "CWE-593": "Authentication Bypass: OpenSSL CTX Object Modified after SSL Objects are Created", + "CWE-594": "J2EE Framework: Saving Unserializable Objects to Disk", + "CWE-595": "Comparison of Object References Instead of Object Contents", + "CWE-596": "DEPRECATED: Incorrect Semantic Object Comparison", + "CWE-597": "Use of Wrong Operator in String Comparison", + "CWE-598": "Use of GET Request Method With Sensitive Query Strings", + "CWE-599": "Missing Validation of OpenSSL Certificate", + "CWE-6": "J2EE Misconfiguration: Insufficient Session-ID Length", + "CWE-600": "Uncaught Exception in Servlet", + "CWE-601": "URL Redirection to Untrusted Site ('Open Redirect')", + "CWE-602": "Client-Side Enforcement of Server-Side Security", + "CWE-603": "Use of Client-Side Authentication", + "CWE-605": "Multiple Binds to the Same Port", + "CWE-606": "Unchecked Input for Loop Condition", + "CWE-607": "Public Static Final Field References Mutable Object", + "CWE-608": "Struts: Non-private Field in ActionForm Class", + "CWE-609": "Double-Checked Locking", + "CWE-61": "UNIX Symbolic Link (Symlink) Following", + "CWE-610": "Externally Controlled Reference to a Resource in Another Sphere", + "CWE-611": "Improper Restriction of XML External Entity Reference", + "CWE-612": "Improper Authorization of Index Containing Sensitive Information", + "CWE-613": "Insufficient Session Expiration", + "CWE-614": "Sensitive Cookie in HTTPS Session Without 'Secure' Attribute", + "CWE-615": "Inclusion of Sensitive Information in Source Code Comments", + "CWE-616": "Incomplete Identification of Uploaded File Variables (PHP)", + "CWE-617": "Reachable Assertion", + "CWE-618": "Exposed Unsafe ActiveX Method", + "CWE-619": "Dangling Database Cursor ('Cursor Injection')", + "CWE-62": "UNIX Hard Link", + "CWE-620": "Unverified Password Change", + "CWE-621": "Variable Extraction Error", + "CWE-622": "Improper Validation of Function Hook Arguments", + "CWE-623": "Unsafe ActiveX Control Marked Safe For Scripting", + "CWE-624": "Executable Regular Expression Error", + "CWE-625": "Permissive Regular Expression", + "CWE-626": "Null Byte Interaction Error (Poison Null Byte)", + "CWE-627": "Dynamic Variable Evaluation", + "CWE-628": "Function Call with Incorrectly Specified Arguments", + "CWE-636": "Not Failing Securely ('Failing Open')", + "CWE-637": "Unnecessary Complexity in Protection Mechanism (Not Using 'Economy of Mechanism')", + "CWE-638": "Not Using Complete Mediation", + "CWE-639": "Authorization Bypass Through User-Controlled Key", + "CWE-64": "Windows Shortcut Following (.LNK)", + "CWE-640": "Weak Password Recovery Mechanism for Forgotten Password", + "CWE-641": "Improper Restriction of Names for Files and Other Resources", + "CWE-642": "External Control of Critical State Data", + "CWE-643": "Improper Neutralization of Data within XPath Expressions ('XPath Injection')", + "CWE-644": "Improper Neutralization of HTTP Headers for Scripting Syntax", + "CWE-645": "Overly Restrictive Account Lockout Mechanism", + "CWE-646": "Reliance on File Name or Extension of Externally-Supplied File", + "CWE-647": "Use of Non-Canonical URL Paths for Authorization Decisions", + "CWE-648": "Incorrect Use of Privileged APIs", + "CWE-649": "Reliance on Obfuscation or Encryption of Security-Relevant Inputs without Integrity Checking", + "CWE-65": "Windows Hard Link", + "CWE-650": "Trusting HTTP Permission Methods on the Server Side", + "CWE-651": "Exposure of WSDL File Containing Sensitive Information", + "CWE-652": "Improper Neutralization of Data within XQuery Expressions ('XQuery Injection')", + "CWE-653": "Improper Isolation or Compartmentalization", + "CWE-654": "Reliance on a Single Factor in a Security Decision", + "CWE-655": "Insufficient Psychological Acceptability", + "CWE-656": "Reliance on Security Through Obscurity", + "CWE-657": "Violation of Secure Design Principles", + "CWE-66": "Improper Handling of File Names that Identify Virtual Resources", + "CWE-662": "Improper Synchronization", + "CWE-663": "Use of a Non-reentrant Function in a Concurrent Context", + "CWE-664": "Improper Control of a Resource Through its Lifetime", + "CWE-665": "Improper Initialization", + "CWE-666": "Operation on Resource in Wrong Phase of Lifetime", + "CWE-667": "Improper Locking", + "CWE-668": "Exposure of Resource to Wrong Sphere", + "CWE-669": "Incorrect Resource Transfer Between Spheres", + "CWE-67": "Improper Handling of Windows Device Names", + "CWE-670": "Always-Incorrect Control Flow Implementation", + "CWE-671": "Lack of Administrator Control over Security", + "CWE-672": "Operation on a Resource after Expiration or Release", + "CWE-673": "External Influence of Sphere Definition", + "CWE-674": "Uncontrolled Recursion", + "CWE-675": "Multiple Operations on Resource in Single-Operation Context", + "CWE-676": "Use of Potentially Dangerous Function", + "CWE-680": "Integer Overflow to Buffer Overflow", + "CWE-681": "Incorrect Conversion between Numeric Types", + "CWE-682": "Incorrect Calculation", + "CWE-683": "Function Call With Incorrect Order of Arguments", + "CWE-684": "Incorrect Provision of Specified Functionality", + "CWE-685": "Function Call With Incorrect Number of Arguments", + "CWE-686": "Function Call With Incorrect Argument Type", + "CWE-687": "Function Call With Incorrectly Specified Argument Value", + "CWE-688": "Function Call With Incorrect Variable or Reference as Argument", + "CWE-689": "Permission Race Condition During Resource Copy", + "CWE-69": "Improper Handling of Windows ::DATA Alternate Data Stream", + "CWE-690": "Unchecked Return Value to NULL Pointer Dereference", + "CWE-691": "Insufficient Control Flow Management", + "CWE-692": "Incomplete Denylist to Cross-Site Scripting", + "CWE-693": "Protection Mechanism Failure", + "CWE-694": "Use of Multiple Resources with Duplicate Identifier", + "CWE-695": "Use of Low-Level Functionality", + "CWE-696": "Incorrect Behavior Order", + "CWE-697": "Incorrect Comparison", + "CWE-698": "Execution After Redirect (EAR)", + "CWE-7": "J2EE Misconfiguration: Missing Custom Error Page", + "CWE-703": "Improper Check or Handling of Exceptional Conditions", + "CWE-704": "Incorrect Type Conversion or Cast", + "CWE-705": "Incorrect Control Flow Scoping", + "CWE-706": "Use of Incorrectly-Resolved Name or Reference", + "CWE-707": "Improper Neutralization", + "CWE-708": "Incorrect Ownership Assignment", + "CWE-71": "DEPRECATED: Apple '.DS_Store'", + "CWE-710": "Improper Adherence to Coding Standards", + "CWE-72": "Improper Handling of Apple HFS+ Alternate Data Stream Path", + "CWE-73": "External Control of File Name or Path", + "CWE-732": "Incorrect Permission Assignment for Critical Resource", + "CWE-733": "Compiler Optimization Removal or Modification of Security-critical Code", + "CWE-74": "Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')", + "CWE-749": "Exposed Dangerous Method or Function", + "CWE-75": "Failure to Sanitize Special Elements into a Different Plane (Special Element Injection)", + "CWE-754": "Improper Check for Unusual or Exceptional Conditions", + "CWE-755": "Improper Handling of Exceptional Conditions", + "CWE-756": "Missing Custom Error Page", + "CWE-757": "Selection of Less-Secure Algorithm During Negotiation ('Algorithm Downgrade')", + "CWE-758": "Reliance on Undefined, Unspecified, or Implementation-Defined Behavior", + "CWE-759": "Use of a One-Way Hash without a Salt", + "CWE-76": "Improper Neutralization of Equivalent Special Elements", + "CWE-760": "Use of a One-Way Hash with a Predictable Salt", + "CWE-761": "Free of Pointer not at Start of Buffer", + "CWE-762": "Mismatched Memory Management Routines", + "CWE-763": "Release of Invalid Pointer or Reference", + "CWE-764": "Multiple Locks of a Critical Resource", + "CWE-765": "Multiple Unlocks of a Critical Resource", + "CWE-766": "Critical Data Element Declared Public", + "CWE-767": "Access to Critical Private Variable via Public Method", + "CWE-768": "Incorrect Short Circuit Evaluation", + "CWE-769": "DEPRECATED: Uncontrolled File Descriptor Consumption", + "CWE-77": "Improper Neutralization of Special Elements used in a Command ('Command Injection')", + "CWE-770": "Allocation of Resources Without Limits or Throttling", + "CWE-771": "Missing Reference to Active Allocated Resource", + "CWE-772": "Missing Release of Resource after Effective Lifetime", + "CWE-773": "Missing Reference to Active File Descriptor or Handle", + "CWE-774": "Allocation of File Descriptors or Handles Without Limits or Throttling", + "CWE-775": "Missing Release of File Descriptor or Handle after Effective Lifetime", + "CWE-776": "Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')", + "CWE-777": "Regular Expression without Anchors", + "CWE-778": "Insufficient Logging", + "CWE-779": "Logging of Excessive Data", + "CWE-78": "Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')", + "CWE-780": "Use of RSA Algorithm without OAEP", + "CWE-781": "Improper Address Validation in IOCTL with METHOD_NEITHER I/O Control Code", + "CWE-782": "Exposed IOCTL with Insufficient Access Control", + "CWE-783": "Operator Precedence Logic Error", + "CWE-784": "Reliance on Cookies without Validation and Integrity Checking in a Security Decision", + "CWE-785": "Use of Path Manipulation Function without Maximum-sized Buffer", + "CWE-786": "Access of Memory Location Before Start of Buffer", + "CWE-787": "Out-of-bounds Write", + "CWE-788": "Access of Memory Location After End of Buffer", + "CWE-789": "Memory Allocation with Excessive Size Value", + "CWE-79": "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')", + "CWE-790": "Improper Filtering of Special Elements", + "CWE-791": "Incomplete Filtering of Special Elements", + "CWE-792": "Incomplete Filtering of One or More Instances of Special Elements", + "CWE-793": "Only Filtering One Instance of a Special Element", + "CWE-794": "Incomplete Filtering of Multiple Instances of Special Elements", + "CWE-795": "Only Filtering Special Elements at a Specified Location", + "CWE-796": "Only Filtering Special Elements Relative to a Marker", + "CWE-797": "Only Filtering Special Elements at an Absolute Position", + "CWE-798": "Use of Hard-coded Credentials", + "CWE-799": "Improper Control of Interaction Frequency", + "CWE-8": "J2EE Misconfiguration: Entity Bean Declared Remote", + "CWE-80": "Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)", + "CWE-804": "Guessable CAPTCHA", + "CWE-805": "Buffer Access with Incorrect Length Value", + "CWE-806": "Buffer Access Using Size of Source Buffer", + "CWE-807": "Reliance on Untrusted Inputs in a Security Decision", + "CWE-81": "Improper Neutralization of Script in an Error Message Web Page", + "CWE-82": "Improper Neutralization of Script in Attributes of IMG Tags in a Web Page", + "CWE-820": "Missing Synchronization", + "CWE-821": "Incorrect Synchronization", + "CWE-822": "Untrusted Pointer Dereference", + "CWE-823": "Use of Out-of-range Pointer Offset", + "CWE-824": "Access of Uninitialized Pointer", + "CWE-825": "Expired Pointer Dereference", + "CWE-826": "Premature Release of Resource During Expected Lifetime", + "CWE-827": "Improper Control of Document Type Definition", + "CWE-828": "Signal Handler with Functionality that is not Asynchronous-Safe", + "CWE-829": "Inclusion of Functionality from Untrusted Control Sphere", + "CWE-83": "Improper Neutralization of Script in Attributes in a Web Page", + "CWE-830": "Inclusion of Web Functionality from an Untrusted Source", + "CWE-831": "Signal Handler Function Associated with Multiple Signals", + "CWE-832": "Unlock of a Resource that is not Locked", + "CWE-833": "Deadlock", + "CWE-834": "Excessive Iteration", + "CWE-835": "Loop with Unreachable Exit Condition ('Infinite Loop')", + "CWE-836": "Use of Password Hash Instead of Password for Authentication", + "CWE-837": "Improper Enforcement of a Single, Unique Action", + "CWE-838": "Inappropriate Encoding for Output Context", + "CWE-839": "Numeric Range Comparison Without Minimum Check", + "CWE-84": "Improper Neutralization of Encoded URI Schemes in a Web Page", + "CWE-841": "Improper Enforcement of Behavioral Workflow", + "CWE-842": "Placement of User into Incorrect Group", + "CWE-843": "Access of Resource Using Incompatible Type ('Type Confusion')", + "CWE-85": "Doubled Character XSS Manipulations", + "CWE-86": "Improper Neutralization of Invalid Characters in Identifiers in Web Pages", + "CWE-862": "Missing Authorization", + "CWE-863": "Incorrect Authorization", + "CWE-87": "Improper Neutralization of Alternate XSS Syntax", + "CWE-88": "Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')", + "CWE-89": "Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')", + "CWE-9": "J2EE Misconfiguration: Weak Access Permissions for EJB Methods", + "CWE-90": "Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')", + "CWE-908": "Use of Uninitialized Resource", + "CWE-909": "Missing Initialization of Resource", + "CWE-91": "XML Injection (aka Blind XPath Injection)", + "CWE-910": "Use of Expired File Descriptor", + "CWE-911": "Improper Update of Reference Count", + "CWE-912": "Hidden Functionality", + "CWE-913": "Improper Control of Dynamically-Managed Code Resources", + "CWE-914": "Improper Control of Dynamically-Identified Variables", + "CWE-915": "Improperly Controlled Modification of Dynamically-Determined Object Attributes", + "CWE-916": "Use of Password Hash With Insufficient Computational Effort", + "CWE-917": "Improper Neutralization of Special Elements used in an Expression Language Statement ('Expression Language Injection')", + "CWE-918": "Server-Side Request Forgery (SSRF)", + "CWE-92": "DEPRECATED: Improper Sanitization of Custom Special Characters", + "CWE-920": "Improper Restriction of Power Consumption", + "CWE-921": "Storage of Sensitive Data in a Mechanism without Access Control", + "CWE-922": "Insecure Storage of Sensitive Information", + "CWE-923": "Improper Restriction of Communication Channel to Intended Endpoints", + "CWE-924": "Improper Enforcement of Message Integrity During Transmission in a Communication Channel", + "CWE-925": "Improper Verification of Intent by Broadcast Receiver", + "CWE-926": "Improper Export of Android Application Components", + "CWE-927": "Use of Implicit Intent for Sensitive Communication", + "CWE-93": "Improper Neutralization of CRLF Sequences ('CRLF Injection')", + "CWE-939": "Improper Authorization in Handler for Custom URL Scheme", + "CWE-94": "Improper Control of Generation of Code ('Code Injection')", + "CWE-940": "Improper Verification of Source of a Communication Channel", + "CWE-941": "Incorrectly Specified Destination in a Communication Channel", + "CWE-942": "Permissive Cross-domain Security Policy with Untrusted Domains", + "CWE-943": "Improper Neutralization of Special Elements in Data Query Logic", + "CWE-95": "Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')", + "CWE-96": "Improper Neutralization of Directives in Statically Saved Code ('Static Code Injection')", + "CWE-97": "Improper Neutralization of Server-Side Includes (SSI) Within a Web Page", + "CWE-98": "Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')", + "CWE-99": "Improper Control of Resource Identifiers ('Resource Injection')" +} diff --git a/scripts/gen-advisory b/scripts/gen-advisory new file mode 100644 index 00000000000..cefbf05630c --- /dev/null +++ b/scripts/gen-advisory @@ -0,0 +1,848 @@ +#!/usr/bin/env python3 +"""Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX documents from wolfSSL +CVE Program records (CVE JSON 5.x). + +The CVE record (the authoritative artefact wolfSSL authors as a CNA, e.g. with +Vulnogram and published to cve.org / cvelistV5) supplies the structural facts: +CVE id, title, description, CWE, CVSS, affected/fixed version ranges, +references, credits, and dates. + +What a CVE record does NOT carry in machine-readable form is the VEX +*determination* and the wolfSSL-specific product nuances. Those live in a +small per-CVE overlay (--vex-overlay): + + * state / justification / response / detail -- the VEX determination. + * fixed_versions / remediation -- the mainline fix guidance. + * fips { ... } -- a separate FIPS product entry + (own module version + CMVP certificate, own status, own remediation): + FIPS customers cannot freely upgrade, and many CVEs fall outside the + FIPS module boundary, so FIPS is modelled as a distinct product. + * requires_defines / default_status -- a no-cost hedge: which build + flag gates the vulnerable code. Recorded as an informational note + only; this tool does NOT compute per-build reachability (that would be + a future, safety-critical "build-aware VEX" feature). + +Granularity follows Red Hat's model: per-CVE is the default (the VEX +automation primitive); pass several records to emit one *bundled* per-release +CSAF advisory + one CycloneDX BOM carrying all vulnerabilities[]. + +This mirrors scripts/gen-sbom: pure stdlib, SOURCE_DATE_EPOCH-reproducible, +deterministic UUIDs, fail-rather-than-emit-garbage on malformed input. +""" + +import argparse +import json +import os +import pathlib +import sys +import urllib.request +import uuid +from datetime import datetime, timezone + + +GEN_TOOL_NAME = 'wolfssl-advisory-gen' +GEN_TOOL_VERSION = '0.3' + +_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPTS_DIR.parent + +# Canonical single-source-of-truth for wolfSSL advisories. `make advisory` +# and a bare `gen-advisory` invocation both default to these locations, so the +# tool and the build target are interchangeable. testdata/ is a *separate*, +# frozen copy used only by the test suite (see scripts/testdata/README.md). +DEFAULT_RECORDS_DIR = _REPO_ROOT / 'advisories' / 'records' +DEFAULT_OVERLAY = _REPO_ROOT / 'advisories' / 'vex-overlay.json' +DEFAULT_OUT_DIR = _REPO_ROOT / 'advisories' / 'out' + +# Official CWE id -> name catalogue, shipped alongside this script. CSAF +# mandatory test 6.1.11 requires /vulnerabilities[]/cwe/name to be the *exact* +# MITRE name for the id, so we resolve the name from the catalogue rather than +# trusting the (often differently-cased) free text in the CVE record's +# problemType. Regenerate scripts/cwe-names.json from the official CWE list +# when MITRE publishes a new version. +_CWE_NAMES_PATH = _SCRIPTS_DIR / 'cwe-names.json' + + +def _load_cwe_names(): + try: + with open(_CWE_NAMES_PATH) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + # Absence is non-fatal: gen-advisory simply omits cwe.name (and hence + # the whole cwe object) from CSAF, which keeps the output conformant. + return {} + + +CWE_NAMES = _load_cwe_names() + +WOLFSSL_VENDOR = 'wolfSSL' +WOLFSSL_PUBLISHER = { + 'category': 'vendor', + 'name': 'wolfSSL Inc.', + 'namespace': 'https://www.wolfssl.com', +} +WOLFSSL_ADVISORIES_URL = 'https://www.wolfssl.com/docs/security-vulnerabilities/' + +ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, + 'https://wolfssl.com/advisory/') + +# Severity ordering for picking a bundle's aggregate_severity. +_SEV_RANK = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'MODERATE': 2, 'LOW': 1, + 'NONE': 0} + +# CycloneDX analysis.state -> CSAF product_status bucket for affected products. +# not_affected is handled separately (it also needs a flag justification). +_STATE_TO_BUCKET = { + 'exploitable': 'known_affected', + 'in_triage': 'under_investigation', + 'resolved': 'known_affected', + 'not_affected': 'known_not_affected', +} + +# CycloneDX not_affected justification -> CSAF flag label (same VEX concept). +_JUSTIFICATION_TO_CSAF_FLAG = { + 'code_not_present': 'vulnerable_code_not_present', + 'code_not_reachable': 'vulnerable_code_not_in_execute_path', + 'requires_configuration': 'vulnerable_code_not_in_execute_path', + 'requires_dependency': 'vulnerable_code_not_in_execute_path', + 'requires_environment': 'vulnerable_code_not_in_execute_path', + 'protected_by_compiler': 'inline_mitigations_already_exist', + 'protected_at_perimeter': 'inline_mitigations_already_exist', + 'protected_at_runtime': 'inline_mitigations_already_exist', + 'protected_by_mitigating_control': 'inline_mitigations_already_exist', +} + + +def derived_uuid(*parts): + """Deterministic UUID from joined parts (NUL-separated, no aliasing).""" + return str(uuid.uuid5(ADVISORY_UUID_NAMESPACE, '\x00'.join(parts))) + + +def build_timestamp(): + """(datetime, ISO-8601-Z) honoring SOURCE_DATE_EPOCH for reproducibility.""" + 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') + + +def cpe_for(product, version): + """CPE 2.3 for a wolfSSL product at a version (matches gen-sbom).""" + return f'cpe:2.3:a:wolfssl:{product.lower()}:{version}:*:*:*:*:*:*:*' + + +def purl_for(product, version): + """PURL for a wolfSSL product release (matches gen-sbom: pkg:github).""" + return f'pkg:github/wolfSSL/{product.lower()}@v{version}' + + +# --------------------------------------------------------------------------- # +# CVE record parsing +# --------------------------------------------------------------------------- # + +def load_cve_record(path=None, cve_id=None): + if path: + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read CVE record {path!r}: {e}") + url = f'https://cveawg.mitre.org/api/cve/{cve_id}' + try: + with urllib.request.urlopen(url, timeout=30) as r: + return json.loads(r.read().decode()) + except Exception as e: # noqa: BLE001 - surface any fetch/parse failure + sys.exit(f"ERROR: cannot fetch {url}: {e}") + + +def _cna(record): + try: + return record['containers']['cna'] + except (KeyError, TypeError): + sys.exit("ERROR: CVE record has no containers.cna section") + + +def _best_cvss(metrics): + """Pick the highest-priority CVSS block (v4 > v3.1 > v3.0 > v2). + + Used for the CycloneDX rating (which supports v4) and for the document's + aggregate_severity.""" + for key, ver, method in ( + ('cvssV4_0', 'v4', 'CVSSv4'), + ('cvssV3_1', 'v3', 'CVSSv31'), + ('cvssV3_0', 'v3', 'CVSSv3'), + ('cvssV2_0', 'v2', 'CVSSv2'), + ): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}', + 'cdx_method': method} + return None + + +def _best_cvss_csaf20(metrics): + """Pick the highest-priority CVSS block that CSAF 2.0 scores[] can carry. + + The CSAF 2.0 schema predates CVSS v4 and only defines cvss_v2 / cvss_v3 in + a score object, so a v4 block must NOT be placed there (it fails the strict + schema). When only v4 exists the CSAF emitter records it as a note and + relies on the CycloneDX VEX output for the machine-readable v4 rating.""" + for key, ver in (('cvssV3_1', 'v3'), ('cvssV3_0', 'v3'), ('cvssV2_0', 'v2')): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}'} + return None + + +def parse_record(record): + """Reduce a CVE 5.x record to the fields the emitters need.""" + meta = record.get('cveMetadata', {}) + cna = _cna(record) + + cve_id = meta.get('cveId') or cna.get('cveId') + if not cve_id: + sys.exit("ERROR: CVE record has no cveId") + + description = '' + for d in cna.get('descriptions', []): + if d.get('lang', '').lower().startswith('en'): + description = d.get('value', '') + break + + # CSAF 2.0 carries a single `cwe` {id, name}; take the primary one. The + # name is resolved from the official CWE catalogue (CWE_NAMES) so it is the + # exact MITRE string CSAF test 6.1.11 checks against -- the record's + # free-text problemType often differs in casing. `name` may be None when + # the id is absent from the catalogue; the CSAF emitter then omits cwe. + cwe = None + for pt in cna.get('problemTypes', []): + for d in pt.get('descriptions', []): + cid = d.get('cweId') + if not cid: + continue + cwe = {'id': cid, 'name': CWE_NAMES.get(cid)} + break + if cwe: + break + + metrics = cna.get('metrics', []) + cvss = _best_cvss(metrics) + cvss_csaf = _best_cvss_csaf20(metrics) + + affected = [] + for a in cna.get('affected', []): + affected.append({ + 'vendor': a.get('vendor', WOLFSSL_VENDOR), + 'product': a.get('product', 'wolfSSL'), + 'versions': a.get('versions', []), + 'default_status': a.get('defaultStatus', 'unknown'), + }) + + references = [r['url'] for r in cna.get('references', []) if r.get('url')] + credits = [c.get('value', '') for c in cna.get('credits', []) + if c.get('value')] + + return { + 'cve': cve_id, + 'title': cna.get('title') or cve_id, + 'description': description, + 'cwe': cwe, + 'cvss': cvss, + 'cvss_csaf': cvss_csaf, + 'affected': affected, + 'references': references, + 'credits': credits, + 'date_published': meta.get('datePublished'), + 'date_updated': meta.get('dateUpdated'), + } + + +def _range_label(v): + base = v.get('version', '0') + if v.get('lessThanOrEqual'): + return f'<= {v["lessThanOrEqual"]}' if base in ('0', '*') \ + else f'{base} <= x <= {v["lessThanOrEqual"]}' + if v.get('lessThan'): + return f'< {v["lessThan"]}' if base in ('0', '*') \ + else f'{base} <= x < {v["lessThan"]}' + return base + + +def _vers_range(v): + parts = [] + base = v.get('version') + if base and base not in ('0', '*'): + parts.append(f'>={base}') + if v.get('lessThanOrEqual'): + parts.append(f'<={v["lessThanOrEqual"]}') + elif v.get('lessThan'): + parts.append(f'<{v["lessThan"]}') + elif base and base not in ('0', '*'): + return f'vers:generic/{base}' + return 'vers:generic/' + '|'.join(parts) if parts else 'vers:generic/*' + + +# --------------------------------------------------------------------------- # +# Product model: normalize record + overlay into a list of product entries. +# --------------------------------------------------------------------------- # + +def product_model(adv, ov): + """Return the products this CVE describes: the mainline wolfSSL product + (from the CVE record) plus an optional, separately-modelled FIPS product + (from the overlay).""" + products = [] + + state = ov.get('state', 'exploitable') + bucket = _STATE_TO_BUCKET.get(state, 'known_affected') + + # ---- mainline product(s) from the CVE record's affected[] ---- + for a in adv['affected']: + product = a['product'] + affected_ranges = [] + for v in a['versions']: + if v.get('status') == 'affected' or a['default_status'] == 'affected': + affected_ranges.append({ + 'label': _range_label(v), + 'cpe': cpe_for(product, '*'), + 'vers': _vers_range(v), + }) + fixed = [] + for fv in ov.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for(product, fv), + 'purl': purl_for(product, fv)}) + remediation = ov.get('remediation') + if not remediation and fixed: + remediation = f'Update to {product} {fixed[0]["version"]} or later.' + products.append({ + 'product_name': product, + 'cdx_key': product.lower(), + 'bucket': bucket, + 'justification': ov.get('justification') if bucket + == 'known_not_affected' else None, + 'remediation': remediation, + 'remediation_category': 'vendor_fix' if fixed else 'none_available', + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': [], + }) + + # ---- optional FIPS product from the overlay ---- + fips = ov.get('fips') + if fips: + name = fips.get('name', 'wolfCrypt FIPS Module') + modver = fips.get('module_version') + fbucket = _STATE_TO_BUCKET.get(fips.get('status', 'exploitable'), + 'known_affected') + if fips.get('status') in _STATE_TO_BUCKET: + fbucket = _STATE_TO_BUCKET[fips['status']] + affected_ranges = [] + if fbucket != 'fixed' and modver: + affected_ranges.append({ + 'label': modver, + 'cpe': cpe_for('wolfcrypt', modver), + 'vers': f'vers:generic/{modver}', + }) + fixed = [] + for fv in fips.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for('wolfcrypt', fv), + 'purl': purl_for('wolfcrypt', fv)}) + model_numbers = [] + if fips.get('cmvp_cert'): + model_numbers.append(f'CMVP Certificate #{fips["cmvp_cert"]}') + products.append({ + 'product_name': name, + 'cdx_key': 'wolfcrypt-fips', + 'bucket': fbucket, + 'justification': fips.get('justification') if fbucket + == 'known_not_affected' else None, + 'remediation': fips.get('remediation'), + 'remediation_category': 'vendor_fix' if fixed else ( + 'no_fix_planned' if fbucket == 'known_not_affected' + else 'none_available'), + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': model_numbers, + 'module_version': modver, + }) + + return products + + +def _hedge_note(ov): + """Render the no-cost reachability hedge as informational text (only).""" + bits = [] + defines = ov.get('requires_defines') + if defines: + bits.append('Reachable only in builds compiled with: ' + + ', '.join(defines) + '.') + if ov.get('default_status') in ('off', 'disabled'): + bits.append('The affected feature is disabled by default.') + return ' '.join(bits) if bits else None + + +# --------------------------------------------------------------------------- # +# CSAF 2.0 emitter (handles 1..N vulnerabilities in one document) +# --------------------------------------------------------------------------- # + +def generate_csaf(advs, ov_map, advisory_id, timestamp): + # Shared product_tree: product leaves are deduplicated by product_id across + # all CVEs in the bundle (Red Hat-style shared product ids). + tree = {} # (vendor, product_name) -> {product_id: leaf} + tree_order = [] # preserve insertion order of (vendor, product_name) + vulns = [] + agg_rank = -1 + agg_text = None + init_dates = [] + cur_dates = [] + + def _leaf_range(pname, label, cpe, model_numbers): + pid = derived_uuid(pname, 'range', label) + helper = {'cpe': cpe} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version_range', + 'name': label, + 'product': { + 'product_id': pid, + 'name': f'{pname} {label}', + 'product_identification_helper': helper, + }, + } + + def _leaf_fixed(pname, fx, model_numbers): + pid = derived_uuid(pname, 'fixed', fx['version']) + helper = {'cpe': fx['cpe'], 'purl': fx['purl']} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version', + 'name': fx['version'], + 'product': { + 'product_id': pid, + 'name': f'{pname} {fx["version"]}', + 'product_identification_helper': helper, + }, + } + + def _register(pname, pid, leaf): + key = (WOLFSSL_VENDOR, pname) + if key not in tree: + tree[key] = {} + tree_order.append(key) + tree[key].setdefault(pid, leaf) + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + status_buckets = {} # bucket -> [pids] + score_targets = [] + flags = {} # flag_label -> [pids] + remediations = {} # (category, text, url) -> [pids] + + for prod in products: + pname = prod['product_name'] + affected_pids = [] + for r in prod['affected_ranges']: + pid, leaf = _leaf_range(pname, r['label'], r['cpe'], + prod['model_numbers']) + _register(pname, pid, leaf) + affected_pids.append(pid) + status_buckets.setdefault(prod['bucket'], []).append(pid) + if prod['bucket'] in ('known_affected', 'under_investigation'): + score_targets.append(pid) + if prod['bucket'] == 'known_not_affected': + flag = _JUSTIFICATION_TO_CSAF_FLAG.get( + prod['justification'] or '', + 'vulnerable_code_not_in_execute_path') + flags.setdefault(flag, []).append(pid) + for fx in prod['fixed']: + pid, leaf = _leaf_fixed(pname, fx, prod['model_numbers']) + _register(pname, pid, leaf) + status_buckets.setdefault('fixed', []).append(pid) + if prod['remediation'] and affected_pids: + url = adv['references'][0] if adv['references'] else None + key = (prod['remediation_category'], prod['remediation'], url) + remediations.setdefault(key, []).extend(affected_pids) + + vuln = { + 'cve': adv['cve'], + 'notes': [{ + 'category': 'description', + 'text': adv['description'], + 'title': 'Vulnerability description', + }], + 'product_status': {k: v for k, v in status_buckets.items() if v}, + } + hedge = _hedge_note(ov) + if hedge: + vuln['notes'].append({ + 'category': 'other', 'title': 'Build reachability', 'text': hedge}) + # Emit cwe only when we resolved the exact catalogue name (CSAF 6.1.11). + if adv['cwe'] and adv['cwe'].get('name'): + vuln['cwe'] = {'id': adv['cwe']['id'], 'name': adv['cwe']['name']} + if adv['references']: + vuln['references'] = [{'summary': u, 'url': u, 'category': 'external'} + for u in adv['references']] + if flags: + vuln['flags'] = [{'label': lbl, 'product_ids': pids} + for lbl, pids in flags.items()] + if remediations: + vuln['remediations'] = [] + for (cat, text, url), pids in remediations.items(): + rem = {'category': cat, 'details': text, 'product_ids': pids} + if url: + rem['url'] = url + vuln['remediations'].append(rem) + # CSAF 2.0 scores[] can only carry CVSS v2/v3 (the schema predates v4). + if adv['cvss_csaf'] and score_targets: + vuln['scores'] = [{adv['cvss_csaf']['csaf_key']: + adv['cvss_csaf']['data'], + 'products': score_targets}] + # aggregate_severity uses the best CVSS available (which may be v4). + best = adv['cvss'] + if best: + sev = best['data'].get('baseSeverity', '').upper() + if _SEV_RANK.get(sev, -1) > agg_rank: + agg_rank = _SEV_RANK[sev] + agg_text = best['data'].get('baseSeverity') + # A v4-only finding cannot be represented in CSAF 2.0 scores[]; + # preserve the rating as a note so it is not silently dropped. + if best['csaf_key'] == 'cvss_v4' and not adv['cvss_csaf']: + v4 = best['data'] + vuln['notes'].append({ + 'category': 'other', + 'title': 'CVSS v4.0', + 'text': (f"CVSS v4.0 base score {v4.get('baseScore')} " + f"({v4.get('baseSeverity')}); vector " + f"{v4.get('vectorString')}. CSAF 2.0 scores[] " + f"cannot encode CVSS v4; the machine-readable v4 " + f"rating is provided in the CycloneDX VEX output."), + }) + if adv['credits']: + vuln['acknowledgments'] = [{'summary': c} for c in adv['credits']] + vulns.append(vuln) + + if adv['date_published']: + init_dates.append(adv['date_published']) + cur_dates.append(adv['date_updated'] or adv['date_published'] or timestamp) + + # Assemble product_tree branches. + branches = [] + for (vendor, pname) in tree_order: + branches.append({ + 'category': 'vendor', 'name': vendor, + 'branches': [{ + 'category': 'product_name', 'name': pname, + 'branches': list(tree[(vendor, pname)].values()), + }], + }) + + bundle = len(advs) > 1 + if bundle: + title = f'wolfSSL Security Advisory {advisory_id}' + else: + title = f'wolfSSL: {advs[0]["title"]}' + + doc = { + 'document': { + 'category': 'csaf_security_advisory', + 'csaf_version': '2.0', + 'title': title, + 'publisher': WOLFSSL_PUBLISHER, + 'tracking': { + 'id': advisory_id, + 'status': 'final', + 'version': '1', + 'initial_release_date': min(init_dates) if init_dates + else timestamp, + 'current_release_date': max(cur_dates) if cur_dates + else timestamp, + 'revision_history': [{ + 'number': '1', + 'date': min(init_dates) if init_dates else timestamp, + 'summary': 'Initial release', + }], + 'generator': { + 'engine': {'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}, + }, + }, + 'distribution': {'tlp': {'label': 'WHITE'}}, + 'references': [{ + 'summary': 'wolfSSL published security vulnerabilities', + 'url': WOLFSSL_ADVISORIES_URL, 'category': 'self'}], + 'notes': [{ + 'category': 'summary', + 'title': 'Summary', + 'text': (f'wolfSSL security advisory {advisory_id} covering ' + f'{len(advs)} vulnerabilities.' if bundle + else advs[0]['description']), + }], + }, + 'product_tree': {'branches': branches}, + 'vulnerabilities': vulns, + } + if agg_text: + doc['document']['aggregate_severity'] = {'text': agg_text} + return doc + + +# --------------------------------------------------------------------------- # +# CycloneDX 1.6 VEX emitter (handles 1..N vulnerabilities in one BOM) +# --------------------------------------------------------------------------- # + +def _cdx_severity(cvss_data): + sev = (cvss_data or {}).get('baseSeverity', '').lower() + return sev if sev in ('critical', 'high', 'medium', 'low', 'none') \ + else 'unknown' + + +def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): + main_ref = derived_uuid('cdx-component', 'wolfssl') + extra_components = {} # ref -> component dict (e.g. FIPS module) + vulns = [] + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + affects = [] + for prod in products: + if prod['cdx_key'] == 'wolfcrypt-fips': + ref = derived_uuid('cdx-component', 'wolfcrypt-fips', + prod.get('module_version') or '') + if ref not in extra_components: + comp = { + 'bom-ref': ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': prod['product_name'], + 'cpe': cpe_for('wolfcrypt', prod.get('module_version') + or '*'), + } + if prod.get('module_version'): + comp['version'] = prod['module_version'] + if prod['model_numbers']: + comp['properties'] = [ + {'name': 'wolfssl:fips:cmvp', 'value': m} + for m in prod['model_numbers']] + extra_components[ref] = comp + else: + ref = main_ref + # CycloneDX affects[].versions[].status uses 'unaffected' for a + # not-affected product; the 'not_affected' term belongs to + # analysis.state only. + astatus = 'unaffected' if prod['bucket'] == 'known_not_affected' \ + else 'affected' + versions = [{'range': r['vers'], 'status': astatus} + for r in prod['affected_ranges']] + versions += [{'version': fx['version'], 'status': 'unaffected'} + for fx in prod['fixed']] + if versions: + affects.append({'ref': ref, 'versions': versions}) + + vuln = { + 'id': adv['cve'], + 'source': {'name': 'wolfSSL', 'url': WOLFSSL_ADVISORIES_URL}, + 'description': adv['description'], + 'affects': affects or [{'ref': main_ref}], + } + if adv['cvss']: + rating = {'source': {'name': 'wolfSSL'}, + 'severity': _cdx_severity(adv['cvss']['data']), + 'method': adv['cvss']['cdx_method']} + if 'baseScore' in adv['cvss']['data']: + rating['score'] = adv['cvss']['data']['baseScore'] + if 'vectorString' in adv['cvss']['data']: + rating['vector'] = adv['cvss']['data']['vectorString'] + vuln['ratings'] = [rating] + if adv['cwe']: + try: + vuln['cwes'] = [int(adv['cwe']['id'].split('-')[-1])] + except ValueError: + pass + if adv['references']: + vuln['advisories'] = [{'url': u} for u in adv['references']] + + analysis = {'state': ov.get('state', 'exploitable')} + if analysis['state'] == 'not_affected' and ov.get('justification'): + analysis['justification'] = ov['justification'] + if ov.get('response'): + analysis['response'] = ov['response'] + detail_bits = [b for b in (ov.get('detail'), _hedge_note(ov)) if b] + if detail_bits: + analysis['detail'] = ' '.join(detail_bits) + vuln['analysis'] = analysis + + if adv['credits']: + vuln['credits'] = {'individuals': [{'name': c} + for c in adv['credits']]} + if adv['date_published']: + vuln['published'] = adv['date_published'] + if adv['date_updated']: + vuln['updated'] = adv['date_updated'] + vulns.append(vuln) + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'serialNumber': f'urn:uuid:{derived_uuid(advisory_id, "serial")}', + 'version': 1, + 'metadata': { + 'timestamp': timestamp, + 'tools': {'components': [{ + 'type': 'application', 'author': 'wolfSSL Inc.', + 'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}]}, + 'component': { + 'bom-ref': main_ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': 'wolfssl', + 'cpe': cpe_for('wolfssl', '*'), + 'purl': 'pkg:github/wolfSSL/wolfssl', + }, + }, + 'components': list(extra_components.values()), + 'vulnerabilities': vulns, + } + + +# --------------------------------------------------------------------------- # + +def load_overlay(path): + if not path: + return {} + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}") + + +def _write_json(obj, path): + try: + with open(path, 'w') as f: + json.dump(obj, f, indent=2) + f.write('\n') + except OSError as e: + sys.exit(f"ERROR: cannot write {path}: {e}") + + +def _emit(advs, ov_map, advisory_id, timestamp, csaf_out, cdx_out): + """Emit one CSAF and/or one CycloneDX document covering `advs`.""" + if csaf_out: + _write_json(generate_csaf(advs, ov_map, advisory_id, timestamp), + csaf_out) + print(f"Generated: {csaf_out}") + if cdx_out: + _write_json(generate_cdx_vex(advs, ov_map, advisory_id, timestamp), + cdx_out) + print(f"Generated: {cdx_out}") + + +def main(): + p = argparse.ArgumentParser( + description='Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX from ' + 'wolfSSL CVE Program records. With no record arguments it ' + 'processes every record in the canonical advisories/ tree ' + '(the same inputs `make advisory` uses), writing one CSAF ' + '+ one CycloneDX document per CVE into the output ' + 'directory.') + p.add_argument('--cve-record', action='append', default=[], + help='Path to a CVE JSON 5.x record (repeatable). Overrides ' + 'the default --records-dir scan.') + p.add_argument('--cve-id', action='append', default=[], + help='Fetch a record from cve.org by id (repeatable). ' + 'Overrides the default --records-dir scan.') + p.add_argument('--records-dir', default=str(DEFAULT_RECORDS_DIR), + help='Directory of CVE JSON 5.x records scanned when no ' + f'--cve-record/--cve-id is given (default: ' + f'{DEFAULT_RECORDS_DIR}).') + p.add_argument('--vex-overlay', default=None, + help='JSON file mapping CVE id -> VEX overlay (state, ' + 'justification, detail, response, fixed_versions, ' + 'remediation, fips{}, requires_defines, ' + f'default_status). Default: {DEFAULT_OVERLAY} if it ' + 'exists.') + p.add_argument('--advisory-id', + help='Tracking id when bundling several records into ONE ' + 'document. Defaults to the CVE id for a single record.') + p.add_argument('--out-dir', default=str(DEFAULT_OUT_DIR), + help='Output directory for the per-CVE documents written in ' + 'batch mode (default: ' f'{DEFAULT_OUT_DIR}).') + p.add_argument('--csaf-out', + help='Write a single CSAF document to this path instead of ' + 'batch mode (one record, or several with ' + '--advisory-id).') + p.add_argument('--cdx-vex-out', + help='Write a single CycloneDX VEX document to this path ' + 'instead of batch mode.') + args = p.parse_args() + + # ---- resolve the input records ---- + explicit = bool(args.cve_record or args.cve_id) + if explicit: + records = [load_cve_record(path=pth) for pth in args.cve_record] + records += [load_cve_record(cve_id=cid) for cid in args.cve_id] + else: + rec_dir = pathlib.Path(args.records_dir) + paths = sorted(rec_dir.glob('*.json')) + if not paths: + sys.exit(f"ERROR: no CVE records found in {rec_dir}. Add records " + f"there, or pass --cve-record / --cve-id.") + records = [load_cve_record(path=str(pth)) for pth in paths] + advs = [parse_record(r) for r in records] + + # ---- resolve the overlay (explicit > default-if-present > none) ---- + overlay_path = args.vex_overlay + if overlay_path is None and DEFAULT_OVERLAY.exists(): + overlay_path = str(DEFAULT_OVERLAY) + ov_map = load_overlay(overlay_path) + if overlay_path: + for adv in advs: + if adv['cve'] not in ov_map: + print(f"WARNING: no VEX overlay entry for {adv['cve']}; " + f"defaulting to state=exploitable", file=sys.stderr) + + _, timestamp = build_timestamp() + + # ---- single-document mode (explicit output path) ---- + if args.csaf_out or args.cdx_vex_out: + if args.advisory_id: + advisory_id = args.advisory_id + elif len(advs) == 1: + advisory_id = advs[0]['cve'] + else: + sys.exit("ERROR: --advisory-id is required when bundling several " + "records into one --csaf-out/--cdx-vex-out document.") + _emit(advs, ov_map, advisory_id, timestamp, + args.csaf_out, args.cdx_vex_out) + return + + # ---- batch mode: one CSAF + one CycloneDX per CVE into --out-dir ---- + out_dir = pathlib.Path(args.out_dir) + try: + out_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + sys.exit(f"ERROR: cannot create output directory {out_dir}: {e}") + if args.advisory_id and len(advs) > 1: + _emit(advs, ov_map, args.advisory_id, timestamp, + str(out_dir / f'{args.advisory_id}.csaf.json'), + str(out_dir / f'{args.advisory_id}.cdx.json')) + else: + for adv in advs: + _emit([adv], ov_map, adv['cve'], timestamp, + str(out_dir / f"{adv['cve']}.csaf.json"), + str(out_dir / f"{adv['cve']}.cdx.json")) + print(f"Wrote {len(advs)} advisory document set(s) to {out_dir}") + + +if __name__ == '__main__': + main() diff --git a/scripts/include.am b/scripts/include.am index 7fc38739cd0..a3d38d8d42c 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -185,3 +185,11 @@ EXTRA_DIST += scripts/test_gen_sbom.py # re-verify the OmniBOR graph against its enriched SPDX without going # back to the git repo. EXTRA_DIST += scripts/bomsh_verify.py + +# Security advisory generator (invoked from `make advisory`), its canonical +# CWE-name catalogue, and the VEX overlay schema + example. Shipped so a +# downstream consumer building from a release tarball can run `make advisory`. +EXTRA_DIST += scripts/gen-advisory \ + scripts/cwe-names.json \ + scripts/advisory-vex-overlay.schema.json \ + scripts/advisory-vex-overlay.example.json From a9c6055d1b97b51e6793df664edbba38b7955fa1 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Fri, 5 Jun 2026 16:03:25 +0300 Subject: [PATCH 34/39] advisory: tests, CSAF mandatory-test gate, and CI Add scripts/test_gen_advisory.py (record->model logic, CSAF semantic invariants, reproducibility, batch/default-tree resolution, fail-loud behaviour) and a CSAF 2.0 conformance gate (scripts/csaf_validate.mjs) running the strict schema plus all mandatory tests via @secvisogram/csaf-validator-lib. advisory.yml runs unit, schema (CycloneDX 1.6 strict + overlay JSON Schema + reproducibility) and CSAF mandatory-test jobs. Both the schema and CSAF jobs also exercise the zero-argument default/batch path against the canonical advisories/ tree, proving `make advisory` and the script are interchangeable. CVE-2026-5999 is a synthetic CVSS v3.1 fixture that exercises the CSAF scores[] path the v4-only records do not. Signed-off-by: Sameeh Jubran --- .github/workflows/advisory.yml | 217 +++++++++ scripts/csaf_validate.mjs | 83 ++++ scripts/include.am | 10 + scripts/test_gen_advisory.py | 672 ++++++++++++++++++++++++++++ scripts/testdata/CVE-2026-5501.json | 122 +++++ scripts/testdata/CVE-2026-5778.json | 122 +++++ scripts/testdata/CVE-2026-5999.json | 99 ++++ scripts/testdata/README.md | 11 + 8 files changed, 1336 insertions(+) create mode 100644 .github/workflows/advisory.yml create mode 100644 scripts/csaf_validate.mjs create mode 100644 scripts/test_gen_advisory.py create mode 100644 scripts/testdata/CVE-2026-5501.json create mode 100644 scripts/testdata/CVE-2026-5778.json create mode 100644 scripts/testdata/CVE-2026-5999.json create mode 100644 scripts/testdata/README.md diff --git a/.github/workflows/advisory.yml b/.github/workflows/advisory.yml new file mode 100644 index 00000000000..a57452a2a11 --- /dev/null +++ b/.github/workflows/advisory.yml @@ -0,0 +1,217 @@ +name: Advisory Tests + +# START OF COMMON SECTION +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '*' ] + +# Defence-in-depth: this workflow only reads the tree and validates generated +# advisories (no API writes, no git push, no release upload), so pin the token +# to read-only per GitHub's supply-chain hardening guidance. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +# END OF COMMON SECTION + +jobs: + # Tier 1 - pure-Python unit + semantic tests for scripts/gen-advisory. + # No build, no pip deps. Runs in seconds and is the cheapest gate for the + # record->model logic and the CSAF semantic invariants (every product_id + # defined/used, no contradicting status, flags only on not-affected + # products, no cvss_v4 in CSAF 2.0 scores, canonical CWE names, ...). + unit: + name: gen-advisory 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-advisory + + - name: Unit tests + run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_advisory.py -v + + # Tier 2 - format-level validation: generate per-CVE and bundled advisories + # from the committed CVE fixtures + example overlay, then validate the + # CycloneDX VEX against the 1.6 strict schema (same validator the SBOM + # workflow uses) and the VEX overlay against its JSON Schema. Also pins + # SOURCE_DATE_EPOCH reproducibility for both emitters. + schema: + name: advisory schema validation + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install validators + # cyclonedx-bom provides the CycloneDX 1.6 strict JSON validator (same + # pin as .github/workflows/sbom.yml); jsonschema validates the VEX + # overlay against scripts/advisory-vex-overlay.schema.json. Pinned so + # a validator release cannot silently change what "valid" means. + run: | + python3 -m pip install --user --upgrade pip + python3 -m pip install --user 'cyclonedx-bom==7.*' 'jsonschema==4.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Overlay validates against its JSON Schema + run: | + python3 - <<'PY' + import json, jsonschema + schema = json.load(open('scripts/advisory-vex-overlay.schema.json')) + overlay = json.load(open('scripts/advisory-vex-overlay.example.json')) + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(overlay) + print('OK: example overlay matches advisory-vex-overlay.schema.json') + PY + + - name: Generate advisories (per-CVE + bundled) + # Mirrors how a release would be cut: one document per CVE, plus a + # bundled per-release advisory carrying both. SOURCE_DATE_EPOCH makes + # the run deterministic for the reproducibility check below. + run: | + mkdir -p /tmp/adv + for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record "scripts/testdata/$id.json" \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --csaf-out "/tmp/adv/$id.csaf.json" \ + --cdx-vex-out "/tmp/adv/$id.cdx.json" + done + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ + --cdx-vex-out /tmp/adv/wolfSSL-SA-5.9.1.cdx.json + + - name: CycloneDX VEX validates per CycloneDX 1.6 strict schema + run: | + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + paths = sorted(glob.glob('/tmp/adv/*.cdx.json')) + assert paths, 'no CycloneDX VEX documents were generated' + for p in paths: + errs = v.validate_str(open(p).read()) + if errs: + print(f'INVALID: {p}: {errs}', file=sys.stderr) + sys.exit(1) + print(f'OK: {p}') + PY + + - name: Reproducibility - two runs are byte-identical + run: | + mkdir -p /tmp/adv-r2 + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json \ + --cdx-vex-out /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json + diff /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ + /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json + diff /tmp/adv/wolfSSL-SA-5.9.1.cdx.json \ + /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json + + - name: Default/batch path matches `make advisory` + # No record flags: gen-advisory falls back to the canonical + # advisories/ tree (the exact inputs `make advisory` feeds it via + # --records-dir/--vex-overlay), proving the script and the build target + # are interchangeable and that the committed real records + overlay + # generate and validate. + run: | + python3 scripts/gen-advisory --out-dir /tmp/adv-default + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + paths = sorted(glob.glob('/tmp/adv-default/*.cdx.json')) + assert paths, 'batch mode produced no CycloneDX documents' + for p in paths: + errs = v.validate_str(open(p).read()) + if errs: + print(f'INVALID: {p}: {errs}', file=sys.stderr) + sys.exit(1) + print(f'OK: {p}') + PY + + - name: Upload generated advisories + if: always() + uses: actions/upload-artifact@v4 + with: + name: advisories-${{ github.sha }} + path: /tmp/adv/*.json + if-no-files-found: warn + retention-days: 90 + + # Tier 2 - CSAF 2.0 conformance: the real gate. JSON-schema validity is + # necessary but not sufficient; CSAF defines mandatory tests (section 6.1.*) + # -- CVSS/vector consistency, contradicting product status, product_id + # defined/used, tracking.version vs revision_history, CWE name match, ... -- + # that a bare schema pass accepts. scripts/csaf_validate.mjs runs the strict + # 2.0 schema + all mandatory tests via the Secvisogram reference + # implementation (bundles every schema incl. the first.org CVSS schemas, so + # it is fully offline once installed). + csaf-conformance: + name: CSAF 2.0 mandatory tests + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install csaf-validator-lib (pinned) + # Pinned: csaf-validator-lib implements the CSAF mandatory tests, and + # an unpinned upgrade could change pass/fail semantics under us. The + # bare `csaf-validator-lib` name on npm is an unrelated placeholder; + # the reference implementation is the @secvisogram scope. + run: npm install --no-save @secvisogram/csaf-validator-lib@2.0.25 + + - name: Generate CSAF advisories (per-CVE + bundled) + run: | + mkdir -p /tmp/adv + for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do + python3 scripts/gen-advisory \ + --cve-record "scripts/testdata/$id.json" \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --csaf-out "/tmp/adv/$id.csaf.json" + done + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json + + - name: CSAF strict schema + mandatory tests + run: node scripts/csaf_validate.mjs /tmp/adv/*.csaf.json + + - name: CSAF default/batch path (canonical advisories/ tree) + # Same conformance gate, but driven through the zero-argument default + # path `make advisory` uses, against the committed real records + + # advisories/vex-overlay.json. + run: | + python3 scripts/gen-advisory --out-dir /tmp/adv-default + node scripts/csaf_validate.mjs /tmp/adv-default/*.csaf.json diff --git a/scripts/csaf_validate.mjs b/scripts/csaf_validate.mjs new file mode 100644 index 00000000000..3d9fc12215f --- /dev/null +++ b/scripts/csaf_validate.mjs @@ -0,0 +1,83 @@ +// CSAF 2.0 conformance gate for documents emitted by scripts/gen-advisory. +// +// JSON-schema validity is necessary but NOT sufficient for CSAF: the standard +// defines a battery of *mandatory tests* (section 6.1.*) -- CVSS/vector +// consistency, contradicting product status, product_id defined/used, +// tracking.version vs revision_history, and so on -- that a bare schema pass +// happily accepts. This runner uses the Secvisogram reference implementation +// (@secvisogram/csaf-validator-lib) which bundles every schema (incl. the +// first.org CVSS schemas) and implements those mandatory tests, so the check +// is fully offline and reproducible once the pinned dependency is installed. +// +// Gate = the strict CSAF 2.0 schema test + all mandatory tests. Optional and +// informative tests are reported as warnings only (they encode house-style +// preferences, not conformance). +// +// Usage: node scripts/csaf_validate.mjs [ ...] +// Exit 0 if every document passes the gate, 1 otherwise. + +import { readFileSync } from 'node:fs' +import validate from '@secvisogram/csaf-validator-lib/validate.js' +import * as schemaTests from '@secvisogram/csaf-validator-lib/schemaTests.js' +import * as mandatoryTests from '@secvisogram/csaf-validator-lib/mandatoryTests.js' +import * as optionalTests from '@secvisogram/csaf-validator-lib/optionalTests.js' + +const files = process.argv.slice(2) +if (files.length === 0) { + console.error('usage: node scripts/csaf_validate.mjs ...') + process.exit(2) +} + +// The gate: strict 2.0 schema + every mandatory test. +const gateTests = [schemaTests.csaf_2_0_strict, ...Object.values(mandatoryTests)] +// Reported for visibility but non-fatal. +const advisoryTests = [...Object.values(optionalTests)] + +function summarize(testResults) { + // testResults: [{ name, isValid, errors, warnings, infos }] + const failed = [] + for (const t of testResults) { + if (t.isValid === false || (t.errors && t.errors.length > 0)) { + failed.push(t) + } + } + return failed +} + +let anyInvalid = false + +for (const file of files) { + let doc + try { + doc = JSON.parse(readFileSync(file, 'utf8')) + } catch (e) { + console.error(`ERROR: cannot read/parse ${file}: ${e.message}`) + anyInvalid = true + continue + } + + const gate = await validate(gateTests, doc) + const advisory = await validate(advisoryTests, doc) + + if (gate.isValid) { + console.log(`OK ${file} (strict schema + ${Object.keys(mandatoryTests).length} mandatory tests)`) + } else { + anyInvalid = true + console.error(`FAIL ${file}`) + for (const t of summarize(gate.tests)) { + for (const err of t.errors || []) { + console.error(` [${t.name}] ${err.instancePath || '/'}: ${err.message}`) + } + } + } + + // Surface optional-test warnings without failing the build. + const optWarn = summarize(advisory.tests) + for (const t of optWarn) { + for (const err of t.errors || []) { + console.warn(` warn ${file} [${t.name}] ${err.instancePath || '/'}: ${err.message}`) + } + } +} + +process.exit(anyInvalid ? 1 : 0) diff --git a/scripts/include.am b/scripts/include.am index a3d38d8d42c..ebac0c06bbb 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -193,3 +193,13 @@ EXTRA_DIST += scripts/gen-advisory \ scripts/cwe-names.json \ scripts/advisory-vex-overlay.schema.json \ scripts/advisory-vex-overlay.example.json + +# Advisory regression suite + CSAF 2.0 conformance gate, with the frozen CVE +# fixtures they run against. Shipped so a downstream consumer / CRA reviewer +# can re-run the advisory tests from a release tarball. +EXTRA_DIST += scripts/csaf_validate.mjs \ + scripts/test_gen_advisory.py \ + scripts/testdata/README.md \ + scripts/testdata/CVE-2026-5501.json \ + scripts/testdata/CVE-2026-5778.json \ + scripts/testdata/CVE-2026-5999.json diff --git a/scripts/test_gen_advisory.py b/scripts/test_gen_advisory.py new file mode 100644 index 00000000000..c4981111b55 --- /dev/null +++ b/scripts/test_gen_advisory.py @@ -0,0 +1,672 @@ +#!/usr/bin/env python3 +"""Unit + semantic tests for scripts/gen-advisory. + +Run from the repo root: + + python3 -m unittest scripts/test_gen_advisory.py + +These tests are pure stdlib (no network, no pip deps) so they form the cheap +PR gate, mirroring scripts/test_gen_sbom.py. They cover three things the +JSON-schema validators in .github/workflows/advisory.yml do NOT: + + 1. the pure record->model logic (CVSS priority, CWE extraction, version + ranges, the FIPS product split, the reachability hedge); + 2. CSAF *semantic* invariants that a bare JSON-schema pass accepts but the + CSAF mandatory tests reject (every referenced product_id is defined in + the product_tree, no product is simultaneously affected and not-affected, + flags only sit on not-affected products, scores only target affected + products, tracking.version matches the latest revision_history entry); + 3. the two regressions already fixed once (CycloneDX uses `unaffected` + not `not_affected` in affects[].versions[].status; every CSAF reference + carries the required `summary`). + +The full CSAF 2.0 schema + mandatory-test conformance and the CycloneDX 1.6 +strict-schema pass run in CI against csaf-validator-lib / cyclonedx-bom; this +file deliberately avoids those heavyweight deps. +""" + +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from importlib.machinery import SourceFileLoader + + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = HERE / 'gen-advisory' +TESTDATA = HERE / 'testdata' +EXAMPLE_OVERLAY = HERE / 'advisory-vex-overlay.example.json' +OVERLAY_SCHEMA = HERE / 'advisory-vex-overlay.schema.json' + +# Pinned epoch -> 2023-11-14T22:13:20Z. Shared by the reproducibility test +# and the timestamp unit test so the expected string is single-sourced. +PINNED_EPOCH = '1700000000' +PINNED_EPOCH_ISO = '2023-11-14T22:13:20Z' + + +def _load_gen_advisory(): + """Load gen-advisory (no .py extension) as module 'ga', same trick as + test_gen_sbom.py uses for gen-sbom.""" + if not SCRIPT.is_file(): + raise FileNotFoundError(f"expected gen-advisory alongside this test at {SCRIPT}") + loader = SourceFileLoader('ga', str(SCRIPT)) + spec = importlib.util.spec_from_loader('ga', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +ga = _load_gen_advisory() + + +def _record(name): + with open(TESTDATA / name) as f: + return json.load(f) + + +def _adv(name): + return ga.parse_record(_record(name)) + + +def _overlay(): + with open(EXAMPLE_OVERLAY) as f: + return json.load(f) + + +def _collect_product_ids(node): + """Every product_id declared anywhere in a CSAF product_tree branch.""" + pids = set() + prod = node.get('product') + if isinstance(prod, dict) and 'product_id' in prod: + pids.add(prod['product_id']) + for child in node.get('branches', []): + pids |= _collect_product_ids(child) + return pids + + +def _tree_product_ids(doc): + pids = set() + for branch in doc['product_tree'].get('branches', []): + pids |= _collect_product_ids(branch) + return pids + + +# Valid CSAF 2.0 enum subsets we rely on (spec 6.1.* / schema enums). +CSAF_STATUS_BUCKETS = { + 'first_affected', 'first_fixed', 'fixed', 'known_affected', + 'known_not_affected', 'last_affected', 'recommended', + 'under_investigation', +} +CSAF_FLAG_LABELS = { + 'component_not_present', 'inline_mitigations_already_exist', + 'vulnerable_code_cannot_be_controlled_by_adversary', + 'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present', +} +CSAF_REMEDIATION_CATEGORIES = { + 'mitigation', 'no_fix_planned', 'none_available', 'optional_patch', + 'vendor_fix', 'workaround', 'fix_planned', +} +CDX_AFFECTS_STATUS = {'affected', 'unaffected', 'unknown'} + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + +class TestDerivedUuid(unittest.TestCase): + def test_deterministic(self): + self.assertEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'b')) + + def test_distinct_inputs_distinct_output(self): + self.assertNotEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'c')) + + def test_no_aliasing_across_separator(self): + # NUL-separated join: ('a','bc') must not collide with ('ab','c'). + self.assertNotEqual(ga.derived_uuid('a', 'bc'), ga.derived_uuid('ab', 'c')) + + def test_is_uuid(self): + self.assertRegex( + ga.derived_uuid('x'), + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') + + +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'] = PINNED_EPOCH + _, iso = ga.build_timestamp() + self.assertEqual(iso, PINNED_EPOCH_ISO) + + def test_invalid_epoch_falls_back_to_now(self): + os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' + _, iso = ga.build_timestamp() + # Falls back to wallclock; just assert a well-formed Z timestamp. + self.assertRegex(iso, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') + + +class TestCpePurl(unittest.TestCase): + def test_cpe(self): + self.assertEqual(ga.cpe_for('wolfSSL', '5.9.1'), + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + + def test_purl(self): + self.assertEqual(ga.purl_for('wolfSSL', '5.9.1'), + 'pkg:github/wolfSSL/wolfssl@v5.9.1') + + +class TestBestCvss(unittest.TestCase): + def test_priority_v4_over_v3(self): + metrics = [{'cvssV3_1': {'x': 1}}, {'cvssV4_0': {'y': 2}}] + best = ga._best_cvss(metrics) + self.assertEqual(best['csaf_key'], 'cvss_v4') + self.assertEqual(best['cdx_method'], 'CVSSv4') + self.assertEqual(best['data'], {'y': 2}) + + def test_v31_over_v30_over_v2(self): + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}, {'cvssV3_0': {}}])['csaf_key'], + 'cvss_v3') + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}])['csaf_key'], 'cvss_v2') + + def test_none_when_absent(self): + self.assertIsNone(ga._best_cvss([])) + self.assertIsNone(ga._best_cvss([{'other': {}}])) + + +class TestParseRecord(unittest.TestCase): + def test_core_fields(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cve'], 'CVE-2026-5501') + self.assertTrue(adv['title'].startswith('Improper Certificate')) + self.assertIn('wolfSSL_X509_verify_cert', adv['description']) + self.assertEqual(adv['date_published'], '2026-04-10T03:07:39.604Z') + self.assertEqual(adv['date_updated'], '2026-04-22T13:59:28.514Z') + + def test_cwe_id_and_canonical_name(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cwe']['id'], 'CWE-295') + # Resolved from the official catalogue (exact MITRE casing), NOT the + # record's lowercase free text -- required by CSAF test 6.1.11. + self.assertEqual(adv['cwe']['name'], 'Improper Certificate Validation') + + def test_cvss_is_v4_and_no_csaf20_compatible_score(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cvss']['csaf_key'], 'cvss_v4') + self.assertEqual(adv['cvss']['data']['baseSeverity'], 'HIGH') + self.assertEqual(adv['cvss']['data']['baseScore'], 8.6) + # The record carries only CVSS v4, which CSAF 2.0 scores[] cannot hold. + self.assertIsNone(adv['cvss_csaf']) + + def test_affected_and_credits(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(len(adv['affected']), 1) + a = adv['affected'][0] + self.assertEqual(a['product'], 'wolfSSL') + self.assertEqual(a['default_status'], 'unaffected') + self.assertEqual(a['versions'][0]['lessThanOrEqual'], '5.9.0') + self.assertEqual(adv['references'], + ['https://github.com/wolfSSL/wolfssl/pull/10102']) + self.assertEqual(len(adv['credits']), 1) + + def test_missing_cveid_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'containers': {'cna': {}}, 'cveMetadata': {}}) + + def test_missing_cna_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'cveMetadata': {'cveId': 'CVE-1'}}) + + +class TestRangeLabelAndVers(unittest.TestCase): + def test_less_than_or_equal_from_zero(self): + v = {'version': '0', 'lessThanOrEqual': '5.9.0'} + self.assertEqual(ga._range_label(v), '<= 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/<=5.9.0') + + def test_less_than_with_base(self): + v = {'version': '5.0.0', 'lessThan': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.0.0 <= x < 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/>=5.0.0|<5.9.0') + + def test_single_version(self): + v = {'version': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/5.9.0') + + +class TestProductModel(unittest.TestCase): + def test_mainline_only(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model(adv, {'state': 'exploitable', + 'fixed_versions': ['5.9.1']}) + self.assertEqual(len(prods), 1) + p = prods[0] + self.assertEqual(p['product_name'], 'wolfSSL') + self.assertEqual(p['bucket'], 'known_affected') + self.assertEqual(len(p['affected_ranges']), 1) + self.assertEqual(p['fixed'][0]['version'], '5.9.1') + self.assertEqual(p['remediation_category'], 'vendor_fix') + + def test_not_affected_state_sets_bucket_and_justification(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model( + adv, {'state': 'not_affected', 'justification': 'code_not_present'}) + self.assertEqual(prods[0]['bucket'], 'known_not_affected') + self.assertEqual(prods[0]['justification'], 'code_not_present') + + def test_fips_modelled_as_second_product(self): + adv = _adv('CVE-2026-5501.json') + ov = _overlay()['CVE-2026-5501'] + prods = ga.product_model(adv, ov) + self.assertEqual(len(prods), 2) + fips = [p for p in prods if p['cdx_key'] == 'wolfcrypt-fips'][0] + self.assertEqual(fips['bucket'], 'known_not_affected') + self.assertEqual(fips['justification'], 'code_not_present') + self.assertIn('CMVP Certificate #4718', fips['model_numbers']) + self.assertEqual(fips['module_version'], '5.2.1') + # not-affected FIPS with no fix => no_fix_planned, not none_available. + self.assertEqual(fips['remediation_category'], 'no_fix_planned') + + +class TestHedgeNote(unittest.TestCase): + def test_renders_defines_and_default_off(self): + note = ga._hedge_note({'requires_defines': ['WOLFSSL_SNIFFER'], + 'default_status': 'off'}) + self.assertIn('WOLFSSL_SNIFFER', note) + self.assertIn('disabled by default', note) + + def test_none_when_empty(self): + self.assertIsNone(ga._hedge_note({})) + + +# --------------------------------------------------------------------------- # +# CSAF emitter: structure + semantic invariants +# --------------------------------------------------------------------------- # + +class TestGenerateCsaf(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.single = ga.generate_csaf( + [_adv('CVE-2026-5501.json')], self.ov, 'CVE-2026-5501', + PINNED_EPOCH_ISO) + self.bundle = ga.generate_csaf( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_required_document_skeleton(self): + d = self.single['document'] + self.assertEqual(d['csaf_version'], '2.0') + self.assertEqual(d['category'], 'csaf_security_advisory') + self.assertEqual(d['publisher']['category'], 'vendor') + self.assertEqual(d['tracking']['id'], 'CVE-2026-5501') + self.assertEqual(d['tracking']['status'], 'final') + self.assertIn('initial_release_date', d['tracking']) + self.assertIn('current_release_date', d['tracking']) + self.assertTrue(d['distribution']['tlp']['label']) + self.assertTrue(d['notes']) + + def test_tracking_version_matches_latest_revision(self): + # CSAF 6.1.x: for a non-draft doc the latest revision_history number + # must equal tracking.version. + tr = self.single['document']['tracking'] + latest = tr['revision_history'][-1]['number'] + self.assertEqual(tr['version'], latest) + + def test_document_references_have_summary(self): + # Regression: CSAF rejects references without `summary`. + for ref in self.single['document'].get('references', []): + self.assertIn('summary', ref) + self.assertTrue(ref['summary']) + + def test_all_product_ids_defined_in_tree(self): + for doc in (self.single, self.bundle): + defined = _tree_product_ids(doc) + self.assertTrue(defined) + for v in doc['vulnerabilities']: + for bucket, pids in v.get('product_status', {}).items(): + self.assertIn(bucket, CSAF_STATUS_BUCKETS) + self.assertTrue(set(pids) <= defined, + f'undefined pid in {bucket}') + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= defined) + for f in v.get('flags', []): + self.assertTrue(set(f['product_ids']) <= defined) + for r in v.get('remediations', []): + self.assertTrue(set(r['product_ids']) <= defined) + + def test_no_product_both_affected_and_not_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + affected = set(ps.get('known_affected', [])) + not_affected = set(ps.get('known_not_affected', [])) + self.assertEqual(affected & not_affected, set()) + + def test_vuln_references_have_summary(self): + for v in self.bundle['vulnerabilities']: + for ref in v.get('references', []): + self.assertIn('summary', ref) + + def test_flags_only_on_not_affected_products(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + not_affected = set(ps.get('known_not_affected', [])) + for f in v.get('flags', []): + self.assertIn(f['label'], CSAF_FLAG_LABELS) + self.assertTrue(set(f['product_ids']) <= not_affected) + + def test_scores_only_target_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + scoreable = set(ps.get('known_affected', [])) \ + | set(ps.get('under_investigation', [])) + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= scoreable) + + def test_no_cvss_v4_in_csaf_scores(self): + # Regression: CSAF 2.0 scores[] has no cvss_v4 property; a v4 block + # there fails the strict schema. These records are v4-only, so no + # scores[] should be emitted at all. + for doc in (self.single, self.bundle): + for v in doc['vulnerabilities']: + for s in v.get('scores', []): + self.assertNotIn('cvss_v4', s) + + def test_v4_only_record_emits_cvss_note(self): + # The v4 rating must not be silently dropped from CSAF: it is preserved + # as a note pointing at the CycloneDX VEX for the machine-readable form. + v = self.single['vulnerabilities'][0] + titles = [n.get('title') for n in v['notes']] + self.assertIn('CVSS v4.0', titles) + note = [n for n in v['notes'] if n.get('title') == 'CVSS v4.0'][0] + self.assertIn('8.6', note['text']) + + def test_cwe_uses_canonical_catalogue_name(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + self.assertEqual(v['cwe']['id'], 'CWE-191') + self.assertEqual(v['cwe']['name'], + 'Integer Underflow (Wrap or Wraparound)') + + def test_remediation_categories_valid(self): + for v in self.bundle['vulnerabilities']: + for r in v.get('remediations', []): + self.assertIn(r['category'], CSAF_REMEDIATION_CATEGORIES) + + def test_fips_is_its_own_product_branch(self): + names = set() + + def walk(node): + if node.get('category') == 'product_name': + names.add(node['name']) + for c in node.get('branches', []): + walk(c) + for b in self.single['product_tree']['branches']: + walk(b) + self.assertIn('wolfSSL', names) + self.assertTrue(any('FIPS' in n for n in names), + f'expected a FIPS product branch, got {names}') + + def test_bundle_has_two_vulns_and_aggregate_severity(self): + self.assertEqual(len(self.bundle['vulnerabilities']), 2) + cves = {v['cve'] for v in self.bundle['vulnerabilities']} + self.assertEqual(cves, {'CVE-2026-5501', 'CVE-2026-5778'}) + # HIGH (5501) outranks LOW (5778). + self.assertEqual(self.bundle['document']['aggregate_severity']['text'], + 'HIGH') + + def test_hedge_note_present_for_sniffer_cve(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + texts = ' '.join(n['text'] for n in v['notes']) + self.assertIn('WOLFSSL_SNIFFER', texts) + + +class TestCsafV3Scores(unittest.TestCase): + """The v4-only fixtures never populate CSAF scores[]; this exercises the + positive path with a CVSS v3.1 record (CSAF 2.0 can carry v3).""" + + def setUp(self): + self.ov = _overlay() + self.adv = _adv('CVE-2026-5999.json') + self.doc = ga.generate_csaf([self.adv], self.ov, 'CVE-2026-5999', + PINNED_EPOCH_ISO) + + def test_parse_selects_v3_for_csaf(self): + self.assertEqual(self.adv['cvss']['csaf_key'], 'cvss_v3') + self.assertIsNotNone(self.adv['cvss_csaf']) + self.assertEqual(self.adv['cvss_csaf']['csaf_key'], 'cvss_v3') + self.assertEqual(self.adv['cvss_csaf']['data']['baseScore'], 7.5) + + def test_csaf_emits_cvss_v3_score(self): + v = self.doc['vulnerabilities'][0] + self.assertEqual(len(v['scores']), 1) + score = v['scores'][0] + self.assertIn('cvss_v3', score) + self.assertNotIn('cvss_v4', score) + self.assertTrue(score['products']) + # v3 path -> no CVSS v4 fallback note. + self.assertNotIn('CVSS v4.0', [n.get('title') for n in v['notes']]) + + def test_aggregate_severity_from_v3(self): + self.assertEqual(self.doc['document']['aggregate_severity']['text'], + 'HIGH') + + +# --------------------------------------------------------------------------- # +# CycloneDX VEX emitter +# --------------------------------------------------------------------------- # + +class TestGenerateCdxVex(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.bom = ga.generate_cdx_vex( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_bom_skeleton(self): + self.assertEqual(self.bom['bomFormat'], 'CycloneDX') + self.assertEqual(self.bom['specVersion'], '1.6') + self.assertRegex(self.bom['serialNumber'], r'^urn:uuid:[0-9a-f-]{36}$') + self.assertEqual(self.bom['metadata']['component']['name'], 'wolfssl') + + def test_fips_component_present(self): + names = {c['name'] for c in self.bom['components']} + self.assertTrue(any('FIPS' in n for n in names), names) + + def test_affects_status_uses_unaffected_not_not_affected(self): + # Regression sentinel: CycloneDX affects[].versions[].status only + # accepts affected/unaffected/unknown; not_affected belongs to + # analysis.state alone. + for v in self.bom['vulnerabilities']: + for aff in v['affects']: + for ver in aff.get('versions', []): + self.assertIn(ver['status'], CDX_AFFECTS_STATUS) + + def test_not_affected_fips_range_is_unaffected(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + # the FIPS component is not_affected -> its range status is unaffected. + fips_refs = {c['bom-ref'] for c in self.bom['components']} + fips_affects = [a for a in v['affects'] if a['ref'] in fips_refs] + self.assertTrue(fips_affects) + for a in fips_affects: + for ver in a['versions']: + self.assertEqual(ver['status'], 'unaffected') + + def test_analysis_state_and_cwe_and_rating(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + self.assertEqual(v['analysis']['state'], 'exploitable') + self.assertEqual(v['cwes'], [295]) + self.assertEqual(v['ratings'][0]['method'], 'CVSSv4') + self.assertEqual(v['ratings'][0]['severity'], 'high') + + +# --------------------------------------------------------------------------- # +# Overlay matches its own schema vocabulary (lightweight, no jsonschema). +# The authoritative jsonschema pass runs in CI; this guards the committed +# example overlay against drift without adding a pip dep to the unit gate. +# --------------------------------------------------------------------------- # + +class TestExampleOverlay(unittest.TestCase): + def setUp(self): + with open(OVERLAY_SCHEMA) as f: + self.schema = json.load(f) + self.overlay = _overlay() + + def _enum(self, name): + return set(self.schema['$defs'][name]['enum']) + + def test_states_and_justifications_in_vocab(self): + states = self._enum('analysisState') + justs = self._enum('justification') + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if 'state' in entry: + self.assertIn(entry['state'], states) + if 'justification' in entry: + self.assertIn(entry['justification'], justs) + fips = entry.get('fips', {}) + if 'status' in fips: + self.assertIn(fips['status'], states) + if 'justification' in fips: + self.assertIn(fips['justification'], justs) + + def test_not_affected_requires_justification(self): + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if entry.get('state') == 'not_affected': + self.assertIn('justification', entry) + if entry.get('fips', {}).get('status') == 'not_affected': + self.assertIn('justification', entry['fips']) + + +# --------------------------------------------------------------------------- # +# End-to-end via the CLI: reproducibility + fail-loud behaviour. +# --------------------------------------------------------------------------- # + +class TestCliBehaviour(unittest.TestCase): + def _run(self, args, env=None): + e = dict(os.environ) + if env: + e.update(env) + return subprocess.run([sys.executable, str(SCRIPT)] + args, + capture_output=True, text=True, env=e) + + def test_reproducible_under_source_date_epoch(self): + with tempfile.TemporaryDirectory() as d: + outs = [] + for i in (1, 2): + csaf = os.path.join(d, f'a{i}.csaf.json') + cdx = os.path.join(d, f'a{i}.cdx.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--advisory-id', 'wolfSSL-SA-5.9.1', + '--csaf-out', csaf, '--cdx-vex-out', cdx], + env={'SOURCE_DATE_EPOCH': PINNED_EPOCH}) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf, 'rb') as f: + csaf_b = f.read() + with open(cdx, 'rb') as f: + cdx_b = f.read() + outs.append((csaf_b, cdx_b)) + self.assertEqual(outs[0][0], outs[1][0], 'CSAF not reproducible') + self.assertEqual(outs[0][1], outs[1][1], 'CDX not reproducible') + + def test_single_record_defaults_advisory_id_to_cve(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'one.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--csaf-out', csaf]) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], + 'CVE-2026-5501') + + def test_bundling_without_advisory_id_fails(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'x.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf), + 'no output should be written on error') + + def test_empty_records_dir_fails(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + r = self._run(['--records-dir', recs, '--out-dir', d]) + self.assertNotEqual(r.returncode, 0) + self.assertIn('no CVE records found', r.stderr) + + def test_batch_mode_writes_per_cve_documents(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + shutil.copy(str(TESTDATA / 'CVE-2026-5501.json'), + os.path.join(recs, 'CVE-2026-5501.json')) + shutil.copy(str(TESTDATA / 'CVE-2026-5999.json'), + os.path.join(recs, 'CVE-2026-5999.json')) + out = os.path.join(d, 'out') + r = self._run(['--records-dir', recs, '--out-dir', out, + '--vex-overlay', str(EXAMPLE_OVERLAY)]) + self.assertEqual(r.returncode, 0, r.stderr) + for cve in ('CVE-2026-5501', 'CVE-2026-5999'): + csaf = os.path.join(out, f'{cve}.csaf.json') + cdx = os.path.join(out, f'{cve}.cdx.json') + self.assertTrue(os.path.exists(csaf), csaf) + self.assertTrue(os.path.exists(cdx), cdx) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], cve) + + def test_default_records_dir_is_canonical_tree(self): + # No --cve-record/--cve-id and no --records-dir: must fall back to the + # canonical advisories/records/ tree (the same inputs `make advisory` + # uses). Output is redirected to a temp dir so the repo is untouched. + with tempfile.TemporaryDirectory() as d: + r = self._run(['--out-dir', d]) + self.assertEqual(r.returncode, 0, r.stderr) + produced = sorted(f for f in os.listdir(d) + if f.endswith('.csaf.json')) + self.assertIn('CVE-2026-5501.csaf.json', produced) + self.assertIn('CVE-2026-5778.csaf.json', produced) + + def test_malformed_record_fails_without_writing(self): + with tempfile.TemporaryDirectory() as d: + bad = os.path.join(d, 'bad.json') + with open(bad, 'w') as f: + f.write('{ this is not json') + csaf = os.path.join(d, 'out.csaf.json') + r = self._run(['--cve-record', bad, '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf)) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/testdata/CVE-2026-5501.json b/scripts/testdata/CVE-2026-5501.json new file mode 100644 index 00000000000..6db50821c6c --- /dev/null +++ b/scripts/testdata/CVE-2026-5501.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "LOW", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.6, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/scripts/testdata/CVE-2026-5778.json b/scripts/testdata/CVE-2026-5778.json new file mode 100644 index 00000000000..75296072f1a --- /dev/null +++ b/scripts/testdata/CVE-2026-5778.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "LOW", + "userInteraction": "PASSIVE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "LOW", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "LOW", + "baseScore": 2.1, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/scripts/testdata/CVE-2026-5999.json b/scripts/testdata/CVE-2026-5999.json new file mode 100644 index 00000000000..97aa65eaeb6 --- /dev/null +++ b/scripts/testdata/CVE-2026-5999.json @@ -0,0 +1,99 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5999", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-15T09:00:00.000Z", + "datePublished": "2026-04-18T12:00:00.000Z", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "title": "Out-of-bounds read parsing a malformed DTLS handshake message.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-125", + "description": "CWE-125 Out-of-bounds read", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "programFiles": [ + "src/dtls.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "A synthetic test fixture (not a real CVE). An out-of-bounds read in wolfSSL DTLS handshake parsing <= 5.9.0 allows a remote unauthenticated attacker to read past the end of a record buffer by sending a malformed handshake message, potentially crashing the server. This record exists to exercise the CVSS v3.1 scores[] path of gen-advisory." + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/99999" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV3_1": { + "version": "3.1", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "NONE", + "availabilityImpact": "NONE", + "baseScore": 7.5, + "baseSeverity": "HIGH", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "wolfSSL internal testing", + "type": "finder" + } + ], + "source": { + "discovery": "INTERNAL" + } + } + } +} diff --git a/scripts/testdata/README.md b/scripts/testdata/README.md new file mode 100644 index 00000000000..8ea3d8a80ac --- /dev/null +++ b/scripts/testdata/README.md @@ -0,0 +1,11 @@ +# gen-advisory test fixtures + +CVE Program records (CVE JSON 5.x) used by `scripts/test_gen_advisory.py` and +the `.github/workflows/advisory.yml` jobs. Committed so the tests are hermetic +(no network fetch from cve.org at test time). + +| File | Provenance | +|------|------------| +| `CVE-2026-5501.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5778.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5999.json` | **Synthetic fixture, not a real CVE.** Carries a CVSS v3.1 block so the CSAF `scores[]` emission path (and the CVSS-consistency mandatory tests 6.1.8/6.1.9) is exercised; the v4-only records above never populate `scores[]` in CSAF 2.0. | From 29b84d8457f9df269adcf62a77bcfae66eedc328 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Mon, 15 Jun 2026 13:42:54 +0300 Subject: [PATCH 35/39] sbom: address Skoll review findings Thread a new SBOM_DEP_VERSIONS make variable to gen-sbom's --dep-version so autotools packagers without pkg-config avoid NOASSERTION dep versions, resolve each dependency version once instead of per-format, drop a dead FIPS bucket branch in gen-advisory, and make the gen-advisory exec bit match gen-sbom. Signed-off-by: Sameeh Jubran --- Makefile.am | 9 +++++++ doc/SBOM.md | 10 +++++++- scripts/gen-advisory | 4 +--- scripts/gen-sbom | 19 +++++++++++++++ scripts/test_gen_sbom.py | 52 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) mode change 100644 => 100755 scripts/gen-advisory diff --git a/Makefile.am b/Makefile.am index cd18f6987da..cf2571a70ff 100644 --- a/Makefile.am +++ b/Makefile.am @@ -470,6 +470,14 @@ WOLFSSL_LIB_DSO_BASENAMES = \ # their own URL should set this to a URI they # actually serve (e.g. # https://example.com/sbom/wolfssl-X.Y.Z.spdx.json). +# SBOM_DEP_VERSIONS Space-separated KEY=VERSION list forwarded to +# gen-sbom as repeated --dep-version flags (KEY is +# one of the known deps, e.g. liboqs / libz). Use +# this on build/packaging hosts that lack the dep's +# pkg-config .pc file, where version detection would +# otherwise fall back to NOASSERTION (SPDX) / an +# omitted version+purl (CycloneDX). Example: +# make sbom SBOM_DEP_VERSIONS='liboqs=0.10.0 libz=1.3.1'. # SBOM_LIB_OVERRIDE Absolute path to the library artefact whose # SHA-256 should land in the SBOM, INSTEAD of # discovering one via a private staging install. @@ -547,6 +555,7 @@ sbom: --lib "$$sbom_lib" \ --dep-libz $(ENABLED_LIBZ) \ --dep-liboqs $(ENABLED_LIBOQS) \ + $(foreach dv,$(SBOM_DEP_VERSIONS),--dep-version '$(dv)') \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ diff --git a/doc/SBOM.md b/doc/SBOM.md index c119210e9f6..36a1e49aea4 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -498,7 +498,15 @@ its `.pc` file is missing): prints a warning to stderr. For embedded / cross-compile builds without `pkg-config`, the standalone -entry point exposes a `--dep-version libz=1.3.1` override (see § 1.2). +entry point exposes a `--dep-version libz=1.3.1` override (see § 1.2). The +autotools path exposes the same override through the `SBOM_DEP_VERSIONS` +make variable (space-separated `KEY=VERSION` pairs), so a packaging host +that lacks the dependency's `.pc` file can still record the version instead +of `NOASSERTION`: + +```sh +make sbom SBOM_DEP_VERSIONS='liboqs=0.10.0 libz=1.3.1' +``` ### 2.5 Validating the SBOM manually diff --git a/scripts/gen-advisory b/scripts/gen-advisory old mode 100644 new mode 100755 index cefbf05630c..0cbf15b38df --- a/scripts/gen-advisory +++ b/scripts/gen-advisory @@ -340,10 +340,8 @@ def product_model(adv, ov): modver = fips.get('module_version') fbucket = _STATE_TO_BUCKET.get(fips.get('status', 'exploitable'), 'known_affected') - if fips.get('status') in _STATE_TO_BUCKET: - fbucket = _STATE_TO_BUCKET[fips['status']] affected_ranges = [] - if fbucket != 'fixed' and modver: + if modver: affected_ranges.append({ 'label': modver, 'cpe': cpe_for('wolfcrypt', modver), diff --git a/scripts/gen-sbom b/scripts/gen-sbom index d6e99448356..ad5d56c7d16 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -923,6 +923,21 @@ def _parse_dep_version_overrides(spec_list): return overrides +def _resolve_dep_versions(enabled_deps, overrides): + """Resolve each enabled dependency's version exactly once, mutating and + returning `overrides` so both the CDX and SPDX emitters reuse the same + value instead of each re-invoking pkg-config. Caching the result + (including None) means a later dep_version() lookup short-circuits on the + membership check rather than re-shelling to `pkg-config --modversion`, so + a default --with-libz --with-liboqs build calls pkg-config once per dep + (not once per dep per output format) and the two documents can never + disagree if pkg-config output were ever non-deterministic.""" + for key in enabled_deps: + if key not in overrides: + overrides[key] = dep_version(key, overrides) + return overrides + + def main(): parser = argparse.ArgumentParser( description='Generate CycloneDX and SPDX SBOMs for wolfssl. ' @@ -1061,6 +1076,10 @@ def main(): if flag.lower() == 'yes' ] dep_version_overrides = _parse_dep_version_overrides(args.dep_version) + # Resolve each enabled dependency's version once, here, and feed the + # result to both the CDX and SPDX emitters via the overrides map (see + # _resolve_dep_versions for the once-per-dep pkg-config rationale). + _resolve_dep_versions(enabled_deps, dep_version_overrides) if args.license_override: license_id = args.license_override diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index 70f4a4864be..b6d7340ed47 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -1253,6 +1253,58 @@ def test_parse_overrides_accepts_known_keys(self): self.assertEqual(out, {'libz': '1.3.1', 'liboqs': '0.10.0'}) +class TestResolveDepVersionsSingleShot(unittest.TestCase): + """Each enabled dependency's version must be resolved exactly once (in + main, via _resolve_dep_versions), not once per output format. Without + the precompute, generate_cdx and generate_spdx each call dep_version() + independently, so a default --with-libz --with-liboqs build would shell + out to `pkg-config --modversion` four times (2 deps x CDX+SPDX) instead + of twice -- and the two documents could disagree if pkg-config were ever + non-deterministic. These tests lock that single-resolution behaviour in.""" + + def test_pkgconfig_called_once_per_dep(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '1.2.3')[1] + overrides = gs._resolve_dep_versions(['libz', 'liboqs'], {}) + self.assertEqual(len(calls), 2) + self.assertEqual(overrides['libz'], '1.2.3') + self.assertEqual(overrides['liboqs'], '1.2.3') + # The emitters reuse the cached value: a later dep_version() for + # an already-resolved key must not re-invoke pkg-config. + gs.dep_version('libz', overrides) + gs.dep_version('liboqs', overrides) + self.assertEqual(len(calls), 2) + finally: + gs.pkgconfig_version = original + + def test_user_override_skips_pkgconfig(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '9.9.9')[1] + overrides = gs._resolve_dep_versions(['libz'], {'libz': '1.3.1'}) + self.assertEqual(overrides['libz'], '1.3.1') + self.assertEqual(calls, []) + finally: + gs.pkgconfig_version = original + + def test_none_is_cached_when_pkgconfig_missing(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), None)[1] + overrides = gs._resolve_dep_versions(['liboqs'], {}) + self.assertIn('liboqs', overrides) + self.assertIsNone(overrides['liboqs']) + # A cached None must short-circuit later lookups too. + gs.dep_version('liboqs', overrides) + self.assertEqual(len(calls), 1) + finally: + gs.pkgconfig_version = original + + class TestCliMutualExclusion(unittest.TestCase): """The two entry-point shapes (autotools / standalone) must be mutually exclusive. Mixing them would produce a hash whose From 25e9bd8c5e904fff52f5416cc94dea58988a1f95 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 17 Jun 2026 12:52:27 +0300 Subject: [PATCH 36/39] ci: fix codespell, source-text, and SBOM check failures Normalize CRLF->LF in advisory feature files (advisory.yml, gen-advisory, CVE records, testdata) to clear check-source-text CR violations. Ignore "cna" and skip cwe-names.json in codespell; rename "justs" var. Drop dead/buggy props block in sbom.yml that hit KeyError 'name' on SPDX annotations (no 'name' key). Signed-off-by: Sameeh Jubran --- .github/workflows/advisory.yml | 434 +++--- .github/workflows/codespell.yml | 4 +- .github/workflows/sbom.yml | 12 +- Makefile.am | 2 +- advisories/records/CVE-2026-5501.json | 244 +-- advisories/records/CVE-2026-5778.json | 244 +-- advisories/vex-overlay.json | 42 +- scripts/advisory-vex-overlay.example.json | 90 +- scripts/advisory-vex-overlay.schema.json | 234 +-- scripts/csaf_validate.mjs | 166 +- scripts/gen-advisory | 1692 ++++++++++----------- scripts/include.am | 2 +- scripts/test_gen_advisory.py | 1344 ++++++++-------- scripts/testdata/CVE-2026-5501.json | 244 +-- scripts/testdata/CVE-2026-5778.json | 244 +-- scripts/testdata/CVE-2026-5999.json | 198 +-- scripts/testdata/README.md | 22 +- 17 files changed, 2605 insertions(+), 2613 deletions(-) diff --git a/.github/workflows/advisory.yml b/.github/workflows/advisory.yml index a57452a2a11..1431c6ab923 100644 --- a/.github/workflows/advisory.yml +++ b/.github/workflows/advisory.yml @@ -1,217 +1,217 @@ -name: Advisory Tests - -# START OF COMMON SECTION -on: - push: - branches: [ 'master', 'main', 'release/**' ] - pull_request: - branches: [ '*' ] - -# Defence-in-depth: this workflow only reads the tree and validates generated -# advisories (no API writes, no git push, no release upload), so pin the token -# to read-only per GitHub's supply-chain hardening guidance. -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -# END OF COMMON SECTION - -jobs: - # Tier 1 - pure-Python unit + semantic tests for scripts/gen-advisory. - # No build, no pip deps. Runs in seconds and is the cheapest gate for the - # record->model logic and the CSAF semantic invariants (every product_id - # defined/used, no contradicting status, flags only on not-affected - # products, no cvss_v4 in CSAF 2.0 scores, canonical CWE names, ...). - unit: - name: gen-advisory 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-advisory - - - name: Unit tests - run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_advisory.py -v - - # Tier 2 - format-level validation: generate per-CVE and bundled advisories - # from the committed CVE fixtures + example overlay, then validate the - # CycloneDX VEX against the 1.6 strict schema (same validator the SBOM - # workflow uses) and the VEX overlay against its JSON Schema. Also pins - # SOURCE_DATE_EPOCH reproducibility for both emitters. - schema: - name: advisory schema validation - if: github.repository_owner == 'wolfssl' - runs-on: ubuntu-24.04 - needs: unit - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - name: Install validators - # cyclonedx-bom provides the CycloneDX 1.6 strict JSON validator (same - # pin as .github/workflows/sbom.yml); jsonschema validates the VEX - # overlay against scripts/advisory-vex-overlay.schema.json. Pinned so - # a validator release cannot silently change what "valid" means. - run: | - python3 -m pip install --user --upgrade pip - python3 -m pip install --user 'cyclonedx-bom==7.*' 'jsonschema==4.*' - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Overlay validates against its JSON Schema - run: | - python3 - <<'PY' - import json, jsonschema - schema = json.load(open('scripts/advisory-vex-overlay.schema.json')) - overlay = json.load(open('scripts/advisory-vex-overlay.example.json')) - jsonschema.Draft202012Validator.check_schema(schema) - jsonschema.Draft202012Validator(schema).validate(overlay) - print('OK: example overlay matches advisory-vex-overlay.schema.json') - PY - - - name: Generate advisories (per-CVE + bundled) - # Mirrors how a release would be cut: one document per CVE, plus a - # bundled per-release advisory carrying both. SOURCE_DATE_EPOCH makes - # the run deterministic for the reproducibility check below. - run: | - mkdir -p /tmp/adv - for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do - SOURCE_DATE_EPOCH=1700000000 \ - python3 scripts/gen-advisory \ - --cve-record "scripts/testdata/$id.json" \ - --vex-overlay scripts/advisory-vex-overlay.example.json \ - --csaf-out "/tmp/adv/$id.csaf.json" \ - --cdx-vex-out "/tmp/adv/$id.cdx.json" - done - SOURCE_DATE_EPOCH=1700000000 \ - python3 scripts/gen-advisory \ - --cve-record scripts/testdata/CVE-2026-5501.json \ - --cve-record scripts/testdata/CVE-2026-5778.json \ - --vex-overlay scripts/advisory-vex-overlay.example.json \ - --advisory-id wolfSSL-SA-5.9.1 \ - --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ - --cdx-vex-out /tmp/adv/wolfSSL-SA-5.9.1.cdx.json - - - name: CycloneDX VEX validates per CycloneDX 1.6 strict schema - run: | - python3 - <<'PY' - import glob, sys - from cyclonedx.validation.json import JsonStrictValidator - from cyclonedx.schema import SchemaVersion - v = JsonStrictValidator(SchemaVersion.V1_6) - paths = sorted(glob.glob('/tmp/adv/*.cdx.json')) - assert paths, 'no CycloneDX VEX documents were generated' - for p in paths: - errs = v.validate_str(open(p).read()) - if errs: - print(f'INVALID: {p}: {errs}', file=sys.stderr) - sys.exit(1) - print(f'OK: {p}') - PY - - - name: Reproducibility - two runs are byte-identical - run: | - mkdir -p /tmp/adv-r2 - SOURCE_DATE_EPOCH=1700000000 \ - python3 scripts/gen-advisory \ - --cve-record scripts/testdata/CVE-2026-5501.json \ - --cve-record scripts/testdata/CVE-2026-5778.json \ - --vex-overlay scripts/advisory-vex-overlay.example.json \ - --advisory-id wolfSSL-SA-5.9.1 \ - --csaf-out /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json \ - --cdx-vex-out /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json - diff /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ - /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json - diff /tmp/adv/wolfSSL-SA-5.9.1.cdx.json \ - /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json - - - name: Default/batch path matches `make advisory` - # No record flags: gen-advisory falls back to the canonical - # advisories/ tree (the exact inputs `make advisory` feeds it via - # --records-dir/--vex-overlay), proving the script and the build target - # are interchangeable and that the committed real records + overlay - # generate and validate. - run: | - python3 scripts/gen-advisory --out-dir /tmp/adv-default - python3 - <<'PY' - import glob, sys - from cyclonedx.validation.json import JsonStrictValidator - from cyclonedx.schema import SchemaVersion - v = JsonStrictValidator(SchemaVersion.V1_6) - paths = sorted(glob.glob('/tmp/adv-default/*.cdx.json')) - assert paths, 'batch mode produced no CycloneDX documents' - for p in paths: - errs = v.validate_str(open(p).read()) - if errs: - print(f'INVALID: {p}: {errs}', file=sys.stderr) - sys.exit(1) - print(f'OK: {p}') - PY - - - name: Upload generated advisories - if: always() - uses: actions/upload-artifact@v4 - with: - name: advisories-${{ github.sha }} - path: /tmp/adv/*.json - if-no-files-found: warn - retention-days: 90 - - # Tier 2 - CSAF 2.0 conformance: the real gate. JSON-schema validity is - # necessary but not sufficient; CSAF defines mandatory tests (section 6.1.*) - # -- CVSS/vector consistency, contradicting product status, product_id - # defined/used, tracking.version vs revision_history, CWE name match, ... -- - # that a bare schema pass accepts. scripts/csaf_validate.mjs runs the strict - # 2.0 schema + all mandatory tests via the Secvisogram reference - # implementation (bundles every schema incl. the first.org CVSS schemas, so - # it is fully offline once installed). - csaf-conformance: - name: CSAF 2.0 mandatory tests - if: github.repository_owner == 'wolfssl' - runs-on: ubuntu-24.04 - needs: unit - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install csaf-validator-lib (pinned) - # Pinned: csaf-validator-lib implements the CSAF mandatory tests, and - # an unpinned upgrade could change pass/fail semantics under us. The - # bare `csaf-validator-lib` name on npm is an unrelated placeholder; - # the reference implementation is the @secvisogram scope. - run: npm install --no-save @secvisogram/csaf-validator-lib@2.0.25 - - - name: Generate CSAF advisories (per-CVE + bundled) - run: | - mkdir -p /tmp/adv - for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do - python3 scripts/gen-advisory \ - --cve-record "scripts/testdata/$id.json" \ - --vex-overlay scripts/advisory-vex-overlay.example.json \ - --csaf-out "/tmp/adv/$id.csaf.json" - done - python3 scripts/gen-advisory \ - --cve-record scripts/testdata/CVE-2026-5501.json \ - --cve-record scripts/testdata/CVE-2026-5778.json \ - --vex-overlay scripts/advisory-vex-overlay.example.json \ - --advisory-id wolfSSL-SA-5.9.1 \ - --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json - - - name: CSAF strict schema + mandatory tests - run: node scripts/csaf_validate.mjs /tmp/adv/*.csaf.json - - - name: CSAF default/batch path (canonical advisories/ tree) - # Same conformance gate, but driven through the zero-argument default - # path `make advisory` uses, against the committed real records + - # advisories/vex-overlay.json. - run: | - python3 scripts/gen-advisory --out-dir /tmp/adv-default - node scripts/csaf_validate.mjs /tmp/adv-default/*.csaf.json +name: Advisory Tests + +# START OF COMMON SECTION +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '*' ] + +# Defence-in-depth: this workflow only reads the tree and validates generated +# advisories (no API writes, no git push, no release upload), so pin the token +# to read-only per GitHub's supply-chain hardening guidance. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +# END OF COMMON SECTION + +jobs: + # Tier 1 - pure-Python unit + semantic tests for scripts/gen-advisory. + # No build, no pip deps. Runs in seconds and is the cheapest gate for the + # record->model logic and the CSAF semantic invariants (every product_id + # defined/used, no contradicting status, flags only on not-affected + # products, no cvss_v4 in CSAF 2.0 scores, canonical CWE names, ...). + unit: + name: gen-advisory 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-advisory + + - name: Unit tests + run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_advisory.py -v + + # Tier 2 - format-level validation: generate per-CVE and bundled advisories + # from the committed CVE fixtures + example overlay, then validate the + # CycloneDX VEX against the 1.6 strict schema (same validator the SBOM + # workflow uses) and the VEX overlay against its JSON Schema. Also pins + # SOURCE_DATE_EPOCH reproducibility for both emitters. + schema: + name: advisory schema validation + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install validators + # cyclonedx-bom provides the CycloneDX 1.6 strict JSON validator (same + # pin as .github/workflows/sbom.yml); jsonschema validates the VEX + # overlay against scripts/advisory-vex-overlay.schema.json. Pinned so + # a validator release cannot silently change what "valid" means. + run: | + python3 -m pip install --user --upgrade pip + python3 -m pip install --user 'cyclonedx-bom==7.*' 'jsonschema==4.*' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Overlay validates against its JSON Schema + run: | + python3 - <<'PY' + import json, jsonschema + schema = json.load(open('scripts/advisory-vex-overlay.schema.json')) + overlay = json.load(open('scripts/advisory-vex-overlay.example.json')) + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(overlay) + print('OK: example overlay matches advisory-vex-overlay.schema.json') + PY + + - name: Generate advisories (per-CVE + bundled) + # Mirrors how a release would be cut: one document per CVE, plus a + # bundled per-release advisory carrying both. SOURCE_DATE_EPOCH makes + # the run deterministic for the reproducibility check below. + run: | + mkdir -p /tmp/adv + for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record "scripts/testdata/$id.json" \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --csaf-out "/tmp/adv/$id.csaf.json" \ + --cdx-vex-out "/tmp/adv/$id.cdx.json" + done + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ + --cdx-vex-out /tmp/adv/wolfSSL-SA-5.9.1.cdx.json + + - name: CycloneDX VEX validates per CycloneDX 1.6 strict schema + run: | + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + paths = sorted(glob.glob('/tmp/adv/*.cdx.json')) + assert paths, 'no CycloneDX VEX documents were generated' + for p in paths: + errs = v.validate_str(open(p).read()) + if errs: + print(f'INVALID: {p}: {errs}', file=sys.stderr) + sys.exit(1) + print(f'OK: {p}') + PY + + - name: Reproducibility - two runs are byte-identical + run: | + mkdir -p /tmp/adv-r2 + SOURCE_DATE_EPOCH=1700000000 \ + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json \ + --cdx-vex-out /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json + diff /tmp/adv/wolfSSL-SA-5.9.1.csaf.json \ + /tmp/adv-r2/wolfSSL-SA-5.9.1.csaf.json + diff /tmp/adv/wolfSSL-SA-5.9.1.cdx.json \ + /tmp/adv-r2/wolfSSL-SA-5.9.1.cdx.json + + - name: Default/batch path matches `make advisory` + # No record flags: gen-advisory falls back to the canonical + # advisories/ tree (the exact inputs `make advisory` feeds it via + # --records-dir/--vex-overlay), proving the script and the build target + # are interchangeable and that the committed real records + overlay + # generate and validate. + run: | + python3 scripts/gen-advisory --out-dir /tmp/adv-default + python3 - <<'PY' + import glob, sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + v = JsonStrictValidator(SchemaVersion.V1_6) + paths = sorted(glob.glob('/tmp/adv-default/*.cdx.json')) + assert paths, 'batch mode produced no CycloneDX documents' + for p in paths: + errs = v.validate_str(open(p).read()) + if errs: + print(f'INVALID: {p}: {errs}', file=sys.stderr) + sys.exit(1) + print(f'OK: {p}') + PY + + - name: Upload generated advisories + if: always() + uses: actions/upload-artifact@v4 + with: + name: advisories-${{ github.sha }} + path: /tmp/adv/*.json + if-no-files-found: warn + retention-days: 90 + + # Tier 2 - CSAF 2.0 conformance: the real gate. JSON-schema validity is + # necessary but not sufficient; CSAF defines mandatory tests (section 6.1.*) + # -- CVSS/vector consistency, contradicting product status, product_id + # defined/used, tracking.version vs revision_history, CWE name match, ... -- + # that a bare schema pass accepts. scripts/csaf_validate.mjs runs the strict + # 2.0 schema + all mandatory tests via the Secvisogram reference + # implementation (bundles every schema incl. the first.org CVSS schemas, so + # it is fully offline once installed). + csaf-conformance: + name: CSAF 2.0 mandatory tests + if: github.repository_owner == 'wolfssl' + runs-on: ubuntu-24.04 + needs: unit + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install csaf-validator-lib (pinned) + # Pinned: csaf-validator-lib implements the CSAF mandatory tests, and + # an unpinned upgrade could change pass/fail semantics under us. The + # bare `csaf-validator-lib` name on npm is an unrelated placeholder; + # the reference implementation is the @secvisogram scope. + run: npm install --no-save @secvisogram/csaf-validator-lib@2.0.25 + + - name: Generate CSAF advisories (per-CVE + bundled) + run: | + mkdir -p /tmp/adv + for id in CVE-2026-5501 CVE-2026-5778 CVE-2026-5999; do + python3 scripts/gen-advisory \ + --cve-record "scripts/testdata/$id.json" \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --csaf-out "/tmp/adv/$id.csaf.json" + done + python3 scripts/gen-advisory \ + --cve-record scripts/testdata/CVE-2026-5501.json \ + --cve-record scripts/testdata/CVE-2026-5778.json \ + --vex-overlay scripts/advisory-vex-overlay.example.json \ + --advisory-id wolfSSL-SA-5.9.1 \ + --csaf-out /tmp/adv/wolfSSL-SA-5.9.1.csaf.json + + - name: CSAF strict schema + mandatory tests + run: node scripts/csaf_validate.mjs /tmp/adv/*.csaf.json + + - name: CSAF default/batch path (canonical advisories/ tree) + # Same conformance gate, but driven through the zero-argument default + # path `make advisory` uses, against the committed real records + + # advisories/vex-overlay.json. + run: | + python3 scripts/gen-advisory --out-dir /tmp/adv-default + node scripts/csaf_validate.mjs /tmp/adv-default/*.csaf.json diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 657fa8de9ac..1afde20693d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -24,8 +24,8 @@ jobs: check_filenames: true check_hidden: true # Add comma separated list of words that occur multiple times that should be ignored (sorted alphabetically, case sensitive) - ignore_words_list: adin,aNULL,brunch,carryIn,chainG,ciph,cLen,cliKs,dout,haveA,inCreated,inOut,inout,larg,LEAPYEAR,Merget,optionA,parm,parms,repid,rIn,userA,ser,siz,te,Te,HSI,failT,toLen, + ignore_words_list: adin,aNULL,brunch,carryIn,chainG,ciph,cLen,cliKs,cna,dout,haveA,inCreated,inOut,inout,larg,LEAPYEAR,Merget,optionA,parm,parms,repid,rIn,userA,ser,siz,te,Te,HSI,failT,toLen, # The exclude_file contains lines of code that should be ignored. This is useful for individual lines which have non-words that can safely be ignored. exclude_file: '.codespellexcludelines' # To skip files entirely from being processed, add it to the following list: - skip: '*.cproject,*.csr,*.der,*.mtpj,*.pem,*.vcxproj,.git,*.launch,*.scfg,*.revoked,./examples/asn1/dumpasn1.cfg,./examples/asn1/oid_names.h' + skip: '*.cproject,*.csr,*.der,*.mtpj,*.pem,*.vcxproj,.git,*.launch,*.scfg,*.revoked,./examples/asn1/dumpasn1.cfg,./examples/asn1/oid_names.h,./scripts/cwe-names.json' diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 192f4cfbd10..ce1e1023dff 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -738,11 +738,6 @@ jobs: 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) @@ -759,12 +754,9 @@ jobs: 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. + # block depending on SPDX version; serialize the whole doc and + # check the flag is present somewhere. combined = json.dumps(d) assert 'HAVE_FALCON' in combined, \ "HAVE_FALCON missing from SBOM build properties" diff --git a/Makefile.am b/Makefile.am index cf2571a70ff..532a825b2dc 100644 --- a/Makefile.am +++ b/Makefile.am @@ -464,7 +464,7 @@ WOLFSSL_LIB_DSO_BASENAMES = \ # LicenseRef is in use; `make sbom` exits with an # error if it is missing. # SBOM_DOCUMENT_NAMESPACE Override the SPDX documentNamespace. Default -# is a deterministic urn:uuid (SPDX 2.3 §6.5 +# is a deterministic urn:uuid (SPDX 2.3 sec. 6.5 # requires only uniqueness, not resolvability). # Downstream packagers re-hosting the SBOM under # their own URL should set this to a URI they diff --git a/advisories/records/CVE-2026-5501.json b/advisories/records/CVE-2026-5501.json index 6db50821c6c..8f7a3f2677a 100644 --- a/advisories/records/CVE-2026-5501.json +++ b/advisories/records/CVE-2026-5501.json @@ -1,122 +1,122 @@ -{ - "dataType": "CVE_RECORD", - "dataVersion": "5.2", - "cveMetadata": { - "cveId": "CVE-2026-5501", - "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "state": "PUBLISHED", - "assignerShortName": "wolfSSL", - "dateReserved": "2026-04-03T15:46:09.302Z", - "datePublished": "2026-04-10T03:07:39.604Z", - "dateUpdated": "2026-04-22T13:59:28.514Z" - }, - "containers": { - "cna": { - "providerMetadata": { - "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "shortName": "wolfSSL", - "dateUpdated": "2026-04-10T03:07:39.604Z" - }, - "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", - "problemTypes": [ - { - "descriptions": [ - { - "lang": "en", - "cweId": "CWE-295", - "description": "CWE-295 Improper certificate validation", - "type": "CWE" - } - ] - } - ], - "affected": [ - { - "vendor": "wolfSSL", - "product": "wolfSSL", - "modules": [ - "wolfSSL_X509_verify_cert" - ], - "programFiles": [ - "src/x509_str.c" - ], - "versions": [ - { - "status": "affected", - "version": "0", - "lessThanOrEqual": "5.9.0", - "versionType": "semver" - } - ], - "defaultStatus": "unaffected" - } - ], - "descriptions": [ - { - "lang": "en", - "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", - "supportingMedia": [ - { - "type": "text/html", - "base64": false, - "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." - } - ] - } - ], - "references": [ - { - "url": "https://github.com/wolfSSL/wolfssl/pull/10102" - } - ], - "metrics": [ - { - "format": "CVSS", - "scenarios": [ - { - "lang": "en", - "value": "GENERAL" - } - ], - "cvssV4_0": { - "attackVector": "NETWORK", - "attackComplexity": "LOW", - "attackRequirements": "NONE", - "privilegesRequired": "LOW", - "userInteraction": "NONE", - "vulnConfidentialityImpact": "HIGH", - "subConfidentialityImpact": "NONE", - "vulnIntegrityImpact": "HIGH", - "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "NONE", - "subAvailabilityImpact": "NONE", - "exploitMaturity": "NOT_DEFINED", - "Safety": "NOT_DEFINED", - "Automatable": "NOT_DEFINED", - "Recovery": "NOT_DEFINED", - "valueDensity": "NOT_DEFINED", - "vulnerabilityResponseEffort": "NOT_DEFINED", - "providerUrgency": "NOT_DEFINED", - "version": "4.0", - "baseSeverity": "HIGH", - "baseScore": 8.6, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" - } - } - ], - "credits": [ - { - "lang": "en", - "value": "Calif.io in collaboration with Claude and Anthropic Research", - "type": "finder" - } - ], - "source": { - "discovery": "EXTERNAL" - }, - "x_generator": { - "engine": "Vulnogram 1.0.1" - } - } - } -} +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "LOW", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.6, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/records/CVE-2026-5778.json b/advisories/records/CVE-2026-5778.json index 75296072f1a..2964d7af1e4 100644 --- a/advisories/records/CVE-2026-5778.json +++ b/advisories/records/CVE-2026-5778.json @@ -1,122 +1,122 @@ -{ - "dataType": "CVE_RECORD", - "dataVersion": "5.2", - "cveMetadata": { - "cveId": "CVE-2026-5778", - "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "state": "PUBLISHED", - "assignerShortName": "wolfSSL", - "dateReserved": "2026-04-08T08:25:15.400Z", - "datePublished": "2026-04-09T21:45:09.053Z", - "dateUpdated": "2026-04-10T13:53:29.181Z" - }, - "containers": { - "cna": { - "providerMetadata": { - "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "shortName": "wolfSSL", - "dateUpdated": "2026-04-09T21:45:09.053Z" - }, - "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", - "problemTypes": [ - { - "descriptions": [ - { - "lang": "en", - "cweId": "CWE-191", - "description": "CWE-191 Integer underflow (wrap or wraparound)", - "type": "CWE" - } - ] - } - ], - "affected": [ - { - "vendor": "wolfSSL", - "product": "wolfSSL", - "modules": [ - "Packet sniffer" - ], - "programFiles": [ - "src/sniffer.c" - ], - "versions": [ - { - "status": "affected", - "version": "0", - "lessThanOrEqual": "5.9.0", - "versionType": "semver" - } - ], - "defaultStatus": "unaffected" - } - ], - "descriptions": [ - { - "lang": "en", - "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", - "supportingMedia": [ - { - "type": "text/html", - "base64": false, - "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." - } - ] - } - ], - "references": [ - { - "url": "https://github.com/wolfSSL/wolfssl/pull/10125" - } - ], - "metrics": [ - { - "format": "CVSS", - "scenarios": [ - { - "lang": "en", - "value": "GENERAL" - } - ], - "cvssV4_0": { - "attackVector": "NETWORK", - "attackComplexity": "LOW", - "attackRequirements": "PRESENT", - "privilegesRequired": "LOW", - "userInteraction": "PASSIVE", - "vulnConfidentialityImpact": "NONE", - "subConfidentialityImpact": "NONE", - "vulnIntegrityImpact": "NONE", - "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "LOW", - "subAvailabilityImpact": "NONE", - "exploitMaturity": "NOT_DEFINED", - "Safety": "NOT_DEFINED", - "Automatable": "NOT_DEFINED", - "Recovery": "NOT_DEFINED", - "valueDensity": "NOT_DEFINED", - "vulnerabilityResponseEffort": "NOT_DEFINED", - "providerUrgency": "NOT_DEFINED", - "version": "4.0", - "baseSeverity": "LOW", - "baseScore": 2.1, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" - } - } - ], - "credits": [ - { - "lang": "en", - "value": "Zou Dikai", - "type": "finder" - } - ], - "source": { - "discovery": "EXTERNAL" - }, - "x_generator": { - "engine": "Vulnogram 1.0.1" - } - } - } -} +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "LOW", + "userInteraction": "PASSIVE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "LOW", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "LOW", + "baseScore": 2.1, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/vex-overlay.json b/advisories/vex-overlay.json index 14a0ae0c1f8..c04c11d67c1 100644 --- a/advisories/vex-overlay.json +++ b/advisories/vex-overlay.json @@ -1,21 +1,21 @@ -{ - "_comment": "Canonical wolfSSL VEX overlay consumed by `make advisory` / `scripts/gen-advisory`. Keyed by CVE id; carries the determinations the CVE Program record cannot express (analysis state, justification, fixed versions, remediation, optional FIPS product, optional build-reachability hedge). Constrained by scripts/advisory-vex-overlay.schema.json. To model a wolfCrypt FIPS module as a separate product, add a \"fips\" block per the format in scripts/advisory-vex-overlay.example.json using the real validated module version and CMVP certificate number (do NOT copy the illustrative placeholder values from the example).", - - "CVE-2026-5501": { - "state": "exploitable", - "response": ["update"], - "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", - "fixed_versions": ["5.9.1"], - "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation." - }, - - "CVE-2026-5778": { - "state": "exploitable", - "response": ["update"], - "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", - "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], - "default_status": "off", - "fixed_versions": ["5.9.1"], - "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected." - } -} +{ + "_comment": "Canonical wolfSSL VEX overlay consumed by `make advisory` / `scripts/gen-advisory`. Keyed by CVE id; carries the determinations the CVE Program record cannot express (analysis state, justification, fixed versions, remediation, optional FIPS product, optional build-reachability hedge). Constrained by scripts/advisory-vex-overlay.schema.json. To model a wolfCrypt FIPS module as a separate product, add a \"fips\" block per the format in scripts/advisory-vex-overlay.example.json using the real validated module version and CMVP certificate number (do NOT copy the illustrative placeholder values from the example).", + + "CVE-2026-5501": { + "state": "exploitable", + "response": ["update"], + "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation." + }, + + "CVE-2026-5778": { + "state": "exploitable", + "response": ["update"], + "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", + "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], + "default_status": "off", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected." + } +} diff --git a/scripts/advisory-vex-overlay.example.json b/scripts/advisory-vex-overlay.example.json index b82be42c434..bb1237ea55c 100644 --- a/scripts/advisory-vex-overlay.example.json +++ b/scripts/advisory-vex-overlay.example.json @@ -1,45 +1,45 @@ -{ - "_comment": "Human-authored VEX determinations keyed by CVE id. The CVE Program record carries the structural facts (CWE/CVSS/affected ranges); this overlay carries what it cannot express in machine-readable form: the analysis state, the not-affected justification, the response, free-text scope detail, the mainline fixed release version(s), an optional separately-modelled FIPS product entry, and an optional no-cost build-reachability hedge (requires_defines / default_status). The FIPS module_version and cmvp_cert values below are ILLUSTRATIVE placeholders; substitute the real validated module version and CMVP certificate number. gen-advisory folds these into both the CSAF and CycloneDX VEX outputs. requires_defines/default_status are recorded as informational notes only -- this tool does not compute per-build reachability.", - - "CVE-2026-5501": { - "state": "exploitable", - "response": ["update"], - "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", - "fixed_versions": ["5.9.1"], - "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation.", - "fips": { - "name": "wolfCrypt FIPS 140-3 Module", - "module_version": "5.2.1", - "cmvp_cert": "4718", - "status": "not_affected", - "justification": "code_not_present", - "remediation": "No action required for the FIPS-validated module: the affected OpenSSL compatibility layer (wolfSSL_X509_verify_cert) is outside the wolfCrypt FIPS module boundary." - } - }, - - "CVE-2026-5778": { - "state": "exploitable", - "response": ["update"], - "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", - "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], - "default_status": "off", - "fixed_versions": ["5.9.1"], - "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected.", - "fips": { - "name": "wolfCrypt FIPS 140-3 Module", - "module_version": "5.2.1", - "cmvp_cert": "4718", - "status": "not_affected", - "justification": "code_not_present", - "remediation": "No action required for the FIPS-validated module: the packet sniffer (src/sniffer.c) is outside the wolfCrypt FIPS module boundary." - } - }, - - "CVE-2026-5999": { - "state": "exploitable", - "response": ["update"], - "detail": "Synthetic fixture overlay: a simple mainline-only finding (no separate FIPS product) used to exercise the CVSS v3.1 scores[] path.", - "fixed_versions": ["5.9.1"], - "remediation": "Update to wolfSSL 5.9.1 or later." - } -} +{ + "_comment": "Human-authored VEX determinations keyed by CVE id. The CVE Program record carries the structural facts (CWE/CVSS/affected ranges); this overlay carries what it cannot express in machine-readable form: the analysis state, the not-affected justification, the response, free-text scope detail, the mainline fixed release version(s), an optional separately-modelled FIPS product entry, and an optional no-cost build-reachability hedge (requires_defines / default_status). The FIPS module_version and cmvp_cert values below are ILLUSTRATIVE placeholders; substitute the real validated module version and CMVP certificate number. gen-advisory folds these into both the CSAF and CycloneDX VEX outputs. requires_defines/default_status are recorded as informational notes only -- this tool does not compute per-build reachability.", + + "CVE-2026-5501": { + "state": "exploitable", + "response": ["update"], + "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the affected OpenSSL compatibility layer (wolfSSL_X509_verify_cert) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5778": { + "state": "exploitable", + "response": ["update"], + "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", + "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], + "default_status": "off", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the packet sniffer (src/sniffer.c) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5999": { + "state": "exploitable", + "response": ["update"], + "detail": "Synthetic fixture overlay: a simple mainline-only finding (no separate FIPS product) used to exercise the CVSS v3.1 scores[] path.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later." + } +} diff --git a/scripts/advisory-vex-overlay.schema.json b/scripts/advisory-vex-overlay.schema.json index 9ae5e3e05d4..09b42c0bee4 100644 --- a/scripts/advisory-vex-overlay.schema.json +++ b/scripts/advisory-vex-overlay.schema.json @@ -1,117 +1,117 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://www.wolfssl.com/schema/advisory-vex-overlay-1.json", - "title": "wolfSSL gen-advisory VEX overlay", - "description": "Human-authored VEX determinations keyed by CVE id, consumed by scripts/gen-advisory. The CVE Program record supplies the structural facts (CWE/CVSS/affected ranges); this overlay supplies what the record cannot express in machine-readable form. Enum values mirror the CycloneDX 1.6 vulnerability analysis vocabulary so the same terms map cleanly into both the CSAF and CycloneDX VEX outputs.", - "type": "object", - "properties": { - "_comment": { - "type": "string", - "description": "Free-text note ignored by gen-advisory." - } - }, - "patternProperties": { - "^CVE-[0-9]{4}-[0-9]{4,}$": { "$ref": "#/$defs/overlayEntry" } - }, - "additionalProperties": false, - "$defs": { - "analysisState": { - "type": "string", - "description": "CycloneDX 1.6 vulnerability analysis state.", - "enum": [ - "resolved", - "resolved_with_pedigree", - "exploitable", - "in_triage", - "false_positive", - "not_affected" - ] - }, - "justification": { - "type": "string", - "description": "CycloneDX 1.6 impact analysis justification (required by gen-advisory when state is not_affected so a CSAF flag can be emitted).", - "enum": [ - "code_not_present", - "code_not_reachable", - "requires_configuration", - "requires_dependency", - "requires_environment", - "protected_by_compiler", - "protected_at_perimeter", - "protected_at_runtime", - "protected_by_mitigating_control" - ] - }, - "response": { - "type": "array", - "description": "CycloneDX 1.6 vulnerability analysis response.", - "items": { - "type": "string", - "enum": [ - "can_not_fix", - "will_not_fix", - "update", - "rollback", - "workaround_available" - ] - } - }, - "versionList": { - "type": "array", - "items": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "fips": { - "type": "object", - "description": "Optional separately-modelled FIPS product entry. FIPS customers cannot freely upgrade and many CVEs fall outside the validated module boundary, so FIPS is modelled as its own product with its own status and remediation.", - "properties": { - "name": { "type": "string", "minLength": 1 }, - "module_version": { "type": "string", "minLength": 1 }, - "cmvp_cert": { - "type": "string", - "minLength": 1, - "description": "CMVP certificate number, recorded as a CSAF model_number / CycloneDX property." - }, - "status": { "$ref": "#/$defs/analysisState" }, - "justification": { "$ref": "#/$defs/justification" }, - "fixed_versions": { "$ref": "#/$defs/versionList" }, - "remediation": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false, - "allOf": [ - { - "if": { "properties": { "status": { "const": "not_affected" } }, "required": ["status"] }, - "then": { "required": ["justification"] } - } - ] - }, - "overlayEntry": { - "type": "object", - "properties": { - "state": { "$ref": "#/$defs/analysisState" }, - "justification": { "$ref": "#/$defs/justification" }, - "response": { "$ref": "#/$defs/response" }, - "detail": { "type": "string" }, - "fixed_versions": { "$ref": "#/$defs/versionList" }, - "remediation": { "type": "string", "minLength": 1 }, - "requires_defines": { - "type": "array", - "items": { "type": "string", "minLength": 1 }, - "description": "Build flags that gate the vulnerable code. Recorded as an informational note only; gen-advisory does NOT compute per-build reachability." - }, - "default_status": { - "type": "string", - "enum": ["on", "off", "enabled", "disabled"] - }, - "fips": { "$ref": "#/$defs/fips" } - }, - "additionalProperties": false, - "allOf": [ - { - "if": { "properties": { "state": { "const": "not_affected" } }, "required": ["state"] }, - "then": { "required": ["justification"] } - } - ] - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.wolfssl.com/schema/advisory-vex-overlay-1.json", + "title": "wolfSSL gen-advisory VEX overlay", + "description": "Human-authored VEX determinations keyed by CVE id, consumed by scripts/gen-advisory. The CVE Program record supplies the structural facts (CWE/CVSS/affected ranges); this overlay supplies what the record cannot express in machine-readable form. Enum values mirror the CycloneDX 1.6 vulnerability analysis vocabulary so the same terms map cleanly into both the CSAF and CycloneDX VEX outputs.", + "type": "object", + "properties": { + "_comment": { + "type": "string", + "description": "Free-text note ignored by gen-advisory." + } + }, + "patternProperties": { + "^CVE-[0-9]{4}-[0-9]{4,}$": { "$ref": "#/$defs/overlayEntry" } + }, + "additionalProperties": false, + "$defs": { + "analysisState": { + "type": "string", + "description": "CycloneDX 1.6 vulnerability analysis state.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ] + }, + "justification": { + "type": "string", + "description": "CycloneDX 1.6 impact analysis justification (required by gen-advisory when state is not_affected so a CSAF flag can be emitted).", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_perimeter", + "protected_at_runtime", + "protected_by_mitigating_control" + ] + }, + "response": { + "type": "array", + "description": "CycloneDX 1.6 vulnerability analysis response.", + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ] + } + }, + "versionList": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "fips": { + "type": "object", + "description": "Optional separately-modelled FIPS product entry. FIPS customers cannot freely upgrade and many CVEs fall outside the validated module boundary, so FIPS is modelled as its own product with its own status and remediation.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "module_version": { "type": "string", "minLength": 1 }, + "cmvp_cert": { + "type": "string", + "minLength": 1, + "description": "CMVP certificate number, recorded as a CSAF model_number / CycloneDX property." + }, + "status": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "status": { "const": "not_affected" } }, "required": ["status"] }, + "then": { "required": ["justification"] } + } + ] + }, + "overlayEntry": { + "type": "object", + "properties": { + "state": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "response": { "$ref": "#/$defs/response" }, + "detail": { "type": "string" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 }, + "requires_defines": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Build flags that gate the vulnerable code. Recorded as an informational note only; gen-advisory does NOT compute per-build reachability." + }, + "default_status": { + "type": "string", + "enum": ["on", "off", "enabled", "disabled"] + }, + "fips": { "$ref": "#/$defs/fips" } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "state": { "const": "not_affected" } }, "required": ["state"] }, + "then": { "required": ["justification"] } + } + ] + } + } +} diff --git a/scripts/csaf_validate.mjs b/scripts/csaf_validate.mjs index 3d9fc12215f..19c39972232 100644 --- a/scripts/csaf_validate.mjs +++ b/scripts/csaf_validate.mjs @@ -1,83 +1,83 @@ -// CSAF 2.0 conformance gate for documents emitted by scripts/gen-advisory. -// -// JSON-schema validity is necessary but NOT sufficient for CSAF: the standard -// defines a battery of *mandatory tests* (section 6.1.*) -- CVSS/vector -// consistency, contradicting product status, product_id defined/used, -// tracking.version vs revision_history, and so on -- that a bare schema pass -// happily accepts. This runner uses the Secvisogram reference implementation -// (@secvisogram/csaf-validator-lib) which bundles every schema (incl. the -// first.org CVSS schemas) and implements those mandatory tests, so the check -// is fully offline and reproducible once the pinned dependency is installed. -// -// Gate = the strict CSAF 2.0 schema test + all mandatory tests. Optional and -// informative tests are reported as warnings only (they encode house-style -// preferences, not conformance). -// -// Usage: node scripts/csaf_validate.mjs [ ...] -// Exit 0 if every document passes the gate, 1 otherwise. - -import { readFileSync } from 'node:fs' -import validate from '@secvisogram/csaf-validator-lib/validate.js' -import * as schemaTests from '@secvisogram/csaf-validator-lib/schemaTests.js' -import * as mandatoryTests from '@secvisogram/csaf-validator-lib/mandatoryTests.js' -import * as optionalTests from '@secvisogram/csaf-validator-lib/optionalTests.js' - -const files = process.argv.slice(2) -if (files.length === 0) { - console.error('usage: node scripts/csaf_validate.mjs ...') - process.exit(2) -} - -// The gate: strict 2.0 schema + every mandatory test. -const gateTests = [schemaTests.csaf_2_0_strict, ...Object.values(mandatoryTests)] -// Reported for visibility but non-fatal. -const advisoryTests = [...Object.values(optionalTests)] - -function summarize(testResults) { - // testResults: [{ name, isValid, errors, warnings, infos }] - const failed = [] - for (const t of testResults) { - if (t.isValid === false || (t.errors && t.errors.length > 0)) { - failed.push(t) - } - } - return failed -} - -let anyInvalid = false - -for (const file of files) { - let doc - try { - doc = JSON.parse(readFileSync(file, 'utf8')) - } catch (e) { - console.error(`ERROR: cannot read/parse ${file}: ${e.message}`) - anyInvalid = true - continue - } - - const gate = await validate(gateTests, doc) - const advisory = await validate(advisoryTests, doc) - - if (gate.isValid) { - console.log(`OK ${file} (strict schema + ${Object.keys(mandatoryTests).length} mandatory tests)`) - } else { - anyInvalid = true - console.error(`FAIL ${file}`) - for (const t of summarize(gate.tests)) { - for (const err of t.errors || []) { - console.error(` [${t.name}] ${err.instancePath || '/'}: ${err.message}`) - } - } - } - - // Surface optional-test warnings without failing the build. - const optWarn = summarize(advisory.tests) - for (const t of optWarn) { - for (const err of t.errors || []) { - console.warn(` warn ${file} [${t.name}] ${err.instancePath || '/'}: ${err.message}`) - } - } -} - -process.exit(anyInvalid ? 1 : 0) +// CSAF 2.0 conformance gate for documents emitted by scripts/gen-advisory. +// +// JSON-schema validity is necessary but NOT sufficient for CSAF: the standard +// defines a battery of *mandatory tests* (section 6.1.*) -- CVSS/vector +// consistency, contradicting product status, product_id defined/used, +// tracking.version vs revision_history, and so on -- that a bare schema pass +// happily accepts. This runner uses the Secvisogram reference implementation +// (@secvisogram/csaf-validator-lib) which bundles every schema (incl. the +// first.org CVSS schemas) and implements those mandatory tests, so the check +// is fully offline and reproducible once the pinned dependency is installed. +// +// Gate = the strict CSAF 2.0 schema test + all mandatory tests. Optional and +// informative tests are reported as warnings only (they encode house-style +// preferences, not conformance). +// +// Usage: node scripts/csaf_validate.mjs [ ...] +// Exit 0 if every document passes the gate, 1 otherwise. + +import { readFileSync } from 'node:fs' +import validate from '@secvisogram/csaf-validator-lib/validate.js' +import * as schemaTests from '@secvisogram/csaf-validator-lib/schemaTests.js' +import * as mandatoryTests from '@secvisogram/csaf-validator-lib/mandatoryTests.js' +import * as optionalTests from '@secvisogram/csaf-validator-lib/optionalTests.js' + +const files = process.argv.slice(2) +if (files.length === 0) { + console.error('usage: node scripts/csaf_validate.mjs ...') + process.exit(2) +} + +// The gate: strict 2.0 schema + every mandatory test. +const gateTests = [schemaTests.csaf_2_0_strict, ...Object.values(mandatoryTests)] +// Reported for visibility but non-fatal. +const advisoryTests = [...Object.values(optionalTests)] + +function summarize(testResults) { + // testResults: [{ name, isValid, errors, warnings, infos }] + const failed = [] + for (const t of testResults) { + if (t.isValid === false || (t.errors && t.errors.length > 0)) { + failed.push(t) + } + } + return failed +} + +let anyInvalid = false + +for (const file of files) { + let doc + try { + doc = JSON.parse(readFileSync(file, 'utf8')) + } catch (e) { + console.error(`ERROR: cannot read/parse ${file}: ${e.message}`) + anyInvalid = true + continue + } + + const gate = await validate(gateTests, doc) + const advisory = await validate(advisoryTests, doc) + + if (gate.isValid) { + console.log(`OK ${file} (strict schema + ${Object.keys(mandatoryTests).length} mandatory tests)`) + } else { + anyInvalid = true + console.error(`FAIL ${file}`) + for (const t of summarize(gate.tests)) { + for (const err of t.errors || []) { + console.error(` [${t.name}] ${err.instancePath || '/'}: ${err.message}`) + } + } + } + + // Surface optional-test warnings without failing the build. + const optWarn = summarize(advisory.tests) + for (const t of optWarn) { + for (const err of t.errors || []) { + console.warn(` warn ${file} [${t.name}] ${err.instancePath || '/'}: ${err.message}`) + } + } +} + +process.exit(anyInvalid ? 1 : 0) diff --git a/scripts/gen-advisory b/scripts/gen-advisory index 0cbf15b38df..8bfcc2c099c 100755 --- a/scripts/gen-advisory +++ b/scripts/gen-advisory @@ -1,846 +1,846 @@ -#!/usr/bin/env python3 -"""Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX documents from wolfSSL -CVE Program records (CVE JSON 5.x). - -The CVE record (the authoritative artefact wolfSSL authors as a CNA, e.g. with -Vulnogram and published to cve.org / cvelistV5) supplies the structural facts: -CVE id, title, description, CWE, CVSS, affected/fixed version ranges, -references, credits, and dates. - -What a CVE record does NOT carry in machine-readable form is the VEX -*determination* and the wolfSSL-specific product nuances. Those live in a -small per-CVE overlay (--vex-overlay): - - * state / justification / response / detail -- the VEX determination. - * fixed_versions / remediation -- the mainline fix guidance. - * fips { ... } -- a separate FIPS product entry - (own module version + CMVP certificate, own status, own remediation): - FIPS customers cannot freely upgrade, and many CVEs fall outside the - FIPS module boundary, so FIPS is modelled as a distinct product. - * requires_defines / default_status -- a no-cost hedge: which build - flag gates the vulnerable code. Recorded as an informational note - only; this tool does NOT compute per-build reachability (that would be - a future, safety-critical "build-aware VEX" feature). - -Granularity follows Red Hat's model: per-CVE is the default (the VEX -automation primitive); pass several records to emit one *bundled* per-release -CSAF advisory + one CycloneDX BOM carrying all vulnerabilities[]. - -This mirrors scripts/gen-sbom: pure stdlib, SOURCE_DATE_EPOCH-reproducible, -deterministic UUIDs, fail-rather-than-emit-garbage on malformed input. -""" - -import argparse -import json -import os -import pathlib -import sys -import urllib.request -import uuid -from datetime import datetime, timezone - - -GEN_TOOL_NAME = 'wolfssl-advisory-gen' -GEN_TOOL_VERSION = '0.3' - -_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent -_REPO_ROOT = _SCRIPTS_DIR.parent - -# Canonical single-source-of-truth for wolfSSL advisories. `make advisory` -# and a bare `gen-advisory` invocation both default to these locations, so the -# tool and the build target are interchangeable. testdata/ is a *separate*, -# frozen copy used only by the test suite (see scripts/testdata/README.md). -DEFAULT_RECORDS_DIR = _REPO_ROOT / 'advisories' / 'records' -DEFAULT_OVERLAY = _REPO_ROOT / 'advisories' / 'vex-overlay.json' -DEFAULT_OUT_DIR = _REPO_ROOT / 'advisories' / 'out' - -# Official CWE id -> name catalogue, shipped alongside this script. CSAF -# mandatory test 6.1.11 requires /vulnerabilities[]/cwe/name to be the *exact* -# MITRE name for the id, so we resolve the name from the catalogue rather than -# trusting the (often differently-cased) free text in the CVE record's -# problemType. Regenerate scripts/cwe-names.json from the official CWE list -# when MITRE publishes a new version. -_CWE_NAMES_PATH = _SCRIPTS_DIR / 'cwe-names.json' - - -def _load_cwe_names(): - try: - with open(_CWE_NAMES_PATH) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - # Absence is non-fatal: gen-advisory simply omits cwe.name (and hence - # the whole cwe object) from CSAF, which keeps the output conformant. - return {} - - -CWE_NAMES = _load_cwe_names() - -WOLFSSL_VENDOR = 'wolfSSL' -WOLFSSL_PUBLISHER = { - 'category': 'vendor', - 'name': 'wolfSSL Inc.', - 'namespace': 'https://www.wolfssl.com', -} -WOLFSSL_ADVISORIES_URL = 'https://www.wolfssl.com/docs/security-vulnerabilities/' - -ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, - 'https://wolfssl.com/advisory/') - -# Severity ordering for picking a bundle's aggregate_severity. -_SEV_RANK = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'MODERATE': 2, 'LOW': 1, - 'NONE': 0} - -# CycloneDX analysis.state -> CSAF product_status bucket for affected products. -# not_affected is handled separately (it also needs a flag justification). -_STATE_TO_BUCKET = { - 'exploitable': 'known_affected', - 'in_triage': 'under_investigation', - 'resolved': 'known_affected', - 'not_affected': 'known_not_affected', -} - -# CycloneDX not_affected justification -> CSAF flag label (same VEX concept). -_JUSTIFICATION_TO_CSAF_FLAG = { - 'code_not_present': 'vulnerable_code_not_present', - 'code_not_reachable': 'vulnerable_code_not_in_execute_path', - 'requires_configuration': 'vulnerable_code_not_in_execute_path', - 'requires_dependency': 'vulnerable_code_not_in_execute_path', - 'requires_environment': 'vulnerable_code_not_in_execute_path', - 'protected_by_compiler': 'inline_mitigations_already_exist', - 'protected_at_perimeter': 'inline_mitigations_already_exist', - 'protected_at_runtime': 'inline_mitigations_already_exist', - 'protected_by_mitigating_control': 'inline_mitigations_already_exist', -} - - -def derived_uuid(*parts): - """Deterministic UUID from joined parts (NUL-separated, no aliasing).""" - return str(uuid.uuid5(ADVISORY_UUID_NAMESPACE, '\x00'.join(parts))) - - -def build_timestamp(): - """(datetime, ISO-8601-Z) honoring SOURCE_DATE_EPOCH for reproducibility.""" - 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') - - -def cpe_for(product, version): - """CPE 2.3 for a wolfSSL product at a version (matches gen-sbom).""" - return f'cpe:2.3:a:wolfssl:{product.lower()}:{version}:*:*:*:*:*:*:*' - - -def purl_for(product, version): - """PURL for a wolfSSL product release (matches gen-sbom: pkg:github).""" - return f'pkg:github/wolfSSL/{product.lower()}@v{version}' - - -# --------------------------------------------------------------------------- # -# CVE record parsing -# --------------------------------------------------------------------------- # - -def load_cve_record(path=None, cve_id=None): - if path: - try: - with open(path) as f: - return json.load(f) - except (OSError, json.JSONDecodeError) as e: - sys.exit(f"ERROR: cannot read CVE record {path!r}: {e}") - url = f'https://cveawg.mitre.org/api/cve/{cve_id}' - try: - with urllib.request.urlopen(url, timeout=30) as r: - return json.loads(r.read().decode()) - except Exception as e: # noqa: BLE001 - surface any fetch/parse failure - sys.exit(f"ERROR: cannot fetch {url}: {e}") - - -def _cna(record): - try: - return record['containers']['cna'] - except (KeyError, TypeError): - sys.exit("ERROR: CVE record has no containers.cna section") - - -def _best_cvss(metrics): - """Pick the highest-priority CVSS block (v4 > v3.1 > v3.0 > v2). - - Used for the CycloneDX rating (which supports v4) and for the document's - aggregate_severity.""" - for key, ver, method in ( - ('cvssV4_0', 'v4', 'CVSSv4'), - ('cvssV3_1', 'v3', 'CVSSv31'), - ('cvssV3_0', 'v3', 'CVSSv3'), - ('cvssV2_0', 'v2', 'CVSSv2'), - ): - for m in metrics: - if key in m: - return {'data': m[key], 'csaf_key': f'cvss_{ver}', - 'cdx_method': method} - return None - - -def _best_cvss_csaf20(metrics): - """Pick the highest-priority CVSS block that CSAF 2.0 scores[] can carry. - - The CSAF 2.0 schema predates CVSS v4 and only defines cvss_v2 / cvss_v3 in - a score object, so a v4 block must NOT be placed there (it fails the strict - schema). When only v4 exists the CSAF emitter records it as a note and - relies on the CycloneDX VEX output for the machine-readable v4 rating.""" - for key, ver in (('cvssV3_1', 'v3'), ('cvssV3_0', 'v3'), ('cvssV2_0', 'v2')): - for m in metrics: - if key in m: - return {'data': m[key], 'csaf_key': f'cvss_{ver}'} - return None - - -def parse_record(record): - """Reduce a CVE 5.x record to the fields the emitters need.""" - meta = record.get('cveMetadata', {}) - cna = _cna(record) - - cve_id = meta.get('cveId') or cna.get('cveId') - if not cve_id: - sys.exit("ERROR: CVE record has no cveId") - - description = '' - for d in cna.get('descriptions', []): - if d.get('lang', '').lower().startswith('en'): - description = d.get('value', '') - break - - # CSAF 2.0 carries a single `cwe` {id, name}; take the primary one. The - # name is resolved from the official CWE catalogue (CWE_NAMES) so it is the - # exact MITRE string CSAF test 6.1.11 checks against -- the record's - # free-text problemType often differs in casing. `name` may be None when - # the id is absent from the catalogue; the CSAF emitter then omits cwe. - cwe = None - for pt in cna.get('problemTypes', []): - for d in pt.get('descriptions', []): - cid = d.get('cweId') - if not cid: - continue - cwe = {'id': cid, 'name': CWE_NAMES.get(cid)} - break - if cwe: - break - - metrics = cna.get('metrics', []) - cvss = _best_cvss(metrics) - cvss_csaf = _best_cvss_csaf20(metrics) - - affected = [] - for a in cna.get('affected', []): - affected.append({ - 'vendor': a.get('vendor', WOLFSSL_VENDOR), - 'product': a.get('product', 'wolfSSL'), - 'versions': a.get('versions', []), - 'default_status': a.get('defaultStatus', 'unknown'), - }) - - references = [r['url'] for r in cna.get('references', []) if r.get('url')] - credits = [c.get('value', '') for c in cna.get('credits', []) - if c.get('value')] - - return { - 'cve': cve_id, - 'title': cna.get('title') or cve_id, - 'description': description, - 'cwe': cwe, - 'cvss': cvss, - 'cvss_csaf': cvss_csaf, - 'affected': affected, - 'references': references, - 'credits': credits, - 'date_published': meta.get('datePublished'), - 'date_updated': meta.get('dateUpdated'), - } - - -def _range_label(v): - base = v.get('version', '0') - if v.get('lessThanOrEqual'): - return f'<= {v["lessThanOrEqual"]}' if base in ('0', '*') \ - else f'{base} <= x <= {v["lessThanOrEqual"]}' - if v.get('lessThan'): - return f'< {v["lessThan"]}' if base in ('0', '*') \ - else f'{base} <= x < {v["lessThan"]}' - return base - - -def _vers_range(v): - parts = [] - base = v.get('version') - if base and base not in ('0', '*'): - parts.append(f'>={base}') - if v.get('lessThanOrEqual'): - parts.append(f'<={v["lessThanOrEqual"]}') - elif v.get('lessThan'): - parts.append(f'<{v["lessThan"]}') - elif base and base not in ('0', '*'): - return f'vers:generic/{base}' - return 'vers:generic/' + '|'.join(parts) if parts else 'vers:generic/*' - - -# --------------------------------------------------------------------------- # -# Product model: normalize record + overlay into a list of product entries. -# --------------------------------------------------------------------------- # - -def product_model(adv, ov): - """Return the products this CVE describes: the mainline wolfSSL product - (from the CVE record) plus an optional, separately-modelled FIPS product - (from the overlay).""" - products = [] - - state = ov.get('state', 'exploitable') - bucket = _STATE_TO_BUCKET.get(state, 'known_affected') - - # ---- mainline product(s) from the CVE record's affected[] ---- - for a in adv['affected']: - product = a['product'] - affected_ranges = [] - for v in a['versions']: - if v.get('status') == 'affected' or a['default_status'] == 'affected': - affected_ranges.append({ - 'label': _range_label(v), - 'cpe': cpe_for(product, '*'), - 'vers': _vers_range(v), - }) - fixed = [] - for fv in ov.get('fixed_versions', []): - fixed.append({'version': fv, 'cpe': cpe_for(product, fv), - 'purl': purl_for(product, fv)}) - remediation = ov.get('remediation') - if not remediation and fixed: - remediation = f'Update to {product} {fixed[0]["version"]} or later.' - products.append({ - 'product_name': product, - 'cdx_key': product.lower(), - 'bucket': bucket, - 'justification': ov.get('justification') if bucket - == 'known_not_affected' else None, - 'remediation': remediation, - 'remediation_category': 'vendor_fix' if fixed else 'none_available', - 'affected_ranges': affected_ranges, - 'fixed': fixed, - 'model_numbers': [], - }) - - # ---- optional FIPS product from the overlay ---- - fips = ov.get('fips') - if fips: - name = fips.get('name', 'wolfCrypt FIPS Module') - modver = fips.get('module_version') - fbucket = _STATE_TO_BUCKET.get(fips.get('status', 'exploitable'), - 'known_affected') - affected_ranges = [] - if modver: - affected_ranges.append({ - 'label': modver, - 'cpe': cpe_for('wolfcrypt', modver), - 'vers': f'vers:generic/{modver}', - }) - fixed = [] - for fv in fips.get('fixed_versions', []): - fixed.append({'version': fv, 'cpe': cpe_for('wolfcrypt', fv), - 'purl': purl_for('wolfcrypt', fv)}) - model_numbers = [] - if fips.get('cmvp_cert'): - model_numbers.append(f'CMVP Certificate #{fips["cmvp_cert"]}') - products.append({ - 'product_name': name, - 'cdx_key': 'wolfcrypt-fips', - 'bucket': fbucket, - 'justification': fips.get('justification') if fbucket - == 'known_not_affected' else None, - 'remediation': fips.get('remediation'), - 'remediation_category': 'vendor_fix' if fixed else ( - 'no_fix_planned' if fbucket == 'known_not_affected' - else 'none_available'), - 'affected_ranges': affected_ranges, - 'fixed': fixed, - 'model_numbers': model_numbers, - 'module_version': modver, - }) - - return products - - -def _hedge_note(ov): - """Render the no-cost reachability hedge as informational text (only).""" - bits = [] - defines = ov.get('requires_defines') - if defines: - bits.append('Reachable only in builds compiled with: ' - + ', '.join(defines) + '.') - if ov.get('default_status') in ('off', 'disabled'): - bits.append('The affected feature is disabled by default.') - return ' '.join(bits) if bits else None - - -# --------------------------------------------------------------------------- # -# CSAF 2.0 emitter (handles 1..N vulnerabilities in one document) -# --------------------------------------------------------------------------- # - -def generate_csaf(advs, ov_map, advisory_id, timestamp): - # Shared product_tree: product leaves are deduplicated by product_id across - # all CVEs in the bundle (Red Hat-style shared product ids). - tree = {} # (vendor, product_name) -> {product_id: leaf} - tree_order = [] # preserve insertion order of (vendor, product_name) - vulns = [] - agg_rank = -1 - agg_text = None - init_dates = [] - cur_dates = [] - - def _leaf_range(pname, label, cpe, model_numbers): - pid = derived_uuid(pname, 'range', label) - helper = {'cpe': cpe} - if model_numbers: - helper['model_numbers'] = model_numbers - return pid, { - 'category': 'product_version_range', - 'name': label, - 'product': { - 'product_id': pid, - 'name': f'{pname} {label}', - 'product_identification_helper': helper, - }, - } - - def _leaf_fixed(pname, fx, model_numbers): - pid = derived_uuid(pname, 'fixed', fx['version']) - helper = {'cpe': fx['cpe'], 'purl': fx['purl']} - if model_numbers: - helper['model_numbers'] = model_numbers - return pid, { - 'category': 'product_version', - 'name': fx['version'], - 'product': { - 'product_id': pid, - 'name': f'{pname} {fx["version"]}', - 'product_identification_helper': helper, - }, - } - - def _register(pname, pid, leaf): - key = (WOLFSSL_VENDOR, pname) - if key not in tree: - tree[key] = {} - tree_order.append(key) - tree[key].setdefault(pid, leaf) - - for adv in advs: - ov = ov_map.get(adv['cve'], {}) - products = product_model(adv, ov) - - status_buckets = {} # bucket -> [pids] - score_targets = [] - flags = {} # flag_label -> [pids] - remediations = {} # (category, text, url) -> [pids] - - for prod in products: - pname = prod['product_name'] - affected_pids = [] - for r in prod['affected_ranges']: - pid, leaf = _leaf_range(pname, r['label'], r['cpe'], - prod['model_numbers']) - _register(pname, pid, leaf) - affected_pids.append(pid) - status_buckets.setdefault(prod['bucket'], []).append(pid) - if prod['bucket'] in ('known_affected', 'under_investigation'): - score_targets.append(pid) - if prod['bucket'] == 'known_not_affected': - flag = _JUSTIFICATION_TO_CSAF_FLAG.get( - prod['justification'] or '', - 'vulnerable_code_not_in_execute_path') - flags.setdefault(flag, []).append(pid) - for fx in prod['fixed']: - pid, leaf = _leaf_fixed(pname, fx, prod['model_numbers']) - _register(pname, pid, leaf) - status_buckets.setdefault('fixed', []).append(pid) - if prod['remediation'] and affected_pids: - url = adv['references'][0] if adv['references'] else None - key = (prod['remediation_category'], prod['remediation'], url) - remediations.setdefault(key, []).extend(affected_pids) - - vuln = { - 'cve': adv['cve'], - 'notes': [{ - 'category': 'description', - 'text': adv['description'], - 'title': 'Vulnerability description', - }], - 'product_status': {k: v for k, v in status_buckets.items() if v}, - } - hedge = _hedge_note(ov) - if hedge: - vuln['notes'].append({ - 'category': 'other', 'title': 'Build reachability', 'text': hedge}) - # Emit cwe only when we resolved the exact catalogue name (CSAF 6.1.11). - if adv['cwe'] and adv['cwe'].get('name'): - vuln['cwe'] = {'id': adv['cwe']['id'], 'name': adv['cwe']['name']} - if adv['references']: - vuln['references'] = [{'summary': u, 'url': u, 'category': 'external'} - for u in adv['references']] - if flags: - vuln['flags'] = [{'label': lbl, 'product_ids': pids} - for lbl, pids in flags.items()] - if remediations: - vuln['remediations'] = [] - for (cat, text, url), pids in remediations.items(): - rem = {'category': cat, 'details': text, 'product_ids': pids} - if url: - rem['url'] = url - vuln['remediations'].append(rem) - # CSAF 2.0 scores[] can only carry CVSS v2/v3 (the schema predates v4). - if adv['cvss_csaf'] and score_targets: - vuln['scores'] = [{adv['cvss_csaf']['csaf_key']: - adv['cvss_csaf']['data'], - 'products': score_targets}] - # aggregate_severity uses the best CVSS available (which may be v4). - best = adv['cvss'] - if best: - sev = best['data'].get('baseSeverity', '').upper() - if _SEV_RANK.get(sev, -1) > agg_rank: - agg_rank = _SEV_RANK[sev] - agg_text = best['data'].get('baseSeverity') - # A v4-only finding cannot be represented in CSAF 2.0 scores[]; - # preserve the rating as a note so it is not silently dropped. - if best['csaf_key'] == 'cvss_v4' and not adv['cvss_csaf']: - v4 = best['data'] - vuln['notes'].append({ - 'category': 'other', - 'title': 'CVSS v4.0', - 'text': (f"CVSS v4.0 base score {v4.get('baseScore')} " - f"({v4.get('baseSeverity')}); vector " - f"{v4.get('vectorString')}. CSAF 2.0 scores[] " - f"cannot encode CVSS v4; the machine-readable v4 " - f"rating is provided in the CycloneDX VEX output."), - }) - if adv['credits']: - vuln['acknowledgments'] = [{'summary': c} for c in adv['credits']] - vulns.append(vuln) - - if adv['date_published']: - init_dates.append(adv['date_published']) - cur_dates.append(adv['date_updated'] or adv['date_published'] or timestamp) - - # Assemble product_tree branches. - branches = [] - for (vendor, pname) in tree_order: - branches.append({ - 'category': 'vendor', 'name': vendor, - 'branches': [{ - 'category': 'product_name', 'name': pname, - 'branches': list(tree[(vendor, pname)].values()), - }], - }) - - bundle = len(advs) > 1 - if bundle: - title = f'wolfSSL Security Advisory {advisory_id}' - else: - title = f'wolfSSL: {advs[0]["title"]}' - - doc = { - 'document': { - 'category': 'csaf_security_advisory', - 'csaf_version': '2.0', - 'title': title, - 'publisher': WOLFSSL_PUBLISHER, - 'tracking': { - 'id': advisory_id, - 'status': 'final', - 'version': '1', - 'initial_release_date': min(init_dates) if init_dates - else timestamp, - 'current_release_date': max(cur_dates) if cur_dates - else timestamp, - 'revision_history': [{ - 'number': '1', - 'date': min(init_dates) if init_dates else timestamp, - 'summary': 'Initial release', - }], - 'generator': { - 'engine': {'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}, - }, - }, - 'distribution': {'tlp': {'label': 'WHITE'}}, - 'references': [{ - 'summary': 'wolfSSL published security vulnerabilities', - 'url': WOLFSSL_ADVISORIES_URL, 'category': 'self'}], - 'notes': [{ - 'category': 'summary', - 'title': 'Summary', - 'text': (f'wolfSSL security advisory {advisory_id} covering ' - f'{len(advs)} vulnerabilities.' if bundle - else advs[0]['description']), - }], - }, - 'product_tree': {'branches': branches}, - 'vulnerabilities': vulns, - } - if agg_text: - doc['document']['aggregate_severity'] = {'text': agg_text} - return doc - - -# --------------------------------------------------------------------------- # -# CycloneDX 1.6 VEX emitter (handles 1..N vulnerabilities in one BOM) -# --------------------------------------------------------------------------- # - -def _cdx_severity(cvss_data): - sev = (cvss_data or {}).get('baseSeverity', '').lower() - return sev if sev in ('critical', 'high', 'medium', 'low', 'none') \ - else 'unknown' - - -def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): - main_ref = derived_uuid('cdx-component', 'wolfssl') - extra_components = {} # ref -> component dict (e.g. FIPS module) - vulns = [] - - for adv in advs: - ov = ov_map.get(adv['cve'], {}) - products = product_model(adv, ov) - - affects = [] - for prod in products: - if prod['cdx_key'] == 'wolfcrypt-fips': - ref = derived_uuid('cdx-component', 'wolfcrypt-fips', - prod.get('module_version') or '') - if ref not in extra_components: - comp = { - 'bom-ref': ref, 'type': 'library', - 'supplier': {'name': 'wolfSSL Inc.'}, - 'name': prod['product_name'], - 'cpe': cpe_for('wolfcrypt', prod.get('module_version') - or '*'), - } - if prod.get('module_version'): - comp['version'] = prod['module_version'] - if prod['model_numbers']: - comp['properties'] = [ - {'name': 'wolfssl:fips:cmvp', 'value': m} - for m in prod['model_numbers']] - extra_components[ref] = comp - else: - ref = main_ref - # CycloneDX affects[].versions[].status uses 'unaffected' for a - # not-affected product; the 'not_affected' term belongs to - # analysis.state only. - astatus = 'unaffected' if prod['bucket'] == 'known_not_affected' \ - else 'affected' - versions = [{'range': r['vers'], 'status': astatus} - for r in prod['affected_ranges']] - versions += [{'version': fx['version'], 'status': 'unaffected'} - for fx in prod['fixed']] - if versions: - affects.append({'ref': ref, 'versions': versions}) - - vuln = { - 'id': adv['cve'], - 'source': {'name': 'wolfSSL', 'url': WOLFSSL_ADVISORIES_URL}, - 'description': adv['description'], - 'affects': affects or [{'ref': main_ref}], - } - if adv['cvss']: - rating = {'source': {'name': 'wolfSSL'}, - 'severity': _cdx_severity(adv['cvss']['data']), - 'method': adv['cvss']['cdx_method']} - if 'baseScore' in adv['cvss']['data']: - rating['score'] = adv['cvss']['data']['baseScore'] - if 'vectorString' in adv['cvss']['data']: - rating['vector'] = adv['cvss']['data']['vectorString'] - vuln['ratings'] = [rating] - if adv['cwe']: - try: - vuln['cwes'] = [int(adv['cwe']['id'].split('-')[-1])] - except ValueError: - pass - if adv['references']: - vuln['advisories'] = [{'url': u} for u in adv['references']] - - analysis = {'state': ov.get('state', 'exploitable')} - if analysis['state'] == 'not_affected' and ov.get('justification'): - analysis['justification'] = ov['justification'] - if ov.get('response'): - analysis['response'] = ov['response'] - detail_bits = [b for b in (ov.get('detail'), _hedge_note(ov)) if b] - if detail_bits: - analysis['detail'] = ' '.join(detail_bits) - vuln['analysis'] = analysis - - if adv['credits']: - vuln['credits'] = {'individuals': [{'name': c} - for c in adv['credits']]} - if adv['date_published']: - vuln['published'] = adv['date_published'] - if adv['date_updated']: - vuln['updated'] = adv['date_updated'] - vulns.append(vuln) - - return { - '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', - 'bomFormat': 'CycloneDX', - 'specVersion': '1.6', - 'serialNumber': f'urn:uuid:{derived_uuid(advisory_id, "serial")}', - 'version': 1, - 'metadata': { - 'timestamp': timestamp, - 'tools': {'components': [{ - 'type': 'application', 'author': 'wolfSSL Inc.', - 'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}]}, - 'component': { - 'bom-ref': main_ref, 'type': 'library', - 'supplier': {'name': 'wolfSSL Inc.'}, - 'name': 'wolfssl', - 'cpe': cpe_for('wolfssl', '*'), - 'purl': 'pkg:github/wolfSSL/wolfssl', - }, - }, - 'components': list(extra_components.values()), - 'vulnerabilities': vulns, - } - - -# --------------------------------------------------------------------------- # - -def load_overlay(path): - if not path: - return {} - try: - with open(path) as f: - return json.load(f) - except (OSError, json.JSONDecodeError) as e: - sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}") - - -def _write_json(obj, path): - try: - with open(path, 'w') as f: - json.dump(obj, f, indent=2) - f.write('\n') - except OSError as e: - sys.exit(f"ERROR: cannot write {path}: {e}") - - -def _emit(advs, ov_map, advisory_id, timestamp, csaf_out, cdx_out): - """Emit one CSAF and/or one CycloneDX document covering `advs`.""" - if csaf_out: - _write_json(generate_csaf(advs, ov_map, advisory_id, timestamp), - csaf_out) - print(f"Generated: {csaf_out}") - if cdx_out: - _write_json(generate_cdx_vex(advs, ov_map, advisory_id, timestamp), - cdx_out) - print(f"Generated: {cdx_out}") - - -def main(): - p = argparse.ArgumentParser( - description='Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX from ' - 'wolfSSL CVE Program records. With no record arguments it ' - 'processes every record in the canonical advisories/ tree ' - '(the same inputs `make advisory` uses), writing one CSAF ' - '+ one CycloneDX document per CVE into the output ' - 'directory.') - p.add_argument('--cve-record', action='append', default=[], - help='Path to a CVE JSON 5.x record (repeatable). Overrides ' - 'the default --records-dir scan.') - p.add_argument('--cve-id', action='append', default=[], - help='Fetch a record from cve.org by id (repeatable). ' - 'Overrides the default --records-dir scan.') - p.add_argument('--records-dir', default=str(DEFAULT_RECORDS_DIR), - help='Directory of CVE JSON 5.x records scanned when no ' - f'--cve-record/--cve-id is given (default: ' - f'{DEFAULT_RECORDS_DIR}).') - p.add_argument('--vex-overlay', default=None, - help='JSON file mapping CVE id -> VEX overlay (state, ' - 'justification, detail, response, fixed_versions, ' - 'remediation, fips{}, requires_defines, ' - f'default_status). Default: {DEFAULT_OVERLAY} if it ' - 'exists.') - p.add_argument('--advisory-id', - help='Tracking id when bundling several records into ONE ' - 'document. Defaults to the CVE id for a single record.') - p.add_argument('--out-dir', default=str(DEFAULT_OUT_DIR), - help='Output directory for the per-CVE documents written in ' - 'batch mode (default: ' f'{DEFAULT_OUT_DIR}).') - p.add_argument('--csaf-out', - help='Write a single CSAF document to this path instead of ' - 'batch mode (one record, or several with ' - '--advisory-id).') - p.add_argument('--cdx-vex-out', - help='Write a single CycloneDX VEX document to this path ' - 'instead of batch mode.') - args = p.parse_args() - - # ---- resolve the input records ---- - explicit = bool(args.cve_record or args.cve_id) - if explicit: - records = [load_cve_record(path=pth) for pth in args.cve_record] - records += [load_cve_record(cve_id=cid) for cid in args.cve_id] - else: - rec_dir = pathlib.Path(args.records_dir) - paths = sorted(rec_dir.glob('*.json')) - if not paths: - sys.exit(f"ERROR: no CVE records found in {rec_dir}. Add records " - f"there, or pass --cve-record / --cve-id.") - records = [load_cve_record(path=str(pth)) for pth in paths] - advs = [parse_record(r) for r in records] - - # ---- resolve the overlay (explicit > default-if-present > none) ---- - overlay_path = args.vex_overlay - if overlay_path is None and DEFAULT_OVERLAY.exists(): - overlay_path = str(DEFAULT_OVERLAY) - ov_map = load_overlay(overlay_path) - if overlay_path: - for adv in advs: - if adv['cve'] not in ov_map: - print(f"WARNING: no VEX overlay entry for {adv['cve']}; " - f"defaulting to state=exploitable", file=sys.stderr) - - _, timestamp = build_timestamp() - - # ---- single-document mode (explicit output path) ---- - if args.csaf_out or args.cdx_vex_out: - if args.advisory_id: - advisory_id = args.advisory_id - elif len(advs) == 1: - advisory_id = advs[0]['cve'] - else: - sys.exit("ERROR: --advisory-id is required when bundling several " - "records into one --csaf-out/--cdx-vex-out document.") - _emit(advs, ov_map, advisory_id, timestamp, - args.csaf_out, args.cdx_vex_out) - return - - # ---- batch mode: one CSAF + one CycloneDX per CVE into --out-dir ---- - out_dir = pathlib.Path(args.out_dir) - try: - out_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - sys.exit(f"ERROR: cannot create output directory {out_dir}: {e}") - if args.advisory_id and len(advs) > 1: - _emit(advs, ov_map, args.advisory_id, timestamp, - str(out_dir / f'{args.advisory_id}.csaf.json'), - str(out_dir / f'{args.advisory_id}.cdx.json')) - else: - for adv in advs: - _emit([adv], ov_map, adv['cve'], timestamp, - str(out_dir / f"{adv['cve']}.csaf.json"), - str(out_dir / f"{adv['cve']}.cdx.json")) - print(f"Wrote {len(advs)} advisory document set(s) to {out_dir}") - - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 +"""Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX documents from wolfSSL +CVE Program records (CVE JSON 5.x). + +The CVE record (the authoritative artefact wolfSSL authors as a CNA, e.g. with +Vulnogram and published to cve.org / cvelistV5) supplies the structural facts: +CVE id, title, description, CWE, CVSS, affected/fixed version ranges, +references, credits, and dates. + +What a CVE record does NOT carry in machine-readable form is the VEX +*determination* and the wolfSSL-specific product nuances. Those live in a +small per-CVE overlay (--vex-overlay): + + * state / justification / response / detail -- the VEX determination. + * fixed_versions / remediation -- the mainline fix guidance. + * fips { ... } -- a separate FIPS product entry + (own module version + CMVP certificate, own status, own remediation): + FIPS customers cannot freely upgrade, and many CVEs fall outside the + FIPS module boundary, so FIPS is modelled as a distinct product. + * requires_defines / default_status -- a no-cost hedge: which build + flag gates the vulnerable code. Recorded as an informational note + only; this tool does NOT compute per-build reachability (that would be + a future, safety-critical "build-aware VEX" feature). + +Granularity follows Red Hat's model: per-CVE is the default (the VEX +automation primitive); pass several records to emit one *bundled* per-release +CSAF advisory + one CycloneDX BOM carrying all vulnerabilities[]. + +This mirrors scripts/gen-sbom: pure stdlib, SOURCE_DATE_EPOCH-reproducible, +deterministic UUIDs, fail-rather-than-emit-garbage on malformed input. +""" + +import argparse +import json +import os +import pathlib +import sys +import urllib.request +import uuid +from datetime import datetime, timezone + + +GEN_TOOL_NAME = 'wolfssl-advisory-gen' +GEN_TOOL_VERSION = '0.3' + +_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPTS_DIR.parent + +# Canonical single-source-of-truth for wolfSSL advisories. `make advisory` +# and a bare `gen-advisory` invocation both default to these locations, so the +# tool and the build target are interchangeable. testdata/ is a *separate*, +# frozen copy used only by the test suite (see scripts/testdata/README.md). +DEFAULT_RECORDS_DIR = _REPO_ROOT / 'advisories' / 'records' +DEFAULT_OVERLAY = _REPO_ROOT / 'advisories' / 'vex-overlay.json' +DEFAULT_OUT_DIR = _REPO_ROOT / 'advisories' / 'out' + +# Official CWE id -> name catalogue, shipped alongside this script. CSAF +# mandatory test 6.1.11 requires /vulnerabilities[]/cwe/name to be the *exact* +# MITRE name for the id, so we resolve the name from the catalogue rather than +# trusting the (often differently-cased) free text in the CVE record's +# problemType. Regenerate scripts/cwe-names.json from the official CWE list +# when MITRE publishes a new version. +_CWE_NAMES_PATH = _SCRIPTS_DIR / 'cwe-names.json' + + +def _load_cwe_names(): + try: + with open(_CWE_NAMES_PATH) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + # Absence is non-fatal: gen-advisory simply omits cwe.name (and hence + # the whole cwe object) from CSAF, which keeps the output conformant. + return {} + + +CWE_NAMES = _load_cwe_names() + +WOLFSSL_VENDOR = 'wolfSSL' +WOLFSSL_PUBLISHER = { + 'category': 'vendor', + 'name': 'wolfSSL Inc.', + 'namespace': 'https://www.wolfssl.com', +} +WOLFSSL_ADVISORIES_URL = 'https://www.wolfssl.com/docs/security-vulnerabilities/' + +ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, + 'https://wolfssl.com/advisory/') + +# Severity ordering for picking a bundle's aggregate_severity. +_SEV_RANK = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'MODERATE': 2, 'LOW': 1, + 'NONE': 0} + +# CycloneDX analysis.state -> CSAF product_status bucket for affected products. +# not_affected is handled separately (it also needs a flag justification). +_STATE_TO_BUCKET = { + 'exploitable': 'known_affected', + 'in_triage': 'under_investigation', + 'resolved': 'known_affected', + 'not_affected': 'known_not_affected', +} + +# CycloneDX not_affected justification -> CSAF flag label (same VEX concept). +_JUSTIFICATION_TO_CSAF_FLAG = { + 'code_not_present': 'vulnerable_code_not_present', + 'code_not_reachable': 'vulnerable_code_not_in_execute_path', + 'requires_configuration': 'vulnerable_code_not_in_execute_path', + 'requires_dependency': 'vulnerable_code_not_in_execute_path', + 'requires_environment': 'vulnerable_code_not_in_execute_path', + 'protected_by_compiler': 'inline_mitigations_already_exist', + 'protected_at_perimeter': 'inline_mitigations_already_exist', + 'protected_at_runtime': 'inline_mitigations_already_exist', + 'protected_by_mitigating_control': 'inline_mitigations_already_exist', +} + + +def derived_uuid(*parts): + """Deterministic UUID from joined parts (NUL-separated, no aliasing).""" + return str(uuid.uuid5(ADVISORY_UUID_NAMESPACE, '\x00'.join(parts))) + + +def build_timestamp(): + """(datetime, ISO-8601-Z) honoring SOURCE_DATE_EPOCH for reproducibility.""" + 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') + + +def cpe_for(product, version): + """CPE 2.3 for a wolfSSL product at a version (matches gen-sbom).""" + return f'cpe:2.3:a:wolfssl:{product.lower()}:{version}:*:*:*:*:*:*:*' + + +def purl_for(product, version): + """PURL for a wolfSSL product release (matches gen-sbom: pkg:github).""" + return f'pkg:github/wolfSSL/{product.lower()}@v{version}' + + +# --------------------------------------------------------------------------- # +# CVE record parsing +# --------------------------------------------------------------------------- # + +def load_cve_record(path=None, cve_id=None): + if path: + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read CVE record {path!r}: {e}") + url = f'https://cveawg.mitre.org/api/cve/{cve_id}' + try: + with urllib.request.urlopen(url, timeout=30) as r: + return json.loads(r.read().decode()) + except Exception as e: # noqa: BLE001 - surface any fetch/parse failure + sys.exit(f"ERROR: cannot fetch {url}: {e}") + + +def _cna(record): + try: + return record['containers']['cna'] + except (KeyError, TypeError): + sys.exit("ERROR: CVE record has no containers.cna section") + + +def _best_cvss(metrics): + """Pick the highest-priority CVSS block (v4 > v3.1 > v3.0 > v2). + + Used for the CycloneDX rating (which supports v4) and for the document's + aggregate_severity.""" + for key, ver, method in ( + ('cvssV4_0', 'v4', 'CVSSv4'), + ('cvssV3_1', 'v3', 'CVSSv31'), + ('cvssV3_0', 'v3', 'CVSSv3'), + ('cvssV2_0', 'v2', 'CVSSv2'), + ): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}', + 'cdx_method': method} + return None + + +def _best_cvss_csaf20(metrics): + """Pick the highest-priority CVSS block that CSAF 2.0 scores[] can carry. + + The CSAF 2.0 schema predates CVSS v4 and only defines cvss_v2 / cvss_v3 in + a score object, so a v4 block must NOT be placed there (it fails the strict + schema). When only v4 exists the CSAF emitter records it as a note and + relies on the CycloneDX VEX output for the machine-readable v4 rating.""" + for key, ver in (('cvssV3_1', 'v3'), ('cvssV3_0', 'v3'), ('cvssV2_0', 'v2')): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}'} + return None + + +def parse_record(record): + """Reduce a CVE 5.x record to the fields the emitters need.""" + meta = record.get('cveMetadata', {}) + cna = _cna(record) + + cve_id = meta.get('cveId') or cna.get('cveId') + if not cve_id: + sys.exit("ERROR: CVE record has no cveId") + + description = '' + for d in cna.get('descriptions', []): + if d.get('lang', '').lower().startswith('en'): + description = d.get('value', '') + break + + # CSAF 2.0 carries a single `cwe` {id, name}; take the primary one. The + # name is resolved from the official CWE catalogue (CWE_NAMES) so it is the + # exact MITRE string CSAF test 6.1.11 checks against -- the record's + # free-text problemType often differs in casing. `name` may be None when + # the id is absent from the catalogue; the CSAF emitter then omits cwe. + cwe = None + for pt in cna.get('problemTypes', []): + for d in pt.get('descriptions', []): + cid = d.get('cweId') + if not cid: + continue + cwe = {'id': cid, 'name': CWE_NAMES.get(cid)} + break + if cwe: + break + + metrics = cna.get('metrics', []) + cvss = _best_cvss(metrics) + cvss_csaf = _best_cvss_csaf20(metrics) + + affected = [] + for a in cna.get('affected', []): + affected.append({ + 'vendor': a.get('vendor', WOLFSSL_VENDOR), + 'product': a.get('product', 'wolfSSL'), + 'versions': a.get('versions', []), + 'default_status': a.get('defaultStatus', 'unknown'), + }) + + references = [r['url'] for r in cna.get('references', []) if r.get('url')] + credits = [c.get('value', '') for c in cna.get('credits', []) + if c.get('value')] + + return { + 'cve': cve_id, + 'title': cna.get('title') or cve_id, + 'description': description, + 'cwe': cwe, + 'cvss': cvss, + 'cvss_csaf': cvss_csaf, + 'affected': affected, + 'references': references, + 'credits': credits, + 'date_published': meta.get('datePublished'), + 'date_updated': meta.get('dateUpdated'), + } + + +def _range_label(v): + base = v.get('version', '0') + if v.get('lessThanOrEqual'): + return f'<= {v["lessThanOrEqual"]}' if base in ('0', '*') \ + else f'{base} <= x <= {v["lessThanOrEqual"]}' + if v.get('lessThan'): + return f'< {v["lessThan"]}' if base in ('0', '*') \ + else f'{base} <= x < {v["lessThan"]}' + return base + + +def _vers_range(v): + parts = [] + base = v.get('version') + if base and base not in ('0', '*'): + parts.append(f'>={base}') + if v.get('lessThanOrEqual'): + parts.append(f'<={v["lessThanOrEqual"]}') + elif v.get('lessThan'): + parts.append(f'<{v["lessThan"]}') + elif base and base not in ('0', '*'): + return f'vers:generic/{base}' + return 'vers:generic/' + '|'.join(parts) if parts else 'vers:generic/*' + + +# --------------------------------------------------------------------------- # +# Product model: normalize record + overlay into a list of product entries. +# --------------------------------------------------------------------------- # + +def product_model(adv, ov): + """Return the products this CVE describes: the mainline wolfSSL product + (from the CVE record) plus an optional, separately-modelled FIPS product + (from the overlay).""" + products = [] + + state = ov.get('state', 'exploitable') + bucket = _STATE_TO_BUCKET.get(state, 'known_affected') + + # ---- mainline product(s) from the CVE record's affected[] ---- + for a in adv['affected']: + product = a['product'] + affected_ranges = [] + for v in a['versions']: + if v.get('status') == 'affected' or a['default_status'] == 'affected': + affected_ranges.append({ + 'label': _range_label(v), + 'cpe': cpe_for(product, '*'), + 'vers': _vers_range(v), + }) + fixed = [] + for fv in ov.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for(product, fv), + 'purl': purl_for(product, fv)}) + remediation = ov.get('remediation') + if not remediation and fixed: + remediation = f'Update to {product} {fixed[0]["version"]} or later.' + products.append({ + 'product_name': product, + 'cdx_key': product.lower(), + 'bucket': bucket, + 'justification': ov.get('justification') if bucket + == 'known_not_affected' else None, + 'remediation': remediation, + 'remediation_category': 'vendor_fix' if fixed else 'none_available', + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': [], + }) + + # ---- optional FIPS product from the overlay ---- + fips = ov.get('fips') + if fips: + name = fips.get('name', 'wolfCrypt FIPS Module') + modver = fips.get('module_version') + fbucket = _STATE_TO_BUCKET.get(fips.get('status', 'exploitable'), + 'known_affected') + affected_ranges = [] + if modver: + affected_ranges.append({ + 'label': modver, + 'cpe': cpe_for('wolfcrypt', modver), + 'vers': f'vers:generic/{modver}', + }) + fixed = [] + for fv in fips.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for('wolfcrypt', fv), + 'purl': purl_for('wolfcrypt', fv)}) + model_numbers = [] + if fips.get('cmvp_cert'): + model_numbers.append(f'CMVP Certificate #{fips["cmvp_cert"]}') + products.append({ + 'product_name': name, + 'cdx_key': 'wolfcrypt-fips', + 'bucket': fbucket, + 'justification': fips.get('justification') if fbucket + == 'known_not_affected' else None, + 'remediation': fips.get('remediation'), + 'remediation_category': 'vendor_fix' if fixed else ( + 'no_fix_planned' if fbucket == 'known_not_affected' + else 'none_available'), + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': model_numbers, + 'module_version': modver, + }) + + return products + + +def _hedge_note(ov): + """Render the no-cost reachability hedge as informational text (only).""" + bits = [] + defines = ov.get('requires_defines') + if defines: + bits.append('Reachable only in builds compiled with: ' + + ', '.join(defines) + '.') + if ov.get('default_status') in ('off', 'disabled'): + bits.append('The affected feature is disabled by default.') + return ' '.join(bits) if bits else None + + +# --------------------------------------------------------------------------- # +# CSAF 2.0 emitter (handles 1..N vulnerabilities in one document) +# --------------------------------------------------------------------------- # + +def generate_csaf(advs, ov_map, advisory_id, timestamp): + # Shared product_tree: product leaves are deduplicated by product_id across + # all CVEs in the bundle (Red Hat-style shared product ids). + tree = {} # (vendor, product_name) -> {product_id: leaf} + tree_order = [] # preserve insertion order of (vendor, product_name) + vulns = [] + agg_rank = -1 + agg_text = None + init_dates = [] + cur_dates = [] + + def _leaf_range(pname, label, cpe, model_numbers): + pid = derived_uuid(pname, 'range', label) + helper = {'cpe': cpe} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version_range', + 'name': label, + 'product': { + 'product_id': pid, + 'name': f'{pname} {label}', + 'product_identification_helper': helper, + }, + } + + def _leaf_fixed(pname, fx, model_numbers): + pid = derived_uuid(pname, 'fixed', fx['version']) + helper = {'cpe': fx['cpe'], 'purl': fx['purl']} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version', + 'name': fx['version'], + 'product': { + 'product_id': pid, + 'name': f'{pname} {fx["version"]}', + 'product_identification_helper': helper, + }, + } + + def _register(pname, pid, leaf): + key = (WOLFSSL_VENDOR, pname) + if key not in tree: + tree[key] = {} + tree_order.append(key) + tree[key].setdefault(pid, leaf) + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + status_buckets = {} # bucket -> [pids] + score_targets = [] + flags = {} # flag_label -> [pids] + remediations = {} # (category, text, url) -> [pids] + + for prod in products: + pname = prod['product_name'] + affected_pids = [] + for r in prod['affected_ranges']: + pid, leaf = _leaf_range(pname, r['label'], r['cpe'], + prod['model_numbers']) + _register(pname, pid, leaf) + affected_pids.append(pid) + status_buckets.setdefault(prod['bucket'], []).append(pid) + if prod['bucket'] in ('known_affected', 'under_investigation'): + score_targets.append(pid) + if prod['bucket'] == 'known_not_affected': + flag = _JUSTIFICATION_TO_CSAF_FLAG.get( + prod['justification'] or '', + 'vulnerable_code_not_in_execute_path') + flags.setdefault(flag, []).append(pid) + for fx in prod['fixed']: + pid, leaf = _leaf_fixed(pname, fx, prod['model_numbers']) + _register(pname, pid, leaf) + status_buckets.setdefault('fixed', []).append(pid) + if prod['remediation'] and affected_pids: + url = adv['references'][0] if adv['references'] else None + key = (prod['remediation_category'], prod['remediation'], url) + remediations.setdefault(key, []).extend(affected_pids) + + vuln = { + 'cve': adv['cve'], + 'notes': [{ + 'category': 'description', + 'text': adv['description'], + 'title': 'Vulnerability description', + }], + 'product_status': {k: v for k, v in status_buckets.items() if v}, + } + hedge = _hedge_note(ov) + if hedge: + vuln['notes'].append({ + 'category': 'other', 'title': 'Build reachability', 'text': hedge}) + # Emit cwe only when we resolved the exact catalogue name (CSAF 6.1.11). + if adv['cwe'] and adv['cwe'].get('name'): + vuln['cwe'] = {'id': adv['cwe']['id'], 'name': adv['cwe']['name']} + if adv['references']: + vuln['references'] = [{'summary': u, 'url': u, 'category': 'external'} + for u in adv['references']] + if flags: + vuln['flags'] = [{'label': lbl, 'product_ids': pids} + for lbl, pids in flags.items()] + if remediations: + vuln['remediations'] = [] + for (cat, text, url), pids in remediations.items(): + rem = {'category': cat, 'details': text, 'product_ids': pids} + if url: + rem['url'] = url + vuln['remediations'].append(rem) + # CSAF 2.0 scores[] can only carry CVSS v2/v3 (the schema predates v4). + if adv['cvss_csaf'] and score_targets: + vuln['scores'] = [{adv['cvss_csaf']['csaf_key']: + adv['cvss_csaf']['data'], + 'products': score_targets}] + # aggregate_severity uses the best CVSS available (which may be v4). + best = adv['cvss'] + if best: + sev = best['data'].get('baseSeverity', '').upper() + if _SEV_RANK.get(sev, -1) > agg_rank: + agg_rank = _SEV_RANK[sev] + agg_text = best['data'].get('baseSeverity') + # A v4-only finding cannot be represented in CSAF 2.0 scores[]; + # preserve the rating as a note so it is not silently dropped. + if best['csaf_key'] == 'cvss_v4' and not adv['cvss_csaf']: + v4 = best['data'] + vuln['notes'].append({ + 'category': 'other', + 'title': 'CVSS v4.0', + 'text': (f"CVSS v4.0 base score {v4.get('baseScore')} " + f"({v4.get('baseSeverity')}); vector " + f"{v4.get('vectorString')}. CSAF 2.0 scores[] " + f"cannot encode CVSS v4; the machine-readable v4 " + f"rating is provided in the CycloneDX VEX output."), + }) + if adv['credits']: + vuln['acknowledgments'] = [{'summary': c} for c in adv['credits']] + vulns.append(vuln) + + if adv['date_published']: + init_dates.append(adv['date_published']) + cur_dates.append(adv['date_updated'] or adv['date_published'] or timestamp) + + # Assemble product_tree branches. + branches = [] + for (vendor, pname) in tree_order: + branches.append({ + 'category': 'vendor', 'name': vendor, + 'branches': [{ + 'category': 'product_name', 'name': pname, + 'branches': list(tree[(vendor, pname)].values()), + }], + }) + + bundle = len(advs) > 1 + if bundle: + title = f'wolfSSL Security Advisory {advisory_id}' + else: + title = f'wolfSSL: {advs[0]["title"]}' + + doc = { + 'document': { + 'category': 'csaf_security_advisory', + 'csaf_version': '2.0', + 'title': title, + 'publisher': WOLFSSL_PUBLISHER, + 'tracking': { + 'id': advisory_id, + 'status': 'final', + 'version': '1', + 'initial_release_date': min(init_dates) if init_dates + else timestamp, + 'current_release_date': max(cur_dates) if cur_dates + else timestamp, + 'revision_history': [{ + 'number': '1', + 'date': min(init_dates) if init_dates else timestamp, + 'summary': 'Initial release', + }], + 'generator': { + 'engine': {'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}, + }, + }, + 'distribution': {'tlp': {'label': 'WHITE'}}, + 'references': [{ + 'summary': 'wolfSSL published security vulnerabilities', + 'url': WOLFSSL_ADVISORIES_URL, 'category': 'self'}], + 'notes': [{ + 'category': 'summary', + 'title': 'Summary', + 'text': (f'wolfSSL security advisory {advisory_id} covering ' + f'{len(advs)} vulnerabilities.' if bundle + else advs[0]['description']), + }], + }, + 'product_tree': {'branches': branches}, + 'vulnerabilities': vulns, + } + if agg_text: + doc['document']['aggregate_severity'] = {'text': agg_text} + return doc + + +# --------------------------------------------------------------------------- # +# CycloneDX 1.6 VEX emitter (handles 1..N vulnerabilities in one BOM) +# --------------------------------------------------------------------------- # + +def _cdx_severity(cvss_data): + sev = (cvss_data or {}).get('baseSeverity', '').lower() + return sev if sev in ('critical', 'high', 'medium', 'low', 'none') \ + else 'unknown' + + +def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): + main_ref = derived_uuid('cdx-component', 'wolfssl') + extra_components = {} # ref -> component dict (e.g. FIPS module) + vulns = [] + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + affects = [] + for prod in products: + if prod['cdx_key'] == 'wolfcrypt-fips': + ref = derived_uuid('cdx-component', 'wolfcrypt-fips', + prod.get('module_version') or '') + if ref not in extra_components: + comp = { + 'bom-ref': ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': prod['product_name'], + 'cpe': cpe_for('wolfcrypt', prod.get('module_version') + or '*'), + } + if prod.get('module_version'): + comp['version'] = prod['module_version'] + if prod['model_numbers']: + comp['properties'] = [ + {'name': 'wolfssl:fips:cmvp', 'value': m} + for m in prod['model_numbers']] + extra_components[ref] = comp + else: + ref = main_ref + # CycloneDX affects[].versions[].status uses 'unaffected' for a + # not-affected product; the 'not_affected' term belongs to + # analysis.state only. + astatus = 'unaffected' if prod['bucket'] == 'known_not_affected' \ + else 'affected' + versions = [{'range': r['vers'], 'status': astatus} + for r in prod['affected_ranges']] + versions += [{'version': fx['version'], 'status': 'unaffected'} + for fx in prod['fixed']] + if versions: + affects.append({'ref': ref, 'versions': versions}) + + vuln = { + 'id': adv['cve'], + 'source': {'name': 'wolfSSL', 'url': WOLFSSL_ADVISORIES_URL}, + 'description': adv['description'], + 'affects': affects or [{'ref': main_ref}], + } + if adv['cvss']: + rating = {'source': {'name': 'wolfSSL'}, + 'severity': _cdx_severity(adv['cvss']['data']), + 'method': adv['cvss']['cdx_method']} + if 'baseScore' in adv['cvss']['data']: + rating['score'] = adv['cvss']['data']['baseScore'] + if 'vectorString' in adv['cvss']['data']: + rating['vector'] = adv['cvss']['data']['vectorString'] + vuln['ratings'] = [rating] + if adv['cwe']: + try: + vuln['cwes'] = [int(adv['cwe']['id'].split('-')[-1])] + except ValueError: + pass + if adv['references']: + vuln['advisories'] = [{'url': u} for u in adv['references']] + + analysis = {'state': ov.get('state', 'exploitable')} + if analysis['state'] == 'not_affected' and ov.get('justification'): + analysis['justification'] = ov['justification'] + if ov.get('response'): + analysis['response'] = ov['response'] + detail_bits = [b for b in (ov.get('detail'), _hedge_note(ov)) if b] + if detail_bits: + analysis['detail'] = ' '.join(detail_bits) + vuln['analysis'] = analysis + + if adv['credits']: + vuln['credits'] = {'individuals': [{'name': c} + for c in adv['credits']]} + if adv['date_published']: + vuln['published'] = adv['date_published'] + if adv['date_updated']: + vuln['updated'] = adv['date_updated'] + vulns.append(vuln) + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'serialNumber': f'urn:uuid:{derived_uuid(advisory_id, "serial")}', + 'version': 1, + 'metadata': { + 'timestamp': timestamp, + 'tools': {'components': [{ + 'type': 'application', 'author': 'wolfSSL Inc.', + 'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}]}, + 'component': { + 'bom-ref': main_ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': 'wolfssl', + 'cpe': cpe_for('wolfssl', '*'), + 'purl': 'pkg:github/wolfSSL/wolfssl', + }, + }, + 'components': list(extra_components.values()), + 'vulnerabilities': vulns, + } + + +# --------------------------------------------------------------------------- # + +def load_overlay(path): + if not path: + return {} + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}") + + +def _write_json(obj, path): + try: + with open(path, 'w') as f: + json.dump(obj, f, indent=2) + f.write('\n') + except OSError as e: + sys.exit(f"ERROR: cannot write {path}: {e}") + + +def _emit(advs, ov_map, advisory_id, timestamp, csaf_out, cdx_out): + """Emit one CSAF and/or one CycloneDX document covering `advs`.""" + if csaf_out: + _write_json(generate_csaf(advs, ov_map, advisory_id, timestamp), + csaf_out) + print(f"Generated: {csaf_out}") + if cdx_out: + _write_json(generate_cdx_vex(advs, ov_map, advisory_id, timestamp), + cdx_out) + print(f"Generated: {cdx_out}") + + +def main(): + p = argparse.ArgumentParser( + description='Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX from ' + 'wolfSSL CVE Program records. With no record arguments it ' + 'processes every record in the canonical advisories/ tree ' + '(the same inputs `make advisory` uses), writing one CSAF ' + '+ one CycloneDX document per CVE into the output ' + 'directory.') + p.add_argument('--cve-record', action='append', default=[], + help='Path to a CVE JSON 5.x record (repeatable). Overrides ' + 'the default --records-dir scan.') + p.add_argument('--cve-id', action='append', default=[], + help='Fetch a record from cve.org by id (repeatable). ' + 'Overrides the default --records-dir scan.') + p.add_argument('--records-dir', default=str(DEFAULT_RECORDS_DIR), + help='Directory of CVE JSON 5.x records scanned when no ' + f'--cve-record/--cve-id is given (default: ' + f'{DEFAULT_RECORDS_DIR}).') + p.add_argument('--vex-overlay', default=None, + help='JSON file mapping CVE id -> VEX overlay (state, ' + 'justification, detail, response, fixed_versions, ' + 'remediation, fips{}, requires_defines, ' + f'default_status). Default: {DEFAULT_OVERLAY} if it ' + 'exists.') + p.add_argument('--advisory-id', + help='Tracking id when bundling several records into ONE ' + 'document. Defaults to the CVE id for a single record.') + p.add_argument('--out-dir', default=str(DEFAULT_OUT_DIR), + help='Output directory for the per-CVE documents written in ' + 'batch mode (default: ' f'{DEFAULT_OUT_DIR}).') + p.add_argument('--csaf-out', + help='Write a single CSAF document to this path instead of ' + 'batch mode (one record, or several with ' + '--advisory-id).') + p.add_argument('--cdx-vex-out', + help='Write a single CycloneDX VEX document to this path ' + 'instead of batch mode.') + args = p.parse_args() + + # ---- resolve the input records ---- + explicit = bool(args.cve_record or args.cve_id) + if explicit: + records = [load_cve_record(path=pth) for pth in args.cve_record] + records += [load_cve_record(cve_id=cid) for cid in args.cve_id] + else: + rec_dir = pathlib.Path(args.records_dir) + paths = sorted(rec_dir.glob('*.json')) + if not paths: + sys.exit(f"ERROR: no CVE records found in {rec_dir}. Add records " + f"there, or pass --cve-record / --cve-id.") + records = [load_cve_record(path=str(pth)) for pth in paths] + advs = [parse_record(r) for r in records] + + # ---- resolve the overlay (explicit > default-if-present > none) ---- + overlay_path = args.vex_overlay + if overlay_path is None and DEFAULT_OVERLAY.exists(): + overlay_path = str(DEFAULT_OVERLAY) + ov_map = load_overlay(overlay_path) + if overlay_path: + for adv in advs: + if adv['cve'] not in ov_map: + print(f"WARNING: no VEX overlay entry for {adv['cve']}; " + f"defaulting to state=exploitable", file=sys.stderr) + + _, timestamp = build_timestamp() + + # ---- single-document mode (explicit output path) ---- + if args.csaf_out or args.cdx_vex_out: + if args.advisory_id: + advisory_id = args.advisory_id + elif len(advs) == 1: + advisory_id = advs[0]['cve'] + else: + sys.exit("ERROR: --advisory-id is required when bundling several " + "records into one --csaf-out/--cdx-vex-out document.") + _emit(advs, ov_map, advisory_id, timestamp, + args.csaf_out, args.cdx_vex_out) + return + + # ---- batch mode: one CSAF + one CycloneDX per CVE into --out-dir ---- + out_dir = pathlib.Path(args.out_dir) + try: + out_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + sys.exit(f"ERROR: cannot create output directory {out_dir}: {e}") + if args.advisory_id and len(advs) > 1: + _emit(advs, ov_map, args.advisory_id, timestamp, + str(out_dir / f'{args.advisory_id}.csaf.json'), + str(out_dir / f'{args.advisory_id}.cdx.json')) + else: + for adv in advs: + _emit([adv], ov_map, adv['cve'], timestamp, + str(out_dir / f"{adv['cve']}.csaf.json"), + str(out_dir / f"{adv['cve']}.cdx.json")) + print(f"Wrote {len(advs)} advisory document set(s) to {out_dir}") + + +if __name__ == '__main__': + main() diff --git a/scripts/include.am b/scripts/include.am index ebac0c06bbb..921c810f616 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -180,7 +180,7 @@ EXTRA_DIST += scripts/test_gen_sbom.py # Bomsh / OmniBOR provenance verifier (invoked from `.github/workflows/ # sbom.yml` and runnable by hand against any local `make bomsh` output; -# see doc/SBOM.md § 3.5). Must ship with the dist tarball so a +# see doc/SBOM.md sec. 3.5). Must ship with the dist tarball so a # downstream consumer / CRA reviewer who clones a release tarball can # re-verify the OmniBOR graph against its enriched SPDX without going # back to the git repo. diff --git a/scripts/test_gen_advisory.py b/scripts/test_gen_advisory.py index c4981111b55..1b134c706c7 100644 --- a/scripts/test_gen_advisory.py +++ b/scripts/test_gen_advisory.py @@ -1,672 +1,672 @@ -#!/usr/bin/env python3 -"""Unit + semantic tests for scripts/gen-advisory. - -Run from the repo root: - - python3 -m unittest scripts/test_gen_advisory.py - -These tests are pure stdlib (no network, no pip deps) so they form the cheap -PR gate, mirroring scripts/test_gen_sbom.py. They cover three things the -JSON-schema validators in .github/workflows/advisory.yml do NOT: - - 1. the pure record->model logic (CVSS priority, CWE extraction, version - ranges, the FIPS product split, the reachability hedge); - 2. CSAF *semantic* invariants that a bare JSON-schema pass accepts but the - CSAF mandatory tests reject (every referenced product_id is defined in - the product_tree, no product is simultaneously affected and not-affected, - flags only sit on not-affected products, scores only target affected - products, tracking.version matches the latest revision_history entry); - 3. the two regressions already fixed once (CycloneDX uses `unaffected` - not `not_affected` in affects[].versions[].status; every CSAF reference - carries the required `summary`). - -The full CSAF 2.0 schema + mandatory-test conformance and the CycloneDX 1.6 -strict-schema pass run in CI against csaf-validator-lib / cyclonedx-bom; this -file deliberately avoids those heavyweight deps. -""" - -import importlib.util -import json -import os -import pathlib -import re -import shutil -import subprocess -import sys -import tempfile -import unittest -from importlib.machinery import SourceFileLoader - - -HERE = pathlib.Path(__file__).resolve().parent -SCRIPT = HERE / 'gen-advisory' -TESTDATA = HERE / 'testdata' -EXAMPLE_OVERLAY = HERE / 'advisory-vex-overlay.example.json' -OVERLAY_SCHEMA = HERE / 'advisory-vex-overlay.schema.json' - -# Pinned epoch -> 2023-11-14T22:13:20Z. Shared by the reproducibility test -# and the timestamp unit test so the expected string is single-sourced. -PINNED_EPOCH = '1700000000' -PINNED_EPOCH_ISO = '2023-11-14T22:13:20Z' - - -def _load_gen_advisory(): - """Load gen-advisory (no .py extension) as module 'ga', same trick as - test_gen_sbom.py uses for gen-sbom.""" - if not SCRIPT.is_file(): - raise FileNotFoundError(f"expected gen-advisory alongside this test at {SCRIPT}") - loader = SourceFileLoader('ga', str(SCRIPT)) - spec = importlib.util.spec_from_loader('ga', loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -ga = _load_gen_advisory() - - -def _record(name): - with open(TESTDATA / name) as f: - return json.load(f) - - -def _adv(name): - return ga.parse_record(_record(name)) - - -def _overlay(): - with open(EXAMPLE_OVERLAY) as f: - return json.load(f) - - -def _collect_product_ids(node): - """Every product_id declared anywhere in a CSAF product_tree branch.""" - pids = set() - prod = node.get('product') - if isinstance(prod, dict) and 'product_id' in prod: - pids.add(prod['product_id']) - for child in node.get('branches', []): - pids |= _collect_product_ids(child) - return pids - - -def _tree_product_ids(doc): - pids = set() - for branch in doc['product_tree'].get('branches', []): - pids |= _collect_product_ids(branch) - return pids - - -# Valid CSAF 2.0 enum subsets we rely on (spec 6.1.* / schema enums). -CSAF_STATUS_BUCKETS = { - 'first_affected', 'first_fixed', 'fixed', 'known_affected', - 'known_not_affected', 'last_affected', 'recommended', - 'under_investigation', -} -CSAF_FLAG_LABELS = { - 'component_not_present', 'inline_mitigations_already_exist', - 'vulnerable_code_cannot_be_controlled_by_adversary', - 'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present', -} -CSAF_REMEDIATION_CATEGORIES = { - 'mitigation', 'no_fix_planned', 'none_available', 'optional_patch', - 'vendor_fix', 'workaround', 'fix_planned', -} -CDX_AFFECTS_STATUS = {'affected', 'unaffected', 'unknown'} - - -# --------------------------------------------------------------------------- # -# Pure helpers -# --------------------------------------------------------------------------- # - -class TestDerivedUuid(unittest.TestCase): - def test_deterministic(self): - self.assertEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'b')) - - def test_distinct_inputs_distinct_output(self): - self.assertNotEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'c')) - - def test_no_aliasing_across_separator(self): - # NUL-separated join: ('a','bc') must not collide with ('ab','c'). - self.assertNotEqual(ga.derived_uuid('a', 'bc'), ga.derived_uuid('ab', 'c')) - - def test_is_uuid(self): - self.assertRegex( - ga.derived_uuid('x'), - r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') - - -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'] = PINNED_EPOCH - _, iso = ga.build_timestamp() - self.assertEqual(iso, PINNED_EPOCH_ISO) - - def test_invalid_epoch_falls_back_to_now(self): - os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' - _, iso = ga.build_timestamp() - # Falls back to wallclock; just assert a well-formed Z timestamp. - self.assertRegex(iso, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') - - -class TestCpePurl(unittest.TestCase): - def test_cpe(self): - self.assertEqual(ga.cpe_for('wolfSSL', '5.9.1'), - 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') - - def test_purl(self): - self.assertEqual(ga.purl_for('wolfSSL', '5.9.1'), - 'pkg:github/wolfSSL/wolfssl@v5.9.1') - - -class TestBestCvss(unittest.TestCase): - def test_priority_v4_over_v3(self): - metrics = [{'cvssV3_1': {'x': 1}}, {'cvssV4_0': {'y': 2}}] - best = ga._best_cvss(metrics) - self.assertEqual(best['csaf_key'], 'cvss_v4') - self.assertEqual(best['cdx_method'], 'CVSSv4') - self.assertEqual(best['data'], {'y': 2}) - - def test_v31_over_v30_over_v2(self): - self.assertEqual( - ga._best_cvss([{'cvssV2_0': {}}, {'cvssV3_0': {}}])['csaf_key'], - 'cvss_v3') - self.assertEqual( - ga._best_cvss([{'cvssV2_0': {}}])['csaf_key'], 'cvss_v2') - - def test_none_when_absent(self): - self.assertIsNone(ga._best_cvss([])) - self.assertIsNone(ga._best_cvss([{'other': {}}])) - - -class TestParseRecord(unittest.TestCase): - def test_core_fields(self): - adv = _adv('CVE-2026-5501.json') - self.assertEqual(adv['cve'], 'CVE-2026-5501') - self.assertTrue(adv['title'].startswith('Improper Certificate')) - self.assertIn('wolfSSL_X509_verify_cert', adv['description']) - self.assertEqual(adv['date_published'], '2026-04-10T03:07:39.604Z') - self.assertEqual(adv['date_updated'], '2026-04-22T13:59:28.514Z') - - def test_cwe_id_and_canonical_name(self): - adv = _adv('CVE-2026-5501.json') - self.assertEqual(adv['cwe']['id'], 'CWE-295') - # Resolved from the official catalogue (exact MITRE casing), NOT the - # record's lowercase free text -- required by CSAF test 6.1.11. - self.assertEqual(adv['cwe']['name'], 'Improper Certificate Validation') - - def test_cvss_is_v4_and_no_csaf20_compatible_score(self): - adv = _adv('CVE-2026-5501.json') - self.assertEqual(adv['cvss']['csaf_key'], 'cvss_v4') - self.assertEqual(adv['cvss']['data']['baseSeverity'], 'HIGH') - self.assertEqual(adv['cvss']['data']['baseScore'], 8.6) - # The record carries only CVSS v4, which CSAF 2.0 scores[] cannot hold. - self.assertIsNone(adv['cvss_csaf']) - - def test_affected_and_credits(self): - adv = _adv('CVE-2026-5501.json') - self.assertEqual(len(adv['affected']), 1) - a = adv['affected'][0] - self.assertEqual(a['product'], 'wolfSSL') - self.assertEqual(a['default_status'], 'unaffected') - self.assertEqual(a['versions'][0]['lessThanOrEqual'], '5.9.0') - self.assertEqual(adv['references'], - ['https://github.com/wolfSSL/wolfssl/pull/10102']) - self.assertEqual(len(adv['credits']), 1) - - def test_missing_cveid_exits(self): - with self.assertRaises(SystemExit): - ga.parse_record({'containers': {'cna': {}}, 'cveMetadata': {}}) - - def test_missing_cna_exits(self): - with self.assertRaises(SystemExit): - ga.parse_record({'cveMetadata': {'cveId': 'CVE-1'}}) - - -class TestRangeLabelAndVers(unittest.TestCase): - def test_less_than_or_equal_from_zero(self): - v = {'version': '0', 'lessThanOrEqual': '5.9.0'} - self.assertEqual(ga._range_label(v), '<= 5.9.0') - self.assertEqual(ga._vers_range(v), 'vers:generic/<=5.9.0') - - def test_less_than_with_base(self): - v = {'version': '5.0.0', 'lessThan': '5.9.0'} - self.assertEqual(ga._range_label(v), '5.0.0 <= x < 5.9.0') - self.assertEqual(ga._vers_range(v), 'vers:generic/>=5.0.0|<5.9.0') - - def test_single_version(self): - v = {'version': '5.9.0'} - self.assertEqual(ga._range_label(v), '5.9.0') - self.assertEqual(ga._vers_range(v), 'vers:generic/5.9.0') - - -class TestProductModel(unittest.TestCase): - def test_mainline_only(self): - adv = _adv('CVE-2026-5501.json') - prods = ga.product_model(adv, {'state': 'exploitable', - 'fixed_versions': ['5.9.1']}) - self.assertEqual(len(prods), 1) - p = prods[0] - self.assertEqual(p['product_name'], 'wolfSSL') - self.assertEqual(p['bucket'], 'known_affected') - self.assertEqual(len(p['affected_ranges']), 1) - self.assertEqual(p['fixed'][0]['version'], '5.9.1') - self.assertEqual(p['remediation_category'], 'vendor_fix') - - def test_not_affected_state_sets_bucket_and_justification(self): - adv = _adv('CVE-2026-5501.json') - prods = ga.product_model( - adv, {'state': 'not_affected', 'justification': 'code_not_present'}) - self.assertEqual(prods[0]['bucket'], 'known_not_affected') - self.assertEqual(prods[0]['justification'], 'code_not_present') - - def test_fips_modelled_as_second_product(self): - adv = _adv('CVE-2026-5501.json') - ov = _overlay()['CVE-2026-5501'] - prods = ga.product_model(adv, ov) - self.assertEqual(len(prods), 2) - fips = [p for p in prods if p['cdx_key'] == 'wolfcrypt-fips'][0] - self.assertEqual(fips['bucket'], 'known_not_affected') - self.assertEqual(fips['justification'], 'code_not_present') - self.assertIn('CMVP Certificate #4718', fips['model_numbers']) - self.assertEqual(fips['module_version'], '5.2.1') - # not-affected FIPS with no fix => no_fix_planned, not none_available. - self.assertEqual(fips['remediation_category'], 'no_fix_planned') - - -class TestHedgeNote(unittest.TestCase): - def test_renders_defines_and_default_off(self): - note = ga._hedge_note({'requires_defines': ['WOLFSSL_SNIFFER'], - 'default_status': 'off'}) - self.assertIn('WOLFSSL_SNIFFER', note) - self.assertIn('disabled by default', note) - - def test_none_when_empty(self): - self.assertIsNone(ga._hedge_note({})) - - -# --------------------------------------------------------------------------- # -# CSAF emitter: structure + semantic invariants -# --------------------------------------------------------------------------- # - -class TestGenerateCsaf(unittest.TestCase): - def setUp(self): - self.ov = _overlay() - self.single = ga.generate_csaf( - [_adv('CVE-2026-5501.json')], self.ov, 'CVE-2026-5501', - PINNED_EPOCH_ISO) - self.bundle = ga.generate_csaf( - [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], - self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) - - def test_required_document_skeleton(self): - d = self.single['document'] - self.assertEqual(d['csaf_version'], '2.0') - self.assertEqual(d['category'], 'csaf_security_advisory') - self.assertEqual(d['publisher']['category'], 'vendor') - self.assertEqual(d['tracking']['id'], 'CVE-2026-5501') - self.assertEqual(d['tracking']['status'], 'final') - self.assertIn('initial_release_date', d['tracking']) - self.assertIn('current_release_date', d['tracking']) - self.assertTrue(d['distribution']['tlp']['label']) - self.assertTrue(d['notes']) - - def test_tracking_version_matches_latest_revision(self): - # CSAF 6.1.x: for a non-draft doc the latest revision_history number - # must equal tracking.version. - tr = self.single['document']['tracking'] - latest = tr['revision_history'][-1]['number'] - self.assertEqual(tr['version'], latest) - - def test_document_references_have_summary(self): - # Regression: CSAF rejects references without `summary`. - for ref in self.single['document'].get('references', []): - self.assertIn('summary', ref) - self.assertTrue(ref['summary']) - - def test_all_product_ids_defined_in_tree(self): - for doc in (self.single, self.bundle): - defined = _tree_product_ids(doc) - self.assertTrue(defined) - for v in doc['vulnerabilities']: - for bucket, pids in v.get('product_status', {}).items(): - self.assertIn(bucket, CSAF_STATUS_BUCKETS) - self.assertTrue(set(pids) <= defined, - f'undefined pid in {bucket}') - for s in v.get('scores', []): - self.assertTrue(set(s['products']) <= defined) - for f in v.get('flags', []): - self.assertTrue(set(f['product_ids']) <= defined) - for r in v.get('remediations', []): - self.assertTrue(set(r['product_ids']) <= defined) - - def test_no_product_both_affected_and_not_affected(self): - for v in self.bundle['vulnerabilities']: - ps = v.get('product_status', {}) - affected = set(ps.get('known_affected', [])) - not_affected = set(ps.get('known_not_affected', [])) - self.assertEqual(affected & not_affected, set()) - - def test_vuln_references_have_summary(self): - for v in self.bundle['vulnerabilities']: - for ref in v.get('references', []): - self.assertIn('summary', ref) - - def test_flags_only_on_not_affected_products(self): - for v in self.bundle['vulnerabilities']: - ps = v.get('product_status', {}) - not_affected = set(ps.get('known_not_affected', [])) - for f in v.get('flags', []): - self.assertIn(f['label'], CSAF_FLAG_LABELS) - self.assertTrue(set(f['product_ids']) <= not_affected) - - def test_scores_only_target_affected(self): - for v in self.bundle['vulnerabilities']: - ps = v.get('product_status', {}) - scoreable = set(ps.get('known_affected', [])) \ - | set(ps.get('under_investigation', [])) - for s in v.get('scores', []): - self.assertTrue(set(s['products']) <= scoreable) - - def test_no_cvss_v4_in_csaf_scores(self): - # Regression: CSAF 2.0 scores[] has no cvss_v4 property; a v4 block - # there fails the strict schema. These records are v4-only, so no - # scores[] should be emitted at all. - for doc in (self.single, self.bundle): - for v in doc['vulnerabilities']: - for s in v.get('scores', []): - self.assertNotIn('cvss_v4', s) - - def test_v4_only_record_emits_cvss_note(self): - # The v4 rating must not be silently dropped from CSAF: it is preserved - # as a note pointing at the CycloneDX VEX for the machine-readable form. - v = self.single['vulnerabilities'][0] - titles = [n.get('title') for n in v['notes']] - self.assertIn('CVSS v4.0', titles) - note = [n for n in v['notes'] if n.get('title') == 'CVSS v4.0'][0] - self.assertIn('8.6', note['text']) - - def test_cwe_uses_canonical_catalogue_name(self): - v = [x for x in self.bundle['vulnerabilities'] - if x['cve'] == 'CVE-2026-5778'][0] - self.assertEqual(v['cwe']['id'], 'CWE-191') - self.assertEqual(v['cwe']['name'], - 'Integer Underflow (Wrap or Wraparound)') - - def test_remediation_categories_valid(self): - for v in self.bundle['vulnerabilities']: - for r in v.get('remediations', []): - self.assertIn(r['category'], CSAF_REMEDIATION_CATEGORIES) - - def test_fips_is_its_own_product_branch(self): - names = set() - - def walk(node): - if node.get('category') == 'product_name': - names.add(node['name']) - for c in node.get('branches', []): - walk(c) - for b in self.single['product_tree']['branches']: - walk(b) - self.assertIn('wolfSSL', names) - self.assertTrue(any('FIPS' in n for n in names), - f'expected a FIPS product branch, got {names}') - - def test_bundle_has_two_vulns_and_aggregate_severity(self): - self.assertEqual(len(self.bundle['vulnerabilities']), 2) - cves = {v['cve'] for v in self.bundle['vulnerabilities']} - self.assertEqual(cves, {'CVE-2026-5501', 'CVE-2026-5778'}) - # HIGH (5501) outranks LOW (5778). - self.assertEqual(self.bundle['document']['aggregate_severity']['text'], - 'HIGH') - - def test_hedge_note_present_for_sniffer_cve(self): - v = [x for x in self.bundle['vulnerabilities'] - if x['cve'] == 'CVE-2026-5778'][0] - texts = ' '.join(n['text'] for n in v['notes']) - self.assertIn('WOLFSSL_SNIFFER', texts) - - -class TestCsafV3Scores(unittest.TestCase): - """The v4-only fixtures never populate CSAF scores[]; this exercises the - positive path with a CVSS v3.1 record (CSAF 2.0 can carry v3).""" - - def setUp(self): - self.ov = _overlay() - self.adv = _adv('CVE-2026-5999.json') - self.doc = ga.generate_csaf([self.adv], self.ov, 'CVE-2026-5999', - PINNED_EPOCH_ISO) - - def test_parse_selects_v3_for_csaf(self): - self.assertEqual(self.adv['cvss']['csaf_key'], 'cvss_v3') - self.assertIsNotNone(self.adv['cvss_csaf']) - self.assertEqual(self.adv['cvss_csaf']['csaf_key'], 'cvss_v3') - self.assertEqual(self.adv['cvss_csaf']['data']['baseScore'], 7.5) - - def test_csaf_emits_cvss_v3_score(self): - v = self.doc['vulnerabilities'][0] - self.assertEqual(len(v['scores']), 1) - score = v['scores'][0] - self.assertIn('cvss_v3', score) - self.assertNotIn('cvss_v4', score) - self.assertTrue(score['products']) - # v3 path -> no CVSS v4 fallback note. - self.assertNotIn('CVSS v4.0', [n.get('title') for n in v['notes']]) - - def test_aggregate_severity_from_v3(self): - self.assertEqual(self.doc['document']['aggregate_severity']['text'], - 'HIGH') - - -# --------------------------------------------------------------------------- # -# CycloneDX VEX emitter -# --------------------------------------------------------------------------- # - -class TestGenerateCdxVex(unittest.TestCase): - def setUp(self): - self.ov = _overlay() - self.bom = ga.generate_cdx_vex( - [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], - self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) - - def test_bom_skeleton(self): - self.assertEqual(self.bom['bomFormat'], 'CycloneDX') - self.assertEqual(self.bom['specVersion'], '1.6') - self.assertRegex(self.bom['serialNumber'], r'^urn:uuid:[0-9a-f-]{36}$') - self.assertEqual(self.bom['metadata']['component']['name'], 'wolfssl') - - def test_fips_component_present(self): - names = {c['name'] for c in self.bom['components']} - self.assertTrue(any('FIPS' in n for n in names), names) - - def test_affects_status_uses_unaffected_not_not_affected(self): - # Regression sentinel: CycloneDX affects[].versions[].status only - # accepts affected/unaffected/unknown; not_affected belongs to - # analysis.state alone. - for v in self.bom['vulnerabilities']: - for aff in v['affects']: - for ver in aff.get('versions', []): - self.assertIn(ver['status'], CDX_AFFECTS_STATUS) - - def test_not_affected_fips_range_is_unaffected(self): - v = [x for x in self.bom['vulnerabilities'] - if x['id'] == 'CVE-2026-5501'][0] - # the FIPS component is not_affected -> its range status is unaffected. - fips_refs = {c['bom-ref'] for c in self.bom['components']} - fips_affects = [a for a in v['affects'] if a['ref'] in fips_refs] - self.assertTrue(fips_affects) - for a in fips_affects: - for ver in a['versions']: - self.assertEqual(ver['status'], 'unaffected') - - def test_analysis_state_and_cwe_and_rating(self): - v = [x for x in self.bom['vulnerabilities'] - if x['id'] == 'CVE-2026-5501'][0] - self.assertEqual(v['analysis']['state'], 'exploitable') - self.assertEqual(v['cwes'], [295]) - self.assertEqual(v['ratings'][0]['method'], 'CVSSv4') - self.assertEqual(v['ratings'][0]['severity'], 'high') - - -# --------------------------------------------------------------------------- # -# Overlay matches its own schema vocabulary (lightweight, no jsonschema). -# The authoritative jsonschema pass runs in CI; this guards the committed -# example overlay against drift without adding a pip dep to the unit gate. -# --------------------------------------------------------------------------- # - -class TestExampleOverlay(unittest.TestCase): - def setUp(self): - with open(OVERLAY_SCHEMA) as f: - self.schema = json.load(f) - self.overlay = _overlay() - - def _enum(self, name): - return set(self.schema['$defs'][name]['enum']) - - def test_states_and_justifications_in_vocab(self): - states = self._enum('analysisState') - justs = self._enum('justification') - for cve, entry in self.overlay.items(): - if cve.startswith('_'): - continue - if 'state' in entry: - self.assertIn(entry['state'], states) - if 'justification' in entry: - self.assertIn(entry['justification'], justs) - fips = entry.get('fips', {}) - if 'status' in fips: - self.assertIn(fips['status'], states) - if 'justification' in fips: - self.assertIn(fips['justification'], justs) - - def test_not_affected_requires_justification(self): - for cve, entry in self.overlay.items(): - if cve.startswith('_'): - continue - if entry.get('state') == 'not_affected': - self.assertIn('justification', entry) - if entry.get('fips', {}).get('status') == 'not_affected': - self.assertIn('justification', entry['fips']) - - -# --------------------------------------------------------------------------- # -# End-to-end via the CLI: reproducibility + fail-loud behaviour. -# --------------------------------------------------------------------------- # - -class TestCliBehaviour(unittest.TestCase): - def _run(self, args, env=None): - e = dict(os.environ) - if env: - e.update(env) - return subprocess.run([sys.executable, str(SCRIPT)] + args, - capture_output=True, text=True, env=e) - - def test_reproducible_under_source_date_epoch(self): - with tempfile.TemporaryDirectory() as d: - outs = [] - for i in (1, 2): - csaf = os.path.join(d, f'a{i}.csaf.json') - cdx = os.path.join(d, f'a{i}.cdx.json') - r = self._run([ - '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), - '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), - '--vex-overlay', str(EXAMPLE_OVERLAY), - '--advisory-id', 'wolfSSL-SA-5.9.1', - '--csaf-out', csaf, '--cdx-vex-out', cdx], - env={'SOURCE_DATE_EPOCH': PINNED_EPOCH}) - self.assertEqual(r.returncode, 0, r.stderr) - with open(csaf, 'rb') as f: - csaf_b = f.read() - with open(cdx, 'rb') as f: - cdx_b = f.read() - outs.append((csaf_b, cdx_b)) - self.assertEqual(outs[0][0], outs[1][0], 'CSAF not reproducible') - self.assertEqual(outs[0][1], outs[1][1], 'CDX not reproducible') - - def test_single_record_defaults_advisory_id_to_cve(self): - with tempfile.TemporaryDirectory() as d: - csaf = os.path.join(d, 'one.csaf.json') - r = self._run([ - '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), - '--vex-overlay', str(EXAMPLE_OVERLAY), - '--csaf-out', csaf]) - self.assertEqual(r.returncode, 0, r.stderr) - with open(csaf) as f: - doc = json.load(f) - self.assertEqual(doc['document']['tracking']['id'], - 'CVE-2026-5501') - - def test_bundling_without_advisory_id_fails(self): - with tempfile.TemporaryDirectory() as d: - csaf = os.path.join(d, 'x.csaf.json') - r = self._run([ - '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), - '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), - '--csaf-out', csaf]) - self.assertNotEqual(r.returncode, 0) - self.assertFalse(os.path.exists(csaf), - 'no output should be written on error') - - def test_empty_records_dir_fails(self): - with tempfile.TemporaryDirectory() as d: - recs = os.path.join(d, 'records') - os.makedirs(recs) - r = self._run(['--records-dir', recs, '--out-dir', d]) - self.assertNotEqual(r.returncode, 0) - self.assertIn('no CVE records found', r.stderr) - - def test_batch_mode_writes_per_cve_documents(self): - with tempfile.TemporaryDirectory() as d: - recs = os.path.join(d, 'records') - os.makedirs(recs) - shutil.copy(str(TESTDATA / 'CVE-2026-5501.json'), - os.path.join(recs, 'CVE-2026-5501.json')) - shutil.copy(str(TESTDATA / 'CVE-2026-5999.json'), - os.path.join(recs, 'CVE-2026-5999.json')) - out = os.path.join(d, 'out') - r = self._run(['--records-dir', recs, '--out-dir', out, - '--vex-overlay', str(EXAMPLE_OVERLAY)]) - self.assertEqual(r.returncode, 0, r.stderr) - for cve in ('CVE-2026-5501', 'CVE-2026-5999'): - csaf = os.path.join(out, f'{cve}.csaf.json') - cdx = os.path.join(out, f'{cve}.cdx.json') - self.assertTrue(os.path.exists(csaf), csaf) - self.assertTrue(os.path.exists(cdx), cdx) - with open(csaf) as f: - doc = json.load(f) - self.assertEqual(doc['document']['tracking']['id'], cve) - - def test_default_records_dir_is_canonical_tree(self): - # No --cve-record/--cve-id and no --records-dir: must fall back to the - # canonical advisories/records/ tree (the same inputs `make advisory` - # uses). Output is redirected to a temp dir so the repo is untouched. - with tempfile.TemporaryDirectory() as d: - r = self._run(['--out-dir', d]) - self.assertEqual(r.returncode, 0, r.stderr) - produced = sorted(f for f in os.listdir(d) - if f.endswith('.csaf.json')) - self.assertIn('CVE-2026-5501.csaf.json', produced) - self.assertIn('CVE-2026-5778.csaf.json', produced) - - def test_malformed_record_fails_without_writing(self): - with tempfile.TemporaryDirectory() as d: - bad = os.path.join(d, 'bad.json') - with open(bad, 'w') as f: - f.write('{ this is not json') - csaf = os.path.join(d, 'out.csaf.json') - r = self._run(['--cve-record', bad, '--csaf-out', csaf]) - self.assertNotEqual(r.returncode, 0) - self.assertFalse(os.path.exists(csaf)) - - -if __name__ == '__main__': - unittest.main() +#!/usr/bin/env python3 +"""Unit + semantic tests for scripts/gen-advisory. + +Run from the repo root: + + python3 -m unittest scripts/test_gen_advisory.py + +These tests are pure stdlib (no network, no pip deps) so they form the cheap +PR gate, mirroring scripts/test_gen_sbom.py. They cover three things the +JSON-schema validators in .github/workflows/advisory.yml do NOT: + + 1. the pure record->model logic (CVSS priority, CWE extraction, version + ranges, the FIPS product split, the reachability hedge); + 2. CSAF *semantic* invariants that a bare JSON-schema pass accepts but the + CSAF mandatory tests reject (every referenced product_id is defined in + the product_tree, no product is simultaneously affected and not-affected, + flags only sit on not-affected products, scores only target affected + products, tracking.version matches the latest revision_history entry); + 3. the two regressions already fixed once (CycloneDX uses `unaffected` + not `not_affected` in affects[].versions[].status; every CSAF reference + carries the required `summary`). + +The full CSAF 2.0 schema + mandatory-test conformance and the CycloneDX 1.6 +strict-schema pass run in CI against csaf-validator-lib / cyclonedx-bom; this +file deliberately avoids those heavyweight deps. +""" + +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from importlib.machinery import SourceFileLoader + + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = HERE / 'gen-advisory' +TESTDATA = HERE / 'testdata' +EXAMPLE_OVERLAY = HERE / 'advisory-vex-overlay.example.json' +OVERLAY_SCHEMA = HERE / 'advisory-vex-overlay.schema.json' + +# Pinned epoch -> 2023-11-14T22:13:20Z. Shared by the reproducibility test +# and the timestamp unit test so the expected string is single-sourced. +PINNED_EPOCH = '1700000000' +PINNED_EPOCH_ISO = '2023-11-14T22:13:20Z' + + +def _load_gen_advisory(): + """Load gen-advisory (no .py extension) as module 'ga', same trick as + test_gen_sbom.py uses for gen-sbom.""" + if not SCRIPT.is_file(): + raise FileNotFoundError(f"expected gen-advisory alongside this test at {SCRIPT}") + loader = SourceFileLoader('ga', str(SCRIPT)) + spec = importlib.util.spec_from_loader('ga', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +ga = _load_gen_advisory() + + +def _record(name): + with open(TESTDATA / name) as f: + return json.load(f) + + +def _adv(name): + return ga.parse_record(_record(name)) + + +def _overlay(): + with open(EXAMPLE_OVERLAY) as f: + return json.load(f) + + +def _collect_product_ids(node): + """Every product_id declared anywhere in a CSAF product_tree branch.""" + pids = set() + prod = node.get('product') + if isinstance(prod, dict) and 'product_id' in prod: + pids.add(prod['product_id']) + for child in node.get('branches', []): + pids |= _collect_product_ids(child) + return pids + + +def _tree_product_ids(doc): + pids = set() + for branch in doc['product_tree'].get('branches', []): + pids |= _collect_product_ids(branch) + return pids + + +# Valid CSAF 2.0 enum subsets we rely on (spec 6.1.* / schema enums). +CSAF_STATUS_BUCKETS = { + 'first_affected', 'first_fixed', 'fixed', 'known_affected', + 'known_not_affected', 'last_affected', 'recommended', + 'under_investigation', +} +CSAF_FLAG_LABELS = { + 'component_not_present', 'inline_mitigations_already_exist', + 'vulnerable_code_cannot_be_controlled_by_adversary', + 'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present', +} +CSAF_REMEDIATION_CATEGORIES = { + 'mitigation', 'no_fix_planned', 'none_available', 'optional_patch', + 'vendor_fix', 'workaround', 'fix_planned', +} +CDX_AFFECTS_STATUS = {'affected', 'unaffected', 'unknown'} + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + +class TestDerivedUuid(unittest.TestCase): + def test_deterministic(self): + self.assertEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'b')) + + def test_distinct_inputs_distinct_output(self): + self.assertNotEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'c')) + + def test_no_aliasing_across_separator(self): + # NUL-separated join: ('a','bc') must not collide with ('ab','c'). + self.assertNotEqual(ga.derived_uuid('a', 'bc'), ga.derived_uuid('ab', 'c')) + + def test_is_uuid(self): + self.assertRegex( + ga.derived_uuid('x'), + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') + + +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'] = PINNED_EPOCH + _, iso = ga.build_timestamp() + self.assertEqual(iso, PINNED_EPOCH_ISO) + + def test_invalid_epoch_falls_back_to_now(self): + os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' + _, iso = ga.build_timestamp() + # Falls back to wallclock; just assert a well-formed Z timestamp. + self.assertRegex(iso, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') + + +class TestCpePurl(unittest.TestCase): + def test_cpe(self): + self.assertEqual(ga.cpe_for('wolfSSL', '5.9.1'), + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + + def test_purl(self): + self.assertEqual(ga.purl_for('wolfSSL', '5.9.1'), + 'pkg:github/wolfSSL/wolfssl@v5.9.1') + + +class TestBestCvss(unittest.TestCase): + def test_priority_v4_over_v3(self): + metrics = [{'cvssV3_1': {'x': 1}}, {'cvssV4_0': {'y': 2}}] + best = ga._best_cvss(metrics) + self.assertEqual(best['csaf_key'], 'cvss_v4') + self.assertEqual(best['cdx_method'], 'CVSSv4') + self.assertEqual(best['data'], {'y': 2}) + + def test_v31_over_v30_over_v2(self): + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}, {'cvssV3_0': {}}])['csaf_key'], + 'cvss_v3') + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}])['csaf_key'], 'cvss_v2') + + def test_none_when_absent(self): + self.assertIsNone(ga._best_cvss([])) + self.assertIsNone(ga._best_cvss([{'other': {}}])) + + +class TestParseRecord(unittest.TestCase): + def test_core_fields(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cve'], 'CVE-2026-5501') + self.assertTrue(adv['title'].startswith('Improper Certificate')) + self.assertIn('wolfSSL_X509_verify_cert', adv['description']) + self.assertEqual(adv['date_published'], '2026-04-10T03:07:39.604Z') + self.assertEqual(adv['date_updated'], '2026-04-22T13:59:28.514Z') + + def test_cwe_id_and_canonical_name(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cwe']['id'], 'CWE-295') + # Resolved from the official catalogue (exact MITRE casing), NOT the + # record's lowercase free text -- required by CSAF test 6.1.11. + self.assertEqual(adv['cwe']['name'], 'Improper Certificate Validation') + + def test_cvss_is_v4_and_no_csaf20_compatible_score(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cvss']['csaf_key'], 'cvss_v4') + self.assertEqual(adv['cvss']['data']['baseSeverity'], 'HIGH') + self.assertEqual(adv['cvss']['data']['baseScore'], 8.6) + # The record carries only CVSS v4, which CSAF 2.0 scores[] cannot hold. + self.assertIsNone(adv['cvss_csaf']) + + def test_affected_and_credits(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(len(adv['affected']), 1) + a = adv['affected'][0] + self.assertEqual(a['product'], 'wolfSSL') + self.assertEqual(a['default_status'], 'unaffected') + self.assertEqual(a['versions'][0]['lessThanOrEqual'], '5.9.0') + self.assertEqual(adv['references'], + ['https://github.com/wolfSSL/wolfssl/pull/10102']) + self.assertEqual(len(adv['credits']), 1) + + def test_missing_cveid_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'containers': {'cna': {}}, 'cveMetadata': {}}) + + def test_missing_cna_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'cveMetadata': {'cveId': 'CVE-1'}}) + + +class TestRangeLabelAndVers(unittest.TestCase): + def test_less_than_or_equal_from_zero(self): + v = {'version': '0', 'lessThanOrEqual': '5.9.0'} + self.assertEqual(ga._range_label(v), '<= 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/<=5.9.0') + + def test_less_than_with_base(self): + v = {'version': '5.0.0', 'lessThan': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.0.0 <= x < 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/>=5.0.0|<5.9.0') + + def test_single_version(self): + v = {'version': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/5.9.0') + + +class TestProductModel(unittest.TestCase): + def test_mainline_only(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model(adv, {'state': 'exploitable', + 'fixed_versions': ['5.9.1']}) + self.assertEqual(len(prods), 1) + p = prods[0] + self.assertEqual(p['product_name'], 'wolfSSL') + self.assertEqual(p['bucket'], 'known_affected') + self.assertEqual(len(p['affected_ranges']), 1) + self.assertEqual(p['fixed'][0]['version'], '5.9.1') + self.assertEqual(p['remediation_category'], 'vendor_fix') + + def test_not_affected_state_sets_bucket_and_justification(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model( + adv, {'state': 'not_affected', 'justification': 'code_not_present'}) + self.assertEqual(prods[0]['bucket'], 'known_not_affected') + self.assertEqual(prods[0]['justification'], 'code_not_present') + + def test_fips_modelled_as_second_product(self): + adv = _adv('CVE-2026-5501.json') + ov = _overlay()['CVE-2026-5501'] + prods = ga.product_model(adv, ov) + self.assertEqual(len(prods), 2) + fips = [p for p in prods if p['cdx_key'] == 'wolfcrypt-fips'][0] + self.assertEqual(fips['bucket'], 'known_not_affected') + self.assertEqual(fips['justification'], 'code_not_present') + self.assertIn('CMVP Certificate #4718', fips['model_numbers']) + self.assertEqual(fips['module_version'], '5.2.1') + # not-affected FIPS with no fix => no_fix_planned, not none_available. + self.assertEqual(fips['remediation_category'], 'no_fix_planned') + + +class TestHedgeNote(unittest.TestCase): + def test_renders_defines_and_default_off(self): + note = ga._hedge_note({'requires_defines': ['WOLFSSL_SNIFFER'], + 'default_status': 'off'}) + self.assertIn('WOLFSSL_SNIFFER', note) + self.assertIn('disabled by default', note) + + def test_none_when_empty(self): + self.assertIsNone(ga._hedge_note({})) + + +# --------------------------------------------------------------------------- # +# CSAF emitter: structure + semantic invariants +# --------------------------------------------------------------------------- # + +class TestGenerateCsaf(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.single = ga.generate_csaf( + [_adv('CVE-2026-5501.json')], self.ov, 'CVE-2026-5501', + PINNED_EPOCH_ISO) + self.bundle = ga.generate_csaf( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_required_document_skeleton(self): + d = self.single['document'] + self.assertEqual(d['csaf_version'], '2.0') + self.assertEqual(d['category'], 'csaf_security_advisory') + self.assertEqual(d['publisher']['category'], 'vendor') + self.assertEqual(d['tracking']['id'], 'CVE-2026-5501') + self.assertEqual(d['tracking']['status'], 'final') + self.assertIn('initial_release_date', d['tracking']) + self.assertIn('current_release_date', d['tracking']) + self.assertTrue(d['distribution']['tlp']['label']) + self.assertTrue(d['notes']) + + def test_tracking_version_matches_latest_revision(self): + # CSAF 6.1.x: for a non-draft doc the latest revision_history number + # must equal tracking.version. + tr = self.single['document']['tracking'] + latest = tr['revision_history'][-1]['number'] + self.assertEqual(tr['version'], latest) + + def test_document_references_have_summary(self): + # Regression: CSAF rejects references without `summary`. + for ref in self.single['document'].get('references', []): + self.assertIn('summary', ref) + self.assertTrue(ref['summary']) + + def test_all_product_ids_defined_in_tree(self): + for doc in (self.single, self.bundle): + defined = _tree_product_ids(doc) + self.assertTrue(defined) + for v in doc['vulnerabilities']: + for bucket, pids in v.get('product_status', {}).items(): + self.assertIn(bucket, CSAF_STATUS_BUCKETS) + self.assertTrue(set(pids) <= defined, + f'undefined pid in {bucket}') + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= defined) + for f in v.get('flags', []): + self.assertTrue(set(f['product_ids']) <= defined) + for r in v.get('remediations', []): + self.assertTrue(set(r['product_ids']) <= defined) + + def test_no_product_both_affected_and_not_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + affected = set(ps.get('known_affected', [])) + not_affected = set(ps.get('known_not_affected', [])) + self.assertEqual(affected & not_affected, set()) + + def test_vuln_references_have_summary(self): + for v in self.bundle['vulnerabilities']: + for ref in v.get('references', []): + self.assertIn('summary', ref) + + def test_flags_only_on_not_affected_products(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + not_affected = set(ps.get('known_not_affected', [])) + for f in v.get('flags', []): + self.assertIn(f['label'], CSAF_FLAG_LABELS) + self.assertTrue(set(f['product_ids']) <= not_affected) + + def test_scores_only_target_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + scoreable = set(ps.get('known_affected', [])) \ + | set(ps.get('under_investigation', [])) + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= scoreable) + + def test_no_cvss_v4_in_csaf_scores(self): + # Regression: CSAF 2.0 scores[] has no cvss_v4 property; a v4 block + # there fails the strict schema. These records are v4-only, so no + # scores[] should be emitted at all. + for doc in (self.single, self.bundle): + for v in doc['vulnerabilities']: + for s in v.get('scores', []): + self.assertNotIn('cvss_v4', s) + + def test_v4_only_record_emits_cvss_note(self): + # The v4 rating must not be silently dropped from CSAF: it is preserved + # as a note pointing at the CycloneDX VEX for the machine-readable form. + v = self.single['vulnerabilities'][0] + titles = [n.get('title') for n in v['notes']] + self.assertIn('CVSS v4.0', titles) + note = [n for n in v['notes'] if n.get('title') == 'CVSS v4.0'][0] + self.assertIn('8.6', note['text']) + + def test_cwe_uses_canonical_catalogue_name(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + self.assertEqual(v['cwe']['id'], 'CWE-191') + self.assertEqual(v['cwe']['name'], + 'Integer Underflow (Wrap or Wraparound)') + + def test_remediation_categories_valid(self): + for v in self.bundle['vulnerabilities']: + for r in v.get('remediations', []): + self.assertIn(r['category'], CSAF_REMEDIATION_CATEGORIES) + + def test_fips_is_its_own_product_branch(self): + names = set() + + def walk(node): + if node.get('category') == 'product_name': + names.add(node['name']) + for c in node.get('branches', []): + walk(c) + for b in self.single['product_tree']['branches']: + walk(b) + self.assertIn('wolfSSL', names) + self.assertTrue(any('FIPS' in n for n in names), + f'expected a FIPS product branch, got {names}') + + def test_bundle_has_two_vulns_and_aggregate_severity(self): + self.assertEqual(len(self.bundle['vulnerabilities']), 2) + cves = {v['cve'] for v in self.bundle['vulnerabilities']} + self.assertEqual(cves, {'CVE-2026-5501', 'CVE-2026-5778'}) + # HIGH (5501) outranks LOW (5778). + self.assertEqual(self.bundle['document']['aggregate_severity']['text'], + 'HIGH') + + def test_hedge_note_present_for_sniffer_cve(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + texts = ' '.join(n['text'] for n in v['notes']) + self.assertIn('WOLFSSL_SNIFFER', texts) + + +class TestCsafV3Scores(unittest.TestCase): + """The v4-only fixtures never populate CSAF scores[]; this exercises the + positive path with a CVSS v3.1 record (CSAF 2.0 can carry v3).""" + + def setUp(self): + self.ov = _overlay() + self.adv = _adv('CVE-2026-5999.json') + self.doc = ga.generate_csaf([self.adv], self.ov, 'CVE-2026-5999', + PINNED_EPOCH_ISO) + + def test_parse_selects_v3_for_csaf(self): + self.assertEqual(self.adv['cvss']['csaf_key'], 'cvss_v3') + self.assertIsNotNone(self.adv['cvss_csaf']) + self.assertEqual(self.adv['cvss_csaf']['csaf_key'], 'cvss_v3') + self.assertEqual(self.adv['cvss_csaf']['data']['baseScore'], 7.5) + + def test_csaf_emits_cvss_v3_score(self): + v = self.doc['vulnerabilities'][0] + self.assertEqual(len(v['scores']), 1) + score = v['scores'][0] + self.assertIn('cvss_v3', score) + self.assertNotIn('cvss_v4', score) + self.assertTrue(score['products']) + # v3 path -> no CVSS v4 fallback note. + self.assertNotIn('CVSS v4.0', [n.get('title') for n in v['notes']]) + + def test_aggregate_severity_from_v3(self): + self.assertEqual(self.doc['document']['aggregate_severity']['text'], + 'HIGH') + + +# --------------------------------------------------------------------------- # +# CycloneDX VEX emitter +# --------------------------------------------------------------------------- # + +class TestGenerateCdxVex(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.bom = ga.generate_cdx_vex( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_bom_skeleton(self): + self.assertEqual(self.bom['bomFormat'], 'CycloneDX') + self.assertEqual(self.bom['specVersion'], '1.6') + self.assertRegex(self.bom['serialNumber'], r'^urn:uuid:[0-9a-f-]{36}$') + self.assertEqual(self.bom['metadata']['component']['name'], 'wolfssl') + + def test_fips_component_present(self): + names = {c['name'] for c in self.bom['components']} + self.assertTrue(any('FIPS' in n for n in names), names) + + def test_affects_status_uses_unaffected_not_not_affected(self): + # Regression sentinel: CycloneDX affects[].versions[].status only + # accepts affected/unaffected/unknown; not_affected belongs to + # analysis.state alone. + for v in self.bom['vulnerabilities']: + for aff in v['affects']: + for ver in aff.get('versions', []): + self.assertIn(ver['status'], CDX_AFFECTS_STATUS) + + def test_not_affected_fips_range_is_unaffected(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + # the FIPS component is not_affected -> its range status is unaffected. + fips_refs = {c['bom-ref'] for c in self.bom['components']} + fips_affects = [a for a in v['affects'] if a['ref'] in fips_refs] + self.assertTrue(fips_affects) + for a in fips_affects: + for ver in a['versions']: + self.assertEqual(ver['status'], 'unaffected') + + def test_analysis_state_and_cwe_and_rating(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + self.assertEqual(v['analysis']['state'], 'exploitable') + self.assertEqual(v['cwes'], [295]) + self.assertEqual(v['ratings'][0]['method'], 'CVSSv4') + self.assertEqual(v['ratings'][0]['severity'], 'high') + + +# --------------------------------------------------------------------------- # +# Overlay matches its own schema vocabulary (lightweight, no jsonschema). +# The authoritative jsonschema pass runs in CI; this guards the committed +# example overlay against drift without adding a pip dep to the unit gate. +# --------------------------------------------------------------------------- # + +class TestExampleOverlay(unittest.TestCase): + def setUp(self): + with open(OVERLAY_SCHEMA) as f: + self.schema = json.load(f) + self.overlay = _overlay() + + def _enum(self, name): + return set(self.schema['$defs'][name]['enum']) + + def test_states_and_justifications_in_vocab(self): + states = self._enum('analysisState') + justifications = self._enum('justification') + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if 'state' in entry: + self.assertIn(entry['state'], states) + if 'justification' in entry: + self.assertIn(entry['justification'], justifications) + fips = entry.get('fips', {}) + if 'status' in fips: + self.assertIn(fips['status'], states) + if 'justification' in fips: + self.assertIn(fips['justification'], justifications) + + def test_not_affected_requires_justification(self): + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if entry.get('state') == 'not_affected': + self.assertIn('justification', entry) + if entry.get('fips', {}).get('status') == 'not_affected': + self.assertIn('justification', entry['fips']) + + +# --------------------------------------------------------------------------- # +# End-to-end via the CLI: reproducibility + fail-loud behaviour. +# --------------------------------------------------------------------------- # + +class TestCliBehaviour(unittest.TestCase): + def _run(self, args, env=None): + e = dict(os.environ) + if env: + e.update(env) + return subprocess.run([sys.executable, str(SCRIPT)] + args, + capture_output=True, text=True, env=e) + + def test_reproducible_under_source_date_epoch(self): + with tempfile.TemporaryDirectory() as d: + outs = [] + for i in (1, 2): + csaf = os.path.join(d, f'a{i}.csaf.json') + cdx = os.path.join(d, f'a{i}.cdx.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--advisory-id', 'wolfSSL-SA-5.9.1', + '--csaf-out', csaf, '--cdx-vex-out', cdx], + env={'SOURCE_DATE_EPOCH': PINNED_EPOCH}) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf, 'rb') as f: + csaf_b = f.read() + with open(cdx, 'rb') as f: + cdx_b = f.read() + outs.append((csaf_b, cdx_b)) + self.assertEqual(outs[0][0], outs[1][0], 'CSAF not reproducible') + self.assertEqual(outs[0][1], outs[1][1], 'CDX not reproducible') + + def test_single_record_defaults_advisory_id_to_cve(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'one.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--csaf-out', csaf]) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], + 'CVE-2026-5501') + + def test_bundling_without_advisory_id_fails(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'x.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf), + 'no output should be written on error') + + def test_empty_records_dir_fails(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + r = self._run(['--records-dir', recs, '--out-dir', d]) + self.assertNotEqual(r.returncode, 0) + self.assertIn('no CVE records found', r.stderr) + + def test_batch_mode_writes_per_cve_documents(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + shutil.copy(str(TESTDATA / 'CVE-2026-5501.json'), + os.path.join(recs, 'CVE-2026-5501.json')) + shutil.copy(str(TESTDATA / 'CVE-2026-5999.json'), + os.path.join(recs, 'CVE-2026-5999.json')) + out = os.path.join(d, 'out') + r = self._run(['--records-dir', recs, '--out-dir', out, + '--vex-overlay', str(EXAMPLE_OVERLAY)]) + self.assertEqual(r.returncode, 0, r.stderr) + for cve in ('CVE-2026-5501', 'CVE-2026-5999'): + csaf = os.path.join(out, f'{cve}.csaf.json') + cdx = os.path.join(out, f'{cve}.cdx.json') + self.assertTrue(os.path.exists(csaf), csaf) + self.assertTrue(os.path.exists(cdx), cdx) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], cve) + + def test_default_records_dir_is_canonical_tree(self): + # No --cve-record/--cve-id and no --records-dir: must fall back to the + # canonical advisories/records/ tree (the same inputs `make advisory` + # uses). Output is redirected to a temp dir so the repo is untouched. + with tempfile.TemporaryDirectory() as d: + r = self._run(['--out-dir', d]) + self.assertEqual(r.returncode, 0, r.stderr) + produced = sorted(f for f in os.listdir(d) + if f.endswith('.csaf.json')) + self.assertIn('CVE-2026-5501.csaf.json', produced) + self.assertIn('CVE-2026-5778.csaf.json', produced) + + def test_malformed_record_fails_without_writing(self): + with tempfile.TemporaryDirectory() as d: + bad = os.path.join(d, 'bad.json') + with open(bad, 'w') as f: + f.write('{ this is not json') + csaf = os.path.join(d, 'out.csaf.json') + r = self._run(['--cve-record', bad, '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf)) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/testdata/CVE-2026-5501.json b/scripts/testdata/CVE-2026-5501.json index 6db50821c6c..8f7a3f2677a 100644 --- a/scripts/testdata/CVE-2026-5501.json +++ b/scripts/testdata/CVE-2026-5501.json @@ -1,122 +1,122 @@ -{ - "dataType": "CVE_RECORD", - "dataVersion": "5.2", - "cveMetadata": { - "cveId": "CVE-2026-5501", - "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "state": "PUBLISHED", - "assignerShortName": "wolfSSL", - "dateReserved": "2026-04-03T15:46:09.302Z", - "datePublished": "2026-04-10T03:07:39.604Z", - "dateUpdated": "2026-04-22T13:59:28.514Z" - }, - "containers": { - "cna": { - "providerMetadata": { - "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "shortName": "wolfSSL", - "dateUpdated": "2026-04-10T03:07:39.604Z" - }, - "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", - "problemTypes": [ - { - "descriptions": [ - { - "lang": "en", - "cweId": "CWE-295", - "description": "CWE-295 Improper certificate validation", - "type": "CWE" - } - ] - } - ], - "affected": [ - { - "vendor": "wolfSSL", - "product": "wolfSSL", - "modules": [ - "wolfSSL_X509_verify_cert" - ], - "programFiles": [ - "src/x509_str.c" - ], - "versions": [ - { - "status": "affected", - "version": "0", - "lessThanOrEqual": "5.9.0", - "versionType": "semver" - } - ], - "defaultStatus": "unaffected" - } - ], - "descriptions": [ - { - "lang": "en", - "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", - "supportingMedia": [ - { - "type": "text/html", - "base64": false, - "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." - } - ] - } - ], - "references": [ - { - "url": "https://github.com/wolfSSL/wolfssl/pull/10102" - } - ], - "metrics": [ - { - "format": "CVSS", - "scenarios": [ - { - "lang": "en", - "value": "GENERAL" - } - ], - "cvssV4_0": { - "attackVector": "NETWORK", - "attackComplexity": "LOW", - "attackRequirements": "NONE", - "privilegesRequired": "LOW", - "userInteraction": "NONE", - "vulnConfidentialityImpact": "HIGH", - "subConfidentialityImpact": "NONE", - "vulnIntegrityImpact": "HIGH", - "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "NONE", - "subAvailabilityImpact": "NONE", - "exploitMaturity": "NOT_DEFINED", - "Safety": "NOT_DEFINED", - "Automatable": "NOT_DEFINED", - "Recovery": "NOT_DEFINED", - "valueDensity": "NOT_DEFINED", - "vulnerabilityResponseEffort": "NOT_DEFINED", - "providerUrgency": "NOT_DEFINED", - "version": "4.0", - "baseSeverity": "HIGH", - "baseScore": 8.6, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" - } - } - ], - "credits": [ - { - "lang": "en", - "value": "Calif.io in collaboration with Claude and Anthropic Research", - "type": "finder" - } - ], - "source": { - "discovery": "EXTERNAL" - }, - "x_generator": { - "engine": "Vulnogram 1.0.1" - } - } - } -} +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "LOW", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.6, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/scripts/testdata/CVE-2026-5778.json b/scripts/testdata/CVE-2026-5778.json index 75296072f1a..2964d7af1e4 100644 --- a/scripts/testdata/CVE-2026-5778.json +++ b/scripts/testdata/CVE-2026-5778.json @@ -1,122 +1,122 @@ -{ - "dataType": "CVE_RECORD", - "dataVersion": "5.2", - "cveMetadata": { - "cveId": "CVE-2026-5778", - "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "state": "PUBLISHED", - "assignerShortName": "wolfSSL", - "dateReserved": "2026-04-08T08:25:15.400Z", - "datePublished": "2026-04-09T21:45:09.053Z", - "dateUpdated": "2026-04-10T13:53:29.181Z" - }, - "containers": { - "cna": { - "providerMetadata": { - "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "shortName": "wolfSSL", - "dateUpdated": "2026-04-09T21:45:09.053Z" - }, - "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", - "problemTypes": [ - { - "descriptions": [ - { - "lang": "en", - "cweId": "CWE-191", - "description": "CWE-191 Integer underflow (wrap or wraparound)", - "type": "CWE" - } - ] - } - ], - "affected": [ - { - "vendor": "wolfSSL", - "product": "wolfSSL", - "modules": [ - "Packet sniffer" - ], - "programFiles": [ - "src/sniffer.c" - ], - "versions": [ - { - "status": "affected", - "version": "0", - "lessThanOrEqual": "5.9.0", - "versionType": "semver" - } - ], - "defaultStatus": "unaffected" - } - ], - "descriptions": [ - { - "lang": "en", - "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", - "supportingMedia": [ - { - "type": "text/html", - "base64": false, - "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." - } - ] - } - ], - "references": [ - { - "url": "https://github.com/wolfSSL/wolfssl/pull/10125" - } - ], - "metrics": [ - { - "format": "CVSS", - "scenarios": [ - { - "lang": "en", - "value": "GENERAL" - } - ], - "cvssV4_0": { - "attackVector": "NETWORK", - "attackComplexity": "LOW", - "attackRequirements": "PRESENT", - "privilegesRequired": "LOW", - "userInteraction": "PASSIVE", - "vulnConfidentialityImpact": "NONE", - "subConfidentialityImpact": "NONE", - "vulnIntegrityImpact": "NONE", - "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "LOW", - "subAvailabilityImpact": "NONE", - "exploitMaturity": "NOT_DEFINED", - "Safety": "NOT_DEFINED", - "Automatable": "NOT_DEFINED", - "Recovery": "NOT_DEFINED", - "valueDensity": "NOT_DEFINED", - "vulnerabilityResponseEffort": "NOT_DEFINED", - "providerUrgency": "NOT_DEFINED", - "version": "4.0", - "baseSeverity": "LOW", - "baseScore": 2.1, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" - } - } - ], - "credits": [ - { - "lang": "en", - "value": "Zou Dikai", - "type": "finder" - } - ], - "source": { - "discovery": "EXTERNAL" - }, - "x_generator": { - "engine": "Vulnogram 1.0.1" - } - } - } -} +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "LOW", + "userInteraction": "PASSIVE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "LOW", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "LOW", + "baseScore": 2.1, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/scripts/testdata/CVE-2026-5999.json b/scripts/testdata/CVE-2026-5999.json index 97aa65eaeb6..9d60025cb61 100644 --- a/scripts/testdata/CVE-2026-5999.json +++ b/scripts/testdata/CVE-2026-5999.json @@ -1,99 +1,99 @@ -{ - "dataType": "CVE_RECORD", - "dataVersion": "5.2", - "cveMetadata": { - "cveId": "CVE-2026-5999", - "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "state": "PUBLISHED", - "assignerShortName": "wolfSSL", - "dateReserved": "2026-04-15T09:00:00.000Z", - "datePublished": "2026-04-18T12:00:00.000Z", - "dateUpdated": "2026-04-18T12:00:00.000Z" - }, - "containers": { - "cna": { - "providerMetadata": { - "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", - "shortName": "wolfSSL", - "dateUpdated": "2026-04-18T12:00:00.000Z" - }, - "title": "Out-of-bounds read parsing a malformed DTLS handshake message.", - "problemTypes": [ - { - "descriptions": [ - { - "lang": "en", - "cweId": "CWE-125", - "description": "CWE-125 Out-of-bounds read", - "type": "CWE" - } - ] - } - ], - "affected": [ - { - "vendor": "wolfSSL", - "product": "wolfSSL", - "programFiles": [ - "src/dtls.c" - ], - "versions": [ - { - "status": "affected", - "version": "0", - "lessThanOrEqual": "5.9.0", - "versionType": "semver" - } - ], - "defaultStatus": "unaffected" - } - ], - "descriptions": [ - { - "lang": "en", - "value": "A synthetic test fixture (not a real CVE). An out-of-bounds read in wolfSSL DTLS handshake parsing <= 5.9.0 allows a remote unauthenticated attacker to read past the end of a record buffer by sending a malformed handshake message, potentially crashing the server. This record exists to exercise the CVSS v3.1 scores[] path of gen-advisory." - } - ], - "references": [ - { - "url": "https://github.com/wolfSSL/wolfssl/pull/99999" - } - ], - "metrics": [ - { - "format": "CVSS", - "scenarios": [ - { - "lang": "en", - "value": "GENERAL" - } - ], - "cvssV3_1": { - "version": "3.1", - "attackVector": "NETWORK", - "attackComplexity": "LOW", - "privilegesRequired": "NONE", - "userInteraction": "NONE", - "scope": "UNCHANGED", - "confidentialityImpact": "HIGH", - "integrityImpact": "NONE", - "availabilityImpact": "NONE", - "baseScore": 7.5, - "baseSeverity": "HIGH", - "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" - } - } - ], - "credits": [ - { - "lang": "en", - "value": "wolfSSL internal testing", - "type": "finder" - } - ], - "source": { - "discovery": "INTERNAL" - } - } - } -} +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5999", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-15T09:00:00.000Z", + "datePublished": "2026-04-18T12:00:00.000Z", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "title": "Out-of-bounds read parsing a malformed DTLS handshake message.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-125", + "description": "CWE-125 Out-of-bounds read", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "programFiles": [ + "src/dtls.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "A synthetic test fixture (not a real CVE). An out-of-bounds read in wolfSSL DTLS handshake parsing <= 5.9.0 allows a remote unauthenticated attacker to read past the end of a record buffer by sending a malformed handshake message, potentially crashing the server. This record exists to exercise the CVSS v3.1 scores[] path of gen-advisory." + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/99999" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV3_1": { + "version": "3.1", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "NONE", + "availabilityImpact": "NONE", + "baseScore": 7.5, + "baseSeverity": "HIGH", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "wolfSSL internal testing", + "type": "finder" + } + ], + "source": { + "discovery": "INTERNAL" + } + } + } +} diff --git a/scripts/testdata/README.md b/scripts/testdata/README.md index 8ea3d8a80ac..180bc516178 100644 --- a/scripts/testdata/README.md +++ b/scripts/testdata/README.md @@ -1,11 +1,11 @@ -# gen-advisory test fixtures - -CVE Program records (CVE JSON 5.x) used by `scripts/test_gen_advisory.py` and -the `.github/workflows/advisory.yml` jobs. Committed so the tests are hermetic -(no network fetch from cve.org at test time). - -| File | Provenance | -|------|------------| -| `CVE-2026-5501.json` | Real published wolfSSL CNA record (CVSS v4 only). | -| `CVE-2026-5778.json` | Real published wolfSSL CNA record (CVSS v4 only). | -| `CVE-2026-5999.json` | **Synthetic fixture, not a real CVE.** Carries a CVSS v3.1 block so the CSAF `scores[]` emission path (and the CVSS-consistency mandatory tests 6.1.8/6.1.9) is exercised; the v4-only records above never populate `scores[]` in CSAF 2.0. | +# gen-advisory test fixtures + +CVE Program records (CVE JSON 5.x) used by `scripts/test_gen_advisory.py` and +the `.github/workflows/advisory.yml` jobs. Committed so the tests are hermetic +(no network fetch from cve.org at test time). + +| File | Provenance | +|------|------------| +| `CVE-2026-5501.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5778.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5999.json` | **Synthetic fixture, not a real CVE.** Carries a CVSS v3.1 block so the CSAF `scores[]` emission path (and the CVSS-consistency mandatory tests 6.1.8/6.1.9) is exercised; the v4-only records above never populate `scores[]` in CSAF 2.0. | From bd00d2dd98e13ca49f8b4f42c9617b00e9bcde54 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 18 Jun 2026 12:58:03 +0300 Subject: [PATCH 37/39] ci: pin actions/clones to SHAs; fix CVE-5778 availability score Pin all GitHub Actions and the liboqs/strace clones in the SBOM and advisory workflows to commit SHAs so the provenance pipeline's own inputs are immutable. Raise CVE-2026-5778 to VA:H (a sniffer crash is full availability loss): 6.3 Medium -> 8.2 High. Sync the stale scripts/testdata fixtures and unit-test expectations to the corrected record scores, and broaden the advisory dist-hook glob to *.json. Signed-off-by: Sameeh Jubran --- .github/workflows/advisory.yml | 10 ++-- .github/workflows/sbom.yml | 47 +++++++++------- Makefile.am | 24 +++++--- advisories/records/CVE-2026-5501.json | 8 +-- advisories/records/CVE-2026-5778.json | 12 ++-- scripts/advisory-vex-overlay.schema.json | 1 + scripts/bomsh_verify.py | 15 ++++- scripts/gen-advisory | 70 +++++++++++++++++++++--- scripts/gen-sbom | 7 ++- scripts/test_gen_advisory.py | 12 ++-- scripts/testdata/CVE-2026-5501.json | 8 +-- scripts/testdata/CVE-2026-5778.json | 12 ++-- 12 files changed, 156 insertions(+), 70 deletions(-) diff --git a/.github/workflows/advisory.yml b/.github/workflows/advisory.yml index 1431c6ab923..8d0981e9b92 100644 --- a/.github/workflows/advisory.yml +++ b/.github/workflows/advisory.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Syntax check run: python3 -m py_compile scripts/gen-advisory @@ -50,7 +50,7 @@ jobs: needs: unit timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install validators # cyclonedx-bom provides the CycloneDX 1.6 strict JSON validator (same @@ -154,7 +154,7 @@ jobs: - name: Upload generated advisories if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: advisories-${{ github.sha }} path: /tmp/adv/*.json @@ -176,9 +176,9 @@ jobs: needs: unit timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index ce1e1023dff..57b656fa221 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Syntax check run: python3 -m py_compile scripts/gen-sbom @@ -69,7 +69,7 @@ jobs: needs: unit timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install standalone-path deps # pcpp drives --user-settings; spdx-tools provides pyspdxtools @@ -403,7 +403,7 @@ jobs: # assertion above fails (which is precisely when the bytes matter). - name: Upload standalone SBOMs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom-standalone-${{ github.sha }} path: | @@ -428,7 +428,7 @@ jobs: needs: unit timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Pin tool versions; drift in any of these silently changes what # "valid" means and produces mystery CI failures. @@ -664,7 +664,7 @@ jobs: - name: Install liboqs (provides liboqs.pc for --with-liboqs) # Ubuntu noble (24.04) does not ship liboqs-dev in its archive # (Debian sid has 0.7.x; Ubuntu only has unsupported PPAs). Build - # from a pinned upstream tag so this job stays deterministic across + # from a pinned upstream commit so this job stays deterministic across # runs - any future liboqs API/ABI break shows up here, not in # production builds. Pinning matters: SBOM correctness assertions # below check purl shape, and an unpinned 'main' would silently @@ -673,8 +673,13 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ cmake ninja-build libssl-dev - git clone --depth=1 --branch 0.12.0 \ + # Pin to the exact commit the 0.12.0 tag resolves to (a tag is + # mutable, a SHA is not) so this provenance workflow's own build + # inputs are immutable. --filter=blob:none keeps the commit graph + # so `git checkout ` works without a full blob download. + git clone --filter=blob:none \ https://github.com/open-quantum-safe/liboqs /tmp/liboqs + git -C /tmp/liboqs checkout f4b96220e4bd208895172acc4fedb5a191d9f5b1 # 0.12.0 # -DOQS_USE_OPENSSL=OFF is load-bearing: without it, liboqs's # installed common.h pulls (system) into every # TU that includes . wolfssl/wolfcrypt/falcon.h @@ -814,7 +819,7 @@ jobs: # steps later cannot regress this into a hard failure. - name: Upload SBOM artefacts (linux) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom-integration-linux-${{ github.sha }} path: | @@ -835,7 +840,7 @@ jobs: needs: unit timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install build deps and SBOM validators run: | @@ -873,7 +878,7 @@ jobs: # Same `if: always()` rationale as the linux upload above. - name: Upload SBOM artefacts (macos) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom-integration-macos-${{ github.sha }} path: | @@ -895,7 +900,7 @@ jobs: needs: unit timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install build deps + SBOM validators run: | @@ -926,17 +931,18 @@ jobs: # bomsh has no releases; pin to last commit on main as of # 2024-10-31. The patch itself last changed 2024-02-06. BOMSH_SHA: 5823f7db7e5bd958e4ff868ae6ea79a7d871bb07 - # v6.7 (2024-01-29) is the strace release current when the + # strace v6.7 (2024-01-29) is the release current when the # patch was last touched; later releases tend to drift from - # the patch's context lines in src/strace.c. - STRACE_TAG: v6.7 + # the patch's context lines in src/strace.c. Pin to the exact + # commit the v6.7 tag resolves to so the input is immutable. + STRACE_SHA: 091ed4fda1f8ab86b79b52790d4ebc41c333bc49 # v6.7 run: | # --filter=blob:none is the cleanest "shallow-ish" clone when # checking out a specific SHA: it skips file blobs but keeps - # the commit graph, so `git checkout $BOMSH_SHA` still works. - # `--depth=1` would only work with `--branch `, not a - # raw SHA. The strace clone below uses `--depth=1 --branch` - # because it pins to a release tag. + # the commit graph, so `git checkout ` still works. Both + # bomsh and strace are pinned to raw commit SHAs (a tag is + # mutable, a SHA is not), matching how this provenance workflow + # treats every other build input. git clone --filter=blob:none https://github.com/omnibor/bomsh /tmp/bomsh git -C /tmp/bomsh checkout "$BOMSH_SHA" # Even with a pinned SHA, keep the layout-drift guard so the @@ -948,8 +954,9 @@ jobs: ls -la /tmp/bomsh/.devcontainer/ >&2 || true exit 1 fi - git clone --depth=1 --branch "$STRACE_TAG" \ + git clone --filter=blob:none \ https://github.com/strace/strace.git /tmp/strace + git -C /tmp/strace checkout "$STRACE_SHA" cp /tmp/bomsh/.devcontainer/patches/bomtrace3.patch /tmp/strace/ cp /tmp/bomsh/.devcontainer/src/*.[ch] /tmp/strace/src/ ( @@ -1096,7 +1103,7 @@ jobs: # wolfssl-*.cdx.json - CycloneDX equivalent. - name: Upload OmniBOR graph + bomsh-enriched SBOMs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: bomsh-omnibor-${{ github.sha }} path: | @@ -1112,7 +1119,7 @@ jobs: # provenance bundle above stays slim for downstream consumers # who don't need to debug ptrace gaps. if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: bomsh-trace-diag-${{ github.sha }} path: | diff --git a/Makefile.am b/Makefile.am index 532a825b2dc..3a20b7129fe 100644 --- a/Makefile.am +++ b/Makefile.am @@ -588,8 +588,8 @@ CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV) # --vex-overlay advisories/vex-overlay.json \ # --out-dir advisories/out # -# Inputs (tracked in git): advisories/records/CVE-*.json + advisories/vex-overlay.json -# Outputs (build artifacts): advisories/out/CVE-*.{csaf,cdx}.json +# Inputs (tracked in git): advisories/records/*.json + advisories/vex-overlay.json +# Outputs (build artifacts): advisories/out/*.{csaf,cdx}.json ADVISORY_RECORDS_DIR = $(srcdir)/advisories/records ADVISORY_OVERLAY = $(srcdir)/advisories/vex-overlay.json ADVISORY_OUT_DIR = $(abs_builddir)/advisories/out @@ -624,6 +624,7 @@ advisory: install-advisory: advisory $(MKDIR_P) $(DESTDIR)$(advisorydir) @for f in $(ADVISORY_OUT_DIR)/*.json; do \ + test -f "$$f" || continue; \ echo " $(INSTALL_DATA) $$f $(DESTDIR)$(advisorydir)/"; \ $(INSTALL_DATA) "$$f" $(DESTDIR)$(advisorydir)/; \ done @@ -635,10 +636,19 @@ uninstall-advisory: CLEANFILES += advisories/out/*.csaf.json advisories/out/*.cdx.json # Ship the advisory generator inputs in the dist tarball so a downstream -# consumer can `./configure && make advisory` from a release. -EXTRA_DIST += advisories/records/CVE-2026-5501.json \ - advisories/records/CVE-2026-5778.json \ - advisories/vex-overlay.json +# consumer can `./configure && make advisory` from a release. The per-CVE +# records are copied via dist-hook (glob) rather than listed in EXTRA_DIST so +# a newly-added record ships automatically: a hardcoded list silently drops +# new records from `make dist`, and the omission only surfaces as a failing +# downstream `make advisory`. +EXTRA_DIST += advisories/vex-overlay.json + +dist-hook: + $(MKDIR_P) $(distdir)/advisories/records + @for f in $(srcdir)/advisories/records/*.json; do \ + test -f "$$f" || continue; \ + cp -p "$$f" $(distdir)/advisories/records/; \ + done # Bomsh (OmniBOR build artifact tracing + SBOM enrichment) BOMSH_RAWLOG_BASE = $(abs_builddir)/bomsh_raw_logfile @@ -735,4 +745,4 @@ CLEANFILES += $(BOMSH_RAWLOG) $(BOMSH_RAWLOG_BASE).sha256 $(BOMSH_CONF) $(BOMSH_ # safe whether or not those targets were ever run. Depending on them # rather than duplicating their bodies keeps the cleanup paths in lock # step with install-sbom/install-bomsh. -uninstall-hook: uninstall-sbom uninstall-bomsh +uninstall-hook: uninstall-sbom uninstall-bomsh uninstall-advisory diff --git a/advisories/records/CVE-2026-5501.json b/advisories/records/CVE-2026-5501.json index 8f7a3f2677a..ffb42e154c7 100644 --- a/advisories/records/CVE-2026-5501.json +++ b/advisories/records/CVE-2026-5501.json @@ -82,7 +82,7 @@ "attackVector": "NETWORK", "attackComplexity": "LOW", "attackRequirements": "NONE", - "privilegesRequired": "LOW", + "privilegesRequired": "NONE", "userInteraction": "NONE", "vulnConfidentialityImpact": "HIGH", "subConfidentialityImpact": "NONE", @@ -98,9 +98,9 @@ "vulnerabilityResponseEffort": "NOT_DEFINED", "providerUrgency": "NOT_DEFINED", "version": "4.0", - "baseSeverity": "HIGH", - "baseScore": 8.6, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + "baseSeverity": "CRITICAL", + "baseScore": 9.3, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" } } ], diff --git a/advisories/records/CVE-2026-5778.json b/advisories/records/CVE-2026-5778.json index 2964d7af1e4..1dbd2306817 100644 --- a/advisories/records/CVE-2026-5778.json +++ b/advisories/records/CVE-2026-5778.json @@ -82,13 +82,13 @@ "attackVector": "NETWORK", "attackComplexity": "LOW", "attackRequirements": "PRESENT", - "privilegesRequired": "LOW", - "userInteraction": "PASSIVE", + "privilegesRequired": "NONE", + "userInteraction": "NONE", "vulnConfidentialityImpact": "NONE", "subConfidentialityImpact": "NONE", "vulnIntegrityImpact": "NONE", "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "LOW", + "vulnAvailabilityImpact": "HIGH", "subAvailabilityImpact": "NONE", "exploitMaturity": "NOT_DEFINED", "Safety": "NOT_DEFINED", @@ -98,9 +98,9 @@ "vulnerabilityResponseEffort": "NOT_DEFINED", "providerUrgency": "NOT_DEFINED", "version": "4.0", - "baseSeverity": "LOW", - "baseScore": 2.1, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + "baseSeverity": "HIGH", + "baseScore": 8.2, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" } } ], diff --git a/scripts/advisory-vex-overlay.schema.json b/scripts/advisory-vex-overlay.schema.json index 09b42c0bee4..1417dc3ce3f 100644 --- a/scripts/advisory-vex-overlay.schema.json +++ b/scripts/advisory-vex-overlay.schema.json @@ -105,6 +105,7 @@ }, "fips": { "$ref": "#/$defs/fips" } }, + "required": ["state"], "additionalProperties": false, "allOf": [ { diff --git a/scripts/bomsh_verify.py b/scripts/bomsh_verify.py index 683617ee76f..30e41b89730 100644 --- a/scripts/bomsh_verify.py +++ b/scripts/bomsh_verify.py @@ -34,11 +34,17 @@ import hashlib import json import os +import re import sys from typing import List GITOID_LOCATOR_PREFIX = 'gitoid:blob:sha1:' +# An OmniBOR sha1 gitoid is exactly 40 lowercase-hex chars. Validate before +# the value is used to build an object path: a crafted SPDX with a locator like +# 'gitoid:blob:sha1:../../../etc/shadow' otherwise passes the prefix check and +# turns the os.path.join() below into a path-traversal existence oracle. +_SHA1_HEX_RE = re.compile(r'[0-9a-f]{40}\Z') def gitoid_sha1(path): @@ -75,8 +81,13 @@ def load_spdx_gitoids(spdx_path): f'unexpected gitoid locator format: {loc!r} ' f'(expected {GITOID_LOCATOR_PREFIX}; if bomsh ' f'has switched to sha256 the verifier needs updating)') - gitoids.append((pkg.get('name', ''), - loc[len(GITOID_LOCATOR_PREFIX):])) + gid = loc[len(GITOID_LOCATOR_PREFIX):] + if not _SHA1_HEX_RE.match(gid): + raise ValueError( + f'malformed gitoid {gid!r} in locator {loc!r}: expected ' + f'40 lowercase-hex sha1 characters (refusing to use it in ' + f'an object path)') + gitoids.append((pkg.get('name', ''), gid)) return gitoids diff --git a/scripts/gen-advisory b/scripts/gen-advisory index 8bfcc2c099c..39c242284a8 100755 --- a/scripts/gen-advisory +++ b/scripts/gen-advisory @@ -82,6 +82,25 @@ WOLFSSL_PUBLISHER = { 'namespace': 'https://www.wolfssl.com', } WOLFSSL_ADVISORIES_URL = 'https://www.wolfssl.com/docs/security-vulnerabilities/' +# Canonical directory under which the generated CSAF JSON documents are +# published. CSAF 2.0 requires the document's `self` reference to point at the +# canonical location of *this* document (not a generic landing page), so the +# self URL is built as /.csaf.json, matching the on-disk +# filename this script writes. +# +# DEPLOY-TIME FOLLOW-UPS (not enforced by the CSAF mandatory-test gate in +# .github/workflows/advisory.yml, which validates document content, not the +# distribution layout): +# - Base URL: this is a placeholder and will 404 until the documents are +# actually hosted. Point it at wolfSSL's real CSAF distribution location. +# - Filename convention: CSAF's recommended filename is the tracking id +# lowercased with every char outside [+\-a-z0-9] replaced by '_' and a +# plain '.json' extension (e.g. cve-2026-5501.json). We keep the +# '.csaf.json' form here so the self URL stays consistent with the +# emitted file and so co-located '.cdx.json' VEX docs remain glob- +# selectable; switch both the self URL and the writer to the canonical +# lowercase name when the hosting layout is finalized. +WOLFSSL_CSAF_BASE_URL = WOLFSSL_ADVISORIES_URL + 'csaf/' ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/advisory/') @@ -90,15 +109,42 @@ ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, _SEV_RANK = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'MODERATE': 2, 'LOW': 1, 'NONE': 0} -# CycloneDX analysis.state -> CSAF product_status bucket for affected products. -# not_affected is handled separately (it also needs a flag justification). +# CycloneDX analysis.state -> CSAF product_status bucket for the *vulnerable* +# version ranges (those the CVE record marks status=affected). Every state in +# the CycloneDX 1.6 vocabulary (mirrored by the overlay schema) is mapped +# explicitly; _bucket_for() hard-fails on anything else rather than silently +# defaulting an unknown determination to the worst case (known_affected). +# +# 'resolved' / 'resolved_with_pedigree' map to known_affected on purpose: the +# ranges placed in this bucket are the vulnerable versions, which remain +# affected even after a fix ships. The fixed version is emitted separately +# into the 'fixed' bucket (see the per-product registration loop), so a +# resolved CVE correctly reports old ranges as known_affected and the patched +# release as fixed. Mapping 'resolved' to 'fixed' here would wrongly mark the +# vulnerable ranges as patched. +# +# not_affected / false_positive map to known_not_affected (and also emit a CSAF +# flag justification). _STATE_TO_BUCKET = { 'exploitable': 'known_affected', 'in_triage': 'under_investigation', 'resolved': 'known_affected', + 'resolved_with_pedigree': 'known_affected', + 'false_positive': 'known_not_affected', 'not_affected': 'known_not_affected', } + +def _bucket_for(state): + """Map a CycloneDX analysis state to its CSAF product_status bucket, + failing loudly on an unrecognized state instead of defaulting to the + worst-case (known_affected) bucket.""" + try: + return _STATE_TO_BUCKET[state] + except KeyError: + sys.exit(f"ERROR: unknown analysis state {state!r}; expected one of " + f"{', '.join(sorted(_STATE_TO_BUCKET))}") + # CycloneDX not_affected justification -> CSAF flag label (same VEX concept). _JUSTIFICATION_TO_CSAF_FLAG = { 'code_not_present': 'vulnerable_code_not_present', @@ -300,7 +346,7 @@ def product_model(adv, ov): products = [] state = ov.get('state', 'exploitable') - bucket = _STATE_TO_BUCKET.get(state, 'known_affected') + bucket = _bucket_for(state) # ---- mainline product(s) from the CVE record's affected[] ---- for a in adv['affected']: @@ -338,8 +384,7 @@ def product_model(adv, ov): if fips: name = fips.get('name', 'wolfCrypt FIPS Module') modver = fips.get('module_version') - fbucket = _STATE_TO_BUCKET.get(fips.get('status', 'exploitable'), - 'known_affected') + fbucket = _bucket_for(fips.get('status', 'exploitable')) affected_ranges = [] if modver: affected_ranges.append({ @@ -574,9 +619,18 @@ def generate_csaf(advs, ov_map, advisory_id, timestamp): }, }, 'distribution': {'tlp': {'label': 'WHITE'}}, - 'references': [{ - 'summary': 'wolfSSL published security vulnerabilities', - 'url': WOLFSSL_ADVISORIES_URL, 'category': 'self'}], + 'references': [ + { + 'summary': f'Canonical CSAF document for {advisory_id}', + 'url': f'{WOLFSSL_CSAF_BASE_URL}{advisory_id}.csaf.json', + 'category': 'self', + }, + { + 'summary': 'wolfSSL published security vulnerabilities', + 'url': WOLFSSL_ADVISORIES_URL, + 'category': 'external', + }, + ], 'notes': [{ 'category': 'summary', 'title': 'Summary', diff --git a/scripts/gen-sbom b/scripts/gen-sbom index ad5d56c7d16..8dd0fec36b1 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -579,9 +579,12 @@ def gitoid_blob_sha256(path): """ h = hashlib.sha256() try: - size = os.path.getsize(path) - h.update(f'blob {size}\x00'.encode()) with open(path, 'rb') as f: + # Take the size from the open descriptor (not a prior + # os.path.getsize) so the gitoid header length and the bytes + # hashed below come from the same file, with no TOCTOU window. + size = os.fstat(f.fileno()).st_size + h.update(f'blob {size}\x00'.encode()) for chunk in iter(lambda: f.read(65536), b''): h.update(chunk) except OSError as e: diff --git a/scripts/test_gen_advisory.py b/scripts/test_gen_advisory.py index 1b134c706c7..9a9019fe948 100644 --- a/scripts/test_gen_advisory.py +++ b/scripts/test_gen_advisory.py @@ -207,8 +207,8 @@ def test_cwe_id_and_canonical_name(self): def test_cvss_is_v4_and_no_csaf20_compatible_score(self): adv = _adv('CVE-2026-5501.json') self.assertEqual(adv['cvss']['csaf_key'], 'cvss_v4') - self.assertEqual(adv['cvss']['data']['baseSeverity'], 'HIGH') - self.assertEqual(adv['cvss']['data']['baseScore'], 8.6) + self.assertEqual(adv['cvss']['data']['baseSeverity'], 'CRITICAL') + self.assertEqual(adv['cvss']['data']['baseScore'], 9.3) # The record carries only CVSS v4, which CSAF 2.0 scores[] cannot hold. self.assertIsNone(adv['cvss_csaf']) @@ -393,7 +393,7 @@ def test_v4_only_record_emits_cvss_note(self): titles = [n.get('title') for n in v['notes']] self.assertIn('CVSS v4.0', titles) note = [n for n in v['notes'] if n.get('title') == 'CVSS v4.0'][0] - self.assertIn('8.6', note['text']) + self.assertIn('9.3', note['text']) def test_cwe_uses_canonical_catalogue_name(self): v = [x for x in self.bundle['vulnerabilities'] @@ -425,9 +425,9 @@ def test_bundle_has_two_vulns_and_aggregate_severity(self): self.assertEqual(len(self.bundle['vulnerabilities']), 2) cves = {v['cve'] for v in self.bundle['vulnerabilities']} self.assertEqual(cves, {'CVE-2026-5501', 'CVE-2026-5778'}) - # HIGH (5501) outranks LOW (5778). + # CRITICAL (5501) outranks HIGH (5778). self.assertEqual(self.bundle['document']['aggregate_severity']['text'], - 'HIGH') + 'CRITICAL') def test_hedge_note_present_for_sniffer_cve(self): v = [x for x in self.bundle['vulnerabilities'] @@ -514,7 +514,7 @@ def test_analysis_state_and_cwe_and_rating(self): self.assertEqual(v['analysis']['state'], 'exploitable') self.assertEqual(v['cwes'], [295]) self.assertEqual(v['ratings'][0]['method'], 'CVSSv4') - self.assertEqual(v['ratings'][0]['severity'], 'high') + self.assertEqual(v['ratings'][0]['severity'], 'critical') # --------------------------------------------------------------------------- # diff --git a/scripts/testdata/CVE-2026-5501.json b/scripts/testdata/CVE-2026-5501.json index 8f7a3f2677a..ffb42e154c7 100644 --- a/scripts/testdata/CVE-2026-5501.json +++ b/scripts/testdata/CVE-2026-5501.json @@ -82,7 +82,7 @@ "attackVector": "NETWORK", "attackComplexity": "LOW", "attackRequirements": "NONE", - "privilegesRequired": "LOW", + "privilegesRequired": "NONE", "userInteraction": "NONE", "vulnConfidentialityImpact": "HIGH", "subConfidentialityImpact": "NONE", @@ -98,9 +98,9 @@ "vulnerabilityResponseEffort": "NOT_DEFINED", "providerUrgency": "NOT_DEFINED", "version": "4.0", - "baseSeverity": "HIGH", - "baseScore": 8.6, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + "baseSeverity": "CRITICAL", + "baseScore": 9.3, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" } } ], diff --git a/scripts/testdata/CVE-2026-5778.json b/scripts/testdata/CVE-2026-5778.json index 2964d7af1e4..1dbd2306817 100644 --- a/scripts/testdata/CVE-2026-5778.json +++ b/scripts/testdata/CVE-2026-5778.json @@ -82,13 +82,13 @@ "attackVector": "NETWORK", "attackComplexity": "LOW", "attackRequirements": "PRESENT", - "privilegesRequired": "LOW", - "userInteraction": "PASSIVE", + "privilegesRequired": "NONE", + "userInteraction": "NONE", "vulnConfidentialityImpact": "NONE", "subConfidentialityImpact": "NONE", "vulnIntegrityImpact": "NONE", "subIntegrityImpact": "NONE", - "vulnAvailabilityImpact": "LOW", + "vulnAvailabilityImpact": "HIGH", "subAvailabilityImpact": "NONE", "exploitMaturity": "NOT_DEFINED", "Safety": "NOT_DEFINED", @@ -98,9 +98,9 @@ "vulnerabilityResponseEffort": "NOT_DEFINED", "providerUrgency": "NOT_DEFINED", "version": "4.0", - "baseSeverity": "LOW", - "baseScore": 2.1, - "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + "baseSeverity": "HIGH", + "baseScore": 8.2, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" } } ], From f16daa0bdd46848e6ee015e33cdda1bb12cbb6a2 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Tue, 23 Jun 2026 15:36:16 +0300 Subject: [PATCH 38/39] feat(sbom): add --srcs-file, --no-artifact-hash, and hash-source tag Support embedded builds that supply a file-listed source set or have no hashable artefact (ROM/HSM), tagging checksum provenance as lib/srcs/none. Signed-off-by: Sameeh Jubran --- doc/SBOM.md | 44 +++++++- scripts/gen-sbom | 159 ++++++++++++++++++++++---- scripts/test_gen_sbom.py | 236 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 413 insertions(+), 26 deletions(-) diff --git a/doc/SBOM.md b/doc/SBOM.md index 36a1e49aea4..519a2be7a82 100644 --- a/doc/SBOM.md +++ b/doc/SBOM.md @@ -69,8 +69,12 @@ That command produces the same two SBOM JSON files (CycloneDX 1.6 and SPDX | `--user-settings-include DIR` (repeatable) | Include path containing your `user_settings.h` and the wolfSSL tree | Same as the `-I` flags in your build | | `--user-settings-define NAME[=VALUE]` (repeatable) | Macros to predefine for preprocessing | Same as the `-D` flags in your build (at minimum: `-D WOLFSSL_USER_SETTINGS`) | | `--srcs PATH …` | wolfSSL source files compiled into your firmware | The same source list you pass to your compiler | +| `--srcs-file PATH` | A file listing the wolfSSL source files, one per line (`#` comments and blank lines ignored) | Emitted mechanically by your IDE / build (link map, project export) when the list is too long for the command line | | `--cdx-out / --spdx-out` | Output paths for the SBOM JSON files | Anywhere you want | +Exactly one component-checksum source is required: `--lib`, `--srcs` / +`--srcs-file`, or `--no-artifact-hash` (see § 1.4). + Optional flags: | Flag | When to use it | @@ -81,6 +85,7 @@ Optional flags: | `--dep-version libz=1.3.1` | Explicit dep version when `pkg-config` is unavailable (typical cross-compile) | | `--license-override LicenseRef-wolfSSL-Commercial` | If you are a commercial licensee, not GPL | | `--license-text /path/to/commercial-license.txt` | Required when `--license-override` is a `LicenseRef-*` | +| `--no-artifact-hash` | Record a placeholder checksum when **no** hashable artefact exists (ROM image, HSM firmware, binary-only redistribution). Mutually exclusive with `--lib` / `--srcs` / `--srcs-file` (see § 1.4) | | `--document-namespace https://example.com/sbom/wolfssl-5.9.1.spdx.json` | Override the SPDX `documentNamespace`. Default is a deterministic `urn:uuid:` derived from `--name`/`--version` (SPDX 2.3 §6.5 requires only uniqueness, not resolvability). Set this when **your** distribution re-hosts the SBOM under a stable URL. | ### 1.3 Dependencies @@ -126,14 +131,47 @@ The resulting hash: - interoperates with bomsh / OmniBOR tooling, which key off the same gitoid format. -Each standalone SBOM is annotated with two extra properties so the +Each standalone SBOM is annotated with extra properties so the checksum's semantics are unambiguous to downstream consumers: ```json -{ "name": "wolfssl:sbom:hash-kind", "value": "source-merkle-omnibor" }, -{ "name": "wolfssl:sbom:source-set", "value": "aes.c,dh.c,sha.c,sha256.c,..." } +{ "name": "wolfssl:sbom:hash-kind", "value": "source-merkle-omnibor" }, +{ "name": "wolfssl:sbom:hash-source", "value": "srcs" }, +{ "name": "wolfssl:sbom:source-set", "value": "aes.c,dh.c,sha.c,sha256.c,..." } ``` +`wolfssl:sbom:hash-source` is the coarse, stable provenance tag that +downstream tooling filters on — which **input** the checksum came from: + +| `hash-source` | Meaning | +|---|---| +| `lib` | SHA-256 of a built library archive (`--lib`) | +| `srcs` | OmniBOR gitoid Merkle hash of the compiled source set (`--srcs` / `--srcs-file`) | +| `none` | Placeholder; no hashable artefact was available (`--no-artifact-hash`) | + +`wolfssl:sbom:hash-kind` carries the finer implementation detail +(`library-binary` vs `source-merkle-omnibor` vs `none`); `hash-source` +is what an integrator keys on without needing to know the hashing +internals. + +#### No hashable artefact (`--no-artifact-hash`) + +For ROM images, HSM firmware, or binary-only redistributions where +neither a library archive nor the compiled source files are accessible, +pass `--no-artifact-hash`. The checksum field is then a synthetic +64-zero placeholder (`0000…0000`), and the SBOM carries: + +```json +{ "name": "wolfssl:sbom:hash-source", "value": "none" }, +{ "name": "wolfssl:sbom:no-artifact-hash-note", "value": "No artefact hash was available …" } +``` + +The placeholder can never collide with a real SHA-256, and the note +directs integrators to contact wolfSSL for an integrity-verification +approach appropriate to their build. Use this only as a last resort: +a `srcs`-based hash is strongly preferred because it is independently +verifiable against the public wolfSSL source tree. + The autotools `make sbom` path keeps `wolfssl:sbom:hash-kind` implicit (equal to `library-binary`) so its output stays byte-identical to previous releases. diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 8dd0fec36b1..34173510af2 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -21,7 +21,23 @@ from datetime import datetime, timezone # fields. Reproducibility CI keys on byte-equal SBOMs across re-runs, # so this constant must change in lockstep with the output it produces. GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen' -GEN_SBOM_VERSION = '1.1' +GEN_SBOM_VERSION = '1.2' + +# Placeholder recorded in the component checksum fields when the operator +# passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only +# redistribution) where neither a library archive nor the compiled source +# files are accessible to hash. 64 zero hex digits is an obviously-synthetic +# SHA-256 that can never collide with a real artefact, and the companion +# `wolfssl:sbom:hash-source=none` property plus the note below tell a +# downstream auditor the value is intentional, not a generation bug. +_NO_HASH_SENTINEL = '0' * 64 +_NO_HASH_NOTE = ( + 'No artefact hash was available at SBOM generation time ' + '(--no-artifact-hash). The checksum field is a placeholder, not a real ' + 'SHA-256 of any wolfSSL component. Contact wolfssl@wolfssl.com to ' + 'arrange integrity verification appropriate to this build before relying ' + 'on this SBOM for CRA conformance.' +) # Stable namespace for deterministic uuid5 derivation. The seed string is # an opaque input to uuid5 -- it only needs to be (a) constant across @@ -626,6 +642,53 @@ def srcs_merkle_hash(src_paths): return h.hexdigest() +def _collect_srcs(srcs_args, srcs_file): + """Merge the --srcs list and the --srcs-file list into one ordered, + path-deduplicated list of source files. + + --srcs-file is the file-driven companion to --srcs: one path per line, + with blank lines and `#` comment lines ignored. It exists because an + embedded link line can run to hundreds of wolfSSL .c files -- more than + fits comfortably on a command line -- and because an IDE / build system + can emit such a list mechanically (from a link map or project export), + which is exactly how a *complete* source set should be produced rather + than hand-curated. + + Identical paths appearing in both inputs are collapsed (first occurrence + wins) so that combining a base --srcs-file with a couple of extra --srcs + overrides does not trip srcs_merkle_hash's duplicate-basename guard on a + file the operator listed twice by accident. Genuine distinct files that + share a basename are still rejected downstream -- that guard is what keeps + the Merkle hash order-independent. + """ + paths = list(srcs_args or []) + if srcs_file: + try: + with open(srcs_file, 'r') as f: + raw_lines = f.read().splitlines() + except OSError as e: + sys.exit(f"ERROR: cannot read --srcs-file {srcs_file!r}: {e}") + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + paths.append(stripped) + + seen = set() + deduped = [] + for p in paths: + if p not in seen: + seen.add(p) + deduped.append(p) + + if not deduped: + sys.exit( + "ERROR: --srcs / --srcs-file produced an empty source list.\n" + " Pass at least one wolfSSL .c file, or use " + "--no-artifact-hash if no hashable artefact exists.") + return deduped + + def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None): """Return (bom_ref, component_dict) for a CDX dependency component. bom_ref is deterministic for reproducibility.""" @@ -677,7 +740,7 @@ def spdx_dep_package(key, dep_version_overrides=None): def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - srcs_basenames=None, file_entries=None): + hash_source='lib', srcs_basenames=None, file_entries=None): bom_ref = derived_uuid(name, version, 'package') dep_bom_refs = [] @@ -699,6 +762,18 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, # a single property lookup. properties.append( {'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind}) + # hash-source is the coarse, stable provenance tag downstream tooling + # keys on: which *input* the checksum came from -- 'lib' (library + # archive), 'srcs' (compiled source set), or 'none' (no hashable + # artefact). hash-kind above carries the finer implementation detail + # (e.g. source-merkle-omnibor); hash-source is the value an integrator + # filters on without needing to know our hashing internals. + properties.append( + {'name': 'wolfssl:sbom:hash-source', 'value': hash_source}) + if hash_source == 'none': + properties.append( + {'name': 'wolfssl:sbom:no-artifact-hash-note', + 'value': _NO_HASH_NOTE}) if srcs_basenames: properties.append({ 'name': 'wolfssl:sbom:source-set', @@ -777,8 +852,8 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - srcs_basenames=None, document_namespace=None, - file_entries=None): + hash_source='lib', srcs_basenames=None, + document_namespace=None, file_entries=None): build_defines = ', '.join(k for k, _ in build_props) # Hash-kind / source-set / bomsh-traced-binary information used to # be stuffed into the package `comment` as `key=value` slugs, which @@ -803,6 +878,9 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, }) _annotate(f'wolfssl:sbom:hash-kind={hash_kind}') + _annotate(f'wolfssl:sbom:hash-source={hash_source}') + if hash_source == 'none': + _annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}') if srcs_basenames: _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) @@ -1005,7 +1083,25 @@ def main(): 'firmware (embedded entry point). Their ' 'OmniBOR-compatible gitoid Merkle hash is ' 'used as the SBOM component checksum ' - 'instead of --lib.') + 'instead of --lib. May be combined with ' + '--srcs-file.') + parser.add_argument('--srcs-file', default=None, metavar='PATH', + help='Path to a file listing wolfSSL source files, ' + 'one per line (blank lines and lines starting ' + 'with `#` are ignored). The file-driven ' + 'companion to --srcs for link lines too long ' + 'for the command line, or lists emitted ' + 'mechanically by an IDE / build system (link ' + 'map, project export). Merged with --srcs and ' + 'hashed the same way.') + parser.add_argument('--no-artifact-hash', action='store_true', + help='Record a placeholder component checksum when ' + 'no hashable artefact exists (ROM image, HSM ' + 'firmware, binary-only redistribution). Emits ' + 'wolfssl:sbom:hash-source=none and a note ' + 'directing integrators to contact wolfSSL. ' + 'Mutually exclusive with --lib / --srcs / ' + '--srcs-file.') parser.add_argument('--dep-libz', default='no', help='yes if built with --with-libz') parser.add_argument('--dep-liboqs', default='no', @@ -1046,12 +1142,16 @@ def main(): " --user-settings: embedded entry point (path to " "wolfssl/wolfcrypt/settings.h, with --user-settings-include " "pointing at the directory containing user_settings.h).") - if bool(args.lib) == bool(args.srcs): + srcs_provided = bool(args.srcs) or bool(args.srcs_file) + hash_sources = [bool(args.lib), srcs_provided, bool(args.no_artifact_hash)] + if sum(hash_sources) != 1: sys.exit( - "ERROR: pass exactly one of --lib or --srcs.\n" + "ERROR: pass exactly one component-checksum source.\n" " --lib: hash a built library artefact (.so/.a/.dylib).\n" - " --srcs: hash the wolfSSL source files compiled into " - "your firmware (OmniBOR gitoid Merkle hash).") + " --srcs / --srcs-file: hash the wolfSSL source files " + "compiled into your firmware (OmniBOR gitoid Merkle hash).\n" + " --no-artifact-hash: record a placeholder when no " + "hashable artefact exists (ROM/HSM/binary-only).") # SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI # per RFC 3986. `make sbom` runs pyspdxtools afterwards and would @@ -1134,6 +1234,7 @@ def main(): "real library artefact.") lib_sha1, lib_hash = sha1_sha256_file(args.lib) hash_kind = 'library-binary' + hash_source = 'lib' srcs_basenames = None # Single SPDX file entry / CycloneDX file sub-component for # the linked library, so the SBOM names the artefact whose @@ -1146,14 +1247,31 @@ def main(): 'sha1': lib_sha1, 'sha256': lib_hash, }] + elif args.no_artifact_hash: + # No hashable artefact available (ROM image, HSM firmware, + # binary-only redistribution). Record an obviously-synthetic + # placeholder rather than a real SHA-256, flagged by both the + # hash-source property and the contact note so a downstream + # auditor cannot mistake it for a genuine artefact digest. + print( + "NOTE: --no-artifact-hash: recording a placeholder component " + "checksum (no library or source set to hash). Contact " + "wolfssl@wolfssl.com for integrity verification options.", + file=sys.stderr) + lib_hash = _NO_HASH_SENTINEL + hash_kind = 'none' + hash_source = 'none' + srcs_basenames = None else: - # --srcs is the embedded entry point. Zero-byte files in the - # set are uncommon but not necessarily wrong (a cross-compile - # toolchain may stub a per-target source with touch); warn - # rather than fail so the customer can decide whether the - # gitoid for an empty blob is what they want recorded. + # --srcs / --srcs-file is the embedded entry point. Zero-byte + # files in the set are uncommon but not necessarily wrong (a + # cross-compile toolchain may stub a per-target source with + # touch); warn rather than fail so the customer can decide + # whether the gitoid for an empty blob is what they want + # recorded. + srcs = _collect_srcs(args.srcs, args.srcs_file) zero_byte_srcs = [ - p for p in args.srcs if os.path.isfile(p) and os.path.getsize(p) == 0 + p for p in srcs if os.path.isfile(p) and os.path.getsize(p) == 0 ] if zero_byte_srcs: print( @@ -1161,9 +1279,10 @@ def main(): "be the well-known empty-blob hash for these): " + ', '.join(zero_byte_srcs), file=sys.stderr) - lib_hash = srcs_merkle_hash(args.srcs) + lib_hash = srcs_merkle_hash(srcs) hash_kind = 'source-merkle-omnibor' - srcs_basenames = sorted({os.path.basename(p) for p in args.srcs}) + hash_source = 'srcs' + srcs_basenames = sorted({os.path.basename(p) for p in srcs}) dt, timestamp = build_timestamp() year = dt.year @@ -1175,7 +1294,8 @@ def main(): license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, dep_version_overrides=dep_version_overrides, - hash_kind=hash_kind, srcs_basenames=srcs_basenames, + hash_kind=hash_kind, hash_source=hash_source, + srcs_basenames=srcs_basenames, file_entries=file_entries, ) spdx = generate_spdx( @@ -1183,7 +1303,8 @@ def main(): license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, dep_version_overrides=dep_version_overrides, - hash_kind=hash_kind, srcs_basenames=srcs_basenames, + hash_kind=hash_kind, hash_source=hash_source, + srcs_basenames=srcs_basenames, document_namespace=(args.document_namespace or None), file_entries=file_entries, ) diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index b6d7340ed47..dd86c6e825f 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -13,6 +13,7 @@ """ import importlib.util +import json import os import pathlib import re @@ -1305,6 +1306,69 @@ def test_none_is_cached_when_pkgconfig_missing(self): gs.pkgconfig_version = original +class TestCollectSrcs(unittest.TestCase): + """_collect_srcs merges --srcs and --srcs-file into one ordered, + path-deduplicated list. --srcs-file lets an IDE / build system feed + a mechanically-generated source list (the only way to get a truly + complete set) when it is too long for the command line.""" + + def _write(self, lines): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write(lines) + return f.name + + def test_srcs_only(self): + self.assertEqual( + gs._collect_srcs(['a.c', 'b.c'], None), + ['a.c', 'b.c']) + + def test_srcs_file_only(self): + path = self._write('a.c\nb.c\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_blank_and_comment_lines_ignored(self): + path = self._write('# header\n\na.c\n # indented comment\nb.c\n\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_srcs_and_file_merge_and_dedup_paths(self): + # A path appearing in both --srcs and --srcs-file collapses to one + # entry (first occurrence wins) so it does not later trip + # srcs_merkle_hash's duplicate-basename guard. + path = self._write('b.c\nc.c\n') + try: + self.assertEqual( + gs._collect_srcs(['a.c', 'b.c'], path), + ['a.c', 'b.c', 'c.c']) + finally: + os.unlink(path) + + def test_whitespace_is_stripped(self): + path = self._write(' a.c \n\tb.c\t\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_empty_result_exits(self): + path = self._write('# only comments\n\n') + try: + with self.assertRaises(SystemExit): + gs._collect_srcs(None, path) + finally: + os.unlink(path) + + def test_unreadable_srcs_file_exits(self): + with self.assertRaises(SystemExit): + gs._collect_srcs(None, '/nonexistent/dir/does-not-exist.txt') + + class TestCliMutualExclusion(unittest.TestCase): """The two entry-point shapes (autotools / standalone) must be mutually exclusive. Mixing them would produce a hash whose @@ -1352,14 +1416,35 @@ def test_lib_and_srcs_together_fail(self): '--lib', '/dev/null', '--srcs', '/dev/null') self.assertNotEqual(result.returncode, 0) - self.assertIn('--lib or --srcs', result.stderr) + self.assertIn('component-checksum source', result.stderr) def test_neither_lib_nor_srcs_fails(self): result = self._run( *self.BASE, '--options-h', '/dev/null') self.assertNotEqual(result.returncode, 0) - self.assertIn('--lib or --srcs', result.stderr) + self.assertIn('component-checksum source', result.stderr) + + def test_no_artifact_hash_with_srcs_fails(self): + # --no-artifact-hash is the "no hashable artefact" escape hatch; + # combining it with a real hash source (--srcs here) is a + # contradiction the operator must resolve, so gen-sbom refuses it. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--no-artifact-hash', + '--srcs', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) + + def test_no_artifact_hash_with_lib_fails(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--no-artifact-hash', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) def test_licenseref_without_license_text_is_rejected(self): # Hard contract enforced at gen-sbom main() (see gen-sbom:880): @@ -1488,10 +1573,78 @@ def test_user_settings_path_in_help(self): result = self._run('--help') self.assertEqual(result.returncode, 0, result.stderr) for token in ('--user-settings', '--user-settings-include', - '--user-settings-define', '--srcs', - '--dep-version'): + '--user-settings-define', '--srcs', '--srcs-file', + '--no-artifact-hash', '--dep-version'): self.assertIn(token, result.stdout, f'{token!r} missing from --help') + def test_srcs_file_matches_srcs_for_same_list(self): + # --srcs-file is purely an input convenience: for the same set of + # files it must produce a byte-identical SBOM to passing the files + # via --srcs. This pins that equivalence end-to-end so the two + # input paths can never silently diverge. + with tempfile.TemporaryDirectory() as tmp: + aes = os.path.join(tmp, 'aes.c') + sha = os.path.join(tmp, 'sha.c') + with open(aes, 'w') as f: + f.write('/* aes */\n') + with open(sha, 'w') as f: + f.write('/* sha */\n') + listfile = os.path.join(tmp, 'srcs.txt') + with open(listfile, 'w') as f: + f.write(f'# wolfssl sources\n{aes}\n\n{sha}\n') + + cdx_a = os.path.join(tmp, 'a.cdx.json') + spdx_a = os.path.join(tmp, 'a.spdx.json') + cdx_b = os.path.join(tmp, 'b.cdx.json') + spdx_b = os.path.join(tmp, 'b.spdx.json') + common = [ + '--name', 'wolfssl', '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--user-settings', '/dev/null', + ] + env = dict(os.environ, SOURCE_DATE_EPOCH='1700000000') + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = str(here / 'gen-sbom') + r1 = subprocess.run( + ['python3', script, *common, '--srcs', aes, sha, + '--cdx-out', cdx_a, '--spdx-out', spdx_a], + capture_output=True, text=True, env=env) + r2 = subprocess.run( + ['python3', script, *common, '--srcs-file', listfile, + '--cdx-out', cdx_b, '--spdx-out', spdx_b], + capture_output=True, text=True, env=env) + self.assertEqual(r1.returncode, 0, r1.stderr) + self.assertEqual(r2.returncode, 0, r2.stderr) + with open(cdx_a) as f: + a_cdx = f.read() + with open(cdx_b) as f: + b_cdx = f.read() + self.assertEqual(a_cdx, b_cdx) + + def test_no_artifact_hash_emits_placeholder_and_note(self): + # End-to-end: --no-artifact-hash must produce a valid SBOM whose + # checksum is the synthetic 64-zero placeholder, tagged + # hash-source=none with the contact note, so the "no hashable + # artefact" path can never silently masquerade as a real digest. + with tempfile.TemporaryDirectory() as tmp: + cdx = os.path.join(tmp, 'out.cdx.json') + spdx = os.path.join(tmp, 'out.spdx.json') + result = self._run( + '--name', 'wolfssl', '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--user-settings', '/dev/null', + '--no-artifact-hash', + '--cdx-out', cdx, '--spdx-out', spdx) + self.assertEqual(result.returncode, 0, result.stderr) + with open(cdx) as f: + doc = json.load(f) + comp = doc['metadata']['component'] + self.assertEqual(comp['hashes'][0]['content'], '0' * 64) + props = {p['name']: p['value'] for p in comp['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'none') + self.assertIn('wolfssl:sbom:no-artifact-hash-note', props) + # --------------------------------------------------------------------------- # SBOM document generators (generate_cdx / generate_spdx + dep helpers). @@ -1752,6 +1905,50 @@ def test_library_binary_path_emits_hash_kind_property(self): # source-set is only meaningful for the merkle path. self.assertNotIn('wolfssl:sbom:source-set', props) + def test_hash_source_property_defaults_to_lib(self): + # hash-source is the coarse provenance tag downstream tooling + # filters on. The default (autotools / library-binary path) is + # 'lib'; pin it so a refactor of the default cannot silently + # mislabel the autotools SBOM. + doc = gs.generate_cdx(**self.BASE_KW) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'lib') + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props) + + def test_hash_source_srcs_for_source_set(self): + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'hash_source': 'srcs', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'srcs') + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props) + + def test_hash_source_none_carries_contact_note(self): + # The --no-artifact-hash path must flag the synthetic placeholder + # so a downstream auditor cannot mistake the 64-zero checksum for + # a genuine digest. Both the hash-source=none tag and the contact + # note are required. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'lib_hash': gs._NO_HASH_SENTINEL, + 'hash_kind': 'none', + 'hash_source': 'none', + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'none') + self.assertEqual(props['wolfssl:sbom:no-artifact-hash-note'], + gs._NO_HASH_NOTE) + # The placeholder must be the synthetic 64-zero sentinel. + self.assertEqual( + doc['metadata']['component']['hashes'][0]['content'], + '0' * 64) + def test_main_component_carries_security_external_refs(self): # An auditor reading the CDX needs a single in-document link # to the project's security advisories and the RFC 9116 @@ -1995,6 +2192,37 @@ def test_library_binary_path_annotates_via_annotations(self): # Comment is still build-config defines only. self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) + def test_hash_source_annotation_defaults_to_lib(self): + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + comments = [a['comment'] for a in wolfssl_pkg['annotations']] + self.assertIn('wolfssl:sbom:hash-source=lib', comments) + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note=', + ''.join(comments)) + + def test_hash_source_none_annotates_contact_note(self): + # The --no-artifact-hash path must record both the hash-source=none + # tag and the contact note in the SPDX annotations[], mirroring the + # CycloneDX side, so neither format hides the synthetic placeholder. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'lib_hash': gs._NO_HASH_SENTINEL, + 'hash_kind': 'none', + 'hash_source': 'none', + }) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + comments = [a['comment'] for a in wolfssl_pkg['annotations']] + self.assertIn('wolfssl:sbom:hash-source=none', comments) + self.assertIn( + f'wolfssl:sbom:no-artifact-hash-note={gs._NO_HASH_NOTE}', + comments) + self.assertEqual( + wolfssl_pkg['checksums'][0]['checksumValue'], '0' * 64) + def test_file_entries_do_not_leak_into_spdx(self): # SPDX 2.3 forbids package elements (CONTAINS relationships # via hasFiles) when `filesAnalyzed: False`, and flipping From 2c09419302f8fb726b7d819caf98bea65b8f4123 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Thu, 25 Jun 2026 12:02:41 +0300 Subject: [PATCH 39/39] Makefile.am: fix masked SBOM failure in bomsh; document advisory targets Makefile.am: add set -e to bomsh recipe so a failed nested `make sbom` aborts instead of being masked. Document the make advisory / install-advisory targets in INSTALL (new section 21) and README. Signed-off-by: Sameeh Jubran --- INSTALL | 46 ++++++++++++++++++++++++++++++++++++++++++++++ Makefile.am | 3 ++- README.md | 8 ++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/INSTALL b/INSTALL index c82ffa13e3b..982d66c8e87 100644 --- a/INSTALL +++ b/INSTALL @@ -428,3 +428,49 @@ We also have vcpkg ports for wolftpm, wolfmqtt and curl. The generated files are removed by make clean. See doc/SBOM.md for full details. + +21. Generating security advisories (CSAF 2.0 + CycloneDX VEX) + + wolfSSL can generate machine-readable security advisories from a + canonical, git-tracked single source of truth. Each CVE record + produces one CSAF 2.0 document and one CycloneDX 1.6 VEX document, + suitable for downstream vulnerability tooling and CRA reporting. + + Prerequisites: + - python3 (detected automatically by configure) + + Usage: + + $ ./configure + $ make advisory + + Inputs (tracked in git): + + advisories/records/*.json one JSON record per CVE + advisories/vex-overlay.json shared VEX overlay metadata + + Outputs (build artifacts, one pair per record): + + advisories/out/*.csaf.json CSAF 2.0 JSON + advisories/out/*.cdx.json CycloneDX 1.6 VEX JSON + + Output is reproducible: SOURCE_DATE_EPOCH is honored and defaults to + the last git commit timestamp when unset, exactly like make sbom. + + `make advisory` is a thin wrapper around scripts/gen-advisory and is + byte-for-byte interchangeable with running that script by hand: + + $ python3 scripts/gen-advisory \ + --records-dir advisories/records \ + --vex-overlay advisories/vex-overlay.json \ + --out-dir advisories/out + + To install the advisory files to $(datadir)/doc/wolfssl/advisories/: + + $ make install-advisory + + To remove installed advisory files: + + $ make uninstall-advisory + + The generated files are removed by make clean. diff --git a/Makefile.am b/Makefile.am index 3a20b7129fe..975def0ec49 100644 --- a/Makefile.am +++ b/Makefile.am @@ -695,7 +695,8 @@ bomsh: @printf 'raw_logfile=%s\n' '$(BOMSH_RAWLOG_BASE)' > '$(BOMSH_CONF)' $(BOMTRACE3) -c '$(BOMSH_CONF)' $(MAKE) $(BOMSH_CREATE_BOM) -r '$(BOMSH_RAWLOG)' -b '$(BOMSH_OMNIBORDIR)' - @bomsh_artifact=""; \ + @set -e; \ + bomsh_artifact=""; \ for lib in \ $(addprefix "$(abs_builddir)/src/.libs"/,$(WOLFSSL_LIB_DSO_BASENAMES)) \ "$(abs_builddir)/src/.libs/libwolfssl.a" \ diff --git a/README.md b/README.md index 14130eaeeed..3f926af762f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ wolfSSL supports generating an OmniBOR artifact dependency graph via library back to every source file that produced it. See `doc/SBOM.md` for details. +## Security advisories (CSAF / VEX) + +wolfSSL can generate machine-readable security advisories via +`make advisory` (requires `python3`), emitting one CSAF 2.0 document +and one CycloneDX 1.6 VEX document per CVE into `advisories/out/` +(`*.csaf.json` and `*.cdx.json`) from the git-tracked records under +`advisories/`. See section 21 of `INSTALL` for details. + ## Notes, Please Read ### Note 1