@sms-florin/mcp-ssrf-scan 0.3.0 → 0.4.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/README.md +14 -6
- package/dist/cli.js +5 -2
- package/dist/scan.d.ts +3 -1
- package/dist/scan.js +196 -58
- package/dist/scan.test.js +60 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,13 +32,21 @@ the largest public scan of real MCP servers to date.
|
|
|
32
32
|
|
|
33
33
|
- **SSRF** (the headline check): a tool handler (`server.tool(...)` /
|
|
34
34
|
`setRequestHandler(...)`) that passes a caller-controlled value into
|
|
35
|
-
`fetch`/`axios`/`http.request`/`https.request`/`got
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
`fetch`/`axios`/`http.request`/`https.request`/`got` *as the host* — a
|
|
36
|
+
caller value that only fills a path segment under a fixed or configured base
|
|
37
|
+
URL is not reported — with no validation-looking identifier (`allowlist`,
|
|
38
|
+
`isPrivateIP`, `ssrf`, ...) anywhere nearby.
|
|
38
39
|
- **Hardcoded secrets** — Stripe/AWS/GitHub/Google key patterns, and a
|
|
39
|
-
generic `password`/`apiKey`/`token` = "literal string" check
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
generic `password`/`apiKey`/`token` = "literal string" check (placeholders,
|
|
41
|
+
env var names and values without a mix of letters and digits are ignored).
|
|
42
|
+
- **exec/Function injection** — `child_process.exec()`/`execSync()` or
|
|
43
|
+
`new Function()` called with an interpolated string, the shape a
|
|
44
|
+
command-injection bug usually has. `db.exec()`, `RegExp.exec()` and values
|
|
45
|
+
wrapped in a shell-escape helper are not reported.
|
|
46
|
+
|
|
47
|
+
Test, e2e, script, example and docs directories are skipped by default (run
|
|
48
|
+
with `--include-tests` to scan them too). On a 2026-10-19 scan of 36 public MCP
|
|
49
|
+
servers this cut 482 findings to 28.
|
|
42
50
|
|
|
43
51
|
## What it is not
|
|
44
52
|
|
package/dist/cli.js
CHANGED
|
@@ -26,13 +26,16 @@ What it checks:
|
|
|
26
26
|
- SSRF: a tool handler making an outbound request with a caller-controlled
|
|
27
27
|
URL and no nearby host/IP validation
|
|
28
28
|
- Hardcoded secrets (Stripe/AWS/GitHub/Google keys, generic credential strings)
|
|
29
|
-
- exec()/Function() calls
|
|
29
|
+
- child_process exec()/new Function() calls with an interpolated string
|
|
30
|
+
(possible command injection)
|
|
30
31
|
|
|
31
32
|
This is a heuristic, regex-based scanner, not a full AST analyzer — it can
|
|
32
33
|
miss real issues and flag things that are actually fine. Treat every HIGH
|
|
33
34
|
finding as "go read this function," not as a confirmed bug.
|
|
34
35
|
|
|
35
36
|
Options:
|
|
37
|
+
--include-tests Also scan test, e2e, script, example and docs directories
|
|
38
|
+
(skipped by default — they are the main source of noise)
|
|
36
39
|
--json Print findings as JSON instead of the human-readable list
|
|
37
40
|
--sarif Print findings as SARIF 2.1.0 (GitHub/GitLab code scanning,
|
|
38
41
|
VS Code); rules carry OWASP MCP Top 10 tags where one fits
|
|
@@ -126,7 +129,7 @@ async function main() {
|
|
|
126
129
|
const target = args.find((a, i) => !a.startsWith("-") && !valueFlags.has(args[i - 1])) ?? ".";
|
|
127
130
|
const asJson = args.includes("--json");
|
|
128
131
|
const asSarif = args.includes("--sarif");
|
|
129
|
-
const findings = scanDirectory(target);
|
|
132
|
+
const findings = scanDirectory(target, { includeTests: args.includes("--include-tests") });
|
|
130
133
|
const hasHigh = findings.some((f) => f.severity === "high");
|
|
131
134
|
if (asSarif) {
|
|
132
135
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
package/dist/scan.d.ts
CHANGED
|
@@ -8,4 +8,6 @@ export interface Finding {
|
|
|
8
8
|
snippet: string;
|
|
9
9
|
}
|
|
10
10
|
export declare function scanFile(filePath: string, content: string): Finding[];
|
|
11
|
-
export declare function scanDirectory(root: string
|
|
11
|
+
export declare function scanDirectory(root: string, options?: {
|
|
12
|
+
includeTests?: boolean;
|
|
13
|
+
}): Finding[];
|
package/dist/scan.js
CHANGED
|
@@ -14,9 +14,30 @@ const SKIP_DIRS = new Set([
|
|
|
14
14
|
"coverage",
|
|
15
15
|
".next",
|
|
16
16
|
"out",
|
|
17
|
+
"vendor",
|
|
18
|
+
".turbo",
|
|
17
19
|
]);
|
|
20
|
+
// Directories and file suffixes that hold tests, scripts, demos and docs. A
|
|
21
|
+
// scan of 36 public MCP servers (2026-10-19) showed these produce most of the
|
|
22
|
+
// noise: e2e helpers named `exec`, fixture credentials, benchmark scripts.
|
|
23
|
+
// Skipped by default, scanned with --include-tests.
|
|
24
|
+
const TEST_DIRS = new Set([
|
|
25
|
+
"test",
|
|
26
|
+
"tests",
|
|
27
|
+
"__tests__",
|
|
28
|
+
"__mocks__",
|
|
29
|
+
"e2e",
|
|
30
|
+
"scripts",
|
|
31
|
+
"examples",
|
|
32
|
+
"example",
|
|
33
|
+
"docs",
|
|
34
|
+
"fixtures",
|
|
35
|
+
"bench",
|
|
36
|
+
"benchmarks",
|
|
37
|
+
]);
|
|
38
|
+
const TEST_FILE = /\.(test|spec|e2e|unit|story|stories|mock)\.[tj]sx?$/;
|
|
18
39
|
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
19
|
-
function walk(dir, out = []) {
|
|
40
|
+
function walk(dir, includeTests, out = []) {
|
|
20
41
|
let entries;
|
|
21
42
|
try {
|
|
22
43
|
entries = readdirSync(dir);
|
|
@@ -27,6 +48,8 @@ function walk(dir, out = []) {
|
|
|
27
48
|
for (const entry of entries) {
|
|
28
49
|
if (SKIP_DIRS.has(entry))
|
|
29
50
|
continue;
|
|
51
|
+
if (!includeTests && TEST_DIRS.has(entry))
|
|
52
|
+
continue;
|
|
30
53
|
const full = join(dir, entry);
|
|
31
54
|
let st;
|
|
32
55
|
try {
|
|
@@ -36,11 +59,11 @@ function walk(dir, out = []) {
|
|
|
36
59
|
continue;
|
|
37
60
|
}
|
|
38
61
|
if (st.isDirectory()) {
|
|
39
|
-
walk(full, out);
|
|
62
|
+
walk(full, includeTests, out);
|
|
40
63
|
}
|
|
41
64
|
else if (SOURCE_EXT.has(entry.slice(entry.lastIndexOf(".")))) {
|
|
42
|
-
if (
|
|
43
|
-
continue;
|
|
65
|
+
if (!includeTests && TEST_FILE.test(entry))
|
|
66
|
+
continue;
|
|
44
67
|
out.push(full);
|
|
45
68
|
}
|
|
46
69
|
}
|
|
@@ -60,12 +83,32 @@ const SECRET_PATTERNS = [
|
|
|
60
83
|
{ re: /\bAKIA[0-9A-Z]{16}\b/, label: "an AWS access key ID" },
|
|
61
84
|
{ re: /\bghp_[A-Za-z0-9]{36}\b/, label: "a GitHub personal access token" },
|
|
62
85
|
{ re: /\bAIza[0-9A-Za-z\-_]{35}\b/, label: "a Google API key" },
|
|
63
|
-
{
|
|
64
|
-
re: /\b(api[_-]?key|apikey|secret|password|token)\s*[:=]\s*["'][^"'\s]{10,}["']/i,
|
|
65
|
-
label: "a hardcoded credential-looking string",
|
|
66
|
-
},
|
|
67
86
|
];
|
|
68
|
-
|
|
87
|
+
// The generic "name = 'value'" shape is where most secret false positives came
|
|
88
|
+
// from (UI labels, env var names, placeholders), so the value must look real.
|
|
89
|
+
const GENERIC_SECRET = /\b(api[_-]?key|apikey|secret|password|token)\s*[:=]\s*["']([^"'\s]{16,})["']/i;
|
|
90
|
+
const PLACEHOLDER = /(your|example|placeholder|dummy|changeme|xxxx|test|sample|fake|redacted|<|>|\*\*\*)/i;
|
|
91
|
+
function looksLikeRealSecret(value) {
|
|
92
|
+
if (!/\d/.test(value) || !/[A-Za-z]/.test(value))
|
|
93
|
+
return false;
|
|
94
|
+
if (PLACEHOLDER.test(value))
|
|
95
|
+
return false;
|
|
96
|
+
if (/^[A-Z0-9_]+$/.test(value))
|
|
97
|
+
return false; // an env var name, not a value
|
|
98
|
+
if (/^(https?:|\.{0,2}\/)/.test(value))
|
|
99
|
+
return false; // URL or path
|
|
100
|
+
if (/\$\{|process\.env/.test(value))
|
|
101
|
+
return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
// Real shell execution only: child_process exec/execSync (bare name needs the
|
|
105
|
+
// file to import child_process, so db.exec()/regex.exec() never match) and
|
|
106
|
+
// `new Function(`.
|
|
107
|
+
const CP_IMPORT = /\bchild_process\b/;
|
|
108
|
+
const EXEC_BARE = /(?<![.\w$])(exec|execSync)\s*\(/g;
|
|
109
|
+
const EXEC_CP = /\b(?:child_process|childProcess|cp)\s*\.\s*(?:exec|execSync)\s*\(/g;
|
|
110
|
+
const NEW_FUNCTION = /new Function\s*\(/g;
|
|
111
|
+
const SHELL_QUOTE = /\b(shellEscape|escapeShell|shellQuote|quote|shQuote)\s*\(/;
|
|
69
112
|
function extractParamNames(block) {
|
|
70
113
|
// Very rough: pull identifier names out of the first destructured
|
|
71
114
|
// parameter of the handler function, e.g. async ({ url, target }) => or
|
|
@@ -94,60 +137,130 @@ function lineAt(content, index) {
|
|
|
94
137
|
line++;
|
|
95
138
|
return line;
|
|
96
139
|
}
|
|
140
|
+
// True when `index` sits inside a quoted string or a comment on its line — a
|
|
141
|
+
// "fetch (" inside a .describe('...to fetch (from ...)') is not a network call
|
|
142
|
+
// (real false positive from a public MCP server, 2026-10-19).
|
|
143
|
+
function inStringOrComment(content, index) {
|
|
144
|
+
const lineStart = content.lastIndexOf("\n", index - 1) + 1;
|
|
145
|
+
const before = content.slice(lineStart, index);
|
|
146
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(before))
|
|
147
|
+
return true;
|
|
148
|
+
if (/(^|[^:"'`])\/\//.test(before))
|
|
149
|
+
return true;
|
|
150
|
+
for (const q of ["'", '"', "`"]) {
|
|
151
|
+
const count = (before.match(new RegExp(`(?<!\\\\)${q}`, "g")) ?? []).length;
|
|
152
|
+
if (count % 2 === 1)
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// The text of a call's first argument, from just after its "(" to the first
|
|
158
|
+
// top-level comma or the closing ")".
|
|
159
|
+
function firstArgument(content, afterParen) {
|
|
160
|
+
let depth = 0;
|
|
161
|
+
let quote = "";
|
|
162
|
+
const limit = Math.min(content.length, afterParen + 600);
|
|
163
|
+
for (let i = afterParen; i < limit; i++) {
|
|
164
|
+
const c = content[i];
|
|
165
|
+
if (quote) {
|
|
166
|
+
if (c === "\\")
|
|
167
|
+
i++;
|
|
168
|
+
else if (c === quote)
|
|
169
|
+
quote = "";
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (c === "'" || c === '"' || c === "`")
|
|
173
|
+
quote = c;
|
|
174
|
+
else if (c === "(" || c === "[" || c === "{")
|
|
175
|
+
depth++;
|
|
176
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
177
|
+
if (depth === 0)
|
|
178
|
+
return content.slice(afterParen, i);
|
|
179
|
+
depth--;
|
|
180
|
+
}
|
|
181
|
+
else if (c === "," && depth === 0)
|
|
182
|
+
return content.slice(afterParen, i);
|
|
183
|
+
}
|
|
184
|
+
return content.slice(afterParen, limit);
|
|
185
|
+
}
|
|
186
|
+
function paramRegex(p) {
|
|
187
|
+
// `p` as a bare identifier, or as a property of the handler's input object.
|
|
188
|
+
return new RegExp(`(?:(?<![\\w$.])|(?<=\\b(?:args|input|params|req|request)\\.))${p}\\b`);
|
|
189
|
+
}
|
|
190
|
+
// Does this URL expression let the caller choose the *host*? A caller value
|
|
191
|
+
// that only fills a path or query segment under a fixed or configured base
|
|
192
|
+
// (`${config.baseUrl}/jobs/${encodeURIComponent(id)}`) is not SSRF.
|
|
193
|
+
function callerControlsHost(arg, params) {
|
|
194
|
+
const a = arg.trim();
|
|
195
|
+
if (!params.some((p) => paramRegex(p).test(a)))
|
|
196
|
+
return false;
|
|
197
|
+
// Fixed scheme+host or a relative path up front: host is not caller-chosen.
|
|
198
|
+
if (/^["'`]\s*(https?:\/\/|\/)/.test(a) && !/^["'`]\$\{/.test(a))
|
|
199
|
+
return false;
|
|
200
|
+
// Template literal that opens with something that is not a param (a
|
|
201
|
+
// configured base URL): the param only lands in the path.
|
|
202
|
+
const tpl = a.match(/^`\$\{([^}]+)\}/);
|
|
203
|
+
if (tpl && !params.some((p) => paramRegex(p).test(tpl[1])))
|
|
204
|
+
return false;
|
|
205
|
+
// `base + something` where base is not a param.
|
|
206
|
+
const concat = a.match(/^([A-Za-z_$][\w$.]*)\s*\+/);
|
|
207
|
+
if (concat && !params.some((p) => paramRegex(p).test(concat[1])))
|
|
208
|
+
return false;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
97
211
|
export function scanFile(filePath, content) {
|
|
98
212
|
const findings = [];
|
|
213
|
+
const lines = content.split("\n");
|
|
99
214
|
// --- SSRF: look inside each tool-handler block for a network call whose
|
|
100
|
-
//
|
|
101
|
-
// validation keyword.
|
|
215
|
+
// URL argument lets the caller choose the host, with no nearby validation.
|
|
102
216
|
let searchFrom = 0;
|
|
103
217
|
while (true) {
|
|
104
218
|
const startMatch = TOOL_HANDLER_START.exec(content.slice(searchFrom));
|
|
105
219
|
if (!startMatch)
|
|
106
220
|
break;
|
|
107
221
|
const absoluteStart = searchFrom + startMatch.index;
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
222
|
+
// Bounded window after the match as "the handler body" — a real
|
|
223
|
+
// brace-matcher would be more precise, but a fixed window keeps this
|
|
224
|
+
// dependency-free. It stops at the next handler so one handler's code is
|
|
225
|
+
// never judged against another's parameters.
|
|
226
|
+
const rest = content.slice(absoluteStart + 1, absoluteStart + 4000);
|
|
227
|
+
const next = TOOL_HANDLER_START.exec(rest);
|
|
228
|
+
const block = content.slice(absoluteStart, absoluteStart + 1 + (next ? next.index : rest.length));
|
|
112
229
|
const paramNames = extractParamNames(block);
|
|
113
|
-
const netMatch
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
}
|
|
230
|
+
for (const netMatch of block.matchAll(new RegExp(NETWORK_CALL.source, "g"))) {
|
|
231
|
+
const callAbs = absoluteStart + netMatch.index;
|
|
232
|
+
if (inStringOrComment(content, callAbs))
|
|
233
|
+
continue;
|
|
234
|
+
const arg = firstArgument(content, callAbs + netMatch[0].length);
|
|
235
|
+
if (!callerControlsHost(arg, paramNames))
|
|
236
|
+
continue;
|
|
237
|
+
const nearbyValidation = VALIDATION_SIGNAL.test(block);
|
|
238
|
+
const line = lineAt(content, callAbs);
|
|
239
|
+
const snippet = lines[line - 1]?.trim().slice(0, 160) ?? "";
|
|
240
|
+
if (!nearbyValidation) {
|
|
241
|
+
findings.push({
|
|
242
|
+
file: filePath,
|
|
243
|
+
line,
|
|
244
|
+
severity: "high",
|
|
245
|
+
rule: "ssrf",
|
|
246
|
+
message: "Outbound network call inside a tool handler appears to use a caller-controlled URL with no nearby host/IP validation — possible SSRF. A tool argument can reach your server's internal network (cloud metadata endpoint, localhost services, private IPs) unless the destination is checked first.",
|
|
247
|
+
snippet,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
findings.push({
|
|
252
|
+
file: filePath,
|
|
253
|
+
line,
|
|
254
|
+
severity: "info",
|
|
255
|
+
rule: "ssrf",
|
|
256
|
+
message: "Outbound network call uses a caller-controlled URL, but a validation-looking identifier (allowlist/blocklist/isPrivate/...) was found nearby — looks handled, verify it actually runs before the request, not after.",
|
|
257
|
+
snippet,
|
|
258
|
+
});
|
|
144
259
|
}
|
|
145
|
-
void isLiteralOnly; // literal-only calls are fine, nothing to report
|
|
146
260
|
}
|
|
147
261
|
searchFrom = absoluteStart + Math.max(startMatch[0].length, 1);
|
|
148
262
|
}
|
|
149
263
|
// --- Secrets
|
|
150
|
-
const lines = content.split("\n");
|
|
151
264
|
lines.forEach((text, i) => {
|
|
152
265
|
for (const { re, label } of SECRET_PATTERNS) {
|
|
153
266
|
if (re.test(text)) {
|
|
@@ -159,27 +272,52 @@ export function scanFile(filePath, content) {
|
|
|
159
272
|
message: `Line looks like it contains ${label} committed directly in source.`,
|
|
160
273
|
snippet: text.trim().slice(0, 160),
|
|
161
274
|
});
|
|
162
|
-
|
|
275
|
+
return;
|
|
163
276
|
}
|
|
164
277
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
lines.forEach((text, i) => {
|
|
168
|
-
if (EXEC_CALL.test(text) && /\$\{|`.*\$\{/.test(text + (lines[i + 1] ?? ""))) {
|
|
278
|
+
const generic = GENERIC_SECRET.exec(text);
|
|
279
|
+
if (generic && looksLikeRealSecret(generic[2])) {
|
|
169
280
|
findings.push({
|
|
170
281
|
file: filePath,
|
|
171
282
|
line: i + 1,
|
|
172
|
-
severity: "
|
|
173
|
-
rule: "
|
|
174
|
-
message: "
|
|
283
|
+
severity: "medium",
|
|
284
|
+
rule: "secret",
|
|
285
|
+
message: "Line looks like it contains a hardcoded credential-looking string committed directly in source.",
|
|
175
286
|
snippet: text.trim().slice(0, 160),
|
|
176
287
|
});
|
|
177
288
|
}
|
|
178
289
|
});
|
|
290
|
+
// --- exec/eval with an interpolated string (possible command injection)
|
|
291
|
+
const importsChildProcess = CP_IMPORT.test(content);
|
|
292
|
+
const execStarts = [];
|
|
293
|
+
const patterns = importsChildProcess ? [EXEC_BARE, EXEC_CP, NEW_FUNCTION] : [EXEC_CP, NEW_FUNCTION];
|
|
294
|
+
for (const re of patterns) {
|
|
295
|
+
for (const m of content.matchAll(re)) {
|
|
296
|
+
execStarts.push({ index: m.index, callEnd: m.index + m[0].length });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const { index, callEnd } of execStarts) {
|
|
300
|
+
if (inStringOrComment(content, index))
|
|
301
|
+
continue;
|
|
302
|
+
const arg = firstArgument(content, callEnd);
|
|
303
|
+
if (!/\$\{|["'`]\s*\+\s*[A-Za-z_$]|[A-Za-z_$][\w$.]*\s*\+\s*["'`]/.test(arg))
|
|
304
|
+
continue;
|
|
305
|
+
if (SHELL_QUOTE.test(arg))
|
|
306
|
+
continue; // interpolated values are shell-escaped
|
|
307
|
+
const line = lineAt(content, index);
|
|
308
|
+
findings.push({
|
|
309
|
+
file: filePath,
|
|
310
|
+
line,
|
|
311
|
+
severity: "high",
|
|
312
|
+
rule: "exec-injection",
|
|
313
|
+
message: "exec()/Function() call with an interpolated string — if any part of that string comes from tool input, this is a command/code-injection risk, not just a style issue.",
|
|
314
|
+
snippet: lines[line - 1]?.trim().slice(0, 160) ?? "",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
179
317
|
return findings;
|
|
180
318
|
}
|
|
181
|
-
export function scanDirectory(root) {
|
|
182
|
-
const files = walk(root);
|
|
319
|
+
export function scanDirectory(root, options = {}) {
|
|
320
|
+
const files = walk(root, options.includeTests ?? false);
|
|
183
321
|
const findings = [];
|
|
184
322
|
for (const file of files) {
|
|
185
323
|
let content;
|
package/dist/scan.test.js
CHANGED
|
@@ -39,7 +39,66 @@ test("flags a hardcoded Stripe live key as a secret", () => {
|
|
|
39
39
|
assert.ok(findings.some((f) => f.rule === "secret"));
|
|
40
40
|
});
|
|
41
41
|
test("flags exec() next to a template literal as possible injection", () => {
|
|
42
|
-
const src = `exec(\`ls \${userInput}\`);`;
|
|
42
|
+
const src = `import { exec } from "node:child_process";\nexec(\`ls \${userInput}\`);`;
|
|
43
43
|
const findings = scanFile("run.ts", src);
|
|
44
44
|
assert.ok(findings.some((f) => f.rule === "exec-injection"));
|
|
45
45
|
});
|
|
46
|
+
// --- False positives seen in a scan of 36 public MCP servers (2026-10-19) ---
|
|
47
|
+
test("does not flag a caller value used only in the path under a configured base URL", () => {
|
|
48
|
+
const src = `
|
|
49
|
+
server.tool("get_job", "desc", { jobId: z.string() }, async ({ jobId }) => {
|
|
50
|
+
const res = await fetch(\`\${gatewayUrl}/api/jobs/\${encodeURIComponent(jobId)}\`);
|
|
51
|
+
return res.text();
|
|
52
|
+
});`;
|
|
53
|
+
assert.equal(scanFile("server.ts", src).filter((f) => f.rule === "ssrf").length, 0);
|
|
54
|
+
});
|
|
55
|
+
test("does not flag the word 'fetch (' inside a string or comment", () => {
|
|
56
|
+
const src = `
|
|
57
|
+
server.tool("memory", "desc", { id: z.number() }, async ({ id }) => {
|
|
58
|
+
const schema = z.number().describe('Decision id to fetch (from an index_only query).');
|
|
59
|
+
// fetch (id) later
|
|
60
|
+
return id;
|
|
61
|
+
});`;
|
|
62
|
+
assert.equal(scanFile("server.ts", src).filter((f) => f.rule === "ssrf").length, 0);
|
|
63
|
+
});
|
|
64
|
+
test("still flags a template URL that starts with the caller's value", () => {
|
|
65
|
+
const src = `
|
|
66
|
+
server.tool("fetch_url", "desc", { url: z.string() }, async ({ url }) => {
|
|
67
|
+
const res = await fetch(\`\${url}/robots.txt\`);
|
|
68
|
+
return res.text();
|
|
69
|
+
});`;
|
|
70
|
+
assert.equal(scanFile("server.ts", src).filter((f) => f.rule === "ssrf" && f.severity === "high").length, 1);
|
|
71
|
+
});
|
|
72
|
+
test("does not judge one handler's fetch against another handler's parameters", () => {
|
|
73
|
+
const src = `
|
|
74
|
+
server.tool("a", "desc", { url: z.string() }, async ({ url }) => { return url; });
|
|
75
|
+
server.tool("b", "desc", {}, async () => { const url = "https://example.com"; return fetch(url); });`;
|
|
76
|
+
assert.equal(scanFile("server.ts", src).filter((f) => f.rule === "ssrf").length, 0);
|
|
77
|
+
});
|
|
78
|
+
test("does not flag SQLite db.exec or RegExp.exec as command injection", () => {
|
|
79
|
+
const src = `
|
|
80
|
+
import { DatabaseSync } from "node:sqlite";
|
|
81
|
+
db.exec(\`DROP TABLE IF EXISTS \${table}\`);
|
|
82
|
+
const m = new RegExp(\`\${name}=(\\\\w+)\`).exec(attrs);`;
|
|
83
|
+
assert.equal(scanFile("store.ts", src).filter((f) => f.rule === "exec-injection").length, 0);
|
|
84
|
+
});
|
|
85
|
+
test("does not flag a bare exec() when the file never imports child_process", () => {
|
|
86
|
+
const src = "exec(`ls ${dir}`);";
|
|
87
|
+
assert.equal(scanFile("helper.ts", src).filter((f) => f.rule === "exec-injection").length, 0);
|
|
88
|
+
});
|
|
89
|
+
test("does not flag exec when the interpolated value is shell-escaped", () => {
|
|
90
|
+
const src = `import { execSync } from "child_process";\nexecSync(\`ls \${shellEscape(dir)}\`);`;
|
|
91
|
+
assert.equal(scanFile("run.ts", src).filter((f) => f.rule === "exec-injection").length, 0);
|
|
92
|
+
});
|
|
93
|
+
test("ignores placeholder and env-var-name values in the generic secret rule", () => {
|
|
94
|
+
const src = [
|
|
95
|
+
'const apiKey = "your-api-key-goes-here-123";',
|
|
96
|
+
'const token = "GITHUB_PERSONAL_ACCESS_TOKEN";',
|
|
97
|
+
'const password = "labelOnlyNoDigitsHereAtAll";',
|
|
98
|
+
].join("\n");
|
|
99
|
+
assert.equal(scanFile("config.ts", src).filter((f) => f.rule === "secret").length, 0);
|
|
100
|
+
});
|
|
101
|
+
test("still flags a realistic hardcoded credential", () => {
|
|
102
|
+
const src = 'const apiKey = "a8f3k29dLq0x7Zm41PqW";';
|
|
103
|
+
assert.equal(scanFile("config.ts", src).filter((f) => f.rule === "secret").length, 1);
|
|
104
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sms-florin/mcp-ssrf-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A static scanner for MCP servers that catches the exact bug class behind most real MCP SSRF reports: a tool handler making an outbound request to a caller-controlled URL with no host validation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|