agent-security-scanner-mcp 3.8.0 → 3.10.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.
@@ -1,22 +1,86 @@
1
1
  // src/tools/scan-mcp.js
2
2
  import { z } from "zod";
3
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { createHash } from "crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4
5
  import { join, resolve, relative, extname, basename } from "path";
5
6
 
6
7
  export const scanMcpServerSchema = {
7
8
  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
+ 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.")
9
12
  };
10
13
 
11
14
  // File extensions to scan
12
15
  const SCANNABLE_EXTENSIONS = new Set(['.js', '.ts', '.py']);
13
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
+
14
24
  // Directories to skip when walking
15
25
  const SKIP_DIRS = new Set([
16
26
  'node_modules', '.git', 'dist', 'build', '__pycache__',
17
27
  'venv', 'env', '.venv', 'coverage', '.next', '.nuxt'
18
28
  ]);
19
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
+
20
84
  // ============================================================
21
85
  // Security rule definitions for MCP server scanning
22
86
  // ============================================================
@@ -243,6 +307,58 @@ const MCP_SECURITY_RULES = [
243
307
  message: 'yaml.load() without SafeLoader can execute arbitrary Python. Use yaml.safe_load() instead.',
244
308
  pattern: /\byaml\.load\s*\([^)]*(?!Loader\s*=\s*yaml\.SafeLoader)/g,
245
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
246
362
  }
247
363
  ];
248
364
 
@@ -342,6 +458,24 @@ function scanFileContent(filePath, content) {
342
458
  }
343
459
  }
344
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
+
345
479
  findings.push({
346
480
  rule: rule.id,
347
481
  severity: rule.severity,
@@ -409,6 +543,30 @@ function generateRecommendations(findings) {
409
543
  }
410
544
  }
411
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
+
412
570
  if (recommendations.length === 0) {
413
571
  recommendations.push('No critical issues found. Continue following security best practices.');
414
572
  }
@@ -496,11 +654,155 @@ function formatFull(serverPath, filesScanned, findings, grade, scannedFiles) {
496
654
  };
497
655
  }
498
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
+
499
801
  // ============================================================
500
802
  // Main handler
501
803
  // ============================================================
502
804
 
503
- export async function scanMcpServer({ server_path, verbosity }) {
805
+ export async function scanMcpServer({ server_path, verbosity, manifest, update_baseline }) {
504
806
  const resolvedPath = resolve(server_path);
505
807
 
506
808
  if (!existsSync(resolvedPath)) {
@@ -509,10 +811,13 @@ export async function scanMcpServer({ server_path, verbosity }) {
509
811
  };
510
812
  }
511
813
 
814
+ // Compute once; used in multiple places below
815
+ const isDir = statSync(resolvedPath).isDirectory();
816
+
512
817
  // Collect files to scan
513
818
  const files = collectFiles(resolvedPath);
514
819
 
515
- if (files.length === 0) {
820
+ if (files.length === 0 && !manifest) {
516
821
  return {
517
822
  content: [{ type: "text", text: JSON.stringify({
518
823
  server_path: resolvedPath,
@@ -527,6 +832,33 @@ export async function scanMcpServer({ server_path, verbosity }) {
527
832
  // Scan each file
528
833
  const allFindings = [];
529
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
+
530
862
  for (const filePath of files) {
531
863
  let content;
532
864
  try {
@@ -538,7 +870,7 @@ export async function scanMcpServer({ server_path, verbosity }) {
538
870
  const fileFindings = scanFileContent(filePath, content);
539
871
 
540
872
  // Convert absolute paths to relative for output readability
541
- const basePath = statSync(resolvedPath).isDirectory() ? resolvedPath : resolve(resolvedPath, '..');
873
+ const basePath = isDir ? resolvedPath : resolve(resolvedPath, '..');
542
874
  for (const finding of fileFindings) {
543
875
  finding.file = relative(basePath, finding.file) || basename(finding.file);
544
876
  }
@@ -559,24 +891,26 @@ export async function scanMcpServer({ server_path, verbosity }) {
559
891
  const severityOrder = { ERROR: 0, WARNING: 1, INFO: 2 };
560
892
  dedupedFindings.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
561
893
 
562
- const grade = calculateGrade(dedupedFindings, files.length);
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);
563
897
  const level = verbosity || 'compact';
564
898
 
565
899
  // Relativize scanned file list
566
- const basePath = statSync(resolvedPath).isDirectory() ? resolvedPath : resolve(resolvedPath, '..');
900
+ const basePath = isDir ? resolvedPath : resolve(resolvedPath, '..');
567
901
  const scannedFiles = files.map(f => relative(basePath, f) || basename(f));
568
902
 
569
903
  let result;
570
904
  switch (level) {
571
905
  case 'minimal':
572
- result = formatMinimal(resolvedPath, files.length, dedupedFindings, grade);
906
+ result = formatMinimal(resolvedPath, effectiveFilesScanned, dedupedFindings, grade);
573
907
  break;
574
908
  case 'full':
575
- result = formatFull(resolvedPath, files.length, dedupedFindings, grade, scannedFiles);
909
+ result = formatFull(resolvedPath, effectiveFilesScanned, dedupedFindings, grade, scannedFiles);
576
910
  break;
577
911
  case 'compact':
578
912
  default:
579
- result = formatCompact(resolvedPath, files.length, dedupedFindings, grade);
913
+ result = formatCompact(resolvedPath, effectiveFilesScanned, dedupedFindings, grade);
580
914
  }
581
915
 
582
916
  return {
@@ -1,16 +1,20 @@
1
1
  // src/tools/scan-security.js
2
2
  import { z } from "zod";
3
3
  import { existsSync, readFileSync } from "fs";
4
- import { detectLanguage, runAnalyzerAsync, generateFix, toSarif, getEngineMode } from '../utils.js';
4
+ import { dirname } from "path";
5
+ import { detectLanguage, runAnalyzerAsync, generateFix, toSarif, getEngineMode, extractImports, isTestFile } from '../utils.js';
5
6
  import { deduplicateFindings } from '../dedup.js';
6
7
  import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from '../context.js';
7
8
  import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
9
+ import { discoverProjectContext } from './project-context.js';
8
10
 
9
11
  export const scanSecuritySchema = {
10
12
  file_path: z.string().describe("Path to the file to scan"),
11
13
  output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
12
14
  verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
13
- engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)")
15
+ engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)"),
16
+ project_context: z.boolean().optional().describe("Include project context (framework, security middleware, dependencies)"),
17
+ include_context: z.boolean().optional().describe("Include surrounding code context for each issue")
14
18
  };
15
19
 
16
20
  // Verbosity formatters
@@ -58,7 +62,7 @@ function formatFull(file_path, language, issues) {
58
62
  };
59
63
  }
60
64
 
61
- export async function scanSecurity({ file_path, output_format, verbosity, engine }) {
65
+ export async function scanSecurity({ file_path, output_format, verbosity, engine, project_context, include_context }) {
62
66
  if (!existsSync(file_path)) {
63
67
  return {
64
68
  content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }]
@@ -101,15 +105,30 @@ export async function scanSecurity({ file_path, output_format, verbosity, engine
101
105
  // Apply .scannerrc configuration (rule suppression, severity/confidence thresholds)
102
106
  const issues = applyConfig(frameworkAdjusted, file_path, config);
103
107
 
104
- // Enhance issues with fix suggestions
108
+ // Enhance issues with fix suggestions and optional surrounding context
105
109
  const enhancedIssues = issues.map(issue => {
106
110
  const line = lines[issue.line] || '';
107
111
  const fix = generateFix(issue, line, language);
108
- return {
112
+ const enhanced = {
109
113
  ...issue,
110
114
  line_content: line.trim(),
111
115
  suggested_fix: fix
112
116
  };
117
+
118
+ if (include_context) {
119
+ const lineIdx = issue.line;
120
+ const contextLines = 3;
121
+ enhanced.context_before = [];
122
+ enhanced.context_after = [];
123
+ for (let i = Math.max(0, lineIdx - contextLines); i < lineIdx; i++) {
124
+ enhanced.context_before.push({ line: i + 1, content: lines[i] || '' });
125
+ }
126
+ for (let i = lineIdx + 1; i <= Math.min(lines.length - 1, lineIdx + contextLines); i++) {
127
+ enhanced.context_after.push({ line: i + 1, content: lines[i] || '' });
128
+ }
129
+ }
130
+
131
+ return enhanced;
113
132
  });
114
133
 
115
134
  // Determine verbosity (default: compact)
@@ -139,6 +158,14 @@ export async function scanSecurity({ file_path, output_format, verbosity, engine
139
158
  result = formatCompact(file_path, language, enhancedIssues);
140
159
  }
141
160
 
161
+ // Attach project context if requested
162
+ if (project_context) {
163
+ const projectDir = dirname(file_path);
164
+ result.project = discoverProjectContext(projectDir);
165
+ result.is_test_file = isTestFile(file_path);
166
+ result.file_imports = extractImports(content, language);
167
+ }
168
+
142
169
  return {
143
170
  content: [{
144
171
  type: "text",