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.
- mcpprobe/__init__.py +21 -0
- mcpprobe/action.py +69 -0
- mcpprobe/authz.py +313 -0
- mcpprobe/catalogue.py +125 -0
- mcpprobe/checks/__init__.py +100 -0
- mcpprobe/checks/_util.py +102 -0
- mcpprobe/checks/active_stubs.py +212 -0
- mcpprobe/checks/auth.py +473 -0
- mcpprobe/checks/base.py +228 -0
- mcpprobe/checks/baseline.py +219 -0
- mcpprobe/checks/capability.py +171 -0
- mcpprobe/checks/metadata.py +341 -0
- mcpprobe/checks/poisoning.py +480 -0
- mcpprobe/checks/robustness.py +266 -0
- mcpprobe/checks/transport_conformance.py +429 -0
- mcpprobe/cli.py +295 -0
- mcpprobe/dnsutil.py +67 -0
- mcpprobe/governance.py +74 -0
- mcpprobe/models.py +264 -0
- mcpprobe/policy.py +140 -0
- mcpprobe/py.typed +0 -0
- mcpprobe/redact.py +101 -0
- mcpprobe/report/__init__.py +7 -0
- mcpprobe/report/json_report.py +33 -0
- mcpprobe/report/sarif.py +329 -0
- mcpprobe/report/terminal.py +172 -0
- mcpprobe/scanner.py +211 -0
- mcpprobe/transport/__init__.py +34 -0
- mcpprobe/transport/base.py +156 -0
- mcpprobe/transport/evidence.py +135 -0
- mcpprobe/transport/fingerprint.py +154 -0
- mcpprobe/transport/http.py +366 -0
- mcpprobe/transport/ratelimit.py +47 -0
- mcpprobe/transport/stdio.py +201 -0
- mcpprobe_scanner-0.1.0.dist-info/METADATA +240 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +39 -0
- mcpprobe_scanner-0.1.0.dist-info/WHEEL +4 -0
- mcpprobe_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
mcpprobe/checks/_util.py
ADDED
|
@@ -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
|
+
]
|