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,341 @@
1
+ """META-03, META-04, META-05: capability surface, schema hygiene and secrets in metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ from mcpprobe.checks._util import excerpt, shannon_entropy
10
+ from mcpprobe.checks.base import Check, ScanContext, evidence_summary
11
+ from mcpprobe.checks.capability import DANGEROUS_CLASSES, Classification, classify_tools
12
+ from mcpprobe.checks.poisoning import collect_text_items
13
+ from mcpprobe.models import Confidence, EvidenceRecord, Finding, Severity, Standards
14
+ from mcpprobe.redact import SECRET_PATTERNS, redact, redact_secrets
15
+
16
+ TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
17
+ RISKY_ARG_NAME = re.compile(
18
+ r"(path|file|dir|folder|command|cmd|shell|script|url|uri|href|query|sql|code|expr|template|host|address)",
19
+ re.I,
20
+ )
21
+
22
+
23
+ # ----------------------------------------------------------------------------- META-03
24
+ class Meta03DangerousCapabilitySurface(Check):
25
+ id = "META-03"
26
+ title = "Tool annotations contradict the tool's apparent capability"
27
+ severity = Severity.MEDIUM
28
+ standards = Standards(
29
+ mcp=["MCP02:2025", "MCP05:2025"],
30
+ asi=["ASI02", "ASI05"],
31
+ llm_2025=["LLM06:2025"],
32
+ llm_2026=["LLM03:2026"],
33
+ )
34
+ description = (
35
+ "Classifies each tool by name, description and parameter names into capability classes "
36
+ "(exec, file_write, file_read, database_*, network, email, credentials, payments, delete, "
37
+ "search) and cross-checks the declared annotations: a tool that looks like it runs "
38
+ "commands or sends e-mail but is marked readOnlyHint:true, or an exec/delete tool marked "
39
+ "destructiveHint:false, is reported. A summary of the dangerous surface is reported at low "
40
+ "severity. Annotations are treated as untrusted hints, as the spec requires of clients."
41
+ )
42
+ evidence_type = "The tools/list response; matched terms per tool are listed in details."
43
+ false_positive_notes = (
44
+ "Classification is keyword-based: a tool named 'delete_draft' that only removes the "
45
+ "user's own draft will be classed 'delete'. Confidence is medium for contradictions and "
46
+ "the dangerous-surface summary is informational."
47
+ )
48
+ fix = (
49
+ "Make annotations truthful (readOnlyHint only for tools with no side effects, "
50
+ "destructiveHint:true for anything that deletes or executes), and consider splitting "
51
+ "broad tools into narrower ones so clients can gate approval."
52
+ )
53
+
54
+ async def run(self, ctx: ScanContext) -> list[Finding]:
55
+ classes = classify_tools(ctx.inventory.tools)
56
+ ctx.shared["capabilities"] = classes
57
+ findings: list[Finding] = []
58
+ ev = [ctx.inventory.tools_evidence] if ctx.inventory.tools_evidence else []
59
+ for tool in ctx.inventory.tools:
60
+ name = str(tool.get("name", "?"))
61
+ cls: Classification = classes.get(name) or Classification(name=name)
62
+ ann = tool.get("annotations")
63
+ if not isinstance(ann, dict):
64
+ continue
65
+ problems: list[str] = []
66
+ if ann.get("readOnlyHint") is True and cls.writes:
67
+ problems.append(f"readOnlyHint=true but tool looks like {cls.primary}")
68
+ if ann.get("destructiveHint") is False and any(
69
+ c in {"exec", "delete", "file_write", "database_write"} for c in cls.classes
70
+ ):
71
+ problems.append(f"destructiveHint=false but tool looks like {cls.primary}")
72
+ if ann.get("openWorldHint") is False and "network" in cls.classes:
73
+ problems.append("openWorldHint=false but tool appears to make network requests")
74
+ if problems:
75
+ findings.append(
76
+ self.finding(
77
+ description=f"Tool '{name}': "
78
+ + "; ".join(problems)
79
+ + f". Matched terms: {cls.matched_terms}.",
80
+ severity=Severity.MEDIUM,
81
+ confidence=Confidence.MEDIUM,
82
+ evidence=ev,
83
+ excerpt=evidence_summary(
84
+ ev,
85
+ f"tools[{name}] description={excerpt(str(tool.get('description', '')), 80)!r} annotations={ann}",
86
+ ),
87
+ location=f"tools[{name}].annotations",
88
+ details={
89
+ "classes": cls.classes,
90
+ "annotations": ann,
91
+ "matched_terms": cls.matched_terms,
92
+ },
93
+ )
94
+ )
95
+ dangerous = {n: c.classes for n, c in classes.items() if c.dangerous}
96
+ if dangerous:
97
+ by_class: dict[str, list[str]] = {}
98
+ for n, cl in dangerous.items():
99
+ for c in cl:
100
+ if c in DANGEROUS_CLASSES:
101
+ by_class.setdefault(c, []).append(n)
102
+ findings.append(
103
+ self.finding(
104
+ title="Dangerous capability surface exposed",
105
+ description="Tools classified as high-impact: "
106
+ + "; ".join(f"{c}: {', '.join(sorted(ns))}" for c, ns in sorted(by_class.items()))
107
+ + ". Review that each is necessary, scoped and gated by approval.",
108
+ severity=Severity.LOW,
109
+ confidence=Confidence.MEDIUM,
110
+ evidence=ev,
111
+ excerpt=evidence_summary(
112
+ ev, "; ".join(f"{c}: {', '.join(sorted(ns))}" for c, ns in sorted(by_class.items()))
113
+ ),
114
+ location="tools",
115
+ details={"by_class": by_class},
116
+ )
117
+ )
118
+ return findings
119
+
120
+
121
+ # ----------------------------------------------------------------------------- META-04
122
+ class Meta04SchemaHygiene(Check):
123
+ id = "META-04"
124
+ title = "Tool schema hygiene problems"
125
+ severity = Severity.LOW
126
+ standards = Standards(mcp=["MCP05:2025"])
127
+ description = (
128
+ "Checks tool names against [A-Za-z0-9_.-]{1,128} and for duplicates; checks that "
129
+ "inputSchema is a JSON Schema object of type 'object'; flags free-form string arguments "
130
+ "whose names suggest paths, commands, URLs or queries and that carry no enum, pattern, "
131
+ "format or maxLength constraint. Missing outputSchema cannot be judged without calling "
132
+ "the tool and is not reported."
133
+ )
134
+ evidence_type = "The tools/list response."
135
+ false_positive_notes = (
136
+ "An unconstrained 'query' string is often unavoidable (search tools); the finding is "
137
+ "Low severity and meant as a review prompt, not a defect."
138
+ )
139
+ fix = (
140
+ "Use valid names, give every tool an object inputSchema, and constrain risky string "
141
+ "arguments with enum/pattern/format/maxLength so clients and servers can validate."
142
+ )
143
+
144
+ async def run(self, ctx: ScanContext) -> list[Finding]:
145
+ findings: list[Finding] = []
146
+ ev = [ctx.inventory.tools_evidence] if ctx.inventory.tools_evidence else []
147
+ seen: dict[str, int] = {}
148
+ for tool in ctx.inventory.tools:
149
+ name = str(tool.get("name", ""))
150
+ seen[name] = seen.get(name, 0) + 1
151
+ problems: list[str] = []
152
+ if not TOOL_NAME_RE.match(name):
153
+ problems.append("name violates [A-Za-z0-9_.-]{1,128}")
154
+ schema = tool.get("inputSchema")
155
+ risky: list[str] = []
156
+ if not isinstance(schema, dict):
157
+ problems.append("inputSchema missing or not an object")
158
+ else:
159
+ if schema.get("type") not in (None, "object"):
160
+ problems.append(f"inputSchema.type is {schema.get('type')!r}, expected 'object'")
161
+ props = schema.get("properties")
162
+ if isinstance(props, dict):
163
+ for pname, pdef in props.items():
164
+ if not isinstance(pdef, dict):
165
+ continue
166
+ if (
167
+ pdef.get("type") == "string"
168
+ and RISKY_ARG_NAME.search(str(pname))
169
+ and not any(
170
+ k in pdef for k in ("enum", "pattern", "format", "maxLength", "const")
171
+ )
172
+ ):
173
+ risky.append(str(pname))
174
+ if risky:
175
+ problems.append("unconstrained string arguments: " + ", ".join(risky))
176
+ if problems:
177
+ findings.append(
178
+ self.finding(
179
+ description=f"Tool '{name}': " + "; ".join(problems) + ".",
180
+ severity=Severity.MEDIUM
181
+ if "inputSchema missing or not an object" in problems
182
+ else Severity.LOW,
183
+ confidence=Confidence.HIGH,
184
+ evidence=ev,
185
+ excerpt=evidence_summary(
186
+ ev, f"tools[{name}].inputSchema = {excerpt(json.dumps(schema), 160)}"
187
+ ),
188
+ location=f"tools[{name}]",
189
+ details={"problems": problems, "risky_arguments": risky},
190
+ )
191
+ )
192
+ dupes = sorted(n for n, c in seen.items() if c > 1)
193
+ if dupes:
194
+ findings.append(
195
+ self.finding(
196
+ title="Duplicate tool names",
197
+ description="Tool names listed more than once: " + ", ".join(dupes) + ".",
198
+ severity=Severity.MEDIUM,
199
+ confidence=Confidence.HIGH,
200
+ evidence=ev,
201
+ location="tools",
202
+ details={"duplicates": dupes},
203
+ )
204
+ )
205
+ return findings
206
+
207
+
208
+ # ----------------------------------------------------------------------------- META-05
209
+ _ENTROPY_TOKEN = re.compile(r"(?<![\w/+=-])[A-Za-z0-9_\-/+=]{24,}(?![\w/+=-])")
210
+ _SECRET_ENV_NAME = re.compile(r"(key|secret|token|password|passwd|pwd|credential|auth)", re.I)
211
+
212
+
213
+ def find_secrets(text: str) -> list[dict[str, Any]]:
214
+ """Return pattern and entropy hits in ``text`` (values redacted)."""
215
+ hits: list[dict[str, Any]] = []
216
+ covered: list[tuple[int, int]] = []
217
+ for label, pattern, sev in SECRET_PATTERNS:
218
+ for m in pattern.finditer(text):
219
+ value = m.group(1) if m.lastindex else m.group(0)
220
+ hits.append({"kind": label, "severity": sev.value, "value": redact(value), "confidence": "high"})
221
+ covered.append(m.span())
222
+ for m in _ENTROPY_TOKEN.finditer(text):
223
+ if any(a <= m.start() < b for a, b in covered):
224
+ continue
225
+ token = m.group(0)
226
+ if "/" in token and token.count("/") > 2: # looks like a path
227
+ continue
228
+ has_digit = any(c.isdigit() for c in token)
229
+ has_alpha = any(c.isalpha() for c in token)
230
+ if has_digit and has_alpha and shannon_entropy(token) >= 4.2:
231
+ hits.append(
232
+ {
233
+ "kind": "high-entropy string",
234
+ "severity": Severity.LOW.value,
235
+ "value": redact(token),
236
+ "confidence": "low",
237
+ }
238
+ )
239
+ return hits
240
+
241
+
242
+ class Meta05SecretsInMetadata(Check):
243
+ id = "META-05"
244
+ title = "Secret material in server metadata or configuration"
245
+ severity = Severity.HIGH
246
+ standards = Standards(mcp=["MCP01:2025"], llm_2025=["LLM02:2025"], llm_2026=["LLM02:2026"])
247
+ description = (
248
+ "Pattern and entropy scan for AWS/GitHub/Slack/Stripe/Google keys, private key blocks, "
249
+ "JWTs, bearer tokens, connection strings with passwords and generic 'key=...' "
250
+ "assignments across tool/prompt/resource descriptions, parameter descriptions, resource "
251
+ "URIs, server instructions and, for stdio targets, the command line and environment "
252
+ "supplied to the scanner. Matched values are redacted in the report."
253
+ )
254
+ evidence_type = "The listing response that contained the match; values redacted."
255
+ false_positive_notes = (
256
+ "Example keys from vendor documentation (for instance the AWS sample key IDs ending in EXAMPLE) match the patterns. "
257
+ "High-entropy strings are reported at Low severity and low confidence; identifiers such "
258
+ "as content hashes trigger them. Secrets in a stdio server's environment are expected "
259
+ "(the spec says stdio servers take credentials from the environment) and are reported "
260
+ "as Low so the operator can confirm least privilege."
261
+ )
262
+ fix = (
263
+ "Remove secrets from descriptions and resources, rotate anything that was exposed, and "
264
+ "pass credentials through the environment or a secret store, never through metadata."
265
+ )
266
+
267
+ async def run(self, ctx: ScanContext) -> list[Finding]:
268
+ findings: list[Finding] = []
269
+ evidence_map = {
270
+ "tool": ctx.inventory.tools_evidence,
271
+ "tool_param": ctx.inventory.tools_evidence,
272
+ "prompt": ctx.inventory.prompts_evidence,
273
+ "prompt_arg": ctx.inventory.prompts_evidence,
274
+ "resource": ctx.inventory.resources_evidence,
275
+ }
276
+ for item in collect_text_items(ctx):
277
+ hits = find_secrets(item.text)
278
+ if hits:
279
+ findings.append(self._finding_for(item.path, hits, item.text, evidence_map.get(item.kind)))
280
+ for res in ctx.inventory.resources:
281
+ uri = res.get("uri")
282
+ if isinstance(uri, str):
283
+ hits = [h for h in find_secrets(uri) if h["kind"] != "high-entropy string"]
284
+ if hits:
285
+ findings.append(
286
+ self._finding_for(
287
+ f"resources[{res.get('name') or uri}].uri",
288
+ hits,
289
+ uri,
290
+ ctx.inventory.resources_evidence,
291
+ )
292
+ )
293
+ # stdio: command line and environment supplied by the operator.
294
+ if ctx.transport.name == "stdio":
295
+ cmd = " ".join(ctx.options.stdio_command)
296
+ hits = find_secrets(cmd)
297
+ if hits:
298
+ f = self._finding_for("stdio.command", hits, cmd, None)
299
+ f.severity = Severity.MEDIUM
300
+ f.description += " Secrets on the command line are visible in process listings."
301
+ findings.append(f)
302
+ env_hits: list[str] = []
303
+ for key, value in ctx.options.stdio_env.items():
304
+ if _SECRET_ENV_NAME.search(key) and value:
305
+ env_hits.append(f"{key}={redact(value)}")
306
+ else:
307
+ for h in find_secrets(value):
308
+ if h["confidence"] == "high":
309
+ env_hits.append(f"{key}={h['value']} ({h['kind']})")
310
+ if env_hits:
311
+ findings.append(
312
+ self.finding(
313
+ title="Credentials passed to stdio server via environment",
314
+ description="Environment variables that look like credentials: "
315
+ + ", ".join(env_hits)
316
+ + ". This is the expected pattern for stdio servers; "
317
+ "confirm each credential is least-privilege and scoped to this server.",
318
+ severity=Severity.LOW,
319
+ confidence=Confidence.MEDIUM,
320
+ location="stdio.env",
321
+ details={"variables": env_hits},
322
+ )
323
+ )
324
+ return findings
325
+
326
+ def _finding_for(
327
+ self, path: str, hits: list[dict[str, Any]], text: str, ev: EvidenceRecord | None
328
+ ) -> Finding:
329
+ top = max((Severity(h["severity"]) for h in hits), key=lambda s: s.rank)
330
+ confidence = Confidence.HIGH if any(h["confidence"] == "high" for h in hits) else Confidence.LOW
331
+ return self.finding(
332
+ description=f"{path} contains: "
333
+ + "; ".join(f"{h['kind']} {h['value']}" for h in hits)
334
+ + f'. Excerpt: "{excerpt(redact_secrets(text))}"',
335
+ severity=top,
336
+ confidence=confidence,
337
+ evidence=[ev] if ev else [],
338
+ excerpt=f'{path}: "{excerpt(redact_secrets(text), 240)}"',
339
+ location=path,
340
+ details={"hits": hits},
341
+ )