@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/178.index.js +1 -1
  2. package/dist/333.index.js +283 -0
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/839.index.js +170 -0
  7. package/dist/985.index.js +140 -1
  8. package/dist/agentic-security.mjs +10 -10
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -5
  11. package/src/.agentic-security/findings.json +117732 -0
  12. package/src/.agentic-security/last-scan.json +117732 -0
  13. package/src/.agentic-security/last-scan.json.sig +1 -0
  14. package/src/.agentic-security/scan-history.json +12946 -0
  15. package/src/.agentic-security/streak.json +21 -0
  16. package/src/dataflow/.agentic-security/findings.json +6086 -0
  17. package/src/dataflow/.agentic-security/last-scan.json +6086 -0
  18. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  19. package/src/dataflow/.agentic-security/scan-history.json +250 -0
  20. package/src/dataflow/.agentic-security/streak.json +21 -0
  21. package/src/dataflow/cross-service-taint.js +201 -0
  22. package/src/dataflow/formal-verify.js +204 -0
  23. package/src/dataflow/ifds-precise.js +222 -0
  24. package/src/dataflow/k2-summary-cache.js +153 -0
  25. package/src/dataflow/lib-taint-summaries.js +198 -0
  26. package/src/dataflow/privacy-taint.js +205 -0
  27. package/src/dataflow/smt-feasibility.js +189 -0
  28. package/src/engine.js +825 -127
  29. package/src/ir/.agentic-security/findings.json +4011 -0
  30. package/src/ir/.agentic-security/last-scan.json +4011 -0
  31. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  32. package/src/ir/.agentic-security/scan-history.json +193 -0
  33. package/src/ir/.agentic-security/streak.json +20 -0
  34. package/src/ir/cpp-preprocessor.js +142 -0
  35. package/src/ir/csharp-ir.js +604 -0
  36. package/src/ir/universal-ir.js +403 -0
  37. package/src/mcp/.agentic-security/findings.json +8632 -0
  38. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  39. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  40. package/src/mcp/.agentic-security/scan-history.json +331 -0
  41. package/src/mcp/.agentic-security/streak.json +20 -0
  42. package/src/mcp/tools.js +140 -1
  43. package/src/posture/.agentic-security/findings.json +77181 -0
  44. package/src/posture/.agentic-security/last-scan.json +77181 -0
  45. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  46. package/src/posture/.agentic-security/scan-history.json +8904 -0
  47. package/src/posture/.agentic-security/streak.json +21 -0
  48. package/src/posture/api-contract.js +193 -0
  49. package/src/posture/attack-taxonomy.js +227 -0
  50. package/src/posture/auditor-walkthrough.js +252 -0
  51. package/src/posture/claude-authorship.js +197 -0
  52. package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
  53. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
  54. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
  55. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
  56. package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
  57. package/src/posture/compliance-frameworks/ccpa.json +32 -0
  58. package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
  59. package/src/posture/compliance-frameworks/gdpr.json +45 -0
  60. package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
  61. package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
  62. package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
  63. package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
  64. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/cross-repo-memory.js +180 -0
  68. package/src/posture/csharp-analysis.js +330 -0
  69. package/src/posture/dep-add-guard.js +197 -0
  70. package/src/posture/exploit-bundle.js +210 -0
  71. package/src/posture/federated-learning.js +172 -0
  72. package/src/posture/findings-memory.js +152 -0
  73. package/src/posture/fix-style-mirror.js +118 -0
  74. package/src/posture/git-history.js +141 -0
  75. package/src/posture/intent-context.js +175 -0
  76. package/src/posture/license-attributions.js +94 -0
  77. package/src/posture/license-graph.js +238 -0
  78. package/src/posture/model-rescan.js +76 -0
  79. package/src/posture/pattern-propagation.js +39 -0
  80. package/src/posture/pqc-migration-plan.js +158 -0
  81. package/src/posture/pr-augment.js +234 -0
  82. package/src/posture/reachability-filter.js +33 -2
  83. package/src/posture/realtime-cve-monitor.js +214 -0
  84. package/src/posture/risk-dollars.js +158 -0
  85. package/src/posture/runtime-correlation.js +174 -0
  86. package/src/posture/sbom-diff.js +171 -0
  87. package/src/posture/sca-policy.js +235 -0
  88. package/src/posture/sca-upgrade.js +259 -0
  89. package/src/posture/threat-model-auto.js +268 -0
  90. package/src/posture/threat-model-grounding.js +169 -0
  91. package/src/posture/time-to-fix.js +129 -0
  92. package/src/posture/triage-learning.js +170 -0
  93. package/src/posture/triage-memory.js +151 -0
  94. package/src/posture/triage.js +40 -1
  95. package/src/posture/watch-mode.js +171 -0
  96. package/src/posture/workflow-installer.js +231 -0
  97. package/src/sast/.agentic-security/findings.json +6154 -0
  98. package/src/sast/.agentic-security/last-scan.json +6154 -0
  99. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  100. package/src/sast/.agentic-security/scan-history.json +941 -0
  101. package/src/sast/.agentic-security/streak.json +22 -0
  102. package/src/sast/_secret-entropy.js +145 -0
  103. package/src/sast/cloud-iam.js +312 -0
  104. package/src/sast/cpp.js +138 -4
  105. package/src/sast/crypto-protocol.js +388 -0
  106. package/src/sast/csharp-tokenizer.js +392 -0
  107. package/src/sast/csharp.js +924 -138
  108. package/src/sast/dapp-frontend.js +200 -0
  109. package/src/sast/k8s-admission.js +271 -0
  110. package/src/sast/llm-app.js +272 -0
  111. package/src/sast/ml-supply-chain.js +259 -0
  112. package/src/sast/mobile.js +224 -0
  113. package/src/sast/post-quantum-crypto.js +348 -0
  114. package/src/sast/web3-advanced.js +375 -0
  115. package/src/sca/.agentic-security/findings.json +7460 -0
  116. package/src/sca/.agentic-security/last-scan.json +7460 -0
  117. package/src/sca/.agentic-security/last-scan.json.sig +1 -0
  118. package/src/sca/.agentic-security/scan-history.json +113 -0
  119. package/src/sca/.agentic-security/streak.json +21 -0
  120. package/src/sca/CLAUDE.md +161 -0
  121. package/src/sca/binary-metadata.js +37 -15
  122. package/src/sca/sigstore-verify.js +215 -0
@@ -0,0 +1,214 @@
1
+ // Real-time emergent vulnerability monitor — Recommendation #8 of the
2
+ // world-class+2 plan.
3
+ //
4
+ // Polls / streams the OSV.dev feed (and optionally GHSA + vendor
5
+ // advisories) and within minutes of a new CVE matching the customer's
6
+ // SBOM, delivers a push notification via Slack / Discord / generic
7
+ // webhook.
8
+ //
9
+ // Design:
10
+ // 1. Pre-index the project's SBOM by (ecosystem, package) so a new
11
+ // CVE checks against the index in O(1)
12
+ // 2. Poll OSV's published-vulns endpoint every POLL_INTERVAL_MS
13
+ // (default 60s)
14
+ // 3. On match: emit a structured alert + write to a state file so
15
+ // duplicate alerts aren't sent
16
+ // 4. Push delivery via existing webhook utilities; configurable
17
+ // destinations: AGENTIC_SECURITY_ALERT_WEBHOOK, _SLACK, _DISCORD
18
+ //
19
+ // Out of scope: persistent daemon. This module exposes a `cycle()`
20
+ // function the caller invokes from a cron-shape loop (the existing
21
+ // commands/cve-alerts.md skill is the user-facing wrapper).
22
+
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import { statePath, safeWriteState } from './state-dir.js';
26
+
27
+ const STATE_FILE = 'cve-monitor-state.json';
28
+ const DEFAULT_POLL_MS = 60_000;
29
+ const DEFAULT_WINDOW_HOURS = 24;
30
+ const SLA_MINUTES = 5;
31
+
32
+ const OSV_FEED_URL = 'https://api.osv.dev/v1/vulns/recent';
33
+
34
+ function _statePath(scanRoot) { return statePath(scanRoot, STATE_FILE); }
35
+
36
+ function _loadState(scanRoot) {
37
+ const fp = _statePath(scanRoot);
38
+ if (!fs.existsSync(fp)) return { alertedIds: [], lastPolledAt: null };
39
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
40
+ catch { return { alertedIds: [], lastPolledAt: null }; }
41
+ }
42
+ function _saveState(scanRoot, state) {
43
+ safeWriteState(_statePath(scanRoot), JSON.stringify(state, null, 2));
44
+ }
45
+
46
+ /**
47
+ * Build a name→component index from the project's most recent SBOM.
48
+ * Accepts the SBOM as parsed from the engine output's `components`
49
+ * array (each entry: { ecosystem, name, version, … }).
50
+ */
51
+ export function indexSbom(components) {
52
+ const index = new Map(); // key: `${ecosystem}:${name}` → component
53
+ for (const c of components || []) {
54
+ if (!c || !c.ecosystem || !c.name) continue;
55
+ const key = `${String(c.ecosystem).toLowerCase()}:${String(c.name).toLowerCase()}`;
56
+ if (!index.has(key)) index.set(key, []);
57
+ index.get(key).push(c);
58
+ }
59
+ return index;
60
+ }
61
+
62
+ /**
63
+ * Pull the recent-published-vulnerabilities feed from OSV. Returns an
64
+ * array of OSV vulnerability records.
65
+ */
66
+ export async function pollOsv(opts = {}) {
67
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return [];
68
+ const url = opts.feedUrl || OSV_FEED_URL;
69
+ const since = opts.sinceIso || new Date(Date.now() - DEFAULT_WINDOW_HOURS * 3600_000).toISOString();
70
+ try {
71
+ const u = new URL(url);
72
+ u.searchParams.set('time', since);
73
+ const res = await fetch(u.toString(), {
74
+ headers: { 'User-Agent': 'agentic-security/0.1' },
75
+ });
76
+ if (!res.ok) return [];
77
+ const body = await res.json();
78
+ return Array.isArray(body.vulns) ? body.vulns : (Array.isArray(body) ? body : []);
79
+ } catch { return []; }
80
+ }
81
+
82
+ /**
83
+ * Match a single OSV record against the SBOM index. Returns an array of
84
+ * (component, vuln) tuples that match.
85
+ */
86
+ export function matchOsvToSbom(vuln, sbomIndex) {
87
+ const out = [];
88
+ for (const aff of (vuln.affected || [])) {
89
+ const eco = (aff.package?.ecosystem || '').toLowerCase();
90
+ const name = (aff.package?.name || '').toLowerCase();
91
+ if (!eco || !name) continue;
92
+ const key = `${_normalizeEco(eco)}:${name}`;
93
+ const matches = sbomIndex.get(key);
94
+ if (!matches) continue;
95
+ for (const comp of matches) {
96
+ // Best-effort version-range check — many advisories don't carry
97
+ // semver ranges in OSV affected objects; we conservatively
98
+ // include all matches and let the customer triage.
99
+ out.push({ component: comp, vuln });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function _normalizeEco(eco) {
106
+ const e = eco.toLowerCase();
107
+ if (e === 'npm' || e === 'node') return 'npm';
108
+ if (e === 'pypi' || e === 'pip' || e === 'python') return 'pypi';
109
+ if (e === 'maven') return 'maven';
110
+ if (e === 'gem' || e === 'rubygems') return 'rubygems';
111
+ if (e === 'cargo' || e === 'crates.io') return 'cargo';
112
+ if (e === 'go' || e === 'golang') return 'golang';
113
+ if (e === 'packagist' || e === 'composer') return 'packagist';
114
+ if (e === 'pub' || e === 'dart') return 'pub';
115
+ return e;
116
+ }
117
+
118
+ /**
119
+ * Compose the alert payload for delivery.
120
+ */
121
+ function _composeAlert(matchTuple, ts = new Date().toISOString()) {
122
+ const { component, vuln } = matchTuple;
123
+ return {
124
+ schema: 'agentic-security/cve-alert/v1',
125
+ ts,
126
+ cve: (vuln.aliases || []).find(a => /^CVE-/.test(a)) || null,
127
+ osvId: vuln.id,
128
+ summary: vuln.summary || vuln.details?.slice(0, 200) || '(no summary)',
129
+ component: {
130
+ ecosystem: component.ecosystem,
131
+ name: component.name,
132
+ version: component.version,
133
+ },
134
+ references: (vuln.references || []).slice(0, 4).map(r => r.url),
135
+ severity: _inferSeverity(vuln),
136
+ sla: { promised: `${SLA_MINUTES}m`, ts },
137
+ };
138
+ }
139
+
140
+ function _inferSeverity(vuln) {
141
+ if (Array.isArray(vuln.severity) && vuln.severity.length) {
142
+ const cvss = vuln.severity.find(s => /^CVSS_V/.test(s.type || ''));
143
+ if (cvss && cvss.score) return cvss.score;
144
+ }
145
+ if (vuln.database_specific?.severity) return String(vuln.database_specific.severity);
146
+ return 'unknown';
147
+ }
148
+
149
+ /**
150
+ * Deliver an alert via every configured channel. Webhook URLs come from
151
+ * env: AGENTIC_SECURITY_ALERT_WEBHOOK / _SLACK_WEBHOOK / _DISCORD_WEBHOOK.
152
+ */
153
+ export async function deliverAlert(alert) {
154
+ const sinks = {
155
+ generic: process.env.AGENTIC_SECURITY_ALERT_WEBHOOK,
156
+ slack: process.env.AGENTIC_SECURITY_SLACK_WEBHOOK,
157
+ discord: process.env.AGENTIC_SECURITY_DISCORD_WEBHOOK,
158
+ };
159
+ const results = {};
160
+ for (const [name, url] of Object.entries(sinks)) {
161
+ if (!url) continue;
162
+ try {
163
+ const body = name === 'slack'
164
+ ? { text: `*New CVE affecting your stack*\n• ${alert.cve || alert.osvId} — ${alert.severity}\n• Package: \`${alert.component.ecosystem}:${alert.component.name}@${alert.component.version}\`\n• ${alert.summary}` }
165
+ : name === 'discord'
166
+ ? { content: `**New CVE affecting your stack**\n• ${alert.cve || alert.osvId} — ${alert.severity}\n• \`${alert.component.ecosystem}:${alert.component.name}@${alert.component.version}\`\n• ${alert.summary}` }
167
+ : alert;
168
+ const res = await fetch(url, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'agentic-security/0.1' },
171
+ body: JSON.stringify(body),
172
+ });
173
+ results[name] = { ok: res.ok, status: res.status };
174
+ } catch (e) {
175
+ results[name] = { ok: false, error: String(e && e.message || e) };
176
+ }
177
+ }
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * One polling cycle. Caller invokes from a daemon / cron / loop.
183
+ * - Fetches recent OSV records
184
+ * - Matches against the SBOM index
185
+ * - Deduplicates against prior alerts (by OSV id)
186
+ * - Delivers alerts
187
+ * - Persists state
188
+ */
189
+ export async function cycle(scanRoot, sbomComponents, opts = {}) {
190
+ const state = _loadState(scanRoot);
191
+ const seen = new Set(state.alertedIds || []);
192
+ const index = indexSbom(sbomComponents);
193
+ const recent = await pollOsv({ sinceIso: state.lastPolledAt, ...opts });
194
+ const matches = [];
195
+ for (const v of recent) {
196
+ if (seen.has(v.id)) continue;
197
+ const m = matchOsvToSbom(v, index);
198
+ if (m.length === 0) continue;
199
+ matches.push(...m);
200
+ }
201
+ const alerts = matches.map(m => _composeAlert(m));
202
+ const deliveries = [];
203
+ for (const alert of alerts) {
204
+ const d = await deliverAlert(alert);
205
+ deliveries.push({ alert, delivery: d });
206
+ seen.add(alert.osvId);
207
+ }
208
+ state.alertedIds = [...seen];
209
+ state.lastPolledAt = new Date().toISOString();
210
+ _saveState(scanRoot, state);
211
+ return { recentCount: recent.length, matches: matches.length, alerts: alerts.length, deliveries };
212
+ }
213
+
214
+ export const _internals = { _normalizeEco, _composeAlert, _inferSeverity, OSV_FEED_URL, DEFAULT_POLL_MS, SLA_MINUTES };
@@ -0,0 +1,158 @@
1
+ // Risk-in-dollars — expected value of exploitation per finding.
2
+ //
3
+ // Combines three signals into an EV estimate:
4
+ //
5
+ // P(exploited) from EPSS score on the finding's CVE if present,
6
+ // else from family-level base rate
7
+ // Impact($) from crown-jewel mapping (data class) and industry
8
+ // breach-cost averages
9
+ // Discount reachability tier (route-reachable > function-reachable
10
+ // > unknown > unreachable)
11
+ //
12
+ // EV per finding = P × Impact × Discount × ConfidenceFloor
13
+ //
14
+ // Industry breach-cost figures used here are sourced from publicly
15
+ // reported aggregates (Ponemon Cost of a Data Breach Report — IBM/Verizon
16
+ // methodology is widely cited but the figures are reported in the public
17
+ // summary; we use rounded estimates as defaults that users can override
18
+ // via .agentic-security/risk-config.yml).
19
+ //
20
+ // Disclaimer: this is an order-of-magnitude estimate for prioritization.
21
+ // It is NOT an actuarial or insurance assessment.
22
+
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+
26
+ const STATE = '.agentic-security';
27
+
28
+ // Base rates per family (annual probability of at-least-one exploit given
29
+ // an exposed instance). Rough industry estimates; tune via config.
30
+ const FAMILY_BASE_PROB = {
31
+ 'sqli': 0.18, 'sql-injection': 0.18,
32
+ 'xss': 0.12, 'mutation-xss': 0.10,
33
+ 'command-injection': 0.16,
34
+ 'code-injection': 0.20,
35
+ 'deserialization': 0.15,
36
+ 'auth-missing': 0.25,
37
+ 'authz': 0.18, 'idor': 0.15,
38
+ 'csrf': 0.07,
39
+ 'ssrf': 0.10, 'ssrf-cloud-metadata': 0.22,
40
+ 'xxe': 0.08,
41
+ 'open-redirect': 0.05,
42
+ 'path-traversal': 0.10,
43
+ 'crypto-weak-cipher': 0.04, 'crypto-weak-hash': 0.03,
44
+ 'crypto-tls-no-verify': 0.10, 'crypto-tls-version': 0.05,
45
+ 'crypto-jwt-none': 0.20, 'crypto-jwt-key-confusion': 0.18,
46
+ 'hardcoded-secret': 0.30,
47
+ 'vulnerable-dependency': 0.08,
48
+ 'dependency-confusion': 0.06,
49
+ 'iam-overpermissive': 0.10,
50
+ 'k8s-rbac-cluster-admin': 0.12,
51
+ 'k8s-pod-security-privileged': 0.10,
52
+ 'prompt-injection': 0.20,
53
+ 'agent-tool-exec': 0.25,
54
+ 'reentrancy': 0.30,
55
+ 'signature-replay': 0.15,
56
+ 'eth-sign-used': 0.30,
57
+ 'unlimited-approval': 0.18,
58
+ };
59
+
60
+ // Default impact (USD) per crown-jewel / data-class tier.
61
+ const IMPACT_USD = {
62
+ 'PII': 250_000,
63
+ 'PHI': 400_000,
64
+ 'PCI': 500_000,
65
+ 'Confidential': 150_000,
66
+ 'crown-jewel': 300_000,
67
+ 'default': 50_000,
68
+ };
69
+
70
+ const REACH_DISCOUNT = {
71
+ 'reachable-public': 1.0,
72
+ 'public-unauthed': 1.0,
73
+ 'route-reachable': 0.9,
74
+ 'route-reachable-via-function': 0.7,
75
+ 'function-reachable': 0.5,
76
+ 'unknown': 0.3,
77
+ 'unreachable': 0.05,
78
+ 'function-reachable-but-not-route':0.4,
79
+ };
80
+
81
+ function _loadConfig(scanRoot) {
82
+ const fp = path.join(scanRoot, STATE, 'risk-config.yml');
83
+ if (!fs.existsSync(fp)) return null;
84
+ try {
85
+ const body = fs.readFileSync(fp, 'utf8');
86
+ // Tiny YAML — look for impactUSD / familyBaseProb overrides
87
+ const cfg = {};
88
+ const impactMatch = body.match(/^impactUSD\s*:\s*\n((?:\s+\w+\s*:\s*\d+\s*\n?)+)/m);
89
+ if (impactMatch) {
90
+ cfg.impactUSD = {};
91
+ for (const m of impactMatch[1].matchAll(/(\w+)\s*:\s*(\d+)/g)) cfg.impactUSD[m[1]] = parseInt(m[2], 10);
92
+ }
93
+ return cfg;
94
+ } catch { return null; }
95
+ }
96
+
97
+ function _baseProb(family) {
98
+ if (!family) return 0.05;
99
+ return FAMILY_BASE_PROB[family] || FAMILY_BASE_PROB[String(family).toLowerCase()] || 0.05;
100
+ }
101
+
102
+ function _impactFor(finding, cfg) {
103
+ const table = cfg && cfg.impactUSD ? { ...IMPACT_USD, ...cfg.impactUSD } : IMPACT_USD;
104
+ const dc = Array.isArray(finding.dataClasses) ? finding.dataClasses : [];
105
+ if (dc.includes('PHI')) return table.PHI;
106
+ if (dc.includes('PCI')) return table.PCI;
107
+ if (dc.includes('PII')) return table.PII;
108
+ if (dc.includes('Confidential')) return table.Confidential;
109
+ if (finding.threatModel?.crownJewel) return table['crown-jewel'];
110
+ return table.default;
111
+ }
112
+
113
+ function _reachDiscount(finding) {
114
+ const tier = finding.reachabilityTier || finding.routeReachable && 'route-reachable' || 'unknown';
115
+ return REACH_DISCOUNT[tier] || 0.3;
116
+ }
117
+
118
+ function _epssProb(finding) {
119
+ if (typeof finding.epssScore === 'number') return finding.epssScore;
120
+ if (typeof finding.epss === 'number') return finding.epss;
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Compute EV per finding. Mutates the finding in place: adds
126
+ * .riskDollars = { ev, prob, impact, discount }.
127
+ */
128
+ export function annotateRiskDollars(scanRoot, findings) {
129
+ if (!Array.isArray(findings) || findings.length === 0) return { total: 0, sumEv: 0 };
130
+ const cfg = _loadConfig(scanRoot);
131
+ let sumEv = 0;
132
+ let critEv = 0, highEv = 0;
133
+ for (const f of findings) {
134
+ const epss = _epssProb(f);
135
+ const prob = epss != null ? epss : _baseProb(f.family);
136
+ const impact = _impactFor(f, cfg);
137
+ const discount = _reachDiscount(f);
138
+ const confidenceFloor = Math.max(0.4, f.confidence || 0.8);
139
+ const ev = Math.round(prob * impact * discount * confidenceFloor);
140
+ f.riskDollars = { ev, prob: Number(prob.toFixed(3)), impact, discount, confidenceFloor: Number(confidenceFloor.toFixed(2)) };
141
+ sumEv += ev;
142
+ if (f.severity === 'critical') critEv += ev;
143
+ else if (f.severity === 'high') highEv += ev;
144
+ }
145
+ return { total: findings.length, sumEv, critEv, highEv };
146
+ }
147
+
148
+ /**
149
+ * Format a USD figure for display.
150
+ */
151
+ export function fmtUsd(n) {
152
+ if (typeof n !== 'number' || !isFinite(n)) return '$?';
153
+ if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
154
+ if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
155
+ return `$${n}`;
156
+ }
157
+
158
+ export const _internals = { FAMILY_BASE_PROB, IMPACT_USD, REACH_DISCOUNT, _baseProb, _impactFor, _reachDiscount };
@@ -0,0 +1,174 @@
1
+ // eBPF runtime instrumentation correlation — Recommendation #5 of the
2
+ // world-class roadmap.
3
+ //
4
+ // The hardest false-positive class is "this code path is technically
5
+ // reachable but is DEAD in production." Static analysis can't distinguish
6
+ // dead code from reachable code; runtime observation can. This module
7
+ // consumes an eBPF trace dataset (produced by an out-of-band collector
8
+ // running in the customer's prod environment) and demotes findings
9
+ // whose call-graph paths were unobserved.
10
+ //
11
+ // Trace format (JSONL, one record per observation):
12
+ //
13
+ // { "ts": "2026-05-28T...", "host": "prod-host-1",
14
+ // "kind": "function-call", "qid": "com.acme.UserController.getUser",
15
+ // "fileRel": "src/main/java/com/acme/UserController.java", "line": 42,
16
+ // "count": 1234, "lastSeen": "2026-05-28T..." }
17
+ //
18
+ // Common record kinds:
19
+ // - "function-call": QID was invoked at least once in the trace window
20
+ // - "route-hit": HTTP route received traffic
21
+ // - "syscall": filesystem / network / process syscall fired
22
+ // - "file-touch": file was read / written
23
+ //
24
+ // The trace file lives at one of:
25
+ // .agentic-security/runtime-trace.jsonl (per-project, committed)
26
+ // $AGENTIC_SECURITY_RUNTIME_TRACE_PATH (override)
27
+ //
28
+ // Output: every finding gets a `runtimeObserved: true|false|unknown` field.
29
+ // true — at least one node on the finding's call-graph path appears in trace
30
+ // false — none of the nodes appear (finding's path is dead in observed window)
31
+ // unknown — no trace data available
32
+
33
+ import * as fs from 'node:fs';
34
+ import * as path from 'node:path';
35
+ import * as readline from 'node:readline';
36
+ import { createReadStream } from 'node:fs';
37
+
38
+ const DEFAULT_TRACE_NAMES = ['runtime-trace.jsonl', 'runtime.jsonl', 'ebpf-trace.jsonl'];
39
+ const DEFAULT_OBSERVATION_WINDOW_DAYS = 30;
40
+
41
+ export async function loadTrace(scanRoot, opts = {}) {
42
+ const explicit = opts.tracePath || process.env.AGENTIC_SECURITY_RUNTIME_TRACE_PATH;
43
+ const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n => path.join(scanRoot, '.agentic-security', n));
44
+ let chosen = null;
45
+ for (const c of candidates) {
46
+ if (fs.existsSync(c)) { chosen = c; break; }
47
+ }
48
+ if (!chosen) return null;
49
+ const trace = {
50
+ path: chosen,
51
+ qidsObserved: new Set(),
52
+ routesObserved: new Set(),
53
+ filesObserved: new Set(),
54
+ fileLinesObserved: new Map(), // file → Set<line>
55
+ syscallsObserved: new Set(),
56
+ recordCount: 0,
57
+ };
58
+ const window = (opts.windowDays || DEFAULT_OBSERVATION_WINDOW_DAYS) * 86400_000;
59
+ const now = Date.now();
60
+ const stream = createReadStream(chosen, { encoding: 'utf8' });
61
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
62
+ for await (const line of rl) {
63
+ if (!line.trim()) continue;
64
+ let r;
65
+ try { r = JSON.parse(line); } catch { continue; }
66
+ if (r.ts) {
67
+ const tsMs = Date.parse(r.ts);
68
+ if (Number.isFinite(tsMs) && now - tsMs > window) continue;
69
+ }
70
+ trace.recordCount++;
71
+ switch (r.kind) {
72
+ case 'function-call':
73
+ if (r.qid) trace.qidsObserved.add(r.qid);
74
+ if (r.fileRel && typeof r.line === 'number') {
75
+ let set = trace.fileLinesObserved.get(r.fileRel);
76
+ if (!set) { set = new Set(); trace.fileLinesObserved.set(r.fileRel, set); }
77
+ set.add(r.line);
78
+ }
79
+ if (r.fileRel) trace.filesObserved.add(r.fileRel);
80
+ break;
81
+ case 'route-hit':
82
+ if (r.route) trace.routesObserved.add(r.route);
83
+ break;
84
+ case 'syscall':
85
+ if (r.name) trace.syscallsObserved.add(r.name);
86
+ break;
87
+ case 'file-touch':
88
+ if (r.fileRel) trace.filesObserved.add(r.fileRel);
89
+ break;
90
+ }
91
+ }
92
+ return trace;
93
+ }
94
+
95
+ /**
96
+ * Test whether a finding's call-graph path was observed in trace.
97
+ * Checks:
98
+ * 1. The finding's file appears in filesObserved (necessary condition)
99
+ * 2. AT LEAST ONE line on the finding's chain (source, sink, intermediate
100
+ * nodes) was observed in that file
101
+ * 3. The finding's containing function's qid appears in qidsObserved
102
+ */
103
+ export function findingObservedInRuntime(finding, trace) {
104
+ if (!trace) return 'unknown';
105
+ // qid match (most specific)
106
+ if (finding.scope && trace.qidsObserved.has(finding.scope)) return true;
107
+ if (finding.functionQid && trace.qidsObserved.has(finding.functionQid)) return true;
108
+ // file + any-line on the chain match
109
+ const file = finding.file || (finding.sink && finding.sink.file);
110
+ if (file && trace.filesObserved.has(file)) {
111
+ const linesObserved = trace.fileLinesObserved.get(file);
112
+ if (linesObserved) {
113
+ const chainLines = [
114
+ finding.line,
115
+ ...(finding.chain || []).map(s => s.line),
116
+ ...(finding.taintPath || []).map(s => s.line),
117
+ ].filter(n => typeof n === 'number');
118
+ for (const ln of chainLines) {
119
+ // Allow ±2 line tolerance for compiler reordering / minor inlining.
120
+ for (let off = -2; off <= 2; off++) {
121
+ if (linesObserved.has(ln + off)) return true;
122
+ }
123
+ }
124
+ }
125
+ // File appears but no line match — partial evidence. Don't claim
126
+ // observed; don't claim dead.
127
+ return 'unknown';
128
+ }
129
+ // route match — every finding inside a route handler whose route was hit.
130
+ if (finding._inRoute && finding._inRoute.path && trace.routesObserved.has(finding._inRoute.path)) return true;
131
+ if (finding.routeRooted && finding._inRoute && trace.routesObserved.size > 0) {
132
+ for (const r of trace.routesObserved) {
133
+ if (finding._inRoute.path && r.includes(finding._inRoute.path)) return true;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Annotate all findings with runtimeObserved + demote unobserved findings.
141
+ * Demotion: critical → high, high → medium, medium → low.
142
+ *
143
+ * Findings classified as unknown are left alone (no demotion). This is
144
+ * the principled position — absence of observation in a partial trace
145
+ * is not evidence of dead code.
146
+ */
147
+ export async function annotateRuntimeCorrelation(scanRoot, findings, opts = {}) {
148
+ if (!Array.isArray(findings)) return { observed: 0, dead: 0, unknown: 0 };
149
+ const trace = await loadTrace(scanRoot, opts);
150
+ if (!trace) {
151
+ for (const f of findings) f.runtimeObserved = 'unknown';
152
+ return { observed: 0, dead: 0, unknown: findings.length, trace: null };
153
+ }
154
+ let observed = 0, dead = 0, unknown = 0;
155
+ const demoteLadder = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
156
+ for (const f of findings) {
157
+ const v = findingObservedInRuntime(f, trace);
158
+ f.runtimeObserved = v;
159
+ if (v === true) observed++;
160
+ else if (v === false) {
161
+ dead++;
162
+ // Demote one tier — the finding is real but unobserved in 30 days
163
+ // of production traffic, so it's not P0.
164
+ const next = demoteLadder[f.severity];
165
+ if (next) {
166
+ f._runtimeDemoted = f.severity;
167
+ f.severity = next;
168
+ }
169
+ } else unknown++;
170
+ }
171
+ return { observed, dead, unknown, trace: { recordCount: trace.recordCount, path: trace.path } };
172
+ }
173
+
174
+ export const _internals = { DEFAULT_TRACE_NAMES, DEFAULT_OBSERVATION_WINDOW_DAYS };