@blamejs/exceptd-skills 0.12.11 → 0.12.13

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.
@@ -11,37 +11,87 @@
11
11
  * CWE refs — that a human reviewer or AI assistant uses to fill in the
12
12
  * null editorial fields.
13
13
  *
14
- * Not "AI-assisted" in the LLM sense. The cross-reference logic is
15
- * deterministic pattern-matching against catalogs already in the install.
16
- * The output is a checklist of "fields to answer, with candidates ranked
17
- * by relevance" — the reviewer makes the final call on each.
14
+ * Two modes:
18
15
  *
19
- * Output shape (one JSON object on stdout):
20
- * {
21
- * ok: true,
22
- * verb: "refresh",
23
- * mode: "cve-curation",
24
- * cve_id, draft_entry,
25
- * editorial_questions: [
26
- * { field, current_value, candidates: [{id, score, reason}], ask }
27
- * ],
28
- * next_steps: [...]
29
- * }
16
+ * curate(id, { apply: false }) → questionnaire (default)
17
+ * curate(id, { apply: true, answers: {} }) → land answers into the catalog
30
18
  *
31
- * Exits non-zero (3) so CI pipelines see "editorial review pending" even
32
- * on a successful curation run.
19
+ * The apply path validates the resulting entry against the strict CVE
20
+ * schema. When every required field is populated, the draft markers
21
+ * (`_auto_imported` + `_draft` + `_draft_reason`) are removed and the
22
+ * entry becomes a full catalog citizen. When required fields are still
23
+ * missing, the draft markers stay and `residual_warnings` enumerates what
24
+ * is still blocking promotion.
25
+ *
26
+ * Writes go through an atomic tmp + rename pattern so a concurrent
27
+ * `--apply` against the same catalog can't half-write the file.
28
+ *
29
+ * Not "AI-assisted" in the LLM sense. The cross-reference logic is
30
+ * deterministic pattern-matching against catalogs already in the install.
33
31
  */
34
32
 
35
33
  const fs = require("fs");
36
34
  const path = require("path");
35
+ // v0.12.12 (codex P1 #1, #2): the apply path was an unlocked RMW (lost
36
+ // updates under concurrent --apply) and promoted drafts based only on
37
+ // presence checks (not strict schema validation). Borrow the canonical
38
+ // lockfile-gated atomic-write helper from refresh-external and the schema
39
+ // validator from validate-cve-catalog so the apply path:
40
+ // 1. Serializes against any other catalog mutation (refresh + curate),
41
+ // 2. Re-reads the catalog INSIDE the lock so concurrent applies merge,
42
+ // 3. Validates the post-apply entry against lib/schemas/cve-catalog.schema.json
43
+ // before deciding promotion.
44
+ const { withCatalogLock } = require("./refresh-external");
45
+ const { validate: validateAgainstSchema } = require("./validate-cve-catalog");
37
46
 
38
47
  const ROOT = path.resolve(__dirname, "..");
48
+ const CVE_SCHEMA_PATH = path.join(ROOT, "lib", "schemas", "cve-catalog.schema.json");
49
+ let _cveSchemaCache = null;
50
+ function loadCveEntrySchema() {
51
+ if (_cveSchemaCache) return _cveSchemaCache;
52
+ try {
53
+ const root = JSON.parse(fs.readFileSync(CVE_SCHEMA_PATH, "utf8"));
54
+ const entrySchema =
55
+ (root.patternProperties && root.patternProperties["^CVE-\\d{4}-\\d+$"]) ||
56
+ (root.additionalProperties && typeof root.additionalProperties === "object" ? root.additionalProperties : null);
57
+ _cveSchemaCache = entrySchema || null;
58
+ return _cveSchemaCache;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // J7: lazy-loaded module-level catalog cache. The ATLAS / ATT&CK / CWE /
65
+ // framework-control-gaps catalogs don't change inside a single CLI process,
66
+ // so re-reading them per `curate()` call is wasted I/O. Cache once per
67
+ // process; batch-curate paths (future) get the speedup for free.
68
+ const catalogsCache = {};
39
69
 
40
- function loadJson(rel) {
41
- try { return JSON.parse(fs.readFileSync(path.join(ROOT, rel), "utf8")); }
70
+ function loadJsonRaw(absPath) {
71
+ try { return JSON.parse(fs.readFileSync(absPath, "utf8")); }
42
72
  catch { return null; }
43
73
  }
44
74
 
75
+ // J4: resolve a catalog path that may be absolute (operator passed
76
+ // `--catalog C:\tmp\cat.json` or `/tmp/cat.json`) or repo-relative. Plain
77
+ // `path.join(ROOT, abs)` mishandles absolute paths on Windows.
78
+ function resolveCatalogPath(p) {
79
+ if (!p) return path.join(ROOT, "data", "cve-catalog.json");
80
+ if (path.isAbsolute(p)) return p;
81
+ return path.join(ROOT, p);
82
+ }
83
+
84
+ function loadJson(relOrAbs) {
85
+ // Repo-relative reads (atlas-ttps.json, attack-ttps.json, etc.) cache
86
+ // by relative key. Absolute paths bypass the cache — they're operator-
87
+ // supplied and may legitimately change between invocations.
88
+ if (path.isAbsolute(relOrAbs)) return loadJsonRaw(relOrAbs);
89
+ if (catalogsCache[relOrAbs] !== undefined) return catalogsCache[relOrAbs];
90
+ const v = loadJsonRaw(path.join(ROOT, relOrAbs));
91
+ catalogsCache[relOrAbs] = v;
92
+ return v;
93
+ }
94
+
45
95
  /**
46
96
  * Score a candidate by counting keyword overlap with the draft entry.
47
97
  * Returns 0..100. Pure heuristic — reviewer makes the final call.
@@ -76,17 +126,105 @@ function pickCandidates(draftDigest, catalog, idField, descriptionField) {
76
126
  return candidates.slice(0, 5);
77
127
  }
78
128
 
129
+ // J5: report the actual upstream source on a draft. The previous check
130
+ // only knew about `_source_ghsa_id`; OSV-imported drafts (MAL-*, SNYK-*,
131
+ // RUSTSEC-*, etc. that land via lib/source-osv.js) carry `_source_osv_id`
132
+ // and were always tagged "unknown". Add a registry so future sources are
133
+ // a one-line addition.
134
+ const SOURCE_FIELDS = [
135
+ { field: "_source_ghsa_id", label: "GHSA" },
136
+ { field: "_source_osv_id", label: "OSV" },
137
+ { field: "_source_snyk_id", label: "Snyk" },
138
+ { field: "_source_rustsec_id", label: "RustSec" },
139
+ { field: "_source_nvd_id", label: "NVD" },
140
+ ];
141
+
142
+ function autoImportedFrom(draft) {
143
+ for (const { field, label } of SOURCE_FIELDS) {
144
+ if (draft[field]) return `${label}: ${draft[field]}`;
145
+ }
146
+ return "unknown";
147
+ }
148
+
149
+ // J3: severity word. cvss_score: null no longer collapses to "low" (which
150
+ // is wrong + misleading on a draft that has not been scored yet). Use
151
+ // "unrated" so operators can grep for unsevered drafts and the curate
152
+ // summary reflects the actual state.
153
+ function severityWord(score) {
154
+ if (typeof score !== "number" || Number.isNaN(score)) return "unrated";
155
+ if (score >= 9) return "critical";
156
+ if (score >= 7) return "high";
157
+ if (score >= 4) return "medium";
158
+ return "low";
159
+ }
160
+
161
+ // J2: the fields the strict CVE schema requires. Used to (a) extend the
162
+ // questionnaire so the operator is prompted for every blocking gap, and
163
+ // (b) compute `residual_warnings` after an apply.
164
+ const REQUIRED_SCHEMA_FIELDS = [
165
+ "name", "type", "cvss_score", "cvss_vector", "cisa_kev",
166
+ "poc_available", "ai_discovered", "active_exploitation",
167
+ "affected", "affected_versions", "vector",
168
+ "patch_available", "patch_required_reboot", "live_patch_available",
169
+ "framework_control_gaps", "atlas_refs", "attack_refs",
170
+ "rwep_score", "rwep_factors", "source_verified",
171
+ "verification_sources", "last_updated",
172
+ ];
173
+
174
+ function isPopulated(field, value) {
175
+ if (value === null || value === undefined) return false;
176
+ if (field === "affected_versions" || field === "atlas_refs" || field === "attack_refs"
177
+ || field === "verification_sources") {
178
+ return Array.isArray(value) && value.length > 0;
179
+ }
180
+ if (field === "framework_control_gaps") {
181
+ return value && typeof value === "object" && Object.keys(value).length > 0;
182
+ }
183
+ if (field === "rwep_factors") {
184
+ return value && typeof value === "object" && Object.keys(value).length > 0;
185
+ }
186
+ if (field === "cvss_vector" || field === "vector" || field === "affected"
187
+ || field === "name" || field === "type" || field === "active_exploitation"
188
+ || field === "source_verified" || field === "last_updated") {
189
+ return typeof value === "string" && value.length > 0;
190
+ }
191
+ // booleans + numbers: any non-null value counts as populated
192
+ return true;
193
+ }
194
+
195
+ function residualWarnings(entry) {
196
+ const warnings = [];
197
+ for (const f of REQUIRED_SCHEMA_FIELDS) {
198
+ if (!isPopulated(f, entry[f])) {
199
+ warnings.push(`${f} still ${entry[f] === null ? "null" : "empty"}`);
200
+ }
201
+ }
202
+ return warnings;
203
+ }
204
+
79
205
  /**
80
- * Build the curation report for a single CVE ID.
206
+ * Build the curation report questionnaire or apply.
207
+ *
208
+ * curate(cveId, { catalogPath, apply, answers })
209
+ *
210
+ * apply: false (default) → return { ok, editorial_questions, ... }
211
+ * apply: true → consume answers, write the catalog, return
212
+ * { ok, applied_fields, residual_warnings, ... }
81
213
  */
82
- function curate(cveId, { catalogPath, opts = {} } = {}) {
83
- const catalog = loadJson(catalogPath || "data/cve-catalog.json");
214
+ async function curate(cveId, opts = {}) {
215
+ // Back-compat: earlier signature was `curate(id, { catalogPath, opts })`
216
+ // — accept either shape so the CLI wiring stays stable.
217
+ if (opts && opts.opts && !opts.catalogPath && opts.opts.catalogPath) {
218
+ opts = { ...opts, catalogPath: opts.opts.catalogPath };
219
+ }
220
+ const catalogPath = resolveCatalogPath(opts.catalogPath);
221
+ const catalog = loadJsonRaw(catalogPath);
84
222
  if (!catalog || !catalog[cveId]) {
85
223
  return {
86
224
  ok: false,
87
225
  verb: "refresh",
88
226
  mode: "cve-curation",
89
- error: `CVE ${cveId} not in data/cve-catalog.json. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
227
+ error: `CVE ${cveId} not in ${path.relative(ROOT, catalogPath) || catalogPath}. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
90
228
  };
91
229
  }
92
230
  const draft = catalog[cveId];
@@ -104,6 +242,16 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
104
242
  };
105
243
  }
106
244
 
245
+ // ----- APPLY PATH (J1) -------------------------------------------------
246
+ if (opts.apply && opts.answers) {
247
+ return applyAnswers(cveId, draft, catalog, catalogPath, opts.answers);
248
+ }
249
+
250
+ // ----- QUESTIONNAIRE PATH ---------------------------------------------
251
+ return buildQuestionnaire(cveId, draft);
252
+ }
253
+
254
+ function buildQuestionnaire(cveId, draft) {
107
255
  // Digest of the draft — feed into keyword-overlap scoring.
108
256
  const draftDigest = [
109
257
  draft.name || "",
@@ -114,7 +262,7 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
114
262
  ].filter(Boolean).join(" ");
115
263
 
116
264
  // Pull candidate catalogs. Each is optional — missing catalogs are skipped
117
- // gracefully.
265
+ // gracefully. J7 makes these one-shot loads per process.
118
266
  const atlas = loadJson("data/atlas-ttps.json");
119
267
  const attack = loadJson("data/attack-ttps.json");
120
268
  const cwe = loadJson("data/cwe-catalog.json");
@@ -122,7 +270,8 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
122
270
 
123
271
  const atlasCandidates = pickCandidates(draftDigest, atlas, "ttp_id", "description");
124
272
  const attackCandidates = pickCandidates(draftDigest, attack, "ttp_id", "description");
125
- const cweCandidates = pickCandidates(draftDigest, cwe, "cwe_id", "description");
273
+ // CWE candidates surface as part of the vector question — not its own field.
274
+ void cwe;
126
275
  const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
127
276
 
128
277
  // Build the editorial-questions list. Each entry names the catalog field,
@@ -130,7 +279,14 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
130
279
  // specific ASK to surface what the reviewer needs to decide.
131
280
  const questions = [];
132
281
 
133
- if (!draft.atlas_refs || draft.atlas_refs.length === 0) {
282
+ // J6: pre-filled empty containers (`iocs: {}`, `atlas_refs: []`) used to
283
+ // be treated as "answered" because `if (!draft.iocs)` is truthy for {}.
284
+ // Use explicit emptiness checks so drafts seeded with empty objects /
285
+ // arrays still get the prompt.
286
+ const arrEmpty = (a) => !Array.isArray(a) || a.length === 0;
287
+ const objEmpty = (o) => !o || typeof o !== "object" || Object.keys(o).length === 0;
288
+
289
+ if (arrEmpty(draft.atlas_refs)) {
134
290
  questions.push({
135
291
  field: "atlas_refs",
136
292
  current_value: draft.atlas_refs || [],
@@ -139,7 +295,7 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
139
295
  });
140
296
  }
141
297
 
142
- if (!draft.attack_refs || draft.attack_refs.length === 0) {
298
+ if (arrEmpty(draft.attack_refs)) {
143
299
  questions.push({
144
300
  field: "attack_refs",
145
301
  current_value: draft.attack_refs || [],
@@ -148,46 +304,47 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
148
304
  });
149
305
  }
150
306
 
151
- if (!draft.framework_control_gaps) {
307
+ if (objEmpty(draft.framework_control_gaps)) {
152
308
  questions.push({
153
309
  field: "framework_control_gaps",
154
- current_value: null,
310
+ current_value: draft.framework_control_gaps || null,
155
311
  candidates: frameworkCandidates,
156
312
  ask: "Which framework controls CLAIM to cover this CVE's category, and where do they fall short? Per AGENTS.md Hard Rule #6, every framework finding must include a test that distinguishes paper compliance from actual security. Cover NIST-800-53 + EU (NIS2/DORA/EU AI Act) + UK (CAF) + AU (ISM) + ISO 27001:2022 at minimum.",
157
313
  });
158
314
  }
159
315
 
160
- if (!draft.iocs) {
316
+ if (objEmpty(draft.iocs)) {
161
317
  questions.push({
162
318
  field: "iocs",
163
- current_value: null,
319
+ current_value: draft.iocs || null,
164
320
  candidates: [],
165
321
  ask: "What artifacts indicate compromise on a host running the affected version? Group by (a) payload_artifacts — file/process names the payload writes or runs, (b) persistence_artifacts — hooks, config entries, OS-level units that re-arm the payload, (c) behavioral — log / cache / network patterns, (d) destructive — any tripwire that destroys evidence on remediation. The sbom playbook's detect indicators feed off these.",
166
322
  });
167
323
  }
168
324
 
169
- if (draft.poc_available === null) {
325
+ if (draft.poc_available === null || draft.poc_available === undefined) {
170
326
  questions.push({
171
327
  field: "poc_available",
172
- current_value: null,
328
+ current_value: draft.poc_available === undefined ? null : draft.poc_available,
173
329
  candidates: [],
174
330
  ask: "Is a public PoC or in-the-wild exploitation evidence available? If yes, set poc_available: true + add poc_description summarizing capability (deterministic vs probabilistic, single-stage vs multi-stage, byte-size if known).",
175
331
  });
176
332
  }
177
333
 
178
- if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null) {
334
+ if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null
335
+ || draft.ai_discovered === undefined || draft.ai_assisted_weaponization === undefined) {
179
336
  questions.push({
180
337
  field: "ai_discovered + ai_assisted_weaponization",
181
- current_value: { ai_discovered: draft.ai_discovered, ai_assisted_weaponization: draft.ai_assisted_weaponization },
338
+ current_value: { ai_discovered: draft.ai_discovered ?? null, ai_assisted_weaponization: draft.ai_assisted_weaponization ?? null },
182
339
  candidates: [],
183
340
  ask: "Was this vulnerability AI-discovered (per AGENTS.md Hard Rule #7, ~41% of 2025 zero-days were)? AI-assisted weaponization? Set both fields explicitly — null is not acceptable on a published entry.",
184
341
  });
185
342
  }
186
343
 
187
- if (!draft.rwep_factors || draft.rwep_score === null) {
344
+ if (objEmpty(draft.rwep_factors) || draft.rwep_score === null || draft.rwep_score === undefined) {
188
345
  questions.push({
189
346
  field: "rwep_score + rwep_factors",
190
- current_value: { rwep_score: draft.rwep_score, rwep_factors: draft.rwep_factors },
347
+ current_value: { rwep_score: draft.rwep_score ?? null, rwep_factors: draft.rwep_factors ?? null },
191
348
  candidates: [],
192
349
  ask: "Compute RWEP using lib/scoring.js weights: cisa_kev(+25) + poc_available(+20) + ai_factor(+15) + active_exploitation(confirmed=+20 / suspected=+10) + blast_radius(0..30) + patch_available(-15) + live_patch_available(-10) + reboot_required(+5). The score field is the sum; the factors field shows the contributions.",
193
350
  });
@@ -202,6 +359,59 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
202
359
  });
203
360
  }
204
361
 
362
+ // J2: schema-required field prompts. Without these populated, the apply
363
+ // path cannot produce a schema-passing entry — i.e. the entry stays a
364
+ // draft even after curate --apply. Prompt for them explicitly so the
365
+ // operator sees what's actually blocking promotion.
366
+ if (draft.cvss_score === null || draft.cvss_score === undefined
367
+ || draft.cvss_vector === null || draft.cvss_vector === undefined
368
+ || draft.cvss_vector === "") {
369
+ questions.push({
370
+ field: "cvss_score + cvss_vector",
371
+ current_value: { cvss_score: draft.cvss_score ?? null, cvss_vector: draft.cvss_vector ?? null },
372
+ candidates: [],
373
+ ask: "Determine CVSS via the NVD record (https://nvd.nist.gov/vuln/detail/" + cveId + ") or the vendor advisory. Both score (number, 0..10) and vector (CVSS:3.1/... or CVSS:4.0/...) are schema-required. Drafts without these stay flagged as `unrated` in the curate summary.",
374
+ });
375
+ }
376
+
377
+ if (draft.patch_available === null || draft.patch_available === undefined
378
+ || draft.patch_required_reboot === null || draft.patch_required_reboot === undefined
379
+ || draft.live_patch_available === null || draft.live_patch_available === undefined
380
+ || arrEmpty(draft.live_patch_tools)) {
381
+ questions.push({
382
+ field: "patch_available + patch_required_reboot + live_patch_available + live_patch_tools",
383
+ current_value: {
384
+ patch_available: draft.patch_available ?? null,
385
+ patch_required_reboot: draft.patch_required_reboot ?? null,
386
+ live_patch_available: draft.live_patch_available ?? null,
387
+ live_patch_tools: draft.live_patch_tools ?? [],
388
+ },
389
+ candidates: [],
390
+ ask: "Patch availability: is an upstream fix released? Reboot required to land it? Is a live-patching path available (kpatch / kgraft / ksplice / vendor-equivalent) — and if so, list the tool names. All four feed RWEP scoring.",
391
+ });
392
+ }
393
+
394
+ if (arrEmpty(draft.affected_versions)) {
395
+ questions.push({
396
+ field: "affected_versions",
397
+ current_value: draft.affected_versions || [],
398
+ candidates: [],
399
+ ask: "Affected version ranges as an array of strings. Use semver ranges for libraries (e.g. '>=2.4.0 <2.4.7'), kernel ranges for kernel CVEs, build numbers for vendor-shipped firmware. Schema requires at least one entry.",
400
+ });
401
+ }
402
+
403
+ // KEV is always-ask: the operator might know KEV applies (and even has
404
+ // a date) even when the upstream KEV feed hasn't caught up yet. False
405
+ // is a valid answer.
406
+ if (draft.cisa_kev === null || draft.cisa_kev === undefined) {
407
+ questions.push({
408
+ field: "cisa_kev + cisa_kev_date",
409
+ current_value: { cisa_kev: draft.cisa_kev ?? null, cisa_kev_date: draft.cisa_kev_date ?? null },
410
+ candidates: [],
411
+ 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.",
412
+ });
413
+ }
414
+
205
415
  return {
206
416
  ok: true,
207
417
  verb: "refresh",
@@ -211,33 +421,227 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
211
421
  name: draft.name,
212
422
  type: draft.type,
213
423
  cvss_score: draft.cvss_score,
214
- severity: draft.cvss_score >= 9 ? "critical" : draft.cvss_score >= 7 ? "high" : draft.cvss_score >= 4 ? "medium" : "low",
424
+ severity: severityWord(draft.cvss_score),
215
425
  affected: draft.affected,
216
426
  published_at: draft._source_published_at || null,
217
- auto_imported_from: draft._source_ghsa_id ? `GHSA: ${draft._source_ghsa_id}` : "unknown",
427
+ auto_imported_from: autoImportedFrom(draft),
218
428
  },
219
429
  editorial_questions: questions,
220
430
  questions_open: questions.length,
221
431
  next_steps: [
222
432
  `Answer each editorial question above. The catalog gate (lib/validate-cve-catalog.js) currently treats this entry as a DRAFT (warning, not error) — answers convert it to a full entry.`,
433
+ `Apply the answers in one shot: \`exceptd refresh --curate ${cveId} --answers <path-to-answers.json> --apply\`. residual_warnings will list anything still blocking promotion.`,
223
434
  `Add a matching entry to data/zeroday-lessons.json (rule #6: zero-day learning loop must be live).`,
224
435
  `Update last_threat_review and threat_currency_score on any playbook whose cve_refs includes ${cveId}.`,
225
- `Remove the _auto_imported and _draft flags from the catalog entry once editorial review is complete.`,
226
436
  `Run \`npm run predeploy\` — the strict gate should now pass without DRAFT warnings on this entry.`,
227
437
  ],
228
438
  };
229
439
  }
230
440
 
231
441
  /**
232
- * CLI entry wired from bin/exceptd.js via `refresh --curate <id>`.
442
+ * J1: apply operator-supplied answers to a draft and write back atomically.
443
+ *
444
+ * Answers shape (each key optional; missing keys leave the draft unchanged
445
+ * for that field):
446
+ *
447
+ * {
448
+ * framework_control_gaps: { "NIST-800-53-SI-2": "...", ... },
449
+ * iocs: { payload_artifacts: [...], behavioral: [...], ... },
450
+ * atlas_refs: ["AML.T0010", ...],
451
+ * attack_refs: ["T1195.002", ...],
452
+ * rwep_factors: { cisa_kev: 25, blast_radius: 30, ... },
453
+ * rwep_score: 80, // optional; derived from sum if absent
454
+ * cvss_score: 9.8,
455
+ * cvss_vector: "CVSS:3.1/AV:N/...",
456
+ * cisa_kev: true, // or false
457
+ * cisa_kev_date: "2026-05-13",
458
+ * poc_available: true,
459
+ * poc_description: "...",
460
+ * ai_discovered: false,
461
+ * ai_assisted_weaponization: false,
462
+ * active_exploitation: "confirmed" | "suspected" | "none" | "unknown",
463
+ * vector: "...",
464
+ * complexity: "...",
465
+ * patch_available: true,
466
+ * patch_required_reboot: false,
467
+ * live_patch_available: true,
468
+ * live_patch_tools: ["kpatch", "kgraft"],
469
+ * affected_versions: ["...", "..."],
470
+ * verification_sources: ["https://...", ...]
471
+ * }
472
+ */
473
+ async function applyAnswers(cveId, _draftSnapshot, _catalogSnapshot, catalogPath, answers) {
474
+ // v0.12.12 (codex P1 #2): wrap the entire read-modify-write under
475
+ // withCatalogLock. Two concurrent --apply runs against the same catalog
476
+ // previously read the same base, each mutated a different CVE, and the
477
+ // later writer overwrote the earlier writer's changes. The lock + re-read
478
+ // inside the critical section ensures sibling applies merge cleanly.
479
+ let outcome = null;
480
+ await withCatalogLock(catalogPath, (catalog) => {
481
+ if (!catalog || !catalog[cveId]) {
482
+ outcome = {
483
+ ok: false,
484
+ verb: "refresh",
485
+ mode: "cve-curation-apply",
486
+ error: `CVE ${cveId} disappeared from ${path.relative(ROOT, catalogPath) || catalogPath} between question generation and apply.`,
487
+ };
488
+ return catalog; // unchanged
489
+ }
490
+ outcome = applyAnswersUnderLock(cveId, catalog, catalogPath, answers);
491
+ return catalog; // mutated in place
492
+ });
493
+ return outcome;
494
+ }
495
+
496
+ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
497
+ const entry = catalog[cveId];
498
+ const appliedFields = [];
499
+
500
+ // Whitelist of fields the operator may supply. Anything not in this map
501
+ // is ignored (so a typo'd key in answers.json doesn't pollute the entry).
502
+ // Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
503
+ const id = (x) => x;
504
+ const ALLOWED = [
505
+ ["framework_control_gaps", (v) => v && typeof v === "object" && !Array.isArray(v), id],
506
+ ["iocs", (v) => v && typeof v === "object" && !Array.isArray(v), id],
507
+ ["atlas_refs", (v) => Array.isArray(v), id],
508
+ ["attack_refs", (v) => Array.isArray(v), id],
509
+ ["rwep_factors", (v) => v && typeof v === "object" && !Array.isArray(v), id],
510
+ ["rwep_score", (v) => typeof v === "number", id],
511
+ ["rwep_notes", (v) => typeof v === "string", id],
512
+ ["cvss_score", (v) => typeof v === "number", id],
513
+ ["cvss_vector", (v) => typeof v === "string" && /^CVSS:[0-9]/.test(v), id],
514
+ ["cisa_kev", (v) => typeof v === "boolean", id],
515
+ ["cisa_kev_date", (v) => v === null || typeof v === "string", id],
516
+ ["cisa_kev_due_date", (v) => v === null || typeof v === "string", id],
517
+ ["poc_available", (v) => typeof v === "boolean", id],
518
+ ["poc_description", (v) => typeof v === "string", id],
519
+ ["ai_discovered", (v) => typeof v === "boolean", id],
520
+ ["ai_discovery_notes", (v) => typeof v === "string", id],
521
+ ["ai_assisted_weaponization", (v) => typeof v === "boolean", id],
522
+ ["ai_assisted_notes", (v) => typeof v === "string", id],
523
+ ["active_exploitation", (v) => ["confirmed", "suspected", "none", "unknown"].includes(v), id],
524
+ ["affected", (v) => typeof v === "string" && v.length > 0, id],
525
+ ["affected_versions", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
526
+ ["vector", (v) => typeof v === "string" && v.length > 0, id],
527
+ ["complexity", (v) => typeof v === "string", id],
528
+ ["complexity_notes", (v) => typeof v === "string", id],
529
+ ["patch_available", (v) => typeof v === "boolean", id],
530
+ ["patch_required_reboot", (v) => typeof v === "boolean", id],
531
+ ["live_patch_available", (v) => typeof v === "boolean", id],
532
+ ["live_patch_tools", (v) => Array.isArray(v) && v.every(x => typeof x === "string"), id],
533
+ ["live_patch_notes", (v) => typeof v === "string", id],
534
+ ["verification_sources", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
535
+ ["name", (v) => typeof v === "string" && v.length > 0, id],
536
+ ["type", (v) => typeof v === "string" && v.length > 0, id],
537
+ ["source_verified", (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v), id],
538
+ ];
539
+
540
+ const rejected = [];
541
+ for (const [field, validator, transformer] of ALLOWED) {
542
+ if (!(field in answers)) continue;
543
+ const v = answers[field];
544
+ if (!validator(v)) {
545
+ rejected.push({ field, reason: `value ${JSON.stringify(v)} failed validation` });
546
+ continue;
547
+ }
548
+ entry[field] = transformer(v);
549
+ appliedFields.push(field);
550
+ }
551
+
552
+ // Derive rwep_score from rwep_factors when factors supplied without an
553
+ // explicit score. lib/scoring.js owns the canonical formula; we sum the
554
+ // numeric values here as a fallback.
555
+ if ("rwep_factors" in answers && !("rwep_score" in answers)
556
+ && entry.rwep_factors && typeof entry.rwep_factors === "object") {
557
+ let sum = 0;
558
+ for (const v of Object.values(entry.rwep_factors)) {
559
+ if (typeof v === "number") sum += v;
560
+ }
561
+ entry.rwep_score = Math.max(0, Math.min(100, sum));
562
+ appliedFields.push("rwep_score (derived from rwep_factors)");
563
+ }
564
+
565
+ // last_updated reflects the apply moment.
566
+ entry.last_updated = new Date().toISOString().slice(0, 10);
567
+
568
+ // v0.12.12 (codex P1 #1): validate the post-apply entry against the
569
+ // strict CVE schema BEFORE deciding whether to promote. Promotion was
570
+ // previously gated on residualWarnings() alone — a presence check — so
571
+ // an input like `"ai_discovered": "false"` (string) passed presence but
572
+ // would have failed lib/validate-cve-catalog.js on type mismatch,
573
+ // creating a "promoted but invalid" entry. Now: presence + strict schema
574
+ // BOTH must hold for promotion.
575
+ const warnings = residualWarnings(entry);
576
+ const schemaErrors = [];
577
+ const entrySchema = loadCveEntrySchema();
578
+ if (entrySchema) {
579
+ const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
580
+ if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
581
+ }
582
+ let promoted = false;
583
+ if (warnings.length === 0 && schemaErrors.length === 0) {
584
+ delete entry._auto_imported;
585
+ delete entry._draft;
586
+ delete entry._draft_reason;
587
+ promoted = true;
588
+ }
589
+
590
+ // catalog has been mutated in-place under the lock; the locker writes it
591
+ // atomically when this function returns. Concurrent applies are
592
+ // serialized + see the post-merge state.
593
+ catalog[cveId] = entry;
594
+
595
+ return {
596
+ ok: true,
597
+ verb: "refresh",
598
+ mode: "cve-curation-apply",
599
+ cve_id: cveId,
600
+ applied_fields: appliedFields,
601
+ rejected_fields: rejected,
602
+ promoted,
603
+ is_draft: !promoted,
604
+ residual_warnings: warnings,
605
+ schema_errors: schemaErrors,
606
+ catalog_path: catalogPath,
607
+ next_steps: promoted
608
+ ? [
609
+ `Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --quiet\` to confirm it passes the strict schema gate.`,
610
+ `Add a matching entry to data/zeroday-lessons.json (AGENTS.md rule #6).`,
611
+ `Run \`npm run predeploy\` before tagging.`,
612
+ ]
613
+ : [
614
+ `Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`,
615
+ `Re-run \`exceptd refresh --curate ${cveId}\` to see the updated questionnaire.`,
616
+ ],
617
+ };
618
+ }
619
+
620
+ function writeJsonAtomic(p, obj) {
621
+ const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
622
+ fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
623
+ try {
624
+ fs.renameSync(tmpPath, p);
625
+ } catch (err) {
626
+ try { fs.unlinkSync(tmpPath); } catch {}
627
+ throw err;
628
+ }
629
+ }
630
+
631
+ /**
632
+ * CLI entry — wired from bin/exceptd.js via `refresh --curate <id>` and
633
+ * `refresh --curate <id> --answers <path> [--apply]`.
233
634
  */
234
635
  async function cli(argv) {
235
- const opts = { advisory: null, json: false, catalogPath: null };
636
+ const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
236
637
  for (let i = 0; i < argv.length; i++) {
237
638
  const a = argv[i];
238
639
  if (a === "--json") opts.json = true;
640
+ else if (a === "--apply") opts.apply = true;
239
641
  else if (a === "--catalog") opts.catalogPath = argv[++i];
240
642
  else if (a.startsWith("--catalog=")) opts.catalogPath = a.slice("--catalog=".length);
643
+ else if (a === "--answers") opts.answersPath = argv[++i];
644
+ else if (a.startsWith("--answers=")) opts.answersPath = a.slice("--answers=".length);
241
645
  else if (a === "--curate") opts.advisory = argv[++i];
242
646
  else if (a.startsWith("--curate=")) opts.advisory = a.slice("--curate=".length);
243
647
  else if (!a.startsWith("--") && !opts.advisory) opts.advisory = a;
@@ -249,9 +653,47 @@ async function cli(argv) {
249
653
  process.exitCode = 2;
250
654
  return;
251
655
  }
252
- const result = curate(opts.advisory, { catalogPath: opts.catalogPath, opts });
656
+
657
+ // Operator passing --answers <path> means apply. --apply on its own is
658
+ // also accepted (for parity with --advisory --apply), but it's a no-op
659
+ // without --answers — fail loudly so a forgetful operator doesn't think
660
+ // a write succeeded when in fact the questionnaire was returned.
661
+ let answers = null;
662
+ if (opts.answersPath) {
663
+ try {
664
+ const ap = path.isAbsolute(opts.answersPath) ? opts.answersPath : path.resolve(process.cwd(), opts.answersPath);
665
+ answers = JSON.parse(fs.readFileSync(ap, "utf8"));
666
+ } catch (e) {
667
+ const err = { ok: false, verb: "refresh", mode: "cve-curation", error: `--answers ${opts.answersPath}: ${e.message}` };
668
+ if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
669
+ else process.stderr.write(`[refresh --curate] ${err.error}\n`);
670
+ process.exitCode = 2;
671
+ return;
672
+ }
673
+ }
674
+ // --answers alone implies apply; --apply alone without --answers is a
675
+ // user error.
676
+ const doApply = !!answers || opts.apply;
677
+ if (opts.apply && !answers) {
678
+ const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "--apply requires --answers <path-to-answers.json>" };
679
+ if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
680
+ else process.stderr.write(`[refresh --curate] ${err.error}\n`);
681
+ process.exitCode = 2;
682
+ return;
683
+ }
684
+
685
+ const result = await curate(opts.advisory, { catalogPath: opts.catalogPath, apply: doApply, answers });
686
+
253
687
  if (opts.json || !result.ok) {
254
688
  process.stdout.write(JSON.stringify(result) + "\n");
689
+ } else if (result.mode === "cve-curation-apply") {
690
+ process.stdout.write(`[refresh --curate --apply] ${result.cve_id} — ${result.applied_fields.length} field(s) applied; ${result.promoted ? "PROMOTED to full entry" : `still DRAFT (${result.residual_warnings.length} blocker(s))`}\n`);
691
+ if (result.residual_warnings.length > 0) {
692
+ process.stdout.write(` residual: ${result.residual_warnings.slice(0, 6).join(", ")}${result.residual_warnings.length > 6 ? ", ..." : ""}\n`);
693
+ }
694
+ if (result.rejected_fields.length > 0) {
695
+ process.stdout.write(` rejected: ${result.rejected_fields.map(r => r.field).join(", ")}\n`);
696
+ }
255
697
  } else {
256
698
  process.stdout.write(`[refresh --curate] ${result.cve_id} — ${result.questions_open} editorial question(s) open\n`);
257
699
  for (const q of result.editorial_questions) {
@@ -263,12 +705,15 @@ async function cli(argv) {
263
705
  }
264
706
  process.stdout.write(`\n Run with --json for the full structured output.\n`);
265
707
  }
266
- // Always non-zero on a successful curation — "editorial review pending."
267
- process.exitCode = result.ok ? 3 : 2;
708
+ // Questionnaire path: exit 3 ("editorial review pending"). Apply path:
709
+ // exit 0 when promoted, 3 when residual_warnings remain (still draft).
710
+ if (!result.ok) process.exitCode = 2;
711
+ else if (result.mode === "cve-curation-apply") process.exitCode = result.promoted ? 0 : 3;
712
+ else process.exitCode = 3;
268
713
  }
269
714
 
270
715
  if (require.main === module) {
271
716
  cli(process.argv.slice(2));
272
717
  }
273
718
 
274
- module.exports = { curate, keywordOverlapScore };
719
+ module.exports = { curate, keywordOverlapScore, resolveCatalogPath, autoImportedFrom, severityWord, residualWarnings };