@blamejs/exceptd-skills 0.12.20 → 0.12.21

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 (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/bin/exceptd.js +504 -41
  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 +20 -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 +395 -69
  28. package/lib/prefetch.js +3 -3
  29. package/lib/refresh-external.js +13 -2
  30. package/lib/refresh-network.js +13 -13
  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-indexes.js +2 -2
  35. package/lib/verify.js +28 -9
  36. package/manifest.json +47 -47
  37. package/package.json +1 -1
  38. package/sbom.cdx.json +6 -6
  39. package/scripts/check-manifest-snapshot.js +1 -1
  40. package/scripts/check-sbom-currency.js +1 -1
  41. package/scripts/predeploy.js +6 -6
  42. package/scripts/refresh-manifest-snapshot.js +2 -2
  43. package/scripts/validate-vendor-online.js +1 -1
  44. package/scripts/verify-shipped-tarball.js +9 -10
  45. package/skills/compliance-theater/skill.md +4 -1
  46. package/skills/exploit-scoring/skill.md +20 -1
  47. package/skills/framework-gap-analysis/skill.md +6 -2
  48. package/skills/kernel-lpe-triage/skill.md +50 -3
  49. package/skills/threat-model-currency/skill.md +6 -4
  50. package/skills/webapp-security/skill.md +1 -1
  51. package/skills/zeroday-gap-learn/skill.md +44 -1
@@ -290,10 +290,87 @@ function lockFilePath(playbookId) {
290
290
  function acquireLock(playbookId) {
291
291
  const p = lockFilePath(playbookId);
292
292
  if (!p) return null;
293
+ const writePayload = () => fs.writeFileSync(
294
+ p,
295
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
296
+ { flag: 'wx' }
297
+ );
293
298
  try {
294
- fs.writeFileSync(p, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2), { flag: 'wx' });
299
+ writePayload();
295
300
  return p;
296
- } catch { return null; /* already locked or unwritable */ }
301
+ } catch (e) {
302
+ // DD P1-3: stale-PID reclaim. Pre-fix the EEXIST path returned null
303
+ // and callers proceeded UNLOCKED — a process that crashed mid-run
304
+ // left its lockfile behind and every subsequent invocation silently
305
+ // ran without mutex protection. Mirror withCatalogLock's pattern:
306
+ // parse the recorded pid, probe with `process.kill(pid, 0)`. ESRCH
307
+ // means the holder is dead — unlink and retry once. EPERM (alive,
308
+ // different user) or any other condition: leave the lock alone and
309
+ // return null with a diagnostic so the caller knows acquisition
310
+ // failed because the lock is genuinely held (not because the FS is
311
+ // broken or the playbook id is malformed).
312
+ if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
313
+ try {
314
+ const raw = fs.readFileSync(p, 'utf8');
315
+ let pid = null;
316
+ try { pid = JSON.parse(raw).pid; }
317
+ catch {
318
+ const n = Number.parseInt(String(raw).trim(), 10);
319
+ pid = Number.isInteger(n) && n > 0 ? n : null;
320
+ }
321
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
322
+ try { fs.unlinkSync(p); } catch {}
323
+ try { writePayload(); return p; } catch { /* fall through */ }
324
+ }
325
+ } catch { /* unreadable lockfile — treat as held by a live process */ }
326
+ }
327
+ // Lock genuinely held (or filesystem error). Returning null keeps
328
+ // back-compat with existing call sites that test `if (!lockPath)`.
329
+ // Callers that want a clearer diagnostic should call
330
+ // `acquireLockDiagnostic` instead.
331
+ return null;
332
+ }
333
+ }
334
+
335
+ // DD P1-3: callers needing to distinguish "couldn't acquire because the
336
+ // lock is genuinely held by a live process" from "couldn't acquire
337
+ // because of an unexpected error" can use this thin diagnostic wrapper.
338
+ // Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
339
+ // The bare `acquireLock` keeps its historical null-on-failure contract.
340
+ function acquireLockDiagnostic(playbookId) {
341
+ const p = lockFilePath(playbookId);
342
+ if (!p) return { ok: false, reason: 'no_lock_path' };
343
+ try {
344
+ fs.writeFileSync(p,
345
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
346
+ { flag: 'wx' });
347
+ return { ok: true, path: p };
348
+ } catch (e) {
349
+ if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
350
+ let pid = null;
351
+ try {
352
+ const raw = fs.readFileSync(p, 'utf8');
353
+ try { pid = JSON.parse(raw).pid; }
354
+ catch {
355
+ const n = Number.parseInt(String(raw).trim(), 10);
356
+ pid = Number.isInteger(n) && n > 0 ? n : null;
357
+ }
358
+ } catch {}
359
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
360
+ try { fs.unlinkSync(p); } catch {}
361
+ try {
362
+ fs.writeFileSync(p,
363
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
364
+ { flag: 'wx' });
365
+ return { ok: true, path: p, reclaimed_from_pid: pid };
366
+ } catch (e2) {
367
+ return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
368
+ }
369
+ }
370
+ return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
371
+ }
372
+ return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
373
+ }
297
374
  }
298
375
 
299
376
  function releaseLock(lockPath) {
@@ -453,29 +530,52 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
453
530
  // '<id>__fp_checks' in signal_overrides; default behavior (no
454
531
  // attestation) treats every required FP check as UNSATISFIED.
455
532
  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) {
533
+ // BB P2-4: a hostile or buggy attestation may be a Proxy whose property
534
+ // accessors throw. The filter below reads `att[fpName]` for each
535
+ // required check; an exception inside the read would crash detect()
536
+ // and abort the entire run. Wrap the FP-check evaluation in a
537
+ // try/catch: on throw, treat ALL required checks as unsatisfied
538
+ // (safest default never silently honor an attestation we couldn't
539
+ // read) and surface a runtime_error so the operator sees why.
540
+ try {
541
+ const attestation = overrides[`${ind.id}__fp_checks`];
542
+ // S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
543
+ // attestation map. A submission like
544
+ // signal_overrides: { sig__fp_checks: [true, true] }
545
+ // would previously have its truthy entries matched via the index
546
+ // fallback (att['0'] === true), silently bypassing every FP-check
547
+ // requirement. Reject arrays explicitly so they fall through to the
548
+ // empty-attestation branch (every required check unsatisfied).
549
+ const safeAtt = Array.isArray(attestation) ? null : attestation;
550
+ const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
551
+ const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
552
+ // Match either by exact name string OR by indexed key '0', '1', ...
553
+ // because false_positive_checks_required entries are free-text
554
+ // strings, not ids. Operators may attest either by the literal
555
+ // string or by index. Default: unsatisfied.
556
+ if (att[fpName] === true) return false;
557
+ const idx = ind.false_positive_checks_required.indexOf(fpName);
558
+ if (idx !== -1 && att[String(idx)] === true) return false;
559
+ return true;
560
+ });
561
+ if (unsatisfied.length > 0) {
562
+ verdict = 'inconclusive';
563
+ fpChecksUnsatisfied = unsatisfied;
564
+ }
565
+ } catch (e) {
566
+ // Treat every required check as unsatisfied — we couldn't trust the
567
+ // attestation map. Surface the throw so operators can chase the
568
+ // root cause (Proxy with a throwing getter, frozen object that
569
+ // tripped invariants, etc.).
477
570
  verdict = 'inconclusive';
478
- fpChecksUnsatisfied = unsatisfied;
571
+ fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
572
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
573
+ runOpts._runErrors.push({
574
+ kind: 'fp_attestation_threw',
575
+ indicator_id: ind.id,
576
+ message: (e && e.message) ? String(e.message) : String(e),
577
+ });
578
+ }
479
579
  }
480
580
  }
481
581
  } else {
@@ -515,33 +615,57 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
515
615
  // full false_positive_profile checks and reached an explicit verdict —
516
616
  // engine-computed classification can't represent "I saw the indicators and
517
617
  // confirmed they're all benign" without this override.
518
- const override = (agentSubmission.signals && agentSubmission.signals.detection_classification);
618
+ const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
519
619
  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).
620
+ // BB P2-1: any override that's a non-empty string but NOT in the allowlist
621
+ // (e.g. 'present', 'unknown', '', ' detected ', 'Detected') must surface
622
+ // as a runtime_error rather than silently falling through to engine-computed
623
+ // classification. Operators submitting case variants / whitespace-padded
624
+ // strings deserve a clear diagnostic, not a quiet downgrade. Treat the
625
+ // override as absent for classification purposes once recorded.
626
+ const overrideIsString = typeof rawOverride === 'string';
627
+ const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
628
+ if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
629
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
630
+ runOpts._runErrors.push({
631
+ kind: 'classification_override_invalid',
632
+ supplied: rawOverride,
633
+ allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
634
+ reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
635
+ });
636
+ }
637
+ }
638
+ const override = overrideIsInAllowlist ? rawOverride : undefined;
639
+
640
+ // BB P1-1 / BB P1-2: extend the v0.12.19 S P1-B gate to refuse ALL
641
+ // classification overrides (`detected`, `clean`, `not_detected`) when any
642
+ // indicator was FP-downgraded. A submission that maps to `'not_detected'`
643
+ // (either by literal `not_detected` OR by `'clean'`, which v0.12.19 mapped
644
+ // to `'not_detected'` at this site) MUST NOT hide a `verdict: 'hit'`
645
+ // indicator whose `false_positive_checks_required[]` were unattested —
646
+ // that's a strictly worse false-negative outcome than allowing 'detected'
647
+ // through. Substitute 'inconclusive' and emit a runtime_error.
648
+ // BB P2-2: record indicator IDs and an unsatisfied-checks count ONLY —
649
+ // never the literal FP-check check-name strings (those are an attestation-
650
+ // bypass hint for a hostile agent reading the runtime_errors).
529
651
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
530
652
 
531
653
  let classification;
532
- if (override && validOverrides.has(override)) {
654
+ if (override) {
533
655
  classification = override === 'clean' ? 'not_detected' : override;
534
- if (classification === 'detected' && anyFpDowngrade) {
535
- classification = 'inconclusive';
656
+ if (anyFpDowngrade) {
657
+ const substituted = 'inconclusive';
658
+ const attempted = override; // record what the operator submitted, not the mapped form
659
+ classification = substituted;
536
660
  if (runOpts && Array.isArray(runOpts._runErrors)) {
537
661
  runOpts._runErrors.push({
538
662
  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.',
663
+ attempted,
664
+ substituted,
665
+ 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
666
  indicators_with_unsatisfied_fp_checks: indicatorResults
543
667
  .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 })),
668
+ .map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
545
669
  });
546
670
  }
547
671
  }
@@ -580,7 +704,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
580
704
  from_observation: agentSubmission._signal_origins?.[i.id] || null,
581
705
  })),
582
706
  indicators_evaluated_count: indicatorResults.length,
583
- classification_override_applied: validOverrides.has(override) ? (override === 'clean' ? 'not_detected' : override) : null,
707
+ classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
584
708
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
585
709
  // E9: pass through any flat-shape observation collisions detected at
586
710
  // normalize time so analyze() can publish them under
@@ -871,7 +995,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
871
995
  }
872
996
  // F5: use the first evidence-correlated CVE as the canonical attribute
873
997
  // 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
998
+ // evidence to gate on. v0.12.15: the prior fallback was
875
999
  // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
876
1000
  // (secrets, library-author, crypto-codebase, framework, cred-stores,
877
1001
  // containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
@@ -898,7 +1022,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
898
1022
  null);
899
1023
  if (factorCve) factorCveSource = 'domain';
900
1024
  }
901
- // v0.12.15 (audit N F1): five shipped playbooks (secrets, library-author,
1025
+ // v0.12.15: five shipped playbooks (secrets, library-author,
902
1026
  // crypto-codebase, framework, cred-stores, containers, runtime, crypto,
903
1027
  // ai-api) ship with empty `domain.cve_refs` because their attack class is
904
1028
  // class-of-vulnerability rather than CVE-specific. For those playbooks
@@ -1393,7 +1517,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1393
1517
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
1394
1518
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
1395
1519
  : [];
1396
- // audit W P2-B: build every bundle once and reuse, so bundle_body and
1520
+ // B: build every bundle once and reuse, so bundle_body and
1397
1521
  // bundles_by_format[primary] are the same object identity (and hence
1398
1522
  // identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
1399
1523
  // invoked twice for the primary format and each invocation crystallised
@@ -1405,14 +1529,20 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1405
1529
  const builtFormats = new Map();
1406
1530
  const buildOnce = (format) => {
1407
1531
  if (!builtFormats.has(format)) {
1408
- builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt));
1532
+ builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt, runOpts));
1409
1533
  }
1410
1534
  return builtFormats.get(format);
1411
1535
  };
1412
1536
  const primaryBody = buildOnce(primaryFormat);
1413
- const byFormat = extraFormats.length
1414
- ? Object.fromEntries([primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)]))
1415
- : null;
1537
+ // audit CC P2-1: bundles_by_format must always be an object keyed by the
1538
+ // primary format, even when no extra formats were requested. Pre-fix it
1539
+ // was null in the single-format case, forcing downstream tooling into a
1540
+ // `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
1541
+ // consumer. Now the field is canonically present so iteration is
1542
+ // uniform across single- and multi-format emissions.
1543
+ const byFormat = Object.fromEntries(
1544
+ [primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)])
1545
+ );
1416
1546
  return {
1417
1547
  bundle_format: primaryFormat,
1418
1548
  contents: c.evidence_package.contents || [],
@@ -1592,7 +1722,7 @@ function buildProductBinding(playbook, sessionId) {
1592
1722
  // surface at least one candidate when any is known. Returns null when no
1593
1723
  // candidate exists — caller MUST omit `locations` rather than emit empty.
1594
1724
  //
1595
- // audit W P2-A: source segments are heterogeneous — many playbook artifacts
1725
+ // A: source segments are heterogeneous — many playbook artifacts
1596
1726
  // describe a shell-command capture (`uname -r`) or human prose, not a real
1597
1727
  // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
1598
1728
  // (RFC 3986); shell-command text + prose breaks downstream consumers
@@ -1634,10 +1764,38 @@ function sarifLocationsForIndicator(playbook, indicator) {
1634
1764
  return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
1635
1765
  }
1636
1766
 
1637
- function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt) {
1767
+ // Resolve the package version once per process so CSAF tracking.generator
1768
+ // can name the engine that emitted the advisory. Best-effort read — bundle
1769
+ // emission must not crash if package.json is missing (e.g. exotic install).
1770
+ let _CACHED_PKG_VERSION = null;
1771
+ function getEngineVersion() {
1772
+ if (_CACHED_PKG_VERSION != null) return _CACHED_PKG_VERSION;
1773
+ try {
1774
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
1775
+ _CACHED_PKG_VERSION = (pkg && typeof pkg.version === 'string') ? pkg.version : 'unknown';
1776
+ } catch {
1777
+ _CACHED_PKG_VERSION = 'unknown';
1778
+ }
1779
+ return _CACHED_PKG_VERSION;
1780
+ }
1781
+
1782
+ // audit CC P1-3 / P1-4: operator-supplied identity strings (--operator) and
1783
+ // publisher namespace URLs (--publisher-namespace) flow into operator-facing
1784
+ // CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass —
1785
+ // bin/exceptd.js already validates the inputs, but the runner is also called
1786
+ // from library consumers that may bypass the CLI surface.
1787
+ function sanitizeOperatorText(s) {
1788
+ if (typeof s !== 'string') return null;
1789
+ // eslint-disable-next-line no-control-regex
1790
+ const cleaned = s.replace(/[\x00-\x1F\x7F]/g, '').trim();
1791
+ return cleaned.length ? cleaned.slice(0, 256) : null;
1792
+ }
1793
+
1794
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
1795
+ runOpts = runOpts || {};
1638
1796
  const playbookSlug = urnSlug(playbook._meta.id);
1639
1797
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1640
- // audit W P2-B: pin one `now` value per bundle build (and accept an
1798
+ // B: pin one `now` value per bundle build (and accept an
1641
1799
  // upstream-provided issuedAt) so multi-format emit produces identical
1642
1800
  // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
1643
1801
  // building several formats from the same run. Without the parameter,
@@ -1661,7 +1819,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1661
1819
  name: productName,
1662
1820
  product_identification_helper: { purl: productPurl }
1663
1821
  }];
1664
- // audit W P1-A: `fixed` product_status MUST reflect operator-supplied VEX
1822
+ // A: `fixed` product_status MUST reflect operator-supplied VEX
1665
1823
  // disposition (vex_status === 'fixed' — see analyze() F17), not the
1666
1824
  // catalog's global `live_patch_available` flag. The catalog flag means
1667
1825
  // "vendor publishes a live-patch in the world", not "operator deployed
@@ -1671,6 +1829,48 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1671
1829
  // that lied to downstream NVD / Red Hat dashboards. When
1672
1830
  // live_patch_available is the only signal, status stays known_affected
1673
1831
  // and the live-patch route is surfaced as a `vendor_fix` remediation.
1832
+ // audit CC P1-2: CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1833
+ // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
1834
+ // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
1835
+ // validators (BSI CSAF validator, ENISA dashboard) refuse documents that
1836
+ // place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
1837
+ // to the `ids[]` array with a real `system_name`.
1838
+ //
1839
+ // audit CC P2-2: CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1840
+ // cvss_v3 score block is emitted. Drop the entire score block when the
1841
+ // catalog has no CVSS data (score AND vector both unset); otherwise
1842
+ // include version + baseScore + vectorString + baseSeverity from the
1843
+ // catalog entry.
1844
+ const csafCvssSeverity = (score) => {
1845
+ if (typeof score !== 'number') return null;
1846
+ if (score >= 9.0) return 'CRITICAL';
1847
+ if (score >= 7.0) return 'HIGH';
1848
+ if (score >= 4.0) return 'MEDIUM';
1849
+ if (score > 0.0) return 'LOW';
1850
+ return 'NONE';
1851
+ };
1852
+ const csafCvssVersionFromVector = (vec) => {
1853
+ if (typeof vec !== 'string') return '3.1';
1854
+ const m = vec.match(/^CVSS:(\d+\.\d+)\//);
1855
+ if (!m) return '3.1';
1856
+ // CSAF cvss_v3 block only accepts 3.x; if the catalog vector is 2.0 or
1857
+ // 4.0 we still tag the block as the value the catalog declared. Strict
1858
+ // validators that gate cvss_v3 to 3.0/3.1 will reject 2.0/4.0 — but
1859
+ // emitting the wrong version on a 4.0 vector would be worse.
1860
+ return m[1];
1861
+ };
1862
+ const csafIdsFor = (id) => {
1863
+ if (typeof id !== 'string' || !id) return { system_name: 'OSV', text: String(id) };
1864
+ if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1865
+ if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1866
+ if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1867
+ if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1868
+ // Fallback: surface the raw value under a generic OSV system_name; any
1869
+ // strict validator will at least know it's not a CVE.
1870
+ return { system_name: 'OSV', text: id };
1871
+ };
1872
+ const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
1873
+
1674
1874
  const cveVulns = analyze.matched_cves.map(c => {
1675
1875
  const isFixed = c.vex_status === 'fixed';
1676
1876
  const remediations = [{
@@ -1679,21 +1879,46 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1679
1879
  || (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
1880
  product_ids: [productId],
1681
1881
  }];
1682
- return {
1683
- cve: c.cve_id,
1684
- scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
1882
+ // audit CC P2-2: only emit cvss_v3 score block when we have a real
1883
+ // vector string AND a numeric score. Pre-fix every vuln carried
1884
+ // `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
1885
+ // signal — strict validators reject the truncated block, and
1886
+ // `base_score: 0` was a downstream-misleading default that suggested
1887
+ // an authoritative "informational" score where there was simply no
1888
+ // data.
1889
+ const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
1890
+ const scores = hasCvss ? [{
1891
+ products: [productId],
1892
+ cvss_v3: {
1893
+ version: csafCvssVersionFromVector(c.cvss_vector),
1894
+ baseScore: c.cvss_score,
1895
+ vectorString: c.cvss_vector,
1896
+ baseSeverity: csafCvssSeverity(c.cvss_score),
1897
+ }
1898
+ }] : [];
1899
+ const base = {
1900
+ scores,
1685
1901
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
1686
1902
  remediations,
1687
1903
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
1688
1904
  };
1905
+ // audit CC P1-2: route by id shape.
1906
+ if (CSAF_CVE_RE.test(c.cve_id)) {
1907
+ return { cve: c.cve_id, ...base };
1908
+ }
1909
+ return { ids: [csafIdsFor(c.cve_id)], ...base };
1689
1910
  });
1690
1911
  const indicatorVulns = indicatorHits.map(i => ({
1912
+ // CSAF `system_name` values land in operator-facing validators; the
1913
+ // "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
1914
+ // Red Hat / ENISA dashboards render it as a non-CVE finding without
1915
+ // misattributing to a real registry (CVE, GHSA, OSV).
1691
1916
  ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
1692
1917
  notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
1693
1918
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1694
1919
  product_status: { known_affected: [productId] }
1695
1920
  }));
1696
- // audit W P2-D: framework-gap entries used to ride in `vulnerabilities[]`
1921
+ // D: framework-gap entries used to ride in `vulnerabilities[]`
1697
1922
  // with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
1698
1923
  // `system_name` slot is reserved for recognised vulnerability tracking
1699
1924
  // authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
@@ -1715,13 +1940,84 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1715
1940
  text: lines.join('\n'),
1716
1941
  };
1717
1942
  });
1943
+ // audit CC P1-3: CSAF §3.1.7.4 publisher.namespace MUST be the trust
1944
+ // anchor of the entity publishing the advisory — the OPERATOR running the
1945
+ // scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
1946
+ // claimed https://exceptd.com as namespace, falsely attributing
1947
+ // responsibility for advisory accuracy to the tooling provider. Resolve
1948
+ // in priority order: explicit --publisher-namespace > --operator if it
1949
+ // looks URL-shaped > fallback `urn:exceptd:operator:unknown` with a note
1950
+ // documenting the gap.
1951
+ const operatorClean = sanitizeOperatorText(runOpts.operator);
1952
+ const explicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
1953
+ let publisherNamespace;
1954
+ let publisherNamespaceSource;
1955
+ if (explicitNs && /^https?:\/\//i.test(explicitNs)) {
1956
+ publisherNamespace = explicitNs;
1957
+ publisherNamespaceSource = 'runOpts.publisherNamespace';
1958
+ } else if (operatorClean && /^https?:\/\//i.test(operatorClean)) {
1959
+ publisherNamespace = operatorClean;
1960
+ publisherNamespaceSource = 'runOpts.operator';
1961
+ } else {
1962
+ publisherNamespace = 'urn:exceptd:operator:unknown';
1963
+ publisherNamespaceSource = 'fallback';
1964
+ }
1965
+ const namespaceFallbackNote = (publisherNamespaceSource === 'fallback') ? [{
1966
+ category: 'general',
1967
+ title: 'Publisher namespace not supplied',
1968
+ text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
1969
+ }] : [];
1970
+ // audit CC P1-3: ALSO surface the unclaimed-publisher condition through
1971
+ // the structured runtime_errors[] accumulator so machine-readable
1972
+ // consumers (CI gates, dashboards) can branch on it without parsing
1973
+ // notes[] prose. The orchestrator's post-close pass folds late-pushed
1974
+ // _runErrors into phases.analyze.runtime_errors before the run-level
1975
+ // return, so the warning surfaces alongside other run-time anomalies.
1976
+ // De-dupe: only push once per bundle-build pass (multi-format emit
1977
+ // builds CSAF once via memoization, so this fires at most once per run).
1978
+ if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
1979
+ const already = runOpts._runErrors.some(e => e && e.kind === 'bundle_publisher_unclaimed');
1980
+ if (!already) {
1981
+ runOpts._runErrors.push({
1982
+ kind: 'bundle_publisher_unclaimed',
1983
+ 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.',
1984
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
1985
+ });
1986
+ }
1987
+ }
1988
+
1989
+ // audit CC P1-4: thread the validated --operator name into
1990
+ // tracking.generator (engine identity) AND publisher.contact_details
1991
+ // (operator-of-record). engine.version is read from the package once per
1992
+ // process. contact_details is omitted when no operator was supplied so
1993
+ // the field doesn't carry a misleading null.
1994
+ const publisherBlock = {
1995
+ category: 'vendor',
1996
+ name: 'exceptd',
1997
+ namespace: publisherNamespace,
1998
+ };
1999
+ if (operatorClean) publisherBlock.contact_details = operatorClean;
2000
+
2001
+ // audit CC P1-1: CSAF §3.1.11.3.5.1 defines `final` as an immutable
2002
+ // advisory; subsequent re-emits against the same tracking.id are
2003
+ // refused by strict validators (BSI CSAF Validator). Runtime detection
2004
+ // runs with no operator review loop are inherently revisable, so the
2005
+ // default is `interim`. Operators who have reviewed and are ready to
2006
+ // promote pass `--csaf-status final` (threaded via runOpts.csafStatus);
2007
+ // any other value falls back to `interim` rather than emitting an
2008
+ // unrecognized status word.
2009
+ const allowedCsafStatuses = new Set(['draft', 'interim', 'final']);
2010
+ const csafStatus = allowedCsafStatuses.has(runOpts.csafStatus)
2011
+ ? runOpts.csafStatus
2012
+ : 'interim';
2013
+
1718
2014
  return {
1719
2015
  document: {
1720
2016
  category: 'csaf_security_advisory',
1721
2017
  csaf_version: '2.0',
1722
- publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
2018
+ publisher: publisherBlock,
1723
2019
  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,
2020
+ notes: [...namespaceFallbackNote, ...gapNotes],
1725
2021
  tracking: {
1726
2022
  // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
1727
2023
  // from run() via close()) so attestation file names, OpenVEX
@@ -1730,8 +2026,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1730
2026
  // the same millisecond collided and one run's documents
1731
2027
  // referenced ids that didn't match anything else on disk.
1732
2028
  id: `exceptd-${playbook._meta.id}-${sessionId}`,
1733
- status: 'final',
2029
+ status: csafStatus,
1734
2030
  version: playbook._meta.version,
2031
+ // audit CC P1-4: name the engine that emitted the advisory.
2032
+ // CSAF §3.1.11.3.2 places this under tracking.generator.engine.
2033
+ generator: {
2034
+ engine: { name: 'exceptd', version: getEngineVersion() },
2035
+ date: now,
2036
+ },
1735
2037
  initial_release_date: now,
1736
2038
  current_release_date: now,
1737
2039
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
@@ -1748,6 +2050,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1748
2050
  evidence_requirements: validate.evidence_requirements,
1749
2051
  residual_risk_statement: validate.residual_risk_statement,
1750
2052
  indicators_fired: indicatorHits.map(i => ({ id: i.id, confidence: i.confidence, deterministic: i.deterministic })),
2053
+ publisher_namespace_source: publisherNamespaceSource,
1751
2054
  }
1752
2055
  };
1753
2056
  }
@@ -1763,8 +2066,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1763
2066
  // render empty fields.
1764
2067
  if (format === 'sarif' || format === 'sarif-2.1.0') {
1765
2068
  const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
2069
+ // audit CC P2-6: SARIF rule ids are global within a single sarif-log run.
2070
+ // Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
2071
+ // across playbooks) collided when results from multiple playbook runs
2072
+ // were merged into one SARIF document — GitHub Code Scanning de-dupes
2073
+ // by ruleId, so the second playbook's rule definition silently
2074
+ // overwrote the first. Prefix every ruleId with the playbook slug so
2075
+ // every rule definition is unambiguously attributable to one playbook,
2076
+ // and cross-playbook merges retain all results.
2077
+ const rulePrefix = `${playbookSlug}/`;
1766
2078
  const cveResults = analyze.matched_cves.map(c => ({
1767
- ruleId: c.cve_id,
2079
+ ruleId: `${rulePrefix}${c.cve_id}`,
1768
2080
  level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
1769
2081
  message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
1770
2082
  properties: stripNulls({
@@ -1781,7 +2093,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1781
2093
  const indicatorResults = indicatorHits.map(i => {
1782
2094
  const locs = sarifLocationsForIndicator(playbook, i);
1783
2095
  const result = {
1784
- ruleId: i.id,
2096
+ ruleId: `${rulePrefix}${i.id}`,
1785
2097
  level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
1786
2098
  message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
1787
2099
  properties: stripNulls({
@@ -1796,7 +2108,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1796
2108
  return result;
1797
2109
  });
1798
2110
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1799
- ruleId: `framework-gap-${idx}`,
2111
+ ruleId: `${rulePrefix}framework-gap-${idx}`,
1800
2112
  // Framework gaps are control-design observations, not vulnerabilities —
1801
2113
  // SARIF §3.27.9 `kind: informational` routes them appropriately.
1802
2114
  kind: 'informational',
@@ -1805,18 +2117,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1805
2117
  properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
1806
2118
  }));
1807
2119
  const cveRules = analyze.matched_cves.map(c => ({
1808
- id: c.cve_id, shortDescription: { text: c.cve_id },
2120
+ id: `${rulePrefix}${c.cve_id}`, shortDescription: { text: c.cve_id },
1809
2121
  fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
1810
2122
  defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
1811
2123
  helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
1812
2124
  }));
1813
2125
  const indicatorRules = indicatorHits.map(i => ({
1814
- id: i.id, shortDescription: { text: i.id },
2126
+ id: `${rulePrefix}${i.id}`, shortDescription: { text: i.id },
1815
2127
  fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
1816
2128
  defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
1817
2129
  }));
1818
2130
  const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1819
- id: `framework-gap-${idx}`,
2131
+ id: `${rulePrefix}framework-gap-${idx}`,
1820
2132
  shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
1821
2133
  fullDescription: { text: g.actual_gap || `Framework gap in ${g.framework}` },
1822
2134
  defaultConfiguration: { level: 'note' },
@@ -1832,7 +2144,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1832
2144
  } },
1833
2145
  results: [...cveResults, ...indicatorResults, ...gapResults],
1834
2146
  invocations: [{ executionSuccessful: true, properties: stripNulls({
1835
- // audit W P3-A: apply the B7 stripNulls contract here too — the
2147
+ // A: apply the B7 stripNulls contract here too — the
1836
2148
  // `remediation` field is null for any run that didn't surface a
1837
2149
  // selected_remediation, and SARIF viewers render null property
1838
2150
  // values as visible empty rows. Same helper as the result
@@ -1861,7 +2173,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1861
2173
  // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
1862
2174
  // they pass IRI validation in downstream VEX consumers.
1863
2175
  if (format === 'openvex' || format === 'openvex-0.2.0') {
1864
- // audit W P2-B: reuse the bundle-wide `now` so OpenVEX `timestamp`
2176
+ // B: reuse the bundle-wide `now` so OpenVEX `timestamp`
1865
2177
  // aligns with CSAF `document.tracking.initial_release_date` when both
1866
2178
  // formats are emitted in the same close() pass. Pre-fix each format
1867
2179
  // crystallised its own Date.now() value, and the two bundles in
@@ -1881,7 +2193,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1881
2193
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
1882
2194
  return fallback;
1883
2195
  };
1884
- // audit W P1-A: same `vex_status === 'fixed'` correctness rule as the
2196
+ // A: same `vex_status === 'fixed'` correctness rule as the
1885
2197
  // CSAF emitter. The catalog `live_patch_available` flag is a global
1886
2198
  // "vendor publishes a live-patch" signal, not an operator-host
1887
2199
  // disposition. Treating it as `status: fixed` made OpenVEX statements
@@ -2054,6 +2366,16 @@ function normalizeSubmission(submission, playbook) {
2054
2366
  signals: { ...(submission.signals || {}) },
2055
2367
  precondition_checks: { ...(submission.precondition_checks || {}) },
2056
2368
  _original_shape: 'flat (v0.11.0)',
2369
+ // BB P1-4: normalizeSubmission pushes structured errors (e.g.
2370
+ // signal_overrides_invalid) onto submission._runErrors above. If the
2371
+ // submission is flat, the fresh `out` literal built here loses that
2372
+ // accumulator unless we forward it. run()'s harvest at the entry to
2373
+ // detect/analyze reads agentSubmission._runErrors — without this carry,
2374
+ // flat submissions with invalid signal_overrides silently lost the
2375
+ // v0.12.19 U REG-1 contract (errors never reached analyze.runtime_errors).
2376
+ ...(Array.isArray(submission._runErrors) && submission._runErrors.length
2377
+ ? { _runErrors: submission._runErrors.slice() }
2378
+ : {}),
2057
2379
  };
2058
2380
  const knownPreconditions = new Set((playbook?._meta?.preconditions || []).map(p => p.id));
2059
2381
  const knownArtifacts = new Set((playbook?.phases?.look?.artifacts || []).map(a => a.id));
@@ -2678,4 +3000,8 @@ module.exports = {
2678
3000
  _evalCondition: evalCondition,
2679
3001
  _interpolate: interpolate,
2680
3002
  _activeRuns: _activeRuns,
3003
+ _acquireLock: acquireLock,
3004
+ _acquireLockDiagnostic: acquireLockDiagnostic,
3005
+ _releaseLock: releaseLock,
3006
+ _lockFilePath: lockFilePath,
2681
3007
  };