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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""TRANS-01..05: streamable-HTTP transport conformance and hygiene (passive)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcpprobe.checks._util import host_of, is_local_or_private, shannon_entropy
|
|
10
|
+
from mcpprobe.checks.base import Check, ScanContext, evidence_of, evidence_summary
|
|
11
|
+
from mcpprobe.models import Confidence, EvidenceRecord, Finding, Severity, Standards
|
|
12
|
+
from mcpprobe.transport import PROTOCOL_2025_11_25, PROTOCOL_2026_07_28, RpcResponse
|
|
13
|
+
from mcpprobe.transport.base import CLIENT_INFO, is_jsonrpc_error_body
|
|
14
|
+
from mcpprobe.transport.fingerprint import _first_event_name
|
|
15
|
+
|
|
16
|
+
EVIL_ORIGIN = "https://evil.example"
|
|
17
|
+
BOGUS_VERSION = "1999-01-01"
|
|
18
|
+
MIN_SESSION_LENGTH = 16
|
|
19
|
+
MIN_SESSION_ENTROPY_BITS = 3.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _negotiation_request(ctx: ScanContext) -> tuple[str, dict[str, Any], str]:
|
|
23
|
+
"""A request that is valid on any revision and needs no session."""
|
|
24
|
+
if ctx.fingerprint.flavour == "stateless-2026":
|
|
25
|
+
return "server/discover", {}, PROTOCOL_2026_07_28
|
|
26
|
+
version = ctx.fingerprint.protocol_version or PROTOCOL_2025_11_25
|
|
27
|
+
if version == "2024-11-05":
|
|
28
|
+
version = PROTOCOL_2025_11_25
|
|
29
|
+
return (
|
|
30
|
+
"initialize",
|
|
31
|
+
{"protocolVersion": version, "capabilities": {}, "clientInfo": CLIENT_INFO},
|
|
32
|
+
version,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Trans01OriginValidation(Check):
|
|
37
|
+
id = "TRANS-01"
|
|
38
|
+
title = "Origin header not validated (DNS-rebinding exposure)"
|
|
39
|
+
severity = Severity.MEDIUM
|
|
40
|
+
applies_to = frozenset({"http"})
|
|
41
|
+
standards = Standards(mcp=["MCP07:2025"])
|
|
42
|
+
description = (
|
|
43
|
+
"Sends a normal negotiation request with 'Origin: https://evil.example'. The transport "
|
|
44
|
+
"spec says servers MUST validate Origin and return 403 on an invalid one; otherwise a web "
|
|
45
|
+
"page can drive a locally running MCP server through the victim's browser (DNS rebinding)."
|
|
46
|
+
)
|
|
47
|
+
evidence_type = "The POST with the foreign Origin header and the non-403 response."
|
|
48
|
+
false_positive_notes = (
|
|
49
|
+
"If the server answers 401 before looking at Origin the check is indeterminate and no "
|
|
50
|
+
"finding is raised (see scan notes). Reverse proxies that strip Origin will cause a "
|
|
51
|
+
"finding against the proxied endpoint even if the application validates it."
|
|
52
|
+
)
|
|
53
|
+
fix = (
|
|
54
|
+
"Reject requests whose Origin is not on an allow-list with HTTP 403 before processing "
|
|
55
|
+
"the body. For local servers also bind to 127.0.0.1 rather than 0.0.0.0."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
59
|
+
http = ctx.http
|
|
60
|
+
if http is None or ctx.fingerprint.flavour == "legacy-sse":
|
|
61
|
+
return []
|
|
62
|
+
method, params, version = _negotiation_request(ctx)
|
|
63
|
+
resp = await http.rpc(
|
|
64
|
+
method,
|
|
65
|
+
params,
|
|
66
|
+
protocol_version=version,
|
|
67
|
+
include_session=False,
|
|
68
|
+
headers_override={"Origin": EVIL_ORIGIN},
|
|
69
|
+
note="TRANS-01 foreign Origin",
|
|
70
|
+
)
|
|
71
|
+
if resp.status == 403:
|
|
72
|
+
return []
|
|
73
|
+
if resp.status == 401:
|
|
74
|
+
ctx.notes.append("TRANS-01: indeterminate, server returned 401 before Origin validation")
|
|
75
|
+
return []
|
|
76
|
+
if resp.status is None:
|
|
77
|
+
return []
|
|
78
|
+
local = is_local_or_private(host_of(ctx.target))
|
|
79
|
+
reflected = resp.headers.get("access-control-allow-origin")
|
|
80
|
+
details: dict[str, Any] = {"status": resp.status, "origin": EVIL_ORIGIN}
|
|
81
|
+
if reflected:
|
|
82
|
+
details["access_control_allow_origin"] = reflected
|
|
83
|
+
return [
|
|
84
|
+
self.finding(
|
|
85
|
+
description=(
|
|
86
|
+
f"A request with Origin {EVIL_ORIGIN} was answered with HTTP {resp.status} "
|
|
87
|
+
f"instead of 403. The server does not validate Origin as the transport spec requires."
|
|
88
|
+
+ (
|
|
89
|
+
" The server runs on a loopback/private address, where DNS rebinding from a "
|
|
90
|
+
"browser is the realistic attack path."
|
|
91
|
+
if local
|
|
92
|
+
else ""
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
severity=Severity.HIGH if local else Severity.MEDIUM,
|
|
96
|
+
confidence=Confidence.HIGH if resp.result is not None else Confidence.MEDIUM,
|
|
97
|
+
evidence=evidence_of(resp),
|
|
98
|
+
excerpt=evidence_summary(
|
|
99
|
+
evidence_of(resp),
|
|
100
|
+
f"sent Origin: {EVIL_ORIGIN}; expected 403"
|
|
101
|
+
+ ("; got a JSON-RPC result" if resp.result is not None else ""),
|
|
102
|
+
),
|
|
103
|
+
location=ctx.target,
|
|
104
|
+
details=details,
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Trans02ProtocolVersionHandling(Check):
|
|
110
|
+
id = "TRANS-02"
|
|
111
|
+
title = "Unsupported MCP-Protocol-Version header not rejected"
|
|
112
|
+
severity = Severity.LOW
|
|
113
|
+
applies_to = frozenset({"http"})
|
|
114
|
+
standards = Standards()
|
|
115
|
+
description = (
|
|
116
|
+
"Sends a request with 'MCP-Protocol-Version: 1999-01-01'. The spec requires HTTP 400 "
|
|
117
|
+
"(or, on 2026-07-28, an UnsupportedProtocolVersionError). Accepting any version string "
|
|
118
|
+
"suggests the server ignores version negotiation entirely."
|
|
119
|
+
)
|
|
120
|
+
evidence_type = "The request with the bogus version header and the response."
|
|
121
|
+
false_positive_notes = (
|
|
122
|
+
"Pre-2025-06-18 servers never defined the header, so an older server that ignores it is "
|
|
123
|
+
"noted at low severity only."
|
|
124
|
+
)
|
|
125
|
+
fix = "Validate MCP-Protocol-Version against the supported list and reply 400 when it is unknown."
|
|
126
|
+
|
|
127
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
128
|
+
http = ctx.http
|
|
129
|
+
if http is None or ctx.fingerprint.flavour in {"legacy-sse", "unknown"}:
|
|
130
|
+
return []
|
|
131
|
+
method, params, _ = _negotiation_request(ctx)
|
|
132
|
+
resp = await http.rpc(
|
|
133
|
+
method,
|
|
134
|
+
params,
|
|
135
|
+
include_session=False,
|
|
136
|
+
headers_override={"MCP-Protocol-Version": BOGUS_VERSION},
|
|
137
|
+
note="TRANS-02 bogus protocol version",
|
|
138
|
+
)
|
|
139
|
+
if resp.status == 400 or (resp.rpc_error is not None) or is_jsonrpc_error_body(resp.body):
|
|
140
|
+
return []
|
|
141
|
+
if resp.status is None or resp.status in (401, 403):
|
|
142
|
+
return []
|
|
143
|
+
return [
|
|
144
|
+
self.finding(
|
|
145
|
+
description=(
|
|
146
|
+
f"'MCP-Protocol-Version: {BOGUS_VERSION}' was answered with HTTP {resp.status} "
|
|
147
|
+
"and a normal result instead of 400."
|
|
148
|
+
),
|
|
149
|
+
severity=Severity.LOW,
|
|
150
|
+
confidence=Confidence.HIGH if resp.result is not None else Confidence.MEDIUM,
|
|
151
|
+
evidence=evidence_of(resp),
|
|
152
|
+
excerpt=evidence_summary(
|
|
153
|
+
evidence_of(resp), f"sent MCP-Protocol-Version: {BOGUS_VERSION}; expected 400"
|
|
154
|
+
),
|
|
155
|
+
location=ctx.target,
|
|
156
|
+
details={"status": resp.status},
|
|
157
|
+
)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Trans03SessionIdQuality(Check):
|
|
162
|
+
id = "TRANS-03"
|
|
163
|
+
title = "Weak, predictable or unvalidated Mcp-Session-Id"
|
|
164
|
+
severity = Severity.HIGH
|
|
165
|
+
applies_to = frozenset({"http"})
|
|
166
|
+
standards = Standards(mcp=["MCP07:2025"])
|
|
167
|
+
description = (
|
|
168
|
+
"Collects three session IDs from repeated initialize requests and checks length, "
|
|
169
|
+
"character set, Shannon entropy and sequentiality. Then sends tools/list with a random "
|
|
170
|
+
"forged session ID, expecting 400 or 404. Only runs on handshake revisions that issue "
|
|
171
|
+
"Mcp-Session-Id (sessions were removed in 2026-07-28)."
|
|
172
|
+
)
|
|
173
|
+
evidence_type = "The initialize responses (session header) and the forged-session request."
|
|
174
|
+
false_positive_notes = (
|
|
175
|
+
"Entropy is estimated from three samples, so the test is conservative: only IDs shorter "
|
|
176
|
+
"than 16 characters, obviously sequential, or with very low per-character entropy are flagged."
|
|
177
|
+
)
|
|
178
|
+
fix = (
|
|
179
|
+
"Generate session IDs with a CSPRNG (at least 128 bits, e.g. UUIDv4 or 32 random bytes "
|
|
180
|
+
"base64url), store them server-side, and answer 404 for unknown IDs."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
184
|
+
http = ctx.http
|
|
185
|
+
fp = ctx.fingerprint
|
|
186
|
+
if http is None or fp.flavour != "handshake" or not fp.session_id_seen:
|
|
187
|
+
return []
|
|
188
|
+
findings: list[Finding] = []
|
|
189
|
+
version = fp.protocol_version or PROTOCOL_2025_11_25
|
|
190
|
+
ids: list[str] = list(http.sessions_seen)
|
|
191
|
+
responses: list[RpcResponse] = []
|
|
192
|
+
while len(ids) < 3:
|
|
193
|
+
resp = await http.rpc(
|
|
194
|
+
"initialize",
|
|
195
|
+
{"protocolVersion": version, "capabilities": {}, "clientInfo": CLIENT_INFO},
|
|
196
|
+
include_session=False,
|
|
197
|
+
note="TRANS-03 collect session id",
|
|
198
|
+
)
|
|
199
|
+
responses.append(resp)
|
|
200
|
+
sid = resp.headers.get("mcp-session-id")
|
|
201
|
+
if not sid:
|
|
202
|
+
break
|
|
203
|
+
ids.append(sid)
|
|
204
|
+
problems: list[str] = []
|
|
205
|
+
if ids:
|
|
206
|
+
shortest = min(len(s) for s in ids)
|
|
207
|
+
if shortest < MIN_SESSION_LENGTH:
|
|
208
|
+
problems.append(f"shortest session id is {shortest} characters")
|
|
209
|
+
if any(not all(0x21 <= ord(ch) <= 0x7E for ch in s) for s in ids):
|
|
210
|
+
problems.append("session id contains characters outside visible ASCII")
|
|
211
|
+
entropy = shannon_entropy("".join(ids))
|
|
212
|
+
if entropy < MIN_SESSION_ENTROPY_BITS:
|
|
213
|
+
problems.append(f"low per-character entropy ({entropy:.2f} bits)")
|
|
214
|
+
if len(ids) >= 2 and all(s.isdigit() for s in ids):
|
|
215
|
+
diffs = [abs(int(b) - int(a)) for a, b in itertools.pairwise(ids)]
|
|
216
|
+
if all(d <= 1000 for d in diffs):
|
|
217
|
+
problems.append("session ids are sequential integers")
|
|
218
|
+
if len(set(ids)) < len(ids):
|
|
219
|
+
problems.append("the same session id was issued twice")
|
|
220
|
+
if problems:
|
|
221
|
+
findings.append(
|
|
222
|
+
self.finding(
|
|
223
|
+
title="Session IDs are weak or predictable",
|
|
224
|
+
description="Observed Mcp-Session-Id values are not cryptographically secure: "
|
|
225
|
+
+ "; ".join(problems)
|
|
226
|
+
+ ".",
|
|
227
|
+
severity=Severity.HIGH,
|
|
228
|
+
confidence=Confidence.HIGH,
|
|
229
|
+
evidence=evidence_of(*responses),
|
|
230
|
+
excerpt=evidence_summary(
|
|
231
|
+
evidence_of(*responses),
|
|
232
|
+
"Mcp-Session-Id values observed: " + ", ".join(s[:12] for s in ids),
|
|
233
|
+
),
|
|
234
|
+
location=ctx.target,
|
|
235
|
+
details={
|
|
236
|
+
"samples": [s[:8] + "…" if len(s) > 8 else s for s in ids],
|
|
237
|
+
"problems": problems,
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
forged = secrets.token_urlsafe(24)
|
|
242
|
+
resp = await http.rpc(
|
|
243
|
+
"tools/list",
|
|
244
|
+
{},
|
|
245
|
+
include_session=False,
|
|
246
|
+
headers_override={"Mcp-Session-Id": forged},
|
|
247
|
+
note="TRANS-03 forged session id",
|
|
248
|
+
)
|
|
249
|
+
if resp.status is not None and 200 <= resp.status < 300 and resp.result is not None:
|
|
250
|
+
findings.append(
|
|
251
|
+
self.finding(
|
|
252
|
+
title="Forged session ID accepted",
|
|
253
|
+
description=(
|
|
254
|
+
"tools/list with a random, never-issued Mcp-Session-Id was answered with a "
|
|
255
|
+
"result. The server does not validate session IDs (spec: 404 for unknown sessions)."
|
|
256
|
+
),
|
|
257
|
+
severity=Severity.HIGH,
|
|
258
|
+
confidence=Confidence.HIGH,
|
|
259
|
+
evidence=evidence_of(resp),
|
|
260
|
+
excerpt=evidence_summary(
|
|
261
|
+
evidence_of(resp), "tools/list with a never-issued Mcp-Session-Id returned a result"
|
|
262
|
+
),
|
|
263
|
+
location=ctx.target,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
return findings
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Trans04DeprecatedTransport(Check):
|
|
270
|
+
id = "TRANS-04"
|
|
271
|
+
title = "Deprecated HTTP+SSE (2024-11-05) transport in use"
|
|
272
|
+
severity = Severity.INFO
|
|
273
|
+
applies_to = frozenset({"http"})
|
|
274
|
+
standards = Standards()
|
|
275
|
+
description = (
|
|
276
|
+
"Reports when the endpoint answers a GET with an SSE 'endpoint' event, i.e. the "
|
|
277
|
+
"2024-11-05 HTTP+SSE transport that was deprecated in 2025-03-26 and formally classified "
|
|
278
|
+
"Deprecated in 2026-07-28. Also noted when the negotiated revision predates 2025-06-18."
|
|
279
|
+
)
|
|
280
|
+
evidence_type = "The GET request and the first SSE event."
|
|
281
|
+
false_positive_notes = "Informational only."
|
|
282
|
+
fix = "Migrate to Streamable HTTP (single endpoint, POST per message) and current protocol revision."
|
|
283
|
+
|
|
284
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
285
|
+
http = ctx.http
|
|
286
|
+
fp = ctx.fingerprint
|
|
287
|
+
if http is None:
|
|
288
|
+
return []
|
|
289
|
+
findings: list[Finding] = []
|
|
290
|
+
if fp.flavour == "legacy-sse":
|
|
291
|
+
ev = [ctx.evidence.records[-1]] if ctx.evidence.records else []
|
|
292
|
+
findings.append(
|
|
293
|
+
self.finding(
|
|
294
|
+
description="The server only speaks the deprecated HTTP+SSE transport.",
|
|
295
|
+
severity=Severity.INFO,
|
|
296
|
+
confidence=Confidence.HIGH,
|
|
297
|
+
evidence=ev,
|
|
298
|
+
location=ctx.target,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return findings
|
|
302
|
+
resp = await http.legacy_sse_probe()
|
|
303
|
+
if resp.status == 200 and "text/event-stream" in resp.headers.get("content-type", ""):
|
|
304
|
+
if _first_event_name(resp.body) == "endpoint":
|
|
305
|
+
findings.append(
|
|
306
|
+
self.finding(
|
|
307
|
+
description="GET on the MCP endpoint returns a legacy 'endpoint' SSE event; "
|
|
308
|
+
"the deprecated transport is still served alongside Streamable HTTP.",
|
|
309
|
+
severity=Severity.INFO,
|
|
310
|
+
confidence=Confidence.HIGH,
|
|
311
|
+
evidence=evidence_of(resp),
|
|
312
|
+
location=ctx.target,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
if fp.protocol_version and fp.protocol_version < "2025-06-18":
|
|
316
|
+
findings.append(
|
|
317
|
+
self.finding(
|
|
318
|
+
title="Pre-2025-06-18 protocol revision negotiated",
|
|
319
|
+
description=f"The server negotiated protocol revision {fp.protocol_version}, "
|
|
320
|
+
"which predates the current authorization and transport requirements.",
|
|
321
|
+
severity=Severity.INFO,
|
|
322
|
+
confidence=Confidence.HIGH,
|
|
323
|
+
location=ctx.target,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
return findings
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Trans05TlsAndHeaders(Check):
|
|
330
|
+
id = "TRANS-05"
|
|
331
|
+
title = "Plain HTTP, weak TLS or missing security headers"
|
|
332
|
+
severity = Severity.HIGH
|
|
333
|
+
applies_to = frozenset({"http"})
|
|
334
|
+
standards = Standards(mcp=["MCP01:2025"])
|
|
335
|
+
description = (
|
|
336
|
+
"Flags non-loopback endpoints served over plain HTTP (bearer tokens in clear), TLS "
|
|
337
|
+
"versions below 1.2 observed on the connection, and missing Strict-Transport-Security / "
|
|
338
|
+
"X-Content-Type-Options headers on the MCP endpoint responses."
|
|
339
|
+
)
|
|
340
|
+
evidence_type = "Response headers of the negotiation/tools/list exchange and the observed TLS version."
|
|
341
|
+
false_positive_notes = (
|
|
342
|
+
"Loopback plain-HTTP is reported as informational. Header checks look at the MCP endpoint "
|
|
343
|
+
"only; a CDN may add headers on other paths."
|
|
344
|
+
)
|
|
345
|
+
fix = (
|
|
346
|
+
"Serve the endpoint over HTTPS with TLS 1.2+ only, add Strict-Transport-Security and "
|
|
347
|
+
"X-Content-Type-Options: nosniff."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
351
|
+
http = ctx.http
|
|
352
|
+
if http is None:
|
|
353
|
+
return []
|
|
354
|
+
findings: list[Finding] = []
|
|
355
|
+
host = host_of(ctx.target)
|
|
356
|
+
local = is_local_or_private(host)
|
|
357
|
+
scheme = ctx.target.split(":", 1)[0].lower()
|
|
358
|
+
sample = _sample_evidence(ctx)
|
|
359
|
+
if scheme == "http":
|
|
360
|
+
findings.append(
|
|
361
|
+
self.finding(
|
|
362
|
+
title="MCP endpoint served over plain HTTP",
|
|
363
|
+
description=(
|
|
364
|
+
"The endpoint is reachable over unencrypted HTTP; bearer tokens, session IDs "
|
|
365
|
+
"and tool data travel in the clear."
|
|
366
|
+
+ (
|
|
367
|
+
" The host is a loopback/private address, so this is informational."
|
|
368
|
+
if local
|
|
369
|
+
else ""
|
|
370
|
+
)
|
|
371
|
+
),
|
|
372
|
+
severity=Severity.INFO if local else Severity.HIGH,
|
|
373
|
+
confidence=Confidence.HIGH,
|
|
374
|
+
evidence=[sample] if sample else [],
|
|
375
|
+
excerpt=f"target URL scheme is http: {ctx.target}",
|
|
376
|
+
location=ctx.target,
|
|
377
|
+
details={"scheme": scheme, "local": local},
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
if http.tls_version and http.tls_version in {"TLSv1", "TLSv1.1", "SSLv3"}:
|
|
381
|
+
findings.append(
|
|
382
|
+
self.finding(
|
|
383
|
+
title="Weak TLS version negotiated",
|
|
384
|
+
description=f"The connection negotiated {http.tls_version}.",
|
|
385
|
+
severity=Severity.MEDIUM,
|
|
386
|
+
confidence=Confidence.HIGH,
|
|
387
|
+
location=ctx.target,
|
|
388
|
+
details={"tls_version": http.tls_version},
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
if sample is not None:
|
|
392
|
+
headers = sample.response_headers
|
|
393
|
+
missing = []
|
|
394
|
+
if scheme == "https" and "strict-transport-security" not in headers:
|
|
395
|
+
missing.append("Strict-Transport-Security")
|
|
396
|
+
if "x-content-type-options" not in headers:
|
|
397
|
+
missing.append("X-Content-Type-Options")
|
|
398
|
+
if missing:
|
|
399
|
+
findings.append(
|
|
400
|
+
self.finding(
|
|
401
|
+
title="Security headers missing on MCP endpoint",
|
|
402
|
+
description="Responses lack: " + ", ".join(missing) + ".",
|
|
403
|
+
severity=Severity.LOW if scheme == "https" else Severity.INFO,
|
|
404
|
+
confidence=Confidence.HIGH,
|
|
405
|
+
evidence=[sample],
|
|
406
|
+
excerpt=evidence_summary(
|
|
407
|
+
[sample],
|
|
408
|
+
"response headers: "
|
|
409
|
+
+ (
|
|
410
|
+
", ".join(f"{k}: {v}" for k, v in sorted(headers.items())[:6])
|
|
411
|
+
or "(none kept)"
|
|
412
|
+
)
|
|
413
|
+
+ "; missing "
|
|
414
|
+
+ ", ".join(missing),
|
|
415
|
+
),
|
|
416
|
+
location=ctx.target,
|
|
417
|
+
details={"missing": missing},
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
return findings
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _sample_evidence(ctx: ScanContext) -> EvidenceRecord | None:
|
|
424
|
+
if ctx.inventory.tools_evidence is not None:
|
|
425
|
+
return ctx.inventory.tools_evidence
|
|
426
|
+
for rec in ctx.evidence.records:
|
|
427
|
+
if rec.transport == "http" and rec.status is not None and rec.url == ctx.target:
|
|
428
|
+
return rec
|
|
429
|
+
return None
|