@blamejs/exceptd-skills 0.12.13 → 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 (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/bin/exceptd.js +147 -9
  3. package/data/_indexes/_meta.json +45 -45
  4. package/data/_indexes/activity-feed.json +4 -4
  5. package/data/_indexes/catalog-summaries.json +29 -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 +319 -76
  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 +18 -5
  23. package/lib/lint-skills.js +6 -1
  24. package/lib/playbook-runner.js +742 -78
  25. package/lib/refresh-external.js +40 -22
  26. package/lib/refresh-network.js +193 -17
  27. package/lib/scoring.js +20 -7
  28. package/lib/source-ghsa.js +219 -37
  29. package/lib/source-osv.js +381 -122
  30. package/lib/validate-catalog-meta.js +64 -9
  31. package/lib/validate-cve-catalog.js +56 -18
  32. package/lib/validate-indexes.js +88 -37
  33. package/lib/verify.js +72 -0
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest-snapshot.sha256 +1 -0
  36. package/manifest.json +73 -73
  37. package/orchestrator/dispatcher.js +21 -1
  38. package/orchestrator/event-bus.js +52 -8
  39. package/orchestrator/index.js +279 -20
  40. package/orchestrator/pipeline.js +63 -2
  41. package/orchestrator/scanner.js +32 -10
  42. package/orchestrator/scheduler.js +150 -17
  43. package/package.json +3 -1
  44. package/sbom.cdx.json +7 -7
  45. package/scripts/check-manifest-snapshot.js +32 -0
  46. package/scripts/check-sbom-currency.js +65 -3
  47. package/scripts/check-test-coverage.js +142 -19
  48. package/scripts/predeploy.js +83 -39
  49. package/scripts/refresh-manifest-snapshot.js +55 -4
  50. package/scripts/validate-vendor-online.js +169 -0
  51. package/scripts/verify-shipped-tarball.js +106 -3
  52. package/skills/ai-attack-surface/skill.md +18 -10
  53. package/skills/ai-c2-detection/skill.md +7 -2
  54. package/skills/ai-risk-management/skill.md +5 -4
  55. package/skills/api-security/skill.md +3 -3
  56. package/skills/attack-surface-pentest/skill.md +5 -5
  57. package/skills/cloud-security/skill.md +1 -1
  58. package/skills/compliance-theater/skill.md +8 -8
  59. package/skills/container-runtime-security/skill.md +1 -1
  60. package/skills/dlp-gap-analysis/skill.md +5 -1
  61. package/skills/email-security-anti-phishing/skill.md +1 -1
  62. package/skills/exploit-scoring/skill.md +18 -18
  63. package/skills/framework-gap-analysis/skill.md +6 -6
  64. package/skills/global-grc/skill.md +3 -2
  65. package/skills/identity-assurance/skill.md +2 -2
  66. package/skills/incident-response-playbook/skill.md +4 -4
  67. package/skills/kernel-lpe-triage/skill.md +21 -2
  68. package/skills/mcp-agent-trust/skill.md +17 -10
  69. package/skills/mlops-security/skill.md +2 -1
  70. package/skills/ot-ics-security/skill.md +1 -1
  71. package/skills/policy-exception-gen/skill.md +3 -3
  72. package/skills/pqc-first/skill.md +1 -1
  73. package/skills/rag-pipeline-security/skill.md +7 -3
  74. package/skills/researcher/skill.md +20 -3
  75. package/skills/sector-energy/skill.md +1 -1
  76. package/skills/sector-federal-government/skill.md +1 -1
  77. package/skills/sector-financial/skill.md +3 -3
  78. package/skills/sector-healthcare/skill.md +2 -2
  79. package/skills/security-maturity-tiers/skill.md +7 -7
  80. package/skills/skill-update-loop/skill.md +19 -3
  81. package/skills/supply-chain-integrity/skill.md +1 -1
  82. package/skills/threat-model-currency/skill.md +11 -11
  83. package/skills/threat-modeling-methodology/skill.md +3 -3
  84. package/skills/webapp-security/skill.md +1 -1
  85. package/skills/zeroday-gap-learn/skill.md +51 -7
  86. package/vendor/blamejs/_PROVENANCE.json +4 -1
  87. 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');
@@ -234,8 +266,18 @@ function preflight(playbook, runOpts = {}) {
234
266
  return { ok: true, issues };
235
267
  }
236
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.
237
278
  function lockDir() {
238
- 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}`);
239
281
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
240
282
  return dir;
241
283
  }
@@ -275,6 +317,15 @@ function pidAlive(pid) {
275
317
  function govern(playbookId, directiveId, runOpts = {}) {
276
318
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
277
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
+ });
278
329
  return {
279
330
  phase: 'govern',
280
331
  playbook_id: playbookId,
@@ -283,7 +334,7 @@ function govern(playbookId, directiveId, runOpts = {}) {
283
334
  threat_currency_score: playbook._meta.threat_currency_score,
284
335
  last_threat_review: playbook._meta.last_threat_review,
285
336
  air_gap_mode: !!playbook._meta.air_gap_mode || !!runOpts.airGap,
286
- jurisdiction_obligations: g.jurisdiction_obligations || [],
337
+ jurisdiction_obligations: obligations,
287
338
  theater_fingerprints: g.theater_fingerprints || [],
288
339
  framework_context: g.framework_context || {},
289
340
  skill_preload: g.skill_preload || [],
@@ -517,6 +568,13 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
517
568
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
518
569
  const an = resolvedPhase(playbook, directiveId, 'analyze');
519
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
+ }
520
578
 
521
579
  // Resolve catalogued CVEs from the domain.cve_refs list. This list is the
522
580
  // playbook's CVE scan-coverage enumeration — every CVE this playbook can
@@ -552,13 +610,36 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
552
610
  const cveRefs = playbook.domain.cve_refs || [];
553
611
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
554
612
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
555
- 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);
556
632
  const catalogBaselineCves = vexFilter
557
633
  ? allCves.filter(c => !vexFilter.has(c.cve_id))
558
634
  : allCves;
559
635
  const vexDropped = vexFilter
560
636
  ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
561
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
+ : [];
562
643
 
563
644
  // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
564
645
  const correlationsByCve = new Map();
@@ -591,64 +672,254 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
591
672
  }
592
673
  }
593
674
 
594
- 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));
595
708
 
596
709
  // Per-CVE shape — identical between matched_cves and catalog_baseline_cves
597
710
  // so consumers can iterate either without branching. matched_cves entries
598
711
  // carry a non-null correlated_via array; catalog_baseline_cves entries
599
712
  // carry correlated_via:null and a `note` clarifying the field's intent.
600
- const cveShape = (c, correlatedVia) => ({
601
- cve_id: c.cve_id,
602
- rwep: c.rwep_score,
603
- cvss_score: c.entry?.cvss_score ?? null,
604
- cvss_vector: c.entry?.cvss_vector ?? null,
605
- cisa_kev: c.cisa_kev,
606
- cisa_kev_date: c.entry?.cisa_kev_date ?? null,
607
- cisa_kev_due_date: c.entry?.cisa_kev_due_date ?? null,
608
- poc_available: c.entry?.poc_available ?? null,
609
- ai_discovered: c.ai_discovered,
610
- ai_assisted_weaponization: c.entry?.ai_assisted_weaponization ?? null,
611
- active_exploitation: c.active_exploitation,
612
- patch_available: c.entry?.patch_available ?? null,
613
- patch_required_reboot: c.entry?.patch_required_reboot ?? null,
614
- live_patch_available: c.entry?.live_patch_available ?? null,
615
- epss_score: c.entry?.epss_score ?? null,
616
- epss_date: c.entry?.epss_date ?? null,
617
- atlas_refs: c.atlas_refs,
618
- attack_refs: c.attack_refs,
619
- affected_versions: c.entry?.affected_versions ?? null,
620
- correlated_via: correlatedVia,
621
- });
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
+ };
622
742
 
623
743
  const matchedCveEntries = matchedCves.map(c => cveShape(c, correlationsByCve.get(c.cve_id)));
624
- const catalogBaselineEntries = catalogBaselineCves.map(c => ({
744
+ const catalogBaselineEntries = workingCatalogCves.map(c => ({
625
745
  ...cveShape(c, null),
626
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.',
627
747
  }));
628
748
 
629
749
  // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
630
750
  // matches (NOT catalog baseline) so RWEP base reflects what the operator's
631
- // evidence actually surfaced. Adjust by playbook's rwep_inputs based on
632
- // detect hits + agent signals.
633
- 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;
634
880
  let adjustedRwep = baseRwep;
635
881
  const rwepBreakdown = [];
636
882
  for (const input of an.rwep_inputs || []) {
637
883
  const indicator = detectResult.indicators?.find(i => i.id === input.signal_id);
638
884
  const fired = indicator?.verdict === 'hit' || agentSignals[input.signal_id] === true;
639
- if (fired) {
640
- adjustedRwep += input.weight;
641
- 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';
642
905
  } else {
643
- 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;
644
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
+ });
645
920
  }
646
921
  adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
647
922
 
648
- // blast_radius
649
- const blastRubric = an.blast_radius_model?.scoring_rubric || [];
650
- const blastRadiusScore = agentSignals.blast_radius_score || (blastRubric[0]?.blast_radius_score ?? null);
651
-
652
923
  // compliance_theater_check — engine surfaces the test; agent runs it; we
653
924
  // accept the verdict in agentSignals.theater_verdict. When agent didn't
654
925
  // submit a verdict but the detect phase reached a clear classification,
@@ -658,8 +929,25 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
658
929
  // (agent still must run reality_test)
659
930
  // detect.classification = inconclusive → theater_verdict = pending_agent_run
660
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']);
661
939
  let theaterVerdict = agentSignals.theater_verdict;
662
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
+ }
663
951
  if (!theaterVerdict && an.compliance_theater_check) {
664
952
  const cls = detectResult.classification;
665
953
  theaterVerdict = cls === 'not_detected' ? 'clear' : 'pending_agent_run';
@@ -702,15 +990,27 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
702
990
  // matched_cves when surfacing "what CVEs is the operator actually
703
991
  // affected by based on submitted evidence?"
704
992
  catalog_baseline_cves: catalogBaselineEntries,
705
- 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' },
706
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,
707
1003
  blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
708
1004
  compliance_theater_check: {
709
1005
  claim: an.compliance_theater_check?.claim,
710
1006
  audit_evidence: an.compliance_theater_check?.audit_evidence,
711
1007
  reality_test: an.compliance_theater_check?.reality_test,
712
1008
  verdict: theaterVerdict,
713
- 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
714
1014
  },
715
1015
  framework_gap_mapping: frameworkGaps,
716
1016
  escalations,
@@ -743,28 +1043,51 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
743
1043
  }
744
1044
 
745
1045
  /**
746
- * Extract a set of "not affected" CVE IDs from a VEX document. Supports
747
- * CycloneDX VEX (analysis.state in {not_affected, resolved, false_positive})
748
- * 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().
749
1063
  */
750
1064
  function vexFilterFromDoc(doc) {
751
1065
  const out = new Set();
752
- 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
+ }
753
1071
 
754
- // CycloneDX shape
1072
+ // CycloneDX shape — analysis.state values per CycloneDX VEX spec:
1073
+ // not_affected / false_positive → drop
1074
+ // resolved → fixed-annotation
755
1075
  for (const v of (doc.vulnerabilities || [])) {
756
1076
  const state = v.analysis && v.analysis.state;
757
- if (state === 'not_affected' || state === 'resolved' || state === 'false_positive') {
1077
+ if (state === 'not_affected' || state === 'false_positive') {
758
1078
  if (v.id) out.add(v.id);
1079
+ } else if (state === 'resolved') {
1080
+ if (v.id) fixed.add(v.id);
759
1081
  }
760
1082
  }
761
1083
  // OpenVEX shape
762
1084
  for (const s of (doc.statements || [])) {
763
- if (s.status === 'not_affected' || s.status === 'fixed') {
764
- const id = s.vulnerability && (s.vulnerability['@id'] || s.vulnerability.name || s.vulnerability);
765
- if (typeof id === 'string') out.add(id);
766
- }
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);
767
1089
  }
1090
+ out.fixed = fixed;
768
1091
  return out;
769
1092
  }
770
1093
 
@@ -796,9 +1119,42 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
796
1119
  // weren't verified — the agent can surface that to the operator.
797
1120
  if (!selected && paths.length) selected = paths[0];
798
1121
 
799
- // 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.
800
1141
  const triggers = v.regression_trigger || [];
801
- 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
+ }
802
1158
 
803
1159
  return {
804
1160
  phase: 'validate',
@@ -810,21 +1166,71 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
810
1166
  residual_risk_statement: v.residual_risk_statement || null,
811
1167
  evidence_requirements: v.evidence_requirements || [],
812
1168
  regression_trigger: triggers,
813
- 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,
814
1173
  };
815
1174
  }
816
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
+
817
1211
  function computeRegressionNextRun(triggers) {
818
1212
  const now = new Date();
819
1213
  let soonest = null;
1214
+ const eventTriggers = [];
1215
+ const unparseable = [];
820
1216
  for (const t of triggers) {
821
- const m = (t.interval || '').match(/^(\d+)d$/);
822
- if (m) {
823
- const d = new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000);
824
- 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;
1222
+ }
1223
+ if (parsed.unparseable) {
1224
+ unparseable.push({ interval: parsed.unparseable, trigger: t.trigger || null });
1225
+ continue;
825
1226
  }
1227
+ if (parsed.date && (!soonest || parsed.date < soonest)) soonest = parsed.date;
826
1228
  }
827
- return soonest ? soonest.toISOString() : null;
1229
+ return {
1230
+ next_run: soonest ? soonest.toISOString() : null,
1231
+ event_triggers: eventTriggers,
1232
+ unparseable: unparseable,
1233
+ };
828
1234
  }
829
1235
 
830
1236
  // --- phase 7: close ---
@@ -842,6 +1248,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
842
1248
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
843
1249
  const c = resolvedPhase(playbook, directiveId, 'close');
844
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).
845
1258
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
846
1259
 
847
1260
  // notification_actions — compute ISO deadlines from clock_starts events.
@@ -888,7 +1301,30 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
888
1301
  // Evidence the regulator expects attached (from the obligation, not
889
1302
  // just the operator-facing recipient bundle on the notification entry).
890
1303
  evidence_required: obligation?.evidence_required || na.evidence_attached || [],
891
- 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
+ })(),
892
1328
  };
893
1329
  });
894
1330
 
@@ -1000,7 +1436,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1000
1436
  jurisdiction_clocks_count: notificationActions.filter(n => n && n.clock_started_at != null).length,
1001
1437
  exception: exception,
1002
1438
  regression_schedule: regressionSchedule,
1003
- 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,
1004
1446
  };
1005
1447
  }
1006
1448
 
@@ -1021,19 +1463,44 @@ function worstActiveExploitation(matchedCves) {
1021
1463
  return worst || 'unknown';
1022
1464
  }
1023
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
+
1024
1482
  function analyzeFindingShape(a) {
1483
+ const matched = a.matched_cves || [];
1484
+ const rwepAdjusted = a.rwep?.adjusted ?? 0;
1025
1485
  return {
1026
- matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
1027
- matched_cve_count: (a.matched_cves || []).length,
1028
- kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
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,
1029
1494
  // E8: previously this used .find() which returned the first matched CVE
1030
1495
  // with a truthy active_exploitation. With two CVEs where #1 is
1031
1496
  // 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
1032
1497
  // notification drafts — under-stating the threat. Now reduce to the
1033
1498
  // worst rank across all matched CVEs.
1034
- active_exploitation: worstActiveExploitation(a.matched_cves),
1035
- rwep_adjusted: a.rwep?.adjusted ?? 0,
1499
+ active_exploitation: worstActiveExploitation(matched),
1500
+ rwep_adjusted: rwepAdjusted,
1036
1501
  rwep_base: a.rwep?.base ?? 0,
1502
+ // F4: severity surface for playbook conditions.
1503
+ severity: severityForRwep(rwepAdjusted),
1037
1504
  blast_radius_score: a.blast_radius_score ?? 0,
1038
1505
  framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
1039
1506
  control_id_first: a.framework_gap_mapping?.[0]?.claimed_control || null
@@ -1137,7 +1604,13 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1137
1604
  publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
1138
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))`,
1139
1606
  tracking: {
1140
- 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}`,
1141
1614
  status: 'final',
1142
1615
  version: playbook._meta.version,
1143
1616
  initial_release_date: now,
@@ -1323,7 +1796,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1323
1796
  });
1324
1797
  return {
1325
1798
  '@context': 'https://openvex.dev/ns/v0.2.0',
1326
- '@id': `https://exceptd.com/vex/${playbookSlug}/${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')}`,
1327
1804
  author: 'exceptd',
1328
1805
  timestamp: issued,
1329
1806
  version: 1,
@@ -1369,7 +1846,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1369
1846
  return { format: 'markdown', body: lines.join('\n') };
1370
1847
  }
1371
1848
 
1372
- 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
+ };
1373
1859
  }
1374
1860
 
1375
1861
  // --- orchestrate: full run in one call ---
@@ -1389,6 +1875,22 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1389
1875
  function normalizeSubmission(submission, playbook) {
1390
1876
  if (!submission || typeof submission !== "object") return submission || {};
1391
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
+
1392
1894
  // v0.11.3 #71 fix: the CLI may inject `signals._bundle_formats` before
1393
1895
  // calling normalize (for --format <fmt> support). Pre-0.11.3 normalize
1394
1896
  // detected the injected `signals` key and bailed, leaving the flat
@@ -1510,7 +2012,41 @@ function autoDetectPreconditions(submission, playbook) {
1510
2012
  }
1511
2013
 
1512
2014
  function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1513
- 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
+ }
1514
2050
 
1515
2051
  // v0.11.0: accept flat submission shape (observations + verdict). Normalize
1516
2052
  // to the engine's internal nested shape before preflight/detect. Smart
@@ -1518,11 +2054,33 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1518
2054
  // / the host platform matches — the runner can answer those itself rather
1519
2055
  // than blocking on AI declaration.
1520
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 || {}) };
1521
2060
  agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
1522
2061
 
1523
- 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 });
1524
2082
  if (!pre.ok) {
1525
- 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 };
1526
2084
  }
1527
2085
 
1528
2086
  _activeRuns.add(playbookId);
@@ -1533,7 +2091,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1533
2091
  // through each phase via runOpts._playbookCache. Each phase otherwise calls
1534
2092
  // loadPlaybook() independently; for a single run that's seven reads + parses
1535
2093
  // of the same file. Cached version saves the redundant I/O + JSON parses.
1536
- const cachedRunOpts = { ...runOpts, _playbookCache: playbook };
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 };
1537
2103
  // E3: run-time error accumulator for evalCondition regex failures and other
1538
2104
  // non-fatal anomalies surfaced into analyze.runtime_errors[].
1539
2105
  const runErrors = [];
@@ -1594,13 +2160,27 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1594
2160
  }
1595
2161
  }
1596
2162
 
1597
- 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');
1598
2177
  const evidenceHash = crypto.createHash('sha256')
1599
2178
  .update(JSON.stringify({
1600
2179
  playbookId, directiveId,
1601
2180
  cves: phases.analyze.matched_cves.map(c => c.cve_id),
1602
2181
  rwep: phases.analyze.rwep.adjusted,
1603
- classification: phases.detect.classification
2182
+ classification: phases.detect.classification,
2183
+ submission_digest: submissionDigest,
1604
2184
  }))
1605
2185
  .digest('hex');
1606
2186
 
@@ -1610,7 +2190,11 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1610
2190
  directive_id: directiveId,
1611
2191
  session_id: sessionId,
1612
2192
  evidence_hash: evidenceHash,
2193
+ submission_digest: submissionDigest,
1613
2194
  preflight_issues: pre.issues,
2195
+ // F22: source provenance for precondition_checks. Shape:
2196
+ // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
2197
+ precondition_check_source: pcSource,
1614
2198
  phases
1615
2199
  };
1616
2200
  } finally {
@@ -1621,6 +2205,72 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1621
2205
 
1622
2206
  // --- helpers ---
1623
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
+
1624
2274
  function evalCondition(expr, ctx, playbook) {
1625
2275
  if (!expr) return false;
1626
2276
  expr = expr.trim();
@@ -1793,11 +2443,25 @@ function expressionKey(expr) {
1793
2443
  return m ? m[1] : expr;
1794
2444
  }
1795
2445
 
1796
- 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) {
1797
2457
  if (!tpl || typeof tpl !== 'string') return tpl;
1798
2458
  return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
1799
- const v = ctx[key];
1800
- 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}>`;
1801
2465
  });
1802
2466
  }
1803
2467