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,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
|
+
)
|