Files
wolfssl/scripts/test_gen_advisory.py
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

721 lines
30 KiB
Python

#!/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')
# --------------------------------------------------------------------------- #
# 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))
if __name__ == '__main__':
unittest.main()