Files
wolfssl/scripts/test_gen_sbom.py
T
Mark Atwood be9963f392 sbom: drop liboqs dependency support (removed from wolfSSL)
wolfSSL removed liboqs: Falcon is now provided natively by wolfCrypt, and
--with-liboqs is a deprecated no-op (configure.ac). A build therefore no
longer links liboqs, so recording it as an SBOM dependency is dead code and
the SBOM integration CI (which asserted a liboqs dep package) failed.

Remove the liboqs dependency throughout:
- scripts/gen-sbom: drop DEP_META['liboqs'] and the --dep-liboqs flag.
- Makefile.am / configure.ac: drop --dep-liboqs "$(ENABLED_LIBOQS)" and the
  now-unused AC_SUBST([ENABLED_LIBOQS]).
- .github/workflows/sbom.yml: drop the liboqs install / --with-liboqs steps
  and the liboqs dep assertion; keep the native-Falcon build so the
  HAVE_FALCON build-property capture is still exercised.
- scripts/test_gen_sbom.py: drop the liboqs-specific tests, guard against
  the key reappearing, and use openssl as the example dep elsewhere.
- doc/SBOM.md: drop the --dep-liboqs / liboqs dependency references.
2026-07-23 22:32:50 -07:00

2609 lines
116 KiB
Python

#!/usr/bin/env python3
"""Unit tests for the helpers in scripts/gen-sbom.
Run from the repo root:
python3 -m unittest scripts/test_gen_sbom.py
These tests cover the pure logic in gen-sbom (license expression handling,
deterministic UUID derivation, SOURCE_DATE_EPOCH timestamp parsing). They
intentionally avoid touching the filesystem-heavy paths (sha256_file,
parse_options_h, pkg-config) which are exercised end-to-end by the
integration tests in .github/workflows/sbom.yml.
"""
import importlib.util
import json
import os
import pathlib
import re
import tempfile
import unittest
import uuid
from datetime import datetime, timedelta, timezone
from importlib.machinery import SourceFileLoader
def _load_gen_sbom():
"""Load gen-sbom (no .py extension) as a module under the name 'gs'.
spec_from_file_location infers the loader from the suffix; gen-sbom has
none, so we hand it a SourceFileLoader explicitly."""
here = pathlib.Path(__file__).resolve().parent
target = here / 'gen-sbom'
if not target.is_file():
raise FileNotFoundError(
f"expected gen-sbom alongside this test file at {target}"
)
loader = SourceFileLoader('gs', str(target))
spec = importlib.util.spec_from_loader('gs', loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
gs = _load_gen_sbom()
class TestIsSimpleSpdxId(unittest.TestCase):
def test_listed_ids_are_simple(self):
for spdx in ('Apache-2.0', 'MIT', 'GPL-3.0-or-later',
'GPL-2.0-only', 'BSD-3-Clause', 'CC0-1.0', 'Zlib'):
self.assertTrue(gs.is_simple_spdx_id(spdx),
f"{spdx!r} should be simple")
def test_license_refs_are_not_simple(self):
self.assertFalse(gs.is_simple_spdx_id('LicenseRef-wolfSSL-Commercial'))
self.assertFalse(gs.is_simple_spdx_id('LicenseRef-Foo'))
def test_compound_expressions_are_not_simple(self):
self.assertFalse(gs.is_simple_spdx_id('GPL-3.0-only OR MIT'))
self.assertFalse(gs.is_simple_spdx_id(
'Apache-2.0 AND LicenseRef-Foo'))
self.assertFalse(gs.is_simple_spdx_id('(MIT OR Apache-2.0)'))
def test_noassertion_is_not_simple(self):
self.assertFalse(gs.is_simple_spdx_id('NOASSERTION'))
class TestExtractLicenseRefs(unittest.TestCase):
def test_no_refs(self):
self.assertEqual(gs.extract_license_refs('Apache-2.0'), [])
self.assertEqual(gs.extract_license_refs('GPL-3.0-only OR MIT'), [])
self.assertEqual(gs.extract_license_refs(''), [])
self.assertEqual(gs.extract_license_refs(None), [])
def test_single_ref(self):
self.assertEqual(
gs.extract_license_refs('LicenseRef-X'), ['LicenseRef-X'])
self.assertEqual(
gs.extract_license_refs('LicenseRef-wolfSSL-Commercial'),
['LicenseRef-wolfSSL-Commercial'])
def test_multiple_refs_are_sorted_and_deduped(self):
self.assertEqual(
gs.extract_license_refs(
'Apache-2.0 OR LicenseRef-B AND LicenseRef-A'),
['LicenseRef-A', 'LicenseRef-B'])
self.assertEqual(
gs.extract_license_refs(
'LicenseRef-X OR LicenseRef-X AND LicenseRef-X'),
['LicenseRef-X'])
class TestCdxLicenseBlock(unittest.TestCase):
def test_listed_id_uses_id_form(self):
self.assertEqual(
gs.cdx_license_block('Apache-2.0', None),
[{'license': {'id': 'Apache-2.0'}}])
self.assertEqual(
gs.cdx_license_block('GPL-3.0-or-later', None),
[{'license': {'id': 'GPL-3.0-or-later'}}])
def test_single_ref_with_text_uses_name_and_text(self):
block = gs.cdx_license_block('LicenseRef-Foo', 'BODY')
self.assertEqual(len(block), 1)
lic = block[0]['license']
self.assertEqual(lic['name'], 'LicenseRef-Foo')
self.assertEqual(lic['text']['content'], 'BODY')
self.assertEqual(lic['text']['contentType'], 'text/plain')
self.assertNotIn('id', lic)
def test_single_ref_without_text_omits_text_field(self):
block = gs.cdx_license_block('LicenseRef-Foo', None)
lic = block[0]['license']
self.assertEqual(lic['name'], 'LicenseRef-Foo')
self.assertNotIn('text', lic)
def test_compound_uses_expression(self):
# Per CDX 1.6 schema, compound SPDX expressions go into `expression`.
# We must NOT use `id` (only listed IDs allowed) nor `name` (single
# licence only).
self.assertEqual(
gs.cdx_license_block('GPL-3.0-only OR LicenseRef-Foo', 'X'),
[{'expression': 'GPL-3.0-only OR LicenseRef-Foo'}])
self.assertEqual(
gs.cdx_license_block('GPL-3.0-only AND MIT', None),
[{'expression': 'GPL-3.0-only AND MIT'}])
def test_noassertion_uses_name_not_expression(self):
# NOASSERTION is a reserved SPDX literal, not a parseable SPDX
# expression - shoving it into `expression` makes some CDX
# validators choke when they try to parse it.
self.assertEqual(
gs.cdx_license_block('NOASSERTION', None),
[{'license': {'name': 'NOASSERTION'}}])
self.assertEqual(
gs.cdx_license_block('NOASSERTION', 'ignored'),
[{'license': {'name': 'NOASSERTION'}}])
class TestBuildExtractedLicensingInfos(unittest.TestCase):
def test_no_refs_returns_none(self):
self.assertIsNone(
gs.build_extracted_licensing_infos('Apache-2.0', None))
self.assertIsNone(
gs.build_extracted_licensing_infos('GPL-3.0-only AND MIT', None))
def test_single_ref_with_text(self):
infos = gs.build_extracted_licensing_infos(
'LicenseRef-wolfSSL-Commercial', 'BODY')
self.assertEqual(len(infos), 1)
self.assertEqual(infos[0]['licenseId'],
'LicenseRef-wolfSSL-Commercial')
self.assertEqual(infos[0]['extractedText'], 'BODY')
self.assertIn('name', infos[0])
def test_placeholder_when_text_missing(self):
infos = gs.build_extracted_licensing_infos('LicenseRef-X', None)
self.assertEqual(len(infos), 1)
# Placeholder must mention how to fix it so reviewers/auditors who
# inspect the SBOM know what's wrong.
text = infos[0]['extractedText']
self.assertIn('--license-text', text)
def test_multiple_refs_each_get_entry(self):
infos = gs.build_extracted_licensing_infos(
'LicenseRef-A OR LicenseRef-B', 'BODY')
self.assertEqual(
sorted(i['licenseId'] for i in infos),
['LicenseRef-A', 'LicenseRef-B'])
for i in infos:
self.assertEqual(i['extractedText'], 'BODY')
class TestDerivedUuid(unittest.TestCase):
def test_deterministic(self):
a = gs.derived_uuid('wolfssl', '5.9.1', 'package')
b = gs.derived_uuid('wolfssl', '5.9.1', 'package')
self.assertEqual(a, b)
def test_different_inputs_diverge(self):
self.assertNotEqual(
gs.derived_uuid('wolfssl', '5.9.1', 'package'),
gs.derived_uuid('wolfssl', '5.9.2', 'package'))
self.assertNotEqual(
gs.derived_uuid('wolfssl', '5.9.1', 'package'),
gs.derived_uuid('wolfssl', '5.9.1', 'serial'))
def test_returns_valid_uuid_string(self):
s = gs.derived_uuid('a', 'b')
# Will raise if not a valid UUID.
parsed = uuid.UUID(s)
self.assertEqual(str(parsed), s)
def test_separator_does_not_alias_inputs(self):
# If the helper joined parts on a printable character (e.g. '/'),
# then ('a/b', 'c') would collide with ('a', 'b/c'). NUL is not
# representable in any of the call-site inputs, so the join must
# be unambiguous. Regression guard for that contract.
self.assertNotEqual(
gs.derived_uuid('a/b', 'c'),
gs.derived_uuid('a', 'b/c'))
self.assertNotEqual(
gs.derived_uuid('a-b', 'c'),
gs.derived_uuid('a', 'b-c'))
class TestBuildTimestamp(unittest.TestCase):
def setUp(self):
self._saved = os.environ.get('SOURCE_DATE_EPOCH')
def tearDown(self):
if self._saved is None:
os.environ.pop('SOURCE_DATE_EPOCH', None)
else:
os.environ['SOURCE_DATE_EPOCH'] = self._saved
def test_honors_source_date_epoch(self):
os.environ['SOURCE_DATE_EPOCH'] = '1700000000'
dt, ts = gs.build_timestamp()
self.assertEqual(dt.year, 2023)
self.assertEqual(ts, '2023-11-14T22:13:20Z')
def test_two_calls_with_same_sde_match(self):
os.environ['SOURCE_DATE_EPOCH'] = '1700000000'
_, t1 = gs.build_timestamp()
_, t2 = gs.build_timestamp()
self.assertEqual(t1, t2)
def test_invalid_sde_falls_back_to_now(self):
os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number'
dt, ts = gs.build_timestamp()
# Shape check.
self.assertRegex(
ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z')
# Freshness check: regression guard against a future change that
# accidentally hard-codes the fallback (e.g. epoch zero). Five
# seconds is generous for a unit test on slow runners.
self.assertLess(
abs(dt - datetime.now(tz=timezone.utc)),
timedelta(seconds=5))
def test_no_sde_is_current_utc(self):
os.environ.pop('SOURCE_DATE_EPOCH', None)
dt, ts = gs.build_timestamp()
self.assertRegex(
ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z')
self.assertLess(
abs(dt - datetime.now(tz=timezone.utc)),
timedelta(seconds=5))
class TestLoadLicenseText(unittest.TestCase):
def test_empty_path_returns_none(self):
self.assertIsNone(gs.load_license_text(''))
self.assertIsNone(gs.load_license_text(None))
def test_real_file(self):
with tempfile.NamedTemporaryFile('w', suffix='.txt',
delete=False) as f:
f.write('LICENCE BODY\n')
path = f.name
try:
self.assertEqual(gs.load_license_text(path), 'LICENCE BODY\n')
finally:
os.unlink(path)
def test_missing_file_exits(self):
with self.assertRaises(SystemExit):
gs.load_license_text('/no/such/path/please.txt')
class TestDetectLicense(unittest.TestCase):
"""detect_license decides the SPDX licenseConcluded / licenseDeclared
that wolfSSL's SBOM advertises. A regression here silently flips
the licence obligations a downstream integrator parses out of the
SBOM (e.g. GPLv2-or-later misreported as GPLv2-only narrows the
permitted upgrade path; GPLv3 misreported as GPLv2 entirely
misstates compatibility with GPLv2-only third-party code).
Independent oracle: the SPDX licence-list short identifiers
(https://spdx.org/licenses/), determined for each fixture by
reading the GPL version stated in the prose and whether 'or later'
/ 'or any later version' wording appears within 100 characters of
the version mention. No fixture is round-tripped through
detect_license to derive its own oracle."""
def _detect(self, body):
with tempfile.NamedTemporaryFile('w', suffix='.txt',
delete=False) as f:
f.write(body)
path = f.name
try:
return gs.detect_license(path)
finally:
os.unlink(path)
def test_gplv2_only(self):
# Prose mentions GPLv2 with no 'or later' clause. Oracle:
# SPDX 'GPL-2.0-only'.
self.assertEqual(
self._detect(
'This program is licensed under the GNU General Public '
'License version 2.\n'
'See COPYING for the full text.\n'),
'GPL-2.0-only')
def test_gplv2_or_later_any_form(self):
# 'or any later' immediately after the version mention.
# Oracle: SPDX 'GPL-2.0-or-later'.
self.assertEqual(
self._detect(
'Licensed under the GNU General Public License version 2, '
'or any later version.\n'),
'GPL-2.0-or-later')
def test_gplv2_or_later_canonical_fsf_preamble(self):
# The canonical FSF GPL preamble phrase, used verbatim in
# millions of upstream COPYING files:
#
# 'either version N of the License, or (at your option)
# any later version.'
#
# An earlier regex (`or\s+(any\s+)?later`) failed to match
# this because the parenthetical '(at your option)'
# interjects between 'or' and 'any later', so wolfssl-1zj.24
# silently mis-detected the preamble as GPLv2-only. Oracle:
# SPDX 'GPL-2.0-or-later'.
self.assertEqual(
self._detect(
'This program is free software: you can redistribute it '
'and/or modify it under the terms of the GNU General '
'Public License version 2, or (at your option) any '
'later version.\n'),
'GPL-2.0-or-later')
def test_gplv3_or_later_canonical_fsf_preamble(self):
# Same regression guard for GPLv3.
self.assertEqual(
self._detect(
'Licensed under the GNU General Public License version 3, '
'or (at your option) any later version.\n'),
'GPL-3.0-or-later')
def test_gplv2_or_later_short_form(self):
# 'or later' (without 'any') also matches the regex; this
# variant appears in some upstream COPYING files. Oracle:
# 'GPL-2.0-or-later'.
self.assertEqual(
self._detect(
'Licensed under the GNU General Public License version 2 '
'or later.\n'),
'GPL-2.0-or-later')
def test_gplv3_only(self):
self.assertEqual(
self._detect(
'Released under the terms of the GNU General Public '
'License version 3.\n'),
'GPL-3.0-only')
def test_gplv3_or_later(self):
self.assertEqual(
self._detect(
'Released under the terms of the GNU General Public '
'License version 3, or any later version.\n'),
'GPL-3.0-or-later')
def test_gplv3_abbreviation_only(self):
# LICENSING that uses only the "GPLv3" abbreviation, with no
# canonical "GNU General Public License version 3" long form.
# This is exactly wolfSSH's LICENSING shape, which previously
# fell back to NOASSERTION. Oracle: 'GPL-3.0-only'.
self.assertEqual(
self._detect(
'wolfExample is either licensed for use under the GPLv3 '
'or a standard commercial license.\n'),
'GPL-3.0-only')
def test_gplv2_abbreviation_only(self):
# Same abbreviation path for version 2. Oracle: 'GPL-2.0-only'.
self.assertEqual(
self._detect('Distributed under the GPLv2.\n'),
'GPL-2.0-only')
def test_gplv3_plus_abbreviation_is_or_later(self):
# The "+" suffix on the abbreviated form (GPLv3+) denotes the
# or-later variant. Oracle: 'GPL-3.0-or-later'.
self.assertEqual(
self._detect('Licensed under GPLv3+ terms.\n'),
'GPL-3.0-or-later')
def test_gplv2_abbreviation_or_later_prose(self):
# Abbreviated form followed by an explicit "or later" clause in
# prose (no "+") also promotes to or-later. Oracle:
# 'GPL-2.0-or-later'.
self.assertEqual(
self._detect('Available under GPLv2 or later.\n'),
'GPL-2.0-or-later')
def test_real_wolfssh_licensing_shape_is_gpl3_only(self):
# Regression guard for the exact wolfSSH LICENSING wording: the
# "or a standard commercial license" clause after the GPLv3
# abbreviation must NOT be mistaken for an "or later" grant.
self.assertEqual(
self._detect(
'\nwolfSSH is either licensed for use under the GPLv3 or a '
'standard commercial\nlicense. For our users who cannot use '
'wolfSSH under GPLv3, a commercial license\nto wolfSSH is '
'available.\n'),
'GPL-3.0-only')
def test_case_insensitive(self):
# The regex is case-insensitive for both the GPL header line
# and the 'or later' clause. Real-world COPYING files use
# mixed cases ('GNU GENERAL PUBLIC LICENSE Version 2'); a
# case-sensitive regression here would silently emit None.
self.assertEqual(
self._detect(
'GNU GENERAL PUBLIC LICENSE Version 2\n'
'Licensee may redistribute under GPLv2 OR LATER.\n'),
'GPL-2.0-or-later')
def test_or_later_outside_100_byte_excerpt_does_not_match(self):
# The 'or later' search is bounded to the 100 chars
# immediately following the version mention. An 'or later'
# phrase appearing in unrelated boilerplate further down the
# file MUST NOT promote a GPLv2-only declaration to
# GPLv2-or-later. This is the regression Mark called out in
# the review: "someone reworks the regex ... and breaks the
# GPLv2-or-later detection."
body = (
'Licensed under the GNU General Public License version 2.\n'
+ ('Filler not relevant to the license clause. ' * 5)
+ '\nMay be useful or later modified by users.\n'
)
self.assertEqual(self._detect(body), 'GPL-2.0-only')
def test_no_gpl_mention_returns_none_with_warning(self):
import io, contextlib
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
result = self._detect(
'Copyright (c) 2026 Example Corp.\n'
'Licensed under the MIT License.\n'
'Permission is hereby granted, free of charge, ...\n')
self.assertIsNone(result)
# Warning must mention the file path so an operator running
# `make sbom` can see which file was unparsable.
self.assertIn('no GPL version found', stderr.getvalue())
def test_missing_file_returns_none_with_warning(self):
import io, contextlib
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
result = gs.detect_license('/no/such/license/please.txt')
self.assertIsNone(result)
self.assertIn('cannot read license file', stderr.getvalue())
def test_real_wolfssl_licensing_is_gpl3_only(self):
# Regression guard, not an oracle: lock down the SPDX ID that
# the shipped LICENSING file produces today. If wolfSSL ever
# changes the headline licence in LICENSING, this test must
# be updated in the same commit so the SBOM emission change
# does not slip in unreviewed. The "version 3" mention is
# first in the file; the 100-char excerpt that follows is
# `(\u201cGPLv3\u201d) with\nthe following exception: ...`,
# which contains no 'or later' clause - hence GPL-3.0-only.
here = pathlib.Path(__file__).resolve().parent.parent
licensing = here / 'LICENSING'
if not licensing.is_file():
self.skipTest(f'LICENSING fixture not found at {licensing}')
self.assertEqual(
gs.detect_license(str(licensing)), 'GPL-3.0-only',
'real wolfSSL LICENSING no longer maps to GPL-3.0-only; '
'update this regression guard and audit the SBOM '
'licenseConcluded / licenseDeclared change')
class TestSha256File(unittest.TestCase):
def test_real_file_hashes_to_known_value(self):
# Empty file's SHA-256 is well-known; sanity-checks the chunked
# read path produces the same digest as a one-shot hash.
with tempfile.NamedTemporaryFile('wb', delete=False) as f:
path = f.name
try:
empty_sha256 = ('e3b0c44298fc1c149afbf4c8996fb924'
'27ae41e4649b934ca495991b7852b855')
self.assertEqual(gs.sha256_file(path), empty_sha256)
finally:
os.unlink(path)
def test_missing_file_exits_cleanly(self):
# Regression guard: gen-sbom must surface a missing --lib path as
# a clean non-zero exit, not an unhandled OSError, so `make sbom`
# fails fast with a useful message instead of a Python traceback.
with self.assertRaises(SystemExit):
gs.sha256_file('/no/such/library/please.so')
def test_chunked_read_path_matches_one_shot(self):
# The chunked iter(f.read(65536), b'') path in sha256_file is
# what runs for the real wolfSSL library (.so/.a, multi-MB).
# The empty-file vector above never executes the loop body at
# all (size=0). An off-by-one or chunk-boundary regression
# would slip through unless we exercise a buffer that crosses
# the 65536-byte boundary. Independent oracle: hashlib's
# one-shot hash on the same bytes.
import hashlib
body = (b'A' * 70000) + (b'B' * 1000) + b'tail'
with tempfile.NamedTemporaryFile('wb', delete=False) as f:
f.write(body)
path = f.name
try:
expected = hashlib.sha256(body).hexdigest()
self.assertEqual(gs.sha256_file(path), expected)
finally:
os.unlink(path)
class TestParseOptionsH(unittest.TestCase):
def _parse(self, body):
with tempfile.NamedTemporaryFile('w', suffix='.h',
delete=False) as f:
f.write(body)
path = f.name
try:
return gs.parse_options_h(path)
finally:
os.unlink(path)
def test_parses_defines_sorted_and_deduped(self):
pairs = self._parse(
"/* fake options.h */\n"
"#define HAVE_BAR\n"
"#define HAVE_AAA 1\n"
"#define HAVE_FOO 42\n"
)
names = [k for k, _ in pairs]
self.assertEqual(names, sorted(set(names)))
self.assertEqual(dict(pairs)['HAVE_AAA'], '1')
self.assertEqual(dict(pairs)['HAVE_FOO'], '42')
self.assertEqual(dict(pairs)['HAVE_BAR'], '')
def test_strips_trailing_block_comment(self):
# Regression: an earlier version captured the comment text into
# the value, polluting the SBOM build properties.
pairs = dict(self._parse("#define HAVE_FOO 42 /* always */\n"))
self.assertEqual(pairs['HAVE_FOO'], '42')
def test_strips_trailing_line_comment(self):
pairs = dict(self._parse("#define HAVE_FOO 42 // always\n"))
self.assertEqual(pairs['HAVE_FOO'], '42')
def test_strips_comment_from_valueless_define(self):
pairs = dict(self._parse("#define HAVE_BAR /* set elsewhere */\n"))
self.assertEqual(pairs['HAVE_BAR'], '')
def test_preserves_url_in_string_literal(self):
# Regression guard: an earlier comment-stripper used
# `re.split(r'/\*|//', raw, maxsplit=1)[0]`, which truncated
# autoconf-generated PACKAGE_URL / PACKAGE_BUGREPORT defines
# at the first `//` inside the URL. Both ended up as
# `"https:` in the SBOM build properties, falsely showing
# PACKAGE_URL drifting between releases when nothing changed.
pairs = dict(self._parse(
'#define PACKAGE_URL "https://www.wolfssl.com"\n'
'#define PACKAGE_BUGREPORT '
'"https://github.com/wolfssl/wolfssl/issues"\n'
))
self.assertEqual(pairs['PACKAGE_URL'],
'"https://www.wolfssl.com"')
self.assertEqual(pairs['PACKAGE_BUGREPORT'],
'"https://github.com/wolfssl/wolfssl/issues"')
def test_strips_comment_after_string_literal(self):
# Companion to test_preserves_url_in_string_literal: confirm
# the stripper still works when a comment legitimately follows
# a string literal. A regression that disabled stripping
# entirely (the simplest "fix" for the URL bug) would let
# comment text leak into the SBOM.
pairs = dict(self._parse(
'#define PACKAGE_URL "https://www.wolfssl.com" /* upstream */\n'
))
self.assertEqual(pairs['PACKAGE_URL'],
'"https://www.wolfssl.com"')
def test_preserves_block_comment_inside_string_literal(self):
# `/*` inside a string literal must not start a comment.
pairs = dict(self._parse('#define WEIRD "a/*b*/c"\n'))
self.assertEqual(pairs['WEIRD'], '"a/*b*/c"')
def test_handles_escaped_quote_in_string_literal(self):
# An escaped `\"` inside a string literal must not be mistaken
# for the closing quote; otherwise a comment-marker that
# follows would be incorrectly treated as outside the string.
pairs = dict(self._parse(
'#define EMBEDDED_QUOTE "a\\"b//c" /* tail */\n'))
self.assertEqual(pairs['EMBEDDED_QUOTE'], '"a\\"b//c"')
def test_dedup_keeps_last_assignment(self):
# Last assignment wins (matches C preprocessor semantics for
# duplicate #defines after redefinition).
pairs = dict(self._parse(
"#define HAVE_X 1\n"
"#define HAVE_X 2\n"
))
self.assertEqual(pairs['HAVE_X'], '2')
def test_filters_compiler_internals_from_dm_e_dump(self):
# The no-pcpp escape hatch (`$CC -dM -E -include settings.h ...`)
# produces a defines file containing hundreds of host/compiler
# macros - on macOS it includes the entire Apple
# TargetConditionals family, on Linux it includes __GLIBC_*,
# everywhere it includes the C compiler's __INT_*_MAX__ /
# __SSE*__ / __VERSION__ family. parse_options_h must drop them
# so the SBOM reflects wolfSSL configuration, not the build
# host, and is reproducible across hosts.
pairs = dict(self._parse(
"/* simulated `clang -dM -E` dump on macOS */\n"
"#define __VERSION__ \"Homebrew Clang 21.1.4\"\n"
"#define __APPLE__ 1\n"
"#define __MACH__ 1\n"
"#define __SSE2__ 1\n"
"#define __INT_FAST32_MAX__ 2147483647\n"
"#define __clang_major__ 21\n"
"#define _LP64 1\n"
"#define TARGET_OS_MAC 1\n"
"#define TARGET_OS_OSX 1\n"
"#define TARGET_OS_LINUX 0\n"
"#define TARGET_IPHONE_SIMULATOR 0\n"
"#define WOLFSSL_OPTIONS_H\n"
"#define WOLF_CRYPT_SETTINGS_H 1\n"
"#define HAVE_AESGCM 1\n"
"#define NO_DES3 1\n"
"#define WOLFSSL_AES_256 1\n"
))
self.assertEqual(
set(pairs), {'HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256'},
'noise filter let host/compiler macros leak into SBOM')
def test_real_options_h_template_is_only_a_header_guard(self):
# Sanity-check that the noise filter handles wolfSSL's own
# autotools options.h.in: today the template defines exactly
# one macro - the WOLFSSL_OPTIONS_H header guard - which the
# filter must drop. If a future change adds a non-guard macro
# to options.h.in, this test makes the filter audit explicit.
here = pathlib.Path(__file__).resolve().parent.parent
template = here / 'wolfssl' / 'options.h.in'
if not template.is_file():
self.skipTest(f'options.h.in fixture not found at {template}')
body = template.read_text()
names = re.findall(r'^#define[ \t]+(\w+)', body, re.MULTILINE)
self.assertIn('WOLFSSL_OPTIONS_H', names,
'options.h.in unexpectedly missing its header guard')
for name in names:
self.assertTrue(
gs._is_noise_macro(name),
f'options.h.in defines {name!r} but the noise filter does '
'not drop it; either the filter needs widening or '
'options.h.in now contains a real config macro')
def test_real_options_h_preserves_autoconf_have_probes(self):
# An autotools-generated wolfssl/options.h (post-./configure)
# contains both the WOLFSSL_OPTIONS_H header guard (filtered)
# and AC_CHECK_HEADER probe results like WOLFSSL_HAVE_ATOMIC_H
# / WOLFSSL_HAVE_ASSERT_H (must be preserved - they gate
# `#if defined(...)` branches in wc_port.h and types.h).
here = pathlib.Path(__file__).resolve().parent.parent
options_h = here / 'wolfssl' / 'options.h'
if not options_h.is_file():
self.skipTest(
f'no built options.h at {options_h}; run ./configure first')
names = {k for k, _ in gs.parse_options_h(str(options_h))}
# WOLFSSL_OPTIONS_H is the header guard for options.h itself
# and must be filtered out.
self.assertNotIn(
'WOLFSSL_OPTIONS_H', names,
'header guard leaked through into SBOM build properties')
# The autoconf-detected header-availability flags must survive
# the filter (regression guard - see
# TestIsNoiseMacro.test_autoconf_have_header_probes_preserved).
for cflag in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H'):
if cflag in re.findall(r'^#define[ \t]+(\w+)',
options_h.read_text(), re.MULTILINE):
self.assertIn(
cflag, names,
f'{cflag!r} (AC_CHECK_HEADER probe result) was '
'incorrectly dropped by the noise filter')
class TestIsNoiseMacro(unittest.TestCase):
"""The shared filter that keeps build-environment artefacts out of
the SBOM `wolfssl:build:*` properties. Drives both parse_options_h
(no-pcpp / autotools) and parse_user_settings (pcpp embedded) to
the same wolfSSL-only build-property set so the no-pcpp
`$CC -dM -E` shortcut does not produce host-leaking, non-
reproducible-across-hosts SBOMs.
The three macro families this guards against (compiler-reserved,
Apple TargetConditionals, header guards) are documented in
`_NOISE_MACRO_RE` in gen-sbom; the assertions below pin each one,
plus the `_CONFIG_H_TOKENS` carve-out that keeps `*_H`-suffixed
real configuration flags out of the header-guard branch."""
def test_compiler_reserved_double_underscore(self):
# `__*` is reserved-for-implementation per ISO C 7.1.3 and is
# the bulk of what `clang -dM -E` emits. Dropping these is
# what stops `__VERSION__: "Homebrew Clang 21.1.4"` from
# leaking the developer's laptop into the public SBOM.
for name in ('__VERSION__', '__SSE2__', '__INT_FAST32_MAX__',
'__APPLE__', '__MACH__', '__amd64__',
'__GCC_ATOMIC_BOOL_LOCK_FREE',
'__clang_major__', '__BLOCKS__',
'__OBJC_BOOL_IS_BOOL', '__SIZEOF_LONG__',
'__LDBL_DIG__', '__FLT_RADIX__'):
self.assertTrue(gs._is_noise_macro(name),
f'{name!r} should be filtered')
def test_compiler_reserved_single_underscore_uppercase(self):
# ISO C 7.1.3 also reserves `_` + uppercase for the
# implementation; e.g. macOS clang emits `_LP64`, glibc emits
# `_FORTIFY_SOURCE`. Same rationale as `__*`.
for name in ('_LP64', '_FORTIFY_SOURCE', '_LARGEFILE_SOURCE',
'_GNU_SOURCE'):
self.assertTrue(gs._is_noise_macro(name),
f'{name!r} should be filtered')
def test_apple_target_conditionals_filtered(self):
# `clang -include settings.h -x c /dev/null` on macOS pulls in
# <TargetConditionals.h>; without this filter a wolfSSL SBOM
# for an STM32 firmware would falsely show TARGET_OS_MAC=1
# when generated on a Mac, mis-identifying the target platform
# to a CRA reviewer.
for name in ('TARGET_OS_MAC', 'TARGET_OS_OSX', 'TARGET_OS_LINUX',
'TARGET_OS_IOS', 'TARGET_OS_EMBEDDED',
'TARGET_OS_WIN32', 'TARGET_OS_WINDOWS',
'TARGET_IPHONE_SIMULATOR'):
self.assertTrue(gs._is_noise_macro(name),
f'{name!r} (Apple TargetConditionals) should be '
'filtered')
def test_header_guards_filtered(self):
# Both wolfssl/options.h itself and several internal wolfSSL
# headers define `WOLFSSL_*_H` / `WOLF_CRYPT_*_H` guards to
# prevent double inclusion. These describe "which file was
# parsed", not configuration choices.
for name in ('WOLF_CRYPT_SETTINGS_H', 'WOLFSSL_OPTIONS_H',
'WOLF_CRYPT_VISIBILITY_H', 'WOLFSSL_USER_SETTINGS_H'):
self.assertTrue(gs._is_noise_macro(name),
f'{name!r} (header guard) should be filtered')
def test_autoconf_have_header_probes_preserved(self):
# Regression guard: the `_H$` filter must NOT swallow
# AC_CHECK_HEADER results from configure.ac. These live on the
# wolfSSL CFLAGS as `-DWOLFSSL_HAVE_ATOMIC_H` /
# `-DWOLFSSL_HAVE_ASSERT_H`, gate `#if defined(...)` branches in
# wc_port.h / types.h, and so are real configuration flags an
# auditor or vulnerability scanner needs to see in the SBOM.
for name in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H',
'WOLFSSL_HAVE_MLKEM_H', 'HAVE_STDINT_H',
'HAVE_SYS_TYPES_H'):
self.assertFalse(
gs._is_noise_macro(name),
f'{name!r} (autoconf AC_CHECK_HEADER probe) must NOT be '
'filtered - it is real configuration that gates source '
'code branches')
def test_no_h_suffixed_disablement_flags_preserved(self):
# Regression guard for the carve-out specifically. These flags
# are set by NETOS / Telit / WOLFSSL_TELIT_M2MB / similar RTOS
# profiles in wolfssl/wolfcrypt/settings.h to suppress stdlib
# header inclusion (the firmware ships with vendor stdlib
# replacements). They gate real `#if defined(...)` branches:
#
# types.h:398 `#ifndef NO_STDINT_H`
# settings.h:3850 `#ifndef NO_STDINT_H`
# sp.h:42 `#elif !defined(NO_STDINT_H)`
# types.h:2132 `#if !defined(WOLFSSL_NO_ASSERT_H) && ...`
#
# An embedded customer who builds against one of these profiles
# would otherwise get an SBOM that silently omits their
# stdlib-disablement choices - the exact evidence a CRA reviewer
# expects to see.
for name in ('NO_STDINT_H', 'NO_STDLIB_H', 'NO_LIMITS_H',
'NO_CTYPE_H', 'NO_STRING_H', 'NO_STDDEF_H',
'WOLFSSL_NO_ASSERT_H'):
self.assertFalse(
gs._is_noise_macro(name),
f'{name!r} (NO_*_H disablement flag) must NOT be '
'filtered - it gates real wolfSSL source branches')
def test_use_h_suffixed_build_mode_flags_preserved(self):
# Regression guard for the `USE_` carve-out token. Gates the
# flat-vs-tree test/benchmark layout in test.c:165 /
# benchmark.c:219 / examples/server/server.c:70. Customers who
# vendor these example sources select the layout via a `_H`-
# suffixed flag, so it must survive the filter.
for name in ('USE_FLAT_TEST_H', 'USE_FLAT_BENCHMARK_H'):
self.assertFalse(
gs._is_noise_macro(name),
f'{name!r} (USE_*_H build-mode toggle) must NOT be '
'filtered - it gates real wolfSSL source branches')
def test_real_wolfssl_macros_pass_through(self):
# The whole point of filtering is to NOT touch real wolfSSL
# configuration. If any of these get filtered the SBOM loses
# auditor-visible build properties that distinguish one
# wolfSSL configuration from another.
for name in ('HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256',
'WOLFSSL_USER_SETTINGS', 'WC_RSA_BLINDING',
'TFM_ECC256', 'OPENSSL_EXTRA', 'USE_FAST_MATH',
'XTIME', 'CUSTOM_RAND_GENERATE', 'FP_MAX_BITS',
'BENCH_EMBEDDED', 'SIZEOF_LONG_LONG',
'WOLFSSL_SP_NO_DYN_STACK', 'WOLFSSL_SHA512',
'NO_FILESYSTEM', 'SINGLE_THREADED'):
self.assertFalse(gs._is_noise_macro(name),
f'{name!r} (real wolfSSL config) should NOT be '
'filtered')
class TestDepMetaShape(unittest.TestCase):
"""Lock down the dep-tracking surface so renames/removals don't
silently regress vulnerability-scanner identifiers in the SBOM.
These guard against:
* an external dep being added without a CVE-resolvable identifier
* a future PR re-introducing the `falcon`/`libxmss`/`liblms`
keys after they were intentionally removed."""
def test_only_expected_deps_are_tracked(self):
# wolfssl is tracked so downstream wolfSSL-stack products (wolfSSH,
# wolfMQTT, ...) can declare it via --dep-wolfssl; openssl so the
# OpenSSL-compat products (wolfProvider, wolfEngine) can declare it via
# --dep-openssl; libz is wolfSSL's own optional linked dep.
self.assertEqual(set(gs.DEP_META.keys()),
{'wolfssl', 'openssl', 'libz'})
def test_wolfssl_dep_entry_describes_the_linked_artefact(self):
wolfssl = gs.DEP_META['wolfssl']
self.assertEqual(wolfssl['name'], 'wolfssl')
self.assertEqual(wolfssl['supplier'], 'wolfSSL Inc.')
self.assertEqual(wolfssl['pkgconfig'], 'wolfssl')
# wolfSSL ships under GPLv3 (LICENSING: "version 3 (GPLv3)", no
# "or later"); the dependency entry must match what
# detect_license() infers for wolfSSL's own main-package SBOM so a
# downstream product's wolfssl dep and wolfSSL's self-SBOM agree.
self.assertEqual(wolfssl['license'], 'GPL-3.0-only')
self.assertEqual(
wolfssl['purl']('5.7.4'),
'pkg:github/wolfSSL/wolfssl@v5.7.4')
def test_openssl_dep_entry_describes_the_linked_artefact(self):
openssl = gs.DEP_META['openssl']
self.assertEqual(openssl['name'], 'openssl')
self.assertEqual(openssl['supplier'], 'OpenSSL Software Foundation')
self.assertEqual(openssl['pkgconfig'], 'openssl')
# wolfProvider / wolfEngine target the OpenSSL 3.x provider/engine ABI,
# which is Apache-2.0. The purl uses OpenSSL 3.x's "openssl-X.Y.Z" git
# tag form so it resolves in OSV / GHSA.
self.assertEqual(openssl['license'], 'Apache-2.0')
self.assertEqual(
openssl['purl']('3.5.0'),
'pkg:github/openssl/openssl@openssl-3.5.0')
def test_no_stale_dep_keys(self):
# `falcon` is an algorithm, not a linked package; it must not
# appear as a dep entry (algorithm enablement lives in
# build_props parsed from options.h). `liboqs`, `libxmss` and
# `liblms` were removed upstream (Falcon is now native wolfCrypt,
# no liboqs); their re-appearance here would silently emit
# unresolvable identifiers in the SBOM.
for stale in ('falcon', 'liboqs', 'libxmss', 'liblms', 'xmss', 'lms'):
self.assertNotIn(stale, gs.DEP_META)
class TestEnabledDepsCli(unittest.TestCase):
"""End-to-end test of the argparse plumbing for --dep-* flags.
Runs gen-sbom in a child process so we exercise the real argparse
config rather than a re-imported module."""
def _run(self, *argv):
import subprocess
here = pathlib.Path(__file__).resolve().parent
script = here / 'gen-sbom'
return subprocess.run(
['python3', str(script), *argv],
capture_output=True, text=True
)
def test_dep_flags_are_accepted(self):
result = self._run('--help')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--dep-libz', result.stdout)
self.assertIn('--dep-wolfssl', result.stdout)
self.assertIn('--dep-openssl', result.stdout)
def test_removed_flags_are_rejected(self):
# Each of these was removed: --dep-falcon/--dep-libxmss/--dep-liblms
# (the libraries were removed upstream) and --dep-liboqs (liboqs support
# was removed; Falcon is now provided natively by wolfCrypt). argparse
# should reject them as unrecognised, not silently accept them. We pass
# the full set of required args (against /dev/null sentinels) so argparse
# progresses to the unknown-flag check; we never want gen-sbom to
# actually generate anything in this test.
required = [
'--name', 'wolfssl',
'--version', '0.0.0-test',
'--lib', '/dev/null',
'--license-file', '/dev/null',
'--options-h', '/dev/null',
'--cdx-out', '/dev/null',
'--spdx-out', '/dev/null',
]
for stale_flag in ('--dep-falcon', '--dep-liboqs', '--dep-libxmss',
'--dep-liblms', '--dep-libxmss-root',
'--dep-liblms-root', '--git'):
result = self._run(*required, stale_flag, 'no')
self.assertNotEqual(result.returncode, 0,
f"{stale_flag!r} unexpectedly accepted")
self.assertIn('unrecognized arguments', result.stderr,
f"{stale_flag!r}: {result.stderr!r}")
class TestGitoidBlobSha256(unittest.TestCase):
"""The OmniBOR / git gitoid is content-addressed, well-specified, and
independently verifiable. These vectors anchor our implementation
against the canonical values so a future refactor (e.g. switching
chunked I/O strategy) cannot silently drift."""
EMPTY_OID = ('473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749'
'120a303721813')
HELLO_OID = ('8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca'
'36591175aeb60')
def test_empty_blob_matches_canonical_oid(self):
# The well-known SHA-256 gitoid for an empty blob - matches
# `git hash-object --object-format=sha256 /dev/null`.
with tempfile.NamedTemporaryFile('wb', delete=False) as f:
path = f.name
try:
self.assertEqual(gs.gitoid_blob_sha256(path), self.EMPTY_OID)
finally:
os.unlink(path)
def test_hello_matches_canonical_oid(self):
# `git hash-object --object-format=sha256` on a 5-byte 'hello'
# blob; equivalently sha256(b'blob 5\x00hello').
with tempfile.NamedTemporaryFile('wb', delete=False) as f:
f.write(b'hello')
path = f.name
try:
self.assertEqual(gs.gitoid_blob_sha256(path), self.HELLO_OID)
finally:
os.unlink(path)
def test_chunked_read_path_matches_one_shot(self):
# The chunked iter-read path is what hashes large source files
# in real builds; this guards against any off-by-one in the
# 65536-byte chunk handling.
import hashlib
body = (b'A' * 70000) + (b'B' * 1000) + b'tail'
with tempfile.NamedTemporaryFile('wb', delete=False) as f:
f.write(body)
path = f.name
try:
expected = hashlib.sha256(
f'blob {len(body)}\x00'.encode() + body).hexdigest()
self.assertEqual(gs.gitoid_blob_sha256(path), expected)
finally:
os.unlink(path)
def test_missing_file_exits_cleanly(self):
with self.assertRaises(SystemExit):
gs.gitoid_blob_sha256('/no/such/source/please.c')
class TestSrcsMerkleHash(unittest.TestCase):
"""Source-set Merkle hash is the embedded entry point's component
checksum. Two contracts matter here:
1. Order independence: two customers compiling the same files in
any order get the same hash. Without this, the SBOM would
not be portable across build systems with non-deterministic
file ordering.
2. Content sensitivity: a one-byte change in any source file
must change the hash. Without this, the checksum would
not detect a tampered build."""
def _make_files(self, files):
"""files: dict of basename -> bytes contents.
Returns (tmpdir, list_of_paths)."""
import tempfile
tmpdir = tempfile.mkdtemp()
paths = []
for name, contents in files.items():
p = os.path.join(tmpdir, name)
with open(p, 'wb') as f:
f.write(contents)
paths.append(p)
return tmpdir, paths
def test_order_independent(self):
import shutil
tmpdir, paths = self._make_files({
'aes.c': b'aes-body',
'sha.c': b'sha-body',
'dh.c': b'dh-body',
})
try:
h1 = gs.srcs_merkle_hash(paths)
h2 = gs.srcs_merkle_hash(list(reversed(paths)))
h3 = gs.srcs_merkle_hash(sorted(paths))
self.assertEqual(h1, h2)
self.assertEqual(h1, h3)
finally:
shutil.rmtree(tmpdir)
def test_content_change_changes_hash(self):
import shutil
tmpdir, paths = self._make_files({
'aes.c': b'aes-body',
'sha.c': b'sha-body',
})
try:
h_before = gs.srcs_merkle_hash(paths)
with open(paths[0], 'ab') as f:
f.write(b'X')
h_after = gs.srcs_merkle_hash(paths)
self.assertNotEqual(h_before, h_after)
finally:
shutil.rmtree(tmpdir)
def test_basename_only_means_path_independent(self):
"""The Merkle hash deliberately uses basename only, not full
path, so two customers whose wolfSSL trees live at different
absolute paths get the same hash for the same release."""
import shutil
td_a, paths_a = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'})
td_b, paths_b = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'})
try:
self.assertEqual(
gs.srcs_merkle_hash(paths_a),
gs.srcs_merkle_hash(paths_b))
finally:
shutil.rmtree(td_a)
shutil.rmtree(td_b)
def test_missing_file_exits_cleanly(self):
# Mirrors TestGitoidBlobSha256.test_missing_file_exits_cleanly:
# silently emitting an SBOM with a stale or zero hash for a
# missing source would falsify the artefact, so srcs_merkle_hash
# must propagate the underlying gitoid_blob_sha256 SystemExit.
with self.assertRaises(SystemExit):
gs.srcs_merkle_hash(['/no/such/source/please.c'])
def test_duplicate_basenames_rejected(self):
# Order independence relies on unique basenames - if two source
# files in the input collided on basename, sorting on basename
# would suppress one of them and we would silently lose data.
# gen-sbom must reject the configuration rather than emit a
# misleading hash.
import shutil, tempfile
td_a = tempfile.mkdtemp()
td_b = tempfile.mkdtemp()
try:
with open(os.path.join(td_a, 'aes.c'), 'wb') as f:
f.write(b'a')
with open(os.path.join(td_b, 'aes.c'), 'wb') as f:
f.write(b'b')
with self.assertRaises(SystemExit):
gs.srcs_merkle_hash([
os.path.join(td_a, 'aes.c'),
os.path.join(td_b, 'aes.c'),
])
finally:
shutil.rmtree(td_a)
shutil.rmtree(td_b)
class TestParseUserSettings(unittest.TestCase):
"""Walks a synthetic settings.h + user_settings.h pair through
parse_user_settings() to confirm:
* the conditional logic in settings.h is honoured (only the
taken branch's defines reach the SBOM);
* pcpp-internal macros (__DATE__/__TIME__/__FILE__/__PCPP__) are
filtered out (otherwise reproducibility would break);
* function-like macros are filtered out (they are API surface,
not build configuration);
* --user-settings-define KEY=VALUE predefines reach the parser.
pcpp is a hard prerequisite for these tests, not optional. An
earlier revision called self.skipTest on missing pcpp; CI ran the
suite without pcpp installed and silently skipped all of these
cases, leaving the embedded entry point unverified at the very
gate intended to verify it (see review finding wolfssl-1zj.14).
Now the setUp fails loud with an actionable message."""
def setUp(self):
try:
import pcpp # noqa: F401
except ImportError:
self.fail(
'pcpp is not installed but is required to test the '
'standalone embedded entry point '
'(parse_user_settings). Install with: '
"'python3 -m pip install --user pcpp'. CI installs "
'this in the unit job; see .github/workflows/sbom.yml.')
def _run(self, settings_body, user_body, predefines=()):
import shutil, tempfile
tmpdir = tempfile.mkdtemp()
try:
settings_h = os.path.join(tmpdir, 'settings.h')
user_h = os.path.join(tmpdir, 'user_settings.h')
with open(settings_h, 'w') as f:
f.write(settings_body)
with open(user_h, 'w') as f:
f.write(user_body)
return gs.parse_user_settings(
settings_h, [tmpdir], list(predefines))
finally:
shutil.rmtree(tmpdir)
def test_conditional_branches_honoured(self):
# Customer's user_settings.h enables HAVE_X; settings.h then
# gates HAVE_DEPENDENT on HAVE_X. Disabled-branch defines
# must NOT appear.
settings = (
'#ifdef WOLFSSL_USER_SETTINGS\n'
'#include "user_settings.h"\n'
'#endif\n'
'#ifdef HAVE_X\n'
'#define HAVE_DEPENDENT 1\n'
'#else\n'
'#define HAVE_DISABLED_BRANCH 1\n'
'#endif\n'
)
user = '#define HAVE_X 1\n'
pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS'])
names = {k for k, _ in pairs}
self.assertIn('HAVE_X', names)
self.assertIn('HAVE_DEPENDENT', names)
self.assertNotIn('HAVE_DISABLED_BRANCH', names)
self.assertIn('WOLFSSL_USER_SETTINGS', names)
def test_pcpp_internal_macros_filtered(self):
# __DATE__ and __TIME__ are non-deterministic; if they leak
# into the SBOM, two runs of `make sbom` produce different
# output and reproducibility CI fails. __PCPP__ and __FILE__
# are pcpp implementation detail.
pairs = self._run('#define HAVE_X 1\n', '', [])
names = {k for k, _ in pairs}
for forbidden in ('__DATE__', '__TIME__', '__FILE__', '__PCPP__'):
self.assertNotIn(forbidden, names,
f'{forbidden} leaked into SBOM properties')
self.assertIn('HAVE_X', names)
def test_apple_target_conditionals_filtered(self):
# Defensive: if a customer's user_settings.h transitively
# includes a macOS system header, the Apple TargetConditionals
# leak must still be filtered to keep the SBOM target-platform-
# honest. pcpp does not auto-include system headers, so this
# path is uncommon, but the contract with parse_options_h is
# that the same noise filter applies to both entry points.
pairs = self._run(
'#define HAVE_X 1\n'
'#define TARGET_OS_MAC 1\n'
'#define TARGET_OS_LINUX 0\n'
'#define TARGET_IPHONE_SIMULATOR 0\n',
'', [])
names = {k for k, _ in pairs}
self.assertIn('HAVE_X', names)
for forbidden in ('TARGET_OS_MAC', 'TARGET_OS_LINUX',
'TARGET_IPHONE_SIMULATOR'):
self.assertNotIn(forbidden, names)
def test_header_guards_filtered(self):
# wolfSSL's settings.h, visibility.h, etc. all define
# WOLF_CRYPT_*_H guards; they describe which file was parsed,
# not configuration choices, and so are filtered out of the
# SBOM `wolfssl:build:*` property set.
pairs = self._run(
'#define WOLF_CRYPT_SETTINGS_H 1\n'
'#define WOLFSSL_USER_SETTINGS_H 1\n'
'#define HAVE_X 1\n',
'', [])
names = {k for k, _ in pairs}
self.assertIn('HAVE_X', names)
self.assertNotIn('WOLF_CRYPT_SETTINGS_H', names)
self.assertNotIn('WOLFSSL_USER_SETTINGS_H', names)
def test_no_h_and_use_h_config_flags_preserved(self):
# End-to-end pcpp regression for the `_CONFIG_H_TOKENS` carve-
# out: an embedded customer's user_settings.h that disables
# stdint/stdlib (NETOS / Telit / similar profile) must produce
# an SBOM that records the disablements. Mirrors the
# equivalent unit assertion in TestIsNoiseMacro but exercises
# the full pcpp + filter pipeline customers actually use.
user = (
'#define HAVE_X 1\n'
'#define NO_STDINT_H 1\n'
'#define NO_STDLIB_H 1\n'
'#define WOLFSSL_NO_ASSERT_H 1\n'
'#define USE_FLAT_TEST_H 1\n'
'#define USE_FLAT_BENCHMARK_H 1\n'
)
settings = (
'#ifdef WOLFSSL_USER_SETTINGS\n'
'#include "user_settings.h"\n'
'#endif\n'
)
pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS'])
names = {k for k, _ in pairs}
for required in ('HAVE_X', 'NO_STDINT_H', 'NO_STDLIB_H',
'WOLFSSL_NO_ASSERT_H', 'USE_FLAT_TEST_H',
'USE_FLAT_BENCHMARK_H'):
self.assertIn(
required, names,
f'{required!r} (real wolfSSL config) was filtered out '
'of the SBOM - the noise filter is over-aggressive')
def test_pcpp_error_directive_is_fatal(self):
# An `#error` firing inside settings.h or a transitively
# included header is a hard build failure for the C compiler;
# gen-sbom must mirror that semantics. pcpp signals this via
# pp.return_code (it does NOT raise), which is easy to swallow
# silently and emit a partial SBOM if not checked. This test
# pins the fail-fast contract: any #error must produce a
# SystemExit, not a partial SBOM. We deliberately do NOT
# pin the exact error wording; the contract is fail-fast,
# not the message's phrasing.
settings = (
'#ifdef WOLFSSL_USER_SETTINGS\n'
'#include "user_settings.h"\n'
'#endif\n'
'#define HAVE_X 1\n'
)
user = '#error "this configuration is unsupported"\n'
with self.assertRaises(SystemExit) as ctx:
self._run(settings, user, ['WOLFSSL_USER_SETTINGS'])
# Guard against an empty-message regression that would still
# technically satisfy the SystemExit contract but leave the
# operator with no idea why their build broke. Any
# reasonably useful message will exceed this threshold.
msg = str(ctx.exception)
self.assertGreater(len(msg), 20,
f'gen-sbom exit message too short to be '
f'actionable: {msg!r}')
def test_function_like_macros_filtered(self):
# Function-like macros are API surface, not build
# configuration; their post-expansion body would also break
# reproducibility under pcpp token-render whitespace drift.
pairs = self._run(
'#define HAVE_X 1\n'
'#define WC_BITS_TO_BYTES(x) (((x) + 7) >> 3)\n',
'', [])
names = {k for k, _ in pairs}
self.assertIn('HAVE_X', names)
self.assertNotIn('WC_BITS_TO_BYTES', names)
def test_predefine_with_value(self):
pairs = self._run(
'#if VERSION_MAJOR >= 5\n#define ONLY_NEW 1\n#endif\n',
'', ['VERSION_MAJOR=5'])
names = {k for k, _ in pairs}
self.assertIn('ONLY_NEW', names)
self.assertIn('VERSION_MAJOR', names)
def test_returns_sorted_pairs_like_parse_options_h(self):
# The downstream code path is shared between options.h and
# user_settings.h; both producers must return the exact same
# shape (sorted list of (name, value) tuples). A drift here
# would surface as a mystery diff between the two paths.
pairs = self._run(
'#define HAVE_Z 1\n#define HAVE_A 1\n#define HAVE_M 1\n',
'', [])
names = [k for k, _ in pairs]
self.assertEqual(names, sorted(names))
class TestDepVersionOverride(unittest.TestCase):
"""--dep-version is the embedded path's substitute for pkg-config:
cross-compile hosts have no pkg-config for the target, so the
customer must supply the linked dep version explicitly. Without
this flag a baremetal SBOM that reports `--dep-libz yes` would
silently emit `versionInfo: NOASSERTION` and lose CVE-tracking
fidelity for libz."""
def test_explicit_override_wins_over_pkgconfig(self):
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '99.99.99'
self.assertEqual(
gs.dep_version('libz', {'libz': '1.3.1'}),
'1.3.1')
finally:
gs.pkgconfig_version = original
def test_no_override_falls_back_to_pkgconfig(self):
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '1.0.0'
self.assertEqual(gs.dep_version('libz'), '1.0.0')
self.assertEqual(gs.dep_version('libz', {}), '1.0.0')
self.assertEqual(
gs.dep_version('libz', {'openssl': '0.0'}), '1.0.0')
finally:
gs.pkgconfig_version = original
def test_parse_overrides_rejects_unknown_keys(self):
with self.assertRaises(SystemExit):
gs._parse_dep_version_overrides(['libssl=3.0.0'])
def test_parse_overrides_rejects_malformed(self):
with self.assertRaises(SystemExit):
gs._parse_dep_version_overrides(['libz'])
def test_parse_overrides_accepts_known_keys(self):
out = gs._parse_dep_version_overrides([
'libz=1.3.1', 'openssl=3.5.0',
])
self.assertEqual(out, {'libz': '1.3.1', 'openssl': '3.5.0'})
class TestResolveDepVersionsSingleShot(unittest.TestCase):
"""Each enabled dependency's version must be resolved exactly once (in
main, via _resolve_dep_versions), not once per output format. Without
the precompute, generate_cdx and generate_spdx each call dep_version()
independently, so a build linking libz + openssl would shell
out to `pkg-config --modversion` four times (2 deps x CDX+SPDX) instead
of twice -- and the two documents could disagree if pkg-config were ever
non-deterministic. These tests lock that single-resolution behaviour in."""
def test_pkgconfig_called_once_per_dep(self):
calls = []
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '1.2.3')[1]
overrides = gs._resolve_dep_versions(['libz', 'openssl'], {})
self.assertEqual(len(calls), 2)
self.assertEqual(overrides['libz'], '1.2.3')
self.assertEqual(overrides['openssl'], '1.2.3')
# The emitters reuse the cached value: a later dep_version() for
# an already-resolved key must not re-invoke pkg-config.
gs.dep_version('libz', overrides)
gs.dep_version('openssl', overrides)
self.assertEqual(len(calls), 2)
finally:
gs.pkgconfig_version = original
def test_user_override_skips_pkgconfig(self):
calls = []
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '9.9.9')[1]
overrides = gs._resolve_dep_versions(['libz'], {'libz': '1.3.1'})
self.assertEqual(overrides['libz'], '1.3.1')
self.assertEqual(calls, [])
finally:
gs.pkgconfig_version = original
def test_none_is_cached_when_pkgconfig_missing(self):
calls = []
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda pkg: (calls.append(pkg), None)[1]
overrides = gs._resolve_dep_versions(['openssl'], {})
self.assertIn('openssl', overrides)
self.assertIsNone(overrides['openssl'])
# A cached None must short-circuit later lookups too.
gs.dep_version('openssl', overrides)
self.assertEqual(len(calls), 1)
finally:
gs.pkgconfig_version = original
class TestCollectSrcs(unittest.TestCase):
"""_collect_srcs merges --srcs and --srcs-file into one ordered,
path-deduplicated list. --srcs-file lets an IDE / build system feed
a mechanically-generated source list (the only way to get a truly
complete set) when it is too long for the command line."""
def _write(self, lines):
with tempfile.NamedTemporaryFile('w', suffix='.txt',
delete=False) as f:
f.write(lines)
return f.name
def test_srcs_only(self):
self.assertEqual(
gs._collect_srcs(['a.c', 'b.c'], None),
['a.c', 'b.c'])
def test_srcs_file_only(self):
path = self._write('a.c\nb.c\n')
try:
self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c'])
finally:
os.unlink(path)
def test_blank_and_comment_lines_ignored(self):
path = self._write('# header\n\na.c\n # indented comment\nb.c\n\n')
try:
self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c'])
finally:
os.unlink(path)
def test_srcs_and_file_merge_and_dedup_paths(self):
# A path appearing in both --srcs and --srcs-file collapses to one
# entry (first occurrence wins) so it does not later trip
# srcs_merkle_hash's duplicate-basename guard.
path = self._write('b.c\nc.c\n')
try:
self.assertEqual(
gs._collect_srcs(['a.c', 'b.c'], path),
['a.c', 'b.c', 'c.c'])
finally:
os.unlink(path)
def test_whitespace_is_stripped(self):
path = self._write(' a.c \n\tb.c\t\n')
try:
self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c'])
finally:
os.unlink(path)
def test_empty_result_exits(self):
path = self._write('# only comments\n\n')
try:
with self.assertRaises(SystemExit):
gs._collect_srcs(None, path)
finally:
os.unlink(path)
def test_unreadable_srcs_file_exits(self):
with self.assertRaises(SystemExit):
gs._collect_srcs(None, '/nonexistent/dir/does-not-exist.txt')
class TestCliMutualExclusion(unittest.TestCase):
"""The two entry-point shapes (autotools / standalone) must be
mutually exclusive. Mixing them would produce a hash whose
semantics nobody can interpret (library bytes? source merkle?
both?), so gen-sbom refuses the combination upfront with a
clear error."""
def _run(self, *argv):
import subprocess
here = pathlib.Path(__file__).resolve().parent
script = here / 'gen-sbom'
return subprocess.run(
['python3', str(script), *argv],
capture_output=True, text=True
)
BASE = [
'--name', 'wolfssl',
'--version', '0.0.0-test',
'--license-file', '/dev/null',
'--cdx-out', '/dev/null',
'--spdx-out', '/dev/null',
]
def test_options_and_user_settings_together_fail(self):
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--user-settings', '/dev/null',
'--lib', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('--options-h or --user-settings', result.stderr)
def test_neither_options_nor_user_settings_fails(self):
result = self._run(
*self.BASE,
'--lib', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('--options-h or --user-settings', result.stderr)
def test_lib_and_srcs_together_fail(self):
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--lib', '/dev/null',
'--srcs', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('component-checksum source', result.stderr)
def test_neither_lib_nor_srcs_fails(self):
result = self._run(
*self.BASE,
'--options-h', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('component-checksum source', result.stderr)
def test_no_artifact_hash_with_srcs_fails(self):
# --no-artifact-hash is the "no hashable artefact" escape hatch;
# combining it with a real hash source (--srcs here) is a
# contradiction the operator must resolve, so gen-sbom refuses it.
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--no-artifact-hash',
'--srcs', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('component-checksum source', result.stderr)
def test_no_artifact_hash_with_lib_fails(self):
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--no-artifact-hash',
'--lib', '/dev/null')
self.assertNotEqual(result.returncode, 0)
self.assertIn('component-checksum source', result.stderr)
def test_licenseref_without_license_text_is_rejected(self):
# Hard contract enforced at gen-sbom main() (see gen-sbom:880):
# any LicenseRef-* in --license-override must be accompanied by
# --license-text. Without this gate, build_extracted_licensing_infos
# silently emits a placeholder ('NOASSERTION. The text for this
# LicenseRef has not been embedded...') which technically
# validates as SPDX but is worthless to a CRA reviewer.
# TestBuildExtractedLicensingInfos exercises the placeholder
# path in isolation; this test pins the gate that should make
# that path unreachable from main(). A refactor that moves
# the check (e.g. into a helper called by only one entry-point
# shape) would be caught here.
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--lib', '/dev/null',
'--license-override', 'LicenseRef-wolfSSL-Commercial')
self.assertNotEqual(result.returncode, 0,
'gen-sbom must reject LicenseRef-* override '
'without --license-text; CRA reviewers cannot '
'use the placeholder fallback')
# The error must tell the operator how to fix it; the literal
# '--license-text' substring is the actionable hint.
self.assertIn('--license-text', result.stderr)
def test_licenseref_with_license_text_is_accepted(self):
# Positive companion to test_licenseref_without_license_text_is_rejected:
# confirms the gate does NOT fire when --license-text is supplied,
# so a refactor that flips the predicate sense (e.g. tests
# `is not None` where it should test `is None`) is also caught.
# We don't validate the SBOM content here — TestBuildExtractedLicensingInfos
# already covers the shape — only that the gate permits the run.
with tempfile.NamedTemporaryFile('w', suffix='.txt',
delete=False) as f:
f.write('Plain-text wolfSSL commercial licence text.\n')
license_text_path = f.name
# --lib must be non-empty (gen-sbom refuses /dev/null as a
# component checksum); use a tiny stand-in file so we exercise
# the LicenseRef gate without tripping the empty-lib gate.
with tempfile.NamedTemporaryFile('wb', suffix='.so',
delete=False) as f:
f.write(b'\x7fELF stub')
lib_path = f.name
try:
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--lib', lib_path,
'--license-override', 'LicenseRef-wolfSSL-Commercial',
'--license-text', license_text_path)
self.assertEqual(
result.returncode, 0,
f'gen-sbom rejected a valid LicenseRef + license-text '
f'pair: stderr={result.stderr!r}')
finally:
os.unlink(license_text_path)
os.unlink(lib_path)
def test_empty_lib_is_rejected(self):
# The --lib argument is the wolfSSL component checksum source.
# An empty file produces the well-known empty-file SHA-256
# (e3b0c44...b855), which is a valid-looking hash that
# matches no real wolfSSL build artefact ever shipped. Both
# SPDX and CDX validators accept it; nothing else catches
# the lie. gen-sbom must refuse zero-byte --lib.
result = self._run(
*self.BASE,
'--options-h', '/dev/null',
'--lib', '/dev/null')
self.assertNotEqual(result.returncode, 0,
'gen-sbom accepted an empty --lib file; would '
'have shipped an SBOM with the empty-file '
'SHA-256 as the wolfSSL component checksum')
self.assertIn('empty', result.stderr.lower())
self.assertIn('--lib', result.stderr)
def test_zero_byte_srcs_warn_but_do_not_fail(self):
# Companion: --srcs may legitimately include zero-byte
# placeholders in cross-compile setups (a target file the
# build system creates with touch but doesn't compile yet),
# so gen-sbom emits a WARNING rather than failing. This
# gives the embedded customer a chance to see they have a
# stub file in the source set without breaking their build.
with tempfile.NamedTemporaryFile('wb', suffix='.c',
delete=False) as f:
f.write(b'/* real source */\n')
real_src = f.name
with tempfile.NamedTemporaryFile('wb', suffix='.c',
delete=False) as f:
empty_src = f.name
# Rename so the basenames are distinct (srcs_merkle_hash
# rejects duplicate basenames; see TestSrcsMerkleHash).
# Rename and rebind BEFORE the try-block so the finally
# clause always references the live filenames even when an
# assertion fails.
real_renamed = real_src + '.real.c'
empty_renamed = empty_src + '.empty.c'
os.rename(real_src, real_renamed)
os.rename(empty_src, empty_renamed)
real_src = real_renamed
empty_src = empty_renamed
try:
result = self._run(
*self.BASE,
'--user-settings', '/dev/null',
'--srcs', real_src, empty_src)
# The standalone path with /dev/null user-settings should
# complete; the only thing we care about here is that an
# empty source did not abort the run.
self.assertEqual(
result.returncode, 0,
f'gen-sbom failed with zero-byte source: stderr={result.stderr!r}')
self.assertIn('zero-byte source', result.stderr)
finally:
for p in (real_src, empty_src):
try:
os.unlink(p)
except FileNotFoundError:
pass
def test_user_settings_path_in_help(self):
# Discoverability regression guard - if the standalone entry
# point is invisible to `--help`, embedded customers will not
# know it exists.
result = self._run('--help')
self.assertEqual(result.returncode, 0, result.stderr)
for token in ('--user-settings', '--user-settings-include',
'--user-settings-define', '--srcs', '--srcs-file',
'--no-artifact-hash', '--dep-version'):
self.assertIn(token, result.stdout, f'{token!r} missing from --help')
def test_srcs_file_matches_srcs_for_same_list(self):
# --srcs-file is purely an input convenience: for the same set of
# files it must produce a byte-identical SBOM to passing the files
# via --srcs. This pins that equivalence end-to-end so the two
# input paths can never silently diverge.
with tempfile.TemporaryDirectory() as tmp:
aes = os.path.join(tmp, 'aes.c')
sha = os.path.join(tmp, 'sha.c')
with open(aes, 'w') as f:
f.write('/* aes */\n')
with open(sha, 'w') as f:
f.write('/* sha */\n')
listfile = os.path.join(tmp, 'srcs.txt')
with open(listfile, 'w') as f:
f.write(f'# wolfssl sources\n{aes}\n\n{sha}\n')
cdx_a = os.path.join(tmp, 'a.cdx.json')
spdx_a = os.path.join(tmp, 'a.spdx.json')
cdx_b = os.path.join(tmp, 'b.cdx.json')
spdx_b = os.path.join(tmp, 'b.spdx.json')
common = [
'--name', 'wolfssl', '--version', '0.0.0-test',
'--license-file', '/dev/null',
'--user-settings', '/dev/null',
]
env = dict(os.environ, SOURCE_DATE_EPOCH='1700000000')
import subprocess
here = pathlib.Path(__file__).resolve().parent
script = str(here / 'gen-sbom')
r1 = subprocess.run(
['python3', script, *common, '--srcs', aes, sha,
'--cdx-out', cdx_a, '--spdx-out', spdx_a],
capture_output=True, text=True, env=env)
r2 = subprocess.run(
['python3', script, *common, '--srcs-file', listfile,
'--cdx-out', cdx_b, '--spdx-out', spdx_b],
capture_output=True, text=True, env=env)
self.assertEqual(r1.returncode, 0, r1.stderr)
self.assertEqual(r2.returncode, 0, r2.stderr)
with open(cdx_a) as f:
a_cdx = f.read()
with open(cdx_b) as f:
b_cdx = f.read()
self.assertEqual(a_cdx, b_cdx)
def test_no_artifact_hash_emits_placeholder_and_note(self):
# End-to-end: --no-artifact-hash must produce a valid SBOM whose
# checksum is the synthetic 64-zero placeholder, tagged
# hash-source=none with the contact note, so the "no hashable
# artefact" path can never silently masquerade as a real digest.
with tempfile.TemporaryDirectory() as tmp:
cdx = os.path.join(tmp, 'out.cdx.json')
spdx = os.path.join(tmp, 'out.spdx.json')
result = self._run(
'--name', 'wolfssl', '--version', '0.0.0-test',
'--license-file', '/dev/null',
'--user-settings', '/dev/null',
'--no-artifact-hash',
'--cdx-out', cdx, '--spdx-out', spdx)
self.assertEqual(result.returncode, 0, result.stderr)
with open(cdx) as f:
doc = json.load(f)
comp = doc['metadata']['component']
self.assertEqual(comp['hashes'][0]['content'], '0' * 64)
props = {p['name']: p['value'] for p in comp['properties']}
self.assertEqual(props['wolfssl:sbom:hash-source'], 'none')
self.assertIn('wolfssl:sbom:no-artifact-hash-note', props)
# ---------------------------------------------------------------------------
# SBOM document generators (generate_cdx / generate_spdx + dep helpers).
#
# These four functions emit the actual JSON consumed by vulnerability
# scanners and CRA auditors. Until this block landed they were entirely
# untested; an SBOM-shape regression that still produced syntactically
# valid JSON would slip through every CI gate. The independent oracle
# is the CDX 1.6 / SPDX 2.3 schema field names, externally specified.
# ---------------------------------------------------------------------------
class TestCdxDepComponent(unittest.TestCase):
"""gen-sbom:576 cdx_dep_component shapes a single CycloneDX dep entry."""
def test_returns_bomref_and_component(self):
# Stub pkgconfig_version so the test does not depend on the
# build host having libz / openssl installed.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '1.3.1'
ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz')
finally:
gs.pkgconfig_version = original
self.assertEqual(comp['bom-ref'], ref)
self.assertEqual(comp['type'], 'library')
self.assertEqual(comp['name'], 'zlib')
self.assertEqual(comp['supplier']['name'],
'Jean-loup Gailly and Mark Adler')
# Per CDX 1.6, listed-id licences go in license.id (not name).
# A regression that switches to license.name would silently
# produce an SBOM that some validators reject.
self.assertEqual(
comp['licenses'], [{'license': {'id': 'Zlib'}}])
self.assertEqual(comp['version'], '1.3.1')
self.assertTrue(comp['purl'].startswith('pkg:'))
self.assertIn('zlib', comp['purl'])
self.assertEqual(comp['externalReferences'][0]['type'], 'vcs')
def test_omits_version_and_purl_when_unknown(self):
# When pkg-config cannot resolve the dep version, gen-sbom
# emits the component WITHOUT a version field rather than
# advertising a wrong one. CRA scanners distinguish absent
# version from wrong version.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: None
ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz')
finally:
gs.pkgconfig_version = original
self.assertNotIn('version', comp)
self.assertNotIn('purl', comp)
# bom-ref is still present and deterministic.
self.assertTrue(ref)
def test_dep_version_override_wins_over_pkgconfig(self):
# Embedded customers without pkg-config use --dep-version to
# supply the linked dep version explicitly. Confirms the
# override threads through to the emitted CDX component.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '99.99.99'
ref, comp = gs.cdx_dep_component(
'wolfssl', '5.9.1', 'libz', {'libz': '1.3.1'})
finally:
gs.pkgconfig_version = original
self.assertEqual(comp['version'], '1.3.1')
def test_bomref_is_deterministic_for_same_inputs(self):
# Two calls with the same inputs must return identical bom-refs;
# otherwise SBOMs are not byte-identical across reruns and the
# reproducibility guarantee breaks.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '1.3.1'
ref_a, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz')
ref_b, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz')
finally:
gs.pkgconfig_version = original
self.assertEqual(ref_a, ref_b)
class TestSpdxDepPackage(unittest.TestCase):
"""gen-sbom:599 spdx_dep_package shapes a single SPDX dep package."""
def test_returns_spdxid_and_package(self):
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '0.10.0'
spdx_id, pkg = gs.spdx_dep_package('openssl')
finally:
gs.pkgconfig_version = original
self.assertTrue(spdx_id.startswith('SPDXRef-Package-'))
# SPDXID must contain only alphanumeric + '.' + '-' (SPDX
# 2.3 §3.2). spdx_dep_package strips everything else; a
# regression that allowed underscores or 'lib' prefixes
# could produce an SPDXID validators reject.
import re as _re
self.assertTrue(
_re.match(r'\ASPDXRef-[A-Za-z0-9.-]+\Z', spdx_id),
f'invalid SPDXID shape: {spdx_id!r}')
self.assertEqual(pkg['SPDXID'], spdx_id)
self.assertEqual(pkg['name'], 'openssl')
self.assertEqual(pkg['versionInfo'], '0.10.0')
self.assertEqual(pkg['filesAnalyzed'], False)
# Both license fields must agree; SPDX validators accept
# divergence but it is semantically meaningless here.
self.assertEqual(pkg['licenseConcluded'], pkg['licenseDeclared'])
self.assertEqual(pkg['copyrightText'], 'NOASSERTION')
def test_unknown_version_uses_NOASSERTION(self):
# SPDX 2.3 §3.3 requires versionInfo; when truly unknown,
# 'NOASSERTION' is the spec-compliant placeholder. Emitting
# an empty string or omitting the field would fail validation.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: None
_, pkg = gs.spdx_dep_package('openssl')
finally:
gs.pkgconfig_version = original
self.assertEqual(pkg['versionInfo'], 'NOASSERTION')
# externalRefs.purl is only emitted when a version is known
# (a purl with no @version is meaningless to package-manager
# tooling); confirm it is absent here.
self.assertNotIn('externalRefs', pkg)
def test_purl_externalref_present_when_version_known(self):
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '0.10.0'
_, pkg = gs.spdx_dep_package('openssl')
finally:
gs.pkgconfig_version = original
purl_refs = [
r for r in pkg.get('externalRefs', [])
if r.get('referenceType') == 'purl'
]
self.assertEqual(len(purl_refs), 1)
self.assertIn('openssl', purl_refs[0]['referenceLocator'])
self.assertIn('0.10.0', purl_refs[0]['referenceLocator'])
class TestGenerateCdx(unittest.TestCase):
"""gen-sbom:624 generate_cdx assembles the full CycloneDX 1.6 doc."""
BASE_KW = dict(
name='wolfssl',
version='5.9.1',
supplier='wolfSSL Inc.',
license_id='GPL-2.0-only',
license_text=None,
lib_hash='a' * 64,
timestamp='2024-01-01T00:00:00Z',
year=2024,
serial='00000000-0000-0000-0000-000000000001',
enabled_deps=[],
build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')],
)
def test_top_level_shape(self):
doc = gs.generate_cdx(**self.BASE_KW)
self.assertEqual(doc['bomFormat'], 'CycloneDX')
self.assertEqual(doc['specVersion'], '1.6')
self.assertEqual(
doc['$schema'],
'http://cyclonedx.org/schema/bom-1.6.schema.json')
self.assertEqual(doc['version'], 1)
# serialNumber is a urn:uuid: prefix per CDX schema.
self.assertTrue(doc['serialNumber'].startswith('urn:uuid:'))
def test_main_component_fields(self):
doc = gs.generate_cdx(**self.BASE_KW)
comp = doc['metadata']['component']
self.assertEqual(comp['type'], 'library')
self.assertEqual(comp['name'], 'wolfssl')
self.assertEqual(comp['version'], '5.9.1')
# CPE 2.3 with vendor:product:version - downstream
# vulnerability scanners key on this format.
self.assertEqual(
comp['cpe'],
'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*')
# pkg:github resolves to OSV / GHSA / Snyk / Trivy directly,
# without the vendor:product mapping a pkg:generic PURL would
# force. pkg:github tag refs use the upstream `vX.Y.Z` shape
# (rather than bare `X.Y.Z`), matching wolfSSL's release tags.
self.assertEqual(comp['purl'], 'pkg:github/wolfSSL/wolfssl@v5.9.1')
self.assertEqual(comp['hashes'],
[{'alg': 'SHA-256', 'content': 'a' * 64}])
self.assertEqual(comp['licenses'],
[{'license': {'id': 'GPL-2.0-only'}}])
def test_build_properties_emitted(self):
doc = gs.generate_cdx(**self.BASE_KW)
props = doc['metadata']['component']['properties']
names = {p['name']: p['value'] for p in props}
self.assertEqual(names['wolfssl:build:HAVE_AESGCM'], '1')
# An empty define value is rendered as '1' so the SBOM
# consumer can't distinguish '#define X' from '#define X 1'.
self.assertEqual(names['wolfssl:build:NO_DES3'], '1')
def test_dependency_refs_match_components(self):
# Critical invariant: every bom-ref in `dependencies` must
# appear as a `bom-ref` on either the main component or one
# of the dep components. Without this, the dependency graph
# references dangling IDs and CycloneDX-aware tooling cannot
# resolve relationships.
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '1.3.1'
doc = gs.generate_cdx(**{
**self.BASE_KW,
'enabled_deps': ['libz'],
})
finally:
gs.pkgconfig_version = original
all_refs = {doc['metadata']['component']['bom-ref']}
for c in doc['components']:
all_refs.add(c['bom-ref'])
for entry in doc['dependencies']:
self.assertIn(entry['ref'], all_refs,
f"dangling dep ref: {entry['ref']!r}")
for dep in entry.get('dependsOn', []):
self.assertIn(dep, all_refs,
f"dangling dependsOn ref: {dep!r}")
# The wolfssl bom-ref must depend on the libz bom-ref.
wolfssl_ref = doc['metadata']['component']['bom-ref']
wolfssl_entry = next(
e for e in doc['dependencies'] if e['ref'] == wolfssl_ref)
self.assertEqual(len(wolfssl_entry['dependsOn']), 1)
def test_source_merkle_path_emits_hash_kind_property(self):
# The OmniBOR / source-merkle entry point annotates the
# SBOM so an auditor reading the SHA-256 knows it is a hash
# of the source set, not of the built library. Without
# this property the same SHA-256 field carries two
# incompatible semantic meanings depending on entry point.
doc = gs.generate_cdx(**{
**self.BASE_KW,
'hash_kind': 'source-merkle-omnibor',
'srcs_basenames': ['aes.c', 'sha.c'],
})
props = {p['name']: p['value']
for p in doc['metadata']['component']['properties']}
self.assertEqual(props['wolfssl:sbom:hash-kind'],
'source-merkle-omnibor')
self.assertEqual(props['wolfssl:sbom:source-set'], 'aes.c,sha.c')
def test_library_binary_path_emits_hash_kind_property(self):
# The library-binary path now also emits hash-kind: it is the
# auditor's only structured signal for what the SHA-256 in
# `hashes` actually represents. Previously this property was
# only set on the source-merkle path, leaving an autotools
# SBOM ambiguous about its checksum semantics.
doc = gs.generate_cdx(**self.BASE_KW)
props = {p['name']: p['value']
for p in doc['metadata']['component']['properties']}
self.assertEqual(props['wolfssl:sbom:hash-kind'], 'library-binary')
# source-set is only meaningful for the merkle path.
self.assertNotIn('wolfssl:sbom:source-set', props)
def test_hash_source_property_defaults_to_lib(self):
# hash-source is the coarse provenance tag downstream tooling
# filters on. The default (autotools / library-binary path) is
# 'lib'; pin it so a refactor of the default cannot silently
# mislabel the autotools SBOM.
doc = gs.generate_cdx(**self.BASE_KW)
props = {p['name']: p['value']
for p in doc['metadata']['component']['properties']}
self.assertEqual(props['wolfssl:sbom:hash-source'], 'lib')
self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props)
def test_hash_source_srcs_for_source_set(self):
doc = gs.generate_cdx(**{
**self.BASE_KW,
'hash_kind': 'source-merkle-omnibor',
'hash_source': 'srcs',
'srcs_basenames': ['aes.c', 'sha.c'],
})
props = {p['name']: p['value']
for p in doc['metadata']['component']['properties']}
self.assertEqual(props['wolfssl:sbom:hash-source'], 'srcs')
self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props)
def test_hash_source_none_carries_contact_note(self):
# The --no-artifact-hash path must flag the synthetic placeholder
# so a downstream auditor cannot mistake the 64-zero checksum for
# a genuine digest. Both the hash-source=none tag and the contact
# note are required.
doc = gs.generate_cdx(**{
**self.BASE_KW,
'lib_hash': gs._NO_HASH_SENTINEL,
'hash_kind': 'none',
'hash_source': 'none',
})
props = {p['name']: p['value']
for p in doc['metadata']['component']['properties']}
self.assertEqual(props['wolfssl:sbom:hash-source'], 'none')
self.assertEqual(props['wolfssl:sbom:no-artifact-hash-note'],
gs._NO_HASH_NOTE)
# The placeholder must be the synthetic 64-zero sentinel.
self.assertEqual(
doc['metadata']['component']['hashes'][0]['content'],
'0' * 64)
def test_main_component_carries_security_external_refs(self):
# An auditor reading the CDX needs a single in-document link
# to the project's security advisories and the RFC 9116
# security.txt; previously they had to know to go look on
# GitHub or wolfssl.com. Pin the set so a regression that
# drops one of these silently is caught at the cheap CI gate.
doc = gs.generate_cdx(**self.BASE_KW)
refs = doc['metadata']['component']['externalReferences']
types = {r['type'] for r in refs}
self.assertEqual(
{'vcs', 'website', 'issue-tracker', 'advisories',
'security-contact'},
types)
sec_url = next(
r['url'] for r in refs if r['type'] == 'security-contact')
self.assertEqual(
sec_url,
'https://www.wolfssl.com/.well-known/security.txt')
def test_lib_file_entries_become_subcomponents(self):
# CycloneDX 1.6 lets a library component nest file-typed
# sub-components. When the autotools `--lib` path supplies a
# file_entries list, the SBOM names the linked binary by file
# path + SHA-1 + SHA-256 so an auditor / scanner does not have
# to reason about the bare SHA-256 in `hashes` against a
# build-system layout they cannot see.
doc = gs.generate_cdx(**{
**self.BASE_KW,
'file_entries': [{
'name': 'libwolfssl.so.43.0.0',
'sha1': 'b' * 40,
'sha256': 'a' * 64,
}],
})
sub = doc['metadata']['component']['components']
self.assertEqual(len(sub), 1)
self.assertEqual(sub[0]['type'], 'file')
self.assertEqual(sub[0]['name'], 'libwolfssl.so.43.0.0')
algs = {h['alg'] for h in sub[0]['hashes']}
self.assertEqual(algs, {'SHA-1', 'SHA-256'})
def test_tool_metadata_uses_module_constants(self):
# The CDX `metadata.tools.components[]` entry is the only
# producer-identity field in the document; downstream consumers
# pin their parser against the (name, version) pair, so the
# tool name / version must come from the module-level
# constants and not from a stale string baked into the
# generator.
doc = gs.generate_cdx(**self.BASE_KW)
tool = doc['metadata']['tools']['components'][0]
self.assertEqual(tool['name'], gs.GEN_SBOM_TOOL_NAME)
self.assertEqual(tool['version'], gs.GEN_SBOM_VERSION)
class TestGenerateSpdx(unittest.TestCase):
"""gen-sbom:698 generate_spdx assembles the full SPDX 2.3 doc."""
BASE_KW = dict(
name='wolfssl',
version='5.9.1',
supplier='wolfSSL Inc.',
license_id='GPL-2.0-only',
license_text=None,
lib_hash='a' * 64,
timestamp='2024-01-01T00:00:00Z',
year=2024,
doc_ns_uuid='00000000-0000-0000-0000-000000000002',
enabled_deps=[],
build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')],
)
def test_top_level_shape(self):
doc = gs.generate_spdx(**self.BASE_KW)
self.assertEqual(doc['spdxVersion'], 'SPDX-2.3')
self.assertEqual(doc['dataLicense'], 'CC0-1.0')
self.assertEqual(doc['SPDXID'], 'SPDXRef-DOCUMENT')
self.assertEqual(doc['name'], 'wolfssl-5.9.1')
# SPDX 2.3 §6.5: documentNamespace must be a unique URI; no
# requirement that it resolve. Default to `urn:uuid:<derived>`
# rather than a `https://wolfssl.com/sbom/...` URL the project
# does not host -- emitting an unresolvable URL would mislead
# any downstream tool that follows it. The doc_ns_uuid keeps
# the namespace per-version unique without making a hosting
# claim.
self.assertEqual(
doc['documentNamespace'],
f'urn:uuid:{self.BASE_KW["doc_ns_uuid"]}')
def test_document_namespace_override_is_honoured(self):
# Downstream packagers who legitimately re-host the SBOM under
# their own URL pass --document-namespace; the override must
# win over the urn:uuid default. Without this knob a packager
# would have to fork the script to satisfy SPDX 2.3 §6.5
# uniqueness against a self-hosted mirror.
custom = 'https://example.com/sbom/wolfssl-5.9.1.spdx.json'
doc = gs.generate_spdx(**{
**self.BASE_KW,
'document_namespace': custom,
})
self.assertEqual(doc['documentNamespace'], custom)
def test_document_namespace_default_is_urn_uuid(self):
# Negative companion to test_document_namespace_override: when
# no override is supplied (None or empty), the urn:uuid form is
# used and the previously-emitted https://wolfssl.com/sbom/
# URL is NOT reintroduced (regression guard for the M1
# correction).
for explicit in (None, ''):
doc = gs.generate_spdx(**{
**self.BASE_KW,
'document_namespace': explicit,
})
self.assertTrue(
doc['documentNamespace'].startswith('urn:uuid:'),
f'{explicit!r} -> {doc["documentNamespace"]!r}')
self.assertNotIn('wolfssl.com/sbom', doc['documentNamespace'])
def test_main_package_fields(self):
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
self.assertEqual(wolfssl_pkg['name'], 'wolfssl')
self.assertEqual(wolfssl_pkg['versionInfo'], '5.9.1')
self.assertEqual(
wolfssl_pkg['checksums'],
[{'algorithm': 'SHA256', 'checksumValue': 'a' * 64}])
self.assertEqual(wolfssl_pkg['licenseConcluded'], 'GPL-2.0-only')
self.assertEqual(wolfssl_pkg['licenseDeclared'], 'GPL-2.0-only')
def test_describes_relationship(self):
# SPDX 2.3 §11: every document must DESCRIBE its primary package.
doc = gs.generate_spdx(**self.BASE_KW)
describes = [
r for r in doc['relationships']
if r['relationshipType'] == 'DESCRIBES'
]
self.assertEqual(len(describes), 1)
self.assertEqual(describes[0]['spdxElementId'], 'SPDXRef-DOCUMENT')
self.assertEqual(describes[0]['relatedSpdxElement'],
'SPDXRef-Package-wolfssl')
def test_depends_on_relationship_per_dep(self):
original = gs.pkgconfig_version
try:
gs.pkgconfig_version = lambda *_a, **_k: '1.3.1'
doc = gs.generate_spdx(**{
**self.BASE_KW,
'enabled_deps': ['libz'],
})
finally:
gs.pkgconfig_version = original
depends_on = [
r for r in doc['relationships']
if r['relationshipType'] == 'DEPENDS_ON'
]
self.assertEqual(len(depends_on), 1)
self.assertEqual(depends_on[0]['spdxElementId'],
'SPDXRef-Package-wolfssl')
# The relatedSpdxElement must be a real SPDXID in the doc;
# a typo would create a dangling reference.
all_spdx_ids = {p['SPDXID'] for p in doc['packages']}
self.assertIn(depends_on[0]['relatedSpdxElement'], all_spdx_ids)
def test_extracted_licensing_infos_present_for_licenseref(self):
# Critical SPDX 2.3 §10.1 plumbing: when license_id contains
# a LicenseRef-* and license_text is supplied, the document
# MUST carry a hasExtractedLicensingInfos block covering it.
# A regression that drops the wiring in generate_spdx's tail
# produces SBOMs that fail SPDX validation -- the autotools
# path catches this at pyspdxtools time, but the standalone
# path does not validate, so a customer-shipped SBOM would
# silently land at an auditor.
doc = gs.generate_spdx(**{
**self.BASE_KW,
'license_id': 'LicenseRef-wolfSSL-Commercial',
'license_text': 'Commercial licence body.\n',
})
self.assertIn('hasExtractedLicensingInfos', doc)
infos = doc['hasExtractedLicensingInfos']
self.assertEqual(len(infos), 1)
self.assertEqual(infos[0]['licenseId'],
'LicenseRef-wolfSSL-Commercial')
self.assertEqual(infos[0]['extractedText'],
'Commercial licence body.\n')
def test_extracted_licensing_infos_absent_for_simple_id(self):
# Companion to the above: simple SPDX IDs (Apache-2.0,
# GPL-2.0-only, etc.) MUST NOT generate a
# hasExtractedLicensingInfos block, since the licence
# text is well-known and the field is reserved for refs.
doc = gs.generate_spdx(**self.BASE_KW)
self.assertNotIn('hasExtractedLicensingInfos', doc)
def test_source_merkle_path_annotates_via_annotations(self):
# Mirror of TestGenerateCdx.test_source_merkle_path_emits_hash_kind_property
# for SPDX. The hash-kind / source-set used to be stuffed into
# the package `comment` field as positional `key=value` slugs,
# forcing anyone reading the SPDX to grep free-form text.
# SPDX 2.3 §8.5 provides `annotations[]` for exactly this
# producer metadata, and validators (pyspdxtools, NTIA) treat
# them as first-class data.
doc = gs.generate_spdx(**{
**self.BASE_KW,
'hash_kind': 'source-merkle-omnibor',
'srcs_basenames': ['aes.c', 'sha.c'],
})
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
annotation_comments = [
a['comment'] for a in wolfssl_pkg['annotations']
]
self.assertIn(
'wolfssl:sbom:hash-kind=source-merkle-omnibor',
annotation_comments)
self.assertIn(
'wolfssl:sbom:source-set=aes.c,sha.c', annotation_comments)
# `comment` no longer carries the structured hash-kind data --
# it is reserved for the human-readable build-config defines.
self.assertNotIn('hash-kind=', wolfssl_pkg['comment'])
self.assertNotIn('source-set=', wolfssl_pkg['comment'])
def test_library_binary_path_annotates_via_annotations(self):
# Companion to the source-merkle test: library-binary also
# emits hash-kind via annotations[]. The old behaviour of
# only annotating the merkle path left autotools SBOMs with
# no machine-readable signal of their checksum semantics.
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
annotation_comments = [
a['comment'] for a in wolfssl_pkg['annotations']
]
self.assertIn(
'wolfssl:sbom:hash-kind=library-binary', annotation_comments)
# No source-set on library-binary path.
self.assertNotIn('wolfssl:sbom:source-set=',
''.join(annotation_comments))
# Comment is still build-config defines only.
self.assertNotIn('hash-kind=', wolfssl_pkg['comment'])
def test_hash_source_annotation_defaults_to_lib(self):
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
comments = [a['comment'] for a in wolfssl_pkg['annotations']]
self.assertIn('wolfssl:sbom:hash-source=lib', comments)
self.assertNotIn('wolfssl:sbom:no-artifact-hash-note=',
''.join(comments))
def test_hash_source_none_annotates_contact_note(self):
# The --no-artifact-hash path must record both the hash-source=none
# tag and the contact note in the SPDX annotations[], mirroring the
# CycloneDX side, so neither format hides the synthetic placeholder.
doc = gs.generate_spdx(**{
**self.BASE_KW,
'lib_hash': gs._NO_HASH_SENTINEL,
'hash_kind': 'none',
'hash_source': 'none',
})
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
comments = [a['comment'] for a in wolfssl_pkg['annotations']]
self.assertIn('wolfssl:sbom:hash-source=none', comments)
self.assertIn(
f'wolfssl:sbom:no-artifact-hash-note={gs._NO_HASH_NOTE}',
comments)
self.assertEqual(
wolfssl_pkg['checksums'][0]['checksumValue'], '0' * 64)
def test_file_entries_do_not_leak_into_spdx(self):
# SPDX 2.3 forbids package elements (CONTAINS relationships
# via hasFiles) when `filesAnalyzed: False`, and flipping
# `filesAnalyzed: True` would force a packageVerificationCode
# that hashes every file in the package -- not just the
# linked binary. generate_spdx accepts file_entries for
# parameter symmetry with generate_cdx but must not surface
# it as `files[]` / `hasFiles[]`; otherwise pyspdxtools rejects
# the document and `make sbom` fails. Pin the absence so a
# future change cannot quietly reintroduce the validator
# failure that motivated the carve-out.
doc = gs.generate_spdx(**{
**self.BASE_KW,
'file_entries': [{
'name': 'libwolfssl.so.43.0.0',
'sha1': 'b' * 40,
'sha256': 'a' * 64,
}],
})
self.assertNotIn('files', doc)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
self.assertNotIn('hasFiles', wolfssl_pkg)
self.assertEqual(wolfssl_pkg['filesAnalyzed'], False)
self.assertNotIn('packageVerificationCode', wolfssl_pkg)
# CONTAINS relationships are also forbidden under
# filesAnalyzed=False; ensure none leaked through.
contains = [
r for r in doc['relationships']
if r.get('relationshipType') == 'CONTAINS'
]
self.assertEqual(contains, [])
def test_main_package_purl_uses_pkg_github(self):
# PURL parity with the CDX side: pkg:github/<owner>/<repo>@v<v>
# resolves directly in OSV / GHSA / Snyk / Trivy. The previous
# pkg:generic shape forced every scanner into CPE-fallback
# matching, producing the noisy SBOM behaviour auditors
# complain about.
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
purl_refs = [
r for r in wolfssl_pkg['externalRefs']
if r['referenceType'] == 'purl'
]
self.assertEqual(len(purl_refs), 1)
self.assertEqual(
purl_refs[0]['referenceLocator'],
'pkg:github/wolfSSL/wolfssl@v5.9.1')
def test_main_package_carries_advisory_external_ref(self):
# SPDX 2.3 SECURITY/advisory externalRef pointing at the
# GitHub advisories index. Same auditor-facing rationale as
# the CDX side: a single in-document link to the project's
# security disclosures, no out-of-band knowledge required.
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
adv_refs = [
r for r in wolfssl_pkg['externalRefs']
if r['referenceType'] == 'advisory'
]
self.assertEqual(len(adv_refs), 1)
self.assertEqual(
adv_refs[0]['referenceLocator'],
'https://github.com/wolfSSL/wolfssl/security/advisories')
self.assertEqual(adv_refs[0]['referenceCategory'], 'SECURITY')
def test_creation_info_uses_module_constants(self):
# SPDX `creationInfo.creators[]` carries the producer-identity
# signal that downstream tools key on; must come from the
# module-level constants and not from a stale string.
doc = gs.generate_spdx(**self.BASE_KW)
creators = doc['creationInfo']['creators']
expected_tool = (
f'Tool: {gs.GEN_SBOM_TOOL_NAME}-{gs.GEN_SBOM_VERSION}'
)
self.assertIn(expected_tool, creators)
def test_annotations_have_well_formed_metadata(self):
# SPDX 2.3 §8.5: annotation entries require `annotationDate`
# (ISO-8601 with timezone), `annotationType` (one of OTHER,
# REVIEW, ...), `annotator` (Person/Organization/Tool prefix),
# and `comment` (string). pyspdxtools rejects malformed
# annotation entries; pin the shape here at the cheapest CI
# gate so a regression in generate_spdx surfaces in unit
# tests rather than in the integration job.
doc = gs.generate_spdx(**self.BASE_KW)
wolfssl_pkg = next(
p for p in doc['packages']
if p['SPDXID'] == 'SPDXRef-Package-wolfssl')
for ann in wolfssl_pkg['annotations']:
self.assertEqual(ann['annotationDate'], self.BASE_KW['timestamp'])
self.assertEqual(ann['annotationType'], 'OTHER')
self.assertTrue(ann['annotator'].startswith('Tool: '),
f'annotator must use Tool: prefix: {ann!r}')
self.assertIsInstance(ann['comment'], str)
self.assertTrue(ann['comment'])
# ---------------------------------------------------------------------------
# Bomsh provenance verifier
#
# The verifier (scripts/bomsh_verify.py) is invoked by the bomsh: CI job
# against a real OmniBOR graph + enriched SPDX, but its two checks --
# resolvability and object-store integrity -- are pure data-shape
# logic. Exercising them here with synthetic fixtures means a logic
# regression is caught at the cheapest CI gate (the unit job, < 1 s)
# instead of the bomsh integration job (~5 minutes per run, requires
# bomtrace3 + the entire bomsh toolchain to be built).
# ---------------------------------------------------------------------------
class _BomshFixture:
"""Build a self-consistent OmniBOR + SPDX layout in a tmpdir.
Use as a context manager; the tmpdir is cleaned on exit. Methods
let individual tests perturb a single property (delete a blob,
truncate one, etc.) without rebuilding the whole fixture each
time."""
def __init__(self, tmpdir):
self.tmpdir = pathlib.Path(tmpdir)
self.objects_dir = self.tmpdir / 'omnibor' / 'objects'
self.objects_dir.mkdir(parents=True)
self.spdx_path = self.tmpdir / 'omnibor.wolfssl-5.9.1.spdx.json'
# Three distinct blobs staged at their gitoid paths. Stand-in
# for the OmniBOR documents a real `bomsh_create_bom.py` run
# would write under omnibor/objects/; the verifier doesn't care
# whether the content is a doc or an artefact blob, only that
# the file at <aa>/<rest> round-trips through gitoid_sha1. We
# use OmniBOR-doc-shaped bytes here rather than ELF magic so a
# reader doesn't mistakenly conclude the verifier expects raw
# library content under objects/ (it does not -- bomsh stores
# the Input Manifest there, keyed by its bom_id).
self.wolfssl_blob = b'gitoid:blob:sha1\nblob 0123456789abcdef0123456789abcdef01234567\n'
self.aux_blobs = [b'/* aes.c */\n', b'/* sha.c */\n']
self.gitoids = {
'wolfssl': self._stage_blob(self.wolfssl_blob),
}
for i, content in enumerate(self.aux_blobs):
self.gitoids[f'aux{i}'] = self._stage_blob(content)
self._write_spdx()
def _stage_blob(self, content):
"""Write `content` into omnibor/objects/<aa>/<rest> at the
correct gitoid path; return the gitoid hex. Uses
`_gitoid_of_bytes` (an independent reimplementation of the
canonical Git blob hash) rather than calling into
bomsh_verify -- two implementations is the point: a bug in
either is caught by disagreement."""
gid = _gitoid_of_bytes(content)
d = self.objects_dir / gid[:2]
d.mkdir(exist_ok=True)
(d / gid[2:]).write_bytes(content)
return gid
def _write_spdx(self):
"""Emit the enriched SPDX with one gitoid externalRef per
staged blob."""
packages = [{
'name': 'wolfssl',
'externalRefs': [{
'referenceCategory': 'PERSISTENT-ID',
'referenceType': 'gitoid',
'referenceLocator': f'gitoid:blob:sha1:{self.gitoids["wolfssl"]}',
}],
}]
for i in range(len(self.aux_blobs)):
packages.append({
'name': f'wolfssl-aux-{i}',
'externalRefs': [{
'referenceCategory': 'PERSISTENT-ID',
'referenceType': 'gitoid',
'referenceLocator': f'gitoid:blob:sha1:{self.gitoids[f"aux{i}"]}',
}],
})
self.spdx_path.write_text(json.dumps({'packages': packages}))
def verify(self):
"""Run the orchestrator with the fixture's paths."""
return bv.verify(
spdx_glob=str(self.tmpdir / 'omnibor.wolfssl-*.spdx.json'),
omnibor_dir=str(self.tmpdir / 'omnibor'))
def _gitoid_of_bytes(data):
"""Reference implementation used in the fixture so blobs are
placed at the gitoid path the verifier later derives. Independent
of bomsh_verify.gitoid_sha1, which reads from a file -- we want
two implementations so a bug in one is caught by disagreement."""
import hashlib
h = hashlib.sha1()
h.update(f'blob {len(data)}\0'.encode())
h.update(data)
return h.hexdigest()
import json # noqa: E402 (used by the bomsh fixture below)
bv_spec = importlib.util.spec_from_file_location(
'bomsh_verify',
pathlib.Path(__file__).resolve().parent / 'bomsh_verify.py')
bv = importlib.util.module_from_spec(bv_spec)
bv_spec.loader.exec_module(bv)
class TestBomshProvenanceVerify(unittest.TestCase):
"""Exercises bomsh_verify.verify against synthetic fixtures. Each
test starts from a known-good fixture, perturbs exactly one
property, and checks the verifier's failure mode is the right one
-- so a regression that, say, accepts a dangling gitoid as long as
object-store integrity passes is caught here."""
def test_happy_path_passes(self):
# Baseline. An untouched fixture is valid; the verifier should
# report OK and the success message should mention the object
# round-trip count (so a future change that silently drops the
# success-line content is also caught).
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
ok, messages = fx.verify()
self.assertTrue(ok, f'verifier rejected a valid fixture: {messages}')
joined = '\n'.join(messages)
self.assertIn('OK:', joined)
self.assertIn('objects round-trip:', joined)
def test_dangling_gitoid_fails_check_A(self):
# Delete one blob from objects/ but leave its externalRef in
# the SPDX. Check (A) must reject; the failure message must
# mention DANGLING and the missing gitoid path so triage isn't
# just "verifier failed".
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
target_gid = fx.gitoids['aux0']
(fx.objects_dir / target_gid[:2] / target_gid[2:]).unlink()
ok, messages = fx.verify()
self.assertFalse(ok)
joined = '\n'.join(messages)
self.assertIn('DANGLING', joined)
self.assertIn(target_gid, joined)
def test_corrupt_blob_fails_check_B(self):
# Truncate one blob in objects/ so its content no longer
# matches the gitoid encoded in its path. Check (B) must
# reject; check (A) would still pass (the file exists). This
# pins that integrity is checked independently of resolvability.
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
target_gid = fx.gitoids['aux1']
(fx.objects_dir / target_gid[:2] / target_gid[2:]).write_bytes(b'')
ok, messages = fx.verify()
self.assertFalse(ok)
joined = '\n'.join(messages)
self.assertIn('CORRUPT', joined)
self.assertIn('round-trip', joined)
def test_unexpected_gitoid_locator_format_rejected(self):
# bomsh upstream switching from sha1 to sha256 would change
# the locator prefix. load_spdx_gitoids must raise so the
# maintainer is forced to update the verifier in lockstep,
# rather than silently accepting an unparsable value.
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
spdx = json.loads(fx.spdx_path.read_text())
spdx['packages'][0]['externalRefs'][0]['referenceLocator'] = (
'gitoid:blob:sha256:' + 'f' * 64)
fx.spdx_path.write_text(json.dumps(spdx))
ok, messages = fx.verify()
self.assertFalse(ok)
self.assertTrue(
any('unexpected gitoid locator format' in m for m in messages),
messages)
def test_no_gitoid_externalrefs_fails(self):
# Negative companion: an SPDX that contains no gitoid
# externalRefs at all is not a bomsh-enriched document, and
# the verifier should say so plainly rather than silently
# report 0 verified.
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
spdx = json.loads(fx.spdx_path.read_text())
for pkg in spdx['packages']:
pkg['externalRefs'] = []
fx.spdx_path.write_text(json.dumps(spdx))
ok, messages = fx.verify()
self.assertFalse(ok)
self.assertTrue(
any('no gitoid externalRefs' in m for m in messages),
messages)
def test_object_store_integrity_skips_non_blob_files(self):
# OmniBOR objects/ may contain housekeeping files at the root
# (info/, pack/, etc.) that are NOT blobs and must not be
# gitoid-checked. The fanout is exactly two levels deep
# (<aa>/<rest>); anything else gets skipped. Pin this so a
# future "walk everything" rewrite doesn't start failing on
# legitimate non-blob content.
with tempfile.TemporaryDirectory() as tmpdir:
fx = _BomshFixture(tmpdir)
# Drop a bogus file at the objects/ root and inside a
# nested subdir; neither should trigger CORRUPT.
(fx.objects_dir / 'INFO').write_text('housekeeping')
(fx.objects_dir / 'pack').mkdir()
(fx.objects_dir / 'pack' / 'index.idx').write_bytes(b'pack idx')
ok, messages = fx.verify()
self.assertTrue(ok, f'verifier flagged non-blob files: {messages}')
if __name__ == '__main__':
unittest.main(verbosity=2)