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.
- package/cli.mjs +574 -162
- package/index.mjs +795 -659
- package/package.json +7 -3
- package/scan-tool-poisoning.mjs +297 -0
- package/tool-poisoning-detector.mjs +913 -0
|
@@ -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
|
+
}
|