@blamejs/exceptd-skills 0.12.22 → 0.12.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +47 -1
- package/CONTEXT.md +126 -69
- package/README.md +7 -7
- package/bin/exceptd.js +437 -347
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +15 -1
- package/lib/auto-discovery.js +2 -2
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/lint-skills.js +5 -5
- package/lib/playbook-runner.js +296 -297
- package/lib/prefetch.js +21 -21
- package/lib/refresh-external.js +15 -18
- package/lib/refresh-network.js +33 -12
- package/lib/scoring.js +8 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +36 -26
- package/manifest.json +40 -40
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +18 -18
package/lib/playbook-runner.js
CHANGED
|
@@ -48,7 +48,7 @@ const path = require('path');
|
|
|
48
48
|
const os = require('os');
|
|
49
49
|
const crypto = require('crypto');
|
|
50
50
|
|
|
51
|
-
//
|
|
51
|
+
// cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
|
|
52
52
|
// JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
|
|
53
53
|
// failure, returns an empty stub, and accumulates the error in
|
|
54
54
|
// getLoadErrors(). run() probes for accumulated load errors and returns
|
|
@@ -104,7 +104,7 @@ function loadPlaybook(playbookId) {
|
|
|
104
104
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
107
|
+
// Per-run playbook cache. Each phase function reads runOpts._playbookCache
|
|
108
108
|
// before falling back to loadPlaybook(). run() sets _playbookCache once at
|
|
109
109
|
// entry so seven phases share one disk read + JSON parse instead of seven.
|
|
110
110
|
|
|
@@ -149,16 +149,17 @@ function deepMerge(a, b) {
|
|
|
149
149
|
* 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
|
|
150
150
|
* AND with the filesystem lockfile dir blocks the run.
|
|
151
151
|
*
|
|
152
|
-
*
|
|
152
|
+
* When runOpts.strictPreconditions === true, warn-level outcomes
|
|
153
153
|
* (precondition_warn, precondition_unverified with on_fail=warn or
|
|
154
|
-
* skip_phase) are ESCALATED to halts. The function returns ok:false
|
|
155
|
-
* blocked_by='precondition' and an issues array containing
|
|
154
|
+
* skip_phase) are ESCALATED to halts. The function returns ok:false
|
|
155
|
+
* with blocked_by='precondition' and an issues array containing
|
|
156
156
|
* precondition_halt entries. Callers wanting "CI gate: any unverified
|
|
157
157
|
* precondition is a failure" pass strictPreconditions=true.
|
|
158
158
|
*
|
|
159
|
-
*
|
|
159
|
+
* When a precondition with on_fail='skip_phase' fails, the issue carries
|
|
160
160
|
* skip_phase: 'detect' (default) so run() can route to a skipped-phase
|
|
161
|
-
* placeholder rather than executing detect against a missing
|
|
161
|
+
* placeholder rather than executing detect against a missing
|
|
162
|
+
* prerequisite.
|
|
162
163
|
*/
|
|
163
164
|
function preflight(playbook, runOpts = {}) {
|
|
164
165
|
const issues = [];
|
|
@@ -185,7 +186,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
185
186
|
if (submitted === undefined) {
|
|
186
187
|
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
188
|
if (strict) {
|
|
188
|
-
//
|
|
189
|
+
// strictPreconditions promotes unverified to halt regardless of
|
|
189
190
|
// declared on_fail.
|
|
190
191
|
issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
|
|
191
192
|
return {
|
|
@@ -213,7 +214,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
213
214
|
return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
|
|
214
215
|
}
|
|
215
216
|
if (strict) {
|
|
216
|
-
//
|
|
217
|
+
// Warn-level + skip_phase outcomes escalate to halt under strict.
|
|
217
218
|
issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
|
|
218
219
|
return {
|
|
219
220
|
ok: false,
|
|
@@ -223,7 +224,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
223
224
|
};
|
|
224
225
|
}
|
|
225
226
|
if (pc.on_fail === 'skip_phase') {
|
|
226
|
-
//
|
|
227
|
+
// Emit a skip_phase field so run() can route to a skipped-phase
|
|
227
228
|
// placeholder. Default target phase is 'detect' (the most common
|
|
228
229
|
// skip target — preconditions typically gate host-side detection).
|
|
229
230
|
// Playbooks may override via pc.skip_phase.
|
|
@@ -266,11 +267,11 @@ function preflight(playbook, runOpts = {}) {
|
|
|
266
267
|
return { ok: true, issues };
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
//
|
|
270
|
+
// lockDir lives at a stable global path so two CLI invocations from
|
|
270
271
|
// different working directories still share lock state for cross-process
|
|
271
|
-
// mutex enforcement.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
272
|
+
// mutex enforcement. A process.cwd()-relative dir would let invocations
|
|
273
|
+
// from /tmp and from /home/user/project simultaneously each see an empty
|
|
274
|
+
// locks dir and both run unchallenged. The path
|
|
274
275
|
// keys on os.platform() so Windows/macOS/Linux locks live under separate
|
|
275
276
|
// directories (avoids cross-platform stale-PID confusion when a host is
|
|
276
277
|
// shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
|
|
@@ -287,13 +288,14 @@ function lockFilePath(playbookId) {
|
|
|
287
288
|
catch { return null; }
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
291
|
+
// Same-PID stale-lockfile reclaim threshold. A same-process orphan (e.g.
|
|
292
|
+
// an earlier run() that crashed without unlinking, or a try/catch that
|
|
292
293
|
// swallowed the release) older than this is presumed dead and reclaimed.
|
|
293
|
-
// 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
|
|
294
|
-
// no legitimate playbook hold reaches it (govern/look/run phases
|
|
295
|
-
// well inside one second per playbook), short enough that a
|
|
296
|
-
// recovers within one CI step rather than the rest of its
|
|
294
|
+
// 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
|
|
295
|
+
// that no legitimate playbook hold reaches it (govern/look/run phases
|
|
296
|
+
// complete well inside one second per playbook), short enough that a
|
|
297
|
+
// wedged process recovers within one CI step rather than the rest of its
|
|
298
|
+
// lifetime.
|
|
297
299
|
const STALE_LOCK_MS = 30_000;
|
|
298
300
|
|
|
299
301
|
function acquireLock(playbookId) {
|
|
@@ -308,16 +310,14 @@ function acquireLock(playbookId) {
|
|
|
308
310
|
writePayload();
|
|
309
311
|
return p;
|
|
310
312
|
} catch (e) {
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// failed because the lock is genuinely held (not because the FS is
|
|
320
|
-
// broken or the playbook id is malformed).
|
|
313
|
+
// Stale-PID reclaim. Without it, a process that crashed mid-run
|
|
314
|
+
// leaves its lockfile behind and every subsequent invocation runs
|
|
315
|
+
// UNLOCKED. Mirror withCatalogLock's pattern: parse the recorded pid,
|
|
316
|
+
// probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
|
|
317
|
+
// unlink and retry once. EPERM (alive, different user) or any other
|
|
318
|
+
// condition: leave the lock alone and return null with a diagnostic so
|
|
319
|
+
// the caller knows acquisition failed because the lock is genuinely
|
|
320
|
+
// held (not because the FS is broken or the playbook id is malformed).
|
|
321
321
|
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
322
322
|
try {
|
|
323
323
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -331,13 +331,13 @@ function acquireLock(playbookId) {
|
|
|
331
331
|
try { fs.unlinkSync(p); } catch {}
|
|
332
332
|
try { writePayload(); return p; } catch { /* fall through */ }
|
|
333
333
|
}
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// (e.g. nested run() within one process) must still return null
|
|
338
|
-
// the caller knows the lock is held. A fresh same-PID lockfile
|
|
339
|
-
// reentrancy; one older than STALE_LOCK_MS is an orphan from
|
|
340
|
-
// crashed prior hold (or a try/catch that swallowed the release)
|
|
334
|
+
// Same-PID stale-lockfile reclaim. If the recorded pid is ours,
|
|
335
|
+
// the only way to escape an orphaned same-process lockfile is by
|
|
336
|
+
// mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
|
|
337
|
+
// (e.g. nested run() within one process) must still return null
|
|
338
|
+
// so the caller knows the lock is held. A fresh same-PID lockfile
|
|
339
|
+
// is reentrancy; one older than STALE_LOCK_MS is an orphan from
|
|
340
|
+
// a crashed prior hold (or a try/catch that swallowed the release)
|
|
341
341
|
// and must be reclaimed — otherwise the process can never acquire
|
|
342
342
|
// this lock again for the rest of its lifetime.
|
|
343
343
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
@@ -359,9 +359,9 @@ function acquireLock(playbookId) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
362
|
+
// Callers needing to distinguish "couldn't acquire because the lock is
|
|
363
|
+
// genuinely held by a live process" from "couldn't acquire because of an
|
|
364
|
+
// unexpected error" can use this thin diagnostic wrapper.
|
|
365
365
|
// Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
|
|
366
366
|
// The bare `acquireLock` keeps its historical null-on-failure contract.
|
|
367
367
|
function acquireLockDiagnostic(playbookId) {
|
|
@@ -394,10 +394,10 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
394
394
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
|
-
//
|
|
397
|
+
// Same-PID stale-lockfile reclaim (diagnostic variant). Same
|
|
398
398
|
// semantics as in acquireLock: a same-process lockfile older than
|
|
399
|
-
// STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
|
|
400
|
-
// legitimate reentrancy and stays held.
|
|
399
|
+
// STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
|
|
400
|
+
// is legitimate reentrancy and stays held.
|
|
401
401
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
402
402
|
let mtimeMs = null;
|
|
403
403
|
try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
|
|
@@ -441,7 +441,7 @@ function pidAlive(pid) {
|
|
|
441
441
|
function govern(playbookId, directiveId, runOpts = {}) {
|
|
442
442
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
443
443
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
444
|
-
//
|
|
444
|
+
// Sort jurisdiction obligations by window_hours ascending so the
|
|
445
445
|
// tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
|
|
446
446
|
// first. Operators reading the govern output for ack-time briefing need
|
|
447
447
|
// the most urgent clock at the top of the list.
|
|
@@ -557,7 +557,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
557
557
|
return null; // truly unknown — fall through
|
|
558
558
|
};
|
|
559
559
|
|
|
560
|
-
//
|
|
560
|
+
// Per-indicator FP-check attestation map. Operators submit
|
|
561
561
|
// signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
|
|
562
562
|
// to declare which named false_positive_checks_required[] entries on the
|
|
563
563
|
// indicator have been satisfied. An unverified FP check downgrades the
|
|
@@ -572,12 +572,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
572
572
|
let fpChecksUnsatisfied = null;
|
|
573
573
|
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
574
574
|
verdict = override;
|
|
575
|
-
//
|
|
575
|
+
// Gate 'hit' verdict on per-indicator false_positive_checks_required
|
|
576
576
|
// satisfaction. The FP-check attestation arrives as a sibling key
|
|
577
577
|
// '<id>__fp_checks' in signal_overrides; default behavior (no
|
|
578
578
|
// attestation) treats every required FP check as UNSATISFIED.
|
|
579
579
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
580
|
-
//
|
|
580
|
+
// A hostile or buggy attestation may be a Proxy whose property
|
|
581
581
|
// accessors throw. The filter below reads `att[fpName]` for each
|
|
582
582
|
// required check; an exception inside the read would crash detect()
|
|
583
583
|
// and abort the entire run. Wrap the FP-check evaluation in a
|
|
@@ -586,13 +586,14 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
586
586
|
// read) and surface a runtime_error so the operator sees why.
|
|
587
587
|
try {
|
|
588
588
|
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
589
|
-
//
|
|
589
|
+
// Arrays satisfy `typeof === 'object'` but are NOT a valid
|
|
590
590
|
// attestation map. A submission like
|
|
591
591
|
// signal_overrides: { sig__fp_checks: [true, true] }
|
|
592
|
-
// would
|
|
592
|
+
// would otherwise have its truthy entries matched via the index
|
|
593
593
|
// fallback (att['0'] === true), silently bypassing every FP-check
|
|
594
|
-
// requirement. Reject arrays explicitly so they fall through to
|
|
595
|
-
// empty-attestation branch (every required check
|
|
594
|
+
// requirement. Reject arrays explicitly so they fall through to
|
|
595
|
+
// the empty-attestation branch (every required check
|
|
596
|
+
// unsatisfied).
|
|
596
597
|
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
597
598
|
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
598
599
|
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
@@ -632,9 +633,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
632
633
|
// host AI is responsible for that). With NO captured artifacts, this is
|
|
633
634
|
// a clean empty submission — emit 'miss' so the run can reach
|
|
634
635
|
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
// 'pending_agent_run'
|
|
636
|
+
// A clean empty run with no captured artifacts must emit 'miss' so
|
|
637
|
+
// classification can reach 'not_detected'; otherwise theater_verdict
|
|
638
|
+
// stays 'pending_agent_run' indefinitely.
|
|
638
639
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
639
640
|
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
640
641
|
}
|
|
@@ -664,9 +665,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
664
665
|
// confirmed they're all benign" without this override.
|
|
665
666
|
const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
666
667
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
668
|
+
// Any override that's a non-empty string but NOT in the allowlist (e.g.
|
|
669
|
+
// 'present', 'unknown', '', ' detected ', 'Detected') surfaces as a
|
|
670
|
+
// runtime_error rather than silently falling through to engine-computed
|
|
670
671
|
// classification. Operators submitting case variants / whitespace-padded
|
|
671
672
|
// strings deserve a clear diagnostic, not a quiet downgrade. Treat the
|
|
672
673
|
// override as absent for classification purposes once recorded.
|
|
@@ -684,17 +685,17 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
684
685
|
}
|
|
685
686
|
const override = overrideIsInAllowlist ? rawOverride : undefined;
|
|
686
687
|
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
688
|
+
// Refuse ALL classification overrides (`detected`, `clean`,
|
|
689
|
+
// `not_detected`) when any indicator was FP-downgraded. A submission
|
|
690
|
+
// that maps to `'not_detected'` (either literally or via `'clean'`,
|
|
691
|
+
// which maps to `'not_detected'` at this site) MUST NOT hide a
|
|
692
|
+
// `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
|
|
693
|
+
// were unattested — that's a strictly worse false-negative outcome than
|
|
694
|
+
// allowing 'detected' through. Substitute 'inconclusive' and emit a
|
|
695
|
+
// runtime_error.
|
|
696
|
+
// Record indicator IDs and an unsatisfied-checks count ONLY — never the
|
|
697
|
+
// literal FP-check check-name strings (those are an attestation-bypass
|
|
698
|
+
// hint for a hostile agent reading the runtime_errors).
|
|
698
699
|
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
699
700
|
|
|
700
701
|
let classification;
|
|
@@ -753,7 +754,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
753
754
|
indicators_evaluated_count: indicatorResults.length,
|
|
754
755
|
classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
755
756
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
756
|
-
//
|
|
757
|
+
// Pass through any flat-shape observation collisions detected at
|
|
757
758
|
// normalize time so analyze() can publish them under
|
|
758
759
|
// analyze.signal_origins_with_collisions.
|
|
759
760
|
_signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
|
|
@@ -813,14 +814,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
813
814
|
const cveRefs = playbook.domain.cve_refs || [];
|
|
814
815
|
const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
|
|
815
816
|
: (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
|
|
816
|
-
//
|
|
817
|
+
// Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
|
|
817
818
|
// (not_affected / false_positive) from "keep but annotate" dispositions
|
|
818
819
|
// (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
|
|
819
820
|
// is computed below from agentSignals.vex_fixed when the operator passes
|
|
820
821
|
// it (CLI populates it from the VEX doc alongside vex_filter).
|
|
821
822
|
const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
|
|
822
823
|
: (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
|
|
823
|
-
//
|
|
824
|
+
// Wrap xref.byCve() so a corrupt catalog (or transient missing-index
|
|
824
825
|
// anomaly) surfaces as a runtime_error rather than crashing analyze().
|
|
825
826
|
const _byCveSafe = (id) => {
|
|
826
827
|
try { return xref.byCve(id); }
|
|
@@ -838,7 +839,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
838
839
|
const vexDropped = vexFilter
|
|
839
840
|
? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
|
|
840
841
|
: [];
|
|
841
|
-
//
|
|
842
|
+
// VEX-fixed CVEs remain in matched/catalog arrays but get annotated
|
|
842
843
|
// with vex_status:'fixed' downstream so consumers see them as resolved.
|
|
843
844
|
const vexFixedIds = vexFixed
|
|
844
845
|
? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
@@ -875,7 +876,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
875
876
|
}
|
|
876
877
|
}
|
|
877
878
|
|
|
878
|
-
//
|
|
879
|
+
// Indicator-level cve_ref correlation. Indicators may declare a
|
|
879
880
|
// cve_ref (string OR string[]) naming CVEs whose presence the indicator
|
|
880
881
|
// pattern-matches. When such an indicator fires AND the named CVE exists
|
|
881
882
|
// in the catalog, the CVE joins matched_cves with correlated_via=
|
|
@@ -914,7 +915,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
914
915
|
// carry a non-null correlated_via array; catalog_baseline_cves entries
|
|
915
916
|
// carry correlated_via:null and a `note` clarifying the field's intent.
|
|
916
917
|
const cveShape = (c, correlatedVia) => {
|
|
917
|
-
//
|
|
918
|
+
// Annotate VEX-fixed CVEs with vex_status. matched_cves still
|
|
918
919
|
// includes them so audit trails and SBOM reports surface "we know this
|
|
919
920
|
// is in scope but vendor declared it fixed."
|
|
920
921
|
const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
|
|
@@ -951,26 +952,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
951
952
|
|
|
952
953
|
// RWEP composition: start from the per-CVE rwep_score of evidence-correlated
|
|
953
954
|
// matches (NOT catalog baseline) so RWEP base reflects what the operator's
|
|
954
|
-
// evidence actually surfaced.
|
|
955
|
-
//
|
|
955
|
+
// evidence actually surfaced. The "max" reduction across matched CVEs is
|
|
956
|
+
// intentional — RWEP is a "worst-case real-world exploit priority", not
|
|
956
957
|
// an arithmetic average. The most-exploitable CVE in the set drives the
|
|
957
958
|
// base; secondary CVEs add via rwep_inputs adjustments below rather than
|
|
958
959
|
// through base summing (which would double-count overlapping risk).
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
960
|
+
// vex_status='fixed' CVEs do NOT drive the base — vendor declared them
|
|
961
|
+
// resolved. They still appear in matched_cves for audit traceability but
|
|
962
|
+
// don't elevate RWEP.
|
|
962
963
|
const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
|
|
963
964
|
const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
|
|
964
965
|
|
|
965
|
-
//
|
|
966
|
-
// matched CVE having a corresponding attribute.
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
966
|
+
// rwep_factor semantics: each rwep_input.weight is conditional on the
|
|
967
|
+
// matched CVE having a corresponding attribute. Multiply weight by a
|
|
968
|
+
// factor in [0, 1] derived from the first matched CVE's catalog
|
|
969
|
+
// attribute so a weight only fires when its CVE-attribute supports it
|
|
970
|
+
// (e.g. active_exploitation +25 only when the matched CVE is under
|
|
971
|
+
// active exploitation). blast_radius is sourced from the analyze-phase
|
|
972
|
+
// blast_radius_score / 5 (rubric ceiling). Negative weights
|
|
973
|
+
// (patch_available, live_patch_available) keep their sign so a patched
|
|
974
|
+
// CVE deducts the full magnitude when the catalog confirms a
|
|
974
975
|
// patch is available.
|
|
975
976
|
//
|
|
976
977
|
// Aliasing: playbooks ship rwep_factor values `public_poc` and
|
|
@@ -1018,12 +1019,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1018
1019
|
}
|
|
1019
1020
|
};
|
|
1020
1021
|
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1022
|
+
// blast_radius_score validation. No supplied value → null +
|
|
1023
|
+
// signal='default'. Supplied value out of [0,5] → null +
|
|
1024
|
+
// signal='rejected' + runtime_error. Supplied value in range → use it +
|
|
1025
|
+
// signal='supplied'. The runner never defaults to a rubric entry — that
|
|
1026
|
+
// would be the opposite of safe-default when the rubric's lowest entry
|
|
1027
|
+
// is the LOWEST-blast row.
|
|
1027
1028
|
const blastRubric = an.blast_radius_model?.scoring_rubric || [];
|
|
1028
1029
|
let blastRadiusScore = null;
|
|
1029
1030
|
let blastRadiusSignal = 'default';
|
|
@@ -1040,7 +1041,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1040
1041
|
}
|
|
1041
1042
|
}
|
|
1042
1043
|
}
|
|
1043
|
-
//
|
|
1044
|
+
// Use the first evidence-correlated CVE as the canonical attribute
|
|
1044
1045
|
// source for factor scaling. If matchedCves is empty there's no per-CVE
|
|
1045
1046
|
// evidence to gate on. v0.12.15: the prior fallback was
|
|
1046
1047
|
// `factorCve = null` → every factor returned 0 → catalog-shape playbooks
|
|
@@ -1133,11 +1134,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1133
1134
|
// detect.classification = inconclusive → theater_verdict = pending_agent_run
|
|
1134
1135
|
// Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
|
|
1135
1136
|
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// theater, pending_agent_run, unknown.
|
|
1137
|
+
// Validate agentSignals.theater_verdict against an allowlist so
|
|
1138
|
+
// downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
|
|
1139
|
+
// garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
|
|
1140
|
+
// present, theater, pending_agent_run, unknown.
|
|
1141
1141
|
const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
|
|
1142
1142
|
let theaterVerdict = agentSignals.theater_verdict;
|
|
1143
1143
|
if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
|
|
@@ -1193,12 +1193,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1193
1193
|
// matched_cves when surfacing "what CVEs is the operator actually
|
|
1194
1194
|
// affected by based on submitted evidence?"
|
|
1195
1195
|
catalog_baseline_cves: catalogBaselineEntries,
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1196
|
+
// rwep base is reduced via Math.max across matched CVEs. Surface the
|
|
1197
|
+
// reduction strategy as a discoverable field so operators reading the
|
|
1198
1198
|
// bundle understand the semantics without grepping source.
|
|
1199
1199
|
rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
|
|
1200
1200
|
blast_radius_score: blastRadiusScore,
|
|
1201
|
-
//
|
|
1201
|
+
// Visible annotation of where blast_radius_score came from:
|
|
1202
1202
|
// 'supplied' — operator/agent provided a value in [0, 5].
|
|
1203
1203
|
// 'default' — no value supplied; runner returned null (no rubric guess).
|
|
1204
1204
|
// 'rejected' — value supplied but out of range; treated as default + runtime_error.
|
|
@@ -1209,7 +1209,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1209
1209
|
audit_evidence: an.compliance_theater_check?.audit_evidence,
|
|
1210
1210
|
reality_test: an.compliance_theater_check?.reality_test,
|
|
1211
1211
|
verdict: theaterVerdict,
|
|
1212
|
-
//
|
|
1212
|
+
// Render verdict_text for both 'theater' AND 'present' verdicts
|
|
1213
1213
|
// ('present' is a synonym used by some playbooks for "theater is here").
|
|
1214
1214
|
verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
|
|
1215
1215
|
? an.compliance_theater_check?.theater_verdict_if_gap
|
|
@@ -1231,14 +1231,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1231
1231
|
? `${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.`
|
|
1232
1232
|
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
|
|
1233
1233
|
} : null,
|
|
1234
|
-
//
|
|
1234
|
+
// Regex-eval failures surfaced here so operators can see WHICH
|
|
1235
1235
|
// condition expression crashed without the runner dying. Only present
|
|
1236
1236
|
// when at least one evalCondition() call hit a regex exception during
|
|
1237
1237
|
// this analyze pass; runOpts._runErrors is the same accumulator
|
|
1238
1238
|
// populated by run() across all phases, so callers reading this field
|
|
1239
1239
|
// see every regex problem in the run.
|
|
1240
1240
|
runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
|
|
1241
|
-
//
|
|
1241
|
+
// Collisions when two flat-shape observations targeted the same
|
|
1242
1242
|
// indicator id. Empty when there were no collisions or no flat-shape
|
|
1243
1243
|
// observations submitted.
|
|
1244
1244
|
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() : [])
|
|
@@ -1248,8 +1248,8 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1248
1248
|
/**
|
|
1249
1249
|
* Extract VEX disposition sets from a CycloneDX/OpenVEX document.
|
|
1250
1250
|
*
|
|
1251
|
-
*
|
|
1252
|
-
* "drop" set
|
|
1251
|
+
* OpenVEX `fixed` and `not_affected` must NOT collapse into a single
|
|
1252
|
+
* "drop" set — they have different semantics:
|
|
1253
1253
|
*
|
|
1254
1254
|
* - not_affected / false_positive → drop from matched_cves entirely.
|
|
1255
1255
|
* The vendor has formally declared the product not vulnerable; the CVE
|
|
@@ -1298,7 +1298,7 @@ function vexFilterFromDoc(doc) {
|
|
|
1298
1298
|
|
|
1299
1299
|
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
1300
1300
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1301
|
-
//
|
|
1301
|
+
// Surface evalCondition regex errors raised here into the same
|
|
1302
1302
|
// run-wide accumulator that analyze() reads.
|
|
1303
1303
|
const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
1304
1304
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
@@ -1322,7 +1322,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1322
1322
|
// weren't verified — the agent can surface that to the operator.
|
|
1323
1323
|
if (!selected && paths.length) selected = paths[0];
|
|
1324
1324
|
|
|
1325
|
-
//
|
|
1325
|
+
// selected_remediation selection logic:
|
|
1326
1326
|
// 1. Iterate remediation_paths sorted by priority ASC (lower number =
|
|
1327
1327
|
// higher priority per schema convention).
|
|
1328
1328
|
// 2. Pick the FIRST path whose every precondition (evaluated against
|
|
@@ -1335,18 +1335,17 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1335
1335
|
// precondition trace so operators can see why a higher-priority path was
|
|
1336
1336
|
// skipped.
|
|
1337
1337
|
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
// unparseable. Preserve backwards compatibility by keeping
|
|
1338
|
+
// Regression schedule. Returns a structured object with next_run +
|
|
1339
|
+
// event_triggers + unparseable. Backwards compatibility: keep
|
|
1341
1340
|
// regression_next_run as the ISO string (or null) so existing CSAF /
|
|
1342
1341
|
// attestation consumers don't break; expose the structured form
|
|
1343
1342
|
// separately.
|
|
1344
1343
|
const triggers = v.regression_trigger || [];
|
|
1345
1344
|
const regressionResult = computeRegressionNextRun(triggers);
|
|
1346
1345
|
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1346
|
+
// Reason annotation for null next_run — operators see WHY a schedule
|
|
1347
|
+
// didn't emit a calendar date (no day intervals declared, every trigger
|
|
1348
|
+
// is event-driven, or every trigger was unparseable).
|
|
1350
1349
|
let nextRunReason = null;
|
|
1351
1350
|
if (!regressionResult.next_run) {
|
|
1352
1351
|
if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
|
|
@@ -1377,15 +1376,15 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1377
1376
|
}
|
|
1378
1377
|
|
|
1379
1378
|
/**
|
|
1380
|
-
*
|
|
1379
|
+
* Extended interval parser. Supports:
|
|
1381
1380
|
* <N>d — N days
|
|
1382
1381
|
* <N>wk — N weeks
|
|
1383
1382
|
* <N>mo — N calendar months (Date.setMonth semantics)
|
|
1384
1383
|
* <N>yr — N calendar years
|
|
1385
1384
|
* on_event — event-triggered, no date computed; surfaces in
|
|
1386
1385
|
* regression_event_triggers[] for the consumer.
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1386
|
+
* Without all five forms, a playbook declaring "regression on every
|
|
1387
|
+
* release" or
|
|
1389
1388
|
* "monthly review" lost its schedule entry.
|
|
1390
1389
|
*/
|
|
1391
1390
|
function parseInterval(intervalStr, now) {
|
|
@@ -1473,12 +1472,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1473
1472
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1474
1473
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1475
1474
|
);
|
|
1476
|
-
//
|
|
1475
|
+
// Thread runOpts through so computeClockStart can check
|
|
1477
1476
|
// operator_consent.explicit before auto-stamping detect_confirmed.
|
|
1478
1477
|
const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
|
|
1479
|
-
//
|
|
1480
|
-
// matched AND the operator did NOT pass --ack, surface
|
|
1481
|
-
// so the notification record is visibly waiting on
|
|
1478
|
+
// When the clock event is detect_confirmed AND the classification
|
|
1479
|
+
// matched AND the operator did NOT pass --ack, surface
|
|
1480
|
+
// clock_pending_ack so the notification record is visibly waiting on
|
|
1481
|
+
// acknowledgement.
|
|
1482
1482
|
const clockPendingAck = !clockStart
|
|
1483
1483
|
&& obligation?.clock_starts === 'detect_confirmed'
|
|
1484
1484
|
&& agentSignals?.detection_classification === 'detected'
|
|
@@ -1504,13 +1504,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1504
1504
|
// Evidence the regulator expects attached (from the obligation, not
|
|
1505
1505
|
// just the operator-facing recipient bundle on the notification entry).
|
|
1506
1506
|
evidence_required: obligation?.evidence_required || na.evidence_attached || [],
|
|
1507
|
-
//
|
|
1507
|
+
// Track missing interpolation variables so operators see exactly
|
|
1508
1508
|
// which template vars failed to resolve. Empty array when all
|
|
1509
1509
|
// placeholders rendered cleanly.
|
|
1510
1510
|
...(function () {
|
|
1511
1511
|
const missing = [];
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1512
|
+
// analyzeFindingShape is a pure transform but defensive-wrap it
|
|
1513
|
+
// so a malformed analyze result (missing matched_cves, etc.)
|
|
1514
1514
|
// can't bring down the whole close phase. Failures surface in
|
|
1515
1515
|
// runtime_errors via runOpts._runErrors when available.
|
|
1516
1516
|
let findingShape;
|
|
@@ -1564,13 +1564,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1564
1564
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
1565
1565
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
1566
1566
|
: [];
|
|
1567
|
-
//
|
|
1568
|
-
// bundles_by_format[primary]
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1567
|
+
// Build every bundle once and reuse, so bundle_body and
|
|
1568
|
+
// bundles_by_format[primary] share object identity (and timestamps).
|
|
1569
|
+
// Without memoisation, buildEvidenceBundle gets invoked twice for the
|
|
1570
|
+
// primary format and each invocation crystallises a fresh Date.now() —
|
|
1571
|
+
// operators diffing bundle_body against bundles_by_format.<primary> see
|
|
1572
|
+
// spurious millisecond drift on tracking.initial_release_date /
|
|
1573
|
+
// timestamp / current_release_date.
|
|
1574
1574
|
const evidencePackage = c.evidence_package ? (() => {
|
|
1575
1575
|
const issuedAt = new Date().toISOString();
|
|
1576
1576
|
const builtFormats = new Map();
|
|
@@ -1639,8 +1639,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1639
1639
|
validate: validateResult,
|
|
1640
1640
|
finding: analyzeFindingShape(analyzeResult),
|
|
1641
1641
|
...agentSignals,
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1642
|
+
// Surface evalCondition regex failures from the feeds_into chain into
|
|
1643
|
+
// the same accumulator. Without this the regex failure happens but
|
|
1644
1644
|
// analyze.runtime_errors[] never sees it.
|
|
1645
1645
|
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
1646
1646
|
};
|
|
@@ -1665,7 +1665,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1665
1665
|
exception: exception,
|
|
1666
1666
|
regression_schedule: regressionSchedule,
|
|
1667
1667
|
feeds_into: feeds,
|
|
1668
|
-
//
|
|
1668
|
+
// feeds_into surfaces downstream playbook IDs whose preconditions
|
|
1669
1669
|
// were satisfied by this run. The runner does NOT automatically chain
|
|
1670
1670
|
// into them — the agent / operator decides whether to invoke them.
|
|
1671
1671
|
// Surface that contract on the result so consumers don't assume an
|
|
@@ -1674,7 +1674,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1674
1674
|
};
|
|
1675
1675
|
}
|
|
1676
1676
|
|
|
1677
|
-
//
|
|
1677
|
+
// Severity ladder for active_exploitation. The worst-of reduction lets
|
|
1678
1678
|
// analyzeFindingShape report the most-exploited CVE in the matched set, not
|
|
1679
1679
|
// the first-encountered one. Higher index = worse.
|
|
1680
1680
|
const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
|
|
@@ -1691,10 +1691,10 @@ function worstActiveExploitation(matchedCves) {
|
|
|
1691
1691
|
return worst || 'unknown';
|
|
1692
1692
|
}
|
|
1693
1693
|
|
|
1694
|
-
//
|
|
1695
|
-
// `finding.severity` in feeds_into and escalation_criteria conditions
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1694
|
+
// Severity ladder derived from rwep_adjusted. Playbooks reference
|
|
1695
|
+
// `finding.severity` in feeds_into and escalation_criteria conditions;
|
|
1696
|
+
// emit it so those conditions resolve against a real value rather than
|
|
1697
|
+
// undefined. Thresholds:
|
|
1698
1698
|
// rwep >= 80 → critical
|
|
1699
1699
|
// rwep >= 50 → high
|
|
1700
1700
|
// rwep >= 20 → medium
|
|
@@ -1712,22 +1712,21 @@ function analyzeFindingShape(a) {
|
|
|
1712
1712
|
const rwepAdjusted = a.rwep?.adjusted ?? 0;
|
|
1713
1713
|
return {
|
|
1714
1714
|
matched_cve_ids: matched.map(c => c.cve_id).join(', '),
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1715
|
+
// Sibling array form for consumers that want to iterate IDs without
|
|
1716
|
+
// re-splitting the joined string. The joined form stays for backwards
|
|
1717
|
+
// compatibility with notification-draft templates that interpolate
|
|
1718
|
+
// `${matched_cve_ids}` verbatim.
|
|
1719
1719
|
matched_cve_ids_array: matched.map(c => c.cve_id),
|
|
1720
1720
|
matched_cve_count: matched.length,
|
|
1721
1721
|
kev_listed_count: matched.filter(c => c.cisa_kev).length,
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
// 'suspected'
|
|
1725
|
-
//
|
|
1726
|
-
// worst rank across all matched CVEs.
|
|
1722
|
+
// Reduce active_exploitation to the worst rank across all matched
|
|
1723
|
+
// CVEs. A .find() lookup would return the first truthy entry — e.g.
|
|
1724
|
+
// 'suspected' on CVE #1 when CVE #2 is 'confirmed' — under-stating
|
|
1725
|
+
// the threat in notification drafts.
|
|
1727
1726
|
active_exploitation: worstActiveExploitation(matched),
|
|
1728
1727
|
rwep_adjusted: rwepAdjusted,
|
|
1729
1728
|
rwep_base: a.rwep?.base ?? 0,
|
|
1730
|
-
//
|
|
1729
|
+
// Severity surface for playbook conditions.
|
|
1731
1730
|
severity: severityForRwep(rwepAdjusted),
|
|
1732
1731
|
blast_radius_score: a.blast_radius_score ?? 0,
|
|
1733
1732
|
framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
|
|
@@ -1769,7 +1768,7 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
1769
1768
|
// surface at least one candidate when any is known. Returns null when no
|
|
1770
1769
|
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1771
1770
|
//
|
|
1772
|
-
//
|
|
1771
|
+
// Source segments are heterogeneous — many playbook artifacts
|
|
1773
1772
|
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
1774
1773
|
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
1775
1774
|
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
@@ -1826,24 +1825,24 @@ function getEngineVersion() {
|
|
|
1826
1825
|
return _CACHED_PKG_VERSION;
|
|
1827
1826
|
}
|
|
1828
1827
|
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1828
|
+
// Operator-supplied identity strings (--operator) and publisher namespace
|
|
1829
|
+
// URLs (--publisher-namespace) flow into operator-facing CSAF surfaces.
|
|
1830
|
+
// Strip ASCII control characters as defence in depth — bin/exceptd.js
|
|
1831
|
+
// already validates the CLI inputs, but the runner is also called from
|
|
1832
|
+
// library consumers that may bypass the CLI surface.
|
|
1834
1833
|
//
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
//
|
|
1838
|
-
//
|
|
1839
|
-
// already refuses
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
1834
|
+
// Strip Unicode bidi / format / control / surrogate / private-use /
|
|
1835
|
+
// unassigned categories (\p{C} under the `u` regex flag) so direct
|
|
1836
|
+
// library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
|
|
1837
|
+
// OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
|
|
1838
|
+
// already refuses. NFC-normalise first so a decomposed sequence can't
|
|
1839
|
+
// combine past the codepoint check; cap the result at 256 codepoints
|
|
1840
|
+
// (NOT UTF-16 code units) so a string of astral-plane codepoints can't
|
|
1841
|
+
// smuggle a longer-than-256-display string past the cap by exploiting
|
|
1842
|
+
// JavaScript's surrogate-pair string length. Returns null on rejection
|
|
1843
|
+
// (empty after strip, or NFC normalise threw); callers (the
|
|
1844
|
+
// publisher-namespace + contact_details + tracking.generator sites)
|
|
1845
|
+
// treat null as "operator-unclaimed" and route through the existing
|
|
1847
1846
|
// fallback (publisher.namespace = urn:exceptd:operator:unknown +
|
|
1848
1847
|
// bundle_publisher_unclaimed runtime warning).
|
|
1849
1848
|
function sanitizeOperatorText(s) {
|
|
@@ -1871,12 +1870,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1871
1870
|
runOpts = runOpts || {};
|
|
1872
1871
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1873
1872
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1874
|
-
//
|
|
1873
|
+
// Pin one `now` value per bundle build (and accept an
|
|
1875
1874
|
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
1876
1875
|
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
1877
1876
|
// building several formats from the same run. Without the parameter,
|
|
1878
|
-
// each invocation
|
|
1879
|
-
// versus bundles_by_format[primary]
|
|
1877
|
+
// each invocation crystallises a fresh `Date.now()` and bundle_body
|
|
1878
|
+
// versus bundles_by_format[primary] diverge on milliseconds.
|
|
1880
1879
|
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
1881
1880
|
|
|
1882
1881
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
@@ -1895,16 +1894,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1895
1894
|
name: productName,
|
|
1896
1895
|
product_identification_helper: { purl: productPurl }
|
|
1897
1896
|
}];
|
|
1898
|
-
//
|
|
1899
|
-
// disposition (vex_status === 'fixed' — see analyze()
|
|
1900
|
-
// catalog's global `live_patch_available` flag. The catalog flag
|
|
1901
|
-
// "vendor publishes a live-patch in the world", not "operator
|
|
1902
|
-
// it on this host".
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1897
|
+
// `fixed` product_status MUST reflect operator-supplied VEX
|
|
1898
|
+
// disposition (vex_status === 'fixed' — see analyze()), not the
|
|
1899
|
+
// catalog's global `live_patch_available` flag. The catalog flag
|
|
1900
|
+
// means "vendor publishes a live-patch in the world", not "operator
|
|
1901
|
+
// deployed it on this host". Declaring every live-patchable CVE as
|
|
1902
|
+
// fixed regardless of operator evidence would produce CSAF documents
|
|
1903
|
+
// that lie to downstream NVD / Red Hat dashboards. When
|
|
1904
|
+
// live_patch_available is the only signal, status stays
|
|
1905
|
+
// known_affected and the live-patch route is surfaced as a
|
|
1906
|
+
// `vendor_fix` remediation.
|
|
1908
1907
|
// CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
1909
1908
|
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
1910
1909
|
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
@@ -1936,25 +1935,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1936
1935
|
return m[1];
|
|
1937
1936
|
};
|
|
1938
1937
|
const csafIdsFor = (id) => {
|
|
1939
|
-
//
|
|
1940
|
-
// "
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
// dashboards. Return null so the caller
|
|
1944
|
-
// and
|
|
1938
|
+
// null / undefined / non-string id MUST NOT emit literal "null" /
|
|
1939
|
+
// "undefined" text into the vulnerabilities[] entry. String(id)
|
|
1940
|
+
// would coerce both to those literals; strict validators then
|
|
1941
|
+
// reject the document and operators see a phantom "null" CVE in
|
|
1942
|
+
// dashboards. Return null so the caller skips the entry entirely
|
|
1943
|
+
// and surfaces a runtime_error for the missing id.
|
|
1945
1944
|
if (typeof id !== 'string' || !id) return null;
|
|
1946
1945
|
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
1947
1946
|
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
1948
1947
|
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
1949
1948
|
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
|
|
1950
|
-
//
|
|
1951
|
-
// (https://rustsec.org); mis-routing them to system_name 'OSV'
|
|
1952
|
-
// the upstream provenance link and confuses downstream
|
|
1953
|
-
// resolve by (system_name, text) pair.
|
|
1949
|
+
// RUSTSEC advisories carry their own tracking authority
|
|
1950
|
+
// (https://rustsec.org); mis-routing them to system_name 'OSV'
|
|
1951
|
+
// loses the upstream provenance link and confuses downstream
|
|
1952
|
+
// ingesters that resolve by (system_name, text) pair.
|
|
1954
1953
|
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
// every unknown id
|
|
1954
|
+
// Genuinely-unknown prefix surfaces as `exceptd-unknown` so
|
|
1955
|
+
// downstream ingesters see that the authority wasn't recognised
|
|
1956
|
+
// rather than misattributing every unknown id to OSV.
|
|
1958
1957
|
return { system_name: 'exceptd-unknown', text: id };
|
|
1959
1958
|
};
|
|
1960
1959
|
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
@@ -1967,10 +1966,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1967
1966
|
|| (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
|
|
1968
1967
|
product_ids: [productId],
|
|
1969
1968
|
}];
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
// under ids[]. Skip the vulnerability entry entirely and
|
|
1973
|
-
// runtime_error so the catalog gap is visible to
|
|
1969
|
+
// Catalog entries with a missing / non-string cve_id would
|
|
1970
|
+
// otherwise produce literal `text: "null"` / `text: "undefined"`
|
|
1971
|
+
// entries under ids[]. Skip the vulnerability entry entirely and
|
|
1972
|
+
// surface a runtime_error so the catalog gap is visible to
|
|
1973
|
+
// operators / CI gates.
|
|
1974
1974
|
const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
|
|
1975
1975
|
let idEntry = null;
|
|
1976
1976
|
if (!idIsCve) {
|
|
@@ -1997,12 +1997,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1997
1997
|
// an authoritative "informational" score where there was simply no
|
|
1998
1998
|
// data.
|
|
1999
1999
|
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
// cvss_v3 block with version: '2.0' / '4.0', which strict
|
|
2003
|
-
// (BSI CSAF Validator) reject outright. Drop the block
|
|
2004
|
-
// vectors and surface a runtime_error so operators can
|
|
2005
|
-
// CVSS data didn't make it through.
|
|
2000
|
+
// CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
|
|
2001
|
+
// vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
|
|
2002
|
+
// cvss_v3 block with version: '2.0' / '4.0', which strict
|
|
2003
|
+
// validators (BSI CSAF Validator) reject outright. Drop the block
|
|
2004
|
+
// for non-3.x vectors and surface a runtime_error so operators can
|
|
2005
|
+
// see why their CVSS data didn't make it through.
|
|
2006
2006
|
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
2007
2007
|
const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
|
|
2008
2008
|
const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
|
|
@@ -2047,15 +2047,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2047
2047
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
2048
2048
|
product_status: { known_affected: [productId] }
|
|
2049
2049
|
}));
|
|
2050
|
-
//
|
|
2051
|
-
//
|
|
2052
|
-
// `
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2050
|
+
// Framework-gap entries land in `document.notes[]` with
|
|
2051
|
+
// `category: details` rather than `vulnerabilities[]` with
|
|
2052
|
+
// `ids: [{ system_name: 'exceptd-framework-gap' }]`. The `system_name`
|
|
2053
|
+
// slot is reserved for recognised vulnerability tracking authorities
|
|
2054
|
+
// (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
|
|
2055
|
+
// downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
|
|
2056
|
+
// validator) would flag the run for unknown ids and render
|
|
2057
|
+
// false-positive advisories at the framework_gap_mapping length.
|
|
2058
|
+
// Notes are the right home for advisory context that is not itself
|
|
2059
|
+
// a pseudo-CVE.
|
|
2059
2060
|
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
2060
2061
|
const lines = [
|
|
2061
2062
|
`Framework: ${g.framework}`,
|
|
@@ -2273,8 +2274,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2273
2274
|
} },
|
|
2274
2275
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
2275
2276
|
invocations: [{ executionSuccessful: true, properties: stripNulls({
|
|
2276
|
-
//
|
|
2277
|
-
//
|
|
2277
|
+
// Apply the stripNulls contract here too — the `remediation`
|
|
2278
|
+
// field is null for any run that didn't surface a
|
|
2278
2279
|
// selected_remediation, and SARIF viewers render null property
|
|
2279
2280
|
// values as visible empty rows. Same helper as the result
|
|
2280
2281
|
// property bags above.
|
|
@@ -2302,11 +2303,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2302
2303
|
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
2303
2304
|
// they pass IRI validation in downstream VEX consumers.
|
|
2304
2305
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2306
|
+
// Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
|
|
2307
|
+
// CSAF `document.tracking.initial_release_date` when both formats are
|
|
2308
|
+
// emitted in the same close() pass. A per-format Date.now() would
|
|
2309
|
+
// cause the two bundles in bundles_by_format to disagree on
|
|
2310
|
+
// milliseconds.
|
|
2310
2311
|
const issued = now;
|
|
2311
2312
|
const productEntry = {
|
|
2312
2313
|
'@id': productPurl,
|
|
@@ -2322,17 +2323,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2322
2323
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
2323
2324
|
return fallback;
|
|
2324
2325
|
};
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2326
|
+
// Same `vex_status === 'fixed'` correctness rule as the CSAF
|
|
2327
|
+
// emitter. The catalog `live_patch_available` flag is a global
|
|
2327
2328
|
// "vendor publishes a live-patch" signal, not an operator-host
|
|
2328
|
-
// disposition. Treating it as `status: fixed`
|
|
2329
|
-
// claim resolution
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
2333
|
-
//
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2329
|
+
// disposition. Treating it as `status: fixed` would make OpenVEX
|
|
2330
|
+
// statements claim resolution the operator hadn't attested to. VEX
|
|
2331
|
+
// consumers downstream of CISA / SBOM / supply-chain pipelines treat
|
|
2332
|
+
// `fixed` as authoritative — emitting it without operator attestation
|
|
2333
|
+
// is a downstream-misleading bug. The OpenVEX statement says
|
|
2334
|
+
// `affected` (with action_statement pointing to the remediation,
|
|
2335
|
+
// which may itself be the vendor live-patch route) unless the
|
|
2336
|
+
// operator declared `vex_status: fixed` on the matched CVE.
|
|
2336
2337
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
2337
2338
|
const stmt = {
|
|
2338
2339
|
vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
|
|
@@ -2429,11 +2430,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2429
2430
|
return { format: 'markdown', body: lines.join('\n') };
|
|
2430
2431
|
}
|
|
2431
2432
|
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2433
|
+
// The fallback must NOT leak raw analyze + validate internals (matched
|
|
2434
|
+
// CVEs, framework gaps, residual-risk statements) under an arbitrary
|
|
2435
|
+
// "format" name — operators piping output to logging or third-party
|
|
2436
|
+
// tooling could leak finding details just by typo'ing the format flag.
|
|
2437
|
+
// Return the shape advertisement only.
|
|
2437
2438
|
return {
|
|
2438
2439
|
format,
|
|
2439
2440
|
note: 'Unknown format',
|
|
@@ -2458,11 +2459,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2458
2459
|
function normalizeSubmission(submission, playbook) {
|
|
2459
2460
|
if (!submission || typeof submission !== "object") return submission || {};
|
|
2460
2461
|
|
|
2461
|
-
//
|
|
2462
|
-
// value (string "foo", array [...])
|
|
2463
|
-
// via `{ ...(submission.signal_overrides || {}) }
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2462
|
+
// signal_overrides must be a plain object. Without this guard, a
|
|
2463
|
+
// non-object value (string "foo", array [...]) is spread into
|
|
2464
|
+
// out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
|
|
2465
|
+
// — spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
|
|
2466
|
+
// which confuses detect()'s indicator-id lookup. Strip and log instead.
|
|
2466
2467
|
if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
|
|
2467
2468
|
&& (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
|
|
2468
2469
|
if (!submission._runErrors) submission._runErrors = [];
|
|
@@ -2495,13 +2496,13 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2495
2496
|
signals: { ...(submission.signals || {}) },
|
|
2496
2497
|
precondition_checks: { ...(submission.precondition_checks || {}) },
|
|
2497
2498
|
_original_shape: 'flat (v0.11.0)',
|
|
2498
|
-
//
|
|
2499
|
-
// signal_overrides_invalid) onto submission._runErrors above.
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2499
|
+
// normalizeSubmission pushes structured errors (e.g.
|
|
2500
|
+
// signal_overrides_invalid) onto submission._runErrors above. For flat
|
|
2501
|
+
// submissions the fresh `out` literal built here loses that accumulator
|
|
2502
|
+
// unless we forward it; run()'s harvest at the entry to detect/analyze
|
|
2503
|
+
// reads agentSubmission._runErrors, so without the carry, flat
|
|
2504
|
+
// submissions with invalid signal_overrides drop the errors before
|
|
2505
|
+
// they can reach analyze.runtime_errors.
|
|
2505
2506
|
...(Array.isArray(submission._runErrors) && submission._runErrors.length
|
|
2506
2507
|
? { _runErrors: submission._runErrors.slice() }
|
|
2507
2508
|
: {}),
|
|
@@ -2523,7 +2524,7 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2523
2524
|
// detect can emit `from_observation` on each indicator result. Diagnostic
|
|
2524
2525
|
// value for operators chasing "which observation drove this verdict".
|
|
2525
2526
|
//
|
|
2526
|
-
//
|
|
2527
|
+
// When two observations target the same indicator id, last-write-wins
|
|
2527
2528
|
// silently. Track discards in _signal_origins_collisions so analyze can
|
|
2528
2529
|
// surface analyze.signal_origins_with_collisions for batch evidence runs.
|
|
2529
2530
|
out._signal_origins = out._signal_origins || {};
|
|
@@ -2605,7 +2606,7 @@ function autoDetectPreconditions(submission, playbook) {
|
|
|
2605
2606
|
}
|
|
2606
2607
|
|
|
2607
2608
|
function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
2608
|
-
//
|
|
2609
|
+
// Catalog corruption surfaced at module-load blocks runs cleanly.
|
|
2609
2610
|
if (_xrefLoadError) {
|
|
2610
2611
|
return {
|
|
2611
2612
|
ok: false,
|
|
@@ -2619,7 +2620,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2619
2620
|
try {
|
|
2620
2621
|
playbook = loadPlaybook(playbookId);
|
|
2621
2622
|
} catch (e) {
|
|
2622
|
-
//
|
|
2623
|
+
// loadPlaybook failure → structured error (not crash).
|
|
2623
2624
|
return {
|
|
2624
2625
|
ok: false,
|
|
2625
2626
|
blocked_by: 'playbook_not_found',
|
|
@@ -2628,9 +2629,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2628
2629
|
};
|
|
2629
2630
|
}
|
|
2630
2631
|
|
|
2631
|
-
//
|
|
2632
|
-
// inside analyze()/findDirective() uncaught, surfacing
|
|
2633
|
-
// trace
|
|
2632
|
+
// Validate directiveId before any phase runs. An unknown id would
|
|
2633
|
+
// otherwise throw inside analyze() / findDirective() uncaught, surfacing
|
|
2634
|
+
// as a 500-style stack trace; instead return a clean structured error
|
|
2635
|
+
// with the valid directive list.
|
|
2634
2636
|
const validDirectives = (playbook.directives || []).map(d => d.id);
|
|
2635
2637
|
if (!validDirectives.includes(directiveId)) {
|
|
2636
2638
|
return {
|
|
@@ -2647,12 +2649,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2647
2649
|
// / the host platform matches — the runner can answer those itself rather
|
|
2648
2650
|
// than blocking on AI declaration.
|
|
2649
2651
|
agentSubmission = normalizeSubmission(agentSubmission, playbook);
|
|
2650
|
-
//
|
|
2652
|
+
// Capture pre-autoDetect submission preconditions so we report
|
|
2651
2653
|
// user-declared provenance, not engine-auto-resolved values.
|
|
2652
2654
|
const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
|
|
2653
2655
|
agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
|
|
2654
2656
|
|
|
2655
|
-
//
|
|
2657
|
+
// precondition_checks merge order is submission → runOpts (runOpts
|
|
2656
2658
|
// wins on collision). This is intentional: runOpts represents the most
|
|
2657
2659
|
// recent caller intent (CLI flags / programmatic injection from a host
|
|
2658
2660
|
// process), whereas submission was captured earlier during evidence
|
|
@@ -2680,38 +2682,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2680
2682
|
// Cross-process mutex lock for this run. preflight verified no other lock
|
|
2681
2683
|
// exists; we acquire ours and release in the finally block.
|
|
2682
2684
|
const lockPath = acquireLock(playbookId);
|
|
2683
|
-
//
|
|
2684
|
-
// through each phase via runOpts._playbookCache. Each phase otherwise
|
|
2685
|
-
// loadPlaybook() independently; for a single run that's seven
|
|
2686
|
-
// of the same file.
|
|
2685
|
+
// Parse the playbook once at run() entry and thread the parsed object
|
|
2686
|
+
// through each phase via runOpts._playbookCache. Each phase otherwise
|
|
2687
|
+
// calls loadPlaybook() independently; for a single run that's seven
|
|
2688
|
+
// reads + parses of the same file. Caching saves the redundant I/O +
|
|
2689
|
+
// JSON parses.
|
|
2687
2690
|
//
|
|
2688
|
-
//
|
|
2689
|
-
// cachedRunOpts.session_id.
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
//
|
|
2693
|
-
//
|
|
2691
|
+
// session_id is generated ONCE here and threaded into close() via
|
|
2692
|
+
// cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
|
|
2693
|
+
// PURLs / on-disk attestation filenames all share one identifier.
|
|
2694
|
+
// Without the single-source-of-truth, close() would mint its own id
|
|
2695
|
+
// and operators correlating attestation files to embedded bundle URNs
|
|
2696
|
+
// would see mismatches.
|
|
2694
2697
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
2695
2698
|
const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
|
|
2696
|
-
//
|
|
2699
|
+
// Run-time error accumulator for evalCondition regex failures and other
|
|
2697
2700
|
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2698
2701
|
const runErrors = [];
|
|
2699
2702
|
cachedRunOpts._runErrors = runErrors;
|
|
2700
|
-
//
|
|
2701
|
-
// signal_overrides_invalid) onto submission._runErrors.
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2706
|
-
// doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
|
|
2707
|
-
// submission and a non-deterministic _runErrors would change it).
|
|
2703
|
+
// normalizeSubmission may push structured errors (e.g.
|
|
2704
|
+
// signal_overrides_invalid) onto submission._runErrors. Splice them
|
|
2705
|
+
// into the run-level accumulator so analyze.runtime_errors[] surfaces
|
|
2706
|
+
// them, and strip the field off the submission so it doesn't pollute
|
|
2707
|
+
// the evidence_hash digest (the hash canonicalizes the submission and
|
|
2708
|
+
// a non-deterministic _runErrors would change it).
|
|
2708
2709
|
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
2709
2710
|
runErrors.push(...agentSubmission._runErrors);
|
|
2710
2711
|
}
|
|
2711
2712
|
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
2712
2713
|
delete agentSubmission._runErrors;
|
|
2713
2714
|
}
|
|
2714
|
-
//
|
|
2715
|
+
// Phases the runner should SKIP execution for, based on skip_phase
|
|
2715
2716
|
// preconditions surfaced in preflight.issues.
|
|
2716
2717
|
const skipPhases = new Set();
|
|
2717
2718
|
for (const issue of (pre.issues || [])) {
|
|
@@ -2753,7 +2754,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2753
2754
|
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
|
|
2754
2755
|
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
|
|
2755
2756
|
|
|
2756
|
-
//
|
|
2757
|
+
// analyze() already sliced runOpts._runErrors into
|
|
2757
2758
|
// phases.analyze.runtime_errors at return time. Validate + close may
|
|
2758
2759
|
// have pushed additional regex errors AFTER analyze returned; surface
|
|
2759
2760
|
// those onto phases.analyze.runtime_errors so the field reflects every
|
|
@@ -2767,14 +2768,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2767
2768
|
}
|
|
2768
2769
|
}
|
|
2769
2770
|
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
//
|
|
2773
|
-
//
|
|
2774
|
-
//
|
|
2775
|
-
//
|
|
2776
|
-
//
|
|
2777
|
-
// keys recursively. `captured_at` and other timestamp-like fields are
|
|
2771
|
+
// evidence_hash binds the operator's submission to the verdict. The
|
|
2772
|
+
// hash must include the canonicalized submission (observations,
|
|
2773
|
+
// signal_overrides, signals) — keying it on only { playbook, directive,
|
|
2774
|
+
// cves, rwep, classification } would let two operators with completely
|
|
2775
|
+
// different evidence collide on the same hash whenever their
|
|
2776
|
+
// classifications match. Use SHA-256 over the recursively sorted
|
|
2777
|
+
// submission. `captured_at` and other timestamp-like fields are
|
|
2778
2778
|
// INTENTIONALLY excluded so that re-running with the same submission
|
|
2779
2779
|
// produces the same hash — `reattest` relies on this to detect drift
|
|
2780
2780
|
// (different submission → different hash → drift exists).
|
|
@@ -2799,7 +2799,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2799
2799
|
evidence_hash: evidenceHash,
|
|
2800
2800
|
submission_digest: submissionDigest,
|
|
2801
2801
|
preflight_issues: pre.issues,
|
|
2802
|
-
//
|
|
2802
|
+
// Source provenance for precondition_checks. Shape:
|
|
2803
2803
|
// { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
|
|
2804
2804
|
precondition_check_source: pcSource,
|
|
2805
2805
|
phases
|
|
@@ -2813,7 +2813,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2813
2813
|
// --- helpers ---
|
|
2814
2814
|
|
|
2815
2815
|
/**
|
|
2816
|
-
*
|
|
2816
|
+
* Deterministic JSON stringification with recursively sorted keys.
|
|
2817
2817
|
* Without sorted keys two semantically identical submissions ({a:1, b:2}
|
|
2818
2818
|
* vs {b:2, a:1}) would hash to different digests, breaking reattest's
|
|
2819
2819
|
* "same submission → same hash" contract. Arrays preserve order
|
|
@@ -2829,7 +2829,7 @@ function canonicalStringify(v) {
|
|
|
2829
2829
|
}
|
|
2830
2830
|
|
|
2831
2831
|
/**
|
|
2832
|
-
*
|
|
2832
|
+
* Pick the operator-meaningful fields out of the normalized submission
|
|
2833
2833
|
* for hashing. captured_at, _signal_origins, _signal_origins_collisions,
|
|
2834
2834
|
* and _original_shape are intentionally excluded — they're either
|
|
2835
2835
|
* timestamps (would break "same submission → same hash") or runner-internal
|
|
@@ -2936,7 +2936,7 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
2936
2936
|
if (m) {
|
|
2937
2937
|
const val = resolvePath(ctx, m[1]);
|
|
2938
2938
|
if (typeof val !== 'string') return false;
|
|
2939
|
-
//
|
|
2939
|
+
// An operator-supplied or playbook-supplied regex with a syntax bug
|
|
2940
2940
|
// (or pathological backtracking) must NOT crash the engine mid-analyze.
|
|
2941
2941
|
// Catch construction + test exceptions, return false, and push a
|
|
2942
2942
|
// structured _regex_eval_error into ctx._runErrors (when present) so
|
|
@@ -3015,12 +3015,11 @@ function stripOuterParens(expr) {
|
|
|
3015
3015
|
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
3016
3016
|
* incident-response milestones.
|
|
3017
3017
|
*
|
|
3018
|
-
*
|
|
3018
|
+
* Per AGENTS.md Phase 7, the legal contract is that the clock starts
|
|
3019
3019
|
* from OPERATOR AWARENESS — not from the moment the engine emits a
|
|
3020
|
-
* `detected` classification.
|
|
3021
|
-
*
|
|
3022
|
-
*
|
|
3023
|
-
* semantics:
|
|
3020
|
+
* `detected` classification. Auto-stamping Date.now() on detect_confirmed
|
|
3021
|
+
* whenever the engine classifies as detected would be incorrect: the
|
|
3022
|
+
* operator may not have seen the result yet. Semantics:
|
|
3024
3023
|
*
|
|
3025
3024
|
* - If the agent explicitly submits clock_started_at_<event>: use it.
|
|
3026
3025
|
* - Otherwise, for 'detect_confirmed' with classification='detected':
|
|
@@ -3123,9 +3122,9 @@ module.exports = {
|
|
|
3123
3122
|
vexFilterFromDoc,
|
|
3124
3123
|
normalizeSubmission,
|
|
3125
3124
|
autoDetectPreconditions,
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3125
|
+
// Exported so library-side direct callers (the fallback path the CLI
|
|
3126
|
+
// guard cannot reach) can be exercised without spawning a CLI
|
|
3127
|
+
// subprocess.
|
|
3129
3128
|
sanitizeOperatorText,
|
|
3130
3129
|
// internal helpers exposed for tests
|
|
3131
3130
|
_resolvedPhase: resolvedPhase,
|