-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtoxicFlow.ts
More file actions
236 lines (221 loc) · 9.54 KB
/
Copy pathtoxicFlow.ts
File metadata and controls
236 lines (221 loc) · 9.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
/**
* Stage 4 — Toxic-flow graph (the flagship analysis).
*
* The "lethal trifecta": an agent session that can reach
* UNTRUSTED_INPUT → SENSITIVE_DATA_SOURCE → EXTERNAL_SINK
* holds an exfiltration primitive, whether those roles live in one tool or are
* composed across several tools and the client's built-ins.
*
* Honesty contract: static analysis proves the primitive *exists* (capability
* co-presence), not that a runtime chain *will* execute. Findings are worded
* accordingly, and only a single-tool completed trifecta is `confirmed`.
*/
import type {
DetectorContext,
Finding,
ServerSurface,
ToolCapability,
ToxicFlow,
} from '../types.js';
import { CLIENT_BUILTINS } from '../data/capabilityLexicon.js';
import { representativeTrifectaPath, representativeExfilPath, renderPath, type FlowPath } from './flowGraph.js';
interface FlowAnalysis {
flows: ToxicFlow[];
findings: Finding[];
}
const FLOW_REFS: string[] = [];
function isSinkCap(c: ToolCapability): boolean {
return c.tags.includes('external-sink') || c.tags.includes('code-exec');
}
export function analyzeToxicFlows(ctx: DetectorContext): FlowAnalysis {
const caps: ToolCapability[] = [...ctx.capabilities];
if (ctx.config.includeBuiltins) {
for (const b of CLIENT_BUILTINS) caps.push({ tool: b.tool, tags: b.tags, reasons: {} });
}
const untrusted = caps.filter((c) => c.tags.includes('untrusted-input')).map((c) => c.tool);
const sources = caps.filter((c) => c.tags.includes('sensitive-source')).map((c) => c.tool);
const sinks = caps.filter(isSinkCap).map((c) => c.tool);
const selfTrifecta = caps.filter(
(c) => c.tags.includes('untrusted-input') && c.tags.includes('sensitive-source') && isSinkCap(c),
);
const selfTrifectaNames = new Set(selfTrifecta.map((c) => c.tool));
const selfExfil = caps.filter(
(c) =>
!selfTrifectaNames.has(c.tool) &&
c.tags.includes('sensitive-source') &&
isSinkCap(c) &&
!c.tags.includes('untrusted-input'),
);
const fullTrifectaPresent = untrusted.length > 0 && sources.length > 0 && sinks.length > 0;
const flows: ToxicFlow[] = [];
const findings: Finding[] = [];
let flowSeq = 0;
const nextId = (): string => `flow-${++flowSeq}`;
// 1) Self-contained completed trifecta — one tool ingests, reads, and exfiltrates.
for (const c of selfTrifecta) {
const id = nextId();
flows.push({
id,
severity: 'critical',
confidence: 'confirmed',
untrustedInput: [c.tool],
sensitiveSource: [c.tool],
externalSink: [c.tool],
selfContained: true,
description: `Tool "${c.tool}" alone ingests untrusted input, reads sensitive data, and can send it out.`,
});
findings.push({
ruleId: 'MTC-FLOW-001',
title: `Self-contained exfiltration primitive: "${c.tool}"`,
category: 'exfiltration',
severity: 'critical',
confidence: 'confirmed',
description:
`Tool "${c.tool}" holds all three trifecta roles by itself (untrusted-input + sensitive-source + ` +
`external-sink). A single call can pull attacker-controlled content, read private data, and exfiltrate ` +
`it — no composition required.`,
remediation: 'Split responsibilities across separate, individually-consented tools; never combine untrusted input, secret reads, and egress in one tool.',
location: { kind: 'flow', name: c.tool },
owasp: 'LLM02:2025 Sensitive Information Disclosure',
references: FLOW_REFS,
data: { tags: c.tags },
});
}
// 2) Cross-tool completed trifecta (only if no single tool already completes it).
if (fullTrifectaPresent && selfTrifecta.length === 0) {
const id = nextId();
const p: FlowPath | null = representativeTrifectaPath(caps, ctx.surface.tools);
const chain = p ? renderPath(p) : `[${untrusted.join(', ')}] → [${sources.join(', ')}] → [${sinks.join(', ')}]`;
flows.push({
id,
severity: 'critical',
confidence: 'strong',
untrustedInput: untrusted,
sensitiveSource: sources,
externalSink: sinks,
selfContained: false,
path: p?.path,
pathWired: p?.wired,
description: `A cross-tool exfiltration chain exists: ${chain}.`,
});
findings.push({
ruleId: 'MTC-FLOW-002',
title: 'Completed toxic-flow trifecta across tools',
category: 'exfiltration',
severity: 'critical',
confidence: 'strong',
description:
`This server (with${ctx.config.includeBuiltins ? '' : 'out'} client built-ins) exposes a complete ` +
`data-exfiltration chain: ${chain}. Untrusted input is ingested, private data is read, and it can be sent ` +
`to an external sink` +
(p?.wired
? ` — and at least one leg is a direct schema wire (⇒), where a producer's output drops straight into a ` +
`free-text parameter of the next tool, so the chain needs little agent cooperation.`
: ` via the agent composing the tools (→).`) +
` Static analysis proves the primitive exists, not that a specific run will occur.`,
remediation: 'Remove one leg of the trifecta: isolate untrusted-input tools from secret-reading tools and from egress tools, or require human approval between them.',
location: { kind: 'flow', name: p?.path?.join(' → ') },
owasp: 'LLM02:2025 Sensitive Information Disclosure',
references: FLOW_REFS,
data: { untrusted, sources, sinks, path: p?.path, edges: p?.edges, wired: p?.wired },
});
}
// 3) Self-contained exfil (reads secrets + can egress, no untrusted input leg).
for (const c of selfExfil) {
const id = nextId();
flows.push({
id,
severity: 'high',
confidence: 'strong',
untrustedInput: [],
sensitiveSource: [c.tool],
externalSink: [c.tool],
selfContained: true,
description: `Tool "${c.tool}" both reads sensitive data and can send it externally.`,
});
findings.push({
ruleId: 'MTC-FLOW-003',
title: `Read-and-egress in one tool: "${c.tool}"`,
category: 'exfiltration',
severity: 'high',
confidence: 'strong',
description:
`Tool "${c.tool}" can both read sensitive data and send data to an external destination. Even without an ` +
`explicit untrusted-input leg, this is a single-call data-exfiltration path if the model is ever ` +
`manipulated.`,
remediation: 'Separate reading from sending; require explicit user confirmation before egress of file/secret contents.',
location: { kind: 'flow', name: c.tool },
owasp: 'LLM02:2025 Sensitive Information Disclosure',
references: FLOW_REFS,
data: { tags: c.tags },
});
}
// 4) Cross-tool data-out path: a source tool and a *different* sink tool exist.
const crossExfil = !fullTrifectaPresent && sources.some((s) => sinks.some((k) => k !== s));
if (crossExfil) {
const id = nextId();
const p = representativeExfilPath(caps, ctx.surface.tools);
flows.push({
id,
severity: 'high',
confidence: 'strong',
untrustedInput: [],
sensitiveSource: sources,
externalSink: sinks,
selfContained: false,
path: p?.path,
pathWired: p?.wired,
description: `A sensitive-source → external-sink chain exists: ${p ? renderPath(p) : ''}.`,
});
findings.push({
ruleId: 'MTC-FLOW-004',
title: 'Sensitive-source and external-sink co-exist',
category: 'exfiltration',
severity: 'high',
confidence: 'strong',
description:
`Tools that read sensitive data ([${sources.join(', ')}]) and tools that can send data out ` +
`([${sinks.join(', ')}]) are exposed together. An agent can move private data to the sink.`,
remediation: 'Keep secret-reading and egress capabilities on separate, separately-approved servers.',
location: { kind: 'flow' },
owasp: 'LLM02:2025 Sensitive Information Disclosure',
references: FLOW_REFS,
data: { sources, sinks },
});
}
// 5) Untrusted input reaching an action sink (no sensitive source involved).
if (!fullTrifectaPresent && untrusted.length > 0 && sinks.length > 0 && sources.length === 0) {
const id = nextId();
flows.push({
id,
severity: 'medium',
confidence: 'strong',
untrustedInput: untrusted,
sensitiveSource: [],
externalSink: sinks,
selfContained: false,
description: 'Untrusted input can drive an external action even though no sensitive source is exposed.',
});
findings.push({
ruleId: 'MTC-FLOW-005',
title: 'Untrusted input can drive an external action',
category: 'exfiltration',
severity: 'medium',
confidence: 'strong',
description:
`Untrusted-input tools ([${untrusted.join(', ')}]) co-exist with external-action tools ([${sinks.join(', ')}]). ` +
`A prompt injection could cause unwanted external actions, though no direct sensitive-data leak path was found.`,
remediation: 'Require confirmation for state-changing/egress actions triggered after processing untrusted content.',
location: { kind: 'flow' },
owasp: 'LLM06:2025 Excessive Agency',
references: FLOW_REFS,
data: { untrusted, sinks },
});
}
return { flows, findings };
}
/** Convenience wrapper matching the pipeline stage description. */
export function toxicFlowStage(surface: ServerSurface, capabilities: ToolCapability[], config: DetectorContext['config']): FlowAnalysis {
return analyzeToxicFlows({ surface, capabilities, config });
}