sbom: re-vendor wolfGlass at b44ae8d

Picks up per-configuration serialNumber and documentNamespace, so two
configurations of one wolfBoot version no longer share an identifier
inside a scanner. Also brings CPE/PURL build-metadata stripping,
--dep-* value validation, wolfcrypt version inheritance, and the
sbom.am host-cc macro capture.

The Makefile comment that recorded this as an open wolfGlass gap is
no longer true, so it goes.

Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
pull/824/head
Sameeh Jubran 2026-08-20 15:02:24 +03:00 committed by Daniele Lacamera
parent da1d2cfc27
commit 0b6bcd1221
4 changed files with 217 additions and 49 deletions

View File

@ -949,10 +949,10 @@ SBOM_COMPONENT_TYPE?=firmware
SBOM_LICENSE_OVERRIDE?=GPL-3.0-or-later
# One version of wolfBoot has about 100 configurations, and each one is a
# different image with a different source set. Name the document after the
# configuration so a second target does not overwrite the first. gen-sbom still
# derives serialNumber and the SPDX documentNamespace from name and version
# alone, which collides inside a scanner as well; that part is wolfGlass's to
# fix, and this does not paper over it.
# configuration so a second target does not overwrite the first. gen-sbom
# folds the build configuration into the serialNumber and the SPDX
# documentNamespace too, so the documents stay distinct inside a scanner and
# not only on disk.
SBOM_CONFIG_TAG:=$(TARGET)$(if $(SIGN),-$(SIGN))$(if $(HASH),-$(HASH))
SBOM_CDX_OUT:=wolfboot-$(SBOM_CONFIG_TAG)-$(WOLFBOOT_VERSION).cdx.json
SBOM_SPDX_OUT:=wolfboot-$(SBOM_CONFIG_TAG)-$(WOLFBOOT_VERSION).spdx.json

View File

@ -1 +1 @@
d34a906638444b6990218a49927bcebafc5a539b
b44ae8d1d578ccf0b4269ffb395157cfb3fab0dc

View File

@ -25,6 +25,12 @@ 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.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 /
# versionInfo still records the full string. Constructors (cpe23_uri,
# wolfssl_project_purl, DEP_META lambdas) strip; this is not a
# full CPE sanitizer.
# 1.7 wolfBoot CPE is registered in the NVD Official CPE Dictionary
# (published 2026-08-10). PRODUCT_CPE status flips to registered so
# the main package emits `cpe` and the pending properties stop.
@ -50,7 +56,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.7'
GEN_SBOM_VERSION = '1.8'
# Placeholder recorded in the component checksum fields when the operator
# passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only
@ -129,9 +135,32 @@ def github_purl(namespace, repo, tag):
return f'pkg:github/{namespace.lower()}/{repo.lower()}@{tag}'
def identifier_version(version):
"""Return the version used in CPE 2.3 and PURL identifiers.
OpenSSL BUILD_METADATA and PEP 440 local versions append `+...`.
A raw `+` is not a legal CPE 2.3 version character, and the suffix
is not an upstream git tag, so NVD / OSV cannot match it. Keep the
full string in version / versionInfo; use this for identifiers.
This is not a full CPE 2.3 sanitizer: other illegal characters
(`:`, `*`, whitespace) are not rewritten.
"""
if not version:
return version
return version.split('+', 1)[0]
def cpe23_uri(vendor, product, version):
"""CPE 2.3 application URI. Version uses identifier_version()."""
ident = identifier_version(version)
return f'cpe:2.3:a:{vendor}:{product}:{ident}:*:*:*:*:*:*:*'
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))
return github_purl(
'wolfSSL', name, github_release_tag(name, identifier_version(version)))
# Official CPE 2.3 vendor:product pairs. Main-package CPEs are emitted only
@ -165,9 +194,7 @@ PRODUCT_CPE = {
def _product_cpe_string(meta, version):
return (
f"cpe:2.3:a:{meta['vendor']}:{meta['product']}:{version}:*:*:*:*:*:*:*"
)
return cpe23_uri(meta['vendor'], meta['product'], version)
def product_cpe(name, version):
@ -243,6 +270,59 @@ def derived_uuid(*parts):
return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts)))
def config_identity(lib_hash, license_id, build_props, enabled_deps,
dep_versions, *, supplier=None, component_type=None,
license_text=None, hash_kind=None, hash_source=None,
file_names=None, wolfssl_subset=None, subset_basis=None):
"""Stable digest of the build configuration behind one name+version.
Folded into the SBOM serialNumber / SPDX documentNamespace so two builds
of one release with different configuration (FIPS vs non-FIPS, a different
--srcs set, a different feature-flag set) get distinct identifiers, while
an identical configuration still reproduces byte-for-byte. Fields are
tagged and NUL-delimited so no two configurations serialize alike.
Invariant: every CLI input that can change the document body reaches this
digest. One that does not is a collision -- two documents with differing
content under one serialNumber. tests/test_sbom_identity.py classifies
the whole option set and fails on an unclassified new option.
lib_hash is folded in deliberately, so on the --lib path the identity
follows the built binary: the same sources under a different toolchain get
a different serialNumber. An SBOM from --lib describes one artefact. The
--srcs and --no-artifact-hash paths do not have this property."""
h = hashlib.sha256()
def _field(tag, value):
h.update(tag.encode())
h.update(b'\0')
h.update((value or '').encode())
h.update(b'\0')
_field('lib_hash', lib_hash)
_field('license', license_id)
for k, v in sorted(build_props):
_field('prop', f'{k}={v}')
for key in sorted(enabled_deps):
_field('dep', f'{key}={dep_versions.get(key) or ""}')
_field('supplier', supplier)
_field('component_type', component_type)
# Digested, not embedded: the text is a document field of its own and can
# differ while the SPDX identifier is unchanged.
_field('license_text',
hashlib.sha256(license_text.encode()).hexdigest()
if license_text else '')
_field('hash_kind', hash_kind)
_field('hash_source', hash_source)
# Filenames reach the document body without passing through lib_hash,
# which covers bytes only: a rename changes the body but not the hash.
for fname in sorted(file_names or []):
_field('file', fname)
_field('wolfssl_subset', wolfssl_subset)
_field('subset_basis', subset_basis)
return h.hexdigest()
def build_timestamp():
"""Return (datetime, ISO-8601-Z string) honoring SOURCE_DATE_EPOCH.
Reproducible Builds convention: if the env var is set to a valid
@ -294,7 +374,7 @@ DEP_META = {
'pkgconfig': 'wolfssl',
'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}:*:*:*:*:*:*:*',
'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;
@ -315,7 +395,7 @@ DEP_META = {
# 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}:*:*:*:*:*:*:*',
'cpe': lambda v: cpe23_uri('wolfssl', 'wolfcrypt', v),
},
'libz': {
'name': 'zlib',
@ -326,8 +406,9 @@ DEP_META = {
# pkg:github resolves in OSV / GHSA / Snyk / Trivy without the
# 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}:*:*:*:*:*:*:*',
'purl': lambda v: github_purl(
'madler', 'zlib', f'v{identifier_version(v)}'),
'cpe': lambda v: cpe23_uri('zlib', 'zlib', v),
},
# openssl, declared as a dependency by the OpenSSL-compat products
# (wolfProvider, wolfEngine) that link libcrypto/libssl alongside wolfSSL.
@ -342,8 +423,9 @@ DEP_META = {
'license': 'Apache-2.0',
'download': 'https://github.com/openssl/openssl',
'pkgconfig': 'openssl',
'purl': lambda v: github_purl('openssl', 'openssl', f'openssl-{v}'),
'cpe': lambda v: f'cpe:2.3:a:openssl:openssl:{v}:*:*:*:*:*:*:*',
'purl': lambda v: github_purl(
'openssl', 'openssl', f'openssl-{identifier_version(v)}'),
'cpe': lambda v: cpe23_uri('openssl', 'openssl', v),
},
}
@ -988,6 +1070,8 @@ def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None):
'externalReferences': [{'type': 'vcs', 'url': meta['download']}],
}
if version:
# Pass the full version: constructors apply identifier_version().
# 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
@ -1435,6 +1519,53 @@ def _resolve_dep_versions(enabled_deps, overrides):
return overrides
def _inherit_wolfcrypt_version(enabled_deps, overrides, name, version):
"""Give wolfcrypt a version so its CPE and PURL are 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
wolfssl dependency's version, else (for wolfSSL's own SBOM) the package
version.
MUST be called AFTER `_resolve_dep_versions`. The wolfssl 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.
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
'wolfcrypt' is always present in `overrides`.
"""
if 'wolfcrypt' not in enabled_deps or overrides.get('wolfcrypt'):
return overrides
if overrides.get('wolfssl'):
overrides['wolfcrypt'] = overrides['wolfssl']
elif name.lower() == 'wolfssl':
overrides['wolfcrypt'] = version
return overrides
def _enabled_deps(dep_flags):
"""Return the enabled dependency keys, rejecting values outside {yes, no}.
Treating anything that is not 'yes' as 'no' loses a dependency silently.
A build fragment wiring `--dep-wolfssl=$(HAVE_WOLFSSL)` where the variable
expands to 1, true, on or Y would drop a CVE-bearing component from the
SBOM with no diagnostic, which is the one failure mode a CRA tool must
never have. Fail loudly instead.
"""
enabled = []
for key, flag_name, value in dep_flags:
normalized = (value or '').strip().lower()
if normalized not in ('yes', 'no'):
sys.exit(f"ERROR: {flag_name} expects 'yes' or 'no', got {value!r}. "
f"A value outside that set would silently drop {key} from "
f"the SBOM.")
if normalized == 'yes':
enabled.append(key)
return enabled
def main():
parser = argparse.ArgumentParser(
description='Generate CycloneDX and SPDX SBOMs for wolfssl. '
@ -1618,28 +1749,19 @@ def main():
"https://example.com/sbom/wolfssl-X.Y.Z.spdx.json or "
"urn:uuid:00000000-0000-0000-0000-000000000000.")
enabled_deps = [
key for key, flag in [
('wolfssl', args.dep_wolfssl),
('wolfcrypt', args.dep_wolfcrypt),
('openssl', args.dep_openssl),
('libz', args.dep_libz),
]
if flag.lower() == 'yes'
]
enabled_deps = _enabled_deps([
('wolfssl', '--dep-wolfssl', args.dep_wolfssl),
('wolfcrypt', '--dep-wolfcrypt', args.dep_wolfcrypt),
('openssl', '--dep-openssl', args.dep_openssl),
('libz', '--dep-libz', args.dep_libz),
])
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).
_resolve_dep_versions(enabled_deps, dep_version_overrides)
_inherit_wolfcrypt_version(enabled_deps, dep_version_overrides,
args.name, args.version)
if args.license_override:
license_id = args.license_override
@ -1756,8 +1878,28 @@ def main():
dt, timestamp = build_timestamp()
year = dt.year
serial = derived_uuid(args.name, args.version, 'serial')
doc_ns_uuid = derived_uuid(args.name, args.version, 'document')
# SPDX 2.3 6.5 requires documentNamespace to be unique, and a CycloneDX
# version:1 sharing a serialNumber over differing content is
# self-contradictory. Every body-affecting input goes into the digest;
# see config_identity for the invariant.
identity_file_names = (
[fe['name'] for fe in file_entries] if file_entries
else (srcs_basenames or [])
)
config_id = config_identity(
lib_hash, license_id, build_props, enabled_deps,
dep_version_overrides,
supplier=args.supplier,
component_type=args.component_type,
license_text=license_text,
hash_kind=hash_kind,
hash_source=hash_source,
file_names=identity_file_names,
wolfssl_subset=wolfssl_subset,
subset_basis=subset_basis,
)
serial = derived_uuid(args.name, args.version, 'serial', config_id)
doc_ns_uuid = derived_uuid(args.name, args.version, 'document', config_id)
cdx = generate_cdx(
args.name, args.version, args.supplier,

View File

@ -10,7 +10,7 @@
# build-time dependency on a separate wolfSSL checkout.
#
# ---------------------------------------------------------------------------
# The including Makefile.am MUST set, before `include scripts/sbom.am`:
# The including Makefile.am MUST set, before `include tools/sbom/sbom.am`:
# SBOM_PKGNAME Product name recorded in the SBOM (e.g. wolfssh). Drives
# the output filenames and gen-sbom --name.
# SBOM_LICENSE_FILE Path to the product's LICENSING file
@ -44,15 +44,23 @@
# captured (e.g. wolfEngine: $(abs_builddir)/include/config.h;
# wolfCLU: $(abs_builddir)/src/config.h).
# Default: $(abs_builddir)/config.h.
# SBOM_HOSTCC Host C compiler used to expand the build macros (the
# -D/-U/-I tokens only, never the configured cross $(CC)),
# so the SBOM is byte-reproducible across toolchains.
# Default: cc.
#
# The wolfSSL/OpenSSL dependency flags are feature-detected against gen-sbom
# --help, so a product wired for them still produces a valid SBOM (with a NOTE)
# against a gen-sbom that predates the flag.
#
# gen-sbom is located next to this fragment in the vendored `tools/sbom/`
# directory unless SBOM_GEN is overridden. GEN_SBOM is accepted as a legacy
# alias. python3, pyspdxtools and git come from configure (AC_PATH_PROG); git
# is used only to derive SOURCE_DATE_EPOCH.
# gen-sbom is located in the vendored directory (SBOM_VENDOR_DIR, default
# $(srcdir)/tools/sbom), or, if not vendored, in a wolfSSL source tree via
# WOLFSSL_DIR (the wolfSSH-style route); SBOM_GEN overrides both. NOTE: this
# fragment cannot locate itself at make time -- Automake's `include` is textual,
# so $(MAKEFILE_LIST) resolves to the top Makefile, not this file -- which is why
# the vendored directory is named explicitly rather than derived from the
# fragment's own path. python3, pyspdxtools and git come from configure
# (AC_PATH_PROG); git is used only to derive SOURCE_DATE_EPOCH.
#
# NOTE: this fragment requires GNU make. It uses GNU conditional assignment
# (?=) and the GNU make functions $(wildcard), $(if), $(firstword) and
@ -65,7 +73,16 @@ SBOM_BIN_NAME ?= $(SBOM_PKGNAME)
SBOM_DEP_WOLFSSL ?= no
SBOM_DEP_OPENSSL ?= no
SBOM_CONFIG_H ?= $(abs_builddir)/config.h
SBOM_AM_DIR ?= $(dir $(lastword $(MAKEFILE_LIST)))
# Host C compiler used to capture the build macros (matching the Make/CMake
# paths' HOSTCC), NOT the configured $(CC): on a cross build $(CC) would bake
# the cross compiler's target-specific predefined macros into the SBOM, so the
# same product built through a different toolchain would produce a different
# document. Only the -D/-U/-I tokens are fed to it (see the capture below), so
# it never sees target arch flags it cannot parse.
SBOM_HOSTCC ?= cc
# Directory the wolfGlass tooling (gen-sbom) is vendored into. Products that
# vendor elsewhere override this (or SBOM_GEN directly).
SBOM_VENDOR_DIR ?= $(srcdir)/tools/sbom
SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json
SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json
@ -74,8 +91,12 @@ SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx
# equals $(datadir)/doc/$(PACKAGE) by default).
sbomdir = $(docdir)
# Prefer the vendored sibling copy. Callers may override SBOM_GEN explicitly.
SBOM_GEN := $(or $(SBOM_GEN),$(GEN_SBOM),$(abspath $(SBOM_AM_DIR)/gen-sbom))
# Prefer the vendored copy; else fall back to a wolfSSL source tree via
# WOLFSSL_DIR. Empty if neither exists -- the `test -f "$(SBOM_GEN)"` check in
# the recipe then fails with a clear error. Callers may override SBOM_GEN.
SBOM_GEN ?= $(abspath $(firstword \
$(wildcard $(SBOM_VENDOR_DIR)/gen-sbom) \
$(if $(WOLFSSL_DIR),$(wildcard $(WOLFSSL_DIR)/scripts/gen-sbom))))
# Library artifact search order (versioned first) covering ELF, Mach-O and PE.
# Windows import libs (.lib) come with and without the "lib" prefix.
@ -92,16 +113,18 @@ SBOM_LIB_GLOBS = \
# Automake requires CLEANFILES to be initialised with `=` before `+=`; the
# including Makefile.am must declare `CLEANFILES =` (typically in its primaries
# init block) before `include scripts/sbom.am`.
# init block) before `include tools/sbom/sbom.am`.
CLEANFILES += $(SBOM_CDX) $(SBOM_SPDX) $(SBOM_SPDX_TV)
.PHONY: sbom install-sbom uninstall-sbom
# Stage a `make install` into a private tree, discover the installed artifact
# (shared/static library or program; ELF/Mach-O/PE), hash it, capture the
# configured build macros (from SBOM_OPTIONS_H if set, else AM_CPPFLAGS/
# AM_CFLAGS/CFLAGS + config.h; some products carry their feature -D flags in
# AM_CFLAGS rather than AM_CPPFLAGS, and some outside config.h entirely),
# configured build macros (from SBOM_OPTIONS_H if set, else the -D/-U/-I tokens
# of AM_CPPFLAGS/AM_CFLAGS/CFLAGS + config.h, expanded through the HOST compiler
# SBOM_HOSTCC so the SBOM is reproducible across cross toolchains; some products
# carry their feature -D flags in AM_CFLAGS rather than AM_CPPFLAGS, and some
# outside config.h entirely),
# generate SPDX+CDX, validate
# the SPDX, then convert to tag-value. The staging tree and temp defines file
# are removed unconditionally via `trap`, even on failure. SOURCE_DATE_EPOCH is
@ -147,8 +170,11 @@ sbom:
opts_h="$(SBOM_OPTIONS_H)"; \
if test -z "$$opts_h"; then \
opts_h="$$_defines"; \
$(CC) -dM -E $(DEFAULT_INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) \
$(AM_CFLAGS) $(CFLAGS) \
sbom_cpp=""; \
for f in $(AM_CPPFLAGS) $(CPPFLAGS) $(AM_CFLAGS) $(CFLAGS); do \
case "$$f" in -D*|-U*|-I*) sbom_cpp="$$sbom_cpp $$f";; esac; \
done; \
$(SBOM_HOSTCC) -dM -E $(DEFAULT_INCLUDES) $$sbom_cpp \
$(if $(wildcard $(SBOM_CONFIG_H)),-include $(SBOM_CONFIG_H)) \
-x c /dev/null > "$$_defines"; \
fi; \