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,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
+ }