Skip to content

Commit 271d883

Browse files
committed
test(sbom): regression coverage and macOS CI job
Add tests for the review-driven gen-sbom fixes, tighten file-handle hygiene in the linux integration job, and add a macOS smoke job for .dylib detection. Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
1 parent 3ef8f32 commit 271d883

2 files changed

Lines changed: 176 additions & 37 deletions

File tree

.github/workflows/sbom.yml

Lines changed: 116 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
# Tier 2 - integration: build wolfSSL, generate the SBOMs, and assert
3434
# everything an external auditor or vulnerability scanner relies on.
3535
integration:
36-
name: SBOM integration
36+
name: SBOM integration (linux)
3737
if: github.repository_owner == 'wolfssl'
3838
runs-on: ubuntu-24.04
3939
needs: unit
@@ -52,6 +52,12 @@ jobs:
5252
'cyclonedx-bom==7.*'
5353
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
5454
55+
# Test fixture for the LicenseRef-+text matrix step. Using a fixture
56+
# rather than $PWD/COPYING decouples the test from upstream file
57+
# naming and makes the assertion exact ('FIXTURE LICENCE BODY').
58+
- name: Create license-text fixture
59+
run: echo 'FIXTURE LICENCE BODY' > /tmp/sbom-fixture-licence.txt
60+
5561
- name: Configure wolfSSL (shared + static)
5662
run: autoreconf -ivf && ./configure --enable-shared --enable-static
5763

@@ -73,7 +79,8 @@ jobs:
7379
from cyclonedx.schema import SchemaVersion
7480
v = JsonStrictValidator(SchemaVersion.V1_6)
7581
for path in glob.glob('wolfssl-*.cdx.json'):
76-
errors = v.validate_str(open(path).read())
82+
with open(path) as f:
83+
errors = v.validate_str(f.read())
7784
if errors:
7885
print(f"INVALID: {path}: {errors}", file=sys.stderr)
7986
sys.exit(1)
@@ -84,19 +91,29 @@ jobs:
8491

8592
- name: Library hash matches the SBOM
8693
# `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.
94+
# to an independent prefix and re-hash the resulting library.
95+
# Search order matches gen-sbom's so we hash the same artefact.
9096
run: |
9197
rm -rf /tmp/_inst
9298
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)
99+
LIB=""
100+
for cand in /tmp/_inst/usr/local/lib/libwolfssl.so.[0-9]* \
101+
/tmp/_inst/usr/local/lib/libwolfssl.so \
102+
/tmp/_inst/usr/local/lib/libwolfssl.a; do
103+
if [ -f "$cand" ]; then LIB="$cand"; break; fi
104+
done
105+
test -n "$LIB" || (echo "no installed library found"; exit 1)
106+
EXPECTED=$(python3 -c "
107+
import hashlib, sys
108+
h = hashlib.sha256()
109+
with open(sys.argv[1], 'rb') as f:
110+
for chunk in iter(lambda: f.read(65536), b''):
111+
h.update(chunk)
112+
print(h.hexdigest())" "$LIB")
97113
ACTUAL=$(python3 -c "
98114
import json, glob
99-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
115+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
116+
d = json.load(f)
100117
p = [x for x in d['packages'] if x['name'] == 'wolfssl'][0]
101118
print(p['checksums'][0]['checksumValue'])")
102119
test "$EXPECTED" = "$ACTUAL" || \
@@ -108,7 +125,8 @@ jobs:
108125
run: |
109126
python3 - <<'PY'
110127
import glob, json, re, sys
111-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
128+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
129+
d = json.load(f)
112130
refs = {r['referenceType']: r['referenceLocator']
113131
for r in d['packages'][0]['externalRefs']}
114132
assert re.match(r'cpe:2\.3:a:wolfssl:wolfssl:[\d.]+:', refs['cpe23Type']), refs
@@ -141,11 +159,13 @@ jobs:
141159
make sbom
142160
python3 - <<'PY'
143161
import glob, json
144-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
162+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
163+
d = json.load(f)
145164
assert d['packages'][0]['licenseConcluded'].startswith('GPL-3.0-'), \
146165
d['packages'][0]['licenseConcluded']
147166
assert 'hasExtractedLicensingInfos' not in d
148-
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
167+
with open(glob.glob('wolfssl-*.cdx.json')[0]) as f:
168+
cdx = json.load(f)
149169
lic = cdx['metadata']['component']['licenses']
150170
assert lic == [{'license': {'id': d['packages'][0]['licenseConcluded']}}], lic
151171
print('default GPL: ok ->', lic)
@@ -156,42 +176,67 @@ jobs:
156176
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
157177
make sbom \
158178
SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \
159-
SBOM_LICENSE_TEXT="$PWD/COPYING"
179+
SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt
160180
python3 - <<'PY'
161181
import glob, json
162-
expected = open('COPYING').read()
163-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
182+
with open('/tmp/sbom-fixture-licence.txt') as f:
183+
expected = f.read()
184+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
185+
d = json.load(f)
164186
infos = d['hasExtractedLicensingInfos']
165187
assert len(infos) == 1
166188
assert infos[0]['licenseId'] == 'LicenseRef-wolfSSL-Commercial'
167189
assert infos[0]['extractedText'] == expected
168-
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
190+
with open(glob.glob('wolfssl-*.cdx.json')[0]) as f:
191+
cdx = json.load(f)
169192
lic = cdx['metadata']['component']['licenses'][0]['license']
170193
assert lic['name'] == 'LicenseRef-wolfSSL-Commercial'
171194
assert lic['text']['content'] == expected
172195
print('LicenseRef + text: ok')
173196
PY
174197
# The output of this run must still pass NTIA and CDX validators.
175198
ntia-checker -c ntia wolfssl-*.spdx.json
176-
python3 -c "
199+
python3 - <<'PY'
200+
import glob, sys
177201
from cyclonedx.validation.json import JsonStrictValidator
178202
from cyclonedx.schema import SchemaVersion
179-
import glob, sys
180203
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)"
204+
with open(glob.glob('wolfssl-*.cdx.json')[0]) as f:
205+
errs = v.validate_str(f.read())
206+
sys.exit(1 if errs else 0)
207+
PY
208+
209+
- name: License matrix - LicenseRef without text must FAIL
210+
# gen-sbom must refuse to emit a SBOM that names a LicenseRef-*
211+
# but doesn't embed its text - that combo is invalid per SPDX 2.3
212+
# and any "successfully generated" output would mislead auditors.
213+
run: |
214+
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
215+
if make sbom SBOM_LICENSE_OVERRIDE=LicenseRef-wolfSSL-Commercial \
216+
2>/tmp/err; then
217+
echo "FAIL: gen-sbom should have refused this configuration"
218+
exit 1
219+
fi
220+
grep -q 'license-text was not provided' /tmp/err || \
221+
{ echo "FAIL: error message missing actionable hint"; \
222+
cat /tmp/err; exit 1; }
223+
test ! -f wolfssl-5.9.1.spdx.json || \
224+
{ echo "FAIL: SBOM file should not exist after refusal"; \
225+
exit 1; }
183226
184227
- name: License matrix - compound expression
185228
run: |
186229
rm -f wolfssl-*.cdx.json wolfssl-*.spdx.json wolfssl-*.spdx
187230
make sbom \
188231
SBOM_LICENSE_OVERRIDE='GPL-3.0-only OR LicenseRef-wolfSSL-Commercial' \
189-
SBOM_LICENSE_TEXT="$PWD/COPYING"
232+
SBOM_LICENSE_TEXT=/tmp/sbom-fixture-licence.txt
190233
python3 - <<'PY'
191234
import glob, json
192-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
235+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
236+
d = json.load(f)
193237
assert len(d['hasExtractedLicensingInfos']) == 1
194-
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
238+
with open(glob.glob('wolfssl-*.cdx.json')[0]) as f:
239+
cdx = json.load(f)
195240
entry = cdx['metadata']['component']['licenses'][0]
196241
assert 'expression' in entry, entry
197242
print('compound expression: ok')
@@ -203,9 +248,11 @@ jobs:
203248
make sbom SBOM_LICENSE_OVERRIDE=Apache-2.0
204249
python3 - <<'PY'
205250
import glob, json
206-
d = json.load(open(glob.glob('wolfssl-*.spdx.json')[0]))
251+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
252+
d = json.load(f)
207253
assert 'hasExtractedLicensingInfos' not in d
208-
cdx = json.load(open(glob.glob('wolfssl-*.cdx.json')[0]))
254+
with open(glob.glob('wolfssl-*.cdx.json')[0]) as f:
255+
cdx = json.load(f)
209256
lic = cdx['metadata']['component']['licenses'][0]['license']
210257
assert lic == {'id': 'Apache-2.0'}, lic
211258
print('simple SPDX override: ok')
@@ -243,3 +290,46 @@ jobs:
243290
echo "uninstall-hook did not remove SBOM artefacts"
244291
exit 1
245292
fi
293+
294+
# Tier 2 (macOS) - smoke test that gen-sbom finds .dylib artefacts and
295+
# that the autotools target works on Mach-O. Linux already exercises
296+
# the heavy validation matrix; this job is intentionally minimal so the
297+
# macOS runner minutes go to portability coverage, not duplicated checks.
298+
integration-macos:
299+
name: SBOM integration (macos)
300+
if: github.repository_owner == 'wolfssl'
301+
runs-on: macos-latest
302+
needs: unit
303+
timeout-minutes: 20
304+
steps:
305+
- uses: actions/checkout@v4
306+
307+
- name: Install build deps and SBOM validators
308+
run: |
309+
brew install autoconf automake libtool
310+
python3 -m pip install --user --break-system-packages \
311+
'spdx-tools==0.8.*'
312+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
313+
# On some macOS runners pyspdxtools lands in
314+
# Library/Python/<ver>/bin; symlink to a known-on-PATH location.
315+
for d in "$HOME/Library/Python"/*/bin; do
316+
[ -x "$d/pyspdxtools" ] && \
317+
echo "$d" >> "$GITHUB_PATH"
318+
done
319+
320+
- name: Configure wolfSSL (shared)
321+
run: autoreconf -ivf && ./configure --enable-shared
322+
323+
- name: Build + generate SBOM (verifies .dylib detection)
324+
run: make sbom
325+
326+
- name: SBOM hashed a real .dylib
327+
run: |
328+
python3 - <<'PY'
329+
import glob, json, re
330+
with open(glob.glob('wolfssl-*.spdx.json')[0]) as f:
331+
d = json.load(f)
332+
checksum = d['packages'][0]['checksums'][0]['checksumValue']
333+
assert re.fullmatch(r'[0-9a-f]{64}', checksum), checksum
334+
print('macOS SBOM checksum well-formed:', checksum)
335+
PY

scripts/test_gen_sbom.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ def test_compound_uses_expression(self):
122122
gs.cdx_license_block('GPL-3.0-only AND MIT', None),
123123
[{'expression': 'GPL-3.0-only AND MIT'}])
124124

125+
def test_noassertion_uses_name_not_expression(self):
126+
# NOASSERTION is a reserved SPDX literal, not a parseable SPDX
127+
# expression - shoving it into `expression` makes some CDX
128+
# validators choke when they try to parse it.
129+
self.assertEqual(
130+
gs.cdx_license_block('NOASSERTION', None),
131+
[{'license': {'name': 'NOASSERTION'}}])
132+
self.assertEqual(
133+
gs.cdx_license_block('NOASSERTION', 'ignored'),
134+
[{'license': {'name': 'NOASSERTION'}}])
135+
125136

126137
class TestBuildExtractedLicensingInfos(unittest.TestCase):
127138
def test_no_refs_returns_none(self):
@@ -177,6 +188,18 @@ def test_returns_valid_uuid_string(self):
177188
parsed = uuid.UUID(s)
178189
self.assertEqual(str(parsed), s)
179190

191+
def test_separator_does_not_alias_inputs(self):
192+
# If the helper joined parts on a printable character (e.g. '/'),
193+
# then ('a/b', 'c') would collide with ('a', 'b/c'). NUL is not
194+
# representable in any of the call-site inputs, so the join must
195+
# be unambiguous. Regression guard for that contract.
196+
self.assertNotEqual(
197+
gs.derived_uuid('a/b', 'c'),
198+
gs.derived_uuid('a', 'b/c'))
199+
self.assertNotEqual(
200+
gs.derived_uuid('a-b', 'c'),
201+
gs.derived_uuid('a', 'b-c'))
202+
180203

181204
class TestBuildTimestamp(unittest.TestCase):
182205
def setUp(self):
@@ -235,25 +258,51 @@ def test_missing_file_exits(self):
235258

236259

237260
class TestParseOptionsH(unittest.TestCase):
238-
def test_parses_defines_sorted_and_deduped(self):
261+
def _parse(self, body):
239262
with tempfile.NamedTemporaryFile('w', suffix='.h',
240263
delete=False) as f:
241-
f.write(
242-
"/* fake options.h */\n"
243-
"#define HAVE_BAR\n"
244-
"#define HAVE_AAA 1\n"
245-
"#define HAVE_BAR /* duplicate */\n"
246-
"#define HAVE_FOO 42\n"
247-
)
264+
f.write(body)
248265
path = f.name
249266
try:
250-
pairs = gs.parse_options_h(path)
267+
return gs.parse_options_h(path)
251268
finally:
252269
os.unlink(path)
270+
271+
def test_parses_defines_sorted_and_deduped(self):
272+
pairs = self._parse(
273+
"/* fake options.h */\n"
274+
"#define HAVE_BAR\n"
275+
"#define HAVE_AAA 1\n"
276+
"#define HAVE_FOO 42\n"
277+
)
253278
names = [k for k, _ in pairs]
254279
self.assertEqual(names, sorted(set(names)))
255-
self.assertIn(('HAVE_AAA', '1'), pairs)
256-
self.assertIn(('HAVE_FOO', '42'), pairs)
280+
self.assertEqual(dict(pairs)['HAVE_AAA'], '1')
281+
self.assertEqual(dict(pairs)['HAVE_FOO'], '42')
282+
self.assertEqual(dict(pairs)['HAVE_BAR'], '')
283+
284+
def test_strips_trailing_block_comment(self):
285+
# Regression: an earlier version captured the comment text into
286+
# the value, polluting the SBOM build properties.
287+
pairs = dict(self._parse("#define HAVE_FOO 42 /* always */\n"))
288+
self.assertEqual(pairs['HAVE_FOO'], '42')
289+
290+
def test_strips_trailing_line_comment(self):
291+
pairs = dict(self._parse("#define HAVE_FOO 42 // always\n"))
292+
self.assertEqual(pairs['HAVE_FOO'], '42')
293+
294+
def test_strips_comment_from_valueless_define(self):
295+
pairs = dict(self._parse("#define HAVE_BAR /* set elsewhere */\n"))
296+
self.assertEqual(pairs['HAVE_BAR'], '')
297+
298+
def test_dedup_keeps_last_assignment(self):
299+
# Last assignment wins (matches C preprocessor semantics for
300+
# duplicate #defines after redefinition).
301+
pairs = dict(self._parse(
302+
"#define HAVE_X 1\n"
303+
"#define HAVE_X 2\n"
304+
))
305+
self.assertEqual(pairs['HAVE_X'], '2')
257306

258307

259308
if __name__ == '__main__':

0 commit comments

Comments
 (0)