mirror of
https://github.com/wolfSSL/wolfssl.git
synced 2026-08-11 17:01:28 +02:00
- 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>
921 lines
38 KiB
Python
Executable File
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()
|