diff --git a/Makefile b/Makefile index 6deec40694..564de4edf0 100644 --- a/Makefile +++ b/Makefile @@ -937,8 +937,9 @@ pico-sdk-info: FORCE # build CFLAGS, to the vendored wolfGlass driver under tools/sbom/. # # wolfcrypt sources are compiled directly into the wolfBoot image. They stay -# in the source-set hash, and are also declared as a wolfcrypt component so a -# CPE-driven scan can match the registered NVD product wolfssl:wolfcrypt. +# in the source-set hash, and are also declared as a nested wolfcrypt +# component (PURL / provenance). Matching uses the wolfssl CPE. Do not emit +# a wolfcrypt CPE: NVD files crypto CVEs against wolfssl, not wolfcrypt. # # Optional make variables: # HOSTCC Host C compiler for macro capture (default: cc) @@ -983,8 +984,8 @@ SBOM_INCLUDE_DIRS:=$(WOLFBOOT_ROOT)/include $(WOLFBOOT_LIB_WOLFSSL) # user_settings.h includes the generated target.h, so it must exist before the # capture runs. It also carries the flash layout the SBOM records. SBOM_PREREQS:=include/target.h sbom-check-sources -# Coat: wolfssl (TLS/library CPE) + wolfcrypt (crypto CPE). Sources remain in -# the merkle hash; the components give scanners resolvable identifiers. +# Coat: wolfssl (matching CPE) + nested wolfcrypt (PURL / provenance). +# Sources remain in the merkle hash; the wolfssl CPE is what NVD matches. SBOM_DEP_WOLFSSL?=yes SBOM_DEP_WOLFCRYPT?=yes SBOM_WOLFSSL_VERSION?=$(shell sed -n \ diff --git a/docs/SBOM.md b/docs/SBOM.md index 194178a104..8d3d7e903e 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -135,16 +135,17 @@ inside the `wolfssl` component that represents the release they ship in submodule's version). CycloneDX expresses that as a sub-component plus a `wolfssl → wolfcrypt` dependency edge; SPDX as a `CONTAINS` relationship. -That coat carries machine-resolvable identifiers for both scanner families: +That coat carries machine-resolvable identifiers: * `cpe:2.3:a:wolfssl:wolfssl::*:*:*:*:*:*:*` — NVD product for the - wolfSSL library, and the identifier that actually matches: wolfCrypt - advisories are filed against this product, not against the wolfcrypt one. -* `cpe:2.3:a:wolfssl:wolfcrypt::*:*:*:*:*:*:*` — NVD product for - wolfCrypt. Registered, but no CVE is mapped to it today, so it documents - provenance rather than driving matches. + wolfSSL library, and the identifier that matches: wolfCrypt advisories + are filed against this product, not against wolfcrypt. * `pkg:github/wolfssl/wolfssl@v-stable` — resolvable PURL for the wolfssl release (lowercase per purl-spec; `-stable` is the real tag). +* `pkg:github/wolfssl/wolfssl@v-stable#wolfcrypt` — nested + wolfcrypt provenance (supplier, version, PURL). No wolfcrypt CPE: that + product has no mapped CVEs today, and a second CPE on the same sources + is a future double-match risk. * wolfBoot itself: `cpe:2.3:a:wolfssl:wolfboot::*:*:*:*:*:*:*` — registered in the NVD Official CPE Dictionary (published 2026-08-10). @@ -163,8 +164,9 @@ wolfssl:sbom:wolfssl-subset-basis=captured The `wolfssl` component stays in the document regardless. Dropping it would read as more precise and would take the scan from every wolfSSL advisory to -none. Narrow the TLS-only CVEs with a VEX statement instead, which is the -mechanism designed to say "present but not exploitable here". +none. A crypto-only image versus TLS firmware is a disposition you record +on your product. A VEX feed that states `not_affected` / `code_not_present` +for TLS-only CVEs is separate work. ### How the configuration is captured diff --git a/tools/sbom/.wolfglass-rev b/tools/sbom/.wolfglass-rev index 74d3dc681a..8fccd6d223 100644 --- a/tools/sbom/.wolfglass-rev +++ b/tools/sbom/.wolfglass-rev @@ -1 +1 @@ -b44ae8d1d578ccf0b4269ffb395157cfb3fab0dc +48796eab98b7f763f6a083a848ca85c4bf637e95 diff --git a/tools/sbom/build/sbom.cmake b/tools/sbom/build/sbom.cmake index cd5430b707..2eddda3f55 100644 --- a/tools/sbom/build/sbom.cmake +++ b/tools/sbom/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/tools/sbom/build/sbom.mk b/tools/sbom/build/sbom.mk index 6123047c0b..eb13232d75 100644 --- a/tools/sbom/build/sbom.mk +++ b/tools/sbom/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/tools/sbom/gen-sbom b/tools/sbom/gen-sbom index 918550f986..0f8043511f 100755 --- a/tools/sbom/gen-sbom +++ b/tools/sbom/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 @@ -346,14 +350,16 @@ def build_timestamp(): # / Dependency-Track resolve CVEs against the right package). Algorithm # enablement is captured separately via build_props (HAVE_FALCON, ...). # -# Every entry carries both machine-resolvable identifiers, because the two -# scanner families do not agree on one. PURL serves the ecosystem scanners -# (OSV, GHSA, Trivy, Dependency-Track); CPE serves NVD, which is what a CRA / -# IEC 62443 vulnerability-monitoring process keys on. A dependency with only -# a PURL is invisible to a CPE-driven scan, so wolfSSL advisories never reach -# the integrator of a product that embeds wolfSSL. Each `cpe` value must be -# the vendor:product pair NVD actually registers for that dependency; never -# synthesize one. +# Every linked-library entry carries both machine-resolvable identifiers, +# because the two scanner families do not agree on one. PURL serves the +# ecosystem scanners (OSV, GHSA, Trivy, Dependency-Track); CPE serves NVD, +# which is what a CRA / IEC 62443 vulnerability-monitoring process keys on. +# Nested wolfcrypt is the exception: it is provenance (PURL only). NVD files +# crypto CVEs against the parent wolfssl CPE, and a second CPE on the same +# sources is a future double-match risk. A linked dependency with only a +# PURL is still invisible to a CPE-driven scan, so wolfssl itself must keep +# its CPE. Each `cpe` value must be the vendor:product pair NVD actually +# registers; never synthesize one. Omit `cpe` from DEP_META to skip it. DEP_META = { # wolfssl itself, declared as a dependency by downstream wolfSSL-stack # products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl. Only @@ -376,11 +382,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 +399,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 +1080,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 - # 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) + # Emit CPE only when DEP_META provides a constructor. Nested + # wolfcrypt has none; matching rides on the parent wolfssl CPE. + # A versionless CPE matches every release, so never emit one + # without a resolved version. + 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 +1109,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 +1529,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 +1540,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 +1672,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/tools/sbom/validate_sbom.py b/tools/sbom/validate_sbom.py index b3916e51ea..6d40df7ad9 100755 --- a/tools/sbom/validate_sbom.py +++ b/tools/sbom/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)")