diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 4419da0ca..ecc6251a4 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -144,10 +144,11 @@ jobs: if-no-files-found: warn retention-days: 90 - # tools/sbom/ is a vendored copy of the wolfGlass share/ set, pinned by - # tools/sbom/.wolfglass-rev. wolfglass-sync --check fails when the copy - # differs from that revision. - wolfglass_drift: + # tools/sbom/ is a vendored copy of the wolfssl-compliance-tooling share/ + # set, pinned by tools/sbom/.wolfssl-compliance-tooling-rev. + # wolfssl-compliance-tooling-sync --check fails when the copy differs from + # that revision. + compliance_tooling_drift: runs-on: ubuntu-latest container: image: ghcr.io/wolfssl/wolftpm-ci:v1.1 @@ -161,36 +162,36 @@ jobs: with: submodules: true - - 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" - - name: Check out wolfGlass at the pinned revision + - 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 . diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f4ca39eb..2b70fcb2d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1047,9 +1047,10 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/wolftpm/ # SBOM generation target #################################################### # -# The generator is the wolfGlass snapshot in tools/sbom/. WOLFSSL_DIR is -# optional: when set to a wolfSSL source tree, the dependency version is read -# from wolfssl/version.h. Otherwise the driver uses pkg-config. +# The generator is the wolfssl-compliance-tooling snapshot in tools/sbom/. +# WOLFSSL_DIR is optional: when set to a wolfSSL source tree, the dependency +# version is read from wolfssl/version.h. Otherwise the driver uses +# pkg-config. # # Usage: # cmake -B build . @@ -1154,7 +1155,7 @@ if(BUILD_WOLFTPM_LIB) if(NOT _sbom_wolfssl_version STREQUAL "") list(APPEND _sbom_args DEP_VERSION "wolfssl=${_sbom_wolfssl_version}") endif() - wolfglass_add_sbom(${_sbom_args}) + wolfssl_compliance_tooling_add_sbom(${_sbom_args}) # Stage the installed library before the driver hashes it, matching # the autotools `make install` into _sbom_staging. The public `sbom` diff --git a/Makefile.am b/Makefile.am index 45ecf6f78..bdde0ffba 100644 --- a/Makefile.am +++ b/Makefile.am @@ -134,9 +134,10 @@ cppcheck: --error-exitcode=89 --std=c89 \ -I wolftpm src/ hal/ examples -# SBOM generation. The recipe and generator are the vendored wolfGlass -# snapshot in tools/sbom/ (pinned by tools/sbom/.wolfglass-rev). wolfTPM -# declares itself here: a libwolftpm library that links wolfSSL. +# SBOM generation. The recipe and generator are the vendored +# wolfssl-compliance-tooling snapshot in tools/sbom/ (pinned by +# tools/sbom/.wolfssl-compliance-tooling-rev). wolfTPM declares itself here: +# a libwolftpm library that links wolfSSL. # WOLFSSL_DIR is optional. When it points at a wolfSSL source tree, the # recipe reads wolfssl/version.h for the dependency version. Otherwise # gen-sbom uses pkg-config. @@ -162,11 +163,16 @@ EXTRA_DIST += tools/sbom/sbom.am EXTRA_DIST += tools/sbom/validate_sbom.py EXTRA_DIST += tools/sbom/README.md EXTRA_DIST += tools/sbom/VERSION -EXTRA_DIST += tools/sbom/.wolfglass-rev +EXTRA_DIST += tools/sbom/.wolfssl-compliance-tooling-rev +EXTRA_DIST += tools/sbom/build/install-sbom.cmake +EXTRA_DIST += tools/sbom/build/sbom-paths.cmake +EXTRA_DIST += tools/sbom/build/sbom-with-sde.cmake EXTRA_DIST += tools/sbom/build/sbom.cmake EXTRA_DIST += tools/sbom/build/sbom.mk +EXTRA_DIST += tools/sbom/build/uninstall-sbom.cmake EXTRA_DIST += tools/sbom/frontends/compdb_sbom.py EXTRA_DIST += tools/sbom/frontends/iar_sbom.py +EXTRA_DIST += tools/sbom/frontends/included_sources.py EXTRA_DIST += tools/sbom/frontends/zephyr_sbom.py include tools/sbom/sbom.am diff --git a/docs/ja/sbom-and-compliance.md b/docs/ja/sbom-and-compliance.md index 9faef7e72..2abe54c0c 100644 --- a/docs/ja/sbom-and-compliance.md +++ b/docs/ja/sbom-and-compliance.md @@ -4,7 +4,7 @@ wolfTPM は、EU サイバーレジリエンス法 (CRA) への準拠を支援 ## SBOM と EU CRA コンプライアンス -wolfTPM は CycloneDX 1.6 および SPDX 2.3 形式で SBOM を生成します。ジェネレータは `tools/sbom/` にベンダリングされた wolfGlass のスナップショットで、`tools/sbom/.wolfglass-rev` で固定されています。SBOM には、設定されたビルドオプション (`wolftpm/options.h` から取得)、ビルドされた `libwolftpm` ライブラリ成果物 (共有または静的、ELF、Mach-O、PE) のハッシュ、および依存関係としての wolfSSL が記録されるため、脆弱性スキャナーは wolfSSL のアドバイザリを wolfTPM のデプロイメントに関連付けることができます。 +wolfTPM は CycloneDX 1.6 および SPDX 2.3 形式で SBOM を生成します。ジェネレータは `tools/sbom/` にベンダリングされた wolfssl-compliance-tooling のスナップショットで、`tools/sbom/.wolfssl-compliance-tooling-rev` で固定されています。SBOM には、設定されたビルドオプション (`wolftpm/options.h` から取得)、ビルドされた `libwolftpm` ライブラリ成果物 (共有または静的、ELF、Mach-O、PE) のハッシュ、および依存関係としての wolfSSL が記録されるため、脆弱性スキャナーは wolfSSL のアドバイザリを wolfTPM のデプロイメントに関連付けることができます。 出力は再現可能です。`SOURCE_DATE_EPOCH` を設定する (または最後のコミット時刻が使用される git チェックアウトからビルドする) と、繰り返し実行しても出力はバイト単位で同一になります。 @@ -48,7 +48,7 @@ CRA に関するさらなるガイダンスは [wolfssl/doc/CRA.md](https://gith ## ジェネレータの内部構成 -wolfGlass のうち、ベンダリング可能なのは `share/` セットのみです。`tools/wolfglass-sync` スクリプトは、これらのファイルを、固定用ファイル (`VERSION` と `.wolfglass-rev`) とともに、製品内の `tools/sbom/` にコピーします。コピーするのはファイルであり、`share/` フォルダ名ではありません。 +wolfssl-compliance-tooling のうち、ベンダリング可能なのは `share/` セットのみです。`tools/wolfssl-compliance-tooling-sync` スクリプトは、これらのファイルを、固定用ファイル (`VERSION` と `.wolfssl-compliance-tooling-rev`) とともに、製品内の `tools/sbom/` にコピーします。コピーするのはファイルであり、`share/` フォルダ名ではありません。 ### 内容 @@ -60,8 +60,8 @@ wolfGlass のうち、ベンダリング可能なのは `share/` セットのみ | `frontends/compdb_sbom.py` | 任意の `compile_commands.json` に対するエクストラクタ。 | | `frontends/iar_sbom.py` | IAR Embedded Workbench の `.ewp` に対するエクストラクタ。 | | `frontends/zephyr_sbom.py` | Zephyr モジュールの `CMakeLists.txt` に対するエクストラクタ。 | -| `build/sbom.mk` | 共有のプレーン Make フラグメントと `wolfglass_sbom_rule` マクロ。 | -| `build/sbom.cmake` | 共有の CMake ヘルパー: `wolfglass_add_sbom()`。 | +| `build/sbom.mk` | 共有のプレーン Make フラグメントと `wolfssl_compliance_tooling_sbom_rule` マクロ。 | +| `build/sbom.cmake` | 共有の CMake ヘルパー: `wolfssl_compliance_tooling_add_sbom()`。 | | `gen-sbom` | ベンダリングされた SBOM ジェネレータ。 | | `sbom.am` | 共有の autotools フラグメント。 | @@ -92,8 +92,8 @@ wolfGlass のうち、ベンダリング可能なのは `share/` セットのみ 製品はロジックをコピーしません。自身を記述します。 -- **Make:** `SBOM_NAME`、`SBOM_SRCS`、`SBOM_CFLAGS`、およびバージョン (`SBOM_VERSION`、または `SBOM_VERSION_FILE` と `SBOM_VERSION_MACRO`) を設定し、`include tools/sbom/build/sbom.mk` します。2 つ目のターゲットには `$(eval $(call wolfglass_sbom_rule,,))` をインスタンス化します。製品の構成が `user_settings.h` にある場合は、`SBOM_SETTINGS_H` も設定します (そのヘッダーが CFLAGS にまだ含まれていないパスを必要とする場合は `SBOM_INCLUDE_DIRS` も設定)。`SBOM_CFLAGS` だけでは、文字どおりの `-D` の集合のみが記録され、そこから派生するものは記録されません。ゲートされたヘッダーでは、SBOM が誰もビルドしていない構成を記述してしまうことになります。 -- **CMake:** `include(tools/sbom/build/sbom.cmake)` し、`NAME`、`VERSION_FILE`、`VERSION_MACRO`、`TARGETS`、`DEFS`、`LICENSE` を指定して `wolfglass_add_sbom()` を呼び出します。`SBOM_GEN` が正式なジェネレータ上書き用の変数です。`GEN_SBOM` は互換性のため、従来のエイリアスとして残されています。 +- **Make:** `SBOM_NAME`、`SBOM_SRCS`、`SBOM_CFLAGS`、およびバージョン (`SBOM_VERSION`、または `SBOM_VERSION_FILE` と `SBOM_VERSION_MACRO`) を設定し、`include tools/sbom/build/sbom.mk` します。2 つ目のターゲットには `$(eval $(call wolfssl_compliance_tooling_sbom_rule,,))` をインスタンス化します。製品の構成が `user_settings.h` にある場合は、`SBOM_SETTINGS_H` も設定します (そのヘッダーが CFLAGS にまだ含まれていないパスを必要とする場合は `SBOM_INCLUDE_DIRS` も設定)。`SBOM_CFLAGS` だけでは、文字どおりの `-D` の集合のみが記録され、そこから派生するものは記録されません。ゲートされたヘッダーでは、SBOM が誰もビルドしていない構成を記述してしまうことになります。 +- **CMake:** `include(tools/sbom/build/sbom.cmake)` し、`NAME`、`VERSION_FILE`、`VERSION_MACRO`、`TARGETS`、`DEFS`、`LICENSE` を指定して `wolfssl_compliance_tooling_add_sbom()` を呼び出します。`SBOM_GEN` が正式なジェネレータ上書き用の変数です。`GEN_SBOM` は互換性のため、従来のエイリアスとして残されています。 - **Autotools:** `SBOM_*` 変数を設定し、`include tools/sbom/sbom.am` します。 製品に残すのは、真に製品固有の知識のみです。具体的には、ルーティング用スクリプト、モジュールエクストラクタ、HAL ソースセレクタです。 diff --git a/docs/sbom-and-compliance.md b/docs/sbom-and-compliance.md index 1331c49fd..895f08255 100644 --- a/docs/sbom-and-compliance.md +++ b/docs/sbom-and-compliance.md @@ -4,7 +4,7 @@ wolfTPM can generate a Software Bill of Materials (SBOM) to support compliance w ## SBOM and EU CRA compliance -wolfTPM generates an SBOM in CycloneDX 1.6 and SPDX 2.3 formats. The generator is the wolfGlass snapshot vendored in `tools/sbom/` and pinned by `tools/sbom/.wolfglass-rev`. The SBOM records the configured build options (from `wolftpm/options.h`), hashes the built `libwolftpm` library artifact (shared or static; ELF, Mach-O, or PE), and lists wolfSSL as a dependency so vulnerability scanners can associate wolfSSL advisories with a wolfTPM deployment. +wolfTPM generates an SBOM in CycloneDX 1.6 and SPDX 2.3 formats. The generator is the wolfssl-compliance-tooling snapshot vendored in `tools/sbom/` and pinned by `tools/sbom/.wolfssl-compliance-tooling-rev`. The SBOM records the configured build options (from `wolftpm/options.h`), hashes the built `libwolftpm` library artifact (shared or static; ELF, Mach-O, or PE), and lists wolfSSL as a dependency so vulnerability scanners can associate wolfSSL advisories with a wolfTPM deployment. Output is reproducible. Set `SOURCE_DATE_EPOCH` (or build from a git checkout, which uses the last commit time) and repeated runs are byte-identical. @@ -48,7 +48,7 @@ For further CRA guidance see [wolfssl/doc/CRA.md](https://github.com/wolfSSL/wol ## Generator internals -The `share/` set of wolfGlass is the only vendorable part. The `tools/wolfglass-sync` script copies these files into a product at `tools/sbom/`, together with the pin files (`VERSION` and `.wolfglass-rev`). Copy the files, not the `share/` folder name. +The `share/` set of wolfssl-compliance-tooling is the only vendorable part. The `tools/wolfssl-compliance-tooling-sync` script copies these files into a product at `tools/sbom/`, together with the pin files (`VERSION` and `.wolfssl-compliance-tooling-rev`). Copy the files, not the `share/` folder name. ### Contents @@ -60,8 +60,8 @@ The `share/` set of wolfGlass is the only vendorable part. The `tools/wolfglass- | `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()`. | +| `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. | @@ -92,8 +92,8 @@ The shared driver is product-neutral and calls the vendored `share/gen-sbom` by 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` and `SBOM_VERSION_MACRO`), then `include tools/sbom/build/sbom.mk`. For a second target, instantiate `$(eval $(call wolfglass_sbom_rule,,))`. If the product 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()` with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. `SBOM_GEN` is the canonical generator override. `GEN_SBOM` remains a legacy alias for compatibility. +- **Make:** set `SBOM_NAME`, `SBOM_SRCS`, `SBOM_CFLAGS`, and a version (`SBOM_VERSION`, or `SBOM_VERSION_FILE` and `SBOM_VERSION_MACRO`), then `include tools/sbom/build/sbom.mk`. For a second target, instantiate `$(eval $(call wolfssl_compliance_tooling_sbom_rule,,))`. If the product 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 `wolfssl_compliance_tooling_add_sbom()` with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. `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`. 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/.wolfglass-rev b/tools/sbom/.wolfglass-rev deleted file mode 100644 index bed9f2f7c..000000000 --- a/tools/sbom/.wolfglass-rev +++ /dev/null @@ -1 +0,0 @@ -f83b39c925bb3716e8d8bba4198919c0304c017e diff --git a/tools/sbom/.wolfssl-compliance-tooling-rev b/tools/sbom/.wolfssl-compliance-tooling-rev new file mode 100644 index 000000000..c3fb5c42b --- /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 eb6bc4076..b88d114df 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 000000000..9afbc7860 --- /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 000000000..97d5ab341 --- /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 000000000..cdd12cf34 --- /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 2eddda3f5..ae9b309e2 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 eb13232d7..79882d45e 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 000000000..0354c6dad --- /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 7727d79fa..11eaddfec 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 788bdff49..5aa6b3a08 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 000000000..2097a20ac --- /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 0f8043511..312f0740e 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 d2322cf62..9ec1971fe 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 20d81371d..8837615b2 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 32b4a605f..c54906d80 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 6d40df7ad..e9baedb35 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)