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,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]