@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.
Files changed (126) hide show
  1. package/bin/.agentic-security/findings.json +16 -16
  2. package/bin/.agentic-security/last-scan.json +16 -16
  3. package/bin/.agentic-security/last-scan.json.sig +1 -1
  4. package/bin/.agentic-security/scan-history.json +51 -0
  5. package/bin/.agentic-security/streak.json +5 -5
  6. package/bin/agentic-security.js +22 -7
  7. package/dist/178.index.js +1 -1
  8. package/dist/333.index.js +283 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/476.index.js +5 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/700.index.js +138 -0
  13. package/dist/718.index.js +53 -0
  14. package/dist/838.index.js +1 -1
  15. package/dist/985.index.js +95 -1
  16. package/dist/agentic-security.mjs +83 -83
  17. package/dist/agentic-security.mjs.sha256 +1 -1
  18. package/package.json +6 -4
  19. package/src/.agentic-security/findings.json +29799 -7803
  20. package/src/.agentic-security/last-scan.json +29799 -7803
  21. package/src/.agentic-security/last-scan.json.sig +1 -1
  22. package/src/.agentic-security/scan-history.json +5119 -2611
  23. package/src/.agentic-security/streak.json +6 -6
  24. package/src/dataflow/.agentic-security/findings.json +2879 -308
  25. package/src/dataflow/.agentic-security/last-scan.json +2879 -308
  26. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
  27. package/src/dataflow/.agentic-security/scan-history.json +68 -520
  28. package/src/dataflow/.agentic-security/streak.json +6 -7
  29. package/src/dataflow/cross-service-taint.js +201 -0
  30. package/src/dataflow/engine.js +52 -8
  31. package/src/dataflow/formal-verify.js +204 -0
  32. package/src/dataflow/ifds-precise.js +222 -0
  33. package/src/dataflow/k2-summary-cache.js +153 -0
  34. package/src/dataflow/lib-taint-summaries.js +198 -0
  35. package/src/dataflow/privacy-taint.js +205 -0
  36. package/src/dataflow/smt-feasibility.js +189 -0
  37. package/src/engine.js +890 -132
  38. package/src/integrations/index.js +2 -1
  39. package/src/ir/.agentic-security/findings.json +240 -6
  40. package/src/ir/.agentic-security/last-scan.json +240 -6
  41. package/src/ir/.agentic-security/last-scan.json.sig +1 -1
  42. package/src/ir/.agentic-security/scan-history.json +16 -594
  43. package/src/ir/.agentic-security/streak.json +8 -9
  44. package/src/ir/callgraph.js +27 -7
  45. package/src/ir/cpp-preprocessor.js +142 -0
  46. package/src/ir/csharp-ir.js +604 -0
  47. package/src/ir/universal-ir.js +403 -0
  48. package/src/llm-validator/index.js +7 -5
  49. package/src/mcp/.agentic-security/findings.json +8632 -0
  50. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  51. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  52. package/src/mcp/.agentic-security/scan-history.json +143 -0
  53. package/src/mcp/.agentic-security/streak.json +20 -0
  54. package/src/mcp/audit.js +5 -0
  55. package/src/mcp/tools.js +90 -1
  56. package/src/posture/.agentic-security/findings.json +16809 -4367
  57. package/src/posture/.agentic-security/last-scan.json +16809 -4367
  58. package/src/posture/.agentic-security/last-scan.json.sig +1 -1
  59. package/src/posture/.agentic-security/scan-history.json +6689 -177
  60. package/src/posture/.agentic-security/streak.json +8 -7
  61. package/src/posture/api-contract.js +193 -0
  62. package/src/posture/attack-taxonomy.js +227 -0
  63. package/src/posture/calibration-drift.js +2 -1
  64. package/src/posture/calibration.js +3 -2
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/csharp-analysis.js +330 -0
  68. package/src/posture/exploit-bundle.js +210 -0
  69. package/src/posture/federated-learning.js +172 -0
  70. package/src/posture/fix-history.js +8 -2
  71. package/src/posture/license-attributions.js +94 -0
  72. package/src/posture/license-graph.js +238 -0
  73. package/src/posture/pqc-migration-plan.js +158 -0
  74. package/src/posture/profile.js +4 -5
  75. package/src/posture/reachability-filter.js +33 -2
  76. package/src/posture/realtime-cve-monitor.js +214 -0
  77. package/src/posture/rule-overrides.js +2 -3
  78. package/src/posture/rule-pack-signing.js +2 -3
  79. package/src/posture/rule-synthesis.js +5 -6
  80. package/src/posture/runtime-correlation.js +174 -0
  81. package/src/posture/sbom-diff.js +171 -0
  82. package/src/posture/sca-policy.js +235 -0
  83. package/src/posture/sca-upgrade.js +259 -0
  84. package/src/posture/security-trend.js +4 -7
  85. package/src/posture/state-dir.js +124 -0
  86. package/src/posture/streak.js +3 -0
  87. package/src/posture/suppressions.js +5 -8
  88. package/src/posture/threat-model-auto.js +268 -0
  89. package/src/posture/triage-learning.js +170 -0
  90. package/src/posture/triage.js +29 -6
  91. package/src/posture/validator-metrics.js +3 -6
  92. package/src/sast/.agentic-security/findings.json +996 -32
  93. package/src/sast/.agentic-security/last-scan.json +996 -32
  94. package/src/sast/.agentic-security/last-scan.json.sig +1 -1
  95. package/src/sast/.agentic-security/scan-history.json +565 -32
  96. package/src/sast/.agentic-security/streak.json +10 -8
  97. package/src/sast/_secret-entropy.js +145 -0
  98. package/src/sast/cloud-iam.js +312 -0
  99. package/src/sast/cpp.js +138 -4
  100. package/src/sast/crypto-protocol.js +388 -0
  101. package/src/sast/csharp-tokenizer.js +392 -0
  102. package/src/sast/csharp.js +924 -138
  103. package/src/sast/dapp-frontend.js +200 -0
  104. package/src/sast/db-taint.js +24 -0
  105. package/src/sast/k8s-admission.js +271 -0
  106. package/src/sast/llm-app.js +272 -0
  107. package/src/sast/ml-supply-chain.js +259 -0
  108. package/src/sast/mobile.js +224 -0
  109. package/src/sast/post-quantum-crypto.js +348 -0
  110. package/src/sast/rust.js +26 -0
  111. package/src/sast/web3-advanced.js +375 -0
  112. package/src/sca/.agentic-security/findings.json +6044 -171
  113. package/src/sca/.agentic-security/last-scan.json +6044 -171
  114. package/src/sca/.agentic-security/last-scan.json.sig +1 -1
  115. package/src/sca/.agentic-security/scan-history.json +83 -6
  116. package/src/sca/.agentic-security/streak.json +9 -9
  117. package/src/sca/CLAUDE.md +161 -0
  118. package/src/sca/binary-metadata.js +146 -0
  119. package/src/sca/py-package-functions.js +118 -0
  120. package/src/sca/sigstore-verify.js +215 -0
  121. package/src/sca/vendor-detect.js +53 -0
  122. package/src/report/.agentic-security/findings.json +0 -80
  123. package/src/report/.agentic-security/last-scan.json +0 -80
  124. package/src/report/.agentic-security/last-scan.json.sig +0 -1
  125. package/src/report/.agentic-security/scan-history.json +0 -35
  126. 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 path.join(scanRoot || process.cwd(), OVERRIDES_PATH);
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 path.join(scanRoot || process.cwd(), TRUSTED_KEYS_FILE);
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 = path.join(scanRoot || process.cwd(), TRIAGE_PATH);
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 = path.join(scanRoot || process.cwd(), PROPOSED_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, TRIAGE_PATH, PROPOSED_DIR };
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 };