Skip to content

Commit 7b4f591

Browse files
committed
fix(advisory): validate vex overlay at load time
load_overlay only json.load'd the overlay, so a hand-edited entry the schema forbids passed straight through -- above all a not_affected determination with no justification, which then degraded to a default CSAF flag and an omitted CycloneDX analysis.justification (the two VEX outputs silently disagreeing). Add a stdlib validator enforcing the overlay schema's structural invariants at load (required state, not_affected => justification for both the entry and its fips sub-object, enum membership, CVE-id keys, no unknown keys). The authoritative jsonschema pass still runs in CI; a drift test ties the validator's vocabulary to the schema file. 77 advisory tests pass (69 + 8 new).
1 parent 48496b3 commit 7b4f591

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

central/gen-advisory

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,14 +820,84 @@ def generate_cdx_vex(advs, ov_map, advisory_id, timestamp):
820820

821821
# --------------------------------------------------------------------------- #
822822

823+
# Overlay vocabulary, mirroring advisory-vex-overlay.schema.json. The state and
824+
# justification enums are the maps' own keys; the two below and the allowed-key
825+
# sets complete the schema. test_overlay_vocab_matches_schema guards drift.
826+
_OVERLAY_RESPONSES = {'can_not_fix', 'will_not_fix', 'update', 'rollback',
827+
'workaround_available'}
828+
_OVERLAY_DEFAULT_STATUS = {'on', 'off', 'enabled', 'disabled'}
829+
_OVERLAY_ENTRY_KEYS = {'state', 'justification', 'response', 'detail',
830+
'fixed_versions', 'remediation', 'requires_defines',
831+
'default_status', 'fips'}
832+
_OVERLAY_FIPS_KEYS = {'name', 'module_version', 'cmvp_cert', 'status',
833+
'justification', 'fixed_versions', 'remediation'}
834+
835+
836+
def _check_analysis(cve, obj, allowed_keys, state_key, state_required, kind):
837+
"""Validate one overlay object (entry or its fips sub-object) against the
838+
schema invariants that, if violated, would silently degrade the VEX output
839+
-- above all a not_affected determination that omits its justification."""
840+
if not isinstance(obj, dict):
841+
sys.exit(f"ERROR: overlay {cve} {kind} must be a JSON object")
842+
unknown = set(obj) - allowed_keys
843+
if unknown:
844+
sys.exit(f"ERROR: overlay {cve} {kind} has unknown key(s): "
845+
f"{', '.join(sorted(unknown))}")
846+
state = obj.get(state_key)
847+
if state_required and state is None:
848+
sys.exit(f"ERROR: overlay {cve} {kind} is missing required "
849+
f"{state_key!r}")
850+
if state is not None and state not in _STATE_TO_BUCKET:
851+
sys.exit(f"ERROR: overlay {cve} {kind} {state_key}={state!r} is not a "
852+
f"valid state ({', '.join(sorted(_STATE_TO_BUCKET))})")
853+
just = obj.get('justification')
854+
if state == 'not_affected' and not just:
855+
sys.exit(f"ERROR: overlay {cve} {kind} has {state_key}=not_affected "
856+
f"but no 'justification' (required so a CSAF flag / CycloneDX "
857+
f"analysis.justification can be emitted)")
858+
if just is not None and just not in _JUSTIFICATION_TO_CSAF_FLAG:
859+
sys.exit(f"ERROR: overlay {cve} {kind} justification={just!r} is not "
860+
f"valid ({', '.join(sorted(_JUSTIFICATION_TO_CSAF_FLAG))})")
861+
resp = obj.get('response')
862+
if resp is not None and (not isinstance(resp, list)
863+
or any(r not in _OVERLAY_RESPONSES for r in resp)):
864+
sys.exit(f"ERROR: overlay {cve} {kind} response must be a list drawn "
865+
f"from {', '.join(sorted(_OVERLAY_RESPONSES))}")
866+
ds = obj.get('default_status')
867+
if ds is not None and ds not in _OVERLAY_DEFAULT_STATUS:
868+
sys.exit(f"ERROR: overlay {cve} {kind} default_status={ds!r} is not "
869+
f"valid ({', '.join(sorted(_OVERLAY_DEFAULT_STATUS))})")
870+
871+
872+
def _validate_overlay(ov_map):
873+
"""Enforce the overlay schema's structural invariants at load time
874+
(stdlib-only; the authoritative jsonschema pass runs in CI). Without this a
875+
hand-edited overlay silently produces a wrong/omitted VEX justification."""
876+
if not isinstance(ov_map, dict):
877+
sys.exit("ERROR: --vex-overlay must be a JSON object keyed by CVE id")
878+
for cve, entry in ov_map.items():
879+
if cve == '_comment':
880+
continue
881+
if not _CVE_ID_RE.match(cve):
882+
sys.exit(f"ERROR: overlay key {cve!r} is not a CVE id "
883+
f"(expected {_CVE_ID_RE.pattern})")
884+
_check_analysis(cve, entry, _OVERLAY_ENTRY_KEYS, 'state', True, 'entry')
885+
fips = entry.get('fips')
886+
if fips is not None:
887+
_check_analysis(cve, fips, _OVERLAY_FIPS_KEYS, 'status', False,
888+
'fips')
889+
890+
823891
def load_overlay(path):
824892
if not path:
825893
return {}
826894
try:
827895
with open(path) as f:
828-
return json.load(f)
896+
data = json.load(f)
829897
except (OSError, json.JSONDecodeError) as e:
830898
sys.exit(f"ERROR: cannot read --vex-overlay {path!r}: {e}")
899+
_validate_overlay(data)
900+
return data
831901

832902

833903
def _write_json(obj, path):

central/test_gen_advisory.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,75 @@ def test_malformed_record_fails_without_writing(self):
754754
self.assertFalse(os.path.exists(csaf))
755755

756756

757+
class TestOverlayValidation(unittest.TestCase):
758+
"""load_overlay must reject overlays the schema forbids -- above all a
759+
not_affected determination missing its justification, which would otherwise
760+
silently emit a wrong/omitted VEX justification."""
761+
762+
def _load(self, obj):
763+
with tempfile.NamedTemporaryFile('w', suffix='.json',
764+
delete=False) as f:
765+
json.dump(obj, f)
766+
path = f.name
767+
try:
768+
return ga.load_overlay(path)
769+
finally:
770+
os.unlink(path)
771+
772+
def test_vocab_matches_schema(self):
773+
# The hand-rolled stdlib validator's vocabulary must stay in sync with
774+
# advisory-vex-overlay.schema.json (the authoritative jsonschema pass).
775+
with open(OVERLAY_SCHEMA) as f:
776+
s = json.load(f)
777+
d = s['$defs']
778+
self.assertEqual(set(ga._STATE_TO_BUCKET),
779+
set(d['analysisState']['enum']))
780+
self.assertEqual(set(ga._JUSTIFICATION_TO_CSAF_FLAG),
781+
set(d['justification']['enum']))
782+
self.assertEqual(ga._OVERLAY_RESPONSES,
783+
set(d['response']['items']['enum']))
784+
self.assertEqual(
785+
ga._OVERLAY_DEFAULT_STATUS,
786+
set(d['overlayEntry']['properties']['default_status']['enum']))
787+
self.assertEqual(ga._OVERLAY_ENTRY_KEYS,
788+
set(d['overlayEntry']['properties']))
789+
self.assertEqual(ga._OVERLAY_FIPS_KEYS, set(d['fips']['properties']))
790+
791+
def test_not_affected_without_justification_rejected(self):
792+
with self.assertRaises(SystemExit):
793+
self._load({'CVE-2026-1111': {'state': 'not_affected'}})
794+
795+
def test_not_affected_with_justification_ok(self):
796+
ov = self._load({'CVE-2026-1111':
797+
{'state': 'not_affected',
798+
'justification': 'code_not_present'}})
799+
self.assertIn('CVE-2026-1111', ov)
800+
801+
def test_fips_not_affected_without_justification_rejected(self):
802+
with self.assertRaises(SystemExit):
803+
self._load({'CVE-2026-1111': {
804+
'state': 'exploitable',
805+
'fips': {'name': 'wolfCrypt FIPS', 'status': 'not_affected'}}})
806+
807+
def test_unknown_key_rejected(self):
808+
with self.assertRaises(SystemExit):
809+
self._load({'CVE-2026-1111': {'state': 'exploitable',
810+
'justifcation': 'typo'}})
811+
812+
def test_bad_state_enum_rejected(self):
813+
with self.assertRaises(SystemExit):
814+
self._load({'CVE-2026-1111': {'state': 'totally_safe'}})
815+
816+
def test_non_cve_key_rejected(self):
817+
with self.assertRaises(SystemExit):
818+
self._load({'not-a-cve': {'state': 'exploitable'}})
819+
820+
def test_comment_key_allowed(self):
821+
ov = self._load({'_comment': 'note',
822+
'CVE-2026-1111': {'state': 'exploitable'}})
823+
self.assertIn('CVE-2026-1111', ov)
824+
825+
757826
class TestPathIdValidation(unittest.TestCase):
758827
"""cveId and --advisory-id are interpolated into output filenames; a
759828
record is fetched from a remote API and parsed as arbitrary JSON, so an

0 commit comments

Comments
 (0)