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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Lightweight MCP Protocol Client
|
|
3
|
+
*
|
|
4
|
+
* Connects to MCP servers via stdio or HTTP transport,
|
|
5
|
+
* performs the initialization handshake, and queries
|
|
6
|
+
* tools/list, resources/list, prompts/list.
|
|
7
|
+
*
|
|
8
|
+
* Uses JSON-RPC 2.0 over newline-delimited JSON (stdio)
|
|
9
|
+
* or HTTP POST (streamable-http / SSE).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn, type Subprocess } from 'bun';
|
|
13
|
+
import type { MCPServerConfig } from './types';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// MCP Protocol Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
interface JsonRpcRequest {
|
|
20
|
+
jsonrpc: '2.0';
|
|
21
|
+
id: number;
|
|
22
|
+
method: string;
|
|
23
|
+
params?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface JsonRpcResponse {
|
|
27
|
+
jsonrpc: '2.0';
|
|
28
|
+
id: number;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
error?: { code: number; message: string; data?: unknown };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MCPTool {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
inputSchema?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MCPResource {
|
|
40
|
+
uri: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
mimeType?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MCPPrompt {
|
|
47
|
+
name: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MCPServerInfo {
|
|
53
|
+
name?: string;
|
|
54
|
+
version?: string;
|
|
55
|
+
protocolVersion?: string;
|
|
56
|
+
capabilities?: Record<string, unknown>;
|
|
57
|
+
tools: MCPTool[];
|
|
58
|
+
resources: MCPResource[];
|
|
59
|
+
prompts: MCPPrompt[];
|
|
60
|
+
error?: string;
|
|
61
|
+
connectTimeMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Stdio Transport
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
const CONNECT_TIMEOUT = 10_000; // 10s to connect + initialize
|
|
69
|
+
const REQUEST_TIMEOUT = 5_000; // 5s per request
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Connect to an MCP server via stdio transport
|
|
73
|
+
*/
|
|
74
|
+
export async function connectStdio(
|
|
75
|
+
config: MCPServerConfig,
|
|
76
|
+
serverName: string
|
|
77
|
+
): Promise<MCPServerInfo> {
|
|
78
|
+
const startTime = Date.now();
|
|
79
|
+
|
|
80
|
+
if (!config.command) {
|
|
81
|
+
return { tools: [], resources: [], prompts: [], error: 'No command specified' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parse command - first word is the binary, rest are initial args
|
|
85
|
+
const parts = config.command.split(/\s+/);
|
|
86
|
+
const cmd = parts[0];
|
|
87
|
+
const cmdArgs = [...parts.slice(1), ...(config.args || [])];
|
|
88
|
+
|
|
89
|
+
let proc: Subprocess | null = null;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Spawn the server process
|
|
93
|
+
proc = spawn({
|
|
94
|
+
cmd: [cmd, ...cmdArgs],
|
|
95
|
+
env: { ...process.env, ...(config.env || {}) },
|
|
96
|
+
stdin: 'pipe',
|
|
97
|
+
stdout: 'pipe',
|
|
98
|
+
stderr: 'pipe',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const reader = new StdioReader(proc);
|
|
102
|
+
let messageId = 0;
|
|
103
|
+
|
|
104
|
+
// Helper: send JSON-RPC and wait for response
|
|
105
|
+
const sendRequest = async (method: string, params?: Record<string, unknown>): Promise<unknown> => {
|
|
106
|
+
const id = ++messageId;
|
|
107
|
+
const request: JsonRpcRequest = {
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
id,
|
|
110
|
+
method,
|
|
111
|
+
params,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const line = JSON.stringify(request) + '\n';
|
|
115
|
+
proc!.stdin.write(line);
|
|
116
|
+
proc!.stdin.flush();
|
|
117
|
+
|
|
118
|
+
const response = await reader.waitForResponse(id, REQUEST_TIMEOUT);
|
|
119
|
+
if (response.error) {
|
|
120
|
+
throw new Error(`${method} failed: ${response.error.message}`);
|
|
121
|
+
}
|
|
122
|
+
return response.result;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Helper: send notification (no response expected)
|
|
126
|
+
const sendNotification = (method: string, params?: Record<string, unknown>) => {
|
|
127
|
+
const notification = {
|
|
128
|
+
jsonrpc: '2.0' as const,
|
|
129
|
+
method,
|
|
130
|
+
params,
|
|
131
|
+
};
|
|
132
|
+
proc!.stdin.write(JSON.stringify(notification) + '\n');
|
|
133
|
+
proc!.stdin.flush();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Step 1: Initialize
|
|
137
|
+
const initResult = await sendRequest('initialize', {
|
|
138
|
+
protocolVersion: '2024-11-05',
|
|
139
|
+
capabilities: {},
|
|
140
|
+
clientInfo: {
|
|
141
|
+
name: 'sentinel-mcp',
|
|
142
|
+
version: '0.1.0',
|
|
143
|
+
},
|
|
144
|
+
}) as Record<string, unknown>;
|
|
145
|
+
|
|
146
|
+
// Step 2: Send initialized notification
|
|
147
|
+
sendNotification('notifications/initialized');
|
|
148
|
+
|
|
149
|
+
// Step 3: Query tools, resources, prompts in parallel
|
|
150
|
+
const info: MCPServerInfo = {
|
|
151
|
+
name: (initResult?.serverInfo as Record<string, string>)?.name,
|
|
152
|
+
version: (initResult?.serverInfo as Record<string, string>)?.version,
|
|
153
|
+
protocolVersion: initResult?.protocolVersion as string,
|
|
154
|
+
capabilities: initResult?.capabilities as Record<string, unknown>,
|
|
155
|
+
tools: [],
|
|
156
|
+
resources: [],
|
|
157
|
+
prompts: [],
|
|
158
|
+
connectTimeMs: Date.now() - startTime,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Query tools
|
|
162
|
+
try {
|
|
163
|
+
const toolsResult = await sendRequest('tools/list') as { tools?: MCPTool[] };
|
|
164
|
+
info.tools = toolsResult?.tools || [];
|
|
165
|
+
} catch {
|
|
166
|
+
// Server may not support tools
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Query resources
|
|
170
|
+
try {
|
|
171
|
+
const resourcesResult = await sendRequest('resources/list') as { resources?: MCPResource[] };
|
|
172
|
+
info.resources = resourcesResult?.resources || [];
|
|
173
|
+
} catch {
|
|
174
|
+
// Server may not support resources
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Query prompts
|
|
178
|
+
try {
|
|
179
|
+
const promptsResult = await sendRequest('prompts/list') as { prompts?: MCPPrompt[] };
|
|
180
|
+
info.prompts = promptsResult?.prompts || [];
|
|
181
|
+
} catch {
|
|
182
|
+
// Server may not support prompts
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return info;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
188
|
+
return {
|
|
189
|
+
tools: [],
|
|
190
|
+
resources: [],
|
|
191
|
+
prompts: [],
|
|
192
|
+
error: `Failed to connect to "${serverName}": ${error}`,
|
|
193
|
+
connectTimeMs: Date.now() - startTime,
|
|
194
|
+
};
|
|
195
|
+
} finally {
|
|
196
|
+
// Clean up the process
|
|
197
|
+
if (proc) {
|
|
198
|
+
try {
|
|
199
|
+
proc.stdin.end();
|
|
200
|
+
proc.kill();
|
|
201
|
+
} catch {
|
|
202
|
+
// Process may have already exited
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ============================================================================
|
|
209
|
+
// Stdio Reader - Parses newline-delimited JSON-RPC from stdout
|
|
210
|
+
// ============================================================================
|
|
211
|
+
|
|
212
|
+
class StdioReader {
|
|
213
|
+
private buffer = '';
|
|
214
|
+
private pendingResponses = new Map<number, {
|
|
215
|
+
resolve: (r: JsonRpcResponse) => void;
|
|
216
|
+
reject: (e: Error) => void;
|
|
217
|
+
}>();
|
|
218
|
+
private reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
219
|
+
private decoder = new TextDecoder();
|
|
220
|
+
private reading = false;
|
|
221
|
+
|
|
222
|
+
constructor(proc: Subprocess) {
|
|
223
|
+
this.reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
224
|
+
this.startReading();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async startReading() {
|
|
228
|
+
if (this.reading) return;
|
|
229
|
+
this.reading = true;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await this.reader.read();
|
|
234
|
+
if (done) break;
|
|
235
|
+
|
|
236
|
+
this.buffer += this.decoder.decode(value, { stream: true });
|
|
237
|
+
this.processBuffer();
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Stream closed
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private processBuffer() {
|
|
245
|
+
const lines = this.buffer.split('\n');
|
|
246
|
+
// Keep the last incomplete line in the buffer
|
|
247
|
+
this.buffer = lines.pop() || '';
|
|
248
|
+
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
const trimmed = line.trim();
|
|
251
|
+
if (!trimmed) continue;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const msg = JSON.parse(trimmed);
|
|
255
|
+
if (msg.id !== undefined && this.pendingResponses.has(msg.id)) {
|
|
256
|
+
const pending = this.pendingResponses.get(msg.id)!;
|
|
257
|
+
this.pendingResponses.delete(msg.id);
|
|
258
|
+
pending.resolve(msg as JsonRpcResponse);
|
|
259
|
+
}
|
|
260
|
+
// Ignore notifications from server (no id)
|
|
261
|
+
} catch {
|
|
262
|
+
// Skip non-JSON lines (server logs, etc.)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async waitForResponse(id: number, timeoutMs: number): Promise<JsonRpcResponse> {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
this.pendingResponses.delete(id);
|
|
271
|
+
reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`));
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
|
|
274
|
+
this.pendingResponses.set(id, {
|
|
275
|
+
resolve: (r) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
resolve(r);
|
|
278
|
+
},
|
|
279
|
+
reject: (e) => {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
reject(e);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// HTTP Transport
|
|
290
|
+
// ============================================================================
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Connect to an MCP server via HTTP transport (SSE / streamable-http)
|
|
294
|
+
*/
|
|
295
|
+
export async function connectHTTP(
|
|
296
|
+
config: MCPServerConfig,
|
|
297
|
+
serverName: string
|
|
298
|
+
): Promise<MCPServerInfo> {
|
|
299
|
+
const startTime = Date.now();
|
|
300
|
+
|
|
301
|
+
if (!config.url) {
|
|
302
|
+
return { tools: [], resources: [], prompts: [], error: 'No URL specified' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
let messageId = 0;
|
|
307
|
+
|
|
308
|
+
const sendRequest = async (method: string, params?: Record<string, unknown>): Promise<unknown> => {
|
|
309
|
+
const request: JsonRpcRequest = {
|
|
310
|
+
jsonrpc: '2.0',
|
|
311
|
+
id: ++messageId,
|
|
312
|
+
method,
|
|
313
|
+
params,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const response = await fetch(config.url!, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: {
|
|
319
|
+
'Content-Type': 'application/json',
|
|
320
|
+
...(config.env?.AUTHORIZATION ? { Authorization: config.env.AUTHORIZATION } : {}),
|
|
321
|
+
},
|
|
322
|
+
body: JSON.stringify(request),
|
|
323
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (!response.ok) {
|
|
327
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = await response.json() as JsonRpcResponse;
|
|
331
|
+
if (result.error) {
|
|
332
|
+
throw new Error(`${method} failed: ${result.error.message}`);
|
|
333
|
+
}
|
|
334
|
+
return result.result;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Initialize
|
|
338
|
+
const initResult = await sendRequest('initialize', {
|
|
339
|
+
protocolVersion: '2024-11-05',
|
|
340
|
+
capabilities: {},
|
|
341
|
+
clientInfo: { name: 'sentinel-mcp', version: '0.1.0' },
|
|
342
|
+
}) as Record<string, unknown>;
|
|
343
|
+
|
|
344
|
+
const info: MCPServerInfo = {
|
|
345
|
+
name: (initResult?.serverInfo as Record<string, string>)?.name,
|
|
346
|
+
version: (initResult?.serverInfo as Record<string, string>)?.version,
|
|
347
|
+
protocolVersion: initResult?.protocolVersion as string,
|
|
348
|
+
capabilities: initResult?.capabilities as Record<string, unknown>,
|
|
349
|
+
tools: [],
|
|
350
|
+
resources: [],
|
|
351
|
+
prompts: [],
|
|
352
|
+
connectTimeMs: Date.now() - startTime,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Query tools
|
|
356
|
+
try {
|
|
357
|
+
const toolsResult = await sendRequest('tools/list') as { tools?: MCPTool[] };
|
|
358
|
+
info.tools = toolsResult?.tools || [];
|
|
359
|
+
} catch { /* not supported */ }
|
|
360
|
+
|
|
361
|
+
// Query resources
|
|
362
|
+
try {
|
|
363
|
+
const resourcesResult = await sendRequest('resources/list') as { resources?: MCPResource[] };
|
|
364
|
+
info.resources = resourcesResult?.resources || [];
|
|
365
|
+
} catch { /* not supported */ }
|
|
366
|
+
|
|
367
|
+
// Query prompts
|
|
368
|
+
try {
|
|
369
|
+
const promptsResult = await sendRequest('prompts/list') as { prompts?: MCPPrompt[] };
|
|
370
|
+
info.prompts = promptsResult?.prompts || [];
|
|
371
|
+
} catch { /* not supported */ }
|
|
372
|
+
|
|
373
|
+
return info;
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
376
|
+
return {
|
|
377
|
+
tools: [],
|
|
378
|
+
resources: [],
|
|
379
|
+
prompts: [],
|
|
380
|
+
error: `Failed to connect to "${serverName}" at ${config.url}: ${error}`,
|
|
381
|
+
connectTimeMs: Date.now() - startTime,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Core Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Severity & Risk
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
10
|
+
|
|
11
|
+
export type ScanStatus = 'pass' | 'warn' | 'fail';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// MCP Configuration
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export interface MCPServerConfig {
|
|
18
|
+
command?: string;
|
|
19
|
+
args?: string[];
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
url?: string;
|
|
22
|
+
transport?: 'stdio' | 'sse' | 'streamable-http';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MCPConfigFile {
|
|
26
|
+
path: string;
|
|
27
|
+
client: string;
|
|
28
|
+
servers: Record<string, MCPServerConfig>;
|
|
29
|
+
raw: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Scan Findings
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
export interface Finding {
|
|
37
|
+
id: string;
|
|
38
|
+
severity: Severity;
|
|
39
|
+
category: FindingCategory;
|
|
40
|
+
title: string;
|
|
41
|
+
description: string;
|
|
42
|
+
server?: string;
|
|
43
|
+
configFile?: string;
|
|
44
|
+
evidence?: string;
|
|
45
|
+
remediation?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type FindingCategory =
|
|
49
|
+
| 'credential-exposure'
|
|
50
|
+
| 'prompt-injection'
|
|
51
|
+
| 'tool-poisoning'
|
|
52
|
+
| 'ssrf'
|
|
53
|
+
| 'command-injection'
|
|
54
|
+
| 'insecure-transport'
|
|
55
|
+
| 'excessive-permissions'
|
|
56
|
+
| 'supply-chain'
|
|
57
|
+
| 'configuration';
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Scan Report
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export interface ScanReport {
|
|
64
|
+
timestamp: string;
|
|
65
|
+
version: string;
|
|
66
|
+
score: number; // 0-100
|
|
67
|
+
status: ScanStatus;
|
|
68
|
+
configFiles: MCPConfigFile[];
|
|
69
|
+
findings: Finding[];
|
|
70
|
+
summary: ScanSummary;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScanSummary {
|
|
74
|
+
totalServers: number;
|
|
75
|
+
totalFindings: number;
|
|
76
|
+
critical: number;
|
|
77
|
+
high: number;
|
|
78
|
+
medium: number;
|
|
79
|
+
low: number;
|
|
80
|
+
info: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Scanner Interface
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export interface Scanner {
|
|
88
|
+
name: string;
|
|
89
|
+
scan(configs: MCPConfigFile[]): Promise<Finding[]>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - URL Validation & SSRF Protection
|
|
3
|
+
*
|
|
4
|
+
* Validates URLs found in MCP server configurations for SSRF,
|
|
5
|
+
* insecure transports, and suspicious patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Severity } from './types';
|
|
9
|
+
|
|
10
|
+
export interface URLValidationResult {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
reason?: string;
|
|
13
|
+
warnings: string[];
|
|
14
|
+
severity?: Severity;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// SSRF Protection Configuration
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const BLOCKED_HOSTS = [
|
|
22
|
+
'localhost',
|
|
23
|
+
'127.0.0.1',
|
|
24
|
+
'0.0.0.0',
|
|
25
|
+
'::1',
|
|
26
|
+
'[::1]',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const BLOCKED_IP_RANGES: RegExp[] = [
|
|
30
|
+
/^10\./, // 10.0.0.0/8 (RFC1918)
|
|
31
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16.0.0/12 (RFC1918)
|
|
32
|
+
/^192\.168\./, // 192.168.0.0/16 (RFC1918)
|
|
33
|
+
/^169\.254\./, // 169.254.0.0/16 (link-local)
|
|
34
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT)
|
|
35
|
+
/^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2)
|
|
36
|
+
/^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3)
|
|
37
|
+
/^224\./, // 224.0.0.0/4 (multicast)
|
|
38
|
+
/^240\./, // 240.0.0.0/4 (reserved)
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const CLOUD_METADATA_ENDPOINTS = [
|
|
42
|
+
'169.254.169.254', // AWS/GCP/Azure metadata
|
|
43
|
+
'metadata.google.internal',
|
|
44
|
+
'metadata.google',
|
|
45
|
+
'100.100.100.200', // Alibaba Cloud metadata
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Validation
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a URL for SSRF and security concerns
|
|
54
|
+
*/
|
|
55
|
+
export function validateURL(url: string): URLValidationResult {
|
|
56
|
+
const warnings: string[] = [];
|
|
57
|
+
|
|
58
|
+
let parsed: URL;
|
|
59
|
+
try {
|
|
60
|
+
parsed = new URL(url);
|
|
61
|
+
} catch {
|
|
62
|
+
return { valid: false, reason: 'Invalid URL format', warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
66
|
+
const scheme = parsed.protocol.replace(':', '').toLowerCase();
|
|
67
|
+
|
|
68
|
+
// Check scheme
|
|
69
|
+
if (!['https', 'http'].includes(scheme)) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
reason: `Unsafe scheme: ${scheme}`,
|
|
73
|
+
warnings,
|
|
74
|
+
severity: 'high',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// SSRF: blocked hosts
|
|
79
|
+
if (BLOCKED_HOSTS.includes(hostname)) {
|
|
80
|
+
return {
|
|
81
|
+
valid: false,
|
|
82
|
+
reason: `SSRF risk: blocked host ${hostname}`,
|
|
83
|
+
warnings,
|
|
84
|
+
severity: 'critical',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// SSRF: cloud metadata endpoints (check before generic IP ranges for specific messaging)
|
|
89
|
+
if (CLOUD_METADATA_ENDPOINTS.includes(hostname)) {
|
|
90
|
+
return {
|
|
91
|
+
valid: false,
|
|
92
|
+
reason: `SSRF risk: cloud metadata endpoint ${hostname}`,
|
|
93
|
+
warnings,
|
|
94
|
+
severity: 'critical',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// SSRF: blocked IP ranges
|
|
99
|
+
for (const range of BLOCKED_IP_RANGES) {
|
|
100
|
+
if (range.test(hostname)) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
reason: `SSRF risk: private/reserved IP range`,
|
|
104
|
+
warnings,
|
|
105
|
+
severity: 'critical',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// HTTP without TLS
|
|
111
|
+
if (scheme === 'http') {
|
|
112
|
+
warnings.push('Transport uses HTTP (unencrypted) - credentials and tokens may be exposed');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Embedded credentials
|
|
116
|
+
if (parsed.username || parsed.password) {
|
|
117
|
+
warnings.push('URL contains embedded credentials');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Non-standard port
|
|
121
|
+
if (parsed.port && !['80', '443', '8080', '8443'].includes(parsed.port)) {
|
|
122
|
+
warnings.push(`Non-standard port: ${parsed.port}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
valid: true,
|
|
127
|
+
warnings,
|
|
128
|
+
severity: warnings.length > 0 ? 'medium' : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|