@blamejs/exceptd-skills 0.12.23 → 0.12.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,7 @@ const fs = require('fs');
47
47
  const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
+ const scoring = require('./scoring');
50
51
 
51
52
  // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
52
53
  // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
@@ -89,6 +90,61 @@ const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data',
89
90
  // platform integration, not the runner.
90
91
  const _activeRuns = new Set();
91
92
 
93
+ // Bounded push into a runtime_errors array with per-kind caps, optional
94
+ // per-kind dedupe, and a total cap. A long-running detect/analyze loop that
95
+ // rejects a malformed catalog entry on every iteration would otherwise let
96
+ // runtime_errors grow unbounded and balloon the bundle output. When the cap
97
+ // fires the helper records a `_truncated` sentinel so downstream consumers
98
+ // see the drop without needing to compare cardinalities.
99
+ //
100
+ // opts.cap per-kind cap (default 100)
101
+ // opts.totalCap total array cap (default 1000)
102
+ // opts.dedupeKey optional fn(entry) returning a string key. When supplied,
103
+ // a push with the same (kind, dedupeKey) tuple is skipped.
104
+ //
105
+ // Returns true if the entry was pushed, false otherwise (capped or deduped).
106
+ function pushRunError(arr, entry, opts) {
107
+ if (!Array.isArray(arr) || !entry || typeof entry !== 'object') return false;
108
+ opts = opts || {};
109
+ const cap = typeof opts.cap === 'number' ? opts.cap : 100;
110
+ const totalCap = typeof opts.totalCap === 'number' ? opts.totalCap : 1000;
111
+ const kind = entry.kind;
112
+ if (typeof opts.dedupeKey === 'function' && kind) {
113
+ const dk = opts.dedupeKey(entry);
114
+ if (arr.some(e => e && e.kind === kind && opts.dedupeKey(e) === dk)) {
115
+ return false;
116
+ }
117
+ }
118
+ const total = arr.length;
119
+ const kindCount = kind ? arr.filter(e => e && e.kind === kind).length : 0;
120
+ const overTotal = total >= totalCap;
121
+ const overKind = kind && kindCount >= cap;
122
+ if (overTotal || overKind) {
123
+ const reason = overKind ? 'per-kind-cap' : 'total-cap';
124
+ const existing = arr.find(e => e && e.kind === '_truncated' && e.truncated_kind === (kind || null) && e.reason === reason);
125
+ if (existing) {
126
+ existing.dropped = (existing.dropped || 0) + 1;
127
+ } else {
128
+ arr.push({ kind: '_truncated', truncated_kind: kind || null, dropped: 1, reason });
129
+ }
130
+ return false;
131
+ }
132
+ arr.push(entry);
133
+ return true;
134
+ }
135
+
136
+ // Unwrap a legacy `{ _regex_eval_error: { source, expr, message } }` record
137
+ // into the flat fields pushRunError dedupes on. Used by evalCondition()'s
138
+ // regex-failure path so per-(source, expr) duplicates collapse to one entry
139
+ // plus a `_truncated` sentinel when the cap fires.
140
+ function _regexErrorPayload(rec) {
141
+ if (rec && typeof rec === 'object' && rec._regex_eval_error) {
142
+ const { source, expr, message } = rec._regex_eval_error;
143
+ return { source, expr, message, _regex_eval_error: rec._regex_eval_error };
144
+ }
145
+ return { _regex_eval_error: rec };
146
+ }
147
+
92
148
  // --- catalog access ---
93
149
 
94
150
  function listPlaybooks() {
@@ -618,11 +674,11 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
618
674
  verdict = 'inconclusive';
619
675
  fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
620
676
  if (runOpts && Array.isArray(runOpts._runErrors)) {
621
- runOpts._runErrors.push({
677
+ pushRunError(runOpts._runErrors, {
622
678
  kind: 'fp_attestation_threw',
623
679
  indicator_id: ind.id,
624
680
  message: (e && e.message) ? String(e.message) : String(e),
625
- });
681
+ }, { dedupeKey: e => e.indicator_id || '' });
626
682
  }
627
683
  }
628
684
  }
@@ -675,12 +731,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
675
731
  const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
676
732
  if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
677
733
  if (runOpts && Array.isArray(runOpts._runErrors)) {
678
- runOpts._runErrors.push({
734
+ pushRunError(runOpts._runErrors, {
679
735
  kind: 'classification_override_invalid',
680
736
  supplied: rawOverride,
681
737
  allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
682
738
  reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
683
- });
739
+ }, { dedupeKey: e => String(e.supplied) });
684
740
  }
685
741
  }
686
742
  const override = overrideIsInAllowlist ? rawOverride : undefined;
@@ -706,7 +762,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
706
762
  const attempted = override; // record what the operator submitted, not the mapped form
707
763
  classification = substituted;
708
764
  if (runOpts && Array.isArray(runOpts._runErrors)) {
709
- runOpts._runErrors.push({
765
+ pushRunError(runOpts._runErrors, {
710
766
  kind: 'classification_override_blocked',
711
767
  attempted,
712
768
  substituted,
@@ -714,7 +770,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
714
770
  indicators_with_unsatisfied_fp_checks: indicatorResults
715
771
  .filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
716
772
  .map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
717
- });
773
+ }, { dedupeKey: e => String(e.attempted) });
718
774
  }
719
775
  }
720
776
  } else if (hasDeterministicHit || hasHighConfHit) {
@@ -827,7 +883,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
827
883
  try { return xref.byCve(id); }
828
884
  catch (e) {
829
885
  if (Array.isArray(runOpts._runErrors)) {
830
- runOpts._runErrors.push({ kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) });
886
+ pushRunError(runOpts._runErrors, { kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.cve_id || '' });
831
887
  }
832
888
  return { found: false, cve_id: id };
833
889
  }
@@ -1037,7 +1093,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1037
1093
  } else {
1038
1094
  blastRadiusSignal = 'rejected';
1039
1095
  if (Array.isArray(runOpts._runErrors)) {
1040
- runOpts._runErrors.push({ kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' });
1096
+ pushRunError(runOpts._runErrors, { kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' }, { dedupeKey: e => String(e.supplied) });
1041
1097
  }
1042
1098
  }
1043
1099
  }
@@ -1143,11 +1199,11 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1143
1199
  if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
1144
1200
  if (theaterVerdict !== undefined && theaterVerdict !== null && !_theaterAllowlist.has(theaterVerdict)) {
1145
1201
  if (Array.isArray(runOpts._runErrors)) {
1146
- runOpts._runErrors.push({
1202
+ pushRunError(runOpts._runErrors, {
1147
1203
  kind: 'theater_verdict_invalid',
1148
1204
  supplied: theaterVerdict,
1149
1205
  allowed: Array.from(_theaterAllowlist),
1150
- });
1206
+ }, { dedupeKey: e => String(e.supplied) });
1151
1207
  }
1152
1208
  theaterVerdict = undefined;
1153
1209
  }
@@ -1517,7 +1573,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1517
1573
  try { findingShape = analyzeFindingShape(analyzeResult); }
1518
1574
  catch (e) {
1519
1575
  if (Array.isArray(runOpts._runErrors)) {
1520
- runOpts._runErrors.push({ kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) });
1576
+ pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.message || '' });
1521
1577
  }
1522
1578
  findingShape = {};
1523
1579
  }
@@ -1734,6 +1790,121 @@ function analyzeFindingShape(a) {
1734
1790
  };
1735
1791
  }
1736
1792
 
1793
+ // Route a vulnerability identifier to its registry-specific URN namespace.
1794
+ // CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
1795
+ // unrecognised prefixes route to the `urn:exceptd:advisory:` private
1796
+ // namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
1797
+ function vulnIdToUrn(id) {
1798
+ const slug = urnSlug(id);
1799
+ if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${slug}`;
1800
+ if (/^CVE-/i.test(id)) return `urn:cve:${slug}`;
1801
+ if (/^GHSA-/i.test(id)) return `urn:ghsa:${slug}`;
1802
+ if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${slug}`;
1803
+ if (/^MAL-/i.test(id)) return `urn:malicious-package:${slug}`;
1804
+ return `urn:exceptd:advisory:${slug}`;
1805
+ }
1806
+
1807
+ // Build a CSAF product_tree.branches[] tree (vendor → product_name →
1808
+ // product_version). Sources of vendor/product/version, in priority order:
1809
+ // (1) catalog entry `affected_products: [{ vendor, product, version }]`
1810
+ // (2) heuristic parse of `affected_components[]` strings — accepts
1811
+ // `vendor/product@version` and `vendor product version` shapes.
1812
+ // Unparseable component strings emit a `csaf_branch_unparseable` runtime
1813
+ // error and are dropped from the tree. Sort alphabetical at each level so
1814
+ // the output is deterministic across runs.
1815
+ //
1816
+ // Returns `{ branches, productIds }`. productIds is a stable enumeration
1817
+ // CSAFPID-0..N keyed by (vendor, product, version) insertion order so other
1818
+ // emit paths can reference the leaf products by id later.
1819
+ function buildCsafBranches(matchedCves, runOpts) {
1820
+ // Build a (vendor → product → Set<version>) map.
1821
+ const tree = new Map();
1822
+ const addLeaf = (vendor, product, version) => {
1823
+ if (!vendor || !product || !version) return;
1824
+ if (!tree.has(vendor)) tree.set(vendor, new Map());
1825
+ const products = tree.get(vendor);
1826
+ if (!products.has(product)) products.set(product, new Set());
1827
+ products.get(product).add(version);
1828
+ };
1829
+
1830
+ // Heuristic parser. Returns { vendor, product, version } or null.
1831
+ const parseComponentString = (s) => {
1832
+ if (typeof s !== 'string' || !s.trim()) return null;
1833
+ const trimmed = s.trim();
1834
+ // `vendor/product@version`
1835
+ let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
1836
+ if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
1837
+ // `vendor product version` — exactly three whitespace-separated tokens
1838
+ // where the last token starts with a digit or `v\d`.
1839
+ const parts = trimmed.split(/\s+/);
1840
+ if (parts.length >= 3) {
1841
+ const last = parts[parts.length - 1];
1842
+ if (/^v?\d/.test(last)) {
1843
+ return { vendor: parts[0], product: parts.slice(1, -1).join(' '), version: last };
1844
+ }
1845
+ }
1846
+ return null;
1847
+ };
1848
+
1849
+ for (const c of matchedCves || []) {
1850
+ if (Array.isArray(c.affected_products) && c.affected_products.length > 0) {
1851
+ for (const ap of c.affected_products) {
1852
+ if (ap && typeof ap === 'object' && ap.vendor && ap.product && ap.version) {
1853
+ addLeaf(String(ap.vendor), String(ap.product), String(ap.version));
1854
+ }
1855
+ }
1856
+ continue;
1857
+ }
1858
+ const components = Array.isArray(c.affected_components) ? c.affected_components
1859
+ : (Array.isArray(c.affected_versions) ? c.affected_versions : []);
1860
+ for (const comp of components) {
1861
+ const parsed = parseComponentString(comp);
1862
+ if (parsed) {
1863
+ addLeaf(parsed.vendor, parsed.product, parsed.version);
1864
+ } else if (typeof comp === 'string' && comp.trim() && runOpts && Array.isArray(runOpts._runErrors)) {
1865
+ pushRunError(runOpts._runErrors, {
1866
+ kind: 'csaf_branch_unparseable',
1867
+ component: String(comp),
1868
+ cve_id: c.cve_id || null,
1869
+ }, { dedupeKey: e => `${e.cve_id || ''}::${e.component}` });
1870
+ }
1871
+ }
1872
+ }
1873
+
1874
+ // Sort + emit.
1875
+ const productIds = [];
1876
+ let pidCounter = 0;
1877
+ const vendors = Array.from(tree.keys()).sort();
1878
+ const branches = vendors.map(vendor => {
1879
+ const products = tree.get(vendor);
1880
+ const productNames = Array.from(products.keys()).sort();
1881
+ return {
1882
+ category: 'vendor',
1883
+ name: vendor,
1884
+ branches: productNames.map(product => {
1885
+ const versions = Array.from(products.get(product)).sort();
1886
+ return {
1887
+ category: 'product_name',
1888
+ name: product,
1889
+ branches: versions.map(version => {
1890
+ const pid = `CSAFPID-${pidCounter++}`;
1891
+ productIds.push({ vendor, product, version, product_id: pid });
1892
+ return {
1893
+ category: 'product_version',
1894
+ name: version,
1895
+ product: {
1896
+ name: `${vendor}/${product}@${version}`,
1897
+ product_id: pid,
1898
+ },
1899
+ };
1900
+ }),
1901
+ };
1902
+ }),
1903
+ };
1904
+ });
1905
+ return { branches, productIds };
1906
+ }
1907
+
1737
1908
  // Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
1738
1909
  // Empty input → 'unknown' so we never emit zero-length segments.
1739
1910
  function urnSlug(s) {
@@ -1866,6 +2037,50 @@ function sanitizeOperatorText(s) {
1866
2037
  return cps.slice(0, 256).join('');
1867
2038
  }
1868
2039
 
2040
+ /**
2041
+ * Build a single evidence bundle in the requested machine-readable format.
2042
+ *
2043
+ * Positional contract — the seven phase functions cache the closure over
2044
+ * `playbook`, `analyze`, and `validate` so consumers don't reach into the
2045
+ * runner's intermediate state. Library callers that bypass close() (e.g.
2046
+ * external dashboards re-rendering a stored attestation) MUST honor the
2047
+ * same parameter order, names, and types.
2048
+ *
2049
+ * @param {string} format Output dialect. One of: 'csaf-2.0',
2050
+ * 'sarif' / 'sarif-2.1.0', 'openvex' /
2051
+ * 'openvex-0.2.0', 'summary', 'markdown'.
2052
+ * Unknown values return a stub with
2053
+ * supported_formats so callers can branch.
2054
+ * @param {object} playbook Playbook record loaded via loadPlaybook().
2055
+ * Provides _meta.id / version, domain.name,
2056
+ * phases.look.artifacts (for SARIF
2057
+ * locations), and feeds_into / mutex.
2058
+ * @param {object} analyze Output of analyze(). Carries matched_cves,
2059
+ * _detect_indicators, framework_gap_mapping,
2060
+ * rwep, blast_radius_score,
2061
+ * _detect_classification.
2062
+ * @param {object} validate Output of validate(). Carries
2063
+ * selected_remediation, remediation_paths,
2064
+ * evidence_requirements,
2065
+ * residual_risk_statement.
2066
+ * @param {object} agentSignals Agent-submitted signals (signal_overrides
2067
+ * merged + cleaned). Drives the OpenVEX
2068
+ * vex_status:'fixed' attestation trail and
2069
+ * the CSAF cvss_v3 score-block gate.
2070
+ * @param {string} sessionId Run session id (threaded from run()).
2071
+ * Becomes part of CSAF tracking.id,
2072
+ * OpenVEX @id, and the on-disk attestation
2073
+ * file name so all three correlate.
2074
+ * @param {string=} issuedAt Optional ISO 8601 timestamp. Pinning this
2075
+ * across multi-format emits keeps CSAF /
2076
+ * OpenVEX / SARIF agreed on milliseconds;
2077
+ * each call would otherwise crystallise a
2078
+ * fresh Date.now().
2079
+ * @param {object=} runOpts Operator / library knobs. Recognised
2080
+ * fields: operator, publisherNamespace,
2081
+ * csafStatus, tlp, _runErrors accumulator.
2082
+ * @returns {object} The requested format's document body.
2083
+ */
1869
2084
  function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
1870
2085
  runOpts = runOpts || {};
1871
2086
  const playbookSlug = urnSlug(playbook._meta.id);
@@ -1977,14 +2192,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1977
2192
  idEntry = csafIdsFor(c.cve_id);
1978
2193
  if (idEntry == null) {
1979
2194
  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
- }
2195
+ pushRunError(runOpts._runErrors, {
2196
+ kind: 'bundle_cve_id_missing',
2197
+ reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
2198
+ remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
2199
+ }, { dedupeKey: () => 'singleton' });
1988
2200
  }
1989
2201
  return null;
1990
2202
  }
@@ -2004,17 +2216,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2004
2216
  // for non-3.x vectors and surface a runtime_error so operators can
2005
2217
  // see why their CVSS data didn't make it through.
2006
2218
  const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
2007
- const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
2008
- const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
2219
+ // Strict CVSS 3.1 parse (lib/scoring.parseCvss31Vector). The pre-fix
2220
+ // permissive regex accepted any CVSS:X.Y/... prefix and would emit a
2221
+ // cvss_v3 block keyed off a malformed vector — strict validators
2222
+ // (BSI CSAF Validator, ENISA dashboard) then reject the whole
2223
+ // document. Strict parse failures surface as a `csaf_cvss_invalid`
2224
+ // runtime_error, the cvss_v3 block is omitted, and the rest of the
2225
+ // vulnerability entry (product_status, remediations, etc.) survives.
2226
+ let strictParse = null;
2227
+ if (hasCvss) {
2228
+ strictParse = scoring.parseCvss31Vector(c.cvss_vector);
2229
+ }
2230
+ const vectorVersion = hasCvss ? (strictParse && strictParse.version) : null;
2231
+ const cvssV3Eligible = !!(hasCvss && strictParse && strictParse.ok);
2009
2232
  if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
2010
- 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
- }
2233
+ pushRunError(runOpts._runErrors, {
2234
+ kind: 'csaf_cvss_invalid',
2235
+ cve_id: c.cve_id,
2236
+ reason: (strictParse && strictParse.reason) || 'cvss_vector failed strict CVSS 3.1 parse',
2237
+ }, { dedupeKey: e => e.cve_id || 'unknown' });
2018
2238
  }
2019
2239
  const scores = cvssV3Eligible ? [{
2020
2240
  products: [productId],
@@ -2106,14 +2326,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2106
2326
  // De-dupe: only push once per bundle-build pass (multi-format emit
2107
2327
  // builds CSAF once via memoization, so this fires at most once per run).
2108
2328
  if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
2109
- const already = runOpts._runErrors.some(e => e && e.kind === 'bundle_publisher_unclaimed');
2110
- if (!already) {
2111
- runOpts._runErrors.push({
2112
- kind: 'bundle_publisher_unclaimed',
2113
- 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.',
2114
- remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2115
- });
2116
- }
2329
+ pushRunError(runOpts._runErrors, {
2330
+ kind: 'bundle_publisher_unclaimed',
2331
+ reason: 'CSAF document.publisher.namespace fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Operator attribution is unclaimed on this advisory.',
2332
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2333
+ }, { dedupeKey: () => 'singleton' });
2117
2334
  }
2118
2335
 
2119
2336
  // thread the validated --operator name into
@@ -2141,6 +2358,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2141
2358
  ? runOpts.csafStatus
2142
2359
  : 'interim';
2143
2360
 
2361
+ // CSAF §3.1.4 `distribution.tlp`. Optional. When the operator supplies
2362
+ // `--tlp <label>` (threaded as runOpts.tlp), emit
2363
+ // distribution.tlp.label + distribution.text. CSAF allows omission of
2364
+ // the whole distribution block when no level is declared; the
2365
+ // pre-fix runner had no surface for this at all.
2366
+ const allowedTlp = new Set(['CLEAR', 'GREEN', 'AMBER', 'AMBER+STRICT', 'RED']);
2367
+ const csafDistribution = (runOpts.tlp && allowedTlp.has(runOpts.tlp))
2368
+ ? { tlp: { label: runOpts.tlp }, text: `TLP:${runOpts.tlp}` }
2369
+ : null;
2370
+
2144
2371
  return {
2145
2372
  document: {
2146
2373
  category: 'csaf_security_advisory',
@@ -2148,6 +2375,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2148
2375
  publisher: publisherBlock,
2149
2376
  title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
2150
2377
  notes: [...namespaceFallbackNote, ...gapNotes],
2378
+ ...(csafDistribution ? { distribution: csafDistribution } : {}),
2151
2379
  tracking: {
2152
2380
  // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
2153
2381
  // from run() via close()) so attestation file names, OpenVEX
@@ -2169,7 +2397,19 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2169
2397
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
2170
2398
  }
2171
2399
  },
2172
- product_tree: { full_product_names: fullProductNames },
2400
+ product_tree: (function () {
2401
+ // Synthesize a 3-level branches tree (vendor → product → version)
2402
+ // from catalog data. CSAF §3.1.5.1 makes branches[] strongly
2403
+ // recommended for csaf_security_advisory documents because NVD /
2404
+ // ENISA / Red Hat dashboards render the affected-product list off
2405
+ // the branches tree, not full_product_names[]. The pre-fix tree
2406
+ // emitted only the synthetic exceptd-target product and operators
2407
+ // browsing the rendered advisory saw no real-world vendor surface.
2408
+ const { branches } = buildCsafBranches(analyze.matched_cves || [], runOpts);
2409
+ const tree = { full_product_names: fullProductNames };
2410
+ if (branches.length > 0) tree.branches = branches;
2411
+ return tree;
2412
+ })(),
2173
2413
  vulnerabilities: [...cveVulns, ...indicatorVulns],
2174
2414
  exceptd_extension: {
2175
2415
  classification: analyze._detect_classification,
@@ -2273,7 +2513,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2273
2513
  rules: [...cveRules, ...indicatorRules, ...gapRules],
2274
2514
  } },
2275
2515
  results: [...cveResults, ...indicatorResults, ...gapResults],
2276
- invocations: [{ executionSuccessful: true, properties: stripNulls({
2516
+ invocations: [{ executionSuccessful: (analyze._detect_classification !== 'inconclusive'), properties: stripNulls({
2277
2517
  // Apply the stripNulls contract here too — the `remediation`
2278
2518
  // field is null for any run that didn't surface a
2279
2519
  // selected_remediation, and SARIF viewers render null property
@@ -2336,13 +2576,27 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2336
2576
  // operator declared `vex_status: fixed` on the matched CVE.
2337
2577
  const cveStatements = analyze.matched_cves.map(c => {
2338
2578
  const stmt = {
2339
- vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
2579
+ vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
2340
2580
  products: [productEntry],
2341
2581
  timestamp: issued,
2342
2582
  impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
2343
2583
  };
2344
2584
  if (c.vex_status === 'fixed') {
2345
2585
  stmt.status = 'fixed';
2586
+ // OpenVEX 0.2.0 §4.1: `fixed` is an operator-attested resolution,
2587
+ // not a global vendor flag. Augment the impact_statement with an
2588
+ // evidence trail so downstream supply-chain consumers can chase
2589
+ // the attestation back to the operator's submitted evidence.
2590
+ // Short-hash is deterministic for the same (cve_id, signals)
2591
+ // input — re-emitting the bundle for the same submission yields
2592
+ // the same trail.
2593
+ const trailSrc = canonicalStringify({
2594
+ cve_id: c.cve_id,
2595
+ vex_status: 'fixed',
2596
+ signals: agentSignals && typeof agentSignals === 'object' ? agentSignals : {},
2597
+ });
2598
+ const shortHash = crypto.createHash('sha256').update(trailSrc).digest('hex').slice(0, 16);
2599
+ stmt.impact_statement = `${stmt.impact_statement} Operator verified fixed via evidence_hash=${shortHash}.`;
2346
2600
  } else {
2347
2601
  stmt.status = 'affected';
2348
2602
  stmt.action_statement = actionStatementFor(c.live_patch_available
@@ -2467,11 +2721,11 @@ function normalizeSubmission(submission, playbook) {
2467
2721
  if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
2468
2722
  && (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
2469
2723
  if (!submission._runErrors) submission._runErrors = [];
2470
- submission._runErrors.push({
2724
+ pushRunError(submission._runErrors, {
2471
2725
  kind: 'signal_overrides_invalid',
2472
2726
  supplied_type: Array.isArray(submission.signal_overrides) ? 'array' : typeof submission.signal_overrides,
2473
2727
  reason: 'signal_overrides must be a plain object mapping indicator-id → verdict.'
2474
- });
2728
+ }, { dedupeKey: e => String(e.supplied_type) });
2475
2729
  submission = { ...submission, signal_overrides: {} };
2476
2730
  }
2477
2731
 
@@ -2761,8 +3015,18 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2761
3015
  // regex failure in the run. De-dupe by JSON shape so the analyze-time
2762
3016
  // snapshot doesn't double-count.
2763
3017
  if (runErrors.length && phases.analyze) {
2764
- const existing = new Set((phases.analyze.runtime_errors || []).map(e => JSON.stringify(e)));
2765
- const additions = runErrors.filter(e => !existing.has(JSON.stringify(e)));
3018
+ // `_truncated` sentinels are pushed by pushRunError when a per-kind
3019
+ // or total cap fires. They aggregate via in-place `dropped` increments,
3020
+ // so the same sentinel object is BOTH in the analyze snapshot AND in
3021
+ // the late-push `runErrors` ref. Skip them on the dedupe-merge pass
3022
+ // to keep the snapshot's authoritative dropped-count, rather than
3023
+ // double-stamping a second sentinel with the same `dropped` value.
3024
+ const existing = new Set(
3025
+ (phases.analyze.runtime_errors || [])
3026
+ .filter(e => !(e && e.kind === '_truncated'))
3027
+ .map(e => JSON.stringify(e))
3028
+ );
3029
+ const additions = runErrors.filter(e => !(e && e.kind === '_truncated') && !existing.has(JSON.stringify(e)));
2766
3030
  if (additions.length) {
2767
3031
  phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
2768
3032
  }
@@ -2949,8 +3213,19 @@ function evalCondition(expr, ctx, playbook) {
2949
3213
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
2950
3214
  // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
2951
3215
  // form; fall back to ctx.
2952
- if (ctx && Array.isArray(ctx._runErrors)) ctx._runErrors.push(errorRec);
2953
- else if (playbook && Array.isArray(playbook._runErrors)) playbook._runErrors.push(errorRec);
3216
+ // Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
3217
+ // (same source+expr regex error firing N times per playbook would
3218
+ // otherwise spam runtime_errors). The original `_regex_eval_error`
3219
+ // payload is preserved for backward compatibility.
3220
+ const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
3221
+ const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
3222
+ : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
3223
+ : null;
3224
+ if (target) {
3225
+ pushRunError(target, taggedErr, {
3226
+ dedupeKey: x => `${x.source || ''}::${x.expr || ''}`,
3227
+ });
3228
+ }
2954
3229
  return false;
2955
3230
  }
2956
3231
  }