mcpprobe-scanner 0.1.0__py3-none-any.whl → 0.2.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/chains.py +103 -0
- mcpprobe/checks/__init__.py +2 -0
- mcpprobe/checks/auth.py +295 -48
- mcpprobe/checks/base.py +59 -1
- mcpprobe/checks/baseline.py +33 -2
- mcpprobe/checks/capability.py +80 -2
- mcpprobe/checks/metadata.py +339 -82
- mcpprobe/checks/poisoning.py +178 -36
- mcpprobe/checks/proof.py +154 -0
- mcpprobe/checks/robustness.py +111 -2
- mcpprobe/checks/transport_conformance.py +292 -77
- mcpprobe/cli.py +11 -0
- mcpprobe/environment.py +51 -0
- mcpprobe/models.py +80 -3
- mcpprobe/report/json_report.py +7 -0
- mcpprobe/report/sarif.py +21 -3
- mcpprobe/report/terminal.py +52 -3
- mcpprobe/scanner.py +122 -5
- mcpprobe/transport/base.py +2 -1
- mcpprobe/transport/evidence.py +21 -45
- mcpprobe/transport/http.py +13 -1
- {mcpprobe_scanner-0.1.0.dist-info → mcpprobe_scanner-0.2.0.dist-info}/METADATA +13 -7
- mcpprobe_scanner-0.2.0.dist-info/RECORD +43 -0
- mcpprobe_scanner-0.2.0.dist-info/licenses/LICENSE +201 -0
- mcpprobe_scanner-0.2.0.dist-info/licenses/NOTICE +8 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +0 -39
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +0 -21
- {mcpprobe_scanner-0.1.0.dist-info → mcpprobe_scanner-0.2.0.dist-info}/WHEEL +0 -0
- {mcpprobe_scanner-0.1.0.dist-info → mcpprobe_scanner-0.2.0.dist-info}/entry_points.txt +0 -0
mcpprobe/__init__.py
CHANGED
mcpprobe/chains.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Attack chains: findings that together form one attack path.
|
|
2
|
+
|
|
3
|
+
A chain is emitted only when every constituent finding is present in the
|
|
4
|
+
report; it never adds evidence of its own and never upgrades a finding.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from mcpprobe.models import Chain, Finding, ScanReport, Severity
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from mcpprobe.checks.base import ScanContext
|
|
15
|
+
|
|
16
|
+
EXEC = "arbitrary_command_execution"
|
|
17
|
+
FILE_READ = "arbitrary_file_read"
|
|
18
|
+
FILE_WRITE = "arbitrary_file_write"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _first(findings: list[Finding], check_id: str) -> Finding | None:
|
|
22
|
+
return next((f for f in findings if f.id == check_id), None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dangerous_tools(findings: list[Finding]) -> dict[str, list[str]]:
|
|
26
|
+
"""``{tool: [capability, ...]}`` from META-03's arbitrary-capability finding."""
|
|
27
|
+
out: dict[str, list[str]] = {}
|
|
28
|
+
for f in findings:
|
|
29
|
+
if f.id != "META-03":
|
|
30
|
+
continue
|
|
31
|
+
caps = f.details.get("arbitrary")
|
|
32
|
+
if isinstance(caps, dict):
|
|
33
|
+
for tool, kinds in caps.items():
|
|
34
|
+
if isinstance(kinds, list):
|
|
35
|
+
out.setdefault(str(tool), []).extend(str(k) for k in kinds)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _poisoned_tools(findings: list[Finding], candidates: set[str]) -> list[str]:
|
|
40
|
+
hits: list[str] = []
|
|
41
|
+
for f in findings:
|
|
42
|
+
if f.id == "META-01" and f.severity >= Severity.MEDIUM:
|
|
43
|
+
hits.extend(t for t in f.affected if t in candidates and t not in hits)
|
|
44
|
+
return hits
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_chains(report: ScanReport, ctx: ScanContext) -> list[Chain]:
|
|
48
|
+
findings = report.findings
|
|
49
|
+
chains: list[Chain] = []
|
|
50
|
+
auth01 = _first(findings, "AUTH-01")
|
|
51
|
+
dangerous = _dangerous_tools(findings)
|
|
52
|
+
if auth01 is None or not dangerous:
|
|
53
|
+
return chains
|
|
54
|
+
exec_tools = sorted(t for t, k in dangerous.items() if EXEC in k)
|
|
55
|
+
file_tools = sorted(t for t, k in dangerous.items() if FILE_READ in k or FILE_WRITE in k)
|
|
56
|
+
poisoned = _poisoned_tools(findings, set(dangerous))
|
|
57
|
+
ids = ["AUTH-01", "META-03"] + (["META-01"] if poisoned else [])
|
|
58
|
+
if exec_tools:
|
|
59
|
+
what = "remote code execution"
|
|
60
|
+
tools_text = ", ".join(exec_tools + [t for t in file_tools if t not in exec_tools])
|
|
61
|
+
else:
|
|
62
|
+
what = "arbitrary file access"
|
|
63
|
+
tools_text = ", ".join(file_tools)
|
|
64
|
+
title = f"Unauthenticated {what}" + (" steered by a poisoned description" if poisoned else "")
|
|
65
|
+
narrative = (
|
|
66
|
+
f"Anyone who can reach {ctx.target} can call {tools_text} without credentials (AUTH-01). "
|
|
67
|
+
f"The advertised capability of {'these tools' if len(dangerous) > 1 else 'this tool'} is "
|
|
68
|
+
+ "; ".join(
|
|
69
|
+
f"{t}: {', '.join(k.replace('_', ' ') for k in sorted(set(v)))}"
|
|
70
|
+
for t, v in sorted(dangerous.items())
|
|
71
|
+
)
|
|
72
|
+
+ " (META-03)."
|
|
73
|
+
)
|
|
74
|
+
if poisoned:
|
|
75
|
+
narrative += (
|
|
76
|
+
f" The description of {', '.join(poisoned)} also tells the model to call it and hand over "
|
|
77
|
+
"context (META-01), so a connected AI client can be steered into the same path without "
|
|
78
|
+
"the attacker ever touching the endpoint."
|
|
79
|
+
)
|
|
80
|
+
narrative += (
|
|
81
|
+
" Fix the chain by removing or sandboxing the tool first; adding authentication alone "
|
|
82
|
+
"leaves it open to every authenticated or prompt-injected client."
|
|
83
|
+
)
|
|
84
|
+
chains.append(
|
|
85
|
+
Chain(id="CHAIN-01", title=title, severity=Severity.CRITICAL, finding_ids=ids, narrative=narrative)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
trans01 = _first(findings, "TRANS-01")
|
|
89
|
+
if trans01 is not None and not ctx.environment.public and ctx.is_http:
|
|
90
|
+
chains.append(
|
|
91
|
+
Chain(
|
|
92
|
+
id="CHAIN-02",
|
|
93
|
+
title="Browser-driven access to a local server's dangerous tools (DNS rebinding)",
|
|
94
|
+
severity=Severity.HIGH,
|
|
95
|
+
finding_ids=["TRANS-01", "AUTH-01", "META-03"],
|
|
96
|
+
narrative=(
|
|
97
|
+
f"The server at {ctx.target} runs on a local or private address, accepts foreign "
|
|
98
|
+
"Origin headers (TRANS-01) and needs no credentials (AUTH-01). A web page the user "
|
|
99
|
+
f"visits can use DNS rebinding to reach it and call {tools_text} (META-03)."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return chains
|
mcpprobe/checks/__init__.py
CHANGED
|
@@ -20,6 +20,7 @@ from mcpprobe.checks.base import (
|
|
|
20
20
|
Check,
|
|
21
21
|
JudgeFn,
|
|
22
22
|
JudgeVerdict,
|
|
23
|
+
NotApplicable,
|
|
23
24
|
ScanContext,
|
|
24
25
|
ScanOptions,
|
|
25
26
|
)
|
|
@@ -93,6 +94,7 @@ __all__ = [
|
|
|
93
94
|
"Check",
|
|
94
95
|
"JudgeFn",
|
|
95
96
|
"JudgeVerdict",
|
|
97
|
+
"NotApplicable",
|
|
96
98
|
"ScanContext",
|
|
97
99
|
"ScanOptions",
|
|
98
100
|
"get_check",
|