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 CHANGED
@@ -13,7 +13,7 @@ The package is organised as follows:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- __version__ = "0.3.1"
16
+ __version__ = "0.3.3"
17
17
 
18
18
  POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
19
  USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
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 findings at or above ``fail-on``
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 describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) -> tuple[str, str, str]:
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
- found = f" ({breakdown} below the threshold)" if breakdown else ""
98
- return "notice", "mcpprobe: passed", f"No finding at or above '{fail_on}' on {target}{found}."
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 '{fail_on}'. "
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(code: int, target: str, fail_on: str, counts: Mapping[str, int], message: str) -> str:
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
- lines += ["| Severity | Findings |", "|---|---|"]
134
- lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
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
- level, title, message = describe(code, target, fail_on, counts)
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 = [s for s in prm.get("authorization_servers") or [] if isinstance(s, str)]
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. A finding whose confidence is not high must
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,
@@ -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,
@@ -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,
@@ -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 findings at or above ``--fail-on``; 1 = findings at or
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 (used in SARIF)."""
168
- return f"{self.id}:{self.location or '-'}"
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 present, or ``None`` when there are no findings."""
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)
@@ -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,
@@ -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(),
@@ -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
- counts = report.counts()
140
- summary = Text("Findings ")
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.1
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.1
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=WBsl9QPqoSNwYOkW1tf700ZdIPK6zf3kIopirTHV3Ec,821
2
- mcpprobe/action.py,sha256=m9gVgSJABw_WABAAl4hgV4QOyJaOOArhPL3H1GIr8x4,6959
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=AcYELaCNJo94vb6CdsbwD3q_iBGjZXq12D11nfOiey4,12823
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=7TyVUbKJSypS6FJHY7g9N5xptGg8x0Kl8vbrSPp6rRI,13381
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=CHXVDijzpjgAyhxQYo6msIrLxVvCwgt9Ybveg-TNNkw,15561
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=-RaD21lILpHyv2wbw4G7RNin3u2NqQskmmh8ScS68uQ,35638
20
- mcpprobe/checks/base.py,sha256=iw3hAdCE-n7In6GQTtoCFWMMnORo6vA6ncz5z1kMwWc,10872
21
- mcpprobe/checks/baseline.py,sha256=Wm4rsxFg8ZLy_pFD1IiN02lt1pbLTyVFtnZWMftpyOU,11475
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=oXRwnE0itYMpkW0gzCELPCjG9RPVd4e0NMtuhvXDQsU,25870
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=mMIl43tP-d-WkJmIJ501qHOLiwCC-IVjzjPNNDGS164,18215
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=rZ0pHC2KsNoyZaJSaDnSeu4vS3qk7evWRtFl4q4tWvY,30703
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=UtdWT0kWMyKsB9Fx397MqUkwbGmnLjMujirpuE65NCk,5811
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=FgYLa2M1XiRvrWTzq201ZBpwbC8ciJkUUzMCyXhNgjI,1763
46
- mcpprobe/report/sarif.py,sha256=iMdDZjq4KaG6A4m1HdA4WzdajjwFslguc5bZpoDkieM,14458
47
- mcpprobe/report/terminal.py,sha256=Pp2iQKUFrAC19lpWJoinhobk1I7U4WxNCCHxB7enNXg,8677
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.1.dist-info/METADATA,sha256=HUAqMNKaAnC1ncVVc5lSPr3D0YGf_-8rhTF5zRdS_0w,14252
56
- mcpprobe_scanner-0.3.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
57
- mcpprobe_scanner-0.3.1.dist-info/entry_points.txt,sha256=NEMxBH370sFz_Vw8812R7adD_DDQ3ZI7ixcuWZNQCHY,47
58
- mcpprobe_scanner-0.3.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
59
- mcpprobe_scanner-0.3.1.dist-info/licenses/NOTICE,sha256=WJJBETgUGNWnB0pvAWlb0Jx42dRffM3WAtKuabvNF2o,406
60
- mcpprobe_scanner-0.3.1.dist-info/RECORD,,
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,,