diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 09c712c..41bf936 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -1,8 +1,7 @@ name: wolfGlass self-test -# Runs the wolfGlass unit and integration tests on every change. The unit tests -# (scrub, version parse, validate) need no generator. The integration tests run -# only when a gen-sbom is available; this job checks out wolfSSL to provide one. +# Runs the wolfGlass unit and integration tests on every change. The vendored +# gen-sbom makes the integration path self-contained. on: push: @@ -25,23 +24,19 @@ jobs: run: | python -m py_compile \ share/sbom-driver.py \ + share/gen-sbom \ share/validate_sbom.py \ share/frontends/compdb_sbom.py \ share/frontends/iar_sbom.py \ share/frontends/zephyr_sbom.py \ + central/gen-advisory \ + provenance/bomsh_verify.py \ tools/wolfglass-sync \ + tests/test_gen_sbom.py \ tests/test_sbom.py - - name: Checkout wolfSSL (for gen-sbom) - uses: actions/checkout@v4 - continue-on-error: true - with: - repository: wolfSSL/wolfssl - path: _wolfssl + - name: Run generator unit tests + run: python -m unittest tests/test_gen_sbom.py - name: Run self-test - run: | - if [ -f _wolfssl/scripts/gen-sbom ]; then - export WOLFSSL_DIR="$GITHUB_WORKSPACE/_wolfssl" - fi - python tests/test_sbom.py + run: python tests/test_sbom.py diff --git a/README.md b/README.md index 4491847..8ecd46d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ wolfGlass is the single source of truth for the SBOM tools. Each product repository vendors a small, pinned subset of these files. wolfGlass also runs the central advisory tool and the optional build-provenance tool. +This repository now carries the shared SBOM generator, the shared build +fragments, the front ends, the advisory/VEX tooling, and the provenance +verifier. Product repositories vendor the SBOM layer from here. + ## Goals - Make one valid SBOM (CycloneDX 1.6 and SPDX 2.3) for every product. @@ -16,15 +20,33 @@ central advisory tool and the optional build-provenance tool. | Path | Purpose | |---|---| -| `share/` | The only vendorable set. Products copy these files. | -| `central/` | The advisory tool. Runs for wolfSSL only. Not vendored. | -| `provenance/` | The bomsh provenance tool. Opt-in. Linux only. | -| `schemas/` | Pinned CDX 1.6 and SPDX 2.3 schemas for offline validation. | -| `tools/` | The sync tool and the out-of-tree driver. | +| `share/` | The vendorable SBOM driver, front ends, and build fragments. | +| `central/` | Planned home of the advisory/VEX tools. Not vendored. | +| `provenance/` | Planned home of the bomsh provenance tools. Opt-in. | +| `schemas/` | Planned home of pinned offline validation schemas. | +| `tools/` | The sync tool and the future out-of-tree driver. | | `.github/workflows/` | Repository CI and reusable GitHub workflows. | | `tests/` | Tool unit tests. Not vendored. | | `docs/` | The program plan and integration guides. | +## Current State + +Present now: + +- Shared Python driver: `share/sbom-driver.py` and `share/sbom-driver` +- Shared generator: `share/gen-sbom` +- Shared autotools fragment: `share/sbom.am` +- Shared front ends: `frontends/compdb_sbom.py`, `iar_sbom.py`, `zephyr_sbom.py` +- Shared build fragments for Make, CMake, and autotools +- Central advisory/VEX tooling +- Central provenance verifier +- Sync tool, validator, self-test, and GitHub workflows + +Not here yet: + +- Offline schema bundle +- Full provenance driver wiring + ## Quick start Vendor the toolkit into a product and generate an SBOM: @@ -34,8 +56,6 @@ Vendor the toolkit into a product and generate an SBOM: tools/wolfglass-sync --dest /path/to/product # 2. From the product build, call the driver (or the Make/CMake fragment). -# Point it at a gen-sbom until one is vendored (see Open decisions). -export WOLFSSL_DIR=/path/to/wolfssl tools/sbom/sbom-driver --name wolfboot --srcs-file srcs.txt \ --cflags="$CFLAGS" --version-file include/wolfboot/version.h \ --version-macro LIBWOLFBOOT_VERSION_STRING @@ -44,21 +64,22 @@ tools/sbom/sbom-driver --name wolfboot --srcs-file srcs.txt \ Run the self-test: ```sh -WOLFSSL_DIR=/path/to/wolfssl python3 tests/test_sbom.py +python3 tests/test_sbom.py ``` ## Status -Phase 1 is complete: the product-neutral engine (`share/`), the front ends, the -build fragments, the sync tool, the self-test, and the CI workflows are in place -and tested. The full program plan is in [`docs/PLAN.md`](docs/PLAN.md). It lists -the problem, the solution, the benefits, the product tiers, the design, the CI -strategy, and the migration phases. The authoritative product-by-front-end -matrix and the dictionary are in [`docs/TIERS.md`](docs/TIERS.md). +The shared SBOM layer is ready for product adoption now: the product-neutral +engine (`share/`), the vendored generator, the Make/CMake/autotools fragments, +the front ends, the sync tool, the validator, the self-test, and the CI +workflows are in place. + +`gen-sbom` now lives in `share/` and is discovered there by default. A product +can still override it with `--gen-sbom` if it needs to pin a different copy for +testing. -The one item that gates full air-gapped use is the generator home: `gen-sbom` is -not vendored yet. The driver discovers it through `--gen-sbom` or `WOLFSSL_DIR` -until that decision is signed off. See "Open decisions" in the plan. +The full program plan is in [`docs/PLAN.md`](docs/PLAN.md). The authoritative +product-by-front-end matrix and dictionary are in [`docs/TIERS.md`](docs/TIERS.md). ## License diff --git a/central/advisory-vex-overlay.example.json b/central/advisory-vex-overlay.example.json new file mode 100644 index 0000000..bb1237e --- /dev/null +++ b/central/advisory-vex-overlay.example.json @@ -0,0 +1,45 @@ +{ + "_comment": "Human-authored VEX determinations keyed by CVE id. The CVE Program record carries the structural facts (CWE/CVSS/affected ranges); this overlay carries what it cannot express in machine-readable form: the analysis state, the not-affected justification, the response, free-text scope detail, the mainline fixed release version(s), an optional separately-modelled FIPS product entry, and an optional no-cost build-reachability hedge (requires_defines / default_status). The FIPS module_version and cmvp_cert values below are ILLUSTRATIVE placeholders; substitute the real validated module version and CMVP certificate number. gen-advisory folds these into both the CSAF and CycloneDX VEX outputs. requires_defines/default_status are recorded as informational notes only -- this tool does not compute per-build reachability.", + + "CVE-2026-5501": { + "state": "exploitable", + "response": ["update"], + "detail": "Limited to applications using the OpenSSL compatibility API directly (wolfSSL_X509_verify_cert), such as nginx and haproxy integrations. The native wolfSSL TLS handshake path (ProcessPeerCerts) is not susceptible.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later, or avoid relying on wolfSSL_X509_verify_cert in the OpenSSL compatibility layer for chain validation.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the affected OpenSSL compatibility layer (wolfSSL_X509_verify_cert) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5778": { + "state": "exploitable", + "response": ["update"], + "detail": "Integer underflow in the ChaCha20-Poly1305 decryption path of the packet sniffer.", + "requires_defines": ["WOLFSSL_SNIFFER", "HAVE_CHACHA", "HAVE_POLY1305"], + "default_status": "off", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later. Builds without --enable-sniffer are not affected.", + "fips": { + "name": "wolfCrypt FIPS 140-3 Module", + "module_version": "5.2.1", + "cmvp_cert": "4718", + "status": "not_affected", + "justification": "code_not_present", + "remediation": "No action required for the FIPS-validated module: the packet sniffer (src/sniffer.c) is outside the wolfCrypt FIPS module boundary." + } + }, + + "CVE-2026-5999": { + "state": "exploitable", + "response": ["update"], + "detail": "Synthetic fixture overlay: a simple mainline-only finding (no separate FIPS product) used to exercise the CVSS v3.1 scores[] path.", + "fixed_versions": ["5.9.1"], + "remediation": "Update to wolfSSL 5.9.1 or later." + } +} diff --git a/central/advisory-vex-overlay.schema.json b/central/advisory-vex-overlay.schema.json new file mode 100644 index 0000000..1417dc3 --- /dev/null +++ b/central/advisory-vex-overlay.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.wolfssl.com/schema/advisory-vex-overlay-1.json", + "title": "wolfSSL gen-advisory VEX overlay", + "description": "Human-authored VEX determinations keyed by CVE id, consumed by scripts/gen-advisory. The CVE Program record supplies the structural facts (CWE/CVSS/affected ranges); this overlay supplies what the record cannot express in machine-readable form. Enum values mirror the CycloneDX 1.6 vulnerability analysis vocabulary so the same terms map cleanly into both the CSAF and CycloneDX VEX outputs.", + "type": "object", + "properties": { + "_comment": { + "type": "string", + "description": "Free-text note ignored by gen-advisory." + } + }, + "patternProperties": { + "^CVE-[0-9]{4}-[0-9]{4,}$": { "$ref": "#/$defs/overlayEntry" } + }, + "additionalProperties": false, + "$defs": { + "analysisState": { + "type": "string", + "description": "CycloneDX 1.6 vulnerability analysis state.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ] + }, + "justification": { + "type": "string", + "description": "CycloneDX 1.6 impact analysis justification (required by gen-advisory when state is not_affected so a CSAF flag can be emitted).", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_perimeter", + "protected_at_runtime", + "protected_by_mitigating_control" + ] + }, + "response": { + "type": "array", + "description": "CycloneDX 1.6 vulnerability analysis response.", + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ] + } + }, + "versionList": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + }, + "fips": { + "type": "object", + "description": "Optional separately-modelled FIPS product entry. FIPS customers cannot freely upgrade and many CVEs fall outside the validated module boundary, so FIPS is modelled as its own product with its own status and remediation.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "module_version": { "type": "string", "minLength": 1 }, + "cmvp_cert": { + "type": "string", + "minLength": 1, + "description": "CMVP certificate number, recorded as a CSAF model_number / CycloneDX property." + }, + "status": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "status": { "const": "not_affected" } }, "required": ["status"] }, + "then": { "required": ["justification"] } + } + ] + }, + "overlayEntry": { + "type": "object", + "properties": { + "state": { "$ref": "#/$defs/analysisState" }, + "justification": { "$ref": "#/$defs/justification" }, + "response": { "$ref": "#/$defs/response" }, + "detail": { "type": "string" }, + "fixed_versions": { "$ref": "#/$defs/versionList" }, + "remediation": { "type": "string", "minLength": 1 }, + "requires_defines": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Build flags that gate the vulnerable code. Recorded as an informational note only; gen-advisory does NOT compute per-build reachability." + }, + "default_status": { + "type": "string", + "enum": ["on", "off", "enabled", "disabled"] + }, + "fips": { "$ref": "#/$defs/fips" } + }, + "required": ["state"], + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "state": { "const": "not_affected" } }, "required": ["state"] }, + "then": { "required": ["justification"] } + } + ] + } + } +} diff --git a/central/cwe-names.json b/central/cwe-names.json new file mode 100644 index 0000000..d256ca4 --- /dev/null +++ b/central/cwe-names.json @@ -0,0 +1,971 @@ +{ + "CWE-1004": "Sensitive Cookie Without 'HttpOnly' Flag", + "CWE-1007": "Insufficient Visual Distinction of Homoglyphs Presented to User", + "CWE-102": "Struts: Duplicate Validation Forms", + "CWE-1021": "Improper Restriction of Rendered UI Layers or Frames", + "CWE-1022": "Use of Web Link to Untrusted Target with window.opener Access", + "CWE-1023": "Incomplete Comparison with Missing Factors", + "CWE-1024": "Comparison of Incompatible Types", + "CWE-1025": "Comparison Using Wrong Factors", + "CWE-103": "Struts: Incomplete validate() Method Definition", + "CWE-1037": "Processor Optimization Removal or Modification of Security-critical Code", + "CWE-1038": "Insecure Automated Optimizations", + "CWE-1039": "Inadequate Detection or Handling of Adversarial Input Perturbations in Automated Recognition Mechanism", + "CWE-104": "Struts: Form Bean Does Not Extend Validation Class", + "CWE-1041": "Use of Redundant Code", + "CWE-1042": "Static Member Data Element outside of a Singleton Class Element", + "CWE-1043": "Data Element Aggregating an Excessively Large Number of Non-Primitive Elements", + "CWE-1044": "Architecture with Number of Horizontal Layers Outside of Expected Range", + "CWE-1045": "Parent Class with a Virtual Destructor and a Child Class without a Virtual Destructor", + "CWE-1046": "Creation of Immutable Text Using String Concatenation", + "CWE-1047": "Modules with Circular Dependencies", + "CWE-1048": "Invokable Control Element with Large Number of Outward Calls", + "CWE-1049": "Excessive Data Query Operations in a Large Data Table", + "CWE-105": "Struts: Form Field Without Validator", + "CWE-1050": "Excessive Platform Resource Consumption within a Loop", + "CWE-1051": "Initialization with Hard-Coded Network Resource Configuration Data", + "CWE-1052": "Excessive Use of Hard-Coded Literals in Initialization", + "CWE-1053": "Missing Documentation for Design", + "CWE-1054": "Invocation of a Control Element at an Unnecessarily Deep Horizontal Layer", + "CWE-1055": "Multiple Inheritance from Concrete Classes", + "CWE-1056": "Invokable Control Element with Variadic Parameters", + "CWE-1057": "Data Access Operations Outside of Expected Data Manager Component", + "CWE-1058": "Invokable Control Element in Multi-Thread Context with non-Final Static Storable or Member Element", + "CWE-1059": "Insufficient Technical Documentation", + "CWE-106": "Struts: Plug-in Framework not in Use", + "CWE-1060": "Excessive Number of Inefficient Server-Side Data Accesses", + "CWE-1061": "Insufficient Encapsulation", + "CWE-1062": "Parent Class with References to Child Class", + "CWE-1063": "Creation of Class Instance within a Static Code Block", + "CWE-1064": "Invokable Control Element with Signature Containing an Excessive Number of Parameters", + "CWE-1065": "Runtime Resource Management Control Element in a Component Built to Run on Application Servers", + "CWE-1066": "Missing Serialization Control Element", + "CWE-1067": "Excessive Execution of Sequential Searches of Data Resource", + "CWE-1068": "Inconsistency Between Implementation and Documented Design", + "CWE-1069": "Empty Exception Block", + "CWE-107": "Struts: Unused Validation Form", + "CWE-1070": "Serializable Data Element Containing non-Serializable Item Elements", + "CWE-1071": "Empty Code Block", + "CWE-1072": "Data Resource Access without Use of Connection Pooling", + "CWE-1073": "Non-SQL Invokable Control Element with Excessive Number of Data Resource Accesses", + "CWE-1074": "Class with Excessively Deep Inheritance", + "CWE-1075": "Unconditional Control Flow Transfer outside of Switch Block", + "CWE-1076": "Insufficient Adherence to Expected Conventions", + "CWE-1077": "Floating Point Comparison with Incorrect Operator", + "CWE-1078": "Inappropriate Source Code Style or Formatting", + "CWE-1079": "Parent Class without Virtual Destructor Method", + "CWE-108": "Struts: Unvalidated Action Form", + "CWE-1080": "Source Code File with Excessive Number of Lines of Code", + "CWE-1082": "Class Instance Self Destruction Control Element", + "CWE-1083": "Data Access from Outside Expected Data Manager Component", + "CWE-1084": "Invokable Control Element with Excessive File or Data Access Operations", + "CWE-1085": "Invokable Control Element with Excessive Volume of Commented-out Code", + "CWE-1086": "Class with Excessive Number of Child Classes", + "CWE-1087": "Class with Virtual Method without a Virtual Destructor", + "CWE-1088": "Synchronous Access of Remote Resource without Timeout", + "CWE-1089": "Large Data Table with Excessive Number of Indices", + "CWE-109": "Struts: Validator Turned Off", + "CWE-1090": "Method Containing Access of a Member Element from Another Class", + "CWE-1091": "Use of Object without Invoking Destructor Method", + "CWE-1092": "Use of Same Invokable Control Element in Multiple Architectural Layers", + "CWE-1093": "Excessively Complex Data Representation", + "CWE-1094": "Excessive Index Range Scan for a Data Resource", + "CWE-1095": "Loop Condition Value Update within the Loop", + "CWE-1096": "Singleton Class Instance Creation without Proper Locking or Synchronization", + "CWE-1097": "Persistent Storable Data Element without Associated Comparison Control Element", + "CWE-1098": "Data Element containing Pointer Item without Proper Copy Control Element", + "CWE-1099": "Inconsistent Naming Conventions for Identifiers", + "CWE-11": "ASP.NET Misconfiguration: Creating Debug Binary", + "CWE-110": "Struts: Validator Without Form Field", + "CWE-1100": "Insufficient Isolation of System-Dependent Functions", + "CWE-1101": "Reliance on Runtime Component in Generated Code", + "CWE-1102": "Reliance on Machine-Dependent Data Representation", + "CWE-1103": "Use of Platform-Dependent Third Party Components", + "CWE-1104": "Use of Unmaintained Third Party Components", + "CWE-1105": "Insufficient Encapsulation of Machine-Dependent Functionality", + "CWE-1106": "Insufficient Use of Symbolic Constants", + "CWE-1107": "Insufficient Isolation of Symbolic Constant Definitions", + "CWE-1108": "Excessive Reliance on Global Variables", + "CWE-1109": "Use of Same Variable for Multiple Purposes", + "CWE-111": "Direct Use of Unsafe JNI", + "CWE-1110": "Incomplete Design Documentation", + "CWE-1111": "Incomplete I/O Documentation", + "CWE-1112": "Incomplete Documentation of Program Execution", + "CWE-1113": "Inappropriate Comment Style", + "CWE-1114": "Inappropriate Whitespace Style", + "CWE-1115": "Source Code Element without Standard Prologue", + "CWE-1116": "Inaccurate Comments", + "CWE-1117": "Callable with Insufficient Behavioral Summary", + "CWE-1118": "Insufficient Documentation of Error Handling Techniques", + "CWE-1119": "Excessive Use of Unconditional Branching", + "CWE-112": "Missing XML Validation", + "CWE-1120": "Excessive Code Complexity", + "CWE-1121": "Excessive McCabe Cyclomatic Complexity", + "CWE-1122": "Excessive Halstead Complexity", + "CWE-1123": "Excessive Use of Self-Modifying Code", + "CWE-1124": "Excessively Deep Nesting", + "CWE-1125": "Excessive Attack Surface", + "CWE-1126": "Declaration of Variable with Unnecessarily Wide Scope", + "CWE-1127": "Compilation with Insufficient Warnings or Errors", + "CWE-113": "Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting')", + "CWE-114": "Process Control", + "CWE-115": "Misinterpretation of Input", + "CWE-116": "Improper Encoding or Escaping of Output", + "CWE-1164": "Irrelevant Code", + "CWE-117": "Improper Output Neutralization for Logs", + "CWE-1173": "Improper Use of Validation Framework", + "CWE-1174": "ASP.NET Misconfiguration: Improper Model Validation", + "CWE-1176": "Inefficient CPU Computation", + "CWE-1177": "Use of Prohibited Code", + "CWE-118": "Incorrect Access of Indexable Resource ('Range Error')", + "CWE-1187": "DEPRECATED: Use of Uninitialized Resource", + "CWE-1188": "Initialization of a Resource with an Insecure Default", + "CWE-1189": "Improper Isolation of Shared Resources on System-on-a-Chip (SoC)", + "CWE-119": "Improper Restriction of Operations within the Bounds of a Memory Buffer", + "CWE-1190": "DMA Device Enabled Too Early in Boot Phase", + "CWE-1191": "On-Chip Debug and Test Interface With Improper Access Control", + "CWE-1192": "Improper Identifier for IP Block used in System-On-Chip (SOC)", + "CWE-1193": "Power-On of Untrusted Execution Core Before Enabling Fabric Access Control", + "CWE-12": "ASP.NET Misconfiguration: Missing Custom Error Page", + "CWE-120": "Buffer Copy without Checking Size of Input ('Classic Buffer Overflow')", + "CWE-1204": "Generation of Weak Initialization Vector (IV)", + "CWE-1209": "Failure to Disable Reserved Bits", + "CWE-121": "Stack-based Buffer Overflow", + "CWE-122": "Heap-based Buffer Overflow", + "CWE-1220": "Insufficient Granularity of Access Control", + "CWE-1221": "Incorrect Register Defaults or Module Parameters", + "CWE-1222": "Insufficient Granularity of Address Regions Protected by Register Locks", + "CWE-1223": "Race Condition for Write-Once Attributes", + "CWE-1224": "Improper Restriction of Write-Once Bit Fields", + "CWE-1229": "Creation of Emergent Resource", + "CWE-123": "Write-what-where Condition", + "CWE-1230": "Exposure of Sensitive Information Through Metadata", + "CWE-1231": "Improper Prevention of Lock Bit Modification", + "CWE-1232": "Improper Lock Behavior After Power State Transition", + "CWE-1233": "Security-Sensitive Hardware Controls with Missing Lock Bit Protection", + "CWE-1234": "Hardware Internal or Debug Modes Allow Override of Locks", + "CWE-1235": "Incorrect Use of Autoboxing and Unboxing for Performance Critical Operations", + "CWE-1236": "Improper Neutralization of Formula Elements in a CSV File", + "CWE-1239": "Improper Zeroization of Hardware Register", + "CWE-124": "Buffer Underwrite ('Buffer Underflow')", + "CWE-1240": "Use of a Cryptographic Primitive with a Risky Implementation", + "CWE-1241": "Use of Predictable Algorithm in Random Number Generator", + "CWE-1242": "Inclusion of Undocumented Features or Chicken Bits", + "CWE-1243": "Sensitive Non-Volatile Information Not Protected During Debug", + "CWE-1244": "Internal Asset Exposed to Unsafe Debug Access Level or State", + "CWE-1245": "Improper Finite State Machines (FSMs) in Hardware Logic", + "CWE-1246": "Improper Write Handling in Limited-write Non-Volatile Memories", + "CWE-1247": "Improper Protection Against Voltage and Clock Glitches", + "CWE-1248": "Semiconductor Defects in Hardware Logic with Security-Sensitive Implications", + "CWE-1249": "Application-Level Admin Tool with Inconsistent View of Underlying Operating System", + "CWE-125": "Out-of-bounds Read", + "CWE-1250": "Improper Preservation of Consistency Between Independent Representations of Shared State", + "CWE-1251": "Mirrored Regions with Different Values", + "CWE-1252": "CPU Hardware Not Configured to Support Exclusivity of Write and Execute Operations", + "CWE-1253": "Incorrect Selection of Fuse Values", + "CWE-1254": "Incorrect Comparison Logic Granularity", + "CWE-1255": "Comparison Logic is Vulnerable to Power Side-Channel Attacks", + "CWE-1256": "Improper Restriction of Software Interfaces to Hardware Features", + "CWE-1257": "Improper Access Control Applied to Mirrored or Aliased Memory Regions", + "CWE-1258": "Exposure of Sensitive System Information Due to Uncleared Debug Information", + "CWE-1259": "Improper Restriction of Security Token Assignment", + "CWE-126": "Buffer Over-read", + "CWE-1260": "Improper Handling of Overlap Between Protected Memory Ranges", + "CWE-1261": "Improper Handling of Single Event Upsets", + "CWE-1262": "Improper Access Control for Register Interface", + "CWE-1263": "Improper Physical Access Control", + "CWE-1264": "Hardware Logic with Insecure De-Synchronization between Control and Data Channels", + "CWE-1265": "Unintended Reentrant Invocation of Non-reentrant Code Via Nested Calls", + "CWE-1266": "Improper Scrubbing of Sensitive Data from Decommissioned Device", + "CWE-1267": "Policy Uses Obsolete Encoding", + "CWE-1268": "Policy Privileges are not Assigned Consistently Between Control and Data Agents", + "CWE-1269": "Product Released in Non-Release Configuration", + "CWE-127": "Buffer Under-read", + "CWE-1270": "Generation of Incorrect Security Tokens", + "CWE-1271": "Uninitialized Value on Reset for Registers Holding Security Settings", + "CWE-1272": "Sensitive Information Uncleared Before Debug/Power State Transition", + "CWE-1273": "Device Unlock Credential Sharing", + "CWE-1274": "Improper Access Control for Volatile Memory Containing Boot Code", + "CWE-1275": "Sensitive Cookie with Improper SameSite Attribute", + "CWE-1276": "Hardware Child Block Incorrectly Connected to Parent System", + "CWE-1277": "Firmware Not Updateable", + "CWE-1278": "Missing Protection Against Hardware Reverse Engineering Using Integrated Circuit (IC) Imaging Techniques", + "CWE-1279": "Cryptographic Operations are run Before Supporting Units are Ready", + "CWE-128": "Wrap-around Error", + "CWE-1280": "Access Control Check Implemented After Asset is Accessed", + "CWE-1281": "Sequence of Processor Instructions Leads to Unexpected Behavior", + "CWE-1282": "Assumed-Immutable Data is Stored in Writable Memory", + "CWE-1283": "Mutable Attestation or Measurement Reporting Data", + "CWE-1284": "Improper Validation of Specified Quantity in Input", + "CWE-1285": "Improper Validation of Specified Index, Position, or Offset in Input", + "CWE-1286": "Improper Validation of Syntactic Correctness of Input", + "CWE-1287": "Improper Validation of Specified Type of Input", + "CWE-1288": "Improper Validation of Consistency within Input", + "CWE-1289": "Improper Validation of Unsafe Equivalence in Input", + "CWE-129": "Improper Validation of Array Index", + "CWE-1290": "Incorrect Decoding of Security Identifiers", + "CWE-1291": "Public Key Re-Use for Signing both Debug and Production Code", + "CWE-1292": "Incorrect Conversion of Security Identifiers", + "CWE-1293": "Missing Source Correlation of Multiple Independent Data", + "CWE-1294": "Insecure Security Identifier Mechanism", + "CWE-1295": "Debug Messages Revealing Unnecessary Information", + "CWE-1296": "Incorrect Chaining or Granularity of Debug Components", + "CWE-1297": "Unprotected Confidential Information on Device is Accessible by OSAT Vendors", + "CWE-1298": "Hardware Logic Contains Race Conditions", + "CWE-1299": "Missing Protection Mechanism for Alternate Hardware Interface", + "CWE-13": "ASP.NET Misconfiguration: Password in Configuration File", + "CWE-130": "Improper Handling of Length Parameter Inconsistency", + "CWE-1300": "Improper Protection of Physical Side Channels", + "CWE-1301": "Insufficient or Incomplete Data Removal within Hardware Component", + "CWE-1302": "Missing Source Identifier in Entity Transactions on a System-On-Chip (SOC)", + "CWE-1303": "Non-Transparent Sharing of Microarchitectural Resources", + "CWE-1304": "Improperly Preserved Integrity of Hardware Configuration State During a Power Save/Restore Operation", + "CWE-131": "Incorrect Calculation of Buffer Size", + "CWE-1310": "Missing Ability to Patch ROM Code", + "CWE-1311": "Improper Translation of Security Attributes by Fabric Bridge", + "CWE-1312": "Missing Protection for Mirrored Regions in On-Chip Fabric Firewall", + "CWE-1313": "Hardware Allows Activation of Test or Debug Logic at Runtime", + "CWE-1314": "Missing Write Protection for Parametric Data Values", + "CWE-1315": "Improper Setting of Bus Controlling Capability in Fabric End-point", + "CWE-1316": "Fabric-Address Map Allows Programming of Unwarranted Overlaps of Protected and Unprotected Ranges", + "CWE-1317": "Improper Access Control in Fabric Bridge", + "CWE-1318": "Missing Support for Security Features in On-chip Fabrics or Buses", + "CWE-1319": "Improper Protection against Electromagnetic Fault Injection (EM-FI)", + "CWE-132": "DEPRECATED: Miscalculated Null Termination", + "CWE-1320": "Improper Protection for Outbound Error Messages and Alert Signals", + "CWE-1321": "Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')", + "CWE-1322": "Use of Blocking Code in Single-threaded, Non-blocking Context", + "CWE-1323": "Improper Management of Sensitive Trace Data", + "CWE-1324": "DEPRECATED: Sensitive Information Accessible by Physical Probing of JTAG Interface", + "CWE-1325": "Improperly Controlled Sequential Memory Allocation", + "CWE-1326": "Missing Immutable Root of Trust in Hardware", + "CWE-1327": "Binding to an Unrestricted IP Address", + "CWE-1328": "Security Version Number Mutable to Older Versions", + "CWE-1329": "Reliance on Component That is Not Updateable", + "CWE-1330": "Remanent Data Readable after Memory Erase", + "CWE-1331": "Improper Isolation of Shared Resources in Network On Chip (NoC)", + "CWE-1332": "Improper Handling of Faults that Lead to Instruction Skips", + "CWE-1333": "Inefficient Regular Expression Complexity", + "CWE-1334": "Unauthorized Error Injection Can Degrade Hardware Redundancy", + "CWE-1335": "Incorrect Bitwise Shift of Integer", + "CWE-1336": "Improper Neutralization of Special Elements Used in a Template Engine", + "CWE-1338": "Improper Protections Against Hardware Overheating", + "CWE-1339": "Insufficient Precision or Accuracy of a Real Number", + "CWE-134": "Use of Externally-Controlled Format String", + "CWE-1341": "Multiple Releases of Same Resource or Handle", + "CWE-1342": "Information Exposure through Microarchitectural State after Transient Execution", + "CWE-135": "Incorrect Calculation of Multi-Byte String Length", + "CWE-1351": "Improper Handling of Hardware Behavior in Exceptionally Cold Environments", + "CWE-1357": "Reliance on Insufficiently Trustworthy Component", + "CWE-138": "Improper Neutralization of Special Elements", + "CWE-1384": "Improper Handling of Physical or Environmental Conditions", + "CWE-1385": "Missing Origin Validation in WebSockets", + "CWE-1386": "Insecure Operation on Windows Junction / Mount Point", + "CWE-1389": "Incorrect Parsing of Numbers with Different Radices", + "CWE-1390": "Weak Authentication", + "CWE-1391": "Use of Weak Credentials", + "CWE-1392": "Use of Default Credentials", + "CWE-1393": "Use of Default Password", + "CWE-1394": "Use of Default Cryptographic Key", + "CWE-1395": "Dependency on Vulnerable Third-Party Component", + "CWE-14": "Compiler Removal of Code to Clear Buffers", + "CWE-140": "Improper Neutralization of Delimiters", + "CWE-141": "Improper Neutralization of Parameter/Argument Delimiters", + "CWE-1419": "Incorrect Initialization of Resource", + "CWE-142": "Improper Neutralization of Value Delimiters", + "CWE-1420": "Exposure of Sensitive Information during Transient Execution", + "CWE-1421": "Exposure of Sensitive Information in Shared Microarchitectural Structures during Transient Execution", + "CWE-1422": "Exposure of Sensitive Information caused by Incorrect Data Forwarding during Transient Execution", + "CWE-1423": "Exposure of Sensitive Information caused by Shared Microarchitectural Predictor State that Influences Transient Execution", + "CWE-1426": "Improper Validation of Generative AI Output", + "CWE-1427": "Improper Neutralization of Input Used for LLM Prompting", + "CWE-1428": "Reliance on HTTP instead of HTTPS", + "CWE-1429": "Missing Security-Relevant Feedback for Unexecuted Operations in Hardware Interface", + "CWE-143": "Improper Neutralization of Record Delimiters", + "CWE-1431": "Driving Intermediate Cryptographic State/Results to Hardware Module Outputs", + "CWE-1434": "Insecure Setting of Generative AI/ML Model Inference Parameters", + "CWE-144": "Improper Neutralization of Line Delimiters", + "CWE-145": "Improper Neutralization of Section Delimiters", + "CWE-146": "Improper Neutralization of Expression/Command Delimiters", + "CWE-147": "Improper Neutralization of Input Terminators", + "CWE-148": "Improper Neutralization of Input Leaders", + "CWE-149": "Improper Neutralization of Quoting Syntax", + "CWE-15": "External Control of System or Configuration Setting", + "CWE-150": "Improper Neutralization of Escape, Meta, or Control Sequences", + "CWE-151": "Improper Neutralization of Comment Delimiters", + "CWE-152": "Improper Neutralization of Macro Symbols", + "CWE-153": "Improper Neutralization of Substitution Characters", + "CWE-154": "Improper Neutralization of Variable Name Delimiters", + "CWE-155": "Improper Neutralization of Wildcards or Matching Symbols", + "CWE-156": "Improper Neutralization of Whitespace", + "CWE-157": "Failure to Sanitize Paired Delimiters", + "CWE-158": "Improper Neutralization of Null Byte or NUL Character", + "CWE-159": "Improper Handling of Invalid Use of Special Elements", + "CWE-160": "Improper Neutralization of Leading Special Elements", + "CWE-161": "Improper Neutralization of Multiple Leading Special Elements", + "CWE-162": "Improper Neutralization of Trailing Special Elements", + "CWE-163": "Improper Neutralization of Multiple Trailing Special Elements", + "CWE-164": "Improper Neutralization of Internal Special Elements", + "CWE-165": "Improper Neutralization of Multiple Internal Special Elements", + "CWE-166": "Improper Handling of Missing Special Element", + "CWE-167": "Improper Handling of Additional Special Element", + "CWE-168": "Improper Handling of Inconsistent Special Elements", + "CWE-170": "Improper Null Termination", + "CWE-172": "Encoding Error", + "CWE-173": "Improper Handling of Alternate Encoding", + "CWE-174": "Double Decoding of the Same Data", + "CWE-175": "Improper Handling of Mixed Encoding", + "CWE-176": "Improper Handling of Unicode Encoding", + "CWE-177": "Improper Handling of URL Encoding (Hex Encoding)", + "CWE-178": "Improper Handling of Case Sensitivity", + "CWE-179": "Incorrect Behavior Order: Early Validation", + "CWE-180": "Incorrect Behavior Order: Validate Before Canonicalize", + "CWE-181": "Incorrect Behavior Order: Validate Before Filter", + "CWE-182": "Collapse of Data into Unsafe Value", + "CWE-183": "Permissive List of Allowed Inputs", + "CWE-184": "Incomplete List of Disallowed Inputs", + "CWE-185": "Incorrect Regular Expression", + "CWE-186": "Overly Restrictive Regular Expression", + "CWE-187": "Partial String Comparison", + "CWE-188": "Reliance on Data/Memory Layout", + "CWE-190": "Integer Overflow or Wraparound", + "CWE-191": "Integer Underflow (Wrap or Wraparound)", + "CWE-192": "Integer Coercion Error", + "CWE-193": "Off-by-one Error", + "CWE-194": "Unexpected Sign Extension", + "CWE-195": "Signed to Unsigned Conversion Error", + "CWE-196": "Unsigned to Signed Conversion Error", + "CWE-197": "Numeric Truncation Error", + "CWE-198": "Use of Incorrect Byte Ordering", + "CWE-20": "Improper Input Validation", + "CWE-200": "Exposure of Sensitive Information to an Unauthorized Actor", + "CWE-201": "Insertion of Sensitive Information Into Sent Data", + "CWE-202": "Exposure of Sensitive Information Through Data Queries", + "CWE-203": "Observable Discrepancy", + "CWE-204": "Observable Response Discrepancy", + "CWE-205": "Observable Behavioral Discrepancy", + "CWE-206": "Observable Internal Behavioral Discrepancy", + "CWE-207": "Observable Behavioral Discrepancy With Equivalent Products", + "CWE-208": "Observable Timing Discrepancy", + "CWE-209": "Generation of Error Message Containing Sensitive Information", + "CWE-210": "Self-generated Error Message Containing Sensitive Information", + "CWE-211": "Externally-Generated Error Message Containing Sensitive Information", + "CWE-212": "Improper Removal of Sensitive Information Before Storage or Transfer", + "CWE-213": "Exposure of Sensitive Information Due to Incompatible Policies", + "CWE-214": "Invocation of Process Using Visible Sensitive Information", + "CWE-215": "Insertion of Sensitive Information Into Debugging Code", + "CWE-216": "DEPRECATED: Containment Errors (Container Errors)", + "CWE-217": "DEPRECATED: Failure to Protect Stored Data from Modification", + "CWE-218": "DEPRECATED: Failure to provide confidentiality for stored data", + "CWE-219": "Storage of File with Sensitive Data Under Web Root", + "CWE-22": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", + "CWE-220": "Storage of File With Sensitive Data Under FTP Root", + "CWE-221": "Information Loss or Omission", + "CWE-222": "Truncation of Security-relevant Information", + "CWE-223": "Omission of Security-relevant Information", + "CWE-224": "Obscured Security-relevant Information by Alternate Name", + "CWE-225": "DEPRECATED: General Information Management Problems", + "CWE-226": "Sensitive Information in Resource Not Removed Before Reuse", + "CWE-228": "Improper Handling of Syntactically Invalid Structure", + "CWE-229": "Improper Handling of Values", + "CWE-23": "Relative Path Traversal", + "CWE-230": "Improper Handling of Missing Values", + "CWE-231": "Improper Handling of Extra Values", + "CWE-232": "Improper Handling of Undefined Values", + "CWE-233": "Improper Handling of Parameters", + "CWE-234": "Failure to Handle Missing Parameter", + "CWE-235": "Improper Handling of Extra Parameters", + "CWE-236": "Improper Handling of Undefined Parameters", + "CWE-237": "Improper Handling of Structural Elements", + "CWE-238": "Improper Handling of Incomplete Structural Elements", + "CWE-239": "Failure to Handle Incomplete Element", + "CWE-24": "Path Traversal: '../filedir'", + "CWE-240": "Improper Handling of Inconsistent Structural Elements", + "CWE-241": "Improper Handling of Unexpected Data Type", + "CWE-242": "Use of Inherently Dangerous Function", + "CWE-243": "Creation of chroot Jail Without Changing Working Directory", + "CWE-244": "Improper Clearing of Heap Memory Before Release ('Heap Inspection')", + "CWE-245": "J2EE Bad Practices: Direct Management of Connections", + "CWE-246": "J2EE Bad Practices: Direct Use of Sockets", + "CWE-247": "DEPRECATED: Reliance on DNS Lookups in a Security Decision", + "CWE-248": "Uncaught Exception", + "CWE-249": "DEPRECATED: Often Misused: Path Manipulation", + "CWE-25": "Path Traversal: '/../filedir'", + "CWE-250": "Execution with Unnecessary Privileges", + "CWE-252": "Unchecked Return Value", + "CWE-253": "Incorrect Check of Function Return Value", + "CWE-256": "Plaintext Storage of a Password", + "CWE-257": "Storing Passwords in a Recoverable Format", + "CWE-258": "Empty Password in Configuration File", + "CWE-259": "Use of Hard-coded Password", + "CWE-26": "Path Traversal: '/dir/../filename'", + "CWE-260": "Password in Configuration File", + "CWE-261": "Weak Encoding for Password", + "CWE-262": "Not Using Password Aging", + "CWE-263": "Password Aging with Long Expiration", + "CWE-266": "Incorrect Privilege Assignment", + "CWE-267": "Privilege Defined With Unsafe Actions", + "CWE-268": "Privilege Chaining", + "CWE-269": "Improper Privilege Management", + "CWE-27": "Path Traversal: 'dir/../../filename'", + "CWE-270": "Privilege Context Switching Error", + "CWE-271": "Privilege Dropping / Lowering Errors", + "CWE-272": "Least Privilege Violation", + "CWE-273": "Improper Check for Dropped Privileges", + "CWE-274": "Improper Handling of Insufficient Privileges", + "CWE-276": "Incorrect Default Permissions", + "CWE-277": "Insecure Inherited Permissions", + "CWE-278": "Insecure Preserved Inherited Permissions", + "CWE-279": "Incorrect Execution-Assigned Permissions", + "CWE-28": "Path Traversal: '..\\filedir'", + "CWE-280": "Improper Handling of Insufficient Permissions or Privileges", + "CWE-281": "Improper Preservation of Permissions", + "CWE-282": "Improper Ownership Management", + "CWE-283": "Unverified Ownership", + "CWE-284": "Improper Access Control", + "CWE-285": "Improper Authorization", + "CWE-286": "Incorrect User Management", + "CWE-287": "Improper Authentication", + "CWE-288": "Authentication Bypass Using an Alternate Path or Channel", + "CWE-289": "Authentication Bypass by Alternate Name", + "CWE-29": "Path Traversal: '\\..\\filename'", + "CWE-290": "Authentication Bypass by Spoofing", + "CWE-291": "Reliance on IP Address for Authentication", + "CWE-292": "DEPRECATED: Trusting Self-reported DNS Name", + "CWE-293": "Using Referer Field for Authentication", + "CWE-294": "Authentication Bypass by Capture-replay", + "CWE-295": "Improper Certificate Validation", + "CWE-296": "Improper Following of a Certificate's Chain of Trust", + "CWE-297": "Improper Validation of Certificate with Host Mismatch", + "CWE-298": "Improper Validation of Certificate Expiration", + "CWE-299": "Improper Check for Certificate Revocation", + "CWE-30": "Path Traversal: '\\dir\\..\\filename'", + "CWE-300": "Channel Accessible by Non-Endpoint", + "CWE-301": "Reflection Attack in an Authentication Protocol", + "CWE-302": "Authentication Bypass by Assumed-Immutable Data", + "CWE-303": "Incorrect Implementation of Authentication Algorithm", + "CWE-304": "Missing Critical Step in Authentication", + "CWE-305": "Authentication Bypass by Primary Weakness", + "CWE-306": "Missing Authentication for Critical Function", + "CWE-307": "Improper Restriction of Excessive Authentication Attempts", + "CWE-308": "Use of Single-factor Authentication", + "CWE-309": "Use of Password System for Primary Authentication", + "CWE-31": "Path Traversal: 'dir\\..\\..\\filename'", + "CWE-311": "Missing Encryption of Sensitive Data", + "CWE-312": "Cleartext Storage of Sensitive Information", + "CWE-313": "Cleartext Storage in a File or on Disk", + "CWE-314": "Cleartext Storage in the Registry", + "CWE-315": "Cleartext Storage of Sensitive Information in a Cookie", + "CWE-316": "Cleartext Storage of Sensitive Information in Memory", + "CWE-317": "Cleartext Storage of Sensitive Information in GUI", + "CWE-318": "Cleartext Storage of Sensitive Information in Executable", + "CWE-319": "Cleartext Transmission of Sensitive Information", + "CWE-32": "Path Traversal: '...' (Triple Dot)", + "CWE-321": "Use of Hard-coded Cryptographic Key", + "CWE-322": "Key Exchange without Entity Authentication", + "CWE-323": "Reusing a Nonce, Key Pair in Encryption", + "CWE-324": "Use of a Key Past its Expiration Date", + "CWE-325": "Missing Cryptographic Step", + "CWE-326": "Inadequate Encryption Strength", + "CWE-327": "Use of a Broken or Risky Cryptographic Algorithm", + "CWE-328": "Use of Weak Hash", + "CWE-329": "Generation of Predictable IV with CBC Mode", + "CWE-33": "Path Traversal: '....' (Multiple Dot)", + "CWE-330": "Use of Insufficiently Random Values", + "CWE-331": "Insufficient Entropy", + "CWE-332": "Insufficient Entropy in PRNG", + "CWE-333": "Improper Handling of Insufficient Entropy in TRNG", + "CWE-334": "Small Space of Random Values", + "CWE-335": "Incorrect Usage of Seeds in Pseudo-Random Number Generator (PRNG)", + "CWE-336": "Same Seed in Pseudo-Random Number Generator (PRNG)", + "CWE-337": "Predictable Seed in Pseudo-Random Number Generator (PRNG)", + "CWE-338": "Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)", + "CWE-339": "Small Seed Space in PRNG", + "CWE-34": "Path Traversal: '....//'", + "CWE-340": "Generation of Predictable Numbers or Identifiers", + "CWE-341": "Predictable from Observable State", + "CWE-342": "Predictable Exact Value from Previous Values", + "CWE-343": "Predictable Value Range from Previous Values", + "CWE-344": "Use of Invariant Value in Dynamically Changing Context", + "CWE-345": "Insufficient Verification of Data Authenticity", + "CWE-346": "Origin Validation Error", + "CWE-347": "Improper Verification of Cryptographic Signature", + "CWE-348": "Use of Less Trusted Source", + "CWE-349": "Acceptance of Extraneous Untrusted Data With Trusted Data", + "CWE-35": "Path Traversal: '.../...//'", + "CWE-350": "Reliance on Reverse DNS Resolution for a Security-Critical Action", + "CWE-351": "Insufficient Type Distinction", + "CWE-352": "Cross-Site Request Forgery (CSRF)", + "CWE-353": "Missing Support for Integrity Check", + "CWE-354": "Improper Validation of Integrity Check Value", + "CWE-356": "Product UI does not Warn User of Unsafe Actions", + "CWE-357": "Insufficient UI Warning of Dangerous Operations", + "CWE-358": "Improperly Implemented Security Check for Standard", + "CWE-359": "Exposure of Private Personal Information to an Unauthorized Actor", + "CWE-36": "Absolute Path Traversal", + "CWE-360": "Trust of System Event Data", + "CWE-362": "Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')", + "CWE-363": "Race Condition Enabling Link Following", + "CWE-364": "Signal Handler Race Condition", + "CWE-365": "DEPRECATED: Race Condition in Switch", + "CWE-366": "Race Condition within a Thread", + "CWE-367": "Time-of-check Time-of-use (TOCTOU) Race Condition", + "CWE-368": "Context Switching Race Condition", + "CWE-369": "Divide By Zero", + "CWE-37": "Path Traversal: '/absolute/pathname/here'", + "CWE-370": "Missing Check for Certificate Revocation after Initial Check", + "CWE-372": "Incomplete Internal State Distinction", + "CWE-373": "DEPRECATED: State Synchronization Error", + "CWE-374": "Passing Mutable Objects to an Untrusted Method", + "CWE-375": "Returning a Mutable Object to an Untrusted Caller", + "CWE-377": "Insecure Temporary File", + "CWE-378": "Creation of Temporary File With Insecure Permissions", + "CWE-379": "Creation of Temporary File in Directory with Insecure Permissions", + "CWE-38": "Path Traversal: '\\absolute\\pathname\\here'", + "CWE-382": "J2EE Bad Practices: Use of System.exit()", + "CWE-383": "J2EE Bad Practices: Direct Use of Threads", + "CWE-384": "Session Fixation", + "CWE-385": "Covert Timing Channel", + "CWE-386": "Symbolic Name not Mapping to Correct Object", + "CWE-39": "Path Traversal: 'C:dirname'", + "CWE-390": "Detection of Error Condition Without Action", + "CWE-391": "Unchecked Error Condition", + "CWE-392": "Missing Report of Error Condition", + "CWE-393": "Return of Wrong Status Code", + "CWE-394": "Unexpected Status Code or Return Value", + "CWE-395": "Use of NullPointerException Catch to Detect NULL Pointer Dereference", + "CWE-396": "Declaration of Catch for Generic Exception", + "CWE-397": "Declaration of Throws for Generic Exception", + "CWE-40": "Path Traversal: '\\\\UNC\\share\\name\\' (Windows UNC Share)", + "CWE-400": "Uncontrolled Resource Consumption", + "CWE-401": "Missing Release of Memory after Effective Lifetime", + "CWE-402": "Transmission of Private Resources into a New Sphere ('Resource Leak')", + "CWE-403": "Exposure of File Descriptor to Unintended Control Sphere ('File Descriptor Leak')", + "CWE-404": "Improper Resource Shutdown or Release", + "CWE-405": "Asymmetric Resource Consumption (Amplification)", + "CWE-406": "Insufficient Control of Network Message Volume (Network Amplification)", + "CWE-407": "Inefficient Algorithmic Complexity", + "CWE-408": "Incorrect Behavior Order: Early Amplification", + "CWE-409": "Improper Handling of Highly Compressed Data (Data Amplification)", + "CWE-41": "Improper Resolution of Path Equivalence", + "CWE-410": "Insufficient Resource Pool", + "CWE-412": "Unrestricted Externally Accessible Lock", + "CWE-413": "Improper Resource Locking", + "CWE-414": "Missing Lock Check", + "CWE-415": "Double Free", + "CWE-416": "Use After Free", + "CWE-419": "Unprotected Primary Channel", + "CWE-42": "Path Equivalence: 'filename.' (Trailing Dot)", + "CWE-420": "Unprotected Alternate Channel", + "CWE-421": "Race Condition During Access to Alternate Channel", + "CWE-422": "Unprotected Windows Messaging Channel ('Shatter')", + "CWE-423": "DEPRECATED: Proxied Trusted Channel", + "CWE-424": "Improper Protection of Alternate Path", + "CWE-425": "Direct Request ('Forced Browsing')", + "CWE-426": "Untrusted Search Path", + "CWE-427": "Uncontrolled Search Path Element", + "CWE-428": "Unquoted Search Path or Element", + "CWE-43": "Path Equivalence: 'filename....' (Multiple Trailing Dot)", + "CWE-430": "Deployment of Wrong Handler", + "CWE-431": "Missing Handler", + "CWE-432": "Dangerous Signal Handler not Disabled During Sensitive Operations", + "CWE-433": "Unparsed Raw Web Content Delivery", + "CWE-434": "Unrestricted Upload of File with Dangerous Type", + "CWE-435": "Improper Interaction Between Multiple Correctly-Behaving Entities", + "CWE-436": "Interpretation Conflict", + "CWE-437": "Incomplete Model of Endpoint Features", + "CWE-439": "Behavioral Change in New Version or Environment", + "CWE-44": "Path Equivalence: 'file.name' (Internal Dot)", + "CWE-440": "Expected Behavior Violation", + "CWE-441": "Unintended Proxy or Intermediary ('Confused Deputy')", + "CWE-443": "DEPRECATED: HTTP response splitting", + "CWE-444": "Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')", + "CWE-446": "UI Discrepancy for Security Feature", + "CWE-447": "Unimplemented or Unsupported Feature in UI", + "CWE-448": "Obsolete Feature in UI", + "CWE-449": "The UI Performs the Wrong Action", + "CWE-45": "Path Equivalence: 'file...name' (Multiple Internal Dot)", + "CWE-450": "Multiple Interpretations of UI Input", + "CWE-451": "User Interface (UI) Misrepresentation of Critical Information", + "CWE-453": "Insecure Default Variable Initialization", + "CWE-454": "External Initialization of Trusted Variables or Data Stores", + "CWE-455": "Non-exit on Failed Initialization", + "CWE-456": "Missing Initialization of a Variable", + "CWE-457": "Use of Uninitialized Variable", + "CWE-458": "DEPRECATED: Incorrect Initialization", + "CWE-459": "Incomplete Cleanup", + "CWE-46": "Path Equivalence: 'filename ' (Trailing Space)", + "CWE-460": "Improper Cleanup on Thrown Exception", + "CWE-462": "Duplicate Key in Associative List (Alist)", + "CWE-463": "Deletion of Data Structure Sentinel", + "CWE-464": "Addition of Data Structure Sentinel", + "CWE-466": "Return of Pointer Value Outside of Expected Range", + "CWE-467": "Use of sizeof() on a Pointer Type", + "CWE-468": "Incorrect Pointer Scaling", + "CWE-469": "Use of Pointer Subtraction to Determine Size", + "CWE-47": "Path Equivalence: ' filename' (Leading Space)", + "CWE-470": "Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')", + "CWE-471": "Modification of Assumed-Immutable Data (MAID)", + "CWE-472": "External Control of Assumed-Immutable Web Parameter", + "CWE-473": "PHP External Variable Modification", + "CWE-474": "Use of Function with Inconsistent Implementations", + "CWE-475": "Undefined Behavior for Input to API", + "CWE-476": "NULL Pointer Dereference", + "CWE-477": "Use of Obsolete Function", + "CWE-478": "Missing Default Case in Multiple Condition Expression", + "CWE-479": "Signal Handler Use of a Non-reentrant Function", + "CWE-48": "Path Equivalence: 'file name' (Internal Whitespace)", + "CWE-480": "Use of Incorrect Operator", + "CWE-481": "Assigning instead of Comparing", + "CWE-482": "Comparing instead of Assigning", + "CWE-483": "Incorrect Block Delimitation", + "CWE-484": "Omitted Break Statement in Switch", + "CWE-486": "Comparison of Classes by Name", + "CWE-487": "Reliance on Package-level Scope", + "CWE-488": "Exposure of Data Element to Wrong Session", + "CWE-489": "Active Debug Code", + "CWE-49": "Path Equivalence: 'filename/' (Trailing Slash)", + "CWE-491": "Public cloneable() Method Without Final ('Object Hijack')", + "CWE-492": "Use of Inner Class Containing Sensitive Data", + "CWE-493": "Critical Public Variable Without Final Modifier", + "CWE-494": "Download of Code Without Integrity Check", + "CWE-495": "Private Data Structure Returned From A Public Method", + "CWE-496": "Public Data Assigned to Private Array-Typed Field", + "CWE-497": "Exposure of Sensitive System Information to an Unauthorized Control Sphere", + "CWE-498": "Cloneable Class Containing Sensitive Information", + "CWE-499": "Serializable Class Containing Sensitive Data", + "CWE-5": "J2EE Misconfiguration: Data Transmission Without Encryption", + "CWE-50": "Path Equivalence: '//multiple/leading/slash'", + "CWE-500": "Public Static Field Not Marked Final", + "CWE-501": "Trust Boundary Violation", + "CWE-502": "Deserialization of Untrusted Data", + "CWE-506": "Embedded Malicious Code", + "CWE-507": "Trojan Horse", + "CWE-508": "Non-Replicating Malicious Code", + "CWE-509": "Replicating Malicious Code (Virus or Worm)", + "CWE-51": "Path Equivalence: '/multiple//internal/slash'", + "CWE-510": "Trapdoor", + "CWE-511": "Logic/Time Bomb", + "CWE-512": "Spyware", + "CWE-514": "Covert Channel", + "CWE-515": "Covert Storage Channel", + "CWE-516": "DEPRECATED: Covert Timing Channel", + "CWE-52": "Path Equivalence: '/multiple/trailing/slash//'", + "CWE-520": ".NET Misconfiguration: Use of Impersonation", + "CWE-521": "Weak Password Requirements", + "CWE-522": "Insufficiently Protected Credentials", + "CWE-523": "Unprotected Transport of Credentials", + "CWE-524": "Use of Cache Containing Sensitive Information", + "CWE-525": "Use of Web Browser Cache Containing Sensitive Information", + "CWE-526": "Cleartext Storage of Sensitive Information in an Environment Variable", + "CWE-527": "Exposure of Version-Control Repository to an Unauthorized Control Sphere", + "CWE-528": "Exposure of Core Dump File to an Unauthorized Control Sphere", + "CWE-529": "Exposure of Access Control List Files to an Unauthorized Control Sphere", + "CWE-53": "Path Equivalence: '\\multiple\\\\internal\\backslash'", + "CWE-530": "Exposure of Backup File to an Unauthorized Control Sphere", + "CWE-531": "Inclusion of Sensitive Information in Test Code", + "CWE-532": "Insertion of Sensitive Information into Log File", + "CWE-533": "DEPRECATED: Information Exposure Through Server Log Files", + "CWE-534": "DEPRECATED: Information Exposure Through Debug Log Files", + "CWE-535": "Exposure of Information Through Shell Error Message", + "CWE-536": "Servlet Runtime Error Message Containing Sensitive Information", + "CWE-537": "Java Runtime Error Message Containing Sensitive Information", + "CWE-538": "Insertion of Sensitive Information into Externally-Accessible File or Directory", + "CWE-539": "Use of Persistent Cookies Containing Sensitive Information", + "CWE-54": "Path Equivalence: 'filedir\\' (Trailing Backslash)", + "CWE-540": "Inclusion of Sensitive Information in Source Code", + "CWE-541": "Inclusion of Sensitive Information in an Include File", + "CWE-542": "DEPRECATED: Information Exposure Through Cleanup Log Files", + "CWE-543": "Use of Singleton Pattern Without Synchronization in a Multithreaded Context", + "CWE-544": "Missing Standardized Error Handling Mechanism", + "CWE-545": "DEPRECATED: Use of Dynamic Class Loading", + "CWE-546": "Suspicious Comment", + "CWE-547": "Use of Hard-coded, Security-relevant Constants", + "CWE-548": "Exposure of Information Through Directory Listing", + "CWE-549": "Missing Password Field Masking", + "CWE-55": "Path Equivalence: '/./' (Single Dot Directory)", + "CWE-550": "Server-generated Error Message Containing Sensitive Information", + "CWE-551": "Incorrect Behavior Order: Authorization Before Parsing and Canonicalization", + "CWE-552": "Files or Directories Accessible to External Parties", + "CWE-553": "Command Shell in Externally Accessible Directory", + "CWE-554": "ASP.NET Misconfiguration: Not Using Input Validation Framework", + "CWE-555": "J2EE Misconfiguration: Plaintext Password in Configuration File", + "CWE-556": "ASP.NET Misconfiguration: Use of Identity Impersonation", + "CWE-558": "Use of getlogin() in Multithreaded Application", + "CWE-56": "Path Equivalence: 'filedir*' (Wildcard)", + "CWE-560": "Use of umask() with chmod-style Argument", + "CWE-561": "Dead Code", + "CWE-562": "Return of Stack Variable Address", + "CWE-563": "Assignment to Variable without Use", + "CWE-564": "SQL Injection: Hibernate", + "CWE-565": "Reliance on Cookies without Validation and Integrity Checking", + "CWE-566": "Authorization Bypass Through User-Controlled SQL Primary Key", + "CWE-567": "Unsynchronized Access to Shared Data in a Multithreaded Context", + "CWE-568": "finalize() Method Without super.finalize()", + "CWE-57": "Path Equivalence: 'fakedir/../realdir/filename'", + "CWE-570": "Expression is Always False", + "CWE-571": "Expression is Always True", + "CWE-572": "Call to Thread run() instead of start()", + "CWE-573": "Improper Following of Specification by Caller", + "CWE-574": "EJB Bad Practices: Use of Synchronization Primitives", + "CWE-575": "EJB Bad Practices: Use of AWT Swing", + "CWE-576": "EJB Bad Practices: Use of Java I/O", + "CWE-577": "EJB Bad Practices: Use of Sockets", + "CWE-578": "EJB Bad Practices: Use of Class Loader", + "CWE-579": "J2EE Bad Practices: Non-serializable Object Stored in Session", + "CWE-58": "Path Equivalence: Windows 8.3 Filename", + "CWE-580": "clone() Method Without super.clone()", + "CWE-581": "Object Model Violation: Just One of Equals and Hashcode Defined", + "CWE-582": "Array Declared Public, Final, and Static", + "CWE-583": "finalize() Method Declared Public", + "CWE-584": "Return Inside Finally Block", + "CWE-585": "Empty Synchronized Block", + "CWE-586": "Explicit Call to Finalize()", + "CWE-587": "Assignment of a Fixed Address to a Pointer", + "CWE-588": "Attempt to Access Child of a Non-structure Pointer", + "CWE-589": "Call to Non-ubiquitous API", + "CWE-59": "Improper Link Resolution Before File Access ('Link Following')", + "CWE-590": "Free of Memory not on the Heap", + "CWE-591": "Sensitive Data Storage in Improperly Locked Memory", + "CWE-592": "DEPRECATED: Authentication Bypass Issues", + "CWE-593": "Authentication Bypass: OpenSSL CTX Object Modified after SSL Objects are Created", + "CWE-594": "J2EE Framework: Saving Unserializable Objects to Disk", + "CWE-595": "Comparison of Object References Instead of Object Contents", + "CWE-596": "DEPRECATED: Incorrect Semantic Object Comparison", + "CWE-597": "Use of Wrong Operator in String Comparison", + "CWE-598": "Use of GET Request Method With Sensitive Query Strings", + "CWE-599": "Missing Validation of OpenSSL Certificate", + "CWE-6": "J2EE Misconfiguration: Insufficient Session-ID Length", + "CWE-600": "Uncaught Exception in Servlet", + "CWE-601": "URL Redirection to Untrusted Site ('Open Redirect')", + "CWE-602": "Client-Side Enforcement of Server-Side Security", + "CWE-603": "Use of Client-Side Authentication", + "CWE-605": "Multiple Binds to the Same Port", + "CWE-606": "Unchecked Input for Loop Condition", + "CWE-607": "Public Static Final Field References Mutable Object", + "CWE-608": "Struts: Non-private Field in ActionForm Class", + "CWE-609": "Double-Checked Locking", + "CWE-61": "UNIX Symbolic Link (Symlink) Following", + "CWE-610": "Externally Controlled Reference to a Resource in Another Sphere", + "CWE-611": "Improper Restriction of XML External Entity Reference", + "CWE-612": "Improper Authorization of Index Containing Sensitive Information", + "CWE-613": "Insufficient Session Expiration", + "CWE-614": "Sensitive Cookie in HTTPS Session Without 'Secure' Attribute", + "CWE-615": "Inclusion of Sensitive Information in Source Code Comments", + "CWE-616": "Incomplete Identification of Uploaded File Variables (PHP)", + "CWE-617": "Reachable Assertion", + "CWE-618": "Exposed Unsafe ActiveX Method", + "CWE-619": "Dangling Database Cursor ('Cursor Injection')", + "CWE-62": "UNIX Hard Link", + "CWE-620": "Unverified Password Change", + "CWE-621": "Variable Extraction Error", + "CWE-622": "Improper Validation of Function Hook Arguments", + "CWE-623": "Unsafe ActiveX Control Marked Safe For Scripting", + "CWE-624": "Executable Regular Expression Error", + "CWE-625": "Permissive Regular Expression", + "CWE-626": "Null Byte Interaction Error (Poison Null Byte)", + "CWE-627": "Dynamic Variable Evaluation", + "CWE-628": "Function Call with Incorrectly Specified Arguments", + "CWE-636": "Not Failing Securely ('Failing Open')", + "CWE-637": "Unnecessary Complexity in Protection Mechanism (Not Using 'Economy of Mechanism')", + "CWE-638": "Not Using Complete Mediation", + "CWE-639": "Authorization Bypass Through User-Controlled Key", + "CWE-64": "Windows Shortcut Following (.LNK)", + "CWE-640": "Weak Password Recovery Mechanism for Forgotten Password", + "CWE-641": "Improper Restriction of Names for Files and Other Resources", + "CWE-642": "External Control of Critical State Data", + "CWE-643": "Improper Neutralization of Data within XPath Expressions ('XPath Injection')", + "CWE-644": "Improper Neutralization of HTTP Headers for Scripting Syntax", + "CWE-645": "Overly Restrictive Account Lockout Mechanism", + "CWE-646": "Reliance on File Name or Extension of Externally-Supplied File", + "CWE-647": "Use of Non-Canonical URL Paths for Authorization Decisions", + "CWE-648": "Incorrect Use of Privileged APIs", + "CWE-649": "Reliance on Obfuscation or Encryption of Security-Relevant Inputs without Integrity Checking", + "CWE-65": "Windows Hard Link", + "CWE-650": "Trusting HTTP Permission Methods on the Server Side", + "CWE-651": "Exposure of WSDL File Containing Sensitive Information", + "CWE-652": "Improper Neutralization of Data within XQuery Expressions ('XQuery Injection')", + "CWE-653": "Improper Isolation or Compartmentalization", + "CWE-654": "Reliance on a Single Factor in a Security Decision", + "CWE-655": "Insufficient Psychological Acceptability", + "CWE-656": "Reliance on Security Through Obscurity", + "CWE-657": "Violation of Secure Design Principles", + "CWE-66": "Improper Handling of File Names that Identify Virtual Resources", + "CWE-662": "Improper Synchronization", + "CWE-663": "Use of a Non-reentrant Function in a Concurrent Context", + "CWE-664": "Improper Control of a Resource Through its Lifetime", + "CWE-665": "Improper Initialization", + "CWE-666": "Operation on Resource in Wrong Phase of Lifetime", + "CWE-667": "Improper Locking", + "CWE-668": "Exposure of Resource to Wrong Sphere", + "CWE-669": "Incorrect Resource Transfer Between Spheres", + "CWE-67": "Improper Handling of Windows Device Names", + "CWE-670": "Always-Incorrect Control Flow Implementation", + "CWE-671": "Lack of Administrator Control over Security", + "CWE-672": "Operation on a Resource after Expiration or Release", + "CWE-673": "External Influence of Sphere Definition", + "CWE-674": "Uncontrolled Recursion", + "CWE-675": "Multiple Operations on Resource in Single-Operation Context", + "CWE-676": "Use of Potentially Dangerous Function", + "CWE-680": "Integer Overflow to Buffer Overflow", + "CWE-681": "Incorrect Conversion between Numeric Types", + "CWE-682": "Incorrect Calculation", + "CWE-683": "Function Call With Incorrect Order of Arguments", + "CWE-684": "Incorrect Provision of Specified Functionality", + "CWE-685": "Function Call With Incorrect Number of Arguments", + "CWE-686": "Function Call With Incorrect Argument Type", + "CWE-687": "Function Call With Incorrectly Specified Argument Value", + "CWE-688": "Function Call With Incorrect Variable or Reference as Argument", + "CWE-689": "Permission Race Condition During Resource Copy", + "CWE-69": "Improper Handling of Windows ::DATA Alternate Data Stream", + "CWE-690": "Unchecked Return Value to NULL Pointer Dereference", + "CWE-691": "Insufficient Control Flow Management", + "CWE-692": "Incomplete Denylist to Cross-Site Scripting", + "CWE-693": "Protection Mechanism Failure", + "CWE-694": "Use of Multiple Resources with Duplicate Identifier", + "CWE-695": "Use of Low-Level Functionality", + "CWE-696": "Incorrect Behavior Order", + "CWE-697": "Incorrect Comparison", + "CWE-698": "Execution After Redirect (EAR)", + "CWE-7": "J2EE Misconfiguration: Missing Custom Error Page", + "CWE-703": "Improper Check or Handling of Exceptional Conditions", + "CWE-704": "Incorrect Type Conversion or Cast", + "CWE-705": "Incorrect Control Flow Scoping", + "CWE-706": "Use of Incorrectly-Resolved Name or Reference", + "CWE-707": "Improper Neutralization", + "CWE-708": "Incorrect Ownership Assignment", + "CWE-71": "DEPRECATED: Apple '.DS_Store'", + "CWE-710": "Improper Adherence to Coding Standards", + "CWE-72": "Improper Handling of Apple HFS+ Alternate Data Stream Path", + "CWE-73": "External Control of File Name or Path", + "CWE-732": "Incorrect Permission Assignment for Critical Resource", + "CWE-733": "Compiler Optimization Removal or Modification of Security-critical Code", + "CWE-74": "Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')", + "CWE-749": "Exposed Dangerous Method or Function", + "CWE-75": "Failure to Sanitize Special Elements into a Different Plane (Special Element Injection)", + "CWE-754": "Improper Check for Unusual or Exceptional Conditions", + "CWE-755": "Improper Handling of Exceptional Conditions", + "CWE-756": "Missing Custom Error Page", + "CWE-757": "Selection of Less-Secure Algorithm During Negotiation ('Algorithm Downgrade')", + "CWE-758": "Reliance on Undefined, Unspecified, or Implementation-Defined Behavior", + "CWE-759": "Use of a One-Way Hash without a Salt", + "CWE-76": "Improper Neutralization of Equivalent Special Elements", + "CWE-760": "Use of a One-Way Hash with a Predictable Salt", + "CWE-761": "Free of Pointer not at Start of Buffer", + "CWE-762": "Mismatched Memory Management Routines", + "CWE-763": "Release of Invalid Pointer or Reference", + "CWE-764": "Multiple Locks of a Critical Resource", + "CWE-765": "Multiple Unlocks of a Critical Resource", + "CWE-766": "Critical Data Element Declared Public", + "CWE-767": "Access to Critical Private Variable via Public Method", + "CWE-768": "Incorrect Short Circuit Evaluation", + "CWE-769": "DEPRECATED: Uncontrolled File Descriptor Consumption", + "CWE-77": "Improper Neutralization of Special Elements used in a Command ('Command Injection')", + "CWE-770": "Allocation of Resources Without Limits or Throttling", + "CWE-771": "Missing Reference to Active Allocated Resource", + "CWE-772": "Missing Release of Resource after Effective Lifetime", + "CWE-773": "Missing Reference to Active File Descriptor or Handle", + "CWE-774": "Allocation of File Descriptors or Handles Without Limits or Throttling", + "CWE-775": "Missing Release of File Descriptor or Handle after Effective Lifetime", + "CWE-776": "Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')", + "CWE-777": "Regular Expression without Anchors", + "CWE-778": "Insufficient Logging", + "CWE-779": "Logging of Excessive Data", + "CWE-78": "Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')", + "CWE-780": "Use of RSA Algorithm without OAEP", + "CWE-781": "Improper Address Validation in IOCTL with METHOD_NEITHER I/O Control Code", + "CWE-782": "Exposed IOCTL with Insufficient Access Control", + "CWE-783": "Operator Precedence Logic Error", + "CWE-784": "Reliance on Cookies without Validation and Integrity Checking in a Security Decision", + "CWE-785": "Use of Path Manipulation Function without Maximum-sized Buffer", + "CWE-786": "Access of Memory Location Before Start of Buffer", + "CWE-787": "Out-of-bounds Write", + "CWE-788": "Access of Memory Location After End of Buffer", + "CWE-789": "Memory Allocation with Excessive Size Value", + "CWE-79": "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')", + "CWE-790": "Improper Filtering of Special Elements", + "CWE-791": "Incomplete Filtering of Special Elements", + "CWE-792": "Incomplete Filtering of One or More Instances of Special Elements", + "CWE-793": "Only Filtering One Instance of a Special Element", + "CWE-794": "Incomplete Filtering of Multiple Instances of Special Elements", + "CWE-795": "Only Filtering Special Elements at a Specified Location", + "CWE-796": "Only Filtering Special Elements Relative to a Marker", + "CWE-797": "Only Filtering Special Elements at an Absolute Position", + "CWE-798": "Use of Hard-coded Credentials", + "CWE-799": "Improper Control of Interaction Frequency", + "CWE-8": "J2EE Misconfiguration: Entity Bean Declared Remote", + "CWE-80": "Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)", + "CWE-804": "Guessable CAPTCHA", + "CWE-805": "Buffer Access with Incorrect Length Value", + "CWE-806": "Buffer Access Using Size of Source Buffer", + "CWE-807": "Reliance on Untrusted Inputs in a Security Decision", + "CWE-81": "Improper Neutralization of Script in an Error Message Web Page", + "CWE-82": "Improper Neutralization of Script in Attributes of IMG Tags in a Web Page", + "CWE-820": "Missing Synchronization", + "CWE-821": "Incorrect Synchronization", + "CWE-822": "Untrusted Pointer Dereference", + "CWE-823": "Use of Out-of-range Pointer Offset", + "CWE-824": "Access of Uninitialized Pointer", + "CWE-825": "Expired Pointer Dereference", + "CWE-826": "Premature Release of Resource During Expected Lifetime", + "CWE-827": "Improper Control of Document Type Definition", + "CWE-828": "Signal Handler with Functionality that is not Asynchronous-Safe", + "CWE-829": "Inclusion of Functionality from Untrusted Control Sphere", + "CWE-83": "Improper Neutralization of Script in Attributes in a Web Page", + "CWE-830": "Inclusion of Web Functionality from an Untrusted Source", + "CWE-831": "Signal Handler Function Associated with Multiple Signals", + "CWE-832": "Unlock of a Resource that is not Locked", + "CWE-833": "Deadlock", + "CWE-834": "Excessive Iteration", + "CWE-835": "Loop with Unreachable Exit Condition ('Infinite Loop')", + "CWE-836": "Use of Password Hash Instead of Password for Authentication", + "CWE-837": "Improper Enforcement of a Single, Unique Action", + "CWE-838": "Inappropriate Encoding for Output Context", + "CWE-839": "Numeric Range Comparison Without Minimum Check", + "CWE-84": "Improper Neutralization of Encoded URI Schemes in a Web Page", + "CWE-841": "Improper Enforcement of Behavioral Workflow", + "CWE-842": "Placement of User into Incorrect Group", + "CWE-843": "Access of Resource Using Incompatible Type ('Type Confusion')", + "CWE-85": "Doubled Character XSS Manipulations", + "CWE-86": "Improper Neutralization of Invalid Characters in Identifiers in Web Pages", + "CWE-862": "Missing Authorization", + "CWE-863": "Incorrect Authorization", + "CWE-87": "Improper Neutralization of Alternate XSS Syntax", + "CWE-88": "Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')", + "CWE-89": "Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')", + "CWE-9": "J2EE Misconfiguration: Weak Access Permissions for EJB Methods", + "CWE-90": "Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')", + "CWE-908": "Use of Uninitialized Resource", + "CWE-909": "Missing Initialization of Resource", + "CWE-91": "XML Injection (aka Blind XPath Injection)", + "CWE-910": "Use of Expired File Descriptor", + "CWE-911": "Improper Update of Reference Count", + "CWE-912": "Hidden Functionality", + "CWE-913": "Improper Control of Dynamically-Managed Code Resources", + "CWE-914": "Improper Control of Dynamically-Identified Variables", + "CWE-915": "Improperly Controlled Modification of Dynamically-Determined Object Attributes", + "CWE-916": "Use of Password Hash With Insufficient Computational Effort", + "CWE-917": "Improper Neutralization of Special Elements used in an Expression Language Statement ('Expression Language Injection')", + "CWE-918": "Server-Side Request Forgery (SSRF)", + "CWE-92": "DEPRECATED: Improper Sanitization of Custom Special Characters", + "CWE-920": "Improper Restriction of Power Consumption", + "CWE-921": "Storage of Sensitive Data in a Mechanism without Access Control", + "CWE-922": "Insecure Storage of Sensitive Information", + "CWE-923": "Improper Restriction of Communication Channel to Intended Endpoints", + "CWE-924": "Improper Enforcement of Message Integrity During Transmission in a Communication Channel", + "CWE-925": "Improper Verification of Intent by Broadcast Receiver", + "CWE-926": "Improper Export of Android Application Components", + "CWE-927": "Use of Implicit Intent for Sensitive Communication", + "CWE-93": "Improper Neutralization of CRLF Sequences ('CRLF Injection')", + "CWE-939": "Improper Authorization in Handler for Custom URL Scheme", + "CWE-94": "Improper Control of Generation of Code ('Code Injection')", + "CWE-940": "Improper Verification of Source of a Communication Channel", + "CWE-941": "Incorrectly Specified Destination in a Communication Channel", + "CWE-942": "Permissive Cross-domain Security Policy with Untrusted Domains", + "CWE-943": "Improper Neutralization of Special Elements in Data Query Logic", + "CWE-95": "Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')", + "CWE-96": "Improper Neutralization of Directives in Statically Saved Code ('Static Code Injection')", + "CWE-97": "Improper Neutralization of Server-Side Includes (SSI) Within a Web Page", + "CWE-98": "Improper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')", + "CWE-99": "Improper Control of Resource Identifiers ('Resource Injection')" +} diff --git a/central/gen-advisory b/central/gen-advisory new file mode 100755 index 0000000..90a71b1 --- /dev/null +++ b/central/gen-advisory @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +"""Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX documents from wolfSSL +CVE Program records (CVE JSON 5.x). + +The CVE record (the authoritative artefact wolfSSL authors as a CNA, e.g. with +Vulnogram and published to cve.org / cvelistV5) supplies the structural facts: +CVE id, title, description, CWE, CVSS, affected/fixed version ranges, +references, credits, and dates. + +What a CVE record does NOT carry in machine-readable form is the VEX +*determination* and the wolfSSL-specific product nuances. Those live in a +small per-CVE overlay (--vex-overlay): + + * state / justification / response / detail -- the VEX determination. + * fixed_versions / remediation -- the mainline fix guidance. + * fips { ... } -- a separate FIPS product entry + (own module version + CMVP certificate, own status, own remediation): + FIPS customers cannot freely upgrade, and many CVEs fall outside the + FIPS module boundary, so FIPS is modelled as a distinct product. + * requires_defines / default_status -- a no-cost hedge: which build + flag gates the vulnerable code. Recorded as an informational note + only; this tool does NOT compute per-build reachability (that would be + a future, safety-critical "build-aware VEX" feature). + +Granularity follows Red Hat's model: per-CVE is the default (the VEX +automation primitive); pass several records to emit one *bundled* per-release +CSAF advisory + one CycloneDX BOM carrying all vulnerabilities[]. + +This mirrors scripts/gen-sbom: pure stdlib, SOURCE_DATE_EPOCH-reproducible, +deterministic UUIDs, fail-rather-than-emit-garbage on malformed input. +""" + +import argparse +import json +import os +import pathlib +import sys +import urllib.request +import uuid +from datetime import datetime, timezone + + +GEN_TOOL_NAME = 'wolfssl-advisory-gen' +GEN_TOOL_VERSION = '0.3' + +_SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPTS_DIR.parent + +# Canonical single-source-of-truth for wolfSSL advisories. `make advisory` +# and a bare `gen-advisory` invocation both default to these locations, so the +# tool and the build target are interchangeable. testdata/ is a *separate*, +# frozen copy used only by the test suite (see scripts/testdata/README.md). +DEFAULT_RECORDS_DIR = _REPO_ROOT / 'advisories' / 'records' +DEFAULT_OVERLAY = _REPO_ROOT / 'advisories' / 'vex-overlay.json' +DEFAULT_OUT_DIR = _REPO_ROOT / 'advisories' / 'out' + +# Official CWE id -> name catalogue, shipped alongside this script. CSAF +# mandatory test 6.1.11 requires /vulnerabilities[]/cwe/name to be the *exact* +# MITRE name for the id, so we resolve the name from the catalogue rather than +# trusting the (often differently-cased) free text in the CVE record's +# problemType. Regenerate scripts/cwe-names.json from the official CWE list +# when MITRE publishes a new version. +_CWE_NAMES_PATH = _SCRIPTS_DIR / 'cwe-names.json' + + +def _load_cwe_names(): + try: + with open(_CWE_NAMES_PATH) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + # Absence is non-fatal: gen-advisory simply omits cwe.name (and hence + # the whole cwe object) from CSAF, which keeps the output conformant. + return {} + + +CWE_NAMES = _load_cwe_names() + +WOLFSSL_VENDOR = 'wolfSSL' +WOLFSSL_PUBLISHER = { + 'category': 'vendor', + 'name': 'wolfSSL Inc.', + 'namespace': 'https://www.wolfssl.com', +} +WOLFSSL_ADVISORIES_URL = 'https://www.wolfssl.com/docs/security-vulnerabilities/' +# Canonical directory under which the generated CSAF JSON documents are +# published. CSAF 2.0 requires the document's `self` reference to point at the +# canonical location of *this* document (not a generic landing page), so the +# self URL is built as /.csaf.json, matching the on-disk +# filename this script writes. +# +# DEPLOY-TIME FOLLOW-UPS (not enforced by the CSAF mandatory-test gate in +# .github/workflows/advisory.yml, which validates document content, not the +# distribution layout): +# - Base URL: this is a placeholder and will 404 until the documents are +# actually hosted. Point it at wolfSSL's real CSAF distribution location. +# - Filename convention: CSAF's recommended filename is the tracking id +# lowercased with every char outside [+\-a-z0-9] replaced by '_' and a +# plain '.json' extension (e.g. cve-2026-5501.json). We keep the +# '.csaf.json' form here so the self URL stays consistent with the +# emitted file and so co-located '.cdx.json' VEX docs remain glob- +# selectable; switch both the self URL and the writer to the canonical +# lowercase name when the hosting layout is finalized. +WOLFSSL_CSAF_BASE_URL = WOLFSSL_ADVISORIES_URL + 'csaf/' + +ADVISORY_UUID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, + 'https://wolfssl.com/advisory/') + +# Severity ordering for picking a bundle's aggregate_severity. +_SEV_RANK = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'MODERATE': 2, 'LOW': 1, + 'NONE': 0} + +# CycloneDX analysis.state -> CSAF product_status bucket for the *vulnerable* +# version ranges (those the CVE record marks status=affected). Every state in +# the CycloneDX 1.6 vocabulary (mirrored by the overlay schema) is mapped +# explicitly; _bucket_for() hard-fails on anything else rather than silently +# defaulting an unknown determination to the worst case (known_affected). +# +# 'resolved' / 'resolved_with_pedigree' map to known_affected on purpose: the +# ranges placed in this bucket are the vulnerable versions, which remain +# affected even after a fix ships. The fixed version is emitted separately +# into the 'fixed' bucket (see the per-product registration loop), so a +# resolved CVE correctly reports old ranges as known_affected and the patched +# release as fixed. Mapping 'resolved' to 'fixed' here would wrongly mark the +# vulnerable ranges as patched. +# +# not_affected / false_positive map to known_not_affected (and also emit a CSAF +# flag justification). +_STATE_TO_BUCKET = { + 'exploitable': 'known_affected', + 'in_triage': 'under_investigation', + 'resolved': 'known_affected', + 'resolved_with_pedigree': 'known_affected', + 'false_positive': 'known_not_affected', + 'not_affected': 'known_not_affected', +} + + +def _bucket_for(state): + """Map a CycloneDX analysis state to its CSAF product_status bucket, + failing loudly on an unrecognized state instead of defaulting to the + worst-case (known_affected) bucket.""" + try: + return _STATE_TO_BUCKET[state] + except KeyError: + sys.exit(f"ERROR: unknown analysis state {state!r}; expected one of " + f"{', '.join(sorted(_STATE_TO_BUCKET))}") + +# CycloneDX not_affected justification -> CSAF flag label (same VEX concept). +_JUSTIFICATION_TO_CSAF_FLAG = { + 'code_not_present': 'vulnerable_code_not_present', + 'code_not_reachable': 'vulnerable_code_not_in_execute_path', + 'requires_configuration': 'vulnerable_code_not_in_execute_path', + 'requires_dependency': 'vulnerable_code_not_in_execute_path', + 'requires_environment': 'vulnerable_code_not_in_execute_path', + 'protected_by_compiler': 'inline_mitigations_already_exist', + 'protected_at_perimeter': 'inline_mitigations_already_exist', + 'protected_at_runtime': 'inline_mitigations_already_exist', + 'protected_by_mitigating_control': 'inline_mitigations_already_exist', +} + + +def derived_uuid(*parts): + """Deterministic UUID from joined parts (NUL-separated, no aliasing).""" + return str(uuid.uuid5(ADVISORY_UUID_NAMESPACE, '\x00'.join(parts))) + + +def build_timestamp(): + """(datetime, ISO-8601-Z) honoring SOURCE_DATE_EPOCH for reproducibility.""" + 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') + + +def cpe_for(product, version): + """CPE 2.3 for a wolfSSL product at a version (matches gen-sbom).""" + return f'cpe:2.3:a:wolfssl:{product.lower()}:{version}:*:*:*:*:*:*:*' + + +def purl_for(product, version): + """PURL for a wolfSSL product release (matches gen-sbom: pkg:github).""" + return f'pkg:github/wolfSSL/{product.lower()}@v{version}' + + +# --------------------------------------------------------------------------- # +# CVE record parsing +# --------------------------------------------------------------------------- # + +def load_cve_record(path=None, cve_id=None): + if path: + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read CVE record {path!r}: {e}") + # Fetched from the CVE Services API (cveawg.mitre.org), the machine- + # readable endpoint MITRE serves the CVE 5.x JSON records from; cve.org is + # the human-facing catalogue for the same data. + url = f'https://cveawg.mitre.org/api/cve/{cve_id}' + try: + with urllib.request.urlopen(url, timeout=30) as r: + return json.loads(r.read().decode()) + except Exception as e: # noqa: BLE001 - surface any fetch/parse failure + sys.exit(f"ERROR: cannot fetch {url}: {e}") + + +def _cna(record): + try: + return record['containers']['cna'] + except (KeyError, TypeError): + sys.exit("ERROR: CVE record has no containers.cna section") + + +def _best_cvss(metrics): + """Pick the highest-priority CVSS block (v4 > v3.1 > v3.0 > v2). + + Used for the CycloneDX rating (which supports v4) and for the document's + aggregate_severity.""" + for key, ver, method in ( + ('cvssV4_0', 'v4', 'CVSSv4'), + ('cvssV3_1', 'v3', 'CVSSv31'), + ('cvssV3_0', 'v3', 'CVSSv3'), + ('cvssV2_0', 'v2', 'CVSSv2'), + ): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}', + 'cdx_method': method} + return None + + +def _best_cvss_csaf20(metrics): + """Pick the highest-priority CVSS block that CSAF 2.0 scores[] can carry. + + The CSAF 2.0 schema predates CVSS v4 and only defines cvss_v2 / cvss_v3 in + a score object, so a v4 block must NOT be placed there (it fails the strict + schema). When only v4 exists the CSAF emitter records it as a note and + relies on the CycloneDX VEX output for the machine-readable v4 rating.""" + for key, ver in (('cvssV3_1', 'v3'), ('cvssV3_0', 'v3'), ('cvssV2_0', 'v2')): + for m in metrics: + if key in m: + return {'data': m[key], 'csaf_key': f'cvss_{ver}'} + return None + + +def parse_record(record): + """Reduce a CVE 5.x record to the fields the emitters need.""" + meta = record.get('cveMetadata', {}) + cna = _cna(record) + + cve_id = meta.get('cveId') or cna.get('cveId') + if not cve_id: + sys.exit("ERROR: CVE record has no cveId") + + description = '' + for d in cna.get('descriptions', []): + if d.get('lang', '').lower().startswith('en'): + description = d.get('value', '') + break + # CSAF 2.0 requires /vulnerabilities[]/notes[]/text and the document + # summary note to be non-empty (schema minLength 1), and gen_csaf emits + # this description verbatim into both. A record with no (non-empty) + # English description would therefore produce a document the strict CSAF + # gate rejects; fail loudly here rather than writing garbage, per this + # tool's fail-rather-than-emit-garbage contract. + if not description.strip(): + sys.exit(f"ERROR: CVE record {cve_id} has no non-empty English " + f"description (needed for the CSAF/CycloneDX note text)") + + # CSAF 2.0 carries a single `cwe` {id, name}; take the primary one. The + # name is resolved from the official CWE catalogue (CWE_NAMES) so it is the + # exact MITRE string CSAF test 6.1.11 checks against -- the record's + # free-text problemType often differs in casing. `name` may be None when + # the id is absent from the catalogue; the CSAF emitter then omits cwe. + cwe = None + for pt in cna.get('problemTypes', []): + for d in pt.get('descriptions', []): + cid = d.get('cweId') + if not cid: + continue + cwe = {'id': cid, 'name': CWE_NAMES.get(cid)} + break + if cwe: + break + + metrics = cna.get('metrics', []) + cvss = _best_cvss(metrics) + cvss_csaf = _best_cvss_csaf20(metrics) + + affected = [] + for a in cna.get('affected', []): + affected.append({ + 'vendor': a.get('vendor', WOLFSSL_VENDOR), + 'product': a.get('product', 'wolfSSL'), + 'versions': a.get('versions', []), + 'default_status': a.get('defaultStatus', 'unknown'), + }) + + references = [r['url'] for r in cna.get('references', []) if r.get('url')] + credits = [c.get('value', '') for c in cna.get('credits', []) + if c.get('value')] + + return { + 'cve': cve_id, + 'title': cna.get('title') or cve_id, + 'description': description, + 'cwe': cwe, + 'cvss': cvss, + 'cvss_csaf': cvss_csaf, + 'affected': affected, + 'references': references, + 'credits': credits, + 'date_published': meta.get('datePublished'), + 'date_updated': meta.get('dateUpdated'), + } + + +def _range_label(v): + base = v.get('version', '0') + if v.get('lessThanOrEqual'): + return f'<= {v["lessThanOrEqual"]}' if base in ('0', '*') \ + else f'{base} <= x <= {v["lessThanOrEqual"]}' + if v.get('lessThan'): + return f'< {v["lessThan"]}' if base in ('0', '*') \ + else f'{base} <= x < {v["lessThan"]}' + return base + + +def _vers_range(v): + parts = [] + base = v.get('version') + if base and base not in ('0', '*'): + parts.append(f'>={base}') + if v.get('lessThanOrEqual'): + parts.append(f'<={v["lessThanOrEqual"]}') + elif v.get('lessThan'): + parts.append(f'<{v["lessThan"]}') + elif base and base not in ('0', '*'): + return f'vers:generic/{base}' + return 'vers:generic/' + '|'.join(parts) if parts else 'vers:generic/*' + + +# --------------------------------------------------------------------------- # +# Product model: normalize record + overlay into a list of product entries. +# --------------------------------------------------------------------------- # + +def product_model(adv, ov): + """Return the products this CVE describes: the mainline wolfSSL product + (from the CVE record) plus an optional, separately-modelled FIPS product + (from the overlay).""" + products = [] + + state = ov.get('state', 'exploitable') + bucket = _bucket_for(state) + + # ---- mainline product(s) from the CVE record's affected[] ---- + for a in adv['affected']: + product = a['product'] + affected_ranges = [] + for v in a['versions']: + # Fall back to defaultStatus only when the entry carries no explicit + # status. Using `or a['default_status'] == 'affected'` here would + # force EVERY entry into the vulnerable bucket when defaultStatus is + # "affected" -- including entries explicitly marked + # status="unaffected" (the CVE-5.x affected-by-default with + # unaffected/fixed exceptions pattern) -- emitting a fixed release + # as a known_affected range, the opposite of the truth. + if v.get('status', a['default_status']) == 'affected': + affected_ranges.append({ + 'label': _range_label(v), + 'cpe': cpe_for(product, '*'), + 'vers': _vers_range(v), + }) + fixed = [] + for fv in ov.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for(product, fv), + 'purl': purl_for(product, fv)}) + remediation = ov.get('remediation') + if not remediation and fixed: + remediation = f'Update to {product} {fixed[0]["version"]} or later.' + products.append({ + 'product_name': product, + 'cdx_key': product.lower(), + 'bucket': bucket, + 'justification': ov.get('justification') if bucket + == 'known_not_affected' else None, + 'remediation': remediation, + 'remediation_category': 'vendor_fix' if fixed else 'none_available', + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': [], + }) + + # ---- optional FIPS product from the overlay ---- + fips = ov.get('fips') + if fips: + name = fips.get('name', 'wolfCrypt FIPS Module') + modver = fips.get('module_version') + fbucket = _bucket_for(fips.get('status', 'exploitable')) + affected_ranges = [] + if modver: + affected_ranges.append({ + 'label': modver, + 'cpe': cpe_for('wolfcrypt', modver), + 'vers': f'vers:generic/{modver}', + }) + fixed = [] + for fv in fips.get('fixed_versions', []): + fixed.append({'version': fv, 'cpe': cpe_for('wolfcrypt', fv), + 'purl': purl_for('wolfcrypt', fv)}) + model_numbers = [] + if fips.get('cmvp_cert'): + model_numbers.append(f'CMVP Certificate #{fips["cmvp_cert"]}') + products.append({ + 'product_name': name, + 'cdx_key': 'wolfcrypt-fips', + 'bucket': fbucket, + 'justification': fips.get('justification') if fbucket + == 'known_not_affected' else None, + 'remediation': fips.get('remediation'), + 'remediation_category': 'vendor_fix' if fixed else ( + 'no_fix_planned' if fbucket == 'known_not_affected' + else 'none_available'), + 'affected_ranges': affected_ranges, + 'fixed': fixed, + 'model_numbers': model_numbers, + 'module_version': modver, + }) + + return products + + +def _hedge_note(ov): + """Render the no-cost reachability hedge as informational text (only).""" + bits = [] + defines = ov.get('requires_defines') + if defines: + bits.append('Reachable only in builds compiled with: ' + + ', '.join(defines) + '.') + if ov.get('default_status') in ('off', 'disabled'): + bits.append('The affected feature is disabled by default.') + return ' '.join(bits) if bits else None + + +# --------------------------------------------------------------------------- # +# CSAF 2.0 emitter (handles 1..N vulnerabilities in one document) +# --------------------------------------------------------------------------- # + +def generate_csaf(advs, ov_map, advisory_id, timestamp): + # Shared product_tree: product leaves are deduplicated by product_id across + # all CVEs in the bundle (Red Hat-style shared product ids). + tree = {} # (vendor, product_name) -> {product_id: leaf} + tree_order = [] # preserve insertion order of (vendor, product_name) + vulns = [] + agg_rank = -1 + agg_text = None + init_dates = [] + cur_dates = [] + + def _leaf_range(pname, label, cpe, model_numbers): + pid = derived_uuid(pname, 'range', label) + helper = {'cpe': cpe} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version_range', + 'name': label, + 'product': { + 'product_id': pid, + 'name': f'{pname} {label}', + 'product_identification_helper': helper, + }, + } + + def _leaf_fixed(pname, fx, model_numbers): + pid = derived_uuid(pname, 'fixed', fx['version']) + helper = {'cpe': fx['cpe'], 'purl': fx['purl']} + if model_numbers: + helper['model_numbers'] = model_numbers + return pid, { + 'category': 'product_version', + 'name': fx['version'], + 'product': { + 'product_id': pid, + 'name': f'{pname} {fx["version"]}', + 'product_identification_helper': helper, + }, + } + + def _register(pname, pid, leaf): + key = (WOLFSSL_VENDOR, pname) + if key not in tree: + tree[key] = {} + tree_order.append(key) + tree[key].setdefault(pid, leaf) + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + status_buckets = {} # bucket -> [pids] + score_targets = [] + flags = {} # flag_label -> [pids] + remediations = {} # (category, text, url) -> [pids] + + for prod in products: + pname = prod['product_name'] + affected_pids = [] + for r in prod['affected_ranges']: + pid, leaf = _leaf_range(pname, r['label'], r['cpe'], + prod['model_numbers']) + _register(pname, pid, leaf) + affected_pids.append(pid) + status_buckets.setdefault(prod['bucket'], []).append(pid) + if prod['bucket'] in ('known_affected', 'under_investigation'): + score_targets.append(pid) + if prod['bucket'] == 'known_not_affected': + flag = _JUSTIFICATION_TO_CSAF_FLAG.get( + prod['justification'] or '', + 'vulnerable_code_not_in_execute_path') + flags.setdefault(flag, []).append(pid) + for fx in prod['fixed']: + pid, leaf = _leaf_fixed(pname, fx, prod['model_numbers']) + _register(pname, pid, leaf) + status_buckets.setdefault('fixed', []).append(pid) + if prod['remediation'] and affected_pids: + url = adv['references'][0] if adv['references'] else None + key = (prod['remediation_category'], prod['remediation'], url) + remediations.setdefault(key, []).extend(affected_pids) + + vuln = { + 'cve': adv['cve'], + 'notes': [{ + 'category': 'description', + 'text': adv['description'], + 'title': 'Vulnerability description', + }], + 'product_status': {k: v for k, v in status_buckets.items() if v}, + } + hedge = _hedge_note(ov) + if hedge: + vuln['notes'].append({ + 'category': 'other', 'title': 'Build reachability', 'text': hedge}) + # Emit cwe only when we resolved the exact catalogue name (CSAF 6.1.11). + if adv['cwe'] and adv['cwe'].get('name'): + vuln['cwe'] = {'id': adv['cwe']['id'], 'name': adv['cwe']['name']} + if adv['references']: + vuln['references'] = [{'summary': u, 'url': u, 'category': 'external'} + for u in adv['references']] + if flags: + vuln['flags'] = [{'label': lbl, 'product_ids': pids} + for lbl, pids in flags.items()] + if remediations: + vuln['remediations'] = [] + for (cat, text, url), pids in remediations.items(): + rem = {'category': cat, 'details': text, 'product_ids': pids} + if url: + rem['url'] = url + vuln['remediations'].append(rem) + # CSAF 2.0 scores[] can only carry CVSS v2/v3 (the schema predates v4). + if adv['cvss_csaf'] and score_targets: + vuln['scores'] = [{adv['cvss_csaf']['csaf_key']: + adv['cvss_csaf']['data'], + 'products': score_targets}] + # aggregate_severity uses the best CVSS available (which may be v4). + best = adv['cvss'] + if best: + sev = best['data'].get('baseSeverity', '').upper() + if _SEV_RANK.get(sev, -1) > agg_rank: + agg_rank = _SEV_RANK[sev] + agg_text = best['data'].get('baseSeverity') + # A v4-only finding cannot be represented in CSAF 2.0 scores[]; + # preserve the rating as a note so it is not silently dropped. + if best['csaf_key'] == 'cvss_v4' and not adv['cvss_csaf']: + v4 = best['data'] + vuln['notes'].append({ + 'category': 'other', + 'title': 'CVSS v4.0', + 'text': (f"CVSS v4.0 base score {v4.get('baseScore')} " + f"({v4.get('baseSeverity')}); vector " + f"{v4.get('vectorString')}. CSAF 2.0 scores[] " + f"cannot encode CVSS v4; the machine-readable v4 " + f"rating is provided in the CycloneDX VEX output."), + }) + if adv['credits']: + vuln['acknowledgments'] = [{'summary': c} for c in adv['credits']] + vulns.append(vuln) + + if adv['date_published']: + init_dates.append(adv['date_published']) + cur_dates.append(adv['date_updated'] or adv['date_published'] or timestamp) + + # Assemble product_tree branches. + branches = [] + for (vendor, pname) in tree_order: + branches.append({ + 'category': 'vendor', 'name': vendor, + 'branches': [{ + 'category': 'product_name', 'name': pname, + 'branches': list(tree[(vendor, pname)].values()), + }], + }) + + bundle = len(advs) > 1 + if bundle: + title = f'wolfSSL Security Advisory {advisory_id}' + else: + title = f'wolfSSL: {advs[0]["title"]}' + + doc = { + 'document': { + 'category': 'csaf_security_advisory', + 'csaf_version': '2.0', + 'title': title, + 'publisher': WOLFSSL_PUBLISHER, + 'tracking': { + 'id': advisory_id, + 'status': 'final', + 'version': '1', + 'initial_release_date': min(init_dates) if init_dates + else timestamp, + 'current_release_date': max(cur_dates) if cur_dates + else timestamp, + 'revision_history': [{ + 'number': '1', + 'date': min(init_dates) if init_dates else timestamp, + 'summary': 'Initial release', + }], + 'generator': { + 'engine': {'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}, + }, + }, + 'distribution': {'tlp': {'label': 'WHITE'}}, + 'references': [ + { + 'summary': f'Canonical CSAF document for {advisory_id}', + 'url': f'{WOLFSSL_CSAF_BASE_URL}{advisory_id}.csaf.json', + 'category': 'self', + }, + { + 'summary': 'wolfSSL published security vulnerabilities', + 'url': WOLFSSL_ADVISORIES_URL, + 'category': 'external', + }, + ], + 'notes': [{ + 'category': 'summary', + 'title': 'Summary', + 'text': (f'wolfSSL security advisory {advisory_id} covering ' + f'{len(advs)} vulnerabilities.' if bundle + else advs[0]['description']), + }], + }, + 'product_tree': {'branches': branches}, + 'vulnerabilities': vulns, + } + if agg_text: + doc['document']['aggregate_severity'] = {'text': agg_text} + return doc + + +# --------------------------------------------------------------------------- # +# CycloneDX 1.6 VEX emitter (handles 1..N vulnerabilities in one BOM) +# --------------------------------------------------------------------------- # + +def _cdx_severity(cvss_data): + sev = (cvss_data or {}).get('baseSeverity', '').lower() + return sev if sev in ('critical', 'high', 'medium', 'low', 'none') \ + else 'unknown' + + +def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): + main_ref = derived_uuid('cdx-component', 'wolfssl') + extra_components = {} # ref -> component dict (e.g. FIPS module) + vulns = [] + + for adv in advs: + ov = ov_map.get(adv['cve'], {}) + products = product_model(adv, ov) + + affects = [] + for prod in products: + if prod['cdx_key'] == 'wolfcrypt-fips': + ref = derived_uuid('cdx-component', 'wolfcrypt-fips', + prod.get('module_version') or '') + if ref not in extra_components: + comp = { + 'bom-ref': ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': prod['product_name'], + 'cpe': cpe_for('wolfcrypt', prod.get('module_version') + or '*'), + } + if prod.get('module_version'): + comp['version'] = prod['module_version'] + if prod['model_numbers']: + comp['properties'] = [ + {'name': 'wolfssl:fips:cmvp', 'value': m} + for m in prod['model_numbers']] + extra_components[ref] = comp + else: + ref = main_ref + # CycloneDX affects[].versions[].status uses 'unaffected' for a + # not-affected product; the 'not_affected' term belongs to + # analysis.state only. + astatus = 'unaffected' if prod['bucket'] == 'known_not_affected' \ + else 'affected' + versions = [{'range': r['vers'], 'status': astatus} + for r in prod['affected_ranges']] + versions += [{'version': fx['version'], 'status': 'unaffected'} + for fx in prod['fixed']] + if versions: + affects.append({'ref': ref, 'versions': versions}) + + vuln = { + 'id': adv['cve'], + 'source': {'name': 'wolfSSL', 'url': WOLFSSL_ADVISORIES_URL}, + 'description': adv['description'], + 'affects': affects or [{'ref': main_ref}], + } + if adv['cvss']: + rating = {'source': {'name': 'wolfSSL'}, + 'severity': _cdx_severity(adv['cvss']['data']), + 'method': adv['cvss']['cdx_method']} + if 'baseScore' in adv['cvss']['data']: + rating['score'] = adv['cvss']['data']['baseScore'] + if 'vectorString' in adv['cvss']['data']: + rating['vector'] = adv['cvss']['data']['vectorString'] + vuln['ratings'] = [rating] + if adv['cwe']: + try: + vuln['cwes'] = [int(adv['cwe']['id'].split('-')[-1])] + except ValueError: + pass + if adv['references']: + vuln['advisories'] = [{'url': u} for u in adv['references']] + + analysis = {'state': ov.get('state', 'exploitable')} + if analysis['state'] == 'not_affected' and ov.get('justification'): + analysis['justification'] = ov['justification'] + if ov.get('response'): + analysis['response'] = ov['response'] + detail_bits = [b for b in (ov.get('detail'), _hedge_note(ov)) if b] + if detail_bits: + analysis['detail'] = ' '.join(detail_bits) + vuln['analysis'] = analysis + + if adv['credits']: + vuln['credits'] = {'individuals': [{'name': c} + for c in adv['credits']]} + if adv['date_published']: + vuln['published'] = adv['date_published'] + if adv['date_updated']: + vuln['updated'] = adv['date_updated'] + vulns.append(vuln) + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.6.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.6', + 'serialNumber': f'urn:uuid:{derived_uuid(advisory_id, "serial")}', + 'version': 1, + 'metadata': { + 'timestamp': timestamp, + 'tools': {'components': [{ + 'type': 'application', 'author': 'wolfSSL Inc.', + 'name': GEN_TOOL_NAME, 'version': GEN_TOOL_VERSION}]}, + 'component': { + 'bom-ref': main_ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': 'wolfssl', + 'cpe': cpe_for('wolfssl', '*'), + 'purl': 'pkg:github/wolfSSL/wolfssl', + }, + }, + 'components': list(extra_components.values()), + 'vulnerabilities': vulns, + } + + +# --------------------------------------------------------------------------- # + +def load_overlay(path): + if not path: + return {} + try: + with open(path) as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}") + + +def _write_json(obj, path): + try: + with open(path, 'w') as f: + json.dump(obj, f, indent=2) + f.write('\n') + except OSError as e: + sys.exit(f"ERROR: cannot write {path}: {e}") + + +def _emit(advs, ov_map, advisory_id, timestamp, csaf_out, cdx_out): + """Emit one CSAF and/or one CycloneDX document covering `advs`.""" + if csaf_out: + _write_json(generate_csaf(advs, ov_map, advisory_id, timestamp), + csaf_out) + print(f"Generated: {csaf_out}") + if cdx_out: + _write_json(generate_cdx_vex(advs, ov_map, advisory_id, timestamp), + cdx_out) + print(f"Generated: {cdx_out}") + + +def main(): + p = argparse.ArgumentParser( + description='Generate CSAF 2.0 advisories and CycloneDX 1.6 VEX from ' + 'wolfSSL CVE Program records. With no record arguments it ' + 'processes every record in the canonical advisories/ tree ' + '(the same inputs `make advisory` uses), writing one CSAF ' + '+ one CycloneDX document per CVE into the output ' + 'directory.') + p.add_argument('--cve-record', action='append', default=[], + help='Path to a CVE JSON 5.x record (repeatable). Overrides ' + 'the default --records-dir scan.') + p.add_argument('--cve-id', action='append', default=[], + help='Fetch a record by id from the CVE Services API ' + '(cveawg.mitre.org) (repeatable). Overrides the ' + 'default --records-dir scan.') + p.add_argument('--records-dir', default=str(DEFAULT_RECORDS_DIR), + help='Directory of CVE JSON 5.x records scanned when no ' + f'--cve-record/--cve-id is given (default: ' + f'{DEFAULT_RECORDS_DIR}).') + p.add_argument('--vex-overlay', default=None, + help='JSON file mapping CVE id -> VEX overlay (state, ' + 'justification, detail, response, fixed_versions, ' + 'remediation, fips{}, requires_defines, ' + f'default_status). Default: {DEFAULT_OVERLAY} if it ' + 'exists.') + p.add_argument('--advisory-id', + help='Tracking id when bundling several records into ONE ' + 'document. Defaults to the CVE id for a single record.') + p.add_argument('--out-dir', default=str(DEFAULT_OUT_DIR), + help='Output directory for the per-CVE documents written in ' + 'batch mode (default: ' f'{DEFAULT_OUT_DIR}).') + p.add_argument('--csaf-out', + help='Write a single CSAF document to this path instead of ' + 'batch mode (one record, or several with ' + '--advisory-id).') + p.add_argument('--cdx-vex-out', + help='Write a single CycloneDX VEX document to this path ' + 'instead of batch mode.') + args = p.parse_args() + + # ---- resolve the input records ---- + explicit = bool(args.cve_record or args.cve_id) + if explicit: + records = [load_cve_record(path=pth) for pth in args.cve_record] + records += [load_cve_record(cve_id=cid) for cid in args.cve_id] + else: + rec_dir = pathlib.Path(args.records_dir) + paths = sorted(rec_dir.glob('*.json')) + if not paths: + sys.exit(f"ERROR: no CVE records found in {rec_dir}. Add records " + f"there, or pass --cve-record / --cve-id.") + records = [load_cve_record(path=str(pth)) for pth in paths] + advs = [parse_record(r) for r in records] + + # ---- resolve the overlay (explicit > default-if-present > none) ---- + overlay_path = args.vex_overlay + if overlay_path is None and DEFAULT_OVERLAY.exists(): + overlay_path = str(DEFAULT_OVERLAY) + ov_map = load_overlay(overlay_path) + if overlay_path: + for adv in advs: + if adv['cve'] not in ov_map: + print(f"WARNING: no VEX overlay entry for {adv['cve']}; " + f"defaulting to state=exploitable", file=sys.stderr) + + _, timestamp = build_timestamp() + + # ---- single-document mode (explicit output path) ---- + if args.csaf_out or args.cdx_vex_out: + if args.advisory_id: + advisory_id = args.advisory_id + elif len(advs) == 1: + advisory_id = advs[0]['cve'] + else: + sys.exit("ERROR: --advisory-id is required when bundling several " + "records into one --csaf-out/--cdx-vex-out document.") + _emit(advs, ov_map, advisory_id, timestamp, + args.csaf_out, args.cdx_vex_out) + return + + # ---- batch mode: one CSAF + one CycloneDX per CVE into --out-dir ---- + out_dir = pathlib.Path(args.out_dir) + try: + out_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + sys.exit(f"ERROR: cannot create output directory {out_dir}: {e}") + if args.advisory_id and len(advs) > 1: + _emit(advs, ov_map, args.advisory_id, timestamp, + str(out_dir / f'{args.advisory_id}.csaf.json'), + str(out_dir / f'{args.advisory_id}.cdx.json')) + else: + for adv in advs: + _emit([adv], ov_map, adv['cve'], timestamp, + str(out_dir / f"{adv['cve']}.csaf.json"), + str(out_dir / f"{adv['cve']}.cdx.json")) + print(f"Wrote {len(advs)} advisory document set(s) to {out_dir}") + + +if __name__ == '__main__': + main() diff --git a/provenance/bomsh_verify.py b/provenance/bomsh_verify.py new file mode 100644 index 0000000..818cb4b --- /dev/null +++ b/provenance/bomsh_verify.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""End-to-end verifier for the bomsh provenance bundle. + +Two independent self-consistency checks on the artefacts that +`make bomsh` produces. The PERSISTENT-ID assertion in the bomsh CI +job only proves the gitoid externalRef *exists* in the enriched SPDX; +neither of these follow-up properties is guaranteed by it: + + (A) Resolvability -- every gitoid in the SPDX externalRefs resolves + to a blob present at omnibor/objects//. Catches the + `bomsh_sbom.py` regression class that emits a syntactically + well-formed gitoid which does not actually point at anything in + the shipped ADG. + + (B) Object-store integrity -- every blob in omnibor/objects/ + round-trips through sha1(b"blob \\0" + content), so a + corrupt or truncated object store is caught at PR time, not by + a downstream verifier weeks later. + +CLI form (used by `.github/workflows/sbom.yml`): + + python3 scripts/bomsh_verify.py \\ + --spdx-glob 'omnibor.wolfssl-*.spdx.json' \\ + --omnibor-dir omnibor + +Library form (used by scripts/test_gen_sbom.py): + + from scripts import bomsh_verify + ok, messages = bomsh_verify.verify(...) +""" + +import argparse +import glob as _glob +import hashlib +import json +import os +import re +import sys +from typing import List + + +GITOID_LOCATOR_PREFIX = 'gitoid:blob:sha1:' +# An OmniBOR sha1 gitoid is exactly 40 lowercase-hex chars. Validate before +# the value is used to build an object path: a crafted SPDX with a locator like +# 'gitoid:blob:sha1:../../../etc/shadow' otherwise passes the prefix check and +# turns the os.path.join() below into a path-traversal existence oracle. +_SHA1_HEX_RE = re.compile(r'[0-9a-f]{40}\Z') + + +def gitoid_sha1(path): + """OmniBOR `gitoid:blob:sha1:` is the canonical Git blob hash: + sha1(b"blob \\0" + content). Symlinks are followed transparently + by `open()`, which matches what bomsh records (the trace sees the + target, not the symlink).""" + with open(path, 'rb') as f: + data = f.read() + h = hashlib.sha1() + h.update(f'blob {len(data)}\0'.encode()) + h.update(data) + return h.hexdigest() + + +def load_spdx_gitoids(spdx_path): + """Return [(package_name, gitoid_hex), ...] for every externalRef + of referenceType 'gitoid' in the SPDX document at spdx_path. + + Raises ValueError on a malformed locator (anything that isn't + `gitoid:blob:sha1:`). An sha256 locator would land here too + if bomsh ever switches; the failure is the right behaviour, since + a maintainer must update the verifier in lockstep.""" + with open(spdx_path) as f: + spdx = json.load(f) + gitoids = [] + for pkg in spdx.get('packages', []): + for ref in pkg.get('externalRefs', []): + if ref.get('referenceType') != 'gitoid': + continue + loc = ref.get('referenceLocator', '') + if not loc.startswith(GITOID_LOCATOR_PREFIX): + raise ValueError( + f'unexpected gitoid locator format: {loc!r} ' + f'(expected {GITOID_LOCATOR_PREFIX}; if bomsh ' + f'has switched to sha256 the verifier needs updating)') + gid = loc[len(GITOID_LOCATOR_PREFIX):] + if not _SHA1_HEX_RE.match(gid): + raise ValueError( + f'malformed gitoid {gid!r} in locator {loc!r}: expected ' + f'40 lowercase-hex sha1 characters (refusing to use it in ' + f'an object path)') + gitoids.append((pkg.get('name', ''), gid)) + return gitoids + + +def check_resolvability(spdx_gitoids, omnibor_objects_dir): + """(A) Every SPDX gitoid resolves to a file at + `//`. Returns a list of + (pkg_name, gitoid, expected_path) for the unresolved ones; empty + list means every gitoid resolved.""" + missing = [] + for pkg_name, gid in spdx_gitoids: + obj = os.path.join(omnibor_objects_dir, gid[:2], gid[2:]) + if not os.path.isfile(obj): + missing.append((pkg_name, gid, obj)) + return missing + + +_HEX_CHARS = frozenset('0123456789abcdef') + + +def _looks_like_blob_path(parts): + """True iff `parts` is the canonical `/` shape Git uses + for content-addressed blob fanout: exactly two components, the + first of which is a 2-char lowercase-hex prefix and the second of + which is the remaining 38 lowercase-hex chars of a sha1 digest. + Anything else (`info/`, `pack/...`, deeper nesting) is housekeeping + and must NOT be gitoid-checked. + + sha1-only on purpose: check_object_store_integrity() hashes with + gitoid_sha1() and load_spdx_gitoids() rejects non-sha1 locators, so + the whole verifier is sha1-scoped. Admitting a 62-char (sha256) + digest here would only let the integrity check compare it against an + sha1 hash and always report it corrupt; a real bomsh switch to + sha256 must update the hashing (and this length) in lockstep.""" + if len(parts) != 2: + return False + aa, rest = parts + if len(aa) != 2 or not all(c in _HEX_CHARS for c in aa): + return False + if len(rest) != 38: + return False + return all(c in _HEX_CHARS for c in rest) + + +def check_object_store_integrity(omnibor_objects_dir): + """(B) Every blob in round-trips through + `gitoid_sha1`. Returns (count_total, [(path, expected, actual), ...] + for blobs whose content does not match their expected gitoid). + + The directory layout is `//` (Git's + standard fanout, where is the first two hex chars of the + digest); files outside that shape are skipped silently (e.g. + `info/` or `pack/` siblings, README files, etc.).""" + bad = [] + obj_count = 0 + for root, _, files in os.walk(omnibor_objects_dir): + for fname in files: + obj = os.path.join(root, fname) + rel = os.path.relpath(obj, omnibor_objects_dir) + parts = rel.split(os.sep) + if not _looks_like_blob_path(parts): + continue + expected = parts[0] + parts[1] + obj_count += 1 + actual = gitoid_sha1(obj) + if actual != expected: + bad.append((obj, expected, actual)) + return obj_count, bad + + +def verify(spdx_glob, omnibor_dir): + """Orchestrate the two checks. Returns (ok: bool, messages: + List[str]). `messages` is appended to in success and failure both, + so callers can log the success lines ('OK: N gitoid(s) verified' + + ' objects round-trip: M blobs') even when ok is True.""" + messages: List[str] = [] + + spdx_paths = sorted(_glob.glob(spdx_glob)) + if not spdx_paths: + return False, [f'no SPDX matched {spdx_glob!r}'] + + objects_dir = os.path.join(omnibor_dir, 'objects') + ok = True + total_gitoids = 0 + + # (A) EVERY matched SPDX must load and have all its gitoids resolve -- + # not just spdx_paths[0]. When the glob matches several documents (e.g. + # one per product/version), verifying only the first lets a broken or + # tampered SPDX that sorts later pass unexamined; for a provenance + # verifier a silent pass is the failure to avoid. Problems are + # accumulated (rather than returned on the first) so one run reports every + # broken document. + for spdx_path in spdx_paths: + try: + spdx_gitoids = load_spdx_gitoids(spdx_path) + except (json.JSONDecodeError, ValueError) as e: + messages.append( + f'could not load SPDX gitoids from {spdx_path}: {e}') + ok = False + continue + if not spdx_gitoids: + messages.append(f'no gitoid externalRefs in {spdx_path}') + ok = False + continue + missing = check_resolvability(spdx_gitoids, objects_dir) + if missing: + for pkg_name, gid, obj in missing: + messages.append( + f'DANGLING: {spdx_path}: {pkg_name} gitoid {gid} -> {obj}') + messages.append( + f'{len(missing)} SPDX gitoid(s) from {spdx_path} not present ' + f'in {objects_dir}/ (provenance bundle is broken)') + ok = False + continue + total_gitoids += len(spdx_gitoids) + messages.append( + f'OK: {spdx_path}: {len(spdx_gitoids)} gitoid(s) verified') + + # (B) Object-store integrity, once for the whole store. + obj_count, bad = check_object_store_integrity(objects_dir) + if bad: + for obj, expected, actual in bad[:5]: + messages.append( + f'CORRUPT: {obj} expected {expected} got {actual}') + messages.append( + f'{len(bad)} object(s) in {objects_dir}/ failed gitoid ' + f'round-trip (object store is corrupt)') + ok = False + + if ok: + messages.append( + f'OK: {len(spdx_paths)} SPDX file(s), {total_gitoids} ' + f'gitoid(s) verified') + messages.append(f' objects round-trip: {obj_count} blobs') + return ok, messages + + +def main(): + parser = argparse.ArgumentParser( + description='End-to-end verifier for the bomsh provenance bundle.') + parser.add_argument('--spdx-glob', + default='omnibor.wolfssl-*.spdx.json', + help='Glob matching the bomsh-enriched SPDX file ' + '(default: %(default)s)') + parser.add_argument('--omnibor-dir', default='omnibor', + help='Path to the OmniBOR directory containing ' + 'objects/ (default: %(default)s)') + args = parser.parse_args() + + ok, messages = verify(args.spdx_glob, args.omnibor_dir) + for line in messages: + print(line, file=sys.stderr if not ok else sys.stdout) + sys.exit(0 if ok else 1) + + +if __name__ == '__main__': + main() diff --git a/share/README.md b/share/README.md index 1078484..a96587e 100644 --- a/share/README.md +++ b/share/README.md @@ -16,8 +16,8 @@ folder name; copy the files. | `frontends/zephyr_sbom.py` | Extractor for a Zephyr module `CMakeLists.txt`. | | `build/sbom.mk` | Shared plain-Make fragment. | | `build/sbom.cmake` | Shared CMake helper: `wolfglass_add_sbom()`. | -| `gen-sbom` | The SBOM generator (populated once the engine-home decision is signed off; see PLAN.md §21). | -| `sbom.am` | The autotools fragment (to be lifted from wolfSSL). | +| `gen-sbom` | The vendored SBOM generator. | +| `sbom.am` | Shared autotools fragment. | ## The driver contract @@ -47,6 +47,9 @@ The driver captures macros with the host compiler, so the SBOM is reproducible across toolchains. It scrubs absolute host paths from the captured macros unless you pass `--no-scrub`. +The shared driver is product-neutral and calls the vendored `share/gen-sbom` by +default. Pass `--gen-sbom` only when you want to override that copy. + ## The manifest contract A product does not copy logic. It describes itself: @@ -56,7 +59,7 @@ A product does not copy logic. It describes itself: `include tools/sbom/build/sbom.mk`. - CMake: `include(tools/sbom/build/sbom.cmake)` and call `wolfglass_add_sbom()` with `NAME`, `VERSION_FILE`, `VERSION_MACRO`, `TARGETS`, `DEFS`, `LICENSE`. -- Autotools: set the `SBOM_*` variables and `include sbom.am` (existing model). +- Autotools: set the `SBOM_*` variables and `include tools/sbom/sbom.am`. Keep only true product knowledge in the product: the route-through script, the module extractor, and the HAL source selector. diff --git a/share/gen-sbom b/share/gen-sbom new file mode 100755 index 0000000..f90b3f4 --- /dev/null +++ b/share/gen-sbom @@ -0,0 +1,1410 @@ +#!/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:` (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}', + }, + # liboqs is the only PQ external dependency wolfSSL still links against + # after upstream PR #10293 collapsed the rest of the PQ surface into + # native wolfCrypt. Today, --enable-falcon strictly implies --with-liboqs + # (configure.ac enforces both directions), so a build that links liboqs + # is precisely a build that exposed Falcon. + 'liboqs': { + 'name': 'liboqs', + 'supplier': 'Open Quantum Safe', + 'license': 'MIT', + 'download': 'https://github.com/open-quantum-safe/liboqs', + 'pkgconfig': 'liboqs', + 'purl': lambda v: f'pkg:github/open-quantum-safe/liboqs@{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 `. 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 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 (`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 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:` + # 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 --with-liboqs 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-liboqs', default='no', + help='yes if built with --with-liboqs (the package ' + 'wolfSSL links against; --enable-falcon implies ' + 'this in any legal configuration)') + 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), + ('liboqs', args.dep_liboqs), + ] + 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() diff --git a/share/sbom.am b/share/sbom.am new file mode 100644 index 0000000..e43b8a5 --- /dev/null +++ b/share/sbom.am @@ -0,0 +1,219 @@ +# sbom.am - shared Automake recipe for CRA-compliant SBOM generation. +# +# One generator (gen-sbom) does the work; each product just describes itself and +# includes this fragment. It is deliberately product-agnostic: a Makefile.am +# sets a few variables (below) and does `include tools/sbom/sbom.am` to get the +# `sbom`, `install-sbom` and `uninstall-sbom` targets. +# +# This is the canonical copy in wolfGlass. Product repositories vendor it +# together with the sibling `gen-sbom`, so the SBOM path works offline with no +# build-time dependency on a separate wolfSSL checkout. +# +# --------------------------------------------------------------------------- +# The including Makefile.am MUST set, before `include scripts/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 +# (e.g. $(srcdir)/LICENSING). +# +# Optional (defaults shown): +# SBOM_OPTIONS_H Path to a product-generated options header (e.g. +# wolfMQTT's $(builddir)/wolfmqtt/options.h) that records +# the enabled build macros. Set this for products whose +# feature flags are NOT in config.h (no AC_DEFINE); when +# unset the recipe derives the macros from the compiler + +# config.h. Default: unset. +# SBOM_ARTIFACT lib | bin - which build output to hash. Default: lib. +# SBOM_LIB_STEM Library basename w/o extension. Default: lib$(SBOM_PKGNAME). +# SBOM_BIN_NAME Program name when SBOM_ARTIFACT = bin. Default: $(SBOM_PKGNAME). +# SBOM_DEP_WOLFSSL yes | no - record wolfSSL as a dependency. Default: no. +# SBOM_DEP_OPENSSL yes | no - record OpenSSL as a dependency (wolfProvider / +# wolfEngine). Default: no. +# SBOM_LICENSE_OVERRIDE SPDX expression to record instead of the licence +# detected from SBOM_LICENSE_FILE. +# SBOM_LICENSE_TEXT Path to licence text for any LicenseRef-* used in +# SBOM_LICENSE_OVERRIDE (required by SPDX 2.3). +# SBOM_WOLFSSL_VERSION Version recorded for the wolfSSL dependency; +# auto-detected from WOLFSSL_DIR/wolfssl/version.h when unset. +# SBOM_OPENSSL_VERSION Version recorded for the OpenSSL dependency; +# gen-sbom resolves it via pkg-config when unset. +# SBOM_CONFIG_H Path to the configure-generated config header to +# force-include when capturing the configured build +# macros. Products whose AC_CONFIG_HEADERS lives in a +# subdirectory MUST override this so config.h defines are +# captured (e.g. wolfEngine: $(abs_builddir)/include/config.h; +# wolfCLU: $(abs_builddir)/src/config.h). +# Default: $(abs_builddir)/config.h. +# +# 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. 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 +# $(addprefix); under a non-GNU make the SBOM targets will not work. +# --------------------------------------------------------------------------- + +SBOM_ARTIFACT ?= lib +SBOM_LIB_STEM ?= lib$(SBOM_PKGNAME) +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))) + +SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json +SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json +SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx +# Use Automake's $(docdir) so a user's --docdir override is honoured (this +# equals $(datadir)/doc/$(PACKAGE) by default). +sbomdir = $(docdir) + +# Prefer the vendored sibling copy. Callers may override SBOM_GEN explicitly. +SBOM_GEN ?= $(abspath $(SBOM_AM_DIR)/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. +SBOM_LIB_GLOBS = \ + $(SBOM_LIB_STEM).so.[0-9]* \ + $(SBOM_LIB_STEM).so \ + $(SBOM_LIB_STEM).[0-9]*.dylib \ + $(SBOM_LIB_STEM).dylib \ + $(SBOM_LIB_STEM).dll \ + $(SBOM_LIB_STEM).dll.a \ + $(SBOM_LIB_STEM).lib \ + $(SBOM_PKGNAME).lib \ + $(SBOM_LIB_STEM).a + +# 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`. +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), +# 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 +# honoured for reproducible output (defaults to the last git commit time). +sbom: + @test -n "$(PYTHON3)" || { \ + echo "ERROR: 'python3' not found in PATH. Cannot generate SBOM."; \ + exit 1; } + @test -n "$(PYSPDXTOOLS)" || { \ + echo "ERROR: 'pyspdxtools' not found (pip install spdx-tools)."; \ + exit 1; } + @test -f "$(SBOM_GEN)" || { \ + echo "ERROR: gen-sbom not found at $(SBOM_GEN)."; \ + echo " Vendor tools/sbom/gen-sbom or override SBOM_GEN=/path/to/gen-sbom"; \ + exit 1; } + @rm -rf $(abs_builddir)/_sbom_staging + @set -e; \ + _defines=`mktemp $(abs_builddir)/_sbom_defines.XXXXXX`; \ + trap 'rm -rf $(abs_builddir)/_sbom_staging "$$_defines"' EXIT INT TERM HUP; \ + $(MAKE) install DESTDIR=$(abs_builddir)/_sbom_staging; \ + sbom_art=""; \ + if test "$(SBOM_ARTIFACT)" = bin; then \ + for art in \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)" \ + "$(abs_builddir)/_sbom_staging$(bindir)/$(SBOM_BIN_NAME)".exe; do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + else \ + for art in \ + $(addprefix "$(abs_builddir)/_sbom_staging$(libdir)"/,$(SBOM_LIB_GLOBS)) \ + $(addprefix "$(abs_builddir)/_sbom_staging$(bindir)"/,$(SBOM_LIB_STEM).dll $(SBOM_PKGNAME).dll); do \ + if test -f "$$art"; then sbom_art="$$art"; break; fi; \ + done; \ + fi; \ + if test -z "$$sbom_art"; then \ + echo ""; \ + echo "ERROR: no installed $(SBOM_PKGNAME) artifact found for SBOM."; \ + echo " (configure with --enable-shared or --enable-static)"; \ + echo ""; \ + exit 1; \ + fi; \ + echo "SBOM: hashing $$sbom_art"; \ + 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) \ + $(if $(wildcard $(SBOM_CONFIG_H)),-include $(SBOM_CONFIG_H)) \ + -x c /dev/null > "$$_defines"; \ + fi; \ + if test -z "$${SOURCE_DATE_EPOCH:-}" && test -n "$(GIT)" && \ + $(GIT) -C "$(srcdir)" rev-parse --git-dir >/dev/null 2>&1; then \ + sde=`$(GIT) -C "$(srcdir)" log -1 --format=%ct 2>/dev/null`; \ + if test -n "$$sde"; then SOURCE_DATE_EPOCH="$$sde"; export SOURCE_DATE_EPOCH; fi; \ + fi; \ + dep_args=""; \ + if test "$(SBOM_DEP_WOLFSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-wolfssl'; then \ + dep_args="$$dep_args --dep-wolfssl yes"; \ + wv="$(SBOM_WOLFSSL_VERSION)"; \ + if test -z "$$wv" && test -n "$(WOLFSSL_DIR)" && test -f "$(WOLFSSL_DIR)/wolfssl/version.h"; then \ + wv=`sed -n 's/.*LIBWOLFSSL_VERSION_STRING[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$(WOLFSSL_DIR)/wolfssl/version.h"`; \ + fi; \ + if test -n "$$wv"; then \ + dep_args="$$dep_args --dep-version wolfssl=$$wv"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-wolfssl support, so the SBOM"; \ + echo " will not list wolfssl as a dependency component."; \ + fi; \ + fi; \ + if test "$(SBOM_DEP_OPENSSL)" = yes; then \ + if $(PYTHON3) "$(SBOM_GEN)" --help 2>/dev/null \ + | $(GREP) -q -- '--dep-openssl'; then \ + dep_args="$$dep_args --dep-openssl yes"; \ + if test -n "$(SBOM_OPENSSL_VERSION)"; then \ + dep_args="$$dep_args --dep-version openssl=$(SBOM_OPENSSL_VERSION)"; \ + fi; \ + else \ + echo "NOTE: this gen-sbom has no --dep-openssl support; openssl will"; \ + echo " not be listed as a dependency component."; \ + fi; \ + fi; \ + $(PYTHON3) "$(SBOM_GEN)" \ + --name $(SBOM_PKGNAME) \ + --version $(PACKAGE_VERSION) \ + --supplier "wolfSSL Inc." \ + --license-file $(SBOM_LICENSE_FILE) \ + --options-h "$$opts_h" \ + --lib "$$sbom_art" \ + $$dep_args \ + $(if $(SBOM_LICENSE_OVERRIDE),--license-override '$(SBOM_LICENSE_OVERRIDE)') \ + $(if $(SBOM_LICENSE_TEXT),--license-text '$(SBOM_LICENSE_TEXT)') \ + --cdx-out $(abs_builddir)/$(SBOM_CDX) \ + --spdx-out $(abs_builddir)/$(SBOM_SPDX); \ + $(PYSPDXTOOLS) --infile $(abs_builddir)/$(SBOM_SPDX) \ + --outfile $(abs_builddir)/$(SBOM_SPDX_TV) + +install-sbom: sbom + $(MKDIR_P) $(DESTDIR)$(sbomdir) + $(INSTALL_DATA) $(SBOM_CDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX) $(DESTDIR)$(sbomdir)/ + $(INSTALL_DATA) $(SBOM_SPDX_TV) $(DESTDIR)$(sbomdir)/ + +uninstall-sbom: + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_CDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX) + -rm -f $(DESTDIR)$(sbomdir)/$(SBOM_SPDX_TV) + +# SBOM install is intentionally opt-in (`make install-sbom`), so `make install` +# does NOT place SBOM files. uninstall-sbom is still chained into the standard +# `make uninstall` via uninstall-hook so a prior `make install-sbom` is cleaned +# up; it uses `rm -f`, so it is a harmless no-op when no SBOM was installed. +uninstall-hook: uninstall-sbom diff --git a/tests/test_gen_sbom.py b/tests/test_gen_sbom.py new file mode 100644 index 0000000..6b53396 --- /dev/null +++ b/tests/test_gen_sbom.py @@ -0,0 +1,2661 @@ +#!/usr/bin/env python3 +"""Unit tests for the helpers in share/gen-sbom. + +Run from the repo root: + + python3 -m unittest tests/test_gen_sbom.py + +These tests cover the pure logic in gen-sbom (license expression handling, +deterministic UUID derivation, SOURCE_DATE_EPOCH timestamp parsing). They +intentionally avoid touching the filesystem-heavy paths (sha256_file, +parse_options_h, pkg-config) which are exercised end-to-end by the +integration tests in .github/workflows/sbom.yml. +""" + +import importlib.util +import json +import os +import pathlib +import re +import tempfile +import unittest +import uuid +from datetime import datetime, timedelta, timezone +from importlib.machinery import SourceFileLoader + + +def _load_gen_sbom(): + """Load gen-sbom (no .py extension) as a module under the name 'gs'. + spec_from_file_location infers the loader from the suffix; gen-sbom has + none, so we hand it a SourceFileLoader explicitly.""" + here = pathlib.Path(__file__).resolve().parent + target = here.parent / 'share' / 'gen-sbom' + if not target.is_file(): + raise FileNotFoundError( + f"expected gen-sbom alongside this test file at {target}" + ) + loader = SourceFileLoader('gs', str(target)) + spec = importlib.util.spec_from_loader('gs', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +gs = _load_gen_sbom() + + +class TestIsSimpleSpdxId(unittest.TestCase): + def test_listed_ids_are_simple(self): + for spdx in ('Apache-2.0', 'MIT', 'GPL-3.0-or-later', + 'GPL-2.0-only', 'BSD-3-Clause', 'CC0-1.0', 'Zlib'): + self.assertTrue(gs.is_simple_spdx_id(spdx), + f"{spdx!r} should be simple") + + def test_license_refs_are_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('LicenseRef-wolfSSL-Commercial')) + self.assertFalse(gs.is_simple_spdx_id('LicenseRef-Foo')) + + def test_compound_expressions_are_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('GPL-3.0-only OR MIT')) + self.assertFalse(gs.is_simple_spdx_id( + 'Apache-2.0 AND LicenseRef-Foo')) + self.assertFalse(gs.is_simple_spdx_id('(MIT OR Apache-2.0)')) + + def test_noassertion_is_not_simple(self): + self.assertFalse(gs.is_simple_spdx_id('NOASSERTION')) + + +class TestExtractLicenseRefs(unittest.TestCase): + def test_no_refs(self): + self.assertEqual(gs.extract_license_refs('Apache-2.0'), []) + self.assertEqual(gs.extract_license_refs('GPL-3.0-only OR MIT'), []) + self.assertEqual(gs.extract_license_refs(''), []) + self.assertEqual(gs.extract_license_refs(None), []) + + def test_single_ref(self): + self.assertEqual( + gs.extract_license_refs('LicenseRef-X'), ['LicenseRef-X']) + self.assertEqual( + gs.extract_license_refs('LicenseRef-wolfSSL-Commercial'), + ['LicenseRef-wolfSSL-Commercial']) + + def test_multiple_refs_are_sorted_and_deduped(self): + self.assertEqual( + gs.extract_license_refs( + 'Apache-2.0 OR LicenseRef-B AND LicenseRef-A'), + ['LicenseRef-A', 'LicenseRef-B']) + self.assertEqual( + gs.extract_license_refs( + 'LicenseRef-X OR LicenseRef-X AND LicenseRef-X'), + ['LicenseRef-X']) + + +class TestCdxLicenseBlock(unittest.TestCase): + def test_listed_id_uses_id_form(self): + self.assertEqual( + gs.cdx_license_block('Apache-2.0', None), + [{'license': {'id': 'Apache-2.0'}}]) + self.assertEqual( + gs.cdx_license_block('GPL-3.0-or-later', None), + [{'license': {'id': 'GPL-3.0-or-later'}}]) + + def test_single_ref_with_text_uses_name_and_text(self): + block = gs.cdx_license_block('LicenseRef-Foo', 'BODY') + self.assertEqual(len(block), 1) + lic = block[0]['license'] + self.assertEqual(lic['name'], 'LicenseRef-Foo') + self.assertEqual(lic['text']['content'], 'BODY') + self.assertEqual(lic['text']['contentType'], 'text/plain') + self.assertNotIn('id', lic) + + def test_single_ref_without_text_omits_text_field(self): + block = gs.cdx_license_block('LicenseRef-Foo', None) + lic = block[0]['license'] + self.assertEqual(lic['name'], 'LicenseRef-Foo') + self.assertNotIn('text', lic) + + def test_compound_uses_expression(self): + # Per CDX 1.6 schema, compound SPDX expressions go into `expression`. + # We must NOT use `id` (only listed IDs allowed) nor `name` (single + # licence only). + self.assertEqual( + gs.cdx_license_block('GPL-3.0-only OR LicenseRef-Foo', 'X'), + [{'expression': 'GPL-3.0-only OR LicenseRef-Foo'}]) + self.assertEqual( + gs.cdx_license_block('GPL-3.0-only AND MIT', None), + [{'expression': 'GPL-3.0-only AND MIT'}]) + + def test_noassertion_uses_name_not_expression(self): + # NOASSERTION is a reserved SPDX literal, not a parseable SPDX + # expression - shoving it into `expression` makes some CDX + # validators choke when they try to parse it. + self.assertEqual( + gs.cdx_license_block('NOASSERTION', None), + [{'license': {'name': 'NOASSERTION'}}]) + self.assertEqual( + gs.cdx_license_block('NOASSERTION', 'ignored'), + [{'license': {'name': 'NOASSERTION'}}]) + + +class TestBuildExtractedLicensingInfos(unittest.TestCase): + def test_no_refs_returns_none(self): + self.assertIsNone( + gs.build_extracted_licensing_infos('Apache-2.0', None)) + self.assertIsNone( + gs.build_extracted_licensing_infos('GPL-3.0-only AND MIT', None)) + + def test_single_ref_with_text(self): + infos = gs.build_extracted_licensing_infos( + 'LicenseRef-wolfSSL-Commercial', 'BODY') + self.assertEqual(len(infos), 1) + self.assertEqual(infos[0]['licenseId'], + 'LicenseRef-wolfSSL-Commercial') + self.assertEqual(infos[0]['extractedText'], 'BODY') + self.assertIn('name', infos[0]) + + def test_placeholder_when_text_missing(self): + infos = gs.build_extracted_licensing_infos('LicenseRef-X', None) + self.assertEqual(len(infos), 1) + # Placeholder must mention how to fix it so reviewers/auditors who + # inspect the SBOM know what's wrong. + text = infos[0]['extractedText'] + self.assertIn('--license-text', text) + + def test_multiple_refs_each_get_entry(self): + infos = gs.build_extracted_licensing_infos( + 'LicenseRef-A OR LicenseRef-B', 'BODY') + self.assertEqual( + sorted(i['licenseId'] for i in infos), + ['LicenseRef-A', 'LicenseRef-B']) + for i in infos: + self.assertEqual(i['extractedText'], 'BODY') + + +class TestDerivedUuid(unittest.TestCase): + def test_deterministic(self): + a = gs.derived_uuid('wolfssl', '5.9.1', 'package') + b = gs.derived_uuid('wolfssl', '5.9.1', 'package') + self.assertEqual(a, b) + + def test_different_inputs_diverge(self): + self.assertNotEqual( + gs.derived_uuid('wolfssl', '5.9.1', 'package'), + gs.derived_uuid('wolfssl', '5.9.2', 'package')) + self.assertNotEqual( + gs.derived_uuid('wolfssl', '5.9.1', 'package'), + gs.derived_uuid('wolfssl', '5.9.1', 'serial')) + + def test_returns_valid_uuid_string(self): + s = gs.derived_uuid('a', 'b') + # Will raise if not a valid UUID. + parsed = uuid.UUID(s) + self.assertEqual(str(parsed), s) + + def test_separator_does_not_alias_inputs(self): + # If the helper joined parts on a printable character (e.g. '/'), + # then ('a/b', 'c') would collide with ('a', 'b/c'). NUL is not + # representable in any of the call-site inputs, so the join must + # be unambiguous. Regression guard for that contract. + self.assertNotEqual( + gs.derived_uuid('a/b', 'c'), + gs.derived_uuid('a', 'b/c')) + self.assertNotEqual( + gs.derived_uuid('a-b', 'c'), + gs.derived_uuid('a', 'b-c')) + + +class TestBuildTimestamp(unittest.TestCase): + def setUp(self): + self._saved = os.environ.get('SOURCE_DATE_EPOCH') + + def tearDown(self): + if self._saved is None: + os.environ.pop('SOURCE_DATE_EPOCH', None) + else: + os.environ['SOURCE_DATE_EPOCH'] = self._saved + + def test_honors_source_date_epoch(self): + os.environ['SOURCE_DATE_EPOCH'] = '1700000000' + dt, ts = gs.build_timestamp() + self.assertEqual(dt.year, 2023) + self.assertEqual(ts, '2023-11-14T22:13:20Z') + + def test_two_calls_with_same_sde_match(self): + os.environ['SOURCE_DATE_EPOCH'] = '1700000000' + _, t1 = gs.build_timestamp() + _, t2 = gs.build_timestamp() + self.assertEqual(t1, t2) + + def test_invalid_sde_falls_back_to_now(self): + os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' + dt, ts = gs.build_timestamp() + # Shape check. + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + # Freshness check: regression guard against a future change that + # accidentally hard-codes the fallback (e.g. epoch zero). Five + # seconds is generous for a unit test on slow runners. + self.assertLess( + abs(dt - datetime.now(tz=timezone.utc)), + timedelta(seconds=5)) + + def test_no_sde_is_current_utc(self): + os.environ.pop('SOURCE_DATE_EPOCH', None) + dt, ts = gs.build_timestamp() + self.assertRegex( + ts, r'\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z') + self.assertLess( + abs(dt - datetime.now(tz=timezone.utc)), + timedelta(seconds=5)) + + +class TestLoadLicenseText(unittest.TestCase): + def test_empty_path_returns_none(self): + self.assertIsNone(gs.load_license_text('')) + self.assertIsNone(gs.load_license_text(None)) + + def test_real_file(self): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write('LICENCE BODY\n') + path = f.name + try: + self.assertEqual(gs.load_license_text(path), 'LICENCE BODY\n') + finally: + os.unlink(path) + + def test_missing_file_exits(self): + with self.assertRaises(SystemExit): + gs.load_license_text('/no/such/path/please.txt') + + +class TestDetectLicense(unittest.TestCase): + """detect_license decides the SPDX licenseConcluded / licenseDeclared + that wolfSSL's SBOM advertises. A regression here silently flips + the licence obligations a downstream integrator parses out of the + SBOM (e.g. GPLv2-or-later misreported as GPLv2-only narrows the + permitted upgrade path; GPLv3 misreported as GPLv2 entirely + misstates compatibility with GPLv2-only third-party code). + + Independent oracle: the SPDX licence-list short identifiers + (https://spdx.org/licenses/), determined for each fixture by + reading the GPL version stated in the prose and whether 'or later' + / 'or any later version' wording appears within 100 characters of + the version mention. No fixture is round-tripped through + detect_license to derive its own oracle.""" + + def _detect(self, body): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write(body) + path = f.name + try: + return gs.detect_license(path) + finally: + os.unlink(path) + + def test_gplv2_only(self): + # Prose mentions GPLv2 with no 'or later' clause. Oracle: + # SPDX 'GPL-2.0-only'. + self.assertEqual( + self._detect( + 'This program is licensed under the GNU General Public ' + 'License version 2.\n' + 'See COPYING for the full text.\n'), + 'GPL-2.0-only') + + def test_gplv2_or_later_any_form(self): + # 'or any later' immediately after the version mention. + # Oracle: SPDX 'GPL-2.0-or-later'. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 2, ' + 'or any later version.\n'), + 'GPL-2.0-or-later') + + def test_gplv2_or_later_canonical_fsf_preamble(self): + # The canonical FSF GPL preamble phrase, used verbatim in + # millions of upstream COPYING files: + # + # 'either version N of the License, or (at your option) + # any later version.' + # + # An earlier regex (`or\s+(any\s+)?later`) failed to match + # this because the parenthetical '(at your option)' + # interjects between 'or' and 'any later', so wolfssl-1zj.24 + # silently mis-detected the preamble as GPLv2-only. Oracle: + # SPDX 'GPL-2.0-or-later'. + self.assertEqual( + self._detect( + 'This program is free software: you can redistribute it ' + 'and/or modify it under the terms of the GNU General ' + 'Public License version 2, or (at your option) any ' + 'later version.\n'), + 'GPL-2.0-or-later') + + def test_gplv3_or_later_canonical_fsf_preamble(self): + # Same regression guard for GPLv3. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 3, ' + 'or (at your option) any later version.\n'), + 'GPL-3.0-or-later') + + def test_gplv2_or_later_short_form(self): + # 'or later' (without 'any') also matches the regex; this + # variant appears in some upstream COPYING files. Oracle: + # 'GPL-2.0-or-later'. + self.assertEqual( + self._detect( + 'Licensed under the GNU General Public License version 2 ' + 'or later.\n'), + 'GPL-2.0-or-later') + + def test_gplv3_only(self): + self.assertEqual( + self._detect( + 'Released under the terms of the GNU General Public ' + 'License version 3.\n'), + 'GPL-3.0-only') + + def test_gplv3_or_later(self): + self.assertEqual( + self._detect( + 'Released under the terms of the GNU General Public ' + 'License version 3, or any later version.\n'), + 'GPL-3.0-or-later') + + def test_gplv3_abbreviation_only(self): + # LICENSING that uses only the "GPLv3" abbreviation, with no + # canonical "GNU General Public License version 3" long form. + # This is exactly wolfSSH's LICENSING shape, which previously + # fell back to NOASSERTION. Oracle: 'GPL-3.0-only'. + self.assertEqual( + self._detect( + 'wolfExample is either licensed for use under the GPLv3 ' + 'or a standard commercial license.\n'), + 'GPL-3.0-only') + + def test_gplv2_abbreviation_only(self): + # Same abbreviation path for version 2. Oracle: 'GPL-2.0-only'. + self.assertEqual( + self._detect('Distributed under the GPLv2.\n'), + 'GPL-2.0-only') + + def test_gplv3_plus_abbreviation_is_or_later(self): + # The "+" suffix on the abbreviated form (GPLv3+) denotes the + # or-later variant. Oracle: 'GPL-3.0-or-later'. + self.assertEqual( + self._detect('Licensed under GPLv3+ terms.\n'), + 'GPL-3.0-or-later') + + def test_gplv2_abbreviation_or_later_prose(self): + # Abbreviated form followed by an explicit "or later" clause in + # prose (no "+") also promotes to or-later. Oracle: + # 'GPL-2.0-or-later'. + self.assertEqual( + self._detect('Available under GPLv2 or later.\n'), + 'GPL-2.0-or-later') + + def test_real_wolfssh_licensing_shape_is_gpl3_only(self): + # Regression guard for the exact wolfSSH LICENSING wording: the + # "or a standard commercial license" clause after the GPLv3 + # abbreviation must NOT be mistaken for an "or later" grant. + self.assertEqual( + self._detect( + '\nwolfSSH is either licensed for use under the GPLv3 or a ' + 'standard commercial\nlicense. For our users who cannot use ' + 'wolfSSH under GPLv3, a commercial license\nto wolfSSH is ' + 'available.\n'), + 'GPL-3.0-only') + + def test_case_insensitive(self): + # The regex is case-insensitive for both the GPL header line + # and the 'or later' clause. Real-world COPYING files use + # mixed cases ('GNU GENERAL PUBLIC LICENSE Version 2'); a + # case-sensitive regression here would silently emit None. + self.assertEqual( + self._detect( + 'GNU GENERAL PUBLIC LICENSE Version 2\n' + 'Licensee may redistribute under GPLv2 OR LATER.\n'), + 'GPL-2.0-or-later') + + def test_or_later_outside_100_byte_excerpt_does_not_match(self): + # The 'or later' search is bounded to the 100 chars + # immediately following the version mention. An 'or later' + # phrase appearing in unrelated boilerplate further down the + # file MUST NOT promote a GPLv2-only declaration to + # GPLv2-or-later. This is the regression Mark called out in + # the review: "someone reworks the regex ... and breaks the + # GPLv2-or-later detection." + body = ( + 'Licensed under the GNU General Public License version 2.\n' + + ('Filler not relevant to the license clause. ' * 5) + + '\nMay be useful or later modified by users.\n' + ) + self.assertEqual(self._detect(body), 'GPL-2.0-only') + + def test_no_gpl_mention_returns_none_with_warning(self): + import io, contextlib + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = self._detect( + 'Copyright (c) 2026 Example Corp.\n' + 'Licensed under the MIT License.\n' + 'Permission is hereby granted, free of charge, ...\n') + self.assertIsNone(result) + # Warning must mention the file path so an operator running + # `make sbom` can see which file was unparsable. + self.assertIn('no GPL version found', stderr.getvalue()) + + def test_missing_file_returns_none_with_warning(self): + import io, contextlib + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = gs.detect_license('/no/such/license/please.txt') + self.assertIsNone(result) + self.assertIn('cannot read license file', stderr.getvalue()) + + def test_real_wolfssl_licensing_is_gpl3_only(self): + # Regression guard, not an oracle: lock down the SPDX ID that + # the shipped LICENSING file produces today. If wolfSSL ever + # changes the headline licence in LICENSING, this test must + # be updated in the same commit so the SBOM emission change + # does not slip in unreviewed. The "version 3" mention is + # first in the file; the 100-char excerpt that follows is + # `(\u201cGPLv3\u201d) with\nthe following exception: ...`, + # which contains no 'or later' clause - hence GPL-3.0-only. + here = pathlib.Path(__file__).resolve().parent.parent + licensing = here / 'LICENSING' + if not licensing.is_file(): + self.skipTest(f'LICENSING fixture not found at {licensing}') + self.assertEqual( + gs.detect_license(str(licensing)), 'GPL-3.0-only', + 'real wolfSSL LICENSING no longer maps to GPL-3.0-only; ' + 'update this regression guard and audit the SBOM ' + 'licenseConcluded / licenseDeclared change') + + +class TestSha256File(unittest.TestCase): + def test_real_file_hashes_to_known_value(self): + # Empty file's SHA-256 is well-known; sanity-checks the chunked + # read path produces the same digest as a one-shot hash. + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + path = f.name + try: + empty_sha256 = ('e3b0c44298fc1c149afbf4c8996fb924' + '27ae41e4649b934ca495991b7852b855') + self.assertEqual(gs.sha256_file(path), empty_sha256) + finally: + os.unlink(path) + + def test_missing_file_exits_cleanly(self): + # Regression guard: gen-sbom must surface a missing --lib path as + # a clean non-zero exit, not an unhandled OSError, so `make sbom` + # fails fast with a useful message instead of a Python traceback. + with self.assertRaises(SystemExit): + gs.sha256_file('/no/such/library/please.so') + + def test_chunked_read_path_matches_one_shot(self): + # The chunked iter(f.read(65536), b'') path in sha256_file is + # what runs for the real wolfSSL library (.so/.a, multi-MB). + # The empty-file vector above never executes the loop body at + # all (size=0). An off-by-one or chunk-boundary regression + # would slip through unless we exercise a buffer that crosses + # the 65536-byte boundary. Independent oracle: hashlib's + # one-shot hash on the same bytes. + import hashlib + body = (b'A' * 70000) + (b'B' * 1000) + b'tail' + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(body) + path = f.name + try: + expected = hashlib.sha256(body).hexdigest() + self.assertEqual(gs.sha256_file(path), expected) + finally: + os.unlink(path) + + +class TestParseOptionsH(unittest.TestCase): + def _parse(self, body): + with tempfile.NamedTemporaryFile('w', suffix='.h', + delete=False) as f: + f.write(body) + path = f.name + try: + return gs.parse_options_h(path) + finally: + os.unlink(path) + + def test_parses_defines_sorted_and_deduped(self): + pairs = self._parse( + "/* fake options.h */\n" + "#define HAVE_BAR\n" + "#define HAVE_AAA 1\n" + "#define HAVE_FOO 42\n" + ) + names = [k for k, _ in pairs] + self.assertEqual(names, sorted(set(names))) + self.assertEqual(dict(pairs)['HAVE_AAA'], '1') + self.assertEqual(dict(pairs)['HAVE_FOO'], '42') + self.assertEqual(dict(pairs)['HAVE_BAR'], '') + + def test_strips_trailing_block_comment(self): + # Regression: an earlier version captured the comment text into + # the value, polluting the SBOM build properties. + pairs = dict(self._parse("#define HAVE_FOO 42 /* always */\n")) + self.assertEqual(pairs['HAVE_FOO'], '42') + + def test_strips_trailing_line_comment(self): + pairs = dict(self._parse("#define HAVE_FOO 42 // always\n")) + self.assertEqual(pairs['HAVE_FOO'], '42') + + def test_strips_comment_from_valueless_define(self): + pairs = dict(self._parse("#define HAVE_BAR /* set elsewhere */\n")) + self.assertEqual(pairs['HAVE_BAR'], '') + + def test_preserves_url_in_string_literal(self): + # Regression guard: an earlier comment-stripper used + # `re.split(r'/\*|//', raw, maxsplit=1)[0]`, which truncated + # autoconf-generated PACKAGE_URL / PACKAGE_BUGREPORT defines + # at the first `//` inside the URL. Both ended up as + # `"https:` in the SBOM build properties, falsely showing + # PACKAGE_URL drifting between releases when nothing changed. + pairs = dict(self._parse( + '#define PACKAGE_URL "https://www.wolfssl.com"\n' + '#define PACKAGE_BUGREPORT ' + '"https://github.com/wolfssl/wolfssl/issues"\n' + )) + self.assertEqual(pairs['PACKAGE_URL'], + '"https://www.wolfssl.com"') + self.assertEqual(pairs['PACKAGE_BUGREPORT'], + '"https://github.com/wolfssl/wolfssl/issues"') + + def test_strips_comment_after_string_literal(self): + # Companion to test_preserves_url_in_string_literal: confirm + # the stripper still works when a comment legitimately follows + # a string literal. A regression that disabled stripping + # entirely (the simplest "fix" for the URL bug) would let + # comment text leak into the SBOM. + pairs = dict(self._parse( + '#define PACKAGE_URL "https://www.wolfssl.com" /* upstream */\n' + )) + self.assertEqual(pairs['PACKAGE_URL'], + '"https://www.wolfssl.com"') + + def test_preserves_block_comment_inside_string_literal(self): + # `/*` inside a string literal must not start a comment. + pairs = dict(self._parse('#define WEIRD "a/*b*/c"\n')) + self.assertEqual(pairs['WEIRD'], '"a/*b*/c"') + + def test_handles_escaped_quote_in_string_literal(self): + # An escaped `\"` inside a string literal must not be mistaken + # for the closing quote; otherwise a comment-marker that + # follows would be incorrectly treated as outside the string. + pairs = dict(self._parse( + '#define EMBEDDED_QUOTE "a\\"b//c" /* tail */\n')) + self.assertEqual(pairs['EMBEDDED_QUOTE'], '"a\\"b//c"') + + def test_dedup_keeps_last_assignment(self): + # Last assignment wins (matches C preprocessor semantics for + # duplicate #defines after redefinition). + pairs = dict(self._parse( + "#define HAVE_X 1\n" + "#define HAVE_X 2\n" + )) + self.assertEqual(pairs['HAVE_X'], '2') + + def test_filters_compiler_internals_from_dm_e_dump(self): + # The no-pcpp escape hatch (`$CC -dM -E -include settings.h ...`) + # produces a defines file containing hundreds of host/compiler + # macros - on macOS it includes the entire Apple + # TargetConditionals family, on Linux it includes __GLIBC_*, + # everywhere it includes the C compiler's __INT_*_MAX__ / + # __SSE*__ / __VERSION__ family. parse_options_h must drop them + # so the SBOM reflects wolfSSL configuration, not the build + # host, and is reproducible across hosts. + pairs = dict(self._parse( + "/* simulated `clang -dM -E` dump on macOS */\n" + "#define __VERSION__ \"Homebrew Clang 21.1.4\"\n" + "#define __APPLE__ 1\n" + "#define __MACH__ 1\n" + "#define __SSE2__ 1\n" + "#define __INT_FAST32_MAX__ 2147483647\n" + "#define __clang_major__ 21\n" + "#define _LP64 1\n" + "#define TARGET_OS_MAC 1\n" + "#define TARGET_OS_OSX 1\n" + "#define TARGET_OS_LINUX 0\n" + "#define TARGET_IPHONE_SIMULATOR 0\n" + "#define WOLFSSL_OPTIONS_H\n" + "#define WOLF_CRYPT_SETTINGS_H 1\n" + "#define HAVE_AESGCM 1\n" + "#define NO_DES3 1\n" + "#define WOLFSSL_AES_256 1\n" + )) + self.assertEqual( + set(pairs), {'HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256'}, + 'noise filter let host/compiler macros leak into SBOM') + + def test_real_options_h_template_is_only_a_header_guard(self): + # Sanity-check that the noise filter handles wolfSSL's own + # autotools options.h.in: today the template defines exactly + # one macro - the WOLFSSL_OPTIONS_H header guard - which the + # filter must drop. If a future change adds a non-guard macro + # to options.h.in, this test makes the filter audit explicit. + here = pathlib.Path(__file__).resolve().parent.parent + template = here / 'wolfssl' / 'options.h.in' + if not template.is_file(): + self.skipTest(f'options.h.in fixture not found at {template}') + body = template.read_text() + names = re.findall(r'^#define[ \t]+(\w+)', body, re.MULTILINE) + self.assertIn('WOLFSSL_OPTIONS_H', names, + 'options.h.in unexpectedly missing its header guard') + for name in names: + self.assertTrue( + gs._is_noise_macro(name), + f'options.h.in defines {name!r} but the noise filter does ' + 'not drop it; either the filter needs widening or ' + 'options.h.in now contains a real config macro') + + def test_real_options_h_preserves_autoconf_have_probes(self): + # An autotools-generated wolfssl/options.h (post-./configure) + # contains both the WOLFSSL_OPTIONS_H header guard (filtered) + # and AC_CHECK_HEADER probe results like WOLFSSL_HAVE_ATOMIC_H + # / WOLFSSL_HAVE_ASSERT_H (must be preserved - they gate + # `#if defined(...)` branches in wc_port.h and types.h). + here = pathlib.Path(__file__).resolve().parent.parent + options_h = here / 'wolfssl' / 'options.h' + if not options_h.is_file(): + self.skipTest( + f'no built options.h at {options_h}; run ./configure first') + names = {k for k, _ in gs.parse_options_h(str(options_h))} + # WOLFSSL_OPTIONS_H is the header guard for options.h itself + # and must be filtered out. + self.assertNotIn( + 'WOLFSSL_OPTIONS_H', names, + 'header guard leaked through into SBOM build properties') + # The autoconf-detected header-availability flags must survive + # the filter (regression guard - see + # TestIsNoiseMacro.test_autoconf_have_header_probes_preserved). + for cflag in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H'): + if cflag in re.findall(r'^#define[ \t]+(\w+)', + options_h.read_text(), re.MULTILINE): + self.assertIn( + cflag, names, + f'{cflag!r} (AC_CHECK_HEADER probe result) was ' + 'incorrectly dropped by the noise filter') + + +class TestIsNoiseMacro(unittest.TestCase): + """The shared filter that keeps build-environment artefacts out of + the SBOM `wolfssl:build:*` properties. Drives both parse_options_h + (no-pcpp / autotools) and parse_user_settings (pcpp embedded) to + the same wolfSSL-only build-property set so the no-pcpp + `$CC -dM -E` shortcut does not produce host-leaking, non- + reproducible-across-hosts SBOMs. + + The three macro families this guards against (compiler-reserved, + Apple TargetConditionals, header guards) are documented in + `_NOISE_MACRO_RE` in gen-sbom; the assertions below pin each one, + plus the `_CONFIG_H_TOKENS` carve-out that keeps `*_H`-suffixed + real configuration flags out of the header-guard branch.""" + + def test_compiler_reserved_double_underscore(self): + # `__*` is reserved-for-implementation per ISO C 7.1.3 and is + # the bulk of what `clang -dM -E` emits. Dropping these is + # what stops `__VERSION__: "Homebrew Clang 21.1.4"` from + # leaking the developer's laptop into the public SBOM. + for name in ('__VERSION__', '__SSE2__', '__INT_FAST32_MAX__', + '__APPLE__', '__MACH__', '__amd64__', + '__GCC_ATOMIC_BOOL_LOCK_FREE', + '__clang_major__', '__BLOCKS__', + '__OBJC_BOOL_IS_BOOL', '__SIZEOF_LONG__', + '__LDBL_DIG__', '__FLT_RADIX__'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} should be filtered') + + def test_compiler_reserved_single_underscore_uppercase(self): + # ISO C 7.1.3 also reserves `_` + uppercase for the + # implementation; e.g. macOS clang emits `_LP64`, glibc emits + # `_FORTIFY_SOURCE`. Same rationale as `__*`. + for name in ('_LP64', '_FORTIFY_SOURCE', '_LARGEFILE_SOURCE', + '_GNU_SOURCE'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} should be filtered') + + def test_apple_target_conditionals_filtered(self): + # `clang -include settings.h -x c /dev/null` on macOS pulls in + # ; without this filter a wolfSSL SBOM + # for an STM32 firmware would falsely show TARGET_OS_MAC=1 + # when generated on a Mac, mis-identifying the target platform + # to a CRA reviewer. + for name in ('TARGET_OS_MAC', 'TARGET_OS_OSX', 'TARGET_OS_LINUX', + 'TARGET_OS_IOS', 'TARGET_OS_EMBEDDED', + 'TARGET_OS_WIN32', 'TARGET_OS_WINDOWS', + 'TARGET_IPHONE_SIMULATOR'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} (Apple TargetConditionals) should be ' + 'filtered') + + def test_header_guards_filtered(self): + # Both wolfssl/options.h itself and several internal wolfSSL + # headers define `WOLFSSL_*_H` / `WOLF_CRYPT_*_H` guards to + # prevent double inclusion. These describe "which file was + # parsed", not configuration choices. + for name in ('WOLF_CRYPT_SETTINGS_H', 'WOLFSSL_OPTIONS_H', + 'WOLF_CRYPT_VISIBILITY_H', 'WOLFSSL_USER_SETTINGS_H'): + self.assertTrue(gs._is_noise_macro(name), + f'{name!r} (header guard) should be filtered') + + def test_autoconf_have_header_probes_preserved(self): + # Regression guard: the `_H$` filter must NOT swallow + # AC_CHECK_HEADER results from configure.ac. These live on the + # wolfSSL CFLAGS as `-DWOLFSSL_HAVE_ATOMIC_H` / + # `-DWOLFSSL_HAVE_ASSERT_H`, gate `#if defined(...)` branches in + # wc_port.h / types.h, and so are real configuration flags an + # auditor or vulnerability scanner needs to see in the SBOM. + for name in ('WOLFSSL_HAVE_ATOMIC_H', 'WOLFSSL_HAVE_ASSERT_H', + 'WOLFSSL_HAVE_MLKEM_H', 'HAVE_STDINT_H', + 'HAVE_SYS_TYPES_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (autoconf AC_CHECK_HEADER probe) must NOT be ' + 'filtered - it is real configuration that gates source ' + 'code branches') + + def test_no_h_suffixed_disablement_flags_preserved(self): + # Regression guard for the carve-out specifically. These flags + # are set by NETOS / Telit / WOLFSSL_TELIT_M2MB / similar RTOS + # profiles in wolfssl/wolfcrypt/settings.h to suppress stdlib + # header inclusion (the firmware ships with vendor stdlib + # replacements). They gate real `#if defined(...)` branches: + # + # types.h:398 `#ifndef NO_STDINT_H` + # settings.h:3850 `#ifndef NO_STDINT_H` + # sp.h:42 `#elif !defined(NO_STDINT_H)` + # types.h:2132 `#if !defined(WOLFSSL_NO_ASSERT_H) && ...` + # + # An embedded customer who builds against one of these profiles + # would otherwise get an SBOM that silently omits their + # stdlib-disablement choices - the exact evidence a CRA reviewer + # expects to see. + for name in ('NO_STDINT_H', 'NO_STDLIB_H', 'NO_LIMITS_H', + 'NO_CTYPE_H', 'NO_STRING_H', 'NO_STDDEF_H', + 'WOLFSSL_NO_ASSERT_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (NO_*_H disablement flag) must NOT be ' + 'filtered - it gates real wolfSSL source branches') + + def test_use_h_suffixed_build_mode_flags_preserved(self): + # Regression guard for the `USE_` carve-out token. Gates the + # flat-vs-tree test/benchmark layout in test.c:165 / + # benchmark.c:219 / examples/server/server.c:70. Customers who + # vendor these example sources select the layout via a `_H`- + # suffixed flag, so it must survive the filter. + for name in ('USE_FLAT_TEST_H', 'USE_FLAT_BENCHMARK_H'): + self.assertFalse( + gs._is_noise_macro(name), + f'{name!r} (USE_*_H build-mode toggle) must NOT be ' + 'filtered - it gates real wolfSSL source branches') + + def test_real_wolfssl_macros_pass_through(self): + # The whole point of filtering is to NOT touch real wolfSSL + # configuration. If any of these get filtered the SBOM loses + # auditor-visible build properties that distinguish one + # wolfSSL configuration from another. + for name in ('HAVE_AESGCM', 'NO_DES3', 'WOLFSSL_AES_256', + 'WOLFSSL_USER_SETTINGS', 'WC_RSA_BLINDING', + 'TFM_ECC256', 'OPENSSL_EXTRA', 'USE_FAST_MATH', + 'XTIME', 'CUSTOM_RAND_GENERATE', 'FP_MAX_BITS', + 'BENCH_EMBEDDED', 'SIZEOF_LONG_LONG', + 'WOLFSSL_SP_NO_DYN_STACK', 'WOLFSSL_SHA512', + 'NO_FILESYSTEM', 'SINGLE_THREADED'): + self.assertFalse(gs._is_noise_macro(name), + f'{name!r} (real wolfSSL config) should NOT be ' + 'filtered') + + +class TestDepMetaShape(unittest.TestCase): + """Lock down the dep-tracking surface so renames/removals don't + silently regress vulnerability-scanner identifiers in the SBOM. + + These guard against: + * an external dep being added without a CVE-resolvable identifier + * a future PR re-introducing the `falcon`/`libxmss`/`liblms` + keys after they were intentionally removed.""" + + def test_only_expected_deps_are_tracked(self): + # wolfssl is tracked so downstream wolfSSL-stack products (wolfSSH, + # wolfMQTT, ...) can declare it via --dep-wolfssl; openssl so the + # OpenSSL-compat products (wolfProvider, wolfEngine) can declare it via + # --dep-openssl; libz/liboqs are wolfSSL's own optional linked deps. + self.assertEqual(set(gs.DEP_META.keys()), + {'wolfssl', 'openssl', 'libz', 'liboqs'}) + + def test_wolfssl_dep_entry_describes_the_linked_artefact(self): + wolfssl = gs.DEP_META['wolfssl'] + self.assertEqual(wolfssl['name'], 'wolfssl') + self.assertEqual(wolfssl['supplier'], 'wolfSSL Inc.') + self.assertEqual(wolfssl['pkgconfig'], 'wolfssl') + # wolfSSL ships under GPLv3 (LICENSING: "version 3 (GPLv3)", no + # "or later"); the dependency entry must match what + # 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') + self.assertEqual( + wolfssl['purl']('5.7.4'), + 'pkg:github/wolfSSL/wolfssl@v5.7.4') + + def test_openssl_dep_entry_describes_the_linked_artefact(self): + openssl = gs.DEP_META['openssl'] + self.assertEqual(openssl['name'], 'openssl') + self.assertEqual(openssl['supplier'], 'OpenSSL Software Foundation') + self.assertEqual(openssl['pkgconfig'], 'openssl') + # wolfProvider / wolfEngine target the OpenSSL 3.x provider/engine ABI, + # which is Apache-2.0. The purl uses OpenSSL 3.x's "openssl-X.Y.Z" git + # tag form so it resolves in OSV / GHSA. + self.assertEqual(openssl['license'], 'Apache-2.0') + self.assertEqual( + openssl['purl']('3.5.0'), + 'pkg:github/openssl/openssl@openssl-3.5.0') + + def test_liboqs_entry_describes_the_linked_artefact(self): + liboqs = gs.DEP_META['liboqs'] + self.assertEqual(liboqs['name'], 'liboqs') + self.assertEqual(liboqs['supplier'], 'Open Quantum Safe') + self.assertEqual(liboqs['pkgconfig'], 'liboqs') + self.assertEqual( + liboqs['purl']('0.10.0'), + 'pkg:github/open-quantum-safe/liboqs@0.10.0') + + def test_no_stale_dep_keys(self): + # `falcon` is an algorithm, not a linked package; it must not + # appear as a dep entry (algorithm enablement lives in + # build_props parsed from options.h). `libxmss` and `liblms` + # were removed upstream; their re-appearance here would + # silently emit unresolvable identifiers in the SBOM. + for stale in ('falcon', 'libxmss', 'liblms', 'xmss', 'lms'): + self.assertNotIn(stale, gs.DEP_META) + + +class TestEnabledDepsCli(unittest.TestCase): + """End-to-end test of the argparse plumbing for --dep-* flags. + + Runs gen-sbom in a child process so we exercise the real argparse + config rather than a re-imported module.""" + + def _run(self, *argv): + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = here.parent / 'share' / 'gen-sbom' + return subprocess.run( + ['python3', str(script), *argv], + capture_output=True, text=True + ) + + def test_dep_liboqs_is_accepted(self): + result = self._run('--help') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--dep-liboqs', result.stdout) + self.assertIn('--dep-libz', result.stdout) + self.assertIn('--dep-wolfssl', result.stdout) + self.assertIn('--dep-openssl', result.stdout) + + def test_removed_flags_are_rejected(self): + # Each of these was either renamed (--dep-falcon -> --dep-liboqs) + # or removed entirely (--dep-libxmss/--dep-liblms with upstream + # removal of the libraries). argparse should reject them as + # unrecognised, not silently accept them. We pass the full set + # of required args (against /dev/null sentinels) so argparse + # progresses to the unknown-flag check; we never want + # gen-sbom to actually generate anything in this test. + required = [ + '--name', 'wolfssl', + '--version', '0.0.0-test', + '--lib', '/dev/null', + '--license-file', '/dev/null', + '--options-h', '/dev/null', + '--cdx-out', '/dev/null', + '--spdx-out', '/dev/null', + ] + for stale_flag in ('--dep-falcon', '--dep-libxmss', '--dep-liblms', + '--dep-libxmss-root', '--dep-liblms-root', + '--git'): + result = self._run(*required, stale_flag, 'no') + self.assertNotEqual(result.returncode, 0, + f"{stale_flag!r} unexpectedly accepted") + self.assertIn('unrecognized arguments', result.stderr, + f"{stale_flag!r}: {result.stderr!r}") + + +class TestGitoidBlobSha256(unittest.TestCase): + """The OmniBOR / git gitoid is content-addressed, well-specified, and + independently verifiable. These vectors anchor our implementation + against the canonical values so a future refactor (e.g. switching + chunked I/O strategy) cannot silently drift.""" + + EMPTY_OID = ('473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749' + '120a303721813') + HELLO_OID = ('8aec4e4876f854f688d0ebfc8f37598f38e5fd6903cccc850ca' + '36591175aeb60') + + def test_empty_blob_matches_canonical_oid(self): + # The well-known SHA-256 gitoid for an empty blob - matches + # `git hash-object --object-format=sha256 /dev/null`. + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + path = f.name + try: + self.assertEqual(gs.gitoid_blob_sha256(path), self.EMPTY_OID) + finally: + os.unlink(path) + + def test_hello_matches_canonical_oid(self): + # `git hash-object --object-format=sha256` on a 5-byte 'hello' + # blob; equivalently sha256(b'blob 5\x00hello'). + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(b'hello') + path = f.name + try: + self.assertEqual(gs.gitoid_blob_sha256(path), self.HELLO_OID) + finally: + os.unlink(path) + + def test_chunked_read_path_matches_one_shot(self): + # The chunked iter-read path is what hashes large source files + # in real builds; this guards against any off-by-one in the + # 65536-byte chunk handling. + import hashlib + body = (b'A' * 70000) + (b'B' * 1000) + b'tail' + with tempfile.NamedTemporaryFile('wb', delete=False) as f: + f.write(body) + path = f.name + try: + expected = hashlib.sha256( + f'blob {len(body)}\x00'.encode() + body).hexdigest() + self.assertEqual(gs.gitoid_blob_sha256(path), expected) + finally: + os.unlink(path) + + def test_missing_file_exits_cleanly(self): + with self.assertRaises(SystemExit): + gs.gitoid_blob_sha256('/no/such/source/please.c') + + +class TestSrcsMerkleHash(unittest.TestCase): + """Source-set Merkle hash is the embedded entry point's component + checksum. Two contracts matter here: + + 1. Order independence: two customers compiling the same files in + any order get the same hash. Without this, the SBOM would + not be portable across build systems with non-deterministic + file ordering. + 2. Content sensitivity: a one-byte change in any source file + must change the hash. Without this, the checksum would + not detect a tampered build.""" + + def _make_files(self, files): + """files: dict of basename -> bytes contents. + Returns (tmpdir, list_of_paths).""" + import tempfile + tmpdir = tempfile.mkdtemp() + paths = [] + for name, contents in files.items(): + p = os.path.join(tmpdir, name) + with open(p, 'wb') as f: + f.write(contents) + paths.append(p) + return tmpdir, paths + + def test_order_independent(self): + import shutil + tmpdir, paths = self._make_files({ + 'aes.c': b'aes-body', + 'sha.c': b'sha-body', + 'dh.c': b'dh-body', + }) + try: + h1 = gs.srcs_merkle_hash(paths) + h2 = gs.srcs_merkle_hash(list(reversed(paths))) + h3 = gs.srcs_merkle_hash(sorted(paths)) + self.assertEqual(h1, h2) + self.assertEqual(h1, h3) + finally: + shutil.rmtree(tmpdir) + + def test_content_change_changes_hash(self): + import shutil + tmpdir, paths = self._make_files({ + 'aes.c': b'aes-body', + 'sha.c': b'sha-body', + }) + try: + h_before = gs.srcs_merkle_hash(paths) + with open(paths[0], 'ab') as f: + f.write(b'X') + h_after = gs.srcs_merkle_hash(paths) + self.assertNotEqual(h_before, h_after) + finally: + shutil.rmtree(tmpdir) + + def test_basename_only_means_path_independent(self): + """The Merkle hash deliberately uses basename only, not full + path, so two customers whose wolfSSL trees live at different + absolute paths get the same hash for the same release.""" + import shutil + td_a, paths_a = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'}) + td_b, paths_b = self._make_files({'aes.c': b'aes', 'sha.c': b'sha'}) + try: + self.assertEqual( + gs.srcs_merkle_hash(paths_a), + gs.srcs_merkle_hash(paths_b)) + finally: + shutil.rmtree(td_a) + shutil.rmtree(td_b) + + def test_missing_file_exits_cleanly(self): + # Mirrors TestGitoidBlobSha256.test_missing_file_exits_cleanly: + # silently emitting an SBOM with a stale or zero hash for a + # missing source would falsify the artefact, so srcs_merkle_hash + # must propagate the underlying gitoid_blob_sha256 SystemExit. + with self.assertRaises(SystemExit): + gs.srcs_merkle_hash(['/no/such/source/please.c']) + + def test_duplicate_basenames_rejected(self): + # Order independence relies on unique basenames - if two source + # files in the input collided on basename, sorting on basename + # would suppress one of them and we would silently lose data. + # gen-sbom must reject the configuration rather than emit a + # misleading hash. + import shutil, tempfile + td_a = tempfile.mkdtemp() + td_b = tempfile.mkdtemp() + try: + with open(os.path.join(td_a, 'aes.c'), 'wb') as f: + f.write(b'a') + with open(os.path.join(td_b, 'aes.c'), 'wb') as f: + f.write(b'b') + with self.assertRaises(SystemExit): + gs.srcs_merkle_hash([ + os.path.join(td_a, 'aes.c'), + os.path.join(td_b, 'aes.c'), + ]) + finally: + shutil.rmtree(td_a) + shutil.rmtree(td_b) + + +class TestParseUserSettings(unittest.TestCase): + """Walks a synthetic settings.h + user_settings.h pair through + parse_user_settings() to confirm: + * the conditional logic in settings.h is honoured (only the + taken branch's defines reach the SBOM); + * pcpp-internal macros (__DATE__/__TIME__/__FILE__/__PCPP__) are + filtered out (otherwise reproducibility would break); + * function-like macros are filtered out (they are API surface, + not build configuration); + * --user-settings-define KEY=VALUE predefines reach the parser. + + pcpp is a hard prerequisite for these tests, not optional. An + earlier revision called self.skipTest on missing pcpp; CI ran the + suite without pcpp installed and silently skipped all of these + cases, leaving the embedded entry point unverified at the very + gate intended to verify it (see review finding wolfssl-1zj.14). + Now the setUp fails loud with an actionable message.""" + + def setUp(self): + try: + import pcpp # noqa: F401 + except ImportError: + self.fail( + 'pcpp is not installed but is required to test the ' + 'standalone embedded entry point ' + '(parse_user_settings). Install with: ' + "'python3 -m pip install --user pcpp'. CI installs " + 'this in the unit job; see .github/workflows/sbom.yml.') + + def _run(self, settings_body, user_body, predefines=()): + import shutil, tempfile + tmpdir = tempfile.mkdtemp() + try: + settings_h = os.path.join(tmpdir, 'settings.h') + user_h = os.path.join(tmpdir, 'user_settings.h') + with open(settings_h, 'w') as f: + f.write(settings_body) + with open(user_h, 'w') as f: + f.write(user_body) + return gs.parse_user_settings( + settings_h, [tmpdir], list(predefines)) + finally: + shutil.rmtree(tmpdir) + + def test_conditional_branches_honoured(self): + # Customer's user_settings.h enables HAVE_X; settings.h then + # gates HAVE_DEPENDENT on HAVE_X. Disabled-branch defines + # must NOT appear. + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + '#ifdef HAVE_X\n' + '#define HAVE_DEPENDENT 1\n' + '#else\n' + '#define HAVE_DISABLED_BRANCH 1\n' + '#endif\n' + ) + user = '#define HAVE_X 1\n' + pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertIn('HAVE_DEPENDENT', names) + self.assertNotIn('HAVE_DISABLED_BRANCH', names) + self.assertIn('WOLFSSL_USER_SETTINGS', names) + + def test_pcpp_internal_macros_filtered(self): + # __DATE__ and __TIME__ are non-deterministic; if they leak + # into the SBOM, two runs of `make sbom` produce different + # output and reproducibility CI fails. __PCPP__ and __FILE__ + # are pcpp implementation detail. + pairs = self._run('#define HAVE_X 1\n', '', []) + names = {k for k, _ in pairs} + for forbidden in ('__DATE__', '__TIME__', '__FILE__', '__PCPP__'): + self.assertNotIn(forbidden, names, + f'{forbidden} leaked into SBOM properties') + self.assertIn('HAVE_X', names) + + def test_apple_target_conditionals_filtered(self): + # Defensive: if a customer's user_settings.h transitively + # includes a macOS system header, the Apple TargetConditionals + # leak must still be filtered to keep the SBOM target-platform- + # honest. pcpp does not auto-include system headers, so this + # path is uncommon, but the contract with parse_options_h is + # that the same noise filter applies to both entry points. + pairs = self._run( + '#define HAVE_X 1\n' + '#define TARGET_OS_MAC 1\n' + '#define TARGET_OS_LINUX 0\n' + '#define TARGET_IPHONE_SIMULATOR 0\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + for forbidden in ('TARGET_OS_MAC', 'TARGET_OS_LINUX', + 'TARGET_IPHONE_SIMULATOR'): + self.assertNotIn(forbidden, names) + + def test_header_guards_filtered(self): + # wolfSSL's settings.h, visibility.h, etc. all define + # WOLF_CRYPT_*_H guards; they describe which file was parsed, + # not configuration choices, and so are filtered out of the + # SBOM `wolfssl:build:*` property set. + pairs = self._run( + '#define WOLF_CRYPT_SETTINGS_H 1\n' + '#define WOLFSSL_USER_SETTINGS_H 1\n' + '#define HAVE_X 1\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertNotIn('WOLF_CRYPT_SETTINGS_H', names) + self.assertNotIn('WOLFSSL_USER_SETTINGS_H', names) + + def test_no_h_and_use_h_config_flags_preserved(self): + # End-to-end pcpp regression for the `_CONFIG_H_TOKENS` carve- + # out: an embedded customer's user_settings.h that disables + # stdint/stdlib (NETOS / Telit / similar profile) must produce + # an SBOM that records the disablements. Mirrors the + # equivalent unit assertion in TestIsNoiseMacro but exercises + # the full pcpp + filter pipeline customers actually use. + user = ( + '#define HAVE_X 1\n' + '#define NO_STDINT_H 1\n' + '#define NO_STDLIB_H 1\n' + '#define WOLFSSL_NO_ASSERT_H 1\n' + '#define USE_FLAT_TEST_H 1\n' + '#define USE_FLAT_BENCHMARK_H 1\n' + ) + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + ) + pairs = self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + names = {k for k, _ in pairs} + for required in ('HAVE_X', 'NO_STDINT_H', 'NO_STDLIB_H', + 'WOLFSSL_NO_ASSERT_H', 'USE_FLAT_TEST_H', + 'USE_FLAT_BENCHMARK_H'): + self.assertIn( + required, names, + f'{required!r} (real wolfSSL config) was filtered out ' + 'of the SBOM - the noise filter is over-aggressive') + + def test_pcpp_error_directive_is_fatal(self): + # An `#error` firing inside settings.h or a transitively + # included header is a hard build failure for the C compiler; + # gen-sbom must mirror that semantics. pcpp signals this via + # pp.return_code (it does NOT raise), which is easy to swallow + # silently and emit a partial SBOM if not checked. This test + # pins the fail-fast contract: any #error must produce a + # SystemExit, not a partial SBOM. We deliberately do NOT + # pin the exact error wording; the contract is fail-fast, + # not the message's phrasing. + settings = ( + '#ifdef WOLFSSL_USER_SETTINGS\n' + '#include "user_settings.h"\n' + '#endif\n' + '#define HAVE_X 1\n' + ) + user = '#error "this configuration is unsupported"\n' + with self.assertRaises(SystemExit) as ctx: + self._run(settings, user, ['WOLFSSL_USER_SETTINGS']) + # Guard against an empty-message regression that would still + # technically satisfy the SystemExit contract but leave the + # operator with no idea why their build broke. Any + # reasonably useful message will exceed this threshold. + msg = str(ctx.exception) + self.assertGreater(len(msg), 20, + f'gen-sbom exit message too short to be ' + f'actionable: {msg!r}') + + def test_function_like_macros_filtered(self): + # Function-like macros are API surface, not build + # configuration; their post-expansion body would also break + # reproducibility under pcpp token-render whitespace drift. + pairs = self._run( + '#define HAVE_X 1\n' + '#define WC_BITS_TO_BYTES(x) (((x) + 7) >> 3)\n', + '', []) + names = {k for k, _ in pairs} + self.assertIn('HAVE_X', names) + self.assertNotIn('WC_BITS_TO_BYTES', names) + + def test_predefine_with_value(self): + pairs = self._run( + '#if VERSION_MAJOR >= 5\n#define ONLY_NEW 1\n#endif\n', + '', ['VERSION_MAJOR=5']) + names = {k for k, _ in pairs} + self.assertIn('ONLY_NEW', names) + self.assertIn('VERSION_MAJOR', names) + + def test_returns_sorted_pairs_like_parse_options_h(self): + # The downstream code path is shared between options.h and + # user_settings.h; both producers must return the exact same + # shape (sorted list of (name, value) tuples). A drift here + # would surface as a mystery diff between the two paths. + pairs = self._run( + '#define HAVE_Z 1\n#define HAVE_A 1\n#define HAVE_M 1\n', + '', []) + names = [k for k, _ in pairs] + self.assertEqual(names, sorted(names)) + + +class TestDepVersionOverride(unittest.TestCase): + """--dep-version is the embedded path's substitute for pkg-config: + cross-compile hosts have no pkg-config for the target, so the + customer must supply the linked dep version explicitly. Without + this flag a baremetal SBOM that reports `--dep-libz yes` would + silently emit `versionInfo: NOASSERTION` and lose CVE-tracking + fidelity for libz.""" + + def test_explicit_override_wins_over_pkgconfig(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '99.99.99' + self.assertEqual( + gs.dep_version('libz', {'libz': '1.3.1'}), + '1.3.1') + finally: + gs.pkgconfig_version = original + + def test_no_override_falls_back_to_pkgconfig(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.0.0' + self.assertEqual(gs.dep_version('libz'), '1.0.0') + self.assertEqual(gs.dep_version('libz', {}), '1.0.0') + self.assertEqual( + gs.dep_version('libz', {'liboqs': '0.0'}), '1.0.0') + finally: + gs.pkgconfig_version = original + + def test_parse_overrides_rejects_unknown_keys(self): + with self.assertRaises(SystemExit): + gs._parse_dep_version_overrides(['libssl=3.0.0']) + + def test_parse_overrides_rejects_malformed(self): + with self.assertRaises(SystemExit): + gs._parse_dep_version_overrides(['libz']) + + def test_parse_overrides_accepts_known_keys(self): + out = gs._parse_dep_version_overrides([ + 'libz=1.3.1', 'liboqs=0.10.0', + ]) + self.assertEqual(out, {'libz': '1.3.1', 'liboqs': '0.10.0'}) + + +class TestResolveDepVersionsSingleShot(unittest.TestCase): + """Each enabled dependency's version must be resolved exactly once (in + main, via _resolve_dep_versions), not once per output format. Without + the precompute, generate_cdx and generate_spdx each call dep_version() + independently, so a default --with-libz --with-liboqs build would shell + out to `pkg-config --modversion` four times (2 deps x CDX+SPDX) instead + of twice -- and the two documents could disagree if pkg-config were ever + non-deterministic. These tests lock that single-resolution behaviour in.""" + + def test_pkgconfig_called_once_per_dep(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '1.2.3')[1] + overrides = gs._resolve_dep_versions(['libz', 'liboqs'], {}) + self.assertEqual(len(calls), 2) + self.assertEqual(overrides['libz'], '1.2.3') + self.assertEqual(overrides['liboqs'], '1.2.3') + # The emitters reuse the cached value: a later dep_version() for + # an already-resolved key must not re-invoke pkg-config. + gs.dep_version('libz', overrides) + gs.dep_version('liboqs', overrides) + self.assertEqual(len(calls), 2) + finally: + gs.pkgconfig_version = original + + def test_user_override_skips_pkgconfig(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), '9.9.9')[1] + overrides = gs._resolve_dep_versions(['libz'], {'libz': '1.3.1'}) + self.assertEqual(overrides['libz'], '1.3.1') + self.assertEqual(calls, []) + finally: + gs.pkgconfig_version = original + + def test_none_is_cached_when_pkgconfig_missing(self): + calls = [] + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda pkg: (calls.append(pkg), None)[1] + overrides = gs._resolve_dep_versions(['liboqs'], {}) + self.assertIn('liboqs', overrides) + self.assertIsNone(overrides['liboqs']) + # A cached None must short-circuit later lookups too. + gs.dep_version('liboqs', overrides) + self.assertEqual(len(calls), 1) + finally: + gs.pkgconfig_version = original + + +class TestCollectSrcs(unittest.TestCase): + """_collect_srcs merges --srcs and --srcs-file into one ordered, + path-deduplicated list. --srcs-file lets an IDE / build system feed + a mechanically-generated source list (the only way to get a truly + complete set) when it is too long for the command line.""" + + def _write(self, lines): + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write(lines) + return f.name + + def test_srcs_only(self): + self.assertEqual( + gs._collect_srcs(['a.c', 'b.c'], None), + ['a.c', 'b.c']) + + def test_srcs_file_only(self): + path = self._write('a.c\nb.c\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_blank_and_comment_lines_ignored(self): + path = self._write('# header\n\na.c\n # indented comment\nb.c\n\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_srcs_and_file_merge_and_dedup_paths(self): + # A path appearing in both --srcs and --srcs-file collapses to one + # entry (first occurrence wins) so it does not later trip + # srcs_merkle_hash's duplicate-basename guard. + path = self._write('b.c\nc.c\n') + try: + self.assertEqual( + gs._collect_srcs(['a.c', 'b.c'], path), + ['a.c', 'b.c', 'c.c']) + finally: + os.unlink(path) + + def test_whitespace_is_stripped(self): + path = self._write(' a.c \n\tb.c\t\n') + try: + self.assertEqual(gs._collect_srcs(None, path), ['a.c', 'b.c']) + finally: + os.unlink(path) + + def test_empty_result_exits(self): + path = self._write('# only comments\n\n') + try: + with self.assertRaises(SystemExit): + gs._collect_srcs(None, path) + finally: + os.unlink(path) + + def test_unreadable_srcs_file_exits(self): + with self.assertRaises(SystemExit): + gs._collect_srcs(None, '/nonexistent/dir/does-not-exist.txt') + + +class TestCliMutualExclusion(unittest.TestCase): + """The two entry-point shapes (autotools / standalone) must be + mutually exclusive. Mixing them would produce a hash whose + semantics nobody can interpret (library bytes? source merkle? + both?), so gen-sbom refuses the combination upfront with a + clear error.""" + + def _run(self, *argv): + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = here.parent / 'share' / 'gen-sbom' + return subprocess.run( + ['python3', str(script), *argv], + capture_output=True, text=True + ) + + BASE = [ + '--name', 'wolfssl', + '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--cdx-out', '/dev/null', + '--spdx-out', '/dev/null', + ] + + def test_options_and_user_settings_together_fail(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--user-settings', '/dev/null', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--options-h or --user-settings', result.stderr) + + def test_neither_options_nor_user_settings_fails(self): + result = self._run( + *self.BASE, + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('--options-h or --user-settings', result.stderr) + + def test_lib_and_srcs_together_fail(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null', + '--srcs', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) + + def test_neither_lib_nor_srcs_fails(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) + + def test_no_artifact_hash_with_srcs_fails(self): + # --no-artifact-hash is the "no hashable artefact" escape hatch; + # combining it with a real hash source (--srcs here) is a + # contradiction the operator must resolve, so gen-sbom refuses it. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--no-artifact-hash', + '--srcs', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) + + def test_no_artifact_hash_with_lib_fails(self): + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--no-artifact-hash', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0) + self.assertIn('component-checksum source', result.stderr) + + def test_licenseref_without_license_text_is_rejected(self): + # Hard contract enforced at gen-sbom main() (see gen-sbom:880): + # any LicenseRef-* in --license-override must be accompanied by + # --license-text. Without this gate, build_extracted_licensing_infos + # silently emits a placeholder ('NOASSERTION. The text for this + # LicenseRef has not been embedded...') which technically + # validates as SPDX but is worthless to a CRA reviewer. + # TestBuildExtractedLicensingInfos exercises the placeholder + # path in isolation; this test pins the gate that should make + # that path unreachable from main(). A refactor that moves + # the check (e.g. into a helper called by only one entry-point + # shape) would be caught here. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null', + '--license-override', 'LicenseRef-wolfSSL-Commercial') + self.assertNotEqual(result.returncode, 0, + 'gen-sbom must reject LicenseRef-* override ' + 'without --license-text; CRA reviewers cannot ' + 'use the placeholder fallback') + # The error must tell the operator how to fix it; the literal + # '--license-text' substring is the actionable hint. + self.assertIn('--license-text', result.stderr) + + def test_licenseref_with_license_text_is_accepted(self): + # Positive companion to test_licenseref_without_license_text_is_rejected: + # confirms the gate does NOT fire when --license-text is supplied, + # so a refactor that flips the predicate sense (e.g. tests + # `is not None` where it should test `is None`) is also caught. + # We don't validate the SBOM content here — TestBuildExtractedLicensingInfos + # already covers the shape — only that the gate permits the run. + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as f: + f.write('Plain-text wolfSSL commercial licence text.\n') + license_text_path = f.name + # --lib must be non-empty (gen-sbom refuses /dev/null as a + # component checksum); use a tiny stand-in file so we exercise + # the LicenseRef gate without tripping the empty-lib gate. + with tempfile.NamedTemporaryFile('wb', suffix='.so', + delete=False) as f: + f.write(b'\x7fELF stub') + lib_path = f.name + try: + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', lib_path, + '--license-override', 'LicenseRef-wolfSSL-Commercial', + '--license-text', license_text_path) + self.assertEqual( + result.returncode, 0, + f'gen-sbom rejected a valid LicenseRef + license-text ' + f'pair: stderr={result.stderr!r}') + finally: + os.unlink(license_text_path) + os.unlink(lib_path) + + def test_empty_lib_is_rejected(self): + # The --lib argument is the wolfSSL component checksum source. + # An empty file produces the well-known empty-file SHA-256 + # (e3b0c44...b855), which is a valid-looking hash that + # matches no real wolfSSL build artefact ever shipped. Both + # SPDX and CDX validators accept it; nothing else catches + # the lie. gen-sbom must refuse zero-byte --lib. + result = self._run( + *self.BASE, + '--options-h', '/dev/null', + '--lib', '/dev/null') + self.assertNotEqual(result.returncode, 0, + 'gen-sbom accepted an empty --lib file; would ' + 'have shipped an SBOM with the empty-file ' + 'SHA-256 as the wolfSSL component checksum') + self.assertIn('empty', result.stderr.lower()) + self.assertIn('--lib', result.stderr) + + def test_zero_byte_srcs_warn_but_do_not_fail(self): + # Companion: --srcs may legitimately include zero-byte + # placeholders in cross-compile setups (a target file the + # build system creates with touch but doesn't compile yet), + # so gen-sbom emits a WARNING rather than failing. This + # gives the embedded customer a chance to see they have a + # stub file in the source set without breaking their build. + with tempfile.NamedTemporaryFile('wb', suffix='.c', + delete=False) as f: + f.write(b'/* real source */\n') + real_src = f.name + with tempfile.NamedTemporaryFile('wb', suffix='.c', + delete=False) as f: + empty_src = f.name + # Rename so the basenames are distinct (srcs_merkle_hash + # rejects duplicate basenames; see TestSrcsMerkleHash). + # Rename and rebind BEFORE the try-block so the finally + # clause always references the live filenames even when an + # assertion fails. + real_renamed = real_src + '.real.c' + empty_renamed = empty_src + '.empty.c' + os.rename(real_src, real_renamed) + os.rename(empty_src, empty_renamed) + real_src = real_renamed + empty_src = empty_renamed + try: + result = self._run( + *self.BASE, + '--user-settings', '/dev/null', + '--srcs', real_src, empty_src) + # The standalone path with /dev/null user-settings should + # complete; the only thing we care about here is that an + # empty source did not abort the run. + self.assertEqual( + result.returncode, 0, + f'gen-sbom failed with zero-byte source: stderr={result.stderr!r}') + self.assertIn('zero-byte source', result.stderr) + finally: + for p in (real_src, empty_src): + try: + os.unlink(p) + except FileNotFoundError: + pass + + def test_user_settings_path_in_help(self): + # Discoverability regression guard - if the standalone entry + # point is invisible to `--help`, embedded customers will not + # know it exists. + result = self._run('--help') + self.assertEqual(result.returncode, 0, result.stderr) + for token in ('--user-settings', '--user-settings-include', + '--user-settings-define', '--srcs', '--srcs-file', + '--no-artifact-hash', '--dep-version'): + self.assertIn(token, result.stdout, f'{token!r} missing from --help') + + def test_srcs_file_matches_srcs_for_same_list(self): + # --srcs-file is purely an input convenience: for the same set of + # files it must produce a byte-identical SBOM to passing the files + # via --srcs. This pins that equivalence end-to-end so the two + # input paths can never silently diverge. + with tempfile.TemporaryDirectory() as tmp: + aes = os.path.join(tmp, 'aes.c') + sha = os.path.join(tmp, 'sha.c') + with open(aes, 'w') as f: + f.write('/* aes */\n') + with open(sha, 'w') as f: + f.write('/* sha */\n') + listfile = os.path.join(tmp, 'srcs.txt') + with open(listfile, 'w') as f: + f.write(f'# wolfssl sources\n{aes}\n\n{sha}\n') + + cdx_a = os.path.join(tmp, 'a.cdx.json') + spdx_a = os.path.join(tmp, 'a.spdx.json') + cdx_b = os.path.join(tmp, 'b.cdx.json') + spdx_b = os.path.join(tmp, 'b.spdx.json') + common = [ + '--name', 'wolfssl', '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--user-settings', '/dev/null', + ] + env = dict(os.environ, SOURCE_DATE_EPOCH='1700000000') + import subprocess + here = pathlib.Path(__file__).resolve().parent + script = str(here.parent / 'share' / 'gen-sbom') + r1 = subprocess.run( + ['python3', script, *common, '--srcs', aes, sha, + '--cdx-out', cdx_a, '--spdx-out', spdx_a], + capture_output=True, text=True, env=env) + r2 = subprocess.run( + ['python3', script, *common, '--srcs-file', listfile, + '--cdx-out', cdx_b, '--spdx-out', spdx_b], + capture_output=True, text=True, env=env) + self.assertEqual(r1.returncode, 0, r1.stderr) + self.assertEqual(r2.returncode, 0, r2.stderr) + with open(cdx_a) as f: + a_cdx = f.read() + with open(cdx_b) as f: + b_cdx = f.read() + self.assertEqual(a_cdx, b_cdx) + + def test_no_artifact_hash_emits_placeholder_and_note(self): + # End-to-end: --no-artifact-hash must produce a valid SBOM whose + # checksum is the synthetic 64-zero placeholder, tagged + # hash-source=none with the contact note, so the "no hashable + # artefact" path can never silently masquerade as a real digest. + with tempfile.TemporaryDirectory() as tmp: + cdx = os.path.join(tmp, 'out.cdx.json') + spdx = os.path.join(tmp, 'out.spdx.json') + result = self._run( + '--name', 'wolfssl', '--version', '0.0.0-test', + '--license-file', '/dev/null', + '--user-settings', '/dev/null', + '--no-artifact-hash', + '--cdx-out', cdx, '--spdx-out', spdx) + self.assertEqual(result.returncode, 0, result.stderr) + with open(cdx) as f: + doc = json.load(f) + comp = doc['metadata']['component'] + self.assertEqual(comp['hashes'][0]['content'], '0' * 64) + props = {p['name']: p['value'] for p in comp['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'none') + self.assertIn('wolfssl:sbom:no-artifact-hash-note', props) + + +# --------------------------------------------------------------------------- +# SBOM document generators (generate_cdx / generate_spdx + dep helpers). +# +# These four functions emit the actual JSON consumed by vulnerability +# scanners and CRA auditors. Until this block landed they were entirely +# untested; an SBOM-shape regression that still produced syntactically +# valid JSON would slip through every CI gate. The independent oracle +# is the CDX 1.6 / SPDX 2.3 schema field names, externally specified. +# --------------------------------------------------------------------------- + + +class TestCdxDepComponent(unittest.TestCase): + """gen-sbom:576 cdx_dep_component shapes a single CycloneDX dep entry.""" + + def test_returns_bomref_and_component(self): + # Stub pkgconfig_version so the test does not depend on the + # build host having libz / liboqs installed. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertEqual(comp['bom-ref'], ref) + self.assertEqual(comp['type'], 'library') + self.assertEqual(comp['name'], 'zlib') + self.assertEqual(comp['supplier']['name'], + 'Jean-loup Gailly and Mark Adler') + # Per CDX 1.6, listed-id licences go in license.id (not name). + # A regression that switches to license.name would silently + # produce an SBOM that some validators reject. + self.assertEqual( + comp['licenses'], [{'license': {'id': 'Zlib'}}]) + self.assertEqual(comp['version'], '1.3.1') + self.assertTrue(comp['purl'].startswith('pkg:')) + self.assertIn('zlib', comp['purl']) + self.assertEqual(comp['externalReferences'][0]['type'], 'vcs') + + def test_omits_version_and_purl_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. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: None + ref, comp = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertNotIn('version', comp) + self.assertNotIn('purl', comp) + # bom-ref is still present and deterministic. + self.assertTrue(ref) + + 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 + # override threads through to the emitted CDX component. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '99.99.99' + ref, comp = gs.cdx_dep_component( + 'wolfssl', '5.9.1', 'libz', {'libz': '1.3.1'}) + finally: + gs.pkgconfig_version = original + self.assertEqual(comp['version'], '1.3.1') + + def test_bomref_is_deterministic_for_same_inputs(self): + # Two calls with the same inputs must return identical bom-refs; + # otherwise SBOMs are not byte-identical across reruns and the + # reproducibility guarantee breaks. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + ref_a, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + ref_b, _ = gs.cdx_dep_component('wolfssl', '5.9.1', 'libz') + finally: + gs.pkgconfig_version = original + self.assertEqual(ref_a, ref_b) + + +class TestSpdxDepPackage(unittest.TestCase): + """gen-sbom:599 spdx_dep_package shapes a single SPDX dep package.""" + + def test_returns_spdxid_and_package(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '0.10.0' + spdx_id, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + self.assertTrue(spdx_id.startswith('SPDXRef-Package-')) + # SPDXID must contain only alphanumeric + '.' + '-' (SPDX + # 2.3 §3.2). spdx_dep_package strips everything else; a + # regression that allowed underscores or 'lib' prefixes + # could produce an SPDXID validators reject. + import re as _re + self.assertTrue( + _re.match(r'\ASPDXRef-[A-Za-z0-9.-]+\Z', spdx_id), + f'invalid SPDXID shape: {spdx_id!r}') + self.assertEqual(pkg['SPDXID'], spdx_id) + self.assertEqual(pkg['name'], 'liboqs') + self.assertEqual(pkg['versionInfo'], '0.10.0') + self.assertEqual(pkg['filesAnalyzed'], False) + # Both license fields must agree; SPDX validators accept + # divergence but it is semantically meaningless here. + self.assertEqual(pkg['licenseConcluded'], pkg['licenseDeclared']) + self.assertEqual(pkg['copyrightText'], 'NOASSERTION') + + def test_unknown_version_uses_NOASSERTION(self): + # SPDX 2.3 §3.3 requires versionInfo; when truly unknown, + # 'NOASSERTION' is the spec-compliant placeholder. Emitting + # an empty string or omitting the field would fail validation. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: None + _, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + self.assertEqual(pkg['versionInfo'], 'NOASSERTION') + # externalRefs.purl is only emitted when a version is known + # (a purl with no @version is meaningless to package-manager + # tooling); confirm it is absent here. + self.assertNotIn('externalRefs', pkg) + + def test_purl_externalref_present_when_version_known(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '0.10.0' + _, pkg = gs.spdx_dep_package('liboqs') + finally: + gs.pkgconfig_version = original + purl_refs = [ + r for r in pkg.get('externalRefs', []) + if r.get('referenceType') == 'purl' + ] + self.assertEqual(len(purl_refs), 1) + self.assertIn('liboqs', purl_refs[0]['referenceLocator']) + self.assertIn('0.10.0', purl_refs[0]['referenceLocator']) + + +class TestGenerateCdx(unittest.TestCase): + """gen-sbom:624 generate_cdx assembles the full CycloneDX 1.6 doc.""" + + BASE_KW = dict( + name='wolfssl', + version='5.9.1', + supplier='wolfSSL Inc.', + license_id='GPL-2.0-only', + license_text=None, + lib_hash='a' * 64, + timestamp='2024-01-01T00:00:00Z', + year=2024, + serial='00000000-0000-0000-0000-000000000001', + enabled_deps=[], + build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')], + ) + + def test_top_level_shape(self): + doc = gs.generate_cdx(**self.BASE_KW) + self.assertEqual(doc['bomFormat'], 'CycloneDX') + self.assertEqual(doc['specVersion'], '1.6') + self.assertEqual( + doc['$schema'], + 'http://cyclonedx.org/schema/bom-1.6.schema.json') + self.assertEqual(doc['version'], 1) + # serialNumber is a urn:uuid: prefix per CDX schema. + self.assertTrue(doc['serialNumber'].startswith('urn:uuid:')) + + def test_main_component_fields(self): + doc = gs.generate_cdx(**self.BASE_KW) + comp = doc['metadata']['component'] + self.assertEqual(comp['type'], 'library') + self.assertEqual(comp['name'], 'wolfssl') + self.assertEqual(comp['version'], '5.9.1') + # CPE 2.3 with vendor:product:version - downstream + # vulnerability scanners key on this format. + self.assertEqual( + comp['cpe'], + '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') + self.assertEqual(comp['hashes'], + [{'alg': 'SHA-256', 'content': 'a' * 64}]) + self.assertEqual(comp['licenses'], + [{'license': {'id': 'GPL-2.0-only'}}]) + + def test_build_properties_emitted(self): + doc = gs.generate_cdx(**self.BASE_KW) + 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') + + def test_dependency_refs_match_components(self): + # Critical invariant: every bom-ref in `dependencies` must + # appear as a `bom-ref` on either the main component or one + # of the dep components. Without this, the dependency graph + # references dangling IDs and CycloneDX-aware tooling cannot + # resolve relationships. + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'enabled_deps': ['libz'], + }) + finally: + gs.pkgconfig_version = original + all_refs = {doc['metadata']['component']['bom-ref']} + for c in doc['components']: + all_refs.add(c['bom-ref']) + for entry in doc['dependencies']: + self.assertIn(entry['ref'], all_refs, + f"dangling dep ref: {entry['ref']!r}") + for dep in entry.get('dependsOn', []): + self.assertIn(dep, all_refs, + f"dangling dependsOn ref: {dep!r}") + # The wolfssl bom-ref must depend on the libz bom-ref. + wolfssl_ref = doc['metadata']['component']['bom-ref'] + wolfssl_entry = next( + e for e in doc['dependencies'] if e['ref'] == wolfssl_ref) + self.assertEqual(len(wolfssl_entry['dependsOn']), 1) + + def test_source_merkle_path_emits_hash_kind_property(self): + # The OmniBOR / source-merkle entry point annotates the + # SBOM so an auditor reading the SHA-256 knows it is a hash + # of the source set, not of the built library. Without + # this property the same SHA-256 field carries two + # incompatible semantic meanings depending on entry point. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-kind'], + 'source-merkle-omnibor') + self.assertEqual(props['wolfssl:sbom:source-set'], 'aes.c,sha.c') + + def test_library_binary_path_emits_hash_kind_property(self): + # The library-binary path now also emits hash-kind: it is the + # auditor's only structured signal for what the SHA-256 in + # `hashes` actually represents. Previously this property was + # only set on the source-merkle path, leaving an autotools + # SBOM ambiguous about its checksum semantics. + doc = gs.generate_cdx(**self.BASE_KW) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-kind'], 'library-binary') + # source-set is only meaningful for the merkle path. + self.assertNotIn('wolfssl:sbom:source-set', props) + + def test_hash_source_property_defaults_to_lib(self): + # hash-source is the coarse provenance tag downstream tooling + # filters on. The default (autotools / library-binary path) is + # 'lib'; pin it so a refactor of the default cannot silently + # mislabel the autotools SBOM. + doc = gs.generate_cdx(**self.BASE_KW) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'lib') + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props) + + def test_hash_source_srcs_for_source_set(self): + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'hash_source': 'srcs', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'srcs') + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note', props) + + def test_hash_source_none_carries_contact_note(self): + # The --no-artifact-hash path must flag the synthetic placeholder + # so a downstream auditor cannot mistake the 64-zero checksum for + # a genuine digest. Both the hash-source=none tag and the contact + # note are required. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'lib_hash': gs._NO_HASH_SENTINEL, + 'hash_kind': 'none', + 'hash_source': 'none', + }) + props = {p['name']: p['value'] + for p in doc['metadata']['component']['properties']} + self.assertEqual(props['wolfssl:sbom:hash-source'], 'none') + self.assertEqual(props['wolfssl:sbom:no-artifact-hash-note'], + gs._NO_HASH_NOTE) + # The placeholder must be the synthetic 64-zero sentinel. + self.assertEqual( + doc['metadata']['component']['hashes'][0]['content'], + '0' * 64) + + def test_main_component_carries_security_external_refs(self): + # An auditor reading the CDX needs a single in-document link + # to the project's security advisories and the RFC 9116 + # security.txt; previously they had to know to go look on + # GitHub or wolfssl.com. Pin the set so a regression that + # drops one of these silently is caught at the cheap CI gate. + doc = gs.generate_cdx(**self.BASE_KW) + refs = doc['metadata']['component']['externalReferences'] + types = {r['type'] for r in refs} + self.assertEqual( + {'vcs', 'website', 'issue-tracker', 'advisories', + 'security-contact'}, + types) + sec_url = next( + r['url'] for r in refs if r['type'] == 'security-contact') + self.assertEqual( + sec_url, + 'https://www.wolfssl.com/.well-known/security.txt') + + def test_lib_file_entries_become_subcomponents(self): + # CycloneDX 1.6 lets a library component nest file-typed + # sub-components. When the autotools `--lib` path supplies a + # file_entries list, the SBOM names the linked binary by file + # path + SHA-1 + SHA-256 so an auditor / scanner does not have + # to reason about the bare SHA-256 in `hashes` against a + # build-system layout they cannot see. + doc = gs.generate_cdx(**{ + **self.BASE_KW, + 'file_entries': [{ + 'name': 'libwolfssl.so.43.0.0', + 'sha1': 'b' * 40, + 'sha256': 'a' * 64, + }], + }) + sub = doc['metadata']['component']['components'] + self.assertEqual(len(sub), 1) + self.assertEqual(sub[0]['type'], 'file') + self.assertEqual(sub[0]['name'], 'libwolfssl.so.43.0.0') + algs = {h['alg'] for h in sub[0]['hashes']} + self.assertEqual(algs, {'SHA-1', 'SHA-256'}) + + def test_tool_metadata_uses_module_constants(self): + # The CDX `metadata.tools.components[]` entry is the only + # producer-identity field in the document; downstream consumers + # pin their parser against the (name, version) pair, so the + # tool name / version must come from the module-level + # constants and not from a stale string baked into the + # generator. + doc = gs.generate_cdx(**self.BASE_KW) + tool = doc['metadata']['tools']['components'][0] + self.assertEqual(tool['name'], gs.GEN_SBOM_TOOL_NAME) + self.assertEqual(tool['version'], gs.GEN_SBOM_VERSION) + + +class TestGenerateSpdx(unittest.TestCase): + """gen-sbom:698 generate_spdx assembles the full SPDX 2.3 doc.""" + + BASE_KW = dict( + name='wolfssl', + version='5.9.1', + supplier='wolfSSL Inc.', + license_id='GPL-2.0-only', + license_text=None, + lib_hash='a' * 64, + timestamp='2024-01-01T00:00:00Z', + year=2024, + doc_ns_uuid='00000000-0000-0000-0000-000000000002', + enabled_deps=[], + build_props=[('HAVE_AESGCM', '1'), ('NO_DES3', '')], + ) + + def test_top_level_shape(self): + doc = gs.generate_spdx(**self.BASE_KW) + self.assertEqual(doc['spdxVersion'], 'SPDX-2.3') + self.assertEqual(doc['dataLicense'], 'CC0-1.0') + self.assertEqual(doc['SPDXID'], 'SPDXRef-DOCUMENT') + self.assertEqual(doc['name'], 'wolfssl-5.9.1') + # SPDX 2.3 §6.5: documentNamespace must be a unique URI; no + # requirement that it resolve. Default to `urn:uuid:` + # rather than a `https://wolfssl.com/sbom/...` URL the project + # does not host -- emitting an unresolvable URL would mislead + # any downstream tool that follows it. The doc_ns_uuid keeps + # the namespace per-version unique without making a hosting + # claim. + self.assertEqual( + doc['documentNamespace'], + f'urn:uuid:{self.BASE_KW["doc_ns_uuid"]}') + + def test_document_namespace_override_is_honoured(self): + # Downstream packagers who legitimately re-host the SBOM under + # their own URL pass --document-namespace; the override must + # win over the urn:uuid default. Without this knob a packager + # would have to fork the script to satisfy SPDX 2.3 §6.5 + # uniqueness against a self-hosted mirror. + custom = 'https://example.com/sbom/wolfssl-5.9.1.spdx.json' + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'document_namespace': custom, + }) + self.assertEqual(doc['documentNamespace'], custom) + + def test_document_namespace_default_is_urn_uuid(self): + # Negative companion to test_document_namespace_override: when + # no override is supplied (None or empty), the urn:uuid form is + # used and the previously-emitted https://wolfssl.com/sbom/ + # URL is NOT reintroduced (regression guard for the M1 + # correction). + for explicit in (None, ''): + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'document_namespace': explicit, + }) + self.assertTrue( + doc['documentNamespace'].startswith('urn:uuid:'), + f'{explicit!r} -> {doc["documentNamespace"]!r}') + self.assertNotIn('wolfssl.com/sbom', doc['documentNamespace']) + + def test_main_package_fields(self): + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertEqual(wolfssl_pkg['name'], 'wolfssl') + self.assertEqual(wolfssl_pkg['versionInfo'], '5.9.1') + self.assertEqual( + wolfssl_pkg['checksums'], + [{'algorithm': 'SHA256', 'checksumValue': 'a' * 64}]) + self.assertEqual(wolfssl_pkg['licenseConcluded'], 'GPL-2.0-only') + self.assertEqual(wolfssl_pkg['licenseDeclared'], 'GPL-2.0-only') + + def test_describes_relationship(self): + # SPDX 2.3 §11: every document must DESCRIBE its primary package. + doc = gs.generate_spdx(**self.BASE_KW) + describes = [ + r for r in doc['relationships'] + if r['relationshipType'] == 'DESCRIBES' + ] + self.assertEqual(len(describes), 1) + self.assertEqual(describes[0]['spdxElementId'], 'SPDXRef-DOCUMENT') + self.assertEqual(describes[0]['relatedSpdxElement'], + 'SPDXRef-Package-wolfssl') + + def test_depends_on_relationship_per_dep(self): + original = gs.pkgconfig_version + try: + gs.pkgconfig_version = lambda *_a, **_k: '1.3.1' + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'enabled_deps': ['libz'], + }) + finally: + gs.pkgconfig_version = original + depends_on = [ + r for r in doc['relationships'] + if r['relationshipType'] == 'DEPENDS_ON' + ] + self.assertEqual(len(depends_on), 1) + self.assertEqual(depends_on[0]['spdxElementId'], + 'SPDXRef-Package-wolfssl') + # The relatedSpdxElement must be a real SPDXID in the doc; + # a typo would create a dangling reference. + all_spdx_ids = {p['SPDXID'] for p in doc['packages']} + self.assertIn(depends_on[0]['relatedSpdxElement'], all_spdx_ids) + + def test_extracted_licensing_infos_present_for_licenseref(self): + # Critical SPDX 2.3 §10.1 plumbing: when license_id contains + # a LicenseRef-* and license_text is supplied, the document + # MUST carry a hasExtractedLicensingInfos block covering it. + # A regression that drops the wiring in generate_spdx's tail + # produces SBOMs that fail SPDX validation -- the autotools + # path catches this at pyspdxtools time, but the standalone + # path does not validate, so a customer-shipped SBOM would + # silently land at an auditor. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'license_id': 'LicenseRef-wolfSSL-Commercial', + 'license_text': 'Commercial licence body.\n', + }) + self.assertIn('hasExtractedLicensingInfos', doc) + infos = doc['hasExtractedLicensingInfos'] + self.assertEqual(len(infos), 1) + self.assertEqual(infos[0]['licenseId'], + 'LicenseRef-wolfSSL-Commercial') + self.assertEqual(infos[0]['extractedText'], + 'Commercial licence body.\n') + + def test_extracted_licensing_infos_absent_for_simple_id(self): + # Companion to the above: simple SPDX IDs (Apache-2.0, + # GPL-2.0-only, etc.) MUST NOT generate a + # hasExtractedLicensingInfos block, since the licence + # text is well-known and the field is reserved for refs. + doc = gs.generate_spdx(**self.BASE_KW) + self.assertNotIn('hasExtractedLicensingInfos', doc) + + def test_source_merkle_path_annotates_via_annotations(self): + # Mirror of TestGenerateCdx.test_source_merkle_path_emits_hash_kind_property + # for SPDX. The hash-kind / source-set used to be stuffed into + # the package `comment` field as positional `key=value` slugs, + # forcing anyone reading the SPDX to grep free-form text. + # SPDX 2.3 §8.5 provides `annotations[]` for exactly this + # producer metadata, and validators (pyspdxtools, NTIA) treat + # them as first-class data. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'hash_kind': 'source-merkle-omnibor', + 'srcs_basenames': ['aes.c', 'sha.c'], + }) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + annotation_comments = [ + a['comment'] for a in wolfssl_pkg['annotations'] + ] + self.assertIn( + 'wolfssl:sbom:hash-kind=source-merkle-omnibor', + annotation_comments) + self.assertIn( + 'wolfssl:sbom:source-set=aes.c,sha.c', annotation_comments) + # `comment` no longer carries the structured hash-kind data -- + # it is reserved for the human-readable build-config defines. + self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) + self.assertNotIn('source-set=', wolfssl_pkg['comment']) + + def test_library_binary_path_annotates_via_annotations(self): + # Companion to the source-merkle test: library-binary also + # emits hash-kind via annotations[]. The old behaviour of + # only annotating the merkle path left autotools SBOMs with + # no machine-readable signal of their checksum semantics. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + annotation_comments = [ + a['comment'] for a in wolfssl_pkg['annotations'] + ] + self.assertIn( + 'wolfssl:sbom:hash-kind=library-binary', annotation_comments) + # No source-set on library-binary path. + self.assertNotIn('wolfssl:sbom:source-set=', + ''.join(annotation_comments)) + # Comment is still build-config defines only. + self.assertNotIn('hash-kind=', wolfssl_pkg['comment']) + + def test_hash_source_annotation_defaults_to_lib(self): + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + comments = [a['comment'] for a in wolfssl_pkg['annotations']] + self.assertIn('wolfssl:sbom:hash-source=lib', comments) + self.assertNotIn('wolfssl:sbom:no-artifact-hash-note=', + ''.join(comments)) + + def test_hash_source_none_annotates_contact_note(self): + # The --no-artifact-hash path must record both the hash-source=none + # tag and the contact note in the SPDX annotations[], mirroring the + # CycloneDX side, so neither format hides the synthetic placeholder. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'lib_hash': gs._NO_HASH_SENTINEL, + 'hash_kind': 'none', + 'hash_source': 'none', + }) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + comments = [a['comment'] for a in wolfssl_pkg['annotations']] + self.assertIn('wolfssl:sbom:hash-source=none', comments) + self.assertIn( + f'wolfssl:sbom:no-artifact-hash-note={gs._NO_HASH_NOTE}', + comments) + self.assertEqual( + wolfssl_pkg['checksums'][0]['checksumValue'], '0' * 64) + + def test_file_entries_do_not_leak_into_spdx(self): + # SPDX 2.3 forbids package elements (CONTAINS relationships + # via hasFiles) when `filesAnalyzed: False`, and flipping + # `filesAnalyzed: True` would force a packageVerificationCode + # that hashes every file in the package -- not just the + # linked binary. generate_spdx accepts file_entries for + # parameter symmetry with generate_cdx but must not surface + # it as `files[]` / `hasFiles[]`; otherwise pyspdxtools rejects + # the document and `make sbom` fails. Pin the absence so a + # future change cannot quietly reintroduce the validator + # failure that motivated the carve-out. + doc = gs.generate_spdx(**{ + **self.BASE_KW, + 'file_entries': [{ + 'name': 'libwolfssl.so.43.0.0', + 'sha1': 'b' * 40, + 'sha256': 'a' * 64, + }], + }) + self.assertNotIn('files', doc) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + self.assertNotIn('hasFiles', wolfssl_pkg) + self.assertEqual(wolfssl_pkg['filesAnalyzed'], False) + self.assertNotIn('packageVerificationCode', wolfssl_pkg) + # CONTAINS relationships are also forbidden under + # filesAnalyzed=False; ensure none leaked through. + contains = [ + r for r in doc['relationships'] + if r.get('relationshipType') == 'CONTAINS' + ] + self.assertEqual(contains, []) + + def test_main_package_purl_uses_pkg_github(self): + # PURL parity with the CDX side: pkg:github//@v + # resolves directly in OSV / GHSA / Snyk / Trivy. The previous + # pkg:generic shape forced every scanner into CPE-fallback + # matching, producing the noisy SBOM behaviour auditors + # complain about. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + purl_refs = [ + r for r in wolfssl_pkg['externalRefs'] + if r['referenceType'] == 'purl' + ] + self.assertEqual(len(purl_refs), 1) + self.assertEqual( + purl_refs[0]['referenceLocator'], + 'pkg:github/wolfSSL/wolfssl@v5.9.1') + + def test_main_package_carries_advisory_external_ref(self): + # SPDX 2.3 SECURITY/advisory externalRef pointing at the + # GitHub advisories index. Same auditor-facing rationale as + # the CDX side: a single in-document link to the project's + # security disclosures, no out-of-band knowledge required. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + adv_refs = [ + r for r in wolfssl_pkg['externalRefs'] + if r['referenceType'] == 'advisory' + ] + self.assertEqual(len(adv_refs), 1) + self.assertEqual( + adv_refs[0]['referenceLocator'], + 'https://github.com/wolfSSL/wolfssl/security/advisories') + self.assertEqual(adv_refs[0]['referenceCategory'], 'SECURITY') + + def test_creation_info_uses_module_constants(self): + # SPDX `creationInfo.creators[]` carries the producer-identity + # signal that downstream tools key on; must come from the + # module-level constants and not from a stale string. + doc = gs.generate_spdx(**self.BASE_KW) + creators = doc['creationInfo']['creators'] + expected_tool = ( + f'Tool: {gs.GEN_SBOM_TOOL_NAME}-{gs.GEN_SBOM_VERSION}' + ) + self.assertIn(expected_tool, creators) + + def test_annotations_have_well_formed_metadata(self): + # SPDX 2.3 §8.5: annotation entries require `annotationDate` + # (ISO-8601 with timezone), `annotationType` (one of OTHER, + # REVIEW, ...), `annotator` (Person/Organization/Tool prefix), + # and `comment` (string). pyspdxtools rejects malformed + # annotation entries; pin the shape here at the cheapest CI + # gate so a regression in generate_spdx surfaces in unit + # tests rather than in the integration job. + doc = gs.generate_spdx(**self.BASE_KW) + wolfssl_pkg = next( + p for p in doc['packages'] + if p['SPDXID'] == 'SPDXRef-Package-wolfssl') + for ann in wolfssl_pkg['annotations']: + self.assertEqual(ann['annotationDate'], self.BASE_KW['timestamp']) + self.assertEqual(ann['annotationType'], 'OTHER') + self.assertTrue(ann['annotator'].startswith('Tool: '), + f'annotator must use Tool: prefix: {ann!r}') + self.assertIsInstance(ann['comment'], str) + self.assertTrue(ann['comment']) + + +# --------------------------------------------------------------------------- +# Bomsh provenance verifier +# +# The verifier (provenance/bomsh_verify.py) is invoked by the bomsh CI job +# against a real OmniBOR graph + enriched SPDX, but its two checks -- +# resolvability and object-store integrity -- are pure data-shape +# logic. Exercising them here with synthetic fixtures means a logic +# regression is caught at the cheapest CI gate (the unit job, < 1 s) +# instead of the bomsh integration job (~5 minutes per run, requires +# bomtrace3 + the entire bomsh toolchain to be built). +# --------------------------------------------------------------------------- + +class _BomshFixture: + """Build a self-consistent OmniBOR + SPDX layout in a tmpdir. + + Use as a context manager; the tmpdir is cleaned on exit. Methods + let individual tests perturb a single property (delete a blob, + truncate one, etc.) without rebuilding the whole fixture each + time.""" + + def __init__(self, tmpdir): + self.tmpdir = pathlib.Path(tmpdir) + self.objects_dir = self.tmpdir / 'omnibor' / 'objects' + self.objects_dir.mkdir(parents=True) + self.spdx_path = self.tmpdir / 'omnibor.wolfssl-5.9.1.spdx.json' + # Three distinct blobs staged at their gitoid paths. Stand-in + # for the OmniBOR documents a real `bomsh_create_bom.py` run + # would write under omnibor/objects/; the verifier doesn't care + # whether the content is a doc or an artefact blob, only that + # the file at / round-trips through gitoid_sha1. We + # use OmniBOR-doc-shaped bytes here rather than ELF magic so a + # reader doesn't mistakenly conclude the verifier expects raw + # library content under objects/ (it does not -- bomsh stores + # the Input Manifest there, keyed by its bom_id). + self.wolfssl_blob = b'gitoid:blob:sha1\nblob 0123456789abcdef0123456789abcdef01234567\n' + self.aux_blobs = [b'/* aes.c */\n', b'/* sha.c */\n'] + self.gitoids = { + 'wolfssl': self._stage_blob(self.wolfssl_blob), + } + for i, content in enumerate(self.aux_blobs): + self.gitoids[f'aux{i}'] = self._stage_blob(content) + self._write_spdx() + + def _stage_blob(self, content): + """Write `content` into omnibor/objects// at the + correct gitoid path; return the gitoid hex. Uses + `_gitoid_of_bytes` (an independent reimplementation of the + canonical Git blob hash) rather than calling into + bomsh_verify -- two implementations is the point: a bug in + either is caught by disagreement.""" + gid = _gitoid_of_bytes(content) + d = self.objects_dir / gid[:2] + d.mkdir(exist_ok=True) + (d / gid[2:]).write_bytes(content) + return gid + + def _write_spdx(self): + """Emit the enriched SPDX with one gitoid externalRef per + staged blob.""" + packages = [{ + 'name': 'wolfssl', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': f'gitoid:blob:sha1:{self.gitoids["wolfssl"]}', + }], + }] + for i in range(len(self.aux_blobs)): + packages.append({ + 'name': f'wolfssl-aux-{i}', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': f'gitoid:blob:sha1:{self.gitoids[f"aux{i}"]}', + }], + }) + self.spdx_path.write_text(json.dumps({'packages': packages})) + + def verify(self): + """Run the orchestrator with the fixture's paths.""" + return bv.verify( + spdx_glob=str(self.tmpdir / 'omnibor.wolfssl-*.spdx.json'), + omnibor_dir=str(self.tmpdir / 'omnibor')) + + +def _gitoid_of_bytes(data): + """Reference implementation used in the fixture so blobs are + placed at the gitoid path the verifier later derives. Independent + of bomsh_verify.gitoid_sha1, which reads from a file -- we want + two implementations so a bug in one is caught by disagreement.""" + import hashlib + h = hashlib.sha1() + h.update(f'blob {len(data)}\0'.encode()) + h.update(data) + return h.hexdigest() + + +import json # noqa: E402 (used by the bomsh fixture below) + +bv_spec = importlib.util.spec_from_file_location( + 'bomsh_verify', + pathlib.Path(__file__).resolve().parent.parent / 'provenance' / 'bomsh_verify.py') +bv = importlib.util.module_from_spec(bv_spec) +bv_spec.loader.exec_module(bv) + + +class TestBomshProvenanceVerify(unittest.TestCase): + """Exercises bomsh_verify.verify against synthetic fixtures. Each + test starts from a known-good fixture, perturbs exactly one + property, and checks the verifier's failure mode is the right one + -- so a regression that, say, accepts a dangling gitoid as long as + object-store integrity passes is caught here.""" + + def test_happy_path_passes(self): + # Baseline. An untouched fixture is valid; the verifier should + # report OK and the success message should mention the object + # round-trip count (so a future change that silently drops the + # success-line content is also caught). + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + ok, messages = fx.verify() + self.assertTrue(ok, f'verifier rejected a valid fixture: {messages}') + joined = '\n'.join(messages) + self.assertIn('OK:', joined) + self.assertIn('objects round-trip:', joined) + + def test_dangling_gitoid_fails_check_A(self): + # Delete one blob from objects/ but leave its externalRef in + # the SPDX. Check (A) must reject; the failure message must + # mention DANGLING and the missing gitoid path so triage isn't + # just "verifier failed". + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + target_gid = fx.gitoids['aux0'] + (fx.objects_dir / target_gid[:2] / target_gid[2:]).unlink() + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('DANGLING', joined) + self.assertIn(target_gid, joined) + + def test_corrupt_blob_fails_check_B(self): + # Truncate one blob in objects/ so its content no longer + # matches the gitoid encoded in its path. Check (B) must + # reject; check (A) would still pass (the file exists). This + # pins that integrity is checked independently of resolvability. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + target_gid = fx.gitoids['aux1'] + (fx.objects_dir / target_gid[:2] / target_gid[2:]).write_bytes(b'') + ok, messages = fx.verify() + self.assertFalse(ok) + joined = '\n'.join(messages) + self.assertIn('CORRUPT', joined) + self.assertIn('round-trip', joined) + + def test_unexpected_gitoid_locator_format_rejected(self): + # bomsh upstream switching from sha1 to sha256 would change + # the locator prefix. load_spdx_gitoids must raise so the + # maintainer is forced to update the verifier in lockstep, + # rather than silently accepting an unparsable value. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + spdx = json.loads(fx.spdx_path.read_text()) + spdx['packages'][0]['externalRefs'][0]['referenceLocator'] = ( + 'gitoid:blob:sha256:' + 'f' * 64) + fx.spdx_path.write_text(json.dumps(spdx)) + ok, messages = fx.verify() + self.assertFalse(ok) + self.assertTrue( + any('unexpected gitoid locator format' in m for m in messages), + messages) + + def test_no_gitoid_externalrefs_fails(self): + # Negative companion: an SPDX that contains no gitoid + # externalRefs at all is not a bomsh-enriched document, and + # the verifier should say so plainly rather than silently + # report 0 verified. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + spdx = json.loads(fx.spdx_path.read_text()) + for pkg in spdx['packages']: + pkg['externalRefs'] = [] + fx.spdx_path.write_text(json.dumps(spdx)) + ok, messages = fx.verify() + self.assertFalse(ok) + self.assertTrue( + any('no gitoid externalRefs' in m for m in messages), + messages) + + def test_all_matched_spdx_are_verified_not_just_first(self): + # Regression: when the glob matches several SPDX documents, the + # verifier must check every one. A second document that sorts *after* + # the good one and carries a dangling gitoid must fail the run -- + # verifying only spdx_paths[0] would let it pass silently. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) # omnibor.wolfssl-5.9.1 (valid) + bogus_gid = 'a' * 40 # well-formed hex, never staged + second = fx.tmpdir / 'omnibor.wolfssl-5.9.2.spdx.json' + second.write_text(json.dumps({'packages': [{ + 'name': 'wolfssl-later', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': f'gitoid:blob:sha1:{bogus_gid}', + }], + }]})) + ok, messages = fx.verify() + self.assertFalse(ok, f'later dangling SPDX not caught: {messages}') + joined = '\n'.join(messages) + self.assertIn('DANGLING', joined) + self.assertIn(bogus_gid, joined) + self.assertIn(str(second), joined) # names the offending document + + def test_multiple_valid_spdx_all_pass(self): + # Positive companion: two valid SPDX documents both verify, and the + # summary counts gitoids from both (not just the first). + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) # 3 gitoids + second = fx.tmpdir / 'omnibor.wolfssl-5.9.2.spdx.json' + second.write_text(json.dumps({'packages': [{ + 'name': 'wolfssl', + 'externalRefs': [{ + 'referenceCategory': 'PERSISTENT-ID', + 'referenceType': 'gitoid', + 'referenceLocator': + f'gitoid:blob:sha1:{fx.gitoids["wolfssl"]}', + }], + }]})) + ok, messages = fx.verify() + self.assertTrue(ok, messages) + self.assertIn('2 SPDX file(s), 4 gitoid(s) verified', + '\n'.join(messages)) + + def test_object_store_integrity_skips_non_blob_files(self): + # OmniBOR objects/ may contain housekeeping files at the root + # (info/, pack/, etc.) that are NOT blobs and must not be + # gitoid-checked. The fanout is exactly two levels deep + # (/); anything else gets skipped. Pin this so a + # future "walk everything" rewrite doesn't start failing on + # legitimate non-blob content. + with tempfile.TemporaryDirectory() as tmpdir: + fx = _BomshFixture(tmpdir) + # Drop a bogus file at the objects/ root and inside a + # nested subdir; neither should trigger CORRUPT. + (fx.objects_dir / 'INFO').write_text('housekeeping') + (fx.objects_dir / 'pack').mkdir() + (fx.objects_dir / 'pack' / 'index.idx').write_bytes(b'pack idx') + ok, messages = fx.verify() + self.assertTrue(ok, f'verifier flagged non-blob files: {messages}') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_sbom.py b/tests/test_sbom.py index e1d1347..6a2c8be 100755 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -5,14 +5,14 @@ * Unit tests always run. They need no gen-sbom. They cover the path scrub, the version parser, and the structural validator. - * Integration tests run only when gen-sbom is found (via --gen-sbom, GEN_SBOM, - or WOLFSSL_DIR). They cover a full generate for the source-embedded and the + * Integration tests run when gen-sbom is found (vendored, via --gen-sbom, + GEN_SBOM, or WOLFSSL_DIR). They cover a full generate for the source-embedded and the library paths, the path-scrub end to end, and byte-reproducibility. Usage: tests/test_sbom.py [--gen-sbom PATH] GEN_SBOM=/path/to/gen-sbom tests/test_sbom.py - WOLFSSL_DIR=/path/to/wolfssl tests/test_sbom.py + python3 tests/test_sbom.py """ import argparse @@ -93,14 +93,14 @@ def find_gen_sbom(explicit): env = os.environ.get("GEN_SBOM") if env and os.path.isfile(env): return env + vendored = os.path.join(SHARE, "gen-sbom") + if os.path.isfile(vendored): + return vendored wd = os.environ.get("WOLFSSL_DIR") if wd: cand = os.path.join(wd, "scripts", "gen-sbom") if os.path.isfile(cand): return cand - vendored = os.path.join(SHARE, "gen-sbom") - if os.path.isfile(vendored): - return vendored return None @@ -178,7 +178,7 @@ def main(): integration_tests(gen_sbom) else: print("[integration] SKIP: gen-sbom not found " - "(set GEN_SBOM or WOLFSSL_DIR)") + "(vendored copy missing; set GEN_SBOM or WOLFSSL_DIR)") if _fail: print(f"\n{_fail} check(s) FAILED", file=sys.stderr)