diff --git a/docs/cpe-requests/README.md b/docs/cpe-requests/README.md index d1dacad..b050cda 100644 --- a/docs/cpe-requests/README.md +++ b/docs/cpe-requests/README.md @@ -17,6 +17,12 @@ when the SBOM is otherwise correct. All products in the table are registered in the NVD Official CPE Dictionary. `gen-sbom` emits the main-package `cpe` field for each. +The wolfcrypt CPE stays in the dictionary. Nested wolfcrypt components do +**not** emit it: NVD files crypto CVEs against `wolfssl`, not `wolfcrypt`, +and a second CPE on the same sources is a future double-match risk. +Matching rides on `cpe:2.3:a:wolfssl:wolfssl:`. The unique id for +the nested library is the PURL (`pkg:github/wolfssl/wolfssl@v-stable#wolfcrypt`). + ## Pending products (none today) A product with `status: pending` emits **no `cpe` field**. A scanner cannot diff --git a/share/build/sbom.cmake b/share/build/sbom.cmake index cd5430b..2eddda3 100644 --- a/share/build/sbom.cmake +++ b/share/build/sbom.cmake @@ -37,7 +37,8 @@ # INCLUDE_DIRS ... Include directories for that capture (typically the # directory holding user_settings.h). # DEP_WOLFSSL yes|no Record wolfSSL as a dependency component. -# DEP_WOLFCRYPT yes|no Record wolfCrypt as a component (registered NVD CPE). +# DEP_WOLFCRYPT yes|no Record wolfCrypt as a nested component (PURL; +# matching uses the wolfssl CPE). # DEP_OPENSSL yes|no Record OpenSSL as a dependency component. # CRYPTO_ONLY auto|yes|no Whether only the wolfCrypt subset of wolfSSL is # compiled in (default auto: read from the capture). diff --git a/share/build/sbom.mk b/share/build/sbom.mk index 6123047..eb13232 100644 --- a/share/build/sbom.mk +++ b/share/build/sbom.mk @@ -65,7 +65,8 @@ # GEN_SBOM Legacy alias for SBOM_GEN. # SBOM_NO_ARTIFACT_HASH = 1 As-built FIPS/kernel: do not re-hash. # SBOM_DEP_WOLFSSL yes/no - record wolfSSL as a dependency. -# SBOM_DEP_WOLFCRYPT yes/no - record wolfCrypt as a component (NVD CPE). +# SBOM_DEP_WOLFCRYPT yes/no - record wolfCrypt as a nested component +# (PURL; matching uses the wolfssl CPE). # SBOM_DEP_OPENSSL yes/no - record OpenSSL as a dependency. # SBOM_CRYPTO_ONLY auto/yes/no - whether only the wolfCrypt subset of # the wolfSSL release is compiled in. Default auto: diff --git a/share/gen-sbom b/share/gen-sbom index 918550f..41d4b0b 100755 --- a/share/gen-sbom +++ b/share/gen-sbom @@ -25,6 +25,10 @@ from datetime import datetime, timezone # shapes because dropping --dep-liboqs did not bump it, leaving vendored # copies indistinguishable by the only identifier the SBOM records. # +# 1.9 Nested wolfcrypt keeps name, version, supplier and PURL, but no +# longer emits cpe:2.3:a:wolfssl:wolfcrypt. NVD files crypto CVEs +# against wolfssl, not wolfcrypt; a second CPE on the same sources +# is a future double-match risk. Matching stays on the wolfssl CPE. # 1.8 CPE and PURL identifiers drop `+` build metadata (OpenSSL # BUILD_METADATA, PEP 440 local versions). A raw `+` is not legal # in CPE 2.3 and does not match an upstream git tag. version / @@ -56,7 +60,7 @@ from datetime import datetime, timezone # -only/-or-later distinction cannot be inferred. # 1.2 Dropped --dep-liboqs (unversioned; see above). GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen' -GEN_SBOM_VERSION = '1.8' +GEN_SBOM_VERSION = '1.9' # Placeholder recorded in the component checksum fields when the operator # passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only @@ -376,11 +380,12 @@ DEP_META = { # The CPE NVD registers for the wolfSSL library. 'cpe': lambda v: cpe23_uri('wolfssl', 'wolfssl', v), }, - # wolfCrypt is a separate NVD product (cpe:2.3:a:wolfssl:wolfcrypt). - # Embedders such as wolfBoot compile wolfcrypt sources into the image; - # wolfSSL itself co-ships wolfCrypt. Emitting it as a component lets a - # CPE-driven scan match wolfCrypt advisories, which NVD indexes under - # wolfcrypt rather than only under wolfssl. + # wolfCrypt is co-shipped in the wolfSSL tree. Embedders compile those + # sources into the image. Record it as a nested component so the SBOM + # shows provenance (supplier, version, PURL). Do not emit a wolfcrypt + # CPE: NVD files crypto CVEs against cpe:2.3:a:wolfssl:wolfssl, not + # wolfcrypt (zero CVEs on that product today). A second CPE on the same + # sources is a future double-match risk once a CNA starts filing there. 'wolfcrypt': { 'name': 'wolfcrypt', 'supplier': 'wolfSSL Inc.', @@ -392,10 +397,9 @@ DEP_META = { 'pkgconfig': None, # wolfcrypt lives in the wolfssl repository. The resolvable PURL is # the wolfssl release that ships it, with a #wolfcrypt subpath so it - # does not collide with the wolfssl component's own PURL. NVD - # matching keys on the wolfcrypt CPE below. + # does not collide with the wolfssl component's own PURL. Matching + # keys on the wolfssl CPE of the parent component. 'purl': lambda v: wolfssl_project_purl('wolfssl', v) + '#wolfcrypt', - 'cpe': lambda v: cpe23_uri('wolfssl', 'wolfcrypt', v), }, 'libz': { 'name': 'zlib', @@ -1074,10 +1078,12 @@ def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None): # version / versionInfo keep the local string (BUILD_METADATA). comp['version'] = version comp['purl'] = meta['purl'](version) - # Both identifiers are version-bearing, so neither can be emitted + # Identifiers are version-bearing, so they cannot be emitted # without a resolved version: a CPE with an empty version field # matches every release of the dependency in an NVD scan. - comp['cpe'] = meta['cpe'](version) + # wolfcrypt has no CPE: matching rides on the parent wolfssl CPE. + if meta.get('cpe'): + comp['cpe'] = meta['cpe'](version) else: print(f"WARNING: version unknown for {meta['name']}; " "omitting version, purl and cpe", file=sys.stderr) @@ -1101,18 +1107,19 @@ def spdx_dep_package(key, dep_version_overrides=None): 'copyrightText': 'NOASSERTION', } if version: - pkg['externalRefs'] = [ - { + refs = [] + if meta.get('cpe'): + refs.append({ 'referenceCategory': 'SECURITY', 'referenceType': 'cpe23Type', 'referenceLocator': meta['cpe'](version), - }, - { - 'referenceCategory': 'PACKAGE-MANAGER', - 'referenceType': 'purl', - 'referenceLocator': meta['purl'](version), - }, - ] + }) + refs.append({ + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': meta['purl'](version), + }) + pkg['externalRefs'] = refs return spdx_id, pkg @@ -1520,7 +1527,7 @@ def _resolve_dep_versions(enabled_deps, overrides): def _inherit_wolfcrypt_version(enabled_deps, overrides, name, version): - """Give wolfcrypt a version so its CPE and PURL are not dropped. + """Give wolfcrypt a version so its PURL is not dropped. wolfcrypt has no pkg-config file of its own, so `dep_version` can never resolve it. Prefer an explicit `--dep-version wolfcrypt=`, else the @@ -1531,8 +1538,8 @@ def _inherit_wolfcrypt_version(enabled_deps, overrides, name, version): normally arrives from pkg-config rather than from `--dep-version`, so inheriting before the resolve step only ever saw an explicit override: a downstream embedder such as wolfBoot that did not pass - `--dep-version wolfssl=` emitted a wolfcrypt component with no version, - no purl and no cpe, and still exited 0. + `--dep-version wolfssl=` emitted a wolfcrypt component with no version + and no purl, and still exited 0. Tests the value rather than the key, because `_resolve_dep_versions` caches a None for every dep pkg-config cannot resolve, so by this point @@ -1663,10 +1670,12 @@ def main(): 'with --dep-version wolfssl=X.Y.Z on hosts ' 'without wolfssl.pc.') parser.add_argument('--dep-wolfcrypt', default='no', - help='yes to record wolfcrypt as a component with its ' - 'registered NVD CPE (cpe:2.3:a:wolfssl:wolfcrypt). ' - 'Use for embedders (wolfBoot) and for wolfSSL\'s ' - 'own SBOM (containment). Combine with ' + help='yes to record wolfcrypt as a nested component ' + '(supplier, version, PURL). Matching stays on ' + 'the wolfssl CPE; this flag does not emit ' + 'cpe:2.3:a:wolfssl:wolfcrypt. Use for embedders ' + '(wolfBoot) and for wolfSSL\'s own SBOM ' + '(containment). Combine with ' '--dep-version wolfcrypt=X.Y.Z, or inherit the ' 'wolfssl / package version when unset.') parser.add_argument('--crypto-only', default='auto', diff --git a/share/validate_sbom.py b/share/validate_sbom.py index b3916e5..6d40df7 100755 --- a/share/validate_sbom.py +++ b/share/validate_sbom.py @@ -13,8 +13,10 @@ * at least one component or component property recorded * optional --min-properties N on metadata.component.properties * optional --require-dep-version NAME: a components[] entry with that - name must exist and carry a non-empty version and CPE. Nested - sub-components count, since wolfcrypt ships inside wolfssl. + name must exist and carry a non-empty version. Nested sub-components + count, since wolfcrypt ships inside wolfssl. wolfcrypt is provenance + only (PURL, no CPE); matching rides on the parent wolfssl CPE. Every + other required dep must also carry a CPE. SPDX (*.spdx.json): * spdxVersion starts with "SPDX-2" @@ -34,6 +36,12 @@ import sys +# Nested wolfcrypt is a provenance component. NVD files crypto CVEs against +# wolfssl, not wolfcrypt; requiring a wolfcrypt CPE would fail a correct +# SBOM and would later double-match. The unique id is the PURL. +_CPE_OPTIONAL_DEPS = frozenset({"wolfcrypt"}) + + def fail(path, msg): print(f"FAIL [{path}]: {msg}", file=sys.stderr) sys.exit(1) @@ -81,6 +89,11 @@ def validate_cyclonedx(path, d, name_prefix, min_properties, require_deps): if not matches[0].get("version"): fail(path, f"dependency component {dep_name!r} has no version " f"(pass --dep-version or set WOLFSSL_DIR)") + if dep_name in _CPE_OPTIONAL_DEPS: + if not matches[0].get("purl"): + fail(path, f"dependency component {dep_name!r} has no purl " + f"(provenance id; matching uses the wolfssl CPE)") + continue if not matches[0].get("cpe"): fail(path, f"dependency component {dep_name!r} has no cpe " f"(CPE-driven scanners cannot match it)") diff --git a/tests/test_gen_sbom.py b/tests/test_gen_sbom.py index 13d8856..af0dde2 100644 --- a/tests/test_gen_sbom.py +++ b/tests/test_gen_sbom.py @@ -854,13 +854,14 @@ def test_wolfssl_dep_entry_describes_the_linked_artefact(self): wolfssl['cpe']('5.7.4'), 'cpe:2.3:a:wolfssl:wolfssl:5.7.4:*:*:*:*:*:*:*') - def test_wolfcrypt_dep_entry_carries_registered_nvd_cpe(self): + def test_wolfcrypt_dep_entry_has_purl_and_no_cpe(self): + # Provenance only: NVD files crypto CVEs against wolfssl, not + # wolfcrypt. A nested CPE would not match today and would + # double-match later. wc = gs.DEP_META['wolfcrypt'] self.assertEqual(wc['name'], 'wolfcrypt') self.assertIsNone(wc['pkgconfig']) - self.assertEqual( - wc['cpe']('5.9.1'), - 'cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*') + self.assertIsNone(wc.get('cpe')) # Resolvable github PURL for the wolfssl release that ships it, # with a #wolfcrypt subpath so it does not collide with wolfssl. self.assertEqual( @@ -907,12 +908,14 @@ def test_dep_constructors_strip_plus_build_metadata(self): dirty = '1.2.3+local' for key, meta in gs.DEP_META.items(): with self.subTest(dep=key): - cpe = meta['cpe'](dirty) - self.assertNotIn('+', cpe, cpe) - self.assertIn(':1.2.3:', cpe) purl = meta['purl'](dirty) self.assertNotIn('+', purl, purl) self.assertIn('1.2.3', purl) + if not meta.get('cpe'): + continue + cpe = meta['cpe'](dirty) + self.assertNotIn('+', cpe, cpe) + self.assertIn(':1.2.3:', cpe) def test_openssl_dep_cpe_and_purl_drop_build_metadata(self): # wolfProvider patches OpenSSL BUILD_METADATA, so openssl version @@ -943,16 +946,19 @@ def test_openssl_dep_cpe_and_purl_drop_build_metadata(self): self.assertEqual( locators['purl'], 'pkg:github/openssl/openssl@openssl-3.5.4') - def test_every_dep_entry_carries_both_identifiers(self): - # A dep with only one identifier is invisible to half the scanner - # population: PURL serves OSV / Trivy / Dependency-Track, CPE serves - # NVD, which is what a CRA vulnerability-monitoring process keys on. + def test_every_dep_entry_carries_a_purl(self): + # PURL serves OSV / Trivy / Dependency-Track. CPE serves NVD. + # wolfcrypt is the exception: matching rides on the parent + # wolfssl CPE, so a nested wolfcrypt CPE is omitted on purpose. for key, meta in gs.DEP_META.items(): with self.subTest(dep=key): purl = meta['purl']('1.2.3') - cpe = meta['cpe']('1.2.3') self.assertTrue(purl.startswith('pkg:'), purl) self.assertIn('1.2.3', purl) + if key == 'wolfcrypt': + self.assertIsNone(meta.get('cpe')) + continue + cpe = meta['cpe']('1.2.3') self.assertTrue(cpe.startswith('cpe:2.3:a:'), cpe) self.assertEqual(len(cpe.split(':')), 13, cpe) self.assertIn(':1.2.3:', cpe) @@ -1955,13 +1961,12 @@ def test_wolfssl_dep_component_carries_nvd_cpe(self): self.assertEqual( comp['purl'], 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') - def test_wolfcrypt_dep_component_carries_nvd_cpe(self): + def test_wolfcrypt_dep_component_has_purl_and_no_cpe(self): _, comp = gs.cdx_dep_component( 'wolfboot', '2.9.0', 'wolfcrypt', {'wolfcrypt': '5.9.1'}) self.assertEqual(comp['name'], 'wolfcrypt') self.assertEqual(comp['version'], '5.9.1') - self.assertEqual( - comp['cpe'], 'cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*') + self.assertNotIn('cpe', comp) self.assertEqual( comp['purl'], 'pkg:github/wolfssl/wolfssl@v5.9.1-stable#wolfcrypt') @@ -2067,6 +2072,12 @@ def test_cpe_externalref_present_when_version_known(self): cpe_refs[0]['referenceLocator'], 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + def test_wolfcrypt_omits_cpe_externalref(self): + _, pkg = gs.spdx_dep_package('wolfcrypt', {'wolfcrypt': '5.9.1'}) + types = [r['referenceType'] for r in pkg.get('externalRefs', [])] + self.assertNotIn('cpe23Type', types) + self.assertIn('purl', types) + class TestGenerateCdx(unittest.TestCase): """gen-sbom:624 generate_cdx assembles the full CycloneDX 1.6 doc.""" @@ -2996,9 +3007,11 @@ def test_cdx_nests_wolfcrypt_inside_wolfssl(self): nested = {c['name']: c for c in top['wolfssl']['components']} self.assertEqual(set(nested), {'wolfcrypt'}) + self.assertEqual(nested['wolfcrypt']['version'], '5.9.1') self.assertEqual( - nested['wolfcrypt']['cpe'], - 'cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*') + nested['wolfcrypt']['purl'], + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable#wolfcrypt') + self.assertNotIn('cpe', nested['wolfcrypt']) def test_cdx_dependency_edges_follow_the_nesting(self): doc = gs.generate_cdx(**self.BASE_KW) @@ -3053,6 +3066,18 @@ def test_spdx_expresses_containment_not_a_second_dependency(self): self.assertEqual( rels[('SPDXRef-Package-wolfssl', 'SPDXRef-Package-wolfcrypt')], 'CONTAINS') + pkgs = {p['name']: p for p in doc['packages']} + wc_refs = {r['referenceType']: r['referenceLocator'] + for r in pkgs['wolfcrypt']['externalRefs']} + self.assertNotIn('cpe23Type', wc_refs) + self.assertEqual( + wc_refs['purl'], + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable#wolfcrypt') + ssl_refs = {r['referenceType']: r['referenceLocator'] + for r in pkgs['wolfssl']['externalRefs']} + self.assertEqual( + ssl_refs['cpe23Type'], + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') def test_spdx_wolfssl_own_sbom_contains_its_wolfcrypt(self): # wolfSSL ships wolfCrypt; it does not depend on it. @@ -3075,11 +3100,12 @@ def test_wolfssl_own_sbom_keeps_wolfcrypt_top_level(self): self.BASE_KW, name='wolfssl', version='5.9.1', enabled_deps=['wolfcrypt'], component_type='library')) self.assertEqual([c['name'] for c in doc['components']], ['wolfcrypt']) + self.assertNotIn('cpe', doc['components'][0]) - def test_tool_version_is_1_8(self): + def test_tool_version_is_1_9(self): doc = gs.generate_cdx(**self.BASE_KW) tools = doc['metadata']['tools']['components'] - self.assertEqual(tools[0]['version'], '1.8') + self.assertEqual(tools[0]['version'], '1.9') if __name__ == '__main__': @@ -3092,7 +3118,7 @@ class TestWolfcryptVersionInheritance(unittest.TestCase): Regression: the inheritance ran BEFORE _resolve_dep_versions, so it only ever saw versions passed explicitly via --dep-version. A downstream embedder (wolfBoot) whose wolfssl version came from pkg-config got a - wolfcrypt component with no version, no purl and no cpe, and exit 0. + wolfcrypt component with no version and no purl, and exit 0. """ def test_inherits_wolfssl_version_resolved_by_pkgconfig(self): @@ -3121,17 +3147,20 @@ def test_noop_when_wolfcrypt_not_enabled(self): ['wolfssl'], overrides, 'wolfboot', '2.9.0') self.assertNotIn('wolfcrypt', overrides) - def test_inherited_version_yields_a_resolvable_cpe(self): - # End state that matters: the CPE 2.3 formatted string is well formed - # (13 colon-separated fields) and carries the inherited version. + def test_inherited_version_yields_a_resolvable_purl_and_no_cpe(self): + # End state that matters: the nested component is versioned and + # has a resolvable PURL. Matching stays on the wolfssl CPE. overrides = {'wolfssl': '5.9.1', 'wolfcrypt': None} gs._inherit_wolfcrypt_version( ['wolfssl', 'wolfcrypt'], overrides, 'wolfboot', '2.9.0') _ref, comp = gs.cdx_dep_component( 'wolfcrypt', overrides['wolfcrypt'], 'wolfcrypt', overrides) self.assertEqual( - comp['cpe'], 'cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*') - self.assertEqual(len(comp['cpe'].split(':')), 13) + comp['purl'], + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable#wolfcrypt') + self.assertNotIn('cpe', comp) + + class TestEnabledDepsValidation(unittest.TestCase): """--dep-* must be exactly yes or no. diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 0cc8913..19161ef 100755 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -176,7 +176,7 @@ def unit_tests(): "cpe": "cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*", "components": [{ "name": "wolfcrypt", "version": "5.9.1", - "cpe": "cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*", + "purl": "pkg:github/wolfssl/wolfssl@v5.9.1-stable#wolfcrypt", }], }], }, f) @@ -186,6 +186,44 @@ def unit_tests(): stdout=subprocess.DEVNULL) check(rc == 0, "validator finds a nested dependency component") + no_purl = os.path.join(d, "no-purl.cdx.json") + with open(no_purl, "w") as f: + json.dump({ + "bomFormat": "CycloneDX", "specVersion": "1.6", + "metadata": {"component": {"name": "wolfboot", + "version": "2.9.0", + "properties": [{"a": "b"}]}}, + "components": [{ + "name": "wolfssl", "version": "5.9.1", + "cpe": "cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*", + "components": [{ + "name": "wolfcrypt", "version": "5.9.1", + }], + }], + }, f) + rc = subprocess.call([sys.executable, VALIDATE, + "--require-dep-version", "wolfcrypt", no_purl], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + check(rc != 0, "validator rejects nested wolfcrypt without a PURL") + + no_cpe = os.path.join(d, "no-cpe.cdx.json") + with open(no_cpe, "w") as f: + json.dump({ + "bomFormat": "CycloneDX", "specVersion": "1.6", + "metadata": {"component": {"name": "wolfboot", + "version": "2.9.0", + "properties": [{"a": "b"}]}}, + "components": [{ + "name": "wolfssl", "version": "5.9.1", + }], + }, f) + rc = subprocess.call([sys.executable, VALIDATE, + "--require-dep-version", "wolfssl", no_cpe], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + check(rc != 0, "validator still requires a CPE on wolfssl") + def find_gen_sbom(explicit): if explicit and os.path.isfile(explicit):