Files
wolfssl/scripts/gen-advisory
T
Sameeh JubranandMark Atwood 5ab0ad9fab sbom: address advisory/SBOM review findings
- gen-advisory: honour explicit per-version status when defaultStatus is
  "affected", so unaffected/fixed releases are no longer marked vulnerable
- gen-advisory: fail loudly when a CVE record has no non-empty English
  description (CSAF/CycloneDX note text is required, minLength 1)
- gen-advisory: note that --cve-id fetches from the CVE Services API
- bomsh_verify: scope the object-store shape check to sha1, matching the
  sha1 gitoid hashing (drop the unreachable sha256-length branch)
- Makefile.am: fail `make bomsh` early when python3/pyspdxtools are absent;
  quote $(ENABLED_LIBZ)/$(ENABLED_LIBOQS); consolidate clean-local so the
  omnibor/ and advisories/out/ build dirs are removed on clean
- tests: cover the defaultStatus fix, the _bucket_for unknown-state
  hard-fail, and a csaf_validate.mjs runner self-test wired into CI

Signed-off-by: Sameeh Jubran <sameeh@wolfssl.com>
2026-07-23 21:51:26 -07:00

921 lines
38 KiB
Python
Executable File

#!/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 <base>/<tracking-id>.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
# '<ID>.csaf.json' form here so the self URL stays consistent with the
# emitted file and so co-located '<ID>.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()