mcpsec 0.1.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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +48 -0
- package/src/cli/index.ts +158 -0
- package/src/lib/injection-patterns.ts +283 -0
- package/src/lib/mcp-client.ts +384 -0
- package/src/lib/types.ts +90 -0
- package/src/lib/url-validator.ts +130 -0
- package/src/scanner/config-scanner.ts +262 -0
- package/src/scanner/credential-scanner.ts +200 -0
- package/src/scanner/live-scanner.ts +375 -0
- package/src/scanner/report.ts +248 -0
- package/src/scanner/tool-scanner.ts +142 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Live Server Scanner
|
|
3
|
+
*
|
|
4
|
+
* Connects to running MCP servers and scans their tool descriptions,
|
|
5
|
+
* resource URIs, and prompt templates for security issues:
|
|
6
|
+
*
|
|
7
|
+
* - Tool poisoning (hidden instructions in tool descriptions)
|
|
8
|
+
* - Tool shadowing (tools impersonating other tools)
|
|
9
|
+
* - Data exfiltration patterns in tool descriptions
|
|
10
|
+
* - SSRF in resource URIs
|
|
11
|
+
* - Prompt injection in prompt templates
|
|
12
|
+
* - Excessive permissions / dangerous capabilities
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MCPConfigFile, Finding, Scanner } from '../lib/types';
|
|
16
|
+
import {
|
|
17
|
+
connectStdio,
|
|
18
|
+
connectHTTP,
|
|
19
|
+
type MCPServerInfo,
|
|
20
|
+
type MCPTool,
|
|
21
|
+
type MCPResource,
|
|
22
|
+
type MCPPrompt,
|
|
23
|
+
} from '../lib/mcp-client';
|
|
24
|
+
import {
|
|
25
|
+
analyzeForInjection,
|
|
26
|
+
INJECTION_PATTERNS,
|
|
27
|
+
TOOL_POISONING_PATTERNS,
|
|
28
|
+
} from '../lib/injection-patterns';
|
|
29
|
+
import { validateURL } from '../lib/url-validator';
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Dangerous Tool Patterns
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tool names/descriptions that indicate dangerous capabilities
|
|
37
|
+
*/
|
|
38
|
+
const DANGEROUS_TOOL_PATTERNS = [
|
|
39
|
+
{ pattern: /\b(exec|execute|run|shell|bash|cmd|command|system)\b/i, risk: 'Command execution capability' },
|
|
40
|
+
{ pattern: /\b(eval|evaluate)\b/i, risk: 'Code evaluation capability' },
|
|
41
|
+
{ pattern: /\b(sudo|root|admin|privilege)/i, risk: 'Elevated privilege operations' },
|
|
42
|
+
{ pattern: /\b(delete|remove|drop|truncate|destroy)\s+(all|every|database|table|collection)/i, risk: 'Bulk destructive operations' },
|
|
43
|
+
{ pattern: /\b(send|post|upload)\s+(to|email|message|webhook)/i, risk: 'External communication capability' },
|
|
44
|
+
{ pattern: /\b(install|download)\s+(package|module|binary|executable)/i, risk: 'Software installation capability' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Known-safe tool name prefixes (reduce false positives)
|
|
49
|
+
*/
|
|
50
|
+
const SAFE_TOOL_PREFIXES = [
|
|
51
|
+
'@modelcontextprotocol/',
|
|
52
|
+
'@anthropic/',
|
|
53
|
+
'mcp-server-',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Live Scanner
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
export const liveScanner: Scanner = {
|
|
61
|
+
name: 'Live Server Scanner',
|
|
62
|
+
|
|
63
|
+
async scan(configs: MCPConfigFile[]): Promise<Finding[]> {
|
|
64
|
+
const findings: Finding[] = [];
|
|
65
|
+
let findingId = 0;
|
|
66
|
+
|
|
67
|
+
for (const config of configs) {
|
|
68
|
+
for (const [serverName, serverConfig] of Object.entries(config.servers)) {
|
|
69
|
+
// Connect to the server
|
|
70
|
+
let info: MCPServerInfo;
|
|
71
|
+
|
|
72
|
+
process.stderr.write(` Connecting to ${serverName}...`);
|
|
73
|
+
|
|
74
|
+
if (serverConfig.url) {
|
|
75
|
+
info = await connectHTTP(serverConfig, serverName);
|
|
76
|
+
} else {
|
|
77
|
+
info = await connectStdio(serverConfig, serverName);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (info.error) {
|
|
81
|
+
process.stderr.write(` \x1b[33mfailed\x1b[0m (${info.error})\n`);
|
|
82
|
+
findings.push({
|
|
83
|
+
id: `LIVE-${++findingId}`,
|
|
84
|
+
severity: 'info',
|
|
85
|
+
category: 'configuration',
|
|
86
|
+
title: `Could not connect to server`,
|
|
87
|
+
description: `Server "${serverName}" could not be reached: ${info.error}`,
|
|
88
|
+
server: serverName,
|
|
89
|
+
configFile: config.path,
|
|
90
|
+
remediation: 'Verify the server command/URL is correct and the server is running.',
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toolCount = info.tools.length;
|
|
96
|
+
const resourceCount = info.resources.length;
|
|
97
|
+
const promptCount = info.prompts.length;
|
|
98
|
+
process.stderr.write(
|
|
99
|
+
` \x1b[32mconnected\x1b[0m (${info.connectTimeMs}ms) ` +
|
|
100
|
+
`[${toolCount} tools, ${resourceCount} resources, ${promptCount} prompts]\n`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Scan tools
|
|
104
|
+
for (const tool of info.tools) {
|
|
105
|
+
const toolFindings = scanTool(tool, serverName, config.path, findingId);
|
|
106
|
+
findingId += toolFindings.length;
|
|
107
|
+
findings.push(...toolFindings);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Scan for tool shadowing across servers
|
|
111
|
+
const toolNames = info.tools.map((t) => t.name);
|
|
112
|
+
const duplicateNames = findDuplicateToolNames(toolNames);
|
|
113
|
+
for (const dup of duplicateNames) {
|
|
114
|
+
findings.push({
|
|
115
|
+
id: `LIVE-${++findingId}`,
|
|
116
|
+
severity: 'high',
|
|
117
|
+
category: 'tool-poisoning',
|
|
118
|
+
title: `Duplicate tool name: "${dup}"`,
|
|
119
|
+
description: `Server "${serverName}" exposes multiple tools named "${dup}". This may indicate tool shadowing.`,
|
|
120
|
+
server: serverName,
|
|
121
|
+
configFile: config.path,
|
|
122
|
+
remediation: 'Investigate why the server has duplicate tool names. This is unusual and may indicate a compromised server.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Scan resources
|
|
127
|
+
for (const resource of info.resources) {
|
|
128
|
+
const resourceFindings = scanResource(resource, serverName, config.path, findingId);
|
|
129
|
+
findingId += resourceFindings.length;
|
|
130
|
+
findings.push(...resourceFindings);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Scan prompts
|
|
134
|
+
for (const prompt of info.prompts) {
|
|
135
|
+
const promptFindings = scanPrompt(prompt, serverName, config.path, findingId);
|
|
136
|
+
findingId += promptFindings.length;
|
|
137
|
+
findings.push(...promptFindings);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for excessive capabilities
|
|
141
|
+
if (info.capabilities) {
|
|
142
|
+
const capFindings = scanCapabilities(info.capabilities, serverName, config.path, findingId);
|
|
143
|
+
findingId += capFindings.length;
|
|
144
|
+
findings.push(...capFindings);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return findings;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Tool Scanning
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
function scanTool(tool: MCPTool, serverName: string, configFile: string, startId: number): Finding[] {
|
|
158
|
+
const findings: Finding[] = [];
|
|
159
|
+
let findingId = startId;
|
|
160
|
+
|
|
161
|
+
const description = tool.description || '';
|
|
162
|
+
const schemaStr = tool.inputSchema ? JSON.stringify(tool.inputSchema) : '';
|
|
163
|
+
const fullText = `${tool.name} ${description} ${schemaStr}`;
|
|
164
|
+
|
|
165
|
+
// Check tool description for injection patterns
|
|
166
|
+
if (description.length > 10) {
|
|
167
|
+
const analysis = analyzeForInjection(description, [INJECTION_PATTERNS, TOOL_POISONING_PATTERNS]);
|
|
168
|
+
|
|
169
|
+
if (analysis.detected) {
|
|
170
|
+
for (const match of analysis.matches) {
|
|
171
|
+
findings.push({
|
|
172
|
+
id: `LIVE-${++findingId}`,
|
|
173
|
+
severity: match.severity,
|
|
174
|
+
category: match.category.includes('Shadowing') || match.category.includes('hidden') || match.category.includes('Exfiltration')
|
|
175
|
+
? 'tool-poisoning'
|
|
176
|
+
: 'prompt-injection',
|
|
177
|
+
title: `${match.name} in tool "${tool.name}"`,
|
|
178
|
+
description: `Tool "${tool.name}" on server "${serverName}" has a suspicious pattern in its description: ${match.name}.`,
|
|
179
|
+
server: serverName,
|
|
180
|
+
configFile,
|
|
181
|
+
evidence: truncate(description, 150),
|
|
182
|
+
remediation: 'This tool description contains patterns associated with tool poisoning attacks. Review the MCP server source code.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for dangerous tool capabilities
|
|
189
|
+
for (const dangerous of DANGEROUS_TOOL_PATTERNS) {
|
|
190
|
+
if (dangerous.pattern.test(tool.name) || dangerous.pattern.test(description)) {
|
|
191
|
+
findings.push({
|
|
192
|
+
id: `LIVE-${++findingId}`,
|
|
193
|
+
severity: 'medium',
|
|
194
|
+
category: 'excessive-permissions',
|
|
195
|
+
title: `${dangerous.risk}: "${tool.name}"`,
|
|
196
|
+
description: `Tool "${tool.name}" on server "${serverName}" has ${dangerous.risk.toLowerCase()}. Ensure this is expected and properly sandboxed.`,
|
|
197
|
+
server: serverName,
|
|
198
|
+
configFile,
|
|
199
|
+
evidence: truncate(`${tool.name}: ${description}`, 150),
|
|
200
|
+
remediation: 'Verify this tool capability is expected. Consider restricting its scope or adding authorization checks.',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check input schema for suspicious default values
|
|
206
|
+
if (tool.inputSchema && typeof tool.inputSchema === 'object') {
|
|
207
|
+
const schemaAnalysis = analyzeForInjection(schemaStr, [INJECTION_PATTERNS]);
|
|
208
|
+
if (schemaAnalysis.detected) {
|
|
209
|
+
findings.push({
|
|
210
|
+
id: `LIVE-${++findingId}`,
|
|
211
|
+
severity: 'high',
|
|
212
|
+
category: 'prompt-injection',
|
|
213
|
+
title: `Injection pattern in tool schema: "${tool.name}"`,
|
|
214
|
+
description: `Tool "${tool.name}" input schema contains injection patterns. Default values or descriptions may be poisoned.`,
|
|
215
|
+
server: serverName,
|
|
216
|
+
configFile,
|
|
217
|
+
evidence: truncate(schemaStr, 150),
|
|
218
|
+
remediation: 'Review the tool input schema for hidden instructions in default values or field descriptions.',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return findings;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Resource Scanning
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
function scanResource(resource: MCPResource, serverName: string, configFile: string, startId: number): Finding[] {
|
|
231
|
+
const findings: Finding[] = [];
|
|
232
|
+
let findingId = startId;
|
|
233
|
+
|
|
234
|
+
// Check resource URI for SSRF
|
|
235
|
+
if (resource.uri.startsWith('http://') || resource.uri.startsWith('https://')) {
|
|
236
|
+
const urlResult = validateURL(resource.uri);
|
|
237
|
+
if (!urlResult.valid) {
|
|
238
|
+
findings.push({
|
|
239
|
+
id: `LIVE-${++findingId}`,
|
|
240
|
+
severity: urlResult.severity || 'high',
|
|
241
|
+
category: 'ssrf',
|
|
242
|
+
title: `Unsafe resource URI on "${serverName}"`,
|
|
243
|
+
description: `Resource "${resource.name || resource.uri}" has an unsafe URI: ${urlResult.reason}`,
|
|
244
|
+
server: serverName,
|
|
245
|
+
configFile,
|
|
246
|
+
evidence: `uri: ${resource.uri}`,
|
|
247
|
+
remediation: 'Review the resource URI. It may point to internal services or metadata endpoints.',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check resource description for injection
|
|
253
|
+
if (resource.description && resource.description.length > 10) {
|
|
254
|
+
const analysis = analyzeForInjection(resource.description, [INJECTION_PATTERNS, TOOL_POISONING_PATTERNS]);
|
|
255
|
+
if (analysis.detected) {
|
|
256
|
+
findings.push({
|
|
257
|
+
id: `LIVE-${++findingId}`,
|
|
258
|
+
severity: analysis.risk === 'critical' ? 'critical' : 'high',
|
|
259
|
+
category: 'prompt-injection',
|
|
260
|
+
title: `Injection in resource description: "${resource.name || resource.uri}"`,
|
|
261
|
+
description: `Resource description on "${serverName}" contains suspicious patterns.`,
|
|
262
|
+
server: serverName,
|
|
263
|
+
configFile,
|
|
264
|
+
evidence: truncate(resource.description, 150),
|
|
265
|
+
remediation: 'Review the resource description for hidden instructions.',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return findings;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Prompt Scanning
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
function scanPrompt(prompt: MCPPrompt, serverName: string, configFile: string, startId: number): Finding[] {
|
|
278
|
+
const findings: Finding[] = [];
|
|
279
|
+
let findingId = startId;
|
|
280
|
+
|
|
281
|
+
const description = prompt.description || '';
|
|
282
|
+
|
|
283
|
+
if (description.length > 10) {
|
|
284
|
+
const analysis = analyzeForInjection(description, [INJECTION_PATTERNS, TOOL_POISONING_PATTERNS]);
|
|
285
|
+
if (analysis.detected) {
|
|
286
|
+
for (const match of analysis.matches) {
|
|
287
|
+
findings.push({
|
|
288
|
+
id: `LIVE-${++findingId}`,
|
|
289
|
+
severity: match.severity,
|
|
290
|
+
category: 'prompt-injection',
|
|
291
|
+
title: `${match.name} in prompt "${prompt.name}"`,
|
|
292
|
+
description: `Prompt "${prompt.name}" on server "${serverName}" contains suspicious patterns in its description.`,
|
|
293
|
+
server: serverName,
|
|
294
|
+
configFile,
|
|
295
|
+
evidence: truncate(description, 150),
|
|
296
|
+
remediation: 'Review the prompt template for hidden instructions or injection payloads.',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check argument descriptions
|
|
303
|
+
if (prompt.arguments) {
|
|
304
|
+
for (const arg of prompt.arguments) {
|
|
305
|
+
if (arg.description && arg.description.length > 10) {
|
|
306
|
+
const argAnalysis = analyzeForInjection(arg.description, [INJECTION_PATTERNS]);
|
|
307
|
+
if (argAnalysis.detected) {
|
|
308
|
+
findings.push({
|
|
309
|
+
id: `LIVE-${++findingId}`,
|
|
310
|
+
severity: 'high',
|
|
311
|
+
category: 'prompt-injection',
|
|
312
|
+
title: `Injection in prompt argument "${arg.name}"`,
|
|
313
|
+
description: `Prompt "${prompt.name}" argument "${arg.name}" on "${serverName}" contains injection patterns.`,
|
|
314
|
+
server: serverName,
|
|
315
|
+
configFile,
|
|
316
|
+
evidence: truncate(arg.description, 150),
|
|
317
|
+
remediation: 'Review prompt argument descriptions for hidden instructions.',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return findings;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============================================================================
|
|
328
|
+
// Capabilities Scanning
|
|
329
|
+
// ============================================================================
|
|
330
|
+
|
|
331
|
+
function scanCapabilities(
|
|
332
|
+
capabilities: Record<string, unknown>,
|
|
333
|
+
serverName: string,
|
|
334
|
+
configFile: string,
|
|
335
|
+
startId: number
|
|
336
|
+
): Finding[] {
|
|
337
|
+
const findings: Finding[] = [];
|
|
338
|
+
let findingId = startId;
|
|
339
|
+
|
|
340
|
+
// Check for sampling capability (server can make LLM calls)
|
|
341
|
+
if (capabilities.sampling) {
|
|
342
|
+
findings.push({
|
|
343
|
+
id: `LIVE-${++findingId}`,
|
|
344
|
+
severity: 'medium',
|
|
345
|
+
category: 'excessive-permissions',
|
|
346
|
+
title: `Server requests sampling capability`,
|
|
347
|
+
description: `Server "${serverName}" requests the sampling capability, allowing it to make LLM calls through the client. This could be used for prompt injection amplification.`,
|
|
348
|
+
server: serverName,
|
|
349
|
+
configFile,
|
|
350
|
+
evidence: `capabilities.sampling: ${JSON.stringify(capabilities.sampling)}`,
|
|
351
|
+
remediation: 'Verify the server needs sampling capability. This allows the server to influence LLM behavior.',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return findings;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ============================================================================
|
|
359
|
+
// Helpers
|
|
360
|
+
// ============================================================================
|
|
361
|
+
|
|
362
|
+
function truncate(s: string, maxLen: number): string {
|
|
363
|
+
if (s.length <= maxLen) return s;
|
|
364
|
+
return s.substring(0, maxLen) + '...';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function findDuplicateToolNames(names: string[]): string[] {
|
|
368
|
+
const seen = new Set<string>();
|
|
369
|
+
const duplicates = new Set<string>();
|
|
370
|
+
for (const name of names) {
|
|
371
|
+
if (seen.has(name)) duplicates.add(name);
|
|
372
|
+
seen.add(name);
|
|
373
|
+
}
|
|
374
|
+
return [...duplicates];
|
|
375
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Report Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates security scan reports with scoring, findings, and remediation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ScanReport, ScanSummary, Finding, MCPConfigFile, ScanStatus } from '../lib/types';
|
|
8
|
+
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Score Calculation
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate security score (0-100, higher is better)
|
|
17
|
+
*
|
|
18
|
+
* Scoring:
|
|
19
|
+
* - Start at 100
|
|
20
|
+
* - Critical finding: -25 points
|
|
21
|
+
* - High finding: -15 points
|
|
22
|
+
* - Medium finding: -8 points
|
|
23
|
+
* - Low finding: -3 points
|
|
24
|
+
* - Info finding: -0 points
|
|
25
|
+
* - Minimum score: 0
|
|
26
|
+
*/
|
|
27
|
+
function calculateScore(findings: Finding[]): number {
|
|
28
|
+
let score = 100;
|
|
29
|
+
|
|
30
|
+
for (const finding of findings) {
|
|
31
|
+
switch (finding.severity) {
|
|
32
|
+
case 'critical': score -= 25; break;
|
|
33
|
+
case 'high': score -= 15; break;
|
|
34
|
+
case 'medium': score -= 8; break;
|
|
35
|
+
case 'low': score -= 3; break;
|
|
36
|
+
case 'info': break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Math.max(0, score);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Determine overall scan status from score
|
|
45
|
+
*/
|
|
46
|
+
function getStatus(score: number): ScanStatus {
|
|
47
|
+
if (score >= 80) return 'pass';
|
|
48
|
+
if (score >= 50) return 'warn';
|
|
49
|
+
return 'fail';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a summary from findings
|
|
54
|
+
*/
|
|
55
|
+
function summarize(findings: Finding[], configs: MCPConfigFile[]): ScanSummary {
|
|
56
|
+
const totalServers = configs.reduce(
|
|
57
|
+
(sum, c) => sum + Object.keys(c.servers).length, 0
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
totalServers,
|
|
62
|
+
totalFindings: findings.length,
|
|
63
|
+
critical: findings.filter((f) => f.severity === 'critical').length,
|
|
64
|
+
high: findings.filter((f) => f.severity === 'high').length,
|
|
65
|
+
medium: findings.filter((f) => f.severity === 'medium').length,
|
|
66
|
+
low: findings.filter((f) => f.severity === 'low').length,
|
|
67
|
+
info: findings.filter((f) => f.severity === 'info').length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Report Generation
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a full scan report
|
|
77
|
+
*/
|
|
78
|
+
export function generateReport(
|
|
79
|
+
configs: MCPConfigFile[],
|
|
80
|
+
findings: Finding[]
|
|
81
|
+
): ScanReport {
|
|
82
|
+
const score = calculateScore(findings);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
version: VERSION,
|
|
87
|
+
score,
|
|
88
|
+
status: getStatus(score),
|
|
89
|
+
configFiles: configs,
|
|
90
|
+
findings: findings.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity)),
|
|
91
|
+
summary: summarize(findings, configs),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function severityOrder(s: string): number {
|
|
96
|
+
const order: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
97
|
+
return order[s] ?? 5;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Console Output
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
const COLORS = {
|
|
105
|
+
reset: '\x1b[0m',
|
|
106
|
+
bold: '\x1b[1m',
|
|
107
|
+
dim: '\x1b[2m',
|
|
108
|
+
red: '\x1b[31m',
|
|
109
|
+
green: '\x1b[32m',
|
|
110
|
+
yellow: '\x1b[33m',
|
|
111
|
+
blue: '\x1b[34m',
|
|
112
|
+
magenta: '\x1b[35m',
|
|
113
|
+
cyan: '\x1b[36m',
|
|
114
|
+
white: '\x1b[37m',
|
|
115
|
+
bgRed: '\x1b[41m',
|
|
116
|
+
bgGreen: '\x1b[42m',
|
|
117
|
+
bgYellow: '\x1b[43m',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function severityColor(severity: string): string {
|
|
121
|
+
switch (severity) {
|
|
122
|
+
case 'critical': return COLORS.bgRed + COLORS.white;
|
|
123
|
+
case 'high': return COLORS.red;
|
|
124
|
+
case 'medium': return COLORS.yellow;
|
|
125
|
+
case 'low': return COLORS.blue;
|
|
126
|
+
case 'info': return COLORS.dim;
|
|
127
|
+
default: return COLORS.reset;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scoreColor(score: number): string {
|
|
132
|
+
if (score >= 80) return COLORS.green;
|
|
133
|
+
if (score >= 50) return COLORS.yellow;
|
|
134
|
+
return COLORS.red;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function statusBadge(status: ScanStatus): string {
|
|
138
|
+
switch (status) {
|
|
139
|
+
case 'pass': return `${COLORS.bgGreen}${COLORS.white} PASS ${COLORS.reset}`;
|
|
140
|
+
case 'warn': return `${COLORS.bgYellow}${COLORS.white} WARN ${COLORS.reset}`;
|
|
141
|
+
case 'fail': return `${COLORS.bgRed}${COLORS.white} FAIL ${COLORS.reset}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Print the scan report to console
|
|
147
|
+
*/
|
|
148
|
+
export function printReport(report: ScanReport): void {
|
|
149
|
+
const { summary, findings, score, status, configFiles } = report;
|
|
150
|
+
|
|
151
|
+
// Header
|
|
152
|
+
console.log();
|
|
153
|
+
console.log(`${COLORS.bold}${COLORS.cyan} Sentinel MCP Security Scanner v${VERSION}${COLORS.reset}`);
|
|
154
|
+
console.log(`${COLORS.dim} ${'─'.repeat(50)}${COLORS.reset}`);
|
|
155
|
+
console.log();
|
|
156
|
+
|
|
157
|
+
// Config files found
|
|
158
|
+
if (configFiles.length === 0) {
|
|
159
|
+
console.log(` ${COLORS.yellow}No MCP configuration files found.${COLORS.reset}`);
|
|
160
|
+
console.log(` ${COLORS.dim}Checked: Claude Desktop, Cursor, VS Code, Claude Code, Windsurf, Cline${COLORS.reset}`);
|
|
161
|
+
console.log();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(` ${COLORS.bold}Configurations Found${COLORS.reset}`);
|
|
166
|
+
for (const config of configFiles) {
|
|
167
|
+
const serverCount = Object.keys(config.servers).length;
|
|
168
|
+
console.log(` ${COLORS.cyan}${config.client}${COLORS.reset} (${serverCount} server${serverCount !== 1 ? 's' : ''})`);
|
|
169
|
+
console.log(` ${COLORS.dim}${config.path}${COLORS.reset}`);
|
|
170
|
+
|
|
171
|
+
for (const serverName of Object.keys(config.servers)) {
|
|
172
|
+
console.log(` ${COLORS.dim}└─${COLORS.reset} ${serverName}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
console.log();
|
|
176
|
+
|
|
177
|
+
// Score
|
|
178
|
+
console.log(` ${COLORS.bold}Security Score${COLORS.reset}`);
|
|
179
|
+
console.log(` ${scoreColor(score)}${COLORS.bold}${score}/100${COLORS.reset} ${statusBadge(status)}`);
|
|
180
|
+
console.log();
|
|
181
|
+
|
|
182
|
+
// Summary bar
|
|
183
|
+
if (summary.totalFindings > 0) {
|
|
184
|
+
const parts: string[] = [];
|
|
185
|
+
if (summary.critical > 0) parts.push(`${COLORS.bgRed}${COLORS.white} ${summary.critical} CRITICAL ${COLORS.reset}`);
|
|
186
|
+
if (summary.high > 0) parts.push(`${COLORS.red}${summary.high} high${COLORS.reset}`);
|
|
187
|
+
if (summary.medium > 0) parts.push(`${COLORS.yellow}${summary.medium} medium${COLORS.reset}`);
|
|
188
|
+
if (summary.low > 0) parts.push(`${COLORS.blue}${summary.low} low${COLORS.reset}`);
|
|
189
|
+
if (summary.info > 0) parts.push(`${COLORS.dim}${summary.info} info${COLORS.reset}`);
|
|
190
|
+
console.log(` ${parts.join(' ')}`);
|
|
191
|
+
console.log();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Findings
|
|
195
|
+
if (findings.length === 0) {
|
|
196
|
+
console.log(` ${COLORS.green}No security issues found.${COLORS.reset}`);
|
|
197
|
+
console.log();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(` ${COLORS.bold}Findings${COLORS.reset}`);
|
|
202
|
+
console.log(` ${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}`);
|
|
203
|
+
|
|
204
|
+
for (const finding of findings) {
|
|
205
|
+
const sev = severityColor(finding.severity);
|
|
206
|
+
const sevLabel = finding.severity.toUpperCase().padEnd(8);
|
|
207
|
+
|
|
208
|
+
console.log();
|
|
209
|
+
console.log(` ${sev}${sevLabel}${COLORS.reset} ${COLORS.bold}${finding.title}${COLORS.reset} ${COLORS.dim}[${finding.id}]${COLORS.reset}`);
|
|
210
|
+
|
|
211
|
+
if (finding.server) {
|
|
212
|
+
console.log(` ${COLORS.dim}Server:${COLORS.reset} ${finding.server}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log(` ${finding.description}`);
|
|
216
|
+
|
|
217
|
+
if (finding.evidence) {
|
|
218
|
+
console.log(` ${COLORS.dim}Evidence: ${finding.evidence}${COLORS.reset}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (finding.remediation) {
|
|
222
|
+
console.log(` ${COLORS.green}Fix: ${finding.remediation}${COLORS.reset}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log();
|
|
227
|
+
console.log(` ${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}`);
|
|
228
|
+
console.log(` ${summary.totalServers} server${summary.totalServers !== 1 ? 's' : ''} scanned across ${configFiles.length} config file${configFiles.length !== 1 ? 's' : ''}`);
|
|
229
|
+
console.log(` ${summary.totalFindings} finding${summary.totalFindings !== 1 ? 's' : ''} reported`);
|
|
230
|
+
console.log();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Output report as JSON
|
|
235
|
+
*/
|
|
236
|
+
export function printReportJSON(report: ScanReport): void {
|
|
237
|
+
// Strip raw config data to avoid leaking secrets in JSON output
|
|
238
|
+
const safeReport = {
|
|
239
|
+
...report,
|
|
240
|
+
configFiles: report.configFiles.map((c) => ({
|
|
241
|
+
path: c.path,
|
|
242
|
+
client: c.client,
|
|
243
|
+
servers: Object.keys(c.servers),
|
|
244
|
+
})),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
console.log(JSON.stringify(safeReport, null, 2));
|
|
248
|
+
}
|