@blamejs/exceptd-skills 0.18.21 → 0.18.23

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.
@@ -22,7 +22,7 @@
22
22
  * is tolerant of this annotation; the audit / stale-content index
23
23
  * surfaces uncurated entries so they don't sit indefinitely.
24
24
  *
25
- * Both discovery functions accept a `cap` (default 20) so a burst
25
+ * Both discovery functions accept a `cap` (default 100) so a burst
26
26
  * upstream addition doesn't generate an unreviewable PR. Items past
27
27
  * the cap spill to the next run.
28
28
  */
@@ -30,8 +30,9 @@
30
30
  const fs = require("fs");
31
31
  const path = require("path");
32
32
  const crypto = require("crypto");
33
- const { scoreCustom, RWEP_WEIGHTS, ACTIVE_EXPLOITATION_LADDER } = require("./scoring");
33
+ const { scoreCustom, postWeightFactors } = require("./scoring");
34
34
  const { selectNvdCvss } = require("./cvss");
35
+ const { deriveMechanicalFields } = require("./cve-enrich");
35
36
 
36
37
  // Stored rwep_factors must reproduce the stored rwep_score.
37
38
  // `buildScoringInputs` is the single source of truth for both — it captures
@@ -65,30 +66,11 @@ function buildScoringInputs(kevEntry /*, nvdPayload */) {
65
66
  // builder stored the SHAPE-A (boolean + string-ladder) factor bag — semantically
66
67
  // fine because deriveRwepFromFactors handles either shape, but the strict
67
68
  // JSON-schema validator (and any downstream tooling that types-checks the
68
- // catalog field) rejected drafts as malformed. Convert the boolean inputs to
69
- // the schema-required post-weight shape so the curate-apply gate (which loads
70
- // the strict schema) doesn't reject KEV-discovered drafts post-promotion.
71
- //
72
- // `ai_factor` is the schema-required key name; auto-discovery's boolean
73
- // inputs are split across `ai_discovered` + `ai_assisted_weapon`, mirroring
74
- // scoreCustom's contract. The factor fires when either flag is true (same as
75
- // scoreCustom).
76
- function toPostWeightFactors(inputs) {
77
- const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[inputs.active_exploitation] ?? 0;
78
- const reboot = (inputs.reboot_required === true) || (inputs.patch_required_reboot === true);
79
- const blastRaw = Number.isFinite(Number(inputs.blast_radius)) ? Number(inputs.blast_radius) : 0;
80
- const blastClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw));
81
- return {
82
- cisa_kev: inputs.cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0,
83
- poc_available: inputs.poc_available ? RWEP_WEIGHTS.poc_available : 0,
84
- ai_factor: (inputs.ai_assisted_weapon || inputs.ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0,
85
- active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
86
- blast_radius: blastClamped,
87
- patch_available: inputs.patch_available ? RWEP_WEIGHTS.patch_available : 0,
88
- live_patch_available: inputs.live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0,
89
- reboot_required: reboot ? RWEP_WEIGHTS.reboot_required : 0,
90
- };
91
- }
69
+ // catalog field) rejected drafts as malformed. `scoring.postWeightFactors` (the
70
+ // canonical home for this conversion, also consumed by lib/cve-enrich.js's
71
+ // batch-curation path) now does the boolean -> schema-required post-weight
72
+ // conversion, so the nightly draft builder and the batch tool can't drift on
73
+ // the math.
92
74
 
93
75
  /**
94
76
  * O — diff severity nuance for KEV-discovered drafts.
@@ -125,7 +107,7 @@ function deriveKevSeverity(kevEntry) {
125
107
  const TODAY = new Date().toISOString().slice(0, 10);
126
108
  const TIMEOUT_MS = 10_000;
127
109
  const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
128
- const DEFAULT_CAP = 20;
110
+ const DEFAULT_CAP = 100;
129
111
 
130
112
  // IETF Datatracker codes → human-readable status strings used in
131
113
  // data/rfc-references.json.
@@ -208,6 +190,11 @@ function extractNvdMetrics(payload, id) {
208
190
  .map((d) => d.value)
209
191
  .filter((v) => /^CWE-\d+$/.test(v))
210
192
  ),
193
+ // Carry NVD's reference list (url + tags) so buildKevDraftEntry can feed
194
+ // real "Vendor Advisory"-tagged links into cve-enrich and a nightly
195
+ // draft's vendor_advisories reflects genuine advisories (empty otherwise,
196
+ // correctly tripping the cisa_kev-but-no-advisory curation-gap detector).
197
+ references: (vuln.references || []).map((r) => ({ url: r.url, tags: r.tags || [] })),
211
198
  };
212
199
  }
213
200
 
@@ -248,9 +235,6 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
248
235
  const nvd = nvdPayload ? extractNvdMetrics(nvdPayload, id) : null;
249
236
  const epss = epssPayload ? extractEpss(epssPayload, id) : null;
250
237
 
251
- const knownRansomware =
252
- String(kevEntry.knownRansomwareCampaignUse || "").toLowerCase() === "known";
253
-
254
238
  // Stored rwep_factors and computed rwep_score MUST agree.
255
239
  // Previously rwep_factors held nulls (for unknown poc/ai/reboot) but
256
240
  // rwep_score was computed from concrete defaults (poc=true, reboot=true).
@@ -263,7 +247,8 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
263
247
  // that scoreCustom consumes. Pre-fix the boolean shape was stored
264
248
  // verbatim, so curate-apply's strict-schema gate rejected KEV-discovered
265
249
  // drafts as soon as anyone tried to promote them — they were
266
- // permanently unpromotable.
250
+ // permanently unpromotable. `scoring.postWeightFactors` (shared with the
251
+ // batch-curation path in lib/cve-enrich.js) does the conversion now.
267
252
  //
268
253
  // The curation flow rewrites these once an operator answers the editorial
269
254
  // questions; until then, the post-weight numeric shape on rwep_factors
@@ -271,31 +256,59 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
271
256
  // blast_radius weight=30 matches the raw-cap convention documented in
272
257
  // scoring.js header).
273
258
  const scoringInputs = buildScoringInputs(kevEntry, nvdPayload);
274
- const rwep_factors = toPostWeightFactors(scoringInputs);
259
+ const rwep_factors = postWeightFactors(scoringInputs);
275
260
  const rwep_score = scoreCustom(scoringInputs);
276
261
 
277
262
  const product = [kevEntry.vendorProject, kevEntry.product]
278
263
  .filter(Boolean)
279
264
  .join(" ");
280
265
 
266
+ // Mechanical fields (name, cvss_score/vector, cwe_refs, cisa_kev +
267
+ // cisa_kev_date/due_date, known_ransomware_use, complexity, vector, epss_*,
268
+ // vendor_advisories, verification_sources, source_verified/last_updated)
269
+ // are derived through the SAME cve-enrich module the `--curate-batch`
270
+ // tool uses, so the nightly auto-PR path and the batch tool never drift on
271
+ // how a raw KEV/NVD/EPSS fact set becomes a mechanical field. See
272
+ // lib/cve-enrich.js:deriveMechanicalFields — this module maps the already-
273
+ // extracted `nvd`/`epss` payloads (see extractNvdMetrics/extractEpss above)
274
+ // and the raw `kevEntry` into that module's `facts` shape.
275
+ const facts = {
276
+ id,
277
+ nvd_desc: nvd && nvd.description,
278
+ cvss: nvd && nvd.cvss_score != null
279
+ ? { version: "3.1", base_score: nvd.cvss_score, vector: nvd.cvss_vector, severity: null }
280
+ : null,
281
+ cwe_nvd: (nvd && nvd.cwe_refs) || [],
282
+ references: (nvd && nvd.references) || [],
283
+ kev: {
284
+ name: kevEntry.vulnerabilityName,
285
+ vendor: kevEntry.vendorProject,
286
+ product: kevEntry.product,
287
+ dateAdded: kevEntry.dateAdded,
288
+ dueDate: kevEntry.dueDate,
289
+ ransomware: kevEntry.knownRansomwareCampaignUse,
290
+ shortDescription: kevEntry.shortDescription,
291
+ },
292
+ epss: epss && { score: epss.score, percentile: epss.percentile, date: epss.date },
293
+ };
294
+ const mech = deriveMechanicalFields(facts, TODAY);
295
+
281
296
  return {
282
- name: String(kevEntry.vulnerabilityName || "TBD — verify against vendor advisory"),
297
+ ...mech,
283
298
  type: "TBD",
284
- cvss_score: nvd?.cvss_score ?? null,
285
- cvss_vector: nvd?.cvss_vector ?? null,
286
- cisa_kev: true,
287
- cisa_kev_date: kevEntry.dateAdded || null,
288
- cisa_kev_due_date: kevEntry.dueDate || null,
289
299
  poc_available: null,
290
300
  poc_description: null,
291
301
  ai_discovered: null,
292
302
  ai_discovery_notes: null,
293
303
  ai_assisted_weaponization: null,
304
+ // Conservative pre-curation default — overrides deriveMechanicalFields'
305
+ // 'confirmed' (mechanically derived from "this CVE has a KEV listing").
306
+ // A nightly draft must not claim CONFIRMED active exploitation before a
307
+ // human has reviewed it; 'suspected' is the honest starting posture even
308
+ // though KEV listing alone already implies exploitation is real.
294
309
  active_exploitation: "suspected",
295
310
  affected: product || "See vendor advisory",
296
311
  affected_versions: [],
297
- vector: nvd?.description || kevEntry.shortDescription || "TBD",
298
- complexity: null,
299
312
  complexity_notes: null,
300
313
  patch_available: null,
301
314
  patch_required_reboot: null,
@@ -305,34 +318,8 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
305
318
  framework_control_gaps: {},
306
319
  atlas_refs: [],
307
320
  attack_refs: [],
308
- cwe_refs: nvd?.cwe_refs || [],
309
- known_ransomware_use: knownRansomware,
310
- epss_score: epss?.score ?? null,
311
- epss_percentile: epss?.percentile ?? null,
312
- epss_date: epss?.date ?? null,
313
321
  rwep_score,
314
322
  rwep_factors,
315
- verification_sources: [
316
- "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
317
- kevEntry.notes ? String(kevEntry.notes) : null,
318
- ].filter(Boolean),
319
- // v0.12.15 (B): schema requires source_verified to be a
320
- // YYYY-MM-DD string; the prior `false` boolean (then null) produced
321
- // entries that failed strict catalog validation.
322
- //
323
- // the CISA KEV listing IS the verification source for a
324
- // KEV-discovered draft — the entry's `verification_sources` array
325
- // already points to the KEV catalog URL, and KEV's appearance is what
326
- // triggered the auto-import. Pre-fix the field stayed null, which
327
- // (a) blocked curate-apply's strict-schema check (which requires a
328
- // YYYY-MM-DD string) and (b) left operators no signal that the
329
- // upstream HAD in fact verified the entry's authoritative listing.
330
- // Now we date-stamp it as TODAY (the import day). Operators may
331
- // overwrite during full curation if they revalidate from a fresher
332
- // KEV pull — the field always semantically means "the date a
333
- // verification source confirmed this CVE id."
334
- source_verified: TODAY,
335
- last_updated: TODAY,
336
323
  last_verified: TODAY,
337
324
  // v0.12.15 (D): `_auto_imported` must be the boolean `true`
338
325
  // for lib/validate-cve-catalog.js's draft-recognition check (strict
@@ -351,7 +338,7 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
351
338
  "active_exploitation upgrade from 'suspected' to 'confirmed' once a campaign is documented",
352
339
  "framework_control_gaps mapping (NIST/ISO/PCI/SOC 2 controls this defeats)",
353
340
  "atlas_refs + attack_refs categorization",
354
- "complexity assessment + complexity_notes",
341
+ "complexity_notes (complexity auto-derived from CVSS AC when NVD data present)",
355
342
  "patch_available + live_patch_available + live_patch_tools",
356
343
  "blast_radius numeric in rwep_factors (currently default 15)",
357
344
  "RWEP score recompute after the above land",
@@ -0,0 +1,216 @@
1
+ 'use strict';
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]`.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const enrich = require('./cve-enrich.js');
14
+
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.
22
+ function _metaEnd(text) {
23
+ const m = /\n {2}"_meta"\s*:/.exec(text);
24
+ if (!m) throw new Error('cve-batch: could not locate top-level "_meta" member');
25
+ let i = text.indexOf('{', m.index);
26
+ let depth = 0;
27
+ for (; i < text.length; i++) {
28
+ const c = text[i];
29
+ if (c === '{') depth++;
30
+ else if (c === '}') { depth--; if (depth === 0) { i++; break; } }
31
+ else if (c === '"') { i++; while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; } }
32
+ }
33
+ 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.
38
+ if (comma !== -1 && text.slice(i, comma).trim() === '') return { at: comma + 1, leadingComma: false };
39
+ return { at: i, leadingComma: true };
40
+ }
41
+
42
+ function insertEntries(catalogText, entriesById) {
43
+ const { at, leadingComma } = _metaEnd(catalogText);
44
+ const blocks = Object.entries(entriesById).map(([id, obj]) => {
45
+ const body = JSON.stringify(obj, null, 2).split('\n').map((l, idx) => idx === 0 ? l : ' ' + l).join('\n');
46
+ return `\n ${JSON.stringify(id)}: ${body}`;
47
+ });
48
+ const insert = leadingComma ? (',' + blocks.join(',')) : blocks.map(b => b + ',').join('');
49
+ return catalogText.slice(0, at) + insert + catalogText.slice(at);
50
+ }
51
+
52
+ function recomputeAiMeta(catalogText, aiCount, total) {
53
+ // current_rate is the ROUNDED rate (a separate test asserts it equals
54
+ // round(observed, 3)). The floor, however, is enforced against the RAW rate
55
+ // (the catalog test checks `rawRate >= floor`), so the floor must be lowered
56
+ // to the raw rate FLOORED to 3 decimals — using the rounded rate here let a
57
+ // raw rate of 0.02262 (rounds to 0.023) leave a 0.023 floor the raw rate
58
+ // cannot clear. Never raise the floor; only lower it when the rate dilutes.
59
+ const rawRate = aiCount / total;
60
+ const rate = Math.round(rawRate * 1000) / 1000;
61
+ let out = catalogText.replace(/("current_rate"\s*:\s*)[0-9.]+/, `$1${rate}`);
62
+ const floorM = /("current_floor_enforced_by_test"\s*:\s*)([0-9.]+)/.exec(out);
63
+ if (floorM) {
64
+ const curFloor = Number(floorM[2]);
65
+ const rawFloor = Math.floor(rawRate * 1000) / 1000;
66
+ if (rawFloor < curFloor) {
67
+ out = out.replace(/("current_floor_enforced_by_test"\s*:\s*)[0-9.]+/, `$1${rawFloor}`);
68
+ out = out.replace(/("floor_correction_note"\s*:\s*")([^"]*)(")/,
69
+ (_m, a, note, z) => `${a}${note} batch curation: floor ${curFloor} → ${rawFloor} tracking the diluted honest rate (${aiCount}/${total} AI-discovered) after a bulk KEV curation pass.${z}`);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------
76
+ // zeroday-lessons.json writer (Task 7) — this file DOES round-trip.
77
+ // ---------------------------------------------------------------------
78
+
79
+ function addLessons(lessonsObj, lessonsById) {
80
+ const out = { ...lessonsObj };
81
+ let added = 0;
82
+ for (const [id, lesson] of Object.entries(lessonsById)) {
83
+ if (!(id in out)) added++;
84
+ out[id] = lesson;
85
+ }
86
+ out._meta = { ...(out._meta || {}) };
87
+ out._meta.entry_count = (out._meta.entry_count || 0) + added;
88
+ return out;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------
92
+ // Per-CVE test-file generator (Task 8)
93
+ // ---------------------------------------------------------------------
94
+
95
+ function renderTest(cveId) {
96
+ return `const test = require('node:test');
97
+ const assert = require('node:assert');
98
+ const catalog = require('../data/cve-catalog.json');
99
+ const lessons = require('../data/zeroday-lessons.json');
100
+
101
+ test('${cveId}: curated KEV entry is complete and self-consistent', () => {
102
+ const e = catalog['${cveId}'];
103
+ assert.ok(e, '${cveId} present in catalog');
104
+ assert.notStrictEqual(e._auto_imported, true, 'not a draft');
105
+ assert.strictEqual(e.cisa_kev, true);
106
+ assert.strictEqual(typeof e.cvss_score, 'number');
107
+ assert.ok(Array.isArray(e.cwe_refs) && e.cwe_refs.length > 0, 'has cwe_refs');
108
+ assert.ok(Array.isArray(e.attack_refs) && e.attack_refs.length > 0, 'has attack_refs');
109
+ const sum = Object.values(e.rwep_factors).reduce((a, b) => a + b, 0);
110
+ assert.strictEqual(sum, e.rwep_score, 'Σ rwep_factors === rwep_score');
111
+ if (e.poc_available === true) {
112
+ assert.ok(e.iocs && Object.keys(e.iocs).length > 0, 'poc_available → iocs populated');
113
+ }
114
+ assert.ok(lessons['${cveId}'], 'has a matching zeroday lesson');
115
+ });
116
+ `;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------
120
+ // curateBatch orchestration + CLI (Task 9)
121
+ // ---------------------------------------------------------------------
122
+
123
+ function _loadIds(root, file, key) {
124
+ const p = path.join(root, 'data', file);
125
+ if (!fs.existsSync(p)) return new Set();
126
+ const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
127
+ if (key) return new Set(Object.keys(obj[key] || {}));
128
+ return new Set(Object.keys(obj).filter((k) => k !== '_meta'));
129
+ }
130
+
131
+ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today }) {
132
+ const facts = JSON.parse(fs.readFileSync(factsPath, 'utf8'));
133
+ const judgments = JSON.parse(fs.readFileSync(judgmentsPath, 'utf8'));
134
+ const catalogs = {
135
+ cwe: _loadIds(catalogRoot, 'cwe-catalog.json'),
136
+ attack: _loadIds(catalogRoot, 'attack-techniques.json'),
137
+ atlas: _loadIds(catalogRoot, 'atlas-ttps.json'),
138
+ frameworkGaps: _loadIds(catalogRoot, 'framework-control-gaps.json'),
139
+ };
140
+ const entries = {}; const lessons = {}; const orphans = []; const errors = [];
141
+ for (const id of Object.keys(facts)) {
142
+ const { entry, errors: e } = enrich.assembleEntry(facts[id], judgments[id] || {}, today);
143
+ entry.id = id; // for orphan messages
144
+ errors.push(...e);
145
+ orphans.push(...enrich.findOrphans({ ...entry, id }, catalogs));
146
+ delete entry.id;
147
+ entries[id] = entry;
148
+ if (judgments[id] && judgments[id].lesson) lessons[id] = judgments[id].lesson;
149
+ else errors.push(`${id}: missing lesson in judgments.`);
150
+ }
151
+ const ok = orphans.length === 0 && errors.length === 0;
152
+ if (!apply || !ok) return { ok, entries, orphans, errors, written: false };
153
+
154
+ // WRITE
155
+ const catPath = path.join(catalogRoot, 'data', 'cve-catalog.json');
156
+ let text = fs.readFileSync(catPath, 'utf8');
157
+ text = insertEntries(text, entries);
158
+ const parsed = JSON.parse(text);
159
+ const ids = Object.keys(parsed).filter((k) => k !== '_meta');
160
+ const aiCount = ids.filter((k) => parsed[k].ai_discovered === true).length;
161
+ text = recomputeAiMeta(text, aiCount, ids.length);
162
+ fs.writeFileSync(catPath, text);
163
+
164
+ const lesPath = path.join(catalogRoot, 'data', 'zeroday-lessons.json');
165
+ const lesObj = addLessons(JSON.parse(fs.readFileSync(lesPath, 'utf8')), lessons);
166
+ fs.writeFileSync(lesPath, JSON.stringify(lesObj, null, 2) + '\n');
167
+
168
+ // id is already "CVE-2025-0108" — lowercasing alone yields the repo's
169
+ // existing tests/cve-<year>-<num>.test.js convention; prepending another
170
+ // "cve-" would double the prefix (tests/cve-cve-2025-0108.test.js).
171
+ for (const id of Object.keys(entries))
172
+ fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
173
+
174
+ return { ok: true, entries, orphans, errors, written: true };
175
+ }
176
+
177
+ async function cli(argv) {
178
+ const opts = { facts: null, judgments: null, apply: false, catalogRoot: path.join(__dirname, '..') };
179
+ for (let i = 0; i < argv.length; i++) {
180
+ const a = argv[i];
181
+ if (a === '--facts') opts.facts = argv[++i];
182
+ else if (a === '--judgments') opts.judgments = argv[++i];
183
+ else if (a === '--apply') opts.apply = true;
184
+ }
185
+ // Guard missing/unreadable inputs before touching curateBatch — without
186
+ // this, fs.readFileSync(null) / a nonexistent path throws out of the
187
+ // async function and crashes the CLI instead of returning a structured
188
+ // {ok:false} envelope like every other refresh-curate error path.
189
+ if (!opts.facts || !opts.judgments || !fs.existsSync(opts.facts) || !fs.existsSync(opts.judgments)) {
190
+ const missing = [];
191
+ if (!opts.facts) missing.push('--facts <path>');
192
+ else if (!fs.existsSync(opts.facts)) missing.push(`--facts ${opts.facts} (not found)`);
193
+ if (!opts.judgments) missing.push('--judgments <path>');
194
+ else if (!fs.existsSync(opts.judgments)) missing.push(`--judgments ${opts.judgments} (not found)`);
195
+ const err = { ok: false, verb: 'refresh', mode: 'cve-batch',
196
+ error: `missing or unreadable required input(s): ${missing.join(', ')}` };
197
+ process.stdout.write(JSON.stringify(err) + '\n');
198
+ process.exitCode = 2;
199
+ return;
200
+ }
201
+ const today = new Date().toISOString().slice(0, 10);
202
+ let r;
203
+ try {
204
+ r = await curateBatch({ factsPath: opts.facts, judgmentsPath: opts.judgments, apply: opts.apply, catalogRoot: opts.catalogRoot, today });
205
+ } catch (e) {
206
+ const err = { ok: false, verb: 'refresh', mode: 'cve-batch', error: String((e && e.message) || e) };
207
+ process.stdout.write(JSON.stringify(err) + '\n');
208
+ process.exitCode = 2;
209
+ return;
210
+ }
211
+ process.stdout.write(JSON.stringify({ ok: r.ok, count: Object.keys(r.entries).length,
212
+ orphans: r.orphans, errors: r.errors, written: r.written }, null, 2) + '\n');
213
+ if (!r.ok) process.exitCode = 1;
214
+ }
215
+
216
+ module.exports = { insertEntries, recomputeAiMeta, addLessons, renderTest, curateBatch, cli };
@@ -738,6 +738,10 @@ function writeJsonAtomic(p, obj) {
738
738
  * `refresh --curate <id> --answers <path> [--apply]`.
739
739
  */
740
740
  async function cli(argv) {
741
+ // Batch curation (facts file + judgments file → gate-passing catalog
742
+ // entries) is a distinct workflow from the single-CVE questionnaire
743
+ // below; delegate before the --curate opts loop touches argv.
744
+ if (argv.includes("--curate-batch")) return require("./cve-batch.js").cli(argv);
741
745
  const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
742
746
  for (let i = 0; i < argv.length; i++) {
743
747
  const a = argv[i];
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const scoring = require('./scoring.js');
4
+
5
+ const AV = { N: 'network', A: 'adjacent', L: 'local', P: 'physical' };
6
+ const AC = { L: 'low', H: 'high' };
7
+
8
+ function parseCvssVector(vectorString) {
9
+ if (typeof vectorString !== 'string') return { complexity: null, attack_vector: null };
10
+ const av = /\bAV:([NALP])\b/.exec(vectorString);
11
+ const ac = /\bAC:([LH])\b/.exec(vectorString);
12
+ return {
13
+ complexity: ac ? AC[ac[1]] : null,
14
+ attack_vector: av ? AV[av[1]] : null,
15
+ };
16
+ }
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.
29
+ function extractVendorAdvisories(references, kevVendor, cveId) {
30
+ void cveId;
31
+ const out = [];
32
+ for (const r of references || []) {
33
+ if (Array.isArray(r.tags) && r.tags.includes('Vendor Advisory')) {
34
+ out.push({ vendor: kevVendor || 'Vendor', advisory_id: r.url.split('/').pop() || r.url,
35
+ url: r.url, severity: 'unknown', published_date: null });
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function _num(x) { const n = Number(x); return Number.isFinite(n) ? n : null; }
42
+
43
+ function deriveMechanicalFields(facts, today) {
44
+ const f = facts || {};
45
+ const kev = f.kev || {};
46
+ const cvss = f.cvss || null;
47
+ const parsed = parseCvssVector(cvss && cvss.vector);
48
+ const cweRefs = (f.cwe_nvd || []).filter((c) => /^CWE-\d+$/.test(c));
49
+ const vendorAdv = extractVendorAdvisories(f.references, kev.vendor, f.id);
50
+ const verification = [...new Set([
51
+ `https://nvd.nist.gov/vuln/detail/${f.id}`,
52
+ 'https://www.cisa.gov/known-exploited-vulnerabilities-catalog',
53
+ ...vendorAdv.map((a) => a.url),
54
+ ])];
55
+ const epss = f.epss || null;
56
+ return {
57
+ name: String(kev.name || f.id),
58
+ cvss_score: cvss ? _num(cvss.base_score) : null,
59
+ cvss_vector: cvss ? cvss.vector : null,
60
+ cwe_refs: cweRefs,
61
+ cisa_kev: !!f.kev,
62
+ cisa_kev_date: kev.dateAdded || null,
63
+ cisa_kev_due_date: kev.dueDate || null,
64
+ known_ransomware_use: String(kev.ransomware || '').toLowerCase() === 'known',
65
+ active_exploitation: f.kev ? 'confirmed' : 'none',
66
+ complexity: parsed.complexity,
67
+ vector: String(f.nvd_desc || kev.shortDescription || '').trim() || `${f.id} — see vendor advisory`,
68
+ epss_score: epss ? _num(epss.score) : null,
69
+ epss_percentile: epss ? _num(epss.percentile) : null,
70
+ epss_date: epss ? (epss.date || null) : null,
71
+ epss_source: epss ? `https://api.first.org/data/v1/epss?cve=${f.id}` : null,
72
+ vendor_advisories: vendorAdv,
73
+ verification_sources: verification,
74
+ source_verified: today,
75
+ last_updated: today,
76
+ _kev_short_description: kev.shortDescription || null,
77
+ };
78
+ }
79
+
80
+ const JUDGMENT_KEYS = ['type','blast_radius','poc_available','poc_description','iocs','ai_discovered',
81
+ 'ai_discovery_notes','ai_discovery_source','discovery_attribution_note','ai_assisted_weaponization',
82
+ 'active_exploitation_notes','attack_refs','atlas_refs','framework_control_gaps','patch_available',
83
+ 'patch_required_reboot','live_patch_available','live_patch_tools','live_patch_notes','affected',
84
+ 'affected_versions','vendor_update_paths',
85
+ // A curator/research agent can supply the real vendor advisories when NVD's
86
+ // refs carry no "Vendor Advisory"-tagged link; this overlays (replaces) the
87
+ // auto-derived array via the JUDGMENT_KEYS spread loop below.
88
+ 'vendor_advisories'];
89
+
90
+ function assembleEntry(facts, judgment, today) {
91
+ const errors = [];
92
+ const j = judgment || {};
93
+ const entry = deriveMechanicalFields(facts, today);
94
+ for (const k of JUDGMENT_KEYS) if (k in j && j[k] !== undefined) entry[k] = j[k];
95
+ if (typeof j.vector === 'string' && j.vector.trim()) entry.vector = j.vector.trim();
96
+
97
+ // cwe_refs: mechanical (from NVD) wins when present; fall back to a curator-
98
+ // supplied list (e.g. when NVD only returns NVD-CWE-noinfo). Kept out of the
99
+ // blanket JUDGMENT_KEYS loop so facts take precedence over judgment here.
100
+ if ((!entry.cwe_refs || entry.cwe_refs.length === 0) && Array.isArray(j.cwe_refs))
101
+ entry.cwe_refs = j.cwe_refs;
102
+
103
+ // Defaults + intake markers.
104
+ if (entry.ai_discovery_source === undefined)
105
+ entry.ai_discovery_source = entry.ai_discovered ? 'unknown' : 'vendor_research';
106
+ if (entry.live_patch_tools === undefined) entry.live_patch_tools = [];
107
+ entry._auto_imported = false;
108
+ entry._intake_method = 'batch-curated';
109
+
110
+ // RWEP via canonical scoring — Σ factors must equal score AND agree with scoreCustom.
111
+ const inputs = {
112
+ cisa_kev: entry.cisa_kev === true,
113
+ poc_available: entry.poc_available === true,
114
+ ai_discovered: entry.ai_discovered === true,
115
+ ai_assisted_weaponization: entry.ai_assisted_weaponization === true,
116
+ active_exploitation: entry.active_exploitation,
117
+ blast_radius: entry.blast_radius,
118
+ patch_available: entry.patch_available === true,
119
+ live_patch_available: entry.live_patch_available === true,
120
+ reboot_required: entry.patch_required_reboot === true,
121
+ };
122
+ const factors = scoring.postWeightFactors(inputs);
123
+ const sum = Object.values(factors).reduce((a, b) => a + b, 0);
124
+ entry.rwep_factors = factors;
125
+ entry.rwep_score = sum;
126
+ if (sum < 0 || sum > 100)
127
+ errors.push(`${facts.id}: rwep sum ${sum} outside [0,100] — lower blast_radius so Σfactors stays in range (clamp would break the Σ===score invariant).`);
128
+
129
+ // Hard Rule #14: poc_available true → iocs populated.
130
+ const iocsOk = entry.iocs && typeof entry.iocs === 'object' && !Array.isArray(entry.iocs) && Object.keys(entry.iocs).length > 0;
131
+ if (entry.poc_available === true && !iocsOk)
132
+ errors.push(`${facts.id}: poc_available=true requires a populated iocs block (Hard Rule #14).`);
133
+
134
+ // Required judgment fields — populated, not merely present. A key that is
135
+ // present-but-empty (empty string / empty array / empty object / non-boolean)
136
+ // fails the same §7 gate as a missing key, so an under-specified judgment
137
+ // cannot slip an incomplete entry past the writer.
138
+ if (typeof j.type !== 'string' || j.type.trim() === '')
139
+ errors.push(`${facts.id}: required judgment field "type" missing or not a non-empty string.`);
140
+ if (typeof j.blast_radius !== 'number' || !Number.isFinite(j.blast_radius))
141
+ errors.push(`${facts.id}: required judgment field "blast_radius" missing or not a finite number.`);
142
+ if (typeof j.poc_available !== 'boolean')
143
+ errors.push(`${facts.id}: required judgment field "poc_available" missing (must be a boolean).`);
144
+ if (!Array.isArray(j.attack_refs) || j.attack_refs.length === 0)
145
+ errors.push(`${facts.id}: required judgment field "attack_refs" missing or empty (every KEV CVE maps to ≥1 ATT&CK TTP).`);
146
+ if (!j.framework_control_gaps || typeof j.framework_control_gaps !== 'object' || Array.isArray(j.framework_control_gaps) || Object.keys(j.framework_control_gaps).length === 0)
147
+ errors.push(`${facts.id}: required judgment field "framework_control_gaps" missing or empty.`);
148
+ if (typeof j.discovery_attribution_note !== 'string' || j.discovery_attribution_note.trim() === '')
149
+ errors.push(`${facts.id}: required judgment field "discovery_attribution_note" missing or not a non-empty string.`);
150
+
151
+ // blast_radius range [0,30]. Surfaces a unit typo (e.g. 250) as an error
152
+ // rather than letting postWeightFactors silently clamp it inside rwep_factors.
153
+ if (typeof entry.blast_radius === 'number' && Number.isFinite(entry.blast_radius) &&
154
+ (entry.blast_radius < 0 || entry.blast_radius > 30))
155
+ errors.push(`${facts.id}: blast_radius ${entry.blast_radius} outside [0,30].`);
156
+
157
+ entry.rwep_notes = `RWEP ${sum}. ` + Object.entries(factors).filter(([, v]) => v !== 0)
158
+ .map(([k, v]) => `${k} ${v > 0 ? '+' : ''}${v}`).join(', ') + '. Σ factors === rwep_score.';
159
+ return { entry, errors };
160
+ }
161
+
162
+ function findOrphans(entry, catalogs) {
163
+ const out = [];
164
+ const chk = (ids, set, label) => { for (const id of ids || []) if (!set.has(id)) out.push(`${entry.id || entry.name}: ${label} "${id}" not in catalog — add it before --apply.`); };
165
+ chk(entry.cwe_refs, catalogs.cwe, 'cwe_refs');
166
+ chk(entry.attack_refs, catalogs.attack, 'attack_refs');
167
+ chk(entry.atlas_refs, catalogs.atlas, 'atlas_refs');
168
+ chk(Object.keys(entry.framework_control_gaps || {}), catalogs.frameworkGaps, 'framework_control_gaps');
169
+ return out;
170
+ }
171
+
172
+ module.exports = { parseCvssVector, extractVendorAdvisories, deriveMechanicalFields, assembleEntry, findOrphans };