@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/lib/cve-batch.js CHANGED
@@ -1,24 +1,16 @@
1
1
  'use strict';
2
2
 
3
- // Batch curation orchestration: read a facts file + a judgments file, run
4
- // each pair through cve-enrich.assembleEntry, refuse to write when any
5
- // entry has an orphaned reference or an assembly error, and otherwise
6
- // write the catalog (targeted string surgery — never round-tripped),
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
- // Catalog string-surgery insert + _meta recompute (Task 6)
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
- // A comma whose gap from _meta's closing brace is whitespace-only is _meta's
35
- // member separator → insert after it (a member follows). Otherwise _meta is
36
- // the only/last member (no separator) → insert right after its brace with a
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 members of the catalog text. insertEntries
43
- // splices new members in after _meta and cannot replace an existing one, so an
44
- // id that is already there would end up written TWICE. JSON.parse keeps the
45
- // last duplicate, which is the copy that was already on disk — so a re-apply
46
- // silently discards the new entry, inflates the file, and leaves every count,
47
- // schema check and orphan scan passing on stale data. Refusing is the only
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 ROUNDED rate (a separate test asserts it equals
84
- // round(observed, 3)). The floor, however, is enforced against the RAW rate
85
- // (the catalog test checks `rawRate >= floor`), so the floor must be lowered
86
- // to the raw rate FLOORED to 3 decimals — using the rounded rate here let a
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
- // Report an already-present id as a normal validation error so the DRY RUN
183
- // surfaces it. insertEntries throws on the same condition, but that fires only
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 is already "CVE-2025-0108" — lowercasing alone yields the repo's
207
- // existing tests/cve-<year>-<num>.test.js convention; prepending another
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
- // Guard missing/unreadable inputs before touching curateBatch — without
224
- // this, fs.readFileSync(null) / a nonexistent path throws out of the
225
- // async function and crashes the CLI instead of returning a structured
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>');
@@ -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
- // Extract REAL vendor advisories from a reference list — only references
19
- // explicitly tagged "Vendor Advisory" by NVD. Returns [] when none are
20
- // present. NVD itself is an aggregator, not a vendor, so it is deliberately
21
- // NOT synthesized in here — its detail URL lives in `verification_sources`
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 operator-readable restatement of an entry's EPSS fields. It is DERIVED
59
- // text, not authored, so it has exactly one definition — here — which both the
60
- // writer (lib/refresh-external.js, when a refresh moves the numbers) and the
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
- // Deduplicate: an upstream weakness list can name the same CWE twice (one per
75
- // assigning source), and a repeated id becomes a repeated relationship
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
- // A curator/research agent can supply the real vendor advisories when NVD's
115
- // refs carry no "Vendor Advisory"-tagged link; this overlays (replaces) the
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
- // cwe_refs: mechanical (from NVD) wins when present; fall back to a curator-
127
- // supplied list (e.g. when NVD only returns NVD-CWE-noinfo). Kept out of the
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
- // RWEP via canonical scoring — Σ factors must equal score AND agree with scoreCustom.
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 — populated, not merely present. A key that is
179
- // present-but-empty (empty string / empty array / empty object / non-boolean)
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
- // blast_radius range [0,30]. Surfaces a unit typo (e.g. 250) as an error
196
- // rather than letting postWeightFactors silently clamp it inside rwep_factors.
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].`);
@@ -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
- if (d.field === "cisa_kev" || d.field === "cisa_kev_date") {
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