@sms-florin/mcp-ssrf-scan 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florin Arsenie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # mcp-ssrf-scan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A static scanner for MCP (Model Context Protocol) servers, built around one
4
+ specific, common, real bug class: **a tool handler making an outbound
5
+ request to a URL the caller controls, with no check on where that URL
6
+ actually points.**
7
+
8
+ ```bash
9
+ npx @sms-florin/mcp-ssrf-scan ./src
10
+ ```
11
+
12
+ ## Why this exists
13
+
14
+ I found this exact bug in production, on my own server, before I ever
15
+ thought about building a scanner for it. [sms-florin](https://flo-voice1.com)
16
+ is a real phone-number-rental API I run — a GOIP webhook route on it
17
+ forwards SMS content from hardware gateways, and an early version of that
18
+ route made an outbound call using a value that, with the wrong input, could
19
+ have reached internal infrastructure instead of the intended destination.
20
+ It got caught and fixed before it mattered, but it's the kind of bug that
21
+ doesn't announce itself — the code runs, the tests pass, nothing looks wrong
22
+ until someone points it at `169.254.169.254` or `localhost:6379`.
23
+
24
+ MCP tool handlers are the same shape, at scale: a function that takes
25
+ caller-supplied arguments and, somewhere inside, makes a network call.
26
+ [BlueRock scanned 8,000+ public MCP servers and found 36.7% have unbounded
27
+ URI fetch / SSRF flaws](https://www.bluerock.io/post/private-repo-scanning-mcp-servers-secure-by-default)
28
+ — this isn't a theoretical category, it's the single most common finding in
29
+ the largest public scan of real MCP servers to date.
30
+
31
+ ## What it checks
32
+
33
+ - **SSRF** (the headline check): a tool handler (`server.tool(...)` /
34
+ `setRequestHandler(...)`) that passes a caller-controlled value into
35
+ `fetch`/`axios`/`http.request`/`https.request`/`got`, with no
36
+ validation-looking identifier (`allowlist`, `isPrivateIP`, `ssrf`, ...)
37
+ anywhere nearby.
38
+ - **Hardcoded secrets** — Stripe/AWS/GitHub/Google key patterns, and a
39
+ generic `password`/`apiKey`/`token` = "literal string" check.
40
+ - **exec/Function injection** — `exec()`/`new Function()` next to a
41
+ template literal, the shape a command-injection bug usually has.
42
+
43
+ ## What it is not
44
+
45
+ This is a regex-based heuristic scanner, not a real AST analyzer or a
46
+ dynamic test. It has false positives (a validated call it doesn't recognize
47
+ as validated) and false negatives (a vulnerable call it doesn't recognize as
48
+ vulnerable, especially across files or through an abstraction layer). Treat
49
+ a `HIGH` finding as "go read this function," not as a confirmed bug — and
50
+ don't assume a clean scan means a server is safe.
51
+
52
+ ## CI usage
53
+
54
+ Exit code is `1` if any `HIGH` finding is reported, `0` otherwise:
55
+
56
+ ```yaml
57
+ - run: npx @sms-florin/mcp-ssrf-scan ./src
58
+ ```
59
+
60
+ ## License
61
+
62
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { scanDirectory } from "./scan.js";
3
+ const COLOR = {
4
+ high: "\x1b[31m", // red
5
+ medium: "\x1b[33m", // yellow
6
+ info: "\x1b[36m", // cyan
7
+ reset: "\x1b[0m",
8
+ dim: "\x1b[2m",
9
+ bold: "\x1b[1m",
10
+ };
11
+ function severityLabel(s) {
12
+ const colors = { high: COLOR.high, medium: COLOR.medium, info: COLOR.info };
13
+ return `${colors[s]}${s.toUpperCase()}${COLOR.reset}`;
14
+ }
15
+ function printHelp() {
16
+ console.log(`mcp-ssrf-scan — a static scanner for MCP server source code
17
+
18
+ Usage:
19
+ npx @sms-florin/mcp-ssrf-scan [path] Scan a directory (default: current directory)
20
+ npx @sms-florin/mcp-ssrf-scan --help
21
+
22
+ What it checks:
23
+ - SSRF: a tool handler making an outbound request with a caller-controlled
24
+ URL and no nearby host/IP validation
25
+ - Hardcoded secrets (Stripe/AWS/GitHub/Google keys, generic credential strings)
26
+ - exec()/Function() calls next to a template literal (possible command injection)
27
+
28
+ This is a heuristic, regex-based scanner, not a full AST analyzer — it can
29
+ miss real issues and flag things that are actually fine. Treat every HIGH
30
+ finding as "go read this function," not as a confirmed bug.
31
+
32
+ Exit code is 1 if any HIGH finding is reported, 0 otherwise (so it's usable
33
+ as a CI gate: fail the build on HIGH, let MEDIUM/INFO through as warnings).
34
+ `);
35
+ }
36
+ function main() {
37
+ const args = process.argv.slice(2);
38
+ if (args.includes("--help") || args.includes("-h")) {
39
+ printHelp();
40
+ process.exit(0);
41
+ }
42
+ const target = args.find((a) => !a.startsWith("-")) ?? ".";
43
+ console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
44
+ const findings = scanDirectory(target);
45
+ if (findings.length === 0) {
46
+ console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
47
+ process.exit(0);
48
+ }
49
+ const order = ["high", "medium", "info"];
50
+ const grouped = order.flatMap((sev) => findings.filter((f) => f.severity === sev));
51
+ for (const f of grouped) {
52
+ console.log(`${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
53
+ console.log(` ${f.message}`);
54
+ console.log(` ${COLOR.dim}${f.snippet}${COLOR.reset}\n`);
55
+ }
56
+ const counts = order.map((sev) => `${findings.filter((f) => f.severity === sev).length} ${sev}`);
57
+ console.log(`${COLOR.bold}${findings.length} finding(s)${COLOR.reset} — ${counts.join(", ")}`);
58
+ const hasHigh = findings.some((f) => f.severity === "high");
59
+ process.exit(hasHigh ? 1 : 0);
60
+ }
61
+ main();
@@ -0,0 +1,2 @@
1
+ export { scanDirectory, scanFile } from "./scan.js";
2
+ export type { Finding, Severity } from "./scan.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { scanDirectory, scanFile } from "./scan.js";
package/dist/scan.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type Severity = "high" | "medium" | "info";
2
+ export interface Finding {
3
+ file: string;
4
+ line: number;
5
+ severity: Severity;
6
+ rule: "ssrf" | "secret" | "exec-injection";
7
+ message: string;
8
+ snippet: string;
9
+ }
10
+ export declare function scanFile(filePath: string, content: string): Finding[];
11
+ export declare function scanDirectory(root: string): Finding[];
package/dist/scan.js ADDED
@@ -0,0 +1,197 @@
1
+ // Static scanner for MCP (Model Context Protocol) servers. Zero dependencies,
2
+ // on purpose — this walks source files with plain regex heuristics instead of
3
+ // a real AST, which means it can be wrong in both directions (false positives
4
+ // on code that's actually fine, false negatives on cleverly-hidden issues).
5
+ // It is a pre-flight smoke test, not a guarantee. Read every HIGH finding
6
+ // yourself before trusting it either way.
7
+ import { readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ const SKIP_DIRS = new Set([
10
+ "node_modules",
11
+ "dist",
12
+ "build",
13
+ ".git",
14
+ "coverage",
15
+ ".next",
16
+ "out",
17
+ ]);
18
+ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
19
+ function walk(dir, out = []) {
20
+ let entries;
21
+ try {
22
+ entries = readdirSync(dir);
23
+ }
24
+ catch {
25
+ return out;
26
+ }
27
+ for (const entry of entries) {
28
+ if (SKIP_DIRS.has(entry))
29
+ continue;
30
+ const full = join(dir, entry);
31
+ let st;
32
+ try {
33
+ st = statSync(full);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (st.isDirectory()) {
39
+ walk(full, out);
40
+ }
41
+ else if (SOURCE_EXT.has(entry.slice(entry.lastIndexOf(".")))) {
42
+ if (/\.(test|spec)\.[tj]sx?$/.test(entry))
43
+ continue; // skip test files
44
+ out.push(full);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ // Calls that reach the network. Deliberately specific (not a bare "request(")
50
+ // to keep false positives down — MCP SDK code itself uses "request" as a
51
+ // name constantly (handleRequest, RequestSchema, ...) for unrelated things.
52
+ const NETWORK_CALL = /\b(fetch|axios(?:\.(?:get|post|put|patch|delete|request))?|got|undici\.request|http\.(?:get|request)|https\.(?:get|request))\s*\(/;
53
+ // MCP SDK tool-registration shapes this looks for, across the two common
54
+ // patterns (server.tool(...) helper, and the lower-level setRequestHandler).
55
+ const TOOL_HANDLER_START = /\b(?:server|mcpServer|this)\s*\.\s*tool\s*\(|setRequestHandler\s*\(/;
56
+ const VALIDATION_SIGNAL = /\b(isPrivate\w*|privateIP|is_private|ssrf|SSRF|blockedHost|blocklist|blockList|denylist|denyList|allowlist|allowList|isAllowedHost|validateUrl|sanitizeUrl|checkHost|resolveDns)\b/;
57
+ const SECRET_PATTERNS = [
58
+ { re: /\bsk_live_[A-Za-z0-9]{10,}/, label: "a live Stripe secret key" },
59
+ { re: /\bsk_test_[A-Za-z0-9]{10,}/, label: "a Stripe test secret key" },
60
+ { re: /\bAKIA[0-9A-Z]{16}\b/, label: "an AWS access key ID" },
61
+ { re: /\bghp_[A-Za-z0-9]{36}\b/, label: "a GitHub personal access token" },
62
+ { 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
+ ];
68
+ const EXEC_CALL = /\b(child_process\.)?exec(?:Sync)?\s*\(|new Function\s*\(/;
69
+ function extractParamNames(block) {
70
+ // Very rough: pull identifier names out of the first destructured
71
+ // parameter of the handler function, e.g. async ({ url, target }) => or
72
+ // function (args) { ... args.url ... }. Good enough to catch the common
73
+ // "the URL came from the tool's own input" shape without a real parser.
74
+ const names = new Set();
75
+ const destructureMatch = block.match(/\(\s*\{([^}]{0,300})\}/);
76
+ if (destructureMatch) {
77
+ for (const part of destructureMatch[1].split(",")) {
78
+ const name = part.trim().split(/[:=]/)[0].trim();
79
+ if (/^[a-zA-Z_$][\w$]*$/.test(name))
80
+ names.add(name);
81
+ }
82
+ }
83
+ // Also catch a plain single-arg name (args, input, params) used as
84
+ // args.url / input.url / params.url later in the block.
85
+ for (const m of block.matchAll(/\b(args|input|params|req|request)\s*\.\s*([a-zA-Z_$][\w$]*)/g)) {
86
+ names.add(m[2]);
87
+ }
88
+ return [...names];
89
+ }
90
+ function lineAt(content, index) {
91
+ let line = 1;
92
+ for (let i = 0; i < index; i++)
93
+ if (content[i] === "\n")
94
+ line++;
95
+ return line;
96
+ }
97
+ export function scanFile(filePath, content) {
98
+ const findings = [];
99
+ // --- SSRF: look inside each tool-handler block for a network call whose
100
+ // target looks like it came from the handler's own input, with no nearby
101
+ // validation keyword.
102
+ let searchFrom = 0;
103
+ while (true) {
104
+ const startMatch = TOOL_HANDLER_START.exec(content.slice(searchFrom));
105
+ if (!startMatch)
106
+ break;
107
+ const absoluteStart = searchFrom + startMatch.index;
108
+ // Capture a bounded window after the match as "the handler body" — a
109
+ // real brace-matcher would be more precise, but a fixed window keeps
110
+ // this dependency-free and is plenty for a single handler in practice.
111
+ const block = content.slice(absoluteStart, absoluteStart + 4000);
112
+ const paramNames = extractParamNames(block);
113
+ const netMatch = NETWORK_CALL.exec(block);
114
+ if (netMatch) {
115
+ const callLineAbs = absoluteStart + netMatch.index;
116
+ // Look at the network-call line plus the next 2 lines for the actual URL argument.
117
+ const argWindow = content.slice(callLineAbs, callLineAbs + 300);
118
+ const usesParam = paramNames.some((p) => new RegExp(`\\b${p}\\b`).test(argWindow));
119
+ const isLiteralOnly = /\(\s*["'`][^$]/.test(argWindow) && !usesParam;
120
+ if (usesParam) {
121
+ const nearbyValidation = VALIDATION_SIGNAL.test(block);
122
+ const line = lineAt(content, callLineAbs);
123
+ const snippet = content.split("\n")[line - 1]?.trim().slice(0, 160) ?? "";
124
+ if (!nearbyValidation) {
125
+ findings.push({
126
+ file: filePath,
127
+ line,
128
+ severity: "high",
129
+ rule: "ssrf",
130
+ message: "Outbound network call inside a tool handler appears to use a caller-controlled value 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.",
131
+ snippet,
132
+ });
133
+ }
134
+ else {
135
+ findings.push({
136
+ file: filePath,
137
+ line,
138
+ severity: "info",
139
+ rule: "ssrf",
140
+ message: "Outbound network call uses a caller-controlled value, but a validation-looking identifier (allowlist/blocklist/isPrivate/...) was found nearby — looks handled, verify it actually runs before the request, not after.",
141
+ snippet,
142
+ });
143
+ }
144
+ }
145
+ void isLiteralOnly; // literal-only calls are fine, nothing to report
146
+ }
147
+ searchFrom = absoluteStart + Math.max(startMatch[0].length, 1);
148
+ }
149
+ // --- Secrets
150
+ const lines = content.split("\n");
151
+ lines.forEach((text, i) => {
152
+ for (const { re, label } of SECRET_PATTERNS) {
153
+ if (re.test(text)) {
154
+ findings.push({
155
+ file: filePath,
156
+ line: i + 1,
157
+ severity: "medium",
158
+ rule: "secret",
159
+ message: `Line looks like it contains ${label} committed directly in source.`,
160
+ snippet: text.trim().slice(0, 160),
161
+ });
162
+ break;
163
+ }
164
+ }
165
+ });
166
+ // --- exec/eval with a nearby variable (possible command injection)
167
+ lines.forEach((text, i) => {
168
+ if (EXEC_CALL.test(text) && /\$\{|`.*\$\{/.test(text + (lines[i + 1] ?? ""))) {
169
+ findings.push({
170
+ file: filePath,
171
+ line: i + 1,
172
+ severity: "high",
173
+ rule: "exec-injection",
174
+ message: "exec()/Function() call next to a template literal — if any part of that string comes from tool input, this is a command/code-injection risk, not just a style issue.",
175
+ snippet: text.trim().slice(0, 160),
176
+ });
177
+ }
178
+ });
179
+ return findings;
180
+ }
181
+ export function scanDirectory(root) {
182
+ const files = walk(root);
183
+ const findings = [];
184
+ for (const file of files) {
185
+ let content;
186
+ try {
187
+ content = readFileSync(file, "utf8");
188
+ }
189
+ catch {
190
+ continue;
191
+ }
192
+ for (const f of scanFile(file, content)) {
193
+ findings.push({ ...f, file: relative(root, file) });
194
+ }
195
+ }
196
+ return findings;
197
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { scanFile } from "./scan.js";
4
+ test("flags an unvalidated fetch inside a tool handler as SSRF (high)", () => {
5
+ const src = `
6
+ server.tool("fetch_url", "desc", { url: z.string() }, async ({ url }) => {
7
+ const res = await fetch(url);
8
+ return res.text();
9
+ });`;
10
+ const findings = scanFile("server.ts", src);
11
+ const ssrf = findings.find((f) => f.rule === "ssrf");
12
+ assert.ok(ssrf, "expected an ssrf finding");
13
+ assert.equal(ssrf.severity, "high");
14
+ });
15
+ test("downgrades to info when a validation-looking identifier is nearby", () => {
16
+ const src = `
17
+ server.tool("fetch_url", "desc", { url: z.string() }, async ({ url }) => {
18
+ if (!isAllowedHost(url)) throw new Error("blocked");
19
+ const res = await fetch(url);
20
+ return res.text();
21
+ });`;
22
+ const findings = scanFile("server.ts", src);
23
+ const ssrf = findings.find((f) => f.rule === "ssrf");
24
+ assert.ok(ssrf, "expected an ssrf finding");
25
+ assert.equal(ssrf.severity, "info");
26
+ });
27
+ test("does not flag a handler whose fetch uses only a literal URL", () => {
28
+ const src = `
29
+ server.tool("ping", "desc", {}, async () => {
30
+ const res = await fetch("https://example.com/health");
31
+ return res.text();
32
+ });`;
33
+ const findings = scanFile("server.ts", src);
34
+ assert.equal(findings.filter((f) => f.rule === "ssrf").length, 0);
35
+ });
36
+ test("flags a hardcoded Stripe live key as a secret", () => {
37
+ const src = `const key = "sk_live_51AbCdEfGhIjKlMnOpQrStUv";`;
38
+ const findings = scanFile("config.ts", src);
39
+ assert.ok(findings.some((f) => f.rule === "secret"));
40
+ });
41
+ test("flags exec() next to a template literal as possible injection", () => {
42
+ const src = `exec(\`ls \${userInput}\`);`;
43
+ const findings = scanFile("run.ts", src);
44
+ assert.ok(findings.some((f) => f.rule === "exec-injection"));
45
+ });
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@sms-florin/mcp-ssrf-scan",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "mcp-ssrf-scan": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "test": "node --test dist/*.test.js"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "model-context-protocol",
21
+ "security",
22
+ "ssrf",
23
+ "static-analysis",
24
+ "ai-agents",
25
+ "scanner"
26
+ ],
27
+ "author": "Florin Arsenie",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://gitlab.com/florin-builds/mcp-ssrf-scan"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.9.3",
35
+ "@types/node": "^22.0.0"
36
+ }
37
+ }