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/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)