@blamejs/exceptd-skills 0.12.11 → 0.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +339 -0
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -45,9 +45,41 @@
45
45
 
46
46
  const fs = require('fs');
47
47
  const path = require('path');
48
+ const os = require('os');
48
49
  const crypto = require('crypto');
49
50
 
50
- const xref = require('./cross-ref-api');
51
+ // F7: cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
52
+ // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
53
+ // failure, returns an empty stub, and accumulates the error in
54
+ // getLoadErrors(). run() probes for accumulated load errors and returns
55
+ // a structured `blocked_by:'catalog_corrupt'` rather than letting analyze
56
+ // silently operate against an empty catalog. Note: the call to
57
+ // xref.byCve below force-touches the catalog so the load error surfaces
58
+ // at module load (it's lazy otherwise), which gives run() a deterministic
59
+ // signal regardless of submission shape.
60
+ let xref;
61
+ let _xrefLoadError = null;
62
+ try {
63
+ xref = require('./cross-ref-api');
64
+ // Probe-load the catalog so any parse error is observable BEFORE the
65
+ // first real analyze() call. Without this, a corrupt catalog would
66
+ // only surface on the first byCve invocation, which could be
67
+ // mid-pipeline (after preflight/govern/direct phases have already
68
+ // emitted artifacts).
69
+ try { xref.byCve('__exceptd-probe__'); } catch {}
70
+ if (typeof xref.getLoadErrors === 'function') {
71
+ const errs = xref.getLoadErrors();
72
+ if (errs && errs.length) {
73
+ _xrefLoadError = `${errs.length} catalog/index load error(s): ${errs.map(e => `${e.file}: ${e.error}`).join('; ')}`;
74
+ }
75
+ }
76
+ } catch (e) {
77
+ _xrefLoadError = (e && e.message) ? String(e.message) : String(e);
78
+ xref = {
79
+ byCve: () => ({ found: false, _error: _xrefLoadError }),
80
+ _error: _xrefLoadError,
81
+ };
82
+ }
51
83
 
52
84
  const ROOT = path.join(__dirname, '..');
53
85
  const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data', 'playbooks');
@@ -72,6 +104,10 @@ function loadPlaybook(playbookId) {
72
104
  return JSON.parse(fs.readFileSync(p, 'utf8'));
73
105
  }
74
106
 
107
+ // E12: per-run playbook cache. Each phase function reads runOpts._playbookCache
108
+ // before falling back to loadPlaybook(). run() sets _playbookCache once at
109
+ // entry so seven phases share one disk read + JSON parse instead of seven.
110
+
75
111
  function findDirective(playbook, directiveId) {
76
112
  const d = playbook.directives.find(x => x.id === directiveId);
77
113
  if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
@@ -100,9 +136,34 @@ function deepMerge(a, b) {
100
136
 
101
137
  // --- pre-flight: currency + preconditions + mutex ---
102
138
 
139
+ /**
140
+ * Pre-flight gate. Three concerns:
141
+ *
142
+ * 1. Currency. threat_currency_score < 50 hard-blocks unless
143
+ * runOpts.forceStale=true. < 70 emits a warning issue.
144
+ * 2. Preconditions. _meta.preconditions[] entries with on_fail in
145
+ * {halt, warn, skip_phase} are evaluated against
146
+ * runOpts.precondition_checks[id]. Missing values → precondition_unverified
147
+ * issue (plus halt if on_fail=halt). False values → precondition_warn or
148
+ * precondition_skip per on_fail.
149
+ * 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
150
+ * AND with the filesystem lockfile dir blocks the run.
151
+ *
152
+ * E5: when runOpts.strictPreconditions === true, warn-level outcomes
153
+ * (precondition_warn, precondition_unverified with on_fail=warn or
154
+ * skip_phase) are ESCALATED to halts. The function returns ok:false with
155
+ * blocked_by='precondition' and an issues array containing
156
+ * precondition_halt entries. Callers wanting "CI gate: any unverified
157
+ * precondition is a failure" pass strictPreconditions=true.
158
+ *
159
+ * E6: when a precondition with on_fail='skip_phase' fails, the issue carries
160
+ * skip_phase: 'detect' (default) so run() can route to a skipped-phase
161
+ * placeholder rather than executing detect against a missing prerequisite.
162
+ */
103
163
  function preflight(playbook, runOpts = {}) {
104
164
  const issues = [];
105
165
  const meta = playbook._meta;
166
+ const strict = runOpts.strictPreconditions === true;
106
167
 
107
168
  // 1. Currency gate
108
169
  const score = meta.threat_currency_score;
@@ -123,6 +184,18 @@ function preflight(playbook, runOpts = {}) {
123
184
  const submitted = runOpts.precondition_checks?.[pc.id];
124
185
  if (submitted === undefined) {
125
186
  const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. The runner lifts this into runOpts before the gate evaluates.`;
187
+ if (strict) {
188
+ // E5: strictPreconditions promotes unverified to halt regardless of
189
+ // declared on_fail.
190
+ issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
191
+ return {
192
+ ok: false,
193
+ blocked_by: 'precondition',
194
+ reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; strict-preconditions enabled.`,
195
+ remediation: submission_hint,
196
+ issues
197
+ };
198
+ }
126
199
  issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint });
127
200
  if (pc.on_fail === 'halt') {
128
201
  return {
@@ -139,7 +212,25 @@ function preflight(playbook, runOpts = {}) {
139
212
  if (pc.on_fail === 'halt') {
140
213
  return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
141
214
  }
142
- issues.push({ kind: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn', id: pc.id, message: pc.description });
215
+ if (strict) {
216
+ // E5: warn-level + skip_phase outcomes escalate to halt under strict.
217
+ issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
218
+ return {
219
+ ok: false,
220
+ blocked_by: 'precondition',
221
+ reason: `Precondition ${pc.id} (${pc.check}) failed; strict-preconditions enabled.`,
222
+ issues
223
+ };
224
+ }
225
+ if (pc.on_fail === 'skip_phase') {
226
+ // E6: emit a skip_phase field so run() can route to a skipped-phase
227
+ // placeholder. Default target phase is 'detect' (the most common
228
+ // skip target — preconditions typically gate host-side detection).
229
+ // Playbooks may override via pc.skip_phase.
230
+ issues.push({ kind: 'precondition_skip', id: pc.id, message: pc.description, skip_phase: pc.skip_phase || 'detect' });
231
+ } else {
232
+ issues.push({ kind: 'precondition_warn', id: pc.id, message: pc.description });
233
+ }
143
234
  }
144
235
  }
145
236
 
@@ -175,8 +266,18 @@ function preflight(playbook, runOpts = {}) {
175
266
  return { ok: true, issues };
176
267
  }
177
268
 
269
+ // F28: lockDir lives at a stable global path so two CLI invocations from
270
+ // different working directories still share lock state for cross-process
271
+ // mutex enforcement. Pre-fix this used process.cwd(), which meant invoking
272
+ // the same playbook from /tmp and from /home/user/project simultaneously
273
+ // would each see an empty locks dir and both run unchallenged. The path
274
+ // keys on os.platform() so Windows/macOS/Linux locks live under separate
275
+ // directories (avoids cross-platform stale-PID confusion when a host is
276
+ // shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
277
+ // container/CI scenarios that need an explicit shared location.
178
278
  function lockDir() {
179
- const dir = path.join(process.cwd(), '.exceptd', 'locks');
279
+ const dir = process.env.EXCEPTD_LOCK_DIR
280
+ || path.join(os.tmpdir(), `exceptd-locks-${process.platform}`);
180
281
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
181
282
  return dir;
182
283
  }
@@ -214,8 +315,17 @@ function pidAlive(pid) {
214
315
  * fingerprints, framework gap summary, and skills to preload.
215
316
  */
216
317
  function govern(playbookId, directiveId, runOpts = {}) {
217
- const playbook = loadPlaybook(playbookId);
318
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
218
319
  const g = resolvedPhase(playbook, directiveId, 'govern');
320
+ // F12: sort jurisdiction obligations by window_hours ascending so the
321
+ // tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
322
+ // first. Operators reading the govern output for ack-time briefing need
323
+ // the most urgent clock at the top of the list.
324
+ const obligations = (g.jurisdiction_obligations || []).slice().sort((a, b) => {
325
+ const aw = (a && typeof a.window_hours === 'number') ? a.window_hours : Number.POSITIVE_INFINITY;
326
+ const bw = (b && typeof b.window_hours === 'number') ? b.window_hours : Number.POSITIVE_INFINITY;
327
+ return aw - bw;
328
+ });
219
329
  return {
220
330
  phase: 'govern',
221
331
  playbook_id: playbookId,
@@ -224,7 +334,7 @@ function govern(playbookId, directiveId, runOpts = {}) {
224
334
  threat_currency_score: playbook._meta.threat_currency_score,
225
335
  last_threat_review: playbook._meta.last_threat_review,
226
336
  air_gap_mode: !!playbook._meta.air_gap_mode || !!runOpts.airGap,
227
- jurisdiction_obligations: g.jurisdiction_obligations || [],
337
+ jurisdiction_obligations: obligations,
228
338
  theater_fingerprints: g.theater_fingerprints || [],
229
339
  framework_context: g.framework_context || {},
230
340
  skill_preload: g.skill_preload || [],
@@ -238,8 +348,8 @@ function govern(playbookId, directiveId, runOpts = {}) {
238
348
 
239
349
  // --- phase 2: direct ---
240
350
 
241
- function direct(playbookId, directiveId) {
242
- const playbook = loadPlaybook(playbookId);
351
+ function direct(playbookId, directiveId, runOpts = {}) {
352
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
243
353
  const d = resolvedPhase(playbook, directiveId, 'direct');
244
354
  return {
245
355
  phase: 'direct',
@@ -256,7 +366,7 @@ function direct(playbookId, directiveId) {
256
366
  // --- phase 3: look (engine emits, agent executes) ---
257
367
 
258
368
  function look(playbookId, directiveId, runOpts = {}) {
259
- const playbook = loadPlaybook(playbookId);
369
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
260
370
  const l = resolvedPhase(playbook, directiveId, 'look');
261
371
  const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
262
372
  return {
@@ -303,8 +413,8 @@ function look(playbookId, directiveId, runOpts = {}) {
303
413
  * and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
304
414
  * record an indicator outcome the agent computed using its own pattern matching.
305
415
  */
306
- function detect(playbookId, directiveId, agentSubmission = {}) {
307
- const playbook = loadPlaybook(playbookId);
416
+ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
417
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
308
418
  const det = resolvedPhase(playbook, directiveId, 'detect');
309
419
  const artifacts = agentSubmission.artifacts || {};
310
420
  const overrides = agentSubmission.signal_overrides || {};
@@ -323,24 +433,61 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
323
433
  return null; // truly unknown — fall through
324
434
  };
325
435
 
436
+ // E1: per-indicator FP-check attestation map. Operators submit
437
+ // signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
438
+ // to declare which named false_positive_checks_required[] entries on the
439
+ // indicator have been satisfied. An unverified FP check downgrades the
440
+ // verdict from 'hit' to 'inconclusive' and surfaces fp_checks_unsatisfied
441
+ // on the per-indicator result. See AGENTS.md Hard Rule #6 (compliance
442
+ // theater) and AGENTS.md §"detect (AI)" — a `hit` without its FP checks
443
+ // is not yet a `detected` classification.
326
444
  const indicatorResults = (det.indicators || []).map(ind => {
327
445
  const rawOverride = overrides[ind.id];
328
446
  const override = canonicalize(rawOverride);
329
447
  let verdict;
448
+ let fpChecksUnsatisfied = null;
330
449
  if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
331
450
  verdict = override;
451
+ // E1: gate 'hit' verdict on per-indicator false_positive_checks_required
452
+ // satisfaction. The FP-check attestation arrives as a sibling key
453
+ // '<id>__fp_checks' in signal_overrides; default behavior (no
454
+ // attestation) treats every required FP check as UNSATISFIED.
455
+ if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
456
+ const attestation = overrides[`${ind.id}__fp_checks`];
457
+ const att = (attestation && typeof attestation === 'object') ? attestation : {};
458
+ const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
459
+ // Match either by exact name string OR by indexed key '0', '1', ...
460
+ // because false_positive_checks_required entries are free-text
461
+ // strings, not ids. Operators may attest either by the literal
462
+ // string or by index. Default: unsatisfied.
463
+ if (att[fpName] === true) return false;
464
+ const idx = ind.false_positive_checks_required.indexOf(fpName);
465
+ if (idx !== -1 && att[String(idx)] === true) return false;
466
+ return true;
467
+ });
468
+ if (unsatisfied.length > 0) {
469
+ verdict = 'inconclusive';
470
+ fpChecksUnsatisfied = unsatisfied;
471
+ }
472
+ }
332
473
  } else {
333
474
  // Without an explicit override, treat any captured artifact as evidence
334
- // the indicator could be evaluated. Mark inconclusive if no related
335
- // artifact was captured engine doesn't pattern-match raw artifact
336
- // content; the host AI is responsible for that.
475
+ // the indicator could be evaluated. Mark inconclusive if any artifact
476
+ // was captured (engine doesn't pattern-match raw artifact content; the
477
+ // host AI is responsible for that). With NO captured artifacts, this is
478
+ // a clean empty submission — emit 'miss' so the run can reach
479
+ // classification:'not_detected' rather than getting stuck inconclusive.
480
+ // E2: pre-fix both arms emitted 'inconclusive', so a clean empty run
481
+ // could never reach not_detected and theater_verdict stayed
482
+ // 'pending_agent_run' forever.
337
483
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
338
- verdict = anyCaptured ? 'inconclusive' : 'inconclusive';
484
+ verdict = anyCaptured ? 'inconclusive' : 'miss';
339
485
  }
340
486
  return {
341
487
  id: ind.id, type: ind.type, confidence: ind.confidence,
342
488
  deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
343
- attack_ref: ind.attack_ref || null, verdict
489
+ attack_ref: ind.attack_ref || null, verdict,
490
+ ...(fpChecksUnsatisfied ? { fp_checks_unsatisfied: fpChecksUnsatisfied } : {})
344
491
  };
345
492
  });
346
493
 
@@ -402,7 +549,11 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
402
549
  })),
403
550
  indicators_evaluated_count: indicatorResults.length,
404
551
  classification_override_applied: validOverrides.has(override) ? (override === 'clean' ? 'not_detected' : override) : null,
405
- submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty')
552
+ submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
553
+ // E9: pass through any flat-shape observation collisions detected at
554
+ // normalize time so analyze() can publish them under
555
+ // analyze.signal_origins_with_collisions.
556
+ _signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
406
557
  };
407
558
  }
408
559
 
@@ -413,10 +564,17 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
413
564
  * mapping + escalation evaluation. Inputs are the detect result + any
414
565
  * agent-submitted signal_values (e.g. blast_radius classification).
415
566
  */
416
- function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
417
- const playbook = loadPlaybook(playbookId);
567
+ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOpts = {}) {
568
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
418
569
  const an = resolvedPhase(playbook, directiveId, 'analyze');
419
570
  const directive = findDirective(playbook, directiveId);
571
+ // F6/F20/F24: when analyze() is called directly (not via run()), no
572
+ // runtime-error accumulator exists in runOpts. Ensure there's always a
573
+ // local array so blast_radius / theater / xref errors surface in the
574
+ // returned analyze.runtime_errors.
575
+ if (!Array.isArray(runOpts._runErrors)) {
576
+ runOpts = { ...runOpts, _runErrors: [] };
577
+ }
420
578
 
421
579
  // Resolve catalogued CVEs from the domain.cve_refs list. This list is the
422
580
  // playbook's CVE scan-coverage enumeration — every CVE this playbook can
@@ -452,13 +610,36 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
452
610
  const cveRefs = playbook.domain.cve_refs || [];
453
611
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
454
612
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
455
- const allCves = cveRefs.map(id => xref.byCve(id)).filter(r => r.found);
613
+ // F17: distinguish OpenVEX/CycloneDX "drop entirely" dispositions
614
+ // (not_affected / false_positive) from "keep but annotate" dispositions
615
+ // (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
616
+ // is computed below from agentSignals.vex_fixed when the operator passes
617
+ // it (CLI populates it from the VEX doc alongside vex_filter).
618
+ const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
619
+ : (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
620
+ // F20: wrap xref.byCve() so a corrupt catalog (or transient missing-index
621
+ // anomaly) surfaces as a runtime_error rather than crashing analyze().
622
+ const _byCveSafe = (id) => {
623
+ try { return xref.byCve(id); }
624
+ catch (e) {
625
+ if (Array.isArray(runOpts._runErrors)) {
626
+ runOpts._runErrors.push({ kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) });
627
+ }
628
+ return { found: false, cve_id: id };
629
+ }
630
+ };
631
+ const allCves = cveRefs.map(id => _byCveSafe(id)).filter(r => r.found);
456
632
  const catalogBaselineCves = vexFilter
457
633
  ? allCves.filter(c => !vexFilter.has(c.cve_id))
458
634
  : allCves;
459
635
  const vexDropped = vexFilter
460
636
  ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
461
637
  : [];
638
+ // F17: VEX-fixed CVEs remain in matched/catalog arrays but get annotated
639
+ // with vex_status:'fixed' downstream so consumers see them as resolved.
640
+ const vexFixedIds = vexFixed
641
+ ? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
642
+ : [];
462
643
 
463
644
  // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
464
645
  const correlationsByCve = new Map();
@@ -491,64 +672,254 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
491
672
  }
492
673
  }
493
674
 
494
- const matchedCves = catalogBaselineCves.filter(c => correlationsByCve.has(c.cve_id));
675
+ // F3: indicator-level cve_ref correlation. Indicators may declare a
676
+ // cve_ref (string OR string[]) naming CVEs whose presence the indicator
677
+ // pattern-matches. When such an indicator fires AND the named CVE exists
678
+ // in the catalog, the CVE joins matched_cves with correlated_via=
679
+ // 'indicator_cve_ref:<indicator-id>'. The catalog lookup also brings in
680
+ // CVEs the playbook didn't enumerate in domain.cve_refs — they're appended
681
+ // to the working catalog set so the downstream matchedCves filter picks
682
+ // them up. Dedupe is automatic via correlationsByCve (Map keyed on cve_id).
683
+ const extraCatalogCves = [];
684
+ const seenCatalogIds = new Set(catalogBaselineCves.map(c => c.cve_id));
685
+ for (const fired of firedIndicators) {
686
+ const indicator = (playbookDetect.indicators || []).find(i => i.id === fired.id);
687
+ if (!indicator) continue;
688
+ const raw = indicator.cve_ref;
689
+ const refs = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
690
+ for (const cveId of refs) {
691
+ // VEX-drop these the same as catalog CVEs.
692
+ if (vexFilter && vexFilter.has(cveId)) continue;
693
+ let cveEntry = catalogBaselineCves.find(c => c.cve_id === cveId);
694
+ if (!cveEntry) {
695
+ const looked = _byCveSafe(cveId);
696
+ if (!looked || !looked.found) continue; // CVE not in catalog — skip
697
+ if (!seenCatalogIds.has(looked.cve_id)) {
698
+ extraCatalogCves.push(looked);
699
+ seenCatalogIds.add(looked.cve_id);
700
+ }
701
+ }
702
+ addCorrelation(cveId, `indicator_cve_ref:${fired.id}`);
703
+ }
704
+ }
705
+ const workingCatalogCves = catalogBaselineCves.concat(extraCatalogCves);
706
+
707
+ const matchedCves = workingCatalogCves.filter(c => correlationsByCve.has(c.cve_id));
495
708
 
496
709
  // Per-CVE shape — identical between matched_cves and catalog_baseline_cves
497
710
  // so consumers can iterate either without branching. matched_cves entries
498
711
  // carry a non-null correlated_via array; catalog_baseline_cves entries
499
712
  // carry correlated_via:null and a `note` clarifying the field's intent.
500
- const cveShape = (c, correlatedVia) => ({
501
- cve_id: c.cve_id,
502
- rwep: c.rwep_score,
503
- cvss_score: c.entry?.cvss_score ?? null,
504
- cvss_vector: c.entry?.cvss_vector ?? null,
505
- cisa_kev: c.cisa_kev,
506
- cisa_kev_date: c.entry?.cisa_kev_date ?? null,
507
- cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
508
- poc_available: c.entry?.poc_available ?? null,
509
- ai_discovered: c.ai_discovered,
510
- ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
511
- active_exploitation: c.active_exploitation,
512
- patch_available: c.entry?.patch_available ?? null,
513
- patch_required_reboot: c.entry?.patch_required_reboot ?? null,
514
- live_patch_available: c.entry?.live_patch_available ?? null,
515
- epss_score: c.entry?.epss_score ?? null,
516
- epss_date: c.entry?.epss_date ?? null,
517
- atlas_refs: c.atlas_refs,
518
- attack_refs: c.attack_refs,
519
- affected_versions: c.entry?.affected_versions ?? null,
520
- correlated_via: correlatedVia,
521
- });
713
+ const cveShape = (c, correlatedVia) => {
714
+ // F17: annotate VEX-fixed CVEs with vex_status. matched_cves still
715
+ // includes them so audit trails and SBOM reports surface "we know this
716
+ // is in scope but vendor declared it fixed."
717
+ const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
718
+ return {
719
+ cve_id: c.cve_id,
720
+ rwep: c.rwep_score,
721
+ cvss_score: c.entry?.cvss_score ?? null,
722
+ cvss_vector: c.entry?.cvss_vector ?? null,
723
+ cisa_kev: c.cisa_kev,
724
+ cisa_kev_date: c.entry?.cisa_kev_date ?? null,
725
+ cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
726
+ poc_available: c.entry?.poc_available ?? null,
727
+ ai_discovered: c.ai_discovered,
728
+ ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
729
+ active_exploitation: c.active_exploitation,
730
+ patch_available: c.entry?.patch_available ?? null,
731
+ patch_required_reboot: c.entry?.patch_required_reboot ?? null,
732
+ live_patch_available: c.entry?.live_patch_available ?? null,
733
+ epss_score: c.entry?.epss_score ?? null,
734
+ epss_date: c.entry?.epss_date ?? null,
735
+ atlas_refs: c.atlas_refs,
736
+ attack_refs: c.attack_refs,
737
+ affected_versions: c.entry?.affected_versions ?? null,
738
+ correlated_via: correlatedVia,
739
+ ...(vexStatus ? { vex_status: vexStatus } : {}),
740
+ };
741
+ };
522
742
 
523
743
  const matchedCveEntries = matchedCves.map(c => cveShape(c, correlationsByCve.get(c.cve_id)));
524
- const catalogBaselineEntries = catalogBaselineCves.map(c => ({
744
+ const catalogBaselineEntries = workingCatalogCves.map(c => ({
525
745
  ...cveShape(c, null),
526
746
  note: 'Catalog-baseline entry — this CVE is in the playbook\'s scan coverage but no submitted evidence correlated to it. Not a statement that the operator is affected.',
527
747
  }));
528
748
 
529
749
  // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
530
750
  // matches (NOT catalog baseline) so RWEP base reflects what the operator's
531
- // evidence actually surfaced. Adjust by playbook's rwep_inputs based on
532
- // detect hits + agent signals.
533
- const baseRwep = matchedCves.length ? Math.max(...matchedCves.map(c => c.rwep_score)) : 0;
751
+ // evidence actually surfaced. F18: the "max" reduction across matched CVEs
752
+ // is intentional RWEP is a "worst-case real-world exploit priority", not
753
+ // an arithmetic average. The most-exploitable CVE in the set drives the
754
+ // base; secondary CVEs add via rwep_inputs adjustments below rather than
755
+ // through base summing (which would double-count overlapping risk).
756
+ // F17: vex_status='fixed' CVEs do NOT drive the base — vendor declared
757
+ // them resolved. They still appear in matched_cves for audit traceability
758
+ // but don't elevate RWEP.
759
+ const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
760
+ const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
761
+
762
+ // F5: rwep_factor semantics. Each rwep_input.weight is conditional on the
763
+ // matched CVE having a corresponding attribute. Pre-fix, every weight fired
764
+ // unconditionally when its signal_id indicator hit — operators saw RWEP +25
765
+ // for active_exploitation regardless of whether the matched CVE was actually
766
+ // under active exploitation. Now we multiply weight by a factor in [0, 1]
767
+ // derived from the first matched CVE's catalog attribute. blast_radius is
768
+ // sourced from the analyze-phase blast_radius_score / 5 (rubric ceiling).
769
+ // Negative weights (patch_available, live_patch_available) keep their sign
770
+ // so a patched CVE deducts the full magnitude when the catalog confirms a
771
+ // patch is available.
772
+ //
773
+ // Aliasing: playbooks ship rwep_factor values `public_poc` and
774
+ // `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
775
+ // Both spellings resolve here.
776
+ const _activeExploitationLadder = { confirmed: 1.0, suspected: 0.5, unknown: 0.25, none: 0 };
777
+ const _factorScale = (factorName, cve, blastScore) => {
778
+ if (!cve) return 0;
779
+ switch (factorName) {
780
+ case 'cisa_kev':
781
+ return cve.cisa_kev === true ? 1 : 0;
782
+ case 'active_exploitation': {
783
+ const v = cve.active_exploitation || (cve.entry && cve.entry.active_exploitation);
784
+ return _activeExploitationLadder[v] ?? 0;
785
+ }
786
+ case 'poc_available':
787
+ case 'public_poc': {
788
+ const v = cve.entry?.poc_available ?? cve.poc_available;
789
+ return v === true ? 1 : 0;
790
+ }
791
+ case 'ai_factor':
792
+ case 'ai_weaponization': {
793
+ const aiDisc = cve.ai_discovered === true || cve.entry?.ai_discovered === true;
794
+ const aiWeap = cve.entry?.ai_assisted_weaponization === true;
795
+ if (aiDisc && aiWeap) return 1.0;
796
+ if (aiDisc || aiWeap) return 0.5;
797
+ return 0;
798
+ }
799
+ case 'patch_available':
800
+ return cve.entry?.patch_available === true ? 1 : 0;
801
+ case 'live_patch_available':
802
+ return cve.entry?.live_patch_available === true ? 1 : 0;
803
+ case 'reboot_required':
804
+ return cve.entry?.patch_required_reboot === true ? 1 : 0;
805
+ case 'blast_radius': {
806
+ // blast_radius weights scale by the 0-5 rubric score so a max-blast
807
+ // finding gets full weight and a low-blast finding gets a fraction.
808
+ if (typeof blastScore !== 'number' || blastScore < 0) return 0;
809
+ return Math.min(1, blastScore / 5);
810
+ }
811
+ default:
812
+ // Unknown factor: fire as binary (legacy behavior) so playbooks with
813
+ // novel rwep_factor strings don't silently zero out.
814
+ return 1;
815
+ }
816
+ };
817
+
818
+ // F6: blast_radius_score validation. Pre-fix, when no agent signal was
819
+ // supplied the runner silently defaulted to blast_rubric[0].blast_radius_score
820
+ // — typically the LOWEST-blast rubric entry — which is the opposite of
821
+ // safe-default. Now: no supplied value → null + signal='default'. Supplied
822
+ // value out of [0,5] → null + signal='rejected' + runtime_error. Supplied
823
+ // value in range → use it + signal='supplied'.
824
+ const blastRubric = an.blast_radius_model?.scoring_rubric || [];
825
+ let blastRadiusScore = null;
826
+ let blastRadiusSignal = 'default';
827
+ if (agentSignals.blast_radius_score !== undefined && agentSignals.blast_radius_score !== null) {
828
+ const raw = agentSignals.blast_radius_score;
829
+ const num = typeof raw === 'number' ? raw : parseFloat(raw);
830
+ if (Number.isFinite(num) && num >= 0 && num <= 5) {
831
+ blastRadiusScore = num;
832
+ blastRadiusSignal = 'supplied';
833
+ } else {
834
+ blastRadiusSignal = 'rejected';
835
+ if (Array.isArray(runOpts._runErrors)) {
836
+ runOpts._runErrors.push({ kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' });
837
+ }
838
+ }
839
+ }
840
+ // F5: use the first evidence-correlated CVE as the canonical attribute
841
+ // source for factor scaling. If matchedCves is empty there's no per-CVE
842
+ // evidence to gate on. v0.12.15 (audit N F1): the prior fallback was
843
+ // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
844
+ // (secrets, library-author, crypto-codebase, framework, cred-stores,
845
+ // containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
846
+ // evidence correlation emitted `weight_applied: 0` for every fired
847
+ // indicator, producing `adjusted: 0` for every detection. The e2e suite
848
+ // caught this — 9/20 scenarios failed `json_path_min.adjusted >= N`.
849
+ //
850
+ // Domain-level fallback: when no evidence-correlated CVE is available,
851
+ // use the highest-rwep_score entry from `workingCatalogCves` (which is
852
+ // built from `playbook.domain.cve_refs[]` — the playbook's canonical
853
+ // "what we're about"). This preserves factor-scaling semantics while
854
+ // recognizing that a catalog-shape playbook's threat class is already
855
+ // declared by its domain refs. The factor-scale annotation surfaces
856
+ // `factor_cve_source: 'evidence' | 'domain' | 'none'` so operators see
857
+ // which fallback was used.
858
+ let factorCveSource = 'none';
859
+ let factorCve = matchedCves[0] || null;
860
+ if (factorCve) {
861
+ factorCveSource = 'evidence';
862
+ } else if (workingCatalogCves.length > 0) {
863
+ // Highest rwep_score from domain refs.
864
+ factorCve = workingCatalogCves.reduce((worst, c) =>
865
+ (typeof c.rwep_score === 'number' && (!worst || c.rwep_score > worst.rwep_score)) ? c : worst,
866
+ null);
867
+ if (factorCve) factorCveSource = 'domain';
868
+ }
869
+ // v0.12.15 (audit N F1): five shipped playbooks (secrets, library-author,
870
+ // crypto-codebase, framework, cred-stores, containers, runtime, crypto,
871
+ // ai-api) ship with empty `domain.cve_refs` because their attack class is
872
+ // class-of-vulnerability rather than CVE-specific. For those playbooks
873
+ // neither evidence-correlation NOR the domain-CVE fallback yields a
874
+ // factorCve, so every fired indicator's `weight_applied` was forced to
875
+ // zero by `_factorScale` returning 0. Fall back to the pre-v0.12.14
876
+ // semantics for this case only: apply the declared weight as-is
877
+ // (factor_scale=1, legacy semantics). The factor_cve_source annotation
878
+ // surfaces 'class' so operators see which mode the run used.
879
+ const _classScaleFallback = !factorCve;
534
880
  let adjustedRwep = baseRwep;
535
881
  const rwepBreakdown = [];
536
882
  for (const input of an.rwep_inputs || []) {
537
883
  const indicator = detectResult.indicators?.find(i => i.id === input.signal_id);
538
884
  const fired = indicator?.verdict === 'hit' || agentSignals[input.signal_id] === true;
539
- if (fired) {
540
- adjustedRwep += input.weight;
541
- rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: input.weight, fired: true });
885
+ if (!fired) {
886
+ rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false, factor_scale: 0 });
887
+ continue;
888
+ }
889
+ // v0.12.15: class-of-vulnerability playbooks (no factorCve from
890
+ // evidence OR domain) apply weights as-is via the legacy semantics.
891
+ // For CVE-anchored playbooks, scale by the matched CVE's attributes.
892
+ // Class fallback covers blast_radius too — when the agent submitted a
893
+ // blast score, _factorScale honors it; otherwise the class-fallback
894
+ // applies full weight (matching pre-v0.12.14 behavior, where every
895
+ // fired indicator contributed its full declared weight).
896
+ let scale, factorCveSourceForBreakdown;
897
+ if (_classScaleFallback) {
898
+ if (input.rwep_factor === 'blast_radius' && typeof blastRadiusScore === 'number') {
899
+ // Operator-supplied blast score is still honored even in class mode.
900
+ scale = Math.min(1, blastRadiusScore / 5);
901
+ } else {
902
+ scale = 1;
903
+ }
904
+ factorCveSourceForBreakdown = 'class';
542
905
  } else {
543
- rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false });
906
+ scale = _factorScale(input.rwep_factor, factorCve, blastRadiusScore);
907
+ factorCveSourceForBreakdown = factorCveSource;
544
908
  }
909
+ const applied = input.weight * scale;
910
+ adjustedRwep += applied;
911
+ rwepBreakdown.push({
912
+ signal_id: input.signal_id,
913
+ rwep_factor: input.rwep_factor,
914
+ weight_applied: applied,
915
+ weight_declared: input.weight,
916
+ factor_scale: scale,
917
+ factor_cve_source: factorCveSourceForBreakdown,
918
+ fired: true,
919
+ });
545
920
  }
546
921
  adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
547
922
 
548
- // blast_radius
549
- const blastRubric = an.blast_radius_model?.scoring_rubric || [];
550
- const blastRadiusScore = agentSignals.blast_radius_score || (blastRubric[0]?.blast_radius_score ?? null);
551
-
552
923
  // compliance_theater_check — engine surfaces the test; agent runs it; we
553
924
  // accept the verdict in agentSignals.theater_verdict. When agent didn't
554
925
  // submit a verdict but the detect phase reached a clear classification,
@@ -558,8 +929,25 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
558
929
  // (agent still must run reality_test)
559
930
  // detect.classification = inconclusive → theater_verdict = pending_agent_run
560
931
  // Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
932
+ //
933
+ // F24: validate against an allowlist. Pre-fix, any free-text string the
934
+ // operator passed through agentSignals.theater_verdict was accepted, so
935
+ // downstream consumers (CSAF/SARIF/OpenVEX) emitted bundles with garbage
936
+ // verdicts like "TODO" or "let me think". Allowlist: clear, present,
937
+ // theater, pending_agent_run, unknown.
938
+ const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
561
939
  let theaterVerdict = agentSignals.theater_verdict;
562
940
  if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
941
+ if (theaterVerdict !== undefined && theaterVerdict !== null && !_theaterAllowlist.has(theaterVerdict)) {
942
+ if (Array.isArray(runOpts._runErrors)) {
943
+ runOpts._runErrors.push({
944
+ kind: 'theater_verdict_invalid',
945
+ supplied: theaterVerdict,
946
+ allowed: Array.from(_theaterAllowlist),
947
+ });
948
+ }
949
+ theaterVerdict = undefined;
950
+ }
563
951
  if (!theaterVerdict && an.compliance_theater_check) {
564
952
  const cls = detectResult.classification;
565
953
  theaterVerdict = cls === 'not_detected' ? 'clear' : 'pending_agent_run';
@@ -572,8 +960,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
572
960
 
573
961
  // escalation criteria
574
962
  const escalations = [];
963
+ const runtimeErrors = []; // E3: collect regex-eval errors during analyze
964
+ const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
575
965
  for (const ec of an.escalation_criteria || []) {
576
- if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
966
+ if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals, ...evalCtxRoot }, playbook)) {
577
967
  escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
578
968
  }
579
969
  }
@@ -600,15 +990,27 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
600
990
  // matched_cves when surfacing "what CVEs is the operator actually
601
991
  // affected by based on submitted evidence?"
602
992
  catalog_baseline_cves: catalogBaselineEntries,
603
- rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null },
993
+ // F18: rwep base is reduced via Math.max across matched CVEs. Surface
994
+ // the reduction strategy as a discoverable field so operators reading the
995
+ // bundle understand the semantics without grepping source.
996
+ rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
604
997
  blast_radius_score: blastRadiusScore,
998
+ // F6: visible annotation of where blast_radius_score came from:
999
+ // 'supplied' — operator/agent provided a value in [0, 5].
1000
+ // 'default' — no value supplied; runner returned null (no rubric guess).
1001
+ // 'rejected' — value supplied but out of range; treated as default + runtime_error.
1002
+ blast_radius_signal: blastRadiusSignal,
605
1003
  blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
606
1004
  compliance_theater_check: {
607
1005
  claim: an.compliance_theater_check?.claim,
608
1006
  audit_evidence: an.compliance_theater_check?.audit_evidence,
609
1007
  reality_test: an.compliance_theater_check?.reality_test,
610
1008
  verdict: theaterVerdict,
611
- verdict_text: theaterVerdict === 'theater' ? an.compliance_theater_check?.theater_verdict_if_gap : null
1009
+ // F25: render verdict_text for both 'theater' AND 'present' verdicts
1010
+ // ('present' is a synonym used by some playbooks for "theater is here").
1011
+ verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
1012
+ ? an.compliance_theater_check?.theater_verdict_if_gap
1013
+ : null
612
1014
  },
613
1015
  framework_gap_mapping: frameworkGaps,
614
1016
  escalations,
@@ -625,40 +1027,77 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
625
1027
  note: vexDropped.length
626
1028
  ? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / resolved / false_positive. They remain in cve-catalog.json; the disposition lives in the VEX file.`
627
1029
  : "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
628
- } : null
1030
+ } : null,
1031
+ // E3: regex-eval failures surfaced here so operators can see WHICH
1032
+ // condition expression crashed without the runner dying. Only present
1033
+ // when at least one evalCondition() call hit a regex exception during
1034
+ // this analyze pass; runOpts._runErrors is the same accumulator
1035
+ // populated by run() across all phases, so callers reading this field
1036
+ // see every regex problem in the run.
1037
+ runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
1038
+ // E9: collisions when two flat-shape observations targeted the same
1039
+ // indicator id. Empty when there were no collisions or no flat-shape
1040
+ // observations submitted.
1041
+ signal_origins_with_collisions: Array.isArray(agentSignals?._signal_origins_collisions) ? agentSignals._signal_origins_collisions.slice() : (Array.isArray(detectResult?._signal_origins_collisions) ? detectResult._signal_origins_collisions.slice() : [])
629
1042
  };
630
1043
  }
631
1044
 
632
1045
  /**
633
- * Extract a set of "not affected" CVE IDs from a VEX document. Supports
634
- * CycloneDX VEX (analysis.state in {not_affected, resolved, false_positive})
635
- * and OpenVEX (statements[].status === "not_affected"). Returns a Set<string>.
1046
+ * Extract VEX disposition sets from a CycloneDX/OpenVEX document.
1047
+ *
1048
+ * F17: pre-fix this conflated OpenVEX `fixed` and `not_affected` into one
1049
+ * "drop" set. They have different semantics:
1050
+ *
1051
+ * - not_affected / false_positive → drop from matched_cves entirely.
1052
+ * The vendor has formally declared the product not vulnerable; the CVE
1053
+ * is not in scope.
1054
+ * - fixed / resolved → KEEP in matched_cves but annotate vex_status:'fixed'.
1055
+ * The product was vulnerable; the vendor shipped a patch. Operators
1056
+ * still need audit trails, SBOM coverage, and confirmation that the
1057
+ * fix landed in their build.
1058
+ *
1059
+ * Returns a `Set<string>` for the legacy "drop" set (the function's
1060
+ * historical contract), with `.fixed` attached as an own property for
1061
+ * callers that want the split. The CLI passes both as
1062
+ * agentSignals.vex_filter + agentSignals.vex_fixed to analyze().
636
1063
  */
637
1064
  function vexFilterFromDoc(doc) {
638
1065
  const out = new Set();
639
- if (!doc || typeof doc !== 'object') return out;
1066
+ const fixed = new Set();
1067
+ if (!doc || typeof doc !== 'object') {
1068
+ out.fixed = fixed;
1069
+ return out;
1070
+ }
640
1071
 
641
- // CycloneDX shape
1072
+ // CycloneDX shape — analysis.state values per CycloneDX VEX spec:
1073
+ // not_affected / false_positive → drop
1074
+ // resolved → fixed-annotation
642
1075
  for (const v of (doc.vulnerabilities || [])) {
643
1076
  const state = v.analysis && v.analysis.state;
644
- if (state === 'not_affected' || state === 'resolved' || state === 'false_positive') {
1077
+ if (state === 'not_affected' || state === 'false_positive') {
645
1078
  if (v.id) out.add(v.id);
1079
+ } else if (state === 'resolved') {
1080
+ if (v.id) fixed.add(v.id);
646
1081
  }
647
1082
  }
648
1083
  // OpenVEX shape
649
1084
  for (const s of (doc.statements || [])) {
650
- if (s.status === 'not_affected' || s.status === 'fixed') {
651
- const id = s.vulnerability && (s.vulnerability['@id'] || s.vulnerability.name || s.vulnerability);
652
- if (typeof id === 'string') out.add(id);
653
- }
1085
+ const id = s.vulnerability && (s.vulnerability['@id'] || s.vulnerability.name || s.vulnerability);
1086
+ if (typeof id !== 'string') continue;
1087
+ if (s.status === 'not_affected') out.add(id);
1088
+ else if (s.status === 'fixed') fixed.add(id);
654
1089
  }
1090
+ out.fixed = fixed;
655
1091
  return out;
656
1092
  }
657
1093
 
658
1094
  // --- phase 6: validate ---
659
1095
 
660
- function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
661
- const playbook = loadPlaybook(playbookId);
1096
+ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
1097
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1098
+ // E3: surface evalCondition regex errors raised here into the same
1099
+ // run-wide accumulator that analyze() reads.
1100
+ const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
662
1101
  const v = resolvedPhase(playbook, directiveId, 'validate');
663
1102
 
664
1103
  // Pick the highest-priority remediation_path whose preconditions are all
@@ -669,7 +1108,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
669
1108
  for (const p of paths) {
670
1109
  const pcResult = (p.preconditions || []).map(expr => ({
671
1110
  expr,
672
- satisfied: evalCondition(expr, agentSignals, playbook),
1111
+ satisfied: evalCondition(expr, evalCtx, playbook),
673
1112
  submitted: agentSignals[expressionKey(expr)] !== undefined
674
1113
  }));
675
1114
  const allSatisfied = pcResult.every(x => x.satisfied);
@@ -680,9 +1119,42 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
680
1119
  // weren't verified — the agent can surface that to the operator.
681
1120
  if (!selected && paths.length) selected = paths[0];
682
1121
 
683
- // Compute regression schedule next_run (engine sets a single soonest run).
1122
+ // F26: selected_remediation selection logic:
1123
+ // 1. Iterate remediation_paths sorted by priority ASC (lower number =
1124
+ // higher priority per schema convention).
1125
+ // 2. Pick the FIRST path whose every precondition (evaluated against
1126
+ // agentSignals + playbook context) is satisfied.
1127
+ // 3. Fallback: when nothing satisfies, surface the highest-priority
1128
+ // path anyway so the agent has SOMETHING to propose to the operator —
1129
+ // better than emitting null and forcing the agent to guess.
1130
+ // Above this block: paths.sort + the loop populating `considered` +
1131
+ // `selected`. `remediation_options_considered[]` carries the full per-path
1132
+ // precondition trace so operators can see why a higher-priority path was
1133
+ // skipped.
1134
+
1135
+ // F10: regression schedule. Pre-fix this returned a single ISO string;
1136
+ // now returns a structured object with next_run + event_triggers +
1137
+ // unparseable. Preserve backwards compatibility by keeping
1138
+ // regression_next_run as the ISO string (or null) so existing CSAF /
1139
+ // attestation consumers don't break; expose the structured form
1140
+ // separately.
684
1141
  const triggers = v.regression_trigger || [];
685
- const nextRun = computeRegressionNextRun(triggers);
1142
+ const regressionResult = computeRegressionNextRun(triggers);
1143
+
1144
+ // F30: reason annotation for null next_run — operators see WHY a
1145
+ // schedule didn't emit a calendar date (no day intervals declared,
1146
+ // every trigger is event-driven, or every trigger was unparseable).
1147
+ let nextRunReason = null;
1148
+ if (!regressionResult.next_run) {
1149
+ if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
1150
+ else if (regressionResult.event_triggers.length && !regressionResult.unparseable.length) {
1151
+ nextRunReason = 'all_triggers_event_driven';
1152
+ } else if (regressionResult.unparseable.length && !regressionResult.event_triggers.length) {
1153
+ nextRunReason = 'all_triggers_unparseable';
1154
+ } else {
1155
+ nextRunReason = 'no_calendar_interval_resolved';
1156
+ }
1157
+ }
686
1158
 
687
1159
  return {
688
1160
  phase: 'validate',
@@ -694,21 +1166,71 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
694
1166
  residual_risk_statement: v.residual_risk_statement || null,
695
1167
  evidence_requirements: v.evidence_requirements || [],
696
1168
  regression_trigger: triggers,
697
- regression_next_run: nextRun
1169
+ regression_next_run: regressionResult.next_run,
1170
+ regression_next_run_reason: nextRunReason,
1171
+ regression_event_triggers: regressionResult.event_triggers,
1172
+ regression_unparseable_triggers: regressionResult.unparseable,
698
1173
  };
699
1174
  }
700
1175
 
1176
+ /**
1177
+ * F10: extended interval parser. Supports:
1178
+ * <N>d — N days
1179
+ * <N>wk — N weeks
1180
+ * <N>mo — N calendar months (Date.setMonth semantics)
1181
+ * <N>yr — N calendar years
1182
+ * on_event — event-triggered, no date computed; surfaces in
1183
+ * regression_event_triggers[] for the consumer.
1184
+ * Pre-fix, only Nd was honored; wk/mo/yr/on_event triggers were silently
1185
+ * dropped, so a playbook declaring "regression on every release" or
1186
+ * "monthly review" lost its schedule entry.
1187
+ */
1188
+ function parseInterval(intervalStr, now) {
1189
+ if (!intervalStr || typeof intervalStr !== 'string') return null;
1190
+ const s = intervalStr.trim();
1191
+ if (s === 'on_event') return { event: true };
1192
+ let m = s.match(/^(\d+)d$/);
1193
+ if (m) return { date: new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000) };
1194
+ m = s.match(/^(\d+)wk$/);
1195
+ if (m) return { date: new Date(now.getTime() + parseInt(m[1], 10) * 7 * 24 * 3600 * 1000) };
1196
+ m = s.match(/^(\d+)mo$/);
1197
+ if (m) {
1198
+ const d = new Date(now.getTime());
1199
+ d.setMonth(d.getMonth() + parseInt(m[1], 10));
1200
+ return { date: d };
1201
+ }
1202
+ m = s.match(/^(\d+)yr$/);
1203
+ if (m) {
1204
+ const d = new Date(now.getTime());
1205
+ d.setFullYear(d.getFullYear() + parseInt(m[1], 10));
1206
+ return { date: d };
1207
+ }
1208
+ return { unparseable: s };
1209
+ }
1210
+
701
1211
  function computeRegressionNextRun(triggers) {
702
1212
  const now = new Date();
703
1213
  let soonest = null;
1214
+ const eventTriggers = [];
1215
+ const unparseable = [];
704
1216
  for (const t of triggers) {
705
- const m = (t.interval || '').match(/^(\d+)d$/);
706
- if (m) {
707
- const d = new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000);
708
- if (!soonest || d < soonest) soonest = d;
1217
+ const parsed = parseInterval(t.interval, now);
1218
+ if (!parsed) continue;
1219
+ if (parsed.event) {
1220
+ eventTriggers.push({ interval: t.interval, trigger: t.trigger || t.event || null });
1221
+ continue;
709
1222
  }
1223
+ if (parsed.unparseable) {
1224
+ unparseable.push({ interval: parsed.unparseable, trigger: t.trigger || null });
1225
+ continue;
1226
+ }
1227
+ if (parsed.date && (!soonest || parsed.date < soonest)) soonest = parsed.date;
710
1228
  }
711
- return soonest ? soonest.toISOString() : null;
1229
+ return {
1230
+ next_run: soonest ? soonest.toISOString() : null,
1231
+ event_triggers: eventTriggers,
1232
+ unparseable: unparseable,
1233
+ };
712
1234
  }
713
1235
 
714
1236
  // --- phase 7: close ---
@@ -723,9 +1245,16 @@ function computeRegressionNextRun(triggers) {
723
1245
  * - feeds_into chaining suggestions
724
1246
  */
725
1247
  function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
726
- const playbook = loadPlaybook(playbookId);
1248
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
727
1249
  const c = resolvedPhase(playbook, directiveId, 'close');
728
1250
  const g = resolvedPhase(playbook, directiveId, 'govern');
1251
+ // F2/F9: run() generates session_id once and threads it via runOpts.session_id.
1252
+ // Pre-fix, close() generated its own session_id independently of run()'s,
1253
+ // so CSAF tracking.id, OpenVEX @id, the attestation file name on disk, and
1254
+ // the run()-returned session_id were all different hex strings — operators
1255
+ // couldn't correlate the attestation file with the bundle URN inside it.
1256
+ // crypto.randomBytes() fallback only fires for direct close() calls that
1257
+ // bypass run() (e.g. unit tests).
729
1258
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
730
1259
 
731
1260
  // notification_actions — compute ISO deadlines from clock_starts events.
@@ -741,7 +1270,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
741
1270
  const obligation = (g.jurisdiction_obligations || []).find(o =>
742
1271
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
743
1272
  );
744
- const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals) : null;
1273
+ // E7: thread runOpts through so computeClockStart can check
1274
+ // operator_consent.explicit before auto-stamping detect_confirmed.
1275
+ const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
1276
+ // E7: when the clock event is detect_confirmed AND the classification
1277
+ // matched AND the operator did NOT pass --ack, surface clock_pending_ack
1278
+ // so the notification record is visibly waiting on acknowledgement.
1279
+ const clockPendingAck = !clockStart
1280
+ && obligation?.clock_starts === 'detect_confirmed'
1281
+ && agentSignals?.detection_classification === 'detected'
1282
+ && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
745
1283
  const deadline = obligation && clockStart
746
1284
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
747
1285
  : 'pending_clock_start_event';
@@ -756,20 +1294,45 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
756
1294
  window_hours: obligation?.window_hours ?? null,
757
1295
  clock_start_event: obligation?.clock_starts || null,
758
1296
  clock_started_at: clockStart?.toISOString() || null,
1297
+ ...(clockPendingAck ? { clock_pending_ack: true } : {}),
759
1298
  deadline,
760
1299
  // Alias matching compliance-team vocabulary.
761
1300
  notification_deadline: deadline,
762
1301
  // Evidence the regulator expects attached (from the obligation, not
763
1302
  // just the operator-facing recipient bundle on the notification entry).
764
1303
  evidence_required: obligation?.evidence_required || na.evidence_attached || [],
765
- draft_notification: interpolate(na.draft_notification, { ...agentSignals, ...analyzeFindingShape(analyzeResult) })
1304
+ // F14: track missing interpolation variables so operators see exactly
1305
+ // which template vars failed to resolve. Empty array when all
1306
+ // placeholders rendered cleanly.
1307
+ ...(function () {
1308
+ const missing = [];
1309
+ // F20: analyzeFindingShape is a pure transform but defensive-wrap
1310
+ // it so a malformed analyze result (missing matched_cves, etc.)
1311
+ // can't bring down the whole close phase. Failures surface in
1312
+ // runtime_errors via runOpts._runErrors when available.
1313
+ let findingShape;
1314
+ try { findingShape = analyzeFindingShape(analyzeResult); }
1315
+ catch (e) {
1316
+ if (Array.isArray(runOpts._runErrors)) {
1317
+ runOpts._runErrors.push({ kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) });
1318
+ }
1319
+ findingShape = {};
1320
+ }
1321
+ const draft = interpolate(
1322
+ na.draft_notification,
1323
+ { ...agentSignals, ...findingShape },
1324
+ missing,
1325
+ );
1326
+ return { draft_notification: draft, missing_interpolation_vars: missing };
1327
+ })(),
766
1328
  };
767
1329
  });
768
1330
 
769
1331
  // exception_generation — evaluate trigger.
770
1332
  let exception = null;
771
1333
  if (c.exception_generation) {
772
- const triggered = evalCondition(c.exception_generation.trigger_condition, agentSignals, playbook);
1334
+ const closeEvalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
1335
+ const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
773
1336
  if (triggered) {
774
1337
  const t = c.exception_generation.exception_template;
775
1338
  exception = {
@@ -803,9 +1366,9 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
803
1366
  contents: c.evidence_package.contents || [],
804
1367
  destination: c.evidence_package.destination || 'local_only',
805
1368
  signed: c.evidence_package.signed !== false,
806
- bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals),
1369
+ bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals, sessionId),
807
1370
  bundles_by_format: extraFormats.length ? Object.fromEntries(
808
- [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals)])
1371
+ [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals, sessionId)])
809
1372
  ) : null,
810
1373
  } : null;
811
1374
 
@@ -847,7 +1410,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
847
1410
  analyze: analyzeResult,
848
1411
  validate: validateResult,
849
1412
  finding: analyzeFindingShape(analyzeResult),
850
- ...agentSignals
1413
+ ...agentSignals,
1414
+ // E3: surface evalCondition regex failures from the feeds_into chain
1415
+ // into the same accumulator. Without this the regex failure happens but
1416
+ // analyze.runtime_errors[] never sees it.
1417
+ ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
851
1418
  };
852
1419
  const feeds = (playbook._meta.feeds_into || [])
853
1420
  .filter(f => evalCondition(f.condition, feedsCtx, playbook))
@@ -869,53 +1436,165 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
869
1436
  jurisdiction_clocks_count: notificationActions.filter(n => n && n.clock_started_at != null).length,
870
1437
  exception: exception,
871
1438
  regression_schedule: regressionSchedule,
872
- feeds_into: feeds
1439
+ feeds_into: feeds,
1440
+ // F21: feeds_into surfaces downstream playbook IDs whose preconditions
1441
+ // were satisfied by this run. The runner does NOT automatically chain
1442
+ // into them — the agent / operator decides whether to invoke them.
1443
+ // Surface that contract on the result so consumers don't assume an
1444
+ // automated handoff happened.
1445
+ feeds_into_auto_chained: false,
873
1446
  };
874
1447
  }
875
1448
 
1449
+ // E8: severity ladder for active_exploitation. The worst-of reduction lets
1450
+ // analyzeFindingShape report the most-exploited CVE in the matched set, not
1451
+ // the first-encountered one. Higher index = worse.
1452
+ const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
1453
+
1454
+ function worstActiveExploitation(matchedCves) {
1455
+ let worst = null;
1456
+ let worstRank = -1;
1457
+ for (const c of (matchedCves || [])) {
1458
+ const v = c && c.active_exploitation;
1459
+ if (!v) continue;
1460
+ const rank = ACTIVE_EXPLOITATION_RANK[v] ?? -1;
1461
+ if (rank > worstRank) { worst = v; worstRank = rank; }
1462
+ }
1463
+ return worst || 'unknown';
1464
+ }
1465
+
1466
+ // F4: severity ladder derived from rwep_adjusted. Playbooks reference
1467
+ // `finding.severity` in feeds_into and escalation_criteria conditions but
1468
+ // pre-fix analyzeFindingShape never emitted it, so those conditions silently
1469
+ // resolved against undefined. Thresholds:
1470
+ // rwep >= 80 → critical
1471
+ // rwep >= 50 → high
1472
+ // rwep >= 20 → medium
1473
+ // rwep < 20 → low
1474
+ function severityForRwep(rwep) {
1475
+ const r = typeof rwep === 'number' ? rwep : 0;
1476
+ if (r >= 80) return 'critical';
1477
+ if (r >= 50) return 'high';
1478
+ if (r >= 20) return 'medium';
1479
+ return 'low';
1480
+ }
1481
+
876
1482
  function analyzeFindingShape(a) {
1483
+ const matched = a.matched_cves || [];
1484
+ const rwepAdjusted = a.rwep?.adjusted ?? 0;
877
1485
  return {
878
- matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
879
- matched_cve_count: (a.matched_cves || []).length,
880
- kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
881
- active_exploitation: (a.matched_cves || []).find(c => c.active_exploitation)?.active_exploitation || 'unknown',
882
- rwep_adjusted: a.rwep?.adjusted ?? 0,
1486
+ matched_cve_ids: matched.map(c => c.cve_id).join(', '),
1487
+ // F19: sibling array form for consumers that want to iterate IDs
1488
+ // without re-splitting the joined string. The joined form stays for
1489
+ // backwards compatibility with notification-draft templates that
1490
+ // interpolate `${matched_cve_ids}` verbatim.
1491
+ matched_cve_ids_array: matched.map(c => c.cve_id),
1492
+ matched_cve_count: matched.length,
1493
+ kev_listed_count: matched.filter(c => c.cisa_kev).length,
1494
+ // E8: previously this used .find() which returned the first matched CVE
1495
+ // with a truthy active_exploitation. With two CVEs where #1 is
1496
+ // 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
1497
+ // notification drafts — under-stating the threat. Now reduce to the
1498
+ // worst rank across all matched CVEs.
1499
+ active_exploitation: worstActiveExploitation(matched),
1500
+ rwep_adjusted: rwepAdjusted,
883
1501
  rwep_base: a.rwep?.base ?? 0,
1502
+ // F4: severity surface for playbook conditions.
1503
+ severity: severityForRwep(rwepAdjusted),
884
1504
  blast_radius_score: a.blast_radius_score ?? 0,
885
1505
  framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
886
1506
  control_id_first: a.framework_gap_mapping?.[0]?.claimed_control || null
887
1507
  };
888
1508
  }
889
1509
 
890
- function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals) {
1510
+ // Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
1511
+ // Empty input → 'unknown' so we never emit zero-length segments.
1512
+ function urnSlug(s) {
1513
+ if (s == null) return 'unknown';
1514
+ const slug = String(s)
1515
+ .toLowerCase()
1516
+ .replace(/[^a-z0-9_-]+/g, '-')
1517
+ .replace(/^-+|-+$/g, '');
1518
+ return slug.length ? slug : 'unknown';
1519
+ }
1520
+
1521
+ // Build the canonical product binding shared by CSAF + OpenVEX. CSAF's
1522
+ // product_tree must declare every product referenced from
1523
+ // vulnerabilities[].product_status; OpenVEX statements MUST carry a
1524
+ // `products` array per spec §4.3.
1525
+ function buildProductBinding(playbook, sessionId) {
1526
+ const playbookSlug = urnSlug(playbook._meta.id);
1527
+ const sessionSlug = urnSlug(sessionId || 'session');
1528
+ const productId = `exceptd-target-${playbookSlug}-${sessionSlug}`;
1529
+ const productPurl = `pkg:exceptd/scan/${sessionSlug}/${playbookSlug}`;
1530
+ return {
1531
+ productId,
1532
+ productPurl,
1533
+ productName: playbook.domain?.name || playbook._meta.id,
1534
+ };
1535
+ }
1536
+
1537
+ // Best-effort SARIF location list for an indicator hit. Indicator records
1538
+ // don't carry a direct artifact reference; we fall back to the playbook's
1539
+ // look-phase artifact source paths (the inspected files/processes). GitHub
1540
+ // Code Scanning hides results without `artifactLocation.uri`, so we
1541
+ // surface at least one candidate when any is known. Returns null when no
1542
+ // candidate exists — caller MUST omit `locations` rather than emit empty.
1543
+ function sarifLocationsForIndicator(playbook, indicator) {
1544
+ const arts = (playbook.phases?.look?.artifacts) || [];
1545
+ const candidates = arts
1546
+ .map(a => a && (a.source || a.air_gap_alternative))
1547
+ .filter(Boolean)
1548
+ .map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
1549
+ .filter(src => src && !/^https?:/i.test(src));
1550
+ if (!candidates.length) return null;
1551
+ return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
1552
+ }
1553
+
1554
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId) {
1555
+ const playbookSlug = urnSlug(playbook._meta.id);
1556
+ const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1557
+
891
1558
  // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
892
1559
  // catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
893
1560
  // under `exceptd:` namespace), so playbooks without catalogue CVEs still
894
1561
  // emit a non-empty bundle.
1562
+ //
1563
+ // v0.12.12 (B5): emit a product_tree so csaf_security_advisory documents
1564
+ // pass NVD/ENISA/Red Hat dashboard validation. Every vulnerability
1565
+ // entry references the product via product_status so the binding is
1566
+ // real, not cosmetic.
895
1567
  if (format === 'csaf-2.0') {
896
1568
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
897
- const cveVulns = analyze.matched_cves.map(c => ({
898
- cve: c.cve_id,
899
- scores: [{ products: [], cvss_v3: { base_score: c.cvss_score || 0 } }],
900
- threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
901
- remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
902
- }));
1569
+ const fullProductNames = [{
1570
+ product_id: productId,
1571
+ name: productName,
1572
+ product_identification_helper: { purl: productPurl }
1573
+ }];
1574
+ const cveVulns = analyze.matched_cves.map(c => {
1575
+ const isAffected = c.live_patch_available !== true;
1576
+ return {
1577
+ cve: c.cve_id,
1578
+ scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
1579
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
1580
+ remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.', product_ids: [productId] }],
1581
+ product_status: isAffected ? { known_affected: [productId] } : { fixed: [productId] }
1582
+ };
1583
+ });
903
1584
  const indicatorVulns = indicatorHits.map(i => ({
904
- // Pseudo-CVE id for indicator findings (CSAF requires `cve` or `ids`).
905
1585
  ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
906
1586
  notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
907
- remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.` }],
1587
+ remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1588
+ product_status: { known_affected: [productId] }
908
1589
  }));
909
- // v0.11.6 (#91): framework_gap_mapping → CSAF vulnerabilities. Each gap
910
- // becomes a vulnerability keyed by the framework + control, with the
911
- // gap text as the description and the required_control as the remediation.
912
1590
  const gapVulns = (analyze.framework_gap_mapping || []).map((g, idx) => ({
913
1591
  ids: [{ system_name: 'exceptd-framework-gap', text: `${g.framework}:${g.claimed_control || `gap-${idx}`}` }],
914
1592
  notes: [
915
1593
  { category: 'description', text: g.actual_gap || `Framework gap in ${g.framework} ${g.claimed_control || ''}` },
916
1594
  { category: 'general', text: g.claimed_control ? `Claimed control: ${g.claimed_control}` : null },
917
1595
  ].filter(n => n.text),
918
- remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control }] : [],
1596
+ remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control, product_ids: [productId] }] : [],
1597
+ product_status: { under_investigation: [productId] }
919
1598
  }));
920
1599
  const now = new Date().toISOString();
921
1600
  return {
@@ -925,17 +1604,21 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
925
1604
  publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
926
1605
  title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
927
1606
  tracking: {
928
- id: `exceptd-${playbook._meta.id}-${Date.now()}`,
1607
+ // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
1608
+ // from run() via close()) so attestation file names, OpenVEX
1609
+ // @id, and CSAF tracking.id all share the same correlation
1610
+ // identifier. Pre-fix the timestamp was used, so two runs in
1611
+ // the same millisecond collided and one run's documents
1612
+ // referenced ids that didn't match anything else on disk.
1613
+ id: `exceptd-${playbook._meta.id}-${sessionId}`,
929
1614
  status: 'final',
930
1615
  version: playbook._meta.version,
931
1616
  initial_release_date: now,
932
- // v0.11.6 (#92): CSAF 2.0 §3.2.1.12 requires current_release_date
933
- // non-null. Pre-0.11.6 we only set initial_release_date and
934
- // downstream validators rejected the bundle.
935
1617
  current_release_date: now,
936
1618
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
937
1619
  }
938
1620
  },
1621
+ product_tree: { full_product_names: fullProductNames },
939
1622
  vulnerabilities: [...cveVulns, ...indicatorVulns, ...gapVulns],
940
1623
  exceptd_extension: {
941
1624
  classification: analyze._detect_classification,
@@ -953,36 +1636,54 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
953
1636
  // SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
954
1637
  // / most static-analysis tooling.
955
1638
  //
956
- // v0.11.5 (#82): emit results from BOTH matched_cves AND fired indicators.
957
- // Pre-0.11.5 we emitted only matched_cves, which produced an empty bundle
958
- // for playbooks like crypto-codebase / library-author whose domain.cve_refs
959
- // is intentionally empty (the playbook checks process/posture, not catalog
960
- // CVEs). Indicators that fire (verdict: hit) and framework gaps are now
961
- // first-class SARIF results — a clean run still emits a usable bundle.
1639
+ // v0.12.12 (B6): thread artifact source paths through to
1640
+ // result.locations[].physicalLocation.artifactLocation.uri. GitHub Code
1641
+ // Scanning hides results without populated locations, so the heuristic
1642
+ // ensures clean playbook runs still surface findings in the alerts UI.
1643
+ // v0.12.12 (B7): omit null property-bag keys so SARIF viewers don't
1644
+ // render empty fields.
962
1645
  if (format === 'sarif' || format === 'sarif-2.1.0') {
1646
+ const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
963
1647
  const cveResults = analyze.matched_cves.map(c => ({
964
1648
  ruleId: c.cve_id,
965
1649
  level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
966
1650
  message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
967
- properties: {
1651
+ properties: stripNulls({
968
1652
  kind: 'cve_match',
969
- rwep: c.rwep, cisa_kev: c.cisa_kev, cisa_kev_due_date: c.cisa_kev_due_date,
970
- active_exploitation: c.active_exploitation, ai_discovered: c.ai_discovered,
1653
+ rwep: c.rwep,
1654
+ cisa_kev: c.cisa_kev,
1655
+ cisa_kev_due_date: c.cisa_kev_due_date ?? null,
1656
+ active_exploitation: c.active_exploitation ?? null,
1657
+ ai_discovered: c.ai_discovered ?? null,
971
1658
  blast_radius_score: analyze.blast_radius_score,
972
- }
1659
+ }),
973
1660
  }));
974
1661
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
975
- const indicatorResults = indicatorHits.map(i => ({
976
- ruleId: i.id,
977
- level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
978
- message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
979
- properties: { kind: 'indicator_hit', confidence: i.confidence, deterministic: i.deterministic, atlas_ref: i.atlas_ref, attack_ref: i.attack_ref },
980
- }));
1662
+ const indicatorResults = indicatorHits.map(i => {
1663
+ const locs = sarifLocationsForIndicator(playbook, i);
1664
+ const result = {
1665
+ ruleId: i.id,
1666
+ level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
1667
+ message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
1668
+ properties: stripNulls({
1669
+ kind: 'indicator_hit',
1670
+ confidence: i.confidence,
1671
+ deterministic: i.deterministic,
1672
+ atlas_ref: i.atlas_ref,
1673
+ attack_ref: i.attack_ref,
1674
+ }),
1675
+ };
1676
+ if (locs) result.locations = locs;
1677
+ return result;
1678
+ });
981
1679
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
982
1680
  ruleId: `framework-gap-${idx}`,
1681
+ // Framework gaps are control-design observations, not vulnerabilities —
1682
+ // SARIF §3.27.9 `kind: informational` routes them appropriately.
1683
+ kind: 'informational',
983
1684
  level: 'note',
984
1685
  message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
985
- properties: { kind: 'framework_gap', framework: g.framework, control: g.claimed_control },
1686
+ properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
986
1687
  }));
987
1688
  const cveRules = analyze.matched_cves.map(c => ({
988
1689
  id: c.cve_id, shortDescription: { text: c.cve_id },
@@ -995,11 +1696,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
995
1696
  fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
996
1697
  defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
997
1698
  }));
998
- // v0.11.6 (#93): SARIF spec §3.27.3 — every referenced ruleId SHOULD have
999
- // a corresponding rule definition in tool.driver.rules. Pre-0.11.6 we
1000
- // referenced framework-gap-N ids without defining them; GitHub Code
1001
- // Scanning + VS Code SARIF Viewer + Azure DevOps would warn or fail to
1002
- // display rule context. Now we emit one rule per framework gap.
1003
1699
  const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1004
1700
  id: `framework-gap-${idx}`,
1005
1701
  shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
@@ -1025,42 +1721,90 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
1025
1721
  };
1026
1722
  }
1027
1723
 
1028
- // OpenVEX 0.2.0 — supply-chain VEX statements. v0.11.5 (#82): also include
1029
- // statements derived from fired indicators (treated as advisory findings)
1030
- // so playbooks with empty cve_refs still emit a meaningful bundle.
1724
+ // OpenVEX 0.2.0 — supply-chain VEX statements.
1725
+ //
1726
+ // v0.12.12 (B1-B4): correctness sweep against the OpenVEX 0.2.0 spec.
1727
+ // - B1: every statement now carries a `products` array (spec MUST).
1728
+ // - B2: `status` derives from the verdict + confidence rather than being
1729
+ // hard-coded to `under_investigation`. Hits emit `affected` with
1730
+ // an action_statement; misses emit `not_affected` with a
1731
+ // justification; inconclusive findings keep `under_investigation`.
1732
+ // - B3: framework gaps are control-design observations, not
1733
+ // vulnerabilities — they are removed from the VEX emit path. They
1734
+ // remain in CSAF (informational notes) and SARIF (kind:
1735
+ // informational rules).
1736
+ // - B4: vulnerability `@id` values switch to the registered URN namespace
1737
+ // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
1738
+ // they pass IRI validation in downstream VEX consumers.
1031
1739
  if (format === 'openvex' || format === 'openvex-0.2.0') {
1032
1740
  const issued = new Date().toISOString();
1033
- const cveStatements = analyze.matched_cves.map(c => ({
1034
- vulnerability: { '@id': c.cve_id, name: c.cve_id },
1035
- status: c.active_exploitation === 'confirmed' ? 'under_investigation' : (c.live_patch_available ? 'fixed' : 'affected'),
1036
- timestamp: issued,
1037
- action_statement: validate.selected_remediation?.description || null,
1038
- impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`
1039
- }));
1040
- const indicatorStatements = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit').map(i => ({
1041
- vulnerability: { '@id': `exceptd:${playbook._meta.id}:${i.id}`, name: i.id },
1042
- status: 'under_investigation',
1043
- timestamp: issued,
1044
- action_statement: validate.selected_remediation?.description || `Run \`exceptd brief ${playbook._meta.id}\` for context.`,
1045
- impact_statement: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
1046
- }));
1047
- // v0.11.6 (#91): framework gaps → OpenVEX statements. Each gap becomes
1048
- // a statement with a pseudo-CVE id under the exceptd:framework-gap
1049
- // namespace so VEX downstreams ingest them cleanly.
1050
- const gapStatements = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1051
- vulnerability: { '@id': `exceptd:framework-gap:${g.framework}:${g.claimed_control || idx}`, name: `${g.framework} ${g.claimed_control || `gap-${idx}`}` },
1052
- status: 'under_investigation',
1053
- timestamp: issued,
1054
- action_statement: g.required_control || null,
1055
- impact_statement: g.actual_gap || `Framework gap in ${g.framework}.`,
1056
- }));
1741
+ const productEntry = {
1742
+ '@id': productPurl,
1743
+ subcomponents: [{ '@id': productPurl }],
1744
+ };
1745
+ const remediationId = validate.selected_remediation?.id || (validate.remediation_paths?.[0]?.id) || null;
1746
+ const remediationDescription = validate.selected_remediation?.description || null;
1747
+ const actionStatementFor = (fallback) => {
1748
+ if (remediationId && remediationDescription) {
1749
+ return `Apply remediation from validate phase: ${remediationId}. ${remediationDescription}`;
1750
+ }
1751
+ if (remediationId) return `Apply remediation from validate phase: ${remediationId}`;
1752
+ if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
1753
+ return fallback;
1754
+ };
1755
+ const cveStatements = analyze.matched_cves.map(c => {
1756
+ const stmt = {
1757
+ vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
1758
+ products: [productEntry],
1759
+ timestamp: issued,
1760
+ impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
1761
+ };
1762
+ if (c.live_patch_available) {
1763
+ stmt.status = 'fixed';
1764
+ } else {
1765
+ stmt.status = 'affected';
1766
+ stmt.action_statement = actionStatementFor('Apply remediation from validate phase.');
1767
+ }
1768
+ return stmt;
1769
+ });
1770
+ const indicatorStatements = (analyze._detect_indicators || [])
1771
+ .filter(i => i.verdict === 'hit' || i.verdict === 'miss' || i.verdict === 'inconclusive')
1772
+ .map(i => {
1773
+ const stmt = {
1774
+ vulnerability: {
1775
+ '@id': `urn:exceptd:indicator:${playbookSlug}:${urnSlug(i.id)}`,
1776
+ name: i.id,
1777
+ },
1778
+ products: [productEntry],
1779
+ timestamp: issued,
1780
+ impact_statement: `Indicator ${i.id} (${i.verdict}; ${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
1781
+ };
1782
+ if (i.verdict === 'hit') {
1783
+ // Deterministic and high-confidence hits both map to `affected`.
1784
+ // The `deterministic` flag describes regex specificity, not
1785
+ // operator-evidence confidence — neither warrants
1786
+ // under_investigation when the indicator actually fired.
1787
+ stmt.status = 'affected';
1788
+ stmt.action_statement = actionStatementFor(`Run \`exceptd brief ${playbook._meta.id}\` for context.`);
1789
+ } else if (i.verdict === 'miss') {
1790
+ stmt.status = 'not_affected';
1791
+ stmt.justification = 'vulnerable_code_not_present';
1792
+ } else {
1793
+ stmt.status = 'under_investigation';
1794
+ }
1795
+ return stmt;
1796
+ });
1057
1797
  return {
1058
1798
  '@context': 'https://openvex.dev/ns/v0.2.0',
1059
- '@id': `https://exceptd.com/vex/${playbook._meta.id}/${Date.now()}`,
1799
+ // F2/F9: OpenVEX @id baked from session_id (not Date.now()) so the
1800
+ // document URN aligns with CSAF tracking.id and on-disk
1801
+ // attestation file name. Falls back to a urnSlug if sessionId
1802
+ // somehow arrived empty.
1803
+ '@id': `https://exceptd.com/vex/${playbookSlug}/${urnSlug(sessionId || 'session')}`,
1060
1804
  author: 'exceptd',
1061
1805
  timestamp: issued,
1062
1806
  version: 1,
1063
- statements: [...cveStatements, ...indicatorStatements, ...gapStatements],
1807
+ statements: [...cveStatements, ...indicatorStatements],
1064
1808
  };
1065
1809
  }
1066
1810
 
@@ -1102,7 +1846,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
1102
1846
  return { format: 'markdown', body: lines.join('\n') };
1103
1847
  }
1104
1848
 
1105
- return { format, note: 'Unknown format supported: csaf-2.0, sarif, openvex, markdown.', analyze, validate };
1849
+ // F16: pre-fix the fallback leaked raw analyze + validate internals
1850
+ // (matched CVEs, framework gaps, residual-risk statements) under an
1851
+ // arbitrary "format" name. Operators piping output to logging or
1852
+ // third-party tooling could leak finding details just by typo'ing the
1853
+ // format flag. Return the shape advertisement only.
1854
+ return {
1855
+ format,
1856
+ note: 'Unknown format',
1857
+ supported_formats: ['csaf-2.0', 'sarif', 'sarif-2.1.0', 'openvex', 'openvex-0.2.0', 'summary', 'markdown'],
1858
+ };
1106
1859
  }
1107
1860
 
1108
1861
  // --- orchestrate: full run in one call ---
@@ -1122,6 +1875,22 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
1122
1875
  function normalizeSubmission(submission, playbook) {
1123
1876
  if (!submission || typeof submission !== "object") return submission || {};
1124
1877
 
1878
+ // F15: signal_overrides must be a plain object. Pre-fix, a non-object
1879
+ // value (string "foo", array [...]) was spread into out.signal_overrides
1880
+ // via `{ ...(submission.signal_overrides || {}) }`. Spreading a string
1881
+ // splatted it into { '0': 'f', '1': 'o', '2': 'o' }, which then
1882
+ // confused detect()'s indicator-id lookup. Strip and log instead.
1883
+ if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
1884
+ && (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
1885
+ if (!submission._runErrors) submission._runErrors = [];
1886
+ submission._runErrors.push({
1887
+ kind: 'signal_overrides_invalid',
1888
+ supplied_type: Array.isArray(submission.signal_overrides) ? 'array' : typeof submission.signal_overrides,
1889
+ reason: 'signal_overrides must be a plain object mapping indicator-id → verdict.'
1890
+ });
1891
+ submission = { ...submission, signal_overrides: {} };
1892
+ }
1893
+
1125
1894
  // v0.11.3 #71 fix: the CLI may inject `signals._bundle_formats` before
1126
1895
  // calling normalize (for --format <fmt> support). Pre-0.11.3 normalize
1127
1896
  // detected the injected `signals` key and bailed, leaving the flat
@@ -1160,7 +1929,12 @@ function normalizeSubmission(submission, playbook) {
1160
1929
  // v0.11.5 (#85): track which observation produced each signal_override so
1161
1930
  // detect can emit `from_observation` on each indicator result. Diagnostic
1162
1931
  // value for operators chasing "which observation drove this verdict".
1932
+ //
1933
+ // E9: when two observations target the same indicator id, last-write-wins
1934
+ // silently. Track discards in _signal_origins_collisions so analyze can
1935
+ // surface analyze.signal_origins_with_collisions for batch evidence runs.
1163
1936
  out._signal_origins = out._signal_origins || {};
1937
+ out._signal_origins_collisions = out._signal_origins_collisions || [];
1164
1938
  for (const [key, val] of Object.entries(submission.observations || {})) {
1165
1939
  if (knownPreconditions.has(key)) {
1166
1940
  out.precondition_checks[key] = val === "ok" || val === true || val === "true";
@@ -1170,7 +1944,20 @@ function normalizeSubmission(submission, playbook) {
1170
1944
  const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
1171
1945
  out.artifacts[aid] = { value: val.value, captured: val.captured !== false };
1172
1946
  if (val.indicator && val.result !== undefined) {
1173
- out.signal_overrides[val.indicator] = canonicalizeOutcome(val.result);
1947
+ const newVerdict = canonicalizeOutcome(val.result);
1948
+ if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
1949
+ // Collision: a prior observation already set this indicator.
1950
+ // Record the prior (which is now discarded) into the collision
1951
+ // log, then overwrite with the new one (last-write-wins).
1952
+ out._signal_origins_collisions.push({
1953
+ indicator_id: val.indicator,
1954
+ source_observation_key: out._signal_origins[val.indicator],
1955
+ verdict: out.signal_overrides[val.indicator],
1956
+ discarded: true,
1957
+ replaced_by: key
1958
+ });
1959
+ }
1960
+ out.signal_overrides[val.indicator] = newVerdict;
1174
1961
  out._signal_origins[val.indicator] = key;
1175
1962
  }
1176
1963
  }
@@ -1225,7 +2012,41 @@ function autoDetectPreconditions(submission, playbook) {
1225
2012
  }
1226
2013
 
1227
2014
  function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1228
- const playbook = loadPlaybook(playbookId);
2015
+ // F7: catalog corruption surfaced at module-load now blocks runs cleanly.
2016
+ if (_xrefLoadError) {
2017
+ return {
2018
+ ok: false,
2019
+ blocked_by: 'catalog_corrupt',
2020
+ error: _xrefLoadError,
2021
+ reason: 'cve-catalog.json or an index could not be parsed at module load. Run `npm run build-indexes` to regenerate, or restore the file from git.'
2022
+ };
2023
+ }
2024
+
2025
+ let playbook;
2026
+ try {
2027
+ playbook = loadPlaybook(playbookId);
2028
+ } catch (e) {
2029
+ // F20: loadPlaybook failure → structured error (not crash).
2030
+ return {
2031
+ ok: false,
2032
+ blocked_by: 'playbook_not_found',
2033
+ error: (e && e.message) ? String(e.message) : String(e),
2034
+ reason: `Failed to load playbook '${playbookId}'. Check that data/playbooks/${playbookId}.json exists.`
2035
+ };
2036
+ }
2037
+
2038
+ // F8: validate directiveId before any phase runs. Unknown id used to throw
2039
+ // inside analyze()/findDirective() uncaught, surfacing as a 500-style stack
2040
+ // trace. Now returns a clean structured error with the valid directive list.
2041
+ const validDirectives = (playbook.directives || []).map(d => d.id);
2042
+ if (!validDirectives.includes(directiveId)) {
2043
+ return {
2044
+ ok: false,
2045
+ blocked_by: 'directive_not_found',
2046
+ reason: `Directive '${directiveId}' not found in playbook '${playbookId}'.`,
2047
+ valid_directives: validDirectives,
2048
+ };
2049
+ }
1229
2050
 
1230
2051
  // v0.11.0: accept flat submission shape (observations + verdict). Normalize
1231
2052
  // to the engine's internal nested shape before preflight/detect. Smart
@@ -1233,35 +2054,133 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1233
2054
  // / the host platform matches — the runner can answer those itself rather
1234
2055
  // than blocking on AI declaration.
1235
2056
  agentSubmission = normalizeSubmission(agentSubmission, playbook);
2057
+ // F22: capture pre-autoDetect submission preconditions so we report
2058
+ // user-declared provenance, not engine-auto-resolved values.
2059
+ const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
1236
2060
  agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
1237
2061
 
1238
- const pre = preflight(playbook, { ...runOpts, precondition_checks: { ...(agentSubmission.precondition_checks || {}), ...(runOpts.precondition_checks || {}) } });
2062
+ // F22: precondition_checks merge order is submission runOpts (runOpts
2063
+ // wins on collision). This is intentional: runOpts represents the most
2064
+ // recent caller intent (CLI flags / programmatic injection from a host
2065
+ // process), whereas submission was captured earlier during evidence
2066
+ // collection. The order is documented here AND surfaced as
2067
+ // preflight.precondition_check_source on the result so callers can see
2068
+ // whether the value came from the submission, runOpts, or both
2069
+ // (merged with runOpts winning). Provenance reports the ORIGINAL submission
2070
+ // contents — autoDetectPreconditions adds engine-derived values that
2071
+ // wouldn't be meaningful as "submission" provenance.
2072
+ const fullSubmissionPCs = agentSubmission.precondition_checks || {};
2073
+ const runOptsPCs = runOpts.precondition_checks || {};
2074
+ const mergedPCs = { ...fullSubmissionPCs, ...runOptsPCs };
2075
+ const pcSource = {};
2076
+ for (const k of Object.keys(mergedPCs)) {
2077
+ const inOrigSub = Object.prototype.hasOwnProperty.call(originalSubmissionPCs, k);
2078
+ const inRun = Object.prototype.hasOwnProperty.call(runOptsPCs, k);
2079
+ pcSource[k] = (inOrigSub && inRun) ? 'merged' : (inRun ? 'runOpts' : 'submission');
2080
+ }
2081
+ const pre = preflight(playbook, { ...runOpts, precondition_checks: mergedPCs });
1239
2082
  if (!pre.ok) {
1240
- return { ok: false, phase: 'preflight', blocked_by: pre.blocked_by, reason: pre.reason, issues: pre.issues };
2083
+ return { ok: false, phase: 'preflight', blocked_by: pre.blocked_by, reason: pre.reason, issues: pre.issues, precondition_check_source: pcSource };
1241
2084
  }
1242
2085
 
1243
2086
  _activeRuns.add(playbookId);
1244
2087
  // Cross-process mutex lock for this run. preflight verified no other lock
1245
2088
  // exists; we acquire ours and release in the finally block.
1246
2089
  const lockPath = acquireLock(playbookId);
2090
+ // E12: parse the playbook once at run() entry and thread the parsed object
2091
+ // through each phase via runOpts._playbookCache. Each phase otherwise calls
2092
+ // loadPlaybook() independently; for a single run that's seven reads + parses
2093
+ // of the same file. Cached version saves the redundant I/O + JSON parses.
2094
+ //
2095
+ // F2/F9: session_id generated ONCE here, threaded into close() via
2096
+ // cachedRunOpts.session_id. Pre-fix close() generated its own session_id
2097
+ // independently, so CSAF tracking.id / OpenVEX @id / product PURLs all
2098
+ // diverged from the run()-returned session_id and the on-disk attestation
2099
+ // file name. Operators correlating attestation files to embedded bundle
2100
+ // URNs got mismatched ids.
2101
+ const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
2102
+ const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
2103
+ // E3: run-time error accumulator for evalCondition regex failures and other
2104
+ // non-fatal anomalies surfaced into analyze.runtime_errors[].
2105
+ const runErrors = [];
2106
+ cachedRunOpts._runErrors = runErrors;
2107
+ // E6: phases the runner should SKIP execution for, based on skip_phase
2108
+ // preconditions surfaced in preflight.issues.
2109
+ const skipPhases = new Set();
2110
+ for (const issue of (pre.issues || [])) {
2111
+ if (issue.kind === 'precondition_skip' && issue.skip_phase) {
2112
+ skipPhases.add(issue.skip_phase);
2113
+ }
2114
+ }
1247
2115
  try {
1248
2116
  const phases = {
1249
- govern: govern(playbookId, directiveId, runOpts),
1250
- direct: direct(playbookId, directiveId),
1251
- look: look(playbookId, directiveId, runOpts),
1252
- detect: detect(playbookId, directiveId, agentSubmission),
2117
+ govern: govern(playbookId, directiveId, cachedRunOpts),
2118
+ direct: direct(playbookId, directiveId, cachedRunOpts),
2119
+ look: look(playbookId, directiveId, cachedRunOpts),
1253
2120
  };
1254
- phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {});
1255
- phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {});
1256
- phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, runOpts);
2121
+ if (skipPhases.has('detect')) {
2122
+ const skipIssue = (pre.issues || []).find(i => i.kind === 'precondition_skip' && i.skip_phase === 'detect');
2123
+ phases.detect = {
2124
+ phase: 'detect',
2125
+ playbook_id: playbookId,
2126
+ directive_id: directiveId,
2127
+ skipped: true,
2128
+ reason: skipIssue ? skipIssue.id : 'precondition_skip',
2129
+ classification: 'skipped',
2130
+ indicators: [],
2131
+ false_positive_checks_required: [],
2132
+ indicators_evaluated: [],
2133
+ indicators_evaluated_count: 0,
2134
+ observations_received: [],
2135
+ signals_received: []
2136
+ };
2137
+ // analyze() must still run, but with an empty submission so it doesn't
2138
+ // resolve indicator hits against a non-existent detect result.
2139
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, {}, cachedRunOpts);
2140
+ // Annotate analyze with the skip vocabulary so consumers can branch.
2141
+ phases.analyze.classification = 'skipped';
2142
+ } else {
2143
+ phases.detect = detect(playbookId, directiveId, agentSubmission, cachedRunOpts);
2144
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {}, cachedRunOpts);
2145
+ }
2146
+ phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
2147
+ phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
2148
+
2149
+ // E3: analyze() already sliced runOpts._runErrors into
2150
+ // phases.analyze.runtime_errors at return time. Validate + close may
2151
+ // have pushed additional regex errors AFTER analyze returned; surface
2152
+ // those onto phases.analyze.runtime_errors so the field reflects every
2153
+ // regex failure in the run. De-dupe by JSON shape so the analyze-time
2154
+ // snapshot doesn't double-count.
2155
+ if (runErrors.length && phases.analyze) {
2156
+ const existing = new Set((phases.analyze.runtime_errors || []).map(e => JSON.stringify(e)));
2157
+ const additions = runErrors.filter(e => !existing.has(JSON.stringify(e)));
2158
+ if (additions.length) {
2159
+ phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
2160
+ }
2161
+ }
1257
2162
 
1258
- const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
2163
+ // F1: evidence_hash binds the operator's submission to the verdict.
2164
+ // Pre-fix the hash only covered { playbook, directive, cves, rwep,
2165
+ // classification } — two operators submitting completely different
2166
+ // evidence that happened to produce the same classification got the
2167
+ // same evidence_hash, breaking the contract that the hash uniquely
2168
+ // identifies a run. Now the hash includes a canonicalized SHA-256 over
2169
+ // the submission (observations, signal_overrides, signals) with sorted
2170
+ // keys recursively. `captured_at` and other timestamp-like fields are
2171
+ // INTENTIONALLY excluded so that re-running with the same submission
2172
+ // produces the same hash — `reattest` relies on this to detect drift
2173
+ // (different submission → different hash → drift exists).
2174
+ const submissionDigest = crypto.createHash('sha256')
2175
+ .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
2176
+ .digest('hex');
1259
2177
  const evidenceHash = crypto.createHash('sha256')
1260
2178
  .update(JSON.stringify({
1261
2179
  playbookId, directiveId,
1262
2180
  cves: phases.analyze.matched_cves.map(c => c.cve_id),
1263
2181
  rwep: phases.analyze.rwep.adjusted,
1264
- classification: phases.detect.classification
2182
+ classification: phases.detect.classification,
2183
+ submission_digest: submissionDigest,
1265
2184
  }))
1266
2185
  .digest('hex');
1267
2186
 
@@ -1271,7 +2190,11 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1271
2190
  directive_id: directiveId,
1272
2191
  session_id: sessionId,
1273
2192
  evidence_hash: evidenceHash,
2193
+ submission_digest: submissionDigest,
1274
2194
  preflight_issues: pre.issues,
2195
+ // F22: source provenance for precondition_checks. Shape:
2196
+ // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
2197
+ precondition_check_source: pcSource,
1275
2198
  phases
1276
2199
  };
1277
2200
  } finally {
@@ -1282,6 +2205,72 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1282
2205
 
1283
2206
  // --- helpers ---
1284
2207
 
2208
+ /**
2209
+ * F1: deterministic JSON stringification with recursively sorted keys.
2210
+ * Without sorted keys two semantically identical submissions ({a:1, b:2}
2211
+ * vs {b:2, a:1}) would hash to different digests, breaking reattest's
2212
+ * "same submission → same hash" contract. Arrays preserve order
2213
+ * (submission order is meaningful for evidence). null + primitives pass
2214
+ * through directly. Avoids JSON.stringify's replacer indirection because
2215
+ * a top-level array would otherwise miss the canonicalization recursion.
2216
+ */
2217
+ function canonicalStringify(v) {
2218
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
2219
+ if (Array.isArray(v)) return '[' + v.map(canonicalStringify).join(',') + ']';
2220
+ const keys = Object.keys(v).sort();
2221
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}';
2222
+ }
2223
+
2224
+ /**
2225
+ * F1: pick the operator-meaningful fields out of the normalized submission
2226
+ * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
2227
+ * and _original_shape are intentionally excluded — they're either
2228
+ * timestamps (would break "same submission → same hash") or runner-internal
2229
+ * provenance metadata that isn't part of what the operator submitted.
2230
+ */
2231
+ function extractSubmissionForHash(sub) {
2232
+ if (!sub || typeof sub !== 'object') return {};
2233
+ const pick = {};
2234
+ // Strip captured_at from artifact entries so timestamp drift doesn't
2235
+ // perturb the digest. The semantic content (value + captured-ness +
2236
+ // optional indicator binding) is what matters for "did the operator
2237
+ // submit the same evidence?".
2238
+ if (sub.artifacts && typeof sub.artifacts === 'object') {
2239
+ pick.artifacts = {};
2240
+ for (const [k, v] of Object.entries(sub.artifacts)) {
2241
+ if (v && typeof v === 'object') {
2242
+ const { captured_at, _captured_at, ...rest } = v;
2243
+ pick.artifacts[k] = rest;
2244
+ } else {
2245
+ pick.artifacts[k] = v;
2246
+ }
2247
+ }
2248
+ }
2249
+ if (sub.signal_overrides && typeof sub.signal_overrides === 'object') {
2250
+ pick.signal_overrides = sub.signal_overrides;
2251
+ }
2252
+ if (sub.signals && typeof sub.signals === 'object') {
2253
+ // vex_filter and vex_fixed may be Sets — convert to sorted arrays so
2254
+ // canonicalStringify can serialize them.
2255
+ const signals = {};
2256
+ for (const [k, v] of Object.entries(sub.signals)) {
2257
+ if (v instanceof Set) signals[k] = Array.from(v).sort();
2258
+ else signals[k] = v;
2259
+ }
2260
+ pick.signals = signals;
2261
+ }
2262
+ if (sub.precondition_checks && typeof sub.precondition_checks === 'object') {
2263
+ pick.precondition_checks = sub.precondition_checks;
2264
+ }
2265
+ if (sub.observations && typeof sub.observations === 'object') {
2266
+ pick.observations = sub.observations;
2267
+ }
2268
+ if (sub.verdict && typeof sub.verdict === 'object') {
2269
+ pick.verdict = sub.verdict;
2270
+ }
2271
+ return pick;
2272
+ }
2273
+
1285
2274
  function evalCondition(expr, ctx, playbook) {
1286
2275
  if (!expr) return false;
1287
2276
  expr = expr.trim();
@@ -1340,7 +2329,23 @@ function evalCondition(expr, ctx, playbook) {
1340
2329
  if (m) {
1341
2330
  const val = resolvePath(ctx, m[1]);
1342
2331
  if (typeof val !== 'string') return false;
1343
- return new RegExp(m[2], 'i').test(val);
2332
+ // E3: an operator-supplied or playbook-supplied regex with a syntax bug
2333
+ // (or pathological backtracking) must NOT crash the engine mid-analyze.
2334
+ // Catch construction + test exceptions, return false, and push a
2335
+ // structured _regex_eval_error into ctx._runErrors (when present) so
2336
+ // analyze() can surface analyze.runtime_errors[] without losing the
2337
+ // diagnostic.
2338
+ try {
2339
+ return new RegExp(m[2], 'i').test(val);
2340
+ } catch (e) {
2341
+ const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
2342
+ // Two sites where ctx may carry an accumulator: runOpts._runErrors
2343
+ // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
2344
+ // form; fall back to ctx.
2345
+ if (ctx && Array.isArray(ctx._runErrors)) ctx._runErrors.push(errorRec);
2346
+ else if (playbook && Array.isArray(playbook._runErrors)) playbook._runErrors.push(errorRec);
2347
+ return false;
2348
+ }
1344
2349
  }
1345
2350
 
1346
2351
  if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
@@ -1398,13 +2403,35 @@ function stripOuterParens(expr) {
1398
2403
  return expr;
1399
2404
  }
1400
2405
 
1401
- function computeClockStart(eventName, agentSignals) {
2406
+ /**
2407
+ * Compute the start instant for a jurisdictional clock event. The agent
2408
+ * submits clock_started_at_<event> ISO strings as it progresses through
2409
+ * incident-response milestones.
2410
+ *
2411
+ * E7: per AGENTS.md Phase 7, the legal contract is that the clock starts
2412
+ * from OPERATOR AWARENESS — not from the moment the engine emits a
2413
+ * `detected` classification. Pre-fix, this auto-stamped Date.now() on
2414
+ * detect_confirmed whenever the engine classified as detected, which is
2415
+ * incorrect: the operator may not have seen the result yet. The corrected
2416
+ * semantics:
2417
+ *
2418
+ * - If the agent explicitly submits clock_started_at_<event>: use it.
2419
+ * - Otherwise, for 'detect_confirmed' with classification='detected':
2420
+ * stamp `now` ONLY if runOpts.operator_consent?.explicit === true
2421
+ * (i.e. the operator passed --ack). Without --ack, return null and
2422
+ * the caller (close()) surfaces clock_pending_ack: true on the
2423
+ * notification_actions entry so the operator sees that the clock is
2424
+ * waiting on acknowledgement.
2425
+ * - All other events without an explicit timestamp: return null.
2426
+ */
2427
+ function computeClockStart(eventName, agentSignals, runOpts = {}) {
1402
2428
  // The agent submits clock_started_at_<event> ISO strings as it progresses.
1403
2429
  const key = `clock_started_at_${eventName}`;
1404
- if (agentSignals[key]) return new Date(agentSignals[key]);
1405
- // Fallback: use the standard 'detect_confirmed' default of "now" for the
1406
- // most common case so notification deadlines aren't always pending.
1407
- if (eventName === 'detect_confirmed' && agentSignals.detection_classification === 'detected') {
2430
+ if (agentSignals && agentSignals[key]) return new Date(agentSignals[key]);
2431
+ // For detect_confirmed: only auto-stamp when the operator has explicitly
2432
+ // acknowledged the result via --ack. Otherwise leave the clock pending.
2433
+ if (eventName === 'detect_confirmed' && agentSignals?.detection_classification === 'detected'
2434
+ && runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
1408
2435
  return new Date();
1409
2436
  }
1410
2437
  return null;
@@ -1416,11 +2443,25 @@ function expressionKey(expr) {
1416
2443
  return m ? m[1] : expr;
1417
2444
  }
1418
2445
 
1419
- function interpolate(tpl, ctx) {
2446
+ /**
2447
+ * Substitute ${var} placeholders against ctx. F14: pre-fix, missing keys
2448
+ * silently re-emitted the literal `${var}` placeholder, so notification
2449
+ * drafts could ship to regulators with `${cisa_kev_due_date}` rendered as
2450
+ * the raw template — a visible failure that operators wouldn't catch
2451
+ * before sending. Now: render as `<MISSING:${var}>` so the failure mode
2452
+ * is loud, AND if a tracker array is passed as the third argument,
2453
+ * collect the missing keys for caller surfacing as
2454
+ * missing_interpolation_vars[].
2455
+ */
2456
+ function interpolate(tpl, ctx, missingTracker) {
1420
2457
  if (!tpl || typeof tpl !== 'string') return tpl;
1421
2458
  return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
1422
- const v = ctx[key];
1423
- return v !== undefined && v !== null ? String(v) : `\${${key}}`;
2459
+ const v = ctx ? ctx[key] : undefined;
2460
+ if (v !== undefined && v !== null) return String(v);
2461
+ if (missingTracker && Array.isArray(missingTracker) && !missingTracker.includes(key)) {
2462
+ missingTracker.push(key);
2463
+ }
2464
+ return `<MISSING:${key}>`;
1424
2465
  });
1425
2466
  }
1426
2467