@blamejs/exceptd-skills 0.12.21 → 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 +103 -24
- package/CONTEXT.md +126 -69
- package/README.md +7 -7
- package/bin/exceptd.js +687 -295
- package/data/_indexes/_meta.json +4 -4
- 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 +3 -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 +406 -274
- package/lib/prefetch.js +21 -21
- package/lib/refresh-external.js +15 -18
- package/lib/refresh-network.js +39 -13
- 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 +3 -3
- package/lib/verify.js +63 -22
- package/manifest.json +41 -41
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +22 -18
- package/skills/threat-model-currency/skill.md +1 -1
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,6 +288,16 @@ function lockFilePath(playbookId) {
|
|
|
287
288
|
catch { return null; }
|
|
288
289
|
}
|
|
289
290
|
|
|
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
|
|
293
|
+
// swallowed the release) older than this is presumed dead and reclaimed.
|
|
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.
|
|
299
|
+
const STALE_LOCK_MS = 30_000;
|
|
300
|
+
|
|
290
301
|
function acquireLock(playbookId) {
|
|
291
302
|
const p = lockFilePath(playbookId);
|
|
292
303
|
if (!p) return null;
|
|
@@ -299,16 +310,14 @@ function acquireLock(playbookId) {
|
|
|
299
310
|
writePayload();
|
|
300
311
|
return p;
|
|
301
312
|
} catch (e) {
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// failed because the lock is genuinely held (not because the FS is
|
|
311
|
-
// 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).
|
|
312
321
|
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
313
322
|
try {
|
|
314
323
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -322,6 +331,24 @@ function acquireLock(playbookId) {
|
|
|
322
331
|
try { fs.unlinkSync(p); } catch {}
|
|
323
332
|
try { writePayload(); return p; } catch { /* fall through */ }
|
|
324
333
|
}
|
|
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
|
+
// and must be reclaimed — otherwise the process can never acquire
|
|
342
|
+
// this lock again for the rest of its lifetime.
|
|
343
|
+
if (Number.isInteger(pid) && pid === process.pid) {
|
|
344
|
+
try {
|
|
345
|
+
const stat = fs.statSync(p);
|
|
346
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
347
|
+
try { fs.unlinkSync(p); } catch {}
|
|
348
|
+
try { writePayload(); return p; } catch { /* fall through */ }
|
|
349
|
+
}
|
|
350
|
+
} catch { /* stat failed — treat as held */ }
|
|
351
|
+
}
|
|
325
352
|
} catch { /* unreadable lockfile — treat as held by a live process */ }
|
|
326
353
|
}
|
|
327
354
|
// Lock genuinely held (or filesystem error). Returning null keeps
|
|
@@ -332,9 +359,9 @@ function acquireLock(playbookId) {
|
|
|
332
359
|
}
|
|
333
360
|
}
|
|
334
361
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
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.
|
|
338
365
|
// Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
|
|
339
366
|
// The bare `acquireLock` keeps its historical null-on-failure contract.
|
|
340
367
|
function acquireLockDiagnostic(playbookId) {
|
|
@@ -367,6 +394,26 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
367
394
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
368
395
|
}
|
|
369
396
|
}
|
|
397
|
+
// Same-PID stale-lockfile reclaim (diagnostic variant). Same
|
|
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
|
+
// is legitimate reentrancy and stays held.
|
|
401
|
+
if (Number.isInteger(pid) && pid === process.pid) {
|
|
402
|
+
let mtimeMs = null;
|
|
403
|
+
try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
|
|
404
|
+
if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
|
|
405
|
+
try { fs.unlinkSync(p); } catch {}
|
|
406
|
+
try {
|
|
407
|
+
fs.writeFileSync(p,
|
|
408
|
+
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
409
|
+
{ flag: 'wx' });
|
|
410
|
+
return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
|
|
411
|
+
} catch (e3) {
|
|
412
|
+
return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { ok: false, reason: 'held_by_self', lock_path: p, holder_pid: pid };
|
|
416
|
+
}
|
|
370
417
|
return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
|
|
371
418
|
}
|
|
372
419
|
return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
|
|
@@ -394,7 +441,7 @@ function pidAlive(pid) {
|
|
|
394
441
|
function govern(playbookId, directiveId, runOpts = {}) {
|
|
395
442
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
396
443
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
397
|
-
//
|
|
444
|
+
// Sort jurisdiction obligations by window_hours ascending so the
|
|
398
445
|
// tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
|
|
399
446
|
// first. Operators reading the govern output for ack-time briefing need
|
|
400
447
|
// the most urgent clock at the top of the list.
|
|
@@ -510,7 +557,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
510
557
|
return null; // truly unknown — fall through
|
|
511
558
|
};
|
|
512
559
|
|
|
513
|
-
//
|
|
560
|
+
// Per-indicator FP-check attestation map. Operators submit
|
|
514
561
|
// signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
|
|
515
562
|
// to declare which named false_positive_checks_required[] entries on the
|
|
516
563
|
// indicator have been satisfied. An unverified FP check downgrades the
|
|
@@ -525,12 +572,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
525
572
|
let fpChecksUnsatisfied = null;
|
|
526
573
|
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
527
574
|
verdict = override;
|
|
528
|
-
//
|
|
575
|
+
// Gate 'hit' verdict on per-indicator false_positive_checks_required
|
|
529
576
|
// satisfaction. The FP-check attestation arrives as a sibling key
|
|
530
577
|
// '<id>__fp_checks' in signal_overrides; default behavior (no
|
|
531
578
|
// attestation) treats every required FP check as UNSATISFIED.
|
|
532
579
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
533
|
-
//
|
|
580
|
+
// A hostile or buggy attestation may be a Proxy whose property
|
|
534
581
|
// accessors throw. The filter below reads `att[fpName]` for each
|
|
535
582
|
// required check; an exception inside the read would crash detect()
|
|
536
583
|
// and abort the entire run. Wrap the FP-check evaluation in a
|
|
@@ -539,13 +586,14 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
539
586
|
// read) and surface a runtime_error so the operator sees why.
|
|
540
587
|
try {
|
|
541
588
|
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
542
|
-
//
|
|
589
|
+
// Arrays satisfy `typeof === 'object'` but are NOT a valid
|
|
543
590
|
// attestation map. A submission like
|
|
544
591
|
// signal_overrides: { sig__fp_checks: [true, true] }
|
|
545
|
-
// would
|
|
592
|
+
// would otherwise have its truthy entries matched via the index
|
|
546
593
|
// fallback (att['0'] === true), silently bypassing every FP-check
|
|
547
|
-
// requirement. Reject arrays explicitly so they fall through to
|
|
548
|
-
// 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).
|
|
549
597
|
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
550
598
|
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
551
599
|
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
@@ -585,9 +633,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
585
633
|
// host AI is responsible for that). With NO captured artifacts, this is
|
|
586
634
|
// a clean empty submission — emit 'miss' so the run can reach
|
|
587
635
|
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
// '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.
|
|
591
639
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
592
640
|
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
593
641
|
}
|
|
@@ -617,9 +665,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
617
665
|
// confirmed they're all benign" without this override.
|
|
618
666
|
const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
619
667
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
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
|
|
623
671
|
// classification. Operators submitting case variants / whitespace-padded
|
|
624
672
|
// strings deserve a clear diagnostic, not a quiet downgrade. Treat the
|
|
625
673
|
// override as absent for classification purposes once recorded.
|
|
@@ -637,17 +685,17 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
637
685
|
}
|
|
638
686
|
const override = overrideIsInAllowlist ? rawOverride : undefined;
|
|
639
687
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
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).
|
|
651
699
|
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
652
700
|
|
|
653
701
|
let classification;
|
|
@@ -706,7 +754,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
706
754
|
indicators_evaluated_count: indicatorResults.length,
|
|
707
755
|
classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
708
756
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
709
|
-
//
|
|
757
|
+
// Pass through any flat-shape observation collisions detected at
|
|
710
758
|
// normalize time so analyze() can publish them under
|
|
711
759
|
// analyze.signal_origins_with_collisions.
|
|
712
760
|
_signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
|
|
@@ -766,14 +814,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
766
814
|
const cveRefs = playbook.domain.cve_refs || [];
|
|
767
815
|
const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
|
|
768
816
|
: (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
|
|
769
|
-
//
|
|
817
|
+
// Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
|
|
770
818
|
// (not_affected / false_positive) from "keep but annotate" dispositions
|
|
771
819
|
// (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
|
|
772
820
|
// is computed below from agentSignals.vex_fixed when the operator passes
|
|
773
821
|
// it (CLI populates it from the VEX doc alongside vex_filter).
|
|
774
822
|
const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
|
|
775
823
|
: (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
|
|
776
|
-
//
|
|
824
|
+
// Wrap xref.byCve() so a corrupt catalog (or transient missing-index
|
|
777
825
|
// anomaly) surfaces as a runtime_error rather than crashing analyze().
|
|
778
826
|
const _byCveSafe = (id) => {
|
|
779
827
|
try { return xref.byCve(id); }
|
|
@@ -791,7 +839,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
791
839
|
const vexDropped = vexFilter
|
|
792
840
|
? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
|
|
793
841
|
: [];
|
|
794
|
-
//
|
|
842
|
+
// VEX-fixed CVEs remain in matched/catalog arrays but get annotated
|
|
795
843
|
// with vex_status:'fixed' downstream so consumers see them as resolved.
|
|
796
844
|
const vexFixedIds = vexFixed
|
|
797
845
|
? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
@@ -828,7 +876,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
828
876
|
}
|
|
829
877
|
}
|
|
830
878
|
|
|
831
|
-
//
|
|
879
|
+
// Indicator-level cve_ref correlation. Indicators may declare a
|
|
832
880
|
// cve_ref (string OR string[]) naming CVEs whose presence the indicator
|
|
833
881
|
// pattern-matches. When such an indicator fires AND the named CVE exists
|
|
834
882
|
// in the catalog, the CVE joins matched_cves with correlated_via=
|
|
@@ -867,7 +915,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
867
915
|
// carry a non-null correlated_via array; catalog_baseline_cves entries
|
|
868
916
|
// carry correlated_via:null and a `note` clarifying the field's intent.
|
|
869
917
|
const cveShape = (c, correlatedVia) => {
|
|
870
|
-
//
|
|
918
|
+
// Annotate VEX-fixed CVEs with vex_status. matched_cves still
|
|
871
919
|
// includes them so audit trails and SBOM reports surface "we know this
|
|
872
920
|
// is in scope but vendor declared it fixed."
|
|
873
921
|
const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
|
|
@@ -904,26 +952,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
904
952
|
|
|
905
953
|
// RWEP composition: start from the per-CVE rwep_score of evidence-correlated
|
|
906
954
|
// matches (NOT catalog baseline) so RWEP base reflects what the operator's
|
|
907
|
-
// evidence actually surfaced.
|
|
908
|
-
//
|
|
955
|
+
// evidence actually surfaced. The "max" reduction across matched CVEs is
|
|
956
|
+
// intentional — RWEP is a "worst-case real-world exploit priority", not
|
|
909
957
|
// an arithmetic average. The most-exploitable CVE in the set drives the
|
|
910
958
|
// base; secondary CVEs add via rwep_inputs adjustments below rather than
|
|
911
959
|
// through base summing (which would double-count overlapping risk).
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
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.
|
|
915
963
|
const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
|
|
916
964
|
const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
|
|
917
965
|
|
|
918
|
-
//
|
|
919
|
-
// matched CVE having a corresponding attribute.
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
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
|
|
927
975
|
// patch is available.
|
|
928
976
|
//
|
|
929
977
|
// Aliasing: playbooks ship rwep_factor values `public_poc` and
|
|
@@ -971,12 +1019,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
971
1019
|
}
|
|
972
1020
|
};
|
|
973
1021
|
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
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.
|
|
980
1028
|
const blastRubric = an.blast_radius_model?.scoring_rubric || [];
|
|
981
1029
|
let blastRadiusScore = null;
|
|
982
1030
|
let blastRadiusSignal = 'default';
|
|
@@ -993,7 +1041,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
993
1041
|
}
|
|
994
1042
|
}
|
|
995
1043
|
}
|
|
996
|
-
//
|
|
1044
|
+
// Use the first evidence-correlated CVE as the canonical attribute
|
|
997
1045
|
// source for factor scaling. If matchedCves is empty there's no per-CVE
|
|
998
1046
|
// evidence to gate on. v0.12.15: the prior fallback was
|
|
999
1047
|
// `factorCve = null` → every factor returned 0 → catalog-shape playbooks
|
|
@@ -1086,11 +1134,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1086
1134
|
// detect.classification = inconclusive → theater_verdict = pending_agent_run
|
|
1087
1135
|
// Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
|
|
1088
1136
|
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
// 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.
|
|
1094
1141
|
const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
|
|
1095
1142
|
let theaterVerdict = agentSignals.theater_verdict;
|
|
1096
1143
|
if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
|
|
@@ -1146,12 +1193,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1146
1193
|
// matched_cves when surfacing "what CVEs is the operator actually
|
|
1147
1194
|
// affected by based on submitted evidence?"
|
|
1148
1195
|
catalog_baseline_cves: catalogBaselineEntries,
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1196
|
+
// rwep base is reduced via Math.max across matched CVEs. Surface the
|
|
1197
|
+
// reduction strategy as a discoverable field so operators reading the
|
|
1151
1198
|
// bundle understand the semantics without grepping source.
|
|
1152
1199
|
rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
|
|
1153
1200
|
blast_radius_score: blastRadiusScore,
|
|
1154
|
-
//
|
|
1201
|
+
// Visible annotation of where blast_radius_score came from:
|
|
1155
1202
|
// 'supplied' — operator/agent provided a value in [0, 5].
|
|
1156
1203
|
// 'default' — no value supplied; runner returned null (no rubric guess).
|
|
1157
1204
|
// 'rejected' — value supplied but out of range; treated as default + runtime_error.
|
|
@@ -1162,7 +1209,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1162
1209
|
audit_evidence: an.compliance_theater_check?.audit_evidence,
|
|
1163
1210
|
reality_test: an.compliance_theater_check?.reality_test,
|
|
1164
1211
|
verdict: theaterVerdict,
|
|
1165
|
-
//
|
|
1212
|
+
// Render verdict_text for both 'theater' AND 'present' verdicts
|
|
1166
1213
|
// ('present' is a synonym used by some playbooks for "theater is here").
|
|
1167
1214
|
verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
|
|
1168
1215
|
? an.compliance_theater_check?.theater_verdict_if_gap
|
|
@@ -1184,14 +1231,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1184
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.`
|
|
1185
1232
|
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
|
|
1186
1233
|
} : null,
|
|
1187
|
-
//
|
|
1234
|
+
// Regex-eval failures surfaced here so operators can see WHICH
|
|
1188
1235
|
// condition expression crashed without the runner dying. Only present
|
|
1189
1236
|
// when at least one evalCondition() call hit a regex exception during
|
|
1190
1237
|
// this analyze pass; runOpts._runErrors is the same accumulator
|
|
1191
1238
|
// populated by run() across all phases, so callers reading this field
|
|
1192
1239
|
// see every regex problem in the run.
|
|
1193
1240
|
runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
|
|
1194
|
-
//
|
|
1241
|
+
// Collisions when two flat-shape observations targeted the same
|
|
1195
1242
|
// indicator id. Empty when there were no collisions or no flat-shape
|
|
1196
1243
|
// observations submitted.
|
|
1197
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() : [])
|
|
@@ -1201,8 +1248,8 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1201
1248
|
/**
|
|
1202
1249
|
* Extract VEX disposition sets from a CycloneDX/OpenVEX document.
|
|
1203
1250
|
*
|
|
1204
|
-
*
|
|
1205
|
-
* "drop" set
|
|
1251
|
+
* OpenVEX `fixed` and `not_affected` must NOT collapse into a single
|
|
1252
|
+
* "drop" set — they have different semantics:
|
|
1206
1253
|
*
|
|
1207
1254
|
* - not_affected / false_positive → drop from matched_cves entirely.
|
|
1208
1255
|
* The vendor has formally declared the product not vulnerable; the CVE
|
|
@@ -1251,7 +1298,7 @@ function vexFilterFromDoc(doc) {
|
|
|
1251
1298
|
|
|
1252
1299
|
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
1253
1300
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1254
|
-
//
|
|
1301
|
+
// Surface evalCondition regex errors raised here into the same
|
|
1255
1302
|
// run-wide accumulator that analyze() reads.
|
|
1256
1303
|
const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
1257
1304
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
@@ -1275,7 +1322,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1275
1322
|
// weren't verified — the agent can surface that to the operator.
|
|
1276
1323
|
if (!selected && paths.length) selected = paths[0];
|
|
1277
1324
|
|
|
1278
|
-
//
|
|
1325
|
+
// selected_remediation selection logic:
|
|
1279
1326
|
// 1. Iterate remediation_paths sorted by priority ASC (lower number =
|
|
1280
1327
|
// higher priority per schema convention).
|
|
1281
1328
|
// 2. Pick the FIRST path whose every precondition (evaluated against
|
|
@@ -1288,18 +1335,17 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1288
1335
|
// precondition trace so operators can see why a higher-priority path was
|
|
1289
1336
|
// skipped.
|
|
1290
1337
|
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// unparseable. Preserve backwards compatibility by keeping
|
|
1338
|
+
// Regression schedule. Returns a structured object with next_run +
|
|
1339
|
+
// event_triggers + unparseable. Backwards compatibility: keep
|
|
1294
1340
|
// regression_next_run as the ISO string (or null) so existing CSAF /
|
|
1295
1341
|
// attestation consumers don't break; expose the structured form
|
|
1296
1342
|
// separately.
|
|
1297
1343
|
const triggers = v.regression_trigger || [];
|
|
1298
1344
|
const regressionResult = computeRegressionNextRun(triggers);
|
|
1299
1345
|
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
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).
|
|
1303
1349
|
let nextRunReason = null;
|
|
1304
1350
|
if (!regressionResult.next_run) {
|
|
1305
1351
|
if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
|
|
@@ -1330,15 +1376,15 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1330
1376
|
}
|
|
1331
1377
|
|
|
1332
1378
|
/**
|
|
1333
|
-
*
|
|
1379
|
+
* Extended interval parser. Supports:
|
|
1334
1380
|
* <N>d — N days
|
|
1335
1381
|
* <N>wk — N weeks
|
|
1336
1382
|
* <N>mo — N calendar months (Date.setMonth semantics)
|
|
1337
1383
|
* <N>yr — N calendar years
|
|
1338
1384
|
* on_event — event-triggered, no date computed; surfaces in
|
|
1339
1385
|
* regression_event_triggers[] for the consumer.
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1386
|
+
* Without all five forms, a playbook declaring "regression on every
|
|
1387
|
+
* release" or
|
|
1342
1388
|
* "monthly review" lost its schedule entry.
|
|
1343
1389
|
*/
|
|
1344
1390
|
function parseInterval(intervalStr, now) {
|
|
@@ -1426,12 +1472,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1426
1472
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1427
1473
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1428
1474
|
);
|
|
1429
|
-
//
|
|
1475
|
+
// Thread runOpts through so computeClockStart can check
|
|
1430
1476
|
// operator_consent.explicit before auto-stamping detect_confirmed.
|
|
1431
1477
|
const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
|
|
1432
|
-
//
|
|
1433
|
-
// matched AND the operator did NOT pass --ack, surface
|
|
1434
|
-
// 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.
|
|
1435
1482
|
const clockPendingAck = !clockStart
|
|
1436
1483
|
&& obligation?.clock_starts === 'detect_confirmed'
|
|
1437
1484
|
&& agentSignals?.detection_classification === 'detected'
|
|
@@ -1457,13 +1504,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1457
1504
|
// Evidence the regulator expects attached (from the obligation, not
|
|
1458
1505
|
// just the operator-facing recipient bundle on the notification entry).
|
|
1459
1506
|
evidence_required: obligation?.evidence_required || na.evidence_attached || [],
|
|
1460
|
-
//
|
|
1507
|
+
// Track missing interpolation variables so operators see exactly
|
|
1461
1508
|
// which template vars failed to resolve. Empty array when all
|
|
1462
1509
|
// placeholders rendered cleanly.
|
|
1463
1510
|
...(function () {
|
|
1464
1511
|
const missing = [];
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1512
|
+
// analyzeFindingShape is a pure transform but defensive-wrap it
|
|
1513
|
+
// so a malformed analyze result (missing matched_cves, etc.)
|
|
1467
1514
|
// can't bring down the whole close phase. Failures surface in
|
|
1468
1515
|
// runtime_errors via runOpts._runErrors when available.
|
|
1469
1516
|
let findingShape;
|
|
@@ -1517,13 +1564,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1517
1564
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
1518
1565
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
1519
1566
|
: [];
|
|
1520
|
-
//
|
|
1521
|
-
// bundles_by_format[primary]
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
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.
|
|
1527
1574
|
const evidencePackage = c.evidence_package ? (() => {
|
|
1528
1575
|
const issuedAt = new Date().toISOString();
|
|
1529
1576
|
const builtFormats = new Map();
|
|
@@ -1534,7 +1581,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1534
1581
|
return builtFormats.get(format);
|
|
1535
1582
|
};
|
|
1536
1583
|
const primaryBody = buildOnce(primaryFormat);
|
|
1537
|
-
//
|
|
1584
|
+
// bundles_by_format must always be an object keyed by the
|
|
1538
1585
|
// primary format, even when no extra formats were requested. Pre-fix it
|
|
1539
1586
|
// was null in the single-format case, forcing downstream tooling into a
|
|
1540
1587
|
// `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
|
|
@@ -1592,8 +1639,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1592
1639
|
validate: validateResult,
|
|
1593
1640
|
finding: analyzeFindingShape(analyzeResult),
|
|
1594
1641
|
...agentSignals,
|
|
1595
|
-
//
|
|
1596
|
-
//
|
|
1642
|
+
// Surface evalCondition regex failures from the feeds_into chain into
|
|
1643
|
+
// the same accumulator. Without this the regex failure happens but
|
|
1597
1644
|
// analyze.runtime_errors[] never sees it.
|
|
1598
1645
|
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
1599
1646
|
};
|
|
@@ -1618,7 +1665,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1618
1665
|
exception: exception,
|
|
1619
1666
|
regression_schedule: regressionSchedule,
|
|
1620
1667
|
feeds_into: feeds,
|
|
1621
|
-
//
|
|
1668
|
+
// feeds_into surfaces downstream playbook IDs whose preconditions
|
|
1622
1669
|
// were satisfied by this run. The runner does NOT automatically chain
|
|
1623
1670
|
// into them — the agent / operator decides whether to invoke them.
|
|
1624
1671
|
// Surface that contract on the result so consumers don't assume an
|
|
@@ -1627,7 +1674,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1627
1674
|
};
|
|
1628
1675
|
}
|
|
1629
1676
|
|
|
1630
|
-
//
|
|
1677
|
+
// Severity ladder for active_exploitation. The worst-of reduction lets
|
|
1631
1678
|
// analyzeFindingShape report the most-exploited CVE in the matched set, not
|
|
1632
1679
|
// the first-encountered one. Higher index = worse.
|
|
1633
1680
|
const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
|
|
@@ -1644,10 +1691,10 @@ function worstActiveExploitation(matchedCves) {
|
|
|
1644
1691
|
return worst || 'unknown';
|
|
1645
1692
|
}
|
|
1646
1693
|
|
|
1647
|
-
//
|
|
1648
|
-
// `finding.severity` in feeds_into and escalation_criteria conditions
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
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:
|
|
1651
1698
|
// rwep >= 80 → critical
|
|
1652
1699
|
// rwep >= 50 → high
|
|
1653
1700
|
// rwep >= 20 → medium
|
|
@@ -1665,22 +1712,21 @@ function analyzeFindingShape(a) {
|
|
|
1665
1712
|
const rwepAdjusted = a.rwep?.adjusted ?? 0;
|
|
1666
1713
|
return {
|
|
1667
1714
|
matched_cve_ids: matched.map(c => c.cve_id).join(', '),
|
|
1668
|
-
//
|
|
1669
|
-
//
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
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.
|
|
1672
1719
|
matched_cve_ids_array: matched.map(c => c.cve_id),
|
|
1673
1720
|
matched_cve_count: matched.length,
|
|
1674
1721
|
kev_listed_count: matched.filter(c => c.cisa_kev).length,
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
// 'suspected'
|
|
1678
|
-
//
|
|
1679
|
-
// 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.
|
|
1680
1726
|
active_exploitation: worstActiveExploitation(matched),
|
|
1681
1727
|
rwep_adjusted: rwepAdjusted,
|
|
1682
1728
|
rwep_base: a.rwep?.base ?? 0,
|
|
1683
|
-
//
|
|
1729
|
+
// Severity surface for playbook conditions.
|
|
1684
1730
|
severity: severityForRwep(rwepAdjusted),
|
|
1685
1731
|
blast_radius_score: a.blast_radius_score ?? 0,
|
|
1686
1732
|
framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
|
|
@@ -1722,7 +1768,7 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
1722
1768
|
// surface at least one candidate when any is known. Returns null when no
|
|
1723
1769
|
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1724
1770
|
//
|
|
1725
|
-
//
|
|
1771
|
+
// Source segments are heterogeneous — many playbook artifacts
|
|
1726
1772
|
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
1727
1773
|
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
1728
1774
|
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
@@ -1779,28 +1825,57 @@ function getEngineVersion() {
|
|
|
1779
1825
|
return _CACHED_PKG_VERSION;
|
|
1780
1826
|
}
|
|
1781
1827
|
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
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.
|
|
1833
|
+
//
|
|
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
|
|
1846
|
+
// fallback (publisher.namespace = urn:exceptd:operator:unknown +
|
|
1847
|
+
// bundle_publisher_unclaimed runtime warning).
|
|
1787
1848
|
function sanitizeOperatorText(s) {
|
|
1788
1849
|
if (typeof s !== 'string') return null;
|
|
1789
|
-
//
|
|
1790
|
-
|
|
1791
|
-
|
|
1850
|
+
// NFC first: a Cf codepoint may be expressed as a base + combining mark
|
|
1851
|
+
// that recomposes into the format category under NFC. Normalise so the
|
|
1852
|
+
// strip catches it.
|
|
1853
|
+
let normalised;
|
|
1854
|
+
try { normalised = s.normalize('NFC'); }
|
|
1855
|
+
catch { return null; }
|
|
1856
|
+
// Strip every Unicode codepoint matching General Category C
|
|
1857
|
+
// (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
|
|
1858
|
+
const stripped = normalised.replace(/\p{C}/gu, '');
|
|
1859
|
+
const trimmed = stripped.trim();
|
|
1860
|
+
if (trimmed.length === 0) return null;
|
|
1861
|
+
// Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
|
|
1862
|
+
// units, so a 256-codepoint astral-plane string isn't silently extended
|
|
1863
|
+
// past the cap by surrogate-pair encoding).
|
|
1864
|
+
const cps = Array.from(trimmed);
|
|
1865
|
+
if (cps.length <= 256) return cps.join('');
|
|
1866
|
+
return cps.slice(0, 256).join('');
|
|
1792
1867
|
}
|
|
1793
1868
|
|
|
1794
1869
|
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
|
|
1795
1870
|
runOpts = runOpts || {};
|
|
1796
1871
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1797
1872
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1798
|
-
//
|
|
1873
|
+
// Pin one `now` value per bundle build (and accept an
|
|
1799
1874
|
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
1800
1875
|
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
1801
1876
|
// building several formats from the same run. Without the parameter,
|
|
1802
|
-
// each invocation
|
|
1803
|
-
// 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.
|
|
1804
1879
|
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
1805
1880
|
|
|
1806
1881
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
@@ -1819,24 +1894,24 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1819
1894
|
name: productName,
|
|
1820
1895
|
product_identification_helper: { purl: productPurl }
|
|
1821
1896
|
}];
|
|
1822
|
-
//
|
|
1823
|
-
// disposition (vex_status === 'fixed' — see analyze()
|
|
1824
|
-
// catalog's global `live_patch_available` flag. The catalog flag
|
|
1825
|
-
// "vendor publishes a live-patch in the world", not "operator
|
|
1826
|
-
// it on this host".
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
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.
|
|
1907
|
+
// CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
1833
1908
|
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
1834
1909
|
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
1835
1910
|
// validators (BSI CSAF validator, ENISA dashboard) refuse documents that
|
|
1836
1911
|
// place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
|
|
1837
1912
|
// to the `ids[]` array with a real `system_name`.
|
|
1838
1913
|
//
|
|
1839
|
-
//
|
|
1914
|
+
// CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
|
|
1840
1915
|
// cvss_v3 score block is emitted. Drop the entire score block when the
|
|
1841
1916
|
// catalog has no CVSS data (score AND vector both unset); otherwise
|
|
1842
1917
|
// include version + baseScore + vectorString + baseSeverity from the
|
|
@@ -1853,21 +1928,33 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1853
1928
|
if (typeof vec !== 'string') return '3.1';
|
|
1854
1929
|
const m = vec.match(/^CVSS:(\d+\.\d+)\//);
|
|
1855
1930
|
if (!m) return '3.1';
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1931
|
+
// Returns the declared version verbatim. The CALLER is responsible for
|
|
1932
|
+
// gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
|
|
1933
|
+
// 4.0 vectors are tagged here for diagnostic clarity but never reach
|
|
1934
|
+
// the cvss_v3 block downstream.
|
|
1860
1935
|
return m[1];
|
|
1861
1936
|
};
|
|
1862
1937
|
const csafIdsFor = (id) => {
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
return { system_name: '
|
|
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.
|
|
1944
|
+
if (typeof id !== 'string' || !id) return null;
|
|
1945
|
+
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
1946
|
+
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
1947
|
+
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
1948
|
+
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
|
|
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.
|
|
1953
|
+
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: 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.
|
|
1957
|
+
return { system_name: 'exceptd-unknown', text: id };
|
|
1871
1958
|
};
|
|
1872
1959
|
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
1873
1960
|
|
|
@@ -1879,18 +1966,60 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1879
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.'),
|
|
1880
1967
|
product_ids: [productId],
|
|
1881
1968
|
}];
|
|
1882
|
-
//
|
|
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
|
+
const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
|
|
1975
|
+
let idEntry = null;
|
|
1976
|
+
if (!idIsCve) {
|
|
1977
|
+
idEntry = csafIdsFor(c.cve_id);
|
|
1978
|
+
if (idEntry == null) {
|
|
1979
|
+
if (Array.isArray(runOpts._runErrors)) {
|
|
1980
|
+
const alreadyMissing = runOpts._runErrors.some(e => e && e.kind === 'bundle_cve_id_missing');
|
|
1981
|
+
if (!alreadyMissing) {
|
|
1982
|
+
runOpts._runErrors.push({
|
|
1983
|
+
kind: 'bundle_cve_id_missing',
|
|
1984
|
+
reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
|
|
1985
|
+
remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
// only emit cvss_v3 score block when we have a real
|
|
1883
1993
|
// vector string AND a numeric score. Pre-fix every vuln carried
|
|
1884
1994
|
// `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
|
|
1885
1995
|
// signal — strict validators reject the truncated block, and
|
|
1886
1996
|
// `base_score: 0` was a downstream-misleading default that suggested
|
|
1887
1997
|
// an authoritative "informational" score where there was simply no
|
|
1888
1998
|
// data.
|
|
1999
|
+
//
|
|
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.
|
|
1889
2006
|
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
1890
|
-
const
|
|
2007
|
+
const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
|
|
2008
|
+
const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
|
|
2009
|
+
if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
|
|
2010
|
+
const alreadyUnsup = runOpts._runErrors.some(e => e && e.kind === 'bundle_cvss_v3_version_unsupported');
|
|
2011
|
+
if (!alreadyUnsup) {
|
|
2012
|
+
runOpts._runErrors.push({
|
|
2013
|
+
kind: 'bundle_cvss_v3_version_unsupported',
|
|
2014
|
+
reason: `Catalog entry carries CVSS vector with version ${vectorVersion}; CSAF 2.0 cvss_v3 block only accepts versions 3.0 / 3.1. The score block was omitted from this vulnerability to keep the document valid against strict CSAF validators.`,
|
|
2015
|
+
remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const scores = cvssV3Eligible ? [{
|
|
1891
2020
|
products: [productId],
|
|
1892
2021
|
cvss_v3: {
|
|
1893
|
-
version:
|
|
2022
|
+
version: vectorVersion,
|
|
1894
2023
|
baseScore: c.cvss_score,
|
|
1895
2024
|
vectorString: c.cvss_vector,
|
|
1896
2025
|
baseSeverity: csafCvssSeverity(c.cvss_score),
|
|
@@ -1902,12 +2031,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1902
2031
|
remediations,
|
|
1903
2032
|
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
1904
2033
|
};
|
|
1905
|
-
//
|
|
1906
|
-
if (
|
|
2034
|
+
// route by id shape.
|
|
2035
|
+
if (idIsCve) {
|
|
1907
2036
|
return { cve: c.cve_id, ...base };
|
|
1908
2037
|
}
|
|
1909
|
-
return { ids: [
|
|
1910
|
-
});
|
|
2038
|
+
return { ids: [idEntry], ...base };
|
|
2039
|
+
}).filter(v => v != null);
|
|
1911
2040
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
1912
2041
|
// CSAF `system_name` values land in operator-facing validators; the
|
|
1913
2042
|
// "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
|
|
@@ -1918,15 +2047,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1918
2047
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
1919
2048
|
product_status: { known_affected: [productId] }
|
|
1920
2049
|
}));
|
|
1921
|
-
//
|
|
1922
|
-
//
|
|
1923
|
-
// `
|
|
1924
|
-
//
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
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.
|
|
1930
2060
|
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
1931
2061
|
const lines = [
|
|
1932
2062
|
`Framework: ${g.framework}`,
|
|
@@ -1940,7 +2070,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1940
2070
|
text: lines.join('\n'),
|
|
1941
2071
|
};
|
|
1942
2072
|
});
|
|
1943
|
-
//
|
|
2073
|
+
// CSAF §3.1.7.4 publisher.namespace MUST be the trust
|
|
1944
2074
|
// anchor of the entity publishing the advisory — the OPERATOR running the
|
|
1945
2075
|
// scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
|
|
1946
2076
|
// claimed https://exceptd.com as namespace, falsely attributing
|
|
@@ -1967,7 +2097,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1967
2097
|
title: 'Publisher namespace not supplied',
|
|
1968
2098
|
text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
|
|
1969
2099
|
}] : [];
|
|
1970
|
-
//
|
|
2100
|
+
// ALSO surface the unclaimed-publisher condition through
|
|
1971
2101
|
// the structured runtime_errors[] accumulator so machine-readable
|
|
1972
2102
|
// consumers (CI gates, dashboards) can branch on it without parsing
|
|
1973
2103
|
// notes[] prose. The orchestrator's post-close pass folds late-pushed
|
|
@@ -1986,7 +2116,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1986
2116
|
}
|
|
1987
2117
|
}
|
|
1988
2118
|
|
|
1989
|
-
//
|
|
2119
|
+
// thread the validated --operator name into
|
|
1990
2120
|
// tracking.generator (engine identity) AND publisher.contact_details
|
|
1991
2121
|
// (operator-of-record). engine.version is read from the package once per
|
|
1992
2122
|
// process. contact_details is omitted when no operator was supplied so
|
|
@@ -1998,7 +2128,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1998
2128
|
};
|
|
1999
2129
|
if (operatorClean) publisherBlock.contact_details = operatorClean;
|
|
2000
2130
|
|
|
2001
|
-
//
|
|
2131
|
+
// CSAF §3.1.11.3.5.1 defines `final` as an immutable
|
|
2002
2132
|
// advisory; subsequent re-emits against the same tracking.id are
|
|
2003
2133
|
// refused by strict validators (BSI CSAF Validator). Runtime detection
|
|
2004
2134
|
// runs with no operator review loop are inherently revisable, so the
|
|
@@ -2028,7 +2158,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2028
2158
|
id: `exceptd-${playbook._meta.id}-${sessionId}`,
|
|
2029
2159
|
status: csafStatus,
|
|
2030
2160
|
version: playbook._meta.version,
|
|
2031
|
-
//
|
|
2161
|
+
// name the engine that emitted the advisory.
|
|
2032
2162
|
// CSAF §3.1.11.3.2 places this under tracking.generator.engine.
|
|
2033
2163
|
generator: {
|
|
2034
2164
|
engine: { name: 'exceptd', version: getEngineVersion() },
|
|
@@ -2066,7 +2196,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2066
2196
|
// render empty fields.
|
|
2067
2197
|
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
2068
2198
|
const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
|
2069
|
-
//
|
|
2199
|
+
// SARIF rule ids are global within a single sarif-log run.
|
|
2070
2200
|
// Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
|
|
2071
2201
|
// across playbooks) collided when results from multiple playbook runs
|
|
2072
2202
|
// were merged into one SARIF document — GitHub Code Scanning de-dupes
|
|
@@ -2144,8 +2274,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2144
2274
|
} },
|
|
2145
2275
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
2146
2276
|
invocations: [{ executionSuccessful: true, properties: stripNulls({
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2277
|
+
// Apply the stripNulls contract here too — the `remediation`
|
|
2278
|
+
// field is null for any run that didn't surface a
|
|
2149
2279
|
// selected_remediation, and SARIF viewers render null property
|
|
2150
2280
|
// values as visible empty rows. Same helper as the result
|
|
2151
2281
|
// property bags above.
|
|
@@ -2173,11 +2303,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2173
2303
|
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
2174
2304
|
// they pass IRI validation in downstream VEX consumers.
|
|
2175
2305
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
2176
|
-
//
|
|
2177
|
-
//
|
|
2178
|
-
//
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
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.
|
|
2181
2311
|
const issued = now;
|
|
2182
2312
|
const productEntry = {
|
|
2183
2313
|
'@id': productPurl,
|
|
@@ -2193,17 +2323,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2193
2323
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
2194
2324
|
return fallback;
|
|
2195
2325
|
};
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2326
|
+
// Same `vex_status === 'fixed'` correctness rule as the CSAF
|
|
2327
|
+
// emitter. The catalog `live_patch_available` flag is a global
|
|
2198
2328
|
// "vendor publishes a live-patch" signal, not an operator-host
|
|
2199
|
-
// disposition. Treating it as `status: fixed`
|
|
2200
|
-
// claim resolution
|
|
2201
|
-
//
|
|
2202
|
-
//
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
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.
|
|
2207
2337
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
2208
2338
|
const stmt = {
|
|
2209
2339
|
vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
|
|
@@ -2300,11 +2430,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2300
2430
|
return { format: 'markdown', body: lines.join('\n') };
|
|
2301
2431
|
}
|
|
2302
2432
|
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
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.
|
|
2308
2438
|
return {
|
|
2309
2439
|
format,
|
|
2310
2440
|
note: 'Unknown format',
|
|
@@ -2329,11 +2459,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2329
2459
|
function normalizeSubmission(submission, playbook) {
|
|
2330
2460
|
if (!submission || typeof submission !== "object") return submission || {};
|
|
2331
2461
|
|
|
2332
|
-
//
|
|
2333
|
-
// value (string "foo", array [...])
|
|
2334
|
-
// via `{ ...(submission.signal_overrides || {}) }
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
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.
|
|
2337
2467
|
if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
|
|
2338
2468
|
&& (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
|
|
2339
2469
|
if (!submission._runErrors) submission._runErrors = [];
|
|
@@ -2366,13 +2496,13 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2366
2496
|
signals: { ...(submission.signals || {}) },
|
|
2367
2497
|
precondition_checks: { ...(submission.precondition_checks || {}) },
|
|
2368
2498
|
_original_shape: 'flat (v0.11.0)',
|
|
2369
|
-
//
|
|
2370
|
-
// signal_overrides_invalid) onto submission._runErrors above.
|
|
2371
|
-
//
|
|
2372
|
-
//
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
//
|
|
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.
|
|
2376
2506
|
...(Array.isArray(submission._runErrors) && submission._runErrors.length
|
|
2377
2507
|
? { _runErrors: submission._runErrors.slice() }
|
|
2378
2508
|
: {}),
|
|
@@ -2394,7 +2524,7 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2394
2524
|
// detect can emit `from_observation` on each indicator result. Diagnostic
|
|
2395
2525
|
// value for operators chasing "which observation drove this verdict".
|
|
2396
2526
|
//
|
|
2397
|
-
//
|
|
2527
|
+
// When two observations target the same indicator id, last-write-wins
|
|
2398
2528
|
// silently. Track discards in _signal_origins_collisions so analyze can
|
|
2399
2529
|
// surface analyze.signal_origins_with_collisions for batch evidence runs.
|
|
2400
2530
|
out._signal_origins = out._signal_origins || {};
|
|
@@ -2476,7 +2606,7 @@ function autoDetectPreconditions(submission, playbook) {
|
|
|
2476
2606
|
}
|
|
2477
2607
|
|
|
2478
2608
|
function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
2479
|
-
//
|
|
2609
|
+
// Catalog corruption surfaced at module-load blocks runs cleanly.
|
|
2480
2610
|
if (_xrefLoadError) {
|
|
2481
2611
|
return {
|
|
2482
2612
|
ok: false,
|
|
@@ -2490,7 +2620,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2490
2620
|
try {
|
|
2491
2621
|
playbook = loadPlaybook(playbookId);
|
|
2492
2622
|
} catch (e) {
|
|
2493
|
-
//
|
|
2623
|
+
// loadPlaybook failure → structured error (not crash).
|
|
2494
2624
|
return {
|
|
2495
2625
|
ok: false,
|
|
2496
2626
|
blocked_by: 'playbook_not_found',
|
|
@@ -2499,9 +2629,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2499
2629
|
};
|
|
2500
2630
|
}
|
|
2501
2631
|
|
|
2502
|
-
//
|
|
2503
|
-
// inside analyze()/findDirective() uncaught, surfacing
|
|
2504
|
-
// 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.
|
|
2505
2636
|
const validDirectives = (playbook.directives || []).map(d => d.id);
|
|
2506
2637
|
if (!validDirectives.includes(directiveId)) {
|
|
2507
2638
|
return {
|
|
@@ -2518,12 +2649,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2518
2649
|
// / the host platform matches — the runner can answer those itself rather
|
|
2519
2650
|
// than blocking on AI declaration.
|
|
2520
2651
|
agentSubmission = normalizeSubmission(agentSubmission, playbook);
|
|
2521
|
-
//
|
|
2652
|
+
// Capture pre-autoDetect submission preconditions so we report
|
|
2522
2653
|
// user-declared provenance, not engine-auto-resolved values.
|
|
2523
2654
|
const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
|
|
2524
2655
|
agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
|
|
2525
2656
|
|
|
2526
|
-
//
|
|
2657
|
+
// precondition_checks merge order is submission → runOpts (runOpts
|
|
2527
2658
|
// wins on collision). This is intentional: runOpts represents the most
|
|
2528
2659
|
// recent caller intent (CLI flags / programmatic injection from a host
|
|
2529
2660
|
// process), whereas submission was captured earlier during evidence
|
|
@@ -2551,38 +2682,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2551
2682
|
// Cross-process mutex lock for this run. preflight verified no other lock
|
|
2552
2683
|
// exists; we acquire ours and release in the finally block.
|
|
2553
2684
|
const lockPath = acquireLock(playbookId);
|
|
2554
|
-
//
|
|
2555
|
-
// through each phase via runOpts._playbookCache. Each phase otherwise
|
|
2556
|
-
// loadPlaybook() independently; for a single run that's seven
|
|
2557
|
-
// 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.
|
|
2558
2690
|
//
|
|
2559
|
-
//
|
|
2560
|
-
// cachedRunOpts.session_id.
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
//
|
|
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.
|
|
2565
2697
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
2566
2698
|
const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
|
|
2567
|
-
//
|
|
2699
|
+
// Run-time error accumulator for evalCondition regex failures and other
|
|
2568
2700
|
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2569
2701
|
const runErrors = [];
|
|
2570
2702
|
cachedRunOpts._runErrors = runErrors;
|
|
2571
|
-
//
|
|
2572
|
-
// signal_overrides_invalid) onto submission._runErrors.
|
|
2573
|
-
//
|
|
2574
|
-
//
|
|
2575
|
-
//
|
|
2576
|
-
//
|
|
2577
|
-
// doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
|
|
2578
|
-
// 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).
|
|
2579
2709
|
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
2580
2710
|
runErrors.push(...agentSubmission._runErrors);
|
|
2581
2711
|
}
|
|
2582
2712
|
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
2583
2713
|
delete agentSubmission._runErrors;
|
|
2584
2714
|
}
|
|
2585
|
-
//
|
|
2715
|
+
// Phases the runner should SKIP execution for, based on skip_phase
|
|
2586
2716
|
// preconditions surfaced in preflight.issues.
|
|
2587
2717
|
const skipPhases = new Set();
|
|
2588
2718
|
for (const issue of (pre.issues || [])) {
|
|
@@ -2624,7 +2754,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2624
2754
|
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
|
|
2625
2755
|
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
|
|
2626
2756
|
|
|
2627
|
-
//
|
|
2757
|
+
// analyze() already sliced runOpts._runErrors into
|
|
2628
2758
|
// phases.analyze.runtime_errors at return time. Validate + close may
|
|
2629
2759
|
// have pushed additional regex errors AFTER analyze returned; surface
|
|
2630
2760
|
// those onto phases.analyze.runtime_errors so the field reflects every
|
|
@@ -2638,14 +2768,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2638
2768
|
}
|
|
2639
2769
|
}
|
|
2640
2770
|
|
|
2641
|
-
//
|
|
2642
|
-
//
|
|
2643
|
-
//
|
|
2644
|
-
//
|
|
2645
|
-
//
|
|
2646
|
-
//
|
|
2647
|
-
//
|
|
2648
|
-
// 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
|
|
2649
2778
|
// INTENTIONALLY excluded so that re-running with the same submission
|
|
2650
2779
|
// produces the same hash — `reattest` relies on this to detect drift
|
|
2651
2780
|
// (different submission → different hash → drift exists).
|
|
@@ -2670,7 +2799,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2670
2799
|
evidence_hash: evidenceHash,
|
|
2671
2800
|
submission_digest: submissionDigest,
|
|
2672
2801
|
preflight_issues: pre.issues,
|
|
2673
|
-
//
|
|
2802
|
+
// Source provenance for precondition_checks. Shape:
|
|
2674
2803
|
// { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
|
|
2675
2804
|
precondition_check_source: pcSource,
|
|
2676
2805
|
phases
|
|
@@ -2684,7 +2813,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2684
2813
|
// --- helpers ---
|
|
2685
2814
|
|
|
2686
2815
|
/**
|
|
2687
|
-
*
|
|
2816
|
+
* Deterministic JSON stringification with recursively sorted keys.
|
|
2688
2817
|
* Without sorted keys two semantically identical submissions ({a:1, b:2}
|
|
2689
2818
|
* vs {b:2, a:1}) would hash to different digests, breaking reattest's
|
|
2690
2819
|
* "same submission → same hash" contract. Arrays preserve order
|
|
@@ -2700,7 +2829,7 @@ function canonicalStringify(v) {
|
|
|
2700
2829
|
}
|
|
2701
2830
|
|
|
2702
2831
|
/**
|
|
2703
|
-
*
|
|
2832
|
+
* Pick the operator-meaningful fields out of the normalized submission
|
|
2704
2833
|
* for hashing. captured_at, _signal_origins, _signal_origins_collisions,
|
|
2705
2834
|
* and _original_shape are intentionally excluded — they're either
|
|
2706
2835
|
* timestamps (would break "same submission → same hash") or runner-internal
|
|
@@ -2807,7 +2936,7 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
2807
2936
|
if (m) {
|
|
2808
2937
|
const val = resolvePath(ctx, m[1]);
|
|
2809
2938
|
if (typeof val !== 'string') return false;
|
|
2810
|
-
//
|
|
2939
|
+
// An operator-supplied or playbook-supplied regex with a syntax bug
|
|
2811
2940
|
// (or pathological backtracking) must NOT crash the engine mid-analyze.
|
|
2812
2941
|
// Catch construction + test exceptions, return false, and push a
|
|
2813
2942
|
// structured _regex_eval_error into ctx._runErrors (when present) so
|
|
@@ -2886,12 +3015,11 @@ function stripOuterParens(expr) {
|
|
|
2886
3015
|
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
2887
3016
|
* incident-response milestones.
|
|
2888
3017
|
*
|
|
2889
|
-
*
|
|
3018
|
+
* Per AGENTS.md Phase 7, the legal contract is that the clock starts
|
|
2890
3019
|
* from OPERATOR AWARENESS — not from the moment the engine emits a
|
|
2891
|
-
* `detected` classification.
|
|
2892
|
-
*
|
|
2893
|
-
*
|
|
2894
|
-
* 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:
|
|
2895
3023
|
*
|
|
2896
3024
|
* - If the agent explicitly submits clock_started_at_<event>: use it.
|
|
2897
3025
|
* - Otherwise, for 'detect_confirmed' with classification='detected':
|
|
@@ -2994,6 +3122,10 @@ module.exports = {
|
|
|
2994
3122
|
vexFilterFromDoc,
|
|
2995
3123
|
normalizeSubmission,
|
|
2996
3124
|
autoDetectPreconditions,
|
|
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.
|
|
3128
|
+
sanitizeOperatorText,
|
|
2997
3129
|
// internal helpers exposed for tests
|
|
2998
3130
|
_resolvedPhase: resolvedPhase,
|
|
2999
3131
|
_deepMerge: deepMerge,
|