@blamejs/exceptd-skills 0.12.11 → 0.12.13
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 +93 -0
- package/bin/exceptd.js +152 -39
- package/data/_indexes/_meta.json +7 -6
- package/data/_indexes/activity-feed.json +10 -2
- package/data/_indexes/catalog-summaries.json +23 -1
- package/data/attack-techniques.json +96 -0
- package/lib/cve-curation.js +491 -46
- package/lib/lint-skills.js +212 -15
- package/lib/playbook-runner.js +485 -108
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +221 -73
- package/lib/refresh-network.js +15 -1
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +68 -5
- package/lib/sign.js +112 -3
- package/lib/validate-cve-catalog.js +171 -3
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +241 -16
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scheduler.js +50 -7
- package/package.json +1 -1
- package/sbom.cdx.json +8 -8
- package/scripts/predeploy.js +31 -5
package/lib/playbook-runner.js
CHANGED
|
@@ -72,6 +72,10 @@ function loadPlaybook(playbookId) {
|
|
|
72
72
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// E12: per-run playbook cache. Each phase function reads runOpts._playbookCache
|
|
76
|
+
// before falling back to loadPlaybook(). run() sets _playbookCache once at
|
|
77
|
+
// entry so seven phases share one disk read + JSON parse instead of seven.
|
|
78
|
+
|
|
75
79
|
function findDirective(playbook, directiveId) {
|
|
76
80
|
const d = playbook.directives.find(x => x.id === directiveId);
|
|
77
81
|
if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
|
|
@@ -100,9 +104,34 @@ function deepMerge(a, b) {
|
|
|
100
104
|
|
|
101
105
|
// --- pre-flight: currency + preconditions + mutex ---
|
|
102
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Pre-flight gate. Three concerns:
|
|
109
|
+
*
|
|
110
|
+
* 1. Currency. threat_currency_score < 50 hard-blocks unless
|
|
111
|
+
* runOpts.forceStale=true. < 70 emits a warning issue.
|
|
112
|
+
* 2. Preconditions. _meta.preconditions[] entries with on_fail in
|
|
113
|
+
* {halt, warn, skip_phase} are evaluated against
|
|
114
|
+
* runOpts.precondition_checks[id]. Missing values → precondition_unverified
|
|
115
|
+
* issue (plus halt if on_fail=halt). False values → precondition_warn or
|
|
116
|
+
* precondition_skip per on_fail.
|
|
117
|
+
* 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
|
|
118
|
+
* AND with the filesystem lockfile dir blocks the run.
|
|
119
|
+
*
|
|
120
|
+
* E5: when runOpts.strictPreconditions === true, warn-level outcomes
|
|
121
|
+
* (precondition_warn, precondition_unverified with on_fail=warn or
|
|
122
|
+
* skip_phase) are ESCALATED to halts. The function returns ok:false with
|
|
123
|
+
* blocked_by='precondition' and an issues array containing
|
|
124
|
+
* precondition_halt entries. Callers wanting "CI gate: any unverified
|
|
125
|
+
* precondition is a failure" pass strictPreconditions=true.
|
|
126
|
+
*
|
|
127
|
+
* E6: when a precondition with on_fail='skip_phase' fails, the issue carries
|
|
128
|
+
* skip_phase: 'detect' (default) so run() can route to a skipped-phase
|
|
129
|
+
* placeholder rather than executing detect against a missing prerequisite.
|
|
130
|
+
*/
|
|
103
131
|
function preflight(playbook, runOpts = {}) {
|
|
104
132
|
const issues = [];
|
|
105
133
|
const meta = playbook._meta;
|
|
134
|
+
const strict = runOpts.strictPreconditions === true;
|
|
106
135
|
|
|
107
136
|
// 1. Currency gate
|
|
108
137
|
const score = meta.threat_currency_score;
|
|
@@ -123,6 +152,18 @@ function preflight(playbook, runOpts = {}) {
|
|
|
123
152
|
const submitted = runOpts.precondition_checks?.[pc.id];
|
|
124
153
|
if (submitted === undefined) {
|
|
125
154
|
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.`;
|
|
155
|
+
if (strict) {
|
|
156
|
+
// E5: strictPreconditions promotes unverified to halt regardless of
|
|
157
|
+
// declared on_fail.
|
|
158
|
+
issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
blocked_by: 'precondition',
|
|
162
|
+
reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; strict-preconditions enabled.`,
|
|
163
|
+
remediation: submission_hint,
|
|
164
|
+
issues
|
|
165
|
+
};
|
|
166
|
+
}
|
|
126
167
|
issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint });
|
|
127
168
|
if (pc.on_fail === 'halt') {
|
|
128
169
|
return {
|
|
@@ -139,7 +180,25 @@ function preflight(playbook, runOpts = {}) {
|
|
|
139
180
|
if (pc.on_fail === 'halt') {
|
|
140
181
|
return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
|
|
141
182
|
}
|
|
142
|
-
|
|
183
|
+
if (strict) {
|
|
184
|
+
// E5: warn-level + skip_phase outcomes escalate to halt under strict.
|
|
185
|
+
issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
blocked_by: 'precondition',
|
|
189
|
+
reason: `Precondition ${pc.id} (${pc.check}) failed; strict-preconditions enabled.`,
|
|
190
|
+
issues
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (pc.on_fail === 'skip_phase') {
|
|
194
|
+
// E6: emit a skip_phase field so run() can route to a skipped-phase
|
|
195
|
+
// placeholder. Default target phase is 'detect' (the most common
|
|
196
|
+
// skip target — preconditions typically gate host-side detection).
|
|
197
|
+
// Playbooks may override via pc.skip_phase.
|
|
198
|
+
issues.push({ kind: 'precondition_skip', id: pc.id, message: pc.description, skip_phase: pc.skip_phase || 'detect' });
|
|
199
|
+
} else {
|
|
200
|
+
issues.push({ kind: 'precondition_warn', id: pc.id, message: pc.description });
|
|
201
|
+
}
|
|
143
202
|
}
|
|
144
203
|
}
|
|
145
204
|
|
|
@@ -214,7 +273,7 @@ function pidAlive(pid) {
|
|
|
214
273
|
* fingerprints, framework gap summary, and skills to preload.
|
|
215
274
|
*/
|
|
216
275
|
function govern(playbookId, directiveId, runOpts = {}) {
|
|
217
|
-
const playbook = loadPlaybook(playbookId);
|
|
276
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
218
277
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
219
278
|
return {
|
|
220
279
|
phase: 'govern',
|
|
@@ -238,8 +297,8 @@ function govern(playbookId, directiveId, runOpts = {}) {
|
|
|
238
297
|
|
|
239
298
|
// --- phase 2: direct ---
|
|
240
299
|
|
|
241
|
-
function direct(playbookId, directiveId) {
|
|
242
|
-
const playbook = loadPlaybook(playbookId);
|
|
300
|
+
function direct(playbookId, directiveId, runOpts = {}) {
|
|
301
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
243
302
|
const d = resolvedPhase(playbook, directiveId, 'direct');
|
|
244
303
|
return {
|
|
245
304
|
phase: 'direct',
|
|
@@ -256,7 +315,7 @@ function direct(playbookId, directiveId) {
|
|
|
256
315
|
// --- phase 3: look (engine emits, agent executes) ---
|
|
257
316
|
|
|
258
317
|
function look(playbookId, directiveId, runOpts = {}) {
|
|
259
|
-
const playbook = loadPlaybook(playbookId);
|
|
318
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
260
319
|
const l = resolvedPhase(playbook, directiveId, 'look');
|
|
261
320
|
const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
|
|
262
321
|
return {
|
|
@@ -303,8 +362,8 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
303
362
|
* and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
|
|
304
363
|
* record an indicator outcome the agent computed using its own pattern matching.
|
|
305
364
|
*/
|
|
306
|
-
function detect(playbookId, directiveId, agentSubmission = {}) {
|
|
307
|
-
const playbook = loadPlaybook(playbookId);
|
|
365
|
+
function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
366
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
308
367
|
const det = resolvedPhase(playbook, directiveId, 'detect');
|
|
309
368
|
const artifacts = agentSubmission.artifacts || {};
|
|
310
369
|
const overrides = agentSubmission.signal_overrides || {};
|
|
@@ -323,24 +382,61 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
|
|
|
323
382
|
return null; // truly unknown — fall through
|
|
324
383
|
};
|
|
325
384
|
|
|
385
|
+
// E1: per-indicator FP-check attestation map. Operators submit
|
|
386
|
+
// signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
|
|
387
|
+
// to declare which named false_positive_checks_required[] entries on the
|
|
388
|
+
// indicator have been satisfied. An unverified FP check downgrades the
|
|
389
|
+
// verdict from 'hit' to 'inconclusive' and surfaces fp_checks_unsatisfied
|
|
390
|
+
// on the per-indicator result. See AGENTS.md Hard Rule #6 (compliance
|
|
391
|
+
// theater) and AGENTS.md §"detect (AI)" — a `hit` without its FP checks
|
|
392
|
+
// is not yet a `detected` classification.
|
|
326
393
|
const indicatorResults = (det.indicators || []).map(ind => {
|
|
327
394
|
const rawOverride = overrides[ind.id];
|
|
328
395
|
const override = canonicalize(rawOverride);
|
|
329
396
|
let verdict;
|
|
397
|
+
let fpChecksUnsatisfied = null;
|
|
330
398
|
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
331
399
|
verdict = override;
|
|
400
|
+
// E1: gate 'hit' verdict on per-indicator false_positive_checks_required
|
|
401
|
+
// satisfaction. The FP-check attestation arrives as a sibling key
|
|
402
|
+
// '<id>__fp_checks' in signal_overrides; default behavior (no
|
|
403
|
+
// attestation) treats every required FP check as UNSATISFIED.
|
|
404
|
+
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
405
|
+
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
406
|
+
const att = (attestation && typeof attestation === 'object') ? attestation : {};
|
|
407
|
+
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
408
|
+
// Match either by exact name string OR by indexed key '0', '1', ...
|
|
409
|
+
// because false_positive_checks_required entries are free-text
|
|
410
|
+
// strings, not ids. Operators may attest either by the literal
|
|
411
|
+
// string or by index. Default: unsatisfied.
|
|
412
|
+
if (att[fpName] === true) return false;
|
|
413
|
+
const idx = ind.false_positive_checks_required.indexOf(fpName);
|
|
414
|
+
if (idx !== -1 && att[String(idx)] === true) return false;
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
if (unsatisfied.length > 0) {
|
|
418
|
+
verdict = 'inconclusive';
|
|
419
|
+
fpChecksUnsatisfied = unsatisfied;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
332
422
|
} else {
|
|
333
423
|
// Without an explicit override, treat any captured artifact as evidence
|
|
334
|
-
// the indicator could be evaluated. Mark inconclusive if
|
|
335
|
-
//
|
|
336
|
-
//
|
|
424
|
+
// the indicator could be evaluated. Mark inconclusive if any artifact
|
|
425
|
+
// was captured (engine doesn't pattern-match raw artifact content; the
|
|
426
|
+
// host AI is responsible for that). With NO captured artifacts, this is
|
|
427
|
+
// a clean empty submission — emit 'miss' so the run can reach
|
|
428
|
+
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
429
|
+
// E2: pre-fix both arms emitted 'inconclusive', so a clean empty run
|
|
430
|
+
// could never reach not_detected and theater_verdict stayed
|
|
431
|
+
// 'pending_agent_run' forever.
|
|
337
432
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
338
|
-
verdict = anyCaptured ? 'inconclusive' : '
|
|
433
|
+
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
339
434
|
}
|
|
340
435
|
return {
|
|
341
436
|
id: ind.id, type: ind.type, confidence: ind.confidence,
|
|
342
437
|
deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
|
|
343
|
-
attack_ref: ind.attack_ref || null, verdict
|
|
438
|
+
attack_ref: ind.attack_ref || null, verdict,
|
|
439
|
+
...(fpChecksUnsatisfied ? { fp_checks_unsatisfied: fpChecksUnsatisfied } : {})
|
|
344
440
|
};
|
|
345
441
|
});
|
|
346
442
|
|
|
@@ -402,7 +498,11 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
|
|
|
402
498
|
})),
|
|
403
499
|
indicators_evaluated_count: indicatorResults.length,
|
|
404
500
|
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')
|
|
501
|
+
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
502
|
+
// E9: pass through any flat-shape observation collisions detected at
|
|
503
|
+
// normalize time so analyze() can publish them under
|
|
504
|
+
// analyze.signal_origins_with_collisions.
|
|
505
|
+
_signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
|
|
406
506
|
};
|
|
407
507
|
}
|
|
408
508
|
|
|
@@ -413,8 +513,8 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
|
|
|
413
513
|
* mapping + escalation evaluation. Inputs are the detect result + any
|
|
414
514
|
* agent-submitted signal_values (e.g. blast_radius classification).
|
|
415
515
|
*/
|
|
416
|
-
function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
|
|
417
|
-
const playbook = loadPlaybook(playbookId);
|
|
516
|
+
function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOpts = {}) {
|
|
517
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
418
518
|
const an = resolvedPhase(playbook, directiveId, 'analyze');
|
|
419
519
|
const directive = findDirective(playbook, directiveId);
|
|
420
520
|
|
|
@@ -572,8 +672,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
|
|
|
572
672
|
|
|
573
673
|
// escalation criteria
|
|
574
674
|
const escalations = [];
|
|
675
|
+
const runtimeErrors = []; // E3: collect regex-eval errors during analyze
|
|
676
|
+
const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
|
|
575
677
|
for (const ec of an.escalation_criteria || []) {
|
|
576
|
-
if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
|
|
678
|
+
if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals, ...evalCtxRoot }, playbook)) {
|
|
577
679
|
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
578
680
|
}
|
|
579
681
|
}
|
|
@@ -625,7 +727,18 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
|
|
|
625
727
|
note: vexDropped.length
|
|
626
728
|
? `${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
729
|
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
|
|
628
|
-
} : null
|
|
730
|
+
} : null,
|
|
731
|
+
// E3: regex-eval failures surfaced here so operators can see WHICH
|
|
732
|
+
// condition expression crashed without the runner dying. Only present
|
|
733
|
+
// when at least one evalCondition() call hit a regex exception during
|
|
734
|
+
// this analyze pass; runOpts._runErrors is the same accumulator
|
|
735
|
+
// populated by run() across all phases, so callers reading this field
|
|
736
|
+
// see every regex problem in the run.
|
|
737
|
+
runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
|
|
738
|
+
// E9: collisions when two flat-shape observations targeted the same
|
|
739
|
+
// indicator id. Empty when there were no collisions or no flat-shape
|
|
740
|
+
// observations submitted.
|
|
741
|
+
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
742
|
};
|
|
630
743
|
}
|
|
631
744
|
|
|
@@ -657,8 +770,11 @@ function vexFilterFromDoc(doc) {
|
|
|
657
770
|
|
|
658
771
|
// --- phase 6: validate ---
|
|
659
772
|
|
|
660
|
-
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
|
|
661
|
-
const playbook = loadPlaybook(playbookId);
|
|
773
|
+
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
774
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
775
|
+
// E3: surface evalCondition regex errors raised here into the same
|
|
776
|
+
// run-wide accumulator that analyze() reads.
|
|
777
|
+
const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
662
778
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
663
779
|
|
|
664
780
|
// Pick the highest-priority remediation_path whose preconditions are all
|
|
@@ -669,7 +785,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
|
|
|
669
785
|
for (const p of paths) {
|
|
670
786
|
const pcResult = (p.preconditions || []).map(expr => ({
|
|
671
787
|
expr,
|
|
672
|
-
satisfied: evalCondition(expr,
|
|
788
|
+
satisfied: evalCondition(expr, evalCtx, playbook),
|
|
673
789
|
submitted: agentSignals[expressionKey(expr)] !== undefined
|
|
674
790
|
}));
|
|
675
791
|
const allSatisfied = pcResult.every(x => x.satisfied);
|
|
@@ -723,7 +839,7 @@ function computeRegressionNextRun(triggers) {
|
|
|
723
839
|
* - feeds_into chaining suggestions
|
|
724
840
|
*/
|
|
725
841
|
function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
|
|
726
|
-
const playbook = loadPlaybook(playbookId);
|
|
842
|
+
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
727
843
|
const c = resolvedPhase(playbook, directiveId, 'close');
|
|
728
844
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
729
845
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
@@ -741,7 +857,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
741
857
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
742
858
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
743
859
|
);
|
|
744
|
-
|
|
860
|
+
// E7: thread runOpts through so computeClockStart can check
|
|
861
|
+
// operator_consent.explicit before auto-stamping detect_confirmed.
|
|
862
|
+
const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
|
|
863
|
+
// E7: when the clock event is detect_confirmed AND the classification
|
|
864
|
+
// matched AND the operator did NOT pass --ack, surface clock_pending_ack
|
|
865
|
+
// so the notification record is visibly waiting on acknowledgement.
|
|
866
|
+
const clockPendingAck = !clockStart
|
|
867
|
+
&& obligation?.clock_starts === 'detect_confirmed'
|
|
868
|
+
&& agentSignals?.detection_classification === 'detected'
|
|
869
|
+
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
745
870
|
const deadline = obligation && clockStart
|
|
746
871
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
747
872
|
: 'pending_clock_start_event';
|
|
@@ -756,6 +881,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
756
881
|
window_hours: obligation?.window_hours ?? null,
|
|
757
882
|
clock_start_event: obligation?.clock_starts || null,
|
|
758
883
|
clock_started_at: clockStart?.toISOString() || null,
|
|
884
|
+
...(clockPendingAck ? { clock_pending_ack: true } : {}),
|
|
759
885
|
deadline,
|
|
760
886
|
// Alias matching compliance-team vocabulary.
|
|
761
887
|
notification_deadline: deadline,
|
|
@@ -769,7 +895,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
769
895
|
// exception_generation — evaluate trigger.
|
|
770
896
|
let exception = null;
|
|
771
897
|
if (c.exception_generation) {
|
|
772
|
-
const
|
|
898
|
+
const closeEvalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
899
|
+
const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
|
|
773
900
|
if (triggered) {
|
|
774
901
|
const t = c.exception_generation.exception_template;
|
|
775
902
|
exception = {
|
|
@@ -803,9 +930,9 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
803
930
|
contents: c.evidence_package.contents || [],
|
|
804
931
|
destination: c.evidence_package.destination || 'local_only',
|
|
805
932
|
signed: c.evidence_package.signed !== false,
|
|
806
|
-
bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals),
|
|
933
|
+
bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals, sessionId),
|
|
807
934
|
bundles_by_format: extraFormats.length ? Object.fromEntries(
|
|
808
|
-
[primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals)])
|
|
935
|
+
[primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals, sessionId)])
|
|
809
936
|
) : null,
|
|
810
937
|
} : null;
|
|
811
938
|
|
|
@@ -847,7 +974,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
847
974
|
analyze: analyzeResult,
|
|
848
975
|
validate: validateResult,
|
|
849
976
|
finding: analyzeFindingShape(analyzeResult),
|
|
850
|
-
...agentSignals
|
|
977
|
+
...agentSignals,
|
|
978
|
+
// E3: surface evalCondition regex failures from the feeds_into chain
|
|
979
|
+
// into the same accumulator. Without this the regex failure happens but
|
|
980
|
+
// analyze.runtime_errors[] never sees it.
|
|
981
|
+
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
851
982
|
};
|
|
852
983
|
const feeds = (playbook._meta.feeds_into || [])
|
|
853
984
|
.filter(f => evalCondition(f.condition, feedsCtx, playbook))
|
|
@@ -873,12 +1004,34 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
873
1004
|
};
|
|
874
1005
|
}
|
|
875
1006
|
|
|
1007
|
+
// E8: severity ladder for active_exploitation. The worst-of reduction lets
|
|
1008
|
+
// analyzeFindingShape report the most-exploited CVE in the matched set, not
|
|
1009
|
+
// the first-encountered one. Higher index = worse.
|
|
1010
|
+
const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
|
|
1011
|
+
|
|
1012
|
+
function worstActiveExploitation(matchedCves) {
|
|
1013
|
+
let worst = null;
|
|
1014
|
+
let worstRank = -1;
|
|
1015
|
+
for (const c of (matchedCves || [])) {
|
|
1016
|
+
const v = c && c.active_exploitation;
|
|
1017
|
+
if (!v) continue;
|
|
1018
|
+
const rank = ACTIVE_EXPLOITATION_RANK[v] ?? -1;
|
|
1019
|
+
if (rank > worstRank) { worst = v; worstRank = rank; }
|
|
1020
|
+
}
|
|
1021
|
+
return worst || 'unknown';
|
|
1022
|
+
}
|
|
1023
|
+
|
|
876
1024
|
function analyzeFindingShape(a) {
|
|
877
1025
|
return {
|
|
878
1026
|
matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
|
|
879
1027
|
matched_cve_count: (a.matched_cves || []).length,
|
|
880
1028
|
kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
|
|
881
|
-
|
|
1029
|
+
// E8: previously this used .find() which returned the first matched CVE
|
|
1030
|
+
// with a truthy active_exploitation. With two CVEs where #1 is
|
|
1031
|
+
// 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
|
|
1032
|
+
// notification drafts — under-stating the threat. Now reduce to the
|
|
1033
|
+
// worst rank across all matched CVEs.
|
|
1034
|
+
active_exploitation: worstActiveExploitation(a.matched_cves),
|
|
882
1035
|
rwep_adjusted: a.rwep?.adjusted ?? 0,
|
|
883
1036
|
rwep_base: a.rwep?.base ?? 0,
|
|
884
1037
|
blast_radius_score: a.blast_radius_score ?? 0,
|
|
@@ -887,35 +1040,94 @@ function analyzeFindingShape(a) {
|
|
|
887
1040
|
};
|
|
888
1041
|
}
|
|
889
1042
|
|
|
890
|
-
|
|
1043
|
+
// Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
|
|
1044
|
+
// Empty input → 'unknown' so we never emit zero-length segments.
|
|
1045
|
+
function urnSlug(s) {
|
|
1046
|
+
if (s == null) return 'unknown';
|
|
1047
|
+
const slug = String(s)
|
|
1048
|
+
.toLowerCase()
|
|
1049
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
1050
|
+
.replace(/^-+|-+$/g, '');
|
|
1051
|
+
return slug.length ? slug : 'unknown';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Build the canonical product binding shared by CSAF + OpenVEX. CSAF's
|
|
1055
|
+
// product_tree must declare every product referenced from
|
|
1056
|
+
// vulnerabilities[].product_status; OpenVEX statements MUST carry a
|
|
1057
|
+
// `products` array per spec §4.3.
|
|
1058
|
+
function buildProductBinding(playbook, sessionId) {
|
|
1059
|
+
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1060
|
+
const sessionSlug = urnSlug(sessionId || 'session');
|
|
1061
|
+
const productId = `exceptd-target-${playbookSlug}-${sessionSlug}`;
|
|
1062
|
+
const productPurl = `pkg:exceptd/scan/${sessionSlug}/${playbookSlug}`;
|
|
1063
|
+
return {
|
|
1064
|
+
productId,
|
|
1065
|
+
productPurl,
|
|
1066
|
+
productName: playbook.domain?.name || playbook._meta.id,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Best-effort SARIF location list for an indicator hit. Indicator records
|
|
1071
|
+
// don't carry a direct artifact reference; we fall back to the playbook's
|
|
1072
|
+
// look-phase artifact source paths (the inspected files/processes). GitHub
|
|
1073
|
+
// Code Scanning hides results without `artifactLocation.uri`, so we
|
|
1074
|
+
// surface at least one candidate when any is known. Returns null when no
|
|
1075
|
+
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1076
|
+
function sarifLocationsForIndicator(playbook, indicator) {
|
|
1077
|
+
const arts = (playbook.phases?.look?.artifacts) || [];
|
|
1078
|
+
const candidates = arts
|
|
1079
|
+
.map(a => a && (a.source || a.air_gap_alternative))
|
|
1080
|
+
.filter(Boolean)
|
|
1081
|
+
.map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
|
|
1082
|
+
.filter(src => src && !/^https?:/i.test(src));
|
|
1083
|
+
if (!candidates.length) return null;
|
|
1084
|
+
return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId) {
|
|
1088
|
+
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1089
|
+
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1090
|
+
|
|
891
1091
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
892
1092
|
// catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
|
|
893
1093
|
// under `exceptd:` namespace), so playbooks without catalogue CVEs still
|
|
894
1094
|
// emit a non-empty bundle.
|
|
1095
|
+
//
|
|
1096
|
+
// v0.12.12 (B5): emit a product_tree so csaf_security_advisory documents
|
|
1097
|
+
// pass NVD/ENISA/Red Hat dashboard validation. Every vulnerability
|
|
1098
|
+
// entry references the product via product_status so the binding is
|
|
1099
|
+
// real, not cosmetic.
|
|
895
1100
|
if (format === 'csaf-2.0') {
|
|
896
1101
|
const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1102
|
+
const fullProductNames = [{
|
|
1103
|
+
product_id: productId,
|
|
1104
|
+
name: productName,
|
|
1105
|
+
product_identification_helper: { purl: productPurl }
|
|
1106
|
+
}];
|
|
1107
|
+
const cveVulns = analyze.matched_cves.map(c => {
|
|
1108
|
+
const isAffected = c.live_patch_available !== true;
|
|
1109
|
+
return {
|
|
1110
|
+
cve: c.cve_id,
|
|
1111
|
+
scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
|
|
1112
|
+
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
|
|
1113
|
+
remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.', product_ids: [productId] }],
|
|
1114
|
+
product_status: isAffected ? { known_affected: [productId] } : { fixed: [productId] }
|
|
1115
|
+
};
|
|
1116
|
+
});
|
|
903
1117
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
904
|
-
// Pseudo-CVE id for indicator findings (CSAF requires `cve` or `ids`).
|
|
905
1118
|
ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
|
|
906
1119
|
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}
|
|
1120
|
+
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
1121
|
+
product_status: { known_affected: [productId] }
|
|
908
1122
|
}));
|
|
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
1123
|
const gapVulns = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
913
1124
|
ids: [{ system_name: 'exceptd-framework-gap', text: `${g.framework}:${g.claimed_control || `gap-${idx}`}` }],
|
|
914
1125
|
notes: [
|
|
915
1126
|
{ category: 'description', text: g.actual_gap || `Framework gap in ${g.framework} ${g.claimed_control || ''}` },
|
|
916
1127
|
{ category: 'general', text: g.claimed_control ? `Claimed control: ${g.claimed_control}` : null },
|
|
917
1128
|
].filter(n => n.text),
|
|
918
|
-
remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control }] : [],
|
|
1129
|
+
remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control, product_ids: [productId] }] : [],
|
|
1130
|
+
product_status: { under_investigation: [productId] }
|
|
919
1131
|
}));
|
|
920
1132
|
const now = new Date().toISOString();
|
|
921
1133
|
return {
|
|
@@ -929,13 +1141,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
|
|
|
929
1141
|
status: 'final',
|
|
930
1142
|
version: playbook._meta.version,
|
|
931
1143
|
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
1144
|
current_release_date: now,
|
|
936
1145
|
revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
|
|
937
1146
|
}
|
|
938
1147
|
},
|
|
1148
|
+
product_tree: { full_product_names: fullProductNames },
|
|
939
1149
|
vulnerabilities: [...cveVulns, ...indicatorVulns, ...gapVulns],
|
|
940
1150
|
exceptd_extension: {
|
|
941
1151
|
classification: analyze._detect_classification,
|
|
@@ -953,36 +1163,54 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
|
|
|
953
1163
|
// SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
|
|
954
1164
|
// / most static-analysis tooling.
|
|
955
1165
|
//
|
|
956
|
-
// v0.
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
1166
|
+
// v0.12.12 (B6): thread artifact source paths through to
|
|
1167
|
+
// result.locations[].physicalLocation.artifactLocation.uri. GitHub Code
|
|
1168
|
+
// Scanning hides results without populated locations, so the heuristic
|
|
1169
|
+
// ensures clean playbook runs still surface findings in the alerts UI.
|
|
1170
|
+
// v0.12.12 (B7): omit null property-bag keys so SARIF viewers don't
|
|
1171
|
+
// render empty fields.
|
|
962
1172
|
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
1173
|
+
const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
|
963
1174
|
const cveResults = analyze.matched_cves.map(c => ({
|
|
964
1175
|
ruleId: c.cve_id,
|
|
965
1176
|
level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
|
|
966
1177
|
message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
|
|
967
|
-
properties: {
|
|
1178
|
+
properties: stripNulls({
|
|
968
1179
|
kind: 'cve_match',
|
|
969
|
-
rwep: c.rwep,
|
|
970
|
-
|
|
1180
|
+
rwep: c.rwep,
|
|
1181
|
+
cisa_kev: c.cisa_kev,
|
|
1182
|
+
cisa_kev_due_date: c.cisa_kev_due_date ?? null,
|
|
1183
|
+
active_exploitation: c.active_exploitation ?? null,
|
|
1184
|
+
ai_discovered: c.ai_discovered ?? null,
|
|
971
1185
|
blast_radius_score: analyze.blast_radius_score,
|
|
972
|
-
}
|
|
1186
|
+
}),
|
|
973
1187
|
}));
|
|
974
1188
|
const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
|
|
975
|
-
const indicatorResults = indicatorHits.map(i =>
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1189
|
+
const indicatorResults = indicatorHits.map(i => {
|
|
1190
|
+
const locs = sarifLocationsForIndicator(playbook, i);
|
|
1191
|
+
const result = {
|
|
1192
|
+
ruleId: i.id,
|
|
1193
|
+
level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
|
|
1194
|
+
message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
|
|
1195
|
+
properties: stripNulls({
|
|
1196
|
+
kind: 'indicator_hit',
|
|
1197
|
+
confidence: i.confidence,
|
|
1198
|
+
deterministic: i.deterministic,
|
|
1199
|
+
atlas_ref: i.atlas_ref,
|
|
1200
|
+
attack_ref: i.attack_ref,
|
|
1201
|
+
}),
|
|
1202
|
+
};
|
|
1203
|
+
if (locs) result.locations = locs;
|
|
1204
|
+
return result;
|
|
1205
|
+
});
|
|
981
1206
|
const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
982
1207
|
ruleId: `framework-gap-${idx}`,
|
|
1208
|
+
// Framework gaps are control-design observations, not vulnerabilities —
|
|
1209
|
+
// SARIF §3.27.9 `kind: informational` routes them appropriately.
|
|
1210
|
+
kind: 'informational',
|
|
983
1211
|
level: 'note',
|
|
984
1212
|
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 },
|
|
1213
|
+
properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
|
|
986
1214
|
}));
|
|
987
1215
|
const cveRules = analyze.matched_cves.map(c => ({
|
|
988
1216
|
id: c.cve_id, shortDescription: { text: c.cve_id },
|
|
@@ -995,11 +1223,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
|
|
|
995
1223
|
fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
|
|
996
1224
|
defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
|
|
997
1225
|
}));
|
|
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
1226
|
const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
1004
1227
|
id: `framework-gap-${idx}`,
|
|
1005
1228
|
shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
|
|
@@ -1025,42 +1248,86 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
|
|
|
1025
1248
|
};
|
|
1026
1249
|
}
|
|
1027
1250
|
|
|
1028
|
-
// OpenVEX 0.2.0 — supply-chain VEX statements.
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1251
|
+
// OpenVEX 0.2.0 — supply-chain VEX statements.
|
|
1252
|
+
//
|
|
1253
|
+
// v0.12.12 (B1-B4): correctness sweep against the OpenVEX 0.2.0 spec.
|
|
1254
|
+
// - B1: every statement now carries a `products` array (spec MUST).
|
|
1255
|
+
// - B2: `status` derives from the verdict + confidence rather than being
|
|
1256
|
+
// hard-coded to `under_investigation`. Hits emit `affected` with
|
|
1257
|
+
// an action_statement; misses emit `not_affected` with a
|
|
1258
|
+
// justification; inconclusive findings keep `under_investigation`.
|
|
1259
|
+
// - B3: framework gaps are control-design observations, not
|
|
1260
|
+
// vulnerabilities — they are removed from the VEX emit path. They
|
|
1261
|
+
// remain in CSAF (informational notes) and SARIF (kind:
|
|
1262
|
+
// informational rules).
|
|
1263
|
+
// - B4: vulnerability `@id` values switch to the registered URN namespace
|
|
1264
|
+
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
1265
|
+
// they pass IRI validation in downstream VEX consumers.
|
|
1031
1266
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
1032
1267
|
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
|
-
|
|
1268
|
+
const productEntry = {
|
|
1269
|
+
'@id': productPurl,
|
|
1270
|
+
subcomponents: [{ '@id': productPurl }],
|
|
1271
|
+
};
|
|
1272
|
+
const remediationId = validate.selected_remediation?.id || (validate.remediation_paths?.[0]?.id) || null;
|
|
1273
|
+
const remediationDescription = validate.selected_remediation?.description || null;
|
|
1274
|
+
const actionStatementFor = (fallback) => {
|
|
1275
|
+
if (remediationId && remediationDescription) {
|
|
1276
|
+
return `Apply remediation from validate phase: ${remediationId}. ${remediationDescription}`;
|
|
1277
|
+
}
|
|
1278
|
+
if (remediationId) return `Apply remediation from validate phase: ${remediationId}`;
|
|
1279
|
+
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
1280
|
+
return fallback;
|
|
1281
|
+
};
|
|
1282
|
+
const cveStatements = analyze.matched_cves.map(c => {
|
|
1283
|
+
const stmt = {
|
|
1284
|
+
vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
|
|
1285
|
+
products: [productEntry],
|
|
1286
|
+
timestamp: issued,
|
|
1287
|
+
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
|
|
1288
|
+
};
|
|
1289
|
+
if (c.live_patch_available) {
|
|
1290
|
+
stmt.status = 'fixed';
|
|
1291
|
+
} else {
|
|
1292
|
+
stmt.status = 'affected';
|
|
1293
|
+
stmt.action_statement = actionStatementFor('Apply remediation from validate phase.');
|
|
1294
|
+
}
|
|
1295
|
+
return stmt;
|
|
1296
|
+
});
|
|
1297
|
+
const indicatorStatements = (analyze._detect_indicators || [])
|
|
1298
|
+
.filter(i => i.verdict === 'hit' || i.verdict === 'miss' || i.verdict === 'inconclusive')
|
|
1299
|
+
.map(i => {
|
|
1300
|
+
const stmt = {
|
|
1301
|
+
vulnerability: {
|
|
1302
|
+
'@id': `urn:exceptd:indicator:${playbookSlug}:${urnSlug(i.id)}`,
|
|
1303
|
+
name: i.id,
|
|
1304
|
+
},
|
|
1305
|
+
products: [productEntry],
|
|
1306
|
+
timestamp: issued,
|
|
1307
|
+
impact_statement: `Indicator ${i.id} (${i.verdict}; ${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
|
|
1308
|
+
};
|
|
1309
|
+
if (i.verdict === 'hit') {
|
|
1310
|
+
// Deterministic and high-confidence hits both map to `affected`.
|
|
1311
|
+
// The `deterministic` flag describes regex specificity, not
|
|
1312
|
+
// operator-evidence confidence — neither warrants
|
|
1313
|
+
// under_investigation when the indicator actually fired.
|
|
1314
|
+
stmt.status = 'affected';
|
|
1315
|
+
stmt.action_statement = actionStatementFor(`Run \`exceptd brief ${playbook._meta.id}\` for context.`);
|
|
1316
|
+
} else if (i.verdict === 'miss') {
|
|
1317
|
+
stmt.status = 'not_affected';
|
|
1318
|
+
stmt.justification = 'vulnerable_code_not_present';
|
|
1319
|
+
} else {
|
|
1320
|
+
stmt.status = 'under_investigation';
|
|
1321
|
+
}
|
|
1322
|
+
return stmt;
|
|
1323
|
+
});
|
|
1057
1324
|
return {
|
|
1058
1325
|
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
1059
|
-
'@id': `https://exceptd.com/vex/${
|
|
1326
|
+
'@id': `https://exceptd.com/vex/${playbookSlug}/${Date.now()}`,
|
|
1060
1327
|
author: 'exceptd',
|
|
1061
1328
|
timestamp: issued,
|
|
1062
1329
|
version: 1,
|
|
1063
|
-
statements: [...cveStatements, ...indicatorStatements
|
|
1330
|
+
statements: [...cveStatements, ...indicatorStatements],
|
|
1064
1331
|
};
|
|
1065
1332
|
}
|
|
1066
1333
|
|
|
@@ -1160,7 +1427,12 @@ function normalizeSubmission(submission, playbook) {
|
|
|
1160
1427
|
// v0.11.5 (#85): track which observation produced each signal_override so
|
|
1161
1428
|
// detect can emit `from_observation` on each indicator result. Diagnostic
|
|
1162
1429
|
// value for operators chasing "which observation drove this verdict".
|
|
1430
|
+
//
|
|
1431
|
+
// E9: when two observations target the same indicator id, last-write-wins
|
|
1432
|
+
// silently. Track discards in _signal_origins_collisions so analyze can
|
|
1433
|
+
// surface analyze.signal_origins_with_collisions for batch evidence runs.
|
|
1163
1434
|
out._signal_origins = out._signal_origins || {};
|
|
1435
|
+
out._signal_origins_collisions = out._signal_origins_collisions || [];
|
|
1164
1436
|
for (const [key, val] of Object.entries(submission.observations || {})) {
|
|
1165
1437
|
if (knownPreconditions.has(key)) {
|
|
1166
1438
|
out.precondition_checks[key] = val === "ok" || val === true || val === "true";
|
|
@@ -1170,7 +1442,20 @@ function normalizeSubmission(submission, playbook) {
|
|
|
1170
1442
|
const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
|
|
1171
1443
|
out.artifacts[aid] = { value: val.value, captured: val.captured !== false };
|
|
1172
1444
|
if (val.indicator && val.result !== undefined) {
|
|
1173
|
-
|
|
1445
|
+
const newVerdict = canonicalizeOutcome(val.result);
|
|
1446
|
+
if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
|
|
1447
|
+
// Collision: a prior observation already set this indicator.
|
|
1448
|
+
// Record the prior (which is now discarded) into the collision
|
|
1449
|
+
// log, then overwrite with the new one (last-write-wins).
|
|
1450
|
+
out._signal_origins_collisions.push({
|
|
1451
|
+
indicator_id: val.indicator,
|
|
1452
|
+
source_observation_key: out._signal_origins[val.indicator],
|
|
1453
|
+
verdict: out.signal_overrides[val.indicator],
|
|
1454
|
+
discarded: true,
|
|
1455
|
+
replaced_by: key
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
out.signal_overrides[val.indicator] = newVerdict;
|
|
1174
1459
|
out._signal_origins[val.indicator] = key;
|
|
1175
1460
|
}
|
|
1176
1461
|
}
|
|
@@ -1244,16 +1529,70 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
1244
1529
|
// Cross-process mutex lock for this run. preflight verified no other lock
|
|
1245
1530
|
// exists; we acquire ours and release in the finally block.
|
|
1246
1531
|
const lockPath = acquireLock(playbookId);
|
|
1532
|
+
// E12: parse the playbook once at run() entry and thread the parsed object
|
|
1533
|
+
// through each phase via runOpts._playbookCache. Each phase otherwise calls
|
|
1534
|
+
// loadPlaybook() independently; for a single run that's seven reads + parses
|
|
1535
|
+
// of the same file. Cached version saves the redundant I/O + JSON parses.
|
|
1536
|
+
const cachedRunOpts = { ...runOpts, _playbookCache: playbook };
|
|
1537
|
+
// E3: run-time error accumulator for evalCondition regex failures and other
|
|
1538
|
+
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
1539
|
+
const runErrors = [];
|
|
1540
|
+
cachedRunOpts._runErrors = runErrors;
|
|
1541
|
+
// E6: phases the runner should SKIP execution for, based on skip_phase
|
|
1542
|
+
// preconditions surfaced in preflight.issues.
|
|
1543
|
+
const skipPhases = new Set();
|
|
1544
|
+
for (const issue of (pre.issues || [])) {
|
|
1545
|
+
if (issue.kind === 'precondition_skip' && issue.skip_phase) {
|
|
1546
|
+
skipPhases.add(issue.skip_phase);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1247
1549
|
try {
|
|
1248
1550
|
const phases = {
|
|
1249
|
-
govern: govern(playbookId, directiveId,
|
|
1250
|
-
direct: direct(playbookId, directiveId),
|
|
1251
|
-
look: look(playbookId, directiveId,
|
|
1252
|
-
detect: detect(playbookId, directiveId, agentSubmission),
|
|
1551
|
+
govern: govern(playbookId, directiveId, cachedRunOpts),
|
|
1552
|
+
direct: direct(playbookId, directiveId, cachedRunOpts),
|
|
1553
|
+
look: look(playbookId, directiveId, cachedRunOpts),
|
|
1253
1554
|
};
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1555
|
+
if (skipPhases.has('detect')) {
|
|
1556
|
+
const skipIssue = (pre.issues || []).find(i => i.kind === 'precondition_skip' && i.skip_phase === 'detect');
|
|
1557
|
+
phases.detect = {
|
|
1558
|
+
phase: 'detect',
|
|
1559
|
+
playbook_id: playbookId,
|
|
1560
|
+
directive_id: directiveId,
|
|
1561
|
+
skipped: true,
|
|
1562
|
+
reason: skipIssue ? skipIssue.id : 'precondition_skip',
|
|
1563
|
+
classification: 'skipped',
|
|
1564
|
+
indicators: [],
|
|
1565
|
+
false_positive_checks_required: [],
|
|
1566
|
+
indicators_evaluated: [],
|
|
1567
|
+
indicators_evaluated_count: 0,
|
|
1568
|
+
observations_received: [],
|
|
1569
|
+
signals_received: []
|
|
1570
|
+
};
|
|
1571
|
+
// analyze() must still run, but with an empty submission so it doesn't
|
|
1572
|
+
// resolve indicator hits against a non-existent detect result.
|
|
1573
|
+
phases.analyze = analyze(playbookId, directiveId, phases.detect, {}, cachedRunOpts);
|
|
1574
|
+
// Annotate analyze with the skip vocabulary so consumers can branch.
|
|
1575
|
+
phases.analyze.classification = 'skipped';
|
|
1576
|
+
} else {
|
|
1577
|
+
phases.detect = detect(playbookId, directiveId, agentSubmission, cachedRunOpts);
|
|
1578
|
+
phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {}, cachedRunOpts);
|
|
1579
|
+
}
|
|
1580
|
+
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
|
|
1581
|
+
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
|
|
1582
|
+
|
|
1583
|
+
// E3: analyze() already sliced runOpts._runErrors into
|
|
1584
|
+
// phases.analyze.runtime_errors at return time. Validate + close may
|
|
1585
|
+
// have pushed additional regex errors AFTER analyze returned; surface
|
|
1586
|
+
// those onto phases.analyze.runtime_errors so the field reflects every
|
|
1587
|
+
// regex failure in the run. De-dupe by JSON shape so the analyze-time
|
|
1588
|
+
// snapshot doesn't double-count.
|
|
1589
|
+
if (runErrors.length && phases.analyze) {
|
|
1590
|
+
const existing = new Set((phases.analyze.runtime_errors || []).map(e => JSON.stringify(e)));
|
|
1591
|
+
const additions = runErrors.filter(e => !existing.has(JSON.stringify(e)));
|
|
1592
|
+
if (additions.length) {
|
|
1593
|
+
phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1257
1596
|
|
|
1258
1597
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
1259
1598
|
const evidenceHash = crypto.createHash('sha256')
|
|
@@ -1340,7 +1679,23 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
1340
1679
|
if (m) {
|
|
1341
1680
|
const val = resolvePath(ctx, m[1]);
|
|
1342
1681
|
if (typeof val !== 'string') return false;
|
|
1343
|
-
|
|
1682
|
+
// E3: an operator-supplied or playbook-supplied regex with a syntax bug
|
|
1683
|
+
// (or pathological backtracking) must NOT crash the engine mid-analyze.
|
|
1684
|
+
// Catch construction + test exceptions, return false, and push a
|
|
1685
|
+
// structured _regex_eval_error into ctx._runErrors (when present) so
|
|
1686
|
+
// analyze() can surface analyze.runtime_errors[] without losing the
|
|
1687
|
+
// diagnostic.
|
|
1688
|
+
try {
|
|
1689
|
+
return new RegExp(m[2], 'i').test(val);
|
|
1690
|
+
} catch (e) {
|
|
1691
|
+
const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
|
|
1692
|
+
// Two sites where ctx may carry an accumulator: runOpts._runErrors
|
|
1693
|
+
// (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
|
|
1694
|
+
// form; fall back to ctx.
|
|
1695
|
+
if (ctx && Array.isArray(ctx._runErrors)) ctx._runErrors.push(errorRec);
|
|
1696
|
+
else if (playbook && Array.isArray(playbook._runErrors)) playbook._runErrors.push(errorRec);
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1344
1699
|
}
|
|
1345
1700
|
|
|
1346
1701
|
if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
|
|
@@ -1398,13 +1753,35 @@ function stripOuterParens(expr) {
|
|
|
1398
1753
|
return expr;
|
|
1399
1754
|
}
|
|
1400
1755
|
|
|
1401
|
-
|
|
1756
|
+
/**
|
|
1757
|
+
* Compute the start instant for a jurisdictional clock event. The agent
|
|
1758
|
+
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
1759
|
+
* incident-response milestones.
|
|
1760
|
+
*
|
|
1761
|
+
* E7: per AGENTS.md Phase 7, the legal contract is that the clock starts
|
|
1762
|
+
* from OPERATOR AWARENESS — not from the moment the engine emits a
|
|
1763
|
+
* `detected` classification. Pre-fix, this auto-stamped Date.now() on
|
|
1764
|
+
* detect_confirmed whenever the engine classified as detected, which is
|
|
1765
|
+
* incorrect: the operator may not have seen the result yet. The corrected
|
|
1766
|
+
* semantics:
|
|
1767
|
+
*
|
|
1768
|
+
* - If the agent explicitly submits clock_started_at_<event>: use it.
|
|
1769
|
+
* - Otherwise, for 'detect_confirmed' with classification='detected':
|
|
1770
|
+
* stamp `now` ONLY if runOpts.operator_consent?.explicit === true
|
|
1771
|
+
* (i.e. the operator passed --ack). Without --ack, return null and
|
|
1772
|
+
* the caller (close()) surfaces clock_pending_ack: true on the
|
|
1773
|
+
* notification_actions entry so the operator sees that the clock is
|
|
1774
|
+
* waiting on acknowledgement.
|
|
1775
|
+
* - All other events without an explicit timestamp: return null.
|
|
1776
|
+
*/
|
|
1777
|
+
function computeClockStart(eventName, agentSignals, runOpts = {}) {
|
|
1402
1778
|
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
1403
1779
|
const key = `clock_started_at_${eventName}`;
|
|
1404
|
-
if (agentSignals[key]) return new Date(agentSignals[key]);
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
if (eventName === 'detect_confirmed' && agentSignals
|
|
1780
|
+
if (agentSignals && agentSignals[key]) return new Date(agentSignals[key]);
|
|
1781
|
+
// For detect_confirmed: only auto-stamp when the operator has explicitly
|
|
1782
|
+
// acknowledged the result via --ack. Otherwise leave the clock pending.
|
|
1783
|
+
if (eventName === 'detect_confirmed' && agentSignals?.detection_classification === 'detected'
|
|
1784
|
+
&& runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
|
|
1408
1785
|
return new Date();
|
|
1409
1786
|
}
|
|
1410
1787
|
return null;
|