@blamejs/exceptd-skills 0.19.33 → 0.19.35

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 +28 -0
  2. package/bin/exceptd.js +895 -2828
  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 +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  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 +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  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 -155
  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 +39 -113
  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 +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  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 +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  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 -413
  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 +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  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 +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  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 +63 -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 +62 -81
  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 +83 -198
  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 +7 -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 +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  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 -141
@@ -1,76 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * lib/validate-playbooks.js — exceptd playbook validator.
4
- *
5
- * Walks every JSON file in data/playbooks/, validates it against
6
- * lib/schemas/playbook.schema.json (using the same inline JSON-Schema
7
- * subset validator as lib/validate-cve-catalog.js), and additionally
8
- * resolves every cross-playbook + cross-catalog reference the playbook
9
- * shape carries.
10
- *
11
- * Cross-references checked:
12
- * - _meta.feeds_into[].playbook_id → other playbook files
13
- * - _meta.mutex[] → other playbook files
14
- * - _meta.skill_chain[] → manifest.json.skills[]
15
- * (legacy alias; the canonical chain lives under
16
- * phases.direct.skill_chain[].skill — both are resolved)
17
- * - phases.govern.skill_preload[] → manifest.json.skills[]
18
- * - domain.atlas_refs[] → data/atlas-ttps.json keys
19
- * - domain.attack_refs[] → data/attack-techniques.json keys
20
- * - domain.cve_refs[] → data/cve-catalog.json keys
21
- * - domain.cwe_refs[] → data/cwe-catalog.json keys
22
- * - domain.d3fend_refs[] → data/d3fend-catalog.json keys
23
- * - phases.detect.indicators[].attack_ref → data/attack-techniques.json
24
- * - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
25
- * - phases.detect.indicators[].cve_ref → data/cve-catalog.json
26
- * - phases.detect.false_positive_profile[].indicator_id
27
- * → phases.detect.indicators[].id
28
- * - directives[].applies_to.cve → data/cve-catalog.json keys
29
- * - directives[].applies_to.atlas_ttp → data/atlas-ttps.json keys
30
- * - directives[].applies_to.attack_technique → data/attack-techniques.json
31
- * - directives[].phase_overrides.govern.jurisdiction_obligations[].clock_starts
32
- * → closed clock_starts vocabulary
33
- * - directives[].phase_overrides.direct.rwep_threshold → ordering + range
34
- * - directives[].phase_overrides.close.notification_actions[].obligation_ref
35
- * → effective jurisdiction_obligations
36
- *
37
- * Internal consistency:
38
- * - Indicator ids are unique within a playbook.
39
- * - Every playbook maps to at least one TTP via domain.atlas_refs or
40
- * domain.attack_refs (the cross-cutting correlation layer is exempt).
41
- * - When _meta.air_gap_mode is true, network-sourced look.artifacts carry
42
- * a non-empty air_gap_alternative (error if missing).
43
- * - Closed controlled vocabularies (jurisdiction_obligations[].clock_starts,
44
- * domain.frameworks_in_scope[]) are enforced at error severity, unlike the
45
- * evolving-drift enums (artifact/indicator `type`) which stay warnings.
46
- * - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
47
- * - close.notification_actions[].obligation_ref resolves to a synthesized
48
- * "<jurisdiction>/<regulation> <window_hours>h" key from
49
- * govern.jurisdiction_obligations[] (the schema does not give
50
- * jurisdiction_obligations an explicit `id` field; the shipped playbooks
51
- * reference them by this composite string).
52
- * - _meta.mutex is symmetric across the whole playbook set: if A lists B,
53
- * B must list A. Asymmetry surfaces as a warning by default (promoted to
54
- * an error under --strict) — see checkMutexReciprocity().
55
- *
56
- * Finding severity:
57
- * - error — structural problems that block the runner (missing required
58
- * field, JSON parse error, internal ordering violation,
59
- * duplicate indicator id).
60
- * - warning — schema-shape drift the runner can still tolerate (enum
61
- * vocabulary lag, cross-catalog refs introduced after the
62
- * playbook last shipped). Surfaced to the operator without
63
- * failing the gate by default; promoted to hard errors under
64
- * --strict (predeploy `informational: false`).
65
- *
66
- * Exit code: 0 if no errors (warnings allowed), 1 if any errors, 2 on
67
- * argv error.
68
- *
69
- * Usage:
70
- * node lib/validate-playbooks.js validate every playbook
71
- * node lib/validate-playbooks.js --quiet only print FAIL playbooks + summary
72
- * node lib/validate-playbooks.js --strict treat warnings as errors (used by
73
- * the predeploy gate).
3
+ * lib/validate-playbooks.js — validates every data/playbooks/*.json against
4
+ * lib/schemas/playbook.schema.json and resolves the cross-references it carries.
5
+ * Findings are `error` (blocks the runner) or `warning` (tolerated drift);
6
+ * --strict promotes every warning to an error. Exits 0 clean, 1 on any error,
7
+ * 2 on an argv error.
74
8
  */
75
9
 
76
10
  'use strict';
@@ -79,33 +13,15 @@ const fs = require('node:fs');
79
13
  const path = require('node:path');
80
14
  const process = require('node:process');
81
15
  const { safeExit } = require('./exit-codes');
82
- // _evalCondition is the SAME parser the runner uses at analyze/validate/close
83
- // time. Exercising every escalation / feeds_into / remediation-precondition
84
- // condition through the real evaluator lets the validator reject a condition the
85
- // engine cannot parse (condition_unparsed) — a prose / bare-token /
86
- // unimplemented-syntax condition that would silently return false for every
87
- // input and disable the escalation it gates. No cycle: playbook-runner does not
88
- // require this validator. Loaded defensively: the engine carries a dependency
89
- // closure (scoring / cross-ref-api / id-validation) that a stripped test mirror
90
- // staging only the validator + schema does not copy, so a hard top-level require
91
- // would crash the validator there. In the real repo and the shipped tarball the
92
- // engine is always present, so the parse-gate always runs; when it is genuinely
93
- // unresolvable the gate surfaces a WARNING (never a silent error-skip — see
94
- // checkCrossRefs) so the degradation is observable rather than a false pass.
16
+ // The SAME parser the runner uses, so a condition the engine cannot parse is
17
+ // rejected here instead of silently returning false for every input. Required
18
+ // defensively: a stripped test mirror stages this validator without the engine.
95
19
  let _evalCondition = null;
96
20
  try { ({ _evalCondition } = require('./playbook-runner')); } catch { /* warned at the gate */ }
97
21
 
98
- // Decompose a condition into its leaf atoms the SAME way evalCondition does —
99
- // strip wrapping parens, split on top-level OR (lowest precedence), then on
100
- // top-level AND, quote- and depth-aware. This exists because evalCondition
101
- // evaluates AND via `.every` / OR via `.some`, which SHORT-CIRCUIT: a dead
102
- // (unparseable) sub-clause behind a leading clause that resolves false (AND) or
103
- // true (OR) in the empty validation context is never reached, so running the
104
- // whole condition once never surfaces its condition_unparsed. Atomizing and
105
- // parse-checking each leaf independently catches a dead sub-clause regardless of
106
- // short-circuit (e.g. `blast_radius_score >= 3 AND any deterministic indicator
107
- // fires` — the first clause is false with no signals, so the dead prose second
108
- // clause never parsed). Mirrors evalCondition's splitAtTopLevel / stripOuterParens.
22
+ // Decompose a condition into leaf atoms the way evalCondition does. Checking each
23
+ // leaf independently catches a dead sub-clause: evalCondition short-circuits AND
24
+ // via `.every` and OR via `.some`, so a later clause never reports unparsed.
109
25
  function _splitTopLevel(expr, sep) {
110
26
  const parts = []; const needle = ' ' + sep + ' ';
111
27
  let depth = 0, buf = '', i = 0, quote = null;
@@ -182,24 +98,15 @@ function readJson(p) {
182
98
  return JSON.parse(fs.readFileSync(p, 'utf8'));
183
99
  }
184
100
 
185
- function readJsonIfExists(p) {
186
- if (!fs.existsSync(p)) return null;
187
- return readJson(p);
188
- }
189
-
190
101
  function typeOf(value) {
191
102
  if (value === null) return 'null';
192
103
  if (Array.isArray(value)) return 'array';
193
104
  return typeof value;
194
105
  }
195
106
 
196
- // Strict ISO calendar-date check for format:date fields. The shape regex alone
197
- // accepts impossible dates — `new Date('2026-02-30T00:00:00Z')` does NOT throw,
198
- // it silently rolls over to March 2 and reports a valid getTime() — so a
199
- // malformed _meta.last_threat_review or changelog `date` (2026-02-30,
200
- // 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
201
- // parsed Y-M-D to round-trip back to the input components, mirroring
202
- // parseIsoDateStrict in lib/validate-catalog-meta.js.
107
+ // Strict ISO calendar check: the shape regex alone accepts impossible dates —
108
+ // `new Date('2026-02-30T00:00:00Z')` rolls over to March 2 — so the parsed Y-M-D
109
+ // must round-trip. Mirrors parseIsoDateStrict in lib/validate-catalog-meta.js.
203
110
  function isStrictIsoDate(value) {
204
111
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
205
112
  const d = new Date(value + 'T00:00:00Z');
@@ -219,12 +126,10 @@ function typeMatches(value, expected) {
219
126
  return actual === expected;
220
127
  }
221
128
 
222
- /* Inline JSON-Schema subset validator. Returns a flat list of finding objects
223
- * shaped as { severity, message }. Severity defaults to 'error'; enum
224
- * mismatches and unknown additional properties under
225
- * additionalProperties:false are downgraded to 'warning' so vocabulary drift
226
- * between the schema and shipped playbooks does not hard-fail by default.
227
- * Promoted to errors under --strict / predeploy informational:false. */
129
+ /* Inline JSON-Schema subset validator. Returns a flat list of
130
+ * { severity, message }: 'error' except enum mismatches and unknown properties
131
+ * under additionalProperties:false, which are 'warning' so vocabulary drift does
132
+ * not hard-fail outside --strict. */
228
133
  function validate(value, schema, schemaName, pathStr) {
229
134
  const findings = [];
230
135
  const here = pathStr || schemaName;
@@ -239,8 +144,6 @@ function validate(value, schema, schemaName, pathStr) {
239
144
 
240
145
  if (schema.enum !== undefined) {
241
146
  if (!schema.enum.includes(value)) {
242
- // Enum drift is downgraded to a warning so vocabulary-evolution does
243
- // not break patch-class releases.
244
147
  err(
245
148
  `${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`,
246
149
  'warning',
@@ -315,8 +218,6 @@ function validate(value, schema, schemaName, pathStr) {
315
218
  } else if (addlSchema) {
316
219
  findings.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
317
220
  } else if (!allowAdditional) {
318
- // Drift between schema and shipped data: surface as warning, not
319
- // an error. Promoted to an error under --strict.
320
221
  err(`${here}: unexpected property "${k}"`, 'warning');
321
222
  }
322
223
  }
@@ -331,19 +232,14 @@ function loadContext() {
331
232
  const cve = readJson(CVE_PATH);
332
233
  const cwe = readJson(CWE_PATH);
333
234
  const d3 = readJson(D3FEND_PATH);
334
- // Required, like the other ref catalogs (cwe / d3fend). It is committed and
335
- // always present; loading it optionally let attack_ref validation skip
336
- // silently when it was absent — the asymmetry that allowed an unresolvable
337
- // attack_ref to ship. Its absence must fail loud.
235
+ // Required, not optional: an optional load skips attack_ref validation silently.
338
236
  const attack = readJson(ATTACK_PATH);
237
+ if (!attack || typeof attack !== 'object' || Array.isArray(attack)) {
238
+ throw new Error(`validate-playbooks: ${ATTACK_PATH} did not parse to a technique map (got ${attack === null ? 'null' : typeof attack}). Refusing to validate with the attack_ref checks silently disabled.`);
239
+ }
339
240
 
340
- // Closed controlled-vocabulary enums sourced from the schema so the
341
- // hard-error enum checks in checkCrossRefs stay in lockstep with the
342
- // schema's own enum lists. A typo'd clock_starts must hard-fail the
343
- // predeploy gate (it changes when a notification clock starts ticking),
344
- // so unlike evolving-drift enums (artifact/indicator `type`) these are
345
- // promoted to error severity rather than left as generic-validator
346
- // warnings.
241
+ // Sourced from the schema so the hard-error checks in checkCrossRefs stay in
242
+ // lockstep with its enum lists.
347
243
  let clockStartsEnum = null;
348
244
  let frameworksEnum = null;
349
245
  try {
@@ -354,13 +250,10 @@ function loadContext() {
354
250
  frameworksEnum =
355
251
  schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
356
252
  } catch (e) {
357
- // The playbook schema is a committed, required file. Swallowing a read error
358
- // here silently disabled the clock_starts / frameworks_in_scope closed-vocab
359
- // checks, so a typo in those fields could ship. Fail loud instead.
360
253
  throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
361
254
  }
362
- // A successful parse with the wrong shape (enum path moved/renamed) would also
363
- // leave the enums null and disable the checks. Treat that as fatal too.
255
+ // A wrong-shape parse leaves the enums null and disables the checks, so it is
256
+ // fatal too.
364
257
  if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
365
258
  throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
366
259
  }
@@ -371,9 +264,9 @@ function loadContext() {
371
264
  cveKeys: new Set(Object.keys(cve).filter((k) => !k.startsWith('_'))),
372
265
  cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
373
266
  d3fendKeys: new Set(Object.keys(d3).filter((k) => !k.startsWith('_'))),
374
- attackKeys: attack
375
- ? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
376
- : null,
267
+ // Never nullable: a null here silently disables every attack_ref check, the
268
+ // exact failure the required load above exists to prevent.
269
+ attackKeys: new Set(Object.keys(attack).filter((k) => !k.startsWith('_'))),
377
270
  clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
378
271
  frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
379
272
  };
@@ -397,21 +290,14 @@ function loadPlaybooks() {
397
290
  }
398
291
 
399
292
  function obligationKey(o) {
400
- // The schema does not define an explicit `id` field on
401
- // jurisdiction_obligations entries; the shipped playbooks reference them
402
- // by the composite "<jurisdiction>/<regulation> <window_hours>h" string.
293
+ // Obligations carry no `id`; playbooks reference one by this composite string.
403
294
  return `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
404
295
  }
405
296
 
406
297
  function checkCrossRefs(playbook, ctx, playbookIds) {
407
298
  const findings = [];
408
- // A null/array/primitive playbook has no cross-refs to check, and validate()
409
- // already emits the top-level `expected type "object", got null` error for
410
- // it (main() line ~755). Guarding here turns the uncaught `playbook._meta`
411
- // TypeError on a literal-null playbook file into a clean no-op so main()
412
- // still reports the FAIL and continues to the remaining playbooks instead of
413
- // aborting the whole gate. The FAIL is preserved — it originates in
414
- // validate(), not here.
299
+ // validate() already emits the type error; returning empty keeps main()
300
+ // reporting the FAIL instead of dying on `playbook._meta`.
415
301
  if (!playbook || typeof playbook !== 'object' || Array.isArray(playbook)) {
416
302
  return findings;
417
303
  }
@@ -431,9 +317,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
431
317
  warn(`_meta.mutex: unresolved playbook_id "${m}"`);
432
318
  }
433
319
  }
434
- // Some playbooks may carry a legacy _meta.skill_chain[] (string list); the
435
- // canonical chain lives at phases.direct.skill_chain[].skill but we still
436
- // resolve a flat list if present, per the task brief.
320
+ // _meta.skill_chain[] aliases the canonical phases.direct.skill_chain[].skill.
437
321
  for (const s of meta.skill_chain || []) {
438
322
  if (typeof s === 'string' && !ctx.skillKeys.has(s)) {
439
323
  warn(`_meta.skill_chain: unresolved skill "${s}"`);
@@ -459,12 +343,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
459
343
  warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
460
344
  }
461
345
  }
462
- // Hard Rule #4 ("no orphaned controls"): domain.attack_refs must resolve to
463
- // the ATT&CK technique catalog, mirroring the atlas_refs block above and the
464
- // detect.indicators[].attack_ref check below. Without this, every TTP listed
465
- // at the domain level bypassed catalog cross-referencing.
346
+ // Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
466
347
  for (const a of domain.attack_refs || []) {
467
- if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
348
+ if (!ctx.attackKeys.has(a)) {
468
349
  warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
469
350
  }
470
351
  }
@@ -484,7 +365,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
484
365
  }
485
366
  }
486
367
 
487
- // Indicators: id uniqueness, attack_ref / atlas_ref / cve_ref resolution.
488
368
  const detect = phases.detect || {};
489
369
  const indIds = new Set();
490
370
  const indicators = detect.indicators || [];
@@ -499,7 +379,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
499
379
  }
500
380
  indIds.add(ind.id);
501
381
  }
502
- if (ind.attack_ref && ctx.attackKeys && !ctx.attackKeys.has(ind.attack_ref)) {
382
+ if (ind.attack_ref && !ctx.attackKeys.has(ind.attack_ref)) {
503
383
  warn(
504
384
  `phases.detect.indicators[${i}].attack_ref: unresolved "${ind.attack_ref}" (not in data/attack-techniques.json)`,
505
385
  );
@@ -516,10 +396,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
516
396
  }
517
397
  }
518
398
 
519
- // false_positive_profile[].indicator_id must reference a real indicator id.
520
- // A dangling reference means an FP-distinguishing test is wired to nothing,
521
- // so the runner can never apply it. Warning severity (vocabulary-style
522
- // drift, not a structural break).
399
+ // A dangling indicator_id wires an FP test to nothing the runner can apply.
523
400
  for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
524
401
  if (!fp || typeof fp !== 'object') continue;
525
402
  if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
@@ -529,12 +406,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
529
406
  }
530
407
  }
531
408
 
532
- // validate.remediation_paths[].for_signals[] must reference real indicator
533
- // ids. A dangling ref silently never matches, so selected_remediation falls
534
- // back to priority-1 without surfacing the intended finding-specific link —
535
- // exactly the kind of "looks wired, does nothing" drift this gate exists to
536
- // catch. Warning severity (promoted to a hard error under --strict, matching
537
- // the false_positive_profile precedent above).
409
+ // A dangling for_signals never matches: selected_remediation falls back to
410
+ // priority 1 without the finding-specific link.
538
411
  const validatePhase = phases.validate || {};
539
412
  for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
540
413
  if (!rp || typeof rp !== 'object' || !Array.isArray(rp.for_signals)) continue;
@@ -547,18 +420,11 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
547
420
  }
548
421
  }
549
422
 
550
- // rwep_threshold ordering. Hard error — a misordered threshold actively
551
- // breaks the scoring path. Factored into a helper so the same check runs
552
- // against the playbook-level direct phase AND any directive-level
553
- // phase_overrides.direct copy the runner deep-merges at run time.
423
+ // Helpers, so the same checks run against a directive's phase_overrides copy.
554
424
  checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
555
425
 
556
- // clock_starts closed-vocab against the base govern phase. Factored into a
557
- // helper for the same reason — an override-supplied jurisdiction_obligations
558
- // copy must pass the same closed-vocabulary gate.
559
426
  checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
560
427
 
561
- // notification_actions obligation_ref resolution.
562
428
  const obligationKeys = new Set(
563
429
  (govern.jurisdiction_obligations || []).map(obligationKey),
564
430
  );
@@ -572,15 +438,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
572
438
  }
573
439
  }
574
440
 
575
- // Escalation / feeds_into condition path-root resolvability. The
576
- // analyze-phase escalation context resolves the flat keys (rwep,
577
- // blast_radius_score, theater_verdict, agent signals) plus the `analyze`
578
- // and `finding` roots; close()'s feeds_into context additionally resolves
579
- // `validate` and `theater_score`. A dotted path rooted at any OTHER phase
580
- // name can never resolve at evaluation time — the condition would
581
- // silently never fire — so it is rejected here. Bare (un-dotted)
582
- // identifiers are agent-signal names, an open vocabulary, and are not
583
- // checked.
441
+ // The escalation context resolves the flat keys plus `analyze` and `finding`;
442
+ // feeds_into also resolves `validate`. A dotted path rooted at any other phase
443
+ // name never resolves. Bare identifiers are agent-signal names, not checked.
584
444
  const conditionPathRoots = (cond) => {
585
445
  if (typeof cond !== 'string') return [];
586
446
  const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
@@ -614,31 +474,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
614
474
  }
615
475
  }
616
476
 
617
- // Condition PARSEABILITY gate. The path-root check above only catches a
618
- // dotted path rooted at an unavailable phase; the broader and far more common
619
- // dead-condition shape is a condition the evaluator cannot parse AT ALL —
620
- // free-text prose ("A single compromised identity can rewrite the trail"), a
621
- // bare-truthiness token without `== true`, an unimplemented keyword
622
- // (`<tok> fired`, `not in`, `is`), or an apostrophe-bearing token that fails
623
- // the LHS pattern. Every such condition falls through evalCondition to a
624
- // silent `false` for every input, so the raise_severity / trigger_playbook /
625
- // remediation-precondition it gates never fires — and nothing flagged it,
626
- // because the validator never ran the actual evaluator. Run each condition in
627
- // the THREE locations the runner threads through evalCondition (analyze
628
- // escalation_criteria, _meta.feeds_into, validate remediation_paths
629
- // preconditions) and hard-fail on condition_unparsed. Scoped to exactly those
630
- // three: scoring_rubric entries are descriptive blast-level prose (never
631
- // evaluated — the score comes from agentSignals.blast_radius_score) and
632
- // regression_trigger conditions are consumed by computeRegressionNextRun's own
633
- // cadence/event parser, so neither is in scope here. Gate ONLY on
634
- // condition_unparsed — a genuine PARSE failure is input-independent;
635
- // condition_path_unresolved / condition_type_mismatch are runtime data
636
- // diagnostics that would false-positive against this empty validation context
637
- // (a dotted path legitimately resolves null when no run has populated it).
477
+ // Parseability gate over the three places the runner threads a condition through
478
+ // evalCondition: analyze escalation_criteria, _meta.feeds_into, and validate
479
+ // remediation_paths preconditions. Only condition_unparsed gates: a parse failure
480
+ // is input-independent, where condition_path_unresolved and
481
+ // condition_type_mismatch are runtime diagnostics that false-positive here.
638
482
  if (!_evalCondition) {
639
- // Engine module not resolvable from this validator location (a stripped
640
- // test mirror). Surface it as a WARNING so the skip is observable — in the
641
- // real repo + shipped tarball the engine always loads and this never fires.
642
483
  warn(
643
484
  'escalation/feeds_into/precondition parse-gate skipped: lib/playbook-runner.js (the condition evaluator) was not resolvable from this validator location, so condition parseability was not checked',
644
485
  );
@@ -646,9 +487,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
646
487
  const conditionParses = (cond) => {
647
488
  if (typeof cond !== 'string' || !cond.trim()) return true;
648
489
  if (!_evalCondition) return true; // engine unavailable — warned above, never a silent error-skip
649
- // Check EACH atomic leaf, not the whole condition once: evalCondition's
650
- // AND/OR short-circuit hides a dead sub-clause behind a leading clause that
651
- // resolves false (AND) / true (OR) in the empty validation context.
652
490
  for (const atom of atomizeCondition(cond)) {
653
491
  const runErrors = [];
654
492
  try { _evalCondition(atom, { _runErrors: runErrors }, { _runErrors: runErrors }); }
@@ -682,26 +520,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
682
520
  }
683
521
  }
684
522
 
685
- // Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
686
- // to touch the network, so every artifact whose source is a network call
687
- // (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
688
- // non-empty air_gap_alternative or the run is silently incomplete. The
689
- // schema encodes this as an allOf/if/then block, but the inline validator
690
- // does not implement conditional keywords, so it is enforced imperatively
691
- // here at error severity.
523
+ // Under _meta.air_gap_mode the runner refuses the network, so a network-sourced
524
+ // artifact needs a non-empty air_gap_alternative. The schema states this as an
525
+ // allOf/if/then block the inline validator does not implement.
692
526
  if (meta.air_gap_mode === true) {
693
527
  const look = phases.look || {};
694
- // Case-insensitive + word-bounded so `HTTPS://`, `Curl`, and `fetch(` (no
695
- // trailing space) still flag a network source — otherwise an artifact could
696
- // ship under air_gap_mode with no offline alternative and run incomplete.
697
- // Network-source detection includes API-verb-phrased sources ("GET
698
- // /directoryRoles via Graph", "Entra ID", "Okta", "Microsoft Graph") so a
699
- // REST/Graph endpoint described in prose still flags under air_gap_mode and
700
- // is not silently collected offline-incomplete. `api/v\d` is deliberately
701
- // NOT a token — it false-positives on local code-scan artifacts that merely
702
- // reference an API path. NOTE: lib/schemas/playbook.schema.json carries the
703
- // same narrow `source` pattern and must be broadened in lockstep with this
704
- // regex (main-thread item — that file is not edited here).
528
+ // `api/v\d` is deliberately not a token — it false-positives on local
529
+ // code-scan artifacts that merely reference an API path. The same `source`
530
+ // pattern lives in lib/schemas/playbook.schema.json and must be broadened
531
+ // in lockstep with this one.
705
532
  const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|\bGraph\b|\b(?:Okta|Entra ID|Microsoft Graph)\b)/i;
706
533
  for (const [i, art] of (look.artifacts || []).entries()) {
707
534
  if (!art || typeof art !== 'object') continue;
@@ -716,11 +543,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
716
543
  }
717
544
  }
718
545
 
719
- // TTP-mapping floor (Hard Rule #4): every playbook must map to at least one
720
- // adversary technique via domain.atlas_refs OR domain.attack_refs. The sole
721
- // exemption is the cross-cutting correlation layer (_meta.scope ===
722
- // "cross-cutting"), which has no first-party TTPs — it correlates findings
723
- // produced by the other playbooks. Error severity for everything else.
546
+ // TTP-mapping floor (Hard Rule #4): every playbook maps to at least one
547
+ // technique. The cross-cutting correlation layer has no first-party TTPs.
724
548
  const atlasCount = (domain.atlas_refs || []).length;
725
549
  const attackCount = (domain.attack_refs || []).length;
726
550
  if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
@@ -729,9 +553,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
729
553
  );
730
554
  }
731
555
 
732
- // frameworks_in_scope closed vocabulary. A value outside the schema's closed
733
- // enum is an error, not a warning, so a typo cannot ship — frameworks_in_scope
734
- // drives gap-analysis routing.
556
+ // frameworks_in_scope drives gap-analysis routing, so a value outside the
557
+ // schema's closed enum errors rather than warns.
735
558
  if (ctx.frameworksEnum) {
736
559
  for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
737
560
  if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
@@ -742,19 +565,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
742
565
  }
743
566
  }
744
567
 
745
- // Directive-level coverage. A directive's applies_to fields and its
746
- // phase_overrides both reach the runner live (the runner selects directives
747
- // by id, deep-merges phase_overrides into the base phase, and surfaces
748
- // applies_to in the discovery API) but neither was cross-referenced or
749
- // re-validated — so a stale CVE/TTP reference or a tampered override
750
- // (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
751
- // even though the identical content is a hard error at playbook level.
568
+ // Directives reach the runner live — phase_overrides deep-merged into the base
569
+ // phase — so they face the same cross-reference and override checks.
752
570
  for (const [i, d] of (playbook.directives || []).entries()) {
753
571
  if (!d || typeof d !== 'object') continue;
754
572
  const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
755
573
 
756
- // applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
757
- // domain-ref checks at warning severity (promoted to error under --strict).
758
574
  const at = d.applies_to;
759
575
  if (at && typeof at === 'object') {
760
576
  if (at.cve && !ctx.cveKeys.has(at.cve)) {
@@ -763,17 +579,13 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
763
579
  if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
764
580
  warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
765
581
  }
766
- // Guard the attack catalog the same way domain.attack_refs does:
767
- // attack-techniques.json is loaded via readJsonIfExists and may be null.
768
- if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
582
+ if (at.attack_technique && !ctx.attackKeys.has(at.attack_technique)) {
769
583
  warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
770
584
  }
771
585
  }
772
586
 
773
- // phase_overrides re-validation. The runner merges these into the base
774
- // phase before govern()/close() consume them, so an override-supplied
775
- // clock_starts or rwep_threshold must pass the same gates as the base
776
- // phase or the regulatory clock / scoring path breaks at run time.
587
+ // The runner merges these into the base phase, so an override-supplied
588
+ // clock_starts or rwep_threshold must clear the same gates.
777
589
  const ov = d.phase_overrides;
778
590
  if (ov && typeof ov === 'object') {
779
591
  if (ov.govern && typeof ov.govern === 'object') {
@@ -788,10 +600,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
788
600
  `${label}.phase_overrides.direct.rwep_threshold`,
789
601
  );
790
602
  }
791
- // An override-supplied notification obligation_ref must resolve against
792
- // the EFFECTIVE obligation set the runner sees after the merge: the
793
- // base govern obligations, plus any the override adds. Warning severity,
794
- // matching the base-phase obligation_ref precedent.
603
+ // Resolved against the effective set the runner sees after the merge: the
604
+ // override's own obligations when it supplies them, else the base phase's.
795
605
  if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
796
606
  const overrideObligations =
797
607
  (ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
@@ -812,29 +622,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
812
622
 
813
623
  return findings;
814
624
 
815
- // ---- local helpers (hoisted; close over `findings`/`ctx`/`err`) ----
625
+ // Hoisted for the calls above; they close over `findings`, `ctx` and `err`.
816
626
 
817
- // rwep_threshold ordering + range. close <= monitor <= escalate, each in
818
- // 0..100. Error severity — a misordered or out-of-range threshold actively
819
- // breaks the scoring path. `pathPrefix` keeps the message accurate whether
820
- // the source is the base phase or a directive override.
627
+ // close <= monitor <= escalate, each in 0..100. `pathPrefix` keeps the message
628
+ // accurate for both the base phase and a directive override.
821
629
  function checkRwepThreshold(rwepObj, pathPrefix) {
822
630
  const rwep = rwepObj || {};
823
- // Range check is PER-KEY and independent of the others. A directive's
824
- // phase_overrides.direct.rwep_threshold is a partial fragment the runner
825
- // deep-merges, so it may carry only one of the three keys — gating the
826
- // whole check on all-three-present let an out-of-range single-key override
827
- // (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
828
- // runner. Validate each present numeric key on its own.
631
+ // Per-key and independent: an override is a partial fragment, so gating the
632
+ // range check on all three keys present lets `{escalate: 150}` through.
829
633
  for (const k of ['close', 'monitor', 'escalate']) {
830
634
  const v = rwep[k];
831
635
  if (typeof v === 'number' && (v < 0 || v > 100)) {
832
636
  err(`${pathPrefix}.${k}: ${v} outside 0..100`);
833
637
  }
834
638
  }
835
- // Ordering is only meaningful when the full triple is present (a partial
836
- // override inherits the missing edges from the base phase, so close <=
837
- // monitor <= escalate can't be evaluated on the fragment alone).
639
+ // Ordering needs the full triple: a partial override inherits its missing edges.
838
640
  if (
839
641
  typeof rwep.close === 'number' &&
840
642
  typeof rwep.monitor === 'number' &&
@@ -848,9 +650,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
848
650
  }
849
651
  }
850
652
 
851
- // clock_starts closed-vocabulary check over a jurisdiction_obligations list.
852
- // Error severity — clock_starts decides when a notification deadline starts
853
- // counting; an out-of-vocabulary value silently never starts the clock.
653
+ // clock_starts decides when a notification deadline begins counting; an
654
+ // out-of-vocabulary value never starts the clock.
854
655
  function checkClockStarts(obligations, pathPrefix) {
855
656
  if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
856
657
  for (const [i, o] of obligations.entries()) {
@@ -864,19 +665,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
864
665
  }
865
666
  }
866
667
 
867
- /* Cross-playbook mutex-reciprocity check.
868
- *
869
- * `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
870
- * Asymmetry is a latent runner bug — the engine's mutex enforcement only
871
- * blocks concurrent execution from whichever side declared the conflict, so
872
- * an asymmetric declaration silently degrades to a race condition when the
873
- * undeclared side is started first.
874
- *
875
- * Emits one warning per asymmetric pair (keyed off the side that declares
876
- * the edge). Kept at warning severity by default per the patch-class
877
- * cadence; promoted to an error under --strict / predeploy
878
- * `informational: false`.
879
- */
668
+ /* `_meta.mutex` is symmetric: if playbook A lists B, B must list A. The engine
669
+ * blocks concurrency only from the side that declared the conflict, so an
670
+ * asymmetric declaration degrades to a race. Returns one warning per asymmetric
671
+ * pair, keyed by the declaring playbook. */
880
672
  function checkMutexReciprocity(playbooks) {
881
673
  const mutexMap = new Map();
882
674
  for (const pb of playbooks) {