@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 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`, with no
36
- validation-looking identifier (`allowlist`, `isPrivateIP`, `ssrf`, ...)
37
- anywhere nearby.
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
- - **exec/Function injection** — `exec()`/`new Function()` next to a
41
- template literal, the shape a command-injection bug usually has.
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 next to a template literal (possible command injection)
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): Finding[];
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 (/\.(test|spec)\.[tj]sx?$/.test(entry))
43
- continue; // skip test files
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
- const EXEC_CALL = /\b(child_process\.)?exec(?:Sync)?\s*\(|new Function\s*\(/;
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
- // target looks like it came from the handler's own input, with no nearby
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
- // 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);
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 = 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
- }
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
- break;
275
+ return;
163
276
  }
164
277
  }
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] ?? ""))) {
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: "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.",
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.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": {