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
mcpprobe/checks/base.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Plugin framework for checks.
|
|
2
|
+
|
|
3
|
+
A check is a class with an ``id``, ``title``, default ``severity``, a
|
|
4
|
+
``standards`` mapping, a ``mode`` (passive or active) and an async
|
|
5
|
+
``run(ctx) -> list[Finding]``. The orchestrator decides whether a check may run
|
|
6
|
+
(active checks require the authorisation gate) and records a
|
|
7
|
+
:class:`~mcpprobe.models.CheckResult` for every check, including skipped ones,
|
|
8
|
+
so reports show coverage honestly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import abc
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from mcpprobe.models import (
|
|
19
|
+
CheckMode,
|
|
20
|
+
Confidence,
|
|
21
|
+
EvidenceRecord,
|
|
22
|
+
Finding,
|
|
23
|
+
Fingerprint,
|
|
24
|
+
Inventory,
|
|
25
|
+
Severity,
|
|
26
|
+
Standards,
|
|
27
|
+
)
|
|
28
|
+
from mcpprobe.transport import EvidenceLog, HttpTransport, Transport
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from mcpprobe.authz import AuthorisationRecord
|
|
32
|
+
|
|
33
|
+
#: Signature of the optional LLM-judge hook. It receives the text under review
|
|
34
|
+
#: and a short task description and returns ``None`` (abstain) or a verdict.
|
|
35
|
+
JudgeFn = Callable[[str, str], "JudgeVerdict | None"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class JudgeVerdict:
|
|
40
|
+
"""Verdict from an optional second-opinion judge (no network call is made by mcpprobe)."""
|
|
41
|
+
|
|
42
|
+
suspicious: bool
|
|
43
|
+
rationale: str = ""
|
|
44
|
+
confidence: Confidence = Confidence.LOW
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ScanOptions:
|
|
49
|
+
"""User-controlled knobs that checks may consult."""
|
|
50
|
+
|
|
51
|
+
mode: CheckMode = CheckMode.PASSIVE
|
|
52
|
+
baseline_path: str | None = None
|
|
53
|
+
write_baseline: bool = True
|
|
54
|
+
judge: JudgeFn | None = None
|
|
55
|
+
excluded_tools: set[str] = field(default_factory=set)
|
|
56
|
+
#: AUTH-03 fetches the authorization server's metadata, which may live on a
|
|
57
|
+
#: third-party host. Set False to keep all traffic on the target host.
|
|
58
|
+
follow_authorization_server: bool = True
|
|
59
|
+
#: Environment passed to a stdio server (scanned by META-05 for secrets).
|
|
60
|
+
stdio_env: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
stdio_command: list[str] = field(default_factory=list)
|
|
62
|
+
timeout: float = 15.0
|
|
63
|
+
#: DNS TXT resolver used for opt-out checks on third-party hosts (injected in tests).
|
|
64
|
+
txt_lookup: Callable[[str], list[str]] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ScanContext:
|
|
68
|
+
"""Everything a check needs: the negotiated transport, the inventory and options."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
target: str,
|
|
74
|
+
transport: Transport,
|
|
75
|
+
fingerprint: Fingerprint,
|
|
76
|
+
inventory: Inventory,
|
|
77
|
+
evidence: EvidenceLog,
|
|
78
|
+
options: ScanOptions | None = None,
|
|
79
|
+
authorisation: AuthorisationRecord | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.target = target
|
|
82
|
+
self.transport = transport
|
|
83
|
+
self.fingerprint = fingerprint
|
|
84
|
+
self.inventory = inventory
|
|
85
|
+
self.evidence = evidence
|
|
86
|
+
self.options = options or ScanOptions()
|
|
87
|
+
self.authorisation = authorisation
|
|
88
|
+
self.notes: list[str] = []
|
|
89
|
+
#: Scratch space for checks to share derived data (e.g. META-03 classes).
|
|
90
|
+
self.shared: dict[str, Any] = {}
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def http(self) -> HttpTransport | None:
|
|
94
|
+
return self.transport if isinstance(self.transport, HttpTransport) else None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_http(self) -> bool:
|
|
98
|
+
return self.http is not None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def active_allowed(self) -> bool:
|
|
102
|
+
"""Active checks may run only when the authorisation gate produced a verified record."""
|
|
103
|
+
return (
|
|
104
|
+
self.options.mode == CheckMode.ACTIVE
|
|
105
|
+
and self.authorisation is not None
|
|
106
|
+
and self.authorisation.verified
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def tool_names(self) -> list[str]:
|
|
110
|
+
return [str(t.get("name", "")) for t in self.inventory.tools]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Check(abc.ABC):
|
|
114
|
+
"""Base class for all checks. Subclasses set the class attributes and implement ``run``."""
|
|
115
|
+
|
|
116
|
+
id: str = "BASE-00"
|
|
117
|
+
title: str = ""
|
|
118
|
+
severity: Severity = Severity.MEDIUM
|
|
119
|
+
standards: Standards = Standards()
|
|
120
|
+
mode: CheckMode = CheckMode.PASSIVE
|
|
121
|
+
#: Which transports the check makes sense for.
|
|
122
|
+
applies_to: frozenset[str] = frozenset({"http", "stdio"})
|
|
123
|
+
#: One-paragraph description for CHECKS.md and SARIF rule help.
|
|
124
|
+
description: str = ""
|
|
125
|
+
#: What the evidence consists of (used in docs).
|
|
126
|
+
evidence_type: str = ""
|
|
127
|
+
#: Known false-positive conditions (used in docs and attached to findings).
|
|
128
|
+
false_positive_notes: str = ""
|
|
129
|
+
#: Default remediation text.
|
|
130
|
+
fix: str = ""
|
|
131
|
+
|
|
132
|
+
@abc.abstractmethod
|
|
133
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
134
|
+
"""Execute the check and return zero or more findings."""
|
|
135
|
+
|
|
136
|
+
def finding(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
description: str,
|
|
140
|
+
severity: Severity | None = None,
|
|
141
|
+
confidence: Confidence = Confidence.HIGH,
|
|
142
|
+
evidence: list[EvidenceRecord] | None = None,
|
|
143
|
+
location: str | None = None,
|
|
144
|
+
fix: str | None = None,
|
|
145
|
+
details: dict[str, Any] | None = None,
|
|
146
|
+
title: str | None = None,
|
|
147
|
+
excerpt: str | None = None,
|
|
148
|
+
) -> Finding:
|
|
149
|
+
"""Build a :class:`Finding` pre-filled from the check's metadata.
|
|
150
|
+
|
|
151
|
+
``excerpt`` overrides the evidence excerpt that is otherwise derived from
|
|
152
|
+
the first evidence record.
|
|
153
|
+
"""
|
|
154
|
+
records = [e for e in (evidence or []) if e is not None]
|
|
155
|
+
return Finding(
|
|
156
|
+
id=self.id,
|
|
157
|
+
title=title or self.title,
|
|
158
|
+
severity=severity or self.severity,
|
|
159
|
+
confidence=confidence,
|
|
160
|
+
standards=self.standards,
|
|
161
|
+
description=description,
|
|
162
|
+
fix=fix or self.fix,
|
|
163
|
+
evidence=records,
|
|
164
|
+
evidence_excerpt=excerpt if excerpt is not None else evidence_excerpt(records),
|
|
165
|
+
location=location,
|
|
166
|
+
details=details or {},
|
|
167
|
+
false_positive_notes=self.false_positive_notes or None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def applies(self, ctx: ScanContext) -> bool:
|
|
171
|
+
return ctx.transport.name in self.applies_to
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ActiveCheck(Check):
|
|
175
|
+
"""Base for checks that may call tools or send probe payloads.
|
|
176
|
+
|
|
177
|
+
Subclasses must call :meth:`require_active` first. The orchestrator also
|
|
178
|
+
refuses to run active checks without authorisation, so this is a second
|
|
179
|
+
line of defence inside the check itself.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
mode = CheckMode.ACTIVE
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def require_active(ctx: ScanContext) -> None:
|
|
186
|
+
if not ctx.active_allowed:
|
|
187
|
+
raise PermissionError("active check requires --active with a verified authorisation record")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
EXCERPT_BODY_CHARS = 240
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def evidence_excerpt(records: list[EvidenceRecord]) -> str:
|
|
194
|
+
"""One-line summary of the first evidence record: request, status and a body snippet."""
|
|
195
|
+
if not records:
|
|
196
|
+
return ""
|
|
197
|
+
rec = records[0]
|
|
198
|
+
head = rec.method
|
|
199
|
+
if rec.url and rec.transport == "http":
|
|
200
|
+
head += f" {rec.url}"
|
|
201
|
+
if rec.status is not None:
|
|
202
|
+
head += f" -> HTTP {rec.status}"
|
|
203
|
+
elif rec.error:
|
|
204
|
+
head += f" -> {rec.error}"
|
|
205
|
+
body = " ".join(rec.response_body.split())
|
|
206
|
+
if len(body) > EXCERPT_BODY_CHARS:
|
|
207
|
+
body = body[: EXCERPT_BODY_CHARS - 1] + "\u2026"
|
|
208
|
+
extra = f" (+{len(records) - 1} more)" if len(records) > 1 else ""
|
|
209
|
+
return f"[{rec.id}] {head}" + (f": {body}" if body else "") + extra
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def evidence_summary(records: list[EvidenceRecord], summary: str) -> str:
|
|
213
|
+
"""``[ev-0005] tools/list -> HTTP 200: <summary>`` for checks that know what matters."""
|
|
214
|
+
if not records:
|
|
215
|
+
return summary
|
|
216
|
+
rec = records[0]
|
|
217
|
+
head = rec.method + (f" -> HTTP {rec.status}" if rec.status is not None else "")
|
|
218
|
+
return f"[{rec.id}] {head}: {summary}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def evidence_of(*responses: Any) -> list[EvidenceRecord]:
|
|
222
|
+
"""Collect evidence records from RpcResponse objects, ignoring ``None``."""
|
|
223
|
+
out: list[EvidenceRecord] = []
|
|
224
|
+
for r in responses:
|
|
225
|
+
rec = getattr(r, "evidence", None)
|
|
226
|
+
if rec is not None:
|
|
227
|
+
out.append(rec)
|
|
228
|
+
return out
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""META-02: canonical hashing of the server's listed surface and rug-pull diffing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcpprobe.checks._util import canonical_json
|
|
11
|
+
from mcpprobe.checks.base import Check, ScanContext
|
|
12
|
+
from mcpprobe.models import Confidence, Finding, Severity, Standards, utcnow
|
|
13
|
+
|
|
14
|
+
BASELINE_FORMAT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sha256_hex(text: str) -> str:
|
|
18
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _key(item: dict[str, Any], fallback: str) -> str:
|
|
22
|
+
return str(item.get("name") or item.get("uri") or fallback)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_snapshot(ctx: ScanContext) -> dict[str, Any]:
|
|
26
|
+
"""Canonical snapshot and per-item hashes of tools, prompts, resources and instructions."""
|
|
27
|
+
items: dict[str, Any] = {"tools": {}, "prompts": {}, "resources": {}, "instructions": None}
|
|
28
|
+
for kind in ("tools", "prompts", "resources"):
|
|
29
|
+
for idx, entry in enumerate(getattr(ctx.inventory, kind)):
|
|
30
|
+
items[kind][_key(entry, f"#{idx}")] = sha256_hex(canonical_json(entry))
|
|
31
|
+
if ctx.fingerprint.instructions:
|
|
32
|
+
items["instructions"] = sha256_hex(ctx.fingerprint.instructions)
|
|
33
|
+
snapshot = {
|
|
34
|
+
"tools": ctx.inventory.tools,
|
|
35
|
+
"prompts": ctx.inventory.prompts,
|
|
36
|
+
"resources": ctx.inventory.resources,
|
|
37
|
+
"instructions": ctx.fingerprint.instructions,
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
"format": BASELINE_FORMAT_VERSION,
|
|
41
|
+
"target": ctx.target,
|
|
42
|
+
"created_at": utcnow().isoformat(),
|
|
43
|
+
"overall_sha256": sha256_hex(canonical_json(snapshot)),
|
|
44
|
+
"items": items,
|
|
45
|
+
"snapshot": snapshot,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def diff_snapshots(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""Return added/removed/changed names per kind plus changed fields for tools."""
|
|
51
|
+
out: dict[str, Any] = {}
|
|
52
|
+
old_items = old.get("items") or {}
|
|
53
|
+
new_items = new.get("items") or {}
|
|
54
|
+
for kind in ("tools", "prompts", "resources"):
|
|
55
|
+
o = old_items.get(kind) or {}
|
|
56
|
+
n = new_items.get(kind) or {}
|
|
57
|
+
changed = sorted(k for k in o.keys() & n.keys() if o[k] != n[k])
|
|
58
|
+
entry: dict[str, Any] = {
|
|
59
|
+
"added": sorted(n.keys() - o.keys()),
|
|
60
|
+
"removed": sorted(o.keys() - n.keys()),
|
|
61
|
+
"changed": changed,
|
|
62
|
+
}
|
|
63
|
+
if kind == "tools" and changed:
|
|
64
|
+
old_tools = {_key(t, ""): t for t in (old.get("snapshot") or {}).get("tools") or []}
|
|
65
|
+
new_tools = {_key(t, ""): t for t in (new.get("snapshot") or {}).get("tools") or []}
|
|
66
|
+
fields: dict[str, list[str]] = {}
|
|
67
|
+
for name in changed:
|
|
68
|
+
a, b = old_tools.get(name, {}), new_tools.get(name, {})
|
|
69
|
+
fields[name] = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
|
|
70
|
+
entry["changed_fields"] = fields
|
|
71
|
+
out[kind] = entry
|
|
72
|
+
out["instructions_changed"] = old_items.get("instructions") != new_items.get("instructions")
|
|
73
|
+
out["any_change"] = out["instructions_changed"] or any(
|
|
74
|
+
out[k]["added"] or out[k]["removed"] or out[k]["changed"] for k in ("tools", "prompts", "resources")
|
|
75
|
+
)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Meta02RugPullBaseline(Check):
|
|
80
|
+
id = "META-02"
|
|
81
|
+
title = "Tool surface changed since the stored baseline (rug-pull indicator)"
|
|
82
|
+
severity = Severity.MEDIUM
|
|
83
|
+
standards = Standards(mcp=["MCP03:2025"], asi=["ASI04"])
|
|
84
|
+
description = (
|
|
85
|
+
"Computes SHA-256 over the canonical JSON of each tool, prompt and resource definition "
|
|
86
|
+
"and the server instructions. With --baseline FILE the current snapshot is compared to "
|
|
87
|
+
"the stored one and any added, removed or changed definition is reported (a changed "
|
|
88
|
+
"description after approval is the classic 'rug pull'). The snapshot is then written "
|
|
89
|
+
"back to the file. Also notes when the server advertises tools.listChanged."
|
|
90
|
+
)
|
|
91
|
+
evidence_type = "The current tools/list response plus per-item hashes and the diff."
|
|
92
|
+
false_positive_notes = (
|
|
93
|
+
"Any legitimate release that edits a description will trigger the diff; the finding "
|
|
94
|
+
"says what changed so the reviewer can decide. Servers that embed timestamps or random "
|
|
95
|
+
"data in descriptions will change on every scan."
|
|
96
|
+
)
|
|
97
|
+
fix = (
|
|
98
|
+
"Treat tool definitions as code: version them, review changes, and have clients pin and "
|
|
99
|
+
"re-approve definitions when the hash changes."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
103
|
+
findings: list[Finding] = []
|
|
104
|
+
current = build_snapshot(ctx)
|
|
105
|
+
ctx.shared["baseline_current"] = current
|
|
106
|
+
ctx.notes.append(
|
|
107
|
+
f"META-02: surface hash {current['overall_sha256'][:16]}... "
|
|
108
|
+
f"({len(current['items']['tools'])} tools)"
|
|
109
|
+
)
|
|
110
|
+
caps = ctx.fingerprint.capabilities or {}
|
|
111
|
+
tools_caps = caps.get("tools") if isinstance(caps, dict) else None
|
|
112
|
+
if isinstance(tools_caps, dict) and tools_caps.get("listChanged"):
|
|
113
|
+
findings.append(
|
|
114
|
+
self.finding(
|
|
115
|
+
title="Server can change its tool list mid-session (listChanged)",
|
|
116
|
+
description="The server advertises tools.listChanged, so definitions may change "
|
|
117
|
+
"after a client has approved them. Clients should re-review on change.",
|
|
118
|
+
severity=Severity.INFO,
|
|
119
|
+
confidence=Confidence.HIGH,
|
|
120
|
+
location="capabilities.tools.listChanged",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
path = ctx.options.baseline_path
|
|
124
|
+
if not path:
|
|
125
|
+
return findings
|
|
126
|
+
file = Path(path)
|
|
127
|
+
if file.exists():
|
|
128
|
+
try:
|
|
129
|
+
previous = json.loads(file.read_text("utf-8"))
|
|
130
|
+
except (OSError, ValueError) as exc:
|
|
131
|
+
ctx.notes.append(f"META-02: could not read baseline {path}: {exc}")
|
|
132
|
+
previous = None
|
|
133
|
+
if isinstance(previous, dict):
|
|
134
|
+
if previous.get("target") != ctx.target:
|
|
135
|
+
ctx.notes.append(
|
|
136
|
+
f"META-02: baseline target {previous.get('target')!r} differs from {ctx.target!r}"
|
|
137
|
+
)
|
|
138
|
+
diff = diff_snapshots(previous, current)
|
|
139
|
+
findings.extend(self._findings_from_diff(ctx, diff, previous))
|
|
140
|
+
if ctx.options.write_baseline:
|
|
141
|
+
try:
|
|
142
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
file.write_text(json.dumps(current, indent=2, sort_keys=True), "utf-8")
|
|
144
|
+
ctx.notes.append(f"META-02: baseline written to {path}")
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
ctx.notes.append(f"META-02: could not write baseline {path}: {exc}")
|
|
147
|
+
return findings
|
|
148
|
+
|
|
149
|
+
def _findings_from_diff(
|
|
150
|
+
self, ctx: ScanContext, diff: dict[str, Any], previous: dict[str, Any]
|
|
151
|
+
) -> list[Finding]:
|
|
152
|
+
if not diff["any_change"]:
|
|
153
|
+
ctx.notes.append("META-02: no change since baseline " + str(previous.get("created_at")))
|
|
154
|
+
return []
|
|
155
|
+
out: list[Finding] = []
|
|
156
|
+
ev = [ctx.inventory.tools_evidence] if ctx.inventory.tools_evidence else []
|
|
157
|
+
tools = diff["tools"]
|
|
158
|
+
for name in tools["changed"]:
|
|
159
|
+
fields = tools.get("changed_fields", {}).get(name, [])
|
|
160
|
+
sev = Severity.MEDIUM if ("description" in fields or "inputSchema" in fields) else Severity.LOW
|
|
161
|
+
out.append(
|
|
162
|
+
self.finding(
|
|
163
|
+
description=f"Tool '{name}' changed since baseline {previous.get('created_at')}: "
|
|
164
|
+
f"fields {', '.join(fields) or 'unknown'}.",
|
|
165
|
+
severity=sev,
|
|
166
|
+
confidence=Confidence.HIGH,
|
|
167
|
+
evidence=ev,
|
|
168
|
+
location=f"tools[{name}]",
|
|
169
|
+
details={"changed_fields": fields},
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if tools["added"]:
|
|
173
|
+
out.append(
|
|
174
|
+
self.finding(
|
|
175
|
+
title="New tools appeared since baseline",
|
|
176
|
+
description="Added tools: " + ", ".join(tools["added"]) + ".",
|
|
177
|
+
severity=Severity.LOW,
|
|
178
|
+
confidence=Confidence.HIGH,
|
|
179
|
+
evidence=ev,
|
|
180
|
+
location="tools",
|
|
181
|
+
details={"added": tools["added"]},
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
if tools["removed"]:
|
|
185
|
+
out.append(
|
|
186
|
+
self.finding(
|
|
187
|
+
title="Tools removed since baseline",
|
|
188
|
+
description="Removed tools: " + ", ".join(tools["removed"]) + ".",
|
|
189
|
+
severity=Severity.INFO,
|
|
190
|
+
confidence=Confidence.HIGH,
|
|
191
|
+
evidence=ev,
|
|
192
|
+
location="tools",
|
|
193
|
+
details={"removed": tools["removed"]},
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
for kind in ("prompts", "resources"):
|
|
197
|
+
d = diff[kind]
|
|
198
|
+
if d["added"] or d["removed"] or d["changed"]:
|
|
199
|
+
out.append(
|
|
200
|
+
self.finding(
|
|
201
|
+
title=f"{kind.capitalize()} changed since baseline",
|
|
202
|
+
description=f"{kind}: added {d['added']}, removed {d['removed']}, changed {d['changed']}.",
|
|
203
|
+
severity=Severity.LOW,
|
|
204
|
+
confidence=Confidence.HIGH,
|
|
205
|
+
location=kind,
|
|
206
|
+
details=d,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
if diff["instructions_changed"]:
|
|
210
|
+
out.append(
|
|
211
|
+
self.finding(
|
|
212
|
+
title="Server instructions changed since baseline",
|
|
213
|
+
description="The 'instructions' text returned at negotiation differs from the baseline.",
|
|
214
|
+
severity=Severity.MEDIUM,
|
|
215
|
+
confidence=Confidence.HIGH,
|
|
216
|
+
location="initialize.instructions",
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return out
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Heuristic classification of tools into capability classes.
|
|
2
|
+
|
|
3
|
+
Used by META-03 (annotation cross-check) and to weight the severity of other
|
|
4
|
+
findings (an unauthenticated server exposing ``run_command`` is worse than one
|
|
5
|
+
exposing ``get_weather``). Annotations are *not* used as input, because the
|
|
6
|
+
spec says clients must treat them as untrusted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
#: Classes considered dangerous when exposed without authentication.
|
|
16
|
+
DANGEROUS_CLASSES: frozenset[str] = frozenset(
|
|
17
|
+
{"exec", "file_write", "database_write", "network", "email", "credentials", "payments"}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#: Classes that imply a write/side effect (should not be ``readOnlyHint: true``).
|
|
21
|
+
WRITE_CLASSES: frozenset[str] = frozenset(
|
|
22
|
+
{"exec", "file_write", "database_write", "email", "payments", "delete"}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
26
|
+
(
|
|
27
|
+
"exec",
|
|
28
|
+
re.compile(
|
|
29
|
+
r"\b(exec(ute)?|shell|bash|sh|cmd|command|run_?(command|script|code)|"
|
|
30
|
+
r"subprocess|powershell|terminal|eval|spawn|system)\b",
|
|
31
|
+
re.I,
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
(
|
|
35
|
+
"file_write",
|
|
36
|
+
re.compile(
|
|
37
|
+
r"\b(write_?file|save_?file|create_?file|edit_?file|move_?file|"
|
|
38
|
+
r"rename|chmod|mkdir|upload|overwrite|append_?file)\b",
|
|
39
|
+
re.I,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
("delete", re.compile(r"\b(delete|remove|rm|drop|purge|destroy|truncate|wipe)\b", re.I)),
|
|
43
|
+
(
|
|
44
|
+
"file_read",
|
|
45
|
+
re.compile(
|
|
46
|
+
r"\b(read_?file|cat|open_?file|list_?dir(ectory)?|ls|glob|"
|
|
47
|
+
r"file_?(content|path)|filesystem|directory)\b",
|
|
48
|
+
re.I,
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
"database_write",
|
|
53
|
+
re.compile(
|
|
54
|
+
r"\b(insert|update|upsert|alter|create_?table|migrate|"
|
|
55
|
+
r"execute_?sql|run_?sql|sql_?(exec|write))\b",
|
|
56
|
+
re.I,
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"database_read",
|
|
61
|
+
re.compile(
|
|
62
|
+
r"\b(sql|query|select|database|db|postgres|mysql|sqlite|mongo|"
|
|
63
|
+
r"redis|table|schema)\b",
|
|
64
|
+
re.I,
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
"network",
|
|
69
|
+
re.compile(
|
|
70
|
+
r"\b(fetch|http|url|curl|wget|download|request|browse|crawl|scrape|"
|
|
71
|
+
r"webhook|api_?call|proxy|ssrf)\b",
|
|
72
|
+
re.I,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"email",
|
|
77
|
+
re.compile(
|
|
78
|
+
r"\b(e-?mail|smtp|send_?(mail|message|sms|slack|notification)|"
|
|
79
|
+
r"bcc|cc|recipient|inbox|mailbox)\b",
|
|
80
|
+
re.I,
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"credentials",
|
|
85
|
+
re.compile(
|
|
86
|
+
r"\b(secret|token|credential|password|api_?key|ssh|private_?key|"
|
|
87
|
+
r"keychain|vault|oauth|\.env)\b",
|
|
88
|
+
re.I,
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"payments",
|
|
93
|
+
re.compile(
|
|
94
|
+
r"\b(payment|pay|charge|refund|invoice|stripe|billing|transfer_?funds|"
|
|
95
|
+
r"wallet|crypto)\b",
|
|
96
|
+
re.I,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
("search", re.compile(r"\b(search|find|lookup|get|list|read|fetch_?info|describe|show)\b", re.I)),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True)
|
|
104
|
+
class Classification:
|
|
105
|
+
name: str
|
|
106
|
+
classes: list[str] = field(default_factory=list)
|
|
107
|
+
matched_terms: dict[str, list[str]] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def dangerous(self) -> bool:
|
|
111
|
+
return any(c in DANGEROUS_CLASSES for c in self.classes)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def writes(self) -> bool:
|
|
115
|
+
return any(c in WRITE_CLASSES for c in self.classes)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def primary(self) -> str:
|
|
119
|
+
return self.classes[0] if self.classes else "other"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _text_of(tool: dict[str, Any]) -> tuple[str, str]:
|
|
123
|
+
"""Return (name-ish text, description-ish text)."""
|
|
124
|
+
name_text = (
|
|
125
|
+
" ".join(str(tool.get(k, "")) for k in ("name", "title") if tool.get(k))
|
|
126
|
+
.replace("_", " ")
|
|
127
|
+
.replace("-", " ")
|
|
128
|
+
.replace(".", " ")
|
|
129
|
+
)
|
|
130
|
+
desc_parts = [str(tool.get("description", "") or "")]
|
|
131
|
+
schema = tool.get("inputSchema")
|
|
132
|
+
if isinstance(schema, dict):
|
|
133
|
+
props = schema.get("properties")
|
|
134
|
+
if isinstance(props, dict):
|
|
135
|
+
for pname, pdef in props.items():
|
|
136
|
+
desc_parts.append(str(pname).replace("_", " "))
|
|
137
|
+
if isinstance(pdef, dict) and pdef.get("description"):
|
|
138
|
+
desc_parts.append(str(pdef["description"]))
|
|
139
|
+
return name_text, " ".join(desc_parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def classify_tool(tool: dict[str, Any]) -> Classification:
|
|
143
|
+
"""Classify one tool. Name matches weigh more than description matches."""
|
|
144
|
+
name_text, desc_text = _text_of(tool)
|
|
145
|
+
name = str(tool.get("name", ""))
|
|
146
|
+
scores: dict[str, int] = {}
|
|
147
|
+
terms: dict[str, list[str]] = {}
|
|
148
|
+
for cls, pattern in _RULES:
|
|
149
|
+
name_hits = pattern.findall(name_text + " " + name.replace("_", " "))
|
|
150
|
+
desc_hits = pattern.findall(desc_text)
|
|
151
|
+
hits = [h if isinstance(h, str) else h[0] for h in name_hits + desc_hits]
|
|
152
|
+
hits = [h for h in hits if h]
|
|
153
|
+
if not hits:
|
|
154
|
+
continue
|
|
155
|
+
score = 3 * len([h for h in name_hits if h]) + len([h for h in desc_hits if h])
|
|
156
|
+
scores[cls] = score
|
|
157
|
+
terms[cls] = sorted(set(hits))[:5]
|
|
158
|
+
# "search" is a weak catch-all; drop it when anything stronger matched.
|
|
159
|
+
if "search" in scores and len(scores) > 1:
|
|
160
|
+
scores.pop("search")
|
|
161
|
+
terms.pop("search", None)
|
|
162
|
+
# database_read is implied by database_write.
|
|
163
|
+
if "database_write" in scores and "database_read" in scores:
|
|
164
|
+
scores.pop("database_read")
|
|
165
|
+
terms.pop("database_read", None)
|
|
166
|
+
ordered = [c for c, _ in sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
167
|
+
return Classification(name=name, classes=ordered, matched_terms=terms)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def classify_tools(tools: list[dict[str, Any]]) -> dict[str, Classification]:
|
|
171
|
+
return {str(t.get("name", "")): classify_tool(t) for t in tools}
|