@blamejs/exceptd-skills 0.18.21 → 0.18.22
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 +1 -1
- package/CHANGELOG.md +6 -0
- package/bin/exceptd.js +4 -1
- package/data/_indexes/_meta.json +9 -9
- package/data/_indexes/activity-feed.json +3 -3
- package/data/_indexes/catalog-summaries.json +8 -8
- package/data/_indexes/chains.json +56413 -604
- package/data/_indexes/frequency.json +6 -0
- package/data/attack-techniques.json +244 -6
- package/data/cve-catalog.json +9315 -1
- package/data/cwe-catalog.json +273 -7
- package/data/framework-control-gaps.json +519 -12
- package/data/zeroday-lessons.json +3916 -1
- package/lib/auto-discovery.js +54 -67
- package/lib/cve-batch.js +216 -0
- package/lib/cve-curation.js +4 -0
- package/lib/cve-enrich.js +172 -0
- package/lib/prefetch.js +189 -110
- package/lib/scoring.js +24 -0
- package/manifest.json +53 -53
- package/package.json +2 -2
- package/sbom.cdx.json +65 -35
package/lib/auto-discovery.js
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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",
|
package/lib/cve-batch.js
ADDED
|
@@ -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 };
|
package/lib/cve-curation.js
CHANGED
|
@@ -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 };
|