@blamejs/exceptd-skills 0.9.4 → 0.10.0
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/AGENTS.md +45 -0
- package/CHANGELOG.md +131 -0
- package/README.md +30 -5
- package/bin/exceptd.js +403 -1
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/currency.json +138 -138
- package/data/playbooks/ai-api.json +763 -0
- package/data/playbooks/containers.json +766 -0
- package/data/playbooks/cred-stores.json +715 -0
- package/data/playbooks/crypto.json +726 -0
- package/data/playbooks/framework.json +725 -0
- package/data/playbooks/hardening.json +672 -0
- package/data/playbooks/kernel.json +549 -0
- package/data/playbooks/mcp.json +727 -0
- package/data/playbooks/runtime.json +649 -0
- package/data/playbooks/sbom.json +893 -0
- package/data/playbooks/secrets.json +690 -0
- package/lib/cross-ref-api.js +224 -0
- package/lib/playbook-runner.js +826 -0
- package/lib/schemas/playbook.schema.json +652 -0
- package/lib/verify.js +45 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/dispatcher.js +13 -1
- package/orchestrator/index.js +119 -5
- package/orchestrator/pipeline.js +8 -2
- package/orchestrator/scanner.js +191 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/builders/currency.js +5 -3
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Playbook runner — executes the seven-phase investigation contract defined in
|
|
5
|
+
* lib/schemas/playbook.schema.json:
|
|
6
|
+
*
|
|
7
|
+
* 1. govern exceptd. Loads GRC context: jurisdiction obligations, theater
|
|
8
|
+
* fingerprints, framework gaps, skills to preload. Sets the
|
|
9
|
+
* compliance lens before any investigation runs.
|
|
10
|
+
* 2. direct exceptd. Scopes the investigation: threat context with current
|
|
11
|
+
* CVE/TTP citations, RWEP thresholds, framework lag declaration,
|
|
12
|
+
* skill chain, token budget.
|
|
13
|
+
* 3. look host AI. Collects typed artifacts (logs/files/processes/
|
|
14
|
+
* network/etc.) per artifact spec, with air-gap fallbacks.
|
|
15
|
+
* 4. detect host AI. Evaluates artifacts against typed indicators, applies
|
|
16
|
+
* false-positive profile, classifies as detected | inconclusive
|
|
17
|
+
* | not_detected.
|
|
18
|
+
* 5. analyze exceptd. Computes RWEP from rwep_inputs, scores blast radius,
|
|
19
|
+
* runs compliance_theater_check, generates framework_gap_mapping
|
|
20
|
+
* entries, fires escalation_criteria.
|
|
21
|
+
* 6. validate exceptd. Picks remediation_path by priority + preconditions,
|
|
22
|
+
* emits validation_tests, renders residual_risk_statement, lists
|
|
23
|
+
* evidence_requirements, computes regression schedule.
|
|
24
|
+
* 7. close exceptd. Closes the GRC loop: assembles evidence_package
|
|
25
|
+
* (signed by default), drafts learning_loop lesson, computes
|
|
26
|
+
* notification_actions deadlines from govern.jurisdiction_obligations
|
|
27
|
+
* clock_starts + window_hours, evaluates exception_generation
|
|
28
|
+
* trigger and renders auditor-ready language, finalizes
|
|
29
|
+
* regression_schedule.next_run.
|
|
30
|
+
*
|
|
31
|
+
* Currency gate: _meta.threat_currency_score < 50 hard-blocks execution unless
|
|
32
|
+
* the caller passes { forceStale: true }. Below 70 warns. The schema declares
|
|
33
|
+
* the score; the runner enforces.
|
|
34
|
+
*
|
|
35
|
+
* Preconditions: each _meta.preconditions entry has on_fail = halt|warn|skip_phase.
|
|
36
|
+
* Engine evaluates the (host AI-supplied) check value and reacts accordingly.
|
|
37
|
+
*
|
|
38
|
+
* Mutex: an in-process Set tracks active playbook runs. Engine refuses to start
|
|
39
|
+
* a playbook whose _meta.mutex intersects active runs.
|
|
40
|
+
*
|
|
41
|
+
* feeds_into: close() returns a list of downstream playbook IDs whose
|
|
42
|
+
* conditions are satisfied by this run's finding — the agent decides whether
|
|
43
|
+
* to chain into them.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const crypto = require('crypto');
|
|
49
|
+
|
|
50
|
+
const xref = require('./cross-ref-api');
|
|
51
|
+
|
|
52
|
+
const ROOT = path.join(__dirname, '..');
|
|
53
|
+
const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data', 'playbooks');
|
|
54
|
+
|
|
55
|
+
// In-process mutex tracker. Survives only the current Node process.
|
|
56
|
+
// Persistent cross-process coordination is out of scope — that's for the GRC
|
|
57
|
+
// platform integration, not the runner.
|
|
58
|
+
const _activeRuns = new Set();
|
|
59
|
+
|
|
60
|
+
// --- catalog access ---
|
|
61
|
+
|
|
62
|
+
function listPlaybooks() {
|
|
63
|
+
if (!fs.existsSync(PLAYBOOK_DIR)) return [];
|
|
64
|
+
return fs.readdirSync(PLAYBOOK_DIR)
|
|
65
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('_'))
|
|
66
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadPlaybook(playbookId) {
|
|
70
|
+
const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
|
|
71
|
+
if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
|
|
72
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findDirective(playbook, directiveId) {
|
|
76
|
+
const d = playbook.directives.find(x => x.id === directiveId);
|
|
77
|
+
if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
|
|
78
|
+
return d;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- phase-resolution: merge playbook.phases with directive.phase_overrides ---
|
|
82
|
+
|
|
83
|
+
function resolvedPhase(playbook, directiveId, phaseName) {
|
|
84
|
+
const base = playbook.phases[phaseName] || {};
|
|
85
|
+
const directive = playbook.directives.find(x => x.id === directiveId);
|
|
86
|
+
const override = directive?.phase_overrides?.[phaseName];
|
|
87
|
+
if (!override) return base;
|
|
88
|
+
return deepMerge(base, override);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function deepMerge(a, b) {
|
|
92
|
+
if (b === null || b === undefined) return a;
|
|
93
|
+
if (typeof b !== 'object' || Array.isArray(b)) return b;
|
|
94
|
+
const out = { ...a };
|
|
95
|
+
for (const [k, v] of Object.entries(b)) {
|
|
96
|
+
out[k] = (k in out) ? deepMerge(out[k], v) : v;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- pre-flight: currency + preconditions + mutex ---
|
|
102
|
+
|
|
103
|
+
function preflight(playbook, runOpts = {}) {
|
|
104
|
+
const issues = [];
|
|
105
|
+
const meta = playbook._meta;
|
|
106
|
+
|
|
107
|
+
// 1. Currency gate
|
|
108
|
+
const score = meta.threat_currency_score;
|
|
109
|
+
if (score < 50 && !runOpts.forceStale) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
blocked_by: 'currency',
|
|
113
|
+
reason: `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`,
|
|
114
|
+
issues
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (score < 70) {
|
|
118
|
+
issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Preconditions
|
|
122
|
+
for (const pc of meta.preconditions || []) {
|
|
123
|
+
const submitted = runOpts.precondition_checks?.[pc.id];
|
|
124
|
+
if (submitted === undefined) {
|
|
125
|
+
issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail });
|
|
126
|
+
if (pc.on_fail === 'halt') {
|
|
127
|
+
return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; on_fail=halt.`, issues };
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (submitted === false) {
|
|
132
|
+
if (pc.on_fail === 'halt') {
|
|
133
|
+
return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
|
|
134
|
+
}
|
|
135
|
+
issues.push({ kind: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn', id: pc.id, message: pc.description });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. Mutex
|
|
140
|
+
for (const conflictId of meta.mutex || []) {
|
|
141
|
+
if (_activeRuns.has(conflictId)) {
|
|
142
|
+
return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict: playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: true, issues };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- phase 1: govern ---
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Load GRC context for the agent. Returns jurisdiction obligations (with
|
|
153
|
+
* window_hours + clock_starts so close() can compute deadlines later), theater
|
|
154
|
+
* fingerprints, framework gap summary, and skills to preload.
|
|
155
|
+
*/
|
|
156
|
+
function govern(playbookId, directiveId, runOpts = {}) {
|
|
157
|
+
const playbook = loadPlaybook(playbookId);
|
|
158
|
+
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
159
|
+
return {
|
|
160
|
+
phase: 'govern',
|
|
161
|
+
playbook_id: playbookId,
|
|
162
|
+
directive_id: directiveId,
|
|
163
|
+
domain: playbook.domain,
|
|
164
|
+
threat_currency_score: playbook._meta.threat_currency_score,
|
|
165
|
+
last_threat_review: playbook._meta.last_threat_review,
|
|
166
|
+
air_gap_mode: !!playbook._meta.air_gap_mode || !!runOpts.airGap,
|
|
167
|
+
jurisdiction_obligations: g.jurisdiction_obligations || [],
|
|
168
|
+
theater_fingerprints: g.theater_fingerprints || [],
|
|
169
|
+
framework_context: g.framework_context || {},
|
|
170
|
+
skill_preload: g.skill_preload || []
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- phase 2: direct ---
|
|
175
|
+
|
|
176
|
+
function direct(playbookId, directiveId) {
|
|
177
|
+
const playbook = loadPlaybook(playbookId);
|
|
178
|
+
const d = resolvedPhase(playbook, directiveId, 'direct');
|
|
179
|
+
return {
|
|
180
|
+
phase: 'direct',
|
|
181
|
+
playbook_id: playbookId,
|
|
182
|
+
directive_id: directiveId,
|
|
183
|
+
threat_context: d.threat_context,
|
|
184
|
+
rwep_threshold: d.rwep_threshold,
|
|
185
|
+
framework_lag_declaration: d.framework_lag_declaration,
|
|
186
|
+
skill_chain: d.skill_chain || [],
|
|
187
|
+
token_budget: d.token_budget || {}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- phase 3: look (engine emits, agent executes) ---
|
|
192
|
+
|
|
193
|
+
function look(playbookId, directiveId, runOpts = {}) {
|
|
194
|
+
const playbook = loadPlaybook(playbookId);
|
|
195
|
+
const l = resolvedPhase(playbook, directiveId, 'look');
|
|
196
|
+
const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
|
|
197
|
+
return {
|
|
198
|
+
phase: 'look',
|
|
199
|
+
playbook_id: playbookId,
|
|
200
|
+
directive_id: directiveId,
|
|
201
|
+
air_gap_mode: airGap,
|
|
202
|
+
artifacts: (l.artifacts || []).map(a => ({
|
|
203
|
+
...a,
|
|
204
|
+
// Surface the air-gap alternative as the primary source when air_gap_mode
|
|
205
|
+
// is active, so the agent doesn't accidentally hit the network.
|
|
206
|
+
source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
|
|
207
|
+
_original_source: a.source
|
|
208
|
+
})),
|
|
209
|
+
collection_scope: l.collection_scope,
|
|
210
|
+
environment_assumptions: l.environment_assumptions || [],
|
|
211
|
+
fallback_if_unavailable: l.fallback_if_unavailable || []
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- phase 4: detect ---
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Evaluate artifacts the agent submitted against the playbook's typed
|
|
219
|
+
* indicators. Returns a per-indicator hit/miss/inconclusive verdict plus a
|
|
220
|
+
* minimum_signal classification (detected | inconclusive | not_detected).
|
|
221
|
+
*
|
|
222
|
+
* The agent submits `artifacts` as { artifact_id: { value, captured: true|false, reason? } }
|
|
223
|
+
* and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
|
|
224
|
+
* record an indicator outcome the agent computed using its own pattern matching.
|
|
225
|
+
*/
|
|
226
|
+
function detect(playbookId, directiveId, agentSubmission = {}) {
|
|
227
|
+
const playbook = loadPlaybook(playbookId);
|
|
228
|
+
const det = resolvedPhase(playbook, directiveId, 'detect');
|
|
229
|
+
const artifacts = agentSubmission.artifacts || {};
|
|
230
|
+
const overrides = agentSubmission.signal_overrides || {};
|
|
231
|
+
|
|
232
|
+
const indicatorResults = (det.indicators || []).map(ind => {
|
|
233
|
+
const override = overrides[ind.id];
|
|
234
|
+
let verdict;
|
|
235
|
+
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
236
|
+
verdict = override;
|
|
237
|
+
} else {
|
|
238
|
+
// Without an explicit override, treat any captured artifact as evidence
|
|
239
|
+
// the indicator could be evaluated. Mark inconclusive if no related
|
|
240
|
+
// artifact was captured — engine doesn't pattern-match raw artifact
|
|
241
|
+
// content; the host AI is responsible for that.
|
|
242
|
+
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
243
|
+
verdict = anyCaptured ? 'inconclusive' : 'inconclusive';
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
id: ind.id, type: ind.type, confidence: ind.confidence,
|
|
247
|
+
deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
|
|
248
|
+
attack_ref: ind.attack_ref || null, verdict
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// false-positive profile — engine highlights which FP tests the agent
|
|
253
|
+
// should still run against any indicator the agent reported as 'hit'.
|
|
254
|
+
const fpChecksRequired = (det.false_positive_profile || []).filter(fp =>
|
|
255
|
+
indicatorResults.find(r => r.id === fp.indicator_id && r.verdict === 'hit')
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const hits = indicatorResults.filter(r => r.verdict === 'hit');
|
|
259
|
+
const hasDeterministicHit = hits.some(r => r.deterministic);
|
|
260
|
+
const hasHighConfHit = hits.some(r => r.confidence === 'high' || r.confidence === 'deterministic');
|
|
261
|
+
|
|
262
|
+
let classification;
|
|
263
|
+
if (hasDeterministicHit || hasHighConfHit) {
|
|
264
|
+
classification = 'detected';
|
|
265
|
+
} else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
|
|
266
|
+
classification = 'not_detected';
|
|
267
|
+
} else {
|
|
268
|
+
classification = 'inconclusive';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
phase: 'detect',
|
|
273
|
+
playbook_id: playbookId,
|
|
274
|
+
directive_id: directiveId,
|
|
275
|
+
indicators: indicatorResults,
|
|
276
|
+
false_positive_checks_required: fpChecksRequired,
|
|
277
|
+
classification,
|
|
278
|
+
minimum_signal_basis: det.minimum_signal?.[classification === 'detected' ? 'detected' : classification === 'not_detected' ? 'not_detected' : 'inconclusive']
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- phase 5: analyze ---
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* RWEP composition + blast-radius scoring + theater check + framework gap
|
|
286
|
+
* mapping + escalation evaluation. Inputs are the detect result + any
|
|
287
|
+
* agent-submitted signal_values (e.g. blast_radius classification).
|
|
288
|
+
*/
|
|
289
|
+
function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
|
|
290
|
+
const playbook = loadPlaybook(playbookId);
|
|
291
|
+
const an = resolvedPhase(playbook, directiveId, 'analyze');
|
|
292
|
+
const directive = findDirective(playbook, directiveId);
|
|
293
|
+
|
|
294
|
+
// Match catalogued CVEs from the domain.cve_refs list. The agent submits
|
|
295
|
+
// signal values; engine joins to the catalog for RWEP context.
|
|
296
|
+
const cveRefs = playbook.domain.cve_refs || [];
|
|
297
|
+
const matchedCves = cveRefs
|
|
298
|
+
.map(id => xref.byCve(id))
|
|
299
|
+
.filter(r => r.found);
|
|
300
|
+
|
|
301
|
+
// RWEP composition: start from the catalogue's per-CVE rwep_score (already
|
|
302
|
+
// baked from KEV + PoC + AI-disc + active-exploitation + blast-radius), then
|
|
303
|
+
// adjust by playbook's rwep_inputs based on detect hits + agent signals.
|
|
304
|
+
const baseRwep = matchedCves.length ? Math.max(...matchedCves.map(c => c.rwep_score)) : 0;
|
|
305
|
+
let adjustedRwep = baseRwep;
|
|
306
|
+
const rwepBreakdown = [];
|
|
307
|
+
for (const input of an.rwep_inputs || []) {
|
|
308
|
+
const indicator = detectResult.indicators?.find(i => i.id === input.signal_id);
|
|
309
|
+
const fired = indicator?.verdict === 'hit' || agentSignals[input.signal_id] === true;
|
|
310
|
+
if (fired) {
|
|
311
|
+
adjustedRwep += input.weight;
|
|
312
|
+
rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: input.weight, fired: true });
|
|
313
|
+
} else {
|
|
314
|
+
rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
|
|
318
|
+
|
|
319
|
+
// blast_radius
|
|
320
|
+
const blastRubric = an.blast_radius_model?.scoring_rubric || [];
|
|
321
|
+
const blastRadiusScore = agentSignals.blast_radius_score || (blastRubric[0]?.blast_radius_score ?? null);
|
|
322
|
+
|
|
323
|
+
// compliance_theater_check — engine surfaces the test; agent runs it; we
|
|
324
|
+
// accept the verdict in agentSignals.theater_verdict.
|
|
325
|
+
const theaterVerdict = agentSignals.theater_verdict || (an.compliance_theater_check ? 'pending_agent_run' : null);
|
|
326
|
+
|
|
327
|
+
// framework_gap_mapping — engine emits the mapping verbatim; analyze does
|
|
328
|
+
// not compute new gaps here, just attaches the playbook-declared ones.
|
|
329
|
+
const frameworkGaps = an.framework_gap_mapping || [];
|
|
330
|
+
|
|
331
|
+
// escalation criteria
|
|
332
|
+
const escalations = [];
|
|
333
|
+
for (const ec of an.escalation_criteria || []) {
|
|
334
|
+
if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
|
|
335
|
+
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
phase: 'analyze',
|
|
341
|
+
playbook_id: playbookId,
|
|
342
|
+
directive_id: directiveId,
|
|
343
|
+
matched_cves: matchedCves.map(c => ({
|
|
344
|
+
cve_id: c.cve_id, rwep: c.rwep_score, cisa_kev: c.cisa_kev,
|
|
345
|
+
active_exploitation: c.active_exploitation, ai_discovered: c.ai_discovered
|
|
346
|
+
})),
|
|
347
|
+
rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null },
|
|
348
|
+
blast_radius_score: blastRadiusScore,
|
|
349
|
+
blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
|
|
350
|
+
compliance_theater_check: {
|
|
351
|
+
claim: an.compliance_theater_check?.claim,
|
|
352
|
+
audit_evidence: an.compliance_theater_check?.audit_evidence,
|
|
353
|
+
reality_test: an.compliance_theater_check?.reality_test,
|
|
354
|
+
verdict: theaterVerdict,
|
|
355
|
+
verdict_text: theaterVerdict === 'theater' ? an.compliance_theater_check?.theater_verdict_if_gap : null
|
|
356
|
+
},
|
|
357
|
+
framework_gap_mapping: frameworkGaps,
|
|
358
|
+
escalations
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- phase 6: validate ---
|
|
363
|
+
|
|
364
|
+
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
|
|
365
|
+
const playbook = loadPlaybook(playbookId);
|
|
366
|
+
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
367
|
+
|
|
368
|
+
// Pick the highest-priority remediation_path whose preconditions are all
|
|
369
|
+
// either satisfied by agentSignals or marked unverified=allow.
|
|
370
|
+
const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
|
|
371
|
+
let selected = null;
|
|
372
|
+
const considered = [];
|
|
373
|
+
for (const p of paths) {
|
|
374
|
+
const pcResult = (p.preconditions || []).map(expr => ({
|
|
375
|
+
expr,
|
|
376
|
+
satisfied: evalCondition(expr, agentSignals, playbook),
|
|
377
|
+
submitted: agentSignals[expressionKey(expr)] !== undefined
|
|
378
|
+
}));
|
|
379
|
+
const allSatisfied = pcResult.every(x => x.satisfied);
|
|
380
|
+
considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, preconditions: pcResult });
|
|
381
|
+
if (allSatisfied && !selected) selected = p;
|
|
382
|
+
}
|
|
383
|
+
// Always at least propose the highest-priority path even if preconditions
|
|
384
|
+
// weren't verified — the agent can surface that to the operator.
|
|
385
|
+
if (!selected && paths.length) selected = paths[0];
|
|
386
|
+
|
|
387
|
+
// Compute regression schedule next_run (engine sets a single soonest run).
|
|
388
|
+
const triggers = v.regression_trigger || [];
|
|
389
|
+
const nextRun = computeRegressionNextRun(triggers);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
phase: 'validate',
|
|
393
|
+
playbook_id: playbookId,
|
|
394
|
+
directive_id: directiveId,
|
|
395
|
+
selected_remediation: selected,
|
|
396
|
+
remediation_options_considered: considered,
|
|
397
|
+
validation_tests: v.validation_tests || [],
|
|
398
|
+
residual_risk_statement: v.residual_risk_statement || null,
|
|
399
|
+
evidence_requirements: v.evidence_requirements || [],
|
|
400
|
+
regression_trigger: triggers,
|
|
401
|
+
regression_next_run: nextRun
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function computeRegressionNextRun(triggers) {
|
|
406
|
+
const now = new Date();
|
|
407
|
+
let soonest = null;
|
|
408
|
+
for (const t of triggers) {
|
|
409
|
+
const m = (t.interval || '').match(/^(\d+)d$/);
|
|
410
|
+
if (m) {
|
|
411
|
+
const d = new Date(now.getTime() + parseInt(m[1], 10) * 24 * 3600 * 1000);
|
|
412
|
+
if (!soonest || d < soonest) soonest = d;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return soonest ? soonest.toISOString() : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- phase 7: close ---
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Assemble the closure artifacts:
|
|
422
|
+
* - evidence_package (CSAF-2.0 shaped if requested; signed if signing key present)
|
|
423
|
+
* - learning_loop lesson template populated with current finding context
|
|
424
|
+
* - notification_actions with computed ISO 8601 deadlines from clock_starts + window_hours
|
|
425
|
+
* - exception_generation auditor-ready language if trigger fires
|
|
426
|
+
* - regression_schedule.next_run from validate.regression_next_run
|
|
427
|
+
* - feeds_into chaining suggestions
|
|
428
|
+
*/
|
|
429
|
+
function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
|
|
430
|
+
const playbook = loadPlaybook(playbookId);
|
|
431
|
+
const c = resolvedPhase(playbook, directiveId, 'close');
|
|
432
|
+
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
433
|
+
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
434
|
+
|
|
435
|
+
// notification_actions — compute ISO deadlines from clock_starts events.
|
|
436
|
+
const notificationActions = (c.notification_actions || []).map(na => {
|
|
437
|
+
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
438
|
+
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
439
|
+
);
|
|
440
|
+
const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals) : null;
|
|
441
|
+
const deadline = obligation && clockStart
|
|
442
|
+
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
443
|
+
: 'pending_clock_start_event';
|
|
444
|
+
return {
|
|
445
|
+
...na,
|
|
446
|
+
deadline,
|
|
447
|
+
clock_start_event: obligation?.clock_starts,
|
|
448
|
+
clock_started_at: clockStart?.toISOString() || null,
|
|
449
|
+
draft_notification: interpolate(na.draft_notification, { ...agentSignals, ...analyzeFindingShape(analyzeResult) })
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// exception_generation — evaluate trigger.
|
|
454
|
+
let exception = null;
|
|
455
|
+
if (c.exception_generation) {
|
|
456
|
+
const triggered = evalCondition(c.exception_generation.trigger_condition, agentSignals, playbook);
|
|
457
|
+
if (triggered) {
|
|
458
|
+
const t = c.exception_generation.exception_template;
|
|
459
|
+
exception = {
|
|
460
|
+
scope: interpolate(t.scope, { ...agentSignals, ...analyzeFindingShape(analyzeResult) }),
|
|
461
|
+
duration: t.duration,
|
|
462
|
+
compensating_controls: t.compensating_controls,
|
|
463
|
+
risk_acceptance_owner: t.risk_acceptance_owner,
|
|
464
|
+
auditor_ready_language: interpolate(t.auditor_ready_language, {
|
|
465
|
+
...agentSignals,
|
|
466
|
+
...analyzeFindingShape(analyzeResult),
|
|
467
|
+
framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
|
|
468
|
+
control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
|
|
469
|
+
ciso_name: agentSignals.ciso_name || '<CISO NAME>',
|
|
470
|
+
acceptance_date: new Date().toISOString().slice(0, 10),
|
|
471
|
+
duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
|
|
472
|
+
})
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// evidence_package
|
|
478
|
+
const evidencePackage = c.evidence_package ? {
|
|
479
|
+
bundle_format: c.evidence_package.bundle_format || 'csaf-2.0',
|
|
480
|
+
contents: c.evidence_package.contents || [],
|
|
481
|
+
destination: c.evidence_package.destination || 'local_only',
|
|
482
|
+
signed: c.evidence_package.signed !== false,
|
|
483
|
+
bundle_body: buildEvidenceBundle(c.evidence_package.bundle_format || 'csaf-2.0', playbook, analyzeResult, validateResult, agentSignals)
|
|
484
|
+
} : null;
|
|
485
|
+
|
|
486
|
+
if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
|
|
487
|
+
const body = JSON.stringify(evidencePackage.bundle_body);
|
|
488
|
+
evidencePackage.signature = crypto
|
|
489
|
+
.createHmac('sha256', runOpts.session_key)
|
|
490
|
+
.update(body)
|
|
491
|
+
.digest('hex');
|
|
492
|
+
evidencePackage.signature_algorithm = 'HMAC-SHA256-session-key';
|
|
493
|
+
} else if (evidencePackage && evidencePackage.signed) {
|
|
494
|
+
evidencePackage.signature = null;
|
|
495
|
+
evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node lib/sign.js sign-evidence <bundle.json>` post-emit.';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// learning_loop lesson
|
|
499
|
+
const lesson = c.learning_loop?.enabled ? {
|
|
500
|
+
enabled: true,
|
|
501
|
+
attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
|
|
502
|
+
control_gap: c.learning_loop.lesson_template.control_gap,
|
|
503
|
+
framework_gap: c.learning_loop.lesson_template.framework_gap,
|
|
504
|
+
new_control_requirement: c.learning_loop.lesson_template.new_control_requirement,
|
|
505
|
+
feeds_back_to_skills: c.learning_loop.feeds_back_to_skills || [],
|
|
506
|
+
proposed_for_zeroday_lessons_id: `lesson-${playbook._meta.id}-${sessionId}`
|
|
507
|
+
} : { enabled: false };
|
|
508
|
+
|
|
509
|
+
// regression_schedule
|
|
510
|
+
const regressionSchedule = c.regression_schedule ? {
|
|
511
|
+
next_run: validateResult.regression_next_run,
|
|
512
|
+
trigger: c.regression_schedule.trigger,
|
|
513
|
+
notify_on_skip: c.regression_schedule.notify_on_skip !== false
|
|
514
|
+
} : null;
|
|
515
|
+
|
|
516
|
+
// feeds_into chaining — full analyze result is exposed so conditions can
|
|
517
|
+
// reference `analyze.compliance_theater_check.verdict` etc.
|
|
518
|
+
const feedsCtx = {
|
|
519
|
+
rwep: analyzeResult.rwep?.adjusted,
|
|
520
|
+
theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 0 : 100,
|
|
521
|
+
analyze: analyzeResult,
|
|
522
|
+
validate: validateResult,
|
|
523
|
+
finding: analyzeFindingShape(analyzeResult),
|
|
524
|
+
...agentSignals
|
|
525
|
+
};
|
|
526
|
+
const feeds = (playbook._meta.feeds_into || [])
|
|
527
|
+
.filter(f => evalCondition(f.condition, feedsCtx, playbook))
|
|
528
|
+
.map(f => f.playbook_id);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
phase: 'close',
|
|
532
|
+
playbook_id: playbookId,
|
|
533
|
+
directive_id: directiveId,
|
|
534
|
+
evidence_package: evidencePackage,
|
|
535
|
+
learning_loop: lesson,
|
|
536
|
+
notification_actions: notificationActions,
|
|
537
|
+
exception: exception,
|
|
538
|
+
regression_schedule: regressionSchedule,
|
|
539
|
+
feeds_into: feeds
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function analyzeFindingShape(a) {
|
|
544
|
+
return {
|
|
545
|
+
matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
|
|
546
|
+
matched_cve_count: (a.matched_cves || []).length,
|
|
547
|
+
kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
|
|
548
|
+
active_exploitation: (a.matched_cves || []).find(c => c.active_exploitation)?.active_exploitation || 'unknown',
|
|
549
|
+
rwep_adjusted: a.rwep?.adjusted ?? 0,
|
|
550
|
+
rwep_base: a.rwep?.base ?? 0,
|
|
551
|
+
blast_radius_score: a.blast_radius_score ?? 0,
|
|
552
|
+
framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
|
|
553
|
+
control_id_first: a.framework_gap_mapping?.[0]?.claimed_control || null
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals) {
|
|
558
|
+
// CSAF-2.0 shape — minimal valid envelope; production GRC submission would
|
|
559
|
+
// need full distribution + product_tree population, deferred to the GRC
|
|
560
|
+
// integration layer.
|
|
561
|
+
if (format === 'csaf-2.0') {
|
|
562
|
+
return {
|
|
563
|
+
document: {
|
|
564
|
+
category: 'csaf_security_advisory',
|
|
565
|
+
csaf_version: '2.0',
|
|
566
|
+
publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
|
|
567
|
+
title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} catalogued CVEs)`,
|
|
568
|
+
tracking: {
|
|
569
|
+
id: `exceptd-${playbook._meta.id}-${Date.now()}`,
|
|
570
|
+
status: 'final',
|
|
571
|
+
version: playbook._meta.version,
|
|
572
|
+
initial_release_date: new Date().toISOString(),
|
|
573
|
+
revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial finding emission' }]
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
vulnerabilities: analyze.matched_cves.map(c => ({
|
|
577
|
+
cve: c.cve_id,
|
|
578
|
+
scores: [{ products: [], cvss_v3: { base_score: 0 } }],
|
|
579
|
+
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
|
|
580
|
+
remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
|
|
581
|
+
})),
|
|
582
|
+
exceptd_extension: {
|
|
583
|
+
rwep: analyze.rwep,
|
|
584
|
+
blast_radius_score: analyze.blast_radius_score,
|
|
585
|
+
compliance_theater: analyze.compliance_theater,
|
|
586
|
+
framework_gap_mapping: analyze.framework_gap_mapping,
|
|
587
|
+
evidence_requirements: validate.evidence_requirements,
|
|
588
|
+
residual_risk_statement: validate.residual_risk_statement
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
// Other formats deferred.
|
|
593
|
+
return { format, note: 'Non-CSAF formats deferred to GRC integration layer.', analyze, validate };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// --- orchestrate: full run in one call ---
|
|
597
|
+
|
|
598
|
+
function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
599
|
+
const playbook = loadPlaybook(playbookId);
|
|
600
|
+
const pre = preflight(playbook, runOpts);
|
|
601
|
+
if (!pre.ok) {
|
|
602
|
+
return { ok: false, phase: 'preflight', blocked_by: pre.blocked_by, reason: pre.reason, issues: pre.issues };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
_activeRuns.add(playbookId);
|
|
606
|
+
try {
|
|
607
|
+
const phases = {
|
|
608
|
+
govern: govern(playbookId, directiveId, runOpts),
|
|
609
|
+
direct: direct(playbookId, directiveId),
|
|
610
|
+
look: look(playbookId, directiveId, runOpts),
|
|
611
|
+
detect: detect(playbookId, directiveId, agentSubmission),
|
|
612
|
+
};
|
|
613
|
+
phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {});
|
|
614
|
+
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {});
|
|
615
|
+
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, runOpts);
|
|
616
|
+
|
|
617
|
+
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
618
|
+
const evidenceHash = crypto.createHash('sha256')
|
|
619
|
+
.update(JSON.stringify({
|
|
620
|
+
playbookId, directiveId,
|
|
621
|
+
cves: phases.analyze.matched_cves.map(c => c.cve_id),
|
|
622
|
+
rwep: phases.analyze.rwep.adjusted,
|
|
623
|
+
classification: phases.detect.classification
|
|
624
|
+
}))
|
|
625
|
+
.digest('hex');
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
ok: true,
|
|
629
|
+
playbook_id: playbookId,
|
|
630
|
+
directive_id: directiveId,
|
|
631
|
+
session_id: sessionId,
|
|
632
|
+
evidence_hash: evidenceHash,
|
|
633
|
+
preflight_issues: pre.issues,
|
|
634
|
+
phases
|
|
635
|
+
};
|
|
636
|
+
} finally {
|
|
637
|
+
_activeRuns.delete(playbookId);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// --- helpers ---
|
|
642
|
+
|
|
643
|
+
function evalCondition(expr, ctx, playbook) {
|
|
644
|
+
if (!expr) return false;
|
|
645
|
+
expr = expr.trim();
|
|
646
|
+
expr = stripOuterParens(expr);
|
|
647
|
+
if (expr === 'always') return true;
|
|
648
|
+
if (expr === 'true') return true;
|
|
649
|
+
if (expr === 'false') return false;
|
|
650
|
+
|
|
651
|
+
// Honor operator precedence: OR is lower precedence than AND, so split on OR
|
|
652
|
+
// first. splitAtTopLevel walks the expression depth-aware so parens correctly
|
|
653
|
+
// group sub-expressions — i.e. `A OR (B AND C)` parses with B,C as one AND
|
|
654
|
+
// group rather than splitting at the inner AND.
|
|
655
|
+
const orParts = splitAtTopLevel(expr, 'OR');
|
|
656
|
+
if (orParts.length > 1) return orParts.some(s => evalCondition(s, ctx, playbook));
|
|
657
|
+
|
|
658
|
+
const andParts = splitAtTopLevel(expr, 'AND');
|
|
659
|
+
if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
|
|
660
|
+
|
|
661
|
+
// "rwep >= 90"
|
|
662
|
+
let m = expr.match(/^(\w+(?:\.\w+)*)\s*(>=|<=|==|=|<|>|!=)\s*(['"]?)([^'"]+)\3$/);
|
|
663
|
+
if (m) {
|
|
664
|
+
const [, lhs, op, quote, rhsRaw] = m;
|
|
665
|
+
const lv = resolvePath(ctx, lhs);
|
|
666
|
+
let rv = rhsRaw;
|
|
667
|
+
if (quote) {
|
|
668
|
+
// Explicit quoted string literal — keep as-is.
|
|
669
|
+
} else if (rv === 'true') rv = true;
|
|
670
|
+
else if (rv === 'false') rv = false;
|
|
671
|
+
else if (!isNaN(parseFloat(rv)) && /^-?\d+(\.\d+)?$/.test(rv.trim())) rv = parseFloat(rv);
|
|
672
|
+
else if (/^[a-z_][\w.]*$/i.test(rv.trim())) {
|
|
673
|
+
// Unquoted identifier — treat as a context path. Falls through to the
|
|
674
|
+
// raw string if resolution returns undefined (matches the prior behavior
|
|
675
|
+
// for literals like `theater` that aren't quoted).
|
|
676
|
+
const resolved = resolvePath(ctx, rv.trim());
|
|
677
|
+
if (resolved !== undefined && resolved !== null) rv = resolved;
|
|
678
|
+
}
|
|
679
|
+
switch (op) {
|
|
680
|
+
case '==': case '=': return lv == rv;
|
|
681
|
+
case '!=': return lv != rv;
|
|
682
|
+
case '>=': return lv >= rv;
|
|
683
|
+
case '<=': return lv <= rv;
|
|
684
|
+
case '>': return lv > rv;
|
|
685
|
+
case '<': return lv < rv;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// "scope.targets includes named_remote"
|
|
690
|
+
m = expr.match(/^(\w+(?:\.\w+)*)\s+includes\s+(\w+)$/);
|
|
691
|
+
if (m) {
|
|
692
|
+
const arr = resolvePath(ctx, m[1]);
|
|
693
|
+
return Array.isArray(arr) && arr.includes(m[2]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// "matched_cve.vector matches /regex/"
|
|
697
|
+
m = expr.match(/^(\w+(?:\.\w+)*)\s+matches\s+\/(.+)\/$/);
|
|
698
|
+
if (m) {
|
|
699
|
+
const val = resolvePath(ctx, m[1]);
|
|
700
|
+
if (typeof val !== 'string') return false;
|
|
701
|
+
return new RegExp(m[2], 'i').test(val);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function resolvePath(obj, dot) {
|
|
709
|
+
return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
|
|
714
|
+
* surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
|
|
715
|
+
* sub-expression list. Used by evalCondition so `A OR (B AND C)` splits into
|
|
716
|
+
* [`A`, `(B AND C)`] on OR, instead of naively splitting at the inner AND.
|
|
717
|
+
*/
|
|
718
|
+
function splitAtTopLevel(expr, sep) {
|
|
719
|
+
const parts = [];
|
|
720
|
+
const needle = ' ' + sep + ' ';
|
|
721
|
+
let depth = 0, buf = '', i = 0;
|
|
722
|
+
while (i < expr.length) {
|
|
723
|
+
const ch = expr[i];
|
|
724
|
+
if (ch === '(') { depth++; buf += ch; i++; continue; }
|
|
725
|
+
if (ch === ')') { depth--; buf += ch; i++; continue; }
|
|
726
|
+
if (depth === 0 && expr.startsWith(needle, i)) {
|
|
727
|
+
parts.push(buf.trim());
|
|
728
|
+
buf = '';
|
|
729
|
+
i += needle.length;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
buf += ch;
|
|
733
|
+
i++;
|
|
734
|
+
}
|
|
735
|
+
parts.push(buf.trim());
|
|
736
|
+
return parts;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Strip a balanced pair of outer parens, if and only if the very first and last
|
|
741
|
+
* characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
|
|
742
|
+
* its parens; `((A AND B))` peels one layer.
|
|
743
|
+
*/
|
|
744
|
+
function stripOuterParens(expr) {
|
|
745
|
+
while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
|
|
746
|
+
let depth = 0;
|
|
747
|
+
let outerMatches = true;
|
|
748
|
+
for (let i = 0; i < expr.length - 1; i++) {
|
|
749
|
+
if (expr[i] === '(') depth++;
|
|
750
|
+
else if (expr[i] === ')') depth--;
|
|
751
|
+
if (depth === 0 && i < expr.length - 1) { outerMatches = false; break; }
|
|
752
|
+
}
|
|
753
|
+
if (outerMatches) expr = expr.slice(1, -1).trim();
|
|
754
|
+
else break;
|
|
755
|
+
}
|
|
756
|
+
return expr;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function computeClockStart(eventName, agentSignals) {
|
|
760
|
+
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
761
|
+
const key = `clock_started_at_${eventName}`;
|
|
762
|
+
if (agentSignals[key]) return new Date(agentSignals[key]);
|
|
763
|
+
// Fallback: use the standard 'detect_confirmed' default of "now" for the
|
|
764
|
+
// most common case so notification deadlines aren't always pending.
|
|
765
|
+
if (eventName === 'detect_confirmed' && agentSignals.detection_classification === 'detected') {
|
|
766
|
+
return new Date();
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function expressionKey(expr) {
|
|
772
|
+
// For agentSignals precondition lookups — strip operators/values to leave key.
|
|
773
|
+
const m = expr.match(/^(\w+(?:\.\w+)*)/);
|
|
774
|
+
return m ? m[1] : expr;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function interpolate(tpl, ctx) {
|
|
778
|
+
if (!tpl || typeof tpl !== 'string') return tpl;
|
|
779
|
+
return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
|
|
780
|
+
const v = ctx[key];
|
|
781
|
+
return v !== undefined && v !== null ? String(v) : `\${${key}}`;
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// --- pre-run discovery API: list all directives across all playbooks ---
|
|
786
|
+
|
|
787
|
+
function plan(opts = {}) {
|
|
788
|
+
const ids = opts.playbookIds || listPlaybooks();
|
|
789
|
+
return {
|
|
790
|
+
contract: 'seven-phase: govern → direct → look → detect → analyze → validate → close',
|
|
791
|
+
host_ai_owns: ['look', 'detect'],
|
|
792
|
+
exceptd_owns: ['govern', 'direct', 'analyze', 'validate', 'close'],
|
|
793
|
+
generated_at: new Date().toISOString(),
|
|
794
|
+
session_id: opts.session_id || crypto.randomBytes(8).toString('hex'),
|
|
795
|
+
playbooks: ids.map(id => {
|
|
796
|
+
const pb = loadPlaybook(id);
|
|
797
|
+
return {
|
|
798
|
+
id,
|
|
799
|
+
domain: pb.domain,
|
|
800
|
+
threat_currency_score: pb._meta.threat_currency_score,
|
|
801
|
+
directives: pb.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }))
|
|
802
|
+
};
|
|
803
|
+
})
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
module.exports = {
|
|
808
|
+
listPlaybooks,
|
|
809
|
+
loadPlaybook,
|
|
810
|
+
plan,
|
|
811
|
+
preflight,
|
|
812
|
+
govern,
|
|
813
|
+
direct,
|
|
814
|
+
look,
|
|
815
|
+
detect,
|
|
816
|
+
analyze,
|
|
817
|
+
validate,
|
|
818
|
+
close,
|
|
819
|
+
run,
|
|
820
|
+
// internal helpers exposed for tests
|
|
821
|
+
_resolvedPhase: resolvedPhase,
|
|
822
|
+
_deepMerge: deepMerge,
|
|
823
|
+
_evalCondition: evalCondition,
|
|
824
|
+
_interpolate: interpolate,
|
|
825
|
+
_activeRuns: _activeRuns,
|
|
826
|
+
};
|