mcplens-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.
- mcplens/__init__.py +3 -0
- mcplens/baseline.py +102 -0
- mcplens/cli.py +157 -0
- mcplens/models.py +116 -0
- mcplens/probes.py +315 -0
- mcplens/report.py +72 -0
- mcplens/rules.py +231 -0
- mcplens/sandbox.py +96 -0
- mcplens/transport.py +125 -0
- mcplens_scanner-0.1.0.dist-info/METADATA +175 -0
- mcplens_scanner-0.1.0.dist-info/RECORD +15 -0
- mcplens_scanner-0.1.0.dist-info/WHEEL +5 -0
- mcplens_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcplens_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcplens_scanner-0.1.0.dist-info/top_level.txt +1 -0
mcplens/__init__.py
ADDED
mcplens/baseline.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Baseline snapshots and rug-pull diffing.
|
|
2
|
+
|
|
3
|
+
A baseline pins the SHA-256 of every tool's canonical definition
|
|
4
|
+
(name + description + inputSchema). Re-scanning and diffing against the
|
|
5
|
+
baseline exposes silent definition changes — the 'rug pull' pattern where
|
|
6
|
+
a server behaves during review and mutates afterwards.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from .models import ToolInfo
|
|
17
|
+
|
|
18
|
+
SNAPSHOT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tool_hash(tool: ToolInfo) -> str:
|
|
22
|
+
return hashlib.sha256(tool.canonical_json().encode("utf-8")).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_hash(tools: list[ToolInfo]) -> str:
|
|
26
|
+
joined = "\n".join(f"{t.name}:{tool_hash(t)}" for t in sorted(tools, key=lambda x: x.name))
|
|
27
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_snapshot(tools: list[ToolInfo], label: str = "") -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"mcplens_snapshot": SNAPSHOT_VERSION,
|
|
33
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
34
|
+
"target": label,
|
|
35
|
+
"set_sha256": set_hash(tools),
|
|
36
|
+
"tools": {
|
|
37
|
+
t.name: {"sha256": tool_hash(t), "definition": t.canonical()}
|
|
38
|
+
for t in tools
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_snapshot(snapshot: dict[str, Any], path: str) -> None:
|
|
44
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
45
|
+
json.dump(snapshot, fh, indent=2, sort_keys=True)
|
|
46
|
+
fh.write("\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_snapshot(path: str) -> dict[str, Any]:
|
|
50
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
51
|
+
data = json.load(fh)
|
|
52
|
+
if data.get("mcplens_snapshot") != SNAPSHOT_VERSION:
|
|
53
|
+
raise ValueError(f"{path} is not an mcplens snapshot (version mismatch)")
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ToolChange:
|
|
59
|
+
name: str
|
|
60
|
+
kind: str # "added" | "removed" | "changed"
|
|
61
|
+
fields: list[str] = field(default_factory=list) # for "changed": which fields differ
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class DiffResult:
|
|
66
|
+
changes: list[ToolChange] = field(default_factory=list)
|
|
67
|
+
old_set_hash: str = ""
|
|
68
|
+
new_set_hash: str = ""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_changes(self) -> bool:
|
|
72
|
+
return bool(self.changes)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _field_diffs(old_def: dict[str, Any], new_def: dict[str, Any]) -> list[str]:
|
|
76
|
+
fields = []
|
|
77
|
+
if old_def.get("description") != new_def.get("description"):
|
|
78
|
+
fields.append("description")
|
|
79
|
+
if json.dumps(old_def.get("inputSchema") or {}, sort_keys=True) != \
|
|
80
|
+
json.dumps(new_def.get("inputSchema") or {}, sort_keys=True):
|
|
81
|
+
fields.append("inputSchema")
|
|
82
|
+
return fields
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def diff_snapshot(snapshot: dict[str, Any], current: list[ToolInfo]) -> DiffResult:
|
|
86
|
+
old_tools: dict[str, Any] = snapshot.get("tools") or {}
|
|
87
|
+
result = DiffResult(old_set_hash=snapshot.get("set_sha256", ""),
|
|
88
|
+
new_set_hash=set_hash(current))
|
|
89
|
+
current_by_name = {t.name: t for t in current}
|
|
90
|
+
|
|
91
|
+
for name in sorted(set(old_tools) - set(current_by_name)):
|
|
92
|
+
result.changes.append(ToolChange(name=name, kind="removed"))
|
|
93
|
+
for name in sorted(set(current_by_name) - set(old_tools)):
|
|
94
|
+
result.changes.append(ToolChange(name=name, kind="added"))
|
|
95
|
+
for name in sorted(set(old_tools) & set(current_by_name)):
|
|
96
|
+
tool = current_by_name[name]
|
|
97
|
+
if old_tools[name].get("sha256") != tool_hash(tool):
|
|
98
|
+
old_def = old_tools[name].get("definition") or {}
|
|
99
|
+
fields = _field_diffs(old_def, tool.canonical())
|
|
100
|
+
result.changes.append(ToolChange(name=name, kind="changed",
|
|
101
|
+
fields=fields or ["definition"]))
|
|
102
|
+
return result
|
mcplens/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""mcplens command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
mcplens scan --cmd "..." | --config cfg.json [--name X] | --url URL
|
|
5
|
+
[--probes] [--format text|json]
|
|
6
|
+
mcplens baseline (same target flags) [--out mcplens-baseline.json]
|
|
7
|
+
mcplens diff (same target flags) [--baseline mcplens-baseline.json]
|
|
8
|
+
|
|
9
|
+
Exit codes: scan -> 1 if any critical finding, else 0.
|
|
10
|
+
diff -> 1 if tool definitions drifted from the baseline.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .baseline import diff_snapshot, load_snapshot, make_snapshot, save_snapshot
|
|
20
|
+
from .models import ScanReport
|
|
21
|
+
from .report import render_diff, render_text
|
|
22
|
+
from .rules import analyze_tools
|
|
23
|
+
from .transport import Target, fetch_tools
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_target_args(p: argparse.ArgumentParser) -> None:
|
|
27
|
+
g = p.add_argument_group("target (pick one)")
|
|
28
|
+
g.add_argument("--cmd", metavar="COMMAND",
|
|
29
|
+
help='stdio server command, e.g. --cmd "python server.py"')
|
|
30
|
+
g.add_argument("--config", metavar="PATH",
|
|
31
|
+
help="client config JSON (mcpServers format)")
|
|
32
|
+
g.add_argument("--name", metavar="SERVER",
|
|
33
|
+
help="server name inside --config (required if it holds several)")
|
|
34
|
+
g.add_argument("--url", metavar="URL",
|
|
35
|
+
help="streamable HTTP MCP endpoint URL")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_target(args: argparse.Namespace) -> Target:
|
|
39
|
+
if getattr(args, "url", None):
|
|
40
|
+
return Target.from_url(args.url)
|
|
41
|
+
if getattr(args, "config", None):
|
|
42
|
+
return Target.from_config(args.config, args.name)
|
|
43
|
+
if getattr(args, "cmd", None):
|
|
44
|
+
return Target.from_command(args.cmd)
|
|
45
|
+
raise SystemExit("error: specify a target with --cmd, --config, or --url")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="mcplens",
|
|
51
|
+
description="MCP server security scanner with runtime behavioral verification.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("--version", action="version", version=f"mcplens {__version__}")
|
|
54
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
p_scan = sub.add_parser("scan", help="enumerate tools, run static rules, optionally probe behavior")
|
|
57
|
+
_add_target_args(p_scan)
|
|
58
|
+
p_scan.add_argument("--probes", action="store_true",
|
|
59
|
+
help="run dynamic canary probes in a sandbox (stdio targets only)")
|
|
60
|
+
p_scan.add_argument("--format", choices=("text", "json"), default="text")
|
|
61
|
+
|
|
62
|
+
p_base = sub.add_parser("baseline", help="save a hash snapshot of tool definitions")
|
|
63
|
+
_add_target_args(p_base)
|
|
64
|
+
p_base.add_argument("--out", default="mcplens-baseline.json",
|
|
65
|
+
help="snapshot file to write (default: mcplens-baseline.json)")
|
|
66
|
+
|
|
67
|
+
p_diff = sub.add_parser("diff", help="compare current tool definitions against a baseline")
|
|
68
|
+
_add_target_args(p_diff)
|
|
69
|
+
p_diff.add_argument("--baseline", default="mcplens-baseline.json",
|
|
70
|
+
help="snapshot file to compare against (default: mcplens-baseline.json)")
|
|
71
|
+
return parser
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
75
|
+
target = _resolve_target(args)
|
|
76
|
+
report = ScanReport(target_label=target.label, transport=target.kind)
|
|
77
|
+
|
|
78
|
+
probe_findings = []
|
|
79
|
+
if args.probes:
|
|
80
|
+
if target.kind != "stdio":
|
|
81
|
+
print("error: --probes requires a stdio target (--cmd or --config)", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
from .probes import run_probes
|
|
84
|
+
run = run_probes(target)
|
|
85
|
+
report.tools = run.tools
|
|
86
|
+
report.probes = run.results
|
|
87
|
+
report.probes_run = True
|
|
88
|
+
probe_findings = run.findings
|
|
89
|
+
else:
|
|
90
|
+
report.tools = fetch_tools(target)
|
|
91
|
+
|
|
92
|
+
static_findings = analyze_tools(report.tools,
|
|
93
|
+
target_url=target.url if target.kind == "http" else "")
|
|
94
|
+
report.findings = static_findings + probe_findings
|
|
95
|
+
|
|
96
|
+
if args.format == "json":
|
|
97
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
98
|
+
else:
|
|
99
|
+
print(render_text(report))
|
|
100
|
+
return 1 if any(f.severity == "critical" for f in report.findings) else 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_baseline(args: argparse.Namespace) -> int:
|
|
104
|
+
target = _resolve_target(args)
|
|
105
|
+
tools = fetch_tools(target)
|
|
106
|
+
snapshot = make_snapshot(tools, label=target.label)
|
|
107
|
+
save_snapshot(snapshot, args.out)
|
|
108
|
+
print(f"Baseline saved to {args.out}")
|
|
109
|
+
print(f" target: {target.label}")
|
|
110
|
+
print(f" tools: {len(tools)}")
|
|
111
|
+
print(f" set hash: {snapshot['set_sha256']}")
|
|
112
|
+
for t in tools:
|
|
113
|
+
print(f" {t.name:<32} {snapshot['tools'][t.name]['sha256'][:16]}…")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
118
|
+
target = _resolve_target(args)
|
|
119
|
+
try:
|
|
120
|
+
snapshot = load_snapshot(args.baseline)
|
|
121
|
+
except (OSError, ValueError) as exc:
|
|
122
|
+
print(f"error: cannot load baseline: {exc}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
tools = fetch_tools(target)
|
|
125
|
+
result = diff_snapshot(snapshot, tools)
|
|
126
|
+
print(render_diff(result, target.label))
|
|
127
|
+
return 1 if result.has_changes else 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _flatten(exc: BaseException) -> str:
|
|
131
|
+
"""One-line message for an exception, unwrapping exception groups."""
|
|
132
|
+
if isinstance(exc, BaseExceptionGroup) and exc.exceptions:
|
|
133
|
+
return _flatten(exc.exceptions[0])
|
|
134
|
+
return str(exc) or type(exc).__name__
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
parser = build_parser()
|
|
139
|
+
args = parser.parse_args(argv)
|
|
140
|
+
try:
|
|
141
|
+
if args.command == "scan":
|
|
142
|
+
return cmd_scan(args)
|
|
143
|
+
if args.command == "baseline":
|
|
144
|
+
return cmd_baseline(args)
|
|
145
|
+
if args.command == "diff":
|
|
146
|
+
return cmd_diff(args)
|
|
147
|
+
except (ValueError, OSError) as exc:
|
|
148
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
except BaseExceptionGroup as group: # transport/task-group failures
|
|
151
|
+
print(f"error: {_flatten(group)}", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|
mcplens/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Core data structures for mcplens."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
9
|
+
SEVERITY_ORDER = {name: i for i, name in enumerate(SEVERITIES)}
|
|
10
|
+
SEVERITY_PENALTY = {"critical": 40, "high": 20, "medium": 8, "low": 3, "info": 1}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolInfo:
|
|
15
|
+
"""A single tool definition as advertised by an MCP server (tools/list)."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
description: str = ""
|
|
19
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_mcp(cls, tool: Any) -> "ToolInfo":
|
|
23
|
+
schema = getattr(tool, "inputSchema", None) or \
|
|
24
|
+
getattr(tool, "input_schema", None) or {}
|
|
25
|
+
if not isinstance(schema, dict):
|
|
26
|
+
try:
|
|
27
|
+
schema = dict(schema)
|
|
28
|
+
except Exception:
|
|
29
|
+
schema = {}
|
|
30
|
+
return cls(
|
|
31
|
+
name=getattr(tool, "name", "") or "",
|
|
32
|
+
description=getattr(tool, "description", None) or "",
|
|
33
|
+
input_schema=schema,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def canonical(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"name": self.name,
|
|
39
|
+
"description": self.description,
|
|
40
|
+
"inputSchema": self.input_schema,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def canonical_json(self) -> str:
|
|
44
|
+
return json.dumps(self.canonical(), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Finding:
|
|
49
|
+
rule_id: str
|
|
50
|
+
severity: str # one of SEVERITIES
|
|
51
|
+
title: str
|
|
52
|
+
tool: Optional[str] # tool name, or None for server-level findings
|
|
53
|
+
detail: str
|
|
54
|
+
owasp: str = "" # OWASP MCP Top 10 mapping, e.g. "MCP05 Tool Poisoning"
|
|
55
|
+
source: str = "static" # "static" or "probe"
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return asdict(self)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ToolProbeResult:
|
|
63
|
+
tool: str
|
|
64
|
+
probed: bool
|
|
65
|
+
note: str = ""
|
|
66
|
+
declared: list[str] = field(default_factory=list) # behaviors implied by name/description
|
|
67
|
+
observed: list[str] = field(default_factory=list) # behaviors actually observed
|
|
68
|
+
error: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ScanReport:
|
|
76
|
+
target_label: str
|
|
77
|
+
transport: str # "stdio" or "http"
|
|
78
|
+
tools: list[ToolInfo] = field(default_factory=list)
|
|
79
|
+
findings: list[Finding] = field(default_factory=list)
|
|
80
|
+
probes: list[ToolProbeResult] = field(default_factory=list)
|
|
81
|
+
probes_run: bool = False
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def score(self) -> int:
|
|
85
|
+
score = 100
|
|
86
|
+
for f in self.findings:
|
|
87
|
+
score -= SEVERITY_PENALTY.get(f.severity, 0)
|
|
88
|
+
return max(0, score)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def grade(self) -> str:
|
|
92
|
+
s = self.score
|
|
93
|
+
if s >= 90:
|
|
94
|
+
return "A"
|
|
95
|
+
if s >= 75:
|
|
96
|
+
return "B"
|
|
97
|
+
if s >= 60:
|
|
98
|
+
return "C"
|
|
99
|
+
if s >= 40:
|
|
100
|
+
return "D"
|
|
101
|
+
return "F"
|
|
102
|
+
|
|
103
|
+
def sorted_findings(self) -> list[Finding]:
|
|
104
|
+
return sorted(self.findings, key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.rule_id))
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"target": self.target_label,
|
|
109
|
+
"transport": self.transport,
|
|
110
|
+
"grade": self.grade,
|
|
111
|
+
"score": self.score,
|
|
112
|
+
"tools": [t.canonical() for t in self.tools],
|
|
113
|
+
"findings": [f.to_dict() for f in self.sorted_findings()],
|
|
114
|
+
"probes_run": self.probes_run,
|
|
115
|
+
"probes": [p.to_dict() for p in self.probes],
|
|
116
|
+
}
|