diff --git a/.github/workflows/sbom-reusable.yml b/.github/workflows/sbom-reusable.yml index 794c562..200adf7 100644 --- a/.github/workflows/sbom-reusable.yml +++ b/.github/workflows/sbom-reusable.yml @@ -44,6 +44,9 @@ on: jobs: sbom: runs-on: ubuntu-latest + # This job only reads the product tree and uploads an artifact. + permissions: + contents: read steps: - name: Checkout product uses: actions/checkout@v4 @@ -53,29 +56,56 @@ jobs: with: python-version: '3.x' + # Caller inputs are bound to env and referenced as "$VAR" in the shell. + # Never interpolate ${{ inputs.* }} directly into a run: block: the runner + # substitutes it before the shell parses the script, so a value containing + # shell metacharacters would execute as code (this is a reusable workflow + # shipped to every product repo). env values are not re-parsed by ${{ }}. - name: Check vendored drift if: inputs.wolfglass-ref != '' + env: + WOLFGLASS_REF: ${{ inputs.wolfglass-ref }} + VENDORED_PATH: ${{ inputs.vendored-path }} run: | - git clone --depth 1 --branch "${{ inputs.wolfglass-ref }}" \ + git clone --depth 1 --branch "$WOLFGLASS_REF" \ https://github.com/wolfSSL/wolfGlass _wolfglass python _wolfglass/tools/wolfglass-sync \ --dest "$GITHUB_WORKSPACE" \ - --subdir "${{ inputs.vendored-path }}" \ + --subdir "$VENDORED_PATH" \ --check - name: Build SBOM - run: ${{ inputs.build-command }} + env: + BUILD_COMMAND: ${{ inputs.build-command }} + run: | + bash -c "$BUILD_COMMAND" - name: Validate SBOM + env: + VENDORED_PATH: ${{ inputs.vendored-path }} + NAME_PREFIX: ${{ inputs.name-prefix }} + OUTPUTS: ${{ inputs.outputs }} run: | - python "${{ inputs.vendored-path }}/validate_sbom.py" \ - --name-prefix "${{ inputs.name-prefix }}" ${{ inputs.outputs }} + python "$VENDORED_PATH/validate_sbom.py" \ + --name-prefix "$NAME_PREFIX" $OUTPUTS - name: Assert no host path leak + env: + OUTPUTS: ${{ inputs.outputs }} run: | # A published SBOM must not contain build-machine absolute paths. - if grep -REn '"/(home|Users|root)/' ${{ inputs.outputs }}; then - echo "ERROR: absolute host path found in SBOM (scrub failed)." >&2 + # grep exit 0 = match (fail), 1 = clean, >=2 = real error (e.g. a glob + # that matched no file): treat only 1 as "clean" so a bad glob can't + # silently report OK. + set +e + out="$(grep -REn '"/(home|Users|root)/' $OUTPUTS)" + rc=$? + if [ "$rc" -eq 0 ]; then + echo "ERROR: absolute host path found in SBOM (scrub failed):" >&2 + echo "$out" >&2 + exit 1 + elif [ "$rc" -ne 1 ]; then + echo "ERROR: could not scan SBOM outputs (glob '$OUTPUTS' matched no file?)." >&2 exit 1 fi echo "OK: no host path leak." diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 41bf936..929a72a 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -30,6 +30,7 @@ jobs: share/frontends/iar_sbom.py \ share/frontends/zephyr_sbom.py \ central/gen-advisory \ + central/test_gen_advisory.py \ provenance/bomsh_verify.py \ tools/wolfglass-sync \ tests/test_gen_sbom.py \ @@ -38,5 +39,8 @@ jobs: - name: Run generator unit tests run: python -m unittest tests/test_gen_sbom.py + - name: Run advisory generator unit tests + run: python -m unittest central/test_gen_advisory.py + - name: Run self-test run: python tests/test_sbom.py diff --git a/advisories/records/CVE-2026-5501.json b/advisories/records/CVE-2026-5501.json new file mode 100644 index 0000000..ffb42e1 --- /dev/null +++ b/advisories/records/CVE-2026-5501.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "CRITICAL", + "baseScore": 9.3, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/records/CVE-2026-5778.json b/advisories/records/CVE-2026-5778.json new file mode 100644 index 0000000..1dbd230 --- /dev/null +++ b/advisories/records/CVE-2026-5778.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "HIGH", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.2, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/advisories/vex-overlay.json b/advisories/vex-overlay.json new file mode 100644 index 0000000..c04c11d --- /dev/null +++ b/advisories/vex-overlay.json @@ -0,0 +1,21 @@ +{ + "_comment": "Canonical wolfSSL VEX overlay consumed by `make advisory` / `scripts/gen-advisory`. Keyed by CVE id; carries the determinations the CVE Program record cannot express (analysis state, justification, fixed versions, remediation, optional FIPS product, optional build-reachability hedge). Constrained by scripts/advisory-vex-overlay.schema.json. To model a wolfCrypt FIPS module as a separate product, add a \"fips\" block per the format in scripts/advisory-vex-overlay.example.json using the real validated module version and CMVP certificate number (do NOT copy the illustrative placeholder values from the example).", + + "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." + }, + + "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." + } +} diff --git a/central/gen-advisory b/central/gen-advisory index 90a71b1..33bfaa3 100755 --- a/central/gen-advisory +++ b/central/gen-advisory @@ -34,6 +34,7 @@ import argparse import json import os import pathlib +import re import sys import urllib.request import uuid @@ -43,6 +44,23 @@ from datetime import datetime, timezone GEN_TOOL_NAME = 'wolfssl-advisory-gen' GEN_TOOL_VERSION = '0.3' +# CVE ids and advisory ids are interpolated into output filenames +# (.csaf.json / .cdx.json) and into the CSAF self URL. Records are +# fetched from a remote API and parsed as arbitrary JSON, so an unvalidated id +# is an untrusted-input -> arbitrary-file-write vector (e.g. cveId '../x'). +# Constrain both to a safe grammar with no path separators before either is +# used to build a path. +_CVE_ID_RE = re.compile(r'^CVE-[0-9]{4}-[0-9]{4,}$') +_ADVISORY_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$') + + +def _validate_path_id(value, kind, pattern): + """Reject an id that is unsafe to interpolate into an output path.""" + if not pattern.match(value): + sys.exit(f"ERROR: refusing unsafe {kind} {value!r}: must match " + f"{pattern.pattern} (no path separators)") + return value + _SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent _REPO_ROOT = _SCRIPTS_DIR.parent @@ -258,6 +276,7 @@ def parse_record(record): cve_id = meta.get('cveId') or cna.get('cveId') if not cve_id: sys.exit("ERROR: CVE record has no cveId") + _validate_path_id(cve_id, 'cveId', _CVE_ID_RE) description = '' for d in cna.get('descriptions', []): @@ -705,8 +724,22 @@ def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): {'name': 'wolfssl:fips:cmvp', 'value': m} for m in prod['model_numbers']] extra_components[ref] = comp - else: + elif prod['cdx_key'] == 'wolfssl': ref = main_ref + else: + # Any other product (wolfSSH, wolfMQTT, wolfTPM, ...) gets its + # own correctly-named component rather than being collapsed + # onto the wolfssl metadata.component -- otherwise the CDX VEX + # attributes a non-wolfssl product's CVE to wolfssl. + ref = derived_uuid('cdx-component', prod['cdx_key']) + if ref not in extra_components: + extra_components[ref] = { + 'bom-ref': ref, 'type': 'library', + 'supplier': {'name': 'wolfSSL Inc.'}, + 'name': prod['product_name'], + 'cpe': cpe_for(prod['product_name'], '*'), + 'purl': f'pkg:github/wolfSSL/{prod["cdx_key"]}', + } # CycloneDX affects[].versions[].status uses 'unaffected' for a # not-affected product; the 'not_affected' term belongs to # analysis.state only. @@ -787,14 +820,84 @@ def generate_cdx_vex(advs, ov_map, advisory_id, timestamp): # --------------------------------------------------------------------------- # +# Overlay vocabulary, mirroring advisory-vex-overlay.schema.json. The state and +# justification enums are the maps' own keys; the two below and the allowed-key +# sets complete the schema. test_overlay_vocab_matches_schema guards drift. +_OVERLAY_RESPONSES = {'can_not_fix', 'will_not_fix', 'update', 'rollback', + 'workaround_available'} +_OVERLAY_DEFAULT_STATUS = {'on', 'off', 'enabled', 'disabled'} +_OVERLAY_ENTRY_KEYS = {'state', 'justification', 'response', 'detail', + 'fixed_versions', 'remediation', 'requires_defines', + 'default_status', 'fips'} +_OVERLAY_FIPS_KEYS = {'name', 'module_version', 'cmvp_cert', 'status', + 'justification', 'fixed_versions', 'remediation'} + + +def _check_analysis(cve, obj, allowed_keys, state_key, state_required, kind): + """Validate one overlay object (entry or its fips sub-object) against the + schema invariants that, if violated, would silently degrade the VEX output + -- above all a not_affected determination that omits its justification.""" + if not isinstance(obj, dict): + sys.exit(f"ERROR: overlay {cve} {kind} must be a JSON object") + unknown = set(obj) - allowed_keys + if unknown: + sys.exit(f"ERROR: overlay {cve} {kind} has unknown key(s): " + f"{', '.join(sorted(unknown))}") + state = obj.get(state_key) + if state_required and state is None: + sys.exit(f"ERROR: overlay {cve} {kind} is missing required " + f"{state_key!r}") + if state is not None and state not in _STATE_TO_BUCKET: + sys.exit(f"ERROR: overlay {cve} {kind} {state_key}={state!r} is not a " + f"valid state ({', '.join(sorted(_STATE_TO_BUCKET))})") + just = obj.get('justification') + if state == 'not_affected' and not just: + sys.exit(f"ERROR: overlay {cve} {kind} has {state_key}=not_affected " + f"but no 'justification' (required so a CSAF flag / CycloneDX " + f"analysis.justification can be emitted)") + if just is not None and just not in _JUSTIFICATION_TO_CSAF_FLAG: + sys.exit(f"ERROR: overlay {cve} {kind} justification={just!r} is not " + f"valid ({', '.join(sorted(_JUSTIFICATION_TO_CSAF_FLAG))})") + resp = obj.get('response') + if resp is not None and (not isinstance(resp, list) + or any(r not in _OVERLAY_RESPONSES for r in resp)): + sys.exit(f"ERROR: overlay {cve} {kind} response must be a list drawn " + f"from {', '.join(sorted(_OVERLAY_RESPONSES))}") + ds = obj.get('default_status') + if ds is not None and ds not in _OVERLAY_DEFAULT_STATUS: + sys.exit(f"ERROR: overlay {cve} {kind} default_status={ds!r} is not " + f"valid ({', '.join(sorted(_OVERLAY_DEFAULT_STATUS))})") + + +def _validate_overlay(ov_map): + """Enforce the overlay schema's structural invariants at load time + (stdlib-only; the authoritative jsonschema pass runs in CI). Without this a + hand-edited overlay silently produces a wrong/omitted VEX justification.""" + if not isinstance(ov_map, dict): + sys.exit("ERROR: --vex-overlay must be a JSON object keyed by CVE id") + for cve, entry in ov_map.items(): + if cve == '_comment': + continue + if not _CVE_ID_RE.match(cve): + sys.exit(f"ERROR: overlay key {cve!r} is not a CVE id " + f"(expected {_CVE_ID_RE.pattern})") + _check_analysis(cve, entry, _OVERLAY_ENTRY_KEYS, 'state', True, 'entry') + fips = entry.get('fips') + if fips is not None: + _check_analysis(cve, fips, _OVERLAY_FIPS_KEYS, 'status', False, + 'fips') + + def load_overlay(path): if not path: return {} try: with open(path) as f: - return json.load(f) + data = json.load(f) except (OSError, json.JSONDecodeError) as e: sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}") + _validate_overlay(data) + return data def _write_json(obj, path): @@ -858,6 +961,10 @@ def main(): 'instead of batch mode.') args = p.parse_args() + # --advisory-id is interpolated into output filenames; constrain it too. + if args.advisory_id: + _validate_path_id(args.advisory_id, '--advisory-id', _ADVISORY_ID_RE) + # ---- resolve the input records ---- explicit = bool(args.cve_record or args.cve_id) if explicit: diff --git a/central/test_gen_advisory.py b/central/test_gen_advisory.py new file mode 100755 index 0000000..1519a9f --- /dev/null +++ b/central/test_gen_advisory.py @@ -0,0 +1,889 @@ +#!/usr/bin/env python3 +"""Unit + semantic tests for scripts/gen-advisory. + +Run from the repo root: + + python3 -m unittest scripts/test_gen_advisory.py + +These tests are pure stdlib (no network, no pip deps) so they form the cheap +PR gate, mirroring scripts/test_gen_sbom.py. They cover three things the +JSON-schema validators in .github/workflows/advisory.yml do NOT: + + 1. the pure record->model logic (CVSS priority, CWE extraction, version + ranges, the FIPS product split, the reachability hedge); + 2. CSAF *semantic* invariants that a bare JSON-schema pass accepts but the + CSAF mandatory tests reject (every referenced product_id is defined in + the product_tree, no product is simultaneously affected and not-affected, + flags only sit on not-affected products, scores only target affected + products, tracking.version matches the latest revision_history entry); + 3. the two regressions already fixed once (CycloneDX uses `unaffected` + not `not_affected` in affects[].versions[].status; every CSAF reference + carries the required `summary`). + +The full CSAF 2.0 schema + mandatory-test conformance and the CycloneDX 1.6 +strict-schema pass run in CI against csaf-validator-lib / cyclonedx-bom; this +file deliberately avoids those heavyweight deps. +""" + +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from importlib.machinery import SourceFileLoader + + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = HERE / 'gen-advisory' +TESTDATA = HERE / 'testdata' +EXAMPLE_OVERLAY = HERE / 'advisory-vex-overlay.example.json' +OVERLAY_SCHEMA = HERE / 'advisory-vex-overlay.schema.json' + +# Pinned epoch -> 2023-11-14T22:13:20Z. Shared by the reproducibility test +# and the timestamp unit test so the expected string is single-sourced. +PINNED_EPOCH = '1700000000' +PINNED_EPOCH_ISO = '2023-11-14T22:13:20Z' + + +def _load_gen_advisory(): + """Load gen-advisory (no .py extension) as module 'ga', same trick as + test_gen_sbom.py uses for gen-sbom.""" + if not SCRIPT.is_file(): + raise FileNotFoundError(f"expected gen-advisory alongside this test at {SCRIPT}") + loader = SourceFileLoader('ga', str(SCRIPT)) + spec = importlib.util.spec_from_loader('ga', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +ga = _load_gen_advisory() + + +def _record(name): + with open(TESTDATA / name) as f: + return json.load(f) + + +def _adv(name): + return ga.parse_record(_record(name)) + + +def _overlay(): + with open(EXAMPLE_OVERLAY) as f: + return json.load(f) + + +def _collect_product_ids(node): + """Every product_id declared anywhere in a CSAF product_tree branch.""" + pids = set() + prod = node.get('product') + if isinstance(prod, dict) and 'product_id' in prod: + pids.add(prod['product_id']) + for child in node.get('branches', []): + pids |= _collect_product_ids(child) + return pids + + +def _tree_product_ids(doc): + pids = set() + for branch in doc['product_tree'].get('branches', []): + pids |= _collect_product_ids(branch) + return pids + + +# Valid CSAF 2.0 enum subsets we rely on (spec 6.1.* / schema enums). +CSAF_STATUS_BUCKETS = { + 'first_affected', 'first_fixed', 'fixed', 'known_affected', + 'known_not_affected', 'last_affected', 'recommended', + 'under_investigation', +} +CSAF_FLAG_LABELS = { + 'component_not_present', 'inline_mitigations_already_exist', + 'vulnerable_code_cannot_be_controlled_by_adversary', + 'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present', +} +CSAF_REMEDIATION_CATEGORIES = { + 'mitigation', 'no_fix_planned', 'none_available', 'optional_patch', + 'vendor_fix', 'workaround', 'fix_planned', +} +CDX_AFFECTS_STATUS = {'affected', 'unaffected', 'unknown'} + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + +class TestDerivedUuid(unittest.TestCase): + def test_deterministic(self): + self.assertEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'b')) + + def test_distinct_inputs_distinct_output(self): + self.assertNotEqual(ga.derived_uuid('a', 'b'), ga.derived_uuid('a', 'c')) + + def test_no_aliasing_across_separator(self): + # NUL-separated join: ('a','bc') must not collide with ('ab','c'). + self.assertNotEqual(ga.derived_uuid('a', 'bc'), ga.derived_uuid('ab', 'c')) + + def test_is_uuid(self): + self.assertRegex( + ga.derived_uuid('x'), + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') + + +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'] = PINNED_EPOCH + _, iso = ga.build_timestamp() + self.assertEqual(iso, PINNED_EPOCH_ISO) + + def test_invalid_epoch_falls_back_to_now(self): + os.environ['SOURCE_DATE_EPOCH'] = 'not-a-number' + _, iso = ga.build_timestamp() + # Falls back to wallclock; just assert a well-formed Z timestamp. + self.assertRegex(iso, r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') + + +class TestCpePurl(unittest.TestCase): + def test_cpe(self): + self.assertEqual(ga.cpe_for('wolfSSL', '5.9.1'), + 'cpe:2.3:a:wolfssl:wolfssl:5.9.1:*:*:*:*:*:*:*') + + def test_purl(self): + self.assertEqual(ga.purl_for('wolfSSL', '5.9.1'), + 'pkg:github/wolfSSL/wolfssl@v5.9.1') + + +class TestBestCvss(unittest.TestCase): + def test_priority_v4_over_v3(self): + metrics = [{'cvssV3_1': {'x': 1}}, {'cvssV4_0': {'y': 2}}] + best = ga._best_cvss(metrics) + self.assertEqual(best['csaf_key'], 'cvss_v4') + self.assertEqual(best['cdx_method'], 'CVSSv4') + self.assertEqual(best['data'], {'y': 2}) + + def test_v31_over_v30_over_v2(self): + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}, {'cvssV3_0': {}}])['csaf_key'], + 'cvss_v3') + self.assertEqual( + ga._best_cvss([{'cvssV2_0': {}}])['csaf_key'], 'cvss_v2') + + def test_none_when_absent(self): + self.assertIsNone(ga._best_cvss([])) + self.assertIsNone(ga._best_cvss([{'other': {}}])) + + +class TestParseRecord(unittest.TestCase): + def test_core_fields(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cve'], 'CVE-2026-5501') + self.assertTrue(adv['title'].startswith('Improper Certificate')) + self.assertIn('wolfSSL_X509_verify_cert', adv['description']) + self.assertEqual(adv['date_published'], '2026-04-10T03:07:39.604Z') + self.assertEqual(adv['date_updated'], '2026-04-22T13:59:28.514Z') + + def test_cwe_id_and_canonical_name(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cwe']['id'], 'CWE-295') + # Resolved from the official catalogue (exact MITRE casing), NOT the + # record's lowercase free text -- required by CSAF test 6.1.11. + self.assertEqual(adv['cwe']['name'], 'Improper Certificate Validation') + + def test_cvss_is_v4_and_no_csaf20_compatible_score(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(adv['cvss']['csaf_key'], 'cvss_v4') + self.assertEqual(adv['cvss']['data']['baseSeverity'], 'CRITICAL') + self.assertEqual(adv['cvss']['data']['baseScore'], 9.3) + # The record carries only CVSS v4, which CSAF 2.0 scores[] cannot hold. + self.assertIsNone(adv['cvss_csaf']) + + def test_affected_and_credits(self): + adv = _adv('CVE-2026-5501.json') + self.assertEqual(len(adv['affected']), 1) + a = adv['affected'][0] + self.assertEqual(a['product'], 'wolfSSL') + self.assertEqual(a['default_status'], 'unaffected') + self.assertEqual(a['versions'][0]['lessThanOrEqual'], '5.9.0') + self.assertEqual(adv['references'], + ['https://github.com/wolfSSL/wolfssl/pull/10102']) + self.assertEqual(len(adv['credits']), 1) + + def test_missing_cveid_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'containers': {'cna': {}}, 'cveMetadata': {}}) + + def test_missing_cna_exits(self): + with self.assertRaises(SystemExit): + ga.parse_record({'cveMetadata': {'cveId': 'CVE-1'}}) + + +class TestRangeLabelAndVers(unittest.TestCase): + def test_less_than_or_equal_from_zero(self): + v = {'version': '0', 'lessThanOrEqual': '5.9.0'} + self.assertEqual(ga._range_label(v), '<= 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/<=5.9.0') + + def test_less_than_with_base(self): + v = {'version': '5.0.0', 'lessThan': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.0.0 <= x < 5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/>=5.0.0|<5.9.0') + + def test_single_version(self): + v = {'version': '5.9.0'} + self.assertEqual(ga._range_label(v), '5.9.0') + self.assertEqual(ga._vers_range(v), 'vers:generic/5.9.0') + + +class TestProductModel(unittest.TestCase): + def test_mainline_only(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model(adv, {'state': 'exploitable', + 'fixed_versions': ['5.9.1']}) + self.assertEqual(len(prods), 1) + p = prods[0] + self.assertEqual(p['product_name'], 'wolfSSL') + self.assertEqual(p['bucket'], 'known_affected') + self.assertEqual(len(p['affected_ranges']), 1) + self.assertEqual(p['fixed'][0]['version'], '5.9.1') + self.assertEqual(p['remediation_category'], 'vendor_fix') + + def test_not_affected_state_sets_bucket_and_justification(self): + adv = _adv('CVE-2026-5501.json') + prods = ga.product_model( + adv, {'state': 'not_affected', 'justification': 'code_not_present'}) + self.assertEqual(prods[0]['bucket'], 'known_not_affected') + self.assertEqual(prods[0]['justification'], 'code_not_present') + + def test_fips_modelled_as_second_product(self): + adv = _adv('CVE-2026-5501.json') + ov = _overlay()['CVE-2026-5501'] + prods = ga.product_model(adv, ov) + self.assertEqual(len(prods), 2) + fips = [p for p in prods if p['cdx_key'] == 'wolfcrypt-fips'][0] + self.assertEqual(fips['bucket'], 'known_not_affected') + self.assertEqual(fips['justification'], 'code_not_present') + self.assertIn('CMVP Certificate #4718', fips['model_numbers']) + self.assertEqual(fips['module_version'], '5.2.1') + # not-affected FIPS with no fix => no_fix_planned, not none_available. + self.assertEqual(fips['remediation_category'], 'no_fix_planned') + + def test_default_status_affected_honours_explicit_unaffected(self): + # CVE-5.x "affected-by-default with unaffected/fixed exceptions": an + # entry explicitly marked status="unaffected" must NOT be emitted as a + # vulnerable range even when defaultStatus is "affected". Guards the + # `v.get('status', default_status)` fallback (an earlier `or` form + # wrongly marked the fixed release as known_affected). + adv = {'affected': [{ + 'vendor': 'wolfSSL', 'product': 'wolfSSL', + 'default_status': 'affected', + 'versions': [ + {'status': 'affected', 'version': '0', 'lessThan': '5.9.1'}, + {'status': 'unaffected', 'version': '5.9.1'}, + ], + }]} + prods = ga.product_model(adv, {'state': 'exploitable'}) + labels = [r['label'] for r in prods[0]['affected_ranges']] + self.assertEqual(len(labels), 1) + self.assertEqual(labels, ['< 5.9.1']) + self.assertNotIn('5.9.1', labels) + + def test_default_status_affected_covers_unspecified_entries(self): + # An entry with no explicit status DOES fall back to defaultStatus. + adv = {'affected': [{ + 'vendor': 'wolfSSL', 'product': 'wolfSSL', + 'default_status': 'affected', + 'versions': [{'version': '5.8.0'}], + }]} + prods = ga.product_model(adv, {'state': 'exploitable'}) + self.assertEqual(len(prods[0]['affected_ranges']), 1) + + +class TestBucketFor(unittest.TestCase): + def test_known_states_map_to_expected_buckets(self): + self.assertEqual(ga._bucket_for('exploitable'), 'known_affected') + self.assertEqual(ga._bucket_for('not_affected'), 'known_not_affected') + self.assertEqual(ga._bucket_for('in_triage'), 'under_investigation') + + def test_unknown_state_hard_fails(self): + # A deliberately fail-loud sys.exit rather than silently defaulting an + # unrecognized determination to the worst case (known_affected). + with self.assertRaises(SystemExit): + ga._bucket_for('definitely_not_a_real_state') + + def test_product_model_propagates_unknown_state_failure(self): + adv = _adv('CVE-2026-5501.json') + with self.assertRaises(SystemExit): + ga.product_model(adv, {'state': 'definitely_not_a_real_state'}) + + +class TestHedgeNote(unittest.TestCase): + def test_renders_defines_and_default_off(self): + note = ga._hedge_note({'requires_defines': ['WOLFSSL_SNIFFER'], + 'default_status': 'off'}) + self.assertIn('WOLFSSL_SNIFFER', note) + self.assertIn('disabled by default', note) + + def test_none_when_empty(self): + self.assertIsNone(ga._hedge_note({})) + + +# --------------------------------------------------------------------------- # +# CSAF emitter: structure + semantic invariants +# --------------------------------------------------------------------------- # + +class TestGenerateCsaf(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.single = ga.generate_csaf( + [_adv('CVE-2026-5501.json')], self.ov, 'CVE-2026-5501', + PINNED_EPOCH_ISO) + self.bundle = ga.generate_csaf( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_required_document_skeleton(self): + d = self.single['document'] + self.assertEqual(d['csaf_version'], '2.0') + self.assertEqual(d['category'], 'csaf_security_advisory') + self.assertEqual(d['publisher']['category'], 'vendor') + self.assertEqual(d['tracking']['id'], 'CVE-2026-5501') + self.assertEqual(d['tracking']['status'], 'final') + self.assertIn('initial_release_date', d['tracking']) + self.assertIn('current_release_date', d['tracking']) + self.assertTrue(d['distribution']['tlp']['label']) + self.assertTrue(d['notes']) + + def test_tracking_version_matches_latest_revision(self): + # CSAF 6.1.x: for a non-draft doc the latest revision_history number + # must equal tracking.version. + tr = self.single['document']['tracking'] + latest = tr['revision_history'][-1]['number'] + self.assertEqual(tr['version'], latest) + + def test_document_references_have_summary(self): + # Regression: CSAF rejects references without `summary`. + for ref in self.single['document'].get('references', []): + self.assertIn('summary', ref) + self.assertTrue(ref['summary']) + + def test_all_product_ids_defined_in_tree(self): + for doc in (self.single, self.bundle): + defined = _tree_product_ids(doc) + self.assertTrue(defined) + for v in doc['vulnerabilities']: + for bucket, pids in v.get('product_status', {}).items(): + self.assertIn(bucket, CSAF_STATUS_BUCKETS) + self.assertTrue(set(pids) <= defined, + f'undefined pid in {bucket}') + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= defined) + for f in v.get('flags', []): + self.assertTrue(set(f['product_ids']) <= defined) + for r in v.get('remediations', []): + self.assertTrue(set(r['product_ids']) <= defined) + + def test_no_product_both_affected_and_not_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + affected = set(ps.get('known_affected', [])) + not_affected = set(ps.get('known_not_affected', [])) + self.assertEqual(affected & not_affected, set()) + + def test_vuln_references_have_summary(self): + for v in self.bundle['vulnerabilities']: + for ref in v.get('references', []): + self.assertIn('summary', ref) + + def test_flags_only_on_not_affected_products(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + not_affected = set(ps.get('known_not_affected', [])) + for f in v.get('flags', []): + self.assertIn(f['label'], CSAF_FLAG_LABELS) + self.assertTrue(set(f['product_ids']) <= not_affected) + + def test_scores_only_target_affected(self): + for v in self.bundle['vulnerabilities']: + ps = v.get('product_status', {}) + scoreable = set(ps.get('known_affected', [])) \ + | set(ps.get('under_investigation', [])) + for s in v.get('scores', []): + self.assertTrue(set(s['products']) <= scoreable) + + def test_no_cvss_v4_in_csaf_scores(self): + # Regression: CSAF 2.0 scores[] has no cvss_v4 property; a v4 block + # there fails the strict schema. These records are v4-only, so no + # scores[] should be emitted at all. + for doc in (self.single, self.bundle): + for v in doc['vulnerabilities']: + for s in v.get('scores', []): + self.assertNotIn('cvss_v4', s) + + def test_v4_only_record_emits_cvss_note(self): + # The v4 rating must not be silently dropped from CSAF: it is preserved + # as a note pointing at the CycloneDX VEX for the machine-readable form. + v = self.single['vulnerabilities'][0] + titles = [n.get('title') for n in v['notes']] + self.assertIn('CVSS v4.0', titles) + note = [n for n in v['notes'] if n.get('title') == 'CVSS v4.0'][0] + self.assertIn('9.3', note['text']) + + def test_cwe_uses_canonical_catalogue_name(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + self.assertEqual(v['cwe']['id'], 'CWE-191') + self.assertEqual(v['cwe']['name'], + 'Integer Underflow (Wrap or Wraparound)') + + def test_remediation_categories_valid(self): + for v in self.bundle['vulnerabilities']: + for r in v.get('remediations', []): + self.assertIn(r['category'], CSAF_REMEDIATION_CATEGORIES) + + def test_fips_is_its_own_product_branch(self): + names = set() + + def walk(node): + if node.get('category') == 'product_name': + names.add(node['name']) + for c in node.get('branches', []): + walk(c) + for b in self.single['product_tree']['branches']: + walk(b) + self.assertIn('wolfSSL', names) + self.assertTrue(any('FIPS' in n for n in names), + f'expected a FIPS product branch, got {names}') + + def test_bundle_has_two_vulns_and_aggregate_severity(self): + self.assertEqual(len(self.bundle['vulnerabilities']), 2) + cves = {v['cve'] for v in self.bundle['vulnerabilities']} + self.assertEqual(cves, {'CVE-2026-5501', 'CVE-2026-5778'}) + # CRITICAL (5501) outranks HIGH (5778). + self.assertEqual(self.bundle['document']['aggregate_severity']['text'], + 'CRITICAL') + + def test_hedge_note_present_for_sniffer_cve(self): + v = [x for x in self.bundle['vulnerabilities'] + if x['cve'] == 'CVE-2026-5778'][0] + texts = ' '.join(n['text'] for n in v['notes']) + self.assertIn('WOLFSSL_SNIFFER', texts) + + +class TestCsafV3Scores(unittest.TestCase): + """The v4-only fixtures never populate CSAF scores[]; this exercises the + positive path with a CVSS v3.1 record (CSAF 2.0 can carry v3).""" + + def setUp(self): + self.ov = _overlay() + self.adv = _adv('CVE-2026-5999.json') + self.doc = ga.generate_csaf([self.adv], self.ov, 'CVE-2026-5999', + PINNED_EPOCH_ISO) + + def test_parse_selects_v3_for_csaf(self): + self.assertEqual(self.adv['cvss']['csaf_key'], 'cvss_v3') + self.assertIsNotNone(self.adv['cvss_csaf']) + self.assertEqual(self.adv['cvss_csaf']['csaf_key'], 'cvss_v3') + self.assertEqual(self.adv['cvss_csaf']['data']['baseScore'], 7.5) + + def test_csaf_emits_cvss_v3_score(self): + v = self.doc['vulnerabilities'][0] + self.assertEqual(len(v['scores']), 1) + score = v['scores'][0] + self.assertIn('cvss_v3', score) + self.assertNotIn('cvss_v4', score) + self.assertTrue(score['products']) + # v3 path -> no CVSS v4 fallback note. + self.assertNotIn('CVSS v4.0', [n.get('title') for n in v['notes']]) + + def test_aggregate_severity_from_v3(self): + self.assertEqual(self.doc['document']['aggregate_severity']['text'], + 'HIGH') + + +# --------------------------------------------------------------------------- # +# CycloneDX VEX emitter +# --------------------------------------------------------------------------- # + +class TestGenerateCdxVex(unittest.TestCase): + def setUp(self): + self.ov = _overlay() + self.bom = ga.generate_cdx_vex( + [_adv('CVE-2026-5501.json'), _adv('CVE-2026-5778.json')], + self.ov, 'wolfSSL-SA-5.9.1', PINNED_EPOCH_ISO) + + def test_bom_skeleton(self): + self.assertEqual(self.bom['bomFormat'], 'CycloneDX') + self.assertEqual(self.bom['specVersion'], '1.6') + self.assertRegex(self.bom['serialNumber'], r'^urn:uuid:[0-9a-f-]{36}$') + self.assertEqual(self.bom['metadata']['component']['name'], 'wolfssl') + + def test_fips_component_present(self): + names = {c['name'] for c in self.bom['components']} + self.assertTrue(any('FIPS' in n for n in names), names) + + def test_affects_status_uses_unaffected_not_not_affected(self): + # Regression sentinel: CycloneDX affects[].versions[].status only + # accepts affected/unaffected/unknown; not_affected belongs to + # analysis.state alone. + for v in self.bom['vulnerabilities']: + for aff in v['affects']: + for ver in aff.get('versions', []): + self.assertIn(ver['status'], CDX_AFFECTS_STATUS) + + def test_not_affected_fips_range_is_unaffected(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + # the FIPS component is not_affected -> its range status is unaffected. + fips_refs = {c['bom-ref'] for c in self.bom['components']} + fips_affects = [a for a in v['affects'] if a['ref'] in fips_refs] + self.assertTrue(fips_affects) + for a in fips_affects: + for ver in a['versions']: + self.assertEqual(ver['status'], 'unaffected') + + def test_analysis_state_and_cwe_and_rating(self): + v = [x for x in self.bom['vulnerabilities'] + if x['id'] == 'CVE-2026-5501'][0] + self.assertEqual(v['analysis']['state'], 'exploitable') + self.assertEqual(v['cwes'], [295]) + self.assertEqual(v['ratings'][0]['method'], 'CVSSv4') + self.assertEqual(v['ratings'][0]['severity'], 'critical') + + +class TestCdxVexNonWolfsslProduct(unittest.TestCase): + """Regression: a non-wolfssl product's CVE must be attributed to its own + component, not collapsed onto the wolfssl metadata.component (which would + make the CDX VEX say a wolfSSH bug is a wolfssl bug).""" + + def setUp(self): + rec = { + 'cveMetadata': {'cveId': 'CVE-2026-9001'}, + 'containers': {'cna': { + 'descriptions': [{'lang': 'en', 'value': 'A wolfSSH issue.'}], + 'affected': [{'vendor': 'wolfSSL', 'product': 'wolfSSH', + 'versions': [{'version': '0', + 'lessThanOrEqual': '1.4.19', + 'status': 'affected'}]}], + }}, + } + self.bom = ga.generate_cdx_vex([ga.parse_record(rec)], {}, + 'wolfSSH-SA-1', PINNED_EPOCH_ISO) + + def test_metadata_component_stays_wolfssl_umbrella(self): + self.assertEqual(self.bom['metadata']['component']['name'], 'wolfssl') + + def test_product_gets_its_own_component(self): + comps = {c['name']: c for c in self.bom['components']} + self.assertIn('wolfSSH', comps) + self.assertEqual(comps['wolfSSH']['purl'], 'pkg:github/wolfSSL/wolfssh') + self.assertEqual(comps['wolfSSH']['cpe'], + 'cpe:2.3:a:wolfssl:wolfssh:*:*:*:*:*:*:*:*') + + def test_affects_points_to_product_not_wolfssl(self): + wolfssh_ref = next(c['bom-ref'] for c in self.bom['components'] + if c['name'] == 'wolfSSH') + main_ref = self.bom['metadata']['component']['bom-ref'] + refs = {a['ref'] for a in self.bom['vulnerabilities'][0]['affects']} + self.assertIn(wolfssh_ref, refs) + self.assertNotIn(main_ref, refs) + + +# --------------------------------------------------------------------------- # +# Overlay matches its own schema vocabulary (lightweight, no jsonschema). +# The authoritative jsonschema pass runs in CI; this guards the committed +# example overlay against drift without adding a pip dep to the unit gate. +# --------------------------------------------------------------------------- # + +class TestExampleOverlay(unittest.TestCase): + def setUp(self): + with open(OVERLAY_SCHEMA) as f: + self.schema = json.load(f) + self.overlay = _overlay() + + def _enum(self, name): + return set(self.schema['$defs'][name]['enum']) + + def test_states_and_justifications_in_vocab(self): + states = self._enum('analysisState') + justifications = self._enum('justification') + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if 'state' in entry: + self.assertIn(entry['state'], states) + if 'justification' in entry: + self.assertIn(entry['justification'], justifications) + fips = entry.get('fips', {}) + if 'status' in fips: + self.assertIn(fips['status'], states) + if 'justification' in fips: + self.assertIn(fips['justification'], justifications) + + def test_not_affected_requires_justification(self): + for cve, entry in self.overlay.items(): + if cve.startswith('_'): + continue + if entry.get('state') == 'not_affected': + self.assertIn('justification', entry) + if entry.get('fips', {}).get('status') == 'not_affected': + self.assertIn('justification', entry['fips']) + + +# --------------------------------------------------------------------------- # +# End-to-end via the CLI: reproducibility + fail-loud behaviour. +# --------------------------------------------------------------------------- # + +class TestCliBehaviour(unittest.TestCase): + def _run(self, args, env=None): + e = dict(os.environ) + if env: + e.update(env) + return subprocess.run([sys.executable, str(SCRIPT)] + args, + capture_output=True, text=True, env=e) + + def test_reproducible_under_source_date_epoch(self): + with tempfile.TemporaryDirectory() as d: + outs = [] + for i in (1, 2): + csaf = os.path.join(d, f'a{i}.csaf.json') + cdx = os.path.join(d, f'a{i}.cdx.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--advisory-id', 'wolfSSL-SA-5.9.1', + '--csaf-out', csaf, '--cdx-vex-out', cdx], + env={'SOURCE_DATE_EPOCH': PINNED_EPOCH}) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf, 'rb') as f: + csaf_b = f.read() + with open(cdx, 'rb') as f: + cdx_b = f.read() + outs.append((csaf_b, cdx_b)) + self.assertEqual(outs[0][0], outs[1][0], 'CSAF not reproducible') + self.assertEqual(outs[0][1], outs[1][1], 'CDX not reproducible') + + def test_single_record_defaults_advisory_id_to_cve(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'one.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--vex-overlay', str(EXAMPLE_OVERLAY), + '--csaf-out', csaf]) + self.assertEqual(r.returncode, 0, r.stderr) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], + 'CVE-2026-5501') + + def test_bundling_without_advisory_id_fails(self): + with tempfile.TemporaryDirectory() as d: + csaf = os.path.join(d, 'x.csaf.json') + r = self._run([ + '--cve-record', str(TESTDATA / 'CVE-2026-5501.json'), + '--cve-record', str(TESTDATA / 'CVE-2026-5778.json'), + '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf), + 'no output should be written on error') + + def test_empty_records_dir_fails(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + r = self._run(['--records-dir', recs, '--out-dir', d]) + self.assertNotEqual(r.returncode, 0) + self.assertIn('no CVE records found', r.stderr) + + def test_batch_mode_writes_per_cve_documents(self): + with tempfile.TemporaryDirectory() as d: + recs = os.path.join(d, 'records') + os.makedirs(recs) + shutil.copy(str(TESTDATA / 'CVE-2026-5501.json'), + os.path.join(recs, 'CVE-2026-5501.json')) + shutil.copy(str(TESTDATA / 'CVE-2026-5999.json'), + os.path.join(recs, 'CVE-2026-5999.json')) + out = os.path.join(d, 'out') + r = self._run(['--records-dir', recs, '--out-dir', out, + '--vex-overlay', str(EXAMPLE_OVERLAY)]) + self.assertEqual(r.returncode, 0, r.stderr) + for cve in ('CVE-2026-5501', 'CVE-2026-5999'): + csaf = os.path.join(out, f'{cve}.csaf.json') + cdx = os.path.join(out, f'{cve}.cdx.json') + self.assertTrue(os.path.exists(csaf), csaf) + self.assertTrue(os.path.exists(cdx), cdx) + with open(csaf) as f: + doc = json.load(f) + self.assertEqual(doc['document']['tracking']['id'], cve) + + def test_default_records_dir_is_canonical_tree(self): + # No --cve-record/--cve-id and no --records-dir: must fall back to the + # canonical advisories/records/ tree (the same inputs `make advisory` + # uses). Output is redirected to a temp dir so the repo is untouched. + with tempfile.TemporaryDirectory() as d: + r = self._run(['--out-dir', d]) + self.assertEqual(r.returncode, 0, r.stderr) + produced = sorted(f for f in os.listdir(d) + if f.endswith('.csaf.json')) + self.assertIn('CVE-2026-5501.csaf.json', produced) + self.assertIn('CVE-2026-5778.csaf.json', produced) + + def test_malformed_record_fails_without_writing(self): + with tempfile.TemporaryDirectory() as d: + bad = os.path.join(d, 'bad.json') + with open(bad, 'w') as f: + f.write('{ this is not json') + csaf = os.path.join(d, 'out.csaf.json') + r = self._run(['--cve-record', bad, '--csaf-out', csaf]) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(os.path.exists(csaf)) + + +class TestOverlayValidation(unittest.TestCase): + """load_overlay must reject overlays the schema forbids -- above all a + not_affected determination missing its justification, which would otherwise + silently emit a wrong/omitted VEX justification.""" + + def _load(self, obj): + with tempfile.NamedTemporaryFile('w', suffix='.json', + delete=False) as f: + json.dump(obj, f) + path = f.name + try: + return ga.load_overlay(path) + finally: + os.unlink(path) + + def test_vocab_matches_schema(self): + # The hand-rolled stdlib validator's vocabulary must stay in sync with + # advisory-vex-overlay.schema.json (the authoritative jsonschema pass). + with open(OVERLAY_SCHEMA) as f: + s = json.load(f) + d = s['$defs'] + self.assertEqual(set(ga._STATE_TO_BUCKET), + set(d['analysisState']['enum'])) + self.assertEqual(set(ga._JUSTIFICATION_TO_CSAF_FLAG), + set(d['justification']['enum'])) + self.assertEqual(ga._OVERLAY_RESPONSES, + set(d['response']['items']['enum'])) + self.assertEqual( + ga._OVERLAY_DEFAULT_STATUS, + set(d['overlayEntry']['properties']['default_status']['enum'])) + self.assertEqual(ga._OVERLAY_ENTRY_KEYS, + set(d['overlayEntry']['properties'])) + self.assertEqual(ga._OVERLAY_FIPS_KEYS, set(d['fips']['properties'])) + + def test_not_affected_without_justification_rejected(self): + with self.assertRaises(SystemExit): + self._load({'CVE-2026-1111': {'state': 'not_affected'}}) + + def test_not_affected_with_justification_ok(self): + ov = self._load({'CVE-2026-1111': + {'state': 'not_affected', + 'justification': 'code_not_present'}}) + self.assertIn('CVE-2026-1111', ov) + + def test_fips_not_affected_without_justification_rejected(self): + with self.assertRaises(SystemExit): + self._load({'CVE-2026-1111': { + 'state': 'exploitable', + 'fips': {'name': 'wolfCrypt FIPS', 'status': 'not_affected'}}}) + + def test_unknown_key_rejected(self): + with self.assertRaises(SystemExit): + self._load({'CVE-2026-1111': {'state': 'exploitable', + 'justifcation': 'typo'}}) + + def test_bad_state_enum_rejected(self): + with self.assertRaises(SystemExit): + self._load({'CVE-2026-1111': {'state': 'totally_safe'}}) + + def test_non_cve_key_rejected(self): + with self.assertRaises(SystemExit): + self._load({'not-a-cve': {'state': 'exploitable'}}) + + def test_comment_key_allowed(self): + ov = self._load({'_comment': 'note', + 'CVE-2026-1111': {'state': 'exploitable'}}) + self.assertIn('CVE-2026-1111', ov) + + +class TestPathIdValidation(unittest.TestCase): + """cveId and --advisory-id are interpolated into output filenames; a + record is fetched from a remote API and parsed as arbitrary JSON, so an + unvalidated id is an arbitrary-file-write vector. Guard both.""" + + def _run(self, args): + return subprocess.run([sys.executable, str(SCRIPT)] + args, + capture_output=True, text=True) + + @staticmethod + def _record(cve_id): + return {'cveMetadata': {'cveId': cve_id}, + 'containers': {'cna': { + 'descriptions': [{'lang': 'en', 'value': 'test desc'}], + 'affected': [{'vendor': 'wolfSSL', 'product': 'wolfSSL', + 'versions': []}]}}} + + def test_traversal_cveid_rejected_and_writes_nothing_outside(self): + with tempfile.TemporaryDirectory() as d: + rec = os.path.join(d, 'evil.json') + with open(rec, 'w') as f: + json.dump(self._record('../ESCAPED'), f) + out = os.path.join(d, 'out', 'batch') + os.makedirs(out) + r = self._run(['--cve-record', rec, '--out-dir', out]) + self.assertNotEqual(r.returncode, 0, r.stdout) + self.assertIn('unsafe cveId', r.stderr) + # The escaped path (sibling of out/, i.e. d/out/ESCAPED.*) must + # not have been written. + escaped = os.path.join(d, 'out', 'ESCAPED.csaf.json') + self.assertFalse(os.path.exists(escaped), escaped) + + def test_absolute_cveid_rejected(self): + with tempfile.TemporaryDirectory() as d: + rec = os.path.join(d, 'abs.json') + with open(rec, 'w') as f: + json.dump(self._record('/etc/pwned'), f) + r = self._run(['--cve-record', rec, '--out-dir', d]) + self.assertNotEqual(r.returncode, 0, r.stdout) + self.assertIn('unsafe cveId', r.stderr) + + def test_well_formed_cveid_accepted(self): + with tempfile.TemporaryDirectory() as d: + rec = os.path.join(d, 'good.json') + with open(rec, 'w') as f: + json.dump(self._record('CVE-2026-12345'), f) + r = self._run(['--cve-record', rec, '--out-dir', d]) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue( + os.path.exists(os.path.join(d, 'CVE-2026-12345.csaf.json'))) + + def test_traversal_advisory_id_rejected(self): + with tempfile.TemporaryDirectory() as d: + rec = os.path.join(d, 'good.json') + with open(rec, 'w') as f: + json.dump(self._record('CVE-2026-12345'), f) + r = self._run(['--cve-record', rec, '--out-dir', d, + '--advisory-id', '../evil']) + self.assertNotEqual(r.returncode, 0, r.stdout) + self.assertIn('unsafe --advisory-id', r.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/central/testdata/CVE-2026-5501.json b/central/testdata/CVE-2026-5501.json new file mode 100644 index 0000000..ffb42e1 --- /dev/null +++ b/central/testdata/CVE-2026-5501.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5501", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-03T15:46:09.302Z", + "datePublished": "2026-04-10T03:07:39.604Z", + "dateUpdated": "2026-04-22T13:59:28.514Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-10T03:07:39.604Z" + }, + "title": "Improper Certificate Signature Verification in X.509 Chain Validation Allows Forged Leaf Certificates", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-295", + "description": "CWE-295 Improper certificate validation", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "wolfSSL_X509_verify_cert" + ], + "programFiles": [ + "src/x509_str.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "wolfSSL_X509_verify_cert in the OpenSSL compatibility layer accepts a certificate chain in which the leaf's signature is not checked, if the attacker supplies an untrusted intermediate with Basic Constraints `CA:FALSE` that is legitimately signed by a trusted root. An attacker who obtains any leaf certificate from a trusted CA (e.g. a free DV cert from Let's Encrypt) can forge a certificate for any subject name with any public key and arbitrary signature bytes, and the function returns `WOLFSSL_SUCCESS` / `X509_V_OK`. The native wolfSSL TLS handshake path (`ProcessPeerCerts`) is not susceptible and the issue is limited to applications using the OpenSSL compatibility API directly, which would include integrations of wolfSSL into nginx and haproxy." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10102" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "HIGH", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "CRITICAL", + "baseScore": 9.3, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Calif.io in collaboration with Claude and Anthropic Research", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/central/testdata/CVE-2026-5778.json b/central/testdata/CVE-2026-5778.json new file mode 100644 index 0000000..1dbd230 --- /dev/null +++ b/central/testdata/CVE-2026-5778.json @@ -0,0 +1,122 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5778", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-08T08:25:15.400Z", + "datePublished": "2026-04-09T21:45:09.053Z", + "dateUpdated": "2026-04-10T13:53:29.181Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-09T21:45:09.053Z" + }, + "title": "Integer underflow leads to out-of-bounds access in sniffer ChaCha decrypt path.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-191", + "description": "CWE-191 Integer underflow (wrap or wraparound)", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "modules": [ + "Packet sniffer" + ], + "programFiles": [ + "src/sniffer.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records.", + "supportingMedia": [ + { + "type": "text/html", + "base64": false, + "value": "Integer underflow in wolfSSL packet sniffer <= 5.9.0 allows an attacker to cause a program crash in the AEAD decryption path by injecting a TLS record shorter than the explicit IV plus authentication tag into traffic inspected by ssl_DecodePacket. The underflow wraps a 16-bit length to a large value that is passed to AEAD decryption routines, causing a large out-of-bounds read and crash. An unauthenticated attacker can trigger this remotely via malformed TLS Application Data records." + } + ] + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/10125" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "PRESENT", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "NONE", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "HIGH", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.2, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "Zou Dikai", + "type": "finder" + } + ], + "source": { + "discovery": "EXTERNAL" + }, + "x_generator": { + "engine": "Vulnogram 1.0.1" + } + } + } +} diff --git a/central/testdata/CVE-2026-5999.json b/central/testdata/CVE-2026-5999.json new file mode 100644 index 0000000..9d60025 --- /dev/null +++ b/central/testdata/CVE-2026-5999.json @@ -0,0 +1,99 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-5999", + "assignerOrgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "state": "PUBLISHED", + "assignerShortName": "wolfSSL", + "dateReserved": "2026-04-15T09:00:00.000Z", + "datePublished": "2026-04-18T12:00:00.000Z", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "50d2cd11-d01a-48ed-9441-5bfce9d63b27", + "shortName": "wolfSSL", + "dateUpdated": "2026-04-18T12:00:00.000Z" + }, + "title": "Out-of-bounds read parsing a malformed DTLS handshake message.", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-125", + "description": "CWE-125 Out-of-bounds read", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "vendor": "wolfSSL", + "product": "wolfSSL", + "programFiles": [ + "src/dtls.c" + ], + "versions": [ + { + "status": "affected", + "version": "0", + "lessThanOrEqual": "5.9.0", + "versionType": "semver" + } + ], + "defaultStatus": "unaffected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "A synthetic test fixture (not a real CVE). An out-of-bounds read in wolfSSL DTLS handshake parsing <= 5.9.0 allows a remote unauthenticated attacker to read past the end of a record buffer by sending a malformed handshake message, potentially crashing the server. This record exists to exercise the CVSS v3.1 scores[] path of gen-advisory." + } + ], + "references": [ + { + "url": "https://github.com/wolfSSL/wolfssl/pull/99999" + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV3_1": { + "version": "3.1", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "NONE", + "availabilityImpact": "NONE", + "baseScore": 7.5, + "baseSeverity": "HIGH", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" + } + } + ], + "credits": [ + { + "lang": "en", + "value": "wolfSSL internal testing", + "type": "finder" + } + ], + "source": { + "discovery": "INTERNAL" + } + } + } +} diff --git a/central/testdata/README.md b/central/testdata/README.md new file mode 100644 index 0000000..180bc51 --- /dev/null +++ b/central/testdata/README.md @@ -0,0 +1,11 @@ +# gen-advisory test fixtures + +CVE Program records (CVE JSON 5.x) used by `scripts/test_gen_advisory.py` and +the `.github/workflows/advisory.yml` jobs. Committed so the tests are hermetic +(no network fetch from cve.org at test time). + +| File | Provenance | +|------|------------| +| `CVE-2026-5501.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5778.json` | Real published wolfSSL CNA record (CVSS v4 only). | +| `CVE-2026-5999.json` | **Synthetic fixture, not a real CVE.** Carries a CVSS v3.1 block so the CSAF `scores[]` emission path (and the CVSS-consistency mandatory tests 6.1.8/6.1.9) is exercised; the v4-only records above never populate `scores[]` in CSAF 2.0. | diff --git a/share/sbom.am b/share/sbom.am index e43b8a5..49d7a16 100644 --- a/share/sbom.am +++ b/share/sbom.am @@ -49,9 +49,14 @@ # --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. +# gen-sbom is located in the vendored directory (SBOM_VENDOR_DIR, default +# $(srcdir)/tools/sbom), or, if not vendored, in a wolfSSL source tree via +# WOLFSSL_DIR (the wolfSSH-style route); SBOM_GEN overrides both. NOTE: this +# fragment cannot locate itself at make time -- Automake's `include` is textual, +# so $(MAKEFILE_LIST) resolves to the top Makefile, not this file -- which is why +# the vendored directory is named explicitly rather than derived from the +# fragment's own path. python3, pyspdxtools and git come from configure +# (AC_PATH_PROG); git is used only to derive SOURCE_DATE_EPOCH. # # NOTE: this fragment requires GNU make. It uses GNU conditional assignment # (?=) and the GNU make functions $(wildcard), $(if), $(firstword) and @@ -64,7 +69,9 @@ 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))) +# Directory the wolfGlass tooling (gen-sbom) is vendored into. Products that +# vendor elsewhere override this (or SBOM_GEN directly). +SBOM_VENDOR_DIR ?= $(srcdir)/tools/sbom SBOM_CDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).cdx.json SBOM_SPDX = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx.json @@ -73,8 +80,12 @@ SBOM_SPDX_TV = $(SBOM_PKGNAME)-$(PACKAGE_VERSION).spdx # equals $(datadir)/doc/$(PACKAGE) by default). sbomdir = $(docdir) -# Prefer the vendored sibling copy. Callers may override SBOM_GEN explicitly. -SBOM_GEN ?= $(abspath $(SBOM_AM_DIR)/gen-sbom) +# Prefer the vendored copy; else fall back to a wolfSSL source tree via +# WOLFSSL_DIR. Empty if neither exists -- the `test -f "$(SBOM_GEN)"` check in +# the recipe then fails with a clear error. Callers may override SBOM_GEN. +SBOM_GEN ?= $(abspath $(firstword \ + $(wildcard $(SBOM_VENDOR_DIR)/gen-sbom) \ + $(if $(WOLFSSL_DIR),$(wildcard $(WOLFSSL_DIR)/scripts/gen-sbom)))) # Library artifact search order (versioned first) covering ELF, Mach-O and PE. # Windows import libs (.lib) come with and without the "lib" prefix.