agentaudit 3.10.9 → 3.12.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.
@@ -0,0 +1,913 @@
1
+ /**
2
+ * AgentAudit — Tool Poisoning Detection Engine
3
+ *
4
+ * Standalone, zero-dependency detection engine for MCP tool poisoning attacks.
5
+ * Scans tool definitions (name, description, inputSchema) for hidden instructions,
6
+ * unicode tricks, obfuscated payloads, and manipulation patterns.
7
+ *
8
+ * 8 Detection Categories:
9
+ * 1. Hidden Unicode Characters (zero-width, RTL overrides, etc.)
10
+ * 2. Instruction Injection Patterns (embedded LLM commands)
11
+ * 3. Obfuscated Payloads (Base64/Hex encoded instructions)
12
+ * 4. Excessive Description Length (anomaly detection)
13
+ * 5. Cross-Tool Manipulation (tool A references tool B)
14
+ * 6. Homoglyph Obfuscation (Cyrillic/Greek lookalikes)
15
+ * 7. Suspicious URLs (external endpoints in descriptions)
16
+ * 8. Schema Manipulation (malicious inputSchema patterns)
17
+ *
18
+ * LIMITATIONS:
19
+ * - Detects known patterns only, not novel semantic attacks
20
+ * - Cannot detect runtime-constructed payloads
21
+ * - Homoglyph list covers Cyrillic + Greek, not all Unicode blocks
22
+ * - A clean scan is NOT a security certificate
23
+ *
24
+ * @module tool-poisoning-detector
25
+ * @version 1.0.0
26
+ * @license AGPL-3.0
27
+ */
28
+
29
+ // ── Hidden Unicode Characters ────────────────────────────────
30
+
31
+ const HIDDEN_UNICODE = new Map([
32
+ [0x200B, 'Zero-Width Space'],
33
+ [0x200C, 'Zero-Width Non-Joiner'],
34
+ [0x200D, 'Zero-Width Joiner'],
35
+ [0x200E, 'Left-to-Right Mark'],
36
+ [0x200F, 'Right-to-Left Mark'],
37
+ [0x202A, 'Left-to-Right Embedding'],
38
+ [0x202B, 'Right-to-Left Embedding'],
39
+ [0x202C, 'Pop Directional Formatting'],
40
+ [0x202D, 'Left-to-Right Override'],
41
+ [0x202E, 'Right-to-Left Override'],
42
+ [0x2060, 'Word Joiner'],
43
+ [0x2061, 'Function Application'],
44
+ [0x2062, 'Invisible Times'],
45
+ [0x2063, 'Invisible Separator'],
46
+ [0x2064, 'Invisible Plus'],
47
+ [0xFEFF, 'Byte Order Mark'],
48
+ [0xFFF9, 'Interlinear Annotation Anchor'],
49
+ [0xFFFA, 'Interlinear Annotation Separator'],
50
+ [0xFFFB, 'Interlinear Annotation Terminator'],
51
+ // Tag characters (used for invisible text)
52
+ [0xE0001, 'Language Tag'],
53
+ [0xE007F, 'Cancel Tag'],
54
+ ]);
55
+
56
+ // Range-based checks for tag characters U+E0020-U+E007E
57
+ function isTagCharacter(cp) {
58
+ return cp >= 0xE0020 && cp <= 0xE007E;
59
+ }
60
+
61
+ // ── Homoglyph Maps ───────────────────────────────────────────
62
+
63
+ // Cyrillic characters that look like Latin
64
+ const CYRILLIC_HOMOGLYPHS = new Map([
65
+ ['а', 'a'], ['с', 'c'], ['е', 'e'], ['і', 'i'], ['о', 'o'],
66
+ ['р', 'p'], ['ѕ', 's'], ['у', 'y'], ['х', 'x'], ['ј', 'j'],
67
+ ['һ', 'h'], ['ԁ', 'd'], ['ɡ', 'g'], ['ⅼ', 'l'], ['ո', 'n'],
68
+ ['ⅿ', 'm'], ['ԝ', 'w'], ['ν', 'v'], ['Ь', 'b'],
69
+ // Uppercase
70
+ ['А', 'A'], ['В', 'B'], ['С', 'C'], ['Е', 'E'], ['Н', 'H'],
71
+ ['І', 'I'], ['К', 'K'], ['М', 'M'], ['О', 'O'], ['Р', 'P'],
72
+ ['Ѕ', 'S'], ['Т', 'T'], ['Х', 'X'], ['У', 'Y'],
73
+ ]);
74
+
75
+ // Greek characters that look like Latin
76
+ const GREEK_HOMOGLYPHS = new Map([
77
+ ['α', 'a'], ['ο', 'o'], ['ε', 'e'], ['ν', 'v'], ['τ', 't'],
78
+ ['ι', 'i'], ['κ', 'k'], ['ρ', 'p'], ['η', 'n'], ['ω', 'w'],
79
+ ['χ', 'x'], ['γ', 'y'],
80
+ // Uppercase
81
+ ['Α', 'A'], ['Β', 'B'], ['Ε', 'E'], ['Η', 'H'], ['Ι', 'I'],
82
+ ['Κ', 'K'], ['Μ', 'M'], ['Ν', 'N'], ['Ο', 'O'], ['Ρ', 'P'],
83
+ ['Τ', 'T'], ['Χ', 'X'], ['Υ', 'Y'], ['Ζ', 'Z'],
84
+ ]);
85
+
86
+ // ── Instruction Injection Patterns ───────────────────────────
87
+
88
+ const INJECTION_PATTERNS = [
89
+ // Critical: Explicit override/hijack patterns
90
+ { id: 'TP_INJECT_001', pattern: /<\s*IMPORTANT\s*>/i, severity: 'critical', title: 'Hidden <IMPORTANT> tag in description', confidence: 'high' },
91
+ { id: 'TP_INJECT_002', pattern: /<\s*SYSTEM\s*>/i, severity: 'critical', title: 'Hidden <SYSTEM> tag in description', confidence: 'high' },
92
+ { id: 'TP_INJECT_003', pattern: /ignore\s+(?:all\s+)?previous\s+instructions/i, severity: 'critical', title: 'Instruction override attempt', confidence: 'high' },
93
+ { id: 'TP_INJECT_004', pattern: /you\s+are\s+now\s+(?:a|an|in)\b/i, severity: 'critical', title: 'Identity/role hijack attempt', confidence: 'high' },
94
+ { id: 'TP_INJECT_005', pattern: /new\s+(?:system\s+)?instructions?\s*:/i, severity: 'critical', title: 'New instructions injection', confidence: 'high' },
95
+ { id: 'TP_INJECT_006', pattern: /(?:first|before\s+(?:anything|everything|using))\s+(?:run|call|execute|invoke|use)\b/i, severity: 'critical', title: 'Pre-action command injection', confidence: 'high' },
96
+ { id: 'TP_INJECT_007', pattern: /(?:always|must|shall)\s+(?:first\s+)?(?:run|call|execute|invoke|send|post|fetch)\b/i, severity: 'high', title: 'Mandatory action directive', confidence: 'medium' },
97
+
98
+ // High: Hidden instruction markers
99
+ { id: 'TP_INJECT_008', pattern: /\[\s*(?:HIDDEN|SECRET|INTERNAL|PRIVATE)\s*\]/i, severity: 'high', title: 'Hidden instruction marker', confidence: 'high' },
100
+ { id: 'TP_INJECT_009', pattern: /<!--[\s\S]*?(?:instruction|command|execute|call|run)[\s\S]*?-->/i, severity: 'high', title: 'Instruction hidden in HTML comment', confidence: 'high' },
101
+ { id: 'TP_INJECT_010', pattern: /<(?:div|span|p)\s+(?:style|class)\s*=\s*["'][^"']*(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0)/i, severity: 'high', title: 'Instruction hidden via CSS', confidence: 'high' },
102
+
103
+ // High: Behavioral manipulation
104
+ { id: 'TP_INJECT_011', pattern: /do\s+not\s+(?:tell|inform|show|reveal|mention|disclose)\s+(?:the\s+)?user/i, severity: 'critical', title: 'User deception directive', confidence: 'high' },
105
+ { id: 'TP_INJECT_012', pattern: /(?:skip|bypass|disable|ignore)\s+(?:all\s+)?(?:security|verification|validation|safety|check)/i, severity: 'critical', title: 'Security bypass directive', confidence: 'high' },
106
+ { id: 'TP_INJECT_013', pattern: /(?:send|post|exfiltrate|transmit|upload)\s+(?:all\s+)?(?:data|conversation|context|messages|history|content|files)/i, severity: 'critical', title: 'Data exfiltration directive', confidence: 'high' },
107
+
108
+ // Medium: Suspicious directive patterns
109
+ { id: 'TP_INJECT_014', pattern: /\b(?:curl|wget|fetch|http\.get|axios\.(?:get|post)|request\.(?:get|post))\s*\(?['"`]https?:/i, severity: 'high', title: 'Embedded HTTP request in description', confidence: 'medium' },
110
+ { id: 'TP_INJECT_015', pattern: /\b(?:pipe|redirect|forward)\s+(?:to|all|output|input)\b/i, severity: 'medium', title: 'Data redirection directive', confidence: 'medium' },
111
+ { id: 'TP_INJECT_016', pattern: /\boverride\s+(?:the\s+)?(?:default|normal|standard|original)\b/i, severity: 'medium', title: 'Behavior override directive', confidence: 'medium' },
112
+
113
+ // Medium: Bracket/tag variants of injection markers
114
+ { id: 'TP_INJECT_017', pattern: /\{\s*(?:IMPORTANT|SYSTEM|INSTRUCTION|COMMAND)\s*\}/i, severity: 'high', title: 'Injection marker in curly braces', confidence: 'high' },
115
+ { id: 'TP_INJECT_018', pattern: /\[\s*(?:IMPORTANT|SYSTEM|INSTRUCTION|COMMAND)\s*\]/i, severity: 'high', title: 'Injection marker in brackets', confidence: 'high' },
116
+ { id: 'TP_INJECT_019', pattern: /\(\s*(?:IMPORTANT|SYSTEM|INSTRUCTION|COMMAND)\s*\)/i, severity: 'high', title: 'Injection marker in parentheses', confidence: 'high' },
117
+
118
+ // Medium: Markdown-based hiding
119
+ { id: 'TP_INJECT_020', pattern: /<details>[\s\S]*?<summary>[\s\S]*?<\/summary>[\s\S]*?(?:call|run|execute|invoke|send|ignore)/i, severity: 'high', title: 'Instruction hidden in collapsible section', confidence: 'medium' },
120
+
121
+ // Medium: Shell command patterns in descriptions
122
+ { id: 'TP_INJECT_021', pattern: /[`"']\s*(?:rm\s+-rf|curl\s+.*\|\s*(?:bash|sh)|wget\s+.*-O\s*-\s*\|\s*(?:bash|sh)|eval\s*\(|exec\s*\()/i, severity: 'critical', title: 'Shell command in description', confidence: 'high' },
123
+ ];
124
+
125
+ // ── Suspicious URL Patterns ──────────────────────────────────
126
+
127
+ const SUSPICIOUS_URL_PATTERNS = [
128
+ { id: 'TP_URL_001', pattern: /https?:\/\/(?!(?:github\.com|npmjs\.com|pypi\.org|docs\.|api\.|www\.)\b)[a-z0-9][-a-z0-9]*\.[a-z]{2,}(?:\/[^\s"')\]]*)?/i, severity: 'medium', title: 'External URL in tool description', confidence: 'low' },
129
+ { id: 'TP_URL_002', pattern: /(?:ngrok|serveo|localtunnel|localhost|127\.0\.0\.1|0\.0\.0\.0|burp|oast|interact\.sh|webhook\.site|requestbin|pipedream)/i, severity: 'high', title: 'Development/tunneling URL in description', confidence: 'high' },
130
+ ];
131
+
132
+ // ── Detection Functions ──────────────────────────────────────
133
+
134
+ /**
135
+ * Category 1: Hidden Unicode Characters
136
+ */
137
+ function detectHiddenUnicode(text, toolName, field) {
138
+ const findings = [];
139
+ const found = new Map(); // codepoint → positions[]
140
+
141
+ for (let i = 0; i < text.length; i++) {
142
+ const cp = text.codePointAt(i);
143
+ if (cp > 0xFFFF) i++; // Surrogate pair
144
+
145
+ if (HIDDEN_UNICODE.has(cp) || isTagCharacter(cp)) {
146
+ const name = HIDDEN_UNICODE.get(cp) || `Tag Character U+${cp.toString(16).toUpperCase()}`;
147
+ if (!found.has(cp)) found.set(cp, { name, positions: [] });
148
+ found.get(cp).positions.push(i);
149
+ }
150
+ }
151
+
152
+ for (const [cp, info] of found) {
153
+ // Single BOM at position 0 is common and benign
154
+ if (cp === 0xFEFF && info.positions.length === 1 && info.positions[0] === 0) continue;
155
+
156
+ const isRTL = [0x200F, 0x202B, 0x202D, 0x202E].includes(cp);
157
+ const isTag = isTagCharacter(cp);
158
+ const count = info.positions.length;
159
+
160
+ let severity = 'warning';
161
+ if (isRTL || isTag) severity = 'critical';
162
+ else if (count > 3) severity = 'high';
163
+ else if (count > 1) severity = 'medium';
164
+
165
+ findings.push({
166
+ tool_name: toolName,
167
+ field,
168
+ category: 'hidden_unicode',
169
+ pattern_id: isRTL ? 'TP_UNICODE_002' : isTag ? 'TP_UNICODE_003' : 'TP_UNICODE_001',
170
+ severity,
171
+ title: isTag ? 'Invisible tag characters (can encode hidden text)' : `Hidden ${info.name} character(s)`,
172
+ description: `Found ${count} instance(s) of ${info.name} (U+${cp.toString(16).toUpperCase().padStart(4, '0')}) in ${field}. ${isRTL ? 'RTL override characters can make text appear different from its actual content.' : isTag ? 'Tag characters can encode invisible text that is processed by some systems.' : 'Zero-width characters can hide content from visual inspection.'}`,
173
+ evidence: `${count}x U+${cp.toString(16).toUpperCase().padStart(4, '0')} at position(s): ${info.positions.slice(0, 5).join(', ')}${count > 5 ? ` ... and ${count - 5} more` : ''}`,
174
+ position: { start: info.positions[0], end: info.positions[info.positions.length - 1] + 1 },
175
+ confidence: 'high',
176
+ });
177
+ }
178
+
179
+ return findings;
180
+ }
181
+
182
+ /**
183
+ * Category 2: Instruction Injection Patterns
184
+ */
185
+ function detectInjectionPatterns(text, toolName, field) {
186
+ const findings = [];
187
+
188
+ for (const rule of INJECTION_PATTERNS) {
189
+ const match = rule.pattern.exec(text);
190
+ if (match) {
191
+ // Extract context around the match (up to 100 chars before and after)
192
+ const start = Math.max(0, match.index - 50);
193
+ const end = Math.min(text.length, match.index + match[0].length + 50);
194
+ const context = text.slice(start, end);
195
+
196
+ findings.push({
197
+ tool_name: toolName,
198
+ field,
199
+ category: 'instruction_injection',
200
+ pattern_id: rule.id,
201
+ severity: rule.severity,
202
+ title: rule.title,
203
+ description: `Detected pattern in ${field}: "${match[0].slice(0, 100)}"`,
204
+ evidence: context.replace(/[\n\r]/g, ' ').trim(),
205
+ position: { start: match.index, end: match.index + match[0].length },
206
+ confidence: rule.confidence,
207
+ });
208
+ }
209
+ }
210
+
211
+ return findings;
212
+ }
213
+
214
+ /**
215
+ * Category 3: Obfuscated Payloads (Base64, Hex)
216
+ */
217
+ function detectObfuscatedPayloads(text, toolName, field) {
218
+ const findings = [];
219
+
220
+ // Base64 detection
221
+ const b64Regex = /(?:^|[\s"'=:,({[\]])([A-Za-z0-9+/]{24,}={0,2})(?:[\s"',:)}\].]|$)/g;
222
+ let match;
223
+ while ((match = b64Regex.exec(text)) !== null) {
224
+ const candidate = match[1];
225
+ const decoded = tryBase64Decode(candidate);
226
+ if (decoded && isPrintableRatio(decoded, 0.75)) {
227
+ // Check if decoded content contains suspicious patterns
228
+ const subFindings = scanTextForInjection(decoded);
229
+ if (subFindings.length > 0) {
230
+ findings.push({
231
+ tool_name: toolName,
232
+ field,
233
+ category: 'obfuscated_payload',
234
+ pattern_id: 'TP_OBFUSC_001',
235
+ severity: 'critical',
236
+ title: 'Malicious payload hidden in Base64',
237
+ description: `Base64 string decodes to text containing injection pattern(s): ${subFindings.map(f => f.id).join(', ')}`,
238
+ evidence: `Encoded: "${candidate.slice(0, 60)}..." → Decoded: "${decoded.slice(0, 100)}"`,
239
+ position: { start: match.index, end: match.index + match[0].length },
240
+ confidence: 'high',
241
+ });
242
+ } else if (decoded.length > 50) {
243
+ // Long decodeable Base64 without clear injection — still suspicious
244
+ findings.push({
245
+ tool_name: toolName,
246
+ field,
247
+ category: 'obfuscated_payload',
248
+ pattern_id: 'TP_OBFUSC_002',
249
+ severity: 'medium',
250
+ title: 'Suspicious Base64-encoded content in description',
251
+ description: `Found Base64-encoded text (${decoded.length} chars decoded) in tool ${field}. While no injection pattern was detected, encoded content in descriptions is unusual.`,
252
+ evidence: `Encoded: "${candidate.slice(0, 60)}..." → Decoded preview: "${decoded.slice(0, 80)}"`,
253
+ position: { start: match.index, end: match.index + match[0].length },
254
+ confidence: 'medium',
255
+ });
256
+ }
257
+
258
+ // Check nested encoding (depth 2 max)
259
+ const nested = tryBase64Decode(decoded);
260
+ if (nested && isPrintableRatio(nested, 0.75) && nested.length > 10) {
261
+ const nestedFindings = scanTextForInjection(nested);
262
+ findings.push({
263
+ tool_name: toolName,
264
+ field,
265
+ category: 'obfuscated_payload',
266
+ pattern_id: 'TP_OBFUSC_003',
267
+ severity: 'critical',
268
+ title: 'Double-encoded (nested Base64) payload',
269
+ description: `Found nested Base64 encoding${nestedFindings.length > 0 ? ' containing injection patterns: ' + nestedFindings.map(f => f.id).join(', ') : ''}. Double-encoding is a strong indicator of intentional obfuscation.`,
270
+ evidence: `Layer 1: "${candidate.slice(0, 40)}..." → Layer 2: "${nested.slice(0, 60)}"`,
271
+ position: { start: match.index, end: match.index + match[0].length },
272
+ confidence: 'high',
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ // Hex-encoded strings (\x41\x42... or 0x41 0x42...)
279
+ const hexRegex = /(?:\\x[0-9a-f]{2}){8,}/gi;
280
+ while ((match = hexRegex.exec(text)) !== null) {
281
+ const decoded = match[0].replace(/\\x/g, '').replace(/../g, (h) => String.fromCharCode(parseInt(h, 16)));
282
+ if (isPrintableRatio(decoded, 0.75)) {
283
+ const subFindings = scanTextForInjection(decoded);
284
+ findings.push({
285
+ tool_name: toolName,
286
+ field,
287
+ category: 'obfuscated_payload',
288
+ pattern_id: 'TP_OBFUSC_004',
289
+ severity: subFindings.length > 0 ? 'critical' : 'high',
290
+ title: 'Hex-encoded content in description',
291
+ description: `Found hex-encoded text (${decoded.length} chars decoded)${subFindings.length > 0 ? ' containing injection patterns' : ''}. Hex encoding in descriptions is highly suspicious.`,
292
+ evidence: `Hex: "${match[0].slice(0, 60)}..." → Decoded: "${decoded.slice(0, 80)}"`,
293
+ position: { start: match.index, end: match.index + match[0].length },
294
+ confidence: 'high',
295
+ });
296
+ }
297
+ }
298
+
299
+ return findings;
300
+ }
301
+
302
+ /**
303
+ * Category 4: Excessive Description Length (anomaly detection)
304
+ */
305
+ function detectExcessiveLength(tools) {
306
+ const findings = [];
307
+
308
+ // Check for empty/missing descriptions first (before early return)
309
+ for (const tool of tools) {
310
+ if (!tool.description || tool.description.trim().length === 0) {
311
+ findings.push({
312
+ tool_name: tool.name || '(unnamed)',
313
+ field: 'description',
314
+ category: 'excessive_length',
315
+ pattern_id: 'TP_LENGTH_002',
316
+ severity: 'info',
317
+ title: 'Tool has no description',
318
+ description: 'A tool without a description cannot be assessed for content safety. This may indicate poor documentation or intentional omission.',
319
+ evidence: 'Empty or missing description',
320
+ position: { start: 0, end: 0 },
321
+ confidence: 'high',
322
+ });
323
+ }
324
+ }
325
+
326
+ // Get description lengths for all tools that have descriptions
327
+ const lengths = tools
328
+ .filter(t => t.description)
329
+ .map(t => ({ name: t.name, len: t.description.length }));
330
+
331
+ if (lengths.length === 0) return findings;
332
+
333
+ // Absolute thresholds (always apply)
334
+ for (const { name, len } of lengths) {
335
+ if (len > 2000) {
336
+ findings.push({
337
+ tool_name: name,
338
+ field: 'description',
339
+ category: 'excessive_length',
340
+ pattern_id: 'TP_LENGTH_001',
341
+ severity: 'high',
342
+ title: 'Extremely long tool description',
343
+ description: `Description is ${len} characters. Extremely long descriptions can hide injection content and are unusual for legitimate tools.`,
344
+ evidence: `Length: ${len} chars (threshold: 2000)`,
345
+ position: { start: 0, end: len },
346
+ confidence: 'medium',
347
+ });
348
+ } else if (len > 1000) {
349
+ findings.push({
350
+ tool_name: name,
351
+ field: 'description',
352
+ category: 'excessive_length',
353
+ pattern_id: 'TP_LENGTH_001',
354
+ severity: 'warning',
355
+ title: 'Unusually long tool description',
356
+ description: `Description is ${len} characters. While not necessarily malicious, long descriptions provide more surface area for hidden content.`,
357
+ evidence: `Length: ${len} chars (threshold: 1000)`,
358
+ position: { start: 0, end: len },
359
+ confidence: 'low',
360
+ });
361
+ }
362
+ }
363
+
364
+ // z-Score anomaly detection (only meaningful with 5+ tools)
365
+ if (lengths.length >= 5) {
366
+ const mean = lengths.reduce((s, l) => s + l.len, 0) / lengths.length;
367
+ const variance = lengths.reduce((s, l) => s + (l.len - mean) ** 2, 0) / lengths.length;
368
+ const stddev = Math.sqrt(variance);
369
+
370
+ if (stddev > 0) {
371
+ for (const { name, len } of lengths) {
372
+ const zScore = (len - mean) / stddev;
373
+ if (zScore > 2.5) {
374
+ // Don't duplicate if already caught by absolute threshold
375
+ const alreadyFlagged = findings.some(f => f.tool_name === name && f.category === 'excessive_length');
376
+ if (!alreadyFlagged) {
377
+ findings.push({
378
+ tool_name: name,
379
+ field: 'description',
380
+ category: 'excessive_length',
381
+ pattern_id: 'TP_LENGTH_001',
382
+ severity: 'warning',
383
+ title: 'Statistically anomalous description length',
384
+ description: `Description is ${len} characters (z-score: ${zScore.toFixed(1)}, mean: ${Math.round(mean)}, stddev: ${Math.round(stddev)}). This is significantly longer than the other ${lengths.length - 1} tool(s) in this server.`,
385
+ evidence: `Length: ${len} chars, z-score: ${zScore.toFixed(2)} (threshold: 2.5)`,
386
+ position: { start: 0, end: len },
387
+ confidence: 'medium',
388
+ });
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ return findings;
396
+ }
397
+
398
+ /**
399
+ * Category 5: Cross-Tool Manipulation
400
+ */
401
+ function detectCrossToolManipulation(tools) {
402
+ const findings = [];
403
+ const toolNames = new Set(tools.map(t => (t.name || '').toLowerCase()));
404
+
405
+ for (const tool of tools) {
406
+ const text = (tool.description || '') + ' ' + flattenSchema(tool.inputSchema);
407
+ const name = tool.name || '(unnamed)';
408
+
409
+ for (const otherName of toolNames) {
410
+ if (otherName === name.toLowerCase() || otherName.length < 3) continue;
411
+
412
+ // Check if this tool's description references another tool
413
+ const refRegex = new RegExp(
414
+ `(?:call|use|invoke|run|execute|trigger)\\s+(?:the\\s+)?(?:["'\`]?${escapeRegex(otherName)}["'\`]?)\\b` +
415
+ `|(?:before|after|instead\\s+of)\\s+(?:using\\s+)?(?:["'\`]?${escapeRegex(otherName)}["'\`]?)\\b` +
416
+ `|\\b${escapeRegex(otherName)}\\b.*?(?:first|before|instead|always)`,
417
+ 'i'
418
+ );
419
+
420
+ const match = refRegex.exec(text);
421
+ if (match) {
422
+ findings.push({
423
+ tool_name: name,
424
+ field: 'description',
425
+ category: 'cross_tool_manipulation',
426
+ pattern_id: 'TP_CROSS_001',
427
+ severity: 'high',
428
+ title: `Tool references another tool "${otherName}" with action directive`,
429
+ description: `"${name}" contains a directive involving tool "${otherName}". This pattern can be used to chain tool calls in unintended ways.`,
430
+ evidence: match[0].slice(0, 120),
431
+ position: { start: match.index, end: match.index + match[0].length },
432
+ confidence: 'high',
433
+ });
434
+ }
435
+ }
436
+
437
+ // Check for generic cross-tool patterns (without specific tool names)
438
+ const genericPatterns = [
439
+ { pattern: /(?:before|prior\s+to)\s+(?:using|calling|invoking)\s+(?:any\s+)?other\s+tool/i, id: 'TP_CROSS_002', title: 'Priority override — demands execution before other tools' },
440
+ { pattern: /(?:do\s+not|never|avoid)\s+(?:use|call|invoke)\s+(?:any\s+)?other\s+tool/i, id: 'TP_CROSS_003', title: 'Tool exclusivity demand — blocks other tools' },
441
+ { pattern: /(?:replace|override|supersede|take\s+over)\s+(?:the\s+)?(?:function|role|behavior)\s+of/i, id: 'TP_CROSS_003', title: 'Tool impersonation — claims to replace another tool' },
442
+ ];
443
+
444
+ for (const gp of genericPatterns) {
445
+ const gMatch = gp.pattern.exec(text);
446
+ if (gMatch) {
447
+ findings.push({
448
+ tool_name: name,
449
+ field: 'description',
450
+ category: 'cross_tool_manipulation',
451
+ pattern_id: gp.id,
452
+ severity: 'critical',
453
+ title: gp.title,
454
+ description: `"${name}" contains a cross-tool manipulation pattern: "${gMatch[0]}"`,
455
+ evidence: gMatch[0].slice(0, 120),
456
+ position: { start: gMatch.index, end: gMatch.index + gMatch[0].length },
457
+ confidence: 'high',
458
+ });
459
+ }
460
+ }
461
+ }
462
+
463
+ // Check for duplicate tool names
464
+ const nameCounts = {};
465
+ for (const tool of tools) {
466
+ const n = (tool.name || '').toLowerCase();
467
+ nameCounts[n] = (nameCounts[n] || 0) + 1;
468
+ }
469
+ for (const [n, count] of Object.entries(nameCounts)) {
470
+ if (count > 1 && n.length > 0) {
471
+ findings.push({
472
+ tool_name: n,
473
+ field: 'name',
474
+ category: 'cross_tool_manipulation',
475
+ pattern_id: 'TP_CROSS_004',
476
+ severity: 'high',
477
+ title: 'Duplicate tool name detected',
478
+ description: `Tool name "${n}" appears ${count} times. Duplicate names can cause unpredictable behavior and may be used to shadow legitimate tools.`,
479
+ evidence: `"${n}" appears ${count}x`,
480
+ position: { start: 0, end: 0 },
481
+ confidence: 'high',
482
+ });
483
+ }
484
+ }
485
+
486
+ return findings;
487
+ }
488
+
489
+ /**
490
+ * Category 6: Homoglyph Obfuscation
491
+ */
492
+ function detectHomoglyphs(text, toolName, field) {
493
+ const findings = [];
494
+ const found = []; // { char, latin, position }
495
+
496
+ for (let i = 0; i < text.length; i++) {
497
+ const ch = text[i];
498
+ const latinCyrillic = CYRILLIC_HOMOGLYPHS.get(ch);
499
+ const latinGreek = GREEK_HOMOGLYPHS.get(ch);
500
+
501
+ if (latinCyrillic) {
502
+ found.push({ char: ch, latin: latinCyrillic, script: 'Cyrillic', position: i });
503
+ } else if (latinGreek) {
504
+ found.push({ char: ch, latin: latinGreek, script: 'Greek', position: i });
505
+ }
506
+ }
507
+
508
+ if (found.length > 0) {
509
+ // Check if the text also contains Latin characters (mixed-script = suspicious)
510
+ const hasLatin = /[a-zA-Z]/.test(text);
511
+
512
+ if (hasLatin) {
513
+ // Mixed-script text — this is the dangerous case
514
+ const scripts = new Set(found.map(f => f.script));
515
+ const chars = found.map(f => `'${f.char}'→'${f.latin}'`).slice(0, 8);
516
+
517
+ findings.push({
518
+ tool_name: toolName,
519
+ field,
520
+ category: 'homoglyph',
521
+ pattern_id: 'TP_HOMOGLYPH_001',
522
+ severity: found.length > 5 ? 'critical' : 'high',
523
+ title: `Mixed-script text with ${scripts.size > 1 ? 'Cyrillic and Greek' : [...scripts][0]} homoglyphs`,
524
+ description: `Found ${found.length} ${[...scripts].join('/')} character(s) that visually resemble Latin letters, mixed with actual Latin text. This is a strong indicator of homoglyph obfuscation used to bypass text filters.`,
525
+ evidence: `Homoglyphs: ${chars.join(', ')}${found.length > 8 ? ` ... and ${found.length - 8} more` : ''}`,
526
+ position: { start: found[0].position, end: found[found.length - 1].position + 1 },
527
+ confidence: 'high',
528
+ });
529
+ } else if (found.length > 0 && field === 'name') {
530
+ // Tool name entirely in non-Latin script that looks Latin
531
+ findings.push({
532
+ tool_name: toolName,
533
+ field,
534
+ category: 'homoglyph',
535
+ pattern_id: 'TP_HOMOGLYPH_001',
536
+ severity: 'critical',
537
+ title: 'Tool name uses non-Latin characters that mimic Latin letters',
538
+ description: `Tool name "${toolName}" appears to use ${[...new Set(found.map(f => f.script))].join('/')} characters instead of Latin. The name visually resembles "${found.map(f => f.latin).join('')}" but uses different Unicode codepoints.`,
539
+ evidence: found.map(f => `U+${f.char.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')} (${f.script} '${f.char}' → Latin '${f.latin}')`).slice(0, 5).join(', '),
540
+ position: { start: 0, end: toolName.length },
541
+ confidence: 'high',
542
+ });
543
+ }
544
+ }
545
+
546
+ return findings;
547
+ }
548
+
549
+ /**
550
+ * Category 7: Suspicious URLs
551
+ */
552
+ function detectSuspiciousUrls(text, toolName, field) {
553
+ const findings = [];
554
+
555
+ for (const rule of SUSPICIOUS_URL_PATTERNS) {
556
+ const match = rule.pattern.exec(text);
557
+ if (match) {
558
+ findings.push({
559
+ tool_name: toolName,
560
+ field,
561
+ category: 'suspicious_url',
562
+ pattern_id: rule.id,
563
+ severity: rule.severity,
564
+ title: rule.title,
565
+ description: `Found URL/endpoint reference in ${field}: "${match[0].slice(0, 100)}"`,
566
+ evidence: match[0].slice(0, 120),
567
+ position: { start: match.index, end: match.index + match[0].length },
568
+ confidence: rule.confidence,
569
+ });
570
+ }
571
+ }
572
+
573
+ return findings;
574
+ }
575
+
576
+ /**
577
+ * Category 8: Schema Manipulation
578
+ */
579
+ function detectSchemaManipulation(tool) {
580
+ const findings = [];
581
+ const name = tool.name || '(unnamed)';
582
+ const schema = tool.inputSchema;
583
+ if (!schema || typeof schema !== 'object') return findings;
584
+
585
+ // 8a: Check additionalProperties: true with no strict property definitions
586
+ if (schema.additionalProperties === true) {
587
+ const propCount = Object.keys(schema.properties || {}).length;
588
+ if (propCount === 0) {
589
+ findings.push({
590
+ tool_name: name,
591
+ field: 'inputSchema',
592
+ category: 'schema_manipulation',
593
+ pattern_id: 'TP_SCHEMA_001',
594
+ severity: 'high',
595
+ title: 'Schema accepts arbitrary properties with no defined fields',
596
+ description: 'inputSchema allows any properties (additionalProperties: true) without defining expected fields. This can be used to pass hidden parameters.',
597
+ evidence: 'additionalProperties: true, properties: {}',
598
+ position: { start: 0, end: 0 },
599
+ confidence: 'medium',
600
+ });
601
+ }
602
+ }
603
+
604
+ // 8b: Scan description fields inside property definitions
605
+ const props = schema.properties || {};
606
+ for (const [propName, propDef] of Object.entries(props)) {
607
+ if (!propDef || typeof propDef !== 'object') continue;
608
+
609
+ // Check property descriptions for injections
610
+ if (propDef.description && typeof propDef.description === 'string') {
611
+ const fieldPath = `inputSchema.properties.${propName}.description`;
612
+ const injFindings = detectInjectionPatterns(propDef.description, name, fieldPath);
613
+ findings.push(...injFindings);
614
+
615
+ const unicodeFindings = detectHiddenUnicode(propDef.description, name, fieldPath);
616
+ findings.push(...unicodeFindings);
617
+
618
+ const homoglyphFindings = detectHomoglyphs(propDef.description, name, fieldPath);
619
+ findings.push(...homoglyphFindings);
620
+ }
621
+
622
+ // 8c: Check default values for suspicious content
623
+ if (propDef.default !== undefined && typeof propDef.default === 'string') {
624
+ const defaultText = propDef.default;
625
+ const hasShellCmd = /(?:curl|wget|bash|sh|eval|exec|rm\s+-rf|python|node)\b.*(?:\||>|;|`|\$\()/.test(defaultText);
626
+ const hasSpecialChars = /[<>{}\[\]`$|;]/.test(defaultText);
627
+
628
+ if (defaultText.length > 100 || hasSpecialChars || hasShellCmd) {
629
+ const injFindings = scanTextForInjection(defaultText);
630
+ if (injFindings.length > 0 || hasShellCmd || defaultText.length > 200) {
631
+ findings.push({
632
+ tool_name: name,
633
+ field: `inputSchema.properties.${propName}.default`,
634
+ category: 'schema_manipulation',
635
+ pattern_id: 'TP_SCHEMA_002',
636
+ severity: (injFindings.length > 0 || hasShellCmd) ? 'critical' : 'medium',
637
+ title: 'Suspicious default value in schema property',
638
+ description: `Property "${propName}" has a default value that ${injFindings.length > 0 ? 'contains injection patterns' : hasShellCmd ? 'contains shell command patterns' : 'is unusually long or contains special characters'}.`,
639
+ evidence: `Default: "${defaultText.slice(0, 100)}"`,
640
+ position: { start: 0, end: 0 },
641
+ confidence: (injFindings.length > 0 || hasShellCmd) ? 'high' : 'medium',
642
+ });
643
+ }
644
+ }
645
+ }
646
+
647
+ // 8d: Check enum values for embedded commands
648
+ if (Array.isArray(propDef.enum)) {
649
+ for (const val of propDef.enum) {
650
+ if (typeof val === 'string' && val.length > 50) {
651
+ const injFindings = scanTextForInjection(val);
652
+ if (injFindings.length > 0) {
653
+ findings.push({
654
+ tool_name: name,
655
+ field: `inputSchema.properties.${propName}.enum`,
656
+ category: 'schema_manipulation',
657
+ pattern_id: 'TP_SCHEMA_003',
658
+ severity: 'high',
659
+ title: 'Injection pattern in schema enum value',
660
+ description: `Enum value for property "${propName}" contains suspicious content: "${val.slice(0, 80)}"`,
661
+ evidence: val.slice(0, 120),
662
+ position: { start: 0, end: 0 },
663
+ confidence: 'high',
664
+ });
665
+ }
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ return findings;
672
+ }
673
+
674
+ // ── Utility Functions ────────────────────────────────────────
675
+
676
+ function tryBase64Decode(text) {
677
+ try {
678
+ // Validate Base64 format
679
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return null;
680
+ if (text.length < 20) return null;
681
+ const decoded = Buffer.from(text, 'base64').toString('utf8');
682
+ // Check it actually decoded to something different
683
+ if (decoded === text || decoded.length < 4) return null;
684
+ return decoded;
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+
690
+ function isPrintableRatio(text, threshold) {
691
+ if (!text || text.length === 0) return false;
692
+ let printable = 0;
693
+ for (let i = 0; i < text.length; i++) {
694
+ const c = text.charCodeAt(i);
695
+ if ((c >= 32 && c <= 126) || c === 10 || c === 13 || c === 9) printable++;
696
+ }
697
+ return (printable / text.length) >= threshold;
698
+ }
699
+
700
+ function scanTextForInjection(text) {
701
+ const found = [];
702
+ for (const rule of INJECTION_PATTERNS) {
703
+ if (rule.pattern.test(text)) {
704
+ found.push({ id: rule.id, severity: rule.severity });
705
+ }
706
+ // Reset lastIndex for global regexes
707
+ rule.pattern.lastIndex = 0;
708
+ }
709
+ return found;
710
+ }
711
+
712
+ function escapeRegex(str) {
713
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
714
+ }
715
+
716
+ function flattenSchema(schema, depth = 0) {
717
+ if (!schema || typeof schema !== 'object' || depth > 3) return '';
718
+ let text = '';
719
+ if (schema.description) text += ' ' + schema.description;
720
+ if (schema.default && typeof schema.default === 'string') text += ' ' + schema.default;
721
+ if (schema.properties) {
722
+ for (const val of Object.values(schema.properties)) {
723
+ text += flattenSchema(val, depth + 1);
724
+ }
725
+ }
726
+ if (schema.items) text += flattenSchema(schema.items, depth + 1);
727
+ if (Array.isArray(schema.enum)) {
728
+ for (const e of schema.enum) {
729
+ if (typeof e === 'string') text += ' ' + e;
730
+ }
731
+ }
732
+ return text;
733
+ }
734
+
735
+ // ── Main Scanner ─────────────────────────────────────────────
736
+
737
+ /**
738
+ * Scan an array of MCP tool definitions for poisoning indicators.
739
+ *
740
+ * @param {Array<{name: string, description?: string, inputSchema?: object}>} tools
741
+ * Array of tool definitions to scan.
742
+ * @param {object} [options]
743
+ * @param {string} [options.server_name] - Name of the MCP server (for reporting)
744
+ * @param {boolean} [options.include_info] - Include info-level findings (default: false)
745
+ * @returns {{ findings: Array, summary: object }}
746
+ */
747
+ export function scanTools(tools, options = {}) {
748
+ const serverName = options.server_name || 'unknown';
749
+ const includeInfo = options.include_info || false;
750
+
751
+ // Input validation
752
+ if (!Array.isArray(tools)) {
753
+ return {
754
+ findings: [],
755
+ summary: {
756
+ server_name: serverName,
757
+ scan_timestamp: new Date().toISOString(),
758
+ tools_scanned: 0,
759
+ total_findings: 0,
760
+ risk_level: 'unknown',
761
+ error: 'Invalid input: tools must be an array',
762
+ by_category: {},
763
+ },
764
+ };
765
+ }
766
+
767
+ if (tools.length === 0) {
768
+ return {
769
+ findings: [],
770
+ summary: {
771
+ server_name: serverName,
772
+ scan_timestamp: new Date().toISOString(),
773
+ tools_scanned: 0,
774
+ total_findings: 0,
775
+ risk_level: 'none',
776
+ clean: true,
777
+ by_category: {},
778
+ },
779
+ };
780
+ }
781
+
782
+ const allFindings = [];
783
+
784
+ // Per-tool scans (categories 1, 2, 3, 6, 7, 8)
785
+ for (const tool of tools) {
786
+ const name = tool.name || '(unnamed)';
787
+ const desc = tool.description || '';
788
+
789
+ // Truncate extremely long descriptions for performance
790
+ const scanDesc = desc.length > 50_000 ? desc.slice(0, 50_000) : desc;
791
+
792
+ // Scan tool name
793
+ if (name && name !== '(unnamed)') {
794
+ allFindings.push(...detectHiddenUnicode(name, name, 'name'));
795
+ allFindings.push(...detectHomoglyphs(name, name, 'name'));
796
+ }
797
+
798
+ // Scan description
799
+ if (scanDesc) {
800
+ allFindings.push(...detectHiddenUnicode(scanDesc, name, 'description'));
801
+ allFindings.push(...detectInjectionPatterns(scanDesc, name, 'description'));
802
+ allFindings.push(...detectObfuscatedPayloads(scanDesc, name, 'description'));
803
+ allFindings.push(...detectHomoglyphs(scanDesc, name, 'description'));
804
+ allFindings.push(...detectSuspiciousUrls(scanDesc, name, 'description'));
805
+ }
806
+
807
+ // Scan inputSchema (category 8)
808
+ allFindings.push(...detectSchemaManipulation(tool));
809
+ }
810
+
811
+ // Multi-tool scans (categories 4, 5)
812
+ allFindings.push(...detectExcessiveLength(tools));
813
+ allFindings.push(...detectCrossToolManipulation(tools));
814
+
815
+ // Filter info-level findings if not requested
816
+ const findings = includeInfo
817
+ ? allFindings
818
+ : allFindings.filter(f => f.severity !== 'info');
819
+
820
+ // Build summary
821
+ const byCategory = {};
822
+ const bySeverity = { critical: 0, high: 0, medium: 0, warning: 0, info: 0 };
823
+ for (const f of findings) {
824
+ byCategory[f.category] = (byCategory[f.category] || 0) + 1;
825
+ bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
826
+ }
827
+
828
+ // Determine overall risk level
829
+ let riskLevel = 'none';
830
+ if (bySeverity.critical > 0) riskLevel = 'critical';
831
+ else if (bySeverity.high > 0) riskLevel = 'high';
832
+ else if (bySeverity.medium > 0) riskLevel = 'medium';
833
+ else if (bySeverity.warning > 0) riskLevel = 'low';
834
+
835
+ return {
836
+ findings,
837
+ summary: {
838
+ server_name: serverName,
839
+ scan_timestamp: new Date().toISOString(),
840
+ tools_scanned: tools.length,
841
+ total_findings: findings.length,
842
+ risk_level: riskLevel,
843
+ clean: findings.length === 0,
844
+ by_severity: bySeverity,
845
+ by_category: byCategory,
846
+ disclaimer: 'This is a heuristic scanner detecting known patterns. It cannot detect novel semantic attacks. A clean scan does NOT guarantee safety.',
847
+ },
848
+ };
849
+ }
850
+
851
+ // ── Static Tool Definition Extractor ─────────────────────────
852
+
853
+ /**
854
+ * Attempt to statically extract MCP tool definitions from source code files.
855
+ * This is a best-effort heuristic — it cannot handle dynamic tool registration.
856
+ *
857
+ * @param {Array<{path: string, content: string}>} files - Source code files
858
+ * @returns {Array<{name: string, description: string, inputSchema: object|null}>}
859
+ */
860
+ export function extractToolDefinitions(files) {
861
+ const tools = [];
862
+
863
+ for (const file of files) {
864
+ const content = file.content || '';
865
+
866
+ // Pattern 1: JS/TS MCP SDK — tools array with { name, description, inputSchema }
867
+ // Matches objects in a tools array with name and description fields
868
+ const toolBlockRegex = /\{\s*name\s*:\s*['"`]([^'"`]+)['"`]\s*,\s*description\s*:\s*['"`]([\s\S]*?)['"`]\s*(?:,\s*inputSchema\s*:\s*(\{[\s\S]*?\})\s*)?\}/g;
869
+ let match;
870
+ while ((match = toolBlockRegex.exec(content)) !== null) {
871
+ const name = match[1];
872
+ const description = match[2];
873
+ let inputSchema = null;
874
+ if (match[3]) {
875
+ try { inputSchema = JSON.parse(match[3].replace(/'/g, '"')); } catch {}
876
+ }
877
+ // Skip if this looks like a generic object (too short name, common keywords)
878
+ if (name.length >= 2 && !['type', 'name', 'string', 'object'].includes(name)) {
879
+ tools.push({ name, description, inputSchema, source_file: file.path });
880
+ }
881
+ }
882
+
883
+ // Pattern 2: Python FastMCP — @mcp.tool() or @server.tool() decorators
884
+ const pyToolRegex = /@(?:mcp|server|app)\.tool\(\)\s*(?:async\s+)?def\s+(\w+)\s*\([^)]*\)(?:\s*->.*?)?\s*:\s*\n\s*"""([\s\S]*?)"""/g;
885
+ while ((match = pyToolRegex.exec(content)) !== null) {
886
+ tools.push({
887
+ name: match[1],
888
+ description: match[2].trim(),
889
+ inputSchema: null,
890
+ source_file: file.path,
891
+ });
892
+ }
893
+
894
+ // Pattern 3: Python Tool(name=..., description=...)
895
+ const pyToolClassRegex = /Tool\s*\(\s*name\s*=\s*['"](\w+)['"]\s*,\s*description\s*=\s*['"]([^'"]*)['"]/g;
896
+ while ((match = pyToolClassRegex.exec(content)) !== null) {
897
+ tools.push({
898
+ name: match[1],
899
+ description: match[2],
900
+ inputSchema: null,
901
+ source_file: file.path,
902
+ });
903
+ }
904
+ }
905
+
906
+ // Deduplicate by name (keep first occurrence)
907
+ const seen = new Set();
908
+ return tools.filter(t => {
909
+ if (seen.has(t.name)) return false;
910
+ seen.add(t.name);
911
+ return true;
912
+ });
913
+ }