@blamejs/exceptd-skills 0.19.33 → 0.19.34

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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,67 +1,27 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/cve-curation.js
5
- *
6
- * Editorial-enrichment helper for auto-imported CVE catalog drafts. Given
7
- * a CVE ID that exists in data/cve-catalog.json as a draft (flagged
8
- * `_auto_imported: true`), this module cross-references the draft against
9
- * the project's existing catalogs and produces STRUCTURED EDITORIAL
10
- * QUESTIONS — candidate framework gaps, ATLAS techniques, ATT&CK techniques,
11
- * CWE refs — that a human reviewer or AI assistant uses to fill in the
12
- * null editorial fields.
13
- *
14
- * Two modes:
15
- *
16
- * curate(id, { apply: false }) → questionnaire (default)
17
- * curate(id, { apply: true, answers: {} }) → land answers into the catalog
18
- *
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.
4
+ * Editorial enrichment for auto-imported CVE catalog drafts: cross-references a
5
+ * draft in data/cve-catalog.json against the installed catalogs and produces
6
+ * structured editorial questions for a reviewer to answer. The cross-referencing
7
+ * is deterministic pattern-matching, not an LLM.
31
8
  */
32
9
 
33
10
  const fs = require("fs");
34
11
  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
12
  const { withCatalogLock } = require("./refresh-external");
45
- // `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the
46
- // strict rule set (STRICT_CVSS_PATTERN, isUsableDate on every date field,
47
- // cisa_kev=true-requires-valid-date, cross-catalog ref resolution, Hard-Rule
48
- // #14 IoCs). The predeploy gate runs `validate-cve-catalog.js --strict`, which
49
- // promotes additionalChecks warnings to FATAL errors on NON-draft entries. The
50
- // apply path must gate promotion on BOTH — otherwise it strips the _draft
51
- // markers off an entry the very next predeploy run then FATAL-rejects.
13
+ // `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the strict
14
+ // rule set `validate-cve-catalog.js --strict` promotes to FATAL on NON-draft
15
+ // entries. Promotion must gate on BOTH, or the apply path strips the _draft
16
+ // markers off an entry the next predeploy run then FATAL-rejects.
52
17
  const {
53
18
  validate: validateAgainstSchema,
54
19
  additionalChecks,
55
20
  STRICT_CVSS_PATTERN,
56
21
  isUsableDate,
57
22
  } = require("./validate-cve-catalog");
58
- // derive rwep_score via the canonical scoring helper rather
59
- // than a blind `Object.values(...).reduce(sum)`. The helper detects shape
60
- // (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
61
- // so the curation apply-path produces a score that matches whatever the
62
- // catalog scorer or playbook-runner would have produced for the same
63
- // factors. Direct dependency on scoring.js is intentional — scoring.js is
64
- // the authoritative formula.
23
+ // deriveRwepFromFactors detects factor shape (boolean inputs → scoreCustom;
24
+ // post-weight numeric inputs → sum + clamp); scoring.js holds the formula.
65
25
  const { deriveRwepFromFactors } = require("./scoring");
66
26
 
67
27
  const ROOT = path.resolve(__dirname, "..");
@@ -70,16 +30,8 @@ let _cveSchemaCache = null;
70
30
  function loadCveEntrySchema() {
71
31
  if (_cveSchemaCache) return _cveSchemaCache;
72
32
  try {
73
- // v0.12.15 (A): the prior version of this function looked for
74
- // either `root.patternProperties["^CVE-\\d{4}-\\d+$"]` or an object
75
- // `root.additionalProperties`. The actual schema at lib/schemas/cve-
76
- // catalog.schema.json has NEITHER — its top level IS the entry shape
77
- // (`{type:'object', required:[...], properties: {...}}`) because
78
- // validate-cve-catalog.js iterates each CVE id key manually and runs
79
- // the schema validator over each value. Result: loadCveEntrySchema()
80
- // always returned null, the v0.12.12 codex P1 #1 fix (strict-schema
81
- // gating of promotion) was silently disabled, and schema-violating
82
- // entries promoted anyway. Use the root schema directly.
33
+ // The schema's top level IS the entry shape — no patternProperties wrapper
34
+ // to unwrap; validate-cve-catalog.js iterates the CVE id keys itself.
83
35
  const root = JSON.parse(fs.readFileSync(CVE_SCHEMA_PATH, "utf8"));
84
36
  _cveSchemaCache = root || null;
85
37
  return _cveSchemaCache;
@@ -88,10 +40,7 @@ function loadCveEntrySchema() {
88
40
  }
89
41
  }
90
42
 
91
- // Lazy-loaded module-level catalog cache. The ATLAS / ATT&CK / CWE /
92
- // framework-control-gaps catalogs don't change inside a single CLI process,
93
- // so re-reading them per `curate()` call is wasted I/O. Cache once per
94
- // process; batch-curate paths (future) get the speedup for free.
43
+ // These catalogs cannot change inside a CLI process; read once per process.
95
44
  const catalogsCache = {};
96
45
 
97
46
  function loadJsonRaw(absPath) {
@@ -99,9 +48,8 @@ function loadJsonRaw(absPath) {
99
48
  catch { return null; }
100
49
  }
101
50
 
102
- // Resolve a catalog path that may be absolute (operator passed
103
- // `--catalog C:\tmp\cat.json` or `/tmp/cat.json`) or repo-relative. Plain
104
- // `path.join(ROOT, abs)` mishandles absolute paths on Windows.
51
+ // A `--catalog` path may be absolute or repo-relative, and a plain
52
+ // `path.join(ROOT, abs)` mishandles an absolute path on Windows.
105
53
  function resolveCatalogPath(p) {
106
54
  if (!p) return path.join(ROOT, "data", "cve-catalog.json");
107
55
  if (path.isAbsolute(p)) return p;
@@ -109,9 +57,8 @@ function resolveCatalogPath(p) {
109
57
  }
110
58
 
111
59
  function loadJson(relOrAbs) {
112
- // Repo-relative reads (atlas-ttps.json, attack-ttps.json, etc.) cache
113
- // by relative key. Absolute paths bypass the cache — they're operator-
114
- // supplied and may legitimately change between invocations.
60
+ // Repo-relative reads cache by relative key. Absolute paths bypass the cache:
61
+ // they are operator-supplied and may change between invocations.
115
62
  if (path.isAbsolute(relOrAbs)) return loadJsonRaw(relOrAbs);
116
63
  if (catalogsCache[relOrAbs] !== undefined) return catalogsCache[relOrAbs];
117
64
  const v = loadJsonRaw(path.join(ROOT, relOrAbs));
@@ -119,11 +66,8 @@ function loadJson(relOrAbs) {
119
66
  return v;
120
67
  }
121
68
 
122
- // Build the cross-reference context additionalChecks() consumes — the same
123
- // id Sets lib/validate-cve-catalog.js#main() assembles. Cached per process
124
- // (the underlying catalogs are immutable inside one CLI run). A `null` Set
125
- // (catalog absent) makes additionalChecks() skip that ref family, matching
126
- // the validator's defense-in-depth behaviour.
69
+ // The cross-reference context additionalChecks() consumes — the same id Sets
70
+ // lib/validate-cve-catalog.js#main() assembles. A `null` Set skips that family.
127
71
  let _strictCtxCache = null;
128
72
  function strictCheckCtx() {
129
73
  if (_strictCtxCache) return _strictCtxCache;
@@ -143,10 +87,7 @@ function strictCheckCtx() {
143
87
  return _strictCtxCache;
144
88
  }
145
89
 
146
- /**
147
- * Score a candidate by counting keyword overlap with the draft entry.
148
- * Returns 0..100. Pure heuristic — reviewer makes the final call.
149
- */
90
+ // Keyword overlap between a candidate and the draft, 0..100; a ranking heuristic.
150
91
  function keywordOverlapScore(draftText, candidateText) {
151
92
  if (!draftText || !candidateText) return 0;
152
93
  const draftWords = new Set(
@@ -177,10 +118,8 @@ function pickCandidates(draftDigest, catalog, idField, descriptionField) {
177
118
  return candidates.slice(0, 5);
178
119
  }
179
120
 
180
- // Report the actual upstream source on a draft. A check that only knows
181
- // about `_source_ghsa_id` would tag every OSV-imported draft (MAL-*,
182
- // SNYK-*, RUSTSEC-*, etc. that land via lib/source-osv.js) as "unknown".
183
- // The registry makes future sources a one-line addition.
121
+ // A registry rather than a `_source_ghsa_id` check, which would report every
122
+ // OSV-imported draft (MAL-*, SNYK-*, RUSTSEC-*) as "unknown".
184
123
  const SOURCE_FIELDS = [
185
124
  { field: "_source_ghsa_id", label: "GHSA" },
186
125
  { field: "_source_osv_id", label: "OSV" },
@@ -196,10 +135,7 @@ function autoImportedFrom(draft) {
196
135
  return "unknown";
197
136
  }
198
137
 
199
- // Severity word. cvss_score: null returns "unrated" — collapsing it to
200
- // "low" would be wrong + misleading on a draft that has not been scored
201
- // yet. "unrated" lets operators grep for unscored drafts and the curate
202
- // summary reflects the actual state.
138
+ // An unscored draft is "unrated", never "low": no score is not a low score.
203
139
  function severityWord(score) {
204
140
  if (typeof score !== "number" || Number.isNaN(score)) return "unrated";
205
141
  if (score >= 9) return "critical";
@@ -208,9 +144,7 @@ function severityWord(score) {
208
144
  return "low";
209
145
  }
210
146
 
211
- // The fields the strict CVE schema requires. Used to (a) extend the
212
- // questionnaire so the operator is prompted for every blocking gap, and
213
- // (b) compute `residual_warnings` after an apply.
147
+ // The fields the strict CVE schema requires; drives `residual_warnings`.
214
148
  const REQUIRED_SCHEMA_FIELDS = [
215
149
  "name", "type", "cvss_score", "cvss_vector", "cisa_kev",
216
150
  "poc_available", "ai_discovered", "ai_assisted_weaponization", "active_exploitation",
@@ -253,17 +187,14 @@ function residualWarnings(entry) {
253
187
  }
254
188
 
255
189
  /**
256
- * Build the curation report — questionnaire or apply.
257
- *
258
- * curate(cveId, { catalogPath, apply, answers })
190
+ * The curation report for one CVE.
259
191
  *
260
- * apply: false (default) → return { ok, editorial_questions, ... }
261
- * apply: true → consume answers, write the catalog, return
262
- * { ok, applied_fields, residual_warnings, ... }
192
+ * apply: false (default) → { ok, editorial_questions, ... }
193
+ * apply: true → consumes answers, writes the catalog, and returns
194
+ * { ok, applied_fields, residual_warnings, ... }
263
195
  */
264
196
  async function curate(cveId, opts = {}) {
265
- // Back-compat: earlier signature was `curate(id, { catalogPath, opts })`
266
- // — accept either shape so the CLI wiring stays stable.
197
+ // The nested `{ opts: { catalogPath } }` shape is accepted as well.
267
198
  if (opts && opts.opts && !opts.catalogPath && opts.opts.catalogPath) {
268
199
  opts = { ...opts, catalogPath: opts.opts.catalogPath };
269
200
  }
@@ -279,9 +210,7 @@ async function curate(cveId, opts = {}) {
279
210
  }
280
211
  const draft = catalog[cveId];
281
212
 
282
- // Only curate drafts. Editing a human-curated entry is intentional and
283
- // happens via direct file edit — refusing here prevents accidental
284
- // suggestion-storms on entries that have already been reviewed.
213
+ // Drafts only: a reviewed entry is edited directly.
285
214
  if (!draft._auto_imported && !draft._draft) {
286
215
  return {
287
216
  ok: false,
@@ -292,17 +221,14 @@ async function curate(cveId, opts = {}) {
292
221
  };
293
222
  }
294
223
 
295
- // ----- APPLY PATH (J1) -------------------------------------------------
296
224
  if (opts.apply && opts.answers) {
297
225
  return applyAnswers(cveId, draft, catalog, catalogPath, opts.answers);
298
226
  }
299
227
 
300
- // ----- QUESTIONNAIRE PATH ---------------------------------------------
301
228
  return buildQuestionnaire(cveId, draft);
302
229
  }
303
230
 
304
231
  function buildQuestionnaire(cveId, draft) {
305
- // Digest of the draft — feed into keyword-overlap scoring.
306
232
  const draftDigest = [
307
233
  draft.name || "",
308
234
  draft.affected || "",
@@ -311,15 +237,9 @@ function buildQuestionnaire(cveId, draft) {
311
237
  draft.type || "",
312
238
  ].filter(Boolean).join(" ");
313
239
 
314
- // Pull candidate catalogs. Each is optional — missing catalogs are skipped
315
- // gracefully. J7 makes these one-shot loads per process.
240
+ // Each candidate catalog is optional; an absent one loads as null and is
241
+ // skipped. A misnamed path reads the same way — as zero proposals.
316
242
  const atlas = loadJson("data/atlas-ttps.json");
317
- // v0.12.15 (E): the catalog ships as data/attack-techniques.json
318
- // (renamed from data/attack-ttps.json before the v0.12.12 release; the
319
- // canonical file path is also what lib/validate-cve-catalog.js consumes).
320
- // The prior `data/attack-ttps.json` lookup silently fell back to an empty
321
- // object via loadJsonRaw's ENOENT handling, so the ATT&CK candidate
322
- // questionnaire branch always returned zero proposals.
323
243
  const attack = loadJson("data/attack-techniques.json");
324
244
  const frameworkGaps = loadJson("data/framework-control-gaps.json");
325
245
 
@@ -328,15 +248,10 @@ function buildQuestionnaire(cveId, draft) {
328
248
  // CWE candidates surface as part of the vector question — not its own field.
329
249
  const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
330
250
 
331
- // Build the editorial-questions list. Each entry names the catalog field,
332
- // its current value (likely null on a draft), ranked candidates, and a
333
- // specific ASK to surface what the reviewer needs to decide.
334
251
  const questions = [];
335
252
 
336
- // Pre-filled empty containers (`iocs: {}`, `atlas_refs: []`) must NOT
337
- // be treated as "answered". `if (!draft.iocs)` is truthy for {}, which
338
- // would skip the prompt. Use explicit emptiness checks so drafts
339
- // seeded with empty objects / arrays still get the prompt.
253
+ // A pre-filled empty container (`iocs: {}`, `atlas_refs: []`) is not an
254
+ // answer, and `!draft.iocs` is false for `{}` — so emptiness is explicit.
340
255
  const arrEmpty = (a) => !Array.isArray(a) || a.length === 0;
341
256
  const objEmpty = (o) => !o || typeof o !== "object" || Object.keys(o).length === 0;
342
257
 
@@ -413,10 +328,7 @@ function buildQuestionnaire(cveId, draft) {
413
328
  });
414
329
  }
415
330
 
416
- // Schema-required field prompts. Without these populated, the apply
417
- // path cannot produce a schema-passing entry — the entry stays a draft
418
- // even after curate --apply. Prompt for them explicitly so the
419
- // operator sees what's actually blocking promotion.
331
+ // Schema-required: unpopulated, `--apply` leaves the entry a draft.
420
332
  if (draft.cvss_score === null || draft.cvss_score === undefined
421
333
  || draft.cvss_vector === null || draft.cvss_vector === undefined
422
334
  || draft.cvss_vector === "") {
@@ -454,9 +366,7 @@ function buildQuestionnaire(cveId, draft) {
454
366
  });
455
367
  }
456
368
 
457
- // KEV is always-ask: the operator might know KEV applies (and even has
458
- // a date) even when the upstream KEV feed hasn't caught up yet. False
459
- // is a valid answer.
369
+ // Always asked: the operator may know a listing the KEV feed has not caught up.
460
370
  if (draft.cisa_kev === null || draft.cisa_kev === undefined) {
461
371
  questions.push({
462
372
  field: "cisa_kev + cisa_kev_date",
@@ -466,9 +376,8 @@ function buildQuestionnaire(cveId, draft) {
466
376
  });
467
377
  }
468
378
 
469
- // The KEV block above only fires while cisa_kev is still unanswered, so a
470
- // draft that already knows it is listed needs its own checks for the two
471
- // fields that come from the same record and are easy to leave behind.
379
+ // The block above fires only while cisa_kev is unanswered, so a draft that
380
+ // already knows it is listed needs its own check for the sibling fields.
472
381
  if (draft.cisa_kev === true && !draft.cisa_kev_due_date) {
473
382
  questions.push({
474
383
  field: "cisa_kev_due_date",
@@ -478,9 +387,8 @@ function buildQuestionnaire(cveId, draft) {
478
387
  });
479
388
  }
480
389
 
481
- // A KEV-listed entry has to carry the designation as a boolean: an absent
482
- // field reads as false to every consumer, which asserts something CISA's
483
- // record may not say.
390
+ // A KEV-listed entry carries the designation as a boolean: an absent field
391
+ // reads as false to every consumer, asserting something CISA may not say.
484
392
  if (draft.cisa_kev === true && typeof draft.known_ransomware_use !== "boolean") {
485
393
  questions.push({
486
394
  field: "known_ransomware_use",
@@ -517,43 +425,13 @@ function buildQuestionnaire(cveId, draft) {
517
425
  }
518
426
 
519
427
  /**
520
- * Apply operator-supplied answers to a draft and write back atomically.
521
- *
522
- * Answers shape (each key optional; missing keys leave the draft unchanged
523
- * for that field):
524
- *
525
- * {
526
- * framework_control_gaps: { "NIST-800-53-SI-2": "...", ... },
527
- * iocs: { payload_artifacts: [...], behavioral: [...], ... },
528
- * atlas_refs: ["AML.T0010", ...],
529
- * attack_refs: ["T1195.002", ...],
530
- * rwep_factors: { cisa_kev: 25, blast_radius: 30, ... },
531
- * rwep_score: 80, // optional; derived from sum if absent
532
- * cvss_score: 9.8,
533
- * cvss_vector: "CVSS:3.1/AV:N/...",
534
- * cisa_kev: true, // or false
535
- * cisa_kev_date: "2026-05-13",
536
- * poc_available: true,
537
- * poc_description: "...",
538
- * ai_discovered: false,
539
- * ai_assisted_weaponization: false,
540
- * active_exploitation: "confirmed" | "suspected" | "theoretical" | "none" | "unknown",
541
- * vector: "...",
542
- * complexity: "...",
543
- * patch_available: true,
544
- * patch_required_reboot: false,
545
- * live_patch_available: true,
546
- * live_patch_tools: ["kpatch", "kgraft"],
547
- * affected_versions: ["...", "..."],
548
- * verification_sources: ["https://...", ...]
549
- * }
428
+ * Applies operator-supplied answers to a draft and writes back atomically. Every
429
+ * answer key is optional; the ALLOWED table below is the authoritative list of
430
+ * keys and their shapes. rwep_score derives from rwep_factors when it is absent.
550
431
  */
551
432
  async function applyAnswers(cveId, _draftSnapshot, _catalogSnapshot, catalogPath, answers) {
552
- // v0.12.12 (codex P1 #2): wrap the entire read-modify-write under
553
- // withCatalogLock. Two concurrent --apply runs against the same catalog
554
- // previously read the same base, each mutated a different CVE, and the
555
- // later writer overwrote the earlier writer's changes. The lock + re-read
556
- // inside the critical section ensures sibling applies merge cleanly.
433
+ // The whole read-modify-write runs under withCatalogLock, re-reading the
434
+ // catalog inside the critical section so sibling applies merge.
557
435
  let outcome = null;
558
436
  await withCatalogLock(catalogPath, (catalog) => {
559
437
  if (!catalog || !catalog[cveId]) {
@@ -575,24 +453,14 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
575
453
  const entry = catalog[cveId];
576
454
  const appliedFields = [];
577
455
 
578
- // Whitelist of fields the operator may supply. Anything not in this map
579
- // is ignored (so a typo'd key in answers.json doesn't pollute the entry).
580
- // Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
456
+ // The fields an operator may supply, as [field, validator, transformer]. A key
457
+ // absent from the table is ignored, so a typo cannot pollute the entry.
581
458
  const id = (x) => x;
582
459
 
583
- // rwep_factors must arrive as the post-weight (Shape B) block the schema
584
- // mandates: a non-array object whose EVERY value is a finite number. A
585
- // permissive "any object" check let a mixed-shape bag through — post-weight
586
- // integers carrying a string active_exploitation, or an all-numeric block
587
- // plus a rogue extra key holding a ladder string ('confirmed'). Either
588
- // shape flips deriveRwepFromFactors() onto its Shape-A (scoreCustom) route,
589
- // which reads the post-weight integers as booleans and DROPS the unmapped
590
- // ai_factor weight — deriving a silently wrong rwep_score (e.g. 55/75 for a
591
- // bag the operator intended as 90) and writing it to the catalog. Rejecting
592
- // any non-numeric value here turns that silent mis-score into a surfaced
593
- // rejected_fields entry at apply time, instead of relying on the downstream
594
- // scoring.validate() divergence gate (which runs only at predeploy, after
595
- // the wrong score is already on disk).
460
+ // rwep_factors must arrive as the post-weight block the schema mandates: a
461
+ // non-array object whose EVERY value is a finite number. A mixed bag flips
462
+ // deriveRwepFromFactors() onto its scoreCustom route, which reads the integers
463
+ // as booleans and writes a silently wrong rwep_score.
596
464
  const isPostWeightFactors = (v) =>
597
465
  v && typeof v === "object" && !Array.isArray(v) &&
598
466
  Object.values(v).every((x) => typeof x === "number" && Number.isFinite(x));
@@ -606,11 +474,8 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
606
474
  ["rwep_score", (v) => typeof v === "number", id],
607
475
  ["rwep_notes", (v) => typeof v === "string", id],
608
476
  ["cvss_score", (v) => typeof v === "number", id],
609
- // Match the predeploy strict gate at apply time: a non-existent CVSS
610
- // version (`CVSS:9/...`) and a malformed/impossible KEV date previously
611
- // survived the per-field check, landed on the entry, then blocked
612
- // promotion downstream. Rejecting them here surfaces the exact field as a
613
- // rejected_fields entry instead of a silent block.
477
+ // These match the predeploy strict gate at apply time: a `CVSS:9/...` vector
478
+ // or a malformed KEV date otherwise blocks promotion with no field named.
614
479
  ["cvss_vector", (v) => typeof v === "string" && STRICT_CVSS_PATTERN.test(v), id],
615
480
  ["cisa_kev", (v) => typeof v === "boolean", id],
616
481
  ["cisa_kev_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
@@ -653,27 +518,18 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
653
518
  appliedFields.push(field);
654
519
  }
655
520
 
656
- // derive rwep_score via the canonical scoring helper rather
657
- // than a blind sum. deriveRwepFromFactors detects shape (boolean inputs
658
- // → scoreCustom; post-weight numeric inputs → sum + clamp) and routes
659
- // accordingly, so the apply-path produces a score that agrees with
660
- // scoring.validate() instead of diverging from it.
521
+ // Derived through the canonical helper, not a blind sum, so the score agrees
522
+ // with scoring.validate() rather than diverging from it.
661
523
  if ("rwep_factors" in answers && !("rwep_score" in answers)
662
524
  && entry.rwep_factors && typeof entry.rwep_factors === "object") {
663
525
  entry.rwep_score = deriveRwepFromFactors(entry.rwep_factors);
664
526
  appliedFields.push("rwep_score (derived from rwep_factors via scoring.deriveRwepFromFactors)");
665
527
  }
666
528
 
667
- // last_updated reflects the apply moment.
668
529
  entry.last_updated = new Date().toISOString().slice(0, 10);
669
530
 
670
- // v0.12.12 (codex P1 #1): validate the post-apply entry against the
671
- // strict CVE schema BEFORE deciding whether to promote. Promotion was
672
- // previously gated on residualWarnings() alone — a presence check — so
673
- // an input like `"ai_discovered": "false"` (string) passed presence but
674
- // would have failed lib/validate-cve-catalog.js on type mismatch,
675
- // creating a "promoted but invalid" entry. Now: presence + strict schema
676
- // BOTH must hold for promotion.
531
+ // Promotion needs presence AND the strict schema: a presence check alone
532
+ // accepts `"ai_discovered": "false"`, leaving a promoted-but-invalid entry.
677
533
  const warnings = residualWarnings(entry);
678
534
  const schemaErrors = [];
679
535
  const entrySchema = loadCveEntrySchema();
@@ -681,17 +537,9 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
681
537
  const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
682
538
  if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
683
539
  }
684
- // The loose schema accepts values the predeploy `validate-cve-catalog.js
685
- // --strict` gate FATAL-rejects on a non-draft entry: a cvss_vector for a
686
- // non-existent CVSS version (`CVSS:9/...` passes the schema's
687
- // `^CVSS:[0-9]+(\.[0-9]+)?/` but fails STRICT_CVSS_PATTERN), a cisa_kev=true
688
- // with a malformed/impossible cisa_kev_date, an atlas/attack/cwe/d3fend/
689
- // framework ref that resolves to no catalog entry, or a poc+public-exploit
690
- // source with no iocs. Run the SAME additionalChecks() the validator runs
691
- // and fold its findings into the promotion gate, so the apply path never
692
- // strips _draft off an entry the next predeploy run then rejects. Surfaced
693
- // under a dedicated `strict_warnings` key so the operator sees exactly which
694
- // strict rule blocked promotion.
540
+ // The loose schema accepts values `validate-cve-catalog.js --strict`
541
+ // FATAL-rejects on a non-draft entry, so the validator's own additionalChecks()
542
+ // runs here too; `strict_warnings` names the rule.
695
543
  const strictWarnings = additionalChecks(cveId, entry, strictCheckCtx());
696
544
  let promoted = false;
697
545
  if (warnings.length === 0 && schemaErrors.length === 0 && strictWarnings.length === 0) {
@@ -701,9 +549,7 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
701
549
  promoted = true;
702
550
  }
703
551
 
704
- // catalog has been mutated in-place under the lock; the locker writes it
705
- // atomically when this function returns. Concurrent applies are
706
- // serialized + see the post-merge state.
552
+ // Mutated in place under the lock; the locker writes it atomically on return.
707
553
  catalog[cveId] = entry;
708
554
 
709
555
  return {
@@ -738,11 +584,8 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
738
584
 
739
585
  function writeJsonAtomic(p, obj) {
740
586
  const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
741
- // v0.13.0: fsync the tmp file before rename so a power loss between
742
- // write and rename leaves the durable destination intact. Without
743
- // fsync the data sits in the OS page cache and the rename succeeds
744
- // atomically, but the renamed file may be zero-length / partial on
745
- // crash. Open + write + fsync + close + rename is the durable idiom.
587
+ // fsync before the rename: without it the rename is still atomic, but the
588
+ // renamed file can be zero-length or partial after a crash.
746
589
  const fd = fs.openSync(tmpPath, 'w');
747
590
  try {
748
591
  fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
@@ -758,14 +601,10 @@ function writeJsonAtomic(p, obj) {
758
601
  }
759
602
  }
760
603
 
761
- /**
762
- * CLI entry — wired from bin/exceptd.js via `refresh --curate <id>` and
763
- * `refresh --curate <id> --answers <path> [--apply]`.
764
- */
604
+ // CLI entry, wired from bin/exceptd.js as `refresh --curate <id>` and
605
+ // `refresh --curate <id> --answers <path> [--apply]`.
765
606
  async function cli(argv) {
766
- // Batch curation (facts file + judgments file → gate-passing catalog
767
- // entries) is a distinct workflow from the single-CVE questionnaire
768
- // below; delegate before the --curate opts loop touches argv.
607
+ // A distinct workflow, delegated before the opts loop below consumes argv.
769
608
  if (argv.includes("--curate-batch")) return require("./cve-batch.js").cli(argv);
770
609
  const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
771
610
  for (let i = 0; i < argv.length; i++) {
@@ -788,10 +627,8 @@ async function cli(argv) {
788
627
  return;
789
628
  }
790
629
 
791
- // Operator passing --answers <path> means apply. --apply on its own is
792
- // also accepted (for parity with --advisory --apply), but it's a no-op
793
- // without --answers — fail loudly so a forgetful operator doesn't think
794
- // a write succeeded when in fact the questionnaire was returned.
630
+ // `--answers <path>` implies apply. `--apply` alone is a no-op that would
631
+ // return the questionnaire, so it fails loudly rather than read as a write.
795
632
  let answers = null;
796
633
  if (opts.answersPath) {
797
634
  try {
@@ -805,8 +642,6 @@ async function cli(argv) {
805
642
  return;
806
643
  }
807
644
  }
808
- // --answers alone implies apply; --apply alone without --answers is a
809
- // user error.
810
645
  const doApply = !!answers || opts.apply;
811
646
  if (opts.apply && !answers) {
812
647
  const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "--apply requires --answers <path-to-answers.json>" };
@@ -839,8 +674,8 @@ async function cli(argv) {
839
674
  }
840
675
  process.stdout.write(`\n Run with --json for the full structured output.\n`);
841
676
  }
842
- // Questionnaire path: exit 3 ("editorial review pending"). Apply path:
843
- // exit 0 when promoted, 3 when residual_warnings remain (still draft).
677
+ // Exit 2 on error, 3 for "editorial review pending" — the questionnaire, or
678
+ // an apply that left the entry a draft — and 0 only on promotion.
844
679
  if (!result.ok) process.exitCode = 2;
845
680
  else if (result.mode === "cve-curation-apply") process.exitCode = result.promoted ? 0 : 3;
846
681
  else process.exitCode = 3;
@@ -848,10 +683,8 @@ async function cli(argv) {
848
683
 
849
684
  if (require.main === module) {
850
685
  cli(process.argv.slice(2)).catch((err) => {
851
- // An apply-path write failure (disk full / read-only FS / permission
852
- // denied) throws out of cli(). Emit the same {ok:false,verb,mode,error}
853
- // envelope every other error path in cli() produces instead of crashing
854
- // with a raw stack trace, and set exitCode so stderr drains before exit.
686
+ // A throw out of cli() emits the same {ok:false,verb,mode,error} envelope as
687
+ // every other error path, and `process.exitCode`, so stderr drains first.
855
688
  process.stderr.write(JSON.stringify({ ok: false, verb: "refresh", mode: "cve-curation", error: String((err && err.message) || err) }) + "\n");
856
689
  process.exitCode = 2;
857
690
  });