agent-security-scanner-mcp 3.7.0 → 3.9.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,922 @@
1
+ // src/tools/scan-mcp.js
2
+ import { z } from "zod";
3
+ import { createHash } from "crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
5
+ import { join, resolve, relative, extname, basename } from "path";
6
+
7
+ export const scanMcpServerSchema = {
8
+ server_path: z.string().describe("Path to MCP server directory or entry file"),
9
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
10
+ manifest: z.boolean().optional().describe("Also scan server.json manifest file for poisoning indicators (tool poisoning, name spoofing, description injection)"),
11
+ update_baseline: z.boolean().optional().describe("Write current server.json tool hashes as the trusted baseline for future rug pull detection. Stored in .mcp-security-baseline.json in the server directory.")
12
+ };
13
+
14
+ // File extensions to scan
15
+ const SCANNABLE_EXTENSIONS = new Set(['.js', '.ts', '.py']);
16
+
17
+ // Injection phrases for manifest description checking
18
+ const MANIFEST_INJECTION_PHRASES = /ignore\s+previous|exfiltrat|override\s+.*instruction|do\s+not\s+tell|hidden\s+instruction|bypass\s+.*filter|disregard\s+|extract\s+.*credential/i;
19
+
20
+ // Zero-width and bidi char patterns (reuse same ranges as rules above)
21
+ const MANIFEST_ZERO_WIDTH = /[\u200B\u200C\u200D\uFEFF\u2060]/;
22
+ const MANIFEST_BIDI = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/;
23
+
24
+ // Directories to skip when walking
25
+ const SKIP_DIRS = new Set([
26
+ 'node_modules', '.git', 'dist', 'build', '__pycache__',
27
+ 'venv', 'env', '.venv', 'coverage', '.next', '.nuxt'
28
+ ]);
29
+
30
+ // ============================================================
31
+ // Known legitimate MCP tool names (for spoofing detection)
32
+ // ============================================================
33
+ const KNOWN_MCP_TOOLS = new Set([
34
+ // File system
35
+ 'readFile', 'writeFile', 'editFile', 'createFile', 'deleteFile',
36
+ 'listDirectory', 'makeDirectory', 'moveFile', 'copyFile',
37
+ 'readMultipleFiles', 'listFiles',
38
+ // Shell / process
39
+ 'bash', 'execute', 'runCommand', 'runScript',
40
+ // Search
41
+ 'search', 'grep', 'find', 'glob',
42
+ // Web
43
+ 'fetch', 'browse', 'webSearch', 'httpRequest',
44
+ // Git
45
+ 'gitStatus', 'gitDiff', 'gitCommit', 'gitLog', 'gitAdd',
46
+ // Memory / context
47
+ 'remember', 'recall', 'storeMemory', 'searchMemory',
48
+ // Database
49
+ 'query', 'executeQuery', 'dbQuery',
50
+ // Common agent tools
51
+ 'think', 'plan', 'summarize', 'analyze'
52
+ ]);
53
+
54
+ /** Levenshtein distance — O(n*m), capped at strings up to 100 chars */
55
+ function levenshtein(a, b) {
56
+ if (a.length > 100 || b.length > 100) return 999;
57
+ const m = a.length, n = b.length;
58
+ const dp = Array.from({ length: m + 1 }, (_, i) =>
59
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
60
+ );
61
+ for (let i = 1; i <= m; i++) {
62
+ for (let j = 1; j <= n; j++) {
63
+ dp[i][j] = a[i-1] === b[j-1]
64
+ ? dp[i-1][j-1]
65
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
66
+ }
67
+ }
68
+ return dp[m][n];
69
+ }
70
+
71
+ /** Returns the closest known tool and its distance if distance <= 2, else null */
72
+ function findSpoofedTool(toolName) {
73
+ if (KNOWN_MCP_TOOLS.has(toolName)) return null; // exact match = legitimate
74
+ if (toolName.length < 6) return null; // too short to meaningfully compare
75
+ let best = null, bestDist = 3; // only flag distance <= 2
76
+ for (const known of KNOWN_MCP_TOOLS) {
77
+ if (Math.abs(known.length - toolName.length) > 2) continue;
78
+ const d = levenshtein(toolName, known);
79
+ if (d < bestDist) { bestDist = d; best = known; }
80
+ }
81
+ return best ? { spoofed: best, distance: bestDist } : null;
82
+ }
83
+
84
+ // ============================================================
85
+ // Security rule definitions for MCP server scanning
86
+ // ============================================================
87
+
88
+ const MCP_SECURITY_RULES = [
89
+ // ---- Category 1: Overly broad tool permissions ----
90
+ {
91
+ id: 'mcp.shell-exec-no-validation',
92
+ severity: 'ERROR',
93
+ category: 'overly-broad-permissions',
94
+ message: 'Shell command execution without input validation. User-controlled input may reach exec/execSync, enabling arbitrary command execution.',
95
+ pattern: /\b(exec|execSync)\s*\(\s*(`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*(\s*\+|\s*,\s*\{[^}]*shell\s*:\s*true))/g,
96
+ fileTypes: ['.js', '.ts']
97
+ },
98
+ {
99
+ id: 'mcp.shell-exec-direct',
100
+ severity: 'ERROR',
101
+ category: 'overly-broad-permissions',
102
+ message: 'Direct use of exec/execSync with potential string concatenation. Prefer execFile/execFileSync with explicit argument arrays and shell:false.',
103
+ pattern: /\bchild_process\b.*\b(exec|execSync)\b|\b(exec|execSync)\s*\(/g,
104
+ fileTypes: ['.js', '.ts']
105
+ },
106
+ {
107
+ id: 'mcp.spawn-shell-true',
108
+ severity: 'ERROR',
109
+ category: 'overly-broad-permissions',
110
+ message: 'spawn/spawnSync called with shell:true, allowing shell injection. Use shell:false and pass arguments as an array.',
111
+ pattern: /\b(spawn|spawnSync)\s*\([^)]*\{[^}]*shell\s*:\s*true/g,
112
+ fileTypes: ['.js', '.ts']
113
+ },
114
+ {
115
+ id: 'mcp.subprocess-shell',
116
+ severity: 'ERROR',
117
+ category: 'overly-broad-permissions',
118
+ message: 'subprocess called with shell=True, allowing shell injection. Use shell=False with a command list.',
119
+ pattern: /subprocess\.(run|call|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True/g,
120
+ fileTypes: ['.py']
121
+ },
122
+ {
123
+ id: 'mcp.os-system',
124
+ severity: 'ERROR',
125
+ category: 'overly-broad-permissions',
126
+ message: 'os.system() executes commands through the shell. Use subprocess with shell=False instead.',
127
+ pattern: /\bos\.system\s*\(/g,
128
+ fileTypes: ['.py']
129
+ },
130
+ {
131
+ id: 'mcp.fs-write-no-path-validation',
132
+ severity: 'WARNING',
133
+ category: 'overly-broad-permissions',
134
+ message: 'Filesystem write operation without visible path validation. Ensure paths are validated with path.resolve and confined to an allowed directory.',
135
+ pattern: /\b(writeFileSync|writeFile|createWriteStream|appendFileSync|appendFile)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*(?:path\.resolve|path\.join|path\.normalize))/g,
136
+ fileTypes: ['.js', '.ts']
137
+ },
138
+ {
139
+ id: 'mcp.http-request-user-url',
140
+ severity: 'WARNING',
141
+ category: 'overly-broad-permissions',
142
+ message: 'HTTP request to a potentially user-controlled URL. Validate and allowlist target URLs to prevent SSRF.',
143
+ pattern: /\b(fetch|axios\.(get|post|put|delete|request)|http\.request|https\.request|got|request)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*['"`])/g,
144
+ fileTypes: ['.js', '.ts']
145
+ },
146
+ {
147
+ id: 'mcp.env-var-exposure',
148
+ severity: 'WARNING',
149
+ category: 'overly-broad-permissions',
150
+ message: 'Environment variables accessed and potentially exposed in tool output. Ensure secrets are not leaked through MCP responses.',
151
+ pattern: /process\.env\b/g,
152
+ fileTypes: ['.js', '.ts']
153
+ },
154
+ {
155
+ id: 'mcp.env-var-exposure-python',
156
+ severity: 'WARNING',
157
+ category: 'overly-broad-permissions',
158
+ message: 'Environment variables accessed and potentially exposed in tool output. Ensure secrets are not leaked through MCP responses.',
159
+ pattern: /os\.environ\b|os\.getenv\s*\(/g,
160
+ fileTypes: ['.py']
161
+ },
162
+
163
+ // ---- Category 2: Missing input validation ----
164
+ {
165
+ id: 'mcp.no-input-validation',
166
+ severity: 'WARNING',
167
+ category: 'missing-input-validation',
168
+ message: 'Tool handler accepts string input without visible validation or sanitization. Use zod, joi, or manual validation to constrain inputs.',
169
+ // Matches tool handler patterns that take params but don't appear to validate
170
+ pattern: /\.tool\s*\(\s*["'][^"']+["']\s*,\s*["'][^"']*["']\s*,\s*\{[^}]*\}\s*,\s*(async\s+)?\(\s*\{/g,
171
+ fileTypes: ['.js', '.ts'],
172
+ contextCheck: (line, lines, lineIndex) => {
173
+ // Look ahead 15 lines for validation patterns
174
+ const lookahead = lines.slice(lineIndex, lineIndex + 15).join('\n');
175
+ const hasValidation = /\b(z\.|zod\.|joi\.|validate|sanitize|schema|\.parse\(|\.safeParse\(|isValid|assert|check)\b/i.test(lookahead);
176
+ return !hasValidation;
177
+ }
178
+ },
179
+ {
180
+ id: 'mcp.path-no-normalize',
181
+ severity: 'WARNING',
182
+ category: 'missing-input-validation',
183
+ message: 'File path used without normalization. Use path.resolve() or path.normalize() to prevent path traversal attacks.',
184
+ pattern: /\b(readFileSync|readFile|existsSync|statSync|stat|unlink|unlinkSync|rmdir|rmdirSync|mkdir|mkdirSync)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*(?:path\.|resolve|normalize))/g,
185
+ fileTypes: ['.js', '.ts'],
186
+ contextCheck: (line, lines, lineIndex) => {
187
+ // Check if path.resolve/normalize is used in surrounding lines
188
+ const context = lines.slice(Math.max(0, lineIndex - 5), lineIndex + 1).join('\n');
189
+ const hasPathNorm = /path\.(resolve|normalize|join)\s*\(/.test(context);
190
+ return !hasPathNorm;
191
+ }
192
+ },
193
+ {
194
+ id: 'mcp.url-no-validation',
195
+ severity: 'WARNING',
196
+ category: 'missing-input-validation',
197
+ message: 'URL used without validation. Validate URL scheme and host to prevent SSRF and open redirect vulnerabilities.',
198
+ pattern: /new\s+URL\s*\(\s*[a-zA-Z_$][\w$.]*\s*\)|url\.parse\s*\(\s*[a-zA-Z_$][\w$.]*\s*\)/g,
199
+ fileTypes: ['.js', '.ts'],
200
+ contextCheck: (line, lines, lineIndex) => {
201
+ const lookahead = lines.slice(lineIndex, lineIndex + 5).join('\n');
202
+ const hasHostCheck = /\.(hostname|host|protocol|origin)\s*(===|!==|==|!=)|allowlist|whitelist|allowed/i.test(lookahead);
203
+ return !hasHostCheck;
204
+ }
205
+ },
206
+
207
+ // ---- Category 3: Data exfiltration patterns ----
208
+ {
209
+ id: 'mcp.exfiltration-external-request',
210
+ severity: 'ERROR',
211
+ category: 'data-exfiltration',
212
+ message: 'Data sent to an external URL. MCP servers should not exfiltrate data to third-party endpoints without explicit user consent.',
213
+ pattern: /\b(fetch|axios\.(post|put|patch)|http\.request|https\.request)\s*\(\s*['"`](https?:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|::1)[^'"` ]+)['"`]/g,
214
+ fileTypes: ['.js', '.ts']
215
+ },
216
+ {
217
+ id: 'mcp.exfiltration-external-request-python',
218
+ severity: 'ERROR',
219
+ category: 'data-exfiltration',
220
+ message: 'Data sent to an external URL. MCP servers should not exfiltrate data to third-party endpoints without explicit user consent.',
221
+ pattern: /\b(requests\.(post|put|patch)|urllib\.request\.urlopen|httpx\.(post|put|patch))\s*\(\s*['"`](https?:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|::1)[^'"` ]+)['"`]/g,
222
+ fileTypes: ['.py']
223
+ },
224
+ {
225
+ id: 'mcp.exfiltration-network-socket',
226
+ severity: 'WARNING',
227
+ category: 'data-exfiltration',
228
+ message: 'Network socket created. Verify this is not used to exfiltrate data to external hosts.',
229
+ pattern: /\bnet\.(createConnection|connect|Socket)\s*\(|new\s+WebSocket\s*\(/g,
230
+ fileTypes: ['.js', '.ts']
231
+ },
232
+ {
233
+ id: 'mcp.exfiltration-log-secrets',
234
+ severity: 'WARNING',
235
+ category: 'data-exfiltration',
236
+ message: 'Potentially sensitive data (keys, tokens, passwords) logged or printed. This may leak secrets through MCP server stderr.',
237
+ pattern: /\b(console\.(log|error|warn|info)|print|logging\.(info|warning|error|debug))\s*\([^)]*\b(key|token|password|secret|credential|api_key|apiKey|auth|bearer)\b/gi,
238
+ fileTypes: ['.js', '.ts', '.py']
239
+ },
240
+
241
+ // ---- Category 4: Insecure code patterns ----
242
+ {
243
+ id: 'mcp.eval-usage',
244
+ severity: 'ERROR',
245
+ category: 'insecure-patterns',
246
+ message: 'eval() executes arbitrary code. Never use eval with user-controlled input in an MCP server.',
247
+ pattern: /\beval\s*\(/g,
248
+ fileTypes: ['.js', '.ts', '.py']
249
+ },
250
+ {
251
+ id: 'mcp.function-constructor',
252
+ severity: 'ERROR',
253
+ category: 'insecure-patterns',
254
+ message: 'new Function() is equivalent to eval(). Avoid constructing functions from strings.',
255
+ pattern: /new\s+Function\s*\(/g,
256
+ fileTypes: ['.js', '.ts']
257
+ },
258
+ {
259
+ id: 'mcp.exec-string-concat',
260
+ severity: 'ERROR',
261
+ category: 'insecure-patterns',
262
+ message: 'child_process.exec() with string concatenation is vulnerable to command injection. Use execFile() with argument arrays.',
263
+ pattern: /\bexec\s*\(\s*['"`][^'"`]*['"`]\s*\+/g,
264
+ fileTypes: ['.js', '.ts']
265
+ },
266
+ {
267
+ id: 'mcp.cors-wildcard',
268
+ severity: 'WARNING',
269
+ category: 'insecure-patterns',
270
+ message: 'CORS configured with wildcard origin (*). This allows any website to interact with the MCP server.',
271
+ pattern: /cors\s*\(\s*\{[^}]*origin\s*:\s*['"]\*['"]/g,
272
+ fileTypes: ['.js', '.ts']
273
+ },
274
+ {
275
+ id: 'mcp.cors-permissive',
276
+ severity: 'INFO',
277
+ category: 'insecure-patterns',
278
+ message: 'CORS enabled. Verify the origin configuration is appropriately restrictive.',
279
+ pattern: /\bcors\s*\(\s*\)/g,
280
+ fileTypes: ['.js', '.ts']
281
+ },
282
+ {
283
+ id: 'mcp.no-auth-check',
284
+ severity: 'INFO',
285
+ category: 'insecure-patterns',
286
+ message: 'No authentication or authorization checks detected. If this MCP server is network-accessible, add authentication.',
287
+ pattern: /\b(createServer|listen)\s*\(/g,
288
+ fileTypes: ['.js', '.ts'],
289
+ contextCheck: (_line, lines) => {
290
+ const fullSource = lines.join('\n');
291
+ const hasAuth = /\b(auth|authenticate|authorize|jwt|bearer|token|apiKey|api_key|session|passport)\b/i.test(fullSource);
292
+ return !hasAuth;
293
+ }
294
+ },
295
+ {
296
+ id: 'mcp.pickle-load',
297
+ severity: 'ERROR',
298
+ category: 'insecure-patterns',
299
+ message: 'pickle.load/loads deserializes arbitrary Python objects. This can execute arbitrary code if the input is attacker-controlled.',
300
+ pattern: /\bpickle\.(load|loads)\s*\(/g,
301
+ fileTypes: ['.py']
302
+ },
303
+ {
304
+ id: 'mcp.yaml-unsafe-load',
305
+ severity: 'ERROR',
306
+ category: 'insecure-patterns',
307
+ message: 'yaml.load() without SafeLoader can execute arbitrary Python. Use yaml.safe_load() instead.',
308
+ pattern: /\byaml\.load\s*\([^)]*(?!Loader\s*=\s*yaml\.SafeLoader)/g,
309
+ fileTypes: ['.py']
310
+ },
311
+
312
+ // ---- Category 5: Unicode poisoning ----
313
+ {
314
+ id: 'mcp.unicode-zero-width',
315
+ severity: 'ERROR',
316
+ category: 'unicode-poisoning',
317
+ message: 'Zero-width or invisible Unicode character detected in source. This is a common technique to hide injected instructions in tool descriptions.',
318
+ // U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+FEFF BOM, U+2060 WORD JOINER
319
+ pattern: /[\u200B\u200C\u200D\uFEFF\u2060]/g,
320
+ fileTypes: ['.js', '.ts', '.py']
321
+ },
322
+ {
323
+ id: 'mcp.unicode-bidi-override',
324
+ severity: 'ERROR',
325
+ category: 'unicode-poisoning',
326
+ message: 'Bidirectional text override character detected. Attackers use these to make malicious code appear differently in editors vs. execution.',
327
+ // U+202A-202E, U+2066-2069, U+200E, U+200F, U+061C
328
+ pattern: /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g,
329
+ fileTypes: ['.js', '.ts', '.py']
330
+ },
331
+ {
332
+ id: 'mcp.unicode-homoglyph',
333
+ severity: 'WARNING',
334
+ category: 'unicode-poisoning',
335
+ message: 'Cyrillic character found adjacent to ASCII characters. This is a common homoglyph substitution pattern — Cyrillic letters (а, е, о, р, с) are visually identical to ASCII equivalents and used in tool name spoofing attacks.',
336
+ // Cyrillic block (U+0400-U+04FF) adjacent to ASCII — catches common confusables (а/a, е/e, о/o, р/p, с/c)
337
+ pattern: /[a-zA-Z][\u0400-\u04FF]|[\u0400-\u04FF][a-zA-Z]/g,
338
+ fileTypes: ['.js', '.ts', '.py']
339
+ },
340
+
341
+ // ---- Category 6: Description injection ----
342
+ {
343
+ id: 'mcp.description-injection',
344
+ severity: 'ERROR',
345
+ category: 'description-injection',
346
+ message: 'Tool description contains imperative language directed at the LLM. This pattern is used in tool poisoning attacks to inject hidden instructions.',
347
+ // Matches server.tool() calls where the description string contains injection phrases
348
+ pattern: /server\.tool\s*\(\s*["'`][^"'`]*["'`]\s*,\s*["'`][^"'`]*(ignore\s+previous|exfiltrat|override\s+.*instruction|do\s+not\s+tell|hidden\s+instruction|bypass\s+.*filter|disregard\s+|extract\s+.*credential)[^"'`]*["'`]/gi,
349
+ fileTypes: ['.js', '.ts']
350
+ },
351
+
352
+ // ---- Category 7: Tool name spoofing ----
353
+ {
354
+ id: 'mcp.tool-name-spoofing',
355
+ severity: 'ERROR',
356
+ category: 'tool-name-spoofing',
357
+ message: 'Tool name is suspiciously similar to a well-known MCP tool. This may be a name spoofing attack.',
358
+ // Extracts the tool name (1st arg to server.tool) for Levenshtein comparison
359
+ pattern: /server\.tool\s*\(\s*["'`]([a-zA-Z_$][\w$]*)["'`]/g,
360
+ fileTypes: ['.js', '.ts'],
361
+ isSpoofingRule: true
362
+ }
363
+ ];
364
+
365
+ // ============================================================
366
+ // File collection
367
+ // ============================================================
368
+
369
+ function collectFiles(serverPath) {
370
+ const resolvedPath = resolve(serverPath);
371
+
372
+ if (!existsSync(resolvedPath)) {
373
+ return [];
374
+ }
375
+
376
+ let stat;
377
+ try {
378
+ stat = statSync(resolvedPath);
379
+ } catch {
380
+ return [];
381
+ }
382
+
383
+ // If a single file is provided, return it directly
384
+ if (stat.isFile()) {
385
+ const ext = extname(resolvedPath).toLowerCase();
386
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
387
+ return [resolvedPath];
388
+ }
389
+ return [];
390
+ }
391
+
392
+ // Walk the directory
393
+ const files = [];
394
+
395
+ function walk(dir) {
396
+ let entries;
397
+ try {
398
+ entries = readdirSync(dir);
399
+ } catch {
400
+ return;
401
+ }
402
+
403
+ for (const entry of entries) {
404
+ if (entry.startsWith('.')) continue;
405
+
406
+ const fullPath = join(dir, entry);
407
+ let entryStat;
408
+ try {
409
+ entryStat = statSync(fullPath);
410
+ } catch {
411
+ continue;
412
+ }
413
+
414
+ if (entryStat.isDirectory()) {
415
+ if (SKIP_DIRS.has(entry)) continue;
416
+ walk(fullPath);
417
+ } else if (entryStat.isFile()) {
418
+ const ext = extname(entry).toLowerCase();
419
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
420
+ files.push(fullPath);
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ walk(resolvedPath);
427
+ return files;
428
+ }
429
+
430
+ // ============================================================
431
+ // Scanning engine
432
+ // ============================================================
433
+
434
+ function scanFileContent(filePath, content) {
435
+ const ext = extname(filePath).toLowerCase();
436
+ const lines = content.split('\n');
437
+ const findings = [];
438
+
439
+ for (const rule of MCP_SECURITY_RULES) {
440
+ // Check if rule applies to this file type
441
+ if (!rule.fileTypes.includes(ext)) continue;
442
+
443
+ // Reset regex state
444
+ const regex = new RegExp(rule.pattern.source, rule.pattern.flags);
445
+ let match;
446
+
447
+ while ((match = regex.exec(content)) !== null) {
448
+ // Calculate line number from match index
449
+ const upToMatch = content.substring(0, match.index);
450
+ const lineNumber = upToMatch.split('\n').length;
451
+ const lineIndex = lineNumber - 1;
452
+
453
+ // If rule has a context check, apply it
454
+ if (rule.contextCheck) {
455
+ const line = lines[lineIndex] || '';
456
+ if (!rule.contextCheck(line, lines, lineIndex)) {
457
+ continue;
458
+ }
459
+ }
460
+
461
+ // Handle spoofing rules: extract tool name and check Levenshtein distance
462
+ if (rule.isSpoofingRule) {
463
+ const toolName = match[1];
464
+ if (!toolName) continue;
465
+ const spoof = findSpoofedTool(toolName);
466
+ if (!spoof) continue;
467
+ findings.push({
468
+ rule: rule.id,
469
+ severity: rule.severity,
470
+ category: rule.category,
471
+ message: `Tool name "${toolName}" is ${spoof.distance} edit(s) away from well-known tool "${spoof.spoofed}". This may be a spoofing attack.`,
472
+ file: filePath,
473
+ line: lineNumber,
474
+ match: match[0].substring(0, 100)
475
+ });
476
+ continue;
477
+ }
478
+
479
+ findings.push({
480
+ rule: rule.id,
481
+ severity: rule.severity,
482
+ category: rule.category,
483
+ message: rule.message,
484
+ file: filePath,
485
+ line: lineNumber,
486
+ match: match[0].substring(0, 100) // Truncate long matches
487
+ });
488
+ }
489
+ }
490
+
491
+ return findings;
492
+ }
493
+
494
+ // ============================================================
495
+ // Grading
496
+ // ============================================================
497
+
498
+ function calculateGrade(findings, filesScanned) {
499
+ if (filesScanned === 0) return 'A';
500
+
501
+ const errorCount = findings.filter(f => f.severity === 'ERROR').length;
502
+ const warningCount = findings.filter(f => f.severity === 'WARNING').length;
503
+ const totalCount = findings.length;
504
+ const density = totalCount / filesScanned;
505
+
506
+ if (errorCount === 0 && warningCount === 0) return 'A';
507
+ if (errorCount === 0 && density < 0.5) return 'B';
508
+ if (errorCount <= 2 && density < 1.5) return 'C';
509
+ if (errorCount <= 5 && density < 3) return 'D';
510
+ return 'F';
511
+ }
512
+
513
+ // ============================================================
514
+ // Recommendations generator
515
+ // ============================================================
516
+
517
+ function generateRecommendations(findings) {
518
+ const recommendations = [];
519
+ const categories = new Set(findings.map(f => f.category));
520
+
521
+ if (categories.has('overly-broad-permissions')) {
522
+ recommendations.push('Replace exec/execSync with execFile/execFileSync and pass arguments as arrays with shell:false.');
523
+ recommendations.push('Validate and confine file paths using path.resolve() and an allowlist of permitted directories.');
524
+ }
525
+
526
+ if (categories.has('missing-input-validation')) {
527
+ recommendations.push('Add input validation using zod schemas for all tool parameters (strings, paths, URLs).');
528
+ recommendations.push('Normalize file paths with path.resolve() and validate they stay within allowed directories.');
529
+ }
530
+
531
+ if (categories.has('data-exfiltration')) {
532
+ recommendations.push('Audit all outbound network requests. MCP servers should not send data to external endpoints without user consent.');
533
+ recommendations.push('Avoid logging sensitive values (keys, tokens, passwords) to stderr or stdout.');
534
+ }
535
+
536
+ if (categories.has('insecure-patterns')) {
537
+ recommendations.push('Remove all uses of eval() and new Function(). Use structured data parsing instead.');
538
+ if (findings.some(f => f.rule.includes('cors'))) {
539
+ recommendations.push('Configure CORS with specific allowed origins rather than wildcards.');
540
+ }
541
+ if (findings.some(f => f.rule.includes('auth'))) {
542
+ recommendations.push('Add authentication for network-accessible MCP servers (e.g., bearer tokens, API keys).');
543
+ }
544
+ }
545
+
546
+ if (categories.has('unicode-poisoning')) {
547
+ if (findings.some(f => f.rule === 'mcp.unicode-zero-width')) {
548
+ recommendations.push('Zero-width Unicode characters detected. Search for and remove U+200B, U+200C, U+200D, U+FEFF, U+2060 from all tool names and descriptions — these are used to hide injected instructions.');
549
+ }
550
+ if (findings.some(f => f.rule === 'mcp.unicode-bidi-override')) {
551
+ recommendations.push('Bidirectional override characters detected. These make source code appear differently in text editors than how it executes — a known code obfuscation technique. Remove all bidi formatting characters from source.');
552
+ }
553
+ if (findings.some(f => f.rule === 'mcp.unicode-homoglyph' || f.rule === 'mcp.manifest-name-spoofing')) {
554
+ recommendations.push('Cyrillic homoglyph characters detected adjacent to ASCII. Verify all tool names use only ASCII characters to prevent visual spoofing of legitimate tool names (Adversa TOP25 #9).');
555
+ }
556
+ }
557
+
558
+ if (categories.has('description-injection')) {
559
+ recommendations.push('Tool descriptions must describe functionality only. Remove any imperative language or instructions directed at the LLM — this is a tool poisoning attack vector (Adversa TOP25 #2).');
560
+ }
561
+
562
+ if (categories.has('tool-name-spoofing')) {
563
+ recommendations.push('Tool names closely matching well-known MCP tools may be spoofing attacks. Verify all registered tool names are intentional and do not mimic legitimate tools (Adversa TOP25 #9).');
564
+ }
565
+
566
+ if (categories.has('rug-pull')) {
567
+ recommendations.push('Tool schema changed since baseline. Run with update_baseline:true only after manually verifying all changes. Rug pull attacks modify tool behavior after initial user approval (Adversa TOP25 #6).');
568
+ }
569
+
570
+ if (recommendations.length === 0) {
571
+ recommendations.push('No critical issues found. Continue following security best practices.');
572
+ }
573
+
574
+ return recommendations;
575
+ }
576
+
577
+ // ============================================================
578
+ // Verbosity formatters
579
+ // ============================================================
580
+
581
+ function formatMinimal(serverPath, filesScanned, findings, grade) {
582
+ const bySeverity = { ERROR: 0, WARNING: 0, INFO: 0 };
583
+ findings.forEach(f => bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1);
584
+
585
+ return {
586
+ server_path: serverPath,
587
+ files_scanned: filesScanned,
588
+ grade,
589
+ findings_count: findings.length,
590
+ critical: bySeverity.ERROR,
591
+ warning: bySeverity.WARNING,
592
+ info: bySeverity.INFO,
593
+ message: findings.length > 0
594
+ ? `Found ${findings.length} issue(s) across ${filesScanned} files. Grade: ${grade}`
595
+ : `No issues found in ${filesScanned} files. Grade: ${grade}`
596
+ };
597
+ }
598
+
599
+ function formatCompact(serverPath, filesScanned, findings, grade) {
600
+ const recommendations = generateRecommendations(findings);
601
+
602
+ return {
603
+ server_path: serverPath,
604
+ files_scanned: filesScanned,
605
+ grade,
606
+ findings_count: findings.length,
607
+ findings: findings.map(f => ({
608
+ rule: f.rule,
609
+ severity: f.severity,
610
+ message: f.message,
611
+ file: f.file,
612
+ line: f.line
613
+ })),
614
+ recommendations
615
+ };
616
+ }
617
+
618
+ function formatFull(serverPath, filesScanned, findings, grade, scannedFiles) {
619
+ const bySeverity = { ERROR: 0, WARNING: 0, INFO: 0 };
620
+ findings.forEach(f => bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1);
621
+
622
+ const byCategory = {};
623
+ findings.forEach(f => {
624
+ byCategory[f.category] = (byCategory[f.category] || 0) + 1;
625
+ });
626
+
627
+ const byFile = {};
628
+ findings.forEach(f => {
629
+ const rel = f.file;
630
+ byFile[rel] = (byFile[rel] || 0) + 1;
631
+ });
632
+
633
+ const recommendations = generateRecommendations(findings);
634
+
635
+ return {
636
+ server_path: serverPath,
637
+ files_scanned: filesScanned,
638
+ grade,
639
+ findings_count: findings.length,
640
+ by_severity: bySeverity,
641
+ by_category: byCategory,
642
+ by_file: byFile,
643
+ findings: findings.map(f => ({
644
+ rule: f.rule,
645
+ severity: f.severity,
646
+ category: f.category,
647
+ message: f.message,
648
+ file: f.file,
649
+ line: f.line,
650
+ match: f.match
651
+ })),
652
+ recommendations,
653
+ scanned_files: scannedFiles
654
+ };
655
+ }
656
+
657
+ // ============================================================
658
+ // Rug pull detection (baseline hashing)
659
+ // ============================================================
660
+
661
+ const BASELINE_FILENAME = '.mcp-security-baseline.json';
662
+
663
+ function hashTool(tool) {
664
+ return createHash('sha256')
665
+ .update(JSON.stringify({ name: tool.name, description: tool.description }))
666
+ .digest('hex');
667
+ }
668
+
669
+ function buildBaseline(manifestPath) {
670
+ let manifest;
671
+ try {
672
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
673
+ } catch {
674
+ return null;
675
+ }
676
+ const hashes = {};
677
+ for (const tool of (manifest.tools || [])) {
678
+ hashes[tool.name] = hashTool(tool);
679
+ }
680
+ return hashes;
681
+ }
682
+
683
+ function writeBaseline(serverDir, hashes) {
684
+ const baselinePath = join(serverDir, BASELINE_FILENAME);
685
+ writeFileSync(baselinePath, JSON.stringify({ version: 1, tools: hashes }, null, 2), 'utf-8');
686
+ }
687
+
688
+ function checkRugPull(manifestPath, serverDir) {
689
+ const baselinePath = join(serverDir, BASELINE_FILENAME);
690
+ if (!existsSync(baselinePath)) return []; // no baseline yet
691
+
692
+ let baseline;
693
+ try {
694
+ baseline = JSON.parse(readFileSync(baselinePath, 'utf-8'));
695
+ } catch {
696
+ return [];
697
+ }
698
+
699
+ const current = buildBaseline(manifestPath);
700
+ if (!current) return [];
701
+
702
+ const baselineHashes = baseline.tools || {};
703
+ const findings = [];
704
+
705
+ for (const [name, hash] of Object.entries(current)) {
706
+ if (!baselineHashes[name]) {
707
+ findings.push({
708
+ rule: 'mcp.rug-pull-detected',
709
+ severity: 'ERROR',
710
+ category: 'rug-pull',
711
+ message: `New tool "${name}" appeared since baseline was recorded. Verify this addition is intentional (Adversa TOP25 #6).`,
712
+ file: basename(BASELINE_FILENAME),
713
+ line: 1,
714
+ match: name
715
+ });
716
+ } else if (baselineHashes[name] !== hash) {
717
+ findings.push({
718
+ rule: 'mcp.rug-pull-detected',
719
+ severity: 'ERROR',
720
+ category: 'rug-pull',
721
+ message: `Tool "${name}" schema/description changed since baseline. Rug pull indicator — verify the change is intentional (Adversa TOP25 #6).`,
722
+ file: basename(BASELINE_FILENAME),
723
+ line: 1,
724
+ match: name
725
+ });
726
+ }
727
+ }
728
+
729
+ // Also flag tools that were in the baseline but are now gone
730
+ for (const [name] of Object.entries(baselineHashes)) {
731
+ if (!current[name]) {
732
+ findings.push({
733
+ rule: 'mcp.rug-pull-detected',
734
+ severity: 'ERROR',
735
+ category: 'rug-pull',
736
+ message: `Tool "${name}" was removed since baseline was recorded. Verify this removal is intentional (Adversa TOP25 #6).`,
737
+ file: basename(BASELINE_FILENAME),
738
+ line: 1,
739
+ match: name
740
+ });
741
+ }
742
+ }
743
+
744
+ return findings;
745
+ }
746
+
747
+ // ============================================================
748
+ // Manifest scanning (server.json)
749
+ // ============================================================
750
+
751
+ function scanManifest(manifestPath) {
752
+ let raw;
753
+ try {
754
+ raw = readFileSync(manifestPath, 'utf-8');
755
+ } catch {
756
+ return [];
757
+ }
758
+
759
+ let manifest;
760
+ try {
761
+ manifest = JSON.parse(raw);
762
+ } catch {
763
+ return [{ rule: 'mcp.manifest-parse-error', severity: 'WARNING', category: 'manifest', message: 'server.json is not valid JSON.', file: manifestPath, line: 1, match: '' }];
764
+ }
765
+
766
+ const findings = [];
767
+ const tools = manifest.tools || [];
768
+
769
+ for (const tool of tools) {
770
+ const name = tool.name || '';
771
+ const description = tool.description || '';
772
+
773
+ // Zero-width chars in name or description
774
+ if (MANIFEST_ZERO_WIDTH.test(description) || MANIFEST_ZERO_WIDTH.test(name)) {
775
+ findings.push({ rule: 'mcp.unicode-zero-width', severity: 'ERROR', category: 'unicode-poisoning', message: 'Zero-width Unicode character in manifest tool name or description.', file: manifestPath, line: 1, match: name });
776
+ }
777
+ // Bidi overrides
778
+ if (MANIFEST_BIDI.test(description) || MANIFEST_BIDI.test(name)) {
779
+ findings.push({ rule: 'mcp.unicode-bidi-override', severity: 'ERROR', category: 'unicode-poisoning', message: 'Bidirectional override character in manifest tool name or description.', file: manifestPath, line: 1, match: name });
780
+ }
781
+ // Description injection phrases
782
+ if (MANIFEST_INJECTION_PHRASES.test(description)) {
783
+ findings.push({ rule: 'mcp.manifest-description-injection', severity: 'ERROR', category: 'description-injection', message: `Tool "${name}" description contains injection language. Likely tool poisoning (Adversa TOP25 #2).`, file: manifestPath, line: 1, match: description.substring(0, 100) });
784
+ }
785
+ // Tool name spoofing
786
+ if (name) {
787
+ const spoof = findSpoofedTool(name);
788
+ if (spoof) {
789
+ findings.push({ rule: 'mcp.manifest-name-spoofing', severity: 'ERROR', category: 'tool-name-spoofing', message: `Manifest tool name "${name}" is ${spoof.distance} edit(s) away from well-known tool "${spoof.spoofed}" (Adversa TOP25 #9).`, file: manifestPath, line: 1, match: name });
790
+ }
791
+ }
792
+ // Suspiciously long description
793
+ if (description.length > 500) {
794
+ findings.push({ rule: 'mcp.manifest-description-too-long', severity: 'WARNING', category: 'description-injection', message: `Tool "${name}" description is ${description.length} chars — unusually long descriptions often contain hidden instructions.`, file: manifestPath, line: 1, match: description.substring(0, 100) });
795
+ }
796
+ }
797
+
798
+ return findings;
799
+ }
800
+
801
+ // ============================================================
802
+ // Main handler
803
+ // ============================================================
804
+
805
+ export async function scanMcpServer({ server_path, verbosity, manifest, update_baseline }) {
806
+ const resolvedPath = resolve(server_path);
807
+
808
+ if (!existsSync(resolvedPath)) {
809
+ return {
810
+ content: [{ type: "text", text: JSON.stringify({ error: "Server path not found", server_path }) }]
811
+ };
812
+ }
813
+
814
+ // Compute once; used in multiple places below
815
+ const isDir = statSync(resolvedPath).isDirectory();
816
+
817
+ // Collect files to scan
818
+ const files = collectFiles(resolvedPath);
819
+
820
+ if (files.length === 0 && !manifest) {
821
+ return {
822
+ content: [{ type: "text", text: JSON.stringify({
823
+ server_path: resolvedPath,
824
+ files_scanned: 0,
825
+ grade: 'A',
826
+ findings_count: 0,
827
+ message: "No scannable files (.js, .ts, .py) found at the given path."
828
+ }) }]
829
+ };
830
+ }
831
+
832
+ // Scan each file
833
+ const allFindings = [];
834
+
835
+ // Manifest scan (server.json) — when manifest:true is passed
836
+ if (manifest) {
837
+ const serverDir = isDir ? resolvedPath : resolve(resolvedPath, '..');
838
+ const manifestPath = join(serverDir, 'server.json');
839
+ if (existsSync(manifestPath)) {
840
+ // Update baseline if requested (do this BEFORE checking for rug pull)
841
+ if (update_baseline) {
842
+ const hashes = buildBaseline(manifestPath);
843
+ if (hashes) writeBaseline(serverDir, hashes);
844
+ }
845
+
846
+ const manifestFindings = scanManifest(manifestPath);
847
+ // Relativize manifest finding paths
848
+ for (const f of manifestFindings) {
849
+ f.file = relative(serverDir, f.file) || basename(f.file);
850
+ }
851
+ allFindings.push(...manifestFindings);
852
+
853
+ // Rug pull check (only when NOT writing baseline)
854
+ if (!update_baseline) {
855
+ const rugPullFindings = checkRugPull(manifestPath, serverDir);
856
+ // BASELINE_FILENAME is already relative, no need to relativize
857
+ allFindings.push(...rugPullFindings);
858
+ }
859
+ }
860
+ }
861
+
862
+ for (const filePath of files) {
863
+ let content;
864
+ try {
865
+ content = readFileSync(filePath, 'utf-8');
866
+ } catch {
867
+ continue;
868
+ }
869
+
870
+ const fileFindings = scanFileContent(filePath, content);
871
+
872
+ // Convert absolute paths to relative for output readability
873
+ const basePath = isDir ? resolvedPath : resolve(resolvedPath, '..');
874
+ for (const finding of fileFindings) {
875
+ finding.file = relative(basePath, finding.file) || basename(finding.file);
876
+ }
877
+
878
+ allFindings.push(...fileFindings);
879
+ }
880
+
881
+ // Deduplicate findings (same rule + same file + same line)
882
+ const seen = new Set();
883
+ const dedupedFindings = allFindings.filter(f => {
884
+ const key = `${f.rule}:${f.file}:${f.line}`;
885
+ if (seen.has(key)) return false;
886
+ seen.add(key);
887
+ return true;
888
+ });
889
+
890
+ // Sort by severity (ERROR first, then WARNING, then INFO)
891
+ const severityOrder = { ERROR: 0, WARNING: 1, INFO: 2 };
892
+ dedupedFindings.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
893
+
894
+ // When manifest-only scan has findings, count it as 1 "file" for grading purposes
895
+ const effectiveFilesScanned = files.length + (manifest && dedupedFindings.length > 0 ? 1 : 0);
896
+ const grade = calculateGrade(dedupedFindings, effectiveFilesScanned);
897
+ const level = verbosity || 'compact';
898
+
899
+ // Relativize scanned file list
900
+ const basePath = isDir ? resolvedPath : resolve(resolvedPath, '..');
901
+ const scannedFiles = files.map(f => relative(basePath, f) || basename(f));
902
+
903
+ let result;
904
+ switch (level) {
905
+ case 'minimal':
906
+ result = formatMinimal(resolvedPath, effectiveFilesScanned, dedupedFindings, grade);
907
+ break;
908
+ case 'full':
909
+ result = formatFull(resolvedPath, effectiveFilesScanned, dedupedFindings, grade, scannedFiles);
910
+ break;
911
+ case 'compact':
912
+ default:
913
+ result = formatCompact(resolvedPath, effectiveFilesScanned, dedupedFindings, grade);
914
+ }
915
+
916
+ return {
917
+ content: [{
918
+ type: "text",
919
+ text: JSON.stringify(result, null, 2)
920
+ }]
921
+ };
922
+ }