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 CHANGED
@@ -13,7 +13,7 @@ The package is organised as follows:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- __version__ = "0.1.0"
16
+ __version__ = "0.2.0"
17
17
 
18
18
  POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
19
  USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
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
@@ -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",