@clear-capabilities/agentic-security-scanner 0.78.0 → 0.80.0
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/bin/.agentic-security/findings.json +16 -16
- package/bin/.agentic-security/last-scan.json +16 -16
- package/bin/.agentic-security/last-scan.json.sig +1 -1
- package/bin/.agentic-security/scan-history.json +51 -0
- package/bin/.agentic-security/streak.json +5 -5
- package/bin/agentic-security.js +22 -7
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +53 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +95 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +29799 -7803
- package/src/.agentic-security/last-scan.json +29799 -7803
- package/src/.agentic-security/last-scan.json.sig +1 -1
- package/src/.agentic-security/scan-history.json +5119 -2611
- package/src/.agentic-security/streak.json +6 -6
- package/src/dataflow/.agentic-security/findings.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
- package/src/dataflow/.agentic-security/scan-history.json +68 -520
- package/src/dataflow/.agentic-security/streak.json +6 -7
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/engine.js +52 -8
- 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 +890 -132
- package/src/integrations/index.js +2 -1
- package/src/ir/.agentic-security/findings.json +240 -6
- package/src/ir/.agentic-security/last-scan.json +240 -6
- package/src/ir/.agentic-security/last-scan.json.sig +1 -1
- package/src/ir/.agentic-security/scan-history.json +16 -594
- package/src/ir/.agentic-security/streak.json +8 -9
- package/src/ir/callgraph.js +27 -7
- 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/llm-validator/index.js +7 -5
- 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 +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/audit.js +5 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json.sig +1 -1
- package/src/posture/.agentic-security/scan-history.json +6689 -177
- package/src/posture/.agentic-security/streak.json +8 -7
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +3 -2
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/fix-history.js +8 -2
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/profile.js +4 -5
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- 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/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +29 -6
- package/src/posture/validator-metrics.js +3 -6
- package/src/sast/.agentic-security/findings.json +996 -32
- package/src/sast/.agentic-security/last-scan.json +996 -32
- package/src/sast/.agentic-security/last-scan.json.sig +1 -1
- package/src/sast/.agentic-security/scan-history.json +565 -32
- package/src/sast/.agentic-security/streak.json +10 -8
- 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/db-taint.js +24 -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/rust.js +26 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json.sig +1 -1
- package/src/sca/.agentic-security/scan-history.json +83 -6
- package/src/sca/.agentic-security/streak.json +9 -9
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +146 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/sigstore-verify.js +215 -0
- package/src/sca/vendor-detect.js +53 -0
- package/src/report/.agentic-security/findings.json +0 -80
- package/src/report/.agentic-security/last-scan.json +0 -80
- package/src/report/.agentic-security/last-scan.json.sig +0 -1
- package/src/report/.agentic-security/scan-history.json +0 -35
- package/src/report/.agentic-security/streak.json +0 -22
|
@@ -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 };
|
|
@@ -11,11 +11,10 @@ import * as fs from 'node:fs';
|
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import * as yaml from 'js-yaml';
|
|
13
13
|
import { verifyLastScan } from './integrity.js';
|
|
14
|
-
|
|
15
|
-
const OVERRIDES_PATH = '.agentic-security/rules.yml';
|
|
14
|
+
import { statePath } from './state-dir.js';
|
|
16
15
|
|
|
17
16
|
function _path(scanRoot) {
|
|
18
|
-
return
|
|
17
|
+
return statePath(scanRoot, 'rules.yml');
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export function loadOverrides(scanRoot) {
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
import * as fs from 'node:fs';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import * as crypto from 'node:crypto';
|
|
33
|
-
|
|
34
|
-
const TRUSTED_KEYS_FILE = '.agentic-security/trusted-keys.json';
|
|
33
|
+
import { statePath } from './state-dir.js';
|
|
35
34
|
|
|
36
35
|
// Built-in trust root. These are the keys the maintainers of agentic-security
|
|
37
36
|
// use to sign official rule packs. Production deployment requires the
|
|
@@ -49,7 +48,7 @@ export const BUNDLED_OFFICIAL_KEYS = [
|
|
|
49
48
|
];
|
|
50
49
|
|
|
51
50
|
function _trustedKeysPath(scanRoot) {
|
|
52
|
-
return
|
|
51
|
+
return statePath(scanRoot, 'trusted-keys.json');
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
// Load the EFFECTIVE trusted-key set. Composition:
|
|
@@ -13,14 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import * as fs from 'node:fs';
|
|
15
15
|
import * as path from 'node:path';
|
|
16
|
-
|
|
17
|
-
const TRIAGE_PATH = path.join('.agentic-security', 'triage-feedback.json');
|
|
18
|
-
const PROPOSED_DIR = path.join('.agentic-security', 'rules-proposed');
|
|
16
|
+
import { statePath, isSafeStateDir } from './state-dir.js';
|
|
19
17
|
|
|
20
18
|
const DEFAULT_FP_THRESHOLD = 5;
|
|
21
19
|
|
|
22
20
|
function _readTriage(scanRoot) {
|
|
23
|
-
const fp =
|
|
21
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
24
22
|
if (!fs.existsSync(fp)) return null;
|
|
25
23
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
26
24
|
}
|
|
@@ -87,7 +85,8 @@ export function synthesizeRules(scanRoot, opts = {}) {
|
|
|
87
85
|
groups.get(k).push(e);
|
|
88
86
|
}
|
|
89
87
|
const proposals = [];
|
|
90
|
-
const dir =
|
|
88
|
+
const dir = statePath(scanRoot, 'rules-proposed');
|
|
89
|
+
if (!opts.dryRun && !isSafeStateDir(path.dirname(dir))) return [];
|
|
91
90
|
for (const [, group] of groups) {
|
|
92
91
|
if (group.length < threshold) continue;
|
|
93
92
|
const summary = _summarizeGroup(group);
|
|
@@ -105,4 +104,4 @@ export function synthesizeRules(scanRoot, opts = {}) {
|
|
|
105
104
|
return proposals;
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
export const _internals = { DEFAULT_FP_THRESHOLD
|
|
107
|
+
export const _internals = { DEFAULT_FP_THRESHOLD };
|
|
@@ -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 };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SBOM diff + dependency drift detection — Recommendation #10 of the
|
|
2
|
+
// world-class+2 plan.
|
|
3
|
+
//
|
|
4
|
+
// Tracks SBOMs across releases (keyed by git commit hash). On each scan,
|
|
5
|
+
// compares the current SBOM against the previous snapshot to surface
|
|
6
|
+
// drift before a CVE-publication catches it:
|
|
7
|
+
//
|
|
8
|
+
// - dependency-added new package since previous SBOM
|
|
9
|
+
// - dependency-removed package no longer present
|
|
10
|
+
// - dependency-version-bumped version changed
|
|
11
|
+
// - dependency-substitution SAME package name but different
|
|
12
|
+
// ecosystem / publisher / repo source
|
|
13
|
+
// (the SolarWinds / event-stream pattern)
|
|
14
|
+
// - dependency-deprecated transitioned to a deprecated state
|
|
15
|
+
//
|
|
16
|
+
// Suspicious additions (i.e., a new package that doesn't appear in any
|
|
17
|
+
// PR diff / commit message) get a higher severity tier than expected ones.
|
|
18
|
+
//
|
|
19
|
+
// Snapshots live at .agentic-security/sbom-history/<sha>.json. The
|
|
20
|
+
// engine writes the current snapshot at the end of every scan; this
|
|
21
|
+
// module produces the diff on the next scan.
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import * as crypto from 'node:crypto';
|
|
26
|
+
import { execSync } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
const HISTORY_DIR = 'sbom-history';
|
|
29
|
+
|
|
30
|
+
function _historyDir(scanRoot) {
|
|
31
|
+
return path.join(scanRoot, '.agentic-security', HISTORY_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _gitHead(scanRoot) {
|
|
35
|
+
try {
|
|
36
|
+
return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _snapshotKey(component) {
|
|
41
|
+
return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist the current SBOM as a snapshot keyed by the current git HEAD.
|
|
46
|
+
* If no git, falls back to a content hash of the components list.
|
|
47
|
+
*/
|
|
48
|
+
export function persistSbom(scanRoot, components) {
|
|
49
|
+
const dir = _historyDir(scanRoot);
|
|
50
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
51
|
+
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
52
|
+
const snap = {
|
|
53
|
+
sha, ts: new Date().toISOString(),
|
|
54
|
+
componentCount: components.length,
|
|
55
|
+
components: components.map(c => ({
|
|
56
|
+
ecosystem: c.ecosystem, name: c.name, version: c.version,
|
|
57
|
+
purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
|
|
58
|
+
sha256: c.sha256 || c.integrity || null,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
62
|
+
return snap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load the previous SBOM snapshot for diffing.
|
|
67
|
+
*/
|
|
68
|
+
export function loadPreviousSnapshot(scanRoot, currentSha) {
|
|
69
|
+
const dir = _historyDir(scanRoot);
|
|
70
|
+
if (!fs.existsSync(dir)) return null;
|
|
71
|
+
let snaps;
|
|
72
|
+
try { snaps = fs.readdirSync(dir); } catch { return null; }
|
|
73
|
+
snaps = snaps.filter(f => f.endsWith('.json') && f !== `${currentSha}.json`);
|
|
74
|
+
if (!snaps.length) return null;
|
|
75
|
+
// Sort by mtime descending; take the most recent.
|
|
76
|
+
snaps.sort((a, b) => fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs);
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(path.join(dir, snaps[0]), 'utf8'));
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute the structured diff between two SBOMs and emit drift findings.
|
|
84
|
+
*/
|
|
85
|
+
export function diffSboms(previous, current) {
|
|
86
|
+
if (!previous || !current) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 } };
|
|
87
|
+
const findings = [];
|
|
88
|
+
const prevByKey = new Map();
|
|
89
|
+
for (const c of previous.components || []) prevByKey.set(_snapshotKey(c), c);
|
|
90
|
+
const curByKey = new Map();
|
|
91
|
+
for (const c of current.components || []) curByKey.set(_snapshotKey(c), c);
|
|
92
|
+
let added = 0, removed = 0, bumped = 0, substituted = 0;
|
|
93
|
+
|
|
94
|
+
// Added
|
|
95
|
+
for (const [key, cur] of curByKey) {
|
|
96
|
+
if (prevByKey.has(key)) continue;
|
|
97
|
+
added++;
|
|
98
|
+
findings.push({
|
|
99
|
+
family: 'dependency-drift', subfamily: 'dependency-added',
|
|
100
|
+
severity: 'medium', cwe: 'CWE-1357',
|
|
101
|
+
vuln: `Dependency added since previous release: ${cur.ecosystem}:${cur.name}@${cur.version}`,
|
|
102
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
103
|
+
drift: { kind: 'added', component: cur, sinceSha: previous.sha },
|
|
104
|
+
remediation: 'Confirm this addition appears in a reviewed PR. Unexplained additions are the standard supply-chain-attack pattern.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Removed
|
|
108
|
+
for (const [key, prev] of prevByKey) {
|
|
109
|
+
if (curByKey.has(key)) continue;
|
|
110
|
+
removed++;
|
|
111
|
+
findings.push({
|
|
112
|
+
family: 'dependency-drift', subfamily: 'dependency-removed',
|
|
113
|
+
severity: 'low', cwe: 'CWE-1357',
|
|
114
|
+
vuln: `Dependency removed since previous release: ${prev.ecosystem}:${prev.name}@${prev.version}`,
|
|
115
|
+
file: prev.purl || 'package-lock.json', line: 0,
|
|
116
|
+
drift: { kind: 'removed', component: prev, sinceSha: previous.sha },
|
|
117
|
+
remediation: 'Confirm the removal was intentional. Silent removal of a vulnerable dep is fine; silent removal of a fix-receiving dep means CVEs may re-introduce.',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Bumped
|
|
121
|
+
for (const [key, cur] of curByKey) {
|
|
122
|
+
const prev = prevByKey.get(key);
|
|
123
|
+
if (!prev) continue;
|
|
124
|
+
if (prev.version !== cur.version) {
|
|
125
|
+
bumped++;
|
|
126
|
+
const isMajor = _isMajorBump(prev.version, cur.version);
|
|
127
|
+
findings.push({
|
|
128
|
+
family: 'dependency-drift', subfamily: 'dependency-version-bumped',
|
|
129
|
+
severity: isMajor ? 'medium' : 'low',
|
|
130
|
+
cwe: 'CWE-1357',
|
|
131
|
+
vuln: `${cur.ecosystem}:${cur.name} bumped ${prev.version} → ${cur.version}${isMajor ? ' (MAJOR)' : ''}`,
|
|
132
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
133
|
+
drift: { kind: 'bumped', from: prev.version, to: cur.version, isMajor, sinceSha: previous.sha },
|
|
134
|
+
remediation: 'Major-version bumps are breaking-change candidates AND attack pivot points. Verify the changelog signals match what your dependency intended.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Substitution check: integrity / hash changed but version stayed the same
|
|
138
|
+
if (prev.sha256 && cur.sha256 && prev.sha256 !== cur.sha256 && prev.version === cur.version) {
|
|
139
|
+
substituted++;
|
|
140
|
+
findings.push({
|
|
141
|
+
family: 'dependency-drift', subfamily: 'dependency-substitution',
|
|
142
|
+
severity: 'critical', cwe: 'CWE-1357',
|
|
143
|
+
vuln: `Suspicious substitution: ${cur.ecosystem}:${cur.name}@${cur.version} hash changed without version change`,
|
|
144
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
145
|
+
drift: { kind: 'substituted', component: cur, oldHash: prev.sha256, newHash: cur.sha256, sinceSha: previous.sha },
|
|
146
|
+
remediation: 'Same package + same version + DIFFERENT content hash = the registry served a different artifact under the same identity. Investigate immediately; rotate the lockfile via fresh install.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { findings, summary: { added, removed, bumped, substituted } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _isMajorBump(prev, cur) {
|
|
154
|
+
const pa = (prev || '').match(/^(\d+)/);
|
|
155
|
+
const pc = (cur || '').match(/^(\d+)/);
|
|
156
|
+
if (!pa || !pc) return false;
|
|
157
|
+
return parseInt(pa[1], 10) !== parseInt(pc[1], 10);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Convenience entry — run the full pipeline: persist current, diff
|
|
162
|
+
* against previous, return findings.
|
|
163
|
+
*/
|
|
164
|
+
export function runSbomDiff(scanRoot, components) {
|
|
165
|
+
const current = persistSbom(scanRoot, components);
|
|
166
|
+
const previous = loadPreviousSnapshot(scanRoot, current.sha);
|
|
167
|
+
if (!previous) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 }, first: true };
|
|
168
|
+
return diffSboms(previous, current);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const _internals = { _historyDir, _gitHead, _isMajorBump, _snapshotKey };
|