Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/cpe-requests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>`. The unique id for
the nested library is the PURL (`pkg:github/wolfssl/wolfssl@v<ver>-stable#wolfcrypt`).

## Pending products (none today)

A product with `status: pending` emits **no `cpe` field**. A scanner cannot
Expand Down
3 changes: 2 additions & 1 deletion share/build/sbom.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
# INCLUDE_DIRS <dir>... 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).
Expand Down
3 changes: 2 additions & 1 deletion share/build/sbom.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 36 additions & 27 deletions share/gen-sbom
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.',
Expand All @@ -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',
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
17 changes: 15 additions & 2 deletions share/validate_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)")
Expand Down
79 changes: 54 additions & 25 deletions tests/test_gen_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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__':
Expand All @@ -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):
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading