@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.
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/839.index.js +170 -0
- package/dist/985.index.js +140 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/.agentic-security/findings.json +117732 -0
- package/src/.agentic-security/last-scan.json +117732 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12946 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +825 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +331 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +140 -1
- package/src/posture/.agentic-security/findings.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +8904 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/auditor-walkthrough.js +252 -0
- package/src/posture/claude-authorship.js +197 -0
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
- package/src/posture/compliance-frameworks/ccpa.json +32 -0
- package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
- package/src/posture/compliance-frameworks/gdpr.json +45 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/cross-repo-memory.js +180 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/dep-add-guard.js +197 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/findings-memory.js +152 -0
- package/src/posture/fix-style-mirror.js +118 -0
- package/src/posture/git-history.js +141 -0
- package/src/posture/intent-context.js +175 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/model-rescan.js +76 -0
- package/src/posture/pattern-propagation.js +39 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/pr-augment.js +234 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/risk-dollars.js +158 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/threat-model-grounding.js +169 -0
- package/src/posture/time-to-fix.js +129 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage-memory.js +151 -0
- package/src/posture/triage.js +40 -1
- package/src/posture/watch-mode.js +171 -0
- package/src/posture/workflow-installer.js +231 -0
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- 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 };
|