@blamejs/exceptd-skills 0.12.20 → 0.12.22

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +137 -6
  2. package/bin/exceptd.js +835 -70
  3. package/data/_indexes/_meta.json +14 -14
  4. package/data/_indexes/activity-feed.json +3 -3
  5. package/data/_indexes/catalog-summaries.json +3 -3
  6. package/data/_indexes/chains.json +15 -0
  7. package/data/_indexes/jurisdiction-map.json +3 -2
  8. package/data/_indexes/section-offsets.json +175 -175
  9. package/data/_indexes/summary-cards.json +1 -1
  10. package/data/_indexes/token-budget.json +83 -83
  11. package/data/cve-catalog.json +169 -2
  12. package/data/exploit-availability.json +16 -0
  13. package/data/playbooks/ai-api.json +18 -0
  14. package/data/playbooks/containers.json +30 -0
  15. package/data/playbooks/cred-stores.json +18 -0
  16. package/data/playbooks/crypto.json +18 -0
  17. package/data/playbooks/hardening.json +26 -1
  18. package/data/playbooks/kernel.json +22 -2
  19. package/data/playbooks/mcp.json +18 -0
  20. package/data/playbooks/runtime.json +22 -1
  21. package/data/playbooks/sbom.json +18 -0
  22. package/data/playbooks/secrets.json +6 -0
  23. package/data/zeroday-lessons.json +102 -0
  24. package/lib/auto-discovery.js +9 -9
  25. package/lib/cross-ref-api.js +43 -10
  26. package/lib/cve-curation.js +4 -4
  27. package/lib/playbook-runner.js +529 -70
  28. package/lib/prefetch.js +3 -3
  29. package/lib/refresh-external.js +13 -2
  30. package/lib/refresh-network.js +22 -17
  31. package/lib/scoring.js +22 -13
  32. package/lib/sign.js +5 -5
  33. package/lib/validate-catalog-meta.js +1 -1
  34. package/lib/validate-cve-catalog.js +2 -2
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +63 -13
  37. package/manifest.json +47 -47
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +6 -6
  40. package/scripts/check-manifest-snapshot.js +1 -1
  41. package/scripts/check-sbom-currency.js +1 -1
  42. package/scripts/predeploy.js +6 -6
  43. package/scripts/refresh-manifest-snapshot.js +2 -2
  44. package/scripts/validate-vendor-online.js +1 -1
  45. package/scripts/verify-shipped-tarball.js +15 -12
  46. package/skills/compliance-theater/skill.md +4 -1
  47. package/skills/exploit-scoring/skill.md +20 -1
  48. package/skills/framework-gap-analysis/skill.md +6 -2
  49. package/skills/kernel-lpe-triage/skill.md +50 -3
  50. package/skills/threat-model-currency/skill.md +7 -5
  51. package/skills/webapp-security/skill.md +1 -1
  52. package/skills/zeroday-gap-learn/skill.md +44 -1
@@ -287,13 +287,137 @@ function lockFilePath(playbookId) {
287
287
  catch { return null; }
288
288
  }
289
289
 
290
+ // PP P1-1: same-PID stale-lockfile reclaim threshold. A same-process orphan
291
+ // (e.g. an earlier run() that crashed without unlinking, or a try/catch that
292
+ // swallowed the release) older than this is presumed dead and reclaimed.
293
+ // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough that
294
+ // no legitimate playbook hold reaches it (govern/look/run phases complete
295
+ // well inside one second per playbook), short enough that a wedged process
296
+ // recovers within one CI step rather than the rest of its lifetime.
297
+ const STALE_LOCK_MS = 30_000;
298
+
290
299
  function acquireLock(playbookId) {
291
300
  const p = lockFilePath(playbookId);
292
301
  if (!p) return null;
302
+ const writePayload = () => fs.writeFileSync(
303
+ p,
304
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
305
+ { flag: 'wx' }
306
+ );
293
307
  try {
294
- fs.writeFileSync(p, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2), { flag: 'wx' });
308
+ writePayload();
295
309
  return p;
296
- } catch { return null; /* already locked or unwritable */ }
310
+ } catch (e) {
311
+ // DD P1-3: stale-PID reclaim. Pre-fix the EEXIST path returned null
312
+ // and callers proceeded UNLOCKED — a process that crashed mid-run
313
+ // left its lockfile behind and every subsequent invocation silently
314
+ // ran without mutex protection. Mirror withCatalogLock's pattern:
315
+ // parse the recorded pid, probe with `process.kill(pid, 0)`. ESRCH
316
+ // means the holder is dead — unlink and retry once. EPERM (alive,
317
+ // different user) or any other condition: leave the lock alone and
318
+ // return null with a diagnostic so the caller knows acquisition
319
+ // failed because the lock is genuinely held (not because the FS is
320
+ // broken or the playbook id is malformed).
321
+ if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
322
+ try {
323
+ const raw = fs.readFileSync(p, 'utf8');
324
+ let pid = null;
325
+ try { pid = JSON.parse(raw).pid; }
326
+ catch {
327
+ const n = Number.parseInt(String(raw).trim(), 10);
328
+ pid = Number.isInteger(n) && n > 0 ? n : null;
329
+ }
330
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
331
+ try { fs.unlinkSync(p); } catch {}
332
+ try { writePayload(); return p; } catch { /* fall through */ }
333
+ }
334
+ // PP P1-1: same-PID stale-lockfile reclaim. If the recorded pid is
335
+ // ours, the only way to escape an orphaned same-process lockfile is
336
+ // by mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
337
+ // (e.g. nested run() within one process) must still return null so
338
+ // the caller knows the lock is held. A fresh same-PID lockfile is
339
+ // reentrancy; one older than STALE_LOCK_MS is an orphan from a
340
+ // crashed prior hold (or a try/catch that swallowed the release)
341
+ // and must be reclaimed — otherwise the process can never acquire
342
+ // this lock again for the rest of its lifetime.
343
+ if (Number.isInteger(pid) && pid === process.pid) {
344
+ try {
345
+ const stat = fs.statSync(p);
346
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
347
+ try { fs.unlinkSync(p); } catch {}
348
+ try { writePayload(); return p; } catch { /* fall through */ }
349
+ }
350
+ } catch { /* stat failed — treat as held */ }
351
+ }
352
+ } catch { /* unreadable lockfile — treat as held by a live process */ }
353
+ }
354
+ // Lock genuinely held (or filesystem error). Returning null keeps
355
+ // back-compat with existing call sites that test `if (!lockPath)`.
356
+ // Callers that want a clearer diagnostic should call
357
+ // `acquireLockDiagnostic` instead.
358
+ return null;
359
+ }
360
+ }
361
+
362
+ // DD P1-3: callers needing to distinguish "couldn't acquire because the
363
+ // lock is genuinely held by a live process" from "couldn't acquire
364
+ // because of an unexpected error" can use this thin diagnostic wrapper.
365
+ // Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
366
+ // The bare `acquireLock` keeps its historical null-on-failure contract.
367
+ function acquireLockDiagnostic(playbookId) {
368
+ const p = lockFilePath(playbookId);
369
+ if (!p) return { ok: false, reason: 'no_lock_path' };
370
+ try {
371
+ fs.writeFileSync(p,
372
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
373
+ { flag: 'wx' });
374
+ return { ok: true, path: p };
375
+ } catch (e) {
376
+ if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
377
+ let pid = null;
378
+ try {
379
+ const raw = fs.readFileSync(p, 'utf8');
380
+ try { pid = JSON.parse(raw).pid; }
381
+ catch {
382
+ const n = Number.parseInt(String(raw).trim(), 10);
383
+ pid = Number.isInteger(n) && n > 0 ? n : null;
384
+ }
385
+ } catch {}
386
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
387
+ try { fs.unlinkSync(p); } catch {}
388
+ try {
389
+ fs.writeFileSync(p,
390
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
391
+ { flag: 'wx' });
392
+ return { ok: true, path: p, reclaimed_from_pid: pid };
393
+ } catch (e2) {
394
+ return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
395
+ }
396
+ }
397
+ // PP P1-1: same-PID stale-lockfile reclaim (diagnostic variant). Same
398
+ // semantics as in acquireLock: a same-process lockfile older than
399
+ // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one is
400
+ // legitimate reentrancy and stays held.
401
+ if (Number.isInteger(pid) && pid === process.pid) {
402
+ let mtimeMs = null;
403
+ try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
404
+ if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
405
+ try { fs.unlinkSync(p); } catch {}
406
+ try {
407
+ fs.writeFileSync(p,
408
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
409
+ { flag: 'wx' });
410
+ return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
411
+ } catch (e3) {
412
+ return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
413
+ }
414
+ }
415
+ return { ok: false, reason: 'held_by_self', lock_path: p, holder_pid: pid };
416
+ }
417
+ return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
418
+ }
419
+ return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
420
+ }
297
421
  }
298
422
 
299
423
  function releaseLock(lockPath) {
@@ -453,29 +577,52 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
453
577
  // '<id>__fp_checks' in signal_overrides; default behavior (no
454
578
  // attestation) treats every required FP check as UNSATISFIED.
455
579
  if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
456
- const attestation = overrides[`${ind.id}__fp_checks`];
457
- // S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
458
- // attestation map. A submission like
459
- // signal_overrides: { sig__fp_checks: [true, true] }
460
- // would previously have its truthy entries matched via the index
461
- // fallback (att['0'] === true), silently bypassing every FP-check
462
- // requirement. Reject arrays explicitly so they fall through to the
463
- // empty-attestation branch (every required check unsatisfied).
464
- const safeAtt = Array.isArray(attestation) ? null : attestation;
465
- const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
466
- const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
467
- // Match either by exact name string OR by indexed key '0', '1', ...
468
- // because false_positive_checks_required entries are free-text
469
- // strings, not ids. Operators may attest either by the literal
470
- // string or by index. Default: unsatisfied.
471
- if (att[fpName] === true) return false;
472
- const idx = ind.false_positive_checks_required.indexOf(fpName);
473
- if (idx !== -1 && att[String(idx)] === true) return false;
474
- return true;
475
- });
476
- if (unsatisfied.length > 0) {
580
+ // BB P2-4: a hostile or buggy attestation may be a Proxy whose property
581
+ // accessors throw. The filter below reads `att[fpName]` for each
582
+ // required check; an exception inside the read would crash detect()
583
+ // and abort the entire run. Wrap the FP-check evaluation in a
584
+ // try/catch: on throw, treat ALL required checks as unsatisfied
585
+ // (safest default never silently honor an attestation we couldn't
586
+ // read) and surface a runtime_error so the operator sees why.
587
+ try {
588
+ const attestation = overrides[`${ind.id}__fp_checks`];
589
+ // S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
590
+ // attestation map. A submission like
591
+ // signal_overrides: { sig__fp_checks: [true, true] }
592
+ // would previously have its truthy entries matched via the index
593
+ // fallback (att['0'] === true), silently bypassing every FP-check
594
+ // requirement. Reject arrays explicitly so they fall through to the
595
+ // empty-attestation branch (every required check unsatisfied).
596
+ const safeAtt = Array.isArray(attestation) ? null : attestation;
597
+ const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
598
+ const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
599
+ // Match either by exact name string OR by indexed key '0', '1', ...
600
+ // because false_positive_checks_required entries are free-text
601
+ // strings, not ids. Operators may attest either by the literal
602
+ // string or by index. Default: unsatisfied.
603
+ if (att[fpName] === true) return false;
604
+ const idx = ind.false_positive_checks_required.indexOf(fpName);
605
+ if (idx !== -1 && att[String(idx)] === true) return false;
606
+ return true;
607
+ });
608
+ if (unsatisfied.length > 0) {
609
+ verdict = 'inconclusive';
610
+ fpChecksUnsatisfied = unsatisfied;
611
+ }
612
+ } catch (e) {
613
+ // Treat every required check as unsatisfied — we couldn't trust the
614
+ // attestation map. Surface the throw so operators can chase the
615
+ // root cause (Proxy with a throwing getter, frozen object that
616
+ // tripped invariants, etc.).
477
617
  verdict = 'inconclusive';
478
- fpChecksUnsatisfied = unsatisfied;
618
+ fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
619
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
620
+ runOpts._runErrors.push({
621
+ kind: 'fp_attestation_threw',
622
+ indicator_id: ind.id,
623
+ message: (e && e.message) ? String(e.message) : String(e),
624
+ });
625
+ }
479
626
  }
480
627
  }
481
628
  } else {
@@ -515,33 +662,57 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
515
662
  // full false_positive_profile checks and reached an explicit verdict —
516
663
  // engine-computed classification can't represent "I saw the indicators and
517
664
  // confirmed they're all benign" without this override.
518
- const override = (agentSubmission.signals && agentSubmission.signals.detection_classification);
665
+ const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
519
666
  const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
520
-
521
- // S P1-B: block a `detected` agent override when any indicator was
522
- // downgraded to inconclusive because its false_positive_checks_required[]
523
- // entries were not attested. Without this gate, an agent that submits
524
- // `signals.detection_classification: 'detected'` can force the run-level
525
- // classification past FP checks the engine just refused to honor — exactly
526
- // the contract Hard Rule #6 (compliance theater) forbids. Substitute
527
- // 'inconclusive' and surface a runtime_error so the operator sees the
528
- // override was refused (not silently ignored).
667
+ // BB P2-1: any override that's a non-empty string but NOT in the allowlist
668
+ // (e.g. 'present', 'unknown', '', ' detected ', 'Detected') must surface
669
+ // as a runtime_error rather than silently falling through to engine-computed
670
+ // classification. Operators submitting case variants / whitespace-padded
671
+ // strings deserve a clear diagnostic, not a quiet downgrade. Treat the
672
+ // override as absent for classification purposes once recorded.
673
+ const overrideIsString = typeof rawOverride === 'string';
674
+ const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
675
+ if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
676
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
677
+ runOpts._runErrors.push({
678
+ kind: 'classification_override_invalid',
679
+ supplied: rawOverride,
680
+ allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
681
+ reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
682
+ });
683
+ }
684
+ }
685
+ const override = overrideIsInAllowlist ? rawOverride : undefined;
686
+
687
+ // BB P1-1 / BB P1-2: extend the v0.12.19 S P1-B gate to refuse ALL
688
+ // classification overrides (`detected`, `clean`, `not_detected`) when any
689
+ // indicator was FP-downgraded. A submission that maps to `'not_detected'`
690
+ // (either by literal `not_detected` OR by `'clean'`, which v0.12.19 mapped
691
+ // to `'not_detected'` at this site) MUST NOT hide a `verdict: 'hit'`
692
+ // indicator whose `false_positive_checks_required[]` were unattested —
693
+ // that's a strictly worse false-negative outcome than allowing 'detected'
694
+ // through. Substitute 'inconclusive' and emit a runtime_error.
695
+ // BB P2-2: record indicator IDs and an unsatisfied-checks count ONLY —
696
+ // never the literal FP-check check-name strings (those are an attestation-
697
+ // bypass hint for a hostile agent reading the runtime_errors).
529
698
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
530
699
 
531
700
  let classification;
532
- if (override && validOverrides.has(override)) {
701
+ if (override) {
533
702
  classification = override === 'clean' ? 'not_detected' : override;
534
- if (classification === 'detected' && anyFpDowngrade) {
535
- classification = 'inconclusive';
703
+ if (anyFpDowngrade) {
704
+ const substituted = 'inconclusive';
705
+ const attempted = override; // record what the operator submitted, not the mapped form
706
+ classification = substituted;
536
707
  if (runOpts && Array.isArray(runOpts._runErrors)) {
537
708
  runOpts._runErrors.push({
538
709
  kind: 'classification_override_blocked',
539
- attempted: 'detected',
540
- substituted: 'inconclusive',
541
- reason: 'FP-check downgrade: one or more indicators downgraded to inconclusive because false_positive_checks_required entries were not attested. Agent override to `detected` refused.',
710
+ attempted,
711
+ substituted,
712
+ reason: 'FP-check downgrade: one or more indicators downgraded to inconclusive because false_positive_checks_required entries were not attested. Agent classification override refused.',
542
713
  indicators_with_unsatisfied_fp_checks: indicatorResults
543
714
  .filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
544
- .map(r => ({ id: r.id, fp_checks_unsatisfied: r.fp_checks_unsatisfied })),
715
+ .map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
545
716
  });
546
717
  }
547
718
  }
@@ -580,7 +751,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
580
751
  from_observation: agentSubmission._signal_origins?.[i.id] || null,
581
752
  })),
582
753
  indicators_evaluated_count: indicatorResults.length,
583
- classification_override_applied: validOverrides.has(override) ? (override === 'clean' ? 'not_detected' : override) : null,
754
+ classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
584
755
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
585
756
  // E9: pass through any flat-shape observation collisions detected at
586
757
  // normalize time so analyze() can publish them under
@@ -871,7 +1042,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
871
1042
  }
872
1043
  // F5: use the first evidence-correlated CVE as the canonical attribute
873
1044
  // source for factor scaling. If matchedCves is empty there's no per-CVE
874
- // evidence to gate on. v0.12.15 (audit N F1): the prior fallback was
1045
+ // evidence to gate on. v0.12.15: the prior fallback was
875
1046
  // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
876
1047
  // (secrets, library-author, crypto-codebase, framework, cred-stores,
877
1048
  // containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
@@ -898,7 +1069,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
898
1069
  null);
899
1070
  if (factorCve) factorCveSource = 'domain';
900
1071
  }
901
- // v0.12.15 (audit N F1): five shipped playbooks (secrets, library-author,
1072
+ // v0.12.15: five shipped playbooks (secrets, library-author,
902
1073
  // crypto-codebase, framework, cred-stores, containers, runtime, crypto,
903
1074
  // ai-api) ship with empty `domain.cve_refs` because their attack class is
904
1075
  // class-of-vulnerability rather than CVE-specific. For those playbooks
@@ -1393,7 +1564,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1393
1564
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
1394
1565
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
1395
1566
  : [];
1396
- // audit W P2-B: build every bundle once and reuse, so bundle_body and
1567
+ // B: build every bundle once and reuse, so bundle_body and
1397
1568
  // bundles_by_format[primary] are the same object identity (and hence
1398
1569
  // identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
1399
1570
  // invoked twice for the primary format and each invocation crystallised
@@ -1405,14 +1576,20 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1405
1576
  const builtFormats = new Map();
1406
1577
  const buildOnce = (format) => {
1407
1578
  if (!builtFormats.has(format)) {
1408
- builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt));
1579
+ builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt, runOpts));
1409
1580
  }
1410
1581
  return builtFormats.get(format);
1411
1582
  };
1412
1583
  const primaryBody = buildOnce(primaryFormat);
1413
- const byFormat = extraFormats.length
1414
- ? Object.fromEntries([primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)]))
1415
- : null;
1584
+ // bundles_by_format must always be an object keyed by the
1585
+ // primary format, even when no extra formats were requested. Pre-fix it
1586
+ // was null in the single-format case, forcing downstream tooling into a
1587
+ // `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
1588
+ // consumer. Now the field is canonically present so iteration is
1589
+ // uniform across single- and multi-format emissions.
1590
+ const byFormat = Object.fromEntries(
1591
+ [primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)])
1592
+ );
1416
1593
  return {
1417
1594
  bundle_format: primaryFormat,
1418
1595
  contents: c.evidence_package.contents || [],
@@ -1592,7 +1769,7 @@ function buildProductBinding(playbook, sessionId) {
1592
1769
  // surface at least one candidate when any is known. Returns null when no
1593
1770
  // candidate exists — caller MUST omit `locations` rather than emit empty.
1594
1771
  //
1595
- // audit W P2-A: source segments are heterogeneous — many playbook artifacts
1772
+ // A: source segments are heterogeneous — many playbook artifacts
1596
1773
  // describe a shell-command capture (`uname -r`) or human prose, not a real
1597
1774
  // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
1598
1775
  // (RFC 3986); shell-command text + prose breaks downstream consumers
@@ -1634,10 +1811,67 @@ function sarifLocationsForIndicator(playbook, indicator) {
1634
1811
  return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
1635
1812
  }
1636
1813
 
1637
- function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt) {
1814
+ // Resolve the package version once per process so CSAF tracking.generator
1815
+ // can name the engine that emitted the advisory. Best-effort read — bundle
1816
+ // emission must not crash if package.json is missing (e.g. exotic install).
1817
+ let _CACHED_PKG_VERSION = null;
1818
+ function getEngineVersion() {
1819
+ if (_CACHED_PKG_VERSION != null) return _CACHED_PKG_VERSION;
1820
+ try {
1821
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
1822
+ _CACHED_PKG_VERSION = (pkg && typeof pkg.version === 'string') ? pkg.version : 'unknown';
1823
+ } catch {
1824
+ _CACHED_PKG_VERSION = 'unknown';
1825
+ }
1826
+ return _CACHED_PKG_VERSION;
1827
+ }
1828
+
1829
+ // 3 / P1-4: operator-supplied identity strings (--operator) and
1830
+ // publisher namespace URLs (--publisher-namespace) flow into operator-facing
1831
+ // CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass —
1832
+ // bin/exceptd.js already validates the inputs, but the runner is also called
1833
+ // from library consumers that may bypass the CLI surface.
1834
+ //
1835
+ // MM P1-D: extend the strip to Unicode bidi / format / control / surrogate /
1836
+ // private-use / unassigned categories (\p{C} under the `u` regex flag) so
1837
+ // direct library callers of buildEvidenceBundle cannot smuggle a U+202E
1838
+ // "RTL OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
1839
+ // already refuses (--operator validation in bin/exceptd.js). NFC-normalise
1840
+ // first so a decomposed sequence can't combine past the codepoint check;
1841
+ // cap the result at 256 codepoints (NOT UTF-16 code units) so a string of
1842
+ // astral-plane codepoints can't smuggle a longer-than-256-display string
1843
+ // past the cap by exploiting JavaScript's surrogate-pair string length.
1844
+ // Returns null on rejection (empty after strip, or NFC normalise threw);
1845
+ // callers (the publisher-namespace + contact_details + tracking.generator
1846
+ // sites) treat null as "operator-unclaimed" and route through the existing
1847
+ // fallback (publisher.namespace = urn:exceptd:operator:unknown +
1848
+ // bundle_publisher_unclaimed runtime warning).
1849
+ function sanitizeOperatorText(s) {
1850
+ if (typeof s !== 'string') return null;
1851
+ // NFC first: a Cf codepoint may be expressed as a base + combining mark
1852
+ // that recomposes into the format category under NFC. Normalise so the
1853
+ // strip catches it.
1854
+ let normalised;
1855
+ try { normalised = s.normalize('NFC'); }
1856
+ catch { return null; }
1857
+ // Strip every Unicode codepoint matching General Category C
1858
+ // (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
1859
+ const stripped = normalised.replace(/\p{C}/gu, '');
1860
+ const trimmed = stripped.trim();
1861
+ if (trimmed.length === 0) return null;
1862
+ // Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
1863
+ // units, so a 256-codepoint astral-plane string isn't silently extended
1864
+ // past the cap by surrogate-pair encoding).
1865
+ const cps = Array.from(trimmed);
1866
+ if (cps.length <= 256) return cps.join('');
1867
+ return cps.slice(0, 256).join('');
1868
+ }
1869
+
1870
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
1871
+ runOpts = runOpts || {};
1638
1872
  const playbookSlug = urnSlug(playbook._meta.id);
1639
1873
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1640
- // audit W P2-B: pin one `now` value per bundle build (and accept an
1874
+ // B: pin one `now` value per bundle build (and accept an
1641
1875
  // upstream-provided issuedAt) so multi-format emit produces identical
1642
1876
  // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
1643
1877
  // building several formats from the same run. Without the parameter,
@@ -1661,7 +1895,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1661
1895
  name: productName,
1662
1896
  product_identification_helper: { purl: productPurl }
1663
1897
  }];
1664
- // audit W P1-A: `fixed` product_status MUST reflect operator-supplied VEX
1898
+ // A: `fixed` product_status MUST reflect operator-supplied VEX
1665
1899
  // disposition (vex_status === 'fixed' — see analyze() F17), not the
1666
1900
  // catalog's global `live_patch_available` flag. The catalog flag means
1667
1901
  // "vendor publishes a live-patch in the world", not "operator deployed
@@ -1671,6 +1905,60 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1671
1905
  // that lied to downstream NVD / Red Hat dashboards. When
1672
1906
  // live_patch_available is the only signal, status stays known_affected
1673
1907
  // and the live-patch route is surfaced as a `vendor_fix` remediation.
1908
+ // CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1909
+ // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
1910
+ // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
1911
+ // validators (BSI CSAF validator, ENISA dashboard) refuse documents that
1912
+ // place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
1913
+ // to the `ids[]` array with a real `system_name`.
1914
+ //
1915
+ // CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1916
+ // cvss_v3 score block is emitted. Drop the entire score block when the
1917
+ // catalog has no CVSS data (score AND vector both unset); otherwise
1918
+ // include version + baseScore + vectorString + baseSeverity from the
1919
+ // catalog entry.
1920
+ const csafCvssSeverity = (score) => {
1921
+ if (typeof score !== 'number') return null;
1922
+ if (score >= 9.0) return 'CRITICAL';
1923
+ if (score >= 7.0) return 'HIGH';
1924
+ if (score >= 4.0) return 'MEDIUM';
1925
+ if (score > 0.0) return 'LOW';
1926
+ return 'NONE';
1927
+ };
1928
+ const csafCvssVersionFromVector = (vec) => {
1929
+ if (typeof vec !== 'string') return '3.1';
1930
+ const m = vec.match(/^CVSS:(\d+\.\d+)\//);
1931
+ if (!m) return '3.1';
1932
+ // Returns the declared version verbatim. The CALLER is responsible for
1933
+ // gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
1934
+ // 4.0 vectors are tagged here for diagnostic clarity but never reach
1935
+ // the cvss_v3 block downstream.
1936
+ return m[1];
1937
+ };
1938
+ const csafIdsFor = (id) => {
1939
+ // B: null / undefined / non-string id MUST NOT emit literal
1940
+ // "null" / "undefined" text into the vulnerabilities[] entry. Pre-fix
1941
+ // String(id) coerced both to those literals — strict validators then
1942
+ // rejected the document, and operators saw a phantom "null" CVE in
1943
+ // dashboards. Return null so the caller can skip the entry entirely
1944
+ // and surface a runtime_error for the missing id.
1945
+ if (typeof id !== 'string' || !id) return null;
1946
+ if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1947
+ if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1948
+ if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1949
+ if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1950
+ // A: RUSTSEC advisories carry their own tracking authority
1951
+ // (https://rustsec.org); mis-routing them to system_name 'OSV' loses
1952
+ // the upstream provenance link and confuses downstream ingesters that
1953
+ // resolve by (system_name, text) pair.
1954
+ if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
1955
+ // B: genuinely-unknown prefix surfaces as `exceptd-unknown`
1956
+ // so downstream ingesters know the authority wasn't recognized — pre-fix
1957
+ // every unknown id was misattributed to OSV.
1958
+ return { system_name: 'exceptd-unknown', text: id };
1959
+ };
1960
+ const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
1961
+
1674
1962
  const cveVulns = analyze.matched_cves.map(c => {
1675
1963
  const isFixed = c.vex_status === 'fixed';
1676
1964
  const remediations = [{
@@ -1679,21 +1967,87 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1679
1967
  || (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
1680
1968
  product_ids: [productId],
1681
1969
  }];
1682
- return {
1683
- cve: c.cve_id,
1684
- scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
1970
+ // B: catalog entries with a missing / non-string cve_id
1971
+ // pre-fix produced literal `text: "null"` / `text: "undefined"` entries
1972
+ // under ids[]. Skip the vulnerability entry entirely and surface a
1973
+ // runtime_error so the catalog gap is visible to operators / CI gates.
1974
+ const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
1975
+ let idEntry = null;
1976
+ if (!idIsCve) {
1977
+ idEntry = csafIdsFor(c.cve_id);
1978
+ if (idEntry == null) {
1979
+ if (Array.isArray(runOpts._runErrors)) {
1980
+ const alreadyMissing = runOpts._runErrors.some(e => e && e.kind === 'bundle_cve_id_missing');
1981
+ if (!alreadyMissing) {
1982
+ runOpts._runErrors.push({
1983
+ kind: 'bundle_cve_id_missing',
1984
+ reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
1985
+ remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
1986
+ });
1987
+ }
1988
+ }
1989
+ return null;
1990
+ }
1991
+ }
1992
+ // only emit cvss_v3 score block when we have a real
1993
+ // vector string AND a numeric score. Pre-fix every vuln carried
1994
+ // `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
1995
+ // signal — strict validators reject the truncated block, and
1996
+ // `base_score: 0` was a downstream-misleading default that suggested
1997
+ // an authoritative "informational" score where there was simply no
1998
+ // data.
1999
+ //
2000
+ // C: CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1.
2001
+ // Catalog vectors prefixed CVSS:2.0/ or CVSS:4.0/ would pre-fix emit a
2002
+ // cvss_v3 block with version: '2.0' / '4.0', which strict validators
2003
+ // (BSI CSAF Validator) reject outright. Drop the block for non-3.x
2004
+ // vectors and surface a runtime_error so operators can see why their
2005
+ // CVSS data didn't make it through.
2006
+ const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
2007
+ const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
2008
+ const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
2009
+ if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
2010
+ const alreadyUnsup = runOpts._runErrors.some(e => e && e.kind === 'bundle_cvss_v3_version_unsupported');
2011
+ if (!alreadyUnsup) {
2012
+ runOpts._runErrors.push({
2013
+ kind: 'bundle_cvss_v3_version_unsupported',
2014
+ reason: `Catalog entry carries CVSS vector with version ${vectorVersion}; CSAF 2.0 cvss_v3 block only accepts versions 3.0 / 3.1. The score block was omitted from this vulnerability to keep the document valid against strict CSAF validators.`,
2015
+ remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
2016
+ });
2017
+ }
2018
+ }
2019
+ const scores = cvssV3Eligible ? [{
2020
+ products: [productId],
2021
+ cvss_v3: {
2022
+ version: vectorVersion,
2023
+ baseScore: c.cvss_score,
2024
+ vectorString: c.cvss_vector,
2025
+ baseSeverity: csafCvssSeverity(c.cvss_score),
2026
+ }
2027
+ }] : [];
2028
+ const base = {
2029
+ scores,
1685
2030
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
1686
2031
  remediations,
1687
2032
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
1688
2033
  };
1689
- });
2034
+ // route by id shape.
2035
+ if (idIsCve) {
2036
+ return { cve: c.cve_id, ...base };
2037
+ }
2038
+ return { ids: [idEntry], ...base };
2039
+ }).filter(v => v != null);
1690
2040
  const indicatorVulns = indicatorHits.map(i => ({
2041
+ // CSAF `system_name` values land in operator-facing validators; the
2042
+ // "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
2043
+ // Red Hat / ENISA dashboards render it as a non-CVE finding without
2044
+ // misattributing to a real registry (CVE, GHSA, OSV).
1691
2045
  ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
1692
2046
  notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
1693
2047
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1694
2048
  product_status: { known_affected: [productId] }
1695
2049
  }));
1696
- // audit W P2-D: framework-gap entries used to ride in `vulnerabilities[]`
2050
+ // D: framework-gap entries used to ride in `vulnerabilities[]`
1697
2051
  // with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
1698
2052
  // `system_name` slot is reserved for recognised vulnerability tracking
1699
2053
  // authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
@@ -1715,13 +2069,84 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1715
2069
  text: lines.join('\n'),
1716
2070
  };
1717
2071
  });
2072
+ // CSAF §3.1.7.4 publisher.namespace MUST be the trust
2073
+ // anchor of the entity publishing the advisory — the OPERATOR running the
2074
+ // scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
2075
+ // claimed https://exceptd.com as namespace, falsely attributing
2076
+ // responsibility for advisory accuracy to the tooling provider. Resolve
2077
+ // in priority order: explicit --publisher-namespace > --operator if it
2078
+ // looks URL-shaped > fallback `urn:exceptd:operator:unknown` with a note
2079
+ // documenting the gap.
2080
+ const operatorClean = sanitizeOperatorText(runOpts.operator);
2081
+ const explicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
2082
+ let publisherNamespace;
2083
+ let publisherNamespaceSource;
2084
+ if (explicitNs && /^https?:\/\//i.test(explicitNs)) {
2085
+ publisherNamespace = explicitNs;
2086
+ publisherNamespaceSource = 'runOpts.publisherNamespace';
2087
+ } else if (operatorClean && /^https?:\/\//i.test(operatorClean)) {
2088
+ publisherNamespace = operatorClean;
2089
+ publisherNamespaceSource = 'runOpts.operator';
2090
+ } else {
2091
+ publisherNamespace = 'urn:exceptd:operator:unknown';
2092
+ publisherNamespaceSource = 'fallback';
2093
+ }
2094
+ const namespaceFallbackNote = (publisherNamespaceSource === 'fallback') ? [{
2095
+ category: 'general',
2096
+ title: 'Publisher namespace not supplied',
2097
+ text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
2098
+ }] : [];
2099
+ // ALSO surface the unclaimed-publisher condition through
2100
+ // the structured runtime_errors[] accumulator so machine-readable
2101
+ // consumers (CI gates, dashboards) can branch on it without parsing
2102
+ // notes[] prose. The orchestrator's post-close pass folds late-pushed
2103
+ // _runErrors into phases.analyze.runtime_errors before the run-level
2104
+ // return, so the warning surfaces alongside other run-time anomalies.
2105
+ // De-dupe: only push once per bundle-build pass (multi-format emit
2106
+ // builds CSAF once via memoization, so this fires at most once per run).
2107
+ if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
2108
+ const already = runOpts._runErrors.some(e => e && e.kind === 'bundle_publisher_unclaimed');
2109
+ if (!already) {
2110
+ runOpts._runErrors.push({
2111
+ kind: 'bundle_publisher_unclaimed',
2112
+ 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.',
2113
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2114
+ });
2115
+ }
2116
+ }
2117
+
2118
+ // thread the validated --operator name into
2119
+ // tracking.generator (engine identity) AND publisher.contact_details
2120
+ // (operator-of-record). engine.version is read from the package once per
2121
+ // process. contact_details is omitted when no operator was supplied so
2122
+ // the field doesn't carry a misleading null.
2123
+ const publisherBlock = {
2124
+ category: 'vendor',
2125
+ name: 'exceptd',
2126
+ namespace: publisherNamespace,
2127
+ };
2128
+ if (operatorClean) publisherBlock.contact_details = operatorClean;
2129
+
2130
+ // CSAF §3.1.11.3.5.1 defines `final` as an immutable
2131
+ // advisory; subsequent re-emits against the same tracking.id are
2132
+ // refused by strict validators (BSI CSAF Validator). Runtime detection
2133
+ // runs with no operator review loop are inherently revisable, so the
2134
+ // default is `interim`. Operators who have reviewed and are ready to
2135
+ // promote pass `--csaf-status final` (threaded via runOpts.csafStatus);
2136
+ // any other value falls back to `interim` rather than emitting an
2137
+ // unrecognized status word.
2138
+ const allowedCsafStatuses = new Set(['draft', 'interim', 'final']);
2139
+ const csafStatus = allowedCsafStatuses.has(runOpts.csafStatus)
2140
+ ? runOpts.csafStatus
2141
+ : 'interim';
2142
+
1718
2143
  return {
1719
2144
  document: {
1720
2145
  category: 'csaf_security_advisory',
1721
2146
  csaf_version: '2.0',
1722
- publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
2147
+ publisher: publisherBlock,
1723
2148
  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))`,
1724
- notes: gapNotes,
2149
+ notes: [...namespaceFallbackNote, ...gapNotes],
1725
2150
  tracking: {
1726
2151
  // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
1727
2152
  // from run() via close()) so attestation file names, OpenVEX
@@ -1730,8 +2155,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1730
2155
  // the same millisecond collided and one run's documents
1731
2156
  // referenced ids that didn't match anything else on disk.
1732
2157
  id: `exceptd-${playbook._meta.id}-${sessionId}`,
1733
- status: 'final',
2158
+ status: csafStatus,
1734
2159
  version: playbook._meta.version,
2160
+ // name the engine that emitted the advisory.
2161
+ // CSAF §3.1.11.3.2 places this under tracking.generator.engine.
2162
+ generator: {
2163
+ engine: { name: 'exceptd', version: getEngineVersion() },
2164
+ date: now,
2165
+ },
1735
2166
  initial_release_date: now,
1736
2167
  current_release_date: now,
1737
2168
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
@@ -1748,6 +2179,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1748
2179
  evidence_requirements: validate.evidence_requirements,
1749
2180
  residual_risk_statement: validate.residual_risk_statement,
1750
2181
  indicators_fired: indicatorHits.map(i => ({ id: i.id, confidence: i.confidence, deterministic: i.deterministic })),
2182
+ publisher_namespace_source: publisherNamespaceSource,
1751
2183
  }
1752
2184
  };
1753
2185
  }
@@ -1763,8 +2195,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1763
2195
  // render empty fields.
1764
2196
  if (format === 'sarif' || format === 'sarif-2.1.0') {
1765
2197
  const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
2198
+ // SARIF rule ids are global within a single sarif-log run.
2199
+ // Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
2200
+ // across playbooks) collided when results from multiple playbook runs
2201
+ // were merged into one SARIF document — GitHub Code Scanning de-dupes
2202
+ // by ruleId, so the second playbook's rule definition silently
2203
+ // overwrote the first. Prefix every ruleId with the playbook slug so
2204
+ // every rule definition is unambiguously attributable to one playbook,
2205
+ // and cross-playbook merges retain all results.
2206
+ const rulePrefix = `${playbookSlug}/`;
1766
2207
  const cveResults = analyze.matched_cves.map(c => ({
1767
- ruleId: c.cve_id,
2208
+ ruleId: `${rulePrefix}${c.cve_id}`,
1768
2209
  level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
1769
2210
  message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
1770
2211
  properties: stripNulls({
@@ -1781,7 +2222,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1781
2222
  const indicatorResults = indicatorHits.map(i => {
1782
2223
  const locs = sarifLocationsForIndicator(playbook, i);
1783
2224
  const result = {
1784
- ruleId: i.id,
2225
+ ruleId: `${rulePrefix}${i.id}`,
1785
2226
  level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
1786
2227
  message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
1787
2228
  properties: stripNulls({
@@ -1796,7 +2237,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1796
2237
  return result;
1797
2238
  });
1798
2239
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1799
- ruleId: `framework-gap-${idx}`,
2240
+ ruleId: `${rulePrefix}framework-gap-${idx}`,
1800
2241
  // Framework gaps are control-design observations, not vulnerabilities —
1801
2242
  // SARIF §3.27.9 `kind: informational` routes them appropriately.
1802
2243
  kind: 'informational',
@@ -1805,18 +2246,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1805
2246
  properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
1806
2247
  }));
1807
2248
  const cveRules = analyze.matched_cves.map(c => ({
1808
- id: c.cve_id, shortDescription: { text: c.cve_id },
2249
+ id: `${rulePrefix}${c.cve_id}`, shortDescription: { text: c.cve_id },
1809
2250
  fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
1810
2251
  defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
1811
2252
  helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
1812
2253
  }));
1813
2254
  const indicatorRules = indicatorHits.map(i => ({
1814
- id: i.id, shortDescription: { text: i.id },
2255
+ id: `${rulePrefix}${i.id}`, shortDescription: { text: i.id },
1815
2256
  fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
1816
2257
  defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
1817
2258
  }));
1818
2259
  const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1819
- id: `framework-gap-${idx}`,
2260
+ id: `${rulePrefix}framework-gap-${idx}`,
1820
2261
  shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
1821
2262
  fullDescription: { text: g.actual_gap || `Framework gap in ${g.framework}` },
1822
2263
  defaultConfiguration: { level: 'note' },
@@ -1832,7 +2273,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1832
2273
  } },
1833
2274
  results: [...cveResults, ...indicatorResults, ...gapResults],
1834
2275
  invocations: [{ executionSuccessful: true, properties: stripNulls({
1835
- // audit W P3-A: apply the B7 stripNulls contract here too — the
2276
+ // A: apply the B7 stripNulls contract here too — the
1836
2277
  // `remediation` field is null for any run that didn't surface a
1837
2278
  // selected_remediation, and SARIF viewers render null property
1838
2279
  // values as visible empty rows. Same helper as the result
@@ -1861,7 +2302,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1861
2302
  // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
1862
2303
  // they pass IRI validation in downstream VEX consumers.
1863
2304
  if (format === 'openvex' || format === 'openvex-0.2.0') {
1864
- // audit W P2-B: reuse the bundle-wide `now` so OpenVEX `timestamp`
2305
+ // B: reuse the bundle-wide `now` so OpenVEX `timestamp`
1865
2306
  // aligns with CSAF `document.tracking.initial_release_date` when both
1866
2307
  // formats are emitted in the same close() pass. Pre-fix each format
1867
2308
  // crystallised its own Date.now() value, and the two bundles in
@@ -1881,7 +2322,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1881
2322
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
1882
2323
  return fallback;
1883
2324
  };
1884
- // audit W P1-A: same `vex_status === 'fixed'` correctness rule as the
2325
+ // A: same `vex_status === 'fixed'` correctness rule as the
1885
2326
  // CSAF emitter. The catalog `live_patch_available` flag is a global
1886
2327
  // "vendor publishes a live-patch" signal, not an operator-host
1887
2328
  // disposition. Treating it as `status: fixed` made OpenVEX statements
@@ -2054,6 +2495,16 @@ function normalizeSubmission(submission, playbook) {
2054
2495
  signals: { ...(submission.signals || {}) },
2055
2496
  precondition_checks: { ...(submission.precondition_checks || {}) },
2056
2497
  _original_shape: 'flat (v0.11.0)',
2498
+ // BB P1-4: normalizeSubmission pushes structured errors (e.g.
2499
+ // signal_overrides_invalid) onto submission._runErrors above. If the
2500
+ // submission is flat, the fresh `out` literal built here loses that
2501
+ // accumulator unless we forward it. run()'s harvest at the entry to
2502
+ // detect/analyze reads agentSubmission._runErrors — without this carry,
2503
+ // flat submissions with invalid signal_overrides silently lost the
2504
+ // v0.12.19 U REG-1 contract (errors never reached analyze.runtime_errors).
2505
+ ...(Array.isArray(submission._runErrors) && submission._runErrors.length
2506
+ ? { _runErrors: submission._runErrors.slice() }
2507
+ : {}),
2057
2508
  };
2058
2509
  const knownPreconditions = new Set((playbook?._meta?.preconditions || []).map(p => p.id));
2059
2510
  const knownArtifacts = new Set((playbook?.phases?.look?.artifacts || []).map(a => a.id));
@@ -2672,10 +3123,18 @@ module.exports = {
2672
3123
  vexFilterFromDoc,
2673
3124
  normalizeSubmission,
2674
3125
  autoDetectPreconditions,
3126
+ // MM P1-D: exposed for tests/audit-vv-trust-fixes.test.js so library-side
3127
+ // direct callers (the fallback path the CLI guard cannot reach) can be
3128
+ // exercised without spawning a CLI subprocess.
3129
+ sanitizeOperatorText,
2675
3130
  // internal helpers exposed for tests
2676
3131
  _resolvedPhase: resolvedPhase,
2677
3132
  _deepMerge: deepMerge,
2678
3133
  _evalCondition: evalCondition,
2679
3134
  _interpolate: interpolate,
2680
3135
  _activeRuns: _activeRuns,
3136
+ _acquireLock: acquireLock,
3137
+ _acquireLockDiagnostic: acquireLockDiagnostic,
3138
+ _releaseLock: releaseLock,
3139
+ _lockFilePath: lockFilePath,
2681
3140
  };