@blamejs/exceptd-skills 0.12.16 → 0.12.20

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.
@@ -29,7 +29,7 @@
29
29
 
30
30
  const fs = require("fs");
31
31
  const path = require("path");
32
- const { scoreCustom } = require("./scoring");
32
+ const { scoreCustom, RWEP_WEIGHTS, ACTIVE_EXPLOITATION_LADDER } = require("./scoring");
33
33
 
34
34
  // audit M P1-C: stored rwep_factors must reproduce the stored rwep_score.
35
35
  // `buildScoringInputs` is the single source of truth for both — it captures
@@ -56,6 +56,70 @@ function buildScoringInputs(kevEntry /*, nvdPayload */) {
56
56
  };
57
57
  }
58
58
 
59
+ // audit X P1: cve-catalog.schema.json's `rwep_factors` requires the post-weight
60
+ // numeric shape (cisa_kev: 0|25, poc_available: 0|20, ai_factor: 0|15,
61
+ // active_exploitation: 0|5|10|20, blast_radius: 0..30, patch_available: 0|-15,
62
+ // live_patch_available: 0|-10, reboot_required: 0|5). Pre-fix the auto-discovery
63
+ // builder stored the SHAPE-A (boolean + string-ladder) factor bag — semantically
64
+ // fine because deriveRwepFromFactors handles either shape, but the strict
65
+ // JSON-schema validator (and any downstream tooling that types-checks the
66
+ // catalog field) rejected drafts as malformed. Convert the boolean inputs to
67
+ // the schema-required post-weight shape so the curate-apply gate (which loads
68
+ // the strict schema) doesn't reject KEV-discovered drafts post-promotion.
69
+ //
70
+ // `ai_factor` is the schema-required key name; auto-discovery's boolean
71
+ // inputs are split across `ai_discovered` + `ai_assisted_weapon`, mirroring
72
+ // scoreCustom's contract. The factor fires when either flag is true (same as
73
+ // scoreCustom).
74
+ function toPostWeightFactors(inputs) {
75
+ const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[inputs.active_exploitation] ?? 0;
76
+ const reboot = (inputs.reboot_required === true) || (inputs.patch_required_reboot === true);
77
+ const blastRaw = Number.isFinite(Number(inputs.blast_radius)) ? Number(inputs.blast_radius) : 0;
78
+ const blastClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw));
79
+ return {
80
+ cisa_kev: inputs.cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0,
81
+ poc_available: inputs.poc_available ? RWEP_WEIGHTS.poc_available : 0,
82
+ ai_factor: (inputs.ai_assisted_weapon || inputs.ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0,
83
+ active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
84
+ blast_radius: blastClamped,
85
+ patch_available: inputs.patch_available ? RWEP_WEIGHTS.patch_available : 0,
86
+ live_patch_available: inputs.live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0,
87
+ reboot_required: reboot ? RWEP_WEIGHTS.reboot_required : 0,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Audit M P3-O — diff severity nuance for KEV-discovered drafts.
93
+ *
94
+ * Pre-fix every KEV-derived diff carried `severity: "high"`. Operators
95
+ * scanning the diff stream had no way to distinguish "patch in 21 days"
96
+ * from "active ransomware campaign, patch yesterday." Now:
97
+ *
98
+ * - ransomware_use === "Known" → "critical" (campaigns observed in the wild)
99
+ * - dueDate within 7 days of now → "critical" (CISA escalation window)
100
+ * - otherwise → "high" (still actively exploited per KEV listing)
101
+ *
102
+ * A KEV listing inherently means active exploitation; "low" / "medium"
103
+ * never apply here. The split is between "act today" and "act this sprint."
104
+ *
105
+ * @param {object} kevEntry
106
+ * @returns {"critical" | "high"}
107
+ */
108
+ function deriveKevSeverity(kevEntry) {
109
+ const ransomware = String(kevEntry?.knownRansomwareCampaignUse || "").toLowerCase() === "known";
110
+ if (ransomware) return "critical";
111
+ const due = kevEntry?.dueDate;
112
+ if (typeof due === "string" && /^\d{4}-\d{2}-\d{2}/.test(due)) {
113
+ const dueMs = Date.parse(due);
114
+ if (Number.isFinite(dueMs)) {
115
+ const deltaMs = dueMs - Date.now();
116
+ // Within the next 7 days OR already past due → critical.
117
+ if (deltaMs <= 7 * 86_400_000) return "critical";
118
+ }
119
+ }
120
+ return "high";
121
+ }
122
+
59
123
  const TODAY = new Date().toISOString().slice(0, 10);
60
124
  const TIMEOUT_MS = 10_000;
61
125
  const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
@@ -140,11 +204,22 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
140
204
  // rwep_score was computed from concrete defaults (poc=true, reboot=true).
141
205
  // `scoring.validate()` then flagged every auto-imported draft for
142
206
  // divergence > 5. Now: one canonical input object → both surfaces.
207
+ //
208
+ // audit X P1: the catalog's JSON-schema for `rwep_factors` requires the
209
+ // POST-WEIGHT numeric shape (ai_factor / numeric ladder contributions /
210
+ // numeric ±deductions) — not the SHAPE-A boolean + string-ladder shape
211
+ // that scoreCustom consumes. Pre-fix the boolean shape was stored
212
+ // verbatim, so curate-apply's strict-schema gate rejected KEV-discovered
213
+ // drafts as soon as anyone tried to promote them — they were
214
+ // permanently unpromotable.
215
+ //
143
216
  // The curation flow rewrites these once an operator answers the editorial
144
- // questions; until then, the boolean shape on rwep_factors is the
145
- // conservative-default snapshot and reproduces the score exactly.
217
+ // questions; until then, the post-weight numeric shape on rwep_factors
218
+ // reproduces the score exactly (sum of values === rwep_score, because
219
+ // blast_radius weight=30 matches the raw-cap convention documented in
220
+ // scoring.js header).
146
221
  const scoringInputs = buildScoringInputs(kevEntry, nvdPayload);
147
- const rwep_factors = { ...scoringInputs };
222
+ const rwep_factors = toPostWeightFactors(scoringInputs);
148
223
  const rwep_score = scoreCustom(scoringInputs);
149
224
 
150
225
  const product = [kevEntry.vendorProject, kevEntry.product]
@@ -190,10 +265,21 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
190
265
  kevEntry.notes ? String(kevEntry.notes) : null,
191
266
  ].filter(Boolean),
192
267
  // v0.12.15 (audit M P1-B): schema requires source_verified to be a
193
- // YYYY-MM-DD string OR null; the prior `false` boolean produced an
194
- // entry that failed strict catalog validation. Use null to mean
195
- // "not yet verified" — operators populate the date during curation.
196
- source_verified: null,
268
+ // YYYY-MM-DD string; the prior `false` boolean (then null) produced
269
+ // entries that failed strict catalog validation.
270
+ //
271
+ // audit X P1: the CISA KEV listing IS the verification source for a
272
+ // KEV-discovered draft — the entry's `verification_sources` array
273
+ // already points to the KEV catalog URL, and KEV's appearance is what
274
+ // triggered the auto-import. Pre-fix the field stayed null, which
275
+ // (a) blocked curate-apply's strict-schema check (which requires a
276
+ // YYYY-MM-DD string) and (b) left operators no signal that the
277
+ // upstream HAD in fact verified the entry's authoritative listing.
278
+ // Now we date-stamp it as TODAY (the import day). Operators may
279
+ // overwrite during full curation if they revalidate from a fresher
280
+ // KEV pull — the field always semantically means "the date a
281
+ // verification source confirmed this CVE id."
282
+ source_verified: TODAY,
197
283
  last_updated: TODAY,
198
284
  last_verified: TODAY,
199
285
  // v0.12.15 (audit M P1-D): `_auto_imported` must be the boolean `true`
@@ -217,7 +303,6 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
217
303
  "patch_available + live_patch_available + live_patch_tools",
218
304
  "blast_radius numeric in rwep_factors (currently default 15)",
219
305
  "RWEP score recompute after the above land",
220
- "source_verified once a project maintainer has confirmed the upstream",
221
306
  ],
222
307
  },
223
308
  };
@@ -259,7 +344,7 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
259
344
  op: "add",
260
345
  target: "cveCatalog",
261
346
  entry,
262
- severity: "high",
347
+ severity: deriveKevSeverity(kev),
263
348
  meta: {
264
349
  date_added: kev.dateAdded || null,
265
350
  vendor: kev.vendorProject || null,
@@ -534,6 +619,7 @@ module.exports = {
534
619
  discoverNewRfcs,
535
620
  buildKevDraftEntry,
536
621
  getProjectRfcGroups,
622
+ deriveKevSeverity,
537
623
  SEED_RFC_GROUPS,
538
624
  DEFAULT_CAP,
539
625
  };
@@ -454,7 +454,15 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
454
454
  // attestation) treats every required FP check as UNSATISFIED.
455
455
  if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
456
456
  const attestation = overrides[`${ind.id}__fp_checks`];
457
- const att = (attestation && typeof attestation === 'object') ? attestation : {};
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 : {};
458
466
  const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
459
467
  // Match either by exact name string OR by indexed key '0', '1', ...
460
468
  // because false_positive_checks_required entries are free-text
@@ -510,9 +518,33 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
510
518
  const override = (agentSubmission.signals && agentSubmission.signals.detection_classification);
511
519
  const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
512
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).
529
+ const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
530
+
513
531
  let classification;
514
532
  if (override && validOverrides.has(override)) {
515
533
  classification = override === 'clean' ? 'not_detected' : override;
534
+ if (classification === 'detected' && anyFpDowngrade) {
535
+ classification = 'inconclusive';
536
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
537
+ runOpts._runErrors.push({
538
+ 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.',
542
+ indicators_with_unsatisfied_fp_checks: indicatorResults
543
+ .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 })),
545
+ });
546
+ }
547
+ }
516
548
  } else if (hasDeterministicHit || hasHighConfHit) {
517
549
  classification = 'detected';
518
550
  } else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
@@ -1361,16 +1393,35 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1361
1393
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
1362
1394
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
1363
1395
  : [];
1364
- const evidencePackage = c.evidence_package ? {
1365
- bundle_format: primaryFormat,
1366
- contents: c.evidence_package.contents || [],
1367
- destination: c.evidence_package.destination || 'local_only',
1368
- signed: c.evidence_package.signed !== false,
1369
- bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals, sessionId),
1370
- bundles_by_format: extraFormats.length ? Object.fromEntries(
1371
- [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals, sessionId)])
1372
- ) : null,
1373
- } : null;
1396
+ // audit W P2-B: build every bundle once and reuse, so bundle_body and
1397
+ // bundles_by_format[primary] are the same object identity (and hence
1398
+ // identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
1399
+ // invoked twice for the primary format and each invocation crystallised
1400
+ // a fresh Date.now() operators diffing bundle_body against
1401
+ // bundles_by_format.<primary> saw spurious millisecond drift on
1402
+ // tracking.initial_release_date / timestamp / current_release_date.
1403
+ const evidencePackage = c.evidence_package ? (() => {
1404
+ const issuedAt = new Date().toISOString();
1405
+ const builtFormats = new Map();
1406
+ const buildOnce = (format) => {
1407
+ if (!builtFormats.has(format)) {
1408
+ builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt));
1409
+ }
1410
+ return builtFormats.get(format);
1411
+ };
1412
+ const primaryBody = buildOnce(primaryFormat);
1413
+ const byFormat = extraFormats.length
1414
+ ? Object.fromEntries([primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)]))
1415
+ : null;
1416
+ return {
1417
+ bundle_format: primaryFormat,
1418
+ contents: c.evidence_package.contents || [],
1419
+ destination: c.evidence_package.destination || 'local_only',
1420
+ signed: c.evidence_package.signed !== false,
1421
+ bundle_body: primaryBody,
1422
+ bundles_by_format: byFormat,
1423
+ };
1424
+ })() : null;
1374
1425
 
1375
1426
  if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
1376
1427
  const body = JSON.stringify(evidencePackage.bundle_body);
@@ -1540,20 +1591,59 @@ function buildProductBinding(playbook, sessionId) {
1540
1591
  // Code Scanning hides results without `artifactLocation.uri`, so we
1541
1592
  // surface at least one candidate when any is known. Returns null when no
1542
1593
  // candidate exists — caller MUST omit `locations` rather than emit empty.
1594
+ //
1595
+ // audit W P2-A: source segments are heterogeneous — many playbook artifacts
1596
+ // describe a shell-command capture (`uname -r`) or human prose, not a real
1597
+ // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
1598
+ // (RFC 3986); shell-command text + prose breaks downstream consumers
1599
+ // (GitHub Code Scanning rejects with "invalid URI" or renders garbled).
1600
+ // We accept only path-shaped candidates: absolute POSIX paths, `~`-home
1601
+ // paths, relative paths, drive-prefixed Windows paths, or file-URI
1602
+ // strings. Everything else (commands, English) is dropped, and locations
1603
+ // is omitted entirely when no candidate survives.
1604
+ // Path-shape predicate: accept anything that begins with a POSIX absolute
1605
+ // path (`/...`), home (`~/...` or `~`), relative dot (`./...`, `../...`,
1606
+ // or a bare `.`), drive-prefixed Windows path (`C:\...`, `C:/...`), or a
1607
+ // `file:` URI. Also accept simple relative names that contain a slash
1608
+ // (e.g. `etc/os-release`, `subdir/file.json`) — these are common in
1609
+ // playbook artifact source fields. Reject anything with internal
1610
+ // whitespace (commands like `uname -r`, prose like `kpatch list || ls
1611
+ // /sys/kernel/livepatch`) or that looks like a sentence.
1612
+ function looksLikePath(src) {
1613
+ if (typeof src !== 'string') return false;
1614
+ const trimmed = src.trim();
1615
+ if (!trimmed) return false;
1616
+ if (/\s/.test(trimmed)) return false;
1617
+ if (/^file:/i.test(trimmed)) return true;
1618
+ if (/^[A-Za-z]:[/\\]/.test(trimmed)) return true; // Windows drive
1619
+ if (/^[/~]/.test(trimmed)) return true; // POSIX abs / home
1620
+ if (/^\.\.?(?:[/\\]|$)/.test(trimmed)) return true; // relative dot
1621
+ if (/^[A-Za-z0-9_.+-]+[/\\][^\s]+$/.test(trimmed)) return true; // bare relative path
1622
+ return false;
1623
+ }
1543
1624
  function sarifLocationsForIndicator(playbook, indicator) {
1625
+ void indicator;
1544
1626
  const arts = (playbook.phases?.look?.artifacts) || [];
1545
1627
  const candidates = arts
1546
1628
  .map(a => a && (a.source || a.air_gap_alternative))
1547
1629
  .filter(Boolean)
1548
1630
  .map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
1549
- .filter(src => src && !/^https?:/i.test(src));
1631
+ .filter(src => src && !/^https?:/i.test(src))
1632
+ .filter(looksLikePath);
1550
1633
  if (!candidates.length) return null;
1551
1634
  return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
1552
1635
  }
1553
1636
 
1554
- function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId) {
1637
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt) {
1555
1638
  const playbookSlug = urnSlug(playbook._meta.id);
1556
1639
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1640
+ // audit W P2-B: pin one `now` value per bundle build (and accept an
1641
+ // upstream-provided issuedAt) so multi-format emit produces identical
1642
+ // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
1643
+ // building several formats from the same run. Without the parameter,
1644
+ // each invocation crystallised a fresh `Date.now()` and bundle_body
1645
+ // versus bundles_by_format[primary] would diverge on milliseconds.
1646
+ const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
1557
1647
 
1558
1648
  // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
1559
1649
  // catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
@@ -1571,14 +1661,30 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1571
1661
  name: productName,
1572
1662
  product_identification_helper: { purl: productPurl }
1573
1663
  }];
1664
+ // audit W P1-A: `fixed` product_status MUST reflect operator-supplied VEX
1665
+ // disposition (vex_status === 'fixed' — see analyze() F17), not the
1666
+ // catalog's global `live_patch_available` flag. The catalog flag means
1667
+ // "vendor publishes a live-patch in the world", not "operator deployed
1668
+ // it on this host". Pre-fix the CSAF emitter declared every
1669
+ // live-patchable CVE as fixed regardless of whether the operator's
1670
+ // evidence actually showed the patch applied, producing CSAF documents
1671
+ // that lied to downstream NVD / Red Hat dashboards. When
1672
+ // live_patch_available is the only signal, status stays known_affected
1673
+ // and the live-patch route is surfaced as a `vendor_fix` remediation.
1574
1674
  const cveVulns = analyze.matched_cves.map(c => {
1575
- const isAffected = c.live_patch_available !== true;
1675
+ const isFixed = c.vex_status === 'fixed';
1676
+ const remediations = [{
1677
+ category: 'vendor_fix',
1678
+ details: validate.selected_remediation?.description
1679
+ || (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
+ product_ids: [productId],
1681
+ }];
1576
1682
  return {
1577
1683
  cve: c.cve_id,
1578
1684
  scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
1579
1685
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
1580
- remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.', product_ids: [productId] }],
1581
- product_status: isAffected ? { known_affected: [productId] } : { fixed: [productId] }
1686
+ remediations,
1687
+ product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
1582
1688
  };
1583
1689
  });
1584
1690
  const indicatorVulns = indicatorHits.map(i => ({
@@ -1587,22 +1693,35 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1587
1693
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1588
1694
  product_status: { known_affected: [productId] }
1589
1695
  }));
1590
- const gapVulns = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1591
- ids: [{ system_name: 'exceptd-framework-gap', text: `${g.framework}:${g.claimed_control || `gap-${idx}`}` }],
1592
- notes: [
1593
- { category: 'description', text: g.actual_gap || `Framework gap in ${g.framework} ${g.claimed_control || ''}` },
1594
- { category: 'general', text: g.claimed_control ? `Claimed control: ${g.claimed_control}` : null },
1595
- ].filter(n => n.text),
1596
- remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control, product_ids: [productId] }] : [],
1597
- product_status: { under_investigation: [productId] }
1598
- }));
1599
- const now = new Date().toISOString();
1696
+ // audit W P2-D: framework-gap entries used to ride in `vulnerabilities[]`
1697
+ // with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
1698
+ // `system_name` slot is reserved for recognised vulnerability tracking
1699
+ // authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
1700
+ // every downstream CSAF consumer (NVD ingester, Red Hat dashboard,
1701
+ // ENISA validator) flagged every run for unknown ids and rendered
1702
+ // false-positive advisories at the framework_gap_mapping length. Now
1703
+ // framework gaps land in `document.notes[]` with `category: details`
1704
+ // where they belong as advisory context, not pseudo-CVEs.
1705
+ const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
1706
+ const lines = [
1707
+ `Framework: ${g.framework}`,
1708
+ g.claimed_control ? `Claimed control: ${g.claimed_control}` : null,
1709
+ g.actual_gap ? `Gap: ${g.actual_gap}` : null,
1710
+ g.required_control ? `Required: ${g.required_control}` : null,
1711
+ ].filter(Boolean);
1712
+ return {
1713
+ category: 'details',
1714
+ title: `Framework gap ${idx + 1}: ${g.framework}${g.claimed_control ? ' / ' + g.claimed_control : ''}`,
1715
+ text: lines.join('\n'),
1716
+ };
1717
+ });
1600
1718
  return {
1601
1719
  document: {
1602
1720
  category: 'csaf_security_advisory',
1603
1721
  csaf_version: '2.0',
1604
1722
  publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
1605
1723
  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,
1606
1725
  tracking: {
1607
1726
  // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
1608
1727
  // from run() via close()) so attestation file names, OpenVEX
@@ -1619,7 +1738,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1619
1738
  }
1620
1739
  },
1621
1740
  product_tree: { full_product_names: fullProductNames },
1622
- vulnerabilities: [...cveVulns, ...indicatorVulns, ...gapVulns],
1741
+ vulnerabilities: [...cveVulns, ...indicatorVulns],
1623
1742
  exceptd_extension: {
1624
1743
  classification: analyze._detect_classification,
1625
1744
  rwep: analyze.rwep,
@@ -1712,11 +1831,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1712
1831
  rules: [...cveRules, ...indicatorRules, ...gapRules],
1713
1832
  } },
1714
1833
  results: [...cveResults, ...indicatorResults, ...gapResults],
1715
- invocations: [{ executionSuccessful: true, properties: {
1834
+ invocations: [{ executionSuccessful: true, properties: stripNulls({
1835
+ // audit W P3-A: apply the B7 stripNulls contract here too — the
1836
+ // `remediation` field is null for any run that didn't surface a
1837
+ // selected_remediation, and SARIF viewers render null property
1838
+ // values as visible empty rows. Same helper as the result
1839
+ // property bags above.
1716
1840
  playbook: playbook._meta.id, classification: analyze._detect_classification || 'unknown',
1717
1841
  rwep_adjusted: analyze.rwep?.adjusted || 0,
1718
1842
  remediation: validate.selected_remediation?.id || null,
1719
- } }],
1843
+ }) }],
1720
1844
  }]
1721
1845
  };
1722
1846
  }
@@ -1737,7 +1861,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1737
1861
  // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
1738
1862
  // they pass IRI validation in downstream VEX consumers.
1739
1863
  if (format === 'openvex' || format === 'openvex-0.2.0') {
1740
- const issued = new Date().toISOString();
1864
+ // audit W P2-B: reuse the bundle-wide `now` so OpenVEX `timestamp`
1865
+ // aligns with CSAF `document.tracking.initial_release_date` when both
1866
+ // formats are emitted in the same close() pass. Pre-fix each format
1867
+ // crystallised its own Date.now() value, and the two bundles in
1868
+ // bundles_by_format disagreed on milliseconds.
1869
+ const issued = now;
1741
1870
  const productEntry = {
1742
1871
  '@id': productPurl,
1743
1872
  subcomponents: [{ '@id': productPurl }],
@@ -1752,6 +1881,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1752
1881
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
1753
1882
  return fallback;
1754
1883
  };
1884
+ // audit W P1-A: same `vex_status === 'fixed'` correctness rule as the
1885
+ // CSAF emitter. The catalog `live_patch_available` flag is a global
1886
+ // "vendor publishes a live-patch" signal, not an operator-host
1887
+ // disposition. Treating it as `status: fixed` made OpenVEX statements
1888
+ // claim resolution that the operator hadn't actually attested to.
1889
+ // VEX consumers downstream of CISA / SBOM / supply-chain pipelines
1890
+ // treat `fixed` as authoritative — emitting it without operator
1891
+ // attestation is a downstream-misleading bug. Now the OpenVEX
1892
+ // statement says `affected` (with action_statement pointing to the
1893
+ // remediation, which may itself be the vendor live-patch route) unless
1894
+ // the operator declared `vex_status: fixed` on the matched CVE.
1755
1895
  const cveStatements = analyze.matched_cves.map(c => {
1756
1896
  const stmt = {
1757
1897
  vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
@@ -1759,11 +1899,13 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1759
1899
  timestamp: issued,
1760
1900
  impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
1761
1901
  };
1762
- if (c.live_patch_available) {
1902
+ if (c.vex_status === 'fixed') {
1763
1903
  stmt.status = 'fixed';
1764
1904
  } else {
1765
1905
  stmt.status = 'affected';
1766
- stmt.action_statement = actionStatementFor('Apply remediation from validate phase.');
1906
+ stmt.action_statement = actionStatementFor(c.live_patch_available
1907
+ ? 'Vendor publishes a live-patch — see catalog `live_patch_tools` and apply, then re-attest.'
1908
+ : 'Apply remediation from validate phase.');
1767
1909
  }
1768
1910
  return stmt;
1769
1911
  });
@@ -2104,6 +2246,20 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2104
2246
  // non-fatal anomalies surfaced into analyze.runtime_errors[].
2105
2247
  const runErrors = [];
2106
2248
  cachedRunOpts._runErrors = runErrors;
2249
+ // U REG-1: normalizeSubmission may push structured errors (e.g.
2250
+ // signal_overrides_invalid) onto submission._runErrors. Pre-fix these were
2251
+ // stranded — they never reached the run-level accumulator that analyze()
2252
+ // slices into runtime_errors[], so F20's "analyze surfaces all runtime
2253
+ // errors" contract was silently broken. Splice the pre-run errors into
2254
+ // the run-level accumulator and strip the field off the submission so it
2255
+ // doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
2256
+ // submission and a non-deterministic _runErrors would change it).
2257
+ if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
2258
+ runErrors.push(...agentSubmission._runErrors);
2259
+ }
2260
+ if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
2261
+ delete agentSubmission._runErrors;
2262
+ }
2107
2263
  // E6: phases the runner should SKIP execution for, based on skip_phase
2108
2264
  // preconditions surfaced in preflight.issues.
2109
2265
  const skipPhases = new Set();
package/lib/prefetch.js CHANGED
@@ -237,6 +237,26 @@ async function withIndexLock(cacheDir, mutator) {
237
237
  // raised when the other process is mid-unlink). Treat both as
238
238
  // "lock held, back off" rather than a fatal error.
239
239
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
240
+ // T P1-1: PID-liveness check. Same pattern as withCatalogLock in
241
+ // lib/refresh-external.js — read the lockfile's PID, probe with
242
+ // process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
243
+ // EPERM → holder alive (different user), keep waiting. The mtime
244
+ // fallback below covers malformed / unreadable lockfiles.
245
+ let reclaimedByPid = false;
246
+ try {
247
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
248
+ const pid = Number.parseInt(raw, 10);
249
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
250
+ try {
251
+ process.kill(pid, 0);
252
+ } catch (probeErr) {
253
+ if (probeErr && probeErr.code === "ESRCH") {
254
+ try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
255
+ }
256
+ }
257
+ }
258
+ } catch {}
259
+ if (reclaimedByPid) continue;
240
260
  try {
241
261
  const stat = fs.statSync(lockPath);
242
262
  if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
@@ -394,11 +414,20 @@ async function prefetch(options = {}) {
394
414
  const targetPath = entryPath(opts.cacheDir, item.source, item.id);
395
415
  const dir = path.dirname(targetPath);
396
416
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
397
- // v0.12.12 C4: atomic write of the payload. A concurrent reader
398
- // (refresh --from-cache running in parallel) sees the prior
399
- // payload in full or the new payload in full, never a partial
400
- // buffer.
401
- writeFileAtomic(targetPath, JSON.stringify(res.json, null, 2) + "\n");
417
+ const body = JSON.stringify(res.json, null, 2) + "\n";
418
+ // T P1-3: stage the payload to a same-volume tmp file BEFORE
419
+ // attempting to acquire the index lock. If withIndexLock fails
420
+ // (timeout after MAX_RETRIES), we want the partially-completed
421
+ // download discarded not left on disk as an orphan payload
422
+ // with no index entry. Air-gap operators feed off `readCached`,
423
+ // which consults the index; an unindexed payload silently becomes
424
+ // junk taking cache space. Pattern: stage → lock → rename+index
425
+ // → release. The rename is atomic same-volume; if it fails inside
426
+ // the lock we clean up the tmp file. If we never reach the rename
427
+ // (lock acquisition throws), the tmp file is unlinked in the
428
+ // catch block below.
429
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
430
+ fs.writeFileSync(tmpPath, body);
402
431
  const meta = {
403
432
  fetched_at: new Date().toISOString(),
404
433
  etag: res.etag,
@@ -406,16 +435,34 @@ async function prefetch(options = {}) {
406
435
  url: item.url,
407
436
  sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
408
437
  };
409
- idx.entries[entryKey(item.source, item.id)] = meta;
410
- // v0.12.12 C2: persist this entry's metadata to _index.json under
411
- // lock immediately, merging with whatever the on-disk index has
412
- // (another concurrent prefetch may have written sibling entries).
413
- // Without this, only the in-memory idx is updated; the final
414
- // saveIndex() would overwrite a sibling run's writes.
415
- await withIndexLock(opts.cacheDir, (current) => {
416
- current.entries[entryKey(item.source, item.id)] = meta;
417
- return current;
418
- });
438
+ try {
439
+ // v0.12.12 C2: persist this entry's metadata to _index.json under
440
+ // lock immediately, merging with whatever the on-disk index has
441
+ // (another concurrent prefetch may have written sibling entries).
442
+ // Inside the lock we also rename the staged tmp final path so
443
+ // a concurrent reader sees the new payload + new index entry as
444
+ // an atomic pair.
445
+ await withIndexLock(opts.cacheDir, (current) => {
446
+ try {
447
+ fs.renameSync(tmpPath, targetPath);
448
+ } catch (renameErr) {
449
+ // Surface as a failure to mutator: throwing here aborts the
450
+ // lock's write step. We re-throw to the outer catch which
451
+ // will increment errors.
452
+ throw renameErr;
453
+ }
454
+ current.entries[entryKey(item.source, item.id)] = meta;
455
+ return current;
456
+ });
457
+ // Mirror the entry into the in-memory idx for callers that read
458
+ // it later in this run (e.g. the final saveIndex merge).
459
+ idx.entries[entryKey(item.source, item.id)] = meta;
460
+ } catch (lockErr) {
461
+ // Lock failure OR rename-inside-lock failure — unlink the staged
462
+ // tmp so the cache directory does not accumulate orphans.
463
+ try { fs.unlinkSync(tmpPath); } catch {}
464
+ throw lockErr;
465
+ }
419
466
  result.fetched++;
420
467
  result.by_source[item.source].fetched++;
421
468
  log(` [${item.source}] ${item.id} — ok`);
@@ -938,6 +938,33 @@ async function withCatalogLock(catalogPath, mutator) {
938
938
  // Windows the same race surfaces as EPERM (sharing-violation raised
939
939
  // when the holder is mid-unlink). Treat both as "lock held, back off."
940
940
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
941
+ // T P1-1: PID-liveness check before falling back to mtime. The
942
+ // lockfile already contains String(process.pid) of the holder; parse
943
+ // it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
944
+ // dead — reclaim immediately rather than waiting STALE_LOCK_MS for
945
+ // the mtime gate to expire. EPERM (holder alive, different user) is
946
+ // treated as "alive, keep waiting." The mtime gate remains as a
947
+ // belt-and-suspenders for the case where the lockfile content is
948
+ // missing / malformed / belongs to a recycled PID. Matches the PID
949
+ // pattern in orchestrator/index.js _acquireWatchLock and
950
+ // lib/playbook-runner.js pidAlive().
951
+ let reclaimedByPid = false;
952
+ try {
953
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
954
+ const pid = Number.parseInt(raw, 10);
955
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
956
+ try {
957
+ process.kill(pid, 0);
958
+ // holder alive
959
+ } catch (probeErr) {
960
+ if (probeErr && probeErr.code === "ESRCH") {
961
+ try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
962
+ }
963
+ // EPERM and anything else: treat as alive, fall through to mtime/sleep.
964
+ }
965
+ }
966
+ } catch {} // unreadable lockfile — proceed to mtime fallback
967
+ if (reclaimedByPid) continue;
941
968
  // Stale-lock check before sleeping — a long-dead holder shouldn't keep
942
969
  // us waiting MAX_RETRIES * backoff before we recover.
943
970
  try {