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
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""META-01: static tool-description poisoning heuristics.
|
|
2
|
+
|
|
3
|
+
The analysis is deliberately heuristic and tiered. It never produces a
|
|
4
|
+
Critical finding on its own; the confidence attached to each finding reflects
|
|
5
|
+
how specific the matched indicators are. An optional judge hook can be plugged
|
|
6
|
+
in via :class:`~mcpprobe.checks.base.ScanOptions.judge`; mcpprobe itself makes
|
|
7
|
+
no LLM calls.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from mcpprobe.checks._util import excerpt
|
|
21
|
+
from mcpprobe.checks.base import Check, ScanContext
|
|
22
|
+
from mcpprobe.models import Confidence, Finding, Severity, Standards
|
|
23
|
+
|
|
24
|
+
MAX_DESCRIPTION_LENGTH = 2000
|
|
25
|
+
|
|
26
|
+
_MARKUP = re.compile(
|
|
27
|
+
r"<\s*/?\s*(important|system|instructions?|secret|hidden|assistant|prompt|context)\b[^>]*>"
|
|
28
|
+
r"|<!--|-->|\[/?INST\]|<<\s*/?SYS\s*>>|^\s*#+\s*system\b|\bSYSTEM\s*:",
|
|
29
|
+
re.I | re.M,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_HIDDEN_INSTRUCTIONS: list[tuple[str, re.Pattern[str]]] = [
|
|
33
|
+
(
|
|
34
|
+
"conceal from user",
|
|
35
|
+
re.compile(
|
|
36
|
+
r"\b(do\s*n[o']t|never|must\s+not|should\s+not|without)\s+(tell(ing)?|inform(ing)?|"
|
|
37
|
+
r"mention(ing)?|show(ing)?|display(ing)?|reveal(ing)?|disclos(e|ing)|alert(ing)?)\s+"
|
|
38
|
+
r"(the\s+|to\s+the\s+)?(user|human|operator|person)\b"
|
|
39
|
+
r"|\b(keep|hide)\s+(this|it)\s+(secret|hidden|from\s+the\s+user)\b"
|
|
40
|
+
r"|\bimplementation\s+detail\b|\bsilently\b|\bsecretly\b|\bcovertly\b|\bdiscreetly\b",
|
|
41
|
+
re.I,
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"override instructions",
|
|
46
|
+
re.compile(
|
|
47
|
+
r"\b(ignore|disregard|forget|override|bypass)\s+(all\s+|any\s+|the\s+|your\s+)?"
|
|
48
|
+
r"(previous|prior|above|earlier|other|system|safety)?\s*(instructions?|rules?|guidelines?|"
|
|
49
|
+
r"prompts?|constraints?|policies)\b"
|
|
50
|
+
r"|\bregardless\s+of\s+what\s+the\s+user\b|\byou\s+are\s+now\b|\bnew\s+instructions?\s*:",
|
|
51
|
+
re.I,
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"read and forward data",
|
|
56
|
+
re.compile(
|
|
57
|
+
r"\b(read|open|cat|load|access|collect|gather)\s+(the\s+)?(file|contents?\s+of|~|/|\$HOME|%)"
|
|
58
|
+
r"|\b(pass|include|send|put|insert|embed|append|attach)\s+(its|the|their|all)\s+"
|
|
59
|
+
r"(contents?|value|data|text|output)\b"
|
|
60
|
+
r"|\bas\s+(a\s+)?(side\s*note|hidden\s+parameter|extra\s+(parameter|argument))\b"
|
|
61
|
+
r"|\b(exfiltrate|upload|forward|transmit|leak)\b",
|
|
62
|
+
re.I,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"prerequisite coercion",
|
|
67
|
+
re.compile(
|
|
68
|
+
r"\b(before|prior\s+to)\s+(using|calling|invoking|running)\s+(this|any|the)\s+tool"
|
|
69
|
+
r"|\bthis\s+is\s+(a\s+)?(required|mandatory|necessary)\s+step\b"
|
|
70
|
+
r"|\b(the\s+)?(assistant|model|ai|agent|you)\s+(must|should|need\s+to|has\s+to|have\s+to)\s+"
|
|
71
|
+
r"(always\s+)?(first\s+)?(read|include|call|send|run|pass|execute|fetch)\b",
|
|
72
|
+
re.I,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"redirect or replace",
|
|
77
|
+
re.compile(
|
|
78
|
+
r"\b(instead\s+of|rather\s+than|in\s+place\s+of)\s+(the\s+)?(intended|original|requested|user)"
|
|
79
|
+
r"|\b(always\s+)?(bcc|cc|copy|forward)\s+(to\s+)?[\w.+-]+@[\w-]+\.[\w.-]+"
|
|
80
|
+
r"|\b(redirect|reroute|change)\s+(all\s+)?(emails?|messages?|payments?|requests?|output)\b"
|
|
81
|
+
r"|\b(replace|substitute|swap)\s+(the\s+)?(recipient|address|url|destination|account)\b",
|
|
82
|
+
re.I,
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
_SENSITIVE_PATHS = re.compile(
|
|
88
|
+
r"(~|\$HOME|%USERPROFILE%|/home/\w+|/Users/\w+|C:\\Users\\\w+)?"
|
|
89
|
+
r"([/\\]\.ssh\b|\bid_(rsa|ed25519|ecdsa|dsa)\b|[/\\]\.aws[/\\]credentials|\bmcp\.json\b|"
|
|
90
|
+
r"[/\\]\.cursor\b|[/\\]\.claude\b|[/\\]\.config[/\\]|(^|[\s/\\\"'])\.env\b|/etc/(passwd|shadow|hosts)|"
|
|
91
|
+
r"[/\\]\.npmrc\b|[/\\]\.pypirc\b|[/\\]\.git-credentials\b|[/\\]\.kube[/\\]config|"
|
|
92
|
+
r"[/\\]\.docker[/\\]config\.json|\bwallet\.dat\b|\bkeychain\b|[/\\]\.gnupg\b|"
|
|
93
|
+
r"[/\\]\.bash_history\b|[/\\]\.netrc\b|\b(private|secret)[_ ]?key\b|/proc/self/environ)",
|
|
94
|
+
re.I,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
_URL = re.compile(r"\bhttps?://[^\s<>\"')\]]+", re.I)
|
|
98
|
+
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
|
99
|
+
_BASE64 = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])")
|
|
100
|
+
|
|
101
|
+
_ZERO_WIDTH = {
|
|
102
|
+
0x200B,
|
|
103
|
+
0x200C,
|
|
104
|
+
0x200D,
|
|
105
|
+
0x200E,
|
|
106
|
+
0x200F,
|
|
107
|
+
0x2060,
|
|
108
|
+
0x2061,
|
|
109
|
+
0x2062,
|
|
110
|
+
0x2063,
|
|
111
|
+
0x2064,
|
|
112
|
+
0xFEFF,
|
|
113
|
+
0x180E,
|
|
114
|
+
0x00AD,
|
|
115
|
+
}
|
|
116
|
+
_BIDI = set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A))
|
|
117
|
+
_TAG_BLOCK = range(0xE0000, 0xE0080)
|
|
118
|
+
|
|
119
|
+
_SHADOW_CONTEXT = re.compile(
|
|
120
|
+
r"\b(instead|before|after|when|whenever|always|never|redirect|replace|modify|override|"
|
|
121
|
+
r"intercept|wrap|in\s+place\s+of|rather\s+than|prefer|do\s+not\s+use|don't\s+use|ignore)\b",
|
|
122
|
+
re.I,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class Indicator:
|
|
128
|
+
kind: str
|
|
129
|
+
severity: Severity
|
|
130
|
+
confidence: Confidence
|
|
131
|
+
detail: str
|
|
132
|
+
matches: list[str] = field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class TextItem:
|
|
137
|
+
"""A piece of server-supplied text under review."""
|
|
138
|
+
|
|
139
|
+
kind: str # tool | tool_param | prompt | prompt_arg | resource | instructions
|
|
140
|
+
owner: str # tool/prompt/resource name, or 'server'
|
|
141
|
+
path: str # human-readable location, e.g. tools[add].description
|
|
142
|
+
text: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _script_of(ch: str) -> str | None:
|
|
146
|
+
if not ch.isalpha():
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
name = unicodedata.name(ch)
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
for script in ("LATIN", "CYRILLIC", "GREEK", "ARMENIAN", "CHEROKEE"):
|
|
153
|
+
if name.startswith(script):
|
|
154
|
+
return script
|
|
155
|
+
return "OTHER"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def find_mixed_script_words(text: str) -> list[str]:
|
|
159
|
+
"""Words mixing Latin with Cyrillic/Greek/etc. letters (homoglyph smuggling)."""
|
|
160
|
+
out: list[str] = []
|
|
161
|
+
for word in re.findall(r"\w+", text):
|
|
162
|
+
scripts = {s for s in (_script_of(c) for c in word) if s and s != "OTHER"}
|
|
163
|
+
if "LATIN" in scripts and len(scripts) > 1:
|
|
164
|
+
out.append(word)
|
|
165
|
+
return out[:10]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def find_invisible_unicode(text: str) -> dict[str, int]:
|
|
169
|
+
counts = {"zero_width": 0, "bidi_control": 0, "tag_characters": 0}
|
|
170
|
+
for ch in text:
|
|
171
|
+
cp = ord(ch)
|
|
172
|
+
if cp in _ZERO_WIDTH:
|
|
173
|
+
counts["zero_width"] += 1
|
|
174
|
+
elif cp in _BIDI:
|
|
175
|
+
counts["bidi_control"] += 1
|
|
176
|
+
elif cp in _TAG_BLOCK:
|
|
177
|
+
counts["tag_characters"] += 1
|
|
178
|
+
return {k: v for k, v in counts.items() if v}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _looks_like_base64(blob: str) -> bool:
|
|
182
|
+
try:
|
|
183
|
+
decoded = base64.b64decode(blob + "=" * (-len(blob) % 4), validate=True)
|
|
184
|
+
except (binascii.Error, ValueError):
|
|
185
|
+
return False
|
|
186
|
+
printable = sum(1 for b in decoded if 32 <= b < 127 or b in (9, 10, 13))
|
|
187
|
+
# Either decodes to mostly text (hidden instructions) or is long opaque data.
|
|
188
|
+
return printable / max(1, len(decoded)) > 0.8 or len(blob) >= 120
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: str = "") -> list[Indicator]:
|
|
192
|
+
"""Run every heuristic over one text and return the indicators found."""
|
|
193
|
+
indicators: list[Indicator] = []
|
|
194
|
+
if not text:
|
|
195
|
+
return indicators
|
|
196
|
+
|
|
197
|
+
invisible = find_invisible_unicode(text)
|
|
198
|
+
if invisible:
|
|
199
|
+
indicators.append(
|
|
200
|
+
Indicator(
|
|
201
|
+
"invisible_unicode",
|
|
202
|
+
Severity.HIGH,
|
|
203
|
+
Confidence.HIGH,
|
|
204
|
+
"invisible or direction-control Unicode characters present: "
|
|
205
|
+
+ ", ".join(f"{k}={v}" for k, v in invisible.items()),
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
homoglyphs = find_mixed_script_words(text)
|
|
209
|
+
if homoglyphs:
|
|
210
|
+
indicators.append(
|
|
211
|
+
Indicator(
|
|
212
|
+
"homoglyph",
|
|
213
|
+
Severity.MEDIUM,
|
|
214
|
+
Confidence.MEDIUM,
|
|
215
|
+
"words mix Latin with other scripts (possible homoglyph smuggling)",
|
|
216
|
+
homoglyphs,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
markup = [m.group(0) for m in _MARKUP.finditer(text)]
|
|
221
|
+
if markup:
|
|
222
|
+
indicators.append(
|
|
223
|
+
Indicator(
|
|
224
|
+
"hidden_markup",
|
|
225
|
+
Severity.HIGH,
|
|
226
|
+
Confidence.MEDIUM,
|
|
227
|
+
"prompt-style markup inside a description",
|
|
228
|
+
sorted(set(markup))[:5],
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
instruction_kinds: list[str] = []
|
|
233
|
+
instruction_matches: list[str] = []
|
|
234
|
+
for label, pattern in _HIDDEN_INSTRUCTIONS:
|
|
235
|
+
found = [m.group(0) for m in pattern.finditer(text)]
|
|
236
|
+
if found:
|
|
237
|
+
instruction_kinds.append(label)
|
|
238
|
+
instruction_matches.extend(found[:3])
|
|
239
|
+
if instruction_kinds:
|
|
240
|
+
strong = "conceal from user" in instruction_kinds or "override instructions" in instruction_kinds
|
|
241
|
+
indicators.append(
|
|
242
|
+
Indicator(
|
|
243
|
+
"hidden_instruction",
|
|
244
|
+
Severity.HIGH if strong or len(instruction_kinds) >= 2 else Severity.MEDIUM,
|
|
245
|
+
Confidence.HIGH if strong and len(instruction_kinds) >= 2 else Confidence.MEDIUM,
|
|
246
|
+
"imperative instructions aimed at the model: " + ", ".join(instruction_kinds),
|
|
247
|
+
instruction_matches[:6],
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
paths = sorted({m.group(0).strip() for m in _SENSITIVE_PATHS.finditer(text)})
|
|
252
|
+
if paths:
|
|
253
|
+
indicators.append(
|
|
254
|
+
Indicator(
|
|
255
|
+
"sensitive_path",
|
|
256
|
+
Severity.MEDIUM,
|
|
257
|
+
Confidence.MEDIUM,
|
|
258
|
+
"references to credential or configuration file paths",
|
|
259
|
+
paths[:6],
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
lowered = text.lower()
|
|
264
|
+
refs = []
|
|
265
|
+
for name in other_tool_names:
|
|
266
|
+
if not name or name == own_name or len(name) < 3:
|
|
267
|
+
continue
|
|
268
|
+
if re.search(rf"(?<![\w.]){re.escape(name.lower())}(?!\w)(?!\.\w)", lowered):
|
|
269
|
+
refs.append(name)
|
|
270
|
+
if refs:
|
|
271
|
+
shadowing = bool(_SHADOW_CONTEXT.search(text))
|
|
272
|
+
indicators.append(
|
|
273
|
+
Indicator(
|
|
274
|
+
"cross_tool_reference",
|
|
275
|
+
Severity.MEDIUM if shadowing else Severity.LOW,
|
|
276
|
+
Confidence.MEDIUM if shadowing else Confidence.LOW,
|
|
277
|
+
"mentions other tools"
|
|
278
|
+
+ (" with behaviour-modifying language (shadowing)" if shadowing else ""),
|
|
279
|
+
refs[:6],
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
urls = sorted({m.group(0) for m in _URL.finditer(text)})
|
|
284
|
+
if urls:
|
|
285
|
+
exfil = bool(re.search(r"\b(send|post|upload|forward|submit|report)\b", lowered))
|
|
286
|
+
indicators.append(
|
|
287
|
+
Indicator(
|
|
288
|
+
"url",
|
|
289
|
+
Severity.MEDIUM if exfil else Severity.LOW,
|
|
290
|
+
Confidence.MEDIUM if exfil else Confidence.LOW,
|
|
291
|
+
"URL inside description" + (" alongside send/upload wording" if exfil else ""),
|
|
292
|
+
urls[:4],
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
emails = sorted({m.group(0) for m in _EMAIL.finditer(text)})
|
|
296
|
+
if emails and re.search(r"\b(bcc|cc|send|forward|copy)\b", lowered):
|
|
297
|
+
indicators.append(
|
|
298
|
+
Indicator(
|
|
299
|
+
"email_redirect",
|
|
300
|
+
Severity.HIGH,
|
|
301
|
+
Confidence.MEDIUM,
|
|
302
|
+
"email address with send/bcc/forward wording",
|
|
303
|
+
emails[:3],
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
blobs = [m.group(0) for m in _BASE64.finditer(text) if _looks_like_base64(m.group(0))]
|
|
308
|
+
if blobs:
|
|
309
|
+
indicators.append(
|
|
310
|
+
Indicator(
|
|
311
|
+
"base64_blob",
|
|
312
|
+
Severity.MEDIUM,
|
|
313
|
+
Confidence.MEDIUM,
|
|
314
|
+
"base64-looking blob embedded in text",
|
|
315
|
+
[b[:24] + "\u2026" for b in blobs[:3]],
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if len(text) > MAX_DESCRIPTION_LENGTH:
|
|
320
|
+
indicators.append(
|
|
321
|
+
Indicator(
|
|
322
|
+
"excessive_length",
|
|
323
|
+
Severity.LOW,
|
|
324
|
+
Confidence.LOW,
|
|
325
|
+
f"description is {len(text)} characters (limit {MAX_DESCRIPTION_LENGTH})",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return indicators
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def collect_text_items(ctx: ScanContext) -> list[TextItem]:
|
|
332
|
+
"""Gather every server-supplied natural-language string worth scanning."""
|
|
333
|
+
items: list[TextItem] = []
|
|
334
|
+
if ctx.fingerprint.instructions:
|
|
335
|
+
items.append(
|
|
336
|
+
TextItem("instructions", "server", "initialize.instructions", ctx.fingerprint.instructions)
|
|
337
|
+
)
|
|
338
|
+
for tool in ctx.inventory.tools:
|
|
339
|
+
name = str(tool.get("name", "?"))
|
|
340
|
+
for key in ("description", "title"):
|
|
341
|
+
if isinstance(tool.get(key), str) and tool[key]:
|
|
342
|
+
items.append(TextItem("tool", name, f"tools[{name}].{key}", tool[key]))
|
|
343
|
+
schema = tool.get("inputSchema")
|
|
344
|
+
if isinstance(schema, dict):
|
|
345
|
+
for pname, pdef in (
|
|
346
|
+
(schema.get("properties") or {}).items() if isinstance(schema.get("properties"), dict) else []
|
|
347
|
+
):
|
|
348
|
+
if isinstance(pdef, dict) and isinstance(pdef.get("description"), str):
|
|
349
|
+
items.append(
|
|
350
|
+
TextItem(
|
|
351
|
+
"tool_param",
|
|
352
|
+
name,
|
|
353
|
+
f"tools[{name}].inputSchema.{pname}.description",
|
|
354
|
+
pdef["description"],
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
for prompt in ctx.inventory.prompts:
|
|
358
|
+
name = str(prompt.get("name", "?"))
|
|
359
|
+
if isinstance(prompt.get("description"), str):
|
|
360
|
+
items.append(TextItem("prompt", name, f"prompts[{name}].description", prompt["description"]))
|
|
361
|
+
for arg in prompt.get("arguments") or []:
|
|
362
|
+
if isinstance(arg, dict) and isinstance(arg.get("description"), str):
|
|
363
|
+
items.append(
|
|
364
|
+
TextItem(
|
|
365
|
+
"prompt_arg",
|
|
366
|
+
name,
|
|
367
|
+
f"prompts[{name}].arguments[{arg.get('name')}]",
|
|
368
|
+
arg["description"],
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
for res in ctx.inventory.resources:
|
|
372
|
+
name = str(res.get("name") or res.get("uri") or "?")
|
|
373
|
+
for key in ("description", "title"):
|
|
374
|
+
if isinstance(res.get(key), str) and res[key]:
|
|
375
|
+
items.append(TextItem("resource", name, f"resources[{name}].{key}", res[key]))
|
|
376
|
+
return items
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Meta01ToolDescriptionPoisoning(Check):
|
|
380
|
+
id = "META-01"
|
|
381
|
+
title = "Possible tool-description poisoning"
|
|
382
|
+
severity = Severity.HIGH
|
|
383
|
+
standards = Standards(
|
|
384
|
+
mcp=["MCP03:2025"], asi=["ASI01", "ASI04"], llm_2025=["LLM01:2025"], llm_2026=["LLM01:2026"]
|
|
385
|
+
)
|
|
386
|
+
description = (
|
|
387
|
+
"Scans every tool, prompt and resource description, every inputSchema parameter "
|
|
388
|
+
"description and the server 'instructions' for: prompt-style markup (<IMPORTANT>, "
|
|
389
|
+
"<system>), instructions aimed at the model (conceal from user, override instructions, "
|
|
390
|
+
"read-and-forward, prerequisite coercion, redirect/replace), credential file paths, "
|
|
391
|
+
"references to other tools with behaviour-modifying language (shadowing), URLs and "
|
|
392
|
+
"e-mail addresses with send/BCC wording, base64 blobs, zero-width/bidi/tag Unicode, "
|
|
393
|
+
"mixed-script homoglyphs and excessive length. Findings carry tiered confidence and "
|
|
394
|
+
"are never rated Critical on heuristics alone."
|
|
395
|
+
)
|
|
396
|
+
evidence_type = "The tools/list (or prompts/resources/initialize) response and the matched excerpts."
|
|
397
|
+
false_positive_notes = (
|
|
398
|
+
"Legitimate guidance such as 'IMPORTANT: call authenticate before this tool' matches the "
|
|
399
|
+
"markup and prerequisite heuristics at medium confidence. URLs to documentation and "
|
|
400
|
+
"mentions of sibling tools without behaviour-modifying words are reported at low severity. "
|
|
401
|
+
"Review the matched excerpts; a second-opinion judge can be plugged in through the Python API (ScanOptions.judge); mcpprobe itself makes no LLM calls."
|
|
402
|
+
)
|
|
403
|
+
fix = (
|
|
404
|
+
"Remove any text addressed to the model rather than describing the tool. Keep descriptions "
|
|
405
|
+
"short and factual, strip invisible Unicode, and never reference other servers' tools. "
|
|
406
|
+
"Pin and hash approved descriptions in clients (see META-02)."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
410
|
+
names = ctx.tool_names()
|
|
411
|
+
findings: list[Finding] = []
|
|
412
|
+
evidence_map = {
|
|
413
|
+
"tool": ctx.inventory.tools_evidence,
|
|
414
|
+
"tool_param": ctx.inventory.tools_evidence,
|
|
415
|
+
"prompt": ctx.inventory.prompts_evidence,
|
|
416
|
+
"prompt_arg": ctx.inventory.prompts_evidence,
|
|
417
|
+
"resource": ctx.inventory.resources_evidence,
|
|
418
|
+
"instructions": None,
|
|
419
|
+
}
|
|
420
|
+
for item in collect_text_items(ctx):
|
|
421
|
+
indicators = analyse_text(item.text, other_tool_names=names, own_name=item.owner)
|
|
422
|
+
if ctx.options.judge is not None:
|
|
423
|
+
verdict = ctx.options.judge(item.text, f"tool-description poisoning review for {item.path}")
|
|
424
|
+
if verdict is not None and verdict.suspicious:
|
|
425
|
+
indicators.append(
|
|
426
|
+
Indicator(
|
|
427
|
+
"judge",
|
|
428
|
+
Severity.MEDIUM,
|
|
429
|
+
verdict.confidence,
|
|
430
|
+
"judge flagged: " + (verdict.rationale or "no rationale"),
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
if not indicators:
|
|
434
|
+
continue
|
|
435
|
+
severity = max((i.severity for i in indicators), key=lambda s: s.rank)
|
|
436
|
+
confidence = _combine_confidence(indicators)
|
|
437
|
+
ev = evidence_map.get(item.kind)
|
|
438
|
+
findings.append(
|
|
439
|
+
self.finding(
|
|
440
|
+
description=(
|
|
441
|
+
f"{item.path}: "
|
|
442
|
+
+ "; ".join(i.detail for i in indicators)
|
|
443
|
+
+ f'. Excerpt: "{excerpt(item.text)}"'
|
|
444
|
+
),
|
|
445
|
+
severity=severity,
|
|
446
|
+
confidence=confidence,
|
|
447
|
+
evidence=[ev] if ev else [],
|
|
448
|
+
excerpt=f'{item.path}: "{excerpt(item.text, 240)}"',
|
|
449
|
+
location=item.path,
|
|
450
|
+
details={
|
|
451
|
+
"indicators": [
|
|
452
|
+
{
|
|
453
|
+
"kind": i.kind,
|
|
454
|
+
"severity": i.severity.value,
|
|
455
|
+
"confidence": i.confidence.value,
|
|
456
|
+
"detail": i.detail,
|
|
457
|
+
"matches": i.matches,
|
|
458
|
+
}
|
|
459
|
+
for i in indicators
|
|
460
|
+
],
|
|
461
|
+
"length": len(item.text),
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
return findings
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _combine_confidence(indicators: list[Indicator]) -> Confidence:
|
|
469
|
+
highs = sum(1 for i in indicators if i.confidence == Confidence.HIGH)
|
|
470
|
+
mediums = sum(1 for i in indicators if i.confidence == Confidence.MEDIUM)
|
|
471
|
+
if highs or mediums >= 3:
|
|
472
|
+
return Confidence.HIGH
|
|
473
|
+
if mediums:
|
|
474
|
+
return Confidence.MEDIUM
|
|
475
|
+
return Confidence.LOW
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def tool_descriptions(tools: list[dict[str, Any]]) -> list[str]:
|
|
479
|
+
"""Convenience for callers that just want the descriptions."""
|
|
480
|
+
return [str(t.get("description", "")) for t in tools]
|