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