@blamejs/exceptd-skills 0.12.22 → 0.12.24
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 +152 -2
- package/CONTEXT.md +126 -69
- package/README.md +21 -8
- package/bin/exceptd.js +972 -464
- 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/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.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 +39 -1
- package/lib/auto-discovery.js +28 -4
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +73 -6
- package/lib/playbook-runner.js +617 -343
- package/lib/prefetch.js +134 -21
- package/lib/refresh-external.js +205 -26
- package/lib/refresh-network.js +64 -16
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +49 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +56 -30
- package/manifest.json +40 -40
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- package/scripts/verify-shipped-tarball.js +27 -18
package/lib/playbook-runner.js
CHANGED
|
@@ -47,8 +47,9 @@ const fs = require('fs');
|
|
|
47
47
|
const path = require('path');
|
|
48
48
|
const os = require('os');
|
|
49
49
|
const crypto = require('crypto');
|
|
50
|
+
const scoring = require('./scoring');
|
|
50
51
|
|
|
51
|
-
//
|
|
52
|
+
// cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
|
|
52
53
|
// JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
|
|
53
54
|
// failure, returns an empty stub, and accumulates the error in
|
|
54
55
|
// getLoadErrors(). run() probes for accumulated load errors and returns
|
|
@@ -89,6 +90,61 @@ const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data',
|
|
|
89
90
|
// platform integration, not the runner.
|
|
90
91
|
const _activeRuns = new Set();
|
|
91
92
|
|
|
93
|
+
// Bounded push into a runtime_errors array with per-kind caps, optional
|
|
94
|
+
// per-kind dedupe, and a total cap. A long-running detect/analyze loop that
|
|
95
|
+
// rejects a malformed catalog entry on every iteration would otherwise let
|
|
96
|
+
// runtime_errors grow unbounded and balloon the bundle output. When the cap
|
|
97
|
+
// fires the helper records a `_truncated` sentinel so downstream consumers
|
|
98
|
+
// see the drop without needing to compare cardinalities.
|
|
99
|
+
//
|
|
100
|
+
// opts.cap per-kind cap (default 100)
|
|
101
|
+
// opts.totalCap total array cap (default 1000)
|
|
102
|
+
// opts.dedupeKey optional fn(entry) returning a string key. When supplied,
|
|
103
|
+
// a push with the same (kind, dedupeKey) tuple is skipped.
|
|
104
|
+
//
|
|
105
|
+
// Returns true if the entry was pushed, false otherwise (capped or deduped).
|
|
106
|
+
function pushRunError(arr, entry, opts) {
|
|
107
|
+
if (!Array.isArray(arr) || !entry || typeof entry !== 'object') return false;
|
|
108
|
+
opts = opts || {};
|
|
109
|
+
const cap = typeof opts.cap === 'number' ? opts.cap : 100;
|
|
110
|
+
const totalCap = typeof opts.totalCap === 'number' ? opts.totalCap : 1000;
|
|
111
|
+
const kind = entry.kind;
|
|
112
|
+
if (typeof opts.dedupeKey === 'function' && kind) {
|
|
113
|
+
const dk = opts.dedupeKey(entry);
|
|
114
|
+
if (arr.some(e => e && e.kind === kind && opts.dedupeKey(e) === dk)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const total = arr.length;
|
|
119
|
+
const kindCount = kind ? arr.filter(e => e && e.kind === kind).length : 0;
|
|
120
|
+
const overTotal = total >= totalCap;
|
|
121
|
+
const overKind = kind && kindCount >= cap;
|
|
122
|
+
if (overTotal || overKind) {
|
|
123
|
+
const reason = overKind ? 'per-kind-cap' : 'total-cap';
|
|
124
|
+
const existing = arr.find(e => e && e.kind === '_truncated' && e.truncated_kind === (kind || null) && e.reason === reason);
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.dropped = (existing.dropped || 0) + 1;
|
|
127
|
+
} else {
|
|
128
|
+
arr.push({ kind: '_truncated', truncated_kind: kind || null, dropped: 1, reason });
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
arr.push(entry);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Unwrap a legacy `{ _regex_eval_error: { source, expr, message } }` record
|
|
137
|
+
// into the flat fields pushRunError dedupes on. Used by evalCondition()'s
|
|
138
|
+
// regex-failure path so per-(source, expr) duplicates collapse to one entry
|
|
139
|
+
// plus a `_truncated` sentinel when the cap fires.
|
|
140
|
+
function _regexErrorPayload(rec) {
|
|
141
|
+
if (rec && typeof rec === 'object' && rec._regex_eval_error) {
|
|
142
|
+
const { source, expr, message } = rec._regex_eval_error;
|
|
143
|
+
return { source, expr, message, _regex_eval_error: rec._regex_eval_error };
|
|
144
|
+
}
|
|
145
|
+
return { _regex_eval_error: rec };
|
|
146
|
+
}
|
|
147
|
+
|
|
92
148
|
// --- catalog access ---
|
|
93
149
|
|
|
94
150
|
function listPlaybooks() {
|
|
@@ -104,7 +160,7 @@ function loadPlaybook(playbookId) {
|
|
|
104
160
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
105
161
|
}
|
|
106
162
|
|
|
107
|
-
//
|
|
163
|
+
// Per-run playbook cache. Each phase function reads runOpts._playbookCache
|
|
108
164
|
// before falling back to loadPlaybook(). run() sets _playbookCache once at
|
|
109
165
|
// entry so seven phases share one disk read + JSON parse instead of seven.
|
|
110
166
|
|
|
@@ -149,16 +205,17 @@ function deepMerge(a, b) {
|
|
|
149
205
|
* 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
|
|
150
206
|
* AND with the filesystem lockfile dir blocks the run.
|
|
151
207
|
*
|
|
152
|
-
*
|
|
208
|
+
* When runOpts.strictPreconditions === true, warn-level outcomes
|
|
153
209
|
* (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
|
|
210
|
+
* skip_phase) are ESCALATED to halts. The function returns ok:false
|
|
211
|
+
* with blocked_by='precondition' and an issues array containing
|
|
156
212
|
* precondition_halt entries. Callers wanting "CI gate: any unverified
|
|
157
213
|
* precondition is a failure" pass strictPreconditions=true.
|
|
158
214
|
*
|
|
159
|
-
*
|
|
215
|
+
* When a precondition with on_fail='skip_phase' fails, the issue carries
|
|
160
216
|
* skip_phase: 'detect' (default) so run() can route to a skipped-phase
|
|
161
|
-
* placeholder rather than executing detect against a missing
|
|
217
|
+
* placeholder rather than executing detect against a missing
|
|
218
|
+
* prerequisite.
|
|
162
219
|
*/
|
|
163
220
|
function preflight(playbook, runOpts = {}) {
|
|
164
221
|
const issues = [];
|
|
@@ -185,7 +242,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
185
242
|
if (submitted === undefined) {
|
|
186
243
|
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
244
|
if (strict) {
|
|
188
|
-
//
|
|
245
|
+
// strictPreconditions promotes unverified to halt regardless of
|
|
189
246
|
// declared on_fail.
|
|
190
247
|
issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
|
|
191
248
|
return {
|
|
@@ -213,7 +270,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
213
270
|
return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
|
|
214
271
|
}
|
|
215
272
|
if (strict) {
|
|
216
|
-
//
|
|
273
|
+
// Warn-level + skip_phase outcomes escalate to halt under strict.
|
|
217
274
|
issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
|
|
218
275
|
return {
|
|
219
276
|
ok: false,
|
|
@@ -223,7 +280,7 @@ function preflight(playbook, runOpts = {}) {
|
|
|
223
280
|
};
|
|
224
281
|
}
|
|
225
282
|
if (pc.on_fail === 'skip_phase') {
|
|
226
|
-
//
|
|
283
|
+
// Emit a skip_phase field so run() can route to a skipped-phase
|
|
227
284
|
// placeholder. Default target phase is 'detect' (the most common
|
|
228
285
|
// skip target — preconditions typically gate host-side detection).
|
|
229
286
|
// Playbooks may override via pc.skip_phase.
|
|
@@ -266,11 +323,11 @@ function preflight(playbook, runOpts = {}) {
|
|
|
266
323
|
return { ok: true, issues };
|
|
267
324
|
}
|
|
268
325
|
|
|
269
|
-
//
|
|
326
|
+
// lockDir lives at a stable global path so two CLI invocations from
|
|
270
327
|
// different working directories still share lock state for cross-process
|
|
271
|
-
// mutex enforcement.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
328
|
+
// mutex enforcement. A process.cwd()-relative dir would let invocations
|
|
329
|
+
// from /tmp and from /home/user/project simultaneously each see an empty
|
|
330
|
+
// locks dir and both run unchallenged. The path
|
|
274
331
|
// keys on os.platform() so Windows/macOS/Linux locks live under separate
|
|
275
332
|
// directories (avoids cross-platform stale-PID confusion when a host is
|
|
276
333
|
// shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
|
|
@@ -287,13 +344,14 @@ function lockFilePath(playbookId) {
|
|
|
287
344
|
catch { return null; }
|
|
288
345
|
}
|
|
289
346
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
347
|
+
// Same-PID stale-lockfile reclaim threshold. A same-process orphan (e.g.
|
|
348
|
+
// an earlier run() that crashed without unlinking, or a try/catch that
|
|
292
349
|
// 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
|
|
350
|
+
// 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
|
|
351
|
+
// that no legitimate playbook hold reaches it (govern/look/run phases
|
|
352
|
+
// complete well inside one second per playbook), short enough that a
|
|
353
|
+
// wedged process recovers within one CI step rather than the rest of its
|
|
354
|
+
// lifetime.
|
|
297
355
|
const STALE_LOCK_MS = 30_000;
|
|
298
356
|
|
|
299
357
|
function acquireLock(playbookId) {
|
|
@@ -308,16 +366,14 @@ function acquireLock(playbookId) {
|
|
|
308
366
|
writePayload();
|
|
309
367
|
return p;
|
|
310
368
|
} 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).
|
|
369
|
+
// Stale-PID reclaim. Without it, a process that crashed mid-run
|
|
370
|
+
// leaves its lockfile behind and every subsequent invocation runs
|
|
371
|
+
// UNLOCKED. Mirror withCatalogLock's pattern: parse the recorded pid,
|
|
372
|
+
// probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
|
|
373
|
+
// unlink and retry once. EPERM (alive, different user) or any other
|
|
374
|
+
// condition: leave the lock alone and return null with a diagnostic so
|
|
375
|
+
// the caller knows acquisition failed because the lock is genuinely
|
|
376
|
+
// held (not because the FS is broken or the playbook id is malformed).
|
|
321
377
|
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
322
378
|
try {
|
|
323
379
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -331,13 +387,13 @@ function acquireLock(playbookId) {
|
|
|
331
387
|
try { fs.unlinkSync(p); } catch {}
|
|
332
388
|
try { writePayload(); return p; } catch { /* fall through */ }
|
|
333
389
|
}
|
|
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)
|
|
390
|
+
// Same-PID stale-lockfile reclaim. If the recorded pid is ours,
|
|
391
|
+
// the only way to escape an orphaned same-process lockfile is by
|
|
392
|
+
// mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
|
|
393
|
+
// (e.g. nested run() within one process) must still return null
|
|
394
|
+
// so the caller knows the lock is held. A fresh same-PID lockfile
|
|
395
|
+
// is reentrancy; one older than STALE_LOCK_MS is an orphan from
|
|
396
|
+
// a crashed prior hold (or a try/catch that swallowed the release)
|
|
341
397
|
// and must be reclaimed — otherwise the process can never acquire
|
|
342
398
|
// this lock again for the rest of its lifetime.
|
|
343
399
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
@@ -359,9 +415,9 @@ function acquireLock(playbookId) {
|
|
|
359
415
|
}
|
|
360
416
|
}
|
|
361
417
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
418
|
+
// Callers needing to distinguish "couldn't acquire because the lock is
|
|
419
|
+
// genuinely held by a live process" from "couldn't acquire because of an
|
|
420
|
+
// unexpected error" can use this thin diagnostic wrapper.
|
|
365
421
|
// Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
|
|
366
422
|
// The bare `acquireLock` keeps its historical null-on-failure contract.
|
|
367
423
|
function acquireLockDiagnostic(playbookId) {
|
|
@@ -394,10 +450,10 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
394
450
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
395
451
|
}
|
|
396
452
|
}
|
|
397
|
-
//
|
|
453
|
+
// Same-PID stale-lockfile reclaim (diagnostic variant). Same
|
|
398
454
|
// 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.
|
|
455
|
+
// STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
|
|
456
|
+
// is legitimate reentrancy and stays held.
|
|
401
457
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
402
458
|
let mtimeMs = null;
|
|
403
459
|
try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
|
|
@@ -441,7 +497,7 @@ function pidAlive(pid) {
|
|
|
441
497
|
function govern(playbookId, directiveId, runOpts = {}) {
|
|
442
498
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
443
499
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
444
|
-
//
|
|
500
|
+
// Sort jurisdiction obligations by window_hours ascending so the
|
|
445
501
|
// tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
|
|
446
502
|
// first. Operators reading the govern output for ack-time briefing need
|
|
447
503
|
// the most urgent clock at the top of the list.
|
|
@@ -557,7 +613,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
557
613
|
return null; // truly unknown — fall through
|
|
558
614
|
};
|
|
559
615
|
|
|
560
|
-
//
|
|
616
|
+
// Per-indicator FP-check attestation map. Operators submit
|
|
561
617
|
// signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
|
|
562
618
|
// to declare which named false_positive_checks_required[] entries on the
|
|
563
619
|
// indicator have been satisfied. An unverified FP check downgrades the
|
|
@@ -572,12 +628,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
572
628
|
let fpChecksUnsatisfied = null;
|
|
573
629
|
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
574
630
|
verdict = override;
|
|
575
|
-
//
|
|
631
|
+
// Gate 'hit' verdict on per-indicator false_positive_checks_required
|
|
576
632
|
// satisfaction. The FP-check attestation arrives as a sibling key
|
|
577
633
|
// '<id>__fp_checks' in signal_overrides; default behavior (no
|
|
578
634
|
// attestation) treats every required FP check as UNSATISFIED.
|
|
579
635
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
580
|
-
//
|
|
636
|
+
// A hostile or buggy attestation may be a Proxy whose property
|
|
581
637
|
// accessors throw. The filter below reads `att[fpName]` for each
|
|
582
638
|
// required check; an exception inside the read would crash detect()
|
|
583
639
|
// and abort the entire run. Wrap the FP-check evaluation in a
|
|
@@ -586,13 +642,14 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
586
642
|
// read) and surface a runtime_error so the operator sees why.
|
|
587
643
|
try {
|
|
588
644
|
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
589
|
-
//
|
|
645
|
+
// Arrays satisfy `typeof === 'object'` but are NOT a valid
|
|
590
646
|
// attestation map. A submission like
|
|
591
647
|
// signal_overrides: { sig__fp_checks: [true, true] }
|
|
592
|
-
// would
|
|
648
|
+
// would otherwise have its truthy entries matched via the index
|
|
593
649
|
// 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
|
|
650
|
+
// requirement. Reject arrays explicitly so they fall through to
|
|
651
|
+
// the empty-attestation branch (every required check
|
|
652
|
+
// unsatisfied).
|
|
596
653
|
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
597
654
|
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
598
655
|
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
@@ -617,11 +674,11 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
617
674
|
verdict = 'inconclusive';
|
|
618
675
|
fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
|
|
619
676
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
620
|
-
runOpts._runErrors
|
|
677
|
+
pushRunError(runOpts._runErrors, {
|
|
621
678
|
kind: 'fp_attestation_threw',
|
|
622
679
|
indicator_id: ind.id,
|
|
623
680
|
message: (e && e.message) ? String(e.message) : String(e),
|
|
624
|
-
});
|
|
681
|
+
}, { dedupeKey: e => e.indicator_id || '' });
|
|
625
682
|
}
|
|
626
683
|
}
|
|
627
684
|
}
|
|
@@ -632,9 +689,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
632
689
|
// host AI is responsible for that). With NO captured artifacts, this is
|
|
633
690
|
// a clean empty submission — emit 'miss' so the run can reach
|
|
634
691
|
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
// 'pending_agent_run'
|
|
692
|
+
// A clean empty run with no captured artifacts must emit 'miss' so
|
|
693
|
+
// classification can reach 'not_detected'; otherwise theater_verdict
|
|
694
|
+
// stays 'pending_agent_run' indefinitely.
|
|
638
695
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
639
696
|
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
640
697
|
}
|
|
@@ -664,9 +721,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
664
721
|
// confirmed they're all benign" without this override.
|
|
665
722
|
const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
666
723
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
724
|
+
// Any override that's a non-empty string but NOT in the allowlist (e.g.
|
|
725
|
+
// 'present', 'unknown', '', ' detected ', 'Detected') surfaces as a
|
|
726
|
+
// runtime_error rather than silently falling through to engine-computed
|
|
670
727
|
// classification. Operators submitting case variants / whitespace-padded
|
|
671
728
|
// strings deserve a clear diagnostic, not a quiet downgrade. Treat the
|
|
672
729
|
// override as absent for classification purposes once recorded.
|
|
@@ -674,27 +731,27 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
674
731
|
const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
|
|
675
732
|
if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
|
|
676
733
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
677
|
-
runOpts._runErrors
|
|
734
|
+
pushRunError(runOpts._runErrors, {
|
|
678
735
|
kind: 'classification_override_invalid',
|
|
679
736
|
supplied: rawOverride,
|
|
680
737
|
allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
|
|
681
738
|
reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
|
|
682
|
-
});
|
|
739
|
+
}, { dedupeKey: e => String(e.supplied) });
|
|
683
740
|
}
|
|
684
741
|
}
|
|
685
742
|
const override = overrideIsInAllowlist ? rawOverride : undefined;
|
|
686
743
|
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
744
|
+
// Refuse ALL classification overrides (`detected`, `clean`,
|
|
745
|
+
// `not_detected`) when any indicator was FP-downgraded. A submission
|
|
746
|
+
// that maps to `'not_detected'` (either literally or via `'clean'`,
|
|
747
|
+
// which maps to `'not_detected'` at this site) MUST NOT hide a
|
|
748
|
+
// `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
|
|
749
|
+
// were unattested — that's a strictly worse false-negative outcome than
|
|
750
|
+
// allowing 'detected' through. Substitute 'inconclusive' and emit a
|
|
751
|
+
// runtime_error.
|
|
752
|
+
// Record indicator IDs and an unsatisfied-checks count ONLY — never the
|
|
753
|
+
// literal FP-check check-name strings (those are an attestation-bypass
|
|
754
|
+
// hint for a hostile agent reading the runtime_errors).
|
|
698
755
|
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
699
756
|
|
|
700
757
|
let classification;
|
|
@@ -705,7 +762,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
705
762
|
const attempted = override; // record what the operator submitted, not the mapped form
|
|
706
763
|
classification = substituted;
|
|
707
764
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
708
|
-
runOpts._runErrors
|
|
765
|
+
pushRunError(runOpts._runErrors, {
|
|
709
766
|
kind: 'classification_override_blocked',
|
|
710
767
|
attempted,
|
|
711
768
|
substituted,
|
|
@@ -713,7 +770,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
713
770
|
indicators_with_unsatisfied_fp_checks: indicatorResults
|
|
714
771
|
.filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
|
|
715
772
|
.map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
|
|
716
|
-
});
|
|
773
|
+
}, { dedupeKey: e => String(e.attempted) });
|
|
717
774
|
}
|
|
718
775
|
}
|
|
719
776
|
} else if (hasDeterministicHit || hasHighConfHit) {
|
|
@@ -753,7 +810,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
753
810
|
indicators_evaluated_count: indicatorResults.length,
|
|
754
811
|
classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
755
812
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
756
|
-
//
|
|
813
|
+
// Pass through any flat-shape observation collisions detected at
|
|
757
814
|
// normalize time so analyze() can publish them under
|
|
758
815
|
// analyze.signal_origins_with_collisions.
|
|
759
816
|
_signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
|
|
@@ -813,20 +870,20 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
813
870
|
const cveRefs = playbook.domain.cve_refs || [];
|
|
814
871
|
const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
|
|
815
872
|
: (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
|
|
816
|
-
//
|
|
873
|
+
// Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
|
|
817
874
|
// (not_affected / false_positive) from "keep but annotate" dispositions
|
|
818
875
|
// (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
|
|
819
876
|
// is computed below from agentSignals.vex_fixed when the operator passes
|
|
820
877
|
// it (CLI populates it from the VEX doc alongside vex_filter).
|
|
821
878
|
const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
|
|
822
879
|
: (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
|
|
823
|
-
//
|
|
880
|
+
// Wrap xref.byCve() so a corrupt catalog (or transient missing-index
|
|
824
881
|
// anomaly) surfaces as a runtime_error rather than crashing analyze().
|
|
825
882
|
const _byCveSafe = (id) => {
|
|
826
883
|
try { return xref.byCve(id); }
|
|
827
884
|
catch (e) {
|
|
828
885
|
if (Array.isArray(runOpts._runErrors)) {
|
|
829
|
-
runOpts._runErrors
|
|
886
|
+
pushRunError(runOpts._runErrors, { kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.cve_id || '' });
|
|
830
887
|
}
|
|
831
888
|
return { found: false, cve_id: id };
|
|
832
889
|
}
|
|
@@ -838,7 +895,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
838
895
|
const vexDropped = vexFilter
|
|
839
896
|
? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
|
|
840
897
|
: [];
|
|
841
|
-
//
|
|
898
|
+
// VEX-fixed CVEs remain in matched/catalog arrays but get annotated
|
|
842
899
|
// with vex_status:'fixed' downstream so consumers see them as resolved.
|
|
843
900
|
const vexFixedIds = vexFixed
|
|
844
901
|
? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
@@ -875,7 +932,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
875
932
|
}
|
|
876
933
|
}
|
|
877
934
|
|
|
878
|
-
//
|
|
935
|
+
// Indicator-level cve_ref correlation. Indicators may declare a
|
|
879
936
|
// cve_ref (string OR string[]) naming CVEs whose presence the indicator
|
|
880
937
|
// pattern-matches. When such an indicator fires AND the named CVE exists
|
|
881
938
|
// in the catalog, the CVE joins matched_cves with correlated_via=
|
|
@@ -914,7 +971,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
914
971
|
// carry a non-null correlated_via array; catalog_baseline_cves entries
|
|
915
972
|
// carry correlated_via:null and a `note` clarifying the field's intent.
|
|
916
973
|
const cveShape = (c, correlatedVia) => {
|
|
917
|
-
//
|
|
974
|
+
// Annotate VEX-fixed CVEs with vex_status. matched_cves still
|
|
918
975
|
// includes them so audit trails and SBOM reports surface "we know this
|
|
919
976
|
// is in scope but vendor declared it fixed."
|
|
920
977
|
const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
|
|
@@ -951,26 +1008,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
951
1008
|
|
|
952
1009
|
// RWEP composition: start from the per-CVE rwep_score of evidence-correlated
|
|
953
1010
|
// matches (NOT catalog baseline) so RWEP base reflects what the operator's
|
|
954
|
-
// evidence actually surfaced.
|
|
955
|
-
//
|
|
1011
|
+
// evidence actually surfaced. The "max" reduction across matched CVEs is
|
|
1012
|
+
// intentional — RWEP is a "worst-case real-world exploit priority", not
|
|
956
1013
|
// an arithmetic average. The most-exploitable CVE in the set drives the
|
|
957
1014
|
// base; secondary CVEs add via rwep_inputs adjustments below rather than
|
|
958
1015
|
// through base summing (which would double-count overlapping risk).
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
1016
|
+
// vex_status='fixed' CVEs do NOT drive the base — vendor declared them
|
|
1017
|
+
// resolved. They still appear in matched_cves for audit traceability but
|
|
1018
|
+
// don't elevate RWEP.
|
|
962
1019
|
const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
|
|
963
1020
|
const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
|
|
964
1021
|
|
|
965
|
-
//
|
|
966
|
-
// matched CVE having a corresponding attribute.
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
1022
|
+
// rwep_factor semantics: each rwep_input.weight is conditional on the
|
|
1023
|
+
// matched CVE having a corresponding attribute. Multiply weight by a
|
|
1024
|
+
// factor in [0, 1] derived from the first matched CVE's catalog
|
|
1025
|
+
// attribute so a weight only fires when its CVE-attribute supports it
|
|
1026
|
+
// (e.g. active_exploitation +25 only when the matched CVE is under
|
|
1027
|
+
// active exploitation). blast_radius is sourced from the analyze-phase
|
|
1028
|
+
// blast_radius_score / 5 (rubric ceiling). Negative weights
|
|
1029
|
+
// (patch_available, live_patch_available) keep their sign so a patched
|
|
1030
|
+
// CVE deducts the full magnitude when the catalog confirms a
|
|
974
1031
|
// patch is available.
|
|
975
1032
|
//
|
|
976
1033
|
// Aliasing: playbooks ship rwep_factor values `public_poc` and
|
|
@@ -1018,12 +1075,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1018
1075
|
}
|
|
1019
1076
|
};
|
|
1020
1077
|
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1078
|
+
// blast_radius_score validation. No supplied value → null +
|
|
1079
|
+
// signal='default'. Supplied value out of [0,5] → null +
|
|
1080
|
+
// signal='rejected' + runtime_error. Supplied value in range → use it +
|
|
1081
|
+
// signal='supplied'. The runner never defaults to a rubric entry — that
|
|
1082
|
+
// would be the opposite of safe-default when the rubric's lowest entry
|
|
1083
|
+
// is the LOWEST-blast row.
|
|
1027
1084
|
const blastRubric = an.blast_radius_model?.scoring_rubric || [];
|
|
1028
1085
|
let blastRadiusScore = null;
|
|
1029
1086
|
let blastRadiusSignal = 'default';
|
|
@@ -1036,11 +1093,11 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1036
1093
|
} else {
|
|
1037
1094
|
blastRadiusSignal = 'rejected';
|
|
1038
1095
|
if (Array.isArray(runOpts._runErrors)) {
|
|
1039
|
-
runOpts._runErrors
|
|
1096
|
+
pushRunError(runOpts._runErrors, { kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' }, { dedupeKey: e => String(e.supplied) });
|
|
1040
1097
|
}
|
|
1041
1098
|
}
|
|
1042
1099
|
}
|
|
1043
|
-
//
|
|
1100
|
+
// Use the first evidence-correlated CVE as the canonical attribute
|
|
1044
1101
|
// source for factor scaling. If matchedCves is empty there's no per-CVE
|
|
1045
1102
|
// evidence to gate on. v0.12.15: the prior fallback was
|
|
1046
1103
|
// `factorCve = null` → every factor returned 0 → catalog-shape playbooks
|
|
@@ -1133,21 +1190,20 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1133
1190
|
// detect.classification = inconclusive → theater_verdict = pending_agent_run
|
|
1134
1191
|
// Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
|
|
1135
1192
|
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// theater, pending_agent_run, unknown.
|
|
1193
|
+
// Validate agentSignals.theater_verdict against an allowlist so
|
|
1194
|
+
// downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
|
|
1195
|
+
// garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
|
|
1196
|
+
// present, theater, pending_agent_run, unknown.
|
|
1141
1197
|
const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
|
|
1142
1198
|
let theaterVerdict = agentSignals.theater_verdict;
|
|
1143
1199
|
if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
|
|
1144
1200
|
if (theaterVerdict !== undefined && theaterVerdict !== null && !_theaterAllowlist.has(theaterVerdict)) {
|
|
1145
1201
|
if (Array.isArray(runOpts._runErrors)) {
|
|
1146
|
-
runOpts._runErrors
|
|
1202
|
+
pushRunError(runOpts._runErrors, {
|
|
1147
1203
|
kind: 'theater_verdict_invalid',
|
|
1148
1204
|
supplied: theaterVerdict,
|
|
1149
1205
|
allowed: Array.from(_theaterAllowlist),
|
|
1150
|
-
});
|
|
1206
|
+
}, { dedupeKey: e => String(e.supplied) });
|
|
1151
1207
|
}
|
|
1152
1208
|
theaterVerdict = undefined;
|
|
1153
1209
|
}
|
|
@@ -1193,12 +1249,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1193
1249
|
// matched_cves when surfacing "what CVEs is the operator actually
|
|
1194
1250
|
// affected by based on submitted evidence?"
|
|
1195
1251
|
catalog_baseline_cves: catalogBaselineEntries,
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1252
|
+
// rwep base is reduced via Math.max across matched CVEs. Surface the
|
|
1253
|
+
// reduction strategy as a discoverable field so operators reading the
|
|
1198
1254
|
// bundle understand the semantics without grepping source.
|
|
1199
1255
|
rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
|
|
1200
1256
|
blast_radius_score: blastRadiusScore,
|
|
1201
|
-
//
|
|
1257
|
+
// Visible annotation of where blast_radius_score came from:
|
|
1202
1258
|
// 'supplied' — operator/agent provided a value in [0, 5].
|
|
1203
1259
|
// 'default' — no value supplied; runner returned null (no rubric guess).
|
|
1204
1260
|
// 'rejected' — value supplied but out of range; treated as default + runtime_error.
|
|
@@ -1209,7 +1265,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1209
1265
|
audit_evidence: an.compliance_theater_check?.audit_evidence,
|
|
1210
1266
|
reality_test: an.compliance_theater_check?.reality_test,
|
|
1211
1267
|
verdict: theaterVerdict,
|
|
1212
|
-
//
|
|
1268
|
+
// Render verdict_text for both 'theater' AND 'present' verdicts
|
|
1213
1269
|
// ('present' is a synonym used by some playbooks for "theater is here").
|
|
1214
1270
|
verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
|
|
1215
1271
|
? an.compliance_theater_check?.theater_verdict_if_gap
|
|
@@ -1231,14 +1287,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1231
1287
|
? `${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
1288
|
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
|
|
1233
1289
|
} : null,
|
|
1234
|
-
//
|
|
1290
|
+
// Regex-eval failures surfaced here so operators can see WHICH
|
|
1235
1291
|
// condition expression crashed without the runner dying. Only present
|
|
1236
1292
|
// when at least one evalCondition() call hit a regex exception during
|
|
1237
1293
|
// this analyze pass; runOpts._runErrors is the same accumulator
|
|
1238
1294
|
// populated by run() across all phases, so callers reading this field
|
|
1239
1295
|
// see every regex problem in the run.
|
|
1240
1296
|
runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
|
|
1241
|
-
//
|
|
1297
|
+
// Collisions when two flat-shape observations targeted the same
|
|
1242
1298
|
// indicator id. Empty when there were no collisions or no flat-shape
|
|
1243
1299
|
// observations submitted.
|
|
1244
1300
|
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 +1304,8 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1248
1304
|
/**
|
|
1249
1305
|
* Extract VEX disposition sets from a CycloneDX/OpenVEX document.
|
|
1250
1306
|
*
|
|
1251
|
-
*
|
|
1252
|
-
* "drop" set
|
|
1307
|
+
* OpenVEX `fixed` and `not_affected` must NOT collapse into a single
|
|
1308
|
+
* "drop" set — they have different semantics:
|
|
1253
1309
|
*
|
|
1254
1310
|
* - not_affected / false_positive → drop from matched_cves entirely.
|
|
1255
1311
|
* The vendor has formally declared the product not vulnerable; the CVE
|
|
@@ -1298,7 +1354,7 @@ function vexFilterFromDoc(doc) {
|
|
|
1298
1354
|
|
|
1299
1355
|
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
1300
1356
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1301
|
-
//
|
|
1357
|
+
// Surface evalCondition regex errors raised here into the same
|
|
1302
1358
|
// run-wide accumulator that analyze() reads.
|
|
1303
1359
|
const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
1304
1360
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
@@ -1322,7 +1378,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1322
1378
|
// weren't verified — the agent can surface that to the operator.
|
|
1323
1379
|
if (!selected && paths.length) selected = paths[0];
|
|
1324
1380
|
|
|
1325
|
-
//
|
|
1381
|
+
// selected_remediation selection logic:
|
|
1326
1382
|
// 1. Iterate remediation_paths sorted by priority ASC (lower number =
|
|
1327
1383
|
// higher priority per schema convention).
|
|
1328
1384
|
// 2. Pick the FIRST path whose every precondition (evaluated against
|
|
@@ -1335,18 +1391,17 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1335
1391
|
// precondition trace so operators can see why a higher-priority path was
|
|
1336
1392
|
// skipped.
|
|
1337
1393
|
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
// unparseable. Preserve backwards compatibility by keeping
|
|
1394
|
+
// Regression schedule. Returns a structured object with next_run +
|
|
1395
|
+
// event_triggers + unparseable. Backwards compatibility: keep
|
|
1341
1396
|
// regression_next_run as the ISO string (or null) so existing CSAF /
|
|
1342
1397
|
// attestation consumers don't break; expose the structured form
|
|
1343
1398
|
// separately.
|
|
1344
1399
|
const triggers = v.regression_trigger || [];
|
|
1345
1400
|
const regressionResult = computeRegressionNextRun(triggers);
|
|
1346
1401
|
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1402
|
+
// Reason annotation for null next_run — operators see WHY a schedule
|
|
1403
|
+
// didn't emit a calendar date (no day intervals declared, every trigger
|
|
1404
|
+
// is event-driven, or every trigger was unparseable).
|
|
1350
1405
|
let nextRunReason = null;
|
|
1351
1406
|
if (!regressionResult.next_run) {
|
|
1352
1407
|
if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
|
|
@@ -1377,15 +1432,15 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1377
1432
|
}
|
|
1378
1433
|
|
|
1379
1434
|
/**
|
|
1380
|
-
*
|
|
1435
|
+
* Extended interval parser. Supports:
|
|
1381
1436
|
* <N>d — N days
|
|
1382
1437
|
* <N>wk — N weeks
|
|
1383
1438
|
* <N>mo — N calendar months (Date.setMonth semantics)
|
|
1384
1439
|
* <N>yr — N calendar years
|
|
1385
1440
|
* on_event — event-triggered, no date computed; surfaces in
|
|
1386
1441
|
* regression_event_triggers[] for the consumer.
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1442
|
+
* Without all five forms, a playbook declaring "regression on every
|
|
1443
|
+
* release" or
|
|
1389
1444
|
* "monthly review" lost its schedule entry.
|
|
1390
1445
|
*/
|
|
1391
1446
|
function parseInterval(intervalStr, now) {
|
|
@@ -1473,12 +1528,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1473
1528
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1474
1529
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1475
1530
|
);
|
|
1476
|
-
//
|
|
1531
|
+
// Thread runOpts through so computeClockStart can check
|
|
1477
1532
|
// operator_consent.explicit before auto-stamping detect_confirmed.
|
|
1478
1533
|
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
|
|
1534
|
+
// When the clock event is detect_confirmed AND the classification
|
|
1535
|
+
// matched AND the operator did NOT pass --ack, surface
|
|
1536
|
+
// clock_pending_ack so the notification record is visibly waiting on
|
|
1537
|
+
// acknowledgement.
|
|
1482
1538
|
const clockPendingAck = !clockStart
|
|
1483
1539
|
&& obligation?.clock_starts === 'detect_confirmed'
|
|
1484
1540
|
&& agentSignals?.detection_classification === 'detected'
|
|
@@ -1504,20 +1560,20 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1504
1560
|
// Evidence the regulator expects attached (from the obligation, not
|
|
1505
1561
|
// just the operator-facing recipient bundle on the notification entry).
|
|
1506
1562
|
evidence_required: obligation?.evidence_required || na.evidence_attached || [],
|
|
1507
|
-
//
|
|
1563
|
+
// Track missing interpolation variables so operators see exactly
|
|
1508
1564
|
// which template vars failed to resolve. Empty array when all
|
|
1509
1565
|
// placeholders rendered cleanly.
|
|
1510
1566
|
...(function () {
|
|
1511
1567
|
const missing = [];
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1568
|
+
// analyzeFindingShape is a pure transform but defensive-wrap it
|
|
1569
|
+
// so a malformed analyze result (missing matched_cves, etc.)
|
|
1514
1570
|
// can't bring down the whole close phase. Failures surface in
|
|
1515
1571
|
// runtime_errors via runOpts._runErrors when available.
|
|
1516
1572
|
let findingShape;
|
|
1517
1573
|
try { findingShape = analyzeFindingShape(analyzeResult); }
|
|
1518
1574
|
catch (e) {
|
|
1519
1575
|
if (Array.isArray(runOpts._runErrors)) {
|
|
1520
|
-
runOpts._runErrors
|
|
1576
|
+
pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.message || '' });
|
|
1521
1577
|
}
|
|
1522
1578
|
findingShape = {};
|
|
1523
1579
|
}
|
|
@@ -1564,13 +1620,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1564
1620
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
1565
1621
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
1566
1622
|
: [];
|
|
1567
|
-
//
|
|
1568
|
-
// bundles_by_format[primary]
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1623
|
+
// Build every bundle once and reuse, so bundle_body and
|
|
1624
|
+
// bundles_by_format[primary] share object identity (and timestamps).
|
|
1625
|
+
// Without memoisation, buildEvidenceBundle gets invoked twice for the
|
|
1626
|
+
// primary format and each invocation crystallises a fresh Date.now() —
|
|
1627
|
+
// operators diffing bundle_body against bundles_by_format.<primary> see
|
|
1628
|
+
// spurious millisecond drift on tracking.initial_release_date /
|
|
1629
|
+
// timestamp / current_release_date.
|
|
1574
1630
|
const evidencePackage = c.evidence_package ? (() => {
|
|
1575
1631
|
const issuedAt = new Date().toISOString();
|
|
1576
1632
|
const builtFormats = new Map();
|
|
@@ -1639,8 +1695,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1639
1695
|
validate: validateResult,
|
|
1640
1696
|
finding: analyzeFindingShape(analyzeResult),
|
|
1641
1697
|
...agentSignals,
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1698
|
+
// Surface evalCondition regex failures from the feeds_into chain into
|
|
1699
|
+
// the same accumulator. Without this the regex failure happens but
|
|
1644
1700
|
// analyze.runtime_errors[] never sees it.
|
|
1645
1701
|
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
1646
1702
|
};
|
|
@@ -1665,7 +1721,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1665
1721
|
exception: exception,
|
|
1666
1722
|
regression_schedule: regressionSchedule,
|
|
1667
1723
|
feeds_into: feeds,
|
|
1668
|
-
//
|
|
1724
|
+
// feeds_into surfaces downstream playbook IDs whose preconditions
|
|
1669
1725
|
// were satisfied by this run. The runner does NOT automatically chain
|
|
1670
1726
|
// into them — the agent / operator decides whether to invoke them.
|
|
1671
1727
|
// Surface that contract on the result so consumers don't assume an
|
|
@@ -1674,7 +1730,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1674
1730
|
};
|
|
1675
1731
|
}
|
|
1676
1732
|
|
|
1677
|
-
//
|
|
1733
|
+
// Severity ladder for active_exploitation. The worst-of reduction lets
|
|
1678
1734
|
// analyzeFindingShape report the most-exploited CVE in the matched set, not
|
|
1679
1735
|
// the first-encountered one. Higher index = worse.
|
|
1680
1736
|
const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
|
|
@@ -1691,10 +1747,10 @@ function worstActiveExploitation(matchedCves) {
|
|
|
1691
1747
|
return worst || 'unknown';
|
|
1692
1748
|
}
|
|
1693
1749
|
|
|
1694
|
-
//
|
|
1695
|
-
// `finding.severity` in feeds_into and escalation_criteria conditions
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1750
|
+
// Severity ladder derived from rwep_adjusted. Playbooks reference
|
|
1751
|
+
// `finding.severity` in feeds_into and escalation_criteria conditions;
|
|
1752
|
+
// emit it so those conditions resolve against a real value rather than
|
|
1753
|
+
// undefined. Thresholds:
|
|
1698
1754
|
// rwep >= 80 → critical
|
|
1699
1755
|
// rwep >= 50 → high
|
|
1700
1756
|
// rwep >= 20 → medium
|
|
@@ -1712,22 +1768,21 @@ function analyzeFindingShape(a) {
|
|
|
1712
1768
|
const rwepAdjusted = a.rwep?.adjusted ?? 0;
|
|
1713
1769
|
return {
|
|
1714
1770
|
matched_cve_ids: matched.map(c => c.cve_id).join(', '),
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1771
|
+
// Sibling array form for consumers that want to iterate IDs without
|
|
1772
|
+
// re-splitting the joined string. The joined form stays for backwards
|
|
1773
|
+
// compatibility with notification-draft templates that interpolate
|
|
1774
|
+
// `${matched_cve_ids}` verbatim.
|
|
1719
1775
|
matched_cve_ids_array: matched.map(c => c.cve_id),
|
|
1720
1776
|
matched_cve_count: matched.length,
|
|
1721
1777
|
kev_listed_count: matched.filter(c => c.cisa_kev).length,
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
// 'suspected'
|
|
1725
|
-
//
|
|
1726
|
-
// worst rank across all matched CVEs.
|
|
1778
|
+
// Reduce active_exploitation to the worst rank across all matched
|
|
1779
|
+
// CVEs. A .find() lookup would return the first truthy entry — e.g.
|
|
1780
|
+
// 'suspected' on CVE #1 when CVE #2 is 'confirmed' — under-stating
|
|
1781
|
+
// the threat in notification drafts.
|
|
1727
1782
|
active_exploitation: worstActiveExploitation(matched),
|
|
1728
1783
|
rwep_adjusted: rwepAdjusted,
|
|
1729
1784
|
rwep_base: a.rwep?.base ?? 0,
|
|
1730
|
-
//
|
|
1785
|
+
// Severity surface for playbook conditions.
|
|
1731
1786
|
severity: severityForRwep(rwepAdjusted),
|
|
1732
1787
|
blast_radius_score: a.blast_radius_score ?? 0,
|
|
1733
1788
|
framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
|
|
@@ -1735,6 +1790,121 @@ function analyzeFindingShape(a) {
|
|
|
1735
1790
|
};
|
|
1736
1791
|
}
|
|
1737
1792
|
|
|
1793
|
+
// Route a vulnerability identifier to its registry-specific URN namespace.
|
|
1794
|
+
// CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
|
|
1795
|
+
// unrecognised prefixes route to the `urn:exceptd:advisory:` private
|
|
1796
|
+
// namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
|
|
1797
|
+
function vulnIdToUrn(id) {
|
|
1798
|
+
const slug = urnSlug(id);
|
|
1799
|
+
if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${slug}`;
|
|
1800
|
+
if (/^CVE-/i.test(id)) return `urn:cve:${slug}`;
|
|
1801
|
+
if (/^GHSA-/i.test(id)) return `urn:ghsa:${slug}`;
|
|
1802
|
+
if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${slug}`;
|
|
1803
|
+
if (/^MAL-/i.test(id)) return `urn:malicious-package:${slug}`;
|
|
1804
|
+
return `urn:exceptd:advisory:${slug}`;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Build a CSAF product_tree.branches[] tree (vendor → product_name →
|
|
1808
|
+
// product_version). Sources of vendor/product/version, in priority order:
|
|
1809
|
+
// (1) catalog entry `affected_products: [{ vendor, product, version }]`
|
|
1810
|
+
// (2) heuristic parse of `affected_components[]` strings — accepts
|
|
1811
|
+
// `vendor/product@version` and `vendor product version` shapes.
|
|
1812
|
+
// Unparseable component strings emit a `csaf_branch_unparseable` runtime
|
|
1813
|
+
// error and are dropped from the tree. Sort alphabetical at each level so
|
|
1814
|
+
// the output is deterministic across runs.
|
|
1815
|
+
//
|
|
1816
|
+
// Returns `{ branches, productIds }`. productIds is a stable enumeration
|
|
1817
|
+
// CSAFPID-0..N keyed by (vendor, product, version) insertion order so other
|
|
1818
|
+
// emit paths can reference the leaf products by id later.
|
|
1819
|
+
function buildCsafBranches(matchedCves, runOpts) {
|
|
1820
|
+
// Build a (vendor → product → Set<version>) map.
|
|
1821
|
+
const tree = new Map();
|
|
1822
|
+
const addLeaf = (vendor, product, version) => {
|
|
1823
|
+
if (!vendor || !product || !version) return;
|
|
1824
|
+
if (!tree.has(vendor)) tree.set(vendor, new Map());
|
|
1825
|
+
const products = tree.get(vendor);
|
|
1826
|
+
if (!products.has(product)) products.set(product, new Set());
|
|
1827
|
+
products.get(product).add(version);
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
// Heuristic parser. Returns { vendor, product, version } or null.
|
|
1831
|
+
const parseComponentString = (s) => {
|
|
1832
|
+
if (typeof s !== 'string' || !s.trim()) return null;
|
|
1833
|
+
const trimmed = s.trim();
|
|
1834
|
+
// `vendor/product@version`
|
|
1835
|
+
let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
|
|
1836
|
+
if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
|
|
1837
|
+
// `vendor product version` — exactly three whitespace-separated tokens
|
|
1838
|
+
// where the last token starts with a digit or `v\d`.
|
|
1839
|
+
const parts = trimmed.split(/\s+/);
|
|
1840
|
+
if (parts.length >= 3) {
|
|
1841
|
+
const last = parts[parts.length - 1];
|
|
1842
|
+
if (/^v?\d/.test(last)) {
|
|
1843
|
+
return { vendor: parts[0], product: parts.slice(1, -1).join(' '), version: last };
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return null;
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
for (const c of matchedCves || []) {
|
|
1850
|
+
if (Array.isArray(c.affected_products) && c.affected_products.length > 0) {
|
|
1851
|
+
for (const ap of c.affected_products) {
|
|
1852
|
+
if (ap && typeof ap === 'object' && ap.vendor && ap.product && ap.version) {
|
|
1853
|
+
addLeaf(String(ap.vendor), String(ap.product), String(ap.version));
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
const components = Array.isArray(c.affected_components) ? c.affected_components
|
|
1859
|
+
: (Array.isArray(c.affected_versions) ? c.affected_versions : []);
|
|
1860
|
+
for (const comp of components) {
|
|
1861
|
+
const parsed = parseComponentString(comp);
|
|
1862
|
+
if (parsed) {
|
|
1863
|
+
addLeaf(parsed.vendor, parsed.product, parsed.version);
|
|
1864
|
+
} else if (typeof comp === 'string' && comp.trim() && runOpts && Array.isArray(runOpts._runErrors)) {
|
|
1865
|
+
pushRunError(runOpts._runErrors, {
|
|
1866
|
+
kind: 'csaf_branch_unparseable',
|
|
1867
|
+
component: String(comp),
|
|
1868
|
+
cve_id: c.cve_id || null,
|
|
1869
|
+
}, { dedupeKey: e => `${e.cve_id || ''}::${e.component}` });
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Sort + emit.
|
|
1875
|
+
const productIds = [];
|
|
1876
|
+
let pidCounter = 0;
|
|
1877
|
+
const vendors = Array.from(tree.keys()).sort();
|
|
1878
|
+
const branches = vendors.map(vendor => {
|
|
1879
|
+
const products = tree.get(vendor);
|
|
1880
|
+
const productNames = Array.from(products.keys()).sort();
|
|
1881
|
+
return {
|
|
1882
|
+
category: 'vendor',
|
|
1883
|
+
name: vendor,
|
|
1884
|
+
branches: productNames.map(product => {
|
|
1885
|
+
const versions = Array.from(products.get(product)).sort();
|
|
1886
|
+
return {
|
|
1887
|
+
category: 'product_name',
|
|
1888
|
+
name: product,
|
|
1889
|
+
branches: versions.map(version => {
|
|
1890
|
+
const pid = `CSAFPID-${pidCounter++}`;
|
|
1891
|
+
productIds.push({ vendor, product, version, product_id: pid });
|
|
1892
|
+
return {
|
|
1893
|
+
category: 'product_version',
|
|
1894
|
+
name: version,
|
|
1895
|
+
product: {
|
|
1896
|
+
name: `${vendor}/${product}@${version}`,
|
|
1897
|
+
product_id: pid,
|
|
1898
|
+
},
|
|
1899
|
+
};
|
|
1900
|
+
}),
|
|
1901
|
+
};
|
|
1902
|
+
}),
|
|
1903
|
+
};
|
|
1904
|
+
});
|
|
1905
|
+
return { branches, productIds };
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1738
1908
|
// Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
|
|
1739
1909
|
// Empty input → 'unknown' so we never emit zero-length segments.
|
|
1740
1910
|
function urnSlug(s) {
|
|
@@ -1769,7 +1939,7 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
1769
1939
|
// surface at least one candidate when any is known. Returns null when no
|
|
1770
1940
|
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1771
1941
|
//
|
|
1772
|
-
//
|
|
1942
|
+
// Source segments are heterogeneous — many playbook artifacts
|
|
1773
1943
|
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
1774
1944
|
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
1775
1945
|
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
@@ -1826,24 +1996,24 @@ function getEngineVersion() {
|
|
|
1826
1996
|
return _CACHED_PKG_VERSION;
|
|
1827
1997
|
}
|
|
1828
1998
|
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1999
|
+
// Operator-supplied identity strings (--operator) and publisher namespace
|
|
2000
|
+
// URLs (--publisher-namespace) flow into operator-facing CSAF surfaces.
|
|
2001
|
+
// Strip ASCII control characters as defence in depth — bin/exceptd.js
|
|
2002
|
+
// already validates the CLI inputs, but the runner is also called from
|
|
2003
|
+
// library consumers that may bypass the CLI surface.
|
|
1834
2004
|
//
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
//
|
|
1838
|
-
//
|
|
1839
|
-
// already refuses
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
2005
|
+
// Strip Unicode bidi / format / control / surrogate / private-use /
|
|
2006
|
+
// unassigned categories (\p{C} under the `u` regex flag) so direct
|
|
2007
|
+
// library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
|
|
2008
|
+
// OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
|
|
2009
|
+
// already refuses. NFC-normalise first so a decomposed sequence can't
|
|
2010
|
+
// combine past the codepoint check; cap the result at 256 codepoints
|
|
2011
|
+
// (NOT UTF-16 code units) so a string of astral-plane codepoints can't
|
|
2012
|
+
// smuggle a longer-than-256-display string past the cap by exploiting
|
|
2013
|
+
// JavaScript's surrogate-pair string length. Returns null on rejection
|
|
2014
|
+
// (empty after strip, or NFC normalise threw); callers (the
|
|
2015
|
+
// publisher-namespace + contact_details + tracking.generator sites)
|
|
2016
|
+
// treat null as "operator-unclaimed" and route through the existing
|
|
1847
2017
|
// fallback (publisher.namespace = urn:exceptd:operator:unknown +
|
|
1848
2018
|
// bundle_publisher_unclaimed runtime warning).
|
|
1849
2019
|
function sanitizeOperatorText(s) {
|
|
@@ -1867,16 +2037,60 @@ function sanitizeOperatorText(s) {
|
|
|
1867
2037
|
return cps.slice(0, 256).join('');
|
|
1868
2038
|
}
|
|
1869
2039
|
|
|
2040
|
+
/**
|
|
2041
|
+
* Build a single evidence bundle in the requested machine-readable format.
|
|
2042
|
+
*
|
|
2043
|
+
* Positional contract — the seven phase functions cache the closure over
|
|
2044
|
+
* `playbook`, `analyze`, and `validate` so consumers don't reach into the
|
|
2045
|
+
* runner's intermediate state. Library callers that bypass close() (e.g.
|
|
2046
|
+
* external dashboards re-rendering a stored attestation) MUST honor the
|
|
2047
|
+
* same parameter order, names, and types.
|
|
2048
|
+
*
|
|
2049
|
+
* @param {string} format Output dialect. One of: 'csaf-2.0',
|
|
2050
|
+
* 'sarif' / 'sarif-2.1.0', 'openvex' /
|
|
2051
|
+
* 'openvex-0.2.0', 'summary', 'markdown'.
|
|
2052
|
+
* Unknown values return a stub with
|
|
2053
|
+
* supported_formats so callers can branch.
|
|
2054
|
+
* @param {object} playbook Playbook record loaded via loadPlaybook().
|
|
2055
|
+
* Provides _meta.id / version, domain.name,
|
|
2056
|
+
* phases.look.artifacts (for SARIF
|
|
2057
|
+
* locations), and feeds_into / mutex.
|
|
2058
|
+
* @param {object} analyze Output of analyze(). Carries matched_cves,
|
|
2059
|
+
* _detect_indicators, framework_gap_mapping,
|
|
2060
|
+
* rwep, blast_radius_score,
|
|
2061
|
+
* _detect_classification.
|
|
2062
|
+
* @param {object} validate Output of validate(). Carries
|
|
2063
|
+
* selected_remediation, remediation_paths,
|
|
2064
|
+
* evidence_requirements,
|
|
2065
|
+
* residual_risk_statement.
|
|
2066
|
+
* @param {object} agentSignals Agent-submitted signals (signal_overrides
|
|
2067
|
+
* merged + cleaned). Drives the OpenVEX
|
|
2068
|
+
* vex_status:'fixed' attestation trail and
|
|
2069
|
+
* the CSAF cvss_v3 score-block gate.
|
|
2070
|
+
* @param {string} sessionId Run session id (threaded from run()).
|
|
2071
|
+
* Becomes part of CSAF tracking.id,
|
|
2072
|
+
* OpenVEX @id, and the on-disk attestation
|
|
2073
|
+
* file name so all three correlate.
|
|
2074
|
+
* @param {string=} issuedAt Optional ISO 8601 timestamp. Pinning this
|
|
2075
|
+
* across multi-format emits keeps CSAF /
|
|
2076
|
+
* OpenVEX / SARIF agreed on milliseconds;
|
|
2077
|
+
* each call would otherwise crystallise a
|
|
2078
|
+
* fresh Date.now().
|
|
2079
|
+
* @param {object=} runOpts Operator / library knobs. Recognised
|
|
2080
|
+
* fields: operator, publisherNamespace,
|
|
2081
|
+
* csafStatus, tlp, _runErrors accumulator.
|
|
2082
|
+
* @returns {object} The requested format's document body.
|
|
2083
|
+
*/
|
|
1870
2084
|
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
|
|
1871
2085
|
runOpts = runOpts || {};
|
|
1872
2086
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1873
2087
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1874
|
-
//
|
|
2088
|
+
// Pin one `now` value per bundle build (and accept an
|
|
1875
2089
|
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
1876
2090
|
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
1877
2091
|
// building several formats from the same run. Without the parameter,
|
|
1878
|
-
// each invocation
|
|
1879
|
-
// versus bundles_by_format[primary]
|
|
2092
|
+
// each invocation crystallises a fresh `Date.now()` and bundle_body
|
|
2093
|
+
// versus bundles_by_format[primary] diverge on milliseconds.
|
|
1880
2094
|
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
1881
2095
|
|
|
1882
2096
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
@@ -1895,16 +2109,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1895
2109
|
name: productName,
|
|
1896
2110
|
product_identification_helper: { purl: productPurl }
|
|
1897
2111
|
}];
|
|
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
|
-
//
|
|
2112
|
+
// `fixed` product_status MUST reflect operator-supplied VEX
|
|
2113
|
+
// disposition (vex_status === 'fixed' — see analyze()), not the
|
|
2114
|
+
// catalog's global `live_patch_available` flag. The catalog flag
|
|
2115
|
+
// means "vendor publishes a live-patch in the world", not "operator
|
|
2116
|
+
// deployed it on this host". Declaring every live-patchable CVE as
|
|
2117
|
+
// fixed regardless of operator evidence would produce CSAF documents
|
|
2118
|
+
// that lie to downstream NVD / Red Hat dashboards. When
|
|
2119
|
+
// live_patch_available is the only signal, status stays
|
|
2120
|
+
// known_affected and the live-patch route is surfaced as a
|
|
2121
|
+
// `vendor_fix` remediation.
|
|
1908
2122
|
// CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
1909
2123
|
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
1910
2124
|
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
@@ -1936,25 +2150,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1936
2150
|
return m[1];
|
|
1937
2151
|
};
|
|
1938
2152
|
const csafIdsFor = (id) => {
|
|
1939
|
-
//
|
|
1940
|
-
// "
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
// dashboards. Return null so the caller
|
|
1944
|
-
// and
|
|
2153
|
+
// null / undefined / non-string id MUST NOT emit literal "null" /
|
|
2154
|
+
// "undefined" text into the vulnerabilities[] entry. String(id)
|
|
2155
|
+
// would coerce both to those literals; strict validators then
|
|
2156
|
+
// reject the document and operators see a phantom "null" CVE in
|
|
2157
|
+
// dashboards. Return null so the caller skips the entry entirely
|
|
2158
|
+
// and surfaces a runtime_error for the missing id.
|
|
1945
2159
|
if (typeof id !== 'string' || !id) return null;
|
|
1946
2160
|
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
1947
2161
|
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
1948
2162
|
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
1949
2163
|
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.
|
|
2164
|
+
// RUSTSEC advisories carry their own tracking authority
|
|
2165
|
+
// (https://rustsec.org); mis-routing them to system_name 'OSV'
|
|
2166
|
+
// loses the upstream provenance link and confuses downstream
|
|
2167
|
+
// ingesters that resolve by (system_name, text) pair.
|
|
1954
2168
|
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
// every unknown id
|
|
2169
|
+
// Genuinely-unknown prefix surfaces as `exceptd-unknown` so
|
|
2170
|
+
// downstream ingesters see that the authority wasn't recognised
|
|
2171
|
+
// rather than misattributing every unknown id to OSV.
|
|
1958
2172
|
return { system_name: 'exceptd-unknown', text: id };
|
|
1959
2173
|
};
|
|
1960
2174
|
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
@@ -1967,24 +2181,22 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1967
2181
|
|| (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
2182
|
product_ids: [productId],
|
|
1969
2183
|
}];
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
// under ids[]. Skip the vulnerability entry entirely and
|
|
1973
|
-
// runtime_error so the catalog gap is visible to
|
|
2184
|
+
// Catalog entries with a missing / non-string cve_id would
|
|
2185
|
+
// otherwise produce literal `text: "null"` / `text: "undefined"`
|
|
2186
|
+
// entries under ids[]. Skip the vulnerability entry entirely and
|
|
2187
|
+
// surface a runtime_error so the catalog gap is visible to
|
|
2188
|
+
// operators / CI gates.
|
|
1974
2189
|
const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
|
|
1975
2190
|
let idEntry = null;
|
|
1976
2191
|
if (!idIsCve) {
|
|
1977
2192
|
idEntry = csafIdsFor(c.cve_id);
|
|
1978
2193
|
if (idEntry == null) {
|
|
1979
2194
|
if (Array.isArray(runOpts._runErrors)) {
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
-
}
|
|
2195
|
+
pushRunError(runOpts._runErrors, {
|
|
2196
|
+
kind: 'bundle_cve_id_missing',
|
|
2197
|
+
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[].',
|
|
2198
|
+
remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
|
|
2199
|
+
}, { dedupeKey: () => 'singleton' });
|
|
1988
2200
|
}
|
|
1989
2201
|
return null;
|
|
1990
2202
|
}
|
|
@@ -1997,24 +2209,32 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1997
2209
|
// an authoritative "informational" score where there was simply no
|
|
1998
2210
|
// data.
|
|
1999
2211
|
//
|
|
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.
|
|
2212
|
+
// CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
|
|
2213
|
+
// vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
|
|
2214
|
+
// cvss_v3 block with version: '2.0' / '4.0', which strict
|
|
2215
|
+
// validators (BSI CSAF Validator) reject outright. Drop the block
|
|
2216
|
+
// for non-3.x vectors and surface a runtime_error so operators can
|
|
2217
|
+
// see why their CVSS data didn't make it through.
|
|
2006
2218
|
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
2007
|
-
|
|
2008
|
-
|
|
2219
|
+
// Strict CVSS 3.1 parse (lib/scoring.parseCvss31Vector). The pre-fix
|
|
2220
|
+
// permissive regex accepted any CVSS:X.Y/... prefix and would emit a
|
|
2221
|
+
// cvss_v3 block keyed off a malformed vector — strict validators
|
|
2222
|
+
// (BSI CSAF Validator, ENISA dashboard) then reject the whole
|
|
2223
|
+
// document. Strict parse failures surface as a `csaf_cvss_invalid`
|
|
2224
|
+
// runtime_error, the cvss_v3 block is omitted, and the rest of the
|
|
2225
|
+
// vulnerability entry (product_status, remediations, etc.) survives.
|
|
2226
|
+
let strictParse = null;
|
|
2227
|
+
if (hasCvss) {
|
|
2228
|
+
strictParse = scoring.parseCvss31Vector(c.cvss_vector);
|
|
2229
|
+
}
|
|
2230
|
+
const vectorVersion = hasCvss ? (strictParse && strictParse.version) : null;
|
|
2231
|
+
const cvssV3Eligible = !!(hasCvss && strictParse && strictParse.ok);
|
|
2009
2232
|
if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
}
|
|
2233
|
+
pushRunError(runOpts._runErrors, {
|
|
2234
|
+
kind: 'csaf_cvss_invalid',
|
|
2235
|
+
cve_id: c.cve_id,
|
|
2236
|
+
reason: (strictParse && strictParse.reason) || 'cvss_vector failed strict CVSS 3.1 parse',
|
|
2237
|
+
}, { dedupeKey: e => e.cve_id || 'unknown' });
|
|
2018
2238
|
}
|
|
2019
2239
|
const scores = cvssV3Eligible ? [{
|
|
2020
2240
|
products: [productId],
|
|
@@ -2047,15 +2267,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2047
2267
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
2048
2268
|
product_status: { known_affected: [productId] }
|
|
2049
2269
|
}));
|
|
2050
|
-
//
|
|
2051
|
-
//
|
|
2052
|
-
// `
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2270
|
+
// Framework-gap entries land in `document.notes[]` with
|
|
2271
|
+
// `category: details` rather than `vulnerabilities[]` with
|
|
2272
|
+
// `ids: [{ system_name: 'exceptd-framework-gap' }]`. The `system_name`
|
|
2273
|
+
// slot is reserved for recognised vulnerability tracking authorities
|
|
2274
|
+
// (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
|
|
2275
|
+
// downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
|
|
2276
|
+
// validator) would flag the run for unknown ids and render
|
|
2277
|
+
// false-positive advisories at the framework_gap_mapping length.
|
|
2278
|
+
// Notes are the right home for advisory context that is not itself
|
|
2279
|
+
// a pseudo-CVE.
|
|
2059
2280
|
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
2060
2281
|
const lines = [
|
|
2061
2282
|
`Framework: ${g.framework}`,
|
|
@@ -2105,14 +2326,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2105
2326
|
// De-dupe: only push once per bundle-build pass (multi-format emit
|
|
2106
2327
|
// builds CSAF once via memoization, so this fires at most once per run).
|
|
2107
2328
|
if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
|
|
2114
|
-
});
|
|
2115
|
-
}
|
|
2329
|
+
pushRunError(runOpts._runErrors, {
|
|
2330
|
+
kind: 'bundle_publisher_unclaimed',
|
|
2331
|
+
reason: 'CSAF document.publisher.namespace fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Operator attribution is unclaimed on this advisory.',
|
|
2332
|
+
remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
|
|
2333
|
+
}, { dedupeKey: () => 'singleton' });
|
|
2116
2334
|
}
|
|
2117
2335
|
|
|
2118
2336
|
// thread the validated --operator name into
|
|
@@ -2140,6 +2358,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2140
2358
|
? runOpts.csafStatus
|
|
2141
2359
|
: 'interim';
|
|
2142
2360
|
|
|
2361
|
+
// CSAF §3.1.4 `distribution.tlp`. Optional. When the operator supplies
|
|
2362
|
+
// `--tlp <label>` (threaded as runOpts.tlp), emit
|
|
2363
|
+
// distribution.tlp.label + distribution.text. CSAF allows omission of
|
|
2364
|
+
// the whole distribution block when no level is declared; the
|
|
2365
|
+
// pre-fix runner had no surface for this at all.
|
|
2366
|
+
const allowedTlp = new Set(['CLEAR', 'GREEN', 'AMBER', 'AMBER+STRICT', 'RED']);
|
|
2367
|
+
const csafDistribution = (runOpts.tlp && allowedTlp.has(runOpts.tlp))
|
|
2368
|
+
? { tlp: { label: runOpts.tlp }, text: `TLP:${runOpts.tlp}` }
|
|
2369
|
+
: null;
|
|
2370
|
+
|
|
2143
2371
|
return {
|
|
2144
2372
|
document: {
|
|
2145
2373
|
category: 'csaf_security_advisory',
|
|
@@ -2147,6 +2375,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2147
2375
|
publisher: publisherBlock,
|
|
2148
2376
|
title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
|
|
2149
2377
|
notes: [...namespaceFallbackNote, ...gapNotes],
|
|
2378
|
+
...(csafDistribution ? { distribution: csafDistribution } : {}),
|
|
2150
2379
|
tracking: {
|
|
2151
2380
|
// F2/F9: CSAF tracking.id binds to the run's session_id (threaded
|
|
2152
2381
|
// from run() via close()) so attestation file names, OpenVEX
|
|
@@ -2168,7 +2397,19 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2168
2397
|
revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
|
|
2169
2398
|
}
|
|
2170
2399
|
},
|
|
2171
|
-
product_tree:
|
|
2400
|
+
product_tree: (function () {
|
|
2401
|
+
// Synthesize a 3-level branches tree (vendor → product → version)
|
|
2402
|
+
// from catalog data. CSAF §3.1.5.1 makes branches[] strongly
|
|
2403
|
+
// recommended for csaf_security_advisory documents because NVD /
|
|
2404
|
+
// ENISA / Red Hat dashboards render the affected-product list off
|
|
2405
|
+
// the branches tree, not full_product_names[]. The pre-fix tree
|
|
2406
|
+
// emitted only the synthetic exceptd-target product and operators
|
|
2407
|
+
// browsing the rendered advisory saw no real-world vendor surface.
|
|
2408
|
+
const { branches } = buildCsafBranches(analyze.matched_cves || [], runOpts);
|
|
2409
|
+
const tree = { full_product_names: fullProductNames };
|
|
2410
|
+
if (branches.length > 0) tree.branches = branches;
|
|
2411
|
+
return tree;
|
|
2412
|
+
})(),
|
|
2172
2413
|
vulnerabilities: [...cveVulns, ...indicatorVulns],
|
|
2173
2414
|
exceptd_extension: {
|
|
2174
2415
|
classification: analyze._detect_classification,
|
|
@@ -2272,9 +2513,9 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2272
2513
|
rules: [...cveRules, ...indicatorRules, ...gapRules],
|
|
2273
2514
|
} },
|
|
2274
2515
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
2275
|
-
invocations: [{ executionSuccessful:
|
|
2276
|
-
//
|
|
2277
|
-
//
|
|
2516
|
+
invocations: [{ executionSuccessful: (analyze._detect_classification !== 'inconclusive'), properties: stripNulls({
|
|
2517
|
+
// Apply the stripNulls contract here too — the `remediation`
|
|
2518
|
+
// field is null for any run that didn't surface a
|
|
2278
2519
|
// selected_remediation, and SARIF viewers render null property
|
|
2279
2520
|
// values as visible empty rows. Same helper as the result
|
|
2280
2521
|
// property bags above.
|
|
@@ -2302,11 +2543,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2302
2543
|
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
2303
2544
|
// they pass IRI validation in downstream VEX consumers.
|
|
2304
2545
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2546
|
+
// Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
|
|
2547
|
+
// CSAF `document.tracking.initial_release_date` when both formats are
|
|
2548
|
+
// emitted in the same close() pass. A per-format Date.now() would
|
|
2549
|
+
// cause the two bundles in bundles_by_format to disagree on
|
|
2550
|
+
// milliseconds.
|
|
2310
2551
|
const issued = now;
|
|
2311
2552
|
const productEntry = {
|
|
2312
2553
|
'@id': productPurl,
|
|
@@ -2322,26 +2563,40 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2322
2563
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
2323
2564
|
return fallback;
|
|
2324
2565
|
};
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2566
|
+
// Same `vex_status === 'fixed'` correctness rule as the CSAF
|
|
2567
|
+
// emitter. The catalog `live_patch_available` flag is a global
|
|
2327
2568
|
// "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
|
-
//
|
|
2569
|
+
// disposition. Treating it as `status: fixed` would make OpenVEX
|
|
2570
|
+
// statements claim resolution the operator hadn't attested to. VEX
|
|
2571
|
+
// consumers downstream of CISA / SBOM / supply-chain pipelines treat
|
|
2572
|
+
// `fixed` as authoritative — emitting it without operator attestation
|
|
2573
|
+
// is a downstream-misleading bug. The OpenVEX statement says
|
|
2574
|
+
// `affected` (with action_statement pointing to the remediation,
|
|
2575
|
+
// which may itself be the vendor live-patch route) unless the
|
|
2576
|
+
// operator declared `vex_status: fixed` on the matched CVE.
|
|
2336
2577
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
2337
2578
|
const stmt = {
|
|
2338
|
-
vulnerability: { '@id':
|
|
2579
|
+
vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
|
|
2339
2580
|
products: [productEntry],
|
|
2340
2581
|
timestamp: issued,
|
|
2341
2582
|
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
|
|
2342
2583
|
};
|
|
2343
2584
|
if (c.vex_status === 'fixed') {
|
|
2344
2585
|
stmt.status = 'fixed';
|
|
2586
|
+
// OpenVEX 0.2.0 §4.1: `fixed` is an operator-attested resolution,
|
|
2587
|
+
// not a global vendor flag. Augment the impact_statement with an
|
|
2588
|
+
// evidence trail so downstream supply-chain consumers can chase
|
|
2589
|
+
// the attestation back to the operator's submitted evidence.
|
|
2590
|
+
// Short-hash is deterministic for the same (cve_id, signals)
|
|
2591
|
+
// input — re-emitting the bundle for the same submission yields
|
|
2592
|
+
// the same trail.
|
|
2593
|
+
const trailSrc = canonicalStringify({
|
|
2594
|
+
cve_id: c.cve_id,
|
|
2595
|
+
vex_status: 'fixed',
|
|
2596
|
+
signals: agentSignals && typeof agentSignals === 'object' ? agentSignals : {},
|
|
2597
|
+
});
|
|
2598
|
+
const shortHash = crypto.createHash('sha256').update(trailSrc).digest('hex').slice(0, 16);
|
|
2599
|
+
stmt.impact_statement = `${stmt.impact_statement} Operator verified fixed via evidence_hash=${shortHash}.`;
|
|
2345
2600
|
} else {
|
|
2346
2601
|
stmt.status = 'affected';
|
|
2347
2602
|
stmt.action_statement = actionStatementFor(c.live_patch_available
|
|
@@ -2429,11 +2684,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2429
2684
|
return { format: 'markdown', body: lines.join('\n') };
|
|
2430
2685
|
}
|
|
2431
2686
|
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2687
|
+
// The fallback must NOT leak raw analyze + validate internals (matched
|
|
2688
|
+
// CVEs, framework gaps, residual-risk statements) under an arbitrary
|
|
2689
|
+
// "format" name — operators piping output to logging or third-party
|
|
2690
|
+
// tooling could leak finding details just by typo'ing the format flag.
|
|
2691
|
+
// Return the shape advertisement only.
|
|
2437
2692
|
return {
|
|
2438
2693
|
format,
|
|
2439
2694
|
note: 'Unknown format',
|
|
@@ -2458,19 +2713,19 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2458
2713
|
function normalizeSubmission(submission, playbook) {
|
|
2459
2714
|
if (!submission || typeof submission !== "object") return submission || {};
|
|
2460
2715
|
|
|
2461
|
-
//
|
|
2462
|
-
// value (string "foo", array [...])
|
|
2463
|
-
// via `{ ...(submission.signal_overrides || {}) }
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2716
|
+
// signal_overrides must be a plain object. Without this guard, a
|
|
2717
|
+
// non-object value (string "foo", array [...]) is spread into
|
|
2718
|
+
// out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
|
|
2719
|
+
// — spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
|
|
2720
|
+
// which confuses detect()'s indicator-id lookup. Strip and log instead.
|
|
2466
2721
|
if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
|
|
2467
2722
|
&& (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
|
|
2468
2723
|
if (!submission._runErrors) submission._runErrors = [];
|
|
2469
|
-
submission._runErrors
|
|
2724
|
+
pushRunError(submission._runErrors, {
|
|
2470
2725
|
kind: 'signal_overrides_invalid',
|
|
2471
2726
|
supplied_type: Array.isArray(submission.signal_overrides) ? 'array' : typeof submission.signal_overrides,
|
|
2472
2727
|
reason: 'signal_overrides must be a plain object mapping indicator-id → verdict.'
|
|
2473
|
-
});
|
|
2728
|
+
}, { dedupeKey: e => String(e.supplied_type) });
|
|
2474
2729
|
submission = { ...submission, signal_overrides: {} };
|
|
2475
2730
|
}
|
|
2476
2731
|
|
|
@@ -2495,13 +2750,13 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2495
2750
|
signals: { ...(submission.signals || {}) },
|
|
2496
2751
|
precondition_checks: { ...(submission.precondition_checks || {}) },
|
|
2497
2752
|
_original_shape: 'flat (v0.11.0)',
|
|
2498
|
-
//
|
|
2499
|
-
// signal_overrides_invalid) onto submission._runErrors above.
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2753
|
+
// normalizeSubmission pushes structured errors (e.g.
|
|
2754
|
+
// signal_overrides_invalid) onto submission._runErrors above. For flat
|
|
2755
|
+
// submissions the fresh `out` literal built here loses that accumulator
|
|
2756
|
+
// unless we forward it; run()'s harvest at the entry to detect/analyze
|
|
2757
|
+
// reads agentSubmission._runErrors, so without the carry, flat
|
|
2758
|
+
// submissions with invalid signal_overrides drop the errors before
|
|
2759
|
+
// they can reach analyze.runtime_errors.
|
|
2505
2760
|
...(Array.isArray(submission._runErrors) && submission._runErrors.length
|
|
2506
2761
|
? { _runErrors: submission._runErrors.slice() }
|
|
2507
2762
|
: {}),
|
|
@@ -2523,7 +2778,7 @@ function normalizeSubmission(submission, playbook) {
|
|
|
2523
2778
|
// detect can emit `from_observation` on each indicator result. Diagnostic
|
|
2524
2779
|
// value for operators chasing "which observation drove this verdict".
|
|
2525
2780
|
//
|
|
2526
|
-
//
|
|
2781
|
+
// When two observations target the same indicator id, last-write-wins
|
|
2527
2782
|
// silently. Track discards in _signal_origins_collisions so analyze can
|
|
2528
2783
|
// surface analyze.signal_origins_with_collisions for batch evidence runs.
|
|
2529
2784
|
out._signal_origins = out._signal_origins || {};
|
|
@@ -2605,7 +2860,7 @@ function autoDetectPreconditions(submission, playbook) {
|
|
|
2605
2860
|
}
|
|
2606
2861
|
|
|
2607
2862
|
function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
2608
|
-
//
|
|
2863
|
+
// Catalog corruption surfaced at module-load blocks runs cleanly.
|
|
2609
2864
|
if (_xrefLoadError) {
|
|
2610
2865
|
return {
|
|
2611
2866
|
ok: false,
|
|
@@ -2619,7 +2874,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2619
2874
|
try {
|
|
2620
2875
|
playbook = loadPlaybook(playbookId);
|
|
2621
2876
|
} catch (e) {
|
|
2622
|
-
//
|
|
2877
|
+
// loadPlaybook failure → structured error (not crash).
|
|
2623
2878
|
return {
|
|
2624
2879
|
ok: false,
|
|
2625
2880
|
blocked_by: 'playbook_not_found',
|
|
@@ -2628,9 +2883,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2628
2883
|
};
|
|
2629
2884
|
}
|
|
2630
2885
|
|
|
2631
|
-
//
|
|
2632
|
-
// inside analyze()/findDirective() uncaught, surfacing
|
|
2633
|
-
// trace
|
|
2886
|
+
// Validate directiveId before any phase runs. An unknown id would
|
|
2887
|
+
// otherwise throw inside analyze() / findDirective() uncaught, surfacing
|
|
2888
|
+
// as a 500-style stack trace; instead return a clean structured error
|
|
2889
|
+
// with the valid directive list.
|
|
2634
2890
|
const validDirectives = (playbook.directives || []).map(d => d.id);
|
|
2635
2891
|
if (!validDirectives.includes(directiveId)) {
|
|
2636
2892
|
return {
|
|
@@ -2647,12 +2903,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2647
2903
|
// / the host platform matches — the runner can answer those itself rather
|
|
2648
2904
|
// than blocking on AI declaration.
|
|
2649
2905
|
agentSubmission = normalizeSubmission(agentSubmission, playbook);
|
|
2650
|
-
//
|
|
2906
|
+
// Capture pre-autoDetect submission preconditions so we report
|
|
2651
2907
|
// user-declared provenance, not engine-auto-resolved values.
|
|
2652
2908
|
const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
|
|
2653
2909
|
agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
|
|
2654
2910
|
|
|
2655
|
-
//
|
|
2911
|
+
// precondition_checks merge order is submission → runOpts (runOpts
|
|
2656
2912
|
// wins on collision). This is intentional: runOpts represents the most
|
|
2657
2913
|
// recent caller intent (CLI flags / programmatic injection from a host
|
|
2658
2914
|
// process), whereas submission was captured earlier during evidence
|
|
@@ -2680,38 +2936,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2680
2936
|
// Cross-process mutex lock for this run. preflight verified no other lock
|
|
2681
2937
|
// exists; we acquire ours and release in the finally block.
|
|
2682
2938
|
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.
|
|
2939
|
+
// Parse the playbook once at run() entry and thread the parsed object
|
|
2940
|
+
// through each phase via runOpts._playbookCache. Each phase otherwise
|
|
2941
|
+
// calls loadPlaybook() independently; for a single run that's seven
|
|
2942
|
+
// reads + parses of the same file. Caching saves the redundant I/O +
|
|
2943
|
+
// JSON parses.
|
|
2687
2944
|
//
|
|
2688
|
-
//
|
|
2689
|
-
// cachedRunOpts.session_id.
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
//
|
|
2693
|
-
//
|
|
2945
|
+
// session_id is generated ONCE here and threaded into close() via
|
|
2946
|
+
// cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
|
|
2947
|
+
// PURLs / on-disk attestation filenames all share one identifier.
|
|
2948
|
+
// Without the single-source-of-truth, close() would mint its own id
|
|
2949
|
+
// and operators correlating attestation files to embedded bundle URNs
|
|
2950
|
+
// would see mismatches.
|
|
2694
2951
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
2695
2952
|
const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
|
|
2696
|
-
//
|
|
2953
|
+
// Run-time error accumulator for evalCondition regex failures and other
|
|
2697
2954
|
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2698
2955
|
const runErrors = [];
|
|
2699
2956
|
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).
|
|
2957
|
+
// normalizeSubmission may push structured errors (e.g.
|
|
2958
|
+
// signal_overrides_invalid) onto submission._runErrors. Splice them
|
|
2959
|
+
// into the run-level accumulator so analyze.runtime_errors[] surfaces
|
|
2960
|
+
// them, and strip the field off the submission so it doesn't pollute
|
|
2961
|
+
// the evidence_hash digest (the hash canonicalizes the submission and
|
|
2962
|
+
// a non-deterministic _runErrors would change it).
|
|
2708
2963
|
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
2709
2964
|
runErrors.push(...agentSubmission._runErrors);
|
|
2710
2965
|
}
|
|
2711
2966
|
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
2712
2967
|
delete agentSubmission._runErrors;
|
|
2713
2968
|
}
|
|
2714
|
-
//
|
|
2969
|
+
// Phases the runner should SKIP execution for, based on skip_phase
|
|
2715
2970
|
// preconditions surfaced in preflight.issues.
|
|
2716
2971
|
const skipPhases = new Set();
|
|
2717
2972
|
for (const issue of (pre.issues || [])) {
|
|
@@ -2753,28 +3008,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2753
3008
|
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
|
|
2754
3009
|
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
|
|
2755
3010
|
|
|
2756
|
-
//
|
|
3011
|
+
// analyze() already sliced runOpts._runErrors into
|
|
2757
3012
|
// phases.analyze.runtime_errors at return time. Validate + close may
|
|
2758
3013
|
// have pushed additional regex errors AFTER analyze returned; surface
|
|
2759
3014
|
// those onto phases.analyze.runtime_errors so the field reflects every
|
|
2760
3015
|
// regex failure in the run. De-dupe by JSON shape so the analyze-time
|
|
2761
3016
|
// snapshot doesn't double-count.
|
|
2762
3017
|
if (runErrors.length && phases.analyze) {
|
|
2763
|
-
|
|
2764
|
-
|
|
3018
|
+
// `_truncated` sentinels are pushed by pushRunError when a per-kind
|
|
3019
|
+
// or total cap fires. They aggregate via in-place `dropped` increments,
|
|
3020
|
+
// so the same sentinel object is BOTH in the analyze snapshot AND in
|
|
3021
|
+
// the late-push `runErrors` ref. Skip them on the dedupe-merge pass
|
|
3022
|
+
// to keep the snapshot's authoritative dropped-count, rather than
|
|
3023
|
+
// double-stamping a second sentinel with the same `dropped` value.
|
|
3024
|
+
const existing = new Set(
|
|
3025
|
+
(phases.analyze.runtime_errors || [])
|
|
3026
|
+
.filter(e => !(e && e.kind === '_truncated'))
|
|
3027
|
+
.map(e => JSON.stringify(e))
|
|
3028
|
+
);
|
|
3029
|
+
const additions = runErrors.filter(e => !(e && e.kind === '_truncated') && !existing.has(JSON.stringify(e)));
|
|
2765
3030
|
if (additions.length) {
|
|
2766
3031
|
phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
|
|
2767
3032
|
}
|
|
2768
3033
|
}
|
|
2769
3034
|
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
//
|
|
2773
|
-
//
|
|
2774
|
-
//
|
|
2775
|
-
//
|
|
2776
|
-
//
|
|
2777
|
-
// keys recursively. `captured_at` and other timestamp-like fields are
|
|
3035
|
+
// evidence_hash binds the operator's submission to the verdict. The
|
|
3036
|
+
// hash must include the canonicalized submission (observations,
|
|
3037
|
+
// signal_overrides, signals) — keying it on only { playbook, directive,
|
|
3038
|
+
// cves, rwep, classification } would let two operators with completely
|
|
3039
|
+
// different evidence collide on the same hash whenever their
|
|
3040
|
+
// classifications match. Use SHA-256 over the recursively sorted
|
|
3041
|
+
// submission. `captured_at` and other timestamp-like fields are
|
|
2778
3042
|
// INTENTIONALLY excluded so that re-running with the same submission
|
|
2779
3043
|
// produces the same hash — `reattest` relies on this to detect drift
|
|
2780
3044
|
// (different submission → different hash → drift exists).
|
|
@@ -2799,7 +3063,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2799
3063
|
evidence_hash: evidenceHash,
|
|
2800
3064
|
submission_digest: submissionDigest,
|
|
2801
3065
|
preflight_issues: pre.issues,
|
|
2802
|
-
//
|
|
3066
|
+
// Source provenance for precondition_checks. Shape:
|
|
2803
3067
|
// { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
|
|
2804
3068
|
precondition_check_source: pcSource,
|
|
2805
3069
|
phases
|
|
@@ -2813,7 +3077,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2813
3077
|
// --- helpers ---
|
|
2814
3078
|
|
|
2815
3079
|
/**
|
|
2816
|
-
*
|
|
3080
|
+
* Deterministic JSON stringification with recursively sorted keys.
|
|
2817
3081
|
* Without sorted keys two semantically identical submissions ({a:1, b:2}
|
|
2818
3082
|
* vs {b:2, a:1}) would hash to different digests, breaking reattest's
|
|
2819
3083
|
* "same submission → same hash" contract. Arrays preserve order
|
|
@@ -2829,7 +3093,7 @@ function canonicalStringify(v) {
|
|
|
2829
3093
|
}
|
|
2830
3094
|
|
|
2831
3095
|
/**
|
|
2832
|
-
*
|
|
3096
|
+
* Pick the operator-meaningful fields out of the normalized submission
|
|
2833
3097
|
* for hashing. captured_at, _signal_origins, _signal_origins_collisions,
|
|
2834
3098
|
* and _original_shape are intentionally excluded — they're either
|
|
2835
3099
|
* timestamps (would break "same submission → same hash") or runner-internal
|
|
@@ -2936,7 +3200,7 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
2936
3200
|
if (m) {
|
|
2937
3201
|
const val = resolvePath(ctx, m[1]);
|
|
2938
3202
|
if (typeof val !== 'string') return false;
|
|
2939
|
-
//
|
|
3203
|
+
// An operator-supplied or playbook-supplied regex with a syntax bug
|
|
2940
3204
|
// (or pathological backtracking) must NOT crash the engine mid-analyze.
|
|
2941
3205
|
// Catch construction + test exceptions, return false, and push a
|
|
2942
3206
|
// structured _regex_eval_error into ctx._runErrors (when present) so
|
|
@@ -2949,8 +3213,19 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
2949
3213
|
// Two sites where ctx may carry an accumulator: runOpts._runErrors
|
|
2950
3214
|
// (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
|
|
2951
3215
|
// form; fall back to ctx.
|
|
2952
|
-
|
|
2953
|
-
|
|
3216
|
+
// Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
|
|
3217
|
+
// (same source+expr regex error firing N times per playbook would
|
|
3218
|
+
// otherwise spam runtime_errors). The original `_regex_eval_error`
|
|
3219
|
+
// payload is preserved for backward compatibility.
|
|
3220
|
+
const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
|
|
3221
|
+
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
3222
|
+
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
3223
|
+
: null;
|
|
3224
|
+
if (target) {
|
|
3225
|
+
pushRunError(target, taggedErr, {
|
|
3226
|
+
dedupeKey: x => `${x.source || ''}::${x.expr || ''}`,
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
2954
3229
|
return false;
|
|
2955
3230
|
}
|
|
2956
3231
|
}
|
|
@@ -3015,12 +3290,11 @@ function stripOuterParens(expr) {
|
|
|
3015
3290
|
* submits clock_started_at_<event> ISO strings as it progresses through
|
|
3016
3291
|
* incident-response milestones.
|
|
3017
3292
|
*
|
|
3018
|
-
*
|
|
3293
|
+
* Per AGENTS.md Phase 7, the legal contract is that the clock starts
|
|
3019
3294
|
* from OPERATOR AWARENESS — not from the moment the engine emits a
|
|
3020
|
-
* `detected` classification.
|
|
3021
|
-
*
|
|
3022
|
-
*
|
|
3023
|
-
* semantics:
|
|
3295
|
+
* `detected` classification. Auto-stamping Date.now() on detect_confirmed
|
|
3296
|
+
* whenever the engine classifies as detected would be incorrect: the
|
|
3297
|
+
* operator may not have seen the result yet. Semantics:
|
|
3024
3298
|
*
|
|
3025
3299
|
* - If the agent explicitly submits clock_started_at_<event>: use it.
|
|
3026
3300
|
* - Otherwise, for 'detect_confirmed' with classification='detected':
|
|
@@ -3123,9 +3397,9 @@ module.exports = {
|
|
|
3123
3397
|
vexFilterFromDoc,
|
|
3124
3398
|
normalizeSubmission,
|
|
3125
3399
|
autoDetectPreconditions,
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3400
|
+
// Exported so library-side direct callers (the fallback path the CLI
|
|
3401
|
+
// guard cannot reach) can be exercised without spawning a CLI
|
|
3402
|
+
// subprocess.
|
|
3129
3403
|
sanitizeOperatorText,
|
|
3130
3404
|
// internal helpers exposed for tests
|
|
3131
3405
|
_resolvedPhase: resolvedPhase,
|