mcpprobe-scanner 0.3.1__py3-none-any.whl → 0.3.3__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 +1 -1
- mcpprobe/action.py +55 -12
- mcpprobe/checks/auth.py +5 -1
- mcpprobe/checks/base.py +5 -1
- mcpprobe/checks/baseline.py +1 -0
- mcpprobe/checks/metadata.py +2 -0
- mcpprobe/checks/robustness.py +2 -0
- mcpprobe/checks/transport_conformance.py +4 -0
- mcpprobe/cli.py +3 -3
- mcpprobe/models.py +108 -5
- mcpprobe/registry/select.py +10 -1
- mcpprobe/report/json_report.py +5 -0
- mcpprobe/report/sarif.py +1 -0
- mcpprobe/report/terminal.py +46 -10
- mcpprobe/scanner.py +6 -2
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/METADATA +4 -4
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/RECORD +21 -21
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/WHEEL +0 -0
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/entry_points.txt +0 -0
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {mcpprobe_scanner-0.3.1.dist-info → mcpprobe_scanner-0.3.3.dist-info}/licenses/NOTICE +0 -0
mcpprobe/__init__.py
CHANGED
mcpprobe/action.py
CHANGED
|
@@ -6,8 +6,8 @@ always writes SARIF, records outputs in ``$GITHUB_OUTPUT``, writes a job
|
|
|
6
6
|
summary to ``$GITHUB_STEP_SUMMARY``, emits one workflow annotation that names
|
|
7
7
|
the outcome, and exits with the scanner's exit code:
|
|
8
8
|
|
|
9
|
-
- 0 clean (no finding at or above ``fail-on``)
|
|
10
|
-
- 1
|
|
9
|
+
- 0 clean (no finding or attack chain at or above ``fail-on``)
|
|
10
|
+
- 1 a finding or attack chain at or above ``fail-on``
|
|
11
11
|
- 2 error (unreachable target, bad input, opt-out, authorisation refused)
|
|
12
12
|
- 3 the target answered but is not an MCP server (wrong URL or path)
|
|
13
13
|
|
|
@@ -89,19 +89,49 @@ def sarif_counts(path: Path) -> dict[str, int]:
|
|
|
89
89
|
return counts
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
92
|
+
def sarif_chain_counts(path: Path) -> dict[str, int]:
|
|
93
|
+
"""Count attack chains (``runs[].properties.chains``) per severity; empty when unreadable.
|
|
94
|
+
|
|
95
|
+
Chains are not SARIF results: each one is built from findings that are already
|
|
96
|
+
results, so they are counted on their own.
|
|
97
|
+
"""
|
|
98
|
+
counts = dict.fromkeys(_SEVERITIES, 0)
|
|
99
|
+
try:
|
|
100
|
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
for run in data.get("runs", []):
|
|
102
|
+
for chain in run.get("properties", {}).get("chains", []):
|
|
103
|
+
sev = str(chain.get("severity", "info"))
|
|
104
|
+
counts[sev if sev in counts else "info"] += 1
|
|
105
|
+
except (OSError, ValueError, AttributeError):
|
|
106
|
+
return {}
|
|
107
|
+
return counts
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe(
|
|
111
|
+
code: int,
|
|
112
|
+
target: str,
|
|
113
|
+
fail_on: str,
|
|
114
|
+
counts: Mapping[str, int],
|
|
115
|
+
chain_counts: Mapping[str, int] | None = None,
|
|
116
|
+
) -> tuple[str, str, str]:
|
|
93
117
|
"""Return (annotation level, title, message) for an exit code."""
|
|
94
118
|
total = sum(counts.values())
|
|
95
119
|
breakdown = ", ".join(f"{counts[s]} {s}" for s in _SEVERITIES if counts.get(s))
|
|
120
|
+
chain_counts = chain_counts or {}
|
|
121
|
+
chains = sum(chain_counts.values())
|
|
122
|
+
chain_breakdown = ", ".join(f"{chain_counts[s]} {s}" for s in _SEVERITIES if chain_counts.get(s))
|
|
123
|
+
chain_text = f" and {chains} attack chain(s) built from them ({chain_breakdown})" if chains else ""
|
|
96
124
|
if code == EXIT_CLEAN:
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
below = ", ".join(x for x in (breakdown, f"attack chains: {chain_breakdown}" if chains else "") if x)
|
|
126
|
+
found = f" ({below} below the threshold)" if below else ""
|
|
127
|
+
what = "finding or attack chain" if chains else "finding"
|
|
128
|
+
return "notice", "mcpprobe: passed", f"No {what} at or above '{fail_on}' on {target}{found}."
|
|
99
129
|
if code == EXIT_FINDINGS:
|
|
100
130
|
return (
|
|
101
131
|
"error",
|
|
102
132
|
"mcpprobe: findings",
|
|
103
|
-
f"{total} finding(s) on {target} ({breakdown}); at least one is at or above
|
|
104
|
-
"See the job summary or the code scanning alerts.",
|
|
133
|
+
f"{total} finding(s) on {target} ({breakdown}){chain_text}; at least one is at or above "
|
|
134
|
+
f"'{fail_on}'. See the job summary or the code scanning alerts.",
|
|
105
135
|
)
|
|
106
136
|
if code == EXIT_NOT_MCP:
|
|
107
137
|
return (
|
|
@@ -119,7 +149,14 @@ def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) ->
|
|
|
119
149
|
)
|
|
120
150
|
|
|
121
151
|
|
|
122
|
-
def _summary(
|
|
152
|
+
def _summary(
|
|
153
|
+
code: int,
|
|
154
|
+
target: str,
|
|
155
|
+
fail_on: str,
|
|
156
|
+
counts: Mapping[str, int],
|
|
157
|
+
message: str,
|
|
158
|
+
chain_counts: Mapping[str, int] | None = None,
|
|
159
|
+
) -> str:
|
|
123
160
|
lines = [
|
|
124
161
|
"## mcpprobe MCP security scan",
|
|
125
162
|
"",
|
|
@@ -130,8 +167,13 @@ def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], me
|
|
|
130
167
|
"",
|
|
131
168
|
]
|
|
132
169
|
if code in (EXIT_CLEAN, EXIT_FINDINGS) and counts:
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
if chain_counts and sum(chain_counts.values()):
|
|
171
|
+
lines += ["| Severity | Findings | Attack chains |", "|---|---|---|"]
|
|
172
|
+
lines += [f"| {s} | {counts[s]} | {chain_counts.get(s, 0)} |" for s in _SEVERITIES]
|
|
173
|
+
lines += ["", "Each attack chain is built from findings counted in the Findings column."]
|
|
174
|
+
else:
|
|
175
|
+
lines += ["| Severity | Findings |", "|---|---|"]
|
|
176
|
+
lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
|
|
135
177
|
lines.append("")
|
|
136
178
|
lines.append(
|
|
137
179
|
"Docs: https://mcpprobe.tech · checks: https://github.com/mcpprobe-dev/mcpprobe/blob/main/CHECKS.md"
|
|
@@ -157,7 +199,8 @@ def run(env: Mapping[str, str] | None = None) -> int:
|
|
|
157
199
|
fail_on = argv[argv.index("--fail-on") + 1]
|
|
158
200
|
sarif = argv[argv.index("--sarif") + 1]
|
|
159
201
|
counts = sarif_counts(Path(sarif))
|
|
160
|
-
|
|
202
|
+
chain_counts = sarif_chain_counts(Path(sarif))
|
|
203
|
+
level, title, message = describe(code, target, fail_on, counts, chain_counts)
|
|
161
204
|
print(f"::{level} title={_escape_property(title)}::{_escape(message)}", file=sys.stderr)
|
|
162
205
|
out = env.get("GITHUB_OUTPUT")
|
|
163
206
|
if out:
|
|
@@ -168,7 +211,7 @@ def run(env: Mapping[str, str] | None = None) -> int:
|
|
|
168
211
|
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
169
212
|
if summary:
|
|
170
213
|
with Path(summary).open("a", encoding="utf-8") as fh:
|
|
171
|
-
fh.write(_summary(code, target, fail_on, counts, message))
|
|
214
|
+
fh.write(_summary(code, target, fail_on, counts, message, chain_counts))
|
|
172
215
|
return code
|
|
173
216
|
|
|
174
217
|
|
mcpprobe/checks/auth.py
CHANGED
|
@@ -401,7 +401,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
401
401
|
"metadata is missing (AUTH-02).",
|
|
402
402
|
related_to="AUTH-02",
|
|
403
403
|
)
|
|
404
|
-
servers =
|
|
404
|
+
servers = list(dict.fromkeys(s for s in prm.get("authorization_servers") or [] if isinstance(s, str)))
|
|
405
405
|
if not servers:
|
|
406
406
|
raise NotApplicable(
|
|
407
407
|
"The protected resource metadata lists no authorization server (AUTH-02).",
|
|
@@ -501,6 +501,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
501
501
|
confidence=Confidence.HIGH,
|
|
502
502
|
evidence=evidence_of(*responses),
|
|
503
503
|
location=issuer,
|
|
504
|
+
variant="plain-http-endpoints",
|
|
504
505
|
details={"insecure_endpoints": insecure},
|
|
505
506
|
affected=insecure,
|
|
506
507
|
proof=doc_proof,
|
|
@@ -511,6 +512,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
511
512
|
findings.append(
|
|
512
513
|
self.finding(
|
|
513
514
|
title="Authorization server issuer mismatch",
|
|
515
|
+
variant="issuer-mismatch",
|
|
514
516
|
description=f"Metadata issuer {doc['issuer']!r} differs from advertised {issuer!r}.",
|
|
515
517
|
severity=Severity.LOW,
|
|
516
518
|
confidence=Confidence.MEDIUM,
|
|
@@ -625,6 +627,8 @@ class Auth04TokenAudienceAndPassthrough(Check):
|
|
|
625
627
|
else "The response was 2xx but carried no JSON-RPC result, so acceptance is inferred from the status only.",
|
|
626
628
|
evidence=evidence_of(resp),
|
|
627
629
|
location=ctx.target,
|
|
630
|
+
# Both self-minted tokens can be accepted; the second needs its own uid.
|
|
631
|
+
variant=None if label == "HS256/random-key" else "alg-none",
|
|
628
632
|
details={"probe": label, "claims": now_claims},
|
|
629
633
|
affected=[label],
|
|
630
634
|
proof=proof.exchange(
|
mcpprobe/checks/base.py
CHANGED
|
@@ -186,6 +186,7 @@ class Check(abc.ABC):
|
|
|
186
186
|
confidence: Confidence = Confidence.HIGH,
|
|
187
187
|
evidence: list[EvidenceRecord] | None = None,
|
|
188
188
|
location: str | None = None,
|
|
189
|
+
variant: str | None = None,
|
|
189
190
|
fix: str | None = None,
|
|
190
191
|
details: dict[str, Any] | None = None,
|
|
191
192
|
title: str | None = None,
|
|
@@ -198,7 +199,9 @@ class Check(abc.ABC):
|
|
|
198
199
|
"""Build a :class:`Finding` pre-filled from the check's metadata.
|
|
199
200
|
|
|
200
201
|
``excerpt`` overrides the evidence excerpt that is otherwise derived from
|
|
201
|
-
the first evidence record.
|
|
202
|
+
the first evidence record. ``variant`` must be set on every finding after
|
|
203
|
+
the first that the check can report at the same ``location`` in one scan,
|
|
204
|
+
so each finding keeps a distinct, stable ``uid``. A finding whose confidence is not high must
|
|
202
205
|
say why (``confidence_reason``); this is enforced so no label goes unexplained.
|
|
203
206
|
"""
|
|
204
207
|
records = [e for e in (evidence or []) if e is not None]
|
|
@@ -215,6 +218,7 @@ class Check(abc.ABC):
|
|
|
215
218
|
evidence=records,
|
|
216
219
|
evidence_excerpt=excerpt if excerpt is not None else evidence_excerpt(records),
|
|
217
220
|
location=location,
|
|
221
|
+
variant=variant,
|
|
218
222
|
details=details or {},
|
|
219
223
|
false_positive_notes=self.false_positive_notes or None,
|
|
220
224
|
confidence_reason=confidence_reason,
|
mcpprobe/checks/baseline.py
CHANGED
|
@@ -204,6 +204,7 @@ class Meta02RugPullBaseline(Check):
|
|
|
204
204
|
out.append(
|
|
205
205
|
self.finding(
|
|
206
206
|
title="Tools removed since baseline",
|
|
207
|
+
variant="removed",
|
|
207
208
|
description="Removed tools: " + ", ".join(tools["removed"]) + ".",
|
|
208
209
|
severity=Severity.INFO,
|
|
209
210
|
confidence=Confidence.HIGH,
|
mcpprobe/checks/metadata.py
CHANGED
|
@@ -206,6 +206,7 @@ class Meta03DangerousCapabilitySurface(Check):
|
|
|
206
206
|
findings.append(
|
|
207
207
|
self.finding(
|
|
208
208
|
title="High-impact tool capabilities exposed",
|
|
209
|
+
variant="high-impact",
|
|
209
210
|
description="Tools classified as high-impact: "
|
|
210
211
|
+ summary
|
|
211
212
|
+ ". Review that each is necessary, scoped and gated by user approval.",
|
|
@@ -354,6 +355,7 @@ class Meta04SchemaHygiene(Check):
|
|
|
354
355
|
findings.append(
|
|
355
356
|
self.finding(
|
|
356
357
|
title="Duplicate tool names",
|
|
358
|
+
variant="duplicate-names",
|
|
357
359
|
description="Tool names listed more than once: " + ", ".join(dupes) + ".",
|
|
358
360
|
severity=Severity.MEDIUM,
|
|
359
361
|
confidence=Confidence.HIGH,
|
mcpprobe/checks/robustness.py
CHANGED
|
@@ -165,6 +165,7 @@ class Err01VerboseErrors(Check):
|
|
|
165
165
|
findings.append(
|
|
166
166
|
self.finding(
|
|
167
167
|
title="Server header discloses software version",
|
|
168
|
+
variant="server-header",
|
|
168
169
|
description=f"Server: {server_header}",
|
|
169
170
|
severity=Severity.INFO,
|
|
170
171
|
confidence=Confidence.HIGH,
|
|
@@ -281,6 +282,7 @@ class Dos01UnboundedConsumption(Check):
|
|
|
281
282
|
findings.append(
|
|
282
283
|
self.finding(
|
|
283
284
|
title=f"Server struggled with {label} request",
|
|
285
|
+
variant=f"{label}-probe",
|
|
284
286
|
description=f"The {label} probe produced: {problem}. Input limits may be missing.",
|
|
285
287
|
severity=Severity.LOW if "slow" in problem else Severity.MEDIUM,
|
|
286
288
|
confidence=Confidence.MEDIUM,
|
|
@@ -360,6 +360,7 @@ class Trans03SessionIdQuality(Check):
|
|
|
360
360
|
findings.append(
|
|
361
361
|
self.finding(
|
|
362
362
|
title="Forged session ID accepted",
|
|
363
|
+
variant="forged-session-id",
|
|
363
364
|
description=(
|
|
364
365
|
"tools/list with a random, never-issued Mcp-Session-Id was answered with a "
|
|
365
366
|
"result. The server does not validate session IDs (spec: 404 for unknown sessions)."
|
|
@@ -461,6 +462,7 @@ class Trans04DeprecatedTransport(Check):
|
|
|
461
462
|
findings.append(
|
|
462
463
|
self.finding(
|
|
463
464
|
title="Pre-2025-06-18 protocol revision negotiated",
|
|
465
|
+
variant="old-revision",
|
|
464
466
|
description=f"The server negotiated protocol revision {fp.protocol_version}, "
|
|
465
467
|
"which predates the current authorization and transport requirements.",
|
|
466
468
|
severity=Severity.INFO,
|
|
@@ -521,6 +523,7 @@ class Trans05TlsAndHeaders(Check):
|
|
|
521
523
|
findings.append(
|
|
522
524
|
self.finding(
|
|
523
525
|
title="MCP endpoint served over plain HTTP",
|
|
526
|
+
variant="plain-http",
|
|
524
527
|
description=(
|
|
525
528
|
"The endpoint is reachable over unencrypted HTTP; bearer tokens, session IDs "
|
|
526
529
|
"and tool data travel in the clear."
|
|
@@ -546,6 +549,7 @@ class Trans05TlsAndHeaders(Check):
|
|
|
546
549
|
findings.append(
|
|
547
550
|
self.finding(
|
|
548
551
|
title="Weak TLS version negotiated",
|
|
552
|
+
variant="weak-tls",
|
|
549
553
|
description=f"The connection negotiated {http.tls_version}.",
|
|
550
554
|
severity=Severity.MEDIUM,
|
|
551
555
|
confidence=Confidence.HIGH,
|
mcpprobe/cli.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
``mcpprobe <target>`` is shorthand for ``mcpprobe scan <target>``.
|
|
4
4
|
|
|
5
|
-
Exit codes: 0 = no
|
|
6
|
-
above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
|
|
5
|
+
Exit codes: 0 = no finding or attack chain at or above ``--fail-on``; 1 = a
|
|
6
|
+
finding or attack chain at or above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
|
|
7
7
|
refused, bad arguments).
|
|
8
8
|
"""
|
|
9
9
|
|
|
@@ -112,7 +112,7 @@ def _parse_kv(values: Sequence[str], what: str, sep: str) -> dict[str, str]:
|
|
|
112
112
|
default="high",
|
|
113
113
|
type=click.Choice(SEVERITIES),
|
|
114
114
|
show_default=True,
|
|
115
|
-
help="Exit 1 if any finding is at or above this severity.",
|
|
115
|
+
help="Exit 1 if any finding or attack chain is at or above this severity.",
|
|
116
116
|
)
|
|
117
117
|
@click.option(
|
|
118
118
|
"--json", "json_path", type=click.Path(dir_okay=False), help="Write the JSON report (with evidence) here."
|
mcpprobe/models.py
CHANGED
|
@@ -7,10 +7,12 @@ report is a faithful, schema-stable serialisation of what the scanner saw.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import datetime as dt
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
10
12
|
from enum import Enum
|
|
11
13
|
from typing import Any
|
|
12
14
|
|
|
13
|
-
from pydantic import BaseModel, Field
|
|
15
|
+
from pydantic import BaseModel, Field, computed_field
|
|
14
16
|
|
|
15
17
|
#: Maximum number of body bytes retained per request/response in evidence.
|
|
16
18
|
#: Proof excerpts are always built from parsed data, never from this copy.
|
|
@@ -145,6 +147,13 @@ class Finding(BaseModel):
|
|
|
145
147
|
default=None,
|
|
146
148
|
description="Where in the target the issue lives (tool name, header, URL path...)",
|
|
147
149
|
)
|
|
150
|
+
variant: str | None = Field(
|
|
151
|
+
default=None,
|
|
152
|
+
description=(
|
|
153
|
+
"Distinguishes separate findings of one check at the same location "
|
|
154
|
+
"(e.g. 'forged-session-id'); None for the check's main finding"
|
|
155
|
+
),
|
|
156
|
+
)
|
|
148
157
|
details: dict[str, Any] = Field(default_factory=dict)
|
|
149
158
|
false_positive_notes: str | None = None
|
|
150
159
|
confidence_reason: str | None = Field(
|
|
@@ -162,10 +171,82 @@ class Finding(BaseModel):
|
|
|
162
171
|
default_factory=list, description="IDs of the supporting evidence records"
|
|
163
172
|
)
|
|
164
173
|
|
|
174
|
+
@computed_field # type: ignore[prop-decorator]
|
|
175
|
+
@property
|
|
176
|
+
def uid(self) -> str:
|
|
177
|
+
"""Identity of this finding, unique within a report and stable across scans.
|
|
178
|
+
|
|
179
|
+
``<check id>:<location or ->``, plus ``:<variant>`` when the check reports more
|
|
180
|
+
than one thing at the same location. ``id`` stays the check ID (several findings
|
|
181
|
+
can share it); ``uid`` is what tells them apart.
|
|
182
|
+
"""
|
|
183
|
+
base = f"{self.id}:{self.location or '-'}"
|
|
184
|
+
return f"{base}:{self.variant}" if self.variant else base
|
|
185
|
+
|
|
165
186
|
@property
|
|
166
187
|
def fingerprint(self) -> str:
|
|
167
|
-
"""Stable identity for de-duplication across scans (
|
|
168
|
-
return
|
|
188
|
+
"""Stable identity for de-duplication across scans (SARIF ``partialFingerprints``)."""
|
|
189
|
+
return self.uid
|
|
190
|
+
|
|
191
|
+
def content_digest(self) -> str:
|
|
192
|
+
"""Hash of what the finding says (not of its evidence), used to spot genuine duplicates."""
|
|
193
|
+
payload = json.dumps(
|
|
194
|
+
[
|
|
195
|
+
self.id,
|
|
196
|
+
self.location,
|
|
197
|
+
self.variant,
|
|
198
|
+
self.title,
|
|
199
|
+
self.severity.value,
|
|
200
|
+
self.description,
|
|
201
|
+
sorted(self.affected),
|
|
202
|
+
self.details,
|
|
203
|
+
],
|
|
204
|
+
sort_keys=True,
|
|
205
|
+
default=str,
|
|
206
|
+
ensure_ascii=False,
|
|
207
|
+
)
|
|
208
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def dedupe_findings(findings: list[Finding]) -> list[Finding]:
|
|
212
|
+
"""Return ``findings`` with genuine duplicates merged and every ``uid`` unique.
|
|
213
|
+
|
|
214
|
+
Two findings with the same ``uid`` that say the same thing (same title, severity,
|
|
215
|
+
description, affected items and details) are one finding reported twice, for
|
|
216
|
+
example because the server listed the same tool twice; they are merged into the
|
|
217
|
+
first, keeping the union of their evidence. Findings that share a ``uid`` but say
|
|
218
|
+
different things are kept, and each gets a variant derived from its content (not
|
|
219
|
+
from its position), so the ``uid`` stays the same from one scan to the next.
|
|
220
|
+
Order is preserved.
|
|
221
|
+
"""
|
|
222
|
+
by_uid: dict[str, list[Finding]] = {}
|
|
223
|
+
for f in findings:
|
|
224
|
+
by_uid.setdefault(f.uid, []).append(f)
|
|
225
|
+
if all(len(group) == 1 for group in by_uid.values()):
|
|
226
|
+
return list(findings)
|
|
227
|
+
keep: dict[int, Finding] = {}
|
|
228
|
+
for group in by_uid.values():
|
|
229
|
+
merged: dict[str, Finding] = {}
|
|
230
|
+
for f in group:
|
|
231
|
+
digest = f.content_digest()
|
|
232
|
+
first = merged.get(digest)
|
|
233
|
+
if first is None:
|
|
234
|
+
merged[digest] = f
|
|
235
|
+
keep[id(f)] = f
|
|
236
|
+
else:
|
|
237
|
+
_merge_into(first, f)
|
|
238
|
+
if len(merged) > 1:
|
|
239
|
+
for digest, f in merged.items():
|
|
240
|
+
f.variant = f"{f.variant}-{digest[:8]}" if f.variant else digest[:8]
|
|
241
|
+
return [f for f in findings if id(f) in keep]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _merge_into(target: Finding, dup: Finding) -> None:
|
|
245
|
+
seen = {e.id for e in target.evidence}
|
|
246
|
+
target.evidence.extend(e for e in dup.evidence if e.id not in seen)
|
|
247
|
+
for eid in dup.evidence_ids:
|
|
248
|
+
if eid not in target.evidence_ids:
|
|
249
|
+
target.evidence_ids.append(eid)
|
|
169
250
|
|
|
170
251
|
|
|
171
252
|
class CheckStatus(str, Enum):
|
|
@@ -346,12 +427,23 @@ class ScanReport(BaseModel):
|
|
|
346
427
|
notes: list[str] = Field(default_factory=list)
|
|
347
428
|
|
|
348
429
|
def counts(self) -> dict[str, int]:
|
|
349
|
-
"""Number of findings per severity (all five keys always present).
|
|
430
|
+
"""Number of findings per severity (all five keys always present).
|
|
431
|
+
|
|
432
|
+
Attack chains are not findings: each one is built from findings already
|
|
433
|
+
counted here, so they are counted separately by :meth:`chain_counts`.
|
|
434
|
+
"""
|
|
350
435
|
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
351
436
|
for f in self.findings:
|
|
352
437
|
out[f.severity.value] += 1
|
|
353
438
|
return out
|
|
354
439
|
|
|
440
|
+
def chain_counts(self) -> dict[str, int]:
|
|
441
|
+
"""Number of attack chains per severity (all five keys always present)."""
|
|
442
|
+
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
443
|
+
for c in self.chains:
|
|
444
|
+
out[c.severity.value] += 1
|
|
445
|
+
return out
|
|
446
|
+
|
|
355
447
|
def coverage(self) -> dict[str, int]:
|
|
356
448
|
"""Check outcomes. ``passed`` counts only checks that ran and found nothing."""
|
|
357
449
|
out = {
|
|
@@ -370,7 +462,18 @@ class ScanReport(BaseModel):
|
|
|
370
462
|
return out
|
|
371
463
|
|
|
372
464
|
def max_severity(self) -> Severity | None:
|
|
373
|
-
"""Highest severity
|
|
465
|
+
"""Highest severity of any finding or attack chain, or ``None`` when there is neither.
|
|
466
|
+
|
|
467
|
+
A chain can be rated above every finding it is built from (CHAIN-01 is critical
|
|
468
|
+
while AUTH-01 and META-03 are high), so it takes part in the pass/fail decision.
|
|
469
|
+
"""
|
|
470
|
+
sevs = [f.severity for f in self.findings] + [c.severity for c in self.chains]
|
|
471
|
+
if not sevs:
|
|
472
|
+
return None
|
|
473
|
+
return max(sevs, key=lambda s: s.rank)
|
|
474
|
+
|
|
475
|
+
def findings_max_severity(self) -> Severity | None:
|
|
476
|
+
"""Highest severity among findings only, or ``None`` when there are no findings."""
|
|
374
477
|
if not self.findings:
|
|
375
478
|
return None
|
|
376
479
|
return max((f.severity for f in self.findings), key=lambda s: s.rank)
|
mcpprobe/registry/select.py
CHANGED
|
@@ -89,7 +89,12 @@ def select_targets(
|
|
|
89
89
|
*,
|
|
90
90
|
max_urls_per_host: int = 1,
|
|
91
91
|
) -> Selection:
|
|
92
|
-
"""One target per host (streamable-http preferred over SSE); every decision counted.
|
|
92
|
+
"""One target per host (streamable-http preferred over SSE); every decision counted.
|
|
93
|
+
|
|
94
|
+
Every remote URL ends up either selected or under exactly one reason in ``excluded``, so
|
|
95
|
+
``remote_urls - sum(excluded) == selected_urls``, and with ``max_urls_per_host=1``
|
|
96
|
+
``selected_urls == unique_hosts``.
|
|
97
|
+
"""
|
|
93
98
|
exclusions = exclusions or Exclusions()
|
|
94
99
|
sel = Selection()
|
|
95
100
|
by_host: dict[str, dict[str, Any]] = {}
|
|
@@ -130,6 +135,9 @@ def select_targets(
|
|
|
130
135
|
if entry["name"] not in target["entries"]:
|
|
131
136
|
target["entries"].append(entry["name"])
|
|
132
137
|
if url in target["urls"]:
|
|
138
|
+
# The same URL listed again (another entry, version or transport): count it,
|
|
139
|
+
# or the funnel would not add up (remote_urls - excluded != selected URLs).
|
|
140
|
+
sel.excluded["duplicate_url"] += 1
|
|
133
141
|
continue
|
|
134
142
|
if len(target["urls"]) >= max_urls_per_host:
|
|
135
143
|
sel.excluded["duplicate_host"] += 1
|
|
@@ -146,6 +154,7 @@ def select_targets(
|
|
|
146
154
|
"no_remote_no_package_entries": kinds["no_remote_no_package"],
|
|
147
155
|
"remote_urls": remote_urls,
|
|
148
156
|
"excluded": dict(sorted(sel.excluded.items())),
|
|
157
|
+
"selected_urls": sum(len(t["urls"]) for t in sel.targets),
|
|
149
158
|
"unique_hosts": len(sel.targets),
|
|
150
159
|
"rate_keys": len({t["rate_key"] for t in sel.targets}),
|
|
151
160
|
"max_urls_per_host": max_urls_per_host,
|
mcpprobe/report/json_report.py
CHANGED
|
@@ -13,10 +13,15 @@ from mcpprobe.transport import EvidenceLog
|
|
|
13
13
|
def report_to_dict(report: ScanReport, evidence: EvidenceLog | None = None) -> dict[str, Any]:
|
|
14
14
|
data: dict[str, Any] = report.model_dump(mode="json")
|
|
15
15
|
worst = report.max_severity()
|
|
16
|
+
worst_finding = report.findings_max_severity()
|
|
16
17
|
coverage = report.coverage()
|
|
17
18
|
data["summary"] = {
|
|
19
|
+
# Findings only; chains are built from these findings and are counted separately.
|
|
18
20
|
"counts": report.counts(),
|
|
21
|
+
"chain_counts": report.chain_counts(),
|
|
22
|
+
# Worst of findings and attack chains: the value the exit code is decided on.
|
|
19
23
|
"max_severity": worst.value if worst is not None else None,
|
|
24
|
+
"findings_max_severity": worst_finding.value if worst_finding is not None else None,
|
|
20
25
|
"target_kind": report.target_kind,
|
|
21
26
|
# "passed" = ran and found nothing; not_applicable never counts as passed.
|
|
22
27
|
"checks_passed": coverage["passed"],
|
mcpprobe/report/sarif.py
CHANGED
|
@@ -194,6 +194,7 @@ def to_sarif(report: ScanReport, *, location_uri: str | None = None) -> dict[str
|
|
|
194
194
|
"mode": report.mode.value,
|
|
195
195
|
"fingerprint": report.fingerprint.model_dump(mode="json") if report.fingerprint else None,
|
|
196
196
|
"counts": report.counts(),
|
|
197
|
+
"chainCounts": report.chain_counts(),
|
|
197
198
|
"targetKind": report.target_kind,
|
|
198
199
|
# Only checks that ran and found nothing are passes; not_applicable is not a pass.
|
|
199
200
|
"coverage": report.coverage(),
|
mcpprobe/report/terminal.py
CHANGED
|
@@ -93,6 +93,49 @@ def _finding_block(f: Finding, verbose: bool) -> Group:
|
|
|
93
93
|
return Group(*lines)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _count_line(label: str, counts: dict[str, int], tail: str) -> Text:
|
|
97
|
+
line = Text(label)
|
|
98
|
+
for sev in _ORDER:
|
|
99
|
+
style = severity_style(sev, badge=True) if counts[sev.value] else "dim"
|
|
100
|
+
line.append(f" {sev.value} {counts[sev.value]} ", style=style)
|
|
101
|
+
line.append(" ")
|
|
102
|
+
line.append(tail, style="dim")
|
|
103
|
+
return line
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def summary_lines(report: ScanReport) -> list[Text]:
|
|
107
|
+
"""The per-severity count lines: findings, then attack chains when there are any.
|
|
108
|
+
|
|
109
|
+
Chains are counted on their own line because each one is built from findings that
|
|
110
|
+
are already counted; adding them to the findings counts would count those twice.
|
|
111
|
+
"""
|
|
112
|
+
counts = report.counts()
|
|
113
|
+
total = sum(counts.values())
|
|
114
|
+
if not report.chains:
|
|
115
|
+
return [_count_line("Findings ", counts, f"({total} {'finding' if total == 1 else 'findings'})")]
|
|
116
|
+
chain_counts = report.chain_counts()
|
|
117
|
+
n = sum(chain_counts.values())
|
|
118
|
+
return [
|
|
119
|
+
_count_line("Findings ", counts, f"({total} {'finding' if total == 1 else 'findings'})"),
|
|
120
|
+
_count_line(
|
|
121
|
+
"Attack chains ",
|
|
122
|
+
chain_counts,
|
|
123
|
+
f"({n} {'chain' if n == 1 else 'chains'}, built from the findings counted above)",
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fail_reason(report: ScanReport, fail_on: Severity) -> str:
|
|
129
|
+
"""Why the result is FAIL: findings, attack chains, or both, at or above ``fail_on``."""
|
|
130
|
+
findings = any(f.severity >= fail_on for f in report.findings)
|
|
131
|
+
chains = any(c.severity >= fail_on for c in report.chains)
|
|
132
|
+
if findings and chains:
|
|
133
|
+
return f"findings and attack chains at or above {fail_on.value}"
|
|
134
|
+
if chains:
|
|
135
|
+
return f"attack chain at or above {fail_on.value}"
|
|
136
|
+
return f"findings at or above {fail_on.value}"
|
|
137
|
+
|
|
138
|
+
|
|
96
139
|
def render(
|
|
97
140
|
report: ScanReport,
|
|
98
141
|
console: Console | None = None,
|
|
@@ -136,13 +179,8 @@ def render(
|
|
|
136
179
|
console.print(Text("No check was run against this target; nothing passed.", style="dim"))
|
|
137
180
|
return
|
|
138
181
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
for sev in _ORDER:
|
|
142
|
-
style = severity_style(sev, badge=True) if counts[sev.value] else "dim"
|
|
143
|
-
summary.append(f" {sev.value} {counts[sev.value]} ", style=style)
|
|
144
|
-
summary.append(" ")
|
|
145
|
-
console.print(summary)
|
|
182
|
+
for line in summary_lines(report):
|
|
183
|
+
console.print(line)
|
|
146
184
|
console.print()
|
|
147
185
|
|
|
148
186
|
for chain in report.chains:
|
|
@@ -212,9 +250,7 @@ def render(
|
|
|
212
250
|
if fail_on is not None:
|
|
213
251
|
top = report.max_severity()
|
|
214
252
|
if top is not None and top >= fail_on:
|
|
215
|
-
console.print(
|
|
216
|
-
Text(f"Result: FAIL (findings at or above {fail_on.value})", style=severity_style(top))
|
|
217
|
-
)
|
|
253
|
+
console.print(Text(f"Result: FAIL ({fail_reason(report, fail_on)})", style=severity_style(top)))
|
|
218
254
|
else:
|
|
219
255
|
console.print(
|
|
220
256
|
Text(f"Result: PASS (nothing at or above {fail_on.value})", style=f"bold {PASS_HEX}")
|
mcpprobe/scanner.py
CHANGED
|
@@ -22,6 +22,7 @@ from mcpprobe.models import (
|
|
|
22
22
|
Fingerprint,
|
|
23
23
|
Inventory,
|
|
24
24
|
ScanReport,
|
|
25
|
+
dedupe_findings,
|
|
25
26
|
utcnow,
|
|
26
27
|
)
|
|
27
28
|
from mcpprobe.policy import DEFAULT_RATE, check_opt_out, clamp_rate
|
|
@@ -247,7 +248,7 @@ async def run_check(check: Check, ctx: ScanContext) -> CheckResult:
|
|
|
247
248
|
started = time.monotonic()
|
|
248
249
|
throttled_before = ctx.transport.throttle_exhausted
|
|
249
250
|
try:
|
|
250
|
-
result.findings = await check.run(ctx)
|
|
251
|
+
result.findings = dedupe_findings(await check.run(ctx))
|
|
251
252
|
except NotApplicable as exc:
|
|
252
253
|
result.status = CheckStatus.NOT_APPLICABLE
|
|
253
254
|
result.reason = exc.reason
|
|
@@ -372,9 +373,12 @@ async def run_scan(config: ScanConfig) -> tuple[ScanReport, EvidenceLog]:
|
|
|
372
373
|
report.checks.append(result)
|
|
373
374
|
report.findings.extend(result.findings)
|
|
374
375
|
report.notes.extend(ctx.notes)
|
|
376
|
+
# Each check's findings are already de-duplicated; this keeps the report-wide
|
|
377
|
+
# list (and so counts, max severity, chains and SARIF results) to one entry per uid.
|
|
378
|
+
report.findings = dedupe_findings(report.findings)
|
|
375
379
|
report.chains = build_chains(report, ctx)
|
|
376
380
|
report.readiness = build_readiness(report)
|
|
377
381
|
report.finished_at = utcnow()
|
|
378
382
|
report.evidence_count = len(evidence)
|
|
379
|
-
report.findings.sort(key=lambda f: (-f.severity.rank, f.id, f.location or ""))
|
|
383
|
+
report.findings.sort(key=lambda f: (-f.severity.rank, f.id, f.location or "", f.variant or ""))
|
|
380
384
|
return report, evidence
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcpprobe-scanner
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Security scanner for Model Context Protocol (MCP) servers: auth, transport, tool-metadata and poisoning checks with JSON and SARIF output.
|
|
5
5
|
Project-URL: Homepage, https://mcpprobe.tech
|
|
6
6
|
Project-URL: Scanning policy, https://mcpprobe.tech/scanning-policy
|
|
@@ -182,8 +182,8 @@ process are in [docs/registry-report.md](docs/registry-report.md).
|
|
|
182
182
|
|
|
183
183
|
| Code | Meaning |
|
|
184
184
|
|---|---|
|
|
185
|
-
| `0` | No finding at or above `--fail-on` (default `high`) |
|
|
186
|
-
| `1` | At least one finding at or above `--fail-on` |
|
|
185
|
+
| `0` | No finding or attack chain at or above `--fail-on` (default `high`) |
|
|
186
|
+
| `1` | At least one finding or attack chain at or above `--fail-on` |
|
|
187
187
|
| `2` | Error: target unreachable, bad arguments, ownership verification failed, authorisation refused, or the target opted out |
|
|
188
188
|
| `3` | The target answered but is not an MCP server (for example an ordinary web site); every check is reported not applicable and nothing passes |
|
|
189
189
|
|
|
@@ -202,7 +202,7 @@ jobs:
|
|
|
202
202
|
runs-on: ubuntu-latest
|
|
203
203
|
steps:
|
|
204
204
|
- uses: actions/checkout@v4
|
|
205
|
-
- uses: mcpprobe-dev/mcpprobe@v0.3.
|
|
205
|
+
- uses: mcpprobe-dev/mcpprobe@v0.3.3
|
|
206
206
|
id: scan
|
|
207
207
|
with:
|
|
208
208
|
target: https://staging.your-server.example/mcp
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
mcpprobe/__init__.py,sha256=
|
|
2
|
-
mcpprobe/action.py,sha256=
|
|
1
|
+
mcpprobe/__init__.py,sha256=PNej_6ygJx84uJpIgm_HjldTy3UMcNcwp32ImaxuOmE,821
|
|
2
|
+
mcpprobe/action.py,sha256=oHsoM82lKf9eBNjKvP9YM-ec0H_floAUxS5JKk8S1sQ,8760
|
|
3
3
|
mcpprobe/authz.py,sha256=PCql53OFLQsN7oZxm2UtoAqTmT7LWRRw5nmx_50ePik,10595
|
|
4
4
|
mcpprobe/catalogue.py,sha256=j0h-O-2FQq158P97bXf3akD0S3gzxt7P4AUJYMHbYss,4729
|
|
5
5
|
mcpprobe/chains.py,sha256=BgFu82WduTme1MiSFtBkwWpTHbfFBBteCLV_yBB8nWA,4227
|
|
6
|
-
mcpprobe/cli.py,sha256=
|
|
6
|
+
mcpprobe/cli.py,sha256=tC6nFYHrjxljqvv5BOc6vJfKDgdca8r7OwoQiEeAl1I,12871
|
|
7
7
|
mcpprobe/dnsutil.py,sha256=d9AWtU96SHE7QzphZk76L9r61LpI0MuxPYLHEijHK4Y,1958
|
|
8
8
|
mcpprobe/environment.py,sha256=9wbVmoJFXwGnqL4LHvt0D6dZupX650MjAgjq675xsyk,1937
|
|
9
9
|
mcpprobe/governance.py,sha256=khs0t3jjSbwYzFYAjywnOzy_9m_qXy4ctuaFJy8UkdI,3391
|
|
10
|
-
mcpprobe/models.py,sha256=
|
|
10
|
+
mcpprobe/models.py,sha256=ALrCGIVYoMuvI373M86-McojPu4ZcXVAIGOew8pk_4I,17540
|
|
11
11
|
mcpprobe/policy.py,sha256=VWb6R3Udetp3AFk-BJqulRJREquyvtvP1VS8QhUOMdo,4865
|
|
12
12
|
mcpprobe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
mcpprobe/readiness.py,sha256=DzI09yU0PmcGmn8jZhfvVPgRu3VDkP6GxKLbtE0qviY,13234
|
|
14
14
|
mcpprobe/redact.py,sha256=8VhTwssMnU2cKKL2dm2omLfjpxDWtX1GJTS8f_iWcTM,3775
|
|
15
|
-
mcpprobe/scanner.py,sha256=
|
|
15
|
+
mcpprobe/scanner.py,sha256=ERgUL1JUW36q1toagFBAbVqO-TFTZrHAVXpjnD192kM,15854
|
|
16
16
|
mcpprobe/checks/__init__.py,sha256=GR0Hu_enDizBqZYdZZhYVytHSxfMCkliqPABNV2aBz0,3459
|
|
17
17
|
mcpprobe/checks/_util.py,sha256=5_8aLzoU3yfqWz6HcaHweb_j9KxuO0WgRDBiox39GIQ,3125
|
|
18
18
|
mcpprobe/checks/active_stubs.py,sha256=zI-0sw2ncmg6B2tiia0laJOPcfzMFWfagY0G6wfPMfY,9129
|
|
19
|
-
mcpprobe/checks/auth.py,sha256
|
|
20
|
-
mcpprobe/checks/base.py,sha256=
|
|
21
|
-
mcpprobe/checks/baseline.py,sha256=
|
|
19
|
+
mcpprobe/checks/auth.py,sha256=io9T2smWBCHSzTjRMr6TNmnLl7hlUaqf1D2RmLxKT6Q,35946
|
|
20
|
+
mcpprobe/checks/base.py,sha256=GjMGJbXSNXibZUkv8k0E3CBfvpHaAwjVOp18O96HK1o,11124
|
|
21
|
+
mcpprobe/checks/baseline.py,sha256=cyNDmCYBgxLnA3Z3IloY4sA369965qMNqWlnv5ZBO_M,11514
|
|
22
22
|
mcpprobe/checks/capability.py,sha256=3-BQolVRCEnz1ik27I07-05J5Pb2dcojg3DfD4H1k7M,13506
|
|
23
|
-
mcpprobe/checks/metadata.py,sha256=
|
|
23
|
+
mcpprobe/checks/metadata.py,sha256=I83VA_RH_UNxMIjmSq8WxbuH5JLho0IzsLjYLyhqW_o,25960
|
|
24
24
|
mcpprobe/checks/poisoning.py,sha256=oaZrKG_QJWBr0dtOiKsxD2OdkbeMNwuuavNaLc7MJ5g,31915
|
|
25
25
|
mcpprobe/checks/proof.py,sha256=0h4uDF5AvKpkEWRAVtNk9I0Ei5fUugeqRqiNf3Ln1aA,6091
|
|
26
26
|
mcpprobe/checks/protocol_2026.py,sha256=PEWu61h8s718WQf62O6rEii_qTLeP60tC2lA0TiAU5M,35456
|
|
27
|
-
mcpprobe/checks/robustness.py,sha256=
|
|
27
|
+
mcpprobe/checks/robustness.py,sha256=TLL03Dp6wAYRfCvpK5dPahWU1YSX_Vx6M7vQeCR55pM,18310
|
|
28
28
|
mcpprobe/checks/tool_surface.py,sha256=nbHiaM-LoDoK5y0hCo2mMeZUHR2UegXfUtjvjd4zjzQ,16660
|
|
29
|
-
mcpprobe/checks/transport_conformance.py,sha256=
|
|
29
|
+
mcpprobe/checks/transport_conformance.py,sha256=kjNR8-S7n273ESjE3p1SJmaG09glnMtvMzKxOfI0bpI,30878
|
|
30
30
|
mcpprobe/judges/__init__.py,sha256=W-rRq00lnOEiVIH27pAmLK52AVIRSUhRxth3dlZw_Ws,1372
|
|
31
31
|
mcpprobe/judges/hf.py,sha256=lQaMq5qwrdIzivcnrdmMd9sTtHOSNEtoAxguDoMhLMI,5412
|
|
32
32
|
mcpprobe/registry/__init__.py,sha256=Ou7Fy9kZUQgWYKCudnzXPit3Hi5_-RMNham3M_YhJok,1405
|
|
@@ -38,13 +38,13 @@ mcpprobe/registry/common.py,sha256=q-d8W0SMvLopGB6JCmAwlYYckF4p1NUN_42JreuLp2c,9
|
|
|
38
38
|
mcpprobe/registry/fetch.py,sha256=xsj6nwNHw7adhMS-Z-BFjqNmw06pGofLJEZnFmvQjoM,10701
|
|
39
39
|
mcpprobe/registry/pipeline.py,sha256=b3j1Ww3Krwn4EwZTL4h1z8Zf_3Q8XapFSPZfM3qWv4M,17885
|
|
40
40
|
mcpprobe/registry/render.py,sha256=gvDyeXUbOFUaeUw5Nub4QABNgKQUZf9Ih1RDINHUE_g,15447
|
|
41
|
-
mcpprobe/registry/select.py,sha256=
|
|
41
|
+
mcpprobe/registry/select.py,sha256=QW3d5ILDC7VhLSzH2g0NAtzK2OE34TD8_aNN1dvE3Ok,6339
|
|
42
42
|
mcpprobe/registry/static.py,sha256=Lg3akNSRaxrqd0dgeasEvsTmVVPVlHuc4EcaTvI4mKc,4400
|
|
43
43
|
mcpprobe/registry/template.py,sha256=oR4Fn1T2XA6Kf87fpzRutbP_2H9bjMKG_qYqBOV78Jc,7081
|
|
44
44
|
mcpprobe/report/__init__.py,sha256=n1Q29xF32h-8_VKiOHkvEl7uMf2VdKXyeDc9FLVO8u0,318
|
|
45
|
-
mcpprobe/report/json_report.py,sha256=
|
|
46
|
-
mcpprobe/report/sarif.py,sha256=
|
|
47
|
-
mcpprobe/report/terminal.py,sha256=
|
|
45
|
+
mcpprobe/report/json_report.py,sha256=Y5_tq3YktH9ghMMyf3DjCoalk_mYGiHfDMefyf-6tKU,2130
|
|
46
|
+
mcpprobe/report/sarif.py,sha256=MYx6VhSQa-3ujnHXj6ljm2pPgsvZSrwm8DGAbmPFWHQ,14512
|
|
47
|
+
mcpprobe/report/terminal.py,sha256=244NtWlr0bUHKJdScomtyqVRXie-3V8xqeSz61S9BTw,10172
|
|
48
48
|
mcpprobe/transport/__init__.py,sha256=ZmUxTFl9Vm0MWy67Gb3BWM7LwL3ki1ZZ_8r54mK9f3U,863
|
|
49
49
|
mcpprobe/transport/base.py,sha256=toXv95G127UPLODwxeAEQlSkPRU1pioL3Fktg1ZZhbU,6739
|
|
50
50
|
mcpprobe/transport/evidence.py,sha256=NWk11U6Kzwc0ElLvSLezxcbFtizGrDXgDBKvstzDlRE,4156
|
|
@@ -52,9 +52,9 @@ mcpprobe/transport/fingerprint.py,sha256=NdJ0AyQWMPzzLA0T4bRdmdgPocHdJyBPE6Pin0q
|
|
|
52
52
|
mcpprobe/transport/http.py,sha256=apclGNB6eeIqrvk1KreweA2g6WAo3vq7jpFCpUq-LlQ,17624
|
|
53
53
|
mcpprobe/transport/ratelimit.py,sha256=g6mLxbVyXVVk0ndJ2jwJuC_RqfojbyHlABUTL7ys_9w,1578
|
|
54
54
|
mcpprobe/transport/stdio.py,sha256=b_7cLDTidW5HVK_p7mT1fz0uYAdA7hh5ZlmFG65EBf4,7559
|
|
55
|
-
mcpprobe_scanner-0.3.
|
|
56
|
-
mcpprobe_scanner-0.3.
|
|
57
|
-
mcpprobe_scanner-0.3.
|
|
58
|
-
mcpprobe_scanner-0.3.
|
|
59
|
-
mcpprobe_scanner-0.3.
|
|
60
|
-
mcpprobe_scanner-0.3.
|
|
55
|
+
mcpprobe_scanner-0.3.3.dist-info/METADATA,sha256=iEqFaZ8EqmSS9M9MZv8tUJtqUPIm9Hoko3fLZDndTzE,14284
|
|
56
|
+
mcpprobe_scanner-0.3.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
57
|
+
mcpprobe_scanner-0.3.3.dist-info/entry_points.txt,sha256=NEMxBH370sFz_Vw8812R7adD_DDQ3ZI7ixcuWZNQCHY,47
|
|
58
|
+
mcpprobe_scanner-0.3.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
59
|
+
mcpprobe_scanner-0.3.3.dist-info/licenses/NOTICE,sha256=WJJBETgUGNWnB0pvAWlb0Jx42dRffM3WAtKuabvNF2o,406
|
|
60
|
+
mcpprobe_scanner-0.3.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|