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/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""mcpprobe: a security scanner for Model Context Protocol (MCP) servers.
|
|
2
|
+
|
|
3
|
+
The package is organised as follows:
|
|
4
|
+
|
|
5
|
+
- :mod:`mcpprobe.transport` -- a deliberately low-level MCP client (streamable
|
|
6
|
+
HTTP and stdio) that can send malformed or non-conformant requests and
|
|
7
|
+
records every exchange as evidence.
|
|
8
|
+
- :mod:`mcpprobe.checks` -- the plugin framework and the individual checks.
|
|
9
|
+
- :mod:`mcpprobe.authz` -- the hard authorisation gate for active mode.
|
|
10
|
+
- :mod:`mcpprobe.report` -- JSON, SARIF 2.1.0 and terminal reporters.
|
|
11
|
+
- :mod:`mcpprobe.scanner` -- the orchestrator tying everything together.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
POLICY_URL = "https://mcpprobe.tech/scanning-policy"
|
|
19
|
+
USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
|
|
20
|
+
|
|
21
|
+
__all__ = ["POLICY_URL", "USER_AGENT", "__version__"]
|
mcpprobe/action.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Entry point for the Docker container GitHub Action (``action.yml``).
|
|
2
|
+
|
|
3
|
+
GitHub passes action inputs as ``INPUT_<NAME>`` environment variables (name
|
|
4
|
+
upper-cased, hyphens kept). This module turns them into a CLI invocation,
|
|
5
|
+
always writes SARIF, records outputs in ``$GITHUB_OUTPUT`` and exits with the
|
|
6
|
+
scanner's exit code (0 clean, 1 findings at or above ``fail-on``, 2 error).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from mcpprobe.cli import EXIT_ERROR, main
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _input(env: Mapping[str, str], name: str, default: str = "") -> str:
|
|
21
|
+
return env.get(f"INPUT_{name.upper()}", default).strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_argv(env: Mapping[str, str]) -> list[str]:
|
|
25
|
+
"""Translate action inputs into ``mcpprobe scan`` arguments."""
|
|
26
|
+
target = _input(env, "target")
|
|
27
|
+
if not target:
|
|
28
|
+
raise ValueError("input 'target' is required")
|
|
29
|
+
argv = ["scan", target]
|
|
30
|
+
argv += ["--fail-on", _input(env, "fail-on", "high") or "high"]
|
|
31
|
+
argv += ["--rate", _input(env, "rate", "5") or "5"]
|
|
32
|
+
argv += ["--sarif", _input(env, "sarif-output", "mcpprobe.sarif") or "mcpprobe.sarif"]
|
|
33
|
+
if json_out := _input(env, "json-output"):
|
|
34
|
+
argv += ["--json", json_out]
|
|
35
|
+
if loc := _input(env, "sarif-location"):
|
|
36
|
+
argv += ["--sarif-location", loc]
|
|
37
|
+
if baseline := _input(env, "baseline"):
|
|
38
|
+
argv += ["--baseline", baseline]
|
|
39
|
+
for line in _input(env, "headers").splitlines():
|
|
40
|
+
if line.strip():
|
|
41
|
+
argv += ["--header", line.strip()]
|
|
42
|
+
if extra := _input(env, "extra-args"):
|
|
43
|
+
argv += shlex.split(extra)
|
|
44
|
+
return argv
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(env: Mapping[str, str] | None = None) -> int:
|
|
48
|
+
env = os.environ if env is None else env
|
|
49
|
+
try:
|
|
50
|
+
argv = build_argv(env)
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
print(f"::error::{exc}", file=sys.stderr)
|
|
53
|
+
return EXIT_ERROR
|
|
54
|
+
code = EXIT_ERROR
|
|
55
|
+
try:
|
|
56
|
+
main(argv)
|
|
57
|
+
except SystemExit as exc:
|
|
58
|
+
code = exc.code if isinstance(exc.code, int) else EXIT_ERROR
|
|
59
|
+
out = env.get("GITHUB_OUTPUT")
|
|
60
|
+
if out:
|
|
61
|
+
sarif = argv[argv.index("--sarif") + 1]
|
|
62
|
+
with Path(out).open("a", encoding="utf-8") as fh:
|
|
63
|
+
fh.write(f"sarif-file={sarif}\n")
|
|
64
|
+
fh.write(f"exit-code={code}\n")
|
|
65
|
+
return code
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__": # pragma: no cover
|
|
69
|
+
sys.exit(run())
|
mcpprobe/authz.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Authorisation gate for active mode.
|
|
2
|
+
|
|
3
|
+
Active checks call tools or send probe payloads, so they are allowed only when:
|
|
4
|
+
|
|
5
|
+
1. the operator states who authorised the scan (``--authorised-by``) and the
|
|
6
|
+
agreed scope (``--scope``), **and**
|
|
7
|
+
2. ownership verification passes:
|
|
8
|
+
|
|
9
|
+
* ``dns-txt``: a TXT record ``mcpprobe-verification=<token>`` exists at
|
|
10
|
+
``_mcpprobe.<host>``. The token is generated locally with
|
|
11
|
+
``mcpprobe token <host>`` and stored under the user's config directory, or
|
|
12
|
+
supplied with ``--token``.
|
|
13
|
+
* ``local``: only for stdio targets, which the operator runs on their own
|
|
14
|
+
machine.
|
|
15
|
+
|
|
16
|
+
Every active scan writes an authorisation record JSON next to the report. The
|
|
17
|
+
record is the evidence that the scan was authorised (who, what, which proof,
|
|
18
|
+
when, with which limits). Passive mode never consults this module, but the
|
|
19
|
+
ownership rule in README.md applies to every mode.
|
|
20
|
+
|
|
21
|
+
This is a gate in code, not policy: :func:`authorise` raises
|
|
22
|
+
:class:`AuthorisationError` and the scanner refuses to run active checks
|
|
23
|
+
without a verified record.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hashlib
|
|
30
|
+
import ipaddress
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import secrets
|
|
34
|
+
from collections.abc import Iterable
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
from mcpprobe import __version__
|
|
41
|
+
from mcpprobe.dnsutil import DnsLookupError, TxtLookup, default_txt_lookup, record_name
|
|
42
|
+
from mcpprobe.models import CheckMode, utcnow
|
|
43
|
+
|
|
44
|
+
TXT_PREFIX = "mcpprobe-verification="
|
|
45
|
+
VERIFY_METHODS = ("dns-txt", "local")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthorisationError(Exception):
|
|
49
|
+
"""Raised when active mode is requested without a valid authorisation."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VerificationResult(BaseModel):
|
|
53
|
+
method: str
|
|
54
|
+
verified: bool
|
|
55
|
+
detail: str
|
|
56
|
+
evidence: dict[str, Any] = Field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthorisationRecord(BaseModel):
|
|
60
|
+
"""Written next to the report for every active scan."""
|
|
61
|
+
|
|
62
|
+
timestamp: str
|
|
63
|
+
scanner_version: str = __version__
|
|
64
|
+
target: str
|
|
65
|
+
transport: str
|
|
66
|
+
mode: CheckMode
|
|
67
|
+
authorised_by: str | None
|
|
68
|
+
scope: str | None
|
|
69
|
+
operator_reference: str | None = None
|
|
70
|
+
verification: VerificationResult
|
|
71
|
+
excluded_tools: list[str] = Field(default_factory=list)
|
|
72
|
+
rate_cap_rps: float
|
|
73
|
+
max_concurrent_tool_calls: int = 1
|
|
74
|
+
token_fingerprint: str | None = Field(
|
|
75
|
+
default=None, description="sha256 prefix of the verification token, never the token itself"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def verified(self) -> bool:
|
|
80
|
+
return self.verification.verified
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ----------------------------------------------------------------------------- tokens
|
|
84
|
+
def generate_token() -> str:
|
|
85
|
+
"""32 random bytes, base32 lower-case without padding (52 chars)."""
|
|
86
|
+
return base64.b32encode(secrets.token_bytes(32)).decode("ascii").rstrip("=").lower()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def token_store_path() -> Path:
|
|
90
|
+
override = os.environ.get("MCPPROBE_TOKEN_STORE")
|
|
91
|
+
if override:
|
|
92
|
+
return Path(override)
|
|
93
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
94
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
95
|
+
return base / "mcpprobe" / "verification-tokens.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_store(path: Path) -> dict[str, Any]:
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(path.read_text("utf-8"))
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
return {}
|
|
105
|
+
return data if isinstance(data, dict) else {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_token(host: str, token: str, path: Path | None = None) -> Path:
|
|
109
|
+
path = path or token_store_path()
|
|
110
|
+
store = _load_store(path)
|
|
111
|
+
store[host.lower()] = {"token": token, "created_at": utcnow().isoformat()}
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
path.write_text(json.dumps(store, indent=2, sort_keys=True), "utf-8")
|
|
114
|
+
try:
|
|
115
|
+
path.chmod(0o600)
|
|
116
|
+
except OSError: # pragma: no cover - platform dependent
|
|
117
|
+
pass
|
|
118
|
+
return path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_token(host: str, path: Path | None = None) -> str | None:
|
|
122
|
+
entry = _load_store(path or token_store_path()).get(host.lower())
|
|
123
|
+
token = entry.get("token") if isinstance(entry, dict) else None
|
|
124
|
+
return token if isinstance(token, str) else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def token_fingerprint(token: str) -> str:
|
|
128
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def expected_txt_record(token: str) -> str:
|
|
132
|
+
return TXT_PREFIX + token
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------------- verifiers
|
|
136
|
+
def verify_dns_txt(host: str, token: str, lookup: TxtLookup | None = None) -> VerificationResult:
|
|
137
|
+
"""Check for ``mcpprobe-verification=<token>`` at ``_mcpprobe.<host>``."""
|
|
138
|
+
if not host:
|
|
139
|
+
return VerificationResult(method="dns-txt", verified=False, detail="target has no host name")
|
|
140
|
+
if _is_ip_literal(host):
|
|
141
|
+
return VerificationResult(
|
|
142
|
+
method="dns-txt",
|
|
143
|
+
verified=False,
|
|
144
|
+
detail=f"{host} is an IP address; DNS TXT verification needs a host name you control",
|
|
145
|
+
)
|
|
146
|
+
resolver = lookup or default_txt_lookup
|
|
147
|
+
expected = expected_txt_record(token)
|
|
148
|
+
name = record_name(host)
|
|
149
|
+
try:
|
|
150
|
+
records = [r.strip().strip('"') for r in resolver(name)]
|
|
151
|
+
except DnsLookupError as exc:
|
|
152
|
+
raise AuthorisationError(str(exc)) from exc
|
|
153
|
+
# Keep only our own records in the snapshot; other TXT data is not ours to store.
|
|
154
|
+
ours = [r for r in records if r.startswith(TXT_PREFIX)]
|
|
155
|
+
snapshot = {"name": name, "records": ours, "checked_at": utcnow().isoformat()}
|
|
156
|
+
if expected in records:
|
|
157
|
+
return VerificationResult(
|
|
158
|
+
method="dns-txt", verified=True, detail=f"TXT record found at {name}", evidence=snapshot
|
|
159
|
+
)
|
|
160
|
+
return VerificationResult(
|
|
161
|
+
method="dns-txt",
|
|
162
|
+
verified=False,
|
|
163
|
+
detail=f"no TXT record {TXT_PREFIX}<token> matching the saved token found at {name}",
|
|
164
|
+
evidence=snapshot,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_ip_literal(host: str) -> bool:
|
|
169
|
+
try:
|
|
170
|
+
ipaddress.ip_address(host.strip("[]"))
|
|
171
|
+
except ValueError:
|
|
172
|
+
return False
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def verify_local(transport: str) -> VerificationResult:
|
|
177
|
+
if transport != "stdio":
|
|
178
|
+
return VerificationResult(
|
|
179
|
+
method="local",
|
|
180
|
+
verified=False,
|
|
181
|
+
detail="'local' verification is only valid for stdio targets the operator runs themselves",
|
|
182
|
+
)
|
|
183
|
+
return VerificationResult(
|
|
184
|
+
method="local",
|
|
185
|
+
verified=True,
|
|
186
|
+
detail="stdio package executed locally by the operator",
|
|
187
|
+
evidence={"checked_at": utcnow().isoformat()},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------------------ gate
|
|
192
|
+
def authorise(
|
|
193
|
+
*,
|
|
194
|
+
target: str,
|
|
195
|
+
transport: str,
|
|
196
|
+
host: str,
|
|
197
|
+
active: bool,
|
|
198
|
+
authorised_by: str | None,
|
|
199
|
+
scope: str | None,
|
|
200
|
+
verify_method: str | None,
|
|
201
|
+
token: str | None = None,
|
|
202
|
+
excluded_tools: Iterable[str] = (),
|
|
203
|
+
rate_cap_rps: float,
|
|
204
|
+
reference: str | None = None,
|
|
205
|
+
lookup: TxtLookup | None = None,
|
|
206
|
+
token_store: Path | None = None,
|
|
207
|
+
) -> AuthorisationRecord:
|
|
208
|
+
"""Build the authorisation record; raise :class:`AuthorisationError` if active mode is not allowed.
|
|
209
|
+
|
|
210
|
+
For passive mode the function still returns a record (unverified, for the
|
|
211
|
+
report), but never raises.
|
|
212
|
+
"""
|
|
213
|
+
mode = CheckMode.ACTIVE if active else CheckMode.PASSIVE
|
|
214
|
+
fingerprint: str | None = None
|
|
215
|
+
if not active:
|
|
216
|
+
verification = VerificationResult(
|
|
217
|
+
method="none", verified=False, detail="passive mode; no verification"
|
|
218
|
+
)
|
|
219
|
+
return _record(
|
|
220
|
+
target,
|
|
221
|
+
transport,
|
|
222
|
+
mode,
|
|
223
|
+
authorised_by,
|
|
224
|
+
scope,
|
|
225
|
+
reference,
|
|
226
|
+
verification,
|
|
227
|
+
excluded_tools,
|
|
228
|
+
rate_cap_rps,
|
|
229
|
+
None,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
missing = [name for name, value in (("--authorised-by", authorised_by), ("--scope", scope)) if not value]
|
|
233
|
+
if missing:
|
|
234
|
+
raise AuthorisationError("active mode requires " + " and ".join(missing))
|
|
235
|
+
if verify_method not in VERIFY_METHODS:
|
|
236
|
+
raise AuthorisationError(
|
|
237
|
+
f"active mode requires --verify {{{'|'.join(VERIFY_METHODS)}}} (got {verify_method!r})"
|
|
238
|
+
)
|
|
239
|
+
if verify_method == "local":
|
|
240
|
+
verification = verify_local(transport)
|
|
241
|
+
else:
|
|
242
|
+
token = token or load_token(host, token_store)
|
|
243
|
+
if not token:
|
|
244
|
+
raise AuthorisationError(
|
|
245
|
+
f"no verification token for {host}; run 'mcpprobe token {host}' and publish the TXT record, "
|
|
246
|
+
"or pass --token"
|
|
247
|
+
)
|
|
248
|
+
fingerprint = token_fingerprint(token)
|
|
249
|
+
verification = verify_dns_txt(host, token, lookup)
|
|
250
|
+
if not verification.verified:
|
|
251
|
+
raise AuthorisationError(f"ownership verification failed: {verification.detail}")
|
|
252
|
+
return _record(
|
|
253
|
+
target,
|
|
254
|
+
transport,
|
|
255
|
+
mode,
|
|
256
|
+
authorised_by,
|
|
257
|
+
scope,
|
|
258
|
+
reference,
|
|
259
|
+
verification,
|
|
260
|
+
excluded_tools,
|
|
261
|
+
rate_cap_rps,
|
|
262
|
+
fingerprint,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _record(
|
|
267
|
+
target: str,
|
|
268
|
+
transport: str,
|
|
269
|
+
mode: CheckMode,
|
|
270
|
+
authorised_by: str | None,
|
|
271
|
+
scope: str | None,
|
|
272
|
+
reference: str | None,
|
|
273
|
+
verification: VerificationResult,
|
|
274
|
+
excluded_tools: Iterable[str],
|
|
275
|
+
rate_cap_rps: float,
|
|
276
|
+
fingerprint: str | None,
|
|
277
|
+
) -> AuthorisationRecord:
|
|
278
|
+
return AuthorisationRecord(
|
|
279
|
+
timestamp=utcnow().isoformat(),
|
|
280
|
+
target=target,
|
|
281
|
+
transport=transport,
|
|
282
|
+
mode=mode,
|
|
283
|
+
authorised_by=authorised_by,
|
|
284
|
+
scope=scope,
|
|
285
|
+
operator_reference=reference,
|
|
286
|
+
verification=verification,
|
|
287
|
+
excluded_tools=sorted(set(excluded_tools)),
|
|
288
|
+
rate_cap_rps=rate_cap_rps,
|
|
289
|
+
token_fingerprint=fingerprint,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def record_path_for(report_path: str | os.PathLike[str] | None) -> Path:
|
|
294
|
+
"""``<report>.authorisation.json`` next to the report, or a timestamped file in cwd."""
|
|
295
|
+
if report_path:
|
|
296
|
+
p = Path(report_path)
|
|
297
|
+
return p.with_name(p.stem + ".authorisation.json")
|
|
298
|
+
stamp = utcnow().strftime("%Y%m%dT%H%M%SZ")
|
|
299
|
+
return Path(f"mcpprobe-authorisation-{stamp}.json")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def write_record(record: AuthorisationRecord, report_path: str | os.PathLike[str] | None) -> Path:
|
|
303
|
+
path = record_path_for(report_path)
|
|
304
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
path.write_text(record.model_dump_json(indent=2), "utf-8")
|
|
306
|
+
return path
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def require_active_authorisation(record: AuthorisationRecord | None) -> AuthorisationRecord:
|
|
310
|
+
"""Second gate used by the orchestrator right before running active checks."""
|
|
311
|
+
if record is None or record.mode != CheckMode.ACTIVE or not record.verified:
|
|
312
|
+
raise AuthorisationError("active checks refused: no verified authorisation record")
|
|
313
|
+
return record
|
mcpprobe/catalogue.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Generate the public checks catalogue (CHECKS.md) from check metadata.
|
|
2
|
+
|
|
3
|
+
``mcpprobe checks --format markdown`` prints the same text, and a test asserts
|
|
4
|
+
that the committed CHECKS.md matches, so the documentation cannot drift from
|
|
5
|
+
the code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mcpprobe import __version__
|
|
11
|
+
from mcpprobe.checks import ALL_CHECKS, Check
|
|
12
|
+
from mcpprobe.governance import GOVERNANCE_ITEMS
|
|
13
|
+
from mcpprobe.models import CheckMode, Standards
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _std(std: Standards) -> str:
|
|
17
|
+
parts = [f"{m} (beta)" for m in std.mcp] + list(std.asi) + list(std.llm_2026) + list(std.llm_2025)
|
|
18
|
+
return ", ".join(parts) if parts else "MCP spec conformance / hygiene (no OWASP item)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _transports(cls: type[Check]) -> str:
|
|
22
|
+
return ", ".join(sorted(cls.applies_to))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _section(cls: type[Check]) -> list[str]:
|
|
26
|
+
status = "implemented" if cls.mode == CheckMode.PASSIVE else "stub (gated, not implemented)"
|
|
27
|
+
return [
|
|
28
|
+
f"### {cls.id} - {cls.title}",
|
|
29
|
+
"",
|
|
30
|
+
"| | |",
|
|
31
|
+
"|---|---|",
|
|
32
|
+
f"| Mode | {cls.mode.value} ({status}) |",
|
|
33
|
+
f"| Default severity | {cls.severity.value} |",
|
|
34
|
+
f"| Transports | {_transports(cls)} |",
|
|
35
|
+
f"| Standards | {_std(cls.standards)} |",
|
|
36
|
+
f"| Evidence | {cls.evidence_type or '-'} |",
|
|
37
|
+
"",
|
|
38
|
+
cls.description,
|
|
39
|
+
"",
|
|
40
|
+
f"**False positives.** {cls.false_positive_notes or 'None known.'}",
|
|
41
|
+
"",
|
|
42
|
+
f"**Fix.** {cls.fix or '-'}",
|
|
43
|
+
"",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def checks_markdown() -> str:
|
|
48
|
+
lines: list[str] = [
|
|
49
|
+
"# mcpprobe checks catalogue",
|
|
50
|
+
"",
|
|
51
|
+
f"Generated from the check metadata of mcpprobe {__version__} "
|
|
52
|
+
"(`mcpprobe checks --format markdown`). Do not edit by hand.",
|
|
53
|
+
"",
|
|
54
|
+
"Standards: OWASP MCP Top 10 (IDs `MCP0x:2025`, still a beta list, so shown with `(beta)`), "
|
|
55
|
+
"OWASP Top 10 for Agentic Applications (`ASI0x`), OWASP Top 10 for LLM Applications 2026 "
|
|
56
|
+
"(`LLM0x:2026`, primary) and 2025 (`LLM0x:2025`, kept for one release cycle). Severities are "
|
|
57
|
+
"defaults; a check can raise or lower the severity of an individual finding (for example "
|
|
58
|
+
"loopback-only servers are reported one level lower), and heuristic checks never rate a "
|
|
59
|
+
"finding Critical on their own.",
|
|
60
|
+
"",
|
|
61
|
+
"Passive checks send only what a normal MCP client would send plus a few benign malformed "
|
|
62
|
+
"requests. They never call a tool. Active checks are listed so coverage is reported honestly; "
|
|
63
|
+
"in this release they are stubs that run only behind the authorisation gate and report "
|
|
64
|
+
"`not_implemented`.",
|
|
65
|
+
"",
|
|
66
|
+
"## Summary",
|
|
67
|
+
"",
|
|
68
|
+
"| ID | Mode | Default severity | Title | Standards |",
|
|
69
|
+
"|---|---|---|---|---|",
|
|
70
|
+
]
|
|
71
|
+
for cls in ALL_CHECKS:
|
|
72
|
+
lines.append(
|
|
73
|
+
f"| [{cls.id}](#{cls.id.lower()}---{_anchor(cls.title)}) | {cls.mode.value} | "
|
|
74
|
+
f"{cls.severity.value} | {cls.title} | {_std(cls.standards)} |"
|
|
75
|
+
)
|
|
76
|
+
for item in GOVERNANCE_ITEMS:
|
|
77
|
+
lines.append(f"| {item.id} | attestation | - | {item.title} | {_std(item.standards)} |")
|
|
78
|
+
lines += ["", "## Passive checks", ""]
|
|
79
|
+
for cls in ALL_CHECKS:
|
|
80
|
+
if cls.mode == CheckMode.PASSIVE:
|
|
81
|
+
lines += _section(cls)
|
|
82
|
+
lines += [
|
|
83
|
+
"## Active checks (stubs)",
|
|
84
|
+
"",
|
|
85
|
+
"Rules of engagement for when these are implemented: non-destructive, non-persistent "
|
|
86
|
+
"canaries only; tools excluded with `--exclude-tool` or annotated `destructiveHint: true` are "
|
|
87
|
+
"never called; at most one concurrent tool call; every payload is recorded as evidence; the "
|
|
88
|
+
"scan stops on the first sign of an unintended side effect.",
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
for cls in ALL_CHECKS:
|
|
92
|
+
if cls.mode == CheckMode.ACTIVE:
|
|
93
|
+
lines += _section(cls)
|
|
94
|
+
lines += [
|
|
95
|
+
"## Governance items (attestation only)",
|
|
96
|
+
"",
|
|
97
|
+
"These cannot be assessed by an automated black-box scan. Every report lists them as "
|
|
98
|
+
"`attestation-only` with the checklist below; mcpprobe never marks them passed or failed.",
|
|
99
|
+
"",
|
|
100
|
+
]
|
|
101
|
+
for item in GOVERNANCE_ITEMS:
|
|
102
|
+
lines += [
|
|
103
|
+
f"### {item.id} - {item.title}",
|
|
104
|
+
"",
|
|
105
|
+
f"Standards: {_std(item.standards)}",
|
|
106
|
+
"",
|
|
107
|
+
item.reason,
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
lines += [f"- [ ] {c}" for c in item.checklist]
|
|
111
|
+
lines.append("")
|
|
112
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _anchor(title: str) -> str:
|
|
116
|
+
out = []
|
|
117
|
+
for ch in title.lower():
|
|
118
|
+
if ch.isalnum() or ch in "-_":
|
|
119
|
+
out.append(ch)
|
|
120
|
+
elif ch == " ":
|
|
121
|
+
out.append("-")
|
|
122
|
+
return "".join(out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["checks_markdown"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Check registry.
|
|
2
|
+
|
|
3
|
+
``ALL_CHECKS`` lists every check class in execution order. Passive checks run
|
|
4
|
+
by default; active ones are stubs gated behind the authorisation record.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
|
|
11
|
+
from mcpprobe.checks.active_stubs import ACTIVE_STUBS
|
|
12
|
+
from mcpprobe.checks.auth import (
|
|
13
|
+
Auth01NoAuthentication,
|
|
14
|
+
Auth02MissingProtectedResourceMetadata,
|
|
15
|
+
Auth03AuthorizationServerMetadata,
|
|
16
|
+
Auth04TokenAudienceAndPassthrough,
|
|
17
|
+
)
|
|
18
|
+
from mcpprobe.checks.base import (
|
|
19
|
+
ActiveCheck,
|
|
20
|
+
Check,
|
|
21
|
+
JudgeFn,
|
|
22
|
+
JudgeVerdict,
|
|
23
|
+
ScanContext,
|
|
24
|
+
ScanOptions,
|
|
25
|
+
)
|
|
26
|
+
from mcpprobe.checks.baseline import Meta02RugPullBaseline
|
|
27
|
+
from mcpprobe.checks.metadata import (
|
|
28
|
+
Meta03DangerousCapabilitySurface,
|
|
29
|
+
Meta04SchemaHygiene,
|
|
30
|
+
Meta05SecretsInMetadata,
|
|
31
|
+
)
|
|
32
|
+
from mcpprobe.checks.poisoning import Meta01ToolDescriptionPoisoning
|
|
33
|
+
from mcpprobe.checks.robustness import Dos01UnboundedConsumption, Err01VerboseErrors
|
|
34
|
+
from mcpprobe.checks.transport_conformance import (
|
|
35
|
+
Trans01OriginValidation,
|
|
36
|
+
Trans02ProtocolVersionHandling,
|
|
37
|
+
Trans03SessionIdQuality,
|
|
38
|
+
Trans04DeprecatedTransport,
|
|
39
|
+
Trans05TlsAndHeaders,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
PASSIVE_CHECKS: list[type[Check]] = [
|
|
43
|
+
Auth01NoAuthentication,
|
|
44
|
+
Auth02MissingProtectedResourceMetadata,
|
|
45
|
+
Auth03AuthorizationServerMetadata,
|
|
46
|
+
Auth04TokenAudienceAndPassthrough,
|
|
47
|
+
Trans01OriginValidation,
|
|
48
|
+
Trans02ProtocolVersionHandling,
|
|
49
|
+
Trans03SessionIdQuality,
|
|
50
|
+
Trans04DeprecatedTransport,
|
|
51
|
+
Trans05TlsAndHeaders,
|
|
52
|
+
Meta01ToolDescriptionPoisoning,
|
|
53
|
+
Meta02RugPullBaseline,
|
|
54
|
+
Meta03DangerousCapabilitySurface,
|
|
55
|
+
Meta04SchemaHygiene,
|
|
56
|
+
Meta05SecretsInMetadata,
|
|
57
|
+
Err01VerboseErrors,
|
|
58
|
+
Dos01UnboundedConsumption,
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
ALL_CHECKS: list[type[Check]] = [*PASSIVE_CHECKS, *ACTIVE_STUBS]
|
|
62
|
+
|
|
63
|
+
_BY_ID: dict[str, type[Check]] = {c.id: c for c in ALL_CHECKS}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_check(check_id: str) -> type[Check]:
|
|
67
|
+
try:
|
|
68
|
+
return _BY_ID[check_id.upper()]
|
|
69
|
+
except KeyError as exc:
|
|
70
|
+
raise KeyError(f"unknown check id: {check_id}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def select_checks(only: Iterable[str] | None = None, skip: Iterable[str] | None = None) -> list[Check]:
|
|
74
|
+
"""Instantiate checks, honouring ``--only`` / ``--skip`` lists."""
|
|
75
|
+
only_set = {s.upper() for s in only} if only else None
|
|
76
|
+
skip_set = {s.upper() for s in skip} if skip else set()
|
|
77
|
+
for cid in (only_set or set()) | skip_set:
|
|
78
|
+
get_check(cid) # raise on typos
|
|
79
|
+
out: list[Check] = []
|
|
80
|
+
for cls in ALL_CHECKS:
|
|
81
|
+
if only_set is not None and cls.id not in only_set:
|
|
82
|
+
continue
|
|
83
|
+
if cls.id in skip_set:
|
|
84
|
+
continue
|
|
85
|
+
out.append(cls())
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"ALL_CHECKS",
|
|
91
|
+
"PASSIVE_CHECKS",
|
|
92
|
+
"ActiveCheck",
|
|
93
|
+
"Check",
|
|
94
|
+
"JudgeFn",
|
|
95
|
+
"JudgeVerdict",
|
|
96
|
+
"ScanContext",
|
|
97
|
+
"ScanOptions",
|
|
98
|
+
"get_check",
|
|
99
|
+
"select_checks",
|
|
100
|
+
]
|