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.
- package/README.md +232 -5
- package/index.js +81 -1
- package/openclaw.plugin.json +41 -0
- package/package.json +4 -1
- package/regex_fallback.py +3 -1
- package/rules/clawhavoc.yaml +443 -0
- package/src/cli/audit.js +18 -0
- package/src/cli/harden.js +15 -0
- package/src/context.js +4 -0
- package/src/daemon-client.js +10 -0
- package/src/plugin-config.js +77 -0
- package/src/plugin-health.js +49 -0
- package/src/tools/scan-mcp.js +344 -10
- package/src/tools/scan-security.js +32 -5
- package/src/tools/scan-skill.js +743 -0
- package/src/utils.js +58 -0
- package/src/tools/garak-bridge.js +0 -209
package/src/tools/scan-mcp.js
CHANGED
|
@@ -1,22 +1,86 @@
|
|
|
1
1
|
// src/tools/scan-mcp.js
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
906
|
+
result = formatMinimal(resolvedPath, effectiveFilesScanned, dedupedFindings, grade);
|
|
573
907
|
break;
|
|
574
908
|
case 'full':
|
|
575
|
-
result = formatFull(resolvedPath,
|
|
909
|
+
result = formatFull(resolvedPath, effectiveFilesScanned, dedupedFindings, grade, scannedFiles);
|
|
576
910
|
break;
|
|
577
911
|
case 'compact':
|
|
578
912
|
default:
|
|
579
|
-
result = formatCompact(resolvedPath,
|
|
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 {
|
|
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
|
-
|
|
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",
|