diff --git a/Makefile.am b/Makefile.am index 30c3ccdf0ec..720cb8323ad 100644 --- a/Makefile.am +++ b/Makefile.am @@ -583,6 +583,8 @@ sbom: --options-h $(abs_builddir)/wolfssl/options.h \ --lib "$$sbom_lib" \ --dep-libz "$(ENABLED_LIBZ)" \ + --dep-wolfcrypt yes \ + --dep-version wolfcrypt=$(PACKAGE_VERSION) \ $(foreach dv,$(SBOM_DEP_VERSIONS),--dep-version '$(dv)') \ --cdx-out $(abs_builddir)/$(SBOM_CDX) \ --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ diff --git a/scripts/gen-sbom b/scripts/gen-sbom index 9225d7c34bf..31e99a0cea9 100755 --- a/scripts/gen-sbom +++ b/scripts/gen-sbom @@ -20,8 +20,34 @@ from datetime import datetime, timezone # `metadata.tools.components[].version` and SPDX `creationInfo.creators` # fields. Reproducibility CI keys on byte-equal SBOMs across re-runs, # so this constant must change in lockstep with the output it produces. +# +# The CLI counts as auditor-visible: '1.2' shipped in two incompatible +# shapes because dropping --dep-liboqs did not bump it, leaving vendored +# copies indistinguishable by the only identifier the SBOM records. +# +# 1.6 A product whose CPE is only pending at NVD no longer emits a `cpe` +# field; the intended identifier moves to +# `wolfssl:sbom:cpe-requested` alongside +# `wolfssl:sbom:cpe-status=pending`. wolfCrypt is nested inside the +# wolfssl component (CycloneDX sub-component + CONTAINS on the SPDX +# side) instead of sitting beside it, and `--crypto-only` records +# `wolfssl:sbom:wolfssl-subset=wolfcrypt-only` when only the crypto +# subset of the wolfSSL release is compiled in. +# 1.5 Emit wolfCrypt as its own component (registered CPE +# wolfssl:wolfcrypt). Main-package CPEs come from PRODUCT_CPE only: +# registered NVD pairs, plus pending submissions (e.g. wolfBoot) so +# the SBOM and the future dictionary entry agree. wolfssh uses the +# NVD vendor `wolfssh`, not `wolfssl`. +# 1.4 Dependency components carry a CPE 2.3 identifier, so a scanner that +# matches on CPE (NVD) sees the linked dependency and not only the +# product. pkg:github PURLs use the canonical lowercase namespace and +# name, and a version that is the project's real release tag. +# 1.3 Valueless '#define X' records an empty value instead of '1'. +# Warns when the licence file is the full GPL text, where the +# -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.2' +GEN_SBOM_VERSION = '1.6' # Placeholder recorded in the component checksum fields when the operator # passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only @@ -68,6 +94,140 @@ def project_urls(name): } +# Release-tag form per project, keyed by lowercased package name. The version +# of a pkg:github PURL is a git ref, so `@v5.9.1` does not resolve for wolfSSL, +# whose releases are tagged `v5.9.1-stable`: an integrator's scanner cannot +# fetch the reference the SBOM points at. Only a project whose tag differs +# from the plain `v` form needs an entry here. +GITHUB_TAG_FORMS = { + 'wolfssl': 'v{version}-stable', + 'wolfssh': 'v{version}-stable', + 'wolfclu': 'v{version}-stable', + 'wolfscep': 'v{version}-stable', + 'wolfhsm': 'wolfHSM-v{version}', +} +DEFAULT_GITHUB_TAG_FORM = 'v{version}' + + +def github_release_tag(project, version): + """Return the git tag a pkg:github PURL for `project` must point at.""" + form = GITHUB_TAG_FORMS.get(project.lower(), DEFAULT_GITHUB_TAG_FORM) + return form.format(version=version) + + +def github_purl(namespace, repo, tag): + """Build a canonical pkg:github PURL. + + purl-spec requires the namespace and the name of the `github` type to be + lowercased, so `pkg:github/wolfSSL/wolfssl` is not canonical: a consumer + that compares PURL strings (Dependency-Track, Trivy, OSV) reads it as a + different package from the one every other producer emits. The version is + a git ref and keeps the case the project tags with.""" + return f'pkg:github/{namespace.lower()}/{repo.lower()}@{tag}' + + +def wolfssl_project_purl(name, version): + """Canonical pkg:github PURL for a wolfSSL-stack project.""" + return github_purl('wolfSSL', name, github_release_tag(name, version)) + + +# Official CPE 2.3 vendor:product pairs. Main-package CPEs are emitted only +# from this table so a product without an entry never invents a silent false +# match against NVD. +# +# status: +# registered — present in the Official CPE Dictionary; scanners can match +# NVD advisories today, so the `cpe` field is emitted. +# pending — intended name for an NVD dictionary submission, NOT in the +# dictionary yet. No `cpe` field is emitted: a scanner +# cannot distinguish an unlisted CPE from a listed one with +# no advisories, so publishing it asserts a match that does +# not exist. The intended string is recorded instead as +# `wolfssl:sbom:cpe-requested` plus +# `wolfssl:sbom:cpe-status=pending`, which keeps the SBOM +# and the eventual dictionary entry byte-identical without +# claiming registration. Promote the entry to `registered` +# once NVD publishes it. See docs/cpe-requests/ for the +# NIST submission materials. +# +# NVD currently registers under vendor wolfssl: wolfssl, wolfcrypt, +# wolfmqtt, yassl. wolfSSH is registered under vendor wolfssh. +PRODUCT_CPE = { + 'wolfssl': {'vendor': 'wolfssl', 'product': 'wolfssl', 'status': 'registered'}, + 'wolfcrypt': {'vendor': 'wolfssl', 'product': 'wolfcrypt', 'status': 'registered'}, + 'wolfmqtt': {'vendor': 'wolfssl', 'product': 'wolfmqtt', 'status': 'registered'}, + 'wolfssh': {'vendor': 'wolfssh', 'product': 'wolfssh', 'status': 'registered'}, + 'wolfboot': {'vendor': 'wolfssl', 'product': 'wolfboot', 'status': 'pending'}, +} + + +def _product_cpe_string(meta, version): + return ( + f"cpe:2.3:a:{meta['vendor']}:{meta['product']}:{version}:*:*:*:*:*:*:*" + ) + + +def product_cpe(name, version): + """Return the CPE 2.3 to publish for a product, or None. + + Only a `registered` product yields a value. A `pending` product returns + None here and surfaces through product_cpe_requested() instead.""" + meta = PRODUCT_CPE.get((name or '').lower()) + if not meta or not version or meta['status'] != 'registered': + return None + return _product_cpe_string(meta, version) + + +def product_cpe_requested(name, version): + """Return the CPE 2.3 a `pending` product has submitted to NVD, else None.""" + meta = PRODUCT_CPE.get((name or '').lower()) + if not meta or not version or meta['status'] != 'pending': + return None + return _product_cpe_string(meta, version) + + +def product_cpe_status(name): + """Return 'registered', 'pending', or None for a product name.""" + meta = PRODUCT_CPE.get((name or '').lower()) + return meta['status'] if meta else None + + +# Defining this macro compiles the wolfSSL tree with the TLS layer removed, +# leaving only wolfCrypt. wolfBoot sets it in include/user_settings.h for +# every build except wolfHSM-server-with-cert-chain-verify, so it is the +# authoritative signal for "this image holds the crypto subset only" -- more +# reliable than a build-system flag, which would have to restate that +# condition and would drift from it. +CRYPTO_ONLY_MACRO = 'WOLFCRYPT_ONLY' + +# Value recorded for wolfssl:sbom:wolfssl-subset when the macro (or an +# explicit --crypto-only yes) says only wolfCrypt is compiled in. +WOLFSSL_SUBSET_CRYPTO_ONLY = 'wolfcrypt-only' + + +def resolve_crypto_only(mode, build_props): + """Return (is_crypto_only, basis) for --crypto-only auto|yes|no. + + basis is 'declared' when the operator stated it, 'captured' when it was + read out of the configuration macros this SBOM records, and 'unknown' + when there are no captured macros to read (a --source-only front end + such as Zephyr, where nothing in the inputs can settle the question). + + A crypto-only image does NOT drop the wolfssl component. NVD maps 30 + CVEs to cpe:2.3:a:wolfssl:wolfssl: and zero to + cpe:2.3:a:wolfssl:wolfcrypt, because wolfCrypt advisories are filed + against the wolfssl product; removing the wolfssl component to be + precise about the subset would silently take the scan from 30 + matchable advisories to none. The subset is recorded as a property and + narrowed per-CVE with VEX instead.""" + mode = (mode or 'auto').lower() + if mode in ('yes', 'no'): + return mode == 'yes', 'declared' + if not build_props: + return False, 'unknown' + return any(k == CRYPTO_ONLY_MACRO for k, _ in build_props), 'captured' + + 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, @@ -102,6 +262,15 @@ def build_timestamp(): # the *linked artefact* (so vulnerability scanners like OSV / Grype / Trivy # / 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. DEP_META = { # wolfssl itself, declared as a dependency by downstream wolfSSL-stack # products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl. Only @@ -120,7 +289,30 @@ DEP_META = { 'license': 'GPL-3.0-only', 'download': 'https://github.com/wolfSSL/wolfssl', 'pkgconfig': 'wolfssl', - 'purl': lambda v: f'pkg:github/wolfSSL/wolfssl@v{v}', + 'purl': lambda v: wolfssl_project_purl('wolfssl', v), + # The CPE NVD registers for the wolfSSL library. + 'cpe': lambda v: f'cpe:2.3:a: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': { + 'name': 'wolfcrypt', + 'supplier': 'wolfSSL Inc.', + 'license': 'GPL-3.0-only', + 'download': 'https://github.com/wolfSSL/wolfssl', + # Co-released with wolfssl; no separate .pc file. Version comes from + # --dep-version wolfcrypt=X.Y.Z, or is inherited from the wolfssl + # dep / main package version (see main()). + '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. + 'purl': lambda v: wolfssl_project_purl('wolfssl', v) + '#wolfcrypt', + 'cpe': lambda v: f'cpe:2.3:a:wolfssl:wolfcrypt:{v}:*:*:*:*:*:*:*', }, 'libz': { 'name': 'zlib', @@ -129,8 +321,10 @@ DEP_META = { 'download': 'https://github.com/madler/zlib', 'pkgconfig': 'zlib', # pkg:github resolves in OSV / GHSA / Snyk / Trivy without the - # vendor:product mapping a pkg:generic PURL would force. - 'purl': lambda v: f'pkg:github/madler/zlib@{v}', + # vendor:product mapping a pkg:generic PURL would force. zlib tags + # its releases `vX.Y.Z`, so the bare pkg-config version needs the `v`. + 'purl': lambda v: github_purl('madler', 'zlib', f'v{v}'), + 'cpe': lambda v: f'cpe:2.3:a:zlib:zlib:{v}:*:*:*:*:*:*:*', }, # openssl, declared as a dependency by the OpenSSL-compat products # (wolfProvider, wolfEngine) that link libcrypto/libssl alongside wolfSSL. @@ -145,7 +339,8 @@ DEP_META = { 'license': 'Apache-2.0', 'download': 'https://github.com/openssl/openssl', 'pkgconfig': 'openssl', - 'purl': lambda v: f'pkg:github/openssl/openssl@openssl-{v}', + 'purl': lambda v: github_purl('openssl', 'openssl', f'openssl-{v}'), + 'cpe': lambda v: f'cpe:2.3:a:openssl:openssl:{v}:*:*:*:*:*:*:*', }, } @@ -239,6 +434,22 @@ def cdx_license_block(license_expr, license_text): return [{'expression': license_expr}] +# Section headings that appear only in the verbatim GNU licence text, never +# in a short per-project licensing statement such as wolfSSL's LICENSING. +_FULL_LICENSE_MARKERS = ( + 'terms and conditions for copying, distribution and modification', + 'terms and conditions', + 'how to apply these terms to your new programs', +) + + +def _is_full_license_text(text): + """True when the licence file is the verbatim GNU licence rather than a + statement about how the project licenses under it.""" + low = text.lower() + return sum(marker in low for marker in _FULL_LICENSE_MARKERS) >= 2 + + def detect_license(license_file): """Parse LICENSING file and return an SPDX license ID. @@ -289,6 +500,24 @@ def detect_license(license_file): if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later', excerpt, re.IGNORECASE): return f'GPL-{version}.0-or-later' + if _is_full_license_text(text): + # The verbatim GPL is the licence itself, not a statement about how + # this project licenses under it. Whether the project grants "or any + # later version" appears only in the per-file headers, so -only here + # is a guess that silently narrows the grant. wolfBoot ships the full + # GPLv3 as LICENSE while every source header says "either version 3 + # ... or (at your option) any later version", i.e. GPL-3.0-or-later. + print( + f"WARNING: {license_file} is the full GPL text, not a licensing " + f"statement.\n" + f" It cannot say whether this project grants " + f"'or any later version', so\n" + f" GPL-{version}.0-only is assumed and may understate the " + f"grant. Confirm against\n" + f" your source headers and pass --license-override " + f"GPL-{version}.0-or-later if so\n" + f" (Make: SBOM_LICENSE_OVERRIDE).", + file=sys.stderr) return f'GPL-{version}.0-only' @@ -358,7 +587,10 @@ def dep_version(key, overrides=None): entry.""" if overrides and key in overrides: return overrides[key] - return pkgconfig_version(DEP_META[key]['pkgconfig']) + pkg = DEP_META[key].get('pkgconfig') + if not pkg: + return None + return pkgconfig_version(pkg) # Patterns for #define names that pollute the SBOM with build-environment @@ -755,9 +987,13 @@ def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None): if version: 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) else: print(f"WARNING: version unknown for {meta['name']}; " - "omitting version and purl", file=sys.stderr) + "omitting version, purl and cpe", file=sys.stderr) return bom_ref, comp @@ -778,30 +1014,73 @@ def spdx_dep_package(key, dep_version_overrides=None): 'copyrightText': 'NOASSERTION', } if version: - pkg['externalRefs'] = [{ - 'referenceCategory': 'PACKAGE-MANAGER', - 'referenceType': 'purl', - 'referenceLocator': meta['purl'](version), - }] + pkg['externalRefs'] = [ + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'cpe23Type', + 'referenceLocator': meta['cpe'](version), + }, + { + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': meta['purl'](version), + }, + ] return spdx_id, pkg +# CycloneDX 1.6 component.type -> SPDX 2.3 primaryPackagePurpose, so the two +# documents agree on what kind of artifact this is. A bootloader described as +# a 'library' misfiles the product for anyone triaging by artifact class, which +# under IEC 62443 is the difference between a component and the firmware it +# boots. Only the values a wolfSSL-stack product can legitimately be. +SPDX_PACKAGE_PURPOSE = { + 'library': 'LIBRARY', + 'firmware': 'FIRMWARE', + 'application': 'APPLICATION', + 'framework': 'FRAMEWORK', + 'device': 'DEVICE', + 'file': 'FILE', +} + + def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, serial, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', - hash_source='lib', srcs_basenames=None, file_entries=None): + hash_source='lib', srcs_basenames=None, file_entries=None, + component_type='library', wolfssl_subset=None, + subset_basis=None): bom_ref = derived_uuid(name, version, 'package') urls = project_urls(name) - dep_bom_refs = [] - components = [] + # wolfCrypt is shipped inside the wolfSSL release, not beside it, so when + # both are recorded the wolfcrypt component nests in the wolfssl one and + # the dependency edge runs wolfssl -> wolfcrypt. wolfssl stays top-level + # because it is the only identifier of the pair that NVD maps advisories + # to (see resolve_crypto_only). + dep_refs, dep_comps = {}, {} for key in enabled_deps: ref, comp = cdx_dep_component(name, version, key, dep_version_overrides) - dep_bom_refs.append(ref) - components.append(comp) - + dep_refs[key] = ref + dep_comps[key] = comp + + nest_wolfcrypt = 'wolfcrypt' in dep_comps and 'wolfssl' in dep_comps + if nest_wolfcrypt: + dep_comps['wolfssl']['components'] = [dep_comps['wolfcrypt']] + + top_keys = [k for k in enabled_deps + if not (nest_wolfcrypt and k == 'wolfcrypt')] + components = [dep_comps[k] for k in top_keys] + dep_bom_refs = [dep_refs[k] for k in top_keys] + + # A valueless `#define X` is recorded with an empty value, not '1'. + # Coercing to '1' made the two indistinguishable and produced actively + # misleading entries where the macro names a quantity: wolfBoot's + # target.h emits `#define WOLFBOOT_LOAD_ADDRESS` with nothing after it + # when the target does not set a load address, which an auditor then read + # as the address literally being 1. properties = [ - {'name': f'wolfssl:build:{k}', 'value': v if v else '1'} + {'name': f'wolfssl:build:{k}', 'value': v} for k, v in build_props ] # Document what the SHA-256 in `hashes` represents, on every entry @@ -830,16 +1109,43 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, 'value': ','.join(srcs_basenames), }) + if wolfssl_subset: + # Which part of the wolfSSL release is actually compiled in. Without + # this an integrator reading a wolfssl component assumes the TLS + # stack is present and triages TLS advisories that cannot apply. + properties.append({ + 'name': 'wolfssl:sbom:wolfssl-subset', + 'value': wolfssl_subset, + }) + properties.append({ + 'name': 'wolfssl:sbom:wolfssl-subset-basis', + 'value': subset_basis or 'unknown', + }) + + cpe = product_cpe(name, version) + cpe_requested = product_cpe_requested(name, version) + if cpe_requested: + # NVD does not list this product yet, so no `cpe` field is emitted. + # Record the submitted identifier and its status so the SBOM and the + # dictionary entry agree the day NVD publishes it. + properties.append({ + 'name': 'wolfssl:sbom:cpe-status', + 'value': 'pending', + }) + properties.append({ + 'name': 'wolfssl:sbom:cpe-requested', + 'value': cpe_requested, + }) + main_component = { 'bom-ref': bom_ref, - 'type': 'library', + 'type': component_type, 'supplier': {'name': supplier}, 'name': name, 'version': version, 'licenses': cdx_license_block(license_id, license_text), 'copyright': f'Copyright (C) 2006-{year} wolfSSL Inc.', - 'cpe': f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*', - 'purl': f'pkg:github/wolfSSL/{name}@v{version}', + 'purl': wolfssl_project_purl(name, version), 'hashes': [{'alg': 'SHA-256', 'content': lib_hash}], 'externalReferences': [ {'type': 'vcs', @@ -855,6 +1161,8 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, ], 'properties': properties, } + if cpe: + main_component['cpe'] = cpe # Sub-component file entries (CycloneDX file-typed components nested # under the library). Autotools paths nest the linked library # binary so an auditor running a CDX parser can resolve the SHA-256 @@ -894,7 +1202,15 @@ def generate_cdx(name, version, supplier, license_id, license_text, lib_hash, 'components': components, 'dependencies': [ {'ref': bom_ref, 'dependsOn': dep_bom_refs}, - *[{'ref': r, 'dependsOn': []} for r in dep_bom_refs], + *[ + { + 'ref': dep_refs[k], + 'dependsOn': ([dep_refs['wolfcrypt']] + if nest_wolfcrypt and k == 'wolfssl' + else []), + } + for k in enabled_deps + ], ], } @@ -903,7 +1219,9 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid, enabled_deps, build_props, dep_version_overrides=None, hash_kind='library-binary', hash_source='lib', srcs_basenames=None, - document_namespace=None, file_entries=None): + document_namespace=None, file_entries=None, + component_type='library', wolfssl_subset=None, + subset_basis=None): build_defines = ', '.join(k for k, _ in build_props) # Hash-kind / source-set / bomsh-traced-binary information used to # be stuffed into the package `comment` as `key=value` slugs, which @@ -933,6 +1251,10 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, _annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}') if srcs_basenames: _annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames)) + if wolfssl_subset: + _annotate(f'wolfssl:sbom:wolfssl-subset={wolfssl_subset}') + _annotate('wolfssl:sbom:wolfssl-subset-basis=' + + (subset_basis or 'unknown')) urls = project_urls(name) # Main-package SPDXID derived from --name (sanitised per SPDX 2.3 idstring @@ -941,6 +1263,34 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, # is 'SPDXRef-Package-wolfssl', unchanged from before. main_spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', name) + cpe = product_cpe(name, version) + cpe_requested = product_cpe_requested(name, version) + if cpe_requested: + # Pending at NVD: no cpe23Type external ref, since that reference + # category asserts a dictionary entry a scanner can resolve. + _annotate('wolfssl:sbom:cpe-status=pending') + _annotate(f'wolfssl:sbom:cpe-requested={cpe_requested}') + + external_refs = [] + if cpe: + external_refs.append({ + 'referenceCategory': 'SECURITY', + 'referenceType': 'cpe23Type', + 'referenceLocator': cpe, + }) + external_refs.extend([ + { + 'referenceCategory': 'PACKAGE-MANAGER', + 'referenceType': 'purl', + 'referenceLocator': wolfssl_project_purl(name, version), + }, + { + 'referenceCategory': 'SECURITY', + 'referenceType': 'advisory', + 'referenceLocator': urls['advisories'], + }, + ]) + wolfssl_pkg = { 'SPDXID': main_spdx_id, 'name': name, @@ -952,27 +1302,11 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'licenseConcluded': license_id, 'licenseDeclared': license_id, 'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.', + 'primaryPackagePurpose': SPDX_PACKAGE_PURPOSE.get( + component_type, 'LIBRARY'), 'comment': f'Build configuration defines: {build_defines}', 'annotations': annotations, - 'externalRefs': [ - { - 'referenceCategory': 'SECURITY', - 'referenceType': 'cpe23Type', - 'referenceLocator': ( - f'cpe:2.3:a:wolfssl:{name}:{version}:*:*:*:*:*:*:*' - ) - }, - { - 'referenceCategory': 'PACKAGE-MANAGER', - 'referenceType': 'purl', - 'referenceLocator': f'pkg:github/wolfSSL/{name}@v{version}', - }, - { - 'referenceCategory': 'SECURITY', - 'referenceType': 'advisory', - 'referenceLocator': urls['advisories'], - }, - ], + 'externalRefs': external_refs, } # No SPDX `files[]` / `hasFiles[]` inventory. spdx-tools (the @@ -1000,14 +1334,38 @@ def generate_spdx(name, version, supplier, license_id, license_text, lib_hash, 'relationshipType': 'DESCRIBES', }] + # SPDX has no nested-package construct, so the containment the CycloneDX + # side expresses by nesting is a CONTAINS relationship here: wolfcrypt is + # part of the wolfssl release the product depends on, not a second thing + # the product depends on directly. + dep_spdx_ids = {} for key in enabled_deps: spdx_id, pkg = spdx_dep_package(key, dep_version_overrides) + dep_spdx_ids[key] = spdx_id packages.append(pkg) - relationships.append({ - 'spdxElementId': main_spdx_id, - 'relatedSpdxElement': spdx_id, - 'relationshipType': 'DEPENDS_ON', - }) + + # The container is the wolfssl release: the dependency package when the + # product embeds wolfSSL, or this package itself in wolfSSL's own SBOM. + if 'wolfssl' in dep_spdx_ids: + wolfcrypt_container = dep_spdx_ids['wolfssl'] + elif name.lower() == 'wolfssl': + wolfcrypt_container = main_spdx_id + else: + wolfcrypt_container = None + + for key in enabled_deps: + if key == 'wolfcrypt' and wolfcrypt_container: + relationships.append({ + 'spdxElementId': wolfcrypt_container, + 'relatedSpdxElement': dep_spdx_ids['wolfcrypt'], + 'relationshipType': 'CONTAINS', + }) + else: + relationships.append({ + 'spdxElementId': main_spdx_id, + 'relatedSpdxElement': dep_spdx_ids[key], + 'relationshipType': 'DEPENDS_ON', + }) # SPDX 2.3 §6.5: documentNamespace must be a unique URI; it is NOT # required to resolve to anything. Default to `urn:uuid:` @@ -1088,6 +1446,12 @@ def main(): parser.add_argument('--version', required=True, help='Package version') parser.add_argument('--supplier', default='wolfSSL Inc.', help='Supplier name (default: wolfSSL Inc.)') + parser.add_argument('--component-type', default='library', + choices=sorted(SPDX_PACKAGE_PURPOSE), + help='What kind of artifact this is: CycloneDX ' + 'component.type, mirrored to SPDX ' + 'primaryPackagePurpose. Use firmware for a ' + 'bootloader such as wolfBoot (default: library)') parser.add_argument('--license-file', required=True, help='Path to LICENSING file for SPDX ID detection') parser.add_argument('--license-override', default='', @@ -1164,6 +1528,22 @@ def main(): 'wolfSSL\'s own SBOM leaves this off. Combine ' '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 ' + '--dep-version wolfcrypt=X.Y.Z, or inherit the ' + 'wolfssl / package version when unset.') + parser.add_argument('--crypto-only', default='auto', + choices=['auto', 'yes', 'no'], + help='Whether only the wolfCrypt subset of the ' + 'wolfSSL release is compiled in. auto (default) ' + 'reads the ' + CRYPTO_ONLY_MACRO + ' macro out ' + 'of the captured build configuration. Records ' + 'wolfssl:sbom:wolfssl-subset; it never removes ' + 'the wolfssl component, which is the only one ' + 'NVD maps advisories to.') parser.add_argument('--dep-openssl', default='no', help='yes to record openssl as a dependency component ' '(for OpenSSL-compat products such as wolfProvider ' @@ -1237,13 +1617,22 @@ def main(): enabled_deps = [ key for key, flag in [ - ('wolfssl', args.dep_wolfssl), - ('openssl', args.dep_openssl), - ('libz', args.dep_libz), + ('wolfssl', args.dep_wolfssl), + ('wolfcrypt', args.dep_wolfcrypt), + ('openssl', args.dep_openssl), + ('libz', args.dep_libz), ] if flag.lower() == 'yes' ] dep_version_overrides = _parse_dep_version_overrides(args.dep_version) + # wolfcrypt has no pkg-config. Inherit a version so the CPE/PURL are + # not dropped: prefer an explicit --dep-version wolfcrypt=, else the + # wolfssl dep version, else (for wolfSSL's own SBOM) the package version. + if 'wolfcrypt' in enabled_deps and 'wolfcrypt' not in dep_version_overrides: + if dep_version_overrides.get('wolfssl'): + dep_version_overrides['wolfcrypt'] = dep_version_overrides['wolfssl'] + elif args.name.lower() == 'wolfssl': + dep_version_overrides['wolfcrypt'] = args.version # Resolve each enabled dependency's version once, here, and feed the # result to both the CDX and SPDX emitters via the overrides map (see # _resolve_dep_versions for the once-per-dep pkg-config rationale). @@ -1279,6 +1668,19 @@ def main(): args.user_settings_define, ) + crypto_only, subset_basis = resolve_crypto_only(args.crypto_only, + build_props) + wolfssl_subset = WOLFSSL_SUBSET_CRYPTO_ONLY if crypto_only else None + if subset_basis == 'unknown' and 'wolfssl' in enabled_deps: + # A front end that captures no macros (--source-only) cannot answer + # the question either way. Say so rather than defaulting silently to + # "the whole of wolfSSL is in here". + print( + f"NOTE: no build configuration was captured, so {CRYPTO_ONLY_MACRO} " + "could not be read; the SBOM does not state whether only wolfCrypt " + "is compiled in. Pass --crypto-only yes|no to record it.", + file=sys.stderr) + file_entries = None if args.lib: # Refuse the empty-file SHA-256 as a component checksum. A @@ -1362,6 +1764,9 @@ def main(): hash_kind=hash_kind, hash_source=hash_source, srcs_basenames=srcs_basenames, file_entries=file_entries, + component_type=args.component_type, + wolfssl_subset=wolfssl_subset, + subset_basis=subset_basis, ) spdx = generate_spdx( args.name, args.version, args.supplier, @@ -1372,6 +1777,9 @@ def main(): srcs_basenames=srcs_basenames, document_namespace=(args.document_namespace or None), file_entries=file_entries, + component_type=args.component_type, + wolfssl_subset=wolfssl_subset, + subset_basis=subset_basis, ) try: diff --git a/scripts/test_gen_sbom.py b/scripts/test_gen_sbom.py index d46752f83e2..89c6a0bb118 100644 --- a/scripts/test_gen_sbom.py +++ b/scripts/test_gen_sbom.py @@ -832,7 +832,7 @@ def test_only_expected_deps_are_tracked(self): # 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'}) + {'wolfssl', 'wolfcrypt', 'openssl', 'libz'}) def test_wolfssl_dep_entry_describes_the_linked_artefact(self): wolfssl = gs.DEP_META['wolfssl'] @@ -844,9 +844,16 @@ def test_wolfssl_dep_entry_describes_the_linked_artefact(self): # 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') + # Canonical purl: lowercased namespace/name, and the real wolfSSL + # release tag (`-stable`) as the git ref the version resolves to. self.assertEqual( wolfssl['purl']('5.7.4'), - 'pkg:github/wolfSSL/wolfssl@v5.7.4') + 'pkg:github/wolfssl/wolfssl@v5.7.4-stable') + # The CPE NVD registers for wolfSSL, so a CPE-driven scan matches + # wolfSSL advisories against a product that embeds wolfSSL. + self.assertEqual( + wolfssl['cpe']('5.7.4'), + 'cpe:2.3:a:wolfssl:wolfssl:5.7.4:*:*:*:*:*:*:*') def test_openssl_dep_entry_describes_the_linked_artefact(self): openssl = gs.DEP_META['openssl'] @@ -860,6 +867,33 @@ def test_openssl_dep_entry_describes_the_linked_artefact(self): self.assertEqual( openssl['purl']('3.5.0'), 'pkg:github/openssl/openssl@openssl-3.5.0') + self.assertEqual( + openssl['cpe']('3.5.0'), + 'cpe:2.3:a:openssl:openssl:3.5.0:*:*:*:*:*:*:*') + + 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. + 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) + self.assertTrue(cpe.startswith('cpe:2.3:a:'), cpe) + self.assertEqual(len(cpe.split(':')), 13, cpe) + self.assertIn(':1.2.3:', cpe) + + def test_dep_purls_use_canonical_lowercase_namespace_and_name(self): + # purl-spec: the namespace and the name of the `github` type are not + # case sensitive and must be lowercased. A mixed-case purl is read as + # a different package by a consumer that compares purl strings. + for key, meta in gs.DEP_META.items(): + with self.subTest(dep=key): + purl = meta['purl']('1.2.3') + identifier = purl.split('@', 1)[0] + self.assertEqual(identifier, identifier.lower(), purl) def test_no_stale_dep_keys(self): # `falcon` is an algorithm, not a linked package; it must not @@ -872,6 +906,53 @@ def test_no_stale_dep_keys(self): self.assertNotIn(stale, gs.DEP_META) +class TestGithubPurl(unittest.TestCase): + """The pkg:github identifier a consumer has to resolve.""" + + def test_namespace_and_name_are_lowercased(self): + # purl-spec, github type: the namespace and the name are not case + # sensitive and must be lowercased. + self.assertEqual( + gs.github_purl('wolfSSL', 'wolfBoot', 'v2.9.0'), + 'pkg:github/wolfssl/wolfboot@v2.9.0') + + def test_version_keeps_the_case_the_project_tags_with(self): + # The version is a git ref, and refs are case sensitive, so it must + # not be normalized along with the namespace and the name. + self.assertEqual( + gs.github_purl('wolfSSL', 'wolfHSM', 'wolfHSM-v1.4.0'), + 'pkg:github/wolfssl/wolfhsm@wolfHSM-v1.4.0') + + def test_release_tag_form_per_project(self): + # wolfSSL and wolfSSH tag releases `-stable`; wolfBoot and wolfTPM do + # not. Emitting one shape for every project leaves half the stack + # pointing at a tag that does not exist. + self.assertEqual( + gs.github_release_tag('wolfssl', '5.9.1'), 'v5.9.1-stable') + self.assertEqual( + gs.github_release_tag('wolfssh', '1.5.0'), 'v1.5.0-stable') + self.assertEqual( + gs.github_release_tag('wolfhsm', '1.4.0'), 'wolfHSM-v1.4.0') + self.assertEqual( + gs.github_release_tag('wolfboot', '2.9.0'), 'v2.9.0') + self.assertEqual( + gs.github_release_tag('wolftpm', '4.0.0'), 'v4.0.0') + + def test_release_tag_lookup_is_case_insensitive(self): + # --name arrives from a Makefile variable, so its case is not ours to + # assume; 'wolfSSL' must resolve the same form as 'wolfssl'. + self.assertEqual( + gs.github_release_tag('wolfSSL', '5.9.1'), 'v5.9.1-stable') + + def test_project_purl_combines_both_rules(self): + self.assertEqual( + gs.wolfssl_project_purl('wolfboot', '2.9.0'), + 'pkg:github/wolfssl/wolfboot@v2.9.0') + self.assertEqual( + gs.wolfssl_project_purl('wolfssl', '5.9.1'), + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') + + class TestEnabledDepsCli(unittest.TestCase): """End-to-end test of the argparse plumbing for --dep-* flags. @@ -892,6 +973,7 @@ def test_dep_flags_are_accepted(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertIn('--dep-libz', result.stdout) self.assertIn('--dep-wolfssl', result.stdout) + self.assertIn('--dep-wolfcrypt', result.stdout) self.assertIn('--dep-openssl', result.stdout) def test_removed_flags_are_rejected(self): @@ -1751,13 +1833,18 @@ def test_returns_bomref_and_component(self): self.assertEqual(comp['version'], '1.3.1') self.assertTrue(comp['purl'].startswith('pkg:')) self.assertIn('zlib', comp['purl']) + # zlib tags releases `vX.Y.Z`, so the bare pkg-config version alone + # would not resolve to a git ref. + self.assertEqual(comp['purl'], 'pkg:github/madler/zlib@v1.3.1') + self.assertEqual(comp['cpe'], 'cpe:2.3:a:zlib:zlib:1.3.1:*:*:*:*:*:*:*') self.assertEqual(comp['externalReferences'][0]['type'], 'vcs') - def test_omits_version_and_purl_when_unknown(self): + def test_omits_version_purl_and_cpe_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. + # version from wrong version. A versionless CPE is worse than + # absent: `cpe:2.3:a:zlib:zlib::` matches every zlib release. original = gs.pkgconfig_version try: gs.pkgconfig_version = lambda *_a, **_k: None @@ -1766,9 +1853,23 @@ def test_omits_version_and_purl_when_unknown(self): gs.pkgconfig_version = original self.assertNotIn('version', comp) self.assertNotIn('purl', comp) + self.assertNotIn('cpe', comp) # bom-ref is still present and deterministic. self.assertTrue(ref) + def test_wolfssl_dep_component_carries_nvd_cpe(self): + # The wolfSSH / wolfBoot case an integrator's NVD-driven scanner + # needs: the linked wolfSSL must be identifiable by CPE, not by + # purl alone. + _, comp = gs.cdx_dep_component( + 'wolfssh', '1.5.0', 'wolfssl', {'wolfssl': '5.9.1'}) + self.assertEqual(comp['name'], 'wolfssl') + self.assertEqual(comp['version'], '5.9.1') + self.assertEqual( + comp['cpe'], 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + self.assertEqual( + comp['purl'], 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') + 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 @@ -1855,6 +1956,21 @@ def test_purl_externalref_present_when_version_known(self): self.assertIn('openssl', purl_refs[0]['referenceLocator']) self.assertIn('0.10.0', purl_refs[0]['referenceLocator']) + def test_cpe_externalref_present_when_version_known(self): + # SPDX parity with the CDX side: the dep package carries the same + # NVD identifier, under the SECURITY category SPDX 2.3 §11.1 defines + # for cpe23Type. + _, pkg = gs.spdx_dep_package('wolfssl', {'wolfssl': '5.9.1'}) + cpe_refs = [ + r for r in pkg.get('externalRefs', []) + if r.get('referenceType') == 'cpe23Type' + ] + self.assertEqual(len(cpe_refs), 1) + self.assertEqual(cpe_refs[0]['referenceCategory'], 'SECURITY') + self.assertEqual( + cpe_refs[0]['referenceLocator'], + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + class TestGenerateCdx(unittest.TestCase): """gen-sbom:624 generate_cdx assembles the full CycloneDX 1.6 doc.""" @@ -1897,9 +2013,10 @@ def test_main_component_fields(self): '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') + # force. The namespace and name are lowercased (purl-spec) and the + # version is the real release tag, which for wolfSSL is `-stable`. + self.assertEqual(comp['purl'], + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') self.assertEqual(comp['hashes'], [{'alg': 'SHA-256', 'content': 'a' * 64}]) self.assertEqual(comp['licenses'], @@ -1910,9 +2027,10 @@ def test_build_properties_emitted(self): 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') + # A valueless '#define X' keeps an empty value. Coercing it to '1' + # made it indistinguishable from '#define X 1' and misread any macro + # naming a quantity. + self.assertEqual(names['wolfssl:build:NO_DES3'], '') def test_dependency_refs_match_components(self): # Critical invariant: every bom-ref in `dependencies` must @@ -2343,7 +2461,7 @@ def test_main_package_purl_uses_pkg_github(self): self.assertEqual(len(purl_refs), 1) self.assertEqual( purl_refs[0]['referenceLocator'], - 'pkg:github/wolfSSL/wolfssl@v5.9.1') + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') def test_main_package_carries_advisory_external_ref(self): # SPDX 2.3 SECURITY/advisory externalRef pointing at the @@ -2604,5 +2722,94 @@ def test_object_store_integrity_skips_non_blob_files(self): self.assertTrue(ok, f'verifier flagged non-blob files: {messages}') + +class TestProductCpePolicy(unittest.TestCase): + def test_registered_wolfssl_cpe(self): + self.assertEqual( + gs.product_cpe('wolfssl', '5.9.1'), + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + self.assertEqual(gs.product_cpe_status('wolfssl'), 'registered') + + def test_wolfboot_pending_cpe_is_not_published(self): + # Not in the NVD dictionary, so no `cpe` may be published: a scanner + # cannot tell an unlisted CPE from a listed one with no advisories. + self.assertEqual(gs.product_cpe_status('wolfboot'), 'pending') + self.assertIsNone(gs.product_cpe('wolfboot', '2.9.0')) + self.assertEqual( + gs.product_cpe_requested('wolfboot', '2.9.0'), + 'cpe:2.3:a:wolfssl:wolfboot:2.9.0:*:*:*:*:*:*:*') + + def test_wolfssh_uses_nvd_vendor_wolfssh(self): + self.assertEqual( + gs.product_cpe('wolfssh', '1.4.20'), + 'cpe:2.3:a:wolfssh:wolfssh:1.4.20:*:*:*:*:*:*:*') + + +class TestResolveCryptoOnly(unittest.TestCase): + def test_auto_reads_captured_macro(self): + self.assertEqual( + gs.resolve_crypto_only('auto', [('WOLFCRYPT_ONLY', '')]), + (True, 'captured')) + + def test_auto_without_any_capture_is_unknown(self): + self.assertEqual(gs.resolve_crypto_only('auto', []), (False, 'unknown')) + + def test_explicit_value_overrides_the_macro(self): + self.assertEqual( + gs.resolve_crypto_only('no', [('WOLFCRYPT_ONLY', '')]), + (False, 'declared')) + + +class TestWolfbootCoatContract(unittest.TestCase): + BASE_KW = dict( + name='wolfboot', + version='2.9.0', + supplier='wolfSSL Inc.', + license_id='GPL-3.0-or-later', + license_text=None, + lib_hash='b' * 64, + timestamp='2024-01-01T00:00:00Z', + year=2024, + serial='00000000-0000-0000-0000-000000000002', + enabled_deps=['wolfssl', 'wolfcrypt'], + build_props=[('TARGET_stm32u5', '1')], + dep_version_overrides={'wolfssl': '5.9.1', 'wolfcrypt': '5.9.1'}, + component_type='firmware', + ) + + def test_cdx_nests_wolfcrypt_inside_wolfssl(self): + doc = gs.generate_cdx(**self.BASE_KW) + main = doc['metadata']['component'] + # wolfssl stays top-level: it is the only one of the pair NVD maps + # advisories to. + top = {c['name']: c for c in doc['components']} + self.assertEqual(set(top), {'wolfssl'}) + self.assertEqual( + top['wolfssl']['cpe'], + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + self.assertEqual( + top['wolfssl']['purl'], + 'pkg:github/wolfssl/wolfssl@v5.9.1-stable') + nested = {c['name']: c for c in top['wolfssl']['components']} + self.assertEqual( + nested['wolfcrypt']['cpe'], + 'cpe:2.3:a:wolfssl:wolfcrypt:5.9.1:*:*:*:*:*:*:*') + + self.assertNotIn('cpe', main) + props = {p['name']: p['value'] for p in main['properties']} + self.assertEqual(props.get('wolfssl:sbom:cpe-status'), 'pending') + self.assertEqual(props.get('wolfssl:sbom:cpe-requested'), + 'cpe:2.3:a:wolfssl:wolfboot:2.9.0:*:*:*:*:*:*:*') + + def test_wolfssl_own_sbom_keeps_wolfcrypt_top_level(self): + # No wolfssl dependency to nest into, so wolfcrypt must stay + # top-level or components[] goes empty. + doc = gs.generate_cdx(**dict( + 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']) + + + if __name__ == '__main__': unittest.main(verbosity=2)