mcplint-sec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcplint/__init__.py +3 -0
- mcplint/__main__.py +4 -0
- mcplint/aibom.py +50 -0
- mcplint/checks.py +571 -0
- mcplint/cli.py +298 -0
- mcplint/discovery.py +132 -0
- mcplint/lockfile.py +171 -0
- mcplint/models.py +144 -0
- mcplint/packages.py +94 -0
- mcplint/parse.py +180 -0
- mcplint/report/__init__.py +0 -0
- mcplint/report/pretty.py +47 -0
- mcplint/report/sarif.py +69 -0
- mcplint/rules/__init__.py +4 -0
- mcplint/rules/loader.py +34 -0
- mcplint/rules/model.py +22 -0
- mcplint/rules_data/MCP001-secrets-in-config.yaml +36 -0
- mcplint/rules_data/MCP002-file-permissions.yaml +13 -0
- mcplint/rules_data/MCP003-unpinned-package.yaml +15 -0
- mcplint/rules_data/MCP004-typosquat-package.yaml +46 -0
- mcplint/rules_data/MCP005-http-remote.yaml +14 -0
- mcplint/rules_data/MCP006-remote-without-auth.yaml +16 -0
- mcplint/rules_data/MCP007-filesystem-overreach.yaml +17 -0
- mcplint/rules_data/MCP008-shell-execution.yaml +17 -0
- mcplint/rules_data/MCP009-injection-markers.yaml +36 -0
- mcplint/rules_data/MCP010-hidden-unicode.yaml +15 -0
- mcplint/rules_data/MCP011-server-shadowing.yaml +16 -0
- mcplint/rules_data/MCP012-lockfile-drift.yaml +15 -0
- mcplint/rules_data/MCP013-known-cve.yaml +14 -0
- mcplint/scanner.py +79 -0
- mcplint_sec-0.1.0.dist-info/METADATA +155 -0
- mcplint_sec-0.1.0.dist-info/RECORD +35 -0
- mcplint_sec-0.1.0.dist-info/WHEEL +4 -0
- mcplint_sec-0.1.0.dist-info/entry_points.txt +3 -0
- mcplint_sec-0.1.0.dist-info/licenses/LICENSE +201 -0
mcplint/__init__.py
ADDED
mcplint/__main__.py
ADDED
mcplint/aibom.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""CycloneDX AIBOM export for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .lockfile import rel_to
|
|
12
|
+
from .models import MCPConfigFile
|
|
13
|
+
from .packages import package_specs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_aibom(configs: list[MCPConfigFile], root: Path) -> dict[str, Any]:
|
|
17
|
+
components: list[dict[str, Any]] = []
|
|
18
|
+
for config in configs:
|
|
19
|
+
for server in config.servers:
|
|
20
|
+
specs = package_specs(server)
|
|
21
|
+
spec = specs[0] if specs else None
|
|
22
|
+
component: dict[str, Any] = {
|
|
23
|
+
"type": "library" if spec else "application",
|
|
24
|
+
"name": server.name,
|
|
25
|
+
"bom-ref": f"mcp-server:{server.name}:{rel_to(config.path, root)}",
|
|
26
|
+
"properties": [
|
|
27
|
+
{"name": "mcplint:client", "value": config.client},
|
|
28
|
+
{"name": "mcplint:transport", "value": server.transport},
|
|
29
|
+
{"name": "mcplint:config", "value": rel_to(config.path, root)},
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
if spec:
|
|
33
|
+
purl = f"pkg:{'npm' if spec.kind == 'npm' else 'pypi'}/{quote(spec.name, safe='/')}"
|
|
34
|
+
if spec.pinned and spec.version:
|
|
35
|
+
component["version"] = spec.version
|
|
36
|
+
purl += f"@{spec.version}"
|
|
37
|
+
component["purl"] = purl
|
|
38
|
+
component["properties"].append({"name": "mcplint:package", "value": spec.raw})
|
|
39
|
+
components.append(component)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"bomFormat": "CycloneDX",
|
|
43
|
+
"specVersion": "1.5",
|
|
44
|
+
"version": 1,
|
|
45
|
+
"metadata": {
|
|
46
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
47
|
+
"tools": [{"vendor": "mcplint", "name": "mcplint", "version": __version__}],
|
|
48
|
+
},
|
|
49
|
+
"components": components,
|
|
50
|
+
}
|
mcplint/checks.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Built-in check implementations, dispatched by ``rule.check``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .lockfile import verify_lock
|
|
16
|
+
from .models import (
|
|
17
|
+
Finding,
|
|
18
|
+
InstructionFile,
|
|
19
|
+
MCPConfigFile,
|
|
20
|
+
ScanContext,
|
|
21
|
+
Severity,
|
|
22
|
+
)
|
|
23
|
+
from .packages import normalize_package, package_specs
|
|
24
|
+
from .rules.model import Rule
|
|
25
|
+
|
|
26
|
+
CheckFn = Callable[[Rule, Any, ScanContext], list[Finding]]
|
|
27
|
+
CHECKS: dict[str, CheckFn] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check(name: str) -> Callable[[CheckFn], CheckFn]:
|
|
31
|
+
def decorator(fn: CheckFn) -> CheckFn:
|
|
32
|
+
CHECKS[name] = fn
|
|
33
|
+
return fn
|
|
34
|
+
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _finding(
|
|
39
|
+
rule: Rule,
|
|
40
|
+
path: Any,
|
|
41
|
+
message: str,
|
|
42
|
+
server: str | None = None,
|
|
43
|
+
line: int = 1,
|
|
44
|
+
severity: Severity | None = None,
|
|
45
|
+
) -> Finding:
|
|
46
|
+
return Finding(
|
|
47
|
+
rule_id=rule.id,
|
|
48
|
+
severity=severity or rule.severity,
|
|
49
|
+
title=rule.title,
|
|
50
|
+
message=message,
|
|
51
|
+
file=str(path),
|
|
52
|
+
server=server,
|
|
53
|
+
line=max(1, line),
|
|
54
|
+
owasp=rule.owasp or None,
|
|
55
|
+
remediation=rule.remediation or None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _line_of(text: str, needle: str) -> int:
|
|
60
|
+
if not needle:
|
|
61
|
+
return 1
|
|
62
|
+
idx = text.find(needle)
|
|
63
|
+
if idx < 0:
|
|
64
|
+
return 1
|
|
65
|
+
return text.count("\n", 0, idx) + 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_env_ref(value: str) -> bool:
|
|
69
|
+
stripped = value.strip()
|
|
70
|
+
if not stripped:
|
|
71
|
+
return True
|
|
72
|
+
if "${" in stripped:
|
|
73
|
+
return True
|
|
74
|
+
return stripped.startswith("$") and " " not in stripped
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _looks_placeholder(value: str) -> bool:
|
|
78
|
+
placeholders = {
|
|
79
|
+
"changeme",
|
|
80
|
+
"change-me",
|
|
81
|
+
"your-key-here",
|
|
82
|
+
"your_api_key",
|
|
83
|
+
"your-api-key",
|
|
84
|
+
"placeholder",
|
|
85
|
+
"example",
|
|
86
|
+
"todo",
|
|
87
|
+
"none",
|
|
88
|
+
"null",
|
|
89
|
+
}
|
|
90
|
+
return value.strip().lower() in placeholders
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _first_matching_pattern(value: str, patterns: list[dict[str, Any]]) -> str | None:
|
|
94
|
+
for pattern in patterns:
|
|
95
|
+
try:
|
|
96
|
+
if re.search(pattern["regex"], value):
|
|
97
|
+
return str(pattern["name"])
|
|
98
|
+
except re.error:
|
|
99
|
+
continue
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_local_host(hostname: str | None) -> bool:
|
|
104
|
+
return hostname in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@check("secrets_in_config")
|
|
108
|
+
def secrets_in_config(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
109
|
+
findings: list[Finding] = []
|
|
110
|
+
patterns = rule.params.get("patterns", [])
|
|
111
|
+
suspicious_key = rule.params.get("suspicious_env_key", "")
|
|
112
|
+
min_len = int(rule.params.get("min_len", 12))
|
|
113
|
+
|
|
114
|
+
def scan_value(server_name: str, key: str, value: str) -> None:
|
|
115
|
+
if _is_env_ref(value) or _looks_placeholder(value):
|
|
116
|
+
return
|
|
117
|
+
matched = _first_matching_pattern(value, patterns)
|
|
118
|
+
line = _line_of(target.raw_text, key)
|
|
119
|
+
if matched:
|
|
120
|
+
findings.append(
|
|
121
|
+
_finding(
|
|
122
|
+
rule,
|
|
123
|
+
target.path,
|
|
124
|
+
f"{matched} found in env.{key}",
|
|
125
|
+
server=server_name,
|
|
126
|
+
line=line,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
elif suspicious_key and re.search(suspicious_key, key) and len(value) >= min_len:
|
|
130
|
+
findings.append(
|
|
131
|
+
_finding(
|
|
132
|
+
rule,
|
|
133
|
+
target.path,
|
|
134
|
+
f"literal value for sensitive env.{key} (length {len(value)}) — "
|
|
135
|
+
"use an env reference instead",
|
|
136
|
+
server=server_name,
|
|
137
|
+
line=line,
|
|
138
|
+
severity=Severity.HIGH,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for server in target.servers:
|
|
143
|
+
for key, value in server.env.items():
|
|
144
|
+
scan_value(server.name, key, value)
|
|
145
|
+
joined = " ".join(server.command)
|
|
146
|
+
matched = _first_matching_pattern(joined, patterns)
|
|
147
|
+
if matched:
|
|
148
|
+
findings.append(
|
|
149
|
+
_finding(
|
|
150
|
+
rule,
|
|
151
|
+
target.path,
|
|
152
|
+
f"{matched} found in args of server '{server.name}'",
|
|
153
|
+
server=server.name,
|
|
154
|
+
line=_line_of(target.raw_text, server.name),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
for header, value in server.headers.items():
|
|
158
|
+
if _is_env_ref(value):
|
|
159
|
+
continue
|
|
160
|
+
matched = _first_matching_pattern(value, patterns)
|
|
161
|
+
if matched:
|
|
162
|
+
findings.append(
|
|
163
|
+
_finding(
|
|
164
|
+
rule,
|
|
165
|
+
target.path,
|
|
166
|
+
f"{matched} found in header '{header}' of server '{server.name}'",
|
|
167
|
+
server=server.name,
|
|
168
|
+
line=_line_of(target.raw_text, header),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return findings
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@check("file_permissions")
|
|
175
|
+
def file_permissions(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
176
|
+
try:
|
|
177
|
+
mode = stat.S_IMODE(target.path.stat().st_mode)
|
|
178
|
+
except OSError:
|
|
179
|
+
return []
|
|
180
|
+
has_secrets = any(server.env for server in target.servers)
|
|
181
|
+
if mode & 0o002:
|
|
182
|
+
return [
|
|
183
|
+
_finding(
|
|
184
|
+
rule,
|
|
185
|
+
target.path,
|
|
186
|
+
f"config is world-writable (mode {oct(mode)}) — any local process can "
|
|
187
|
+
"tamper with it (rug-pull vector)",
|
|
188
|
+
severity=Severity.HIGH,
|
|
189
|
+
)
|
|
190
|
+
]
|
|
191
|
+
if (mode & 0o044) and has_secrets:
|
|
192
|
+
return [
|
|
193
|
+
_finding(
|
|
194
|
+
rule,
|
|
195
|
+
target.path,
|
|
196
|
+
f"config is group/world-readable (mode {oct(mode)}) and contains "
|
|
197
|
+
"credentials — restrict with chmod 600",
|
|
198
|
+
)
|
|
199
|
+
]
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@check("unpinned_package")
|
|
204
|
+
def unpinned_package(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
205
|
+
findings: list[Finding] = []
|
|
206
|
+
for server in target.servers:
|
|
207
|
+
for spec in package_specs(server):
|
|
208
|
+
if spec.pinned:
|
|
209
|
+
continue
|
|
210
|
+
severity = Severity.HIGH if spec.auto_approve else rule.severity
|
|
211
|
+
extra = " and auto-approved (-y/--yes)" if spec.auto_approve else ""
|
|
212
|
+
findings.append(
|
|
213
|
+
_finding(
|
|
214
|
+
rule,
|
|
215
|
+
target.path,
|
|
216
|
+
f"'{spec.raw}' is not pinned to a version{extra} — a malicious or "
|
|
217
|
+
"broken release is picked up on every run",
|
|
218
|
+
server=server.name,
|
|
219
|
+
line=_line_of(target.raw_text, spec.raw),
|
|
220
|
+
severity=severity,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return findings
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _levenshtein(a: str, b: str) -> int:
|
|
227
|
+
if a == b:
|
|
228
|
+
return 0
|
|
229
|
+
if not a:
|
|
230
|
+
return len(b)
|
|
231
|
+
if not b:
|
|
232
|
+
return len(a)
|
|
233
|
+
previous = list(range(len(b) + 1))
|
|
234
|
+
for i, ca in enumerate(a, start=1):
|
|
235
|
+
current = [i]
|
|
236
|
+
for j, cb in enumerate(b, start=1):
|
|
237
|
+
cost = 0 if ca == cb else 1
|
|
238
|
+
current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost))
|
|
239
|
+
previous = current
|
|
240
|
+
return previous[-1]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@check("typosquat_package")
|
|
244
|
+
def typosquat_package(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
245
|
+
known = [str(k) for k in rule.params.get("known_packages", [])]
|
|
246
|
+
max_distance = int(rule.params.get("max_distance", 2))
|
|
247
|
+
known_norm = {normalize_package(k): k for k in known}
|
|
248
|
+
findings: list[Finding] = []
|
|
249
|
+
for server in target.servers:
|
|
250
|
+
for spec in package_specs(server):
|
|
251
|
+
name = spec.name
|
|
252
|
+
if not name or name in known_norm:
|
|
253
|
+
continue
|
|
254
|
+
for known_name, original in known_norm.items():
|
|
255
|
+
distance = _levenshtein(name, known_name)
|
|
256
|
+
if 1 <= distance <= max_distance and abs(len(name) - len(known_name)) <= 2:
|
|
257
|
+
findings.append(
|
|
258
|
+
_finding(
|
|
259
|
+
rule,
|
|
260
|
+
target.path,
|
|
261
|
+
f"'{spec.raw}' is {distance} edit(s) away from popular package "
|
|
262
|
+
f"'{original}' — possible typosquat",
|
|
263
|
+
server=server.name,
|
|
264
|
+
line=_line_of(target.raw_text, spec.raw),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
break
|
|
268
|
+
return findings
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@check("insecure_remote_transport")
|
|
272
|
+
def insecure_remote_transport(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
273
|
+
findings: list[Finding] = []
|
|
274
|
+
for server in target.servers:
|
|
275
|
+
if not server.url:
|
|
276
|
+
continue
|
|
277
|
+
parsed = urllib.parse.urlparse(server.url)
|
|
278
|
+
if parsed.scheme == "http" and not _is_local_host(parsed.hostname):
|
|
279
|
+
findings.append(
|
|
280
|
+
_finding(
|
|
281
|
+
rule,
|
|
282
|
+
target.path,
|
|
283
|
+
f"remote endpoint '{server.url}' uses plain HTTP — tool calls and results "
|
|
284
|
+
"travel unencrypted",
|
|
285
|
+
server=server.name,
|
|
286
|
+
line=_line_of(target.raw_text, server.url),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return findings
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@check("remote_without_auth")
|
|
293
|
+
def remote_without_auth(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
294
|
+
auth_pattern = rule.params.get("auth_env_pattern", "")
|
|
295
|
+
findings: list[Finding] = []
|
|
296
|
+
for server in target.servers:
|
|
297
|
+
if not server.url:
|
|
298
|
+
continue
|
|
299
|
+
parsed = urllib.parse.urlparse(server.url)
|
|
300
|
+
if _is_local_host(parsed.hostname):
|
|
301
|
+
continue
|
|
302
|
+
has_auth = any(key.lower() == "authorization" for key in server.headers)
|
|
303
|
+
if not has_auth and auth_pattern:
|
|
304
|
+
has_auth = any(re.search(auth_pattern, key) for key in server.env)
|
|
305
|
+
if not has_auth:
|
|
306
|
+
has_auth = any(re.search(auth_pattern, key) for key in server.headers)
|
|
307
|
+
if not has_auth:
|
|
308
|
+
findings.append(
|
|
309
|
+
_finding(
|
|
310
|
+
rule,
|
|
311
|
+
target.path,
|
|
312
|
+
f"remote endpoint '{server.url}' references no authentication material "
|
|
313
|
+
"(no Authorization header, no token/key env)",
|
|
314
|
+
server=server.name,
|
|
315
|
+
line=_line_of(target.raw_text, server.url),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return findings
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@check("filesystem_overreach")
|
|
322
|
+
def filesystem_overreach(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
323
|
+
dangerous = {str(p) for p in rule.params.get("dangerous_paths", [])}
|
|
324
|
+
hints = [str(h).lower() for h in rule.params.get("filesystem_hints", [])]
|
|
325
|
+
findings: list[Finding] = []
|
|
326
|
+
for server in target.servers:
|
|
327
|
+
haystack = " ".join(server.command).lower()
|
|
328
|
+
is_filesystem = any(hint in haystack for hint in hints)
|
|
329
|
+
if not is_filesystem:
|
|
330
|
+
continue
|
|
331
|
+
for token in server.command[1:]:
|
|
332
|
+
if token in dangerous:
|
|
333
|
+
findings.append(
|
|
334
|
+
_finding(
|
|
335
|
+
rule,
|
|
336
|
+
target.path,
|
|
337
|
+
f"filesystem server '{server.name}' is scoped to '{token}' — "
|
|
338
|
+
"it can read and write far beyond the project workspace",
|
|
339
|
+
server=server.name,
|
|
340
|
+
line=_line_of(target.raw_text, token),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return findings
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@check("shell_execution_server")
|
|
347
|
+
def shell_execution_server(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
348
|
+
bins = {str(b).lower() for b in rule.params.get("shell_bins", [])}
|
|
349
|
+
hints = [str(h).lower() for h in rule.params.get("shell_hints", [])]
|
|
350
|
+
findings: list[Finding] = []
|
|
351
|
+
for server in target.servers:
|
|
352
|
+
if not server.command:
|
|
353
|
+
continue
|
|
354
|
+
base = Path(server.command[0]).name.lower()
|
|
355
|
+
haystack = " ".join(server.command).lower()
|
|
356
|
+
if base in bins:
|
|
357
|
+
findings.append(
|
|
358
|
+
_finding(
|
|
359
|
+
rule,
|
|
360
|
+
target.path,
|
|
361
|
+
f"server '{server.name}' spawns a shell ('{server.command[0]}') — "
|
|
362
|
+
"it can execute arbitrary commands on this machine",
|
|
363
|
+
server=server.name,
|
|
364
|
+
line=_line_of(target.raw_text, server.name),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
elif any(hint in haystack for hint in hints):
|
|
368
|
+
findings.append(
|
|
369
|
+
_finding(
|
|
370
|
+
rule,
|
|
371
|
+
target.path,
|
|
372
|
+
f"server '{server.name}' looks like a command-execution server",
|
|
373
|
+
server=server.name,
|
|
374
|
+
line=_line_of(target.raw_text, server.name),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return findings
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@check("injection_markers")
|
|
381
|
+
def injection_markers(rule: Rule, target: Any, ctx: ScanContext) -> list[Finding]:
|
|
382
|
+
if isinstance(target, InstructionFile):
|
|
383
|
+
text = target.text
|
|
384
|
+
path = target.path
|
|
385
|
+
elif isinstance(target, MCPConfigFile):
|
|
386
|
+
text = target.raw_text
|
|
387
|
+
path = target.path
|
|
388
|
+
else:
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
patterns = rule.params.get("patterns", [])
|
|
392
|
+
hits: list[tuple[str, int]] = []
|
|
393
|
+
high_confidence = False
|
|
394
|
+
for pattern in patterns:
|
|
395
|
+
try:
|
|
396
|
+
match = re.search(str(pattern["regex"]), text)
|
|
397
|
+
except re.error:
|
|
398
|
+
continue
|
|
399
|
+
if match:
|
|
400
|
+
hits.append((str(pattern["name"]), match.start()))
|
|
401
|
+
high_confidence = high_confidence or bool(pattern.get("high_confidence"))
|
|
402
|
+
|
|
403
|
+
if not hits:
|
|
404
|
+
return []
|
|
405
|
+
|
|
406
|
+
severity = Severity.HIGH if (high_confidence or len(hits) >= 2) else Severity.MEDIUM
|
|
407
|
+
findings: list[Finding] = []
|
|
408
|
+
for name, position in hits[:5]:
|
|
409
|
+
findings.append(
|
|
410
|
+
_finding(
|
|
411
|
+
rule,
|
|
412
|
+
path,
|
|
413
|
+
f"possible prompt-injection indicator ({name}); {len(hits)} indicator(s) found "
|
|
414
|
+
"in this file",
|
|
415
|
+
line=text.count("\n", 0, position) + 1,
|
|
416
|
+
severity=severity,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
return findings
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
_HIDDEN_NAMES = {
|
|
423
|
+
0x00AD: "soft hyphen",
|
|
424
|
+
0x200B: "zero-width space",
|
|
425
|
+
0x200C: "zero-width non-joiner",
|
|
426
|
+
0x200D: "zero-width joiner",
|
|
427
|
+
0x200E: "left-to-right mark",
|
|
428
|
+
0x200F: "right-to-left mark",
|
|
429
|
+
0x202A: "bidi embedding",
|
|
430
|
+
0x202B: "bidi embedding",
|
|
431
|
+
0x202C: "bidi pop",
|
|
432
|
+
0x202D: "bidi override",
|
|
433
|
+
0x202E: "bidi override",
|
|
434
|
+
0x2060: "word joiner",
|
|
435
|
+
0x2061: "function application",
|
|
436
|
+
0x2062: "invisible times",
|
|
437
|
+
0x2063: "invisible separator",
|
|
438
|
+
0x2064: "invisible plus",
|
|
439
|
+
0xFEFF: "byte order mark",
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@check("hidden_unicode")
|
|
444
|
+
def hidden_unicode(rule: Rule, target: Any, ctx: ScanContext) -> list[Finding]:
|
|
445
|
+
if isinstance(target, InstructionFile):
|
|
446
|
+
text = target.text
|
|
447
|
+
path = target.path
|
|
448
|
+
elif isinstance(target, MCPConfigFile):
|
|
449
|
+
text = target.raw_text
|
|
450
|
+
path = target.path
|
|
451
|
+
else:
|
|
452
|
+
return []
|
|
453
|
+
|
|
454
|
+
codepoints = {int(cp, 16) for cp in rule.params.get("codepoints", [])}
|
|
455
|
+
first_seen: dict[int, int] = {}
|
|
456
|
+
for index, char in enumerate(text):
|
|
457
|
+
value = ord(char)
|
|
458
|
+
if value in codepoints and value not in first_seen:
|
|
459
|
+
first_seen[value] = index
|
|
460
|
+
|
|
461
|
+
findings: list[Finding] = []
|
|
462
|
+
for value, position in sorted(first_seen.items()):
|
|
463
|
+
name = _HIDDEN_NAMES.get(value, "hidden character")
|
|
464
|
+
findings.append(
|
|
465
|
+
_finding(
|
|
466
|
+
rule,
|
|
467
|
+
path,
|
|
468
|
+
f"hidden unicode U+{value:04X} ({name}) — invisible text can smuggle "
|
|
469
|
+
"instructions past human review",
|
|
470
|
+
line=text.count("\n", 0, position) + 1,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
return findings
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@check("server_name_shadowing")
|
|
477
|
+
def server_name_shadowing(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
478
|
+
if ctx.cache.get("shadowing_done"):
|
|
479
|
+
return []
|
|
480
|
+
ctx.cache["shadowing_done"] = True
|
|
481
|
+
|
|
482
|
+
by_name: dict[str, list[MCPConfigFile]] = {}
|
|
483
|
+
for config in ctx.configs:
|
|
484
|
+
for server in config.servers:
|
|
485
|
+
by_name.setdefault(server.name.lower(), []).append(config)
|
|
486
|
+
|
|
487
|
+
findings: list[Finding] = []
|
|
488
|
+
for name, configs in by_name.items():
|
|
489
|
+
files = {str(c.path) for c in configs}
|
|
490
|
+
if len(files) > 1:
|
|
491
|
+
findings.append(
|
|
492
|
+
_finding(
|
|
493
|
+
rule,
|
|
494
|
+
configs[0].path,
|
|
495
|
+
f"server name '{name}' is defined in {len(files)} different configs "
|
|
496
|
+
"(tool shadowing risk)",
|
|
497
|
+
server=name,
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
return findings
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@check("lockfile_drift")
|
|
504
|
+
def lockfile_drift(rule: Rule, target: MCPConfigFile, ctx: ScanContext) -> list[Finding]:
|
|
505
|
+
if ctx.cache.get("lock_drift_done"):
|
|
506
|
+
return []
|
|
507
|
+
ctx.cache["lock_drift_done"] = True
|
|
508
|
+
if not ctx.lock:
|
|
509
|
+
return []
|
|
510
|
+
return verify_lock(ctx.lock, ctx.configs, ctx.root, rule)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _osv_query(kind: str, name: str, version: str) -> list[dict[str, Any]]:
|
|
514
|
+
ecosystem = "npm" if kind == "npm" else "PyPI"
|
|
515
|
+
payload = {"package": {"name": name, "ecosystem": ecosystem}, "version": version}
|
|
516
|
+
request = urllib.request.Request(
|
|
517
|
+
"https://api.osv.dev/v1/query",
|
|
518
|
+
data=json.dumps(payload).encode(),
|
|
519
|
+
headers={"Content-Type": "application/json", "User-Agent": "mcplint"},
|
|
520
|
+
)
|
|
521
|
+
try:
|
|
522
|
+
with urllib.request.urlopen(request, timeout=8) as response:
|
|
523
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
524
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
525
|
+
return []
|
|
526
|
+
vulns = body.get("vulns", [])
|
|
527
|
+
return vulns if isinstance(vulns, list) else []
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _osv_severity(vuln: dict[str, Any]) -> Severity:
|
|
531
|
+
database_specific = vuln.get("database_specific") or {}
|
|
532
|
+
label = str(database_specific.get("severity", "")).lower()
|
|
533
|
+
return {
|
|
534
|
+
"critical": Severity.CRITICAL,
|
|
535
|
+
"high": Severity.HIGH,
|
|
536
|
+
"moderate": Severity.MEDIUM,
|
|
537
|
+
"medium": Severity.MEDIUM,
|
|
538
|
+
"low": Severity.LOW,
|
|
539
|
+
}.get(label, Severity.MEDIUM)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@check("known_vulnerable_dependency")
|
|
543
|
+
def known_vulnerable_dependency(
|
|
544
|
+
rule: Rule, target: MCPConfigFile, ctx: ScanContext
|
|
545
|
+
) -> list[Finding]:
|
|
546
|
+
if not ctx.online:
|
|
547
|
+
return []
|
|
548
|
+
findings: list[Finding] = []
|
|
549
|
+
for server in target.servers:
|
|
550
|
+
for spec in package_specs(server):
|
|
551
|
+
if not spec.pinned or not spec.version:
|
|
552
|
+
continue
|
|
553
|
+
cache_key = f"osv:{spec.kind}:{spec.name}@{spec.version}"
|
|
554
|
+
if cache_key not in ctx.cache:
|
|
555
|
+
ctx.cache[cache_key] = _osv_query(spec.kind, spec.name, spec.version)
|
|
556
|
+
for vuln in ctx.cache[cache_key]:
|
|
557
|
+
vuln_id = str(vuln.get("id", "OSV"))
|
|
558
|
+
summary = str(vuln.get("summary", "")).strip()
|
|
559
|
+
truncated = (summary[:120] + "...") if len(summary) > 120 else summary
|
|
560
|
+
findings.append(
|
|
561
|
+
_finding(
|
|
562
|
+
rule,
|
|
563
|
+
target.path,
|
|
564
|
+
f"{spec.name}@{spec.version} matches {vuln_id}"
|
|
565
|
+
+ (f": {truncated}" if truncated else ""),
|
|
566
|
+
server=server.name,
|
|
567
|
+
line=_line_of(target.raw_text, spec.raw),
|
|
568
|
+
severity=_osv_severity(vuln),
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return findings
|