From 64b4e441d0364262589072ca985df58d16102975 Mon Sep 17 00:00:00 2001 From: Sameeh Jubran Date: Wed, 8 Jul 2026 15:07:18 +0300 Subject: [PATCH] gen-sbom: detect GPLv3 abbreviation and support downstream products Recognize the "GPLv3"/"GPLv3+" short form so downstream LICENSING files map to GPL-3.0-only instead of NOASSERTION. Add --dep-wolfssl, a wolfssl DEP_META entry, and --name-derived project URLs. Update tests. Signed-off-by: Sameeh Jubran --- scripts/gen-sbom | 85 ++++++++++++++++++++++++++++++++++------ scripts/test_gen_sbom.py | 67 ++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 34173510af..e58e8efa28 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -53,6 +53,21 @@ _NO_HASH_NOTE = ( SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/') +def project_urls(name): + """Canonical wolfSSL GitHub URLs for a project, derived from its package + name. Keeping these name-derived (rather than hardcoded to wolfssl) lets + the same generator emit correct VCS / issue-tracker / advisory / download + URLs for every product in the wolfSSL stack (wolfssl, wolfssh, wolfmqtt, + ...). For name='wolfssl' the result is byte-identical to the historical + hardcoded URLs, so existing wolfSSL SBOMs do not change.""" + base = f'https://github.com/wolfSSL/{name}' + return { + 'vcs': base, + 'issues': f'{base}/issues', + 'advisories': f'{base}/security/advisories', + } + + def derived_uuid(*parts): """Deterministic UUID from joined parts under the wolfSSL SBOM namespace. Re-runs of `make sbom` against the same source produce identical UUIDs, @@ -88,6 +103,25 @@ def build_timestamp(): # / Dependency-Track resolve CVEs against the right package). Algorithm # enablement is captured separately via build_props (HAVE_FALCON, ...). DEP_META = { + # wolfssl itself, declared as a dependency by downstream wolfSSL-stack + # products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl. Only + # emitted when the caller passes --dep-wolfssl yes; wolfSSL's own + # `make sbom` never enables it (a package is not its own dependency). + # Recording it is what lets a CRA / vulnerability scanner associate + # wolfSSL advisories with a product that embeds wolfSSL. + 'wolfssl': { + 'name': 'wolfssl', + 'supplier': 'wolfSSL Inc.', + # wolfSSL is distributed under GPLv3 (LICENSING: "version 3 (GPLv3)", + # no "or later"), with a commercial option. This matches what + # detect_license() infers for wolfSSL's own main-package SBOM, so a + # downstream product's wolfssl dependency entry and wolfSSL's own + # self-SBOM agree on the licence. + 'license': 'GPL-3.0-only', + 'download': 'https://github.com/wolfSSL/wolfssl', + 'pkgconfig': 'wolfssl', + 'purl': lambda v: f'pkg:github/wolfSSL/wolfssl@v{v}', + }, # liboqs is the only PQ external dependency wolfSSL still links against # after upstream PR #10293 collapsed the rest of the PQ surface into # native wolfCrypt. Today, --enable-falcon strictly implies --with-liboqs @@ -222,12 +256,25 @@ def detect_license(license_file): r'gnu general public license\s+version\s+(\d+)', text, re.IGNORECASE ) + or_later_plus = False + if not m: + # Abbreviated form: some wolfSSL-stack LICENSING files (e.g. wolfSSH) + # say "GPLv3" rather than the canonical "GNU General Public License + # version 3", so the long-form regex above misses and detection would + # fall back to NOASSERTION. A trailing "+" (GPLv3+) denotes the + # or-later variant; otherwise fall through to the shared "or later" + # prose check below. + m = re.search(r'\bGPLv(\d+)(\+)?', text, re.IGNORECASE) + if m and m.group(2) == '+': + or_later_plus = True if not m: print(f"WARNING: no GPL version found in {license_file}", file=sys.stderr) return None version = m.group(1) + if or_later_plus: + return f'GPL-{version}.0-or-later' excerpt = text[m.end():m.end() + 100] # Match upgrade-permission wording in the 100-byte excerpt that # follows the version mention. Three FSF-derived shapes: @@ -742,6 +789,7 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, dep_version_overrides=None, hash_kind='library-binary', hash_source='lib', srcs_basenames=None, file_entries=None): bom_ref = derived_uuid(name, version, 'package') + urls = project_urls(name) dep_bom_refs = [] components = [] @@ -793,13 +841,13 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], 'externalReferences': [ {'type': 'vcs', - 'url': 'https://github.com/wolfSSL/wolfssl'}, + 'url': urls['vcs']}, {'type': 'website', 'url': 'https://www.wolfssl.com/'}, {'type': 'issue-tracker', - 'url': 'https://github.com/wolfSSL/wolfssl/issues'}, + 'url': urls['issues']}, {'type': 'advisories', - 'url': 'https://github.com/wolfSSL/wolfssl/security/advisories'}, + 'url': urls['advisories']}, {'type': 'security-contact', 'url': 'https://www.wolfssl.com/.well-known/security.txt'}, ], @@ -884,12 +932,19 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, if srcs_basenames: _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) + urls = project_urls(name) + # Main-package SPDXID derived from --name (sanitised per SPDX 2.3 idstring + # rules) rather than hardcoded to wolfssl, so a wolfSSH/wolfMQTT SBOM does + # not mislabel its own package as wolfssl. For name='wolfssl' the result + # is 'SPDXRef-Package-wolfssl', unchanged from before. + main_spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', name) + wolfssl_pkg = { - 'SPDXID': 'SPDXRef-Package-wolfssl', + 'SPDXID': main_spdx_id, 'name': name, 'versionInfo': version, 'supplier': f'Organization: {supplier}', - 'downloadLocation': 'https://github.com/wolfSSL/wolfssl', + 'downloadLocation': urls['vcs'], 'filesAnalyzed': False, 'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}], 'licenseConcluded': license_id, @@ -913,9 +968,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, { 'referenceCategory': 'SECURITY', 'referenceType': 'advisory', - 'referenceLocator': ( - 'https://github.com/wolfSSL/wolfssl/security/advisories' - ), + 'referenceLocator': urls['advisories'], }, ], } @@ -941,7 +994,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, packages = [wolfssl_pkg] relationships = [{ 'spdxElementId': 'SPDXRef-DOCUMENT', - 'relatedSpdxElement': 'SPDXRef-Package-wolfssl', + 'relatedSpdxElement': main_spdx_id, 'relationshipType': 'DESCRIBES', }] @@ -949,7 +1002,7 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, spdx_id, pkg = spdx_dep_package(key, dep_version_overrides) packages.append(pkg) relationships.append({ - 'spdxElementId': 'SPDXRef-Package-wolfssl', + 'spdxElementId': main_spdx_id, 'relatedSpdxElement': spdx_id, 'relationshipType': 'DEPENDS_ON', }) @@ -1102,6 +1155,13 @@ def main(): 'directing integrators to contact wolfSSL. ' 'Mutually exclusive with --lib / --srcs / ' '--srcs-file.') + parser.add_argument('--dep-wolfssl', default='no', + help='yes to record wolfssl as a dependency component ' + '(for downstream wolfSSL-stack products such as ' + 'wolfSSH / wolfMQTT that link libwolfssl). ' + 'wolfSSL\'s own SBOM leaves this off. Combine ' + 'with --dep-version wolfssl=X.Y.Z on hosts ' + 'without wolfssl.pc.') parser.add_argument('--dep-libz', default='no', help='yes if built with --with-libz') parser.add_argument('--dep-liboqs', default='no', @@ -1173,8 +1233,9 @@ def main(): enabled_deps = [ key for key, flag in [ - ('libz', args.dep_libz), - ('liboqs', args.dep_liboqs), + ('wolfssl', args.dep_wolfssl), + ('libz', args.dep_libz), + ('liboqs', args.dep_liboqs), ] if flag.lower() == 'yes' ] diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index dd86c6e825..134c4ec828 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -365,6 +365,50 @@ class TestDetectLicense(unittest.TestCase): '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 @@ -782,8 +826,26 @@ class TestDepMetaShape(unittest.TestCase): * a future PR re-introducing the `falcon`/`libxmss`/`liblms` keys after they were intentionally removed.""" - def test_only_libz_and_liboqs_are_tracked(self): - self.assertEqual(set(gs.DEP_META.keys()), {'libz', 'liboqs'}) + def test_only_expected_deps_are_tracked(self): + # wolfssl is tracked so downstream wolfSSL-stack products (wolfSSH, + # wolfMQTT, ...) can declare it via --dep-wolfssl; libz/liboqs are + # wolfSSL's own optional linked deps. + self.assertEqual(set(gs.DEP_META.keys()), + {'wolfssl', 'libz', 'liboqs'}) + + 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_liboqs_entry_describes_the_linked_artefact(self): liboqs = gs.DEP_META['liboqs'] @@ -824,6 +886,7 @@ class TestEnabledDepsCli(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn('--dep-liboqs', result.stdout) self.assertIn('--dep-libz', result.stdout) + self.assertIn('--dep-wolfssl', result.stdout) def test_removed_flags_are_rejected(self): # Each of these was either renamed (--dep-falcon -> --dep-liboqs)