@blamejs/exceptd-skills 0.12.11 → 0.12.15

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +339 -0
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -11,37 +11,94 @@
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
+ // v0.12.15 (audit M P1-A): the prior version of this function looked for
54
+ // either `root.patternProperties["^CVE-\\d{4}-\\d+$"]` or an object
55
+ // `root.additionalProperties`. The actual schema at lib/schemas/cve-
56
+ // catalog.schema.json has NEITHER — its top level IS the entry shape
57
+ // (`{type:'object', required:[...], properties: {...}}`) because
58
+ // validate-cve-catalog.js iterates each CVE id key manually and runs
59
+ // the schema validator over each value. Result: loadCveEntrySchema()
60
+ // always returned null, the v0.12.12 codex P1 #1 fix (strict-schema
61
+ // gating of promotion) was silently disabled, and schema-violating
62
+ // entries promoted anyway. Use the root schema directly.
63
+ const root = JSON.parse(fs.readFileSync(CVE_SCHEMA_PATH, "utf8"));
64
+ _cveSchemaCache = root || null;
65
+ return _cveSchemaCache;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // J7: lazy-loaded module-level catalog cache. The ATLAS / ATT&CK / CWE /
72
+ // framework-control-gaps catalogs don't change inside a single CLI process,
73
+ // so re-reading them per `curate()` call is wasted I/O. Cache once per
74
+ // process; batch-curate paths (future) get the speedup for free.
75
+ const catalogsCache = {};
39
76
 
40
- function loadJson(rel) {
41
- try { return JSON.parse(fs.readFileSync(path.join(ROOT, rel), "utf8")); }
77
+ function loadJsonRaw(absPath) {
78
+ try { return JSON.parse(fs.readFileSync(absPath, "utf8")); }
42
79
  catch { return null; }
43
80
  }
44
81
 
82
+ // J4: resolve a catalog path that may be absolute (operator passed
83
+ // `--catalog C:\tmp\cat.json` or `/tmp/cat.json`) or repo-relative. Plain
84
+ // `path.join(ROOT, abs)` mishandles absolute paths on Windows.
85
+ function resolveCatalogPath(p) {
86
+ if (!p) return path.join(ROOT, "data", "cve-catalog.json");
87
+ if (path.isAbsolute(p)) return p;
88
+ return path.join(ROOT, p);
89
+ }
90
+
91
+ function loadJson(relOrAbs) {
92
+ // Repo-relative reads (atlas-ttps.json, attack-ttps.json, etc.) cache
93
+ // by relative key. Absolute paths bypass the cache — they're operator-
94
+ // supplied and may legitimately change between invocations.
95
+ if (path.isAbsolute(relOrAbs)) return loadJsonRaw(relOrAbs);
96
+ if (catalogsCache[relOrAbs] !== undefined) return catalogsCache[relOrAbs];
97
+ const v = loadJsonRaw(path.join(ROOT, relOrAbs));
98
+ catalogsCache[relOrAbs] = v;
99
+ return v;
100
+ }
101
+
45
102
  /**
46
103
  * Score a candidate by counting keyword overlap with the draft entry.
47
104
  * Returns 0..100. Pure heuristic — reviewer makes the final call.
@@ -76,17 +133,105 @@ function pickCandidates(draftDigest, catalog, idField, descriptionField) {
76
133
  return candidates.slice(0, 5);
77
134
  }
78
135
 
136
+ // J5: report the actual upstream source on a draft. The previous check
137
+ // only knew about `_source_ghsa_id`; OSV-imported drafts (MAL-*, SNYK-*,
138
+ // RUSTSEC-*, etc. that land via lib/source-osv.js) carry `_source_osv_id`
139
+ // and were always tagged "unknown". Add a registry so future sources are
140
+ // a one-line addition.
141
+ const SOURCE_FIELDS = [
142
+ { field: "_source_ghsa_id", label: "GHSA" },
143
+ { field: "_source_osv_id", label: "OSV" },
144
+ { field: "_source_snyk_id", label: "Snyk" },
145
+ { field: "_source_rustsec_id", label: "RustSec" },
146
+ { field: "_source_nvd_id", label: "NVD" },
147
+ ];
148
+
149
+ function autoImportedFrom(draft) {
150
+ for (const { field, label } of SOURCE_FIELDS) {
151
+ if (draft[field]) return `${label}: ${draft[field]}`;
152
+ }
153
+ return "unknown";
154
+ }
155
+
156
+ // J3: severity word. cvss_score: null no longer collapses to "low" (which
157
+ // is wrong + misleading on a draft that has not been scored yet). Use
158
+ // "unrated" so operators can grep for unsevered drafts and the curate
159
+ // summary reflects the actual state.
160
+ function severityWord(score) {
161
+ if (typeof score !== "number" || Number.isNaN(score)) return "unrated";
162
+ if (score >= 9) return "critical";
163
+ if (score >= 7) return "high";
164
+ if (score >= 4) return "medium";
165
+ return "low";
166
+ }
167
+
168
+ // J2: the fields the strict CVE schema requires. Used to (a) extend the
169
+ // questionnaire so the operator is prompted for every blocking gap, and
170
+ // (b) compute `residual_warnings` after an apply.
171
+ const REQUIRED_SCHEMA_FIELDS = [
172
+ "name", "type", "cvss_score", "cvss_vector", "cisa_kev",
173
+ "poc_available", "ai_discovered", "active_exploitation",
174
+ "affected", "affected_versions", "vector",
175
+ "patch_available", "patch_required_reboot", "live_patch_available",
176
+ "framework_control_gaps", "atlas_refs", "attack_refs",
177
+ "rwep_score", "rwep_factors", "source_verified",
178
+ "verification_sources", "last_updated",
179
+ ];
180
+
181
+ function isPopulated(field, value) {
182
+ if (value === null || value === undefined) return false;
183
+ if (field === "affected_versions" || field === "atlas_refs" || field === "attack_refs"
184
+ || field === "verification_sources") {
185
+ return Array.isArray(value) && value.length > 0;
186
+ }
187
+ if (field === "framework_control_gaps") {
188
+ return value && typeof value === "object" && Object.keys(value).length > 0;
189
+ }
190
+ if (field === "rwep_factors") {
191
+ return value && typeof value === "object" && Object.keys(value).length > 0;
192
+ }
193
+ if (field === "cvss_vector" || field === "vector" || field === "affected"
194
+ || field === "name" || field === "type" || field === "active_exploitation"
195
+ || field === "source_verified" || field === "last_updated") {
196
+ return typeof value === "string" && value.length > 0;
197
+ }
198
+ // booleans + numbers: any non-null value counts as populated
199
+ return true;
200
+ }
201
+
202
+ function residualWarnings(entry) {
203
+ const warnings = [];
204
+ for (const f of REQUIRED_SCHEMA_FIELDS) {
205
+ if (!isPopulated(f, entry[f])) {
206
+ warnings.push(`${f} still ${entry[f] === null ? "null" : "empty"}`);
207
+ }
208
+ }
209
+ return warnings;
210
+ }
211
+
79
212
  /**
80
- * Build the curation report for a single CVE ID.
213
+ * Build the curation report questionnaire or apply.
214
+ *
215
+ * curate(cveId, { catalogPath, apply, answers })
216
+ *
217
+ * apply: false (default) → return { ok, editorial_questions, ... }
218
+ * apply: true → consume answers, write the catalog, return
219
+ * { ok, applied_fields, residual_warnings, ... }
81
220
  */
82
- function curate(cveId, { catalogPath, opts = {} } = {}) {
83
- const catalog = loadJson(catalogPath || "data/cve-catalog.json");
221
+ async function curate(cveId, opts = {}) {
222
+ // Back-compat: earlier signature was `curate(id, { catalogPath, opts })`
223
+ // — accept either shape so the CLI wiring stays stable.
224
+ if (opts && opts.opts && !opts.catalogPath && opts.opts.catalogPath) {
225
+ opts = { ...opts, catalogPath: opts.opts.catalogPath };
226
+ }
227
+ const catalogPath = resolveCatalogPath(opts.catalogPath);
228
+ const catalog = loadJsonRaw(catalogPath);
84
229
  if (!catalog || !catalog[cveId]) {
85
230
  return {
86
231
  ok: false,
87
232
  verb: "refresh",
88
233
  mode: "cve-curation",
89
- error: `CVE ${cveId} not in data/cve-catalog.json. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
234
+ error: `CVE ${cveId} not in ${path.relative(ROOT, catalogPath) || catalogPath}. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
90
235
  };
91
236
  }
92
237
  const draft = catalog[cveId];
@@ -104,6 +249,16 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
104
249
  };
105
250
  }
106
251
 
252
+ // ----- APPLY PATH (J1) -------------------------------------------------
253
+ if (opts.apply && opts.answers) {
254
+ return applyAnswers(cveId, draft, catalog, catalogPath, opts.answers);
255
+ }
256
+
257
+ // ----- QUESTIONNAIRE PATH ---------------------------------------------
258
+ return buildQuestionnaire(cveId, draft);
259
+ }
260
+
261
+ function buildQuestionnaire(cveId, draft) {
107
262
  // Digest of the draft — feed into keyword-overlap scoring.
108
263
  const draftDigest = [
109
264
  draft.name || "",
@@ -114,15 +269,22 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
114
269
  ].filter(Boolean).join(" ");
115
270
 
116
271
  // Pull candidate catalogs. Each is optional — missing catalogs are skipped
117
- // gracefully.
272
+ // gracefully. J7 makes these one-shot loads per process.
118
273
  const atlas = loadJson("data/atlas-ttps.json");
119
- const attack = loadJson("data/attack-ttps.json");
274
+ // v0.12.15 (audit M P1-E): the catalog ships as data/attack-techniques.json
275
+ // (renamed from data/attack-ttps.json before the v0.12.12 release; the
276
+ // canonical file path is also what lib/validate-cve-catalog.js consumes).
277
+ // The prior `data/attack-ttps.json` lookup silently fell back to an empty
278
+ // object via loadJsonRaw's ENOENT handling, so the ATT&CK candidate
279
+ // questionnaire branch always returned zero proposals.
280
+ const attack = loadJson("data/attack-techniques.json");
120
281
  const cwe = loadJson("data/cwe-catalog.json");
121
282
  const frameworkGaps = loadJson("data/framework-control-gaps.json");
122
283
 
123
284
  const atlasCandidates = pickCandidates(draftDigest, atlas, "ttp_id", "description");
124
285
  const attackCandidates = pickCandidates(draftDigest, attack, "ttp_id", "description");
125
- const cweCandidates = pickCandidates(draftDigest, cwe, "cwe_id", "description");
286
+ // CWE candidates surface as part of the vector question — not its own field.
287
+ void cwe;
126
288
  const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
127
289
 
128
290
  // Build the editorial-questions list. Each entry names the catalog field,
@@ -130,7 +292,14 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
130
292
  // specific ASK to surface what the reviewer needs to decide.
131
293
  const questions = [];
132
294
 
133
- if (!draft.atlas_refs || draft.atlas_refs.length === 0) {
295
+ // J6: pre-filled empty containers (`iocs: {}`, `atlas_refs: []`) used to
296
+ // be treated as "answered" because `if (!draft.iocs)` is truthy for {}.
297
+ // Use explicit emptiness checks so drafts seeded with empty objects /
298
+ // arrays still get the prompt.
299
+ const arrEmpty = (a) => !Array.isArray(a) || a.length === 0;
300
+ const objEmpty = (o) => !o || typeof o !== "object" || Object.keys(o).length === 0;
301
+
302
+ if (arrEmpty(draft.atlas_refs)) {
134
303
  questions.push({
135
304
  field: "atlas_refs",
136
305
  current_value: draft.atlas_refs || [],
@@ -139,7 +308,7 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
139
308
  });
140
309
  }
141
310
 
142
- if (!draft.attack_refs || draft.attack_refs.length === 0) {
311
+ if (arrEmpty(draft.attack_refs)) {
143
312
  questions.push({
144
313
  field: "attack_refs",
145
314
  current_value: draft.attack_refs || [],
@@ -148,46 +317,47 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
148
317
  });
149
318
  }
150
319
 
151
- if (!draft.framework_control_gaps) {
320
+ if (objEmpty(draft.framework_control_gaps)) {
152
321
  questions.push({
153
322
  field: "framework_control_gaps",
154
- current_value: null,
323
+ current_value: draft.framework_control_gaps || null,
155
324
  candidates: frameworkCandidates,
156
325
  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
326
  });
158
327
  }
159
328
 
160
- if (!draft.iocs) {
329
+ if (objEmpty(draft.iocs)) {
161
330
  questions.push({
162
331
  field: "iocs",
163
- current_value: null,
332
+ current_value: draft.iocs || null,
164
333
  candidates: [],
165
334
  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
335
  });
167
336
  }
168
337
 
169
- if (draft.poc_available === null) {
338
+ if (draft.poc_available === null || draft.poc_available === undefined) {
170
339
  questions.push({
171
340
  field: "poc_available",
172
- current_value: null,
341
+ current_value: draft.poc_available === undefined ? null : draft.poc_available,
173
342
  candidates: [],
174
343
  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
344
  });
176
345
  }
177
346
 
178
- if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null) {
347
+ if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null
348
+ || draft.ai_discovered === undefined || draft.ai_assisted_weaponization === undefined) {
179
349
  questions.push({
180
350
  field: "ai_discovered + ai_assisted_weaponization",
181
- current_value: { ai_discovered: draft.ai_discovered, ai_assisted_weaponization: draft.ai_assisted_weaponization },
351
+ current_value: { ai_discovered: draft.ai_discovered ?? null, ai_assisted_weaponization: draft.ai_assisted_weaponization ?? null },
182
352
  candidates: [],
183
353
  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
354
  });
185
355
  }
186
356
 
187
- if (!draft.rwep_factors || draft.rwep_score === null) {
357
+ if (objEmpty(draft.rwep_factors) || draft.rwep_score === null || draft.rwep_score === undefined) {
188
358
  questions.push({
189
359
  field: "rwep_score + rwep_factors",
190
- current_value: { rwep_score: draft.rwep_score, rwep_factors: draft.rwep_factors },
360
+ current_value: { rwep_score: draft.rwep_score ?? null, rwep_factors: draft.rwep_factors ?? null },
191
361
  candidates: [],
192
362
  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
363
  });
@@ -202,6 +372,59 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
202
372
  });
203
373
  }
204
374
 
375
+ // J2: schema-required field prompts. Without these populated, the apply
376
+ // path cannot produce a schema-passing entry — i.e. the entry stays a
377
+ // draft even after curate --apply. Prompt for them explicitly so the
378
+ // operator sees what's actually blocking promotion.
379
+ if (draft.cvss_score === null || draft.cvss_score === undefined
380
+ || draft.cvss_vector === null || draft.cvss_vector === undefined
381
+ || draft.cvss_vector === "") {
382
+ questions.push({
383
+ field: "cvss_score + cvss_vector",
384
+ current_value: { cvss_score: draft.cvss_score ?? null, cvss_vector: draft.cvss_vector ?? null },
385
+ candidates: [],
386
+ 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.",
387
+ });
388
+ }
389
+
390
+ if (draft.patch_available === null || draft.patch_available === undefined
391
+ || draft.patch_required_reboot === null || draft.patch_required_reboot === undefined
392
+ || draft.live_patch_available === null || draft.live_patch_available === undefined
393
+ || arrEmpty(draft.live_patch_tools)) {
394
+ questions.push({
395
+ field: "patch_available + patch_required_reboot + live_patch_available + live_patch_tools",
396
+ current_value: {
397
+ patch_available: draft.patch_available ?? null,
398
+ patch_required_reboot: draft.patch_required_reboot ?? null,
399
+ live_patch_available: draft.live_patch_available ?? null,
400
+ live_patch_tools: draft.live_patch_tools ?? [],
401
+ },
402
+ candidates: [],
403
+ 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.",
404
+ });
405
+ }
406
+
407
+ if (arrEmpty(draft.affected_versions)) {
408
+ questions.push({
409
+ field: "affected_versions",
410
+ current_value: draft.affected_versions || [],
411
+ candidates: [],
412
+ 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.",
413
+ });
414
+ }
415
+
416
+ // KEV is always-ask: the operator might know KEV applies (and even has
417
+ // a date) even when the upstream KEV feed hasn't caught up yet. False
418
+ // is a valid answer.
419
+ if (draft.cisa_kev === null || draft.cisa_kev === undefined) {
420
+ questions.push({
421
+ field: "cisa_kev + cisa_kev_date",
422
+ current_value: { cisa_kev: draft.cisa_kev ?? null, cisa_kev_date: draft.cisa_kev_date ?? null },
423
+ candidates: [],
424
+ 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.",
425
+ });
426
+ }
427
+
205
428
  return {
206
429
  ok: true,
207
430
  verb: "refresh",
@@ -211,33 +434,227 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
211
434
  name: draft.name,
212
435
  type: draft.type,
213
436
  cvss_score: draft.cvss_score,
214
- severity: draft.cvss_score >= 9 ? "critical" : draft.cvss_score >= 7 ? "high" : draft.cvss_score >= 4 ? "medium" : "low",
437
+ severity: severityWord(draft.cvss_score),
215
438
  affected: draft.affected,
216
439
  published_at: draft._source_published_at || null,
217
- auto_imported_from: draft._source_ghsa_id ? `GHSA: ${draft._source_ghsa_id}` : "unknown",
440
+ auto_imported_from: autoImportedFrom(draft),
218
441
  },
219
442
  editorial_questions: questions,
220
443
  questions_open: questions.length,
221
444
  next_steps: [
222
445
  `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.`,
446
+ `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
447
  `Add a matching entry to data/zeroday-lessons.json (rule #6: zero-day learning loop must be live).`,
224
448
  `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
449
  `Run \`npm run predeploy\` — the strict gate should now pass without DRAFT warnings on this entry.`,
227
450
  ],
228
451
  };
229
452
  }
230
453
 
231
454
  /**
232
- * CLI entry wired from bin/exceptd.js via `refresh --curate <id>`.
455
+ * J1: apply operator-supplied answers to a draft and write back atomically.
456
+ *
457
+ * Answers shape (each key optional; missing keys leave the draft unchanged
458
+ * for that field):
459
+ *
460
+ * {
461
+ * framework_control_gaps: { "NIST-800-53-SI-2": "...", ... },
462
+ * iocs: { payload_artifacts: [...], behavioral: [...], ... },
463
+ * atlas_refs: ["AML.T0010", ...],
464
+ * attack_refs: ["T1195.002", ...],
465
+ * rwep_factors: { cisa_kev: 25, blast_radius: 30, ... },
466
+ * rwep_score: 80, // optional; derived from sum if absent
467
+ * cvss_score: 9.8,
468
+ * cvss_vector: "CVSS:3.1/AV:N/...",
469
+ * cisa_kev: true, // or false
470
+ * cisa_kev_date: "2026-05-13",
471
+ * poc_available: true,
472
+ * poc_description: "...",
473
+ * ai_discovered: false,
474
+ * ai_assisted_weaponization: false,
475
+ * active_exploitation: "confirmed" | "suspected" | "none" | "unknown",
476
+ * vector: "...",
477
+ * complexity: "...",
478
+ * patch_available: true,
479
+ * patch_required_reboot: false,
480
+ * live_patch_available: true,
481
+ * live_patch_tools: ["kpatch", "kgraft"],
482
+ * affected_versions: ["...", "..."],
483
+ * verification_sources: ["https://...", ...]
484
+ * }
485
+ */
486
+ async function applyAnswers(cveId, _draftSnapshot, _catalogSnapshot, catalogPath, answers) {
487
+ // v0.12.12 (codex P1 #2): wrap the entire read-modify-write under
488
+ // withCatalogLock. Two concurrent --apply runs against the same catalog
489
+ // previously read the same base, each mutated a different CVE, and the
490
+ // later writer overwrote the earlier writer's changes. The lock + re-read
491
+ // inside the critical section ensures sibling applies merge cleanly.
492
+ let outcome = null;
493
+ await withCatalogLock(catalogPath, (catalog) => {
494
+ if (!catalog || !catalog[cveId]) {
495
+ outcome = {
496
+ ok: false,
497
+ verb: "refresh",
498
+ mode: "cve-curation-apply",
499
+ error: `CVE ${cveId} disappeared from ${path.relative(ROOT, catalogPath) || catalogPath} between question generation and apply.`,
500
+ };
501
+ return catalog; // unchanged
502
+ }
503
+ outcome = applyAnswersUnderLock(cveId, catalog, catalogPath, answers);
504
+ return catalog; // mutated in place
505
+ });
506
+ return outcome;
507
+ }
508
+
509
+ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
510
+ const entry = catalog[cveId];
511
+ const appliedFields = [];
512
+
513
+ // Whitelist of fields the operator may supply. Anything not in this map
514
+ // is ignored (so a typo'd key in answers.json doesn't pollute the entry).
515
+ // Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
516
+ const id = (x) => x;
517
+ const ALLOWED = [
518
+ ["framework_control_gaps", (v) => v && typeof v === "object" && !Array.isArray(v), id],
519
+ ["iocs", (v) => v && typeof v === "object" && !Array.isArray(v), id],
520
+ ["atlas_refs", (v) => Array.isArray(v), id],
521
+ ["attack_refs", (v) => Array.isArray(v), id],
522
+ ["rwep_factors", (v) => v && typeof v === "object" && !Array.isArray(v), id],
523
+ ["rwep_score", (v) => typeof v === "number", id],
524
+ ["rwep_notes", (v) => typeof v === "string", id],
525
+ ["cvss_score", (v) => typeof v === "number", id],
526
+ ["cvss_vector", (v) => typeof v === "string" && /^CVSS:[0-9]/.test(v), id],
527
+ ["cisa_kev", (v) => typeof v === "boolean", id],
528
+ ["cisa_kev_date", (v) => v === null || typeof v === "string", id],
529
+ ["cisa_kev_due_date", (v) => v === null || typeof v === "string", id],
530
+ ["poc_available", (v) => typeof v === "boolean", id],
531
+ ["poc_description", (v) => typeof v === "string", id],
532
+ ["ai_discovered", (v) => typeof v === "boolean", id],
533
+ ["ai_discovery_notes", (v) => typeof v === "string", id],
534
+ ["ai_assisted_weaponization", (v) => typeof v === "boolean", id],
535
+ ["ai_assisted_notes", (v) => typeof v === "string", id],
536
+ ["active_exploitation", (v) => ["confirmed", "suspected", "none", "unknown"].includes(v), id],
537
+ ["affected", (v) => typeof v === "string" && v.length > 0, id],
538
+ ["affected_versions", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
539
+ ["vector", (v) => typeof v === "string" && v.length > 0, id],
540
+ ["complexity", (v) => typeof v === "string", id],
541
+ ["complexity_notes", (v) => typeof v === "string", id],
542
+ ["patch_available", (v) => typeof v === "boolean", id],
543
+ ["patch_required_reboot", (v) => typeof v === "boolean", id],
544
+ ["live_patch_available", (v) => typeof v === "boolean", id],
545
+ ["live_patch_tools", (v) => Array.isArray(v) && v.every(x => typeof x === "string"), id],
546
+ ["live_patch_notes", (v) => typeof v === "string", id],
547
+ ["verification_sources", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
548
+ ["name", (v) => typeof v === "string" && v.length > 0, id],
549
+ ["type", (v) => typeof v === "string" && v.length > 0, id],
550
+ ["source_verified", (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v), id],
551
+ ];
552
+
553
+ const rejected = [];
554
+ for (const [field, validator, transformer] of ALLOWED) {
555
+ if (!(field in answers)) continue;
556
+ const v = answers[field];
557
+ if (!validator(v)) {
558
+ rejected.push({ field, reason: `value ${JSON.stringify(v)} failed validation` });
559
+ continue;
560
+ }
561
+ entry[field] = transformer(v);
562
+ appliedFields.push(field);
563
+ }
564
+
565
+ // Derive rwep_score from rwep_factors when factors supplied without an
566
+ // explicit score. lib/scoring.js owns the canonical formula; we sum the
567
+ // numeric values here as a fallback.
568
+ if ("rwep_factors" in answers && !("rwep_score" in answers)
569
+ && entry.rwep_factors && typeof entry.rwep_factors === "object") {
570
+ let sum = 0;
571
+ for (const v of Object.values(entry.rwep_factors)) {
572
+ if (typeof v === "number") sum += v;
573
+ }
574
+ entry.rwep_score = Math.max(0, Math.min(100, sum));
575
+ appliedFields.push("rwep_score (derived from rwep_factors)");
576
+ }
577
+
578
+ // last_updated reflects the apply moment.
579
+ entry.last_updated = new Date().toISOString().slice(0, 10);
580
+
581
+ // v0.12.12 (codex P1 #1): validate the post-apply entry against the
582
+ // strict CVE schema BEFORE deciding whether to promote. Promotion was
583
+ // previously gated on residualWarnings() alone — a presence check — so
584
+ // an input like `"ai_discovered": "false"` (string) passed presence but
585
+ // would have failed lib/validate-cve-catalog.js on type mismatch,
586
+ // creating a "promoted but invalid" entry. Now: presence + strict schema
587
+ // BOTH must hold for promotion.
588
+ const warnings = residualWarnings(entry);
589
+ const schemaErrors = [];
590
+ const entrySchema = loadCveEntrySchema();
591
+ if (entrySchema) {
592
+ const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
593
+ if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
594
+ }
595
+ let promoted = false;
596
+ if (warnings.length === 0 && schemaErrors.length === 0) {
597
+ delete entry._auto_imported;
598
+ delete entry._draft;
599
+ delete entry._draft_reason;
600
+ promoted = true;
601
+ }
602
+
603
+ // catalog has been mutated in-place under the lock; the locker writes it
604
+ // atomically when this function returns. Concurrent applies are
605
+ // serialized + see the post-merge state.
606
+ catalog[cveId] = entry;
607
+
608
+ return {
609
+ ok: true,
610
+ verb: "refresh",
611
+ mode: "cve-curation-apply",
612
+ cve_id: cveId,
613
+ applied_fields: appliedFields,
614
+ rejected_fields: rejected,
615
+ promoted,
616
+ is_draft: !promoted,
617
+ residual_warnings: warnings,
618
+ schema_errors: schemaErrors,
619
+ catalog_path: catalogPath,
620
+ next_steps: promoted
621
+ ? [
622
+ `Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --quiet\` to confirm it passes the strict schema gate.`,
623
+ `Add a matching entry to data/zeroday-lessons.json (AGENTS.md rule #6).`,
624
+ `Run \`npm run predeploy\` before tagging.`,
625
+ ]
626
+ : [
627
+ `Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`,
628
+ `Re-run \`exceptd refresh --curate ${cveId}\` to see the updated questionnaire.`,
629
+ ],
630
+ };
631
+ }
632
+
633
+ function writeJsonAtomic(p, obj) {
634
+ const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
635
+ fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
636
+ try {
637
+ fs.renameSync(tmpPath, p);
638
+ } catch (err) {
639
+ try { fs.unlinkSync(tmpPath); } catch {}
640
+ throw err;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * CLI entry — wired from bin/exceptd.js via `refresh --curate <id>` and
646
+ * `refresh --curate <id> --answers <path> [--apply]`.
233
647
  */
234
648
  async function cli(argv) {
235
- const opts = { advisory: null, json: false, catalogPath: null };
649
+ const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
236
650
  for (let i = 0; i < argv.length; i++) {
237
651
  const a = argv[i];
238
652
  if (a === "--json") opts.json = true;
653
+ else if (a === "--apply") opts.apply = true;
239
654
  else if (a === "--catalog") opts.catalogPath = argv[++i];
240
655
  else if (a.startsWith("--catalog=")) opts.catalogPath = a.slice("--catalog=".length);
656
+ else if (a === "--answers") opts.answersPath = argv[++i];
657
+ else if (a.startsWith("--answers=")) opts.answersPath = a.slice("--answers=".length);
241
658
  else if (a === "--curate") opts.advisory = argv[++i];
242
659
  else if (a.startsWith("--curate=")) opts.advisory = a.slice("--curate=".length);
243
660
  else if (!a.startsWith("--") && !opts.advisory) opts.advisory = a;
@@ -249,9 +666,47 @@ async function cli(argv) {
249
666
  process.exitCode = 2;
250
667
  return;
251
668
  }
252
- const result = curate(opts.advisory, { catalogPath: opts.catalogPath, opts });
669
+
670
+ // Operator passing --answers <path> means apply. --apply on its own is
671
+ // also accepted (for parity with --advisory --apply), but it's a no-op
672
+ // without --answers — fail loudly so a forgetful operator doesn't think
673
+ // a write succeeded when in fact the questionnaire was returned.
674
+ let answers = null;
675
+ if (opts.answersPath) {
676
+ try {
677
+ const ap = path.isAbsolute(opts.answersPath) ? opts.answersPath : path.resolve(process.cwd(), opts.answersPath);
678
+ answers = JSON.parse(fs.readFileSync(ap, "utf8"));
679
+ } catch (e) {
680
+ const err = { ok: false, verb: "refresh", mode: "cve-curation", error: `--answers ${opts.answersPath}: ${e.message}` };
681
+ if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
682
+ else process.stderr.write(`[refresh --curate] ${err.error}\n`);
683
+ process.exitCode = 2;
684
+ return;
685
+ }
686
+ }
687
+ // --answers alone implies apply; --apply alone without --answers is a
688
+ // user error.
689
+ const doApply = !!answers || opts.apply;
690
+ if (opts.apply && !answers) {
691
+ const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "--apply requires --answers <path-to-answers.json>" };
692
+ if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
693
+ else process.stderr.write(`[refresh --curate] ${err.error}\n`);
694
+ process.exitCode = 2;
695
+ return;
696
+ }
697
+
698
+ const result = await curate(opts.advisory, { catalogPath: opts.catalogPath, apply: doApply, answers });
699
+
253
700
  if (opts.json || !result.ok) {
254
701
  process.stdout.write(JSON.stringify(result) + "\n");
702
+ } else if (result.mode === "cve-curation-apply") {
703
+ 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`);
704
+ if (result.residual_warnings.length > 0) {
705
+ process.stdout.write(` residual: ${result.residual_warnings.slice(0, 6).join(", ")}${result.residual_warnings.length > 6 ? ", ..." : ""}\n`);
706
+ }
707
+ if (result.rejected_fields.length > 0) {
708
+ process.stdout.write(` rejected: ${result.rejected_fields.map(r => r.field).join(", ")}\n`);
709
+ }
255
710
  } else {
256
711
  process.stdout.write(`[refresh --curate] ${result.cve_id} — ${result.questions_open} editorial question(s) open\n`);
257
712
  for (const q of result.editorial_questions) {
@@ -263,12 +718,15 @@ async function cli(argv) {
263
718
  }
264
719
  process.stdout.write(`\n Run with --json for the full structured output.\n`);
265
720
  }
266
- // Always non-zero on a successful curation — "editorial review pending."
267
- process.exitCode = result.ok ? 3 : 2;
721
+ // Questionnaire path: exit 3 ("editorial review pending"). Apply path:
722
+ // exit 0 when promoted, 3 when residual_warnings remain (still draft).
723
+ if (!result.ok) process.exitCode = 2;
724
+ else if (result.mode === "cve-curation-apply") process.exitCode = result.promoted ? 0 : 3;
725
+ else process.exitCode = 3;
268
726
  }
269
727
 
270
728
  if (require.main === module) {
271
729
  cli(process.argv.slice(2));
272
730
  }
273
731
 
274
- module.exports = { curate, keywordOverlapScore };
732
+ module.exports = { curate, keywordOverlapScore, resolveCatalogPath, autoImportedFrom, severityWord, residualWarnings };