@blamejs/exceptd-skills 0.19.31 → 0.19.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +32 -23
- package/CHANGELOG.md +28 -0
- package/CONTEXT.md +19 -11
- package/README.md +99 -18
- package/SECURITY.md +65 -45
- package/data/_indexes/_meta.json +9 -9
- package/data/_indexes/activity-feed.json +8 -8
- package/data/_indexes/catalog-summaries.json +8 -8
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +11117 -446
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8331 -2
- package/lib/cve-batch.js +27 -57
- package/lib/cve-curation.js +26 -1
- package/lib/cve-enrich.js +18 -43
- package/lib/refresh-external.js +100 -1
- package/lib/scoring.js +158 -372
- package/lib/validate-cve-catalog.js +11 -0
- package/manifest.json +53 -53
- package/package.json +2 -2
- package/sbom.cdx.json +47 -47
- package/sources/validators/cve-validator.js +40 -15
package/lib/cve-batch.js
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Batch curation
|
|
4
|
-
//
|
|
5
|
-
// entry has an orphaned reference or an assembly error
|
|
6
|
-
//
|
|
7
|
-
// zeroday-lessons.json (canonical writer, it DOES round-trip), and one
|
|
8
|
-
// tests/cve-<id>.test.js per curated CVE. Wired to the CLI as
|
|
9
|
-
// `refresh --curate-batch --facts <path> --judgments <path> [--apply]`.
|
|
3
|
+
// Batch curation: facts + judgments through cve-enrich.assembleEntry, then the
|
|
4
|
+
// catalog, zeroday-lessons.json and one tests/cve-<id>.test.js per CVE. Nothing
|
|
5
|
+
// is written if any entry has an orphaned reference or an assembly error.
|
|
6
|
+
// CLI: `refresh --curate-batch --facts <path> --judgments <path> [--apply]`.
|
|
10
7
|
|
|
11
8
|
const fs = require('fs');
|
|
12
9
|
const path = require('path');
|
|
13
10
|
const enrich = require('./cve-enrich.js');
|
|
14
11
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// ---------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
// Find the insertion point just after the top-level "_meta" member. Brace-
|
|
20
|
-
// matches from the "_meta" key. The catalog is 2-space-indented; new members
|
|
21
|
-
// are emitted at that indent.
|
|
12
|
+
// The insertion point just after the top-level "_meta" member, brace-matched
|
|
13
|
+
// from its key. The catalog is 2-space-indented and new members match it.
|
|
22
14
|
function _metaEnd(text) {
|
|
23
15
|
const m = /\n {2}"_meta"\s*:/.exec(text);
|
|
24
16
|
if (!m) throw new Error('cve-batch: could not locate top-level "_meta" member');
|
|
@@ -31,26 +23,20 @@ function _metaEnd(text) {
|
|
|
31
23
|
else if (c === '"') { i++; while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; } }
|
|
32
24
|
}
|
|
33
25
|
const comma = text.indexOf(',', i);
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// leading comma and no trailing comma.
|
|
26
|
+
// Whitespace-only gap to the next comma means _meta has a following member,
|
|
27
|
+
// so insert after the separator; otherwise _meta is last and the insert
|
|
28
|
+
// supplies its own leading comma.
|
|
38
29
|
if (comma !== -1 && text.slice(i, comma).trim() === '') return { at: comma + 1, leadingComma: false };
|
|
39
30
|
return { at: i, leadingComma: true };
|
|
40
31
|
}
|
|
41
32
|
|
|
42
|
-
// Ids already present as top-level
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// honest outcome: the caller restores the catalog and applies once.
|
|
33
|
+
// Ids already present as top-level catalog members. insertEntries appends and
|
|
34
|
+
// cannot replace, so writing one twice leaves a duplicate JSON key; JSON.parse
|
|
35
|
+
// keeps the last, which is the stale copy, while every count and schema check
|
|
36
|
+
// still passes. Parsed rather than pattern-matched: a CVE id quoted inside
|
|
37
|
+
// another entry's prose is not a member, and building a RegExp from caller ids
|
|
38
|
+
// would be a ReDoS sink in the one function that exists to distrust them.
|
|
49
39
|
function existingIds(catalogText, ids) {
|
|
50
|
-
// Parsed, not pattern-matched. A top-level key is exactly what JSON.parse
|
|
51
|
-
// reports, so this cannot mistake a CVE id quoted inside another entry's prose
|
|
52
|
-
// for a member — and it builds no RegExp from the caller's ids, which would be
|
|
53
|
-
// a ReDoS sink for input this function exists to distrust.
|
|
54
40
|
let parsed;
|
|
55
41
|
try {
|
|
56
42
|
parsed = JSON.parse(catalogText);
|
|
@@ -80,12 +66,10 @@ function insertEntries(catalogText, entriesById) {
|
|
|
80
66
|
}
|
|
81
67
|
|
|
82
68
|
function recomputeAiMeta(catalogText, aiCount, total) {
|
|
83
|
-
// current_rate is the
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// raw rate of 0.02262 (rounds to 0.023) leave a 0.023 floor the raw rate
|
|
88
|
-
// cannot clear. Never raise the floor; only lower it when the rate dilutes.
|
|
69
|
+
// current_rate is ROUNDED; the floor is enforced against the RAW rate, so the
|
|
70
|
+
// floor must be the raw rate FLOORED to 3 decimals. Rounding both leaves a
|
|
71
|
+
// floor the raw rate cannot clear (0.02262 rounds to 0.023). Only ever lower
|
|
72
|
+
// the floor — raising it fails the corpus it describes.
|
|
89
73
|
const rawRate = aiCount / total;
|
|
90
74
|
const rate = Math.round(rawRate * 1000) / 1000;
|
|
91
75
|
let out = catalogText.replace(/("current_rate"\s*:\s*)[0-9.]+/, `$1${rate}`);
|
|
@@ -102,10 +86,7 @@ function recomputeAiMeta(catalogText, aiCount, total) {
|
|
|
102
86
|
return out;
|
|
103
87
|
}
|
|
104
88
|
|
|
105
|
-
//
|
|
106
|
-
// zeroday-lessons.json writer (Task 7) — this file DOES round-trip.
|
|
107
|
-
// ---------------------------------------------------------------------
|
|
108
|
-
|
|
89
|
+
// zeroday-lessons.json round-trips, so it is rewritten whole rather than spliced.
|
|
109
90
|
function addLessons(lessonsObj, lessonsById) {
|
|
110
91
|
const out = { ...lessonsObj };
|
|
111
92
|
let added = 0;
|
|
@@ -118,10 +99,6 @@ function addLessons(lessonsObj, lessonsById) {
|
|
|
118
99
|
return out;
|
|
119
100
|
}
|
|
120
101
|
|
|
121
|
-
// ---------------------------------------------------------------------
|
|
122
|
-
// Per-CVE test-file generator (Task 8)
|
|
123
|
-
// ---------------------------------------------------------------------
|
|
124
|
-
|
|
125
102
|
function renderTest(cveId) {
|
|
126
103
|
return `const test = require('node:test');
|
|
127
104
|
const assert = require('node:assert');
|
|
@@ -146,10 +123,6 @@ test('${cveId}: curated KEV entry is complete and self-consistent', () => {
|
|
|
146
123
|
`;
|
|
147
124
|
}
|
|
148
125
|
|
|
149
|
-
// ---------------------------------------------------------------------
|
|
150
|
-
// curateBatch orchestration + CLI (Task 9)
|
|
151
|
-
// ---------------------------------------------------------------------
|
|
152
|
-
|
|
153
126
|
function _loadIds(root, file, key) {
|
|
154
127
|
const p = path.join(root, 'data', file);
|
|
155
128
|
if (!fs.existsSync(p)) return new Set();
|
|
@@ -179,9 +152,8 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
179
152
|
else errors.push(`${id}: missing lesson in judgments.`);
|
|
180
153
|
}
|
|
181
154
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// under --apply, which is one step too late to be useful.
|
|
155
|
+
// Reported as a validation error so the DRY RUN surfaces it; insertEntries
|
|
156
|
+
// throws on the same condition, but only under --apply.
|
|
185
157
|
const catText = fs.readFileSync(path.join(catalogRoot, 'data', 'cve-catalog.json'), 'utf8');
|
|
186
158
|
for (const id of existingIds(catText, Object.keys(entries)))
|
|
187
159
|
errors.push(`${id}: already present in the catalog — this batch adds entries and cannot replace one. Restore the catalog and apply once.`);
|
|
@@ -203,9 +175,8 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
203
175
|
const lesObj = addLessons(JSON.parse(fs.readFileSync(lesPath, 'utf8')), lessons);
|
|
204
176
|
fs.writeFileSync(lesPath, JSON.stringify(lesObj, null, 2) + '\n');
|
|
205
177
|
|
|
206
|
-
// id
|
|
207
|
-
//
|
|
208
|
-
// "cve-" would double the prefix (tests/cve-cve-2025-0108.test.js).
|
|
178
|
+
// The id already carries its "CVE-" prefix, so lowercasing alone produces the
|
|
179
|
+
// repo's tests/cve-<year>-<num>.test.js convention.
|
|
209
180
|
for (const id of Object.keys(entries))
|
|
210
181
|
fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
|
|
211
182
|
|
|
@@ -220,10 +191,9 @@ async function cli(argv) {
|
|
|
220
191
|
else if (a === '--judgments') opts.judgments = argv[++i];
|
|
221
192
|
else if (a === '--apply') opts.apply = true;
|
|
222
193
|
}
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// {ok:false} envelope like every other refresh-curate error path.
|
|
194
|
+
// Inputs are checked before curateBatch so a missing path returns the same
|
|
195
|
+
// {ok:false} envelope every other refresh-curate error path does, rather than
|
|
196
|
+
// throwing out of the async function.
|
|
227
197
|
if (!opts.facts || !opts.judgments || !fs.existsSync(opts.facts) || !fs.existsSync(opts.judgments)) {
|
|
228
198
|
const missing = [];
|
|
229
199
|
if (!opts.facts) missing.push('--facts <path>');
|
package/lib/cve-curation.js
CHANGED
|
@@ -462,7 +462,31 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
462
462
|
field: "cisa_kev + cisa_kev_date",
|
|
463
463
|
current_value: { cisa_kev: draft.cisa_kev ?? null, cisa_kev_date: draft.cisa_kev_date ?? null },
|
|
464
464
|
candidates: [],
|
|
465
|
-
ask: "Is the CVE on CISA's Known Exploited Vulnerabilities list? If yes, capture the date added (YYYY-MM-DD). If no, answer false explicitly — null fails the strict schema gate.",
|
|
465
|
+
ask: "Is the CVE on CISA's Known Exploited Vulnerabilities list? If yes, capture the date added (YYYY-MM-DD), the due date, and whether the record marks known ransomware campaign use. If no, answer false explicitly — null fails the strict schema gate.",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The KEV block above only fires while cisa_kev is still unanswered, so a
|
|
470
|
+
// draft that already knows it is listed needs its own checks for the two
|
|
471
|
+
// fields that come from the same record and are easy to leave behind.
|
|
472
|
+
if (draft.cisa_kev === true && !draft.cisa_kev_due_date) {
|
|
473
|
+
questions.push({
|
|
474
|
+
field: "cisa_kev_due_date",
|
|
475
|
+
current_value: draft.cisa_kev_due_date ?? null,
|
|
476
|
+
candidates: [],
|
|
477
|
+
ask: "What remediation deadline does the KEV record carry (dueDate, YYYY-MM-DD)? Read it from the record rather than assuming a fixed window after the listing date — CISA sets shorter ones, and this date is rendered into remediation output and notification drafts.",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// A KEV-listed entry has to carry the designation as a boolean: an absent
|
|
482
|
+
// field reads as false to every consumer, which asserts something CISA's
|
|
483
|
+
// record may not say.
|
|
484
|
+
if (draft.cisa_kev === true && typeof draft.known_ransomware_use !== "boolean") {
|
|
485
|
+
questions.push({
|
|
486
|
+
field: "known_ransomware_use",
|
|
487
|
+
current_value: draft.known_ransomware_use ?? null,
|
|
488
|
+
candidates: [],
|
|
489
|
+
ask: "Does the CISA KEV record mark this CVE as known ransomware campaign use? Read knownRansomwareCampaignUse on the KEV record: 'Known' is true, 'Unknown' is false. The field cannot be left null on a KEV-listed entry.",
|
|
466
490
|
});
|
|
467
491
|
}
|
|
468
492
|
|
|
@@ -591,6 +615,7 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
591
615
|
["cisa_kev", (v) => typeof v === "boolean", id],
|
|
592
616
|
["cisa_kev_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
|
|
593
617
|
["cisa_kev_due_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
|
|
618
|
+
["known_ransomware_use", (v) => typeof v === "boolean", id],
|
|
594
619
|
["poc_available", (v) => typeof v === "boolean", id],
|
|
595
620
|
["poc_description", (v) => typeof v === "string", id],
|
|
596
621
|
["ai_discovered", (v) => typeof v === "boolean", id],
|
package/lib/cve-enrich.js
CHANGED
|
@@ -15,26 +15,13 @@ function parseCvssVector(vectorString) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// (see deriveMechanicalFields). Fabricating an NVD "vendor advisory" for
|
|
23
|
-
// every KEV entry would silently satisfy the "cisa_kev:true but
|
|
24
|
-
// vendor_advisories empty" curation-gap detector in lib/gap-detectors.js,
|
|
25
|
-
// hiding entries that genuinely lack a vendor advisory. An empty array is the
|
|
26
|
-
// honest signal that a curator still needs to attach the real advisory.
|
|
27
|
-
// `cveId` is retained in the signature for callers/back-compat; it is not used
|
|
28
|
-
// now that no synthetic NVD entry is appended.
|
|
18
|
+
// References NVD tags "Vendor Advisory", deduplicated by URL; [] when none.
|
|
19
|
+
// NVD is an aggregator, so it is never synthesized in here: a synthetic NVD
|
|
20
|
+
// advisory would satisfy the empty-vendor_advisories gap detector and hide the
|
|
21
|
+
// entries that genuinely lack one. `cveId` is unused, kept for callers.
|
|
29
22
|
function extractVendorAdvisories(references, kevVendor, cveId) {
|
|
30
23
|
void cveId;
|
|
31
24
|
const out = [];
|
|
32
|
-
// Deduplicate by URL. Upstream reference lists repeat the same advisory —
|
|
33
|
-
// NVD carries a link once per tag combination, and a draft that has already
|
|
34
|
-
// been through one enrichment pass can carry its own copy alongside the
|
|
35
|
-
// original. Repeating it produces an advisory list that says the same thing
|
|
36
|
-
// several times in every rendered output, and duplicated relationships in the
|
|
37
|
-
// generated indexes.
|
|
38
25
|
const seen = new Set();
|
|
39
26
|
for (const r of references || []) {
|
|
40
27
|
if (Array.isArray(r.tags) && r.tags.includes('Vendor Advisory')) {
|
|
@@ -55,12 +42,9 @@ function _ordinal(n) {
|
|
|
55
42
|
return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
|
|
56
43
|
}
|
|
57
44
|
|
|
58
|
-
// The
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// gate (scripts/check-epss-consistency.js, which compares an entry's stored
|
|
62
|
-
// note against the fields) call. Two copies of this renderer would drift, and
|
|
63
|
-
// the drift would surface as a gate failure on correctly-refreshed data.
|
|
45
|
+
// The one definition of the derived epss_note. Both the writer
|
|
46
|
+
// (lib/refresh-external.js) and the gate (scripts/check-epss-consistency.js)
|
|
47
|
+
// call this; a second copy would drift and fail the gate on correct data.
|
|
64
48
|
function renderEpssNote(entry) {
|
|
65
49
|
const pct = Math.round(entry.epss_percentile * 100);
|
|
66
50
|
return `FIRST EPSS ${entry.epss_score} (${_ordinal(pct)} percentile) as of ${entry.epss_date}.`;
|
|
@@ -71,9 +55,8 @@ function deriveMechanicalFields(facts, today) {
|
|
|
71
55
|
const kev = f.kev || {};
|
|
72
56
|
const cvss = f.cvss || null;
|
|
73
57
|
const parsed = parseCvssVector(cvss && cvss.vector);
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// wherever the catalog is joined against the CWE catalog.
|
|
58
|
+
// Deduplicated: upstream names the same CWE once per assigning source, and a
|
|
59
|
+
// repeat becomes a repeated relationship in every catalog join.
|
|
77
60
|
const cweRefs = [...new Set((f.cwe_nvd || []).filter((c) => /^CWE-\d+$/.test(c)))];
|
|
78
61
|
const vendorAdv = extractVendorAdvisories(f.references, kev.vendor, f.id);
|
|
79
62
|
const verification = [...new Set([
|
|
@@ -111,9 +94,8 @@ const JUDGMENT_KEYS = ['type','blast_radius','poc_available','poc_description','
|
|
|
111
94
|
'active_exploitation_notes','attack_refs','atlas_refs','framework_control_gaps','patch_available',
|
|
112
95
|
'patch_required_reboot','live_patch_available','live_patch_tools','live_patch_notes','affected',
|
|
113
96
|
'affected_versions','vendor_update_paths',
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// auto-derived array via the JUDGMENT_KEYS spread loop below.
|
|
97
|
+
// Overlays (replaces) the auto-derived array, for CVEs whose NVD refs carry
|
|
98
|
+
// no "Vendor Advisory" tag.
|
|
117
99
|
'vendor_advisories'];
|
|
118
100
|
|
|
119
101
|
function assembleEntry(facts, judgment, today) {
|
|
@@ -123,15 +105,11 @@ function assembleEntry(facts, judgment, today) {
|
|
|
123
105
|
for (const k of JUDGMENT_KEYS) if (k in j && j[k] !== undefined) entry[k] = j[k];
|
|
124
106
|
if (typeof j.vector === 'string' && j.vector.trim()) entry.vector = j.vector.trim();
|
|
125
107
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// blanket JUDGMENT_KEYS loop so facts take precedence over judgment here.
|
|
108
|
+
// Outside the JUDGMENT_KEYS loop so NVD's CWEs win over a curator's; the
|
|
109
|
+
// curator list is the fallback for NVD-CWE-noinfo.
|
|
129
110
|
if ((!entry.cwe_refs || entry.cwe_refs.length === 0) && Array.isArray(j.cwe_refs))
|
|
130
111
|
entry.cwe_refs = [...new Set(j.cwe_refs)];
|
|
131
112
|
|
|
132
|
-
// The same guard on the judgment-supplied reference arrays. These are written
|
|
133
|
-
// by hand, so a repeat is a transcription slip rather than an upstream quirk,
|
|
134
|
-
// but it lands in exactly the same joined output.
|
|
135
113
|
for (const k of ['attack_refs', 'atlas_refs', 'verification_sources', 'vendor_update_paths'])
|
|
136
114
|
if (Array.isArray(entry[k])) entry[k] = [...new Set(entry[k])];
|
|
137
115
|
if (Array.isArray(entry.vendor_advisories)) {
|
|
@@ -144,14 +122,13 @@ function assembleEntry(facts, judgment, today) {
|
|
|
144
122
|
});
|
|
145
123
|
}
|
|
146
124
|
|
|
147
|
-
// Defaults + intake markers.
|
|
148
125
|
if (entry.ai_discovery_source === undefined)
|
|
149
126
|
entry.ai_discovery_source = entry.ai_discovered ? 'unknown' : 'vendor_research';
|
|
150
127
|
if (entry.live_patch_tools === undefined) entry.live_patch_tools = [];
|
|
151
128
|
entry._auto_imported = false;
|
|
152
129
|
entry._intake_method = 'batch-curated';
|
|
153
130
|
|
|
154
|
-
//
|
|
131
|
+
// Σ factors must equal rwep_score and agree with scoring.scoreCustom.
|
|
155
132
|
const inputs = {
|
|
156
133
|
cisa_kev: entry.cisa_kev === true,
|
|
157
134
|
poc_available: entry.poc_available === true,
|
|
@@ -175,10 +152,8 @@ function assembleEntry(facts, judgment, today) {
|
|
|
175
152
|
if (entry.poc_available === true && !iocsOk)
|
|
176
153
|
errors.push(`${facts.id}: poc_available=true requires a populated iocs block (Hard Rule #14).`);
|
|
177
154
|
|
|
178
|
-
// Required judgment fields
|
|
179
|
-
//
|
|
180
|
-
// fails the same §7 gate as a missing key, so an under-specified judgment
|
|
181
|
-
// cannot slip an incomplete entry past the writer.
|
|
155
|
+
// Required judgment fields must be POPULATED, not merely present: an empty
|
|
156
|
+
// string, array or object fails the same gate downstream as a missing key.
|
|
182
157
|
if (typeof j.type !== 'string' || j.type.trim() === '')
|
|
183
158
|
errors.push(`${facts.id}: required judgment field "type" missing or not a non-empty string.`);
|
|
184
159
|
if (typeof j.blast_radius !== 'number' || !Number.isFinite(j.blast_radius))
|
|
@@ -192,8 +167,8 @@ function assembleEntry(facts, judgment, today) {
|
|
|
192
167
|
if (typeof j.discovery_attribution_note !== 'string' || j.discovery_attribution_note.trim() === '')
|
|
193
168
|
errors.push(`${facts.id}: required judgment field "discovery_attribution_note" missing or not a non-empty string.`);
|
|
194
169
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
170
|
+
// Range-checked here so a unit typo is an error; postWeightFactors would
|
|
171
|
+
// silently clamp it inside rwep_factors.
|
|
197
172
|
if (typeof entry.blast_radius === 'number' && Number.isFinite(entry.blast_radius) &&
|
|
198
173
|
(entry.blast_radius < 0 || entry.blast_radius > 30))
|
|
199
174
|
errors.push(`${facts.id}: blast_radius ${entry.blast_radius} outside [0,30].`);
|
package/lib/refresh-external.js
CHANGED
|
@@ -111,6 +111,12 @@ function parseArgs(argv) {
|
|
|
111
111
|
// older than 7d or one that was prefetched without a signing keypair.
|
|
112
112
|
// EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
|
|
113
113
|
else if (a === "--force-stale") out.forceStale = true;
|
|
114
|
+
// --drift-only reconciles the entries the catalog already holds and skips
|
|
115
|
+
// auto-discovery of new ones. Discovery imports drafts that still need
|
|
116
|
+
// curation, so an operator correcting stale fields on shipped entries would
|
|
117
|
+
// otherwise have to take that work in the same commit or leave the fields
|
|
118
|
+
// stale.
|
|
119
|
+
else if (a === "--drift-only") out.driftOnly = true;
|
|
114
120
|
// --prefetch / --no-network are prefetch-cache operations. Capture them so
|
|
115
121
|
// main() can delegate to lib/prefetch.js (the same routing bin/exceptd.js
|
|
116
122
|
// performs) when this script is invoked directly — otherwise the help
|
|
@@ -163,6 +169,11 @@ Modes:
|
|
|
163
169
|
discovery still queries IETF Datatracker live; add --air-gap
|
|
164
170
|
for a fully offline run. Cache must be pre-populated via --prefetch.
|
|
165
171
|
--source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins|ghsa|osv)
|
|
172
|
+
--drift-only reconcile the entries the catalog already holds; skip
|
|
173
|
+
auto-discovery of new ones. Discovered entries arrive as
|
|
174
|
+
drafts that still need curation, so this is the flag for
|
|
175
|
+
correcting stale fields on shipped entries without pulling
|
|
176
|
+
that work into the same change.
|
|
166
177
|
--check-advisories poll primary-source advisory feeds (Qualys TRU, RHSA, USN,
|
|
167
178
|
ZDI, kernel.org, oss-security, vendor research blogs) and
|
|
168
179
|
report newly-seen CVE IDs ahead of NVD enrichment.
|
|
@@ -286,8 +297,19 @@ const KEV_SOURCE = {
|
|
|
286
297
|
for (const r of report.results) {
|
|
287
298
|
if (r.status === "unreachable") errors++;
|
|
288
299
|
for (const d of r.discrepancies || []) {
|
|
289
|
-
|
|
300
|
+
// Every KEV field the validator compares, not just the flag and its
|
|
301
|
+
// date: a filter naming two of the four leaves the deadline and the
|
|
302
|
+
// ransomware designation reconciling on --from-cache runs alone, which
|
|
303
|
+
// is not the path most operators take.
|
|
304
|
+
if (KEV_RECONCILED_FIELDS.has(d.field)) {
|
|
290
305
|
const diff = { id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity };
|
|
306
|
+
// A designation being REMOVED against an implausibly small feed is the
|
|
307
|
+
// feed failing to tell us something, not upstream telling us it is
|
|
308
|
+
// gone — held for review exactly as a de-listing is.
|
|
309
|
+
if (d.field === "known_ransomware_use" && d.local === true && d.fetched === false && !feedComplete) {
|
|
310
|
+
diff.review_only = true;
|
|
311
|
+
diff.note = `Ransomware designation removal held for review: live feed returned only ${liveFeedSize} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete.`;
|
|
312
|
+
}
|
|
291
313
|
// Symmetric with the --from-cache path: a LIVE KEV de-listing
|
|
292
314
|
// (true→false) is held for review (applyDiff skips review_only)
|
|
293
315
|
// instead of auto-downgrading the entry when EITHER the entry carries
|
|
@@ -366,6 +388,11 @@ const KEV_SOURCE = {
|
|
|
366
388
|
if (d.after === false) {
|
|
367
389
|
if ("cisa_kev_date" in entry) entry.cisa_kev_date = null;
|
|
368
390
|
if ("cisa_kev_due_date" in entry) entry.cisa_kev_due_date = null;
|
|
391
|
+
// The designation restates a KEV record that no longer exists, so
|
|
392
|
+
// it cannot stand. Null rather than false: false would assert that
|
|
393
|
+
// CISA records no ransomware use, which is a claim about a record
|
|
394
|
+
// that is gone, not a fact upstream supplied.
|
|
395
|
+
if ("known_ransomware_use" in entry) entry.known_ransomware_use = null;
|
|
369
396
|
}
|
|
370
397
|
}
|
|
371
398
|
catalog[d.id].last_verified = TODAY;
|
|
@@ -389,6 +416,9 @@ const KEV_SOURCE = {
|
|
|
389
416
|
*/
|
|
390
417
|
function kevDiffWithDiscoveryFromCache(ctx) {
|
|
391
418
|
const drift = kevDiffFromCache(ctx);
|
|
419
|
+
if (ctx.driftOnly) {
|
|
420
|
+
return { ...drift, spilled: 0, summary: `${drift.diffs.length} KEV drifts (from cache, discovery suppressed)` };
|
|
421
|
+
}
|
|
392
422
|
const discovery = discoverNewKev(ctx);
|
|
393
423
|
const diffs = [...drift.diffs, ...discovery.diffs];
|
|
394
424
|
const summary =
|
|
@@ -686,6 +716,9 @@ const RFC_SOURCE = {
|
|
|
686
716
|
*/
|
|
687
717
|
async function rfcDiffWithDiscoveryFromCache(ctx) {
|
|
688
718
|
const drift = rfcDiffFromCache(ctx);
|
|
719
|
+
if (ctx.driftOnly) {
|
|
720
|
+
return { ...drift, spilled: 0, summary: `${drift.diffs.length} RFC drifts (from cache, discovery suppressed)` };
|
|
721
|
+
}
|
|
689
722
|
const discovery = await discoverNewRfcs(ctx);
|
|
690
723
|
const diffs = [...drift.diffs, ...discovery.diffs];
|
|
691
724
|
const summary =
|
|
@@ -1052,6 +1085,14 @@ function cvssDiff(id, field, before, after, severity, local) {
|
|
|
1052
1085
|
// refused wholesale when the feed is implausibly small.
|
|
1053
1086
|
const KEV_FEED_MIN_PLAUSIBLE = 500;
|
|
1054
1087
|
|
|
1088
|
+
// The KEV fields both refresh paths reconcile. Named once so the live path and
|
|
1089
|
+
// the cache path cannot cover different subsets — which is exactly how the
|
|
1090
|
+
// deadline and the ransomware designation went unreconciled while the flag and
|
|
1091
|
+
// its date tracked upstream.
|
|
1092
|
+
const KEV_RECONCILED_FIELDS = new Set([
|
|
1093
|
+
"cisa_kev", "cisa_kev_date", "cisa_kev_due_date", "known_ransomware_use",
|
|
1094
|
+
]);
|
|
1095
|
+
|
|
1055
1096
|
function kevDiffFromCache(ctx) {
|
|
1056
1097
|
const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
|
|
1057
1098
|
if (!feed) {
|
|
@@ -1059,10 +1100,22 @@ function kevDiffFromCache(ctx) {
|
|
|
1059
1100
|
}
|
|
1060
1101
|
const kevSet = new Set();
|
|
1061
1102
|
const kevDates = new Map();
|
|
1103
|
+
const kevDue = new Map();
|
|
1104
|
+
const kevRansom = new Map();
|
|
1062
1105
|
for (const v of feed.vulnerabilities || []) {
|
|
1063
1106
|
if (v && v.cveID) {
|
|
1064
1107
|
kevSet.add(v.cveID);
|
|
1065
1108
|
if (v.dateAdded) kevDates.set(v.cveID, v.dateAdded);
|
|
1109
|
+
if (v.dueDate) kevDue.set(v.cveID, v.dueDate);
|
|
1110
|
+
// Recorded ONLY for a value this code understands. An absent or
|
|
1111
|
+
// unrecognised knownRansomwareCampaignUse must not be read as "Unknown":
|
|
1112
|
+
// `String(undefined).toLowerCase() === "known"` is false, which would
|
|
1113
|
+
// propose downgrading a curated true to false on the strength of a field
|
|
1114
|
+
// the feed never carried. Absence is no answer, not a negative answer.
|
|
1115
|
+
const r = typeof v.knownRansomwareCampaignUse === "string"
|
|
1116
|
+
? v.knownRansomwareCampaignUse.trim().toLowerCase() : null;
|
|
1117
|
+
if (r === "known") kevRansom.set(v.cveID, true);
|
|
1118
|
+
else if (r === "unknown") kevRansom.set(v.cveID, false);
|
|
1066
1119
|
}
|
|
1067
1120
|
}
|
|
1068
1121
|
// An implausibly small feed cannot be trusted to de-list curated entries.
|
|
@@ -1107,6 +1160,44 @@ function kevDiffFromCache(ctx) {
|
|
|
1107
1160
|
if (upDate && (entry.cisa_kev_date || null) !== upDate) {
|
|
1108
1161
|
diffs.push({ id, field: "cisa_kev_date", before: entry.cisa_kev_date ?? null, after: upDate, severity: "low" });
|
|
1109
1162
|
}
|
|
1163
|
+
|
|
1164
|
+
// CISA edits a listing after publishing it. The remediation deadline and
|
|
1165
|
+
// the ransomware designation both move that way, and neither was compared
|
|
1166
|
+
// here — so an entry curated on the day of listing kept whatever those
|
|
1167
|
+
// fields held then, indefinitely, while every other KEV field tracked the
|
|
1168
|
+
// feed. Only entries upstream still lists are considered; a de-listing
|
|
1169
|
+
// clears both fields through the cisa_kev branch above.
|
|
1170
|
+
if (upstream) {
|
|
1171
|
+
const upDue = kevDue.get(id) || null;
|
|
1172
|
+
// The deadline is rendered into remediation output and regulator drafts,
|
|
1173
|
+
// so a stale one states a date CISA does not require. It is not decoration
|
|
1174
|
+
// and its severity says so.
|
|
1175
|
+
if (upDue && (entry.cisa_kev_due_date || null) !== upDue) {
|
|
1176
|
+
diffs.push({ id, field: "cisa_kev_due_date", before: entry.cisa_kev_due_date ?? null, after: upDue, severity: "medium" });
|
|
1177
|
+
}
|
|
1178
|
+
const upRansom = kevRansom.has(id) ? kevRansom.get(id) : null;
|
|
1179
|
+
// An entry that omits the field is not asserting anything, but every
|
|
1180
|
+
// consumer reads a missing boolean as false — so an omission on an entry
|
|
1181
|
+
// CISA has since designated is read as "no ransomware association", which
|
|
1182
|
+
// is the wrong answer rather than no answer. For a KEV-listed CVE the
|
|
1183
|
+
// value is a restatement of the feed and carries no curator judgment, so
|
|
1184
|
+
// it is filled from upstream in both directions and the field becomes
|
|
1185
|
+
// total across KEV-listed entries instead of sometimes-absent.
|
|
1186
|
+
const localRansom = typeof entry.known_ransomware_use === "boolean" ? entry.known_ransomware_use : null;
|
|
1187
|
+
if (upRansom !== null && localRansom !== upRansom) {
|
|
1188
|
+
// Same asymmetry as the de-list guard: a designation being ADDED is
|
|
1189
|
+
// upstream telling us something new, while one being REMOVED against an
|
|
1190
|
+
// implausibly small feed is the feed failing to tell us anything. Hold
|
|
1191
|
+
// the removal for review rather than erasing a curated designation.
|
|
1192
|
+
const removing = localRansom === true && upRansom === false;
|
|
1193
|
+
const d = { id, field: "known_ransomware_use", before: localRansom, after: upRansom, severity: "medium" };
|
|
1194
|
+
if (removing && !feedComplete) {
|
|
1195
|
+
d.review_only = true;
|
|
1196
|
+
d.note = `Ransomware designation removal held for review: cached feed has only ${kevSet.size} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete.`;
|
|
1197
|
+
}
|
|
1198
|
+
diffs.push(d);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1110
1201
|
}
|
|
1111
1202
|
return { status: "ok", diffs, errors: 0, summary: `${diffs.length} KEV diffs (from cache)` };
|
|
1112
1203
|
}
|
|
@@ -1294,6 +1385,14 @@ function loadCtx(opts) {
|
|
|
1294
1385
|
// Thread --force-stale through so readCachedJson can downgrade cache-
|
|
1295
1386
|
// integrity refusals to warnings when an operator explicitly opts out.
|
|
1296
1387
|
forceStale: !!(opts && opts.forceStale),
|
|
1388
|
+
// `--drift-only` keeps a refresh to the entries the catalog already holds.
|
|
1389
|
+
// Discovery and drift answer different questions — "what is upstream that
|
|
1390
|
+
// we lack" against "what have we got that upstream has since changed" — and
|
|
1391
|
+
// an operator correcting the second does not necessarily want the first,
|
|
1392
|
+
// because a discovered entry arrives as a draft that has to be curated
|
|
1393
|
+
// before it can ship. Bundling them forces a choice between leaving known
|
|
1394
|
+
// fields stale and importing work that is not ready.
|
|
1395
|
+
driftOnly: !!(opts && opts.driftOnly),
|
|
1297
1396
|
};
|
|
1298
1397
|
if (opts.fromFixture) {
|
|
1299
1398
|
// `--from-fixture` injects frozen test payloads as if they were live
|