@blamejs/exceptd-skills 0.12.23 → 0.12.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -4
- package/CHANGELOG.md +190 -3
- package/README.md +14 -1
- package/bin/exceptd.js +584 -166
- package/data/_indexes/_meta.json +31 -31
- package/data/_indexes/activity-feed.json +45 -45
- package/data/_indexes/catalog-summaries.json +19 -19
- package/data/_indexes/chains.json +320 -0
- package/data/_indexes/currency.json +9 -9
- package/data/_indexes/frequency.json +39 -2
- package/data/_indexes/jurisdiction-clocks.json +2 -2
- package/data/_indexes/jurisdiction-map.json +3 -1
- package/data/_indexes/section-offsets.json +396 -396
- package/data/_indexes/summary-cards.json +3 -3
- package/data/_indexes/token-budget.json +73 -73
- package/data/atlas-ttps.json +491 -19
- package/data/attack-techniques.json +198 -84
- package/data/cve-catalog.json +1309 -9
- package/data/exploit-availability.json +300 -10
- package/data/framework-control-gaps.json +395 -1
- package/data/global-frameworks.json +44 -19
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/secrets.json +25 -1
- package/data/rfc-references.json +93 -1
- package/data/zeroday-lessons.json +475 -13
- package/lib/auto-discovery.js +26 -2
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +68 -1
- package/lib/playbook-runner.js +321 -46
- package/lib/prefetch.js +113 -0
- package/lib/refresh-external.js +190 -8
- package/lib/refresh-network.js +35 -8
- package/lib/schemas/cve-catalog.schema.json +31 -4
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +41 -0
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/verify.js +20 -4
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +59 -59
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- package/scripts/verify-shipped-tarball.js +9 -0
- package/skills/ai-attack-surface/skill.md +11 -2
- package/skills/ai-c2-detection/skill.md +3 -1
- package/skills/ai-risk-management/skill.md +3 -1
- package/skills/api-security/skill.md +4 -0
- package/skills/attack-surface-pentest/skill.md +1 -0
- package/skills/container-runtime-security/skill.md +3 -1
- package/skills/dlp-gap-analysis/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +1 -1
- package/skills/kernel-lpe-triage/skill.md +6 -1
- package/skills/mcp-agent-trust/skill.md +7 -2
- package/skills/mlops-security/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +4 -2
- package/skills/sector-financial/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +1 -1
- package/skills/supply-chain-integrity/skill.md +3 -1
- package/skills/threat-model-currency/skill.md +1 -1
- package/skills/webapp-security/skill.md +2 -0
- package/skills/zeroday-gap-learn/skill.md +2 -2
package/lib/playbook-runner.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
|
|
1986
|
-
});
|
|
1987
|
-
}
|
|
2195
|
+
pushRunError(runOpts._runErrors, {
|
|
2196
|
+
kind: 'bundle_cve_id_missing',
|
|
2197
|
+
reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
|
|
2198
|
+
remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
|
|
2199
|
+
}, { dedupeKey: () => 'singleton' });
|
|
1988
2200
|
}
|
|
1989
2201
|
return null;
|
|
1990
2202
|
}
|
|
@@ -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
|
-
|
|
2008
|
-
|
|
2219
|
+
// Strict CVSS 3.1 parse (lib/scoring.parseCvss31Vector). The pre-fix
|
|
2220
|
+
// permissive regex accepted any CVSS:X.Y/... prefix and would emit a
|
|
2221
|
+
// cvss_v3 block keyed off a malformed vector — strict validators
|
|
2222
|
+
// (BSI CSAF Validator, ENISA dashboard) then reject the whole
|
|
2223
|
+
// document. Strict parse failures surface as a `csaf_cvss_invalid`
|
|
2224
|
+
// runtime_error, the cvss_v3 block is omitted, and the rest of the
|
|
2225
|
+
// vulnerability entry (product_status, remediations, etc.) survives.
|
|
2226
|
+
let strictParse = null;
|
|
2227
|
+
if (hasCvss) {
|
|
2228
|
+
strictParse = scoring.parseCvss31Vector(c.cvss_vector);
|
|
2229
|
+
}
|
|
2230
|
+
const vectorVersion = hasCvss ? (strictParse && strictParse.version) : null;
|
|
2231
|
+
const cvssV3Eligible = !!(hasCvss && strictParse && strictParse.ok);
|
|
2009
2232
|
if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
|
|
2016
|
-
});
|
|
2017
|
-
}
|
|
2233
|
+
pushRunError(runOpts._runErrors, {
|
|
2234
|
+
kind: 'csaf_cvss_invalid',
|
|
2235
|
+
cve_id: c.cve_id,
|
|
2236
|
+
reason: (strictParse && strictParse.reason) || 'cvss_vector failed strict CVSS 3.1 parse',
|
|
2237
|
+
}, { dedupeKey: e => e.cve_id || 'unknown' });
|
|
2018
2238
|
}
|
|
2019
2239
|
const scores = cvssV3Eligible ? [{
|
|
2020
2240
|
products: [productId],
|
|
@@ -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
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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:
|
|
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:
|
|
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':
|
|
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
|
|
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
|
-
|
|
2765
|
-
|
|
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
|
-
|
|
2953
|
-
|
|
3216
|
+
// Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
|
|
3217
|
+
// (same source+expr regex error firing N times per playbook would
|
|
3218
|
+
// otherwise spam runtime_errors). The original `_regex_eval_error`
|
|
3219
|
+
// payload is preserved for backward compatibility.
|
|
3220
|
+
const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
|
|
3221
|
+
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
3222
|
+
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
3223
|
+
: null;
|
|
3224
|
+
if (target) {
|
|
3225
|
+
pushRunError(target, taggedErr, {
|
|
3226
|
+
dedupeKey: x => `${x.source || ''}::${x.expr || ''}`,
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
2954
3229
|
return false;
|
|
2955
3230
|
}
|
|
2956
3231
|
}
|