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.
@@ -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
+ }