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 ADDED
@@ -0,0 +1,3 @@
1
+ """mcplint — local-first, CI-native security scanner for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
mcplint/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mcplint.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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