agent-security-scanner-mcp 3.7.0 → 3.8.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,588 @@
1
+ // src/tools/scan-mcp.js
2
+ import { z } from "zod";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
4
+ import { join, resolve, relative, extname, basename } from "path";
5
+
6
+ export const scanMcpServerSchema = {
7
+ server_path: z.string().describe("Path to MCP server directory or entry file"),
8
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)")
9
+ };
10
+
11
+ // File extensions to scan
12
+ const SCANNABLE_EXTENSIONS = new Set(['.js', '.ts', '.py']);
13
+
14
+ // Directories to skip when walking
15
+ const SKIP_DIRS = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '__pycache__',
17
+ 'venv', 'env', '.venv', 'coverage', '.next', '.nuxt'
18
+ ]);
19
+
20
+ // ============================================================
21
+ // Security rule definitions for MCP server scanning
22
+ // ============================================================
23
+
24
+ const MCP_SECURITY_RULES = [
25
+ // ---- Category 1: Overly broad tool permissions ----
26
+ {
27
+ id: 'mcp.shell-exec-no-validation',
28
+ severity: 'ERROR',
29
+ category: 'overly-broad-permissions',
30
+ message: 'Shell command execution without input validation. User-controlled input may reach exec/execSync, enabling arbitrary command execution.',
31
+ pattern: /\b(exec|execSync)\s*\(\s*(`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*(\s*\+|\s*,\s*\{[^}]*shell\s*:\s*true))/g,
32
+ fileTypes: ['.js', '.ts']
33
+ },
34
+ {
35
+ id: 'mcp.shell-exec-direct',
36
+ severity: 'ERROR',
37
+ category: 'overly-broad-permissions',
38
+ message: 'Direct use of exec/execSync with potential string concatenation. Prefer execFile/execFileSync with explicit argument arrays and shell:false.',
39
+ pattern: /\bchild_process\b.*\b(exec|execSync)\b|\b(exec|execSync)\s*\(/g,
40
+ fileTypes: ['.js', '.ts']
41
+ },
42
+ {
43
+ id: 'mcp.spawn-shell-true',
44
+ severity: 'ERROR',
45
+ category: 'overly-broad-permissions',
46
+ message: 'spawn/spawnSync called with shell:true, allowing shell injection. Use shell:false and pass arguments as an array.',
47
+ pattern: /\b(spawn|spawnSync)\s*\([^)]*\{[^}]*shell\s*:\s*true/g,
48
+ fileTypes: ['.js', '.ts']
49
+ },
50
+ {
51
+ id: 'mcp.subprocess-shell',
52
+ severity: 'ERROR',
53
+ category: 'overly-broad-permissions',
54
+ message: 'subprocess called with shell=True, allowing shell injection. Use shell=False with a command list.',
55
+ pattern: /subprocess\.(run|call|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True/g,
56
+ fileTypes: ['.py']
57
+ },
58
+ {
59
+ id: 'mcp.os-system',
60
+ severity: 'ERROR',
61
+ category: 'overly-broad-permissions',
62
+ message: 'os.system() executes commands through the shell. Use subprocess with shell=False instead.',
63
+ pattern: /\bos\.system\s*\(/g,
64
+ fileTypes: ['.py']
65
+ },
66
+ {
67
+ id: 'mcp.fs-write-no-path-validation',
68
+ severity: 'WARNING',
69
+ category: 'overly-broad-permissions',
70
+ message: 'Filesystem write operation without visible path validation. Ensure paths are validated with path.resolve and confined to an allowed directory.',
71
+ pattern: /\b(writeFileSync|writeFile|createWriteStream|appendFileSync|appendFile)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*(?:path\.resolve|path\.join|path\.normalize))/g,
72
+ fileTypes: ['.js', '.ts']
73
+ },
74
+ {
75
+ id: 'mcp.http-request-user-url',
76
+ severity: 'WARNING',
77
+ category: 'overly-broad-permissions',
78
+ message: 'HTTP request to a potentially user-controlled URL. Validate and allowlist target URLs to prevent SSRF.',
79
+ pattern: /\b(fetch|axios\.(get|post|put|delete|request)|http\.request|https\.request|got|request)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*['"`])/g,
80
+ fileTypes: ['.js', '.ts']
81
+ },
82
+ {
83
+ id: 'mcp.env-var-exposure',
84
+ severity: 'WARNING',
85
+ category: 'overly-broad-permissions',
86
+ message: 'Environment variables accessed and potentially exposed in tool output. Ensure secrets are not leaked through MCP responses.',
87
+ pattern: /process\.env\b/g,
88
+ fileTypes: ['.js', '.ts']
89
+ },
90
+ {
91
+ id: 'mcp.env-var-exposure-python',
92
+ severity: 'WARNING',
93
+ category: 'overly-broad-permissions',
94
+ message: 'Environment variables accessed and potentially exposed in tool output. Ensure secrets are not leaked through MCP responses.',
95
+ pattern: /os\.environ\b|os\.getenv\s*\(/g,
96
+ fileTypes: ['.py']
97
+ },
98
+
99
+ // ---- Category 2: Missing input validation ----
100
+ {
101
+ id: 'mcp.no-input-validation',
102
+ severity: 'WARNING',
103
+ category: 'missing-input-validation',
104
+ message: 'Tool handler accepts string input without visible validation or sanitization. Use zod, joi, or manual validation to constrain inputs.',
105
+ // Matches tool handler patterns that take params but don't appear to validate
106
+ pattern: /\.tool\s*\(\s*["'][^"']+["']\s*,\s*["'][^"']*["']\s*,\s*\{[^}]*\}\s*,\s*(async\s+)?\(\s*\{/g,
107
+ fileTypes: ['.js', '.ts'],
108
+ contextCheck: (line, lines, lineIndex) => {
109
+ // Look ahead 15 lines for validation patterns
110
+ const lookahead = lines.slice(lineIndex, lineIndex + 15).join('\n');
111
+ const hasValidation = /\b(z\.|zod\.|joi\.|validate|sanitize|schema|\.parse\(|\.safeParse\(|isValid|assert|check)\b/i.test(lookahead);
112
+ return !hasValidation;
113
+ }
114
+ },
115
+ {
116
+ id: 'mcp.path-no-normalize',
117
+ severity: 'WARNING',
118
+ category: 'missing-input-validation',
119
+ message: 'File path used without normalization. Use path.resolve() or path.normalize() to prevent path traversal attacks.',
120
+ pattern: /\b(readFileSync|readFile|existsSync|statSync|stat|unlink|unlinkSync|rmdir|rmdirSync|mkdir|mkdirSync)\s*\(\s*[a-zA-Z_$][\w$.]*(?!\s*(?:path\.|resolve|normalize))/g,
121
+ fileTypes: ['.js', '.ts'],
122
+ contextCheck: (line, lines, lineIndex) => {
123
+ // Check if path.resolve/normalize is used in surrounding lines
124
+ const context = lines.slice(Math.max(0, lineIndex - 5), lineIndex + 1).join('\n');
125
+ const hasPathNorm = /path\.(resolve|normalize|join)\s*\(/.test(context);
126
+ return !hasPathNorm;
127
+ }
128
+ },
129
+ {
130
+ id: 'mcp.url-no-validation',
131
+ severity: 'WARNING',
132
+ category: 'missing-input-validation',
133
+ message: 'URL used without validation. Validate URL scheme and host to prevent SSRF and open redirect vulnerabilities.',
134
+ pattern: /new\s+URL\s*\(\s*[a-zA-Z_$][\w$.]*\s*\)|url\.parse\s*\(\s*[a-zA-Z_$][\w$.]*\s*\)/g,
135
+ fileTypes: ['.js', '.ts'],
136
+ contextCheck: (line, lines, lineIndex) => {
137
+ const lookahead = lines.slice(lineIndex, lineIndex + 5).join('\n');
138
+ const hasHostCheck = /\.(hostname|host|protocol|origin)\s*(===|!==|==|!=)|allowlist|whitelist|allowed/i.test(lookahead);
139
+ return !hasHostCheck;
140
+ }
141
+ },
142
+
143
+ // ---- Category 3: Data exfiltration patterns ----
144
+ {
145
+ id: 'mcp.exfiltration-external-request',
146
+ severity: 'ERROR',
147
+ category: 'data-exfiltration',
148
+ message: 'Data sent to an external URL. MCP servers should not exfiltrate data to third-party endpoints without explicit user consent.',
149
+ 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,
150
+ fileTypes: ['.js', '.ts']
151
+ },
152
+ {
153
+ id: 'mcp.exfiltration-external-request-python',
154
+ severity: 'ERROR',
155
+ category: 'data-exfiltration',
156
+ message: 'Data sent to an external URL. MCP servers should not exfiltrate data to third-party endpoints without explicit user consent.',
157
+ 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,
158
+ fileTypes: ['.py']
159
+ },
160
+ {
161
+ id: 'mcp.exfiltration-network-socket',
162
+ severity: 'WARNING',
163
+ category: 'data-exfiltration',
164
+ message: 'Network socket created. Verify this is not used to exfiltrate data to external hosts.',
165
+ pattern: /\bnet\.(createConnection|connect|Socket)\s*\(|new\s+WebSocket\s*\(/g,
166
+ fileTypes: ['.js', '.ts']
167
+ },
168
+ {
169
+ id: 'mcp.exfiltration-log-secrets',
170
+ severity: 'WARNING',
171
+ category: 'data-exfiltration',
172
+ message: 'Potentially sensitive data (keys, tokens, passwords) logged or printed. This may leak secrets through MCP server stderr.',
173
+ 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,
174
+ fileTypes: ['.js', '.ts', '.py']
175
+ },
176
+
177
+ // ---- Category 4: Insecure code patterns ----
178
+ {
179
+ id: 'mcp.eval-usage',
180
+ severity: 'ERROR',
181
+ category: 'insecure-patterns',
182
+ message: 'eval() executes arbitrary code. Never use eval with user-controlled input in an MCP server.',
183
+ pattern: /\beval\s*\(/g,
184
+ fileTypes: ['.js', '.ts', '.py']
185
+ },
186
+ {
187
+ id: 'mcp.function-constructor',
188
+ severity: 'ERROR',
189
+ category: 'insecure-patterns',
190
+ message: 'new Function() is equivalent to eval(). Avoid constructing functions from strings.',
191
+ pattern: /new\s+Function\s*\(/g,
192
+ fileTypes: ['.js', '.ts']
193
+ },
194
+ {
195
+ id: 'mcp.exec-string-concat',
196
+ severity: 'ERROR',
197
+ category: 'insecure-patterns',
198
+ message: 'child_process.exec() with string concatenation is vulnerable to command injection. Use execFile() with argument arrays.',
199
+ pattern: /\bexec\s*\(\s*['"`][^'"`]*['"`]\s*\+/g,
200
+ fileTypes: ['.js', '.ts']
201
+ },
202
+ {
203
+ id: 'mcp.cors-wildcard',
204
+ severity: 'WARNING',
205
+ category: 'insecure-patterns',
206
+ message: 'CORS configured with wildcard origin (*). This allows any website to interact with the MCP server.',
207
+ pattern: /cors\s*\(\s*\{[^}]*origin\s*:\s*['"]\*['"]/g,
208
+ fileTypes: ['.js', '.ts']
209
+ },
210
+ {
211
+ id: 'mcp.cors-permissive',
212
+ severity: 'INFO',
213
+ category: 'insecure-patterns',
214
+ message: 'CORS enabled. Verify the origin configuration is appropriately restrictive.',
215
+ pattern: /\bcors\s*\(\s*\)/g,
216
+ fileTypes: ['.js', '.ts']
217
+ },
218
+ {
219
+ id: 'mcp.no-auth-check',
220
+ severity: 'INFO',
221
+ category: 'insecure-patterns',
222
+ message: 'No authentication or authorization checks detected. If this MCP server is network-accessible, add authentication.',
223
+ pattern: /\b(createServer|listen)\s*\(/g,
224
+ fileTypes: ['.js', '.ts'],
225
+ contextCheck: (_line, lines) => {
226
+ const fullSource = lines.join('\n');
227
+ const hasAuth = /\b(auth|authenticate|authorize|jwt|bearer|token|apiKey|api_key|session|passport)\b/i.test(fullSource);
228
+ return !hasAuth;
229
+ }
230
+ },
231
+ {
232
+ id: 'mcp.pickle-load',
233
+ severity: 'ERROR',
234
+ category: 'insecure-patterns',
235
+ message: 'pickle.load/loads deserializes arbitrary Python objects. This can execute arbitrary code if the input is attacker-controlled.',
236
+ pattern: /\bpickle\.(load|loads)\s*\(/g,
237
+ fileTypes: ['.py']
238
+ },
239
+ {
240
+ id: 'mcp.yaml-unsafe-load',
241
+ severity: 'ERROR',
242
+ category: 'insecure-patterns',
243
+ message: 'yaml.load() without SafeLoader can execute arbitrary Python. Use yaml.safe_load() instead.',
244
+ pattern: /\byaml\.load\s*\([^)]*(?!Loader\s*=\s*yaml\.SafeLoader)/g,
245
+ fileTypes: ['.py']
246
+ }
247
+ ];
248
+
249
+ // ============================================================
250
+ // File collection
251
+ // ============================================================
252
+
253
+ function collectFiles(serverPath) {
254
+ const resolvedPath = resolve(serverPath);
255
+
256
+ if (!existsSync(resolvedPath)) {
257
+ return [];
258
+ }
259
+
260
+ let stat;
261
+ try {
262
+ stat = statSync(resolvedPath);
263
+ } catch {
264
+ return [];
265
+ }
266
+
267
+ // If a single file is provided, return it directly
268
+ if (stat.isFile()) {
269
+ const ext = extname(resolvedPath).toLowerCase();
270
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
271
+ return [resolvedPath];
272
+ }
273
+ return [];
274
+ }
275
+
276
+ // Walk the directory
277
+ const files = [];
278
+
279
+ function walk(dir) {
280
+ let entries;
281
+ try {
282
+ entries = readdirSync(dir);
283
+ } catch {
284
+ return;
285
+ }
286
+
287
+ for (const entry of entries) {
288
+ if (entry.startsWith('.')) continue;
289
+
290
+ const fullPath = join(dir, entry);
291
+ let entryStat;
292
+ try {
293
+ entryStat = statSync(fullPath);
294
+ } catch {
295
+ continue;
296
+ }
297
+
298
+ if (entryStat.isDirectory()) {
299
+ if (SKIP_DIRS.has(entry)) continue;
300
+ walk(fullPath);
301
+ } else if (entryStat.isFile()) {
302
+ const ext = extname(entry).toLowerCase();
303
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
304
+ files.push(fullPath);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ walk(resolvedPath);
311
+ return files;
312
+ }
313
+
314
+ // ============================================================
315
+ // Scanning engine
316
+ // ============================================================
317
+
318
+ function scanFileContent(filePath, content) {
319
+ const ext = extname(filePath).toLowerCase();
320
+ const lines = content.split('\n');
321
+ const findings = [];
322
+
323
+ for (const rule of MCP_SECURITY_RULES) {
324
+ // Check if rule applies to this file type
325
+ if (!rule.fileTypes.includes(ext)) continue;
326
+
327
+ // Reset regex state
328
+ const regex = new RegExp(rule.pattern.source, rule.pattern.flags);
329
+ let match;
330
+
331
+ while ((match = regex.exec(content)) !== null) {
332
+ // Calculate line number from match index
333
+ const upToMatch = content.substring(0, match.index);
334
+ const lineNumber = upToMatch.split('\n').length;
335
+ const lineIndex = lineNumber - 1;
336
+
337
+ // If rule has a context check, apply it
338
+ if (rule.contextCheck) {
339
+ const line = lines[lineIndex] || '';
340
+ if (!rule.contextCheck(line, lines, lineIndex)) {
341
+ continue;
342
+ }
343
+ }
344
+
345
+ findings.push({
346
+ rule: rule.id,
347
+ severity: rule.severity,
348
+ category: rule.category,
349
+ message: rule.message,
350
+ file: filePath,
351
+ line: lineNumber,
352
+ match: match[0].substring(0, 100) // Truncate long matches
353
+ });
354
+ }
355
+ }
356
+
357
+ return findings;
358
+ }
359
+
360
+ // ============================================================
361
+ // Grading
362
+ // ============================================================
363
+
364
+ function calculateGrade(findings, filesScanned) {
365
+ if (filesScanned === 0) return 'A';
366
+
367
+ const errorCount = findings.filter(f => f.severity === 'ERROR').length;
368
+ const warningCount = findings.filter(f => f.severity === 'WARNING').length;
369
+ const totalCount = findings.length;
370
+ const density = totalCount / filesScanned;
371
+
372
+ if (errorCount === 0 && warningCount === 0) return 'A';
373
+ if (errorCount === 0 && density < 0.5) return 'B';
374
+ if (errorCount <= 2 && density < 1.5) return 'C';
375
+ if (errorCount <= 5 && density < 3) return 'D';
376
+ return 'F';
377
+ }
378
+
379
+ // ============================================================
380
+ // Recommendations generator
381
+ // ============================================================
382
+
383
+ function generateRecommendations(findings) {
384
+ const recommendations = [];
385
+ const categories = new Set(findings.map(f => f.category));
386
+
387
+ if (categories.has('overly-broad-permissions')) {
388
+ recommendations.push('Replace exec/execSync with execFile/execFileSync and pass arguments as arrays with shell:false.');
389
+ recommendations.push('Validate and confine file paths using path.resolve() and an allowlist of permitted directories.');
390
+ }
391
+
392
+ if (categories.has('missing-input-validation')) {
393
+ recommendations.push('Add input validation using zod schemas for all tool parameters (strings, paths, URLs).');
394
+ recommendations.push('Normalize file paths with path.resolve() and validate they stay within allowed directories.');
395
+ }
396
+
397
+ if (categories.has('data-exfiltration')) {
398
+ recommendations.push('Audit all outbound network requests. MCP servers should not send data to external endpoints without user consent.');
399
+ recommendations.push('Avoid logging sensitive values (keys, tokens, passwords) to stderr or stdout.');
400
+ }
401
+
402
+ if (categories.has('insecure-patterns')) {
403
+ recommendations.push('Remove all uses of eval() and new Function(). Use structured data parsing instead.');
404
+ if (findings.some(f => f.rule.includes('cors'))) {
405
+ recommendations.push('Configure CORS with specific allowed origins rather than wildcards.');
406
+ }
407
+ if (findings.some(f => f.rule.includes('auth'))) {
408
+ recommendations.push('Add authentication for network-accessible MCP servers (e.g., bearer tokens, API keys).');
409
+ }
410
+ }
411
+
412
+ if (recommendations.length === 0) {
413
+ recommendations.push('No critical issues found. Continue following security best practices.');
414
+ }
415
+
416
+ return recommendations;
417
+ }
418
+
419
+ // ============================================================
420
+ // Verbosity formatters
421
+ // ============================================================
422
+
423
+ function formatMinimal(serverPath, filesScanned, findings, grade) {
424
+ const bySeverity = { ERROR: 0, WARNING: 0, INFO: 0 };
425
+ findings.forEach(f => bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1);
426
+
427
+ return {
428
+ server_path: serverPath,
429
+ files_scanned: filesScanned,
430
+ grade,
431
+ findings_count: findings.length,
432
+ critical: bySeverity.ERROR,
433
+ warning: bySeverity.WARNING,
434
+ info: bySeverity.INFO,
435
+ message: findings.length > 0
436
+ ? `Found ${findings.length} issue(s) across ${filesScanned} files. Grade: ${grade}`
437
+ : `No issues found in ${filesScanned} files. Grade: ${grade}`
438
+ };
439
+ }
440
+
441
+ function formatCompact(serverPath, filesScanned, findings, grade) {
442
+ const recommendations = generateRecommendations(findings);
443
+
444
+ return {
445
+ server_path: serverPath,
446
+ files_scanned: filesScanned,
447
+ grade,
448
+ findings_count: findings.length,
449
+ findings: findings.map(f => ({
450
+ rule: f.rule,
451
+ severity: f.severity,
452
+ message: f.message,
453
+ file: f.file,
454
+ line: f.line
455
+ })),
456
+ recommendations
457
+ };
458
+ }
459
+
460
+ function formatFull(serverPath, filesScanned, findings, grade, scannedFiles) {
461
+ const bySeverity = { ERROR: 0, WARNING: 0, INFO: 0 };
462
+ findings.forEach(f => bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1);
463
+
464
+ const byCategory = {};
465
+ findings.forEach(f => {
466
+ byCategory[f.category] = (byCategory[f.category] || 0) + 1;
467
+ });
468
+
469
+ const byFile = {};
470
+ findings.forEach(f => {
471
+ const rel = f.file;
472
+ byFile[rel] = (byFile[rel] || 0) + 1;
473
+ });
474
+
475
+ const recommendations = generateRecommendations(findings);
476
+
477
+ return {
478
+ server_path: serverPath,
479
+ files_scanned: filesScanned,
480
+ grade,
481
+ findings_count: findings.length,
482
+ by_severity: bySeverity,
483
+ by_category: byCategory,
484
+ by_file: byFile,
485
+ findings: findings.map(f => ({
486
+ rule: f.rule,
487
+ severity: f.severity,
488
+ category: f.category,
489
+ message: f.message,
490
+ file: f.file,
491
+ line: f.line,
492
+ match: f.match
493
+ })),
494
+ recommendations,
495
+ scanned_files: scannedFiles
496
+ };
497
+ }
498
+
499
+ // ============================================================
500
+ // Main handler
501
+ // ============================================================
502
+
503
+ export async function scanMcpServer({ server_path, verbosity }) {
504
+ const resolvedPath = resolve(server_path);
505
+
506
+ if (!existsSync(resolvedPath)) {
507
+ return {
508
+ content: [{ type: "text", text: JSON.stringify({ error: "Server path not found", server_path }) }]
509
+ };
510
+ }
511
+
512
+ // Collect files to scan
513
+ const files = collectFiles(resolvedPath);
514
+
515
+ if (files.length === 0) {
516
+ return {
517
+ content: [{ type: "text", text: JSON.stringify({
518
+ server_path: resolvedPath,
519
+ files_scanned: 0,
520
+ grade: 'A',
521
+ findings_count: 0,
522
+ message: "No scannable files (.js, .ts, .py) found at the given path."
523
+ }) }]
524
+ };
525
+ }
526
+
527
+ // Scan each file
528
+ const allFindings = [];
529
+
530
+ for (const filePath of files) {
531
+ let content;
532
+ try {
533
+ content = readFileSync(filePath, 'utf-8');
534
+ } catch {
535
+ continue;
536
+ }
537
+
538
+ const fileFindings = scanFileContent(filePath, content);
539
+
540
+ // Convert absolute paths to relative for output readability
541
+ const basePath = statSync(resolvedPath).isDirectory() ? resolvedPath : resolve(resolvedPath, '..');
542
+ for (const finding of fileFindings) {
543
+ finding.file = relative(basePath, finding.file) || basename(finding.file);
544
+ }
545
+
546
+ allFindings.push(...fileFindings);
547
+ }
548
+
549
+ // Deduplicate findings (same rule + same file + same line)
550
+ const seen = new Set();
551
+ const dedupedFindings = allFindings.filter(f => {
552
+ const key = `${f.rule}:${f.file}:${f.line}`;
553
+ if (seen.has(key)) return false;
554
+ seen.add(key);
555
+ return true;
556
+ });
557
+
558
+ // Sort by severity (ERROR first, then WARNING, then INFO)
559
+ const severityOrder = { ERROR: 0, WARNING: 1, INFO: 2 };
560
+ dedupedFindings.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
561
+
562
+ const grade = calculateGrade(dedupedFindings, files.length);
563
+ const level = verbosity || 'compact';
564
+
565
+ // Relativize scanned file list
566
+ const basePath = statSync(resolvedPath).isDirectory() ? resolvedPath : resolve(resolvedPath, '..');
567
+ const scannedFiles = files.map(f => relative(basePath, f) || basename(f));
568
+
569
+ let result;
570
+ switch (level) {
571
+ case 'minimal':
572
+ result = formatMinimal(resolvedPath, files.length, dedupedFindings, grade);
573
+ break;
574
+ case 'full':
575
+ result = formatFull(resolvedPath, files.length, dedupedFindings, grade, scannedFiles);
576
+ break;
577
+ case 'compact':
578
+ default:
579
+ result = formatCompact(resolvedPath, files.length, dedupedFindings, grade);
580
+ }
581
+
582
+ return {
583
+ content: [{
584
+ type: "text",
585
+ text: JSON.stringify(result, null, 2)
586
+ }]
587
+ };
588
+ }
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
4
4
  import { join, resolve, relative, extname, basename } from "path";
5
5
  import { execFileSync } from "child_process";
6
6
  import { scanSecurity } from './scan-security.js';
7
- import { matchGlob, loadConfig, shouldExcludeFile } from '../config.js';
7
+ import { matchGlob, loadConfig, shouldExcludeFile, evaluatePolicy } from '../config.js';
8
8
  import { detectLanguage } from '../utils.js';
9
9
 
10
10
  export const scanProjectSchema = {
@@ -223,9 +223,9 @@ export async function scanProject({ directory_path, recursive, include_patterns,
223
223
  let crossFileIssues = [];
224
224
  if (cross_file && files.length <= 50) {
225
225
  try {
226
- const { runCrossFileAnalyzer } = await import('../utils.js');
227
- if (typeof runCrossFileAnalyzer === 'function') {
228
- const crossResults = runCrossFileAnalyzer(files);
226
+ const { runCrossFileAnalyzerAsync } = await import('../utils.js');
227
+ if (typeof runCrossFileAnalyzerAsync === 'function') {
228
+ const crossResults = await runCrossFileAnalyzerAsync(files);
229
229
  if (Array.isArray(crossResults)) {
230
230
  crossFileIssues = crossResults;
231
231
  for (const issue of crossFileIssues) {
@@ -243,6 +243,12 @@ export async function scanProject({ directory_path, recursive, include_patterns,
243
243
  const grade = calculateGrade(allIssues.length, files.length, bySeverity.error);
244
244
  const level = verbosity || 'compact';
245
245
 
246
+ // Evaluate policy
247
+ const policyResult = evaluatePolicy(
248
+ { grade, by_severity: bySeverity, issues_count: allIssues.length },
249
+ config
250
+ );
251
+
246
252
  if (level === 'minimal') {
247
253
  return {
248
254
  content: [{ type: "text", text: JSON.stringify({
@@ -253,6 +259,8 @@ export async function scanProject({ directory_path, recursive, include_patterns,
253
259
  warning: bySeverity.warning,
254
260
  info: bySeverity.info,
255
261
  grade,
262
+ policy_passed: policyResult.passed,
263
+ policy_violations: policyResult.violations.length > 0 ? policyResult.violations : undefined,
256
264
  message: allIssues.length > 0
257
265
  ? `Found ${allIssues.length} issue(s) across ${files.length} files. Grade: ${grade}`
258
266
  : `No issues found in ${files.length} files. Grade: ${grade}`
@@ -285,6 +293,8 @@ export async function scanProject({ directory_path, recursive, include_patterns,
285
293
  by_severity: bySeverity,
286
294
  by_category: byCategory,
287
295
  cross_file_issues: crossFileIssues.length > 0 ? crossFileIssues.length : undefined,
296
+ policy_passed: policyResult.passed,
297
+ policy_violations: policyResult.violations.length > 0 ? policyResult.violations : undefined,
288
298
  issues: topIssues
289
299
  }, null, 2) }]
290
300
  };
@@ -301,6 +311,8 @@ export async function scanProject({ directory_path, recursive, include_patterns,
301
311
  by_category: byCategory,
302
312
  by_file: byFile,
303
313
  cross_file_issues: crossFileIssues.length > 0 ? crossFileIssues : undefined,
314
+ policy_passed: policyResult.passed,
315
+ policy_violations: policyResult.violations.length > 0 ? policyResult.violations : undefined,
304
316
  issues: allIssues,
305
317
  scanned_files: files.map(f => relative(dirPath, f))
306
318
  }, null, 2) }]