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,102 @@
1
+ """Small helpers shared by several checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import math
8
+ import re
9
+ import unicodedata
10
+ from collections import Counter
11
+ from typing import Any
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ _PARAM_RE = re.compile(r'([A-Za-z0-9_\-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))')
15
+
16
+
17
+ def parse_www_authenticate(value: str | None) -> dict[str, str]:
18
+ """Parse a ``WWW-Authenticate`` header into ``{'scheme': ..., param: value}``."""
19
+ out: dict[str, str] = {}
20
+ if not value:
21
+ return out
22
+ scheme, _, rest = value.strip().partition(" ")
23
+ out["scheme"] = scheme
24
+ for match in _PARAM_RE.finditer(rest):
25
+ key = match.group(1).lower()
26
+ out[key] = match.group(2) if match.group(2) is not None else match.group(3)
27
+ return out
28
+
29
+
30
+ def origin_of(url: str) -> str:
31
+ parts = urlsplit(url)
32
+ return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
33
+
34
+
35
+ def well_known_urls(url: str, suffix: str) -> list[str]:
36
+ """Path-aware well-known URL first (RFC 9728 s.3.1), then the host root."""
37
+ parts = urlsplit(url)
38
+ root = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
39
+ urls: list[str] = []
40
+ path = parts.path.rstrip("/")
41
+ if path:
42
+ urls.append(f"{root}/.well-known/{suffix}{path}")
43
+ urls.append(f"{root}/.well-known/{suffix}")
44
+ return urls
45
+
46
+
47
+ def host_of(url: str) -> str:
48
+ return urlsplit(url).hostname or ""
49
+
50
+
51
+ def is_local_or_private(host: str) -> bool:
52
+ """True for loopback, link-local, RFC 1918 and ``localhost``-style names."""
53
+ if not host:
54
+ return False
55
+ h = host.lower().rstrip(".")
56
+ if h in {"localhost", "ip6-localhost"} or h.endswith(".localhost") or h.endswith(".local"):
57
+ return True
58
+ try:
59
+ ip = ipaddress.ip_address(h.strip("[]"))
60
+ except ValueError:
61
+ return False
62
+ return ip.is_loopback or ip.is_private or ip.is_link_local
63
+
64
+
65
+ def shannon_entropy(text: str) -> float:
66
+ """Bits of entropy per character."""
67
+ if not text:
68
+ return 0.0
69
+ counts = Counter(text)
70
+ length = len(text)
71
+ return -sum((c / length) * math.log2(c / length) for c in counts.values())
72
+
73
+
74
+ def canonical_json(data: Any) -> str:
75
+ """Deterministic JSON used for hashing (sorted keys, compact, ASCII-escaped)."""
76
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
77
+
78
+
79
+ def try_json(body: bytes) -> Any | None:
80
+ try:
81
+ return json.loads(body.decode("utf-8", errors="replace"))
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def visible(text: str) -> str:
87
+ """Make invisible/format characters visible as ``<U+XXXX>`` so reviewers can see them."""
88
+ out: list[str] = []
89
+ for ch in text:
90
+ cat = unicodedata.category(ch)
91
+ if ch in "\n\t":
92
+ out.append(" ")
93
+ elif cat in {"Cf", "Cc", "Co", "Cn"} or 0xE0000 <= ord(ch) <= 0xE007F:
94
+ out.append(f"<U+{ord(ch):04X}>")
95
+ else:
96
+ out.append(ch)
97
+ return "".join(out)
98
+
99
+
100
+ def excerpt(text: str, limit: int = 160) -> str:
101
+ text = visible(text)
102
+ return text if len(text) <= limit else text[: limit - 1] + "\u2026"
@@ -0,0 +1,212 @@
1
+ """Active checks: INJ-01..05, SUPPLY-01/02 and SANDBOX-01.
2
+
3
+ These are **stubs**. Each one is registered so that reports show it as
4
+ ``not_implemented`` (when active mode is authorised) or ``skipped`` (otherwise),
5
+ but none sends a probe payload yet. When implemented they must follow the rules
6
+ of engagement in CHECKS.md: non-destructive, non-persistent canaries only;
7
+ tools excluded by the operator or marked ``destructiveHint: true`` are never
8
+ called; every payload is logged as evidence; an emergency stop aborts the run.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from mcpprobe.checks.base import ActiveCheck, ScanContext
14
+ from mcpprobe.models import Finding, Severity, Standards
15
+
16
+ GATE_TEXT = "requires --active and verified ownership"
17
+
18
+
19
+ class _Stub(ActiveCheck):
20
+ """Shared behaviour: enforce the gate, then report not-implemented."""
21
+
22
+ async def run(self, ctx: ScanContext) -> list[Finding]:
23
+ self.require_active(ctx)
24
+ raise NotImplementedError(f"{self.id} is not implemented yet ({GATE_TEXT})")
25
+
26
+
27
+ class Inj01CommandInjection(_Stub):
28
+ """TODO: time-based, non-destructive command-injection canaries.
29
+
30
+ Plan: for tools classified ``exec``/``shell``/``file`` by META-03, submit
31
+ arguments containing ``$(sleep N)``, backtick and ``;`` separated sleep
32
+ markers, ``%0a`` and ``|| true`` and look for a timing delta or an echoed
33
+ marker. Never submit payloads that write, delete or exfiltrate.
34
+ """
35
+
36
+ id = "INJ-01"
37
+ title = "Command/code injection (safe canaries)"
38
+ severity = Severity.CRITICAL
39
+ standards = Standards(mcp=["MCP05:2025"], asi=["ASI05"], llm_2026=["LLM10:2026"])
40
+ description = (
41
+ "ACTIVE, NOT IMPLEMENTED. Will call exec-class tools with time-based, non-destructive "
42
+ "canaries and compare response timing. " + GATE_TEXT + "."
43
+ )
44
+ evidence_type = "tools/call request with the canary and the timed response."
45
+ false_positive_notes = (
46
+ "Network jitter can mimic a timing delta; the probe will repeat and require consistency."
47
+ )
48
+ fix = "Never pass tool arguments to a shell; use argument vectors and allow-lists."
49
+
50
+
51
+ class Inj02PathTraversal(_Stub):
52
+ """TODO: read-only path traversal probes against path-typed arguments.
53
+
54
+ Plan: ``../../../etc/hostname``, ``..%2f`` encodings, absolute paths and
55
+ symlink-style names (CVE-2025-53109/53110 class). Success means content was
56
+ returned from outside the intended root. Read-only files only.
57
+ """
58
+
59
+ id = "INJ-02"
60
+ title = "Path traversal / arbitrary file read"
61
+ severity = Severity.HIGH
62
+ standards = Standards(mcp=["MCP05:2025"], asi=["ASI02"])
63
+ description = (
64
+ "ACTIVE, NOT IMPLEMENTED. Read-only traversal probes on path-typed arguments. " + GATE_TEXT + "."
65
+ )
66
+ evidence_type = "tools/call request with the traversal path and the response excerpt."
67
+ false_positive_notes = (
68
+ "Tools that intentionally expose the whole filesystem are a design decision, not a bug."
69
+ )
70
+ fix = "Canonicalise paths, resolve symlinks, and enforce a root directory check after resolution."
71
+
72
+
73
+ class Inj03Ssrf(_Stub):
74
+ """TODO: SSRF detection via a scanner-controlled callback host.
75
+
76
+ Plan: URL-typed arguments receive a unique callback URL per scan plus denied
77
+ targets (``169.254.169.254``, ``localhost``). A received callback confirms SSRF.
78
+ Requires operator-supplied callback infrastructure; none is bundled.
79
+ """
80
+
81
+ id = "INJ-03"
82
+ title = "Server-side request forgery"
83
+ severity = Severity.HIGH
84
+ standards = Standards(mcp=["MCP05:2025"], asi=["ASI02"])
85
+ description = (
86
+ "ACTIVE, NOT IMPLEMENTED. Callback-based SSRF probes on URL-typed arguments. " + GATE_TEXT + "."
87
+ )
88
+ evidence_type = "tools/call request with the callback URL and the callback log entry."
89
+ false_positive_notes = "Fetch tools are often meant to fetch arbitrary URLs; the finding focuses on internal/metadata addresses."
90
+ fix = "Resolve and validate destination addresses, block link-local and private ranges, use an egress proxy."
91
+
92
+
93
+ class Inj04SqlInjection(_Stub):
94
+ """TODO: boolean/time-based SQL markers with no data modification."""
95
+
96
+ id = "INJ-04"
97
+ title = "SQL injection (safe markers)"
98
+ severity = Severity.HIGH
99
+ standards = Standards(mcp=["MCP05:2025"])
100
+ description = (
101
+ "ACTIVE, NOT IMPLEMENTED. Boolean and timing markers on query/filter arguments, plus error fingerprinting. "
102
+ + GATE_TEXT
103
+ + "."
104
+ )
105
+ evidence_type = "tools/call requests and differential responses."
106
+ false_positive_notes = (
107
+ "Tools that accept raw SQL by design are reported as a capability (META-03), not an injection."
108
+ )
109
+ fix = "Use parameterised queries; never interpolate tool arguments into SQL."
110
+
111
+
112
+ class Inj05ToolOutputInjection(_Stub):
113
+ """TODO: canary instruction in scanner-controlled content fetched by read-only tools.
114
+
115
+ Plan: call read-only fetch/search/resource tools against operator-provided
116
+ content containing a canary instruction and check whether it is returned
117
+ verbatim without marking or sanitisation.
118
+ """
119
+
120
+ id = "INJ-05"
121
+ title = "Tool output returned without sanitisation (contextual payload path)"
122
+ severity = Severity.MEDIUM
123
+ standards = Standards(
124
+ mcp=["MCP06:2025", "MCP10:2025"], asi=["ASI06"], llm_2025=["LLM01:2025"], llm_2026=["LLM01:2026"]
125
+ )
126
+ description = (
127
+ "ACTIVE, NOT IMPLEMENTED. Read-only tool calls against scanner-controlled canary content. "
128
+ + GATE_TEXT
129
+ + "."
130
+ )
131
+ evidence_type = "tools/call request and the returned content."
132
+ false_positive_notes = "Returning fetched content verbatim is normal; the finding documents the path for the client's risk assessment."
133
+ fix = "Mark tool output as untrusted data in content blocks and strip instruction-like markup where feasible."
134
+
135
+
136
+ class Supply01PackageProvenance(_Stub):
137
+ """TODO: npm/PyPI provenance for stdio packages.
138
+
139
+ Plan: version pinning, lockfile presence, OSV vulnerability lookup, maintainer
140
+ count, publish date vs repository tag, install scripts, ``eval``/``child_process.exec``
141
+ usage, and artefact-vs-tag comparison. Network access to registries only.
142
+ """
143
+
144
+ id = "SUPPLY-01"
145
+ title = "Package provenance and known vulnerabilities (stdio)"
146
+ severity = Severity.MEDIUM
147
+ applies_to = frozenset({"stdio"})
148
+ standards = Standards(mcp=["MCP04:2025"], asi=["ASI04"], llm_2025=["LLM03:2025"], llm_2026=["LLM04:2026"])
149
+ description = (
150
+ "ACTIVE, NOT IMPLEMENTED. Registry metadata, OSV lookup and install-script review for the stdio package. "
151
+ + GATE_TEXT
152
+ + "."
153
+ )
154
+ evidence_type = "Registry metadata documents and OSV responses."
155
+ false_positive_notes = (
156
+ "Unpinned versions are common in examples; severity will depend on install-script presence."
157
+ )
158
+ fix = "Pin versions, commit lockfiles, publish with provenance attestations, avoid install scripts."
159
+
160
+
161
+ class Supply02StdioConfigInjection(_Stub):
162
+ """TODO: config-to-command analysis of an ``mcp.json``-style file.
163
+
164
+ Plan: flag ``command`` values with shell metacharacters or ``sh -c``, ``npx -y``
165
+ of unpinned packages, and env blocks containing secrets. Pure file analysis,
166
+ no network, but kept in active mode until the config input is designed.
167
+ """
168
+
169
+ id = "SUPPLY-02"
170
+ title = "stdio configuration launches risky commands"
171
+ severity = Severity.HIGH
172
+ applies_to = frozenset({"stdio"})
173
+ standards = Standards(mcp=["MCP05:2025", "MCP04:2025"], asi=["ASI05"])
174
+ description = (
175
+ "ACTIVE, NOT IMPLEMENTED. Static review of a submitted client configuration file. " + GATE_TEXT + "."
176
+ )
177
+ evidence_type = "The configuration entry."
178
+ false_positive_notes = "npx -y with a pinned version is acceptable; unpinned is flagged."
179
+ fix = "Pin packages, avoid shell wrappers, keep secrets out of config files."
180
+
181
+
182
+ class Sandbox01RuntimeBehaviour(_Stub):
183
+ """TODO: run the stdio package in an egress-logged sandbox.
184
+
185
+ Plan: observe outbound hosts, files touched and processes spawned during
186
+ ``initialize`` + ``tools/list`` alone; report behaviour that the description
187
+ does not explain. Requires a sandbox runner (not bundled).
188
+ """
189
+
190
+ id = "SANDBOX-01"
191
+ title = "Unexpected runtime behaviour during listing (stdio)"
192
+ severity = Severity.HIGH
193
+ applies_to = frozenset({"stdio"})
194
+ standards = Standards(mcp=["MCP04:2025"], asi=["ASI10", "ASI04"])
195
+ description = (
196
+ "ACTIVE, NOT IMPLEMENTED. Sandboxed execution with egress and syscall logging. " + GATE_TEXT + "."
197
+ )
198
+ evidence_type = "Sandbox egress/file/process log."
199
+ false_positive_notes = "Telemetry and update checks are common; the finding will list hosts for review."
200
+ fix = "Do not contact the network or touch files outside the package during start-up and listing."
201
+
202
+
203
+ ACTIVE_STUBS: list[type[ActiveCheck]] = [
204
+ Inj01CommandInjection,
205
+ Inj02PathTraversal,
206
+ Inj03Ssrf,
207
+ Inj04SqlInjection,
208
+ Inj05ToolOutputInjection,
209
+ Supply01PackageProvenance,
210
+ Supply02StdioConfigInjection,
211
+ Sandbox01RuntimeBehaviour,
212
+ ]