@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.
- package/CHANGELOG.md +150 -0
- package/bin/exceptd.js +147 -9
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +18 -5
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +193 -17
- package/lib/scoring.js +20 -7
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/playbook-runner.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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 =
|
|
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.
|
|
632
|
-
//
|
|
633
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
*
|
|
748
|
-
*
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1027
|
-
|
|
1028
|
-
|
|
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(
|
|
1035
|
-
rwep_adjusted:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|