name: SBOM Test on: push: branches: [ 'master', 'main', 'release/**' ] pull_request: branches: [ '*' ] workflow_dispatch: inputs: wolfssl_ref: description: 'wolfssl git ref that provides scripts/gen-sbom' # TODO: switch back to 'master' once wolfSSL/wolfssl#10343 merges. default: 'refs/pull/10343/head' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # This workflow only reads the repo and uploads artefacts; no API writes. permissions: contents: read jobs: sbom: name: wolfSSH SBOM generation (linux) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout wolfssh uses: actions/checkout@v4 with: path: wolfssh # wolfssl is checked out once and used for two things: built + installed # so wolfssh has a library to link, and its source tree (scripts/gen-sbom # + wolfssl/version.h) is passed to `make sbom` via WOLFSSL_DIR. gen-sbom # is not yet on wolfssl master, so default to the open PR head that carries # it (wolfSSL/wolfssl#10343) so CI actually exercises `make sbom` instead of # silently skipping. TODO: switch the fallback back to 'master' once # #10343 merges. - name: Checkout wolfssl (gen-sbom + library source) uses: actions/checkout@v4 with: repository: wolfSSL/wolfssl ref: ${{ github.event.inputs.wolfssl_ref || 'refs/pull/10343/head' }} path: wolfssl - name: Install SBOM validator (pyspdxtools) and pcpp run: | # spdx-tools -> pyspdxtools (validation); pcpp -> the embedded # (--user-settings) path's C preprocessor for walking user_settings.h. python3 -m pip install --user 'spdx-tools==0.8.*' pcpp echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Build and install wolfssl working-directory: wolfssl run: | autoreconf -ivf ./configure --enable-all --prefix="$GITHUB_WORKSPACE/wolfssl-install" make -j"$(nproc)" make install # gen-sbom lives in wolfssl and may not be on the checked-out ref yet # (the wolfSSL SBOM change can land separately). Gate on its presence so # this workflow is safe to merge before that: it stays green and simply # skips SBOM generation until a ref that carries the script is used. - name: Detect gen-sbom availability and capabilities id: gate run: | GS="$GITHUB_WORKSPACE/wolfssl/scripts/gen-sbom" if [ ! -f "$GS" ]; then echo "have=no" >> "$GITHUB_OUTPUT" echo "::notice::wolfssl scripts/gen-sbom not present on this ref; skipping SBOM generation. Re-run via 'Run workflow' with wolfssl_ref set to a branch that has it until it merges to wolfssl master." exit 0 fi echo "have=yes" >> "$GITHUB_OUTPUT" if python3 "$GS" --help 2>/dev/null | grep -q -- '--dep-wolfssl'; then echo "dep_wolfssl=yes" >> "$GITHUB_OUTPUT" else echo "dep_wolfssl=no" >> "$GITHUB_OUTPUT" echo "::notice::gen-sbom on this ref has no --dep-wolfssl; the wolfssl dependency + name-derived identity assertions will be skipped (wolfSSL SBOM change not merged yet)." fi - name: Configure and build wolfssh if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | autoreconf -ivf ./configure --with-wolfssl="$GITHUB_WORKSPACE/wolfssl-install" make -j"$(nproc)" - name: Generate SBOM if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: make sbom WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" - name: Outputs exist and SPDX validates if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | ls wolfssh-*.cdx.json wolfssh-*.spdx.json wolfssh-*.spdx pyspdxtools --infile wolfssh-*.spdx.json - name: CycloneDX is valid JSON with expected identity if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | python3 - <<'PY' import glob, json cdx = json.load(open(glob.glob('wolfssh-*.cdx.json')[0])) assert cdx['bomFormat'] == 'CycloneDX', cdx.get('bomFormat') assert cdx['specVersion'] == '1.6', cdx.get('specVersion') m = cdx['metadata']['component'] assert m['name'] == 'wolfssh', m['name'] assert m['purl'].startswith('pkg:github/wolfSSL/wolfssh@'), m['purl'] props = {p['name'] for p in m.get('properties', [])} # The AM_CPPFLAGS/config.h snapshot must capture real build config; # PACKAGE_VERSION always lands from config.h, so its absence means # the options snapshot regressed to empty. assert any(n.startswith('wolfssl:build:') for n in props), \ 'no wolfssl:build:* properties - options snapshot is empty' print('CDX identity ok:', m['name'], m['purl']) PY - name: Reproducible across two runs (SOURCE_DATE_EPOCH) if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | rm -f wolfssh-*.cdx.json wolfssh-*.spdx.json wolfssh-*.spdx SOURCE_DATE_EPOCH=1700000000 make sbom \ WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" sha256sum wolfssh-*.cdx.json wolfssh-*.spdx.json > /tmp/a.sums rm -f wolfssh-*.cdx.json wolfssh-*.spdx.json wolfssh-*.spdx SOURCE_DATE_EPOCH=1700000000 make sbom \ WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl" sha256sum wolfssh-*.cdx.json wolfssh-*.spdx.json > /tmp/b.sums diff /tmp/a.sums /tmp/b.sums - name: wolfssl recorded as a dependency + wolfssh identity if: steps.gate.outputs.have == 'yes' && steps.gate.outputs.dep_wolfssl == 'yes' working-directory: wolfssh run: | python3 - <<'PY' import glob, json d = json.load(open(glob.glob('wolfssh-*.spdx.json')[0])) pkgs = {p['name']: p for p in d['packages']} assert 'wolfssl' in pkgs, list(pkgs) main = pkgs['wolfssh'] assert main['SPDXID'] == 'SPDXRef-Package-wolfssh', main['SPDXID'] assert 'github.com/wolfSSL/wolfssh' in main['downloadLocation'], \ main['downloadLocation'] rels = [(r['spdxElementId'], r['relationshipType'], r['relatedSpdxElement']) for r in d['relationships']] assert ('SPDXRef-Package-wolfssh', 'DEPENDS_ON', 'SPDXRef-Package-wolfssl') in rels, rels print('wolfssl dependency + wolfssh identity ok') PY # ---- Embedded / IDE path (no autotools) ---------------------------- # Firmware customers don't run ./configure: there's no options.h and no # installed libwolfssh to hash. gen-sbom reads config from user_settings.h # (via pcpp) and hashes the wolfSSH source set instead. This exercises # that path the same way the docs tell customers to invoke it. - name: Generate embedded SBOM (user_settings.h + source set) if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | VER=$(sed -n 's/^AC_INIT(\[wolfssh\],\[\([^]]*\)\].*/\1/p' configure.ac) VER=${VER:-0.0.0} mkdir -p sbom-embedded/cfg sbom-embedded-2 # A minimal embedded-style config; the assertions below prove these # #defines survive pcpp and land as build properties in the SBOM. { echo '#ifndef USER_SETTINGS_H' echo '#define USER_SETTINGS_H' echo '#define WOLFSSH_TERM' echo '#define WOLFSSH_SFTP' echo '#define WOLFSSH_SCP' echo '#endif' } > sbom-embedded/cfg/user_settings.h DEP=() if [ "${{ steps.gate.outputs.dep_wolfssl }}" = "yes" ]; then DEP+=(--dep-wolfssl yes) fi gen() { SOURCE_DATE_EPOCH=1700000000 python3 \ "$GITHUB_WORKSPACE/wolfssl/scripts/gen-sbom" \ --name wolfssh --version "$VER" \ --license-file LICENSING \ --user-settings wolfssh/settings.h \ --user-settings-include . \ --user-settings-include "$GITHUB_WORKSPACE/wolfssl" \ --user-settings-include sbom-embedded/cfg \ --user-settings-define WOLFSSL_USER_SETTINGS \ --srcs src/*.c \ "${DEP[@]}" \ --cdx-out "$1/wolfssh-embedded.cdx.json" \ --spdx-out "$1/wolfssh-embedded.spdx.json" } gen sbom-embedded gen sbom-embedded-2 # Reproducible: identical bytes across runs (SOURCE_DATE_EPOCH fixed, # namespace is uuid5(name,version), source-set hash is path-independent). a=$(sha256sum < sbom-embedded/wolfssh-embedded.cdx.json) b=$(sha256sum < sbom-embedded-2/wolfssh-embedded.cdx.json) test "$a" = "$b" || { echo "embedded SBOM not reproducible"; exit 1; } - name: Embedded SBOM validates + reflects user_settings.h + source hash if: steps.gate.outputs.have == 'yes' working-directory: wolfssh run: | pyspdxtools --infile sbom-embedded/wolfssh-embedded.spdx.json python3 - <<'PY' import json cdx = json.load(open('sbom-embedded/wolfssh-embedded.cdx.json')) m = cdx['metadata']['component'] assert m['name'] == 'wolfssh', m['name'] assert m['purl'].startswith('pkg:github/wolfSSL/wolfssh@'), m['purl'] # Embedded identity is the source-set hash (no library artifact exists). algs = {h['alg'] for h in m.get('hashes', [])} assert algs, 'no component hash - source-set (--srcs) hash missing' # Config must come from user_settings.h through pcpp, not be empty. props = {p['name'] for p in m.get('properties', [])} assert any(n.endswith('WOLFSSH_SFTP') for n in props), \ 'user_settings.h option WOLFSSH_SFTP not captured: %r' % sorted(props) print('embedded ok:', m['name'], m['purl'], '| user_settings props:', sorted(n for n in props if 'WOLFSSH' in n)) PY - name: Embedded SBOM records wolfssl dependency if: steps.gate.outputs.have == 'yes' && steps.gate.outputs.dep_wolfssl == 'yes' working-directory: wolfssh run: | python3 - <<'PY' import json d = json.load(open('sbom-embedded/wolfssh-embedded.spdx.json')) assert 'wolfssl' in {p['name'] for p in d['packages']}, \ [p['name'] for p in d['packages']] rels = [(r['spdxElementId'], r['relationshipType'], r['relatedSpdxElement']) for r in d['relationships']] assert ('SPDXRef-Package-wolfssh', 'DEPENDS_ON', 'SPDXRef-Package-wolfssl') in rels, rels print('embedded wolfssl dependency ok') PY - name: Upload SBOM artefacts if: always() && steps.gate.outputs.have == 'yes' uses: actions/upload-artifact@v4 with: name: wolfssh-sbom-${{ github.sha }} path: | wolfssh/wolfssh-*.cdx.json wolfssh/wolfssh-*.spdx.json wolfssh/wolfssh-*.spdx wolfssh/sbom-embedded/wolfssh-embedded.* if-no-files-found: warn retention-days: 90