diff --git a/.github/workflows/test-sbom.yml b/.github/workflows/test-sbom.yml index 250b01a39d..cc6a71904c 100644 --- a/.github/workflows/test-sbom.yml +++ b/.github/workflows/test-sbom.yml @@ -1,8 +1,8 @@ name: Wolfboot SBOM Canary # Exercises every wolfBoot SBOM route so a change to the build system, the -# vendored wolfGlass tooling under tools/sbom/, or an IDE extractor cannot -# silently break SBOM generation: +# vendored wolfssl-compliance-tooling files under tools/sbom/, or an IDE +# extractor cannot silently break SBOM generation: # # * Make path (methods 1-4) -> make sbom TARGET=sim # * CMake path (method 5) -> cmake --build --target sbom @@ -32,8 +32,9 @@ name: Wolfboot SBOM Canary # driver never calls the cross compiler, so the SBOM is produced for a target # whose toolchain is absent. # -# The wolfglass_drift job holds tools/sbom/ to the revision it claims, so the -# vendored copy cannot drift from wolfGlass unnoticed. +# The compliance_tooling_drift job holds tools/sbom/ to the revision it +# claims, so the vendored copy cannot drift from wolfssl-compliance-tooling +# unnoticed. on: push: @@ -338,56 +339,58 @@ jobs: wolfboot-*.spdx.json if-no-files-found: warn - # tools/sbom/ is a vendored copy of the wolfGlass share/ set, pinned by - # tools/sbom/.wolfglass-rev. Nothing else proves the copy still matches the - # pin, so a stale copy, a local patch, or an unresolvable revision would all - # pass unnoticed and wolfBoot would ship a different generator from the one - # wolfSSL controls. wolfGlass owns the comparison: tools/wolfglass-sync - # --check diffs share/ against the vendored tree and compares HEAD against - # .wolfglass-rev. - wolfglass_drift: + # tools/sbom/ is a vendored copy of the wolfssl-compliance-tooling share/ + # set, pinned by tools/sbom/.wolfssl-compliance-tooling-rev. Nothing else + # proves the copy still matches the pin, so a stale copy, a local patch, or + # an unresolvable revision would all pass unnoticed and wolfBoot would ship + # a different generator from the one wolfSSL controls. + # wolfssl-compliance-tooling owns the comparison: + # tools/wolfssl-compliance-tooling-sync --check diffs share/ against the + # vendored tree and compares HEAD against .wolfssl-compliance-tooling-rev. + compliance_tooling_drift: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - name: Read the pinned wolfGlass revision + - name: Read the pinned wolfssl-compliance-tooling revision id: pin run: | - rev="$(tr -d '[:space:]' < tools/sbom/.wolfglass-rev)" + rev="$(tr -d '[:space:]' < tools/sbom/.wolfssl-compliance-tooling-rev)" if [ -z "$rev" ]; then - echo "ERROR: tools/sbom/.wolfglass-rev is empty." >&2 + echo "ERROR: tools/sbom/.wolfssl-compliance-tooling-rev is empty." >&2 exit 1 fi echo "rev=$rev" >> "$GITHUB_OUTPUT" - echo "Pinned wolfGlass revision: $rev" + echo "Pinned wolfssl-compliance-tooling revision: $rev" - # wolfGlass is public, so this needs no token and the check also runs on a - # fork PR. continue-on-error only defers an unresolvable pin to the step - # below, which can say what went wrong; it never skips the comparison. - - name: Check out wolfGlass at the pinned revision + # wolfssl-compliance-tooling is public, so this needs no token and the + # check also runs on a fork PR. continue-on-error only defers an + # unresolvable pin to the step below, which can say what went wrong; it + # never skips the comparison. + - name: Check out wolfssl-compliance-tooling at the pinned revision id: fetch continue-on-error: true uses: actions/checkout@v4 with: - repository: wolfSSL/wolfGlass + repository: wolfSSL/wolfssl-compliance-tooling ref: ${{ steps.pin.outputs.rev }} - path: .wolfglass-src + path: .wolfssl-compliance-tooling-src - - name: Drift check against wolfGlass + - name: Drift check against wolfssl-compliance-tooling env: PINNED_REV: ${{ steps.pin.outputs.rev }} run: | - if [ ! -d .wolfglass-src/share ]; then - echo "ERROR: wolfGlass revision $PINNED_REV does not resolve." >&2 - echo "tools/sbom/.wolfglass-rev must name a commit that exists in" >&2 - echo "wolfSSL/wolfGlass. Re-run tools/wolfglass-sync to re-pin." >&2 + if [ ! -d .wolfssl-compliance-tooling-src/share ]; then + echo "ERROR: wolfssl-compliance-tooling revision $PINNED_REV does not resolve." >&2 + echo "tools/sbom/.wolfssl-compliance-tooling-rev must name a commit that exists in" >&2 + echo "wolfSSL/wolfssl-compliance-tooling. Re-run tools/wolfssl-compliance-tooling-sync to re-pin." >&2 exit 1 fi - git config --global --add safe.directory "$GITHUB_WORKSPACE/.wolfglass-src" - python3 .wolfglass-src/tools/wolfglass-sync \ - --check --src .wolfglass-src --dest . + git config --global --add safe.directory "$GITHUB_WORKSPACE/.wolfssl-compliance-tooling-src" + python3 .wolfssl-compliance-tooling-src/tools/wolfssl-compliance-tooling-sync \ + --check --src .wolfssl-compliance-tooling-src --dest . windows_sbom: runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 93a59348c3..21195f4992 100644 --- a/.gitignore +++ b/.gitignore @@ -451,7 +451,7 @@ language.settings.xml /**/build /**/build-** -# Exception: vendored wolfGlass SBOM integration sources. +# Exception: vendored wolfssl-compliance-tooling SBOM integration sources. # This directory contains tracked Make/CMake fragments, not generated output. !/tools/sbom/build/ !/tools/sbom/build/** diff --git a/Makefile b/Makefile index 95be9779a6..84b3217ed3 100644 --- a/Makefile +++ b/Makefile @@ -1065,7 +1065,8 @@ pico-sdk-info: FORCE # targets that also have an arch.mk path (TI Hercules, Renesas RX, Zynq). It # extracts the configuration-specific source list from OBJS (fully assembled by # this point: core wolfBoot + wolfcrypt + HAL) and passes it, together with the -# build CFLAGS, to the vendored wolfGlass driver under tools/sbom/. +# build CFLAGS, to the vendored wolfssl-compliance-tooling driver under +# tools/sbom/. # # wolfcrypt sources are compiled directly into the wolfBoot image. They stay # in the source-set hash, and are also declared as a nested wolfcrypt @@ -1181,7 +1182,7 @@ SBOM_HAL_LICENSE_OVERRIDE:=$(SBOM_LICENSE_OVERRIDE) SBOM_HAL_CDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).cdx.json SBOM_HAL_SPDX_OUT:=wolfboot-hal-$(TARGET)-$(WOLFBOOT_VERSION).spdx.json SBOM_HAL_GEN:=$(SBOM_GEN) -$(eval $(call wolfglass_sbom_rule,sbom-hal,SBOM_HAL_)) +$(eval $(call wolfssl_compliance_tooling_sbom_rule,sbom-hal,SBOM_HAL_)) FORCE: diff --git a/README.md b/README.md index 10c37fbd70..c533134c67 100644 --- a/README.md +++ b/README.md @@ -794,7 +794,7 @@ For Visual Studio, the developer command prompt will need to be activated. * Multiboot2: u32 request-list entries per spec v2.0, zero entries as padding, end-tag termination * Signing tools: file-backed custom TLV, custom TLV size limit raised to UINT16_MAX, --custom-tlv-pubkey-der, auxiliary algorithms and cert chain/TPM usage, removed the 14-argument limit * wolfHSM: exposed max verify roots in options.mk, target-independent client build - * SBOM: vendored wolfGlass tooling, SBOMs from every build system and per-target config, CI drift check, registered wolfBoot CPE, identification of the embedded wolfSSL + * SBOM: vendored wolfssl-compliance-tooling files, SBOMs from every build system and per-target config, CI drift check, registered wolfBoot CPE, identification of the embedded wolfSSL * Removed the obsolete python keytools; all users converted to C * Added CONTRIBUTING.md covering the contributor agreement and PR process * Bug fixes and hardening diff --git a/cmake/sbom.cmake b/cmake/sbom.cmake index a89519ebee..0cd85d7441 100644 --- a/cmake/sbom.cmake +++ b/cmake/sbom.cmake @@ -1,4 +1,5 @@ -# cmake/sbom.cmake - wolfBoot wrapper around the vendored wolfGlass CMake helper. +# cmake/sbom.cmake - wolfBoot wrapper around the vendored +# wolfssl-compliance-tooling CMake helper. if(NOT DEFINED WOLFBOOT_ROOT) set(WOLFBOOT_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) @@ -113,4 +114,4 @@ if(DEFINED HOSTCC AND NOT HOSTCC STREQUAL "") list(APPEND _sbom_args HOSTCC ${HOSTCC}) endif() -wolfglass_add_sbom(${_sbom_args}) +wolfssl_compliance_tooling_add_sbom(${_sbom_args}) diff --git a/docs/SBOM.md b/docs/SBOM.md index 8d3d7e903e..cb98fe3777 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -29,7 +29,7 @@ The pieces: | File | Role | | --- | --- | -| `tools/sbom/sbom-driver` | Vendored wolfGlass driver (srcs + config → gen-sbom). | +| `tools/sbom/sbom-driver` | Vendored wolfssl-compliance-tooling driver (srcs + config → gen-sbom). | | `tools/sbom/gen-sbom` | Vendored SBOM generator. | | `cmake/sbom.cmake` | wolfBoot wrapper around the vendored CMake helper. | | `tools/sbom/frontends/iar_sbom.py` | Extracts srcs + defines from an IAR `.ewp`. | @@ -44,7 +44,7 @@ The pieces: * `python3` * A host C compiler. The default is `cc`. To use a different compiler, set `HOSTCC=...`. -* The vendored wolfGlass SBOM set under `tools/sbom/`. +* The vendored wolfssl-compliance-tooling SBOM set under `tools/sbom/`. ## Limitations @@ -64,7 +64,7 @@ Obey these limitations when you make an SBOM. into the wolfBoot source set, so an advisory against one of those products does not match the document. The captured configuration does record the macros (`WOLFBOOT_TPM`, and the equivalents), so the build is still visible. - wolfGlass owns the component table, and this limitation goes away when it + wolfssl-compliance-tooling owns the component table, and this limitation goes away when it gains the other entries. - The document records the source set, not each file. There is no per-file hash, and SPDX reports `filesAnalyzed: false`. Use the source-set Merkle @@ -389,11 +389,11 @@ sim configuration and runs a native-Windows scrub test against artifacts. The same workflow holds `tools/sbom/` to the revision in -`tools/sbom/.wolfglass-rev`. It checks wolfGlass out at that revision and runs -`tools/wolfglass-sync --check`, which fails if a vendored file differs from -wolfGlass `share/`, or if the pinned revision does not resolve. A local patch to +`tools/sbom/.wolfssl-compliance-tooling-rev`. It checks wolfssl-compliance-tooling out at that revision and runs +`tools/wolfssl-compliance-tooling-sync --check`, which fails if a vendored file differs from +wolfssl-compliance-tooling `share/`, or if the pinned revision does not resolve. A local patch to the vendored tooling therefore breaks CI instead of shipping quietly: fix -wolfGlass and re-vendor. +wolfssl-compliance-tooling and re-vendor. ## Reproducibility diff --git a/tools/sbom/.wolfglass-rev b/tools/sbom/.wolfglass-rev deleted file mode 100644 index e7d2a6c779..0000000000 --- a/tools/sbom/.wolfglass-rev +++ /dev/null @@ -1 +0,0 @@ -224a4f328c13e315899e3f26ba125c60641934bd diff --git a/tools/sbom/.wolfssl-compliance-tooling-rev b/tools/sbom/.wolfssl-compliance-tooling-rev new file mode 100644 index 0000000000..c3fb5c42be --- /dev/null +++ b/tools/sbom/.wolfssl-compliance-tooling-rev @@ -0,0 +1 @@ +8a96c64f9dc022fa136682a18f23860196de8610 diff --git a/tools/sbom/README.md b/tools/sbom/README.md index eb6bc4076e..b88d114df2 100644 --- a/tools/sbom/README.md +++ b/tools/sbom/README.md @@ -1,8 +1,8 @@ # share/ -This is the only vendorable set. Use `tools/wolfglass-sync` to copy these files +This is the only vendorable set. Use `tools/wolfssl-compliance-tooling-sync` to copy these files into a product at `tools/sbom/`, together with the pin files (`VERSION` and -`.wolfglass-rev`). Do not copy the `share/` folder name; copy the files. +`.wolfssl-compliance-tooling-rev`). Do not copy the `share/` folder name; copy the files. ## Contents @@ -13,9 +13,10 @@ into a product at `tools/sbom/`, together with the pin files (`VERSION` and | `validate_sbom.py` | Structural validator for CI (`--name-prefix`). | | `frontends/compdb_sbom.py` | Extractor for any `compile_commands.json`. | | `frontends/iar_sbom.py` | Extractor for an IAR Embedded Workbench `.ewp`. | -| `frontends/zephyr_sbom.py` | Extractor for a Zephyr module `CMakeLists.txt`. | -| `build/sbom.mk` | Shared plain-Make fragment and `wolfglass_sbom_rule` macro. | -| `build/sbom.cmake` | Shared CMake helper: `wolfglass_add_sbom()`. | +| `frontends/included_sources.py` | Finds the source files a compile command compiles in through `#include` (`compdb_sbom.py --included-sources`). | +| `frontends/zephyr_sbom.py` | Zephyr module, from a west build's `compile_commands.json`. | +| `build/sbom.mk` | Shared plain-Make fragment and `wolfssl_compliance_tooling_sbom_rule` macro. | +| `build/sbom.cmake` | Shared CMake helper: `wolfssl_compliance_tooling_add_sbom()`. | | `gen-sbom` | The vendored SBOM generator. | | `sbom.am` | Shared autotools fragment. | @@ -28,27 +29,34 @@ Composition (at least one): - `--srcs-file PATH` — the source files compiled into the artifact (tier E). - `--lib PATH` — the built library to hash (tier R/L/S). -- `--no-artifact-hash` — record the artifact as-built and do not re-hash. Use it - with `--lib` for a FIPS canister or a kernel module. Never substitute a source - list for a certified artifact. +- `--no-artifact-hash` — record a placeholder checksum when no hashable + artefact exists (a FIPS canister or a kernel module). Do not pass `--lib` + or `--srcs-file` with it. gen-sbom accepts one checksum source. Never + substitute a source list for a certified artifact. Config (choose one): - `--cflags="..."` — raw CFLAGS; the driver expands the `-D` tokens through the host compiler. Use the `=` form so a leading-dash value is not read as a flag. - `--options-h PATH` — a pre-expanded flat `#define` header, used verbatim. -- `--user-settings PATH` — a `user_settings.h`; the generator captures it. +- `--user-settings PATH` — path to `wolfssl/wolfcrypt/settings.h`. Also pass + `--user-settings-include DIR` (the directory that contains `user_settings.h`) + and `--user-settings-define WOLFSSL_USER_SETTINGS`. - `--source-only` — no build-config macros (for example a Kconfig-driven build). Dependency (for linkers and bindings): `--dep-wolfssl`, `--dep-openssl`, and `--dep-version` are passed through only when the generator supports them. +Copyright: the line reads `Copyright (C) - `. +`--copyright-start` (Make, autotools: `SBOM_COPYRIGHT_START`; CMake: +`COPYRIGHT_START`) sets the first year, which defaults to 2006. + The driver captures macros with the host compiler, so the SBOM is reproducible across toolchains. It scrubs absolute host paths from the captured macros unless you pass `--no-scrub`. -The shared driver is product-neutral and calls the vendored `share/gen-sbom` by -default. Pass `--gen-sbom` only when you want to override that copy. +The shared driver is product-neutral and calls the `gen-sbom` file in the same +directory. Pass `--gen-sbom` only when you want to override that copy. ## The manifest contract @@ -57,17 +65,32 @@ A product does not copy logic. It describes itself: - Make: set `SBOM_NAME`, `SBOM_SRCS`, `SBOM_CFLAGS`, and a version (`SBOM_VERSION`, or `SBOM_VERSION_FILE` + `SBOM_VERSION_MACRO`), then `include tools/sbom/build/sbom.mk`. For a second target, instantiate - `$(eval $(call wolfglass_sbom_rule,,))`. + `$(eval $(call wolfssl_compliance_tooling_sbom_rule,,))`. If the product's configuration lives in a `user_settings.h`, also set `SBOM_SETTINGS_H` (and `SBOM_INCLUDE_DIRS` if that header needs paths the CFLAGS do not already carry). `SBOM_CFLAGS` alone records the literal `-D` set and nothing it derives, which for a gated header means the SBOM describes a configuration nobody built. -- CMake: `include(tools/sbom/build/sbom.cmake)` and call `wolfglass_add_sbom()` +- CMake: `include(tools/sbom/build/sbom.cmake)` and call `wolfssl_compliance_tooling_add_sbom()` with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. + The same call adds `install-sbom` and `uninstall-sbom`. They copy the + CycloneDX and SPDX files to `CMAKE_INSTALL_FULL_DOCDIR` (or + `/share/doc/`) and read `DESTDIR` when the target runs. + Pass `CDX_OUT` / `SPDX_OUT` + when the file name is not `-` (wolfBoot's config tag). + wolfTPM writes the tag-value file in a later `sbom` target: pass + `NO_INSTALL` and call `wolfssl_compliance_tooling_add_sbom_install(DEPENDS sbom TV ...)` + after that target exists. `SBOM_GEN` is the canonical generator override; `GEN_SBOM` remains a legacy alias for compatibility. - Autotools: set the `SBOM_*` variables and `include tools/sbom/sbom.am`. +- Zephyr: call `wolfssl_compliance_tooling_add_sbom(TARGETS + ${ZEPHYR_CURRENT_LIBRARY} ...)` from the module's own CMakeLists, after its + last `zephyr_library_sources()`, so the SBOM records the source set west + actually built. Without that hook, run `zephyr_sbom.py` on the build's + `compile_commands.json` with `--root` set to the module, and pass + `--user-settings` with the module's settings header to record its + configuration. Keep only true product knowledge in the product: the route-through script, the module extractor, and the HAL source selector. diff --git a/tools/sbom/build/install-sbom.cmake b/tools/sbom/build/install-sbom.cmake new file mode 100644 index 0000000000..9afbc7860b --- /dev/null +++ b/tools/sbom/build/install-sbom.cmake @@ -0,0 +1,38 @@ +# cmake -P script: install SBOM artifacts. +# DESTDIR is read from the environment when this script runs, so +# DESTDIR=/staging cmake --build . --target install-sbom stages the files. +# +# The CycloneDX and SPDX JSON files must exist. The tag-value file is +# copied only when the caller set its path and the file exists. + +if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR OR WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR STREQUAL "") + message(FATAL_ERROR "install-sbom.cmake: WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR is required") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/sbom-paths.cmake") +wolfssl_compliance_tooling_sbom_resolve_outputs(_cdx _spdx _tv) + +# file(INSTALL) applies DESTDIR itself. Prepending it here would stage +# the files twice. file(MAKE_DIRECTORY) does not apply DESTDIR, so the +# directory is created at the staged path. +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() +set(_staged "${_destdir}${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") + +if(NOT EXISTS "${_cdx}") + message(FATAL_ERROR "install-sbom.cmake: missing CycloneDX file ${_cdx}") +endif() +if(NOT EXISTS "${_spdx}") + message(FATAL_ERROR "install-sbom.cmake: missing SPDX file ${_spdx}") +endif() + +file(MAKE_DIRECTORY "${_staged}") +file(INSTALL "${_cdx}" "${_spdx}" DESTINATION "${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") +if(NOT _tv STREQUAL "" AND EXISTS "${_tv}") + file(INSTALL "${_tv}" DESTINATION "${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") +endif() + +message(STATUS "Installed SBOM to ${_staged}") diff --git a/tools/sbom/build/sbom-paths.cmake b/tools/sbom/build/sbom-paths.cmake new file mode 100644 index 0000000000..97d5ab3411 --- /dev/null +++ b/tools/sbom/build/sbom-paths.cmake @@ -0,0 +1,77 @@ +# Shared path rules for install-sbom.cmake and uninstall-sbom.cmake. +# The names match the driver default: /-.cdx.json +# and the .spdx.json sibling. A caller that set CDX_OUT / SPDX_OUT passes +# those paths and this file leaves them unchanged. The tag-value path is +# used only when the caller sets TV / TV_OUT. A .spdx sibling is not +# inferred, so a stale file is not installed or removed. + +function(_wolfssl_compliance_tooling_read_version file macro out_var) + set(_ver "") + if(NOT file STREQUAL "" AND NOT macro STREQUAL "" AND EXISTS "${file}") + file(STRINGS "${file}" _lines) + foreach(_line IN LISTS _lines) + if(_line MATCHES "${macro}[ \t]+\"([^\"]*)\"") + set(_ver "${CMAKE_MATCH_1}") + break() + endif() + endforeach() + endif() + set(${out_var} "${_ver}" PARENT_SCOPE) +endfunction() + +function(wolfssl_compliance_tooling_sbom_resolve_outputs out_cdx out_spdx out_tv) + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO "") + endif() + if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR) + set(WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR "") + endif() + + set(_cdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX}") + set(_spdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX}") + set(_tv "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_TV}") + + if(_cdx STREQUAL "" OR _spdx STREQUAL "") + set(_ver "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION}") + if(_ver STREQUAL "") + _wolfssl_compliance_tooling_read_version( + "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE}" + "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO}" + _ver) + endif() + if(WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME STREQUAL "" OR _ver STREQUAL "" + OR WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR STREQUAL "") + message(FATAL_ERROR + "sbom paths: pass CDX and SPDX, or NAME, BINDIR, and VERSION " + "(or VERSION_FILE and VERSION_MACRO)") + endif() + if(_cdx STREQUAL "") + set(_cdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR}/${WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME}-${_ver}.cdx.json") + endif() + if(_spdx STREQUAL "") + set(_spdx "${WOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR}/${WOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME}-${_ver}.spdx.json") + endif() + endif() + + set(${out_cdx} "${_cdx}" PARENT_SCOPE) + set(${out_spdx} "${_spdx}" PARENT_SCOPE) + set(${out_tv} "${_tv}" PARENT_SCOPE) +endfunction() diff --git a/tools/sbom/build/sbom-with-sde.cmake b/tools/sbom/build/sbom-with-sde.cmake new file mode 100644 index 0000000000..cdd12cf341 --- /dev/null +++ b/tools/sbom/build/sbom-with-sde.cmake @@ -0,0 +1,72 @@ +# cmake -P script: run the SBOM driver with SOURCE_DATE_EPOCH set at +# build time. +# +# Keep a value that is already in the environment. When it is empty, use +# the last git commit time of SBOM_ROOT when that directory is the git +# toplevel. A tarball unpacked inside another checkout is not that +# toplevel, so the outer commit time is not used. A tree with no git +# leaves the variable empty, and gen-sbom uses its own default. +# +# Arguments after "--" are the driver command. + +if(NOT DEFINED SBOM_ROOT) + set(SBOM_ROOT "") +endif() + +set(_sde "$ENV{SOURCE_DATE_EPOCH}") +if(_sde STREQUAL "" AND NOT SBOM_ROOT STREQUAL "") + find_program(_wolfssl_compliance_tooling_git git) + if(_wolfssl_compliance_tooling_git) + set(_sde "") + execute_process( + COMMAND "${_wolfssl_compliance_tooling_git}" -C "${SBOM_ROOT}" + rev-parse --show-toplevel + OUTPUT_VARIABLE _git_top + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _git_rc) + if(_git_rc EQUAL 0 AND NOT _git_top STREQUAL "") + # REALPATH is available on CMake 3.16. file(REAL_PATH) needs 3.19. + get_filename_component(_root_real "${SBOM_ROOT}" REALPATH) + get_filename_component(_top_real "${_git_top}" REALPATH) + if(_root_real STREQUAL _top_real) + execute_process( + COMMAND "${_wolfssl_compliance_tooling_git}" -C "${SBOM_ROOT}" + log -1 --format=%ct + OUTPUT_VARIABLE _sde + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _git_rc) + if(NOT _git_rc EQUAL 0) + set(_sde "") + endif() + endif() + endif() + if(NOT _sde STREQUAL "") + set(ENV{SOURCE_DATE_EPOCH} "${_sde}") + endif() + endif() +endif() + +math(EXPR _last "${CMAKE_ARGC} - 1") +set(_args "") +set(_seen_dd FALSE) +foreach(_i RANGE 0 ${_last}) + set(_a "${CMAKE_ARGV${_i}}") + if(_seen_dd) + # A raw semicolon would split the CMake list and break the argument. + string(REPLACE ";" "\\;" _safe "${_a}") + list(APPEND _args "${_safe}") + elseif(_a STREQUAL "--") + set(_seen_dd TRUE) + endif() +endforeach() + +if(NOT _args) + message(FATAL_ERROR "sbom-with-sde.cmake: no command after --") +endif() + +execute_process(COMMAND ${_args} RESULT_VARIABLE _rc) +if(NOT _rc EQUAL 0) + message(FATAL_ERROR "SBOM generation failed (${_rc})") +endif() diff --git a/tools/sbom/build/sbom.cmake b/tools/sbom/build/sbom.cmake index 2eddda3f55..ae9b309e20 100644 --- a/tools/sbom/build/sbom.cmake +++ b/tools/sbom/build/sbom.cmake @@ -1,15 +1,15 @@ -# sbom.cmake - shared CMake helper for wolfGlass SBOM generation. +# sbom.cmake - shared CMake helper for wolfssl-compliance-tooling SBOM generation. # # This replaces the per-product `add_custom_target(sbom ...)` blocks that today # are copied and drifting across wolfMQTT, wolfTPM, and wolfBoot. It exposes ONE -# function, wolfglass_add_sbom(), so each product describes itself in a few lines +# function, wolfssl_compliance_tooling_add_sbom(), so each product describes itself in a few lines # and gets an `sbom` target that calls the same driver as the Make and autotools -# paths. +# paths. That call also adds `install-sbom` and `uninstall-sbom`. # # Include this file, then call the function: # # include(${CMAKE_CURRENT_SOURCE_DIR}/tools/sbom/build/sbom.cmake) -# wolfglass_add_sbom( +# wolfssl_compliance_tooling_add_sbom( # NAME wolfboot # VERSION_FILE ${CMAKE_CURRENT_SOURCE_DIR}/include/wolfboot/version.h # VERSION_MACRO LIBWOLFBOOT_VERSION_STRING @@ -28,6 +28,7 @@ # SPDX_OUT Explicit SPDX output path. # COMPONENT_TYPE CycloneDX component.type, e.g. firmware for a # bootloader (default: the generator's own default). +# COPYRIGHT_START First year of the copyright line (default: 2006). # SETTINGS_H Settings header included in the macro capture, so DEFS # are interpreted by the header that derives from them. # Without it the capture sees the -D list alone and every @@ -40,6 +41,10 @@ # DEP_WOLFCRYPT yes|no Record wolfCrypt as a nested component (PURL; # matching uses the wolfssl CPE). # DEP_OPENSSL yes|no Record OpenSSL as a dependency component. +# DEP_LIBZ yes|no Record zlib. Pass the product's real option. The +# default in gen-sbom is no. +# DOCUMENT_NAMESPACE SPDX documentNamespace. Default is a +# deterministic urn:uuid from the generator. # CRYPTO_ONLY auto|yes|no Whether only the wolfCrypt subset of wolfSSL is # compiled in (default auto: read from the capture). # DEP_VERSION ... Explicit dependency versions. A cross build has @@ -50,34 +55,76 @@ # inferred from LICENSE. # LICENSE_TEXT Plain-text licence for a LicenseRef-* used in # LICENSE_OVERRIDE (required by SPDX 2.3). +# NO_ARTIFACT_HASH Record a placeholder checksum. Do not pass TARGETS +# or LIB with this option. +# +# USER_SETTINGS Path to wolfssl/wolfcrypt/settings.h. This is the +# generator's pcpp entry point. Also pass +# USER_SETTINGS_INCLUDE and USER_SETTINGS_DEFINE +# WOLFSSL_USER_SETTINGS. A user_settings.h path alone +# records only the literals in that file. +# USER_SETTINGS_INCLUDE ... Directories for that preprocess. One of +# them must contain user_settings.h. +# USER_SETTINGS_DEFINE ... Predefines for that preprocess. +# Include WOLFSSL_USER_SETTINGS. +# TV_OUT SPDX tag-value file, when a later step writes one. +# INSTALL_DIR Doc directory for install-sbom. Default: +# CMAKE_INSTALL_FULL_DOCDIR, else +# /share/doc/. +# INSTALL_DEPENDS Target that produces the files. Default: +# TARGET_NAME. wolfTPM builds the tag-value file in a +# later target named sbom, so that product passes +# NO_INSTALL here and calls wolfssl_compliance_tooling_add_sbom_install() +# after that target exists. +# NO_INSTALL Do not create install-sbom / uninstall-sbom. +# INSTALL_TARGET Name of the install target (default: install-sbom). +# UNINSTALL_TARGET Name of the uninstall target (default: uninstall-sbom). +# A second call in the same tree sets both. +# +# wolfssl_compliance_tooling_add_sbom() also creates install-sbom and uninstall-sbom. +# install-sbom reads DESTDIR when the target runs. Output paths are the +# caller's CDX_OUT and SPDX_OUT, so a wolfBoot config tag is installed +# under that name. The tag-value file is installed only when TV_OUT is +# set and the file exists. uninstall-sbom removes the same paths. # # These mirror the SBOM_* variables of build/sbom.mk one for one; the two # fragments must accept the same product description, or the same product # yields two different documents depending on which build system generated it. # -# NOTE: the driver is invoked as a program; on Windows run the target from a -# shell environment (WSL/MSYS/Git-Bash) or use the Make/autotools path. +# The helper runs sbom-driver.py with the Python interpreter. A native +# Windows CMake generator cannot start the extensionless sbom-driver shell +# wrapper. That wrapper remains the Make and shell entry point. # The shared driver sits one directory above this fragment. -set(_WOLFGLASS_SBOM_DIR ${CMAKE_CURRENT_LIST_DIR}) -get_filename_component(_WOLFGLASS_DRIVER - "${_WOLFGLASS_SBOM_DIR}/../sbom-driver" ABSOLUTE) +set(_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR ${CMAKE_CURRENT_LIST_DIR}) +get_filename_component(_WOLFSSL_COMPLIANCE_TOOLING_DRIVER + "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/../sbom-driver.py" ABSOLUTE) +find_package(Python3 COMPONENTS Interpreter) -function(wolfglass_add_sbom) - set(_opts NO_ARTIFACT_HASH SOURCE_ONLY) +function(wolfssl_compliance_tooling_add_sbom) + set(_opts NO_ARTIFACT_HASH SOURCE_ONLY NO_INSTALL) set(_one NAME TARGET_NAME VERSION VERSION_FILE VERSION_MACRO LICENSE SBOM_GEN GEN_SBOM HOSTCC ROOT LIB USER_SETTINGS OPTIONS_H SETTINGS_H - DEP_WOLFSSL DEP_WOLFCRYPT DEP_OPENSSL CRYPTO_ONLY - CDX_OUT SPDX_OUT COMPONENT_TYPE - LICENSE_OVERRIDE LICENSE_TEXT) - set(_multi TARGETS DEFS DEP_VERSION INCLUDE_DIRS) + DEP_WOLFSSL DEP_WOLFCRYPT DEP_OPENSSL DEP_LIBZ CRYPTO_ONLY + CDX_OUT SPDX_OUT TV_OUT COMPONENT_TYPE DOCUMENT_NAMESPACE + COPYRIGHT_START + LICENSE_OVERRIDE LICENSE_TEXT + INSTALL_DIR INSTALL_DEPENDS INSTALL_TARGET UNINSTALL_TARGET) + set(_multi TARGETS DEFS DEP_VERSION INCLUDE_DIRS + USER_SETTINGS_INCLUDE USER_SETTINGS_DEFINE) cmake_parse_arguments(SB "${_opts}" "${_one}" "${_multi}" ${ARGN}) if(NOT SB_NAME) - message(FATAL_ERROR "wolfglass_add_sbom: NAME is required") + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom: NAME is required") endif() - if(NOT SB_TARGETS AND NOT SB_LIB) - message(FATAL_ERROR "wolfglass_add_sbom: set TARGETS or LIB") + if(SB_NO_ARTIFACT_HASH) + if(SB_TARGETS OR SB_LIB) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom: NO_ARTIFACT_HASH stands " + "alone. Do not pass TARGETS or LIB.") + endif() + elseif(NOT SB_TARGETS AND NOT SB_LIB) + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom: set TARGETS or LIB") endif() if(NOT SB_ROOT) set(SB_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) @@ -119,31 +166,44 @@ function(wolfglass_add_sbom) endforeach() list(REMOVE_DUPLICATES _srcs) + if(NOT Python3_EXECUTABLE) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom: python3 was not found") + endif() + set(_cmd ${CMAKE_COMMAND} -E env HOSTCC=${SB_HOSTCC} - ${_WOLFGLASS_DRIVER} + ${Python3_EXECUTABLE} + ${_WOLFSSL_COMPLIANCE_TOOLING_DRIVER} --name ${SB_NAME} --root ${SB_ROOT} --license-file ${SB_LICENSE} --hostcc ${SB_HOSTCC} --skip-missing) - # Composition: a source set from the targets and/or a built library. - if(SB_TARGETS) - set(_srcs_file ${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-sbom-srcs.txt) - string(REPLACE ";" "\n" _srcs_nl "${_srcs}") - file(GENERATE OUTPUT ${_srcs_file} CONTENT "${_srcs_nl}\n") - list(APPEND _cmd --srcs-file ${_srcs_file}) - endif() - if(SB_LIB) - list(APPEND _cmd --lib ${SB_LIB}) - endif() + # Composition: one checksum source. NO_ARTIFACT_HASH stands alone. if(SB_NO_ARTIFACT_HASH) list(APPEND _cmd --no-artifact-hash) + else() + if(SB_TARGETS) + set(_srcs_file ${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-sbom-srcs.txt) + string(REPLACE ";" "\n" _srcs_nl "${_srcs}") + file(GENERATE OUTPUT ${_srcs_file} CONTENT "${_srcs_nl}\n") + list(APPEND _cmd --srcs-file ${_srcs_file}) + endif() + if(SB_LIB) + list(APPEND _cmd --lib ${SB_LIB}) + endif() endif() # Config: user_settings, a pre-expanded header, source-only, or -D flags. if(SB_USER_SETTINGS) list(APPEND _cmd --user-settings ${SB_USER_SETTINGS}) + foreach(_inc IN LISTS SB_USER_SETTINGS_INCLUDE) + list(APPEND _cmd --user-settings-include ${_inc}) + endforeach() + foreach(_def IN LISTS SB_USER_SETTINGS_DEFINE) + list(APPEND _cmd --user-settings-define ${_def}) + endforeach() elseif(SB_OPTIONS_H) list(APPEND _cmd --options-h ${SB_OPTIONS_H}) elseif(SB_SOURCE_ONLY) @@ -170,7 +230,9 @@ function(wolfglass_add_sbom) endif() if(SB_VERSION) - list(APPEND _cmd --version ${SB_VERSION}) + # CMake before 3.24 parses options after -P and treats a separate + # "--version" as its own flag. One argument keeps the driver version. + list(APPEND _cmd --version=${SB_VERSION}) endif() if(SB_VERSION_FILE) list(APPEND _cmd --version-file ${SB_VERSION_FILE}) @@ -181,23 +243,36 @@ function(wolfglass_add_sbom) if(SB_COMPONENT_TYPE) list(APPEND _cmd --component-type ${SB_COMPONENT_TYPE}) endif() + if(NOT "${SB_COPYRIGHT_START}" STREQUAL "") + list(APPEND _cmd --copyright-start ${SB_COPYRIGHT_START}) + endif() if(SB_LICENSE_OVERRIDE) list(APPEND _cmd --license-override ${SB_LICENSE_OVERRIDE}) endif() if(SB_LICENSE_TEXT) list(APPEND _cmd --license-text ${SB_LICENSE_TEXT}) endif() - if(SB_DEP_WOLFSSL) - list(APPEND _cmd --dep-wolfssl ${SB_DEP_WOLFSSL}) + if(SB_DOCUMENT_NAMESPACE) + list(APPEND _cmd --document-namespace ${SB_DOCUMENT_NAMESPACE}) + endif() + # cmake_parse_arguments leaves an omitted keyword undefined. An + # unquoted name then compares as the literal "SB_DEP_*", which is not + # empty, and the flag is appended with no value. Quote the expansion. + # "no" stays a real value and is forwarded. + if(NOT "${SB_DEP_WOLFSSL}" STREQUAL "") + list(APPEND _cmd --dep-wolfssl "${SB_DEP_WOLFSSL}") endif() - if(SB_DEP_WOLFCRYPT) - list(APPEND _cmd --dep-wolfcrypt ${SB_DEP_WOLFCRYPT}) + if(NOT "${SB_DEP_WOLFCRYPT}" STREQUAL "") + list(APPEND _cmd --dep-wolfcrypt "${SB_DEP_WOLFCRYPT}") endif() - if(SB_DEP_OPENSSL) - list(APPEND _cmd --dep-openssl ${SB_DEP_OPENSSL}) + if(NOT "${SB_DEP_OPENSSL}" STREQUAL "") + list(APPEND _cmd --dep-openssl "${SB_DEP_OPENSSL}") endif() - if(SB_CRYPTO_ONLY) - list(APPEND _cmd --crypto-only ${SB_CRYPTO_ONLY}) + if(NOT "${SB_DEP_LIBZ}" STREQUAL "") + list(APPEND _cmd --dep-libz "${SB_DEP_LIBZ}") + endif() + if(NOT "${SB_CRYPTO_ONLY}" STREQUAL "") + list(APPEND _cmd --crypto-only "${SB_CRYPTO_ONLY}") endif() foreach(_dv IN LISTS SB_DEP_VERSION) list(APPEND _cmd --dep-version ${_dv}) @@ -205,6 +280,18 @@ function(wolfglass_add_sbom) if(SB_SBOM_GEN) list(APPEND _cmd --gen-sbom ${SB_SBOM_GEN}) endif() + # A literal version makes the output path known here. Pass it through so + # install-sbom copies the same file the driver writes. A version read + # from a header stays a build-time name; the install script reads that + # header with the same macro. + if(NOT SB_CDX_OUT AND SB_VERSION) + set(SB_CDX_OUT + "${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-${SB_VERSION}.cdx.json") + endif() + if(NOT SB_SPDX_OUT AND SB_VERSION) + set(SB_SPDX_OUT + "${CMAKE_CURRENT_BINARY_DIR}/${SB_NAME}-${SB_VERSION}.spdx.json") + endif() if(SB_CDX_OUT) list(APPEND _cmd --cdx-out ${SB_CDX_OUT}) endif() @@ -212,9 +299,107 @@ function(wolfglass_add_sbom) list(APPEND _cmd --spdx-out ${SB_SPDX_OUT}) endif() + # SOURCE_DATE_EPOCH is applied when the target runs. A value set only + # for the build is kept. Git supplies the commit time only when the + # variable is empty. Configure time does not freeze it. add_custom_target(${SB_TARGET_NAME} - COMMAND ${_cmd} + COMMAND ${CMAKE_COMMAND} + -DSBOM_ROOT=${SB_ROOT} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/sbom-with-sde.cmake" + -- + ${_cmd} WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} VERBATIM COMMENT "Generating ${SB_NAME} SBOM (CycloneDX 1.6 + SPDX 2.3)") + + if(NOT SB_NO_INSTALL) + set(_install_depends ${SB_TARGET_NAME}) + if(SB_INSTALL_DEPENDS) + set(_install_depends ${SB_INSTALL_DEPENDS}) + endif() + wolfssl_compliance_tooling_add_sbom_install( + NAME ${SB_NAME} + DEPENDS ${_install_depends} + CDX "${SB_CDX_OUT}" + SPDX "${SB_SPDX_OUT}" + TV "${SB_TV_OUT}" + VERSION "${SB_VERSION}" + VERSION_FILE "${SB_VERSION_FILE}" + VERSION_MACRO "${SB_VERSION_MACRO}" + BINDIR "${CMAKE_CURRENT_BINARY_DIR}" + INSTALL_DIR "${SB_INSTALL_DIR}" + INSTALL_TARGET "${SB_INSTALL_TARGET}" + UNINSTALL_TARGET "${SB_UNINSTALL_TARGET}") + endif() +endfunction() + +# Install and uninstall the files from one wolfssl_compliance_tooling_add_sbom() call. +# Call this directly when the files are produced by a target that does not +# exist yet inside wolfssl_compliance_tooling_add_sbom(), as wolfTPM does: the public sbom +# target validates SPDX and writes the tag-value file after the helper +# returns. Pass DEPENDS sbom and TV the tag-value path. +function(wolfssl_compliance_tooling_add_sbom_install) + set(_one NAME DEPENDS INSTALL_DIR CDX SPDX TV VERSION VERSION_FILE + VERSION_MACRO BINDIR INSTALL_TARGET UNINSTALL_TARGET) + cmake_parse_arguments(SI "" "${_one}" "" ${ARGN}) + + if(NOT SI_NAME) + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom_install: NAME is required") + endif() + if(NOT SI_DEPENDS) + message(FATAL_ERROR "wolfssl_compliance_tooling_add_sbom_install: DEPENDS is required") + endif() + if(NOT SI_INSTALL_TARGET) + set(SI_INSTALL_TARGET install-sbom) + endif() + if(NOT SI_UNINSTALL_TARGET) + set(SI_UNINSTALL_TARGET uninstall-sbom) + endif() + if(TARGET ${SI_INSTALL_TARGET}) + message(FATAL_ERROR + "wolfssl_compliance_tooling_add_sbom_install: target ${SI_INSTALL_TARGET} already exists. " + "Pass INSTALL_TARGET for a second SBOM.") + endif() + + if(SI_INSTALL_DIR) + set(_docdir "${SI_INSTALL_DIR}") + elseif(CMAKE_INSTALL_FULL_DOCDIR) + set(_docdir "${CMAKE_INSTALL_FULL_DOCDIR}") + else() + set(_docdir "${CMAKE_INSTALL_PREFIX}/share/doc/${SI_NAME}") + endif() + if(NOT IS_ABSOLUTE "${_docdir}") + set(_docdir "${CMAKE_INSTALL_PREFIX}/${_docdir}") + endif() + + add_custom_target(${SI_INSTALL_TARGET} + COMMAND ${CMAKE_COMMAND} + -DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR=${_docdir} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=${SI_CDX} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=${SI_SPDX} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=${SI_TV} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=${SI_NAME} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=${SI_VERSION_MACRO} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=${SI_BINDIR} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/install-sbom.cmake" + VERBATIM + COMMENT "Installing ${SI_NAME} SBOM to ${_docdir}") + add_dependencies(${SI_INSTALL_TARGET} ${SI_DEPENDS}) + + add_custom_target(${SI_UNINSTALL_TARGET} + COMMAND ${CMAKE_COMMAND} + -DWOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR=${_docdir} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_CDX=${SI_CDX} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_SPDX=${SI_SPDX} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_TV=${SI_TV} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_NAME=${SI_NAME} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION=${SI_VERSION} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_FILE=${SI_VERSION_FILE} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_VERSION_MACRO=${SI_VERSION_MACRO} + -DWOLFSSL_COMPLIANCE_TOOLING_SBOM_BINDIR=${SI_BINDIR} + -P "${_WOLFSSL_COMPLIANCE_TOOLING_SBOM_DIR}/uninstall-sbom.cmake" + VERBATIM + COMMENT "Uninstalling ${SI_NAME} SBOM from ${_docdir}") endfunction() diff --git a/tools/sbom/build/sbom.mk b/tools/sbom/build/sbom.mk index eb13232d75..79882d45ee 100644 --- a/tools/sbom/build/sbom.mk +++ b/tools/sbom/build/sbom.mk @@ -1,4 +1,4 @@ -# sbom.mk - shared plain-Make fragment for wolfGlass SBOM generation. +# sbom.mk - shared plain-Make fragment for wolfssl-compliance-tooling SBOM generation. # # One driver does the work; each product describes itself with a few variables # and includes this fragment to get an `sbom` target. It is the plain-Make @@ -8,7 +8,7 @@ # The including Makefile MUST set, before `include .../build/sbom.mk`: # SBOM_NAME Product name recorded in the SBOM (e.g. wolfboot). # -# Composition - set at least one: +# Composition - set one of SBOM_SRCS or SBOM_LIB, or SBOM_NO_ARTIFACT_HASH: # SBOM_SRCS Source files compiled into the artifact (tier E), e.g.: # SBOM_SRCS := $(patsubst %.o,%.c,$(OBJS)) # SBOM_LIB Path to the built library to hash (tier R/L/S). @@ -18,7 +18,16 @@ # The driver keeps the -D tokens and forwards -I/-isystem; # all other flags are dropped. # SBOM_OPTIONS_H A pre-expanded flat #define header (verbatim, scrubbed). -# SBOM_USER_SETTINGS A user_settings.h. +# SBOM_USER_SETTINGS Path to wolfssl/wolfcrypt/settings.h. Also set +# SBOM_USER_SETTINGS_INCLUDES and +# SBOM_USER_SETTINGS_DEFINES. A user_settings.h path +# alone records only the literals in that file. +# SBOM_USER_SETTINGS_INCLUDES +# Directories for that preprocess, space-separated. +# One of them must contain user_settings.h. +# SBOM_USER_SETTINGS_DEFINES +# Predefines for that preprocess, space-separated. +# Include WOLFSSL_USER_SETTINGS. # SBOM_SOURCE_ONLY = 1 Source-inventory SBOM with no build-config macros. # # Config, strongly recommended alongside SBOM_CFLAGS: @@ -52,6 +61,7 @@ # component.type, mirrored to SPDX # primaryPackagePurpose. Default: library. A # bootloader or a flashed image should say firmware. +# SBOM_COPYRIGHT_START First year of the copyright line. Default: 2006. # SBOM_LICENSE_OVERRIDE SPDX expression recorded instead of the one inferred # from SBOM_LICENSE_FILE. Needed whenever that file is # the *full* licence text rather than a licensing @@ -63,11 +73,16 @@ # in SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). # SBOM_GEN Path to gen-sbom. Default: driver auto-discovery. # GEN_SBOM Legacy alias for SBOM_GEN. -# SBOM_NO_ARTIFACT_HASH = 1 As-built FIPS/kernel: do not re-hash. +# SBOM_NO_ARTIFACT_HASH = 1 As-built FIPS/kernel with no hashable +# artefact. Stands alone: do not set SBOM_LIB or +# SBOM_SRCS. The driver sends --no-artifact-hash, +# and gen-sbom records a placeholder checksum. # SBOM_DEP_WOLFSSL yes/no - record wolfSSL as a dependency. # SBOM_DEP_WOLFCRYPT yes/no - record wolfCrypt as a nested component # (PURL; matching uses the wolfssl CPE). # SBOM_DEP_OPENSSL yes/no - record OpenSSL as a dependency. +# SBOM_DEP_LIBZ yes/no - record zlib. Pass the product's real option. +# SBOM_DOCUMENT_NAMESPACE SPDX documentNamespace override. # SBOM_CRYPTO_ONLY auto/yes/no - whether only the wolfCrypt subset of # the wolfSSL release is compiled in. Default auto: # read WOLFCRYPT_ONLY out of the captured macros, @@ -86,14 +101,14 @@ # The driver path is derived from this fragment's own location, so a product # that vendors share/ into tools/sbom/ needs no path configuration. # -# Source-list staging uses $(CURDIR)/.-wolfglass-srcs.txt (not mktemp). +# Source-list staging uses $(CURDIR)/.-wolfssl-compliance-tooling-srcs.txt (not mktemp). # GNU Make expands $${TMPDIR:-/tmp} as an empty Make variable named -# "TMPDIR:-/tmp", which produced "/wolfglass-srcs.XXXXXX" and broke every +# "TMPDIR:-/tmp", which produced "/wolfssl-compliance-tooling-srcs.XXXXXX" and broke every # host. The CURDIR file is .gitignore'd; avoid parallel make -j of the *same* # SBOM target (two recipes would share one staging file). Distinct targets # (sbom vs sbom-hal) use distinct filenames via $(1). # -# Shell variables inside wolfglass_sbom_rule need $$$$name (not $$name): +# Shell variables inside wolfssl_compliance_tooling_sbom_rule need $$$$name (not $$name): # $(call)/$(eval) expands the define once, then the recipe expands again. # $$name becomes $n + ame (empty single-letter Make var) after that double # expansion; $$$$name survives as $name for the shell. @@ -108,7 +123,7 @@ # the arithmetic to the shell, which is what the compile recipes already do. # # To instantiate a second target, set another variable prefix and call: -# $(eval $(call wolfglass_sbom_rule,sbom-hal,SBOM_HAL_)) +# $(eval $(call wolfssl_compliance_tooling_sbom_rule,sbom-hal,SBOM_HAL_)) # using SBOM_HAL_NAME, SBOM_HAL_SRCS, SBOM_HAL_CFLAGS, and so on. SBOM_MK_DIR := $(dir $(lastword $(MAKEFILE_LIST))) @@ -118,16 +133,21 @@ SBOM_ROOT ?= $(CURDIR) SBOM_LICENSE_FILE ?= $(SBOM_ROOT)/LICENSE HOSTCC ?= cc -define wolfglass_sbom_rule +define wolfssl_compliance_tooling_sbom_rule .PHONY: $(1) $(1): $($(2)PREREQS) @test -n "$($(2)NAME)" || { echo "ERROR: set $(2)NAME"; exit 1; } - @test -n "$(strip $($(2)SRCS))$($(2)LIB)" || \ - { echo "ERROR: set $(2)SRCS or $(2)LIB"; exit 1; } + @if [ "$($(2)NO_ARTIFACT_HASH)" = "1" ]; then \ + test -z "$(strip $($(2)SRCS))$($(2)LIB)" || \ + { echo "ERROR: $(2)NO_ARTIFACT_HASH stands alone. Do not set $(2)SRCS or $(2)LIB."; exit 1; }; \ + else \ + test -n "$(strip $($(2)SRCS))$($(2)LIB)" || \ + { echo "ERROR: set $(2)SRCS or $(2)LIB"; exit 1; }; \ + fi @set -e; \ if [ -n "$(strip $($(2)SRCS))" ]; then \ - trap 'rm -f "$(CURDIR)/.$(1)-wolfglass-srcs.txt"' EXIT INT TERM HUP; \ - printf '%s\n' $($(2)SRCS) > "$(CURDIR)/.$(1)-wolfglass-srcs.txt"; \ + trap 'rm -f "$(CURDIR)/.$(1)-wolfssl-compliance-tooling-srcs.txt"' EXIT INT TERM HUP; \ + printf '%s\n' $($(2)SRCS) > "$(CURDIR)/.$(1)-wolfssl-compliance-tooling-srcs.txt"; \ fi; \ dep_ver=""; \ if [ -n "$($(2)DEP_VERSION)" ]; then \ @@ -168,16 +188,21 @@ $(1): $($(2)PREREQS) --root "$(or $($(2)ROOT),$(SBOM_ROOT))" \ --license-file "$(or $($(2)LICENSE_FILE),$(SBOM_LICENSE_FILE))" \ --skip-missing \ - $(if $(strip $($(2)SRCS)),--srcs-file "$(CURDIR)/.$(1)-wolfglass-srcs.txt") \ - $(if $($(2)LIB),--lib "$($(2)LIB)") \ $(if $(filter 1,$($(2)NO_ARTIFACT_HASH)),--no-artifact-hash) \ + $(if $(filter 1,$($(2)NO_ARTIFACT_HASH)),, \ + $(if $(strip $($(2)SRCS)),--srcs-file "$(CURDIR)/.$(1)-wolfssl-compliance-tooling-srcs.txt")) \ + $(if $(filter 1,$($(2)NO_ARTIFACT_HASH)),, \ + $(if $($(2)LIB),--lib "$($(2)LIB)")) \ $(if $(filter 1,$($(2)SOURCE_ONLY)),--source-only) \ $(if $($(2)CFLAGS),--cflags="$$($(2)CFLAGS)") \ $(if $($(2)SETTINGS_H),--settings-h "$($(2)SETTINGS_H)") \ $(foreach d,$($(2)INCLUDE_DIRS),--include-dir "$(d)") \ $(if $($(2)OPTIONS_H),--options-h "$($(2)OPTIONS_H)") \ $(if $($(2)USER_SETTINGS),--user-settings "$($(2)USER_SETTINGS)") \ + $(foreach d,$($(2)USER_SETTINGS_INCLUDES),--user-settings-include "$(d)") \ + $(foreach d,$($(2)USER_SETTINGS_DEFINES),--user-settings-define "$(d)") \ $(if $($(2)COMPONENT_TYPE),--component-type "$($(2)COMPONENT_TYPE)") \ + $(if $($(2)COPYRIGHT_START),--copyright-start "$($(2)COPYRIGHT_START)") \ $(if $($(2)LICENSE_OVERRIDE),--license-override '$($(2)LICENSE_OVERRIDE)') \ $(if $($(2)LICENSE_TEXT),--license-text "$($(2)LICENSE_TEXT)") \ $(if $($(2)VERSION),--version "$($(2)VERSION)") \ @@ -186,6 +211,8 @@ $(1): $($(2)PREREQS) $(if $($(2)DEP_WOLFSSL),--dep-wolfssl "$($(2)DEP_WOLFSSL)") \ $(if $($(2)DEP_WOLFCRYPT),--dep-wolfcrypt "$($(2)DEP_WOLFCRYPT)") \ $(if $($(2)DEP_OPENSSL),--dep-openssl "$($(2)DEP_OPENSSL)") \ + $(if $($(2)DEP_LIBZ),--dep-libz "$($(2)DEP_LIBZ)") \ + $(if $($(2)DOCUMENT_NAMESPACE),--document-namespace "$($(2)DOCUMENT_NAMESPACE)") \ $(if $($(2)CRYPTO_ONLY),--crypto-only "$($(2)CRYPTO_ONLY)") \ $$$$dep_ver \ $(if $(or $($(2)GEN),$(GEN_SBOM)),--gen-sbom "$(or $($(2)GEN),$(GEN_SBOM))") \ @@ -194,4 +221,4 @@ $(1): $($(2)PREREQS) endef SBOM_TARGET ?= sbom -$(eval $(call wolfglass_sbom_rule,$(SBOM_TARGET),SBOM_)) +$(eval $(call wolfssl_compliance_tooling_sbom_rule,$(SBOM_TARGET),SBOM_)) diff --git a/tools/sbom/build/uninstall-sbom.cmake b/tools/sbom/build/uninstall-sbom.cmake new file mode 100644 index 0000000000..0354c6dad6 --- /dev/null +++ b/tools/sbom/build/uninstall-sbom.cmake @@ -0,0 +1,30 @@ +# cmake -P script: remove SBOM artifacts installed by install-sbom.cmake. +# DESTDIR is read from the environment when this script runs. +# file(REMOVE) does nothing when a file is already absent. + +if(NOT DEFINED WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR OR WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR STREQUAL "") + message(FATAL_ERROR "uninstall-sbom.cmake: WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR is required") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/sbom-paths.cmake") +wolfssl_compliance_tooling_sbom_resolve_outputs(_cdx _spdx _tv) + +if(DEFINED ENV{DESTDIR}) + set(_destdir "$ENV{DESTDIR}") +else() + set(_destdir "") +endif() +set(_dest "${_destdir}${WOLFSSL_COMPLIANCE_TOOLING_INSTALL_DIR}") + +get_filename_component(_cdx_name "${_cdx}" NAME) +get_filename_component(_spdx_name "${_spdx}" NAME) +set(_remove + "${_dest}/${_cdx_name}" + "${_dest}/${_spdx_name}") +if(NOT _tv STREQUAL "") + get_filename_component(_tv_name "${_tv}" NAME) + list(APPEND _remove "${_dest}/${_tv_name}") +endif() + +file(REMOVE ${_remove}) +message(STATUS "Uninstalled SBOM from ${_dest}") diff --git a/tools/sbom/frontends/compdb_sbom.py b/tools/sbom/frontends/compdb_sbom.py index 7727d79fa1..11eaddfec3 100755 --- a/tools/sbom/frontends/compdb_sbom.py +++ b/tools/sbom/frontends/compdb_sbom.py @@ -13,6 +13,11 @@ * Renesas e2studio / CCRX (via a build wrapper that records commands) * Xilinx SDK / Vitis (via `bear -- make ...`) +A source a file compiles in through #include, such as wolfSSL's +src/ssl_api_*.c, is not an entry of its own. `--included-sources` adds those +by rerunning the selected commands with -M, which needs a gcc- or clang-style +compiler and runs the compilers the database names. + The extracted sources + defines are handed to the shared driver (sbom-driver), so the resulting CycloneDX 1.6 / SPDX 2.3 SBOM is identical in shape to the Make, CMake, and IAR paths. @@ -31,6 +36,8 @@ import tempfile SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') +# Only these are preprocessed, so only these are rerun with -M. +SCAN_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.S') SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # The shared driver is the sibling of this frontends/ directory. @@ -48,19 +55,50 @@ def entry_tokens(entry): return [] +def file_id(path): + st = os.stat(path) + return st.st_dev, st.st_ino + + +def macro_name(d): + """Macro name of a -D/-U operand: the text before the first '=' or '(', + so a function-like 'F(a)=a' is keyed as 'F'.""" + return re.split(r'[=(]', d, maxsplit=1)[0] + + +def define_value(d): + """A -D operand with the implicit '=1' of a bare -DNAME made explicit.""" + return d if '=' in d else d + '=1' + + def extract_defines(tokens): - defs = [] + """Net -D macros for one compile command, honouring a later -U that cancels + an earlier -D of the same name (the compiler applies them in order, so a + macro undefined on the command line was not compiled in).""" + active = {} # macro name -> full 'NAME' or 'NAME=VALUE' token; last -D wins i = 0 while i < len(tokens): t = tokens[i] if t == '-D' and i + 1 < len(tokens): - defs.append(tokens[i + 1]) + d = tokens[i + 1] + active[macro_name(d)] = d i += 2 continue if t.startswith('-D'): - defs.append(t[2:]) + d = t[2:] + active[macro_name(d)] = d + i += 1 + continue + if t == '-U' and i + 1 < len(tokens): + active.pop(macro_name(tokens[i + 1]), None) + i += 2 + continue + if t.startswith('-U'): + active.pop(macro_name(t[2:]), None) + i += 1 + continue i += 1 - return defs + return list(active.values()) def main(): @@ -69,8 +107,12 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('compdb', help='Path to compile_commands.json') ap.add_argument('--name', required=True, help='Package name for the SBOM.') - ap.add_argument('--include', action='append', default=[]) - ap.add_argument('--exclude', action='append', default=[]) + ap.add_argument('--include', action='append', default=[], + help='Keep sources whose path matches this regex ' + '(re.search, repeatable).') + ap.add_argument('--exclude', action='append', default=[], + help='Drop sources whose path matches this regex ' + '(re.search, repeatable).') ap.add_argument('--driver', default=DEFAULT_DRIVER, help='Path to the shared sbom-driver.') ap.add_argument('--gen-sbom', default=None) @@ -79,6 +121,19 @@ def main(): ap.add_argument('--version-macro', default=None) ap.add_argument('--root', default=None) ap.add_argument('--license-file', default=None) + ap.add_argument('--copyright-start', default=None) + ap.add_argument('--license-override', default=None) + ap.add_argument('--license-text', default=None) + ap.add_argument('--supplier', default=None) + ap.add_argument('--component-type', default=None) + ap.add_argument('--dep-wolfssl', default=None) + ap.add_argument('--dep-wolfcrypt', default=None) + ap.add_argument('--dep-openssl', default=None) + ap.add_argument('--dep-version', action='append', default=[], + help='Dependency version, e.g. wolfssl=5.9.2 (repeatable).') + ap.add_argument('--options-h', default=None, + help='Pre-expanded #define header recorded instead of the ' + '-D set from the database.') # A compilation database lists the -D tokens the compiler was given, not # the macros a settings header derives from them. Without these two the # capture reads an empty translation unit and the recorded configuration @@ -95,8 +150,15 @@ def main(): ap.add_argument('--spdx-out', default=None) ap.add_argument('--srcs-out', default=None) ap.add_argument('--print-only', action='store_true') + ap.add_argument('--included-sources', action='store_true', + help='Rerun the compile commands with -M and add the ' + 'source files they #include. Runs the compilers ' + 'the database names; needs one that supports -M.') args = ap.parse_args() + if args.options_h and (args.settings_h or args.include_dir): + sys.exit("ERROR: --settings-h and --include-dir only feed the -D " + "capture; drop them when passing --options-h.") if not os.path.isfile(args.compdb): sys.exit(f"ERROR: compilation database not found: {args.compdb}") with open(args.compdb) as f: @@ -108,7 +170,14 @@ def main(): inc = [re.compile(p) for p in args.include] exc = [re.compile(p) for p in args.exclude] - srcs, defines, seen = [], set(), set() + # When --root is given, only hash sources inside it; a compile_commands.json + # is untrusted input, so a crafted entry naming an absolute path outside the + # product must not pull an unrelated file (and its path) into the SBOM. + root_abs = os.path.realpath(args.root) if args.root else None + + srcs, seen, selected = [], set(), [] + defined = {} # macro name -> (token, first file that set it) + conflicts = {} # macro name -> {token: example file} for entry in db: fpath = entry.get('file') if not fpath: @@ -123,20 +192,92 @@ def main(): continue if exc and any(r.search(fpath) for r in exc): continue + if root_abs: + real = os.path.realpath(fpath) + if os.path.commonpath([real, root_abs]) != root_abs: + sys.stderr.write( + f"WARNING: skipping source outside --root: {fpath}\n") + continue for d in extract_defines(entry_tokens(entry)): - defines.add(d) - if fpath not in seen and os.path.isfile(fpath): + name = macro_name(d) + if name not in defined: + defined[name] = (d, fpath) + elif define_value(defined[name][0]) != define_value(d): + vals = conflicts.setdefault( + name, {defined[name][0]: defined[name][1]}) + vals.setdefault(d, fpath) + if not os.path.isfile(fpath): + continue + if fpath not in seen: seen.add(fpath) srcs.append(fpath) + ext = os.path.splitext(fpath)[1] + if ext in SCAN_EXTS or ext.lower() in SCAN_EXTS: + selected.append((fpath, entry)) if not srcs: sys.exit("ERROR: no matching source files found in compilation database") - defines = sorted(defines) + if conflicts and not args.options_h: + lines = ["ERROR: the selected compile commands disagree on these " + "macros:"] + for name in sorted(conflicts): + lines.append(f" {name}:") + for tok, src in sorted(conflicts[name].items()): + lines.append(f" -D{tok} (e.g. {src})") + lines.append("Narrow the set with --include/--exclude so one SBOM " + "records one configuration.") + sys.exit("\n".join(lines)) + + included = [] + if args.included_sources: + sys.path.insert(0, SCRIPT_DIR) + from included_sources import ScanError, included_sources + try: + scan_root = root_abs or os.path.commonpath( + [os.path.dirname(s) for s in srcs]) + except ValueError: + sys.exit("ERROR: sources share no common directory; pass --root") + known = {file_id(s) for s in srcs} + names = {os.path.basename(s): s for s in srcs} + scanned = set() + for fpath, entry in selected: + tokens = entry_tokens(entry) + key = (fpath, entry.get('directory'), tuple(tokens)) + if key in scanned: + continue + scanned.add(key) + if not tokens: + sys.stderr.write(f"WARNING: no compile command for {fpath}; " + f"its #include'd sources are not scanned\n") + continue + try: + found = included_sources( + tokens, entry.get('directory', os.getcwd()), fpath, + scan_root, SRC_EXTS) + except ScanError as e: + sys.exit(f"ERROR: {e}") + for path in found: + if (not os.path.isfile(path) or file_id(path) in known + or (inc and not any(r.search(path) for r in inc)) + or (exc and any(r.search(path) for r in exc))): + continue + name = os.path.basename(path) + if name in names: + sys.exit(f"ERROR: {path}, #include'd by {fpath}, has the " + f"same basename as {names[name]}; --exclude " + f"one of them") + known.add(file_id(path)) + names[name] = path + included.append(path) + srcs += included + + defines = sorted(tok for tok, _src in defined.values()) cflags = ' '.join(f'-D{d}' for d in defines) if args.print_only: - print(f"# {len(srcs)} sources, {len(defines)} defines") + print(f"# {len(srcs)} sources ({len(included)} included), " + f"{len(defines)} defines") print("\n[defines]") for d in defines: print(f" -D{d}") @@ -147,19 +288,29 @@ def main(): srcs_out, tmp = args.srcs_out, None if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-compdb-srcs-', suffix='.txt') + fd, srcs_out = tempfile.mkstemp(prefix='wolfssl-compliance-tooling-compdb-srcs-', suffix='.txt') os.close(fd) tmp = srcs_out with open(srcs_out, 'w') as f: f.write('\n'.join(srcs) + '\n') - cmd = [args.driver, '--srcs-file', srcs_out, f'--cflags={cflags}', + config = (['--options-h', args.options_h] if args.options_h + else [f'--cflags={cflags}']) + cmd = [args.driver, '--srcs-file', srcs_out, *config, '--name', args.name] for flag, val in (('--version', args.version), ('--version-file', args.version_file), ('--version-macro', args.version_macro), ('--root', args.root), ('--license-file', args.license_file), + ('--copyright-start', args.copyright_start), + ('--license-override', args.license_override), + ('--license-text', args.license_text), + ('--supplier', args.supplier), + ('--component-type', args.component_type), + ('--dep-wolfssl', args.dep_wolfssl), + ('--dep-wolfcrypt', args.dep_wolfcrypt), + ('--dep-openssl', args.dep_openssl), ('--settings-h', args.settings_h), ('--crypto-only', args.crypto_only), ('--gen-sbom', args.gen_sbom), @@ -169,8 +320,11 @@ def main(): cmd += [flag, val] for inc in args.include_dir: cmd += ['--include-dir', inc] + for dv in args.dep_version: + cmd += ['--dep-version', dv] - print(f"compdb SBOM: {len(srcs)} sources, {len(defines)} defines") + print(f"compdb SBOM: {len(srcs)} sources ({len(included)} included), " + f"{len(defines)} defines") try: rc = subprocess.call(cmd) finally: diff --git a/tools/sbom/frontends/iar_sbom.py b/tools/sbom/frontends/iar_sbom.py index 788bdff49f..5aa6b3a084 100755 --- a/tools/sbom/frontends/iar_sbom.py +++ b/tools/sbom/frontends/iar_sbom.py @@ -61,10 +61,37 @@ def parse_configs(root): return configs -def collect_sources(root, proj_dir): - """Return (present, missing) absolute paths of compiled source files.""" +def _excluded_from(el, cfg_name): + """True if a or carries an block naming + cfg_name -- IAR drops it from that build configuration, so it is not + compiled in.""" + excluded = el.find('excluded') + if excluded is None: + return False + return any((c.text or '').strip() == cfg_name + for c in excluded.findall('configuration')) + + +def _compiled_files(el, cfg_name): + """Yield the elements under el (the project or a ) that are + not excluded from cfg_name. An excluded drops every file in it + and in its nested groups.""" + for child in el: + if _excluded_from(child, cfg_name): + continue + if child.tag == 'group': + yield from _compiled_files(child, cfg_name) + elif child.tag == 'file': + yield child + + +def collect_sources(root, proj_dir, cfg_name): + """Return (present, missing) absolute paths of the source files compiled in + configuration cfg_name. Files and groups IAR marks for + cfg_name are dropped; listing them would over-report the compiled source + set.""" srcs = [] - for file_el in root.iter('file'): + for file_el in _compiled_files(root, cfg_name): name_el = file_el.find('name') if name_el is None or not name_el.text: continue @@ -95,6 +122,7 @@ def main(): ap.add_argument('--version-macro', default=None) ap.add_argument('--root', default=None) ap.add_argument('--license-file', default=None) + ap.add_argument('--copyright-start', default=None) # The .ewp records the -D tokens ICCARM was given, not the macros a # settings header derives from them. Without these two the capture reads # an empty translation unit and the recorded configuration is the -D list @@ -136,7 +164,7 @@ def main(): cfg_name = max(configs, key=lambda k: len(configs[k])) defines = configs[cfg_name] - srcs, missing = collect_sources(root, proj_dir) + srcs, missing = collect_sources(root, proj_dir, cfg_name) if not srcs: sys.exit("ERROR: no existing source files found in .ewp") if missing: @@ -162,7 +190,7 @@ def main(): srcs_out, tmp = args.srcs_out, None if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-iar-srcs-', suffix='.txt') + fd, srcs_out = tempfile.mkstemp(prefix='wolfssl-compliance-tooling-iar-srcs-', suffix='.txt') os.close(fd) tmp = srcs_out with open(srcs_out, 'w') as f: @@ -175,6 +203,7 @@ def main(): ('--version-macro', args.version_macro), ('--root', args.root), ('--license-file', args.license_file), + ('--copyright-start', args.copyright_start), ('--settings-h', args.settings_h), ('--crypto-only', args.crypto_only), ('--gen-sbom', args.gen_sbom), diff --git a/tools/sbom/frontends/included_sources.py b/tools/sbom/frontends/included_sources.py new file mode 100644 index 0000000000..2097a20aca --- /dev/null +++ b/tools/sbom/frontends/included_sources.py @@ -0,0 +1,200 @@ +"""Find the source files a compile command compiles in through #include, +such as wolfSSL's src/ssl_api_*.c, which are not compile_commands.json +entries of their own.""" + +import os +import re +import shlex +import subprocess + +# Options whose next token is their argument, kept for the -M run. +_KEEP_PAIRS = ('-D', '-U', '-I', '-iquote', '-isystem', '-isystem-after', + '-idirafter', '-imacros', '-include', '-isysroot', '-iprefix', + '-iwithprefix', '-iwithprefixbefore', '-iwithsysroot', + '-iframework', '-iframeworkwithsysroot', '-F', '-target', + '-arch', '--sysroot', '-x', '-specs', '--specs', + '--gcc-toolchain', '-gcc-toolchain', '-meabi', '-include-pch') +# Options whose next token is their argument, dropped with it. +_DROP_PAIRS = ('-o', '-MF', '-MT', '-MQ', '-MJ', '-Xclang', '-Xpreprocessor', + '-Xassembler', '-Xlinker', '-mllvm', '--param', '--output', + '-framework', '-undefined', '-force_load', '-filelist', + '-multiply_defined', '-mthread-model', '-arch_only', '-mmlir', + '-module-dependency-dir', '-multiply_defined_unused', + '-target-arch-variant') +_KEEP_EXACT = ('-ansi', '-undef', '-pthread', '-nostdinc', '-nostdinc++', + '-nobuiltininc') +# Joined forms of the pairs, and flags that set target or language macros. +_KEEP = tuple(o for o in _KEEP_PAIRS if o != '-arch') + ( + '--sysroot=', '--target=', '-std=', '--std=', '-stdlib=', '-specs=', + '--specs=', '--gcc-toolchain=', '-m', '-O') +# -f flags that change predefined macros; any other -f may write a file. +_F_KEEP = ('signed-char', 'unsigned-char', 'short-wchar', 'short-enums', + 'freestanding', 'hosted', 'builtin', 'pic', 'PIC', 'pie', 'PIE', + 'stack-protector', 'sanitize=', 'openmp', 'openacc', 'exceptions', + 'cxx-exceptions', 'rtti', 'ms-extensions', 'ms-compatibility', + 'declspec', 'blocks', 'gnu89-inline', 'asm', 'wrapv', 'trapv', + 'char8_t', 'gnu-keywords', 'coroutines', 'fast-math', 'math-errno', + 'finite-math-only', 'inline', 'cf-protection', 'input-charset=', + 'exec-charset=', 'dollars-in-identifiers', 'threadsafe-statics', + 'sized-deallocation', 'aligned-new', 'unwind-tables', + 'leading-underscore', 'single-precision-constant', 'abi-version=') +# Preprocessor options worth forwarding from -Xclang and -Xpreprocessor. +_WRAPPED_PAIRS = ('-D', '-U', '-I', '-include', '-imacros', '-include-pch', + '-isystem', '-iquote', '-idirafter') +_LAUNCHERS = ('ccache', 'sccache', 'distcc', 'icecc') +# GNU as takes -M as --mri. +_ASSEMBLER = re.compile(r'(^|-)as(\.exe)?$') + + +class ScanError(Exception): + pass + + +def _wp_option(t): + """A -Wp, option without its -M and -o parts, or None if none is left.""" + parts, kept = t.split(',')[1:], [] + while parts: + p = parts.pop(0) + if p in ('-MD', '-MMD', '-MF', '-MT', '-MQ', '-o', '--output') \ + and parts: + parts.pop(0) + elif not p.startswith(('-M', '-o', '--output')): + kept.append(p) + return '-Wp,' + ','.join(kept) if kept else None + + +def _keep_flag(t): + """t if it is a single-token flag the -M run needs, else None.""" + if t.startswith('-Wp,'): + return _wp_option(t) + if t.startswith('-f'): + name = t[5:] if t.startswith('-fno-') else t[2:] + return t if name.startswith(_F_KEEP) else None + if t in _KEEP_EXACT or (t.startswith(_KEEP) and t not in _DROP_PAIRS): + return t + return None + + +def _expand_response_files(tokens, directory, depth=0): + out = [] + for t in tokens: + if not t.startswith('@') or t == '@': + out.append(t) + continue + path = os.path.join(directory or '', t[1:]) + if depth > 8: + raise ScanError(f"response files nested too deep at {path}") + try: + with open(path, errors='surrogateescape') as f: + inner = shlex.split(f.read()) + except (OSError, ValueError) as e: + raise ScanError(f"cannot read response file {path}: {e}") + out += _expand_response_files(inner, directory, depth + 1) + return out + + +def _compiler_index(tokens): + i = 0 + while (i < len(tokens) - 1 and os.path.splitext( + os.path.basename(tokens[i]))[0] in _LAUNCHERS): + i += 1 + return i + + +def _arches(tokens): + return list(dict.fromkeys(tokens[j + 1] for j in range(len(tokens) - 1) + if tokens[j] == '-arch')) + + +def dependency_command(tokens, source, arch=None): + """The preprocessor flags of a compile command for one -arch (the + first by default), as a -M run on source.""" + i = _compiler_index(tokens) + rest = tokens[i + 1:] + arch = arch or next(iter(_arches(rest)), None) + # -M goes first so a misparsed option swallows the source and fails. + cmd, i = [tokens[i], '-M'], 0 + while i < len(rest): + t = rest[i] + pair = i + 1 < len(rest) + if t in ('-Xclang', '-Xpreprocessor') and pair: + inner = rest[i + 1] + if (inner in _WRAPPED_PAIRS and i + 3 < len(rest) + and rest[i + 2] == t): + cmd += rest[i:i + 4] + i += 4 + continue + if inner.startswith(('-D', '-U', '-I')): + cmd += [t, inner] + i += 2 + elif t == f'-Xarch_{arch}' and pair: + kept = _keep_flag(rest[i + 1]) + cmd += [kept] if kept else [] + i += 2 + elif (t in _DROP_PAIRS or t.startswith('-Xarch_')) and pair: + i += 2 + elif t in _KEEP_PAIRS and pair: + if t != '-arch' or rest[i + 1] == arch: + cmd += [t, rest[i + 1]] + i += 2 + else: + kept = _keep_flag(t) + cmd += [kept] if kept else [] + i += 1 + return cmd + [source] + + +def parse_make_deps(text): + """Return the prerequisites of the make rules printed by -M.""" + text = text.replace('\\\r\n', ' ').replace('\\\n', ' ') + deps = [] + for rule in text.splitlines(): + prereqs = re.split(r':(?:\s|$)', rule, maxsplit=1) + if len(prereqs) < 2: + continue + deps += [d.replace('\\ ', ' ').replace('\\#', '#').replace('$$', '$') + for d in re.split(r'(?/modules//`. That set follows Kconfig and any CMake logic +the module uses, and it leaves out application files inside the module tree. -The module configuration is Kconfig-driven (CONFIG_* symbols), not a `-D` macro -set, so by default this produces a source-inventory SBOM. If you have a real -Zephyr build and want the exact compiled config, generate the SBOM from that -build's compilation database with compdb_sbom.py instead. +The configuration is Kconfig-driven, so by default this produces a +source-inventory SBOM. `--user-settings` records the module's -D flags and the +macros its settings header defines, preprocessed by the build's own compiler +with the module's compile command. `--options-h` records a flat `#define` +header instead. Usage: - zephyr_sbom.py --name NAME --cmakelists path/to/zephyr/CMakeLists.txt [options] + zephyr_sbom.py --name NAME --compdb build/compile_commands.json \\ + --root MODULE [--user-settings HEADER] [options] """ import argparse +import json import os import re +import shlex import subprocess import sys import tempfile @@ -29,38 +39,221 @@ DEFAULT_DRIVER = os.path.join(os.path.dirname(SCRIPT_DIR), "sbom-driver") SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.s', '.asm') +_ARG_OPTS = ('-D', '-U', '-I', '-iquote', '-isystem', '-idirafter', + '-imacros', '-include', '-o', '-MF', '-MT', '-MQ', '-x') +_NO_DUMP = ('-o', '-MF', '-MT', '-MQ', '-x', '-c', '-M', '-MM', '-MD', '-MMD', + '-MP') +_HOST_OPTS = ('-D', '-U', '-I', '-iquote') -def parse_library_sources(cmakelists): - """Extract the file list from the zephyr_library_sources(...) blocks.""" - with open(cmakelists) as f: - text = f.read() - module_dir = os.path.dirname(os.path.abspath(cmakelists)) +class ParseError(Exception): + pass - srcs = [] - for m in re.finditer(r'zephyr_library_sources\s*\((.*?)\)', text, re.DOTALL): - for raw in m.group(1).split(): - raw = raw.strip() - if not raw or not raw.lower().endswith(SRC_EXTS): - continue - # Resolve the common CMake module-directory variables. - p = raw.replace('${CMAKE_CURRENT_LIST_DIR}', module_dir) - p = p.replace('${CMAKE_CURRENT_SOURCE_DIR}', module_dir) - p = p.replace('${ZEPHYR_CURRENT_MODULE_DIR}', os.path.dirname(module_dir)) - if '${' in p: - sys.stderr.write(f"WARNING: skipping unresolved source: {raw}\n") + +def module_name(root): + """The Zephyr module name: module.yml's name, else the directory name.""" + for yml in ('module.yml', 'module.yaml'): + path = os.path.join(root, 'zephyr', yml) + if os.path.isfile(path): + try: + with open(path, encoding='utf-8') as f: + lines = f.readlines() + except (OSError, UnicodeDecodeError) as e: + raise ParseError(f"cannot read {path}: {e}") + for line in lines: + m = re.match(r'name:\s*([^\s#]+)', line) + if m: + return m.group(1).strip('"\'') + break + return os.path.basename(os.path.normpath(root)) + + +def compdb_entries(compdb, root, name): + """Return the module library's compile commands as [(source, entry)] + for sources under root, plus the library sources outside it.""" + try: + with open(compdb) as f: + db = json.load(f) + except (OSError, json.JSONDecodeError) as e: + raise ParseError(f"cannot read {compdb}: {e}") + root = os.path.realpath(root) + os.sep + picked, outside, seen = [], [], set() + for e in db: + if 'output' not in e: + raise ParseError(f"{compdb} has no 'output' field; it needs " + "CMake 3.20 or later") + if not e.get('file'): + continue + directory = e.get('directory', '') + lib_dir = os.path.normpath( + os.path.join(directory, 'modules', name)) + os.sep + out = os.path.normpath(os.path.join(directory, e['output'])) + src = os.path.normpath(os.path.join(directory, e['file'])) + if (not out.startswith(lib_dir) + or not src.lower().endswith(SRC_EXTS)): + continue + if os.path.realpath(src).startswith(root): + picked.append((src, e)) + elif src not in seen: + seen.add(src) + outside.append(src) + return picked, outside + + +def split_windows_command(cmd): + """Split a command line the way CommandLineToArgvW does.""" + args, cur, have, quoted, i = [], [], False, False, 0 + while i < len(cmd): + c = cmd[i] + if c == '\\': + n = len(cmd[i:]) - len(cmd[i:].lstrip('\\')) + i += n + if cmd[i:i + 1] == '"': + cur.append('\\' * (n // 2)) + if n % 2: + cur.append('"') + i += 1 + else: + cur.append('\\' * n) + have = True + elif c == '"': + quoted, have, i = not quoted, True, i + 1 + elif c in ' \t' and not quoted: + if have: + args.append(''.join(cur)) + cur, have = [], False + i += 1 + else: + cur.append(c) + have, i = True, i + 1 + if have: + args.append(''.join(cur)) + return args + + +def _entry_tokens(entry): + if entry.get('arguments'): + toks = list(entry['arguments']) + else: + cmd = entry.get('command', '') + try: + toks = (split_windows_command(cmd) if os.name == 'nt' + else shlex.split(cmd)) + except ValueError as e: + raise ParseError(f"cannot split the compile command of " + f"{entry.get('file')}: {e}") + if not toks: + raise ParseError(f"empty compile command for {entry.get('file')}") + return toks + + +def _dump_macros(cc, flags, directory): + try: + r = subprocess.run([cc, '-dM', '-E', *flags, '-x', 'c', os.devnull], + cwd=directory or None, capture_output=True, + text=True) + except OSError as e: + raise ParseError(f"cannot run '{cc}': {e}") + if r.returncode: + raise ParseError(f"'{cc} -dM -E' failed:\n{r.stderr.strip()}") + macros = {} + for line in r.stdout.splitlines(): + m = re.match(r'#define (\S+)', line) + if m: + macros[m.group(1)] = line + return macros + + +def _options(entry): + """Yield (option, argument) pairs of a compile command after the + compiler, with argument None for a flag and option None for a source.""" + toks = _entry_tokens(entry) + i = 1 + while i < len(toks): + t = toks[i] + if t.startswith('@'): + raise ParseError(f"response file {t} in the compile command of " + f"{entry.get('file')} is not supported") + if t in _ARG_OPTS and i + 1 < len(toks): + yield t, toks[i + 1] + i += 2 + continue + joined = next((o for o in _ARG_OPTS + if t.startswith(o) and t != o), None) + if joined: + yield joined, t[len(joined):] + elif t.startswith('-'): + yield t, None + else: + yield None, t + i += 1 + + +def _same_file(path, directory, settings_h): + full = os.path.join(directory or '', path) + return os.path.realpath(full) == os.path.realpath(settings_h) + + +def _defines(entries): + """Merge the net -D/-U state of every entry into one -D/-U list, or + raise ParseError when two entries disagree on a macro.""" + merged = {} + for src, entry in entries: + state = {} + for opt, arg in _options(entry): + if arg is None: continue - if not os.path.isabs(p): - p = os.path.join(module_dir, p) - srcs.append(os.path.normpath(p)) + if opt == '-D': + name = re.split(r'[=(]', arg, maxsplit=1)[0] + state[name] = arg if '=' in arg else arg + '=1' + elif opt == '-U': + state[arg] = None + for name, value in state.items(): + if name in merged and merged[name][0] != value: + raise ParseError( + f"the module's compile commands disagree on {name}: " + f"{merged[name][1]} and {src}") + merged.setdefault(name, (value, src)) + flags = [] + for name in sorted(merged): + value = merged[name][0] + flags += ['-U', name] if value is None else ['-D', value] + return flags - seen, present, missing = set(), [], [] - for s in srcs: - if s in seen: + +def capture_user_settings(entries, settings_h, hostcc=None): + """Return the #define lines the module's -D flags and settings_h add. + + The capture runs the first C entry's compiler and flags, with the -D and + -U flags merged across all C entries. With hostcc it runs that compiler + with only the -D, -U, -I, -iquote and autoconf.h flags.""" + c_entries = [(src, e) for src, e in entries + if not src.lower().endswith(('.s', '.asm'))] or entries + entry = c_entries[0][1] + directory = entry.get('directory') + base = [] + for opt, arg in _options(entry): + if opt is None or opt in _NO_DUMP or opt in ('-D', '-U'): + continue + if opt in ('-include', '-imacros') and _same_file(arg, directory, + settings_h): + continue + if hostcc and opt not in _HOST_OPTS and not ( + opt == '-imacros' and os.path.basename(arg) == 'autoconf.h'): continue - seen.add(s) - (present if os.path.isfile(s) else missing).append(s) - return present, missing + base += [opt] if arg is None else [opt, arg] + cc = hostcc or _entry_tokens(entry)[0] + defs = _defines(c_entries) + before = _dump_macros(cc, base, directory) + with_defs = _dump_macros(cc, base + defs, directory) + after = _dump_macros( + cc, base + defs + ['-include', os.path.abspath(settings_h)], + directory) + if after == with_defs: + raise ParseError(f"{settings_h} adds no macros; the compile command " + f"may already include it or a copy of it") + return sorted(line for name, line in after.items() + if before.get(name) != line) def main(): @@ -68,18 +261,40 @@ def main(): description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('--name', required=True, help='Package name for the SBOM.') - ap.add_argument('--cmakelists', required=True, - help='Path to the module CMakeLists.txt.') + ap.add_argument('--compdb', required=True, + help="The west build's compile_commands.json.") + ap.add_argument('--root', required=True, help='The module directory.') + ap.add_argument('--module-name', default=None, + help='Zephyr module name. Default: the name in ' + 'zephyr/module.yml, else the --root directory name.') + ap.add_argument('--user-settings', default=None, + help="The module's settings header, captured with the " + "module's compile flags.") + ap.add_argument('--hostcc', default=None, + help='Capture --user-settings with this host compiler ' + 'instead of the build\'s compiler and target flags.') + ap.add_argument('--options-h', default=None, + help='Flat #define header to record as the build ' + 'configuration, instead of --source-only.') ap.add_argument('--driver', default=DEFAULT_DRIVER) ap.add_argument('--gen-sbom', default=None) ap.add_argument('--version', default=None) ap.add_argument('--version-file', default=None) ap.add_argument('--version-macro', default=None) - ap.add_argument('--root', default=None) + ap.add_argument('--supplier', default=None) + ap.add_argument('--component-type', default=None) ap.add_argument('--license-file', default=None) - # This front end is --source-only: no macros are captured, so the - # generator cannot read WOLFCRYPT_ONLY and will say so unless the - # operator states it here. + ap.add_argument('--copyright-start', default=None) + ap.add_argument('--license-override', default=None) + ap.add_argument('--license-text', default=None) + ap.add_argument('--dep-wolfssl', default=None) + ap.add_argument('--dep-wolfcrypt', default=None) + ap.add_argument('--dep-openssl', default=None) + ap.add_argument('--dep-version', action='append', default=[], + help='Dependency version, e.g. wolfssl=5.9.2 ' + '(repeatable).') + # In source-only mode the generator cannot read WOLFCRYPT_ONLY and will + # say so unless the operator states it here. ap.add_argument('--crypto-only', default=None, help='auto|yes|no, forwarded to the generator.') ap.add_argument('--cdx-out', default=None) @@ -88,53 +303,106 @@ def main(): ap.add_argument('--print-only', action='store_true') args = ap.parse_args() - if not os.path.isfile(args.cmakelists): - sys.exit(f"ERROR: CMakeLists not found: {args.cmakelists}") + if args.user_settings and args.options_h: + sys.exit("ERROR: pass only one of --user-settings and --options-h.") + if args.hostcc and not args.user_settings: + sys.exit("ERROR: --hostcc only applies to --user-settings.") + for path, what in ((args.compdb, 'compilation database'), + (args.user_settings, '--user-settings')): + if path and not os.path.isfile(path): + sys.exit(f"ERROR: {what} not found: {path}") - srcs, missing = parse_library_sources(args.cmakelists) - if missing: + settings = None + try: + name = args.module_name or module_name(args.root) + entries, outside = compdb_entries(args.compdb, args.root, name) + if not entries: + sys.exit(f"ERROR: {args.compdb} has no compile commands for " + f"module '{name}' from sources under {args.root}; " + f"check --root and --module-name.") + if args.user_settings: + settings = capture_user_settings(entries, args.user_settings, + args.hostcc) + except ParseError as e: + sys.exit(f"ERROR: {e}") + if outside: sys.stderr.write( - f"WARNING: {len(missing)} module source(s) not found on disk " - f"(excluded):\n") - for m in missing: - sys.stderr.write(f" - {m}\n") - if not srcs: - sys.exit("ERROR: no zephyr_library_sources found in " + args.cmakelists) + f"WARNING: {len(outside)} source(s) of module '{name}' lie " + f"outside --root (excluded):\n") + for s in outside: + sys.stderr.write(f" - {s}\n") + srcs = list(dict.fromkeys(src for src, _ in entries)) if args.print_only: print(f"# {args.name}: {len(srcs)} sources") for s in srcs: print(f" {s}") + if settings is not None: + print(f"\n# {len(settings)} macros from {args.user_settings}") + for line in settings: + print(f" {line}") return - srcs_out, tmp = args.srcs_out, None - if not srcs_out: - fd, srcs_out = tempfile.mkstemp(prefix='wolfglass-zephyr-srcs-', suffix='.txt') - os.close(fd) - tmp = srcs_out - with open(srcs_out, 'w') as f: - f.write('\n'.join(srcs) + '\n') - - cmd = [args.driver, '--srcs-file', srcs_out, '--source-only', - '--name', args.name] - for flag, val in (('--version', args.version), - ('--version-file', args.version_file), - ('--version-macro', args.version_macro), - ('--root', args.root), - ('--license-file', args.license_file), - ('--crypto-only', args.crypto_only), - ('--gen-sbom', args.gen_sbom), - ('--cdx-out', args.cdx_out), - ('--spdx-out', args.spdx_out)): - if val: - cmd += [flag, val] - - print(f"Zephyr module SBOM: {len(srcs)} sources (source-inventory)") + tmps = [] try: - rc = subprocess.call(cmd) + srcs_out = args.srcs_out + if not srcs_out: + fd, srcs_out = tempfile.mkstemp( + prefix='wolfssl-compliance-tooling-zephyr-srcs-', + suffix='.txt') + os.close(fd) + tmps.append(srcs_out) + try: + with open(srcs_out, 'w') as f: + f.write('\n'.join(srcs) + '\n') + except OSError as e: + sys.exit(f"ERROR: cannot write {srcs_out}: {e}") + + cmd = [args.driver, '--srcs-file', srcs_out, '--name', args.name, + '--root', args.root] + if settings is not None: + fd, opts = tempfile.mkstemp( + prefix='wolfssl-compliance-tooling-zephyr-defs-', suffix='.h') + tmps.append(opts) + with os.fdopen(fd, 'w') as f: + f.write('\n'.join(settings) + '\n') + cmd += ['--options-h', opts] + elif args.options_h: + cmd += ['--options-h', args.options_h] + else: + cmd += ['--source-only'] + for flag, val in (('--version', args.version), + ('--version-file', args.version_file), + ('--version-macro', args.version_macro), + ('--supplier', args.supplier), + ('--component-type', args.component_type), + ('--license-file', args.license_file), + ('--copyright-start', args.copyright_start), + ('--license-override', args.license_override), + ('--license-text', args.license_text), + ('--dep-wolfssl', args.dep_wolfssl), + ('--dep-wolfcrypt', args.dep_wolfcrypt), + ('--dep-openssl', args.dep_openssl), + ('--crypto-only', args.crypto_only), + ('--gen-sbom', args.gen_sbom), + ('--cdx-out', args.cdx_out), + ('--spdx-out', args.spdx_out)): + if val: + cmd += [flag, val] + for dv in args.dep_version: + cmd += ['--dep-version', dv] + + kind = ("user-settings" if settings is not None + else "options-h" if args.options_h else "source-inventory") + print(f"Zephyr module SBOM: {len(srcs)} sources ({kind})") + try: + rc = subprocess.call(cmd) + except OSError as e: + sys.exit(f"ERROR: cannot run {args.driver}: {e}") finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) + for t in tmps: + if os.path.exists(t): + os.remove(t) sys.exit(rc) diff --git a/tools/sbom/gen-sbom b/tools/sbom/gen-sbom index 0f8043511f..312f0740e6 100755 --- a/tools/sbom/gen-sbom +++ b/tools/sbom/gen-sbom @@ -2,13 +2,16 @@ """Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl.""" import argparse +import errno import hashlib import io import json import os import re +import stat import subprocess import sys +import tempfile import uuid from datetime import datetime, timezone @@ -121,6 +124,8 @@ GITHUB_TAG_FORMS = { } DEFAULT_GITHUB_TAG_FORM = 'v{version}' +DEFAULT_COPYRIGHT_START = 2006 + def github_release_tag(project, version): """Return the git tag a pkg:github PURL for `project` must point at.""" @@ -277,7 +282,8 @@ def derived_uuid(*parts): def config_identity(lib_hash, license_id, build_props, enabled_deps, dep_versions, *, supplier=None, component_type=None, license_text=None, hash_kind=None, hash_source=None, - file_names=None, wolfssl_subset=None, subset_basis=None): + file_names=None, wolfssl_subset=None, subset_basis=None, + copyright_start=DEFAULT_COPYRIGHT_START): """Stable digest of the build configuration behind one name+version. Folded into the SBOM serialNumber / SPDX documentNamespace so two builds @@ -324,9 +330,18 @@ def config_identity(lib_hash, license_id, build_props, enabled_deps, _field('file', fname) _field('wolfssl_subset', wolfssl_subset) _field('subset_basis', subset_basis) + # Folded only when set, so default documents keep their serialNumber. + if copyright_start != DEFAULT_COPYRIGHT_START: + _field('copyright_start', str(copyright_start)) return h.hexdigest() +def copyright_text(start, year, supplier): + """Copyright line for the main component, one year when start == year.""" + years = str(year) if start == year else f'{start}-{year}' + return f'Copyright (C) {years} {supplier}' + + 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 @@ -344,6 +359,48 @@ def build_timestamp(): return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ') +def write_json_atomic(obj, path): + """Write `obj` as JSON to `path` atomically via a sibling temp file and + os.replace, so a crash or ENOSPC mid-write cannot leave a truncated + document that a later CI step would pick up as valid.""" + try: + st = os.lstat(path) + except FileNotFoundError: + st = None + # Only a new path or a plain regular file gets the temp+replace dance. A + # symlink (including /dev/stdout), device or fifo is written through + # directly so the link target is updated rather than the link replaced. + if st is not None and not stat.S_ISREG(st.st_mode): + with open(path, 'w') as f: + json.dump(obj, f, indent=2) + f.write('\n') + return + # os.replace only needs a writable directory; keep refusing a read-only + # output the way a plain open(path, 'w') does. + if st is not None and not os.access(path, os.W_OK): + raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), path) + if st is not None: + mode = stat.S_IMODE(st.st_mode) + else: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.', + prefix=os.path.basename(path) + '.') + try: + with os.fdopen(fd, 'w') as f: + json.dump(obj, f, indent=2) + f.write('\n') + os.chmod(tmp, mode) + os.replace(tmp, path) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise + + # 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 @@ -1046,11 +1103,17 @@ def _collect_srcs(srcs_args, srcs_file): continue paths.append(stripped) + # Dedup on the normalized absolute path, not the raw string, so the same + # path spelled two ways (foo.c and ./foo.c) collapses to one entry instead + # of tripping srcs_merkle_hash's duplicate-basename guard. Symlinks are + # not resolved: an alias with its own basename stays a distinct entry. + # The first spelling is kept for opening/hashing. seen = set() deduped = [] for p in paths: - if p not in seen: - seen.add(p) + key = os.path.normpath(os.path.abspath(p)) + if key not in seen: + seen.add(key) deduped.append(p) if not deduped: @@ -1145,7 +1208,7 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, dep_version_overrides=None, hash_kind='library-binary', hash_source='lib', srcs_basenames=None, file_entries=None, component_type='library', wolfssl_subset=None, - subset_basis=None): + subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START): bom_ref = derived_uuid(name, version, 'package') urls = project_urls(name) @@ -1240,7 +1303,7 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, 'name': name, 'version': version, 'licenses': cdx_license_block(license_id, license_text), - 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'copyright': copyright_text(copyright_start, year, supplier), 'purl': wolfssl_project_purl(name, version), 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], 'externalReferences': [ @@ -1317,7 +1380,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, hash_source='lib', srcs_basenames=None, document_namespace=None, file_entries=None, component_type='library', wolfssl_subset=None, - subset_basis=None): + subset_basis=None, copyright_start=DEFAULT_COPYRIGHT_START): 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 @@ -1397,7 +1460,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}], 'licenseConcluded': license_id, 'licenseDeclared': license_id, - 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'copyrightText': copyright_text(copyright_start, year, supplier), 'primaryPackagePurpose': SPDX_PACKAGE_PURPOSE.get( component_type, 'LIBRARY'), 'comment': f'Build configuration defines: {build_defines}', @@ -1595,6 +1658,11 @@ def main(): 'component.type, mirrored to SPDX ' 'primaryPackagePurpose. Use firmware for a ' 'bootloader such as wolfBoot (default: library)') + parser.add_argument('--copyright-start', type=int, + default=DEFAULT_COPYRIGHT_START, metavar='YEAR', + help='First year of the copyright line, written as ' + '"Copyright (C) YEAR- " ' + '(default: %(default)s)') parser.add_argument('--license-file', required=True, help='Path to LICENSING file for SPDX ID detection') parser.add_argument('--license-override', default='', @@ -1760,6 +1828,12 @@ def main(): "https://example.com/sbom/wolfssl-X.Y.Z.spdx.json or " "urn:uuid:00000000-0000-0000-0000-000000000000.") + dt, timestamp = build_timestamp() + year = dt.year + if not 1000 <= args.copyright_start <= year: + sys.exit(f"ERROR: --copyright-start {args.copyright_start} must be a " + f"four-digit year no later than the build year {year}.") + enabled_deps = _enabled_deps([ ('wolfssl', '--dep-wolfssl', args.dep_wolfssl), ('wolfcrypt', '--dep-wolfcrypt', args.dep_wolfcrypt), @@ -1887,8 +1961,6 @@ def main(): hash_source = 'srcs' srcs_basenames = sorted({os.path.basename(p) for p in srcs}) - dt, timestamp = build_timestamp() - year = dt.year # SPDX 2.3 6.5 requires documentNamespace to be unique, and a CycloneDX # version:1 sharing a serialNumber over differing content is # self-contradictory. Every body-affecting input goes into the digest; @@ -1908,6 +1980,7 @@ def main(): file_names=identity_file_names, wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, + copyright_start=args.copyright_start, ) serial = derived_uuid(args.name, args.version, 'serial', config_id) doc_ns_uuid = derived_uuid(args.name, args.version, 'document', config_id) @@ -1923,6 +1996,7 @@ def main(): component_type=args.component_type, wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, + copyright_start=args.copyright_start, ) spdx = generate_spdx( args.name, args.version, args.supplier, @@ -1936,15 +2010,12 @@ def main(): component_type=args.component_type, wolfssl_subset=wolfssl_subset, subset_basis=subset_basis, + copyright_start=args.copyright_start, ) 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') + write_json_atomic(cdx, args.cdx_out) + write_json_atomic(spdx, args.spdx_out) except OSError as e: sys.exit(f"ERROR: cannot write SBOM output: {e}") diff --git a/tools/sbom/sbom-driver b/tools/sbom/sbom-driver index d2322cf627..9ec1971fe9 100755 --- a/tools/sbom/sbom-driver +++ b/tools/sbom/sbom-driver @@ -1,5 +1,5 @@ #!/bin/sh -# wolfGlass SBOM driver - thin shell wrapper. +# wolfssl-compliance-tooling SBOM driver - thin shell wrapper. # # The engine is Python (sbom-driver.py) for Windows and air-gap parity. This # wrapper lets a Make or shell caller invoke it as a plain command. It picks a diff --git a/tools/sbom/sbom-driver.py b/tools/sbom/sbom-driver.py index 20d81371db..8837615b2e 100755 --- a/tools/sbom/sbom-driver.py +++ b/tools/sbom/sbom-driver.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""wolfGlass SBOM driver - the product-neutral SBOM engine. +"""wolfssl-compliance-tooling SBOM driver - the product-neutral SBOM engine. This is the generalized form of wolfBoot's tools/scripts/wolfboot-sbom.sh. It is product-neutral: the name, version, root, generator, and license are all @@ -11,8 +11,10 @@ * Library (tier R/L/S): hash the built library with --lib. * Source-embedded (tier E, e.g. wolfBoot): hash a source set with --srcs-file. - * As-built / FIPS / kernel module: --lib with --no-artifact-hash on the - as-built artifact. Never substitute a source list for a certified artifact. + * As-built / FIPS / kernel module: pass --no-artifact-hash alone. The + driver sends that flag to gen-sbom. Do not pass --lib or --srcs-file + with it: gen-sbom accepts one checksum source and records a placeholder. + Never substitute a source list for a certified artifact. * Linker / binding: record a declared dependency with --dep-wolfssl and friends (feature-detected against the generator). @@ -23,7 +25,11 @@ -D tokens are interpreted by; without it the dump is the -D list alone and every derived macro is missing. --options-h PATH A pre-expanded flat #define header, used verbatim (scrubbed). - --user-settings P A user_settings.h. Passed to the generator, which captures it. + --user-settings P Path to wolfssl/wolfcrypt/settings.h. Also pass + --user-settings-include and + --user-settings-define WOLFSSL_USER_SETTINGS. The + generator walks settings.h; a user_settings.h path + alone records only the literals in that file. --source-only No build-config macros (e.g. a Kconfig-driven build). The macro capture runs the HOST compiler (never the cross compiler), so the SBOM @@ -86,6 +92,11 @@ def scrub_defines(text): def read_version(version_file, version_macro): """Read a version string from a header, e.g. LIBWOLFBOOT_VERSION_STRING.""" + # An empty macro would make re.escape('') a zero-width prefix, so the pattern + # degrades to the first quoted string in the header (a copyright year, an + # unrelated define) -- return "" rather than a silently wrong version. + if not version_macro: + return "" if not version_file or not os.path.isfile(version_file): return "" pat = re.compile(re.escape(version_macro) + r'\s*"([^"]*)"') @@ -200,6 +211,32 @@ def capture_macros(hostcc, cflags, settings_h="", include_dirs=()): return res.stdout +def check_user_settings(args): + """Require the generator's three settings.h inputs, or none of them. + + gen-sbom --user-settings is wolfssl/wolfcrypt/settings.h. settings.h + includes user_settings.h only when WOLFSSL_USER_SETTINGS is defined and + the include path can find that header. Forwarding a user_settings.h path + by itself records the literals in that file and drops every macro + settings.h derives. + """ + if not args.user_settings: + if args.user_settings_include or args.user_settings_define: + sys.exit("ERROR: --user-settings-include and " + "--user-settings-define require --user-settings.") + return + if not os.path.isfile(args.user_settings): + sys.exit(f"ERROR: --user-settings '{args.user_settings}' " + f"does not exist.") + if not args.user_settings_include: + sys.exit("ERROR: --user-settings requires --user-settings-include " + "(the directory that contains user_settings.h).") + names = [item.split("=", 1)[0] for item in args.user_settings_define] + if "WOLFSSL_USER_SETTINGS" not in names: + sys.exit("ERROR: --user-settings requires " + "--user-settings-define WOLFSSL_USER_SETTINGS.") + + def load_srcs(srcs_file, skip_missing): """Read the source list; optionally drop paths that do not exist.""" kept, dropped = [], 0 @@ -231,8 +268,9 @@ def main(): ap.add_argument("--srcs-file", default="", help="File listing compiled-in sources (tier E).") ap.add_argument("--no-artifact-hash", action="store_true", - help="Do not recompute the artifact hash. For as-built FIPS " - "or kernel modules.") + help="Record a placeholder checksum. Stands alone: do not " + "pass --lib or --srcs-file. For a FIPS canister or a " + "kernel module when no hashable artefact exists.") # Config (choose one). grp = ap.add_mutually_exclusive_group() @@ -251,7 +289,19 @@ def main(): grp.add_argument("--options-h", default="", help="A pre-expanded flat #define header, used verbatim.") grp.add_argument("--user-settings", default="", - help="A user_settings.h; passed to the generator.") + help="Path to wolfssl/wolfcrypt/settings.h. Also pass " + "--user-settings-include and " + "--user-settings-define WOLFSSL_USER_SETTINGS.") + ap.add_argument("--user-settings-include", action="append", default=[], + metavar="DIR", + help="Include directory for --user-settings (repeatable). " + "Point it at the directory that contains " + "user_settings.h.") + ap.add_argument("--user-settings-define", action="append", default=[], + metavar="NAME[=VALUE]", + help="Predefine for --user-settings (repeatable). Pass " + "WOLFSSL_USER_SETTINGS so settings.h includes " + "user_settings.h.") grp.add_argument("--source-only", action="store_true", help="Source-inventory SBOM with no build-config macros.") @@ -266,14 +316,22 @@ def main(): "primaryPackagePurpose. Use firmware for a " "bootloader. Passed through only if the generator " "supports it.") + ap.add_argument("--copyright-start", default="", + help="First year of the copyright line. Passed through " + "only if the generator supports it.") ap.add_argument("--license-file", default="") ap.add_argument("--license-override", default="") ap.add_argument("--license-text", default="") + ap.add_argument("--document-namespace", default="", + help="SPDX documentNamespace override.") # Dependencies (feature-detected against the generator). ap.add_argument("--dep-wolfssl", default="") ap.add_argument("--dep-wolfcrypt", default="") ap.add_argument("--dep-openssl", default="") + ap.add_argument("--dep-libz", default="", + help="yes|no. Record zlib. Pass the product's real " + "build option; gen-sbom defaults to no.") ap.add_argument("--dep-version", action="append", default=[], help="Dependency version, e.g. wolfssl=5.9.2 (repeatable).") ap.add_argument("--crypto-only", default="", @@ -302,7 +360,13 @@ def main(): sys.exit("ERROR: could not determine version; pass --version or " "--version-file with --version-macro.") - if not args.lib and not args.srcs_file: + if args.no_artifact_hash: + if args.lib or args.srcs_file: + sys.exit("ERROR: --no-artifact-hash stands alone. " + "Do not pass --lib or --srcs-file.") + elif args.lib and args.srcs_file: + sys.exit("ERROR: pass one of --lib or --srcs-file.") + elif not args.lib and not args.srcs_file: sys.exit("ERROR: pass at least one of --lib or --srcs-file.") if args.source_only and not args.srcs_file: sys.exit("ERROR: --source-only needs --srcs-file.") @@ -310,6 +374,7 @@ def main(): sys.exit(f"ERROR: --srcs-file '{args.srcs_file}' does not exist.") if args.lib and not os.path.isfile(args.lib): sys.exit(f"ERROR: --lib '{args.lib}' does not exist.") + check_user_settings(args) gen_sbom = find_gen_sbom(args.gen_sbom) if not os.path.isfile(gen_sbom): @@ -329,22 +394,24 @@ def main(): "--cdx-out", cdx_out, "--spdx-out", spdx_out] - # Composition. - if args.srcs_file: - srcs = load_srcs(args.srcs_file, args.skip_missing) - fd, srcs_path = tempfile.mkstemp(prefix="wolfglass-srcs-", suffix=".txt") - os.close(fd) - tmp_files.append(srcs_path) - with open(srcs_path, "w", encoding="utf-8") as f: - f.write("\n".join(srcs) + "\n") - cmd += ["--srcs-file", srcs_path] - src_count = len(srcs) - else: - src_count = 0 - if args.lib: - cmd += ["--lib", args.lib] + # Composition. gen-sbom accepts exactly one checksum source. + # --no-artifact-hash is that source and stands alone. + src_count = 0 if args.no_artifact_hash: cmd += ["--no-artifact-hash"] + else: + if args.srcs_file: + srcs = load_srcs(args.srcs_file, args.skip_missing) + fd, srcs_path = tempfile.mkstemp( + prefix="wolfssl-compliance-tooling-srcs-", suffix=".txt") + os.close(fd) + tmp_files.append(srcs_path) + with open(srcs_path, "w", encoding="utf-8") as f: + f.write("\n".join(srcs) + "\n") + cmd += ["--srcs-file", srcs_path] + src_count = len(srcs) + if args.lib: + cmd += ["--lib", args.lib] # Config. if args.settings_h and not args.cflags: @@ -354,10 +421,11 @@ def main(): print("WARNING: --settings-h is only used with --cflags; " "ignoring it here.", file=sys.stderr) if args.user_settings: - if not os.path.isfile(args.user_settings): - sys.exit(f"ERROR: --user-settings '{args.user_settings}' " - f"does not exist.") cmd += ["--user-settings", args.user_settings] + for inc in args.user_settings_include: + cmd += ["--user-settings-include", inc] + for define in args.user_settings_define: + cmd += ["--user-settings-define", define] else: if args.source_only: defines_text = "" @@ -367,7 +435,9 @@ def main(): with open(args.options_h, encoding="utf-8", errors="replace") as f: defines_text = f.read() elif args.cflags: - if args.settings_h and not os.path.isfile(args.settings_h): + # A relative path is left to the compiler's -include search. + if (os.path.isabs(args.settings_h) + and not os.path.isfile(args.settings_h)): sys.exit(f"ERROR: --settings-h '{args.settings_h}' " f"does not exist.") defines_text = capture_macros( @@ -379,7 +449,7 @@ def main(): "--user-settings, or --source-only.") if not args.no_scrub and not args.source_only: defines_text = scrub_defines(defines_text) - fd, defines_path = tempfile.mkstemp(prefix="wolfglass-defs-", suffix=".h") + fd, defines_path = tempfile.mkstemp(prefix="wolfssl-compliance-tooling-defs-", suffix=".h") os.close(fd) tmp_files.append(defines_path) with open(defines_path, "w", encoding="utf-8") as f: @@ -391,11 +461,18 @@ def main(): cmd += ["--license-override", args.license_override] if args.license_text: cmd += ["--license-text", args.license_text] + if args.document_namespace and gen_sbom_supports( + args.python, gen_sbom, "--document-namespace"): + cmd += ["--document-namespace", args.document_namespace] if args.component_type and gen_sbom_supports( args.python, gen_sbom, "--component-type"): cmd += ["--component-type", args.component_type] + if args.copyright_start and gen_sbom_supports( + args.python, gen_sbom, "--copyright-start"): + cmd += ["--copyright-start", args.copyright_start] + # Dependencies, only if the generator supports them. if args.dep_wolfssl and gen_sbom_supports(args.python, gen_sbom, "--dep-wolfssl"): cmd += ["--dep-wolfssl", args.dep_wolfssl] @@ -404,6 +481,8 @@ def main(): cmd += ["--dep-wolfcrypt", args.dep_wolfcrypt] if args.dep_openssl and gen_sbom_supports(args.python, gen_sbom, "--dep-openssl"): cmd += ["--dep-openssl", args.dep_openssl] + if args.dep_libz and gen_sbom_supports(args.python, gen_sbom, "--dep-libz"): + cmd += ["--dep-libz", args.dep_libz] if args.dep_version and gen_sbom_supports(args.python, gen_sbom, "--dep-version"): for dv in args.dep_version: cmd += ["--dep-version", dv] diff --git a/tools/sbom/sbom.am b/tools/sbom/sbom.am index 32b4a605fc..c54906d80e 100644 --- a/tools/sbom/sbom.am +++ b/tools/sbom/sbom.am @@ -5,7 +5,7 @@ # sets a few variables (below) and does `include tools/sbom/sbom.am` to get the # `sbom`, `install-sbom` and `uninstall-sbom` targets. # -# This is the canonical copy in wolfGlass. Product repositories vendor it +# This is the canonical copy in wolfssl-compliance-tooling. Product repositories vendor it # together with the sibling `gen-sbom`, so the SBOM path works offline with no # build-time dependency on a separate wolfSSL checkout. # @@ -29,12 +29,34 @@ # SBOM_DEP_WOLFSSL yes | no - record wolfSSL as a dependency. Default: no. # SBOM_DEP_OPENSSL yes | no - record OpenSSL as a dependency (wolfProvider / # wolfEngine). Default: no. +# SBOM_DEP_LIBZ yes | no - record zlib. Pass the product's real +# option. Default: unset, so gen-sbom keeps its own +# default of no. +# SBOM_DEP_VERSIONS Space-separated KEY=VERSION list, forwarded as +# repeated --dep-version flags (for example +# libz=1.3.1). Use this when the host has no +# pkg-config file for the dependency. +# SBOM_LIB_OVERRIDE Absolute path of the library to hash. When set, the +# recipe does not run a private `make install`. A traced +# build (bomsh) sets this so the SBOM hash and the +# OmniBOR gitoid name the same file. A later install +# relinks a different file. +# SBOM_DOCUMENT_NAMESPACE SPDX documentNamespace. Default: the +# generator's deterministic urn:uuid. # SBOM_LICENSE_OVERRIDE SPDX expression to record instead of the licence # detected from SBOM_LICENSE_FILE. # SBOM_LICENSE_TEXT Path to licence text for any LicenseRef-* used in # SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). -# SBOM_WOLFSSL_VERSION Version recorded for the wolfSSL dependency; -# auto-detected from WOLFSSL_DIR/wolfssl/version.h when unset. +# SBOM_COPYRIGHT_START First year of the copyright line. Default: 2006. +# SBOM_WOLFSSL_VERSION Version recorded for the wolfSSL dependency. When +# unset and WOLFSSL_DIR is set, it is read from that tree: +# wolfssl/version.h, else configure.ac (version.h is +# generated, so 'make distclean' removes it). If neither +# is readable the build fails rather than letting gen-sbom +# fall back to pkg-config, which describes the installed +# wolfSSL rather than the tree at WOLFSSL_DIR. With no +# WOLFSSL_DIR there is no tree to consult and gen-sbom's +# pkg-config lookup is the intended source. # SBOM_OPENSSL_VERSION Version recorded for the OpenSSL dependency; # gen-sbom resolves it via pkg-config when unset. # SBOM_CONFIG_H Path to the configure-generated config header to @@ -72,6 +94,8 @@ SBOM_LIB_STEM ?= lib$(SBOM_PKGNAME) SBOM_BIN_NAME ?= $(SBOM_PKGNAME) SBOM_DEP_WOLFSSL ?= no SBOM_DEP_OPENSSL ?= no +# Leave unset so a product that does not know about zlib does not pass the flag. +# wolfSSL sets this from ENABLED_LIBZ (yes or no). SBOM_CONFIG_H ?= $(abs_builddir)/config.h # Host C compiler used to capture the build macros (matching the Make/CMake # paths' HOSTCC), NOT the configured $(CC): on a cross build $(CC) would bake @@ -80,7 +104,7 @@ SBOM_CONFIG_H ?= $(abs_builddir)/config.h # document. Only the -D/-U/-I tokens are fed to it (see the capture below), so # it never sees target arch flags it cannot parse. SBOM_HOSTCC ?= cc -# Directory the wolfGlass tooling (gen-sbom) is vendored into. Products that +# Directory the wolfssl-compliance-tooling tooling (gen-sbom) is vendored into. Products that # vendor elsewhere override this (or SBOM_GEN directly). SBOM_VENDOR_DIR ?= $(srcdir)/tools/sbom @@ -144,27 +168,37 @@ sbom: @set -e; \ _defines=`mktemp $(abs_builddir)/_sbom_defines.XXXXXX`; \ trap 'rm -rf $(abs_builddir)/_sbom_staging "$$_defines"' EXIT INT TERM HUP; \ - $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ sbom_art=""; \ - if test "$(SBOM_ARTIFACT)" = bin; then \ - for art in \ - "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)" \ - "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)".exe; do \ - if test -f "$$art"; then sbom_art="$$art"; break; fi; \ - done; \ + 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_art="$(SBOM_LIB_OVERRIDE)"; \ else \ - for art in \ - $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(SBOM_LIB_GLOBS)) \ - $(addprefix "$(abs_builddir)/_sbom_staging$(bindir)"/,$(SBOM_LIB_STEM).dll $(SBOM_PKGNAME).dll); do \ - if test -f "$$art"; then sbom_art="$$art"; break; fi; \ - done; \ - fi; \ - if test -z "$$sbom_art"; then \ - echo ""; \ - echo "ERROR: no installed $(SBOM_PKGNAME) artifact found for SBOM."; \ - echo " (configure with --enable-shared or --enable-static)"; \ - echo ""; \ - exit 1; \ + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ + if test "$(SBOM_ARTIFACT)" = bin; then \ + for art in \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)" \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)".exe; do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + else \ + for art in \ + $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(SBOM_LIB_GLOBS)) \ + $(addprefix "$(abs_builddir)/_sbom_staging$(bindir)"/,$(SBOM_LIB_STEM).dll $(SBOM_PKGNAME).dll); do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + fi; \ + if test -z "$$sbom_art"; then \ + echo ""; \ + echo "ERROR: no installed $(SBOM_PKGNAME) artifact found for SBOM."; \ + echo " (configure with --enable-shared or --enable-static)"; \ + echo ""; \ + exit 1; \ + fi; \ fi; \ echo "SBOM: hashing $$sbom_art"; \ opts_h="$(SBOM_OPTIONS_H)"; \ @@ -189,9 +223,27 @@ sbom: | $(GREP) -q -- '--dep-wolfssl'; then \ dep_args="$$dep_args --dep-wolfssl yes"; \ wv="$(SBOM_WOLFSSL_VERSION)"; \ - if test -z "$$wv" && test -n "$(WOLFSSL_DIR)" && test -f "$(WOLFSSL_DIR)/wolfssl/version.h"; then \ - wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ - "$(WOLFSSL_DIR)/wolfssl/version.h"`; \ + if test -z "$$wv" && test -n "$(WOLFSSL_DIR)"; then \ + if test -f "$(WOLFSSL_DIR)/wolfssl/version.h"; then \ + wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$(WOLFSSL_DIR)/wolfssl/version.h"`; \ + fi; \ + if test -z "$$wv" && test -f "$(WOLFSSL_DIR)/configure.ac"; then \ + wv=`sed -n 's/^AC_INIT(\[[^]]*\],\[\([^]]*\)\].*/\1/p' \ + "$(WOLFSSL_DIR)/configure.ac" | sed -n 1p`; \ + fi; \ + if test -z "$$wv"; then \ + echo "ERROR: cannot determine the wolfSSL version from"; \ + echo " $(WOLFSSL_DIR)"; \ + echo " Neither wolfssl/version.h (generated by configure, so"; \ + echo " 'make distclean' removes it) nor configure.ac was"; \ + echo " readable. Refusing to fall back to pkg-config: that"; \ + echo " reports the *installed* wolfSSL, which may be a"; \ + echo " different build than WOLFSSL_DIR, and would record a"; \ + echo " wrong version in the SBOM. Re-run configure in that"; \ + echo " tree, or set SBOM_WOLFSSL_VERSION=X.Y.Z explicitly."; \ + exit 1; \ + fi; \ fi; \ if test -n "$$wv"; then \ dep_args="$$dep_args --dep-version wolfssl=$$wv"; \ @@ -213,6 +265,27 @@ sbom: echo " not be listed as a dependency component."; \ fi; \ fi; \ + if test -n "$(SBOM_DEP_LIBZ)"; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-libz'; then \ + dep_args="$$dep_args --dep-libz $(SBOM_DEP_LIBZ)"; \ + fi; \ + fi; \ + if test -n "$(SBOM_DEP_VERSIONS)"; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-version'; then \ + for dv in $(SBOM_DEP_VERSIONS); do \ + dep_args="$$dep_args --dep-version $$dv"; \ + done; \ + fi; \ + fi; \ + ns_arg=""; \ + if test -n "$(SBOM_DOCUMENT_NAMESPACE)"; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--document-namespace'; then \ + ns_arg=--document-namespace; \ + fi; \ + fi; \ $(PYTHON3) "$(SBOM_GEN)" \ --name $(SBOM_PKGNAME) \ --version $(PACKAGE_VERSION) \ @@ -221,8 +294,10 @@ sbom: --options-h "$$opts_h" \ --lib "$$sbom_art" \ $$dep_args \ + $${ns_arg:+$$ns_arg '$(SBOM_DOCUMENT_NAMESPACE)'} \ $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ + $(if $(SBOM_COPYRIGHT_START),--copyright-start '$(SBOM_COPYRIGHT_START)') \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ diff --git a/tools/sbom/validate_sbom.py b/tools/sbom/validate_sbom.py index 6d40df7ad9..e9baedb35f 100755 --- a/tools/sbom/validate_sbom.py +++ b/tools/sbom/validate_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Structural validator for wolfGlass SBOM output. +"""Structural validator for wolfssl-compliance-tooling SBOM output. This is the product-neutral form of wolfBoot's validate_sbom.py. It asserts the essentials that every SBOM route must satisfy, so CI (and humans) can fail fast @@ -143,10 +143,14 @@ def main(argv): try: with open(path) as f: d = json.load(f) - except FileNotFoundError: - fail(path, "file not found") + except OSError as e: + fail(path, f"cannot read file: {e}") except json.JSONDecodeError as e: fail(path, f"invalid JSON: {e}") + except UnicodeDecodeError as e: + fail(path, f"not UTF-8 text: {e}") + if not isinstance(d, dict): + fail(path, "top-level JSON is not an object") if "bomFormat" in d or path.endswith(".cdx.json"): validate_cyclonedx(path, d, args.name_prefix, args.min_properties, args.require_dep_version) diff --git a/tools/scripts/ide-sbom/compdb_sbom.py b/tools/scripts/ide-sbom/compdb_sbom.py index 76045d6a54..ef0eefc27c 100755 --- a/tools/scripts/ide-sbom/compdb_sbom.py +++ b/tools/scripts/ide-sbom/compdb_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for the vendored wolfGlass compdb frontend.""" +"""Compatibility wrapper for the vendored wolfssl-compliance-tooling compdb frontend.""" import os import sys diff --git a/tools/scripts/ide-sbom/iar_sbom.py b/tools/scripts/ide-sbom/iar_sbom.py index 14a19bf1be..08c83a973d 100755 --- a/tools/scripts/ide-sbom/iar_sbom.py +++ b/tools/scripts/ide-sbom/iar_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for the vendored wolfGlass IAR frontend.""" +"""Compatibility wrapper for the vendored wolfssl-compliance-tooling IAR frontend.""" import os import sys diff --git a/tools/scripts/ide-sbom/validate_sbom.py b/tools/scripts/ide-sbom/validate_sbom.py index 73ee41cc4b..3ee5f829bc 100755 --- a/tools/scripts/ide-sbom/validate_sbom.py +++ b/tools/scripts/ide-sbom/validate_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for the vendored wolfGlass SBOM validator.""" +"""Compatibility wrapper for the vendored wolfssl-compliance-tooling SBOM validator.""" import os import sys diff --git a/tools/scripts/ide-sbom/zephyr_sbom.py b/tools/scripts/ide-sbom/zephyr_sbom.py index aaaa91b7dd..f846f47a6a 100755 --- a/tools/scripts/ide-sbom/zephyr_sbom.py +++ b/tools/scripts/ide-sbom/zephyr_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for the vendored wolfGlass Zephyr frontend.""" +"""Compatibility wrapper for the vendored wolfssl-compliance-tooling Zephyr frontend.""" import os import sys diff --git a/tools/scripts/wolfboot-sbom.sh b/tools/scripts/wolfboot-sbom.sh index fe342700c6..6dfe14e4ad 100755 --- a/tools/scripts/wolfboot-sbom.sh +++ b/tools/scripts/wolfboot-sbom.sh @@ -1,6 +1,6 @@ #!/bin/sh # Compatibility wrapper. wolfBoot now vendors the canonical driver from -# wolfGlass under tools/sbom/. +# wolfssl-compliance-tooling under tools/sbom/. set -e SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)