mcpprobe-scanner 0.2.0__py3-none-any.whl → 0.3.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 +1 -1
- mcpprobe/action.py +113 -6
- mcpprobe/checks/__init__.py +17 -0
- mcpprobe/checks/auth.py +8 -2
- mcpprobe/checks/base.py +9 -2
- mcpprobe/checks/capability.py +106 -0
- mcpprobe/checks/metadata.py +10 -81
- mcpprobe/checks/poisoning.py +4 -1
- mcpprobe/checks/proof.py +9 -0
- mcpprobe/checks/protocol_2026.py +749 -0
- mcpprobe/checks/robustness.py +15 -8
- mcpprobe/checks/tool_surface.py +339 -0
- mcpprobe/checks/transport_conformance.py +6 -3
- mcpprobe/cli.py +39 -0
- mcpprobe/judges/__init__.py +31 -0
- mcpprobe/judges/hf.py +125 -0
- mcpprobe/models.py +41 -1
- mcpprobe/readiness.py +341 -0
- mcpprobe/scanner.py +58 -6
- mcpprobe/transport/base.py +23 -0
- mcpprobe/transport/fingerprint.py +7 -8
- mcpprobe/transport/http.py +99 -1
- {mcpprobe_scanner-0.2.0.dist-info → mcpprobe_scanner-0.3.0.dist-info}/METADATA +55 -7
- mcpprobe_scanner-0.3.0.dist-info/RECORD +48 -0
- mcpprobe_scanner-0.2.0.dist-info/RECORD +0 -43
- {mcpprobe_scanner-0.2.0.dist-info → mcpprobe_scanner-0.3.0.dist-info}/WHEEL +0 -0
- {mcpprobe_scanner-0.2.0.dist-info → mcpprobe_scanner-0.3.0.dist-info}/entry_points.txt +0 -0
- {mcpprobe_scanner-0.2.0.dist-info → mcpprobe_scanner-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {mcpprobe_scanner-0.2.0.dist-info → mcpprobe_scanner-0.3.0.dist-info}/licenses/NOTICE +0 -0
mcpprobe/__init__.py
CHANGED
mcpprobe/action.py
CHANGED
|
@@ -2,19 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
GitHub passes action inputs as ``INPUT_<NAME>`` environment variables (name
|
|
4
4
|
upper-cased, hyphens kept). This module turns them into a CLI invocation,
|
|
5
|
-
always writes SARIF, records outputs in ``$GITHUB_OUTPUT
|
|
6
|
-
|
|
5
|
+
always writes SARIF, records outputs in ``$GITHUB_OUTPUT``, writes a job
|
|
6
|
+
summary to ``$GITHUB_STEP_SUMMARY``, emits one workflow annotation that names
|
|
7
|
+
the outcome, and exits with the scanner's exit code:
|
|
8
|
+
|
|
9
|
+
- 0 clean (no finding at or above ``fail-on``)
|
|
10
|
+
- 1 findings at or above ``fail-on``
|
|
11
|
+
- 2 error (unreachable target, bad input, opt-out, authorisation refused)
|
|
12
|
+
- 3 the target answered but is not an MCP server (wrong URL or path)
|
|
13
|
+
|
|
14
|
+
For exit codes 2 and 3 the SARIF file contains no results. Uploading it to
|
|
15
|
+
code scanning would close every open alert for the category, so workflows
|
|
16
|
+
should upload only when ``result`` is ``clean`` or ``findings``.
|
|
7
17
|
"""
|
|
8
18
|
|
|
9
19
|
from __future__ import annotations
|
|
10
20
|
|
|
21
|
+
import json
|
|
11
22
|
import os
|
|
12
23
|
import shlex
|
|
13
24
|
import sys
|
|
14
25
|
from collections.abc import Mapping
|
|
15
26
|
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
16
28
|
|
|
17
|
-
from mcpprobe.cli import EXIT_ERROR, main
|
|
29
|
+
from mcpprobe.cli import EXIT_CLEAN, EXIT_ERROR, EXIT_FINDINGS, EXIT_NOT_MCP, main
|
|
30
|
+
|
|
31
|
+
#: ``result`` output value for each exit code.
|
|
32
|
+
RESULTS: dict[int, str] = {
|
|
33
|
+
EXIT_CLEAN: "clean",
|
|
34
|
+
EXIT_FINDINGS: "findings",
|
|
35
|
+
EXIT_ERROR: "error",
|
|
36
|
+
EXIT_NOT_MCP: "not-mcp",
|
|
37
|
+
}
|
|
38
|
+
_SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
18
39
|
|
|
19
40
|
|
|
20
41
|
def _input(env: Mapping[str, str], name: str, default: str = "") -> str:
|
|
@@ -44,24 +65,110 @@ def build_argv(env: Mapping[str, str]) -> list[str]:
|
|
|
44
65
|
return argv
|
|
45
66
|
|
|
46
67
|
|
|
68
|
+
def _escape(text: str) -> str:
|
|
69
|
+
"""Escape a workflow-command message (``%``, CR and LF must be encoded)."""
|
|
70
|
+
return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _escape_property(text: str) -> str:
|
|
74
|
+
"""Escape a workflow-command property value (also ``:`` and ``,``)."""
|
|
75
|
+
return _escape(text).replace(":", "%3A").replace(",", "%2C")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sarif_counts(path: Path) -> dict[str, int]:
|
|
79
|
+
"""Count SARIF results per mcpprobe severity (empty when the file is missing or unreadable)."""
|
|
80
|
+
counts = dict.fromkeys(_SEVERITIES, 0)
|
|
81
|
+
try:
|
|
82
|
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
for run in data.get("runs", []):
|
|
84
|
+
for result in run.get("results", []):
|
|
85
|
+
sev = str(result.get("properties", {}).get("severity", "info"))
|
|
86
|
+
counts[sev if sev in counts else "info"] += 1
|
|
87
|
+
except (OSError, ValueError, AttributeError):
|
|
88
|
+
return {}
|
|
89
|
+
return counts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) -> tuple[str, str, str]:
|
|
93
|
+
"""Return (annotation level, title, message) for an exit code."""
|
|
94
|
+
total = sum(counts.values())
|
|
95
|
+
breakdown = ", ".join(f"{counts[s]} {s}" for s in _SEVERITIES if counts.get(s))
|
|
96
|
+
if code == EXIT_CLEAN:
|
|
97
|
+
found = f" ({breakdown} below the threshold)" if breakdown else ""
|
|
98
|
+
return "notice", "mcpprobe: passed", f"No finding at or above '{fail_on}' on {target}{found}."
|
|
99
|
+
if code == EXIT_FINDINGS:
|
|
100
|
+
return (
|
|
101
|
+
"error",
|
|
102
|
+
"mcpprobe: findings",
|
|
103
|
+
f"{total} finding(s) on {target} ({breakdown}); at least one is at or above '{fail_on}'. "
|
|
104
|
+
"See the job summary or the code scanning alerts.",
|
|
105
|
+
)
|
|
106
|
+
if code == EXIT_NOT_MCP:
|
|
107
|
+
return (
|
|
108
|
+
"error",
|
|
109
|
+
"mcpprobe: not an MCP server",
|
|
110
|
+
f"{target} answered, but not as an MCP server, so nothing was checked and nothing passed. "
|
|
111
|
+
"Check the URL and path (Streamable HTTP endpoints are usually /mcp) and that the "
|
|
112
|
+
"deployment is up. Do not upload this SARIF: it has no results and would close open alerts.",
|
|
113
|
+
)
|
|
114
|
+
return (
|
|
115
|
+
"error",
|
|
116
|
+
"mcpprobe: scan error",
|
|
117
|
+
f"The scan of {target} did not complete (exit {code}): the target was unreachable, an input "
|
|
118
|
+
"was invalid, the target opted out, or authorisation was refused. See the step log.",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], message: str) -> str:
|
|
123
|
+
lines = [
|
|
124
|
+
"## mcpprobe MCP security scan",
|
|
125
|
+
"",
|
|
126
|
+
f"**Target:** `{target}` ",
|
|
127
|
+
f"**Result:** {RESULTS.get(code, 'error')} (exit {code}, fail-on `{fail_on}`)",
|
|
128
|
+
"",
|
|
129
|
+
message,
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
if code in (EXIT_CLEAN, EXIT_FINDINGS) and counts:
|
|
133
|
+
lines += ["| Severity | Findings |", "|---|---|"]
|
|
134
|
+
lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append(
|
|
137
|
+
"Docs: https://mcpprobe.tech · checks: https://github.com/mcpprobe-dev/mcpprobe/blob/main/CHECKS.md"
|
|
138
|
+
)
|
|
139
|
+
return "\n".join(lines) + "\n"
|
|
140
|
+
|
|
141
|
+
|
|
47
142
|
def run(env: Mapping[str, str] | None = None) -> int:
|
|
48
143
|
env = os.environ if env is None else env
|
|
49
144
|
try:
|
|
50
145
|
argv = build_argv(env)
|
|
51
146
|
except ValueError as exc:
|
|
52
|
-
print(
|
|
147
|
+
print(
|
|
148
|
+
f"::error title={_escape_property('mcpprobe: bad input')}::{_escape(str(exc))}", file=sys.stderr
|
|
149
|
+
)
|
|
53
150
|
return EXIT_ERROR
|
|
54
151
|
code = EXIT_ERROR
|
|
55
152
|
try:
|
|
56
153
|
main(argv)
|
|
57
154
|
except SystemExit as exc:
|
|
58
155
|
code = exc.code if isinstance(exc.code, int) else EXIT_ERROR
|
|
156
|
+
target = argv[1]
|
|
157
|
+
fail_on = argv[argv.index("--fail-on") + 1]
|
|
158
|
+
sarif = argv[argv.index("--sarif") + 1]
|
|
159
|
+
counts = sarif_counts(Path(sarif))
|
|
160
|
+
level, title, message = describe(code, target, fail_on, counts)
|
|
161
|
+
print(f"::{level} title={_escape_property(title)}::{_escape(message)}", file=sys.stderr)
|
|
59
162
|
out = env.get("GITHUB_OUTPUT")
|
|
60
163
|
if out:
|
|
61
|
-
sarif = argv[argv.index("--sarif") + 1]
|
|
62
164
|
with Path(out).open("a", encoding="utf-8") as fh:
|
|
63
|
-
fh.write(f"sarif-file={sarif}\n")
|
|
165
|
+
fh.write(f"sarif-file={sarif if Path(sarif).exists() else ''}\n")
|
|
64
166
|
fh.write(f"exit-code={code}\n")
|
|
167
|
+
fh.write(f"result={RESULTS.get(code, 'error')}\n")
|
|
168
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
169
|
+
if summary:
|
|
170
|
+
with Path(summary).open("a", encoding="utf-8") as fh:
|
|
171
|
+
fh.write(_summary(code, target, fail_on, counts, message))
|
|
65
172
|
return code
|
|
66
173
|
|
|
67
174
|
|
mcpprobe/checks/__init__.py
CHANGED
|
@@ -31,7 +31,16 @@ from mcpprobe.checks.metadata import (
|
|
|
31
31
|
Meta05SecretsInMetadata,
|
|
32
32
|
)
|
|
33
33
|
from mcpprobe.checks.poisoning import Meta01ToolDescriptionPoisoning
|
|
34
|
+
from mcpprobe.checks.protocol_2026 import (
|
|
35
|
+
Auth05IssuerIdentification,
|
|
36
|
+
Auth06DynamicClientRegistration,
|
|
37
|
+
Trans06ServerDiscover,
|
|
38
|
+
Trans07UnadvertisedVersionAccepted,
|
|
39
|
+
Trans08HeaderBodyMismatch,
|
|
40
|
+
Trans09CachingHints,
|
|
41
|
+
)
|
|
34
42
|
from mcpprobe.checks.robustness import Dos01UnboundedConsumption, Err01VerboseErrors
|
|
43
|
+
from mcpprobe.checks.tool_surface import Meta06ToolAnnotationLint, Meta07ToxicFlow
|
|
35
44
|
from mcpprobe.checks.transport_conformance import (
|
|
36
45
|
Trans01OriginValidation,
|
|
37
46
|
Trans02ProtocolVersionHandling,
|
|
@@ -45,16 +54,24 @@ PASSIVE_CHECKS: list[type[Check]] = [
|
|
|
45
54
|
Auth02MissingProtectedResourceMetadata,
|
|
46
55
|
Auth03AuthorizationServerMetadata,
|
|
47
56
|
Auth04TokenAudienceAndPassthrough,
|
|
57
|
+
Auth05IssuerIdentification,
|
|
58
|
+
Auth06DynamicClientRegistration,
|
|
48
59
|
Trans01OriginValidation,
|
|
49
60
|
Trans02ProtocolVersionHandling,
|
|
50
61
|
Trans03SessionIdQuality,
|
|
51
62
|
Trans04DeprecatedTransport,
|
|
52
63
|
Trans05TlsAndHeaders,
|
|
64
|
+
Trans06ServerDiscover,
|
|
65
|
+
Trans07UnadvertisedVersionAccepted,
|
|
66
|
+
Trans08HeaderBodyMismatch,
|
|
67
|
+
Trans09CachingHints,
|
|
53
68
|
Meta01ToolDescriptionPoisoning,
|
|
54
69
|
Meta02RugPullBaseline,
|
|
55
70
|
Meta03DangerousCapabilitySurface,
|
|
56
71
|
Meta04SchemaHygiene,
|
|
57
72
|
Meta05SecretsInMetadata,
|
|
73
|
+
Meta06ToolAnnotationLint,
|
|
74
|
+
Meta07ToxicFlow,
|
|
58
75
|
Err01VerboseErrors,
|
|
59
76
|
Dos01UnboundedConsumption,
|
|
60
77
|
]
|
mcpprobe/checks/auth.py
CHANGED
|
@@ -461,6 +461,10 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
461
461
|
)
|
|
462
462
|
continue
|
|
463
463
|
doc_proof = f"GET {doc_url} -> HTTP 200\n" + proof.indent(proof.pretty(doc))
|
|
464
|
+
# Shared with AUTH-05/AUTH-06, which read the same document and send nothing.
|
|
465
|
+
ctx.shared.setdefault("as_metadata", []).append(
|
|
466
|
+
{"issuer": issuer, "url": doc_url, "doc": doc, "evidence": responses[-1].evidence}
|
|
467
|
+
)
|
|
464
468
|
methods = doc.get("code_challenge_methods_supported")
|
|
465
469
|
if not isinstance(methods, list) or "S256" not in methods:
|
|
466
470
|
findings.append(
|
|
@@ -524,7 +528,8 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
524
528
|
if assessed == 0:
|
|
525
529
|
raise NotApplicable(
|
|
526
530
|
"Every advertised authorization server is on a third-party host that was not "
|
|
527
|
-
"contacted (--no-follow-as or the host opted out); see scan notes."
|
|
531
|
+
"contacted (--no-follow-as or the host opted out); see scan notes.",
|
|
532
|
+
blocked=True,
|
|
528
533
|
)
|
|
529
534
|
return findings
|
|
530
535
|
|
|
@@ -690,7 +695,8 @@ class Auth04TokenAudienceAndPassthrough(Check):
|
|
|
690
695
|
raise NotApplicable(
|
|
691
696
|
"The forged-token probes were answered with "
|
|
692
697
|
+ ", ".join(f"HTTP {s}" if s else "no response" for s in statuses)
|
|
693
|
-
+ " rather than 401/403, so token validation could not be observed."
|
|
698
|
+
+ " rather than 401/403, so token validation could not be observed.",
|
|
699
|
+
blocked=True,
|
|
694
700
|
)
|
|
695
701
|
return findings
|
|
696
702
|
|
mcpprobe/checks/base.py
CHANGED
|
@@ -44,10 +44,13 @@ class NotApplicable(Exception): # noqa: N818 - it is an outcome, not an error
|
|
|
44
44
|
(e.g. ``"AUTH-01"``). A not-applicable check never counts as a pass.
|
|
45
45
|
"""
|
|
46
46
|
|
|
47
|
-
def __init__(self, reason: str, related_to: str | None = None) -> None:
|
|
47
|
+
def __init__(self, reason: str, related_to: str | None = None, *, blocked: bool = False) -> None:
|
|
48
48
|
super().__init__(reason)
|
|
49
49
|
self.reason = reason
|
|
50
50
|
self.related_to = related_to
|
|
51
|
+
#: True when the behaviour exists but could not be observed (authentication,
|
|
52
|
+
#: rate limiting, a fetch the operator disabled), as opposed to being absent.
|
|
53
|
+
self.blocked = blocked
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
@dataclass(slots=True)
|
|
@@ -140,9 +143,10 @@ class ScanContext:
|
|
|
140
143
|
"tools/list requires authentication and no credentials were supplied, so no tool "
|
|
141
144
|
"metadata could be read; re-run with --header 'Authorization: Bearer ...' to assess it.",
|
|
142
145
|
related_to=None,
|
|
146
|
+
blocked=True,
|
|
143
147
|
)
|
|
144
148
|
if inv.tools_error:
|
|
145
|
-
raise NotApplicable(f"the tool list could not be read ({inv.tools_error}).")
|
|
149
|
+
raise NotApplicable(f"the tool list could not be read ({inv.tools_error}).", blocked=True)
|
|
146
150
|
raise NotApplicable(
|
|
147
151
|
"the server lists no tools, prompts or resources, so there is no metadata to assess."
|
|
148
152
|
)
|
|
@@ -166,6 +170,9 @@ class Check(abc.ABC):
|
|
|
166
170
|
false_positive_notes: str = ""
|
|
167
171
|
#: Default remediation text.
|
|
168
172
|
fix: str = ""
|
|
173
|
+
#: When False (the default) a check that met an HTTP 429 the scanner could not wait
|
|
174
|
+
#: out is reported not applicable instead of drawing conclusions from it.
|
|
175
|
+
handles_rate_limit: bool = False
|
|
169
176
|
|
|
170
177
|
@abc.abstractmethod
|
|
171
178
|
async def run(self, ctx: ScanContext) -> list[Finding]:
|
mcpprobe/checks/capability.py
CHANGED
|
@@ -247,3 +247,109 @@ def arbitrary_capabilities(tool: dict[str, Any]) -> dict[str, str]:
|
|
|
247
247
|
|
|
248
248
|
def classify_tools(tools: list[dict[str, Any]]) -> dict[str, Classification]:
|
|
249
249
|
return {str(t.get("name", "")): classify_tool(t) for t in tools}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ----------------------------------------------------------------------------- toxic-flow roles (META-07)
|
|
253
|
+
ROLE_PRIVATE = "private_data"
|
|
254
|
+
ROLE_UNTRUSTED = "untrusted_content"
|
|
255
|
+
ROLE_SEND = "external_send"
|
|
256
|
+
|
|
257
|
+
#: Reading mail or messages: private data and, at the same time, content written by strangers.
|
|
258
|
+
_INBOX_READ = re.compile(
|
|
259
|
+
r"\b(inbox|mailbox|unread|read\s*_?(e-?mails?|mail|messages?)|"
|
|
260
|
+
r"(list|get|search|fetch|retrieve)\s*_?(e-?mails?|mail|messages?))\b",
|
|
261
|
+
re.I,
|
|
262
|
+
)
|
|
263
|
+
#: Pulling content from the open web.
|
|
264
|
+
_WEB_INTAKE = re.compile(
|
|
265
|
+
r"\b(fetch|browse|crawl|scrape|download|web\s*_?search|search\s*_?(the\s+)?web|"
|
|
266
|
+
r"(read|get|open|load)\s*_?(a\s+|the\s+)?(url|web\s*page|page|website)|rss|feed)\b",
|
|
267
|
+
re.I,
|
|
268
|
+
)
|
|
269
|
+
#: Moving data out of the server's own boundary.
|
|
270
|
+
_SEND_OUT = re.compile(
|
|
271
|
+
r"\b(send|sends|post|posts|publish|tweet|webhook|notify|upload|transmit|forward|reply|"
|
|
272
|
+
r"smtp|share|e-?mail\s+(to|a|an)\b)",
|
|
273
|
+
re.I,
|
|
274
|
+
)
|
|
275
|
+
#: Verbs that make an e-mail or payment tool a write rather than a read.
|
|
276
|
+
_MUTATING = re.compile(
|
|
277
|
+
r"\b(send|sends|post|charge|refund|pay|transfer|create|cancel|delete|update|write|forward|reply)\b",
|
|
278
|
+
re.I,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
#: Classes that change or destroy state on their own (no verb needed).
|
|
282
|
+
MUTATING_CLASSES: frozenset[str] = frozenset({"exec", "file_write", "database_write", "delete"})
|
|
283
|
+
#: Classes that are writes only when a mutating verb is present.
|
|
284
|
+
VERB_MUTATING_CLASSES: frozenset[str] = frozenset({"email", "payments"})
|
|
285
|
+
_PRIVATE_LABEL = {
|
|
286
|
+
"file_read": "files",
|
|
287
|
+
"database_read": "a database",
|
|
288
|
+
"database_write": "a database",
|
|
289
|
+
"credentials": "credentials",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#: Classes that only read.
|
|
293
|
+
READ_CLASSES: frozenset[str] = frozenset({"search", "file_read", "database_read"})
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _plain_text(tool: dict[str, Any]) -> str:
|
|
297
|
+
"""Name (with separators as spaces), title and description; parameter names excluded."""
|
|
298
|
+
name = str(tool.get("name", ""))
|
|
299
|
+
spaced = name.replace("_", " ").replace("-", " ").replace(".", " ")
|
|
300
|
+
return " ".join([name, spaced, str(tool.get("title") or ""), str(tool.get("description") or "")])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def looks_mutating(tool: dict[str, Any], cls: Classification) -> bool:
|
|
304
|
+
"""Whether the advertised capability changes state (used for annotation contradictions)."""
|
|
305
|
+
if ARBITRARY_EXEC in cls.arbitrary or ARBITRARY_FILE_WRITE in cls.arbitrary:
|
|
306
|
+
return True
|
|
307
|
+
if any(c in MUTATING_CLASSES for c in cls.classes):
|
|
308
|
+
return True
|
|
309
|
+
return any(c in VERB_MUTATING_CLASSES for c in cls.classes) and bool(_MUTATING.search(_plain_text(tool)))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def looks_destructive(cls: Classification) -> bool:
|
|
313
|
+
"""Execution, deletion and overwriting writes (destructiveHint:false would be wrong)."""
|
|
314
|
+
return (
|
|
315
|
+
ARBITRARY_EXEC in cls.arbitrary
|
|
316
|
+
or ARBITRARY_FILE_WRITE in cls.arbitrary
|
|
317
|
+
or any(c in MUTATING_CLASSES for c in cls.classes)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def looks_read_only(tool: dict[str, Any], cls: Classification) -> bool:
|
|
322
|
+
"""Only read classes matched and nothing suggests a write."""
|
|
323
|
+
return bool(cls.classes) and set(cls.classes) <= READ_CLASSES and not looks_mutating(tool, cls)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def toxic_roles(tool: dict[str, Any], cls: Classification) -> dict[str, str]:
|
|
327
|
+
"""Lethal-trifecta roles a tool can play, each with the reason (from its advertised metadata).
|
|
328
|
+
|
|
329
|
+
Arbitrary command execution plays all three (a shell reads local data, fetches
|
|
330
|
+
content and sends it anywhere); arbitrary file read is private data.
|
|
331
|
+
"""
|
|
332
|
+
text = _plain_text(tool)
|
|
333
|
+
roles: dict[str, str] = {}
|
|
334
|
+
if ARBITRARY_EXEC in cls.arbitrary:
|
|
335
|
+
why = "arbitrary command execution (see META-03)"
|
|
336
|
+
return {ROLE_PRIVATE: why, ROLE_UNTRUSTED: why, ROLE_SEND: why}
|
|
337
|
+
classes = set(cls.classes)
|
|
338
|
+
if ARBITRARY_FILE_READ in cls.arbitrary:
|
|
339
|
+
roles[ROLE_PRIVATE] = "arbitrary file read (see META-03)"
|
|
340
|
+
elif classes & set(_PRIVATE_LABEL):
|
|
341
|
+
labels = sorted({_PRIVATE_LABEL[c] for c in classes & set(_PRIVATE_LABEL)})
|
|
342
|
+
roles[ROLE_PRIVATE] = "accesses " + " and ".join(labels)
|
|
343
|
+
inbox = _INBOX_READ.search(text) if "email" in classes else None
|
|
344
|
+
if inbox:
|
|
345
|
+
roles.setdefault(ROLE_PRIVATE, f"reads mail or messages ('{inbox.group(0)}')")
|
|
346
|
+
roles[ROLE_UNTRUSTED] = f"returns mail or messages written by others ('{inbox.group(0)}')"
|
|
347
|
+
if "network" in classes:
|
|
348
|
+
web = _WEB_INTAKE.search(text)
|
|
349
|
+
if web:
|
|
350
|
+
roles[ROLE_UNTRUSTED] = f"pulls content from the web ('{web.group(0)}')"
|
|
351
|
+
if classes & {"email", "network"}:
|
|
352
|
+
out = _SEND_OUT.search(text)
|
|
353
|
+
if out:
|
|
354
|
+
roles[ROLE_SEND] = f"sends data out ('{out.group(0).strip()}')"
|
|
355
|
+
return roles
|
mcpprobe/checks/metadata.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import re
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
@@ -14,7 +13,6 @@ from mcpprobe.checks.capability import (
|
|
|
14
13
|
ARBITRARY_FILE_READ,
|
|
15
14
|
ARBITRARY_FILE_WRITE,
|
|
16
15
|
DANGEROUS_CLASSES,
|
|
17
|
-
Classification,
|
|
18
16
|
classify_tools,
|
|
19
17
|
)
|
|
20
18
|
from mcpprobe.checks.poisoning import collect_text_items
|
|
@@ -39,10 +37,13 @@ _CAPABILITY_LABEL = {
|
|
|
39
37
|
def _tools_verify(ctx: ScanContext, jq: str, expect: str, check_id: str) -> str:
|
|
40
38
|
if ctx.http is None:
|
|
41
39
|
return proof.rescan(ctx.target, check_id, stdio=True)
|
|
40
|
+
modern = ctx.fingerprint.flavour == "stateless-2026"
|
|
42
41
|
return proof.curl_rpc(
|
|
43
42
|
ctx.target,
|
|
44
43
|
"tools/list",
|
|
45
|
-
{},
|
|
44
|
+
proof.modern_params() if modern else {},
|
|
45
|
+
protocol_version="2026-07-28" if modern else "2025-11-25",
|
|
46
|
+
headers={"Mcp-Method": "tools/list"} if modern else None,
|
|
46
47
|
include_headers=False,
|
|
47
48
|
auth_placeholder=ctx.fingerprint.credentials_supplied,
|
|
48
49
|
jq=jq,
|
|
@@ -58,7 +59,7 @@ def _require_tools(ctx: ScanContext) -> None:
|
|
|
58
59
|
|
|
59
60
|
class Meta03DangerousCapabilitySurface(Check):
|
|
60
61
|
id = "META-03"
|
|
61
|
-
title = "Dangerous tool capability exposed
|
|
62
|
+
title = "Dangerous tool capability exposed"
|
|
62
63
|
severity = Severity.HIGH
|
|
63
64
|
standards = Standards(
|
|
64
65
|
mcp=["MCP02:2025", "MCP05:2025"],
|
|
@@ -71,9 +72,9 @@ class Meta03DangerousCapabilitySurface(Check):
|
|
|
71
72
|
"Tools that advertise arbitrary command execution, arbitrary file read or arbitrary file "
|
|
72
73
|
"write (a shell/file capability selected by a free-form string argument with no enum, "
|
|
73
74
|
"const or pattern) are rated high on their own and critical when the remote endpoint "
|
|
74
|
-
"needs no authentication.
|
|
75
|
-
"
|
|
76
|
-
"
|
|
75
|
+
"needs no authentication. The remaining high-impact surface (network, e-mail, "
|
|
76
|
+
"credentials, payments) is summarised at low severity. Annotations that contradict the "
|
|
77
|
+
"capability are reported by META-06 (until 0.2 they were reported here). Passive scanning sees the "
|
|
77
78
|
"advertised capability, not whether the implementation really executes: the rating is "
|
|
78
79
|
"based on the name, description and schema, and no tool is called."
|
|
79
80
|
)
|
|
@@ -87,8 +88,7 @@ class Meta03DangerousCapabilitySurface(Check):
|
|
|
87
88
|
fix = (
|
|
88
89
|
"For arbitrary command execution or file access: remove the tool, or sandbox it and "
|
|
89
90
|
"replace the free-form argument with an allow-list (enum) of commands or files. "
|
|
90
|
-
"Annotations come after that
|
|
91
|
-
"anything that executes or deletes); they help a careful client warn the user but do "
|
|
91
|
+
"Annotations come after that (META-06); they help a careful client warn the user but do "
|
|
92
92
|
"nothing about the risk."
|
|
93
93
|
)
|
|
94
94
|
|
|
@@ -193,78 +193,7 @@ class Meta03DangerousCapabilitySurface(Check):
|
|
|
193
193
|
)
|
|
194
194
|
)
|
|
195
195
|
|
|
196
|
-
# 2.
|
|
197
|
-
contradictions: dict[str, list[str]] = {}
|
|
198
|
-
for tool in ctx.inventory.tools:
|
|
199
|
-
name = str(tool.get("name", "?"))
|
|
200
|
-
cls: Classification = classes.get(name) or Classification(name=name)
|
|
201
|
-
ann = tool.get("annotations")
|
|
202
|
-
if not isinstance(ann, dict):
|
|
203
|
-
continue
|
|
204
|
-
problems: list[str] = []
|
|
205
|
-
looks = "command execution" if cls.arbitrary.get(ARBITRARY_EXEC) else cls.primary
|
|
206
|
-
if ann.get("readOnlyHint") is True and (cls.writes or ARBITRARY_EXEC in cls.arbitrary):
|
|
207
|
-
problems.append(f"readOnlyHint=true but tool looks like {looks}")
|
|
208
|
-
if ann.get("destructiveHint") is False and (
|
|
209
|
-
any(c in {"exec", "delete", "file_write", "database_write"} for c in cls.classes)
|
|
210
|
-
or ARBITRARY_EXEC in cls.arbitrary
|
|
211
|
-
):
|
|
212
|
-
problems.append(f"destructiveHint=false but tool looks like {looks}")
|
|
213
|
-
if ann.get("openWorldHint") is False and "network" in cls.classes:
|
|
214
|
-
problems.append("openWorldHint=false but tool appears to make network requests")
|
|
215
|
-
if problems:
|
|
216
|
-
contradictions[name] = problems
|
|
217
|
-
if contradictions:
|
|
218
|
-
names = sorted(contradictions)
|
|
219
|
-
findings.append(
|
|
220
|
-
self.finding(
|
|
221
|
-
title="Tool annotations contradict the tool's apparent capability",
|
|
222
|
-
description="; ".join(f"Tool '{n}': " + "; ".join(contradictions[n]) for n in names)
|
|
223
|
-
+ ". Clients that trust annotations may skip the user's approval for these tools.",
|
|
224
|
-
severity=Severity.MEDIUM,
|
|
225
|
-
confidence=Confidence.MEDIUM,
|
|
226
|
-
confidence_reason=(
|
|
227
|
-
"The capability is inferred from keywords in the name, description and "
|
|
228
|
-
"parameter names, which can misclassify a narrowly scoped tool."
|
|
229
|
-
),
|
|
230
|
-
evidence=ev,
|
|
231
|
-
excerpt=evidence_summary(
|
|
232
|
-
ev, "; ".join(f"{n}: annotations={by_name[n].get('annotations')}" for n in names)
|
|
233
|
-
),
|
|
234
|
-
location="tools[*].annotations",
|
|
235
|
-
details={
|
|
236
|
-
"tools": {
|
|
237
|
-
n: {
|
|
238
|
-
"problems": contradictions[n],
|
|
239
|
-
"annotations": by_name[n].get("annotations"),
|
|
240
|
-
"classes": classes[n].classes,
|
|
241
|
-
"matched_terms": classes[n].matched_terms,
|
|
242
|
-
}
|
|
243
|
-
for n in names
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
fix=(
|
|
247
|
-
"Make annotations truthful: readOnlyHint only for tools with no side effects, "
|
|
248
|
-
"destructiveHint:true for anything that deletes or executes, openWorldHint:true "
|
|
249
|
-
"for tools that reach the network."
|
|
250
|
-
),
|
|
251
|
-
affected=names,
|
|
252
|
-
proof="\n\n".join(
|
|
253
|
-
f"tools[{n}]:\n description: {by_name[n].get('description', '')}\n"
|
|
254
|
-
f" annotations: {json.dumps(by_name[n].get('annotations'))}\n"
|
|
255
|
-
+ "\n".join(f" - {p}" for p in contradictions[n])
|
|
256
|
-
for n in names
|
|
257
|
-
),
|
|
258
|
-
verify=_tools_verify(
|
|
259
|
-
ctx,
|
|
260
|
-
".result.tools[] | {name, annotations}",
|
|
261
|
-
"annotations that match each tool's behaviour",
|
|
262
|
-
self.id,
|
|
263
|
-
),
|
|
264
|
-
)
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
# 3. Remaining high-impact surface (informational review prompt).
|
|
196
|
+
# 2. Remaining high-impact surface (informational review prompt).
|
|
268
197
|
rest = {n: c.classes for n, c in classes.items() if c.dangerous and n not in arbitrary}
|
|
269
198
|
by_class: dict[str, list[str]] = {}
|
|
270
199
|
for n, cl in rest.items():
|
mcpprobe/checks/poisoning.py
CHANGED
|
@@ -595,10 +595,13 @@ def _verify(ctx: ScanContext, item: TextItem) -> str:
|
|
|
595
595
|
return proof.rescan(ctx.target, "META-01", stdio=True)
|
|
596
596
|
if item.kind in {"tool", "tool_param"}:
|
|
597
597
|
jq = f'.result.tools[] | select(.name=="{item.owner}") | {{description, inputSchema}}'
|
|
598
|
+
modern = ctx.fingerprint.flavour == "stateless-2026"
|
|
598
599
|
return proof.curl_rpc(
|
|
599
600
|
ctx.target,
|
|
600
601
|
"tools/list",
|
|
601
|
-
{},
|
|
602
|
+
proof.modern_params() if modern else {},
|
|
603
|
+
protocol_version="2026-07-28" if modern else "2025-11-25",
|
|
604
|
+
headers={"Mcp-Method": "tools/list"} if modern else None,
|
|
602
605
|
include_headers=False,
|
|
603
606
|
auth_placeholder=ctx.fingerprint.credentials_supplied,
|
|
604
607
|
jq=jq,
|
mcpprobe/checks/proof.py
CHANGED
|
@@ -144,6 +144,15 @@ def curl_rpc(
|
|
|
144
144
|
return line
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
def modern_params(version: str = "2026-07-28", **extra: Any) -> dict[str, Any]:
|
|
148
|
+
"""Params for a 2026-07-28 request: the required ``_meta`` plus ``extra``."""
|
|
149
|
+
meta = {
|
|
150
|
+
"io.modelcontextprotocol/protocolVersion": version,
|
|
151
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
152
|
+
}
|
|
153
|
+
return {**extra, "_meta": meta}
|
|
154
|
+
|
|
155
|
+
|
|
147
156
|
def initialize_params(version: str = "2025-11-25") -> dict[str, Any]:
|
|
148
157
|
return {"protocolVersion": version, "capabilities": {}, "clientInfo": VERIFY_CLIENT}
|
|
149
158
|
|