Skip to content

Commit 6ce2ec3

Browse files
committed
test(sbom): unit tests and CI integration workflow
Cover gen-sbom helpers and add a workflow that validates SPDX/CDX, NTIA conformance, reproducibility, and the licence-override matrix. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
1 parent 9373630 commit 6ce2ec3

3 files changed

Lines changed: 509 additions & 2 deletions

File tree

.github/workflows/sbom.yml

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
name: SBOM Tests
2+
3+
# START OF COMMON SECTION
4+
on:
5+
push:
6+
branches: [ 'master', 'main', 'release/**' ]
7+
pull_request:
8+
branches: [ '*' ]
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
# END OF COMMON SECTION
14+
15+
jobs:
16+
# Tier 1 - pure-Python unit tests for scripts/gen-sbom.
17+
# No build, no autotools, no external deps. Runs in seconds and is the
18+
# cheapest gate for licence/UUID/timestamp logic regressions.
19+
unit:
20+
name: gen-sbom unit tests
21+
if: github.repository_owner == 'wolfssl'
22+
runs-on: ubuntu-24.04
23+
timeout-minutes: 5
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- name: Syntax check
28+
run: python3 -m py_compile scripts/gen-sbom
29+
30+
- name: Unit tests
31+
run: python3 -W error::ResourceWarning -m unittest scripts/test_gen_sbom.py -v
32+
33+
# Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert
34+
# everything an external auditor or vulnerability scanner relies on.
35+
integration:
36+
name: SBOM integration
37+
if: github.repository_owner == 'wolfssl'
38+
runs-on: ubuntu-24.04
39+
needs: unit
40+
timeout-minutes: 20
41+
steps:
42+
- uses: actions/checkout@v4
43+
44+
# Pin tool versions; drift in any of these silently changes what
45+
# "valid" means and produces mystery CI failures.
46+
- name: Install SBOM validators
47+
run: |
48+
python3 -m pip install --user --upgrade pip
49+
python3 -m pip install --user \
50+
'spdx-tools==0.8.*' \
51+
'ntia-conformance-checker==5.*' \
52+
'cyclonedx-bom==7.*'
53+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
54+
55+
- name: Configure wolfSSL (shared + static)
56+
run: autoreconf -ivf && ./configure --enable-shared --enable-static
57+
58+
- name: Build + generate SBOM (default GPL)
59+
run: make sbom
60+
61+
# ---- Format-level validators -----------------------------------------
62+
63+
- name: SPDX 2.3 - NTIA Minimum Elements (2021)
64+
# Already validated structurally by pyspdxtools inside `make sbom`.
65+
# NTIA conformance is the additional contract auditors rely on.
66+
run: ntia-checker -c ntia wolfssl-*.spdx.json
67+
68+
- name: CycloneDX 1.6 - JSON schema validation
69+
run: |
70+
python3 - <<'PY'
71+
import glob, sys
72+
from cyclonedx.validation.json import JsonStrictValidator
73+
from cyclonedx.schema import SchemaVersion
74+
v = JsonStrictValidator(SchemaVersion.V1_6)
75+
for path in glob.glob('wolfssl-*.cdx.json'):
76+
errors = v.validate_str(open(path).read())
77+
if errors:
78+
print(f"INVALID: {path}: {errors}", file=sys.stderr)
79+
sys.exit(1)
80+
print(f"OK: {path}")
81+
PY
82+
83+
# ---- Artefact-integrity assertions ----------------------------------
84+
85+
- name: Library hash matches the SBOM
86+
# `make sbom` cleans its private staging tree on exit, so we install
87+
# to an independent prefix and re-hash the resulting library. The
88+
# autotools install is deterministic (identical bytes), so the hash
89+
# the SBOM recorded must match.
90+
run: |
91+
rm -rf /tmp/_inst
92+
make install DESTDIR=/tmp/_inst >/dev/null
93+
LIB=$(ls /tmp/_inst/usr/local/lib/libwolfssl.so* 2>/dev/null \
94+
| grep -v '\.la$' | head -1)
95+
test -n "$LIB" || (echo "no installed shared lib"; exit 1)
96+
EXPECTED=$(sha256sum "$LIB" | cut -d' ' -f1)
97+
ACTUAL=$(python3 -c "
98+
import json, glob
99+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
100+
p = [x for x in d['packages'] if x['name'] == 'wolfssl'][0]
101+
print(p['checksums'][0]['checksumValue'])")
102+
test "$EXPECTED" = "$ACTUAL" || \
103+
{ echo "hash mismatch: expected=$EXPECTED actual=$ACTUAL"; exit 1; }
104+
105+
- name: CPE 2.3 and PURL identifiers well-formed
106+
# A typo in supplier or product name silently breaks every
107+
# downstream OSV / Trivy / Grype scan.
108+
run: |
109+
python3 - <<'PY'
110+
import glob, json, re, sys
111+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
112+
refs = {r['referenceType']: r['referenceLocator']
113+
for r in d['packages'][0]['externalRefs']}
114+
assert re.match(r'cpe:2\.3:a:wolfssl:wolfssl:[\d.]+:', refs['cpe23Type']), refs
115+
assert re.match(r'pkg:generic/wolfssl@[\d.]+', refs['purl']), refs
116+
print('identifiers ok:', refs)
117+
PY
118+
119+
# ---- Reproducibility -------------------------------------------------
120+
121+
- name: Reproducibility under SOURCE_DATE_EPOCH
122+
run: |
123+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
124+
SOURCE_DATE_EPOCH=1700000000 make sbom
125+
sha256sum wolfssl-*.cdx.json wolfssl-*.spdx.json > /tmp/a.sums
126+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
127+
SOURCE_DATE_EPOCH=1700000000 make sbom
128+
sha256sum wolfssl-*.cdx.json wolfssl-*.spdx.json > /tmp/b.sums
129+
diff /tmp/a.sums /tmp/b.sums
130+
131+
# ---- Licence-override matrix ----------------------------------------
132+
133+
- name: License matrix - default GPL
134+
# Detected from LICENSING. The current upstream file reads
135+
# "GNU General Public License version 3" without "or later", so
136+
# detect_license returns GPL-3.0-only. If LICENSING is updated to
137+
# add "or any later version", switch this assertion to
138+
# GPL-3.0-or-later.
139+
run: |
140+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
141+
make sbom
142+
python3 - <<'PY'
143+
import glob, json
144+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
145+
assert d['packages'][0]['licenseConcluded'].startswith('GPL-3.0-'), \
146+
d['packages'][0]['licenseConcluded']
147+
assert 'hasExtractedLicensingInfos' not in d
148+
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
149+
lic = cdx['metadata']['component']['licenses']
150+
assert lic == [{'license': {'id': d['packages'][0]['licenseConcluded']}}], lic
151+
print('default GPL: ok ->', lic)
152+
PY
153+
154+
- name: License matrix - LicenseRef + text
155+
run: |
156+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
157+
make sbom \
158+
SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \
159+
SBOM_LICENSE_TEXT="$PWD/COPYING"
160+
python3 - <<'PY'
161+
import glob, json
162+
expected = open('COPYING').read()
163+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
164+
infos = d['hasExtractedLicensingInfos']
165+
assert len(infos) == 1
166+
assert infos[0]['licenseId'] == 'LicenseRef-wolfSSL-Commercial'
167+
assert infos[0]['extractedText'] == expected
168+
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
169+
lic = cdx['metadata']['component']['licenses'][0]['license']
170+
assert lic['name'] == 'LicenseRef-wolfSSL-Commercial'
171+
assert lic['text']['content'] == expected
172+
print('LicenseRef + text: ok')
173+
PY
174+
# The output of this run must still pass NTIA and CDX validators.
175+
ntia-checker -c ntia wolfssl-*.spdx.json
176+
python3 -c "
177+
from cyclonedx.validation.json import JsonStrictValidator
178+
from cyclonedx.schema import SchemaVersion
179+
import glob, sys
180+
v = JsonStrictValidator(SchemaVersion.V1_6)
181+
errs = v.validate_str(open(glob.glob('wolfssl-*.cdx.json')[0]).read())
182+
sys.exit(1 if errs else 0)"
183+
184+
- name: License matrix - compound expression
185+
run: |
186+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
187+
make sbom \
188+
SBOM_LICENSE_OVERRIDE='GPL-3.0-only OR LicenseRef-wolfSSL-Commercial' \
189+
SBOM_LICENSE_TEXT="$PWD/COPYING"
190+
python3 - <<'PY'
191+
import glob, json
192+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
193+
assert len(d['hasExtractedLicensingInfos']) == 1
194+
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
195+
entry = cdx['metadata']['component']['licenses'][0]
196+
assert 'expression' in entry, entry
197+
print('compound expression: ok')
198+
PY
199+
200+
- name: License matrix - simple SPDX override
201+
run: |
202+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
203+
make sbom SBOM_LICENSE_OVERRIDE=Apache-2.0
204+
python3 - <<'PY'
205+
import glob, json
206+
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
207+
assert 'hasExtractedLicensingInfos' not in d
208+
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
209+
lic = cdx['metadata']['component']['licenses'][0]['license']
210+
assert lic == {'id': 'Apache-2.0'}, lic
211+
print('simple SPDX override: ok')
212+
PY
213+
214+
# ---- Distribution + install hooks -----------------------------------
215+
216+
- name: Tarball roundtrip (make dist -> ./configure -> make sbom)
217+
# If a future change adds a new helper file but forgets EXTRA_DIST,
218+
# the tarball will not contain it and this step fails.
219+
run: |
220+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
221+
make dist
222+
mkdir /tmp/tb
223+
tar -xzf wolfssl-*.tar.gz -C /tmp/tb
224+
cd /tmp/tb/wolfssl-*
225+
./configure --enable-shared
226+
make sbom
227+
228+
- name: Install-sbom / uninstall hook
229+
# `install-sbom` is a separate target (intentional - SBOM generation
230+
# has heavy deps like pyspdxtools that we do not want firing on
231+
# every `make install`). `make uninstall` runs uninstall-hook,
232+
# which removes both regular and SBOM artefacts idempotently.
233+
run: |
234+
rm -rf /tmp/_inst2
235+
make install DESTDIR=/tmp/_inst2 >/dev/null
236+
make install-sbom DESTDIR=/tmp/_inst2
237+
ls /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx.json \
238+
/tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.cdx.json \
239+
/tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx
240+
make uninstall DESTDIR=/tmp/_inst2
241+
if ls /tmp/_inst2/usr/local/share/doc/wolfssl/wolfssl-*.spdx.json \
242+
2>/dev/null; then
243+
echo "uninstall-hook did not remove SBOM artefacts"
244+
exit 1
245+
fi

scripts/gen-sbom

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ def detect_license(license_file):
167167
prints a warning if the file cannot be parsed.
168168
"""
169169
try:
170-
text = open(license_file).read()
170+
with open(license_file) as f:
171+
text = f.read()
171172
except OSError as e:
172173
print(f"WARNING: cannot read license file {license_file}: {e}",
173174
file=sys.stderr)
@@ -247,7 +248,8 @@ def parse_options_h(path):
247248
"""Parse wolfssl/options.h and return sorted deduplicated list of
248249
(name, value) pairs for every #define found."""
249250
try:
250-
text = open(path).read()
251+
with open(path) as f:
252+
text = f.read()
251253
except OSError as e:
252254
print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr)
253255
return []

0 commit comments

Comments
 (0)