wolfssh/.github/workflows/sbom.yml

336 lines
15 KiB
YAML

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'
default: 'master'
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
# landed on wolfssl master in wolfSSL/wolfssl#10343, so CI tracks master.
- name: Checkout wolfssl (gen-sbom + library source)
uses: actions/checkout@v4
with:
repository: wolfSSL/wolfssl
ref: ${{ github.event.inputs.wolfssl_ref || 'master' }}
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 is on wolfssl master (wolfSSL/wolfssl#10343). On master its
# absence, or a gen-sbom too old to support --dep-wolfssl, is a real
# regression: fail rather than skip, so a green run always means
# `make sbom` was actually exercised. Only a deliberately pinned older
# wolfssl_ref, which may predate the script, degrades to a skip.
- name: Detect gen-sbom availability and capabilities
id: gate
env:
WOLFSSL_REF: ${{ github.event.inputs.wolfssl_ref || 'master' }}
run: |
GS="$GITHUB_WORKSPACE/wolfssl/scripts/gen-sbom"
if [ ! -f "$GS" ]; then
if [ "$WOLFSSL_REF" = master ]; then
echo "::error::wolfssl scripts/gen-sbom is missing on master; it landed in wolfSSL/wolfssl#10343, so its absence is a regression. Failing instead of skipping SBOM generation."
exit 1
fi
echo "have=no" >> "$GITHUB_OUTPUT"
echo "::notice::wolfssl scripts/gen-sbom not present on pinned ref '$WOLFSSL_REF'; skipping SBOM generation. Re-run with wolfssl_ref=master to exercise it."
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
if [ "$WOLFSSL_REF" = master ]; then
echo "::error::gen-sbom on master has no --dep-wolfssl support; that is part of wolfSSL/wolfssl#10343. Failing instead of passing with the wolfssl-dependency assertions skipped."
exit 1
fi
echo "dep_wolfssl=no" >> "$GITHUB_OUTPUT"
echo "::notice::gen-sbom on pinned ref '$WOLFSSL_REF' has no --dep-wolfssl; the wolfssl dependency + name-derived identity assertions will be skipped (ref predates wolfSSL/wolfssl#10343)."
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
env:
WOLFSSL_DIR: ${{ github.workspace }}/wolfssl
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
# The recorded version must be the one from WOLFSSL_DIR. Presence
# alone passed while the version was silently taken from the
# installed wolfSSL instead of the source tree.
import os, re
wd = os.environ['WOLFSSL_DIR']
want = None
vh = os.path.join(wd, 'wolfssl', 'version.h')
if os.path.exists(vh):
m = re.search(r'LIBWOLFSSL_VERSION_STRING\s+"([^"]+)"',
open(vh).read())
want = m and m.group(1)
if not want:
m = re.search(r'^AC_INIT\(\[[^]]*\],\[([^]]*)\]',
open(os.path.join(wd, 'configure.ac')).read(), re.M)
want = m and m.group(1)
assert want, 'could not determine expected wolfSSL version'
got = pkgs['wolfssl'].get('versionInfo')
assert got == want, f'SBOM says wolfssl {got!r}, WOLFSSL_DIR says {want!r}'
print(f'wolfssl dependency + wolfssh identity ok (version {got})')
PY
# The runner installs wolfssl from the very tree it passes as
# WOLFSSL_DIR, so pkg-config and wolfssl/version.h always agree and a
# version taken from the wrong source is indistinguishable from the
# right one. Force them apart: a stub pkg-config reports a sentinel,
# and a distclean'd copy of the tree has no generated version.h, so the
# configure.ac fallback is the only path to a correct answer. This is
# the shape that reached a user -- `make sbom` reported the installed
# wolfSSL's version and exited 0.
- name: Version comes from WOLFSSL_DIR, not the installed wolfSSL
if: steps.gate.outputs.have == 'yes' && steps.gate.outputs.dep_wolfssl == 'yes'
env:
SENTINEL: 0.0.0-sentinel
run: |
set -euo pipefail
cp -a wolfssl wolfssl-distclean
( cd wolfssl-distclean && make distclean >/dev/null 2>&1 || true )
if [ -f wolfssl-distclean/wolfssl/version.h ]; then
echo "::error::distclean left wolfssl/version.h in place; this test needs it gone to exercise the configure.ac fallback."
exit 1
fi
mkdir -p "$RUNNER_TEMP/stubbin"
REAL_PKGCONFIG="$(command -v pkg-config)"
cat > "$RUNNER_TEMP/stubbin/pkg-config" <<STUB
#!/bin/bash
if [ "\$1" = --modversion ] && [ "\$2" = wolfssl ]; then
echo "$SENTINEL"; exit 0
fi
exec "$REAL_PKGCONFIG" "\$@"
STUB
chmod +x "$RUNNER_TEMP/stubbin/pkg-config"
test "$(PATH="$RUNNER_TEMP/stubbin:$PATH" pkg-config --modversion wolfssl)" = "$SENTINEL"
cd wolfssh
PATH="$RUNNER_TEMP/stubbin:$PATH" \
make sbom WOLFSSL_DIR="$GITHUB_WORKSPACE/wolfssl-distclean"
python3 - <<'PY'
import glob, json, os, re
d = json.load(open(glob.glob('wolfssh-*.spdx.json')[0]))
pkgs = {p['name']: p for p in d['packages']}
got = pkgs['wolfssl'].get('versionInfo')
sentinel = os.environ['SENTINEL']
assert got != sentinel, (
f'SBOM recorded {got!r}: the version came from pkg-config, i.e. '
'the installed wolfSSL, not WOLFSSL_DIR')
src = open(os.path.join(os.environ['GITHUB_WORKSPACE'],
'wolfssl-distclean', 'configure.ac')).read()
want = re.search(r'^AC_INIT\(\[[^]]*\],\[([^]]*)\]', src, re.M).group(1)
assert got == want, f'SBOM says {got!r}, configure.ac says {want!r}'
print(f'version resolved from WOLFSSL_DIR via configure.ac fallback: {got}')
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