Files
wolfssl/scripts/gen-sbom
T
Mark Atwood be9963f392 sbom: drop liboqs dependency support (removed from wolfSSL)
wolfSSL removed liboqs: Falcon is now provided natively by wolfCrypt, and
--with-liboqs is a deprecated no-op (configure.ac). A build therefore no
longer links liboqs, so recording it as an SBOM dependency is dead code and
the SBOM integration CI (which asserted a liboqs dep package) failed.

Remove the liboqs dependency throughout:
- scripts/gen-sbom: drop DEP_META['liboqs'] and the --dep-liboqs flag.
- Makefile.am / configure.ac: drop --dep-liboqs "$(ENABLED_LIBOQS)" and the
  now-unused AC_SUBST([ENABLED_LIBOQS]).
- .github/workflows/sbom.yml: drop the liboqs install / --with-liboqs steps
  and the liboqs dep assertion; keep the native-Falcon build so the
  HAVE_FALCON build-property capture is still exercised.
- scripts/test_gen_sbom.py: drop the liboqs-specific tests, guard against
  the key reappearing, and use openssl as the example dep elsewhere.
- doc/SBOM.md: drop the --dep-liboqs / liboqs dependency references.
2026-07-23 22:32:50 -07:00

1393 lines
61 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate CycloneDX 1.6 and SPDX 2.3 SBOMs for wolfssl."""
import argparse
import hashlib
import io
import json
import os
import re
import subprocess
import sys
import uuid
from datetime import datetime, timezone
# Tool identification. Bump GEN_SBOM_VERSION whenever the SBOM output
# shape changes in any auditor-visible way (new property, new field,
# semantic change to an existing one) so downstream consumers can pin
# their parser against a known producer. Carried in the CycloneDX
# `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.
GEN_SBOM_TOOL_NAME = 'wolfssl-sbom-gen'
GEN_SBOM_VERSION = '1.2'
# Placeholder recorded in the component checksum fields when the operator
# passes --no-artifact-hash: a build (ROM image, HSM firmware, binary-only
# redistribution) where neither a library archive nor the compiled source
# files are accessible to hash. 64 zero hex digits is an obviously-synthetic
# SHA-256 that can never collide with a real artefact, and the companion
# `wolfssl:sbom:hash-source=none` property plus the note below tell a
# downstream auditor the value is intentional, not a generation bug.
_NO_HASH_SENTINEL = '0' * 64
_NO_HASH_NOTE = (
'No artefact hash was available at SBOM generation time '
'(--no-artifact-hash). The checksum field is a placeholder, not a real '
'SHA-256 of any wolfSSL component. Contact wolfssl@wolfssl.com to '
'arrange integrity verification appropriate to this build before relying '
'on this SBOM for CRA conformance.'
)
# Stable namespace for deterministic uuid5 derivation. The seed string is
# an opaque input to uuid5 -- it only needs to be (a) constant across
# releases so the derived UUIDs reproduce byte-for-byte (any consumer
# pinning a wolfSSL SBOM hash would otherwise see a content rotation
# from a seed change alone), and (b) unlikely to collide with another
# project's uuid5 namespace. It is NOT a URL the SBOM resolves to and
# is NOT what we serialize as the SPDX documentNamespace -- that field
# is now `urn:uuid:<derived>` (see generate_spdx). The historical
# string is preserved verbatim to keep derived UUIDs (bom-refs,
# serialNumbers, the documentNamespace UUID component) stable across
# the documentNamespace shape change.
SBOM_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wolfssl.com/sbom/')
def project_urls(name):
"""Canonical wolfSSL GitHub URLs for a project, derived from its package
name. Keeping these name-derived (rather than hardcoded to wolfssl) lets
the same generator emit correct VCS / issue-tracker / advisory / download
URLs for every product in the wolfSSL stack (wolfssl, wolfssh, wolfmqtt,
...). For name='wolfssl' the result is byte-identical to the historical
hardcoded URLs, so existing wolfSSL SBOMs do not change."""
base = f'https://github.com/wolfSSL/{name}'
return {
'vcs': base,
'issues': f'{base}/issues',
'advisories': f'{base}/security/advisories',
}
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,
which is required for reproducible-build-style SBOM hashing.
Uses NUL as a separator so no aliasing is possible between e.g.
derived_uuid('a/b', 'c') and derived_uuid('a', 'b/c'); NUL cannot
appear in any of the call-site inputs (package name, version, role
label, dep key)."""
return str(uuid.uuid5(SBOM_UUID_NAMESPACE, '\x00'.join(parts)))
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
integer, use it as the SBOM creation timestamp instead of wallclock."""
sde = os.environ.get('SOURCE_DATE_EPOCH', '').strip()
if sde:
try:
dt = datetime.fromtimestamp(int(sde), tz=timezone.utc)
except (ValueError, OverflowError, OSError) as e:
print(f"WARNING: ignoring invalid SOURCE_DATE_EPOCH={sde!r}: {e}",
file=sys.stderr)
dt = datetime.now(timezone.utc)
else:
dt = datetime.now(timezone.utc)
return dt, dt.strftime('%Y-%m-%dT%H:%M:%SZ')
# Known metadata for optional external dependencies. Version is detected
# at runtime via pkg-config; falls back to None. Each entry must describe
# 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, ...).
DEP_META = {
# wolfssl itself, declared as a dependency by downstream wolfSSL-stack
# products (wolfSSH, wolfMQTT, wolfTPM, ...) that link libwolfssl. Only
# emitted when the caller passes --dep-wolfssl yes; wolfSSL's own
# `make sbom` never enables it (a package is not its own dependency).
# Recording it is what lets a CRA / vulnerability scanner associate
# wolfSSL advisories with a product that embeds wolfSSL.
'wolfssl': {
'name': 'wolfssl',
'supplier': 'wolfSSL Inc.',
# wolfSSL is distributed under GPLv3 (LICENSING: "version 3 (GPLv3)",
# no "or later"), with a commercial option. This matches what
# detect_license() infers for wolfSSL's own main-package SBOM, so a
# downstream product's wolfssl dependency entry and wolfSSL's own
# self-SBOM agree on the licence.
'license': 'GPL-3.0-only',
'download': 'https://github.com/wolfSSL/wolfssl',
'pkgconfig': 'wolfssl',
'purl': lambda v: f'pkg:github/wolfSSL/wolfssl@v{v}',
},
'libz': {
'name': 'zlib',
'supplier': 'Jean-loup Gailly and Mark Adler',
'license': 'Zlib',
'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}',
},
# openssl, declared as a dependency by the OpenSSL-compat products
# (wolfProvider, wolfEngine) that link libcrypto/libssl alongside wolfSSL.
# Only emitted when the caller passes --dep-openssl yes. These products
# target the OpenSSL 3.x provider/engine ABI, which is Apache-2.0 (older
# 1.1.x was the SPDX "OpenSSL" licence); Apache-2.0 is therefore the correct
# id for the supported surface. The purl uses OpenSSL 3.x's "openssl-X.Y.Z"
# git tag form so it resolves in OSV / GHSA.
'openssl': {
'name': 'openssl',
'supplier': 'OpenSSL Software Foundation',
'license': 'Apache-2.0',
'download': 'https://github.com/openssl/openssl',
'pkgconfig': 'openssl',
'purl': lambda v: f'pkg:github/openssl/openssl@openssl-{v}',
},
}
# Matches a single SPDX `LicenseRef-` identifier as defined in SPDX 2.3
# Annex D ("idstring = 1*(ALPHA / DIGIT / '-' / '.')"). We use this to
# discover custom license refs inside an arbitrary SPDX expression and to
# decide whether a `licenseConcluded` value needs an accompanying
# `hasExtractedLicensingInfos` block.
LICENSEREF_RE = re.compile(r'LicenseRef-[A-Za-z0-9.\-]+')
# Matches a "simple" SPDX-listed license ID such as `GPL-2.0-or-later` or
# `MIT` (no spaces, no operators, no LicenseRef-). Anything that does not
# match must be expressed via `licenses[].license.name` / `licenses[].expression`
# in CycloneDX, since `license.id` is restricted to the SPDX licence list.
SIMPLE_SPDX_ID_RE = re.compile(r'\A[A-Za-z0-9.+\-]+\Z')
def is_simple_spdx_id(value):
return bool(SIMPLE_SPDX_ID_RE.match(value)) and \
not value.startswith('LicenseRef-') and value != 'NOASSERTION'
def extract_license_refs(expr):
"""Return a sorted, deduplicated list of LicenseRef-* IDs found in expr."""
return sorted(set(LICENSEREF_RE.findall(expr or '')))
def load_license_text(path):
"""Read the license text file given via --license-text, exit on error."""
if not path:
return None
try:
with open(path) as f:
return f.read()
except OSError as e:
sys.exit(f"ERROR: cannot read --license-text {path}: {e}")
def build_extracted_licensing_infos(license_expr, license_text):
"""Return SPDX `hasExtractedLicensingInfos` array for license_expr.
SPDX 2.3 §10 requires every LicenseRef-* used in `licenseConcluded`/
`licenseDeclared` to be declared once at document level via
`hasExtractedLicensingInfos`. Returns None when no LicenseRef-* is
present so the caller can omit the field entirely.
`license_text=None` produces a placeholder entry; main() rejects
that combination upfront, so this fallback is only reachable from
direct programmatic callers (e.g. tests, library reuse).
"""
refs = extract_license_refs(license_expr)
if not refs:
return None
if license_text is None:
license_text = (
'NOASSERTION. The text for this LicenseRef has not been '
'embedded in the SBOM. Provide it via the gen-sbom '
'--license-text PATH flag (or `make sbom SBOM_LICENSE_TEXT=...`).'
)
infos = []
for ref in refs:
infos.append({
'licenseId': ref,
'extractedText': license_text,
'name': ref[len('LicenseRef-'):].replace('-', ' ').strip(),
})
return infos
def cdx_license_block(license_expr, license_text):
"""Return the CycloneDX `licenses[]` entry for an arbitrary SPDX
expression. CDX 1.6 distinguishes:
* `license.id` - an entry from the SPDX licence list
* `license.name` - a non-listed licence (e.g. a LicenseRef-*)
* `expression` - a compound SPDX expression
Picking the wrong shape causes downstream tooling to reject the SBOM."""
# NOASSERTION is a reserved SPDX value, not a parseable SPDX expression;
# emit it via license.name so CDX validators don't choke trying to parse
# it as one.
if license_expr == 'NOASSERTION':
return [{'license': {'name': 'NOASSERTION'}}]
if is_simple_spdx_id(license_expr):
return [{'license': {'id': license_expr}}]
refs = extract_license_refs(license_expr)
if len(refs) == 1 and refs[0] == license_expr:
block = {'name': license_expr}
if license_text:
block['text'] = {'contentType': 'text/plain', 'content': license_text}
return [{'license': block}]
return [{'expression': license_expr}]
def detect_license(license_file):
"""Parse LICENSING file and return an SPDX license ID.
Looks for 'GNU General Public License version N' and whether
'or later' / 'or any later version' follows. Returns None and
prints a warning if the file cannot be parsed.
"""
try:
with open(license_file) as f:
text = f.read()
except OSError as e:
print(f"WARNING: cannot read license file {license_file}: {e}",
file=sys.stderr)
return None
m = re.search(
r'gnu general public license\s+version\s+(\d+)',
text, re.IGNORECASE
)
or_later_plus = False
if not m:
# Abbreviated form: some wolfSSL-stack LICENSING files (e.g. wolfSSH)
# say "GPLv3" rather than the canonical "GNU General Public License
# version 3", so the long-form regex above misses and detection would
# fall back to NOASSERTION. A trailing "+" (GPLv3+) denotes the
# or-later variant; otherwise fall through to the shared "or later"
# prose check below.
m = re.search(r'\bGPLv(\d+)(\+)?', text, re.IGNORECASE)
if m and m.group(2) == '+':
or_later_plus = True
if not m:
print(f"WARNING: no GPL version found in {license_file}",
file=sys.stderr)
return None
version = m.group(1)
if or_later_plus:
return f'GPL-{version}.0-or-later'
excerpt = text[m.end():m.end() + 100]
# Match upgrade-permission wording in the 100-byte excerpt that
# follows the version mention. Three FSF-derived shapes:
# * canonical preamble: "or (at your option) any later version"
# * preamble variant: "or (at the licensee's option) any later"
# * compact form: "or later" / "or any later"
# The optional `[^,.;\n]*?\s+` group consumes parenthesised
# asides without crossing sentence boundaries so unrelated
# "or" / "later" mentions in surrounding prose do not match.
if re.search(r'or\s+(?:[^,.;\n]*?\s+)?(?:any\s+)?later',
excerpt, re.IGNORECASE):
return f'GPL-{version}.0-or-later'
return f'GPL-{version}.0-only'
def sha256_file(path):
h = hashlib.sha256()
try:
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
except OSError as e:
sys.exit(f"ERROR: cannot read library for hashing: {e}")
return h.hexdigest()
def sha1_sha256_file(path):
"""Return (sha1_hex, sha256_hex) computed in a single pass.
SPDX 2.3 §8.4 requires SHA-1 on every file entry (`packageFileChecksum`
cardinality 1..*, with SHA-1 mandatory). CycloneDX accepts either.
Reading the file twice would double the I/O on builds with many
source files; one pass keeps `make sbom` fast on embedded trees."""
s1 = hashlib.sha1()
s256 = hashlib.sha256()
try:
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
s1.update(chunk)
s256.update(chunk)
except OSError as e:
sys.exit(f"ERROR: cannot read file for hashing: {e}")
return s1.hexdigest(), s256.hexdigest()
def pkgconfig_version(pkgname):
"""Return version string from pkg-config, or None if unavailable."""
try:
r = subprocess.run(
['pkg-config', '--modversion', pkgname],
capture_output=True, text=True
)
if r.returncode == 0:
return r.stdout.strip()
except FileNotFoundError:
pass
return None
def dep_version(key, overrides=None):
"""Resolve the runtime version of a DEP_META entry.
Resolution order:
1. Explicit override from `overrides[key]` (set via the
--dep-version CLI flag). This is the only path that works
for embedded / cross-compile builds where pkg-config is not
available on the host that runs gen-sbom.
2. `pkg-config --modversion <pkgconfig>`. Used by the autotools
path on a typical Linux server where the linked dep was
installed via the system package manager.
3. None. Caller emits NOASSERTION (SPDX) / omits the version
(CycloneDX).
A previous source-tree fallback that used `git describe` against
`git_root` was removed once libxmss/liblms were dropped upstream;
if a future PQ dep returns to a source-only integration, restore
the fallback here together with a `git_root` field on the DEP_META
entry."""
if overrides and key in overrides:
return overrides[key]
return pkgconfig_version(DEP_META[key]['pkgconfig'])
# Patterns for #define names that pollute the SBOM with build-environment
# noise rather than wolfSSL configuration. Applied identically to
# parse_options_h (no-pcpp / autotools path) and parse_user_settings
# (pcpp embedded path) so both entry points produce semantically
# equivalent build-property sets for the same effective configuration.
#
# Three families are filtered:
#
# 1. Compiler / preprocessor reserved identifiers (`__*`, `_[A-Z]*`).
# ISO C 7.1.3 reserves these for the implementation; clang, gcc, and
# pcpp emit dozens of them (`__VERSION__`, `__SSE2__`, `_LP64`, ...).
# They describe the build *host*, not wolfSSL, and break SBOM
# reproducibility across hosts (same wolfSSL config built on macOS
# clang vs. arm-none-eabi-gcc otherwise produces different SBOMs).
#
# 2. Apple <TargetConditionals.h> macros (`TARGET_OS_*`,
# `TARGET_IPHONE_*`). The no-pcpp escape hatch
# (`$CC -dM -E -include settings.h`) on macOS transitively pulls in
# macOS system headers and emits this entire family; without the
# filter, a wolfSSL SBOM for an STM32 firmware would falsely
# advertise TARGET_OS_MAC=1 if generated on a Mac.
#
# 3. Header include guards (`*_H` whose token does NOT carry an
# autoconf / wolfSSL configuration prefix).
# wolfssl/options.h itself and many internal wolfSSL headers define
# guards like WOLFSSL_OPTIONS_H, WOLF_CRYPT_SETTINGS_H, and
# WOLFCRYPT_TEST_*_H to prevent double inclusion. Those describe
# *which file was parsed*, not configuration choices.
#
# The carve-out tokens (`HAVE_`, `NO_`, `USE_`) are critical: real
# wolfSSL configuration flags also end in `_H` and would otherwise
# be silently filtered out, falsifying the SBOM for the customers
# who rely on them most:
#
# * `HAVE_*_H` / `WOLFSSL_HAVE_*_H` - autoconf AC_CHECK_HEADER
# results (HAVE_STDINT_H, WOLFSSL_HAVE_ATOMIC_H,
# WOLFSSL_HAVE_ASSERT_H, ...). Gates `#if defined(...)`
# branches in wc_port.h / types.h.
# * `NO_*_H` / `WOLFSSL_NO_*_H` - explicit stdlib / feature
# suppression (NO_STDINT_H, NO_STDLIB_H, NO_LIMITS_H,
# NO_CTYPE_H, NO_STRING_H, NO_STDDEF_H, WOLFSSL_NO_ASSERT_H).
# Set by NETOS / Telit / other RTOS profiles in settings.h to
# replace stdlib headers with vendor headers; gates branches
# in types.h:398 / settings.h:3850 / sp.h:42.
# * `USE_*_H` - build-mode toggles (USE_FLAT_TEST_H,
# USE_FLAT_BENCHMARK_H). Gates which test/benchmark layout
# is compiled in test.c:165 / benchmark.c:219 / server.c:70.
#
# Heuristic limitation: a stray feature flag that ends in `_H`
# without one of those tokens (e.g. WOLFSSL_DEBUG_TRACE_ERROR_CODES_H,
# a debug-only opt-in) would still be filtered. Customers who
# depend on such a flag can either move it to a non-`_H`-suffixed
# name in their user_settings.h, or feed gen-sbom the full
# `$CC -dM -E` dump via --options-h together with a hand-edited
# add-back file. None of the embedded customer profiles in the
# tree (NETOS, Telit, Zephyr, ESP-IDF, GCC-ARM, MDK, IAR, NUTTX)
# use such flags, which is why we accept the heuristic.
_NOISE_MACRO_RE = re.compile(
r'^(?:'
r'__\w+' # compiler/preprocessor reserved
r'|_[A-Z][A-Z0-9_]*' # ISO C reserved (e.g. _LP64)
r'|TARGET_OS_\w+' # Apple TargetConditionals leak
r'|TARGET_IPHONE_\w+' # Apple TargetConditionals leak
r')$'
)
# Tokens that, when present anywhere in a `*_H` macro name, mark it as
# real wolfSSL / autoconf configuration rather than a header include
# guard. Kept tight on purpose - widening (e.g. adding `DEBUG_` or
# `WOLFSSL_`) would let through real guards like WOLFSSL_OPTIONS_H.
_CONFIG_H_TOKENS = ('HAVE_', 'NO_', 'USE_')
def _is_noise_macro(name):
"""True if `name` is a build-environment artefact rather than wolfSSL
configuration, and therefore must not appear as a SBOM
`wolfssl:build:*` property.
Drops three families (see the module-level comment block on
`_NOISE_MACRO_RE` for full rationale):
1. Compiler / preprocessor reserved (`__*`, `_[A-Z]*`).
2. Apple <TargetConditionals.h> (`TARGET_OS_*`, `TARGET_IPHONE_*`).
3. Header include guards (`*_H` not carrying any of
`_CONFIG_H_TOKENS`).
"""
if _NOISE_MACRO_RE.match(name):
return True
if name.endswith('_H') and not any(t in name for t in _CONFIG_H_TOKENS):
return True
return False
def _strip_define_comment(raw):
"""Strip trailing C/C++ comment from a #define value while preserving
`/`-bearing characters that appear inside a double-quoted string.
Earlier versions used `re.split(r'/\\*|//', raw, maxsplit=1)[0]`, which
is unaware of string literals. That regex corrupts autoconf-generated
defines such as
#define PACKAGE_URL "https://www.wolfssl.com"
#define PACKAGE_BUGREPORT "https://github.com/wolfssl/wolfssl/issues"
by truncating at the first `//` inside the URL — both end up as
`"https:` in the SBOM build properties, falsely showing PACKAGE_URL
drifting between releases when nothing actually changed.
Char literals are not handled: autoconf-generated options.h does not
emit them, and pcpp normalises customer user_settings.h before this
helper sees the value, so the only realistic source of `/` in a
#define value is a quoted string."""
in_str = False
i = 0
n = len(raw)
while i < n:
c = raw[i]
if in_str:
if c == '\\' and i + 1 < n:
i += 2
continue
if c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == '/' and i + 1 < n and raw[i + 1] in '/*':
return raw[:i]
i += 1
return raw
def parse_options_h(path):
"""Parse a flat `#define` header and return a sorted deduplicated
list of (name, value) pairs for every wolfSSL-relevant macro.
Accepts both autotools-generated `wolfssl/options.h` (curated by
./configure, contains only wolfSSL macros plus its own header guard)
and raw compiler output from `$CC -dM -E -include settings.h ...`
(the no-pcpp escape hatch documented in doc/SBOM.md § 1.5). The
latter case motivates the `_is_noise_macro` filter: a `clang -dM -E`
dump contains hundreds of compiler internals (`__VERSION__`,
`__SSE2__`, `__INT_FAST32_MAX__`) and Apple system header leaks
(`TARGET_OS_MAC`) that would otherwise drown out the wolfSSL
configuration in the SBOM and break reproducibility across hosts.
Trailing C/C++ comments on a #define line (`#define HAVE_FOO 42 /* x */`
or `// y`) are stripped; otherwise they would land verbatim in the
SBOM build properties. String literals are preserved intact so that
URLs in PACKAGE_URL / PACKAGE_BUGREPORT are not truncated at the
first `//` (see _strip_define_comment)."""
try:
with open(path) as f:
text = f.read()
except OSError as e:
print(f"WARNING: cannot read options.h {path}: {e}", file=sys.stderr)
return []
defines = {}
for m in re.finditer(r'^#define[ \t]+(\w+)(?:[ \t]+(.*))?$', text, re.MULTILINE):
name = m.group(1)
if _is_noise_macro(name):
continue
raw = (m.group(2) or '')
raw = _strip_define_comment(raw)
defines[name] = raw.strip()
return sorted(defines.items())
def parse_user_settings(settings_h_path, include_dirs, predefines):
"""Walk wolfssl/wolfcrypt/settings.h through pcpp and return the same
sorted [(name, value), ...] list shape that parse_options_h() returns.
The customer's user_settings.h is included transitively via the
standard `#ifdef WOLFSSL_USER_SETTINGS` gate inside settings.h, so the
caller predefines `WOLFSSL_USER_SETTINGS` and adds the directory of
user_settings.h to `include_dirs`. This mirrors the way the C compiler
actually sees the wolfSSL build, so the SBOM build properties reflect
the real compiled configuration rather than just the literal text of
user_settings.h.
Filters (see `_is_noise_macro` for the shared family list used by
both this function and parse_options_h):
* compiler/preprocessor reserved names (`__*`, `_[A-Z]*`). pcpp's
own internals (__DATE__/__TIME__/__PCPP__/__FILE__) and any host
compiler defines transitively leaking through pcpp's preprocess
would otherwise break reproducibility across build hosts.
* Apple <TargetConditionals.h> macros (`TARGET_OS_*`,
`TARGET_IPHONE_*`). Defensive: pcpp does not auto-include
system headers, but a customer's user_settings.h may.
* header guards (`*_H` whose token does not carry an autoconf /
wolfSSL config prefix - see _CONFIG_H_TOKENS). wolfSSL's own
settings.h / visibility.h emit guards like
WOLF_CRYPT_SETTINGS_H that describe inclusion, not
configuration; real `_H` configuration flags (NO_STDINT_H,
USE_FLAT_TEST_H, WOLFSSL_NO_ASSERT_H) are preserved.
* function-like macros are dropped (they are API surface, not
build configuration; including their post-expansion body would
also break reproducibility under whitespace/token-render drift).
pcpp is imported lazily so the autotools path (which uses
parse_options_h) does not require the dependency.
"""
try:
from pcpp import Preprocessor
except ImportError:
sys.exit(
"ERROR: --user-settings requires the 'pcpp' Python preprocessor.\n"
" Install: pip install pcpp\n"
" Or pre-process externally and pass the result via "
"--options-h instead\n"
" (e.g. $CC -dM -E -include wolfssl/wolfcrypt/settings.h "
"-DWOLFSSL_USER_SETTINGS - < /dev/null)."
)
pp = Preprocessor()
pp.line_directive = None
for d in include_dirs:
pp.add_path(d)
for predefine in predefines:
# Compiler-style `-D KEY=VALUE` is the universal CLI shape;
# translate to the `"KEY VALUE"` form pcpp.define() expects.
# Bare `-D KEY` (no value) maps to `"KEY"`, also accepted.
spec = predefine.replace('=', ' ', 1) if '=' in predefine else predefine
pp.define(spec)
try:
with open(settings_h_path) as f:
text = f.read()
except OSError as e:
sys.exit(f"ERROR: cannot read settings.h {settings_h_path}: {e}")
pp.parse(text, source=settings_h_path)
# pcpp.write() is what actually drives the preprocessor through #if /
# #ifdef resolution and populates pp.macros with the surviving
# defines. The output stream is intentionally discarded - we only
# care about pp.macros - but this call is NOT optional.
sink = io.StringIO()
pp.write(sink)
# pcpp signals fatal preprocessing problems (an `#error` directive
# firing, an unbalanced `#if`, a missing #include, etc.) by setting
# pp.return_code to non-zero and printing to stderr; it does NOT
# raise. For an SBOM tool whose contract is "this artefact
# faithfully describes the build", a partial macro table produced
# before the failure is the worst possible output - the SBOM would
# silently omit configuration the customer set. Hard-fail instead
# so the build pipeline notices.
if pp.return_code != 0:
sys.exit(
f"ERROR: pcpp failed to preprocess {settings_h_path} "
f"(return_code={pp.return_code}); the resulting SBOM would "
f"be incomplete. Check the pcpp diagnostics printed above "
f"for the offending #error / #include / #if directive."
)
defines = {}
for name, macro in pp.macros.items():
if _is_noise_macro(name):
continue
if macro.arglist is not None:
continue
tokens = macro.value or []
defines[name] = ' '.join(t.value for t in tokens).strip()
return sorted(defines.items())
def gitoid_blob_sha256(path):
"""Compute the OmniBOR / git SHA-256 gitoid for a single file.
The format is `sha256("blob " + filesize + "\\0" + filecontents)`
which is byte-identical to `git hash-object --object-format=sha256`.
Using the gitoid (rather than a plain SHA-256) lets the source-set
Merkle hash interoperate with bomsh/OmniBOR tooling: a customer can
cross-reference the wolfSSL SBOM's component hash with the entries
in an OmniBOR artifact dependency graph and confirm the same files
on both sides.
The well-known empty-blob gitoid sha256 is
473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813
(regression-tested in scripts/test_gen_sbom.py).
"""
h = hashlib.sha256()
try:
with open(path, 'rb') as f:
# Take the size from the open descriptor (not a prior
# os.path.getsize) so the gitoid header length and the bytes
# hashed below come from the same file, with no TOCTOU window.
size = os.fstat(f.fileno()).st_size
h.update(f'blob {size}\x00'.encode())
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
except OSError as e:
sys.exit(f"ERROR: cannot read source for hashing: {e}")
return h.hexdigest()
def srcs_merkle_hash(src_paths):
"""Deterministic SHA-256 over a sorted list of (basename, gitoid)
pairs for the given source files.
Two customers compiling the same wolfSSL release with the same set
of source files get identical hashes regardless of where their
wolfSSL tree lives on disk, the order they passed --srcs, or the
filesystem they built on. Sorting on basename only (not full path)
is what makes this true; collisions across basenames would matter
in theory but wolfSSL's source layout has unique basenames per file
by construction.
A one-byte change in any compiled-in source produces a different
hash, which is the property that makes this useful as the SBOM
component checksum for embedded builds with no separate library
archive."""
seen = set()
entries = []
for path in src_paths:
name = os.path.basename(path)
if name in seen:
sys.exit(
f"ERROR: duplicate basename in --srcs: {name!r}\n"
f" Source files must have unique basenames so the "
f"Merkle hash is order-independent.")
seen.add(name)
entries.append((name, gitoid_blob_sha256(path)))
entries.sort()
h = hashlib.sha256()
for name, oid in entries:
h.update(f'{name}\x00{oid}\n'.encode())
return h.hexdigest()
def _collect_srcs(srcs_args, srcs_file):
"""Merge the --srcs list and the --srcs-file list into one ordered,
path-deduplicated list of source files.
--srcs-file is the file-driven companion to --srcs: one path per line,
with blank lines and `#` comment lines ignored. It exists because an
embedded link line can run to hundreds of wolfSSL .c files -- more than
fits comfortably on a command line -- and because an IDE / build system
can emit such a list mechanically (from a link map or project export),
which is exactly how a *complete* source set should be produced rather
than hand-curated.
Identical paths appearing in both inputs are collapsed (first occurrence
wins) so that combining a base --srcs-file with a couple of extra --srcs
overrides does not trip srcs_merkle_hash's duplicate-basename guard on a
file the operator listed twice by accident. Genuine distinct files that
share a basename are still rejected downstream -- that guard is what keeps
the Merkle hash order-independent.
"""
paths = list(srcs_args or [])
if srcs_file:
try:
with open(srcs_file, 'r') as f:
raw_lines = f.read().splitlines()
except OSError as e:
sys.exit(f"ERROR: cannot read --srcs-file {srcs_file!r}: {e}")
for line in raw_lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
paths.append(stripped)
seen = set()
deduped = []
for p in paths:
if p not in seen:
seen.add(p)
deduped.append(p)
if not deduped:
sys.exit(
"ERROR: --srcs / --srcs-file produced an empty source list.\n"
" Pass at least one wolfSSL .c file, or use "
"--no-artifact-hash if no hashable artefact exists.")
return deduped
def cdx_dep_component(name, pkg_version, key, dep_version_overrides=None):
"""Return (bom_ref, component_dict) for a CDX dependency component.
bom_ref is deterministic for reproducibility."""
meta = DEP_META[key]
version = dep_version(key, dep_version_overrides)
bom_ref = derived_uuid(name, pkg_version, 'dep', key)
comp = {
'bom-ref': bom_ref,
'type': 'library',
'supplier': {'name': meta['supplier']},
'name': meta['name'],
'licenses': [{'license': {'id': meta['license']}}],
'externalReferences': [{'type': 'vcs', 'url': meta['download']}],
}
if version:
comp['version'] = version
comp['purl'] = meta['purl'](version)
else:
print(f"WARNING: version unknown for {meta['name']}; "
"omitting version and purl", file=sys.stderr)
return bom_ref, comp
def spdx_dep_package(key, dep_version_overrides=None):
"""Return (spdx_id, package_dict) for an SPDX dependency package."""
meta = DEP_META[key]
version = dep_version(key, dep_version_overrides)
spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', meta['name'])
pkg = {
'SPDXID': spdx_id,
'name': meta['name'],
'versionInfo': version if version else 'NOASSERTION',
'supplier': f"Organization: {meta['supplier']}",
'downloadLocation': meta['download'],
'filesAnalyzed': False,
'licenseConcluded': meta['license'],
'licenseDeclared': meta['license'],
'copyrightText': 'NOASSERTION',
}
if version:
pkg['externalRefs'] = [{
'referenceCategory': 'PACKAGE-MANAGER',
'referenceType': 'purl',
'referenceLocator': meta['purl'](version),
}]
return spdx_id, pkg
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):
bom_ref = derived_uuid(name, version, 'package')
urls = project_urls(name)
dep_bom_refs = []
components = []
for key in enabled_deps:
ref, comp = cdx_dep_component(name, version, key, dep_version_overrides)
dep_bom_refs.append(ref)
components.append(comp)
properties = [
{'name': f'wolfssl:build:{k}', 'value': v if v else '1'}
for k, v in build_props
]
# Document what the SHA-256 in `hashes` represents, on every entry
# point. Without this property an auditor reading the SBOM has to
# guess whether the SHA-256 is over a library binary, a source-set
# Merkle hash, or something else. Emitting it unconditionally
# turns "what does this hash mean?" from forensic guesswork into
# a single property lookup.
properties.append(
{'name': 'wolfssl:sbom:hash-kind', 'value': hash_kind})
# hash-source is the coarse, stable provenance tag downstream tooling
# keys on: which *input* the checksum came from -- 'lib' (library
# archive), 'srcs' (compiled source set), or 'none' (no hashable
# artefact). hash-kind above carries the finer implementation detail
# (e.g. source-merkle-omnibor); hash-source is the value an integrator
# filters on without needing to know our hashing internals.
properties.append(
{'name': 'wolfssl:sbom:hash-source', 'value': hash_source})
if hash_source == 'none':
properties.append(
{'name': 'wolfssl:sbom:no-artifact-hash-note',
'value': _NO_HASH_NOTE})
if srcs_basenames:
properties.append({
'name': 'wolfssl:sbom:source-set',
'value': ','.join(srcs_basenames),
})
main_component = {
'bom-ref': bom_ref,
'type': 'library',
'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}',
'hashes': [{'alg': 'SHA-256', 'content': lib_hash}],
'externalReferences': [
{'type': 'vcs',
'url': urls['vcs']},
{'type': 'website',
'url': 'https://www.wolfssl.com/'},
{'type': 'issue-tracker',
'url': urls['issues']},
{'type': 'advisories',
'url': urls['advisories']},
{'type': 'security-contact',
'url': 'https://www.wolfssl.com/.well-known/security.txt'},
],
'properties': properties,
}
# 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
# in `hashes` back to a concrete file path; embedded paths skip
# this since the source-set Merkle hash already captures the inputs.
if file_entries:
main_component['components'] = [
{
'type': 'file',
'name': fe['name'],
'hashes': [
{'alg': 'SHA-1', 'content': fe['sha1']},
{'alg': 'SHA-256', 'content': fe['sha256']},
],
}
for fe in file_entries
]
return {
'$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json',
'bomFormat': 'CycloneDX',
'specVersion': '1.6',
'serialNumber': f'urn:uuid:{serial}',
'version': 1,
'metadata': {
'timestamp': timestamp,
'tools': {
'components': [{
'type': 'application',
'author': 'wolfSSL Inc.',
'name': GEN_SBOM_TOOL_NAME,
'version': GEN_SBOM_VERSION,
}]
},
'component': main_component,
},
'components': components,
'dependencies': [
{'ref': bom_ref, 'dependsOn': dep_bom_refs},
*[{'ref': r, 'dependsOn': []} for r in dep_bom_refs],
],
}
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):
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
# forced anyone reading the SPDX to grep free-form text. SPDX 2.3
# §8.5 provides `annotations[]` for exactly this -- structured
# producer notes that validators understand and downstream parsers
# can consume directly. The `comment` field now carries only the
# build-config define list a human reader scans first.
# Annotations on the wolfssl package: structured producer notes
# that the comment field used to carry as positional `key=value`
# slugs. Covered by the SPDX 2.3 §8.5 schema, so validators see
# them as first-class data instead of opaque text.
annotations = []
def _annotate(payload):
annotations.append({
'annotationDate': timestamp,
'annotationType': 'OTHER',
'annotator': f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}',
'comment': payload,
})
_annotate(f'wolfssl:sbom:hash-kind={hash_kind}')
_annotate(f'wolfssl:sbom:hash-source={hash_source}')
if hash_source == 'none':
_annotate(f'wolfssl:sbom:no-artifact-hash-note={_NO_HASH_NOTE}')
if srcs_basenames:
_annotate('wolfssl:sbom:source-set=' + ','.join(srcs_basenames))
urls = project_urls(name)
# Main-package SPDXID derived from --name (sanitised per SPDX 2.3 idstring
# rules) rather than hardcoded to wolfssl, so a wolfSSH/wolfMQTT SBOM does
# not mislabel its own package as wolfssl. For name='wolfssl' the result
# is 'SPDXRef-Package-wolfssl', unchanged from before.
main_spdx_id = 'SPDXRef-Package-' + re.sub(r'[^A-Za-z0-9.]', '', name)
wolfssl_pkg = {
'SPDXID': main_spdx_id,
'name': name,
'versionInfo': version,
'supplier': f'Organization: {supplier}',
'downloadLocation': urls['vcs'],
'filesAnalyzed': False,
'checksums': [{'algorithm': 'SHA256', 'checksumValue': lib_hash}],
'licenseConcluded': license_id,
'licenseDeclared': license_id,
'copyrightText': f'Copyright (C) 2006-{year} wolfSSL Inc.',
'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'],
},
],
}
# No SPDX `files[]` / `hasFiles[]` inventory. spdx-tools (the
# validator the autotools `make sbom` recipe runs) treats any
# `hasFiles` linkage as an implicit CONTAINS relationship, and
# SPDX 2.3 forbids package elements when `filesAnalyzed` is False.
# Flipping `filesAnalyzed` to True is not honest for wolfSSL: the
# package contains hundreds of source/header files, of which we
# only enumerate the linked binary, and `packageVerificationCode`
# under §8.10 requires every file in the package to be hashed.
# The CycloneDX side (which is more permissive about file
# sub-components) carries the linked-binary inventory; the SPDX
# side relies on the package-level SHA-256 plus the
# `wolfssl:sbom:hash-kind` annotation to identify the artefact.
# `file_entries` is accepted for parameter symmetry with
# generate_cdx but ignored here; if a future SPDX 2.4 / 3.0 model
# makes file inventory cleanly compatible with `filesAnalyzed:
# False`, this is the place to add it back.
del file_entries # unused on the SPDX side; see comment above.
packages = [wolfssl_pkg]
relationships = [{
'spdxElementId': 'SPDXRef-DOCUMENT',
'relatedSpdxElement': main_spdx_id,
'relationshipType': 'DESCRIBES',
}]
for key in enabled_deps:
spdx_id, pkg = spdx_dep_package(key, dep_version_overrides)
packages.append(pkg)
relationships.append({
'spdxElementId': main_spdx_id,
'relatedSpdxElement': spdx_id,
'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:<derived>`
# rather than a `https://wolfssl.com/sbom/...` URL the project does
# not actually host -- emitting an unresolvable URL misleads any
# downstream tool that follows it. Downstream packagers who DO host
# a per-version mirror can override via `--document-namespace`
# (Makefile.am: SBOM_DOCUMENT_NAMESPACE).
doc_namespace = document_namespace or f'urn:uuid:{doc_ns_uuid}'
doc = {
'spdxVersion': 'SPDX-2.3',
'dataLicense': 'CC0-1.0',
'SPDXID': 'SPDXRef-DOCUMENT',
'name': f'{name}-{version}',
'documentNamespace': doc_namespace,
'creationInfo': {
'creators': [
f'Organization: {supplier}',
f'Tool: {GEN_SBOM_TOOL_NAME}-{GEN_SBOM_VERSION}',
],
'created': timestamp,
},
'packages': packages,
'relationships': relationships,
}
extracted = build_extracted_licensing_infos(license_id, license_text)
if extracted:
doc['hasExtractedLicensingInfos'] = extracted
return doc
def _parse_dep_version_overrides(spec_list):
"""Parse repeated --dep-version KEY=VERSION flags into a dict.
Rejects unknown keys early so a typo (e.g. --dep-version libssl=…)
does not silently produce an SBOM that omits the dep version."""
overrides = {}
for spec in spec_list:
if '=' not in spec:
sys.exit(
f"ERROR: --dep-version expects KEY=VERSION, got {spec!r}")
key, _, value = spec.partition('=')
if key not in DEP_META:
sys.exit(
f"ERROR: --dep-version key {key!r} is not a known wolfSSL "
f"dependency. Known keys: {', '.join(sorted(DEP_META))}.")
overrides[key] = value
return overrides
def _resolve_dep_versions(enabled_deps, overrides):
"""Resolve each enabled dependency's version exactly once, mutating and
returning `overrides` so both the CDX and SPDX emitters reuse the same
value instead of each re-invoking pkg-config. Caching the result
(including None) means a later dep_version() lookup short-circuits on the
membership check rather than re-shelling to `pkg-config --modversion`, so
a default --with-libz build calls pkg-config once per dep
(not once per dep per output format) and the two documents can never
disagree if pkg-config output were ever non-deterministic."""
for key in enabled_deps:
if key not in overrides:
overrides[key] = dep_version(key, overrides)
return overrides
def main():
parser = argparse.ArgumentParser(
description='Generate CycloneDX and SPDX SBOMs for wolfssl. '
'Supports two entry-point shapes: the autotools / '
'library-binary form (--options-h + --lib) used by '
'`make sbom`, and the standalone embedded form '
'(--user-settings + --srcs) used by customers who '
'build with their own Makefile / IDE and never run '
'./configure.'
)
parser.add_argument('--name', required=True, help='Package name')
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('--license-file', required=True,
help='Path to LICENSING file for SPDX ID detection')
parser.add_argument('--license-override', default='',
help='Override the detected SPDX license expression '
'(e.g. LicenseRef-wolfSSL-Commercial). Useful '
'for commercial licensees regenerating the SBOM '
'for their own product.')
parser.add_argument('--license-text', default='',
help='Path to a plain-text licence file whose '
'contents are embedded in the SBOM as the '
'`extractedText` for any LicenseRef-* used in '
'`--license-override`. Required by SPDX 2.3 '
'validators (e.g. pyspdxtools) for any custom '
'licence reference.')
# Build-configuration source: pick exactly one.
parser.add_argument('--options-h',
help='Path to wolfssl/options.h for build config '
'(autotools entry point). The file is read '
'as a flat list of #define directives; pre-'
'processed `$CC -dM -E -include settings.h` '
'output works equivalently.')
parser.add_argument('--user-settings',
help='Path to wolfssl/wolfcrypt/settings.h to walk '
'through pcpp (embedded entry point). Combine '
'with --user-settings-include to point at the '
'directory containing user_settings.h, and '
'`--user-settings-define WOLFSSL_USER_SETTINGS` '
'to enable the user_settings.h inclusion gate.')
parser.add_argument('--user-settings-include', action='append', default=[],
metavar='DIR',
help='Add an include path for --user-settings '
'preprocessing (repeatable). Equivalent to -I '
'on the compiler command line.')
parser.add_argument('--user-settings-define', action='append', default=[],
metavar='NAME[=VALUE]',
help='Predefine a macro for --user-settings '
'preprocessing (repeatable). Equivalent to -D '
'on the compiler command line. At minimum '
'pass `WOLFSSL_USER_SETTINGS` so settings.h '
'pulls in user_settings.h.')
# Component checksum source: pick exactly one.
parser.add_argument('--lib',
help='Path to the wolfSSL library artifact '
'(shared or static) for SHA-256 hashing '
'(autotools entry point).')
parser.add_argument('--srcs', nargs='+', default=None,
help='wolfSSL source files compiled into the '
'firmware (embedded entry point). Their '
'OmniBOR-compatible gitoid Merkle hash is '
'used as the SBOM component checksum '
'instead of --lib. May be combined with '
'--srcs-file.')
parser.add_argument('--srcs-file', default=None, metavar='PATH',
help='Path to a file listing wolfSSL source files, '
'one per line (blank lines and lines starting '
'with `#` are ignored). The file-driven '
'companion to --srcs for link lines too long '
'for the command line, or lists emitted '
'mechanically by an IDE / build system (link '
'map, project export). Merged with --srcs and '
'hashed the same way.')
parser.add_argument('--no-artifact-hash', action='store_true',
help='Record a placeholder component checksum when '
'no hashable artefact exists (ROM image, HSM '
'firmware, binary-only redistribution). Emits '
'wolfssl:sbom:hash-source=none and a note '
'directing integrators to contact wolfSSL. '
'Mutually exclusive with --lib / --srcs / '
'--srcs-file.')
parser.add_argument('--dep-wolfssl', default='no',
help='yes to record wolfssl as a dependency component '
'(for downstream wolfSSL-stack products such as '
'wolfSSH / wolfMQTT that link libwolfssl). '
'wolfSSL\'s own SBOM leaves this off. Combine '
'with --dep-version wolfssl=X.Y.Z on hosts '
'without wolfssl.pc.')
parser.add_argument('--dep-openssl', default='no',
help='yes to record openssl as a dependency component '
'(for OpenSSL-compat products such as wolfProvider '
'/ wolfEngine that link libcrypto/libssl). Combine '
'with --dep-version openssl=X.Y.Z on hosts without '
'openssl.pc.')
parser.add_argument('--dep-libz', default='no',
help='yes if built with --with-libz')
parser.add_argument('--dep-version', action='append', default=[],
metavar='KEY=VERSION',
help='Override pkg-config version detection for a '
'dependency (repeatable). KEY is one of: '
+ ', '.join(sorted(DEP_META)) + '. Required '
'on hosts without pkg-config (typical embedded '
'cross-compile setups).')
parser.add_argument('--document-namespace', default='',
metavar='URI',
help='Override SPDX documentNamespace. Default '
'is a deterministic urn:uuid derived from '
'--name and --version. Set to a URI you '
'actually host (e.g. '
'https://example.com/sbom/wolfssl-X.Y.Z.spdx.json) '
'when re-publishing the SBOM under your own '
'distribution. SPDX 2.3 §6.5 requires only '
'uniqueness, not resolvability.')
parser.add_argument('--cdx-out', required=True,
help='Output path for CycloneDX JSON')
parser.add_argument('--spdx-out', required=True,
help='Output path for SPDX JSON')
args = parser.parse_args()
# Mutual exclusion + at-least-one validation for the two entry-point
# shapes. Surfacing this here keeps argparse's --required machinery
# simple and produces a friendlier error than argparse's auto-text.
if bool(args.options_h) == bool(args.user_settings):
sys.exit(
"ERROR: pass exactly one of --options-h or --user-settings.\n"
" --options-h: autotools entry point (a flat #define file "
"such as wolfssl/options.h).\n"
" --user-settings: embedded entry point (path to "
"wolfssl/wolfcrypt/settings.h, with --user-settings-include "
"pointing at the directory containing user_settings.h).")
srcs_provided = bool(args.srcs) or bool(args.srcs_file)
hash_sources = [bool(args.lib), srcs_provided, bool(args.no_artifact_hash)]
if sum(hash_sources) != 1:
sys.exit(
"ERROR: pass exactly one component-checksum source.\n"
" --lib: hash a built library artefact (.so/.a/.dylib).\n"
" --srcs / --srcs-file: hash the wolfSSL source files "
"compiled into your firmware (OmniBOR gitoid Merkle hash).\n"
" --no-artifact-hash: record a placeholder when no "
"hashable artefact exists (ROM/HSM/binary-only).")
# SPDX 2.3 §6.5 requires documentNamespace to be a unique absolute URI
# per RFC 3986. `make sbom` runs pyspdxtools afterwards and would
# catch a malformed value, but the standalone entry point has no
# validation gate -- a typo in SBOM_DOCUMENT_NAMESPACE / a packager
# passing a relative path would otherwise land malformed SPDX in
# downstream artefacts. An absolute URI per RFC 3986 §3 has a
# non-empty scheme; urlparse extracts that.
if args.document_namespace:
from urllib.parse import urlparse
scheme = urlparse(args.document_namespace).scheme
if not scheme:
sys.exit(
f"ERROR: --document-namespace {args.document_namespace!r} "
"is not an absolute URI (SPDX 2.3 §6.5 requires RFC 3986 "
"absolute URI form). Expected e.g. "
"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),
('openssl', args.dep_openssl),
('libz', args.dep_libz),
]
if flag.lower() == 'yes'
]
dep_version_overrides = _parse_dep_version_overrides(args.dep_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)
if args.license_override:
license_id = args.license_override
else:
license_id = detect_license(args.license_file)
if license_id is None:
print("WARNING: license could not be determined; using NOASSERTION",
file=sys.stderr)
license_id = 'NOASSERTION'
license_text = load_license_text(args.license_text)
if extract_license_refs(license_id) and license_text is None:
sys.exit(
"ERROR: --license-override contains a LicenseRef-* identifier "
"but --license-text was not provided.\n"
" SPDX 2.3 requires the licence text to be embedded in "
"hasExtractedLicensingInfos for any LicenseRef-* used in "
"licenseConcluded/licenseDeclared.\n"
" Re-run with --license-text PATH (or "
"`make sbom SBOM_LICENSE_TEXT=PATH`)."
)
if args.options_h:
build_props = parse_options_h(args.options_h)
else:
build_props = parse_user_settings(
args.user_settings,
args.user_settings_include,
args.user_settings_define,
)
file_entries = None
if args.lib:
# Refuse the empty-file SHA-256 as a component checksum. A
# build that points --lib at /dev/null, a stub touch(1)'d
# placeholder, or an empty .a that failed to ar-create would
# otherwise emit a valid-looking SBOM whose hash matches no
# compiled wolfSSL artefact ever shipped. The SBOM passes
# both spec validators -- nothing else catches it.
try:
lib_size = os.path.getsize(args.lib)
except OSError as e:
sys.exit(f"ERROR: cannot stat --lib {args.lib!r}: {e}")
if lib_size == 0:
sys.exit(
f"ERROR: --lib {args.lib!r} is empty (0 bytes); refusing "
"to emit an SBOM with the empty-file SHA-256 as the "
"component checksum. Verify your build produced a "
"real library artefact.")
lib_sha1, lib_hash = sha1_sha256_file(args.lib)
hash_kind = 'library-binary'
hash_source = 'lib'
srcs_basenames = None
# Single SPDX file entry / CycloneDX file sub-component for
# the linked library, so the SBOM names the artefact whose
# SHA-256 it is reporting (rather than only carrying the hash
# in `checksums[]`). Auditors and downstream tooling can
# then cross-reference the binary by its canonical filename
# without out-of-band knowledge of the build layout.
file_entries = [{
'name': os.path.basename(args.lib),
'sha1': lib_sha1,
'sha256': lib_hash,
}]
elif args.no_artifact_hash:
# No hashable artefact available (ROM image, HSM firmware,
# binary-only redistribution). Record an obviously-synthetic
# placeholder rather than a real SHA-256, flagged by both the
# hash-source property and the contact note so a downstream
# auditor cannot mistake it for a genuine artefact digest.
print(
"NOTE: --no-artifact-hash: recording a placeholder component "
"checksum (no library or source set to hash). Contact "
"wolfssl@wolfssl.com for integrity verification options.",
file=sys.stderr)
lib_hash = _NO_HASH_SENTINEL
hash_kind = 'none'
hash_source = 'none'
srcs_basenames = None
else:
# --srcs / --srcs-file is the embedded entry point. Zero-byte
# files in the set are uncommon but not necessarily wrong (a
# cross-compile toolchain may stub a per-target source with
# touch); warn rather than fail so the customer can decide
# whether the gitoid for an empty blob is what they want
# recorded.
srcs = _collect_srcs(args.srcs, args.srcs_file)
zero_byte_srcs = [
p for p in srcs if os.path.isfile(p) and os.path.getsize(p) == 0
]
if zero_byte_srcs:
print(
"WARNING: zero-byte source files in --srcs (gitoid will "
"be the well-known empty-blob hash for these): "
+ ', '.join(zero_byte_srcs),
file=sys.stderr)
lib_hash = srcs_merkle_hash(srcs)
hash_kind = 'source-merkle-omnibor'
hash_source = 'srcs'
srcs_basenames = sorted({os.path.basename(p) for p in srcs})
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')
cdx = generate_cdx(
args.name, args.version, args.supplier,
license_id, license_text, lib_hash, timestamp, year, serial,
enabled_deps, build_props,
dep_version_overrides=dep_version_overrides,
hash_kind=hash_kind, hash_source=hash_source,
srcs_basenames=srcs_basenames,
file_entries=file_entries,
)
spdx = generate_spdx(
args.name, args.version, args.supplier,
license_id, license_text, lib_hash, timestamp, year, doc_ns_uuid,
enabled_deps, build_props,
dep_version_overrides=dep_version_overrides,
hash_kind=hash_kind, hash_source=hash_source,
srcs_basenames=srcs_basenames,
document_namespace=(args.document_namespace or None),
file_entries=file_entries,
)
try:
with open(args.cdx_out, 'w') as f:
json.dump(cdx, f, indent=2)
f.write('\n')
with open(args.spdx_out, 'w') as f:
json.dump(spdx, f, indent=2)
f.write('\n')
except OSError as e:
sys.exit(f"ERROR: cannot write SBOM output: {e}")
print(f"Generated: {args.cdx_out}")
print(f"Generated: {args.spdx_out}")
if __name__ == '__main__':
main()