mcpprobe-scanner 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,266 @@
1
+ """ERR-01 (verbose errors) and DOS-01 (bounded unbounded-consumption probes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from mcpprobe.checks.base import Check, ScanContext, evidence_of, evidence_summary
11
+ from mcpprobe.models import Confidence, Finding, Severity, Standards
12
+ from mcpprobe.transport import HttpTransport, RpcResponse, StdioTransport
13
+
14
+ # --------------------------------------------------------------------------- ERR-01
15
+ _STACK_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
16
+ ("python traceback", re.compile(r"Traceback \(most recent call last\)|File \"[^\"]+\.py\", line \d+")),
17
+ (
18
+ "javascript stack",
19
+ re.compile(r"\bat [\w.$<>\[\] ]+ \([^)]*\.(?:js|ts|mjs|cjs):\d+:\d+\)|node_modules[\\/]"),
20
+ ),
21
+ ("java stack", re.compile(r"\bat [\w.$]+\([\w$]+\.java:\d+\)")),
22
+ ("go panic", re.compile(r"\bgoroutine \d+ \[|\bpanic: |[\w/]+\.go:\d+")),
23
+ ("dotnet stack", re.compile(r"\bat [\w.`]+\([^)]*\) in [A-Za-z]:\\[^:]+:line \d+")),
24
+ (
25
+ "internal path",
26
+ re.compile(
27
+ r"(?<![\w:])(?:/home/\w+/|/Users/\w+/|/usr/(?:local|lib)/|/srv/|/var/www/|/opt/|[A-Z]:\\\\?(?:Users|inetpub)\\)"
28
+ ),
29
+ ),
30
+ ]
31
+ _FRAMEWORK_VERSION = re.compile(
32
+ r"\b(uvicorn|starlette|fastapi|express|werkzeug|flask|django|gunicorn|hypercorn|aiohttp|tornado|"
33
+ r"spring|rails|koa|fastify|hono|nestjs|axum|actix|gin|echo|nginx|apache|tomcat|jetty|kestrel)\b[/ ]v?(\d+\.\d+[\w.-]*)",
34
+ re.I,
35
+ )
36
+ _SERVER_HEADER_VERSION = re.compile(r"^[\w.-]+/\d+(?:\.\d+)+")
37
+
38
+
39
+ def inspect_error_body(text: str) -> list[str]:
40
+ """Return the categories of sensitive detail present in an error body."""
41
+ found = [label for label, pat in _STACK_PATTERNS if pat.search(text)]
42
+ if _FRAMEWORK_VERSION.search(text):
43
+ found.append("framework version")
44
+ return found
45
+
46
+
47
+ class Err01VerboseErrors(Check):
48
+ id = "ERR-01"
49
+ title = "Verbose error responses leak stack traces, paths or versions"
50
+ severity = Severity.MEDIUM
51
+ standards = Standards(mcp=["MCP08:2025"], llm_2025=["LLM02:2025"], llm_2026=["LLM02:2026"])
52
+ description = (
53
+ "Sends three malformed requests: truncated JSON, an unknown method and a request whose "
54
+ "params is a string instead of an object. Error bodies and the Server header are "
55
+ "inspected for stack traces, internal file paths and framework version strings."
56
+ )
57
+ evidence_type = "Each malformed request and the error response body."
58
+ false_positive_notes = (
59
+ "A Server header with a version is reported as Info. Framework names without versions "
60
+ "are not reported."
61
+ )
62
+ fix = (
63
+ "Return generic JSON-RPC errors (-32700/-32600/-32601/-32602) without 'data' containing "
64
+ "exceptions, log details server-side, and strip the Server header version at the proxy."
65
+ )
66
+
67
+ async def run(self, ctx: ScanContext) -> list[Finding]:
68
+ findings: list[Finding] = []
69
+ responses: list[RpcResponse] = []
70
+ t = ctx.transport
71
+ # 1. truncated JSON
72
+ bad_json = '{"jsonrpc": "2.0", "id": 9001, "method": '
73
+ if isinstance(t, HttpTransport):
74
+ responses.append(
75
+ await t.raw_request(
76
+ "POST",
77
+ body=bad_json,
78
+ headers=t.default_headers("tools/list"),
79
+ label="malformed-json",
80
+ note="ERR-01 truncated JSON",
81
+ )
82
+ )
83
+ elif isinstance(t, StdioTransport):
84
+ resp = await t.send_raw(
85
+ bad_json, label="malformed-json", want_id=None, note="ERR-01 truncated JSON"
86
+ )
87
+ await asyncio.sleep(0.3)
88
+ if t.unsolicited:
89
+ resp.body = json.dumps(t.unsolicited[-1]).encode()
90
+ responses.append(resp)
91
+ # 2. unknown method
92
+ responses.append(await t.rpc("mcpprobe/does_not_exist", {}, note="ERR-01 unknown method"))
93
+ # 3. wrong param type
94
+ wrong = json.dumps(
95
+ {"jsonrpc": "2.0", "id": t.next_id(), "method": "tools/list", "params": "not-an-object"}
96
+ )
97
+ if isinstance(t, HttpTransport):
98
+ responses.append(
99
+ await t.raw_request(
100
+ "POST",
101
+ body=wrong,
102
+ headers=t.default_headers("tools/list"),
103
+ label="wrong-param-type",
104
+ want_id=json.loads(wrong)["id"],
105
+ note="ERR-01 wrong param type",
106
+ )
107
+ )
108
+ elif isinstance(t, StdioTransport):
109
+ responses.append(
110
+ await t.send_raw(
111
+ wrong,
112
+ label="wrong-param-type",
113
+ want_id=json.loads(wrong)["id"],
114
+ note="ERR-01 wrong param type",
115
+ )
116
+ )
117
+
118
+ leaks: dict[str, list[str]] = {}
119
+ for resp in responses:
120
+ text = resp.body.decode("utf-8", errors="replace")
121
+ cats = inspect_error_body(text)
122
+ if cats:
123
+ leaks[resp.method] = cats
124
+ if leaks:
125
+ all_cats = sorted({c for cs in leaks.values() for c in cs})
126
+ only_version = all_cats == ["framework version"]
127
+ findings.append(
128
+ self.finding(
129
+ description="Error responses contained "
130
+ + ", ".join(all_cats)
131
+ + " (probes: "
132
+ + ", ".join(leaks)
133
+ + ").",
134
+ severity=Severity.LOW if only_version else Severity.MEDIUM,
135
+ confidence=Confidence.HIGH,
136
+ evidence=evidence_of(*[r for r in responses if r.method in leaks]),
137
+ location=ctx.target,
138
+ details={"leaks": leaks},
139
+ )
140
+ )
141
+ server_header = next((r.headers.get("server") for r in responses if r.headers.get("server")), None)
142
+ if server_header and _SERVER_HEADER_VERSION.match(server_header):
143
+ findings.append(
144
+ self.finding(
145
+ title="Server header discloses software version",
146
+ description=f"Server: {server_header}",
147
+ severity=Severity.INFO,
148
+ confidence=Confidence.HIGH,
149
+ evidence=evidence_of(responses[0]) if responses else [],
150
+ location=ctx.target,
151
+ details={"server": server_header},
152
+ )
153
+ )
154
+ return findings
155
+
156
+
157
+ # --------------------------------------------------------------------------- DOS-01
158
+ #: Hard limits. These are the *maximum* the check will ever send.
159
+ OVERSIZED_ARG_BYTES = 1_000_000
160
+ NESTING_DEPTH = 64
161
+ BURST_REQUESTS = 20
162
+ SLOW_THRESHOLD_MS = 5000.0
163
+
164
+
165
+ def nested(depth: int) -> dict[str, Any]:
166
+ obj: dict[str, Any] = {"leaf": True}
167
+ for _ in range(depth):
168
+ obj = {"n": obj}
169
+ return obj
170
+
171
+
172
+ class Dos01UnboundedConsumption(Check):
173
+ id = "DOS-01"
174
+ title = "No visible limits on request size, nesting or rate"
175
+ severity = Severity.LOW
176
+ standards = Standards(asi=["ASI08"], llm_2025=["LLM10:2025"], llm_2026=["LLM06:2026"])
177
+ description = (
178
+ "Three bounded probes, never more: (1) one tools/list whose 'cursor' is a 1 MB string, "
179
+ "(2) one tools/list with params nested 64 levels deep, (3) 20 tools/list requests sent "
180
+ "at the scan rate cap (5 req/s by default). A 5xx, a transport failure or a response "
181
+ "slower than 5 s on (1)/(2) is reported; absence of any 429 during (3) is reported as "
182
+ "informational. No tools are called."
183
+ )
184
+ evidence_type = "The oversized/nested requests (truncated) and timing; the burst responses' statuses."
185
+ false_positive_notes = (
186
+ "The burst runs inside the scanner's own rate cap, so 'no 429 observed' is indicative "
187
+ "only (low confidence) and many well-run servers legitimately allow 5 req/s. A 413 or "
188
+ "connection reset on the oversized probe is treated as a correct defence."
189
+ )
190
+ fix = (
191
+ "Enforce a request body limit (e.g. 1 MB) and JSON depth limit at the edge, return 413/400 "
192
+ "promptly, and rate-limit per client/session with 429 and Retry-After."
193
+ )
194
+
195
+ async def run(self, ctx: ScanContext) -> list[Finding]:
196
+ findings: list[Finding] = []
197
+ t = ctx.transport
198
+ probes: list[tuple[str, RpcResponse]] = []
199
+ # (1) one oversized argument
200
+ big = await t.rpc(
201
+ "tools/list", {"cursor": "A" * OVERSIZED_ARG_BYTES}, note="DOS-01 oversized cursor (1 MB)"
202
+ )
203
+ probes.append(("oversized", big))
204
+ # (2) deep nesting
205
+ deep = await t.rpc(
206
+ "tools/list", {"cursor": None, "_mcpprobe": nested(NESTING_DEPTH)}, note="DOS-01 nesting depth 64"
207
+ )
208
+ probes.append(("nesting", deep))
209
+ for label, resp in probes:
210
+ problem = _problem(resp)
211
+ if problem:
212
+ findings.append(
213
+ self.finding(
214
+ title=f"Server struggled with {label} request",
215
+ description=f"The {label} probe produced: {problem}. Input limits may be missing.",
216
+ severity=Severity.LOW if "slow" in problem else Severity.MEDIUM,
217
+ confidence=Confidence.MEDIUM,
218
+ evidence=evidence_of(resp),
219
+ excerpt=evidence_summary(
220
+ evidence_of(resp), f"{label} probe -> {problem} after {resp.elapsed_ms:.0f} ms"
221
+ ),
222
+ location=ctx.target,
223
+ details={"probe": label, "status": resp.status, "elapsed_ms": resp.elapsed_ms},
224
+ )
225
+ )
226
+ # (3) bounded burst
227
+ statuses: list[int | None] = []
228
+ burst_responses: list[RpcResponse] = []
229
+ for i in range(BURST_REQUESTS):
230
+ resp = await t.rpc("tools/list", {}, note=f"DOS-01 burst {i + 1}/{BURST_REQUESTS}")
231
+ burst_responses.append(resp)
232
+ statuses.append(resp.status)
233
+ if resp.status == 429:
234
+ break
235
+ if isinstance(t, HttpTransport):
236
+ if 429 in statuses:
237
+ ctx.notes.append(f"DOS-01: rate limiting observed after {statuses.index(429) + 1} requests")
238
+ else:
239
+ findings.append(
240
+ self.finding(
241
+ title="No rate limiting observed",
242
+ description=f"{len(statuses)} tools/list requests at the scan rate cap were all accepted "
243
+ "(no 429). Indicative only; the scanner never exceeds its own cap.",
244
+ severity=Severity.INFO,
245
+ confidence=Confidence.LOW,
246
+ evidence=evidence_of(burst_responses[-1]) if burst_responses else [],
247
+ excerpt=f"{len(statuses)} x tools/list at {t.limiter.rate:g} req/s, statuses: "
248
+ + ", ".join(sorted({str(s) for s in statuses})),
249
+ location=ctx.target,
250
+ details={"requests": len(statuses), "rate_cap": t.limiter.rate},
251
+ )
252
+ )
253
+ return findings
254
+
255
+
256
+ def _problem(resp: RpcResponse) -> str | None:
257
+ if resp.error_text and "timeout" in resp.error_text.lower():
258
+ return f"timeout ({resp.error_text})"
259
+ if resp.status is not None and resp.status >= 500:
260
+ return f"HTTP {resp.status}"
261
+ if resp.elapsed_ms > SLOW_THRESHOLD_MS:
262
+ return f"slow response ({resp.elapsed_ms:.0f} ms)"
263
+ if resp.status is None and resp.error_text and resp.result is None and resp.rpc_error is None:
264
+ # stdio: server died or stopped answering
265
+ return f"stdio transport error ({resp.error_text})"
266
+ return None