@zcode-apps/mcp-sentinel 0.1.1 β 0.2.2
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 +46 -58
- package/dist/cli.js +59 -4
- package/dist/scanner.d.ts +13 -2
- package/dist/scanner.js +193 -23
- package/package.json +9 -2
- package/src/cli.ts +73 -6
- package/src/scanner.ts +221 -29
package/README.md
CHANGED
|
@@ -13,24 +13,55 @@ npm install -g @zcode-apps/mcp-sentinel
|
|
|
13
13
|
mcp-sentinel scan https://your-mcp-server.com
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Basic scan (text output)
|
|
20
|
+
npx @zcode-apps/mcp-sentinel scan https://api.example.com/mcp
|
|
21
|
+
|
|
22
|
+
# JSON output
|
|
23
|
+
npx @zcode-apps/mcp-sentinel scan https://api.example.com/mcp --json
|
|
24
|
+
|
|
25
|
+
# Verbose mode (show evidence)
|
|
26
|
+
npx @zcode-apps/mcp-sentinel scan https://api.example.com/mcp --verbose
|
|
27
|
+
```
|
|
28
|
+
|
|
16
29
|
## Features
|
|
17
30
|
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
31
|
+
- **MCP Protocol Detection** - Verifies valid MCP endpoints
|
|
32
|
+
- **Authentication Bypass** - Checks for missing auth
|
|
33
|
+
- **Dangerous Tool Detection** - Finds tools with RCE, file access, SQL risks
|
|
34
|
+
- **Path Traversal** - Detects unsafe resource URIs
|
|
35
|
+
- **Prompt Injection Risks** - Identifies dynamic prompt vulnerabilities
|
|
22
36
|
|
|
23
|
-
##
|
|
37
|
+
## Output Example
|
|
24
38
|
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
```
|
|
40
|
+
π MCP Sentinel - Security Scanner
|
|
41
|
+
|
|
42
|
+
Target: https://api.example.com/mcp
|
|
43
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
npx @zcode-apps/mcp-sentinel scan https://api.example.com --output report.json
|
|
45
|
+
π΅ INFO:
|
|
31
46
|
|
|
32
|
-
|
|
33
|
-
|
|
47
|
+
[INFO]
|
|
48
|
+
MCP Server detected: my-mcp-server v1.0.0
|
|
49
|
+
|
|
50
|
+
[INFO]
|
|
51
|
+
Found 5 exposed tools: ["get_weather", "run_command", "read_file"]
|
|
52
|
+
|
|
53
|
+
π HIGH SEVERITY:
|
|
54
|
+
|
|
55
|
+
[AUTH_BYPASS]
|
|
56
|
+
MCP server accepts unauthenticated connections
|
|
57
|
+
π‘ Recommendation: Implement authentication on MCP endpoints
|
|
58
|
+
|
|
59
|
+
ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
60
|
+
π SUMMARY:
|
|
61
|
+
Critical: 0
|
|
62
|
+
High: 1
|
|
63
|
+
Medium: 0
|
|
64
|
+
Info: 2
|
|
34
65
|
```
|
|
35
66
|
|
|
36
67
|
## Why MCP Sentinel?
|
|
@@ -43,53 +74,10 @@ npx @zcode-apps/mcp-sentinel scan https://api.example.com --verbose
|
|
|
43
74
|
|
|
44
75
|
**Don't be part of the 43%.** Scan your MCP servers today.
|
|
45
76
|
|
|
46
|
-
## Known CVEs Detected
|
|
47
|
-
|
|
48
|
-
| CVE | Type | CVSS |
|
|
49
|
-
|-----|------|------|
|
|
50
|
-
| CVE-2026-01234 | Prompt Injection RCE | 9.8 |
|
|
51
|
-
| CVE-2026-2178 | xcode-mcp-server RCE | 9.1 |
|
|
52
|
-
| CVE-2026-27825 | MCPwnfluence Attack Chain | 9.1 |
|
|
53
|
-
| CVE-2026-27826 | MCPwnfluence RCE | 8.2 |
|
|
54
|
-
| CVE-2026-02345 | MCP DoS | 6.5 |
|
|
55
|
-
|
|
56
|
-
## Output Format
|
|
57
|
-
|
|
58
|
-
```json
|
|
59
|
-
{
|
|
60
|
-
"url": "https://api.example.com",
|
|
61
|
-
"timestamp": "2026-03-13T09:00:00Z",
|
|
62
|
-
"vulnerabilities": [
|
|
63
|
-
{
|
|
64
|
-
"type": "RCE",
|
|
65
|
-
"severity": "CRITICAL",
|
|
66
|
-
"description": "Command injection in tool execution",
|
|
67
|
-
"recommendation": "Sanitize all user inputs before execution"
|
|
68
|
-
}
|
|
69
|
-
],
|
|
70
|
-
"summary": {
|
|
71
|
-
"critical": 1,
|
|
72
|
-
"high": 0,
|
|
73
|
-
"medium": 0,
|
|
74
|
-
"low": 0
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
## Programmatic Usage
|
|
80
|
-
|
|
81
|
-
```typescript
|
|
82
|
-
import { MCPSentinel } from '@zcode-apps/mcp-sentinel';
|
|
83
|
-
|
|
84
|
-
const scanner = new MCPSentinel();
|
|
85
|
-
const results = await scanner.scan('https://api.example.com');
|
|
86
|
-
|
|
87
|
-
console.log(results.vulnerabilities);
|
|
88
|
-
```
|
|
89
|
-
|
|
90
77
|
## Repository
|
|
91
78
|
|
|
92
|
-
**
|
|
79
|
+
**GitHub:** https://github.com/zcode-apps/mcp-sentinel
|
|
80
|
+
**npm:** https://www.npmjs.com/package/@zcode-apps/mcp-sentinel
|
|
93
81
|
|
|
94
82
|
## License
|
|
95
83
|
|
|
@@ -97,4 +85,4 @@ MIT License
|
|
|
97
85
|
|
|
98
86
|
---
|
|
99
87
|
|
|
100
|
-
**Built by
|
|
88
|
+
**Built by Sebastian Zang**
|
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,68 @@ import { MCPSentinel } from './scanner.js';
|
|
|
4
4
|
const program = new Command();
|
|
5
5
|
program
|
|
6
6
|
.name('mcp-sentinel')
|
|
7
|
-
.description('MCP Security Scanner -
|
|
7
|
+
.description('MCP Security Scanner - Detects vulnerabilities in MCP servers')
|
|
8
|
+
.version('0.2.1');
|
|
8
9
|
program
|
|
9
10
|
.command('scan <url>')
|
|
10
|
-
.description('Scan
|
|
11
|
-
.
|
|
11
|
+
.description('Scan an MCP server endpoint for security vulnerabilities')
|
|
12
|
+
.option('-j, --json', 'Output as JSON', false)
|
|
13
|
+
.option('-v, --verbose', 'Show detailed evidence', false)
|
|
14
|
+
.action(async (url, options) => {
|
|
12
15
|
const scanner = new MCPSentinel();
|
|
16
|
+
if (!options.json) {
|
|
17
|
+
console.log(`\nπ MCP Sentinel - Security Scanner\n`);
|
|
18
|
+
console.log(`Target: ${url}\n`);
|
|
19
|
+
console.log('β'.repeat(50));
|
|
20
|
+
}
|
|
13
21
|
const results = await scanner.scan(url);
|
|
14
|
-
|
|
22
|
+
if (options.json) {
|
|
23
|
+
console.log(JSON.stringify(results, null, 2));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Text output
|
|
27
|
+
if (results.length === 0) {
|
|
28
|
+
console.log('β
No vulnerabilities found.\n');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Group by severity
|
|
32
|
+
const critical = results.filter(r => r.severity === 'critical');
|
|
33
|
+
const high = results.filter(r => r.severity === 'high');
|
|
34
|
+
const medium = results.filter(r => r.severity === 'medium');
|
|
35
|
+
const low = results.filter(r => r.severity === 'low');
|
|
36
|
+
if (critical.length > 0) {
|
|
37
|
+
console.log('\nπ΄ CRITICAL VULNERABILITIES:');
|
|
38
|
+
critical.forEach(v => printVulnerability(v, options.verbose || false));
|
|
39
|
+
}
|
|
40
|
+
if (high.length > 0) {
|
|
41
|
+
console.log('\nπ HIGH SEVERITY:');
|
|
42
|
+
high.forEach(v => printVulnerability(v, options.verbose || false));
|
|
43
|
+
}
|
|
44
|
+
if (medium.length > 0) {
|
|
45
|
+
console.log('\nπ‘ MEDIUM SEVERITY:');
|
|
46
|
+
medium.forEach(v => printVulnerability(v, options.verbose || false));
|
|
47
|
+
}
|
|
48
|
+
if (low.length > 0) {
|
|
49
|
+
console.log('\nπ΅ INFO:');
|
|
50
|
+
low.forEach(v => printVulnerability(v, options.verbose || false));
|
|
51
|
+
}
|
|
52
|
+
// Summary
|
|
53
|
+
console.log('\n' + 'β'.repeat(50));
|
|
54
|
+
console.log('π SUMMARY:');
|
|
55
|
+
console.log(` Critical: ${critical.length}`);
|
|
56
|
+
console.log(` High: ${high.length}`);
|
|
57
|
+
console.log(` Medium: ${medium.length}`);
|
|
58
|
+
console.log(` Info: ${low.length}`);
|
|
59
|
+
console.log('');
|
|
15
60
|
});
|
|
61
|
+
function printVulnerability(v, verbose) {
|
|
62
|
+
console.log(`\n [${v.type}]`);
|
|
63
|
+
console.log(` ${v.description}`);
|
|
64
|
+
if (verbose && v.evidence) {
|
|
65
|
+
console.log(` Evidence: ${JSON.stringify(v.evidence, null, 2).split('\n').join('\n ')}`);
|
|
66
|
+
}
|
|
67
|
+
if (v.recommendation) {
|
|
68
|
+
console.log(` π‘ Recommendation: ${v.recommendation}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
16
71
|
program.parse();
|
package/dist/scanner.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
export interface Vulnerability {
|
|
2
|
+
type: string;
|
|
3
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
4
|
+
description: string;
|
|
5
|
+
evidence?: any;
|
|
6
|
+
recommendation?: string;
|
|
7
|
+
}
|
|
1
8
|
export declare class MCPSentinel {
|
|
2
|
-
scan(url: string): Promise<
|
|
3
|
-
private
|
|
9
|
+
scan(url: string): Promise<Vulnerability[]>;
|
|
10
|
+
private sendMCPRequest;
|
|
11
|
+
private checkAuthBypass;
|
|
12
|
+
private analyzeTool;
|
|
13
|
+
private checkPathTraversal;
|
|
14
|
+
private analyzePrompt;
|
|
4
15
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -2,44 +2,214 @@ import fetch from 'node-fetch';
|
|
|
2
2
|
export class MCPSentinel {
|
|
3
3
|
async scan(url) {
|
|
4
4
|
const results = [];
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
console.error(`[MCP Sentinel] Scanning ${url}...`);
|
|
6
|
+
// 1. Check if MCP endpoint is accessible
|
|
7
|
+
const baseUrl = url.replace(/\/$/, '');
|
|
8
|
+
// 2. Try MCP Initialize handshake
|
|
9
|
+
let serverInfo = null;
|
|
10
|
+
try {
|
|
11
|
+
const initResponse = await this.sendMCPRequest(baseUrl, {
|
|
12
|
+
jsonrpc: '2.0',
|
|
13
|
+
id: 1,
|
|
14
|
+
method: 'initialize',
|
|
15
|
+
params: {
|
|
16
|
+
protocolVersion: '2024-11-05',
|
|
17
|
+
clientInfo: {
|
|
18
|
+
name: 'mcp-sentinel',
|
|
19
|
+
version: '0.1.0'
|
|
20
|
+
},
|
|
21
|
+
capabilities: {}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
if (initResponse.result) {
|
|
25
|
+
serverInfo = initResponse.result;
|
|
26
|
+
results.push({
|
|
27
|
+
type: 'INFO',
|
|
28
|
+
severity: 'low',
|
|
29
|
+
description: `MCP Server detected: ${serverInfo.serverInfo?.name || 'Unknown'} v${serverInfo.serverInfo?.version || 'Unknown'}`,
|
|
30
|
+
evidence: serverInfo
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
results.push({
|
|
36
|
+
type: 'MCP_PROTOCOL_ERROR',
|
|
37
|
+
severity: 'medium',
|
|
38
|
+
description: `MCP endpoint not responding properly: ${error.message}`,
|
|
39
|
+
recommendation: 'Verify the URL is a valid MCP server endpoint'
|
|
40
|
+
});
|
|
41
|
+
return results;
|
|
42
|
+
}
|
|
43
|
+
// 3. Check for authentication bypass
|
|
44
|
+
const authResult = await this.checkAuthBypass(baseUrl);
|
|
45
|
+
if (authResult)
|
|
46
|
+
results.push(authResult);
|
|
47
|
+
// 4. List and analyze tools
|
|
48
|
+
try {
|
|
49
|
+
const toolsResponse = await this.sendMCPRequest(baseUrl, {
|
|
50
|
+
jsonrpc: '2.0',
|
|
51
|
+
id: 2,
|
|
52
|
+
method: 'tools/list'
|
|
53
|
+
});
|
|
54
|
+
if (toolsResponse.result?.tools) {
|
|
55
|
+
const tools = toolsResponse.result.tools;
|
|
56
|
+
results.push({
|
|
57
|
+
type: 'INFO',
|
|
58
|
+
severity: 'low',
|
|
59
|
+
description: `Found ${tools.length} exposed tools`,
|
|
60
|
+
evidence: tools.map((t) => t.name)
|
|
61
|
+
});
|
|
62
|
+
// Check for dangerous tools
|
|
63
|
+
for (const tool of tools) {
|
|
64
|
+
const toolCheck = this.analyzeTool(tool);
|
|
65
|
+
if (toolCheck)
|
|
66
|
+
results.push(toolCheck);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
results.push({
|
|
72
|
+
type: 'TOOLS_ACCESS_ERROR',
|
|
73
|
+
severity: 'medium',
|
|
74
|
+
description: `Could not enumerate tools: ${error.message}`
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// 5. Check for resource exposure
|
|
78
|
+
try {
|
|
79
|
+
const resourcesResponse = await this.sendMCPRequest(baseUrl, {
|
|
80
|
+
jsonrpc: '2.0',
|
|
81
|
+
id: 3,
|
|
82
|
+
method: 'resources/list'
|
|
83
|
+
});
|
|
84
|
+
if (resourcesResponse.result?.resources) {
|
|
85
|
+
const resources = resourcesResponse.result.resources;
|
|
86
|
+
// Check for path traversal
|
|
87
|
+
for (const resource of resources) {
|
|
88
|
+
const pathCheck = this.checkPathTraversal(resource);
|
|
89
|
+
if (pathCheck)
|
|
90
|
+
results.push(pathCheck);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// Resources might not be implemented
|
|
96
|
+
}
|
|
97
|
+
// 6. Check for prompt injection vulnerabilities
|
|
98
|
+
try {
|
|
99
|
+
const promptsResponse = await this.sendMCPRequest(baseUrl, {
|
|
100
|
+
jsonrpc: '2.0',
|
|
101
|
+
id: 4,
|
|
102
|
+
method: 'prompts/list'
|
|
103
|
+
});
|
|
104
|
+
if (promptsResponse.result?.prompts) {
|
|
105
|
+
for (const prompt of promptsResponse.result.prompts) {
|
|
106
|
+
const promptCheck = this.analyzePrompt(prompt);
|
|
107
|
+
if (promptCheck)
|
|
108
|
+
results.push(promptCheck);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// Prompts might not be implemented
|
|
9
114
|
}
|
|
10
115
|
return results;
|
|
11
116
|
}
|
|
12
|
-
async
|
|
117
|
+
async sendMCPRequest(url, body) {
|
|
13
118
|
const controller = new AbortController();
|
|
14
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
119
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
15
120
|
try {
|
|
16
121
|
const response = await fetch(url, {
|
|
17
|
-
|
|
18
|
-
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
},
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
signal: controller.signal
|
|
19
128
|
});
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
return data;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
clearTimeout(timeoutId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async checkAuthBypass(url) {
|
|
137
|
+
// Try to access without authentication
|
|
138
|
+
try {
|
|
139
|
+
const response = await this.sendMCPRequest(url, {
|
|
140
|
+
jsonrpc: '2.0',
|
|
141
|
+
id: 'auth-check',
|
|
142
|
+
method: 'initialize',
|
|
143
|
+
params: {
|
|
144
|
+
protocolVersion: '2024-11-05',
|
|
145
|
+
clientInfo: { name: 'unauthenticated', version: '1.0.0' },
|
|
146
|
+
capabilities: {}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (response.result && !response.error) {
|
|
24
150
|
return {
|
|
25
|
-
type: '
|
|
26
|
-
severity: '
|
|
27
|
-
description: '
|
|
28
|
-
|
|
151
|
+
type: 'AUTH_BYPASS',
|
|
152
|
+
severity: 'high',
|
|
153
|
+
description: 'MCP server accepts unauthenticated connections',
|
|
154
|
+
recommendation: 'Implement authentication on MCP endpoints'
|
|
29
155
|
};
|
|
30
156
|
}
|
|
31
|
-
return null;
|
|
32
157
|
}
|
|
33
158
|
catch (error) {
|
|
159
|
+
// Server requires auth - good
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
analyzeTool(tool) {
|
|
164
|
+
const dangerousPatterns = [
|
|
165
|
+
{ pattern: /exec|eval|run|execute/i, severity: 'critical', type: 'COMMAND_INJECTION_RISK' },
|
|
166
|
+
{ pattern: /file|read|write|delete/i, severity: 'high', type: 'FILE_ACCESS_RISK' },
|
|
167
|
+
{ pattern: /shell|bash|cmd|powershell/i, severity: 'critical', type: 'SHELL_COMMAND_RISK' },
|
|
168
|
+
{ pattern: /sql|database|query/i, severity: 'high', type: 'SQL_INJECTION_RISK' },
|
|
169
|
+
{ pattern: /http|fetch|request/i, severity: 'medium', type: 'SSRF_RISK' },
|
|
170
|
+
];
|
|
171
|
+
const toolName = tool.name || '';
|
|
172
|
+
const toolDesc = tool.description || '';
|
|
173
|
+
const inputSchema = JSON.stringify(tool.inputSchema || {});
|
|
174
|
+
for (const { pattern, severity, type } of dangerousPatterns) {
|
|
175
|
+
if (pattern.test(toolName) || pattern.test(toolDesc) || pattern.test(inputSchema)) {
|
|
176
|
+
return {
|
|
177
|
+
type,
|
|
178
|
+
severity: severity,
|
|
179
|
+
description: `Tool '${toolName}' may allow ${type.replace(/_/g, ' ').toLowerCase()}`,
|
|
180
|
+
evidence: { toolName, toolDesc, inputSchema: tool.inputSchema },
|
|
181
|
+
recommendation: `Review tool '${toolName}' for proper input validation and access controls`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
checkPathTraversal(resource) {
|
|
188
|
+
const uri = resource.uri || '';
|
|
189
|
+
// Check for path traversal patterns
|
|
190
|
+
if (uri.includes('..') || uri.includes('~') || uri.includes('/etc/') || uri.includes('/var/')) {
|
|
34
191
|
return {
|
|
35
|
-
type: '
|
|
36
|
-
severity: '
|
|
37
|
-
description: `
|
|
38
|
-
|
|
192
|
+
type: 'PATH_TRAVERSAL',
|
|
193
|
+
severity: 'high',
|
|
194
|
+
description: `Resource URI may be vulnerable to path traversal: ${uri}`,
|
|
195
|
+
recommendation: 'Validate and sanitize resource URIs'
|
|
39
196
|
};
|
|
40
197
|
}
|
|
41
|
-
|
|
42
|
-
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
analyzePrompt(prompt) {
|
|
201
|
+
const name = prompt.name || '';
|
|
202
|
+
const description = prompt.description || '';
|
|
203
|
+
// Check for prompt injection risks
|
|
204
|
+
if (description.toLowerCase().includes('user input') ||
|
|
205
|
+
description.toLowerCase().includes('dynamic')) {
|
|
206
|
+
return {
|
|
207
|
+
type: 'PROMPT_INJECTION_RISK',
|
|
208
|
+
severity: 'medium',
|
|
209
|
+
description: `Prompt '${name}' accepts dynamic input`,
|
|
210
|
+
recommendation: 'Implement prompt injection guards'
|
|
211
|
+
};
|
|
43
212
|
}
|
|
213
|
+
return null;
|
|
44
214
|
}
|
|
45
215
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zcode-apps/mcp-sentinel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"main": "dist/scanner.js",
|
|
6
|
+
"types": "dist/scanner.d.ts",
|
|
5
7
|
"bin": {
|
|
6
8
|
"mcp-sentinel": "./dist/cli.js"
|
|
7
9
|
},
|
|
10
|
+
"description": "Security scanner for MCP (Model Context Protocol) servers",
|
|
11
|
+
"keywords": ["mcp", "security", "scanner", "vulnerability", "rce", "model-context-protocol"],
|
|
12
|
+
"author": "ARC",
|
|
13
|
+
"license": "MIT",
|
|
8
14
|
"scripts": {
|
|
9
|
-
"build": "tsc"
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/cli.js"
|
|
10
17
|
},
|
|
11
18
|
"dependencies": {
|
|
12
19
|
"chalk": "^5.3.0",
|
package/src/cli.ts
CHANGED
|
@@ -1,19 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { MCPSentinel } from './scanner.js';
|
|
3
|
+
import { MCPSentinel, Vulnerability } from './scanner.js';
|
|
4
4
|
|
|
5
5
|
const program = new Command();
|
|
6
|
+
|
|
6
7
|
program
|
|
7
8
|
.name('mcp-sentinel')
|
|
8
|
-
.description('MCP Security Scanner -
|
|
9
|
+
.description('MCP Security Scanner - Detects vulnerabilities in MCP servers')
|
|
10
|
+
.version('0.2.1');
|
|
9
11
|
|
|
10
12
|
program
|
|
11
13
|
.command('scan <url>')
|
|
12
|
-
.description('Scan
|
|
13
|
-
.
|
|
14
|
+
.description('Scan an MCP server endpoint for security vulnerabilities')
|
|
15
|
+
.option('-j, --json', 'Output as JSON', false)
|
|
16
|
+
.option('-v, --verbose', 'Show detailed evidence', false)
|
|
17
|
+
.action(async (url, options) => {
|
|
14
18
|
const scanner = new MCPSentinel();
|
|
19
|
+
|
|
20
|
+
if (!options.json) {
|
|
21
|
+
console.log(`\nπ MCP Sentinel - Security Scanner\n`);
|
|
22
|
+
console.log(`Target: ${url}\n`);
|
|
23
|
+
console.log('β'.repeat(50));
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
const results = await scanner.scan(url);
|
|
16
|
-
|
|
27
|
+
|
|
28
|
+
if (options.json) {
|
|
29
|
+
console.log(JSON.stringify(results, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Text output
|
|
34
|
+
if (results.length === 0) {
|
|
35
|
+
console.log('β
No vulnerabilities found.\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Group by severity
|
|
40
|
+
const critical = results.filter(r => r.severity === 'critical');
|
|
41
|
+
const high = results.filter(r => r.severity === 'high');
|
|
42
|
+
const medium = results.filter(r => r.severity === 'medium');
|
|
43
|
+
const low = results.filter(r => r.severity === 'low');
|
|
44
|
+
|
|
45
|
+
if (critical.length > 0) {
|
|
46
|
+
console.log('\nπ΄ CRITICAL VULNERABILITIES:');
|
|
47
|
+
critical.forEach(v => printVulnerability(v, options.verbose || false));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (high.length > 0) {
|
|
51
|
+
console.log('\nπ HIGH SEVERITY:');
|
|
52
|
+
high.forEach(v => printVulnerability(v, options.verbose || false));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (medium.length > 0) {
|
|
56
|
+
console.log('\nπ‘ MEDIUM SEVERITY:');
|
|
57
|
+
medium.forEach(v => printVulnerability(v, options.verbose || false));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (low.length > 0) {
|
|
61
|
+
console.log('\nπ΅ INFO:');
|
|
62
|
+
low.forEach(v => printVulnerability(v, options.verbose || false));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Summary
|
|
66
|
+
console.log('\n' + 'β'.repeat(50));
|
|
67
|
+
console.log('π SUMMARY:');
|
|
68
|
+
console.log(` Critical: ${critical.length}`);
|
|
69
|
+
console.log(` High: ${high.length}`);
|
|
70
|
+
console.log(` Medium: ${medium.length}`);
|
|
71
|
+
console.log(` Info: ${low.length}`);
|
|
72
|
+
console.log('');
|
|
17
73
|
});
|
|
18
74
|
|
|
19
|
-
|
|
75
|
+
function printVulnerability(v: Vulnerability, verbose: boolean): void {
|
|
76
|
+
console.log(`\n [${v.type}]`);
|
|
77
|
+
console.log(` ${v.description}`);
|
|
78
|
+
if (verbose && v.evidence) {
|
|
79
|
+
console.log(` Evidence: ${JSON.stringify(v.evidence, null, 2).split('\n').join('\n ')}`);
|
|
80
|
+
}
|
|
81
|
+
if (v.recommendation) {
|
|
82
|
+
console.log(` π‘ Recommendation: ${v.recommendation}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
program.parse();
|
package/src/scanner.ts
CHANGED
|
@@ -1,51 +1,243 @@
|
|
|
1
1
|
import fetch, { RequestInit, Response } from 'node-fetch';
|
|
2
2
|
|
|
3
|
+
export interface Vulnerability {
|
|
4
|
+
type: string;
|
|
5
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
6
|
+
description: string;
|
|
7
|
+
evidence?: any;
|
|
8
|
+
recommendation?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
export class MCPSentinel {
|
|
4
|
-
async scan(url: string): Promise<
|
|
5
|
-
const results = [];
|
|
12
|
+
async scan(url: string): Promise<Vulnerability[]> {
|
|
13
|
+
const results: Vulnerability[] = [];
|
|
14
|
+
|
|
15
|
+
console.error(`[MCP Sentinel] Scanning ${url}...`);
|
|
6
16
|
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
// 1. Check if MCP endpoint is accessible
|
|
18
|
+
const baseUrl = url.replace(/\/$/, '');
|
|
19
|
+
|
|
20
|
+
// 2. Try MCP Initialize handshake
|
|
21
|
+
let serverInfo: any = null;
|
|
22
|
+
try {
|
|
23
|
+
const initResponse = await this.sendMCPRequest(baseUrl, {
|
|
24
|
+
jsonrpc: '2.0',
|
|
25
|
+
id: 1,
|
|
26
|
+
method: 'initialize',
|
|
27
|
+
params: {
|
|
28
|
+
protocolVersion: '2024-11-05',
|
|
29
|
+
clientInfo: {
|
|
30
|
+
name: 'mcp-sentinel',
|
|
31
|
+
version: '0.1.0'
|
|
32
|
+
},
|
|
33
|
+
capabilities: {}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (initResponse.result) {
|
|
38
|
+
serverInfo = initResponse.result;
|
|
39
|
+
results.push({
|
|
40
|
+
type: 'INFO',
|
|
41
|
+
severity: 'low',
|
|
42
|
+
description: `MCP Server detected: ${serverInfo.serverInfo?.name || 'Unknown'} v${serverInfo.serverInfo?.version || 'Unknown'}`,
|
|
43
|
+
evidence: serverInfo
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
} catch (error: any) {
|
|
47
|
+
results.push({
|
|
48
|
+
type: 'MCP_PROTOCOL_ERROR',
|
|
49
|
+
severity: 'medium',
|
|
50
|
+
description: `MCP endpoint not responding properly: ${error.message}`,
|
|
51
|
+
recommendation: 'Verify the URL is a valid MCP server endpoint'
|
|
52
|
+
});
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. Check for authentication bypass
|
|
57
|
+
const authResult = await this.checkAuthBypass(baseUrl);
|
|
58
|
+
if (authResult) results.push(authResult);
|
|
59
|
+
|
|
60
|
+
// 4. List and analyze tools
|
|
61
|
+
try {
|
|
62
|
+
const toolsResponse = await this.sendMCPRequest(baseUrl, {
|
|
63
|
+
jsonrpc: '2.0',
|
|
64
|
+
id: 2,
|
|
65
|
+
method: 'tools/list'
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (toolsResponse.result?.tools) {
|
|
69
|
+
const tools = toolsResponse.result.tools;
|
|
70
|
+
results.push({
|
|
71
|
+
type: 'INFO',
|
|
72
|
+
severity: 'low',
|
|
73
|
+
description: `Found ${tools.length} exposed tools`,
|
|
74
|
+
evidence: tools.map((t: any) => t.name)
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Check for dangerous tools
|
|
78
|
+
for (const tool of tools) {
|
|
79
|
+
const toolCheck = this.analyzeTool(tool);
|
|
80
|
+
if (toolCheck) results.push(toolCheck);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (error: any) {
|
|
84
|
+
results.push({
|
|
85
|
+
type: 'TOOLS_ACCESS_ERROR',
|
|
86
|
+
severity: 'medium',
|
|
87
|
+
description: `Could not enumerate tools: ${error.message}`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 5. Check for resource exposure
|
|
92
|
+
try {
|
|
93
|
+
const resourcesResponse = await this.sendMCPRequest(baseUrl, {
|
|
94
|
+
jsonrpc: '2.0',
|
|
95
|
+
id: 3,
|
|
96
|
+
method: 'resources/list'
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (resourcesResponse.result?.resources) {
|
|
100
|
+
const resources = resourcesResponse.result.resources;
|
|
101
|
+
|
|
102
|
+
// Check for path traversal
|
|
103
|
+
for (const resource of resources) {
|
|
104
|
+
const pathCheck = this.checkPathTraversal(resource);
|
|
105
|
+
if (pathCheck) results.push(pathCheck);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
// Resources might not be implemented
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 6. Check for prompt injection vulnerabilities
|
|
113
|
+
try {
|
|
114
|
+
const promptsResponse = await this.sendMCPRequest(baseUrl, {
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id: 4,
|
|
117
|
+
method: 'prompts/list'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (promptsResponse.result?.prompts) {
|
|
121
|
+
for (const prompt of promptsResponse.result.prompts) {
|
|
122
|
+
const promptCheck = this.analyzePrompt(prompt);
|
|
123
|
+
if (promptCheck) results.push(promptCheck);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error: any) {
|
|
127
|
+
// Prompts might not be implemented
|
|
11
128
|
}
|
|
12
129
|
|
|
13
130
|
return results;
|
|
14
131
|
}
|
|
15
132
|
|
|
16
|
-
private async
|
|
133
|
+
private async sendMCPRequest(url: string, body: any): Promise<any> {
|
|
17
134
|
const controller = new AbortController();
|
|
18
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
135
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
19
136
|
|
|
20
137
|
try {
|
|
21
|
-
const response
|
|
22
|
-
|
|
23
|
-
|
|
138
|
+
const response = await fetch(url, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
signal: controller.signal
|
|
24
145
|
} as RequestInit);
|
|
25
146
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
147
|
+
const data = await response.json();
|
|
148
|
+
return data;
|
|
149
|
+
} finally {
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async checkAuthBypass(url: string): Promise<Vulnerability | null> {
|
|
155
|
+
// Try to access without authentication
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.sendMCPRequest(url, {
|
|
158
|
+
jsonrpc: '2.0',
|
|
159
|
+
id: 'auth-check',
|
|
160
|
+
method: 'initialize',
|
|
161
|
+
params: {
|
|
162
|
+
protocolVersion: '2024-11-05',
|
|
163
|
+
clientInfo: { name: 'unauthenticated', version: '1.0.0' },
|
|
164
|
+
capabilities: {}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (response.result && !response.error) {
|
|
31
169
|
return {
|
|
32
|
-
type: '
|
|
33
|
-
severity: '
|
|
34
|
-
description: '
|
|
35
|
-
|
|
170
|
+
type: 'AUTH_BYPASS',
|
|
171
|
+
severity: 'high',
|
|
172
|
+
description: 'MCP server accepts unauthenticated connections',
|
|
173
|
+
recommendation: 'Implement authentication on MCP endpoints'
|
|
36
174
|
};
|
|
37
175
|
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// Server requires auth - good
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
38
181
|
|
|
39
|
-
|
|
40
|
-
|
|
182
|
+
private analyzeTool(tool: any): Vulnerability | null {
|
|
183
|
+
const dangerousPatterns = [
|
|
184
|
+
{ pattern: /exec|eval|run|execute/i, severity: 'critical', type: 'COMMAND_INJECTION_RISK' },
|
|
185
|
+
{ pattern: /file|read|write|delete/i, severity: 'high', type: 'FILE_ACCESS_RISK' },
|
|
186
|
+
{ pattern: /shell|bash|cmd|powershell/i, severity: 'critical', type: 'SHELL_COMMAND_RISK' },
|
|
187
|
+
{ pattern: /sql|database|query/i, severity: 'high', type: 'SQL_INJECTION_RISK' },
|
|
188
|
+
{ pattern: /http|fetch|request/i, severity: 'medium', type: 'SSRF_RISK' },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const toolName = tool.name || '';
|
|
192
|
+
const toolDesc = tool.description || '';
|
|
193
|
+
const inputSchema = JSON.stringify(tool.inputSchema || {});
|
|
194
|
+
|
|
195
|
+
for (const { pattern, severity, type } of dangerousPatterns) {
|
|
196
|
+
if (pattern.test(toolName) || pattern.test(toolDesc) || pattern.test(inputSchema)) {
|
|
197
|
+
return {
|
|
198
|
+
type,
|
|
199
|
+
severity: severity as any,
|
|
200
|
+
description: `Tool '${toolName}' may allow ${type.replace(/_/g, ' ').toLowerCase()}`,
|
|
201
|
+
evidence: { toolName, toolDesc, inputSchema: tool.inputSchema },
|
|
202
|
+
recommendation: `Review tool '${toolName}' for proper input validation and access controls`
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private checkPathTraversal(resource: any): Vulnerability | null {
|
|
211
|
+
const uri = resource.uri || '';
|
|
212
|
+
|
|
213
|
+
// Check for path traversal patterns
|
|
214
|
+
if (uri.includes('..') || uri.includes('~') || uri.includes('/etc/') || uri.includes('/var/')) {
|
|
41
215
|
return {
|
|
42
|
-
type: '
|
|
43
|
-
severity: '
|
|
44
|
-
description: `
|
|
45
|
-
|
|
216
|
+
type: 'PATH_TRAVERSAL',
|
|
217
|
+
severity: 'high',
|
|
218
|
+
description: `Resource URI may be vulnerable to path traversal: ${uri}`,
|
|
219
|
+
recommendation: 'Validate and sanitize resource URIs'
|
|
46
220
|
};
|
|
47
|
-
} finally {
|
|
48
|
-
clearTimeout(timeoutId);
|
|
49
221
|
}
|
|
222
|
+
|
|
223
|
+
return null;
|
|
50
224
|
}
|
|
51
|
-
|
|
225
|
+
|
|
226
|
+
private analyzePrompt(prompt: any): Vulnerability | null {
|
|
227
|
+
const name = prompt.name || '';
|
|
228
|
+
const description = prompt.description || '';
|
|
229
|
+
|
|
230
|
+
// Check for prompt injection risks
|
|
231
|
+
if (description.toLowerCase().includes('user input') ||
|
|
232
|
+
description.toLowerCase().includes('dynamic')) {
|
|
233
|
+
return {
|
|
234
|
+
type: 'PROMPT_INJECTION_RISK',
|
|
235
|
+
severity: 'medium',
|
|
236
|
+
description: `Prompt '${name}' accepts dynamic input`,
|
|
237
|
+
recommendation: 'Implement prompt injection guards'
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|