@sms-florin/mcp-ssrf-scan 0.0.0-stage → 0.2.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 +91 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +149 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scan.d.ts +11 -0
- package/dist/scan.js +197 -0
- package/dist/scan.test.d.ts +1 -0
- package/dist/scan.test.js +45 -0
- package/package.json +35 -4
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,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcp-ssrf-scan
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
`--json` prints findings as JSON instead of the human-readable list.
|
|
61
|
+
|
|
62
|
+
## mcp-ssrf-scan Cloud (optional, paid)
|
|
63
|
+
|
|
64
|
+
The CLI above is free and stays free. [mcp-ssrf-scan Cloud](https://mcpscan.flo-voice1.com)
|
|
65
|
+
($15/month, up to 10 repos) adds what a CI gate alone can't: every run is
|
|
66
|
+
uploaded and diffed against the previous scan of that repo, so the CLI
|
|
67
|
+
prints exactly what's **new since the last scan** and what got **fixed**,
|
|
68
|
+
and every scan stays browsable on a dashboard for 90 days.
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
# GitHub Actions
|
|
72
|
+
- run: npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
|
|
73
|
+
env:
|
|
74
|
+
MCP_SSRF_SCAN_KEY: ${{ secrets.MCP_SSRF_SCAN_KEY }}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
# GitLab CI (MCP_SSRF_SCAN_KEY as a masked CI/CD variable)
|
|
79
|
+
mcp-ssrf-scan:
|
|
80
|
+
image: node:22
|
|
81
|
+
script:
|
|
82
|
+
- npx @sms-florin/mcp-ssrf-scan@latest ./src --upload
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Repo, branch and commit come from the CI environment (or `--repo owner/name`).
|
|
86
|
+
Only findings are uploaded — path, line, rule, message and the flagged line
|
|
87
|
+
— never your source. A failed upload only prints a warning; the exit code is
|
|
88
|
+
always decided by the findings alone.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { scanDirectory } from "./scan.js";
|
|
4
|
+
const COLOR = {
|
|
5
|
+
high: "\x1b[31m", // red
|
|
6
|
+
medium: "\x1b[33m", // yellow
|
|
7
|
+
info: "\x1b[36m", // cyan
|
|
8
|
+
reset: "\x1b[0m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
bold: "\x1b[1m",
|
|
11
|
+
};
|
|
12
|
+
function severityLabel(s) {
|
|
13
|
+
const colors = { high: COLOR.high, medium: COLOR.medium, info: COLOR.info };
|
|
14
|
+
return `${colors[s]}${s.toUpperCase()}${COLOR.reset}`;
|
|
15
|
+
}
|
|
16
|
+
function printHelp() {
|
|
17
|
+
console.log(`mcp-ssrf-scan — a static scanner for MCP server source code
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
npx @sms-florin/mcp-ssrf-scan [path] Scan a directory (default: current directory)
|
|
21
|
+
npx @sms-florin/mcp-ssrf-scan --help
|
|
22
|
+
|
|
23
|
+
What it checks:
|
|
24
|
+
- SSRF: a tool handler making an outbound request with a caller-controlled
|
|
25
|
+
URL and no nearby host/IP validation
|
|
26
|
+
- Hardcoded secrets (Stripe/AWS/GitHub/Google keys, generic credential strings)
|
|
27
|
+
- exec()/Function() calls next to a template literal (possible command injection)
|
|
28
|
+
|
|
29
|
+
This is a heuristic, regex-based scanner, not a full AST analyzer — it can
|
|
30
|
+
miss real issues and flag things that are actually fine. Treat every HIGH
|
|
31
|
+
finding as "go read this function," not as a confirmed bug.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--json Print findings as JSON instead of the human-readable list
|
|
35
|
+
--upload Upload this scan to mcp-ssrf-scan Cloud (needs MCP_SSRF_SCAN_KEY)
|
|
36
|
+
and print what's new/fixed since the last scan of this repo
|
|
37
|
+
--repo owner/name Repo name for --upload (auto-detected in GitHub Actions,
|
|
38
|
+
GitLab CI, or from the git remote)
|
|
39
|
+
|
|
40
|
+
Exit code is 1 if any HIGH finding is reported, 0 otherwise (so it's usable
|
|
41
|
+
as a CI gate: fail the build on HIGH, let MEDIUM/INFO through as warnings).
|
|
42
|
+
A failed --upload only prints a warning — it never changes the exit code.
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
function flagValue(args, name) {
|
|
46
|
+
const i = args.indexOf(name);
|
|
47
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
48
|
+
}
|
|
49
|
+
function git(...cmd) {
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync("git", cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// owner/name from GitHub Actions, GitLab CI, or the local git remote.
|
|
58
|
+
function detectRepo() {
|
|
59
|
+
const env = process.env;
|
|
60
|
+
if (env.GITHUB_REPOSITORY)
|
|
61
|
+
return { repo: env.GITHUB_REPOSITORY, ref: env.GITHUB_REF_NAME, commit: env.GITHUB_SHA };
|
|
62
|
+
if (env.CI_PROJECT_PATH)
|
|
63
|
+
return { repo: env.CI_PROJECT_PATH, ref: env.CI_COMMIT_REF_NAME, commit: env.CI_COMMIT_SHA };
|
|
64
|
+
const remote = git("config", "--get", "remote.origin.url");
|
|
65
|
+
const repo = remote?.replace(/\.git$/, "").match(/[:/]([^/:]+\/[^/]+)$/)?.[1];
|
|
66
|
+
return { repo, ref: git("rev-parse", "--abbrev-ref", "HEAD"), commit: git("rev-parse", "HEAD") };
|
|
67
|
+
}
|
|
68
|
+
// Uploads to mcp-ssrf-scan Cloud (paid, optional). A failed upload is a
|
|
69
|
+
// warning, never a build failure — the exit code stays purely about findings,
|
|
70
|
+
// so an outage on our side can't break someone's pipeline.
|
|
71
|
+
async function upload(findings, args) {
|
|
72
|
+
const key = process.env.MCP_SSRF_SCAN_KEY;
|
|
73
|
+
if (!key) {
|
|
74
|
+
console.error(`${COLOR.high}--upload needs MCP_SSRF_SCAN_KEY${COLOR.reset} (get one at https://mcpscan.flo-voice1.com) — skipping upload.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const detected = detectRepo();
|
|
78
|
+
const repo = flagValue(args, "--repo") ?? detected.repo;
|
|
79
|
+
if (!repo) {
|
|
80
|
+
console.error("Could not detect the repo name — pass --repo owner/name. Skipping upload.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const base = process.env.MCP_SSRF_SCAN_URL ?? "https://mcpscan.flo-voice1.com";
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${base}/api/scans`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ repo, ref: detected.ref, commit: detected.commit, findings }),
|
|
89
|
+
signal: AbortSignal.timeout(20_000),
|
|
90
|
+
});
|
|
91
|
+
const data = (await res.json());
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${data.error ?? `HTTP ${res.status}`}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`\n${COLOR.bold}Uploaded to mcp-ssrf-scan Cloud${COLOR.reset} — ${repo}`);
|
|
97
|
+
if (data.baseline) {
|
|
98
|
+
console.log("First scan of this repo: saved as the baseline, future runs show what changed.");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(`${data.newFindings.length} new since the last scan, ${data.fixedFindings.length} fixed.`);
|
|
102
|
+
for (const f of data.newFindings) {
|
|
103
|
+
console.log(` ${COLOR.bold}NEW${COLOR.reset} ${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
|
|
104
|
+
}
|
|
105
|
+
for (const f of data.fixedFindings) {
|
|
106
|
+
console.log(` ${COLOR.dim}FIXED ${f.severity.toUpperCase()} ${f.file}:${f.line} [${f.rule}]${COLOR.reset}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
console.log(`${COLOR.dim}${data.dashboardUrl}${COLOR.reset}`);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(`${COLOR.high}Upload failed:${COLOR.reset} ${error instanceof Error ? error.message : String(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function main() {
|
|
116
|
+
const args = process.argv.slice(2);
|
|
117
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
118
|
+
printHelp();
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
const valueFlags = new Set(["--repo"]);
|
|
122
|
+
const target = args.find((a, i) => !a.startsWith("-") && !valueFlags.has(args[i - 1])) ?? ".";
|
|
123
|
+
const asJson = args.includes("--json");
|
|
124
|
+
const findings = scanDirectory(target);
|
|
125
|
+
const hasHigh = findings.some((f) => f.severity === "high");
|
|
126
|
+
if (asJson) {
|
|
127
|
+
console.log(JSON.stringify({ target, findings }, null, 2));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
console.log(`${COLOR.bold}mcp-ssrf-scan${COLOR.reset} — scanning ${target}\n`);
|
|
131
|
+
if (findings.length === 0) {
|
|
132
|
+
console.log("No findings. (Heuristic scan — this is not a guarantee, see --help.)");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const order = ["high", "medium", "info"];
|
|
136
|
+
for (const f of order.flatMap((sev) => findings.filter((x) => x.severity === sev))) {
|
|
137
|
+
console.log(`${severityLabel(f.severity)} ${f.file}:${f.line} ${COLOR.dim}[${f.rule}]${COLOR.reset}`);
|
|
138
|
+
console.log(` ${f.message}`);
|
|
139
|
+
console.log(` ${COLOR.dim}${f.snippet}${COLOR.reset}\n`);
|
|
140
|
+
}
|
|
141
|
+
const counts = order.map((sev) => `${findings.filter((f) => f.severity === sev).length} ${sev}`);
|
|
142
|
+
console.log(`${COLOR.bold}${findings.length} finding(s)${COLOR.reset} — ${counts.join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (args.includes("--upload"))
|
|
146
|
+
await upload(findings, args);
|
|
147
|
+
process.exit(hasHigh ? 1 : 0);
|
|
148
|
+
}
|
|
149
|
+
main();
|
package/dist/index.d.ts
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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
|
+
}
|