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/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
``mcpprobe <target>`` is shorthand for ``mcpprobe scan <target>``.
|
|
4
|
+
|
|
5
|
+
Exit codes: 0 = no findings at or above ``--fail-on``; 1 = findings at or
|
|
6
|
+
above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
|
|
7
|
+
refused, bad arguments).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from mcpprobe import POLICY_URL, USER_AGENT, __version__
|
|
20
|
+
from mcpprobe.authz import (
|
|
21
|
+
AuthorisationError,
|
|
22
|
+
authorise,
|
|
23
|
+
expected_txt_record,
|
|
24
|
+
generate_token,
|
|
25
|
+
save_token,
|
|
26
|
+
write_record,
|
|
27
|
+
)
|
|
28
|
+
from mcpprobe.checks import ALL_CHECKS, ScanOptions
|
|
29
|
+
from mcpprobe.checks._util import host_of
|
|
30
|
+
from mcpprobe.dnsutil import record_name
|
|
31
|
+
from mcpprobe.models import CheckMode, Severity
|
|
32
|
+
from mcpprobe.policy import DEFAULT_RATE, MAX_RATE, OptOutError
|
|
33
|
+
from mcpprobe.report import render, write_json, write_sarif
|
|
34
|
+
from mcpprobe.scanner import ScanConfig, run_scan
|
|
35
|
+
from mcpprobe.transport import TransportError, ensure_url
|
|
36
|
+
|
|
37
|
+
EXIT_CLEAN = 0
|
|
38
|
+
EXIT_FINDINGS = 1
|
|
39
|
+
EXIT_ERROR = 2
|
|
40
|
+
|
|
41
|
+
SEVERITIES = [s.value for s in Severity]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
45
|
+
@click.version_option(__version__, prog_name="mcpprobe")
|
|
46
|
+
def cli() -> None:
|
|
47
|
+
"""mcpprobe: security scanner for MCP servers. Scan only servers you own or are authorised to test."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_kv(values: Sequence[str], what: str, sep: str) -> dict[str, str]:
|
|
51
|
+
out: dict[str, str] = {}
|
|
52
|
+
for item in values:
|
|
53
|
+
key, found, value = item.partition(sep)
|
|
54
|
+
if not found or not key.strip():
|
|
55
|
+
raise click.BadParameter(f"{what} must be KEY{sep}VALUE, got {item!r}")
|
|
56
|
+
out[key.strip()] = value.strip()
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command("scan")
|
|
61
|
+
@click.argument("target")
|
|
62
|
+
@click.option("--stdio", is_flag=True, help="Treat TARGET as a local command to spawn (stdio transport).")
|
|
63
|
+
@click.option(
|
|
64
|
+
"--env", "env_vars", multiple=True, help="Environment for the stdio server, KEY=VALUE (repeatable)."
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--header",
|
|
68
|
+
"headers",
|
|
69
|
+
multiple=True,
|
|
70
|
+
help="Extra HTTP header, 'Name: value' (repeatable), e.g. a bearer token you own.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--active", is_flag=True, help="Enable active checks. Requires --authorised-by, --scope and --verify."
|
|
74
|
+
)
|
|
75
|
+
@click.option("--authorised-by", help="Who authorised this scan (name/role/ticket). Required for --active.")
|
|
76
|
+
@click.option(
|
|
77
|
+
"--scope",
|
|
78
|
+
help="Agreed scope statement, e.g. 'https://host/mcp, read-only tools only'. Required for --active.",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--verify",
|
|
82
|
+
"verify_method",
|
|
83
|
+
type=click.Choice(["dns-txt", "local"]),
|
|
84
|
+
help="Ownership verification method for --active.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--token", help="Verification token for --verify dns-txt (default: the one saved by 'mcpprobe token')."
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--reference", help="Operator reference stored in the authorisation record (ticket, change number)."
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--exclude-tool",
|
|
94
|
+
"excluded_tools",
|
|
95
|
+
multiple=True,
|
|
96
|
+
help="Tool names that must never be called (repeatable).",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--rate",
|
|
100
|
+
default=DEFAULT_RATE,
|
|
101
|
+
show_default=True,
|
|
102
|
+
type=click.FloatRange(min=0.1, max=MAX_RATE),
|
|
103
|
+
help=f"Maximum requests per second (hard ceiling {MAX_RATE:g}).",
|
|
104
|
+
)
|
|
105
|
+
@click.option("--timeout", default=15.0, show_default=True, help="Per-request timeout in seconds.")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--fail-on",
|
|
108
|
+
default="high",
|
|
109
|
+
type=click.Choice(SEVERITIES),
|
|
110
|
+
show_default=True,
|
|
111
|
+
help="Exit 1 if any finding is at or above this severity.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--json", "json_path", type=click.Path(dir_okay=False), help="Write the JSON report (with evidence) here."
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--sarif", "sarif_path", type=click.Path(dir_okay=False), help="Write a SARIF 2.1.0 report here."
|
|
118
|
+
)
|
|
119
|
+
@click.option(
|
|
120
|
+
"--sarif-location", help="Repository-relative file to anchor SARIF results to (default: the target URI)."
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--baseline",
|
|
124
|
+
"baseline_path",
|
|
125
|
+
type=click.Path(dir_okay=False),
|
|
126
|
+
help="META-02 baseline file to compare against and update.",
|
|
127
|
+
)
|
|
128
|
+
@click.option("--no-write-baseline", is_flag=True, help="Compare against --baseline but do not overwrite it.")
|
|
129
|
+
@click.option(
|
|
130
|
+
"--no-follow-as",
|
|
131
|
+
is_flag=True,
|
|
132
|
+
help="AUTH-03: do not fetch authorization-server metadata on third-party hosts.",
|
|
133
|
+
)
|
|
134
|
+
@click.option("--only", multiple=True, help="Run only these check IDs (repeatable).")
|
|
135
|
+
@click.option("--skip", multiple=True, help="Skip these check IDs (repeatable).")
|
|
136
|
+
@click.option("--insecure", is_flag=True, help="Do not verify the target's TLS certificate.")
|
|
137
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show descriptions and fixes in the terminal summary.")
|
|
138
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress the terminal summary.")
|
|
139
|
+
def scan_cmd(
|
|
140
|
+
target: str,
|
|
141
|
+
stdio: bool,
|
|
142
|
+
env_vars: Sequence[str],
|
|
143
|
+
headers: Sequence[str],
|
|
144
|
+
active: bool,
|
|
145
|
+
authorised_by: str | None,
|
|
146
|
+
scope: str | None,
|
|
147
|
+
verify_method: str | None,
|
|
148
|
+
token: str | None,
|
|
149
|
+
reference: str | None,
|
|
150
|
+
excluded_tools: Sequence[str],
|
|
151
|
+
rate: float,
|
|
152
|
+
timeout: float,
|
|
153
|
+
fail_on: str,
|
|
154
|
+
json_path: str | None,
|
|
155
|
+
sarif_path: str | None,
|
|
156
|
+
sarif_location: str | None,
|
|
157
|
+
baseline_path: str | None,
|
|
158
|
+
no_write_baseline: bool,
|
|
159
|
+
no_follow_as: bool,
|
|
160
|
+
only: Sequence[str],
|
|
161
|
+
skip: Sequence[str],
|
|
162
|
+
insecure: bool,
|
|
163
|
+
verbose: bool,
|
|
164
|
+
quiet: bool,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Scan TARGET (an http(s) URL, or a command with --stdio)."""
|
|
167
|
+
console = Console(stderr=True)
|
|
168
|
+
err = Console(stderr=True, style="bold red")
|
|
169
|
+
try:
|
|
170
|
+
if not stdio:
|
|
171
|
+
target = ensure_url(target)
|
|
172
|
+
transport_kind = "stdio" if stdio else "http"
|
|
173
|
+
host = "" if stdio else host_of(target)
|
|
174
|
+
try:
|
|
175
|
+
record = authorise(
|
|
176
|
+
target=target,
|
|
177
|
+
transport=transport_kind,
|
|
178
|
+
host=host,
|
|
179
|
+
active=active,
|
|
180
|
+
authorised_by=authorised_by,
|
|
181
|
+
scope=scope,
|
|
182
|
+
verify_method=verify_method,
|
|
183
|
+
token=token,
|
|
184
|
+
excluded_tools=excluded_tools,
|
|
185
|
+
rate_cap_rps=rate,
|
|
186
|
+
reference=reference,
|
|
187
|
+
)
|
|
188
|
+
except AuthorisationError as exc:
|
|
189
|
+
err.print(f"authorisation refused: {exc}", markup=False, highlight=False)
|
|
190
|
+
err.print("active mode was not started; no request was sent to the target.", markup=False)
|
|
191
|
+
sys.exit(EXIT_ERROR)
|
|
192
|
+
options = ScanOptions(
|
|
193
|
+
mode=CheckMode.ACTIVE if active else CheckMode.PASSIVE,
|
|
194
|
+
baseline_path=baseline_path,
|
|
195
|
+
write_baseline=not no_write_baseline,
|
|
196
|
+
excluded_tools=set(excluded_tools),
|
|
197
|
+
follow_authorization_server=not no_follow_as,
|
|
198
|
+
timeout=timeout,
|
|
199
|
+
)
|
|
200
|
+
config = ScanConfig(
|
|
201
|
+
target=target,
|
|
202
|
+
stdio=stdio,
|
|
203
|
+
stdio_env=_parse_kv(env_vars, "--env", "="),
|
|
204
|
+
headers=_parse_kv(headers, "--header", ":"),
|
|
205
|
+
timeout=timeout,
|
|
206
|
+
rate=rate,
|
|
207
|
+
options=options,
|
|
208
|
+
only=list(only) or None,
|
|
209
|
+
skip=list(skip) or None,
|
|
210
|
+
authorisation=record if active else None,
|
|
211
|
+
user_agent=USER_AGENT,
|
|
212
|
+
verify_tls=not insecure,
|
|
213
|
+
)
|
|
214
|
+
if not quiet:
|
|
215
|
+
console.print(
|
|
216
|
+
f"[dim]mcpprobe {__version__} | {('active' if active else 'passive')} mode | rate {rate}/s | "
|
|
217
|
+
f"scan only what you own or are authorised to test | {POLICY_URL}[/dim]"
|
|
218
|
+
)
|
|
219
|
+
report, evidence = asyncio.run(run_scan(config))
|
|
220
|
+
except OptOutError as exc:
|
|
221
|
+
err.print(f"aborted: {exc}", markup=False, highlight=False)
|
|
222
|
+
sys.exit(EXIT_ERROR)
|
|
223
|
+
except (TransportError, AuthorisationError) as exc:
|
|
224
|
+
err.print(f"error: {exc}", markup=False, highlight=False)
|
|
225
|
+
sys.exit(EXIT_ERROR)
|
|
226
|
+
except click.ClickException:
|
|
227
|
+
raise
|
|
228
|
+
except KeyError as exc:
|
|
229
|
+
err.print(f"error: {exc.args[0] if exc.args else exc}", markup=False, highlight=False)
|
|
230
|
+
sys.exit(EXIT_ERROR)
|
|
231
|
+
|
|
232
|
+
if active:
|
|
233
|
+
path = write_record(record, json_path or sarif_path)
|
|
234
|
+
report.authorisation_record = str(path)
|
|
235
|
+
if json_path:
|
|
236
|
+
write_json(report, json_path, evidence)
|
|
237
|
+
if sarif_path:
|
|
238
|
+
write_sarif(report, sarif_path, location_uri=sarif_location)
|
|
239
|
+
threshold = Severity(fail_on)
|
|
240
|
+
if not quiet:
|
|
241
|
+
render(report, Console(), verbose=verbose, show_evidence=True, fail_on=threshold)
|
|
242
|
+
outputs = (
|
|
243
|
+
("JSON", json_path),
|
|
244
|
+
("SARIF", sarif_path),
|
|
245
|
+
("authorisation record", report.authorisation_record),
|
|
246
|
+
)
|
|
247
|
+
for label, out_path in outputs:
|
|
248
|
+
if out_path:
|
|
249
|
+
console.print(f"{label}: {out_path}", style="dim", markup=False, highlight=False)
|
|
250
|
+
worst = report.max_severity()
|
|
251
|
+
sys.exit(EXIT_FINDINGS if worst is not None and worst >= threshold else EXIT_CLEAN)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command("checks")
|
|
255
|
+
@click.option("--format", "fmt", type=click.Choice(["table", "markdown"]), default="table", show_default=True)
|
|
256
|
+
def checks_cmd(fmt: str) -> None:
|
|
257
|
+
"""List every check with its mode, default severity and standards mapping."""
|
|
258
|
+
if fmt == "markdown":
|
|
259
|
+
from mcpprobe.catalogue import checks_markdown
|
|
260
|
+
|
|
261
|
+
click.echo(checks_markdown(), nl=False)
|
|
262
|
+
return
|
|
263
|
+
from rich.table import Table
|
|
264
|
+
|
|
265
|
+
table = Table(title=f"mcpprobe {__version__} checks")
|
|
266
|
+
for col in ("ID", "Mode", "Severity", "Title", "Standards"):
|
|
267
|
+
table.add_column(col)
|
|
268
|
+
for c in ALL_CHECKS:
|
|
269
|
+
table.add_row(c.id, c.mode.value, c.severity.value, c.title, ", ".join(c.standards.as_tags()) or "-")
|
|
270
|
+
Console().print(table)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@cli.command("token")
|
|
274
|
+
@click.argument("host")
|
|
275
|
+
def token_cmd(host: str) -> None:
|
|
276
|
+
"""Generate and save an ownership-verification token for HOST and print the TXT record to publish."""
|
|
277
|
+
token = generate_token()
|
|
278
|
+
path = save_token(host, token)
|
|
279
|
+
click.echo(f"Saved token for {host} in {path}")
|
|
280
|
+
click.echo("Publish this DNS TXT record, then run with --active --verify dns-txt:")
|
|
281
|
+
click.echo(f' {record_name(host)}. TXT "{expected_txt_record(token)}"')
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
285
|
+
"""Entry point: default to the ``scan`` sub-command when the first argument is a target."""
|
|
286
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
287
|
+
known = {"scan", "checks", "token"}
|
|
288
|
+
group_options = {"-h", "--help", "--version"}
|
|
289
|
+
if args and args[0] not in known and args[0] not in group_options:
|
|
290
|
+
args.insert(0, "scan")
|
|
291
|
+
cli.main(args=args, prog_name="mcpprobe", standalone_mode=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__": # pragma: no cover
|
|
295
|
+
main()
|
mcpprobe/dnsutil.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""DNS TXT lookups shared by ownership verification and opt-out handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
#: Resolver signature: name -> list of TXT strings ([] when the name has none).
|
|
9
|
+
TxtLookup = Callable[[str], list[str]]
|
|
10
|
+
|
|
11
|
+
#: Label under which both verification and opt-out records live: ``_mcpprobe.<host>``.
|
|
12
|
+
RECORD_LABEL = "_mcpprobe"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DnsLookupError(Exception):
|
|
16
|
+
"""The resolver failed for a reason other than 'no such record'."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def record_name(host: str) -> str:
|
|
20
|
+
return f"{RECORD_LABEL}.{host.rstrip('.').lower()}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_public_dns_name(host: str) -> bool:
|
|
24
|
+
"""False for IP literals and loopback-only names, which have no public TXT records."""
|
|
25
|
+
h = host.strip("[]").rstrip(".").lower()
|
|
26
|
+
if not h:
|
|
27
|
+
return False
|
|
28
|
+
try:
|
|
29
|
+
ipaddress.ip_address(h)
|
|
30
|
+
except ValueError:
|
|
31
|
+
pass
|
|
32
|
+
else:
|
|
33
|
+
return False
|
|
34
|
+
if h in {"localhost", "ip6-localhost"} or h.endswith((".localhost", ".local", ".internal", ".invalid")):
|
|
35
|
+
return False
|
|
36
|
+
return "." in h
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_txt_lookup(name: str) -> list[str]:
|
|
40
|
+
"""Resolve TXT records with dnspython; ``[]`` on NXDOMAIN / no answer."""
|
|
41
|
+
import dns.exception
|
|
42
|
+
import dns.resolver
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
answers = dns.resolver.resolve(name, "TXT", lifetime=5.0)
|
|
46
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
|
47
|
+
return []
|
|
48
|
+
except dns.exception.DNSException as exc:
|
|
49
|
+
raise DnsLookupError(f"DNS lookup failed for {name}: {exc}") from exc
|
|
50
|
+
out: list[str] = []
|
|
51
|
+
for rdata in answers:
|
|
52
|
+
strings = getattr(rdata, "strings", None)
|
|
53
|
+
if strings is None:
|
|
54
|
+
out.append(str(rdata).strip('"'))
|
|
55
|
+
else:
|
|
56
|
+
out.append(b"".join(strings).decode("utf-8", errors="replace"))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"RECORD_LABEL",
|
|
62
|
+
"DnsLookupError",
|
|
63
|
+
"TxtLookup",
|
|
64
|
+
"default_txt_lookup",
|
|
65
|
+
"has_public_dns_name",
|
|
66
|
+
"record_name",
|
|
67
|
+
]
|
mcpprobe/governance.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""GOV-01..05: governance items that an automated scan cannot assess.
|
|
2
|
+
|
|
3
|
+
They are listed in every report as *attestation only*, with a short checklist
|
|
4
|
+
the operator can work through. mcpprobe never reports them as passed or failed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mcpprobe.models import AttestationItem, Standards
|
|
10
|
+
|
|
11
|
+
GOVERNANCE_ITEMS: list[AttestationItem] = [
|
|
12
|
+
AttestationItem(
|
|
13
|
+
id="GOV-01",
|
|
14
|
+
title="Audit logging and telemetry for tool invocations",
|
|
15
|
+
standards=Standards(mcp=["MCP08:2025"]),
|
|
16
|
+
reason="Whether a server logs and alerts on tool calls is not observable from outside.",
|
|
17
|
+
checklist=[
|
|
18
|
+
"Every tools/call is logged with caller identity, tool name, arguments digest and outcome.",
|
|
19
|
+
"Logs are shipped off-host, retained, and reviewed or alerted on.",
|
|
20
|
+
"Authentication failures and authorisation denials are logged.",
|
|
21
|
+
],
|
|
22
|
+
),
|
|
23
|
+
AttestationItem(
|
|
24
|
+
id="GOV-02",
|
|
25
|
+
title="Shadow MCP servers (inventory of servers in use)",
|
|
26
|
+
standards=Standards(mcp=["MCP09:2025"]),
|
|
27
|
+
reason="An inventory problem across workstations and repositories, not an endpoint property.",
|
|
28
|
+
checklist=[
|
|
29
|
+
"Maintain an inventory of approved MCP servers and their owners.",
|
|
30
|
+
"Periodically review client configuration files (mcp.json and similar) for unapproved entries.",
|
|
31
|
+
],
|
|
32
|
+
),
|
|
33
|
+
AttestationItem(
|
|
34
|
+
id="GOV-03",
|
|
35
|
+
title="Data and model poisoning in training or fine-tuning pipelines",
|
|
36
|
+
standards=Standards(llm_2025=["LLM04:2025"], llm_2026=["LLM05:2026"]),
|
|
37
|
+
reason="Training-pipeline integrity is not black-box testable.",
|
|
38
|
+
checklist=[
|
|
39
|
+
"Training and retrieval data sources are provenance-tracked and access-controlled.",
|
|
40
|
+
"Data changes are reviewed before they reach a model or index.",
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
AttestationItem(
|
|
44
|
+
id="GOV-04",
|
|
45
|
+
title="Inter-agent communication, cascading failures, human trust, rogue agents",
|
|
46
|
+
standards=Standards(asi=["ASI07", "ASI08", "ASI09", "ASI10"]),
|
|
47
|
+
reason="Architecture and process items; multi-agent paths are not externally visible.",
|
|
48
|
+
checklist=[
|
|
49
|
+
"Agent-to-agent channels are authenticated (the AUTH checks apply to any exposed endpoint).",
|
|
50
|
+
"Failure of one tool or agent is contained (timeouts, circuit breakers, budgets).",
|
|
51
|
+
"High-impact actions require explicit human approval with the full action shown.",
|
|
52
|
+
"Agents can be stopped centrally and their credentials revoked.",
|
|
53
|
+
],
|
|
54
|
+
),
|
|
55
|
+
AttestationItem(
|
|
56
|
+
id="GOV-05",
|
|
57
|
+
title="Memory and context poisoning persistence",
|
|
58
|
+
standards=Standards(asi=["ASI06"]),
|
|
59
|
+
reason="Needs a customer-supplied test session across two conversations; not a server-side scan.",
|
|
60
|
+
checklist=[
|
|
61
|
+
"Persistent memory is scoped per user and per tenant.",
|
|
62
|
+
"Stored memories can be reviewed and deleted by the user.",
|
|
63
|
+
"Content from tool output is not written to long-term memory without review.",
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def governance_items() -> list[AttestationItem]:
|
|
70
|
+
"""Fresh copies, so a report can be mutated without touching the module constant."""
|
|
71
|
+
return [item.model_copy(deep=True) for item in GOVERNANCE_ITEMS]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["GOVERNANCE_ITEMS", "governance_items"]
|
mcpprobe/models.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Core data models shared by the transport, checks and reporters.
|
|
2
|
+
|
|
3
|
+
Everything a reporter needs to render is a pydantic model so that the JSON
|
|
4
|
+
report is a faithful, schema-stable serialisation of what the scanner saw.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
#: Maximum number of body bytes retained per request/response in evidence.
|
|
16
|
+
EVIDENCE_BODY_LIMIT = 4096
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Severity(str, Enum):
|
|
20
|
+
"""Finding severity, ordered from least to most severe."""
|
|
21
|
+
|
|
22
|
+
INFO = "info"
|
|
23
|
+
LOW = "low"
|
|
24
|
+
MEDIUM = "medium"
|
|
25
|
+
HIGH = "high"
|
|
26
|
+
CRITICAL = "critical"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def rank(self) -> int:
|
|
30
|
+
"""Numeric rank so severities can be compared (``info`` = 0)."""
|
|
31
|
+
return _SEVERITY_ORDER.index(self)
|
|
32
|
+
|
|
33
|
+
def __ge__(self, other: object) -> bool:
|
|
34
|
+
if not isinstance(other, Severity):
|
|
35
|
+
return NotImplemented
|
|
36
|
+
return self.rank >= other.rank
|
|
37
|
+
|
|
38
|
+
def __gt__(self, other: object) -> bool:
|
|
39
|
+
if not isinstance(other, Severity):
|
|
40
|
+
return NotImplemented
|
|
41
|
+
return self.rank > other.rank
|
|
42
|
+
|
|
43
|
+
def __le__(self, other: object) -> bool:
|
|
44
|
+
if not isinstance(other, Severity):
|
|
45
|
+
return NotImplemented
|
|
46
|
+
return self.rank <= other.rank
|
|
47
|
+
|
|
48
|
+
def __lt__(self, other: object) -> bool:
|
|
49
|
+
if not isinstance(other, Severity):
|
|
50
|
+
return NotImplemented
|
|
51
|
+
return self.rank < other.rank
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SEVERITY_ORDER = [
|
|
55
|
+
Severity.INFO,
|
|
56
|
+
Severity.LOW,
|
|
57
|
+
Severity.MEDIUM,
|
|
58
|
+
Severity.HIGH,
|
|
59
|
+
Severity.CRITICAL,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Confidence(str, Enum):
|
|
64
|
+
"""How sure the scanner is that a finding is a true positive."""
|
|
65
|
+
|
|
66
|
+
HIGH = "high"
|
|
67
|
+
MEDIUM = "medium"
|
|
68
|
+
LOW = "low"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CheckMode(str, Enum):
|
|
72
|
+
"""Passive checks only send requests a client might send plus benign
|
|
73
|
+
malformed ones; active checks need explicit authorisation."""
|
|
74
|
+
|
|
75
|
+
PASSIVE = "passive"
|
|
76
|
+
ACTIVE = "active"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Standards(BaseModel):
|
|
80
|
+
"""Mapping of a check onto the four OWASP artefacts.
|
|
81
|
+
|
|
82
|
+
IDs are kept verbatim as published. The OWASP MCP Top 10 is still a beta
|
|
83
|
+
list, so its IDs carry a ``(beta)`` suffix in rendered output.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
mcp: list[str] = Field(default_factory=list, description="OWASP MCP Top 10, e.g. MCP07:2025")
|
|
87
|
+
asi: list[str] = Field(default_factory=list, description="OWASP Agentic Top 10, e.g. ASI03")
|
|
88
|
+
llm_2025: list[str] = Field(default_factory=list, description="OWASP LLM Top 10 2025")
|
|
89
|
+
llm_2026: list[str] = Field(default_factory=list, description="OWASP LLM Top 10 2026")
|
|
90
|
+
|
|
91
|
+
def as_tags(self) -> list[str]:
|
|
92
|
+
"""Flatten to a list of strings suitable for SARIF ``tags``."""
|
|
93
|
+
tags = [f"{m} (beta)" for m in self.mcp]
|
|
94
|
+
tags += list(self.asi)
|
|
95
|
+
tags += list(self.llm_2026)
|
|
96
|
+
tags += list(self.llm_2025)
|
|
97
|
+
return tags
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EvidenceRecord(BaseModel):
|
|
101
|
+
"""One request/response pair observed by the transport.
|
|
102
|
+
|
|
103
|
+
Bodies are truncated to :data:`EVIDENCE_BODY_LIMIT` bytes; only a fixed
|
|
104
|
+
subset of headers is kept and ``Authorization`` values are redacted.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
timestamp: dt.datetime
|
|
109
|
+
transport: str = Field(description="'http' or 'stdio'")
|
|
110
|
+
method: str = Field(description="JSON-RPC method, or HTTP verb for non-RPC requests")
|
|
111
|
+
url: str | None = None
|
|
112
|
+
status: int | None = Field(default=None, description="HTTP status; None for stdio")
|
|
113
|
+
request_headers: dict[str, str] = Field(default_factory=dict)
|
|
114
|
+
response_headers: dict[str, str] = Field(default_factory=dict)
|
|
115
|
+
request_body: str = ""
|
|
116
|
+
response_body: str = ""
|
|
117
|
+
request_truncated: bool = False
|
|
118
|
+
response_truncated: bool = False
|
|
119
|
+
elapsed_ms: float | None = None
|
|
120
|
+
note: str | None = None
|
|
121
|
+
error: str | None = Field(default=None, description="Transport-level error, if any")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Finding(BaseModel):
|
|
125
|
+
"""A single security finding produced by a check."""
|
|
126
|
+
|
|
127
|
+
id: str = Field(description="Check ID, e.g. AUTH-01")
|
|
128
|
+
title: str
|
|
129
|
+
severity: Severity
|
|
130
|
+
confidence: Confidence
|
|
131
|
+
standards: Standards
|
|
132
|
+
description: str = Field(description="What was observed and why it matters")
|
|
133
|
+
fix: str = Field(description="Plain-English remediation")
|
|
134
|
+
evidence: list[EvidenceRecord] = Field(default_factory=list)
|
|
135
|
+
evidence_excerpt: str = Field(
|
|
136
|
+
default="",
|
|
137
|
+
description="Short human-readable excerpt of the evidence (request, status, body snippet)",
|
|
138
|
+
)
|
|
139
|
+
location: str | None = Field(
|
|
140
|
+
default=None,
|
|
141
|
+
description="Where in the target the issue lives (tool name, header, URL path...)",
|
|
142
|
+
)
|
|
143
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
144
|
+
false_positive_notes: str | None = None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def fingerprint(self) -> str:
|
|
148
|
+
"""Stable identity for de-duplication across scans (used in SARIF)."""
|
|
149
|
+
return f"{self.id}:{self.location or '-'}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CheckStatus(str, Enum):
|
|
153
|
+
"""Outcome of running one check, independent of whether it found anything."""
|
|
154
|
+
|
|
155
|
+
RAN = "ran"
|
|
156
|
+
SKIPPED = "skipped"
|
|
157
|
+
NOT_IMPLEMENTED = "not_implemented"
|
|
158
|
+
ERROR = "error"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CheckResult(BaseModel):
|
|
162
|
+
"""Coverage record for a check: what ran, what was skipped and why."""
|
|
163
|
+
|
|
164
|
+
check_id: str
|
|
165
|
+
title: str
|
|
166
|
+
mode: CheckMode
|
|
167
|
+
status: CheckStatus
|
|
168
|
+
reason: str | None = None
|
|
169
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
170
|
+
duration_ms: float | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Fingerprint(BaseModel):
|
|
174
|
+
"""What the transport learned about the target during negotiation."""
|
|
175
|
+
|
|
176
|
+
transport: str
|
|
177
|
+
protocol_version: str | None = None
|
|
178
|
+
flavour: str = Field(
|
|
179
|
+
default="unknown",
|
|
180
|
+
description="'stateless-2026' | 'handshake' | 'legacy-sse' | 'unknown'",
|
|
181
|
+
)
|
|
182
|
+
auth_required: bool = False
|
|
183
|
+
www_authenticate: str | None = None
|
|
184
|
+
session_id_seen: bool = False
|
|
185
|
+
server_info: dict[str, Any] = Field(default_factory=dict)
|
|
186
|
+
capabilities: dict[str, Any] = Field(default_factory=dict)
|
|
187
|
+
instructions: str | None = None
|
|
188
|
+
attempts: list[dict[str, Any]] = Field(default_factory=list)
|
|
189
|
+
error: str | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Inventory(BaseModel):
|
|
193
|
+
"""Tools, prompts and resources listed by the server."""
|
|
194
|
+
|
|
195
|
+
tools: list[dict[str, Any]] = Field(default_factory=list)
|
|
196
|
+
prompts: list[dict[str, Any]] = Field(default_factory=list)
|
|
197
|
+
resources: list[dict[str, Any]] = Field(default_factory=list)
|
|
198
|
+
tools_error: str | None = None
|
|
199
|
+
prompts_error: str | None = None
|
|
200
|
+
resources_error: str | None = None
|
|
201
|
+
tools_evidence: EvidenceRecord | None = None
|
|
202
|
+
prompts_evidence: EvidenceRecord | None = None
|
|
203
|
+
resources_evidence: EvidenceRecord | None = None
|
|
204
|
+
|
|
205
|
+
def summary(self) -> dict[str, int]:
|
|
206
|
+
return {
|
|
207
|
+
"tools": len(self.tools),
|
|
208
|
+
"prompts": len(self.prompts),
|
|
209
|
+
"resources": len(self.resources),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class AttestationItem(BaseModel):
|
|
214
|
+
"""A governance item that cannot be assessed by an automated scan.
|
|
215
|
+
|
|
216
|
+
Reported honestly as "attestation only" with a short checklist, rather
|
|
217
|
+
than faking coverage.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
id: str
|
|
221
|
+
title: str
|
|
222
|
+
standards: Standards
|
|
223
|
+
reason: str = Field(description="Why a black-box scan cannot assess this")
|
|
224
|
+
checklist: list[str] = Field(default_factory=list)
|
|
225
|
+
status: str = "attestation-only"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ScanReport(BaseModel):
|
|
229
|
+
"""Top-level report document."""
|
|
230
|
+
|
|
231
|
+
scanner: str = "mcpprobe"
|
|
232
|
+
version: str
|
|
233
|
+
target: str
|
|
234
|
+
mode: CheckMode
|
|
235
|
+
started_at: dt.datetime
|
|
236
|
+
finished_at: dt.datetime | None = None
|
|
237
|
+
fingerprint: Fingerprint | None = None
|
|
238
|
+
inventory_summary: dict[str, int] = Field(default_factory=dict)
|
|
239
|
+
checks: list[CheckResult] = Field(default_factory=list)
|
|
240
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
241
|
+
attestations: list[AttestationItem] = Field(default_factory=list)
|
|
242
|
+
evidence_count: int = 0
|
|
243
|
+
authorisation_record: str | None = Field(
|
|
244
|
+
default=None, description="Path of the authorisation record JSON, if active mode"
|
|
245
|
+
)
|
|
246
|
+
notes: list[str] = Field(default_factory=list)
|
|
247
|
+
|
|
248
|
+
def counts(self) -> dict[str, int]:
|
|
249
|
+
"""Number of findings per severity (all five keys always present)."""
|
|
250
|
+
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
251
|
+
for f in self.findings:
|
|
252
|
+
out[f.severity.value] += 1
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
def max_severity(self) -> Severity | None:
|
|
256
|
+
"""Highest severity present, or ``None`` when there are no findings."""
|
|
257
|
+
if not self.findings:
|
|
258
|
+
return None
|
|
259
|
+
return max((f.severity for f in self.findings), key=lambda s: s.rank)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def utcnow() -> dt.datetime:
|
|
263
|
+
"""Timezone-aware UTC now (kept here so tests can monkeypatch one place)."""
|
|
264
|
+
return dt.datetime.now(dt.UTC)
|