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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Configuration Scanner
|
|
3
|
+
*
|
|
4
|
+
* Discovers and parses MCP configuration files from known client locations:
|
|
5
|
+
* - Claude Desktop (claude_desktop_config.json)
|
|
6
|
+
* - Cursor (.cursor/mcp.json)
|
|
7
|
+
* - VS Code (settings.json with mcp servers)
|
|
8
|
+
* - Claude Code (.claude/settings.json or .mcp.json)
|
|
9
|
+
* - Windsurf, Cline, etc.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import type { MCPConfigFile, MCPServerConfig, Finding } from '../lib/types';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Known MCP Config Locations
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
interface ConfigLocation {
|
|
22
|
+
client: string;
|
|
23
|
+
paths: string[];
|
|
24
|
+
parser: (raw: unknown) => Record<string, MCPServerConfig> | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getConfigLocations(): ConfigLocation[] {
|
|
28
|
+
const home = homedir();
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
// Claude Desktop
|
|
33
|
+
{
|
|
34
|
+
client: 'Claude Desktop',
|
|
35
|
+
paths: [
|
|
36
|
+
platform === 'darwin'
|
|
37
|
+
? join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
38
|
+
: platform === 'win32'
|
|
39
|
+
? join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json')
|
|
40
|
+
: join(home, '.config', 'claude', 'claude_desktop_config.json'),
|
|
41
|
+
],
|
|
42
|
+
parser: parseClaudeDesktopConfig,
|
|
43
|
+
},
|
|
44
|
+
// Claude Code (project-level)
|
|
45
|
+
{
|
|
46
|
+
client: 'Claude Code',
|
|
47
|
+
paths: [
|
|
48
|
+
join(process.cwd(), '.mcp.json'),
|
|
49
|
+
join(home, '.claude', 'settings.json'),
|
|
50
|
+
],
|
|
51
|
+
parser: parseClaudeCodeConfig,
|
|
52
|
+
},
|
|
53
|
+
// Cursor
|
|
54
|
+
{
|
|
55
|
+
client: 'Cursor',
|
|
56
|
+
paths: [
|
|
57
|
+
join(home, '.cursor', 'mcp.json'),
|
|
58
|
+
],
|
|
59
|
+
parser: parseCursorConfig,
|
|
60
|
+
},
|
|
61
|
+
// VS Code
|
|
62
|
+
{
|
|
63
|
+
client: 'VS Code',
|
|
64
|
+
paths: [
|
|
65
|
+
platform === 'darwin'
|
|
66
|
+
? join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json')
|
|
67
|
+
: platform === 'win32'
|
|
68
|
+
? join(home, 'AppData', 'Roaming', 'Code', 'User', 'settings.json')
|
|
69
|
+
: join(home, '.config', 'Code', 'User', 'settings.json'),
|
|
70
|
+
],
|
|
71
|
+
parser: parseVSCodeConfig,
|
|
72
|
+
},
|
|
73
|
+
// Windsurf
|
|
74
|
+
{
|
|
75
|
+
client: 'Windsurf',
|
|
76
|
+
paths: [
|
|
77
|
+
join(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
78
|
+
],
|
|
79
|
+
parser: parseClaudeDesktopConfig, // Same format
|
|
80
|
+
},
|
|
81
|
+
// Cline
|
|
82
|
+
{
|
|
83
|
+
client: 'Cline',
|
|
84
|
+
paths: [
|
|
85
|
+
join(home, '.cline', 'mcp_settings.json'),
|
|
86
|
+
],
|
|
87
|
+
parser: parseClineConfig,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Config Parsers
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
function parseClaudeDesktopConfig(raw: unknown): Record<string, MCPServerConfig> | null {
|
|
97
|
+
const config = raw as Record<string, unknown>;
|
|
98
|
+
if (config?.mcpServers && typeof config.mcpServers === 'object') {
|
|
99
|
+
return config.mcpServers as Record<string, MCPServerConfig>;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseClaudeCodeConfig(raw: unknown): Record<string, MCPServerConfig> | null {
|
|
105
|
+
const config = raw as Record<string, unknown>;
|
|
106
|
+
// .mcp.json format
|
|
107
|
+
if (config?.mcpServers && typeof config.mcpServers === 'object') {
|
|
108
|
+
return config.mcpServers as Record<string, MCPServerConfig>;
|
|
109
|
+
}
|
|
110
|
+
// settings.json format
|
|
111
|
+
if (config?.mcpServers && typeof config.mcpServers === 'object') {
|
|
112
|
+
return config.mcpServers as Record<string, MCPServerConfig>;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseCursorConfig(raw: unknown): Record<string, MCPServerConfig> | null {
|
|
118
|
+
const config = raw as Record<string, unknown>;
|
|
119
|
+
if (config?.mcpServers && typeof config.mcpServers === 'object') {
|
|
120
|
+
return config.mcpServers as Record<string, MCPServerConfig>;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseVSCodeConfig(raw: unknown): Record<string, MCPServerConfig> | null {
|
|
126
|
+
const config = raw as Record<string, unknown>;
|
|
127
|
+
// VS Code uses "mcp.servers" key
|
|
128
|
+
const mcpConfig = config?.['mcp'] as Record<string, unknown> | undefined;
|
|
129
|
+
if (mcpConfig?.servers && typeof mcpConfig.servers === 'object') {
|
|
130
|
+
return mcpConfig.servers as Record<string, MCPServerConfig>;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseClineConfig(raw: unknown): Record<string, MCPServerConfig> | null {
|
|
136
|
+
const config = raw as Record<string, unknown>;
|
|
137
|
+
if (config?.mcpServers && typeof config.mcpServers === 'object') {
|
|
138
|
+
return config.mcpServers as Record<string, MCPServerConfig>;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Discovery
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Discover all MCP configuration files on the system
|
|
149
|
+
*/
|
|
150
|
+
export function discoverConfigs(): MCPConfigFile[] {
|
|
151
|
+
const configs: MCPConfigFile[] = [];
|
|
152
|
+
const locations = getConfigLocations();
|
|
153
|
+
|
|
154
|
+
for (const location of locations) {
|
|
155
|
+
for (const configPath of location.paths) {
|
|
156
|
+
if (!existsSync(configPath)) continue;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
160
|
+
const raw = JSON.parse(content);
|
|
161
|
+
const servers = location.parser(raw);
|
|
162
|
+
|
|
163
|
+
if (servers && Object.keys(servers).length > 0) {
|
|
164
|
+
configs.push({
|
|
165
|
+
path: configPath,
|
|
166
|
+
client: location.client,
|
|
167
|
+
servers,
|
|
168
|
+
raw,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Skip unparseable configs
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return configs;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Config-Level Security Checks
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scan config files for configuration-level security issues
|
|
186
|
+
*/
|
|
187
|
+
export function scanConfigs(configs: MCPConfigFile[]): Finding[] {
|
|
188
|
+
const findings: Finding[] = [];
|
|
189
|
+
let findingId = 0;
|
|
190
|
+
|
|
191
|
+
for (const config of configs) {
|
|
192
|
+
for (const [serverName, server] of Object.entries(config.servers)) {
|
|
193
|
+
// Check for stdio transport with absolute paths to unknown binaries
|
|
194
|
+
if (server.command) {
|
|
195
|
+
// npx/bunx with unknown packages
|
|
196
|
+
if (/^(npx|bunx|pnpx)\s/.test(server.command)) {
|
|
197
|
+
const pkg = server.command.split(/\s+/)[1];
|
|
198
|
+
if (pkg && !pkg.startsWith('@anthropic') && !pkg.startsWith('@modelcontextprotocol')) {
|
|
199
|
+
findings.push({
|
|
200
|
+
id: `CFG-${++findingId}`,
|
|
201
|
+
severity: 'medium',
|
|
202
|
+
category: 'supply-chain',
|
|
203
|
+
title: `Unverified npm package: ${pkg}`,
|
|
204
|
+
description: `Server "${serverName}" uses npx/bunx to run "${pkg}". This package is downloaded and executed at runtime without integrity verification.`,
|
|
205
|
+
server: serverName,
|
|
206
|
+
configFile: config.path,
|
|
207
|
+
evidence: `command: ${server.command}`,
|
|
208
|
+
remediation: 'Pin the package version and verify its integrity. Consider installing locally instead of using npx.',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Docker without security flags
|
|
214
|
+
if (/^docker\s+run\b/.test(server.command) && !/--security-opt|--cap-drop/.test(server.command)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
id: `CFG-${++findingId}`,
|
|
217
|
+
severity: 'medium',
|
|
218
|
+
category: 'excessive-permissions',
|
|
219
|
+
title: `Docker container without security restrictions`,
|
|
220
|
+
description: `Server "${serverName}" runs a Docker container without --security-opt or --cap-drop flags.`,
|
|
221
|
+
server: serverName,
|
|
222
|
+
configFile: config.path,
|
|
223
|
+
evidence: `command: ${server.command}`,
|
|
224
|
+
remediation: 'Add --security-opt=no-new-privileges and --cap-drop=ALL to the docker run command.',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Privileged docker
|
|
229
|
+
if (/docker\s+run\b.*--privileged/.test(server.command)) {
|
|
230
|
+
findings.push({
|
|
231
|
+
id: `CFG-${++findingId}`,
|
|
232
|
+
severity: 'critical',
|
|
233
|
+
category: 'excessive-permissions',
|
|
234
|
+
title: `Privileged Docker container`,
|
|
235
|
+
description: `Server "${serverName}" runs a Docker container with --privileged, giving it full host access.`,
|
|
236
|
+
server: serverName,
|
|
237
|
+
configFile: config.path,
|
|
238
|
+
evidence: `command: ${server.command}`,
|
|
239
|
+
remediation: 'Remove --privileged flag and use specific --cap-add flags for required capabilities only.',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check for HTTP (non-HTTPS) transport URLs
|
|
245
|
+
if (server.url && server.url.startsWith('http://')) {
|
|
246
|
+
findings.push({
|
|
247
|
+
id: `CFG-${++findingId}`,
|
|
248
|
+
severity: 'high',
|
|
249
|
+
category: 'insecure-transport',
|
|
250
|
+
title: `Unencrypted HTTP transport`,
|
|
251
|
+
description: `Server "${serverName}" uses HTTP instead of HTTPS. API keys and conversation data are transmitted in cleartext.`,
|
|
252
|
+
server: serverName,
|
|
253
|
+
configFile: config.path,
|
|
254
|
+
evidence: `url: ${server.url}`,
|
|
255
|
+
remediation: 'Use HTTPS (wss:// or https://) for the server URL.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return findings;
|
|
262
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Credential Scanner
|
|
3
|
+
*
|
|
4
|
+
* Detects hardcoded credentials, API keys, and tokens in MCP configurations.
|
|
5
|
+
* Maps to OWASP MCP Top 10: Token/Credential Mismanagement.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MCPConfigFile, Finding, Scanner } from '../lib/types';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Credential Patterns
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
interface CredentialPattern {
|
|
15
|
+
name: string;
|
|
16
|
+
pattern: RegExp;
|
|
17
|
+
description: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CREDENTIAL_PATTERNS: CredentialPattern[] = [
|
|
21
|
+
// API Keys
|
|
22
|
+
{ name: 'Anthropic API Key', pattern: /sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/, description: 'Anthropic API key' },
|
|
23
|
+
{ name: 'OpenAI API Key', pattern: /sk-proj-[A-Za-z0-9_-]{20,}/, description: 'OpenAI project API key' },
|
|
24
|
+
{ name: 'OpenAI Legacy Key', pattern: /sk-[A-Za-z0-9]{48}/, description: 'OpenAI legacy API key' },
|
|
25
|
+
{ name: 'Google API Key', pattern: /AIzaSy[A-Za-z0-9_-]{33}/, description: 'Google API key' },
|
|
26
|
+
{ name: 'AWS Access Key', pattern: /AKIA[A-Z0-9]{16}/, description: 'AWS access key ID' },
|
|
27
|
+
{ name: 'GitHub Token', pattern: /gh[ps]_[A-Za-z0-9]{36,}/, description: 'GitHub personal access token' },
|
|
28
|
+
{ name: 'GitHub Fine-grained', pattern: /github_pat_[A-Za-z0-9_]{20,}/, description: 'GitHub fine-grained PAT' },
|
|
29
|
+
{ name: 'Slack Token', pattern: /xox[bporas]-[A-Za-z0-9-]+/, description: 'Slack API token' },
|
|
30
|
+
{ name: 'Stripe Key', pattern: /sk_live_[A-Za-z0-9]{24,}/, description: 'Stripe live secret key' },
|
|
31
|
+
{ name: 'Supabase Key', pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, description: 'JWT token (possibly Supabase/auth)' },
|
|
32
|
+
{ name: 'Perplexity Key', pattern: /pplx-[A-Za-z0-9]{48,}/, description: 'Perplexity API key' },
|
|
33
|
+
{ name: 'Twilio SID', pattern: /AC[a-f0-9]{32}/, description: 'Twilio Account SID' },
|
|
34
|
+
{ name: 'SendGrid Key', pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/, description: 'SendGrid API key' },
|
|
35
|
+
{ name: 'Mailgun Key', pattern: /key-[a-f0-9]{32}/, description: 'Mailgun API key' },
|
|
36
|
+
{ name: 'HuggingFace Token', pattern: /hf_[A-Za-z0-9]{34,}/, description: 'Hugging Face API token' },
|
|
37
|
+
{ name: 'Replicate Token', pattern: /r8_[A-Za-z0-9]{37,}/, description: 'Replicate API token' },
|
|
38
|
+
|
|
39
|
+
// Generic patterns
|
|
40
|
+
{ name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9_.-]{20,}/, description: 'Bearer authentication token' },
|
|
41
|
+
{ name: 'Basic Auth', pattern: /Basic\s+[A-Za-z0-9+/=]{20,}/, description: 'Basic authentication header' },
|
|
42
|
+
{ name: 'Private Key', pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, description: 'Private key material' },
|
|
43
|
+
{ name: 'Password in URL', pattern: /:\/\/[^:]+:[^@]+@/, description: 'Credentials embedded in URL' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Environment variable names that commonly hold secrets
|
|
47
|
+
const SENSITIVE_ENV_NAMES = [
|
|
48
|
+
/api[_-]?key/i,
|
|
49
|
+
/api[_-]?secret/i,
|
|
50
|
+
/auth[_-]?token/i,
|
|
51
|
+
/access[_-]?token/i,
|
|
52
|
+
/secret[_-]?key/i,
|
|
53
|
+
/private[_-]?key/i,
|
|
54
|
+
/password/i,
|
|
55
|
+
/passwd/i,
|
|
56
|
+
/credential/i,
|
|
57
|
+
/db[_-]?(pass|pwd|password)/i,
|
|
58
|
+
/database[_-]?url/i,
|
|
59
|
+
/connection[_-]?string/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Scanner
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
export const credentialScanner: Scanner = {
|
|
67
|
+
name: 'Credential Scanner',
|
|
68
|
+
|
|
69
|
+
async scan(configs: MCPConfigFile[]): Promise<Finding[]> {
|
|
70
|
+
const findings: Finding[] = [];
|
|
71
|
+
let findingId = 0;
|
|
72
|
+
|
|
73
|
+
for (const config of configs) {
|
|
74
|
+
// Stringify the whole config to catch credentials anywhere
|
|
75
|
+
const configStr = JSON.stringify(config.raw, null, 2);
|
|
76
|
+
|
|
77
|
+
for (const [serverName, server] of Object.entries(config.servers)) {
|
|
78
|
+
// Check command + args for hardcoded credentials
|
|
79
|
+
const commandStr = [
|
|
80
|
+
server.command || '',
|
|
81
|
+
...(server.args || []),
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
84
|
+
for (const cred of CREDENTIAL_PATTERNS) {
|
|
85
|
+
if (cred.pattern.test(commandStr)) {
|
|
86
|
+
findings.push({
|
|
87
|
+
id: `CRED-${++findingId}`,
|
|
88
|
+
severity: 'critical',
|
|
89
|
+
category: 'credential-exposure',
|
|
90
|
+
title: `Hardcoded ${cred.name} in command`,
|
|
91
|
+
description: `Server "${serverName}" has a ${cred.description} hardcoded in its command/args. This is exposed to any process and in config file backups.`,
|
|
92
|
+
server: serverName,
|
|
93
|
+
configFile: config.path,
|
|
94
|
+
evidence: maskCredential(commandStr, cred.pattern),
|
|
95
|
+
remediation: 'Move credentials to environment variables or a secrets manager. Use the "env" field in server config.',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check environment variables for hardcoded values
|
|
101
|
+
if (server.env) {
|
|
102
|
+
for (const [envName, envValue] of Object.entries(server.env)) {
|
|
103
|
+
// Check if env var name suggests it's a secret
|
|
104
|
+
const isSensitiveName = SENSITIVE_ENV_NAMES.some((p) => p.test(envName));
|
|
105
|
+
|
|
106
|
+
// Check if the value matches a known credential pattern
|
|
107
|
+
for (const cred of CREDENTIAL_PATTERNS) {
|
|
108
|
+
if (cred.pattern.test(envValue)) {
|
|
109
|
+
findings.push({
|
|
110
|
+
id: `CRED-${++findingId}`,
|
|
111
|
+
severity: 'critical',
|
|
112
|
+
category: 'credential-exposure',
|
|
113
|
+
title: `Hardcoded ${cred.name} in env config`,
|
|
114
|
+
description: `Server "${serverName}" has a ${cred.description} hardcoded in env.${envName}. Config files are often committed to git or backed up unencrypted.`,
|
|
115
|
+
server: serverName,
|
|
116
|
+
configFile: config.path,
|
|
117
|
+
evidence: `${envName}=${maskCredential(envValue, cred.pattern)}`,
|
|
118
|
+
remediation: 'Reference system environment variables instead of hardcoding values. Use ${ENV_VAR} syntax or a .env file excluded from version control.',
|
|
119
|
+
});
|
|
120
|
+
break; // One match per env var is enough
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Warn about sensitive-looking env vars even without pattern match
|
|
125
|
+
if (isSensitiveName && envValue.length > 10 && !findings.some((f) => f.evidence?.includes(envName))) {
|
|
126
|
+
findings.push({
|
|
127
|
+
id: `CRED-${++findingId}`,
|
|
128
|
+
severity: 'high',
|
|
129
|
+
category: 'credential-exposure',
|
|
130
|
+
title: `Potential secret in env: ${envName}`,
|
|
131
|
+
description: `Server "${serverName}" has a value in env.${envName} that appears to be a secret based on the variable name.`,
|
|
132
|
+
server: serverName,
|
|
133
|
+
configFile: config.path,
|
|
134
|
+
evidence: `${envName}=${envValue.substring(0, 4)}${'*'.repeat(Math.min(envValue.length - 4, 20))}`,
|
|
135
|
+
remediation: 'Use system environment variables or a secrets manager instead of config file values.',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check URL for embedded credentials
|
|
142
|
+
if (server.url) {
|
|
143
|
+
const urlCredPattern = /:\/\/([^:]+):([^@]+)@/;
|
|
144
|
+
if (urlCredPattern.test(server.url)) {
|
|
145
|
+
findings.push({
|
|
146
|
+
id: `CRED-${++findingId}`,
|
|
147
|
+
severity: 'critical',
|
|
148
|
+
category: 'credential-exposure',
|
|
149
|
+
title: `Credentials embedded in server URL`,
|
|
150
|
+
description: `Server "${serverName}" has credentials embedded in the URL. These appear in logs, browser history, and referrer headers.`,
|
|
151
|
+
server: serverName,
|
|
152
|
+
configFile: config.path,
|
|
153
|
+
evidence: server.url.replace(urlCredPattern, '://$1:****@'),
|
|
154
|
+
remediation: 'Pass credentials via environment variables or authentication headers instead of URL.',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Scan raw config for credentials not in server definitions
|
|
161
|
+
for (const cred of CREDENTIAL_PATTERNS) {
|
|
162
|
+
const matches = configStr.match(cred.pattern);
|
|
163
|
+
if (matches) {
|
|
164
|
+
// Check if we already found this credential in a server-specific check
|
|
165
|
+
const alreadyFound = findings.some((f) =>
|
|
166
|
+
f.configFile === config.path && f.title.includes(cred.name)
|
|
167
|
+
);
|
|
168
|
+
if (!alreadyFound) {
|
|
169
|
+
findings.push({
|
|
170
|
+
id: `CRED-${++findingId}`,
|
|
171
|
+
severity: 'high',
|
|
172
|
+
category: 'credential-exposure',
|
|
173
|
+
title: `${cred.name} found in config file`,
|
|
174
|
+
description: `Config file contains what appears to be a ${cred.description}.`,
|
|
175
|
+
configFile: config.path,
|
|
176
|
+
evidence: maskCredential(matches[0], cred.pattern),
|
|
177
|
+
remediation: 'Move credentials out of config files into environment variables or a secrets manager.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return findings;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Helpers
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mask a credential value for safe display
|
|
194
|
+
*/
|
|
195
|
+
function maskCredential(text: string, pattern: RegExp): string {
|
|
196
|
+
return text.replace(pattern, (match) => {
|
|
197
|
+
if (match.length <= 8) return '****';
|
|
198
|
+
return match.substring(0, 4) + '*'.repeat(Math.min(match.length - 8, 20)) + match.substring(match.length - 4);
|
|
199
|
+
});
|
|
200
|
+
}
|