@blamejs/exceptd-skills 0.19.32 → 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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,15 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Cross-reference API — pure read-only knowledge queries over data/ + data/_indexes/.
5
- *
6
- * This is the knowledge layer the host AI calls into during the ANALYZE phase
7
- * of every directive. No probes, no shellouts, no network. Every function takes
8
- * an identifier and returns correlated catalog entries from CVE / CWE / ATLAS /
9
- * ATT&CK / D3FEND / framework-gaps / global-frameworks / RFC / zero-day-lessons,
10
- * plus pre-computed indexes (xref, chains, recipes, theater-fingerprints).
11
- *
12
- * Catalogs are loaded lazily and cached for the lifetime of the process.
4
+ * Cross-reference API — read-only queries over data/ and data/_indexes/. Every
5
+ * function takes an identifier and returns the correlated catalog entries.
6
+ * Catalogs load lazily and are cached for the lifetime of the process.
13
7
  */
14
8
 
15
9
  const fs = require('fs');
@@ -19,35 +13,17 @@ const ROOT = path.join(__dirname, '..');
19
13
  const DATA_DIR = process.env.EXCEPTD_DATA_DIR || path.join(ROOT, 'data');
20
14
  const INDEX_DIR = path.join(DATA_DIR, '_indexes');
21
15
 
22
- // Cache entries store the parsed payload AND the mtimeMs of the source
23
- // file at parse-time. Each load call re-stats the file; if mtime matches,
24
- // the cached value is returned (one syscall, no parse). If mtime changed,
25
- // re-parse + repopulate. If stat fails (file vanished mid-run, permission
26
- // glitch), fall back to the cached value. Without mtime-keyed
27
- // invalidation, long-running `orchestrator watch` processes never see
28
- // `data/cve-catalog.json` mutations driven by `exceptd refresh --apply`.
16
+ // Each entry stores the parsed payload beside the source file's stat signature,
17
+ // so a long-running process sees a catalog that `refresh --apply` rewrote.
29
18
  const _cache = new Map();
30
19
 
31
- // v0.12.14: catalog corruption no longer crashes the runner
32
- // uncaught. A malformed JSON file in data/ used to produce a SyntaxError
33
- // at require-time of any consumer (lib/playbook-runner.js), which threw
34
- // out of the run() entrypoint without honoring AGENTS.md's "non-zero
35
- // exit + {ok:false, error} to stderr" contract. Now: caught + degraded
36
- // to an empty catalog with a recorded _loadError that downstream code
37
- // can inspect.
20
+ // A malformed JSON file degrades to an empty catalog carrying a recorded load
21
+ // error, rather than throwing out of the run() entrypoint.
38
22
  const _loadErrors = [];
39
23
 
40
24
  /**
41
- * v0.13.0: cache invalidation is keyed on (mtimeMs, size). Pre-v0.13 it
42
- * was mtime-only, but on filesystems with 1-2s mtime granularity
43
- * (FAT32, HFS+ pre-APFS, NFSv3, Docker bind-mounts that proxy mtime)
44
- * a rapid refresh-then-reload within the same second served stale
45
- * cached data. Adding `size` catches every content change that affects
46
- * byte count; mtimeMs catches in-place rewrites that preserve byte
47
- * count. Together they cover every realistic catalog-mutation path
48
- * without the cost of a per-load SHA computation. SHA-based tier is
49
- * available via _statContentHash() when callers want full invalidation
50
- * (e.g. long-running daemons against append-only catalogs).
25
+ * Keyed on (mtimeMs, size): on a filesystem with 1-2s mtime granularity a
26
+ * refresh-then-reload inside the same second serves stale data on mtime alone.
51
27
  */
52
28
  function _statSignature(p) {
53
29
  try {
@@ -119,47 +95,34 @@ function entries(catalog) {
119
95
  return Object.entries(catalog).filter(([k]) => !k.startsWith('_'));
120
96
  }
121
97
 
122
- // Auto-imported drafts carry conservative-default mechanical fields and
123
- // null analytical fields pending curation. byCve() excludes them by
124
- // default; every transitive enumeration that walks the same catalog
125
- // (byCwe / byTtp / bySkill) must apply the identical contract so a draft
126
- // never surfaces as a curated cross-reference. Keyed on `_auto_imported`
127
- // to match byCve's exact predicate, so all four entry points agree.
98
+ // Auto-imported drafts carry null analytical fields. byCve, byCwe, byTtp and
99
+ // bySkill all exclude on this one predicate, so a draft never reads as curated.
128
100
  function _isDraftEntry(c) {
129
101
  return !!c && c._auto_imported === true;
130
102
  }
131
103
 
132
- // Single source of truth for the xref sub-maps the skill-correlation
133
- // queries read. These names MUST stay identical to the keys the index
134
- // builder emits into data/_indexes/xref.json; reading under a name the
135
- // builder never writes silently yields empty correlations. The TTP maps
136
- // are split by id space — ATLAS ids (AML.*) live in atlas_refs, ATT&CK
137
- // ids (T*) in attack_refs — so a TTP lookup unions both.
104
+ // These names must match the keys the index builder emits into
105
+ // data/_indexes/xref.json — a name it never writes yields empty correlations with
106
+ // no error. ATLAS ids (AML.*) live in atlas_refs, ATT&CK ids in attack_refs.
138
107
  const XREF_KEYS = {
139
108
  cwe: 'cwe_refs',
140
109
  atlas: 'atlas_refs',
141
110
  attack: 'attack_refs',
142
111
  };
143
112
 
144
- // CWE -> [skill, ...] from the xref index.
145
113
  function skillsForCwe(xref, cweId) {
146
114
  return (xref[XREF_KEYS.cwe] && xref[XREF_KEYS.cwe][cweId]) || [];
147
115
  }
148
116
 
149
- // TTP -> [skill, ...]; ATLAS and ATT&CK ids occupy separate maps, so a
150
- // single id resolves through whichever map owns its prefix (with a fall
151
- // back to the other in case a caller passes an unprefixed id).
117
+ // An id resolves through the map owning its prefix, falling back to the other.
152
118
  function skillsForTtp(xref, ttpId) {
153
119
  const atlas = xref[XREF_KEYS.atlas] || {};
154
120
  const attack = xref[XREF_KEYS.attack] || {};
155
121
  return (ttpId.startsWith('AML.') ? atlas[ttpId] : attack[ttpId]) || atlas[ttpId] || attack[ttpId] || [];
156
122
  }
157
123
 
158
- // No CVE->skill map exists in the index (no skill declares a CVE list, so
159
- // the builder never emits one). The real linkage runs through the CVE's
160
- // declared CWEs: each CWE maps to skills via the cwe_refs map. Union the
161
- // skills across every CWE the CVE references, sorted + de-duplicated so
162
- // the result is stable regardless of CWE ordering.
124
+ // No CVE->skill map exists, so the linkage runs through the CVE's declared CWEs.
125
+ // The union is sorted so the result does not depend on CWE ordering.
163
126
  function skillsForCve(xref, cveEntry) {
164
127
  const out = new Set();
165
128
  for (const cwe of (cveEntry && cveEntry.cwe_refs) || []) {
@@ -168,21 +131,11 @@ function skillsForCve(xref, cveEntry) {
168
131
  return [...out].sort();
169
132
  }
170
133
 
171
- // --- public API ---
172
-
173
134
  /**
174
- * Full correlation for a CVE ID. Returns the catalog entry plus everything
175
- * that references it across skills, framework gaps, theater fingerprints,
176
- * recipes, and zero-day lessons.
177
- *
178
- * Auto-imported drafts (entries with `_auto_imported === true`) are
179
- * EXCLUDED by default. Drafts carry conservative-default mechanical fields
180
- * and null analytical fields pending curation; downstream analyze / bundle
181
- * emitters that assume `byCve()` returns curated data would treat the
182
- * draft's placeholders as authoritative. The cve-curation flow (which
183
- * surfaces the editorial questionnaire) opts in via
184
- * `byCve(id, { include_drafts: true })`; every other caller stays on the
185
- * default exclude path.
135
+ * Full correlation for a CVE id: the catalog entry plus everything referencing
136
+ * it across skills, framework gaps, theater fingerprints and zero-day lessons.
137
+ * Auto-imported drafts are excluded unless `opts.include_drafts` is set, since
138
+ * callers read what this returns as curated.
186
139
  */
187
140
  function byCve(cveId, opts) {
188
141
  const includeDrafts = !!(opts && opts.include_drafts);
@@ -198,27 +151,15 @@ function byCve(cveId, opts) {
198
151
  const gaps = loadCatalog('framework-control-gaps.json');
199
152
  const lessons = loadCatalog('zeroday-lessons.json');
200
153
 
201
- // Skills correlate to a CVE transitively through its declared CWEs
202
- // (CVE -> cwe_refs -> xref.cwe_refs -> skills); there is no direct
203
- // CVE->skill index.
204
154
  const skills = skillsForCve(xref, entry);
205
- // (Recipes are use-case curated, not CVE-triggered — recipes.json has no
206
- // `triggered_by`/CVE keying, so a per-CVE recipe lookup was always empty.
207
- // The dead `recipes:[]` field is no longer emitted.)
208
- //
209
- // Theater fingerprints live under the index's `patterns` container; each
210
- // pattern records a single `evidence.cve` (or `evidence.campaign`, which
211
- // carries no CVE to match). The distinguishing check is `fast_test`.
155
+ // Theater fingerprints live under the index's `patterns` container; each records
156
+ // a single `evidence.cve`, and `fast_test` is the distinguishing check.
212
157
  const theater = Object.entries(theaterFp.patterns || {})
213
158
  .filter(([, t]) => t && t.evidence && t.evidence.cve === cveId)
214
159
  .map(([id, t]) => ({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test }));
215
- // Framework-control-gaps link CVEs through `evidence_cves`; the control
216
- // identifier field is `control_id`.
217
160
  const framework_gaps = entries(gaps).filter(([, g]) =>
218
161
  Array.isArray(g.evidence_cves) && g.evidence_cves.includes(cveId)
219
162
  ).map(([id, g]) => ({ id, framework: g.framework, control: g.control_id, status: g.status }));
220
- // Zero-day lessons are keyed by CVE id, so a referenced lesson is a
221
- // direct key hit rather than a back-reference scan.
222
163
  const lessons_learned = lessons[cveId] ? [cveId] : [];
223
164
 
224
165
  return {
@@ -251,11 +192,8 @@ function byCwe(cweId) {
251
192
  }
252
193
 
253
194
  function byTtp(ttpId) {
254
- // TTP ids span two disjoint catalogs (ATLAS AML.* vs ATT&CK T*).
255
- // Resolve the record from whichever owns the id — namespaces never
256
- // collide, so order is irrelevant. Previously only atlas-ttps.json was
257
- // consulted, so every ATT&CK technique reported found:false / entry:null
258
- // even though skills + related_cves correctly unioned both spaces.
195
+ // TTP ids span two disjoint catalogs (ATLAS AML.* vs ATT&CK T*); consulting
196
+ // only one leaves every technique in the other reporting found:false.
259
197
  const atlas = loadCatalog('atlas-ttps.json');
260
198
  const attack = loadCatalog('attack-techniques.json');
261
199
  const xref = loadIndex('xref.json');
@@ -269,10 +207,8 @@ function byTtp(ttpId) {
269
207
  )
270
208
  )
271
209
  .map(([id]) => id);
272
- // D3FEND maps countermeasures to the techniques they defeat through the
273
- // `counters_attack_techniques` field. The earlier `counters` field is
274
- // empty across every catalog entry, so this correlation was structurally
275
- // dead (a non-existent field .includes() is always false).
210
+ // D3FEND maps a countermeasure through `counters_attack_techniques`; the
211
+ // `counters` field is empty catalog-wide, so filtering on it is dead.
276
212
  const d3fend = entries(loadCatalog('d3fend-catalog.json'))
277
213
  .filter(([, d]) => Array.isArray(d.counters_attack_techniques) && d.counters_attack_techniques.includes(ttpId))
278
214
  .map(([id]) => id);
@@ -283,8 +219,7 @@ function bySkill(skillName) {
283
219
  const xref = loadIndex('xref.json');
284
220
  const summary = loadIndex('summary-cards.json');
285
221
  const card = summary[skillName] || summary.skills?.[skillName] || null;
286
- // TTPs invert the atlas_refs + attack_refs maps: any TTP whose skill
287
- // list contains this skill is a reference. Both id spaces contribute.
222
+ // Invert both TTP maps; both id spaces contribute.
288
223
  const ttpRefs = Object.entries({
289
224
  ...(xref[XREF_KEYS.atlas] || {}),
290
225
  ...(xref[XREF_KEYS.attack] || {}),
@@ -292,9 +227,6 @@ function bySkill(skillName) {
292
227
  .filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
293
228
  .map(([ttp]) => ttp)
294
229
  .sort();
295
- // CVEs link to a skill transitively: a CVE references CWEs, and each CWE
296
- // maps to skills via cwe_refs. Collect every CVE whose CWE set resolves
297
- // to this skill.
298
230
  const cveCatalog = loadCatalog('cve-catalog.json');
299
231
  const cveRefs = entries(cveCatalog)
300
232
  .filter(([, c]) => !_isDraftEntry(c) && (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
@@ -303,13 +235,9 @@ function bySkill(skillName) {
303
235
  return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
304
236
  }
305
237
 
306
- // global-frameworks.json is keyed by REGION (EU/UK/AU/...), each with a nested
307
- // `frameworks: { SHORTKEY: {full_name, catalog_aliases?, ...} }` map. A flat
308
- // `global[frameworkId]` lookup therefore ALWAYS returned null — frameworkId is a
309
- // short key or a catalog display name, never a region — so framework_meta was
310
- // universally null. Walk the nested structure and match the requested id against
311
- // the short key, the full_name, or any catalog_alias (normalized), returning the
312
- // matched framework object annotated with its region + jurisdiction.
238
+ // global-frameworks.json is keyed by region, each region holding a nested
239
+ // `frameworks: { SHORTKEY: {...} }` map, so a flat `global[frameworkId]` lookup
240
+ // never matches. Match the short key, full_name or any catalog_alias, normalized.
313
241
  function resolveFrameworkMeta(global, frameworkId) {
314
242
  if (!global || frameworkId == null) return null;
315
243
  const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
@@ -336,13 +264,8 @@ function byFramework(frameworkId) {
336
264
  const gaps = loadCatalog('framework-control-gaps.json');
337
265
  const global = loadCatalog('global-frameworks.json');
338
266
  const fwMeta = resolveFrameworkMeta(global, frameworkId);
339
- // Match gap rows by the framework's full LABEL SET, not just the literal id.
340
- // The catalog stores a framework's gaps under labels (au-ism / AU ISM / ACSC
341
- // ISM …) that diverge from its short key and full_name, so an exact
342
- // `g.framework === frameworkId` match returned a gap set inconsistent with the
343
- // (now alias-resolved) framework_meta — non-null metadata but a partial,
344
- // sometimes empty, gap list. Resolve the same alias set used for the metadata
345
- // and match any gap label (string or array element) against it.
267
+ // Match gap rows against the framework's whole label set: the catalog files
268
+ // gaps under labels (au-ism / AU ISM / ACSC ISM) that diverge from the key.
346
269
  const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
347
270
  const labels = new Set([norm(frameworkId)]);
348
271
  if (fwMeta) {
@@ -368,17 +291,13 @@ function byFramework(frameworkId) {
368
291
  }
369
292
 
370
293
  /**
371
- * Theater-fingerprint lookup — given a finding shape, return the specific test
372
- * that distinguishes paper compliance from actual security (AGENTS.md Hard
373
- * Rule #6). Drives the validate phase when emit.theater_check = true.
294
+ * For a finding shape, the test that distinguishes paper compliance from actual
295
+ * security. Drives the validate phase when emit.theater_check is true.
374
296
  */
375
297
  function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
376
298
  const fp = loadIndex('theater-fingerprints.json');
377
299
  const matches = [];
378
- // Fingerprints are nested under the index's `patterns` container, not at
379
- // the top level. Each pattern records a single `evidence.cve`, a list of
380
- // `controls` (each {framework, control_id}), and a `source_skill`. A
381
- // framework match accepts either the bare control id ("SI-2") or the
300
+ // A framework match accepts either the bare control id ("SI-2") or the
382
301
  // qualified "framework::control_id" form the by_control index keys on.
383
302
  for (const [id, t] of Object.entries(fp.patterns || {})) {
384
303
  if (!t) continue;
@@ -395,9 +314,8 @@ function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
395
314
  }
396
315
 
397
316
  /**
398
- * Global-first framework correlation — given a finding's CVE/TTP set, return
399
- * the relevant gaps across EU (NIS2/DORA/EU AI Act) + UK (CAF) + AU (ISM /
400
- * Essential 8) + ISO 27001:2022 + NIST. Satisfies AGENTS.md Hard Rule #5.
317
+ * Given a finding's CVE/TTP set, the relevant gaps across EU (NIS2/DORA/EU AI
318
+ * Act), UK (CAF), AU (ISM / Essential 8), ISO 27001:2022 and NIST.
401
319
  */
402
320
  function globalFrameworkContext({ cveIds = [], ttpIds = [] }) {
403
321
  const gaps = loadCatalog('framework-control-gaps.json');
@@ -429,8 +347,6 @@ module.exports = {
429
347
  // Lower-level access (engine uses these directly)
430
348
  _loadCatalog: loadCatalog,
431
349
  _loadIndex: loadIndex,
432
- // v0.12.14: surface accumulated catalog/index load errors. Returns
433
- // [{kind, file, error}, ...] for every catalog/index whose JSON
434
- // parse failed. Empty array on a healthy install.
350
+ // [{kind, file, error}] per catalog or index whose JSON parse failed.
435
351
  getLoadErrors,
436
352
  };
@@ -1,33 +1,15 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/currency-severity.js
4
+ * Pure function the `exceptd doctor` verb uses to turn a currency report into a
5
+ * health verdict, on the ladder orchestrator/pipeline.js scores against:
5
6
  *
6
- * Pure function used by the `exceptd doctor` verb to turn a currency report
7
- * into a health verdict.
7
+ * current (>= 90) → healthy, in neither bucket
8
+ * acceptable (70-89, drifting) → severity:"warn", ok:true, exit 0
9
+ * stale / critical_stale (< 70) → ok:false, exit 1
8
10
  *
9
- * The scoring model in orchestrator/pipeline.js grades every skill on four
10
- * labels — current (score >= 90), acceptable (70-89), stale (50-69),
11
- * critical_stale (< 50) — and raises `action_required` only below 70. The
12
- * pre-extraction predicate in the doctor ignored that ladder and failed on
13
- * `currency_label !== "current"`, which folded the acceptable tier in with
14
- * genuine staleness.
15
- *
16
- * That made the health check a scheduled failure rather than a signal: a
17
- * skill's score drops from 90 to 80 the day it crosses 90 days since
18
- * `last_threat_review`, so any skill will turn `doctor` red on a fixed
19
- * cadence with no code change and nothing actually wrong.
20
- *
21
- * The tiers now map to severity the way the model defines them:
22
- *
23
- * current → healthy, no entry in either bucket
24
- * acceptable (drifting) → severity:"warn", ok:true, exit 0
25
- * stale / critical_stale → ok:false (error), exit 1
26
- *
27
- * `stale_skills` keeps its original meaning — every skill that is not
28
- * `current` — so an existing consumer reading it still sees the full picture.
29
- * `drifting_skills` and `action_required_skills` split that list along the
30
- * boundary the exit code now respects.
11
+ * Failing on `currency_label !== "current"` instead folds the acceptable tier in
12
+ * with real staleness and turns doctor red on a fixed cadence with nothing wrong.
31
13
  */
32
14
 
33
15
  /**
@@ -43,8 +25,7 @@ function classifyCurrency(report, actionRequired) {
43
25
  const actionable = rows.filter(s => s.action_required);
44
26
  const critical = rows.filter(s => s.currency_score !== undefined && s.currency_score < 50);
45
27
 
46
- // The report-level flag is honored even when no row carries action_required,
47
- // so an upstream that raises the flag some other way still fails closed.
28
+ // The report-level flag is honored even when no row sets it, so this fails closed.
48
29
  const ok = actionable.length === 0 && !actionRequired;
49
30
 
50
31
  return {
package/lib/cve-batch.js CHANGED
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
 
3
- // Batch curation: facts + judgments through cve-enrich.assembleEntry, then the
4
- // catalog, zeroday-lessons.json and one tests/cve-<id>.test.js per CVE. Nothing
5
- // is written if any entry has an orphaned reference or an assembly error.
6
- // CLI: `refresh --curate-batch --facts <path> --judgments <path> [--apply]`.
3
+ // Batch curation via `refresh --curate-batch --facts <p> --judgments <p> [--apply]`:
4
+ // facts + judgments through cve-enrich.assembleEntry into the catalog,
5
+ // zeroday-lessons.json and a per-CVE test. Nothing is written unless all assemble.
7
6
 
8
7
  const fs = require('fs');
9
8
  const path = require('path');
@@ -23,19 +22,15 @@ function _metaEnd(text) {
23
22
  else if (c === '"') { i++; while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; } }
24
23
  }
25
24
  const comma = text.indexOf(',', i);
26
- // Whitespace-only gap to the next comma means _meta has a following member,
27
- // so insert after the separator; otherwise _meta is last and the insert
28
- // supplies its own leading comma.
25
+ // A whitespace-only gap to the next comma means _meta has a following member,
26
+ // so insert after the separator; otherwise the insert supplies its own comma.
29
27
  if (comma !== -1 && text.slice(i, comma).trim() === '') return { at: comma + 1, leadingComma: false };
30
28
  return { at: i, leadingComma: true };
31
29
  }
32
30
 
33
- // Ids already present as top-level catalog members. insertEntries appends and
34
- // cannot replace, so writing one twice leaves a duplicate JSON key; JSON.parse
35
- // keeps the last, which is the stale copy, while every count and schema check
36
- // still passes. Parsed rather than pattern-matched: a CVE id quoted inside
37
- // another entry's prose is not a member, and building a RegExp from caller ids
38
- // would be a ReDoS sink in the one function that exists to distrust them.
31
+ // Ids already present as top-level catalog members. Parsed rather than
32
+ // pattern-matched: a CVE id quoted inside another entry's prose is not a member,
33
+ // and a RegExp built from caller ids would be a ReDoS sink.
39
34
  function existingIds(catalogText, ids) {
40
35
  let parsed;
41
36
  try {
@@ -67,9 +62,8 @@ function insertEntries(catalogText, entriesById) {
67
62
 
68
63
  function recomputeAiMeta(catalogText, aiCount, total) {
69
64
  // current_rate is ROUNDED; the floor is enforced against the RAW rate, so the
70
- // floor must be the raw rate FLOORED to 3 decimals. Rounding both leaves a
71
- // floor the raw rate cannot clear (0.02262 rounds to 0.023). Only ever lower
72
- // the floor — raising it fails the corpus it describes.
65
+ // floor is the raw rate FLOORED (rounding both leaves 0.02262 facing a 0.023
66
+ // floor it cannot clear). Only ever lower the floor.
73
67
  const rawRate = aiCount / total;
74
68
  const rate = Math.round(rawRate * 1000) / 1000;
75
69
  let out = catalogText.replace(/("current_rate"\s*:\s*)[0-9.]+/, `$1${rate}`);
@@ -175,8 +169,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
175
169
  const lesObj = addLessons(JSON.parse(fs.readFileSync(lesPath, 'utf8')), lessons);
176
170
  fs.writeFileSync(lesPath, JSON.stringify(lesObj, null, 2) + '\n');
177
171
 
178
- // The id already carries its "CVE-" prefix, so lowercasing alone produces the
179
- // repo's tests/cve-<year>-<num>.test.js convention.
172
+ // The id carries its "CVE-" prefix, so lowercasing gives tests/cve-<y>-<n>.test.js.
180
173
  for (const id of Object.keys(entries))
181
174
  fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
182
175
 
@@ -191,9 +184,8 @@ async function cli(argv) {
191
184
  else if (a === '--judgments') opts.judgments = argv[++i];
192
185
  else if (a === '--apply') opts.apply = true;
193
186
  }
194
- // Inputs are checked before curateBatch so a missing path returns the same
195
- // {ok:false} envelope every other refresh-curate error path does, rather than
196
- // throwing out of the async function.
187
+ // Checked before curateBatch so a missing path returns the same {ok:false}
188
+ // envelope as every other error path, rather than throwing out of the async fn.
197
189
  if (!opts.facts || !opts.judgments || !fs.existsSync(opts.facts) || !fs.existsSync(opts.judgments)) {
198
190
  const missing = [];
199
191
  if (!opts.facts) missing.push('--facts <path>');
package/lib/cve-cli.js CHANGED
@@ -2,11 +2,9 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * lib/cve-cli.js — `exceptd cve <CVE-ID>` resolver.
6
- *
7
- * Catalog -> resolved cache -> one NVD lookup (cached). Tells an agent whether
8
- * a cited CVE is published / rejected / disputed / fabricated / nonexistent
9
- * without it researching NVD by hand. Network is opt-out (--air-gap /
5
+ * `exceptd cve <CVE-ID>` — catalog, then resolved cache, then one cached NVD
6
+ * lookup. Reports published / rejected / disputed / fabricated / nonexistent so
7
+ * an agent need not research NVD by hand. Network is opt-out (--air-gap /
10
8
  * --no-network / EXCEPTD_AIR_GAP=1).
11
9
  */
12
10
 
@@ -15,9 +13,8 @@ const { resolveCve } = require("./citation-resolve.js");
15
13
  (async () => {
16
14
  const argv = process.argv.slice(2);
17
15
  const flags = new Set(argv.filter((a) => a.startsWith("--")));
18
- // Reject unknown flags rather than silently ignoring them — the same
19
- // contract the in-process verbs enforce. A swallowed `--josn` would emit
20
- // human text into a pipe that asked for JSON and defeat a CI gate.
16
+ // A swallowed `--josn` would emit human text into a pipe that asked for
17
+ // JSON, so an unknown flag is an error, matching the in-process verbs.
21
18
  const KNOWN = new Set(["--json", "--pretty", "--air-gap", "--no-network", "--help", "-h"]);
22
19
  const unknown = [...flags].filter((f) => !KNOWN.has(f));
23
20
  if (unknown.length > 0) {
@@ -28,9 +25,8 @@ const { resolveCve } = require("./citation-resolve.js");
28
25
  process.exitCode = 1;
29
26
  return;
30
27
  }
31
- // Trim the positional so a whitespace-only argument (`cve " "`) is
32
- // treated identically to a missing one (`cve ""`) — a usage error, not a
33
- // "fabricated" lookup of the literal spaces.
28
+ // Trimmed so `cve " "` is a usage error like `cve ""`, not a "fabricated"
29
+ // lookup of the literal spaces.
34
30
  const rawId = argv.find((a) => !a.startsWith("--"));
35
31
  const id = rawId == null ? rawId : rawId.trim();
36
32
  const pretty = flags.has("--pretty");
@@ -45,10 +41,9 @@ const { resolveCve } = require("./citation-resolve.js");
45
41
  }
46
42
 
47
43
  const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
48
- // A citation that won't stand up exits non-zero so a CI/script gate trips.
49
- // Derive `ok` from the same set of statuses that drive the exit code — a
50
- // non-zero exit must carry ok:false, never the inverted ok:true the
51
- // envelope previously hardcoded.
44
+ // A citation that will not stand up exits non-zero so a CI gate trips, and
45
+ // `ok` derives from the same statuses — a non-zero exit always carries
46
+ // ok:false.
52
47
  const fails = r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn";
53
48
  const body = { verb: "cve", ...r, ok: !fails };
54
49
 
@@ -70,11 +65,9 @@ const { resolveCve } = require("./citation-resolve.js");
70
65
  process.exitCode = 2;
71
66
  }
72
67
  })().catch((err) => {
73
- // A corrupt/unreadable catalog (or any unexpected throw inside the async
74
- // body) becomes a rejected promise. Emit the same single-line
75
- // {ok:false,error} envelope the verb promises rather than crashing with a
76
- // raw stack trace, and signal failure via exitCode so the event loop drains
77
- // stderr before exit.
68
+ // Any throw inside the async body arrives here as a rejected promise: emit
69
+ // the same single-line {ok:false,error} envelope the verb promises rather
70
+ // than a raw stack, and use exitCode so stderr drains before exit.
78
71
  process.stderr.write(JSON.stringify({ ok: false, verb: "cve", error: String((err && err.message) || err) }) + "\n");
79
72
  process.exitCode = 1;
80
73
  });