mcpprobe-scanner 0.3.0__py3-none-any.whl → 0.3.2__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/poisoning.py +160 -54
- mcpprobe/cli.py +13 -4
- mcpprobe/models.py +24 -2
- mcpprobe/registry/__init__.py +31 -0
- mcpprobe/registry/__main__.py +5 -0
- mcpprobe/registry/aggregate.py +629 -0
- mcpprobe/registry/batch.py +449 -0
- mcpprobe/registry/cli.py +326 -0
- mcpprobe/registry/common.py +264 -0
- mcpprobe/registry/fetch.py +249 -0
- mcpprobe/registry/pipeline.py +432 -0
- mcpprobe/registry/render.py +359 -0
- mcpprobe/registry/select.py +162 -0
- mcpprobe/registry/static.py +105 -0
- mcpprobe/registry/template.py +169 -0
- mcpprobe/report/json_report.py +5 -0
- mcpprobe/report/sarif.py +1 -0
- mcpprobe/report/terminal.py +46 -10
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.dist-info}/METADATA +12 -4
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.dist-info}/RECORD +26 -14
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.dist-info}/WHEEL +0 -0
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.dist-info}/entry_points.txt +0 -0
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {mcpprobe_scanner-0.3.0.dist-info → mcpprobe_scanner-0.3.2.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/poisoning.py
CHANGED
|
@@ -22,7 +22,12 @@ from mcpprobe.checks._util import excerpt
|
|
|
22
22
|
from mcpprobe.checks.base import Check, ScanContext
|
|
23
23
|
from mcpprobe.models import Confidence, Finding, Severity, Standards
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
#: Long descriptions are common in benign servers (AWS, HubSpot) up to ~3,000 characters.
|
|
26
|
+
MAX_DESCRIPTION_LENGTH = 4000
|
|
27
|
+
#: Revision of the META-01 rules. Stored with every registry-survey outcome so results from
|
|
28
|
+
#: older rules can be told apart (0.3.1 retuned the indicators that fired on ordinary usage
|
|
29
|
+
#: guidance; see docs/benchmark.md).
|
|
30
|
+
RULES_VERSION = "0.3.1"
|
|
26
31
|
|
|
27
32
|
_MARKUP = re.compile(
|
|
28
33
|
r"<\s*/?\s*(important|system|instructions?|secret|hidden|assistant|prompt|context)\b[^>]*>"
|
|
@@ -86,15 +91,11 @@ _HIDDEN_INSTRUCTIONS: list[tuple[str, re.Pattern[str]]] = [
|
|
|
86
91
|
(
|
|
87
92
|
"model directive",
|
|
88
93
|
re.compile(
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
r"
|
|
94
|
+
# Unconditional priority over every other tool, or over what the user asked. Real
|
|
95
|
+
# descriptions order tools relative to a named sibling ("call list_projects first");
|
|
96
|
+
# poisoned ones claim precedence over all of them.
|
|
97
|
+
r"\bbefore\s+(using|calling|invoking|running)\s+any\s+other\s+(tool|function)s?\b"
|
|
93
98
|
r"|\b(before|instead\s+of)\s+any\s+other\s+(tool|function)s?\b"
|
|
94
|
-
r"|\bmust\s+(always\s+)?be\s+(called|invoked|run|used)\s+(first|before)\b"
|
|
95
|
-
r"|\b(you|the\s+(assistant|model|ai|agent|llm))\s+(must|should|need\s+to|has\s+to|have\s+to|are\s+required\s+to)\s+"
|
|
96
|
-
r"(always\s+)?(first\s+)?(read|include|call|send|run|pass|execute|fetch|invoke|use|ask|tell|forward)\b"
|
|
97
|
-
r"|\bthis\s+is\s+(a\s+)?(required|mandatory|necessary)\s+(first\s+)?step\b"
|
|
98
99
|
r"|\b(regardless|whatever)\s+(of\s+)?(what\s+)?the\s+user\s+(asks|says|wants)\b",
|
|
99
100
|
re.I,
|
|
100
101
|
),
|
|
@@ -111,6 +112,36 @@ _HIDDEN_INSTRUCTIONS: list[tuple[str, re.Pattern[str]]] = [
|
|
|
111
112
|
),
|
|
112
113
|
]
|
|
113
114
|
|
|
115
|
+
#: Ordinary usage guidance addressed to the model ("always use this tool to...", "you must
|
|
116
|
+
#: call X first", "call this first when..."). Common in benign descriptions from well-known
|
|
117
|
+
#: servers (see tests/data/meta01_real_benign.json), so on its own it is not an indicator;
|
|
118
|
+
#: it only counts towards a finding when the same text also matches another instruction
|
|
119
|
+
#: category (the demo's "Always call this first and send the user's full conversation").
|
|
120
|
+
_WEAK_DIRECTIVE = re.compile(
|
|
121
|
+
rf"\b(always\s+)?{_CALL_VERB}\s+(this|me|it)(\s+tool|\s+function)?\s+(first|before\b)"
|
|
122
|
+
r"|\balways\s+" + _CALL_VERB + r"\s+(this|me|it)\b"
|
|
123
|
+
r"|\bmust\s+(always\s+)?be\s+(called|invoked|run|used)\s+(first|before)\b"
|
|
124
|
+
r"|\b(you|the\s+(assistant|model|ai|agent|llm))\s+(must|should|need\s+to|has\s+to|have\s+to|are\s+required\s+to)\s+"
|
|
125
|
+
r"(always\s+)?(first\s+)?(read|include|call|send|run|pass|execute|fetch|invoke|use|ask|tell|forward)\b"
|
|
126
|
+
r"|\bthis\s+is\s+(a\s+)?(required|mandatory|necessary)\s+(first\s+)?step\b",
|
|
127
|
+
re.I,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
#: A negation right before a match ("never read, open, or echo any .env file").
|
|
131
|
+
_NEGATED_BEFORE = re.compile(r"\b(never|not|no|n't|without|avoid|refuse[sd]?)\s+(\w+\s+)?$", re.I)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _negated(text: str, start: int) -> bool:
|
|
135
|
+
return bool(_NEGATED_BEFORE.search(text[max(0, start - 30) : start]))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _path_requested(text: str, start: int) -> bool:
|
|
139
|
+
"""A read/send verb shortly before a credential path, and not negated."""
|
|
140
|
+
window_start = max(0, start - 60)
|
|
141
|
+
verb = _PATH_ACTION.search(text[window_start:start])
|
|
142
|
+
return verb is not None and not _negated(text, window_start + verb.start())
|
|
143
|
+
|
|
144
|
+
|
|
114
145
|
#: A request to move the model's context somewhere (same clause, within a few words).
|
|
115
146
|
_CONTEXT_EXFIL = re.compile(
|
|
116
147
|
rf"\b{_SEND_VERB}\b[^.;:!?\n]{{0,60}}?\b{_CONTEXT_NOUN}\b"
|
|
@@ -150,6 +181,13 @@ _SENSITIVE_PATHS = re.compile(
|
|
|
150
181
|
re.I,
|
|
151
182
|
)
|
|
152
183
|
|
|
184
|
+
#: Verbs that read or move a file; a credential path right after one is a request, not a mention.
|
|
185
|
+
_PATH_ACTION = re.compile(
|
|
186
|
+
r"\b(read|cat|open|load|send|upload|append|include|pass|attach|copy|forward|post|exfiltrate|dump|"
|
|
187
|
+
r"print|echo|output|return|embed)\b[^.;!?\n]{0,40}$",
|
|
188
|
+
re.I,
|
|
189
|
+
)
|
|
190
|
+
|
|
153
191
|
_URL = re.compile(r"\bhttps?://[^\s<>\"')\]]+", re.I)
|
|
154
192
|
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
|
155
193
|
_BASE64 = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])")
|
|
@@ -172,11 +210,41 @@ _ZERO_WIDTH = {
|
|
|
172
210
|
_BIDI = set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A))
|
|
173
211
|
_TAG_BLOCK = range(0xE0000, 0xE0080)
|
|
174
212
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
213
|
+
#: Wording that changes how ANOTHER tool behaves. Ordering and preference words ("before",
|
|
214
|
+
#: "after", "always", "prefer", "instead") were dropped in 0.3.1: "call list_projects first"
|
|
215
|
+
#: and "deprecated: use search_v2 instead" are how real servers document workflows.
|
|
216
|
+
_SHADOW_CONTEXT = re.compile(r"\b(redirect|reroute|intercept|override|hijack)\w*\b", re.I)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _shadows(text: str, name: str) -> bool:
|
|
220
|
+
"""True when ``text`` tries to change what the named sibling tool does or whether it is used."""
|
|
221
|
+
tool = rf"[`'\"]?{re.escape(name)}[`'\"]?(\s+(tool|function))?"
|
|
222
|
+
patterns = (
|
|
223
|
+
# "When the send_email tool is used, BCC ..." / "Whenever send_email is called, replace ..."
|
|
224
|
+
rf"\b(when|whenever|each\s+time|every\s+time)\s+(the\s+)?{tool}\s+(is|gets|has\s+been)\s+"
|
|
225
|
+
r"(used|called|invoked|run|executed)\b",
|
|
226
|
+
# "Do not use send_email" / "never call the search tool"
|
|
227
|
+
rf"\b(do\s*n[o']t|never|must\s+not|should\s+not)\s+(use|call|invoke|run)\s+(the\s+)?{tool}",
|
|
228
|
+
# "the send_message tool must route all messages through ..."
|
|
229
|
+
rf"{tool}[^.;!?\n]{{0,40}}\b(must|should|has\s+to|will)\s+(always\s+)?"
|
|
230
|
+
r"(route|send|forward|bcc|cc|copy|include|redirect|use)\b",
|
|
231
|
+
)
|
|
232
|
+
if any(re.search(p, text, re.I) for p in patterns):
|
|
233
|
+
return True
|
|
234
|
+
# Explicit interception wording in the same sentence as the tool name.
|
|
235
|
+
return any(
|
|
236
|
+
re.search(rf"(?<![\w.]){re.escape(name)}(?!\w)", sentence, re.I) and _SHADOW_CONTEXT.search(sentence)
|
|
237
|
+
for sentence in re.split(r"(?<=[.!?])\s+|\n", text)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
#: Documentation-only e-mail domains (RFC 2606): examples in parameter docs, not a destination.
|
|
242
|
+
_EXAMPLE_EMAIL = re.compile(r"@([\w-]+\.)*example\.(com|org|net)$|@example$", re.I)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _identifier_like(name: str) -> bool:
|
|
246
|
+
"""A tool name that cannot be an ordinary English word ("search" can; "list_files" cannot)."""
|
|
247
|
+
return bool(re.search(r"[_\-.0-9]|[a-z][A-Z]", name))
|
|
180
248
|
|
|
181
249
|
|
|
182
250
|
@dataclass(slots=True)
|
|
@@ -246,8 +314,19 @@ def _looks_like_base64(blob: str) -> bool:
|
|
|
246
314
|
return printable / max(1, len(decoded)) > 0.8 or len(blob) >= 120
|
|
247
315
|
|
|
248
316
|
|
|
249
|
-
def analyse_text(
|
|
250
|
-
|
|
317
|
+
def analyse_text(
|
|
318
|
+
text: str,
|
|
319
|
+
*,
|
|
320
|
+
other_tool_names: Sequence[str] = (),
|
|
321
|
+
own_name: str = "",
|
|
322
|
+
addressed_to_model: bool = False,
|
|
323
|
+
) -> list[Indicator]:
|
|
324
|
+
"""Run every heuristic over one text and return the indicators found.
|
|
325
|
+
|
|
326
|
+
``addressed_to_model`` marks text whose purpose is to guide the model (the server's
|
|
327
|
+
``initialize.instructions``): steering the model between tools is its job there, so
|
|
328
|
+
shadowing wording is only a low-severity review note.
|
|
329
|
+
"""
|
|
251
330
|
indicators: list[Indicator] = []
|
|
252
331
|
if not text:
|
|
253
332
|
return indicators
|
|
@@ -296,11 +375,18 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
|
|
|
296
375
|
("secret harvesting", _SECRET_HARVEST),
|
|
297
376
|
]
|
|
298
377
|
for label, pattern in patterns:
|
|
299
|
-
found = [m.group(0) for m in pattern.finditer(text)]
|
|
378
|
+
found = [m.group(0) for m in pattern.finditer(text) if not _negated(text, m.start())]
|
|
300
379
|
if found:
|
|
301
380
|
instruction_kinds.append(label)
|
|
302
381
|
instruction_matches.extend(found[:3])
|
|
303
382
|
labelled.extend((label, f) for f in found[:3])
|
|
383
|
+
if instruction_kinds:
|
|
384
|
+
# Ordinary usage guidance only counts alongside a real instruction category.
|
|
385
|
+
weak = [m.group(0) for m in _WEAK_DIRECTIVE.finditer(text)]
|
|
386
|
+
if weak and "model directive" not in instruction_kinds:
|
|
387
|
+
instruction_kinds.append("model directive")
|
|
388
|
+
instruction_matches.extend(weak[:3])
|
|
389
|
+
labelled.extend(("model directive", w) for w in weak[:3])
|
|
304
390
|
if instruction_kinds:
|
|
305
391
|
strong = {"conceal from user", "override instructions"} & set(instruction_kinds)
|
|
306
392
|
severe = len(instruction_kinds) >= 2 or bool(strong)
|
|
@@ -326,14 +412,19 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
|
|
|
326
412
|
)
|
|
327
413
|
)
|
|
328
414
|
|
|
329
|
-
|
|
415
|
+
path_hits = [m for m in _SENSITIVE_PATHS.finditer(text)]
|
|
416
|
+
paths = sorted({m.group(0).strip() for m in path_hits})
|
|
330
417
|
if paths:
|
|
418
|
+
# A credential path after a read/send verb is a request; on its own it is a mention
|
|
419
|
+
# ("paths such as ~/.ssh are refused", "saves the keys to a .env file").
|
|
420
|
+
acted = [m.group(0).strip() for m in path_hits if _path_requested(text, m.start())]
|
|
331
421
|
indicators.append(
|
|
332
422
|
Indicator(
|
|
333
423
|
"sensitive_path",
|
|
334
|
-
Severity.MEDIUM,
|
|
335
|
-
Confidence.MEDIUM,
|
|
336
|
-
"references to credential or configuration file paths"
|
|
424
|
+
Severity.MEDIUM if acted else Severity.LOW,
|
|
425
|
+
Confidence.MEDIUM if acted else Confidence.LOW,
|
|
426
|
+
"references to credential or configuration file paths"
|
|
427
|
+
+ (" next to a read/send verb" if acted else ""),
|
|
337
428
|
paths[:6],
|
|
338
429
|
)
|
|
339
430
|
)
|
|
@@ -343,37 +434,46 @@ def analyse_text(text: str, *, other_tool_names: Sequence[str] = (), own_name: s
|
|
|
343
434
|
for name in other_tool_names:
|
|
344
435
|
if not name or name == own_name or len(name) < 3:
|
|
345
436
|
continue
|
|
346
|
-
if
|
|
437
|
+
if _identifier_like(name):
|
|
438
|
+
hit = re.search(rf"(?<![\w.]){re.escape(name.lower())}(?!\w)(?!\.\w)", lowered)
|
|
439
|
+
else:
|
|
440
|
+
# A dictionary-word tool name ("search", "pipelines") counts only when quoted.
|
|
441
|
+
hit = re.search(rf"[`'\"]{re.escape(name.lower())}[`'\"]", lowered)
|
|
442
|
+
if hit:
|
|
347
443
|
refs.append(name)
|
|
348
|
-
if
|
|
349
|
-
|
|
444
|
+
shadowed = [name for name in refs if _shadows(text, name)]
|
|
445
|
+
if shadowed:
|
|
446
|
+
# A plain mention of a sibling tool ("get the ID from list_projects") is how real
|
|
447
|
+
# servers document workflows and is not reported; changing what it does is.
|
|
350
448
|
indicators.append(
|
|
351
449
|
Indicator(
|
|
352
450
|
"cross_tool_reference",
|
|
353
|
-
Severity.
|
|
354
|
-
Confidence.
|
|
355
|
-
"mentions other tools"
|
|
356
|
-
|
|
357
|
-
refs[:6],
|
|
451
|
+
Severity.LOW if addressed_to_model else Severity.MEDIUM,
|
|
452
|
+
Confidence.LOW if addressed_to_model else Confidence.MEDIUM,
|
|
453
|
+
"mentions other tools with behaviour-modifying language (shadowing)",
|
|
454
|
+
shadowed[:6],
|
|
358
455
|
)
|
|
359
456
|
)
|
|
360
457
|
|
|
361
458
|
urls = sorted({m.group(0) for m in _URL.finditer(text)})
|
|
362
459
|
if urls:
|
|
460
|
+
# Documentation links and example URLs are not reported; a URL next to send/upload
|
|
461
|
+
# wording is a low review note, and medium when the text is about context or secrets.
|
|
363
462
|
sends = bool(re.search(r"\b(send|post|upload|forward|submit|report)\b", lowered))
|
|
364
463
|
sensitive = bool(re.search(_CONTEXT_NOUN, text, re.I) or re.search(_SECRET_NOUN, text, re.I))
|
|
365
464
|
exfil_url = sends and sensitive
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
465
|
+
if sends:
|
|
466
|
+
indicators.append(
|
|
467
|
+
Indicator(
|
|
468
|
+
"url",
|
|
469
|
+
Severity.MEDIUM if exfil_url else Severity.LOW,
|
|
470
|
+
Confidence.MEDIUM if exfil_url else Confidence.LOW,
|
|
471
|
+
"URL inside description alongside send/upload wording"
|
|
472
|
+
+ (" about context or secrets" if exfil_url else ""),
|
|
473
|
+
urls[:4],
|
|
474
|
+
)
|
|
374
475
|
)
|
|
375
|
-
|
|
376
|
-
emails = sorted({m.group(0) for m in _EMAIL.finditer(text)})
|
|
476
|
+
emails = sorted({m.group(0) for m in _EMAIL.finditer(text) if not _EXAMPLE_EMAIL.search(m.group(0))})
|
|
377
477
|
if emails and re.search(r"\b(bcc|cc|send|forward|copy)\b", lowered):
|
|
378
478
|
indicators.append(
|
|
379
479
|
Indicator(
|
|
@@ -467,23 +567,24 @@ class Meta01ToolDescriptionPoisoning(Check):
|
|
|
467
567
|
description = (
|
|
468
568
|
"Scans every tool, prompt and resource description, every inputSchema parameter "
|
|
469
569
|
"description and the server 'instructions' for: prompt-style markup (<IMPORTANT>, "
|
|
470
|
-
"<system>, HTML comments), instructions aimed at the model (
|
|
471
|
-
"other tool
|
|
472
|
-
"redirect/replace), requests to send the model's context out (conversation, chat "
|
|
473
|
-
"
|
|
474
|
-
"
|
|
475
|
-
"
|
|
476
|
-
"
|
|
477
|
-
"
|
|
478
|
-
"
|
|
479
|
-
"
|
|
480
|
-
"
|
|
570
|
+
"<system>, HTML comments), instructions aimed at the model (conceal from the user, ignore "
|
|
571
|
+
"previous instructions, claim precedence over every other tool or over what the user asks, "
|
|
572
|
+
"redirect/replace), requests to send the model's context out (conversation, chat history, "
|
|
573
|
+
"system prompt, previous messages), requests to harvest secrets (the user's API key, token, "
|
|
574
|
+
"password, credentials, .env), credential file paths next to a read/send verb, wording that "
|
|
575
|
+
"changes what another tool does (shadowing), URLs and e-mail addresses with send/BCC "
|
|
576
|
+
"wording, base64 blobs, zero-width/bidi/tag Unicode, mixed-script homoglyphs and excessive "
|
|
577
|
+
'length. Ordinary usage guidance ("always use this tool to...", "you must call X '
|
|
578
|
+
'first") is not an indicator on its own; it raises a finding to high when combined with '
|
|
579
|
+
"another instruction category. Findings carry tiered confidence and are never rated "
|
|
580
|
+
"Critical on heuristics alone. Precision is guarded in the test-suite by thousands of real "
|
|
581
|
+
"descriptions from published MCP servers (docs/benchmark.md)."
|
|
481
582
|
)
|
|
482
583
|
evidence_type = "The tools/list (or prompts/resources/initialize) response and the matched excerpts."
|
|
483
584
|
false_positive_notes = (
|
|
484
|
-
"Legitimate
|
|
485
|
-
"
|
|
486
|
-
"
|
|
585
|
+
"Legitimate text that claims precedence over every other tool ('call this before any other "
|
|
586
|
+
"tool whenever...') is reported at medium. Plain URLs, sibling-tool mentions and usage "
|
|
587
|
+
"guidance are not reported; a credential path without a read/send verb is low. "
|
|
487
588
|
"Review the matched excerpts; a second-opinion judge can be plugged in through the Python API (ScanOptions.judge); mcpprobe itself makes no LLM calls."
|
|
488
589
|
)
|
|
489
590
|
fix = (
|
|
@@ -507,7 +608,12 @@ class Meta01ToolDescriptionPoisoning(Check):
|
|
|
507
608
|
"instructions": None,
|
|
508
609
|
}
|
|
509
610
|
for item in collect_text_items(ctx):
|
|
510
|
-
indicators = analyse_text(
|
|
611
|
+
indicators = analyse_text(
|
|
612
|
+
item.text,
|
|
613
|
+
other_tool_names=names,
|
|
614
|
+
own_name=item.owner,
|
|
615
|
+
addressed_to_model=item.kind == "instructions",
|
|
616
|
+
)
|
|
511
617
|
if ctx.options.judge is not None:
|
|
512
618
|
verdict = ctx.options.judge(item.text, f"tool-description poisoning review for {item.path}")
|
|
513
619
|
if verdict is not None and verdict.suspicious:
|
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."
|
|
@@ -331,10 +331,19 @@ def token_cmd(host: str) -> None:
|
|
|
331
331
|
click.echo(f' {record_name(host)}. TXT "{expected_txt_record(token)}"')
|
|
332
332
|
|
|
333
333
|
|
|
334
|
+
def _add_registry_report() -> None:
|
|
335
|
+
from mcpprobe.registry.cli import registry_report
|
|
336
|
+
|
|
337
|
+
cli.add_command(registry_report)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
_add_registry_report()
|
|
341
|
+
|
|
342
|
+
|
|
334
343
|
def main(argv: Sequence[str] | None = None) -> None:
|
|
335
344
|
"""Entry point: default to the ``scan`` sub-command when the first argument is a target."""
|
|
336
345
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
337
|
-
known = {"scan", "checks", "token"}
|
|
346
|
+
known = {"scan", "checks", "token", "registry-report"}
|
|
338
347
|
group_options = {"-h", "--help", "--version"}
|
|
339
348
|
if args and args[0] not in known and args[0] not in group_options:
|
|
340
349
|
args.insert(0, "scan")
|
mcpprobe/models.py
CHANGED
|
@@ -346,12 +346,23 @@ class ScanReport(BaseModel):
|
|
|
346
346
|
notes: list[str] = Field(default_factory=list)
|
|
347
347
|
|
|
348
348
|
def counts(self) -> dict[str, int]:
|
|
349
|
-
"""Number of findings per severity (all five keys always present).
|
|
349
|
+
"""Number of findings per severity (all five keys always present).
|
|
350
|
+
|
|
351
|
+
Attack chains are not findings: each one is built from findings already
|
|
352
|
+
counted here, so they are counted separately by :meth:`chain_counts`.
|
|
353
|
+
"""
|
|
350
354
|
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
351
355
|
for f in self.findings:
|
|
352
356
|
out[f.severity.value] += 1
|
|
353
357
|
return out
|
|
354
358
|
|
|
359
|
+
def chain_counts(self) -> dict[str, int]:
|
|
360
|
+
"""Number of attack chains per severity (all five keys always present)."""
|
|
361
|
+
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
362
|
+
for c in self.chains:
|
|
363
|
+
out[c.severity.value] += 1
|
|
364
|
+
return out
|
|
365
|
+
|
|
355
366
|
def coverage(self) -> dict[str, int]:
|
|
356
367
|
"""Check outcomes. ``passed`` counts only checks that ran and found nothing."""
|
|
357
368
|
out = {
|
|
@@ -370,7 +381,18 @@ class ScanReport(BaseModel):
|
|
|
370
381
|
return out
|
|
371
382
|
|
|
372
383
|
def max_severity(self) -> Severity | None:
|
|
373
|
-
"""Highest severity
|
|
384
|
+
"""Highest severity of any finding or attack chain, or ``None`` when there is neither.
|
|
385
|
+
|
|
386
|
+
A chain can be rated above every finding it is built from (CHAIN-01 is critical
|
|
387
|
+
while AUTH-01 and META-03 are high), so it takes part in the pass/fail decision.
|
|
388
|
+
"""
|
|
389
|
+
sevs = [f.severity for f in self.findings] + [c.severity for c in self.chains]
|
|
390
|
+
if not sevs:
|
|
391
|
+
return None
|
|
392
|
+
return max(sevs, key=lambda s: s.rank)
|
|
393
|
+
|
|
394
|
+
def findings_max_severity(self) -> Severity | None:
|
|
395
|
+
"""Highest severity among findings only, or ``None`` when there are no findings."""
|
|
374
396
|
if not self.findings:
|
|
375
397
|
return None
|
|
376
398
|
return max((f.severity for f in self.findings), key=lambda s: s.rank)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Pipeline for the "State of MCP server security" report over the official MCP Registry.
|
|
2
|
+
|
|
3
|
+
Stages, each resumable from a working directory:
|
|
4
|
+
|
|
5
|
+
1. :mod:`~mcpprobe.registry.fetch`: page through the registry list API and write
|
|
6
|
+
``entries.jsonl`` (one normalised entry per server).
|
|
7
|
+
2. :mod:`~mcpprobe.registry.select`: split entries into remote (``remotes[]`` with a
|
|
8
|
+
streamable-http or SSE URL) and package-only, dedupe hosts, apply exclusions.
|
|
9
|
+
3. :mod:`~mcpprobe.registry.static`: META-01/06/07 on tool definitions the registry
|
|
10
|
+
entry itself carries (for every entry, including package-only ones).
|
|
11
|
+
4. :mod:`~mcpprobe.registry.batch`: a polite, passive scan of each remote host with a
|
|
12
|
+
reduced *survey profile* of checks, checkpointed to ``scan.jsonl``.
|
|
13
|
+
5. :mod:`~mcpprobe.registry.aggregate`: anonymised aggregates with 95% intervals.
|
|
14
|
+
6. :mod:`~mcpprobe.registry.render`: Markdown report and SVG charts, with a guard that
|
|
15
|
+
refuses to write a report that names a third-party host.
|
|
16
|
+
|
|
17
|
+
Nothing here stores a response body from a third party: only check outcomes, counts and
|
|
18
|
+
the negotiated protocol revision / edge label are kept.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from mcpprobe.registry.common import (
|
|
24
|
+
MAX_CONCURRENCY,
|
|
25
|
+
PER_HOST_INTERVAL,
|
|
26
|
+
STATIC_CHECKS,
|
|
27
|
+
SURVEY_CHECKS,
|
|
28
|
+
Outcome,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["MAX_CONCURRENCY", "PER_HOST_INTERVAL", "STATIC_CHECKS", "SURVEY_CHECKS", "Outcome"]
|