mcpxray-cli 1.0.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.
- mcpxray/__init__.py +18 -0
- mcpxray/__main__.py +6 -0
- mcpxray/badge.py +49 -0
- mcpxray/cli.py +322 -0
- mcpxray/extract/__init__.py +16 -0
- mcpxray/extract/base.py +61 -0
- mcpxray/extract/manifest.py +97 -0
- mcpxray/extract/python_static.py +316 -0
- mcpxray/extract/typescript_static.py +552 -0
- mcpxray/fix.py +166 -0
- mcpxray/ir.py +186 -0
- mcpxray/report/__init__.py +47 -0
- mcpxray/report/card.py +137 -0
- mcpxray/report/github.py +35 -0
- mcpxray/report/json.py +50 -0
- mcpxray/report/plain.py +35 -0
- mcpxray/report/sarif.py +76 -0
- mcpxray/rules/__init__.py +8 -0
- mcpxray/rules/base.py +67 -0
- mcpxray/rules/builtin/__init__.py +5 -0
- mcpxray/rules/builtin/descriptions.py +103 -0
- mcpxray/rules/builtin/schema.py +115 -0
- mcpxray/rules/builtin/source.py +95 -0
- mcpxray/rules/builtin/supply.py +92 -0
- mcpxray/rules/builtin/transport.py +79 -0
- mcpxray/runtime.py +260 -0
- mcpxray/score.py +71 -0
- mcpxray/source.py +253 -0
- mcpxray/verdict.py +139 -0
- mcpxray_cli-1.0.0.dist-info/METADATA +211 -0
- mcpxray_cli-1.0.0.dist-info/RECORD +33 -0
- mcpxray_cli-1.0.0.dist-info/WHEEL +4 -0
- mcpxray_cli-1.0.0.dist-info/entry_points.txt +7 -0
mcpxray/report/sarif.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""SARIF 2.1.0 report for GitHub code scanning and other SARIF consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from mcpxray import __version__
|
|
9
|
+
from mcpxray.ir import SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from mcpxray.ir import Diagnostic, McpServer
|
|
13
|
+
from mcpxray.score import ScoreResult
|
|
14
|
+
|
|
15
|
+
# SARIF level mapping: error→error, warning→warning, info→note.
|
|
16
|
+
_LEVEL = {
|
|
17
|
+
SEVERITY_ERROR: "error",
|
|
18
|
+
SEVERITY_WARNING: "warning",
|
|
19
|
+
SEVERITY_INFO: "note",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(
|
|
24
|
+
diags: list[Diagnostic],
|
|
25
|
+
doc: McpServer | None = None,
|
|
26
|
+
score_result: ScoreResult | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
rule_index: dict[str, int] = {}
|
|
29
|
+
rules: list[dict] = []
|
|
30
|
+
results: list[dict] = []
|
|
31
|
+
|
|
32
|
+
for d in diags:
|
|
33
|
+
if d.rule_id not in rule_index:
|
|
34
|
+
rule_index[d.rule_id] = len(rules)
|
|
35
|
+
rules.append(
|
|
36
|
+
{
|
|
37
|
+
"id": d.rule_id,
|
|
38
|
+
"defaultConfiguration": {"level": _LEVEL.get(d.severity, "warning")},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
location: dict = {}
|
|
42
|
+
if d.file:
|
|
43
|
+
location = {
|
|
44
|
+
"physicalLocation": {
|
|
45
|
+
"artifactLocation": {"uri": d.file},
|
|
46
|
+
"region": {"startLine": d.line or 1},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
result: dict = {
|
|
50
|
+
"ruleId": d.rule_id,
|
|
51
|
+
"level": _LEVEL.get(d.severity, "warning"),
|
|
52
|
+
"message": {"text": d.message},
|
|
53
|
+
"ruleIndex": rule_index[d.rule_id],
|
|
54
|
+
}
|
|
55
|
+
if location:
|
|
56
|
+
result["locations"] = [location]
|
|
57
|
+
results.append(result)
|
|
58
|
+
|
|
59
|
+
sarif = {
|
|
60
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
61
|
+
"version": "2.1.0",
|
|
62
|
+
"runs": [
|
|
63
|
+
{
|
|
64
|
+
"tool": {
|
|
65
|
+
"driver": {
|
|
66
|
+
"name": "mcpxray",
|
|
67
|
+
"version": __version__,
|
|
68
|
+
"informationUri": "https://github.com/cloudroad-io/mcpxray",
|
|
69
|
+
"rules": rules,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"results": results,
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
return _json.dumps(sarif, indent=2)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Lint rules for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcpxray.rules import builtin # noqa: F401 — registers builtin rules
|
|
6
|
+
from mcpxray.rules.base import Rule, register_rule, rules, run_all
|
|
7
|
+
|
|
8
|
+
__all__ = ["Rule", "register_rule", "rules", "run_all"]
|
mcpxray/rules/base.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Rule API, registry, and runner.
|
|
2
|
+
|
|
3
|
+
A :class:`Rule` inspects a :class:`~mcpxray.ir.McpServer` and yields
|
|
4
|
+
:class:`~mcpxray.ir.Diagnostic` findings. Builtins self-register via
|
|
5
|
+
:func:`register_rule`; external packages declare an entry-point in the
|
|
6
|
+
``mcpxray.rules`` group. ``run_all`` applies every applicable rule, sorts the
|
|
7
|
+
findings by severity (errors first), and mirrors them onto ``doc.diagnostics``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from mcpxray.ir import (
|
|
16
|
+
RISK_MEDIUM,
|
|
17
|
+
SEVERITY_WARNING,
|
|
18
|
+
Diagnostic,
|
|
19
|
+
McpServer,
|
|
20
|
+
severity_rank,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Stable plugin API for rule authors (see CONTRIBUTING.md → "Plugin API
|
|
24
|
+
# stability"). Methods/attributes of ``Rule`` below are part of the contract;
|
|
25
|
+
# ``_RULES`` and any ``_``-prefixed name are internal.
|
|
26
|
+
__all__ = ["Rule", "register_rule", "rules", "run_all"]
|
|
27
|
+
|
|
28
|
+
_RULES: list[type[Rule]] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Rule(ABC):
|
|
32
|
+
"""Base class for all lint rules."""
|
|
33
|
+
|
|
34
|
+
id: str = ""
|
|
35
|
+
severity: str = SEVERITY_WARNING
|
|
36
|
+
risk: str = RISK_MEDIUM # scoring weight
|
|
37
|
+
|
|
38
|
+
def applies(self, doc: McpServer) -> bool: # noqa: ARG002
|
|
39
|
+
"""Override to gate the rule (e.g. source-only vs manifest-only)."""
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def check(self, doc: McpServer) -> Iterable[Diagnostic]:
|
|
44
|
+
"""Yield findings for this rule."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_rule(cls: type[Rule]) -> type[Rule]:
|
|
48
|
+
"""Class decorator: register a :class:`Rule` subclass."""
|
|
49
|
+
_RULES.append(cls)
|
|
50
|
+
return cls
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rules() -> list[type[Rule]]:
|
|
54
|
+
"""All registered rule classes (builtins first, registration order)."""
|
|
55
|
+
return list(_RULES)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_all(doc: McpServer) -> list[Diagnostic]:
|
|
59
|
+
"""Run every applicable rule; return findings sorted by severity (errors first)."""
|
|
60
|
+
diags: list[Diagnostic] = []
|
|
61
|
+
for cls in _RULES:
|
|
62
|
+
rule = cls()
|
|
63
|
+
if rule.applies(doc):
|
|
64
|
+
diags.extend(rule.check(doc))
|
|
65
|
+
diags.sort(key=lambda d: -severity_rank(d.severity))
|
|
66
|
+
doc.diagnostics.extend(diags)
|
|
67
|
+
return diags
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Rules over tool descriptions: tool poisoning (MCP101) and hygiene (MCP107)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
from mcpxray.ir import (
|
|
9
|
+
RISK_CRITICAL,
|
|
10
|
+
RISK_LOW,
|
|
11
|
+
SEVERITY_ERROR,
|
|
12
|
+
SEVERITY_WARNING,
|
|
13
|
+
Diagnostic,
|
|
14
|
+
McpServer,
|
|
15
|
+
)
|
|
16
|
+
from mcpxray.rules.base import Rule, register_rule
|
|
17
|
+
|
|
18
|
+
# Indirect-prompt-injection signatures commonly embedded in poisoned tool
|
|
19
|
+
# descriptions (the channel the model trusts most — see Invariant Labs / OWASP).
|
|
20
|
+
_POISON_PATTERNS = [
|
|
21
|
+
re.compile(p, re.IGNORECASE)
|
|
22
|
+
for p in (
|
|
23
|
+
r"ignore\s+(?:all\s+)?previous\s+(?:instructions?|prompts?|rules?)",
|
|
24
|
+
r"disregard\s+(?:all|the|any|previous|above)",
|
|
25
|
+
r"do\s+not\s+(?:show|reveal|display|mention|report)\s+(?:this|the|your)",
|
|
26
|
+
r"instead\s+of\s+(?:responding|answering|following|showing|executing)",
|
|
27
|
+
r"you\s+are\s+(?:now|actually)\s+(?:a|an)\b",
|
|
28
|
+
r"(?:exfiltrate|upload|send|post|transmit|leak).{0,40}?"
|
|
29
|
+
r"(?:to\s+(?:an?\s+)?(?:url|webhook|http|server|email|attacker)|https?://)",
|
|
30
|
+
r"</?(?:system|secret|hidden|implicit)\s*>",
|
|
31
|
+
r"before\s+answering(?:,|:)\s*(?:read|fetch|include|append)",
|
|
32
|
+
)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_DESC_TOO_LONG = 4096 # bloats the context budget on every request
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hidden_format_chars(text: str) -> list[str]:
|
|
39
|
+
"""Zero-width / bidi-override characters invisible to humans but read by the model."""
|
|
40
|
+
return [ch for ch in text if unicodedata.category(ch) == "Cf"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_rule
|
|
44
|
+
class ToolPoisoning(Rule):
|
|
45
|
+
id = "MCP101"
|
|
46
|
+
severity = SEVERITY_ERROR
|
|
47
|
+
risk = RISK_CRITICAL
|
|
48
|
+
|
|
49
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
50
|
+
for tool in doc.tools:
|
|
51
|
+
if not tool.description:
|
|
52
|
+
continue
|
|
53
|
+
for pattern in _POISON_PATTERNS:
|
|
54
|
+
for m in pattern.finditer(tool.description):
|
|
55
|
+
yield Diagnostic(
|
|
56
|
+
self.id,
|
|
57
|
+
self.severity,
|
|
58
|
+
f"tool '{tool.name}': description contains a possible prompt-injection "
|
|
59
|
+
f"signature ({m.group(0)!r})",
|
|
60
|
+
tool=tool.name,
|
|
61
|
+
file=tool.source_path,
|
|
62
|
+
line=tool.line,
|
|
63
|
+
)
|
|
64
|
+
hidden = _hidden_format_chars(tool.description)
|
|
65
|
+
if hidden:
|
|
66
|
+
yield Diagnostic(
|
|
67
|
+
self.id,
|
|
68
|
+
self.severity,
|
|
69
|
+
f"tool '{tool.name}': description contains hidden format/bidi characters "
|
|
70
|
+
f"({len(hidden)})",
|
|
71
|
+
tool=tool.name,
|
|
72
|
+
file=tool.source_path,
|
|
73
|
+
line=tool.line,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register_rule
|
|
78
|
+
class DescriptionHygiene(Rule):
|
|
79
|
+
id = "MCP107"
|
|
80
|
+
severity = SEVERITY_WARNING
|
|
81
|
+
risk = RISK_LOW
|
|
82
|
+
|
|
83
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
84
|
+
for tool in doc.tools:
|
|
85
|
+
if not tool.description or not tool.description.strip():
|
|
86
|
+
yield Diagnostic(
|
|
87
|
+
self.id,
|
|
88
|
+
self.severity,
|
|
89
|
+
f"tool '{tool.name}': no description; model can't decide when to call it",
|
|
90
|
+
tool=tool.name,
|
|
91
|
+
file=tool.source_path,
|
|
92
|
+
line=tool.line,
|
|
93
|
+
)
|
|
94
|
+
elif len(tool.description) > _DESC_TOO_LONG:
|
|
95
|
+
yield Diagnostic(
|
|
96
|
+
self.id,
|
|
97
|
+
self.severity,
|
|
98
|
+
f"tool '{tool.name}': description is {len(tool.description)} chars "
|
|
99
|
+
f"(>{_DESC_TOO_LONG}); bloats the context budget on every request",
|
|
100
|
+
tool=tool.name,
|
|
101
|
+
file=tool.source_path,
|
|
102
|
+
line=tool.line,
|
|
103
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Schema rules: weak/unvalidatable schemas (MCP104) and strict-client compatibility (MCP106)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, McpServer
|
|
6
|
+
from mcpxray.rules.base import Rule, register_rule
|
|
7
|
+
|
|
8
|
+
# Keywords strict clients (Cursor, ChatGPT tool-calling) handle poorly or reject.
|
|
9
|
+
_STRICT_INCOMPAT_KEYS = ("$ref", "oneOf", "anyOf", "allOf")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register_rule
|
|
13
|
+
class WeakSchema(Rule):
|
|
14
|
+
id = "MCP104"
|
|
15
|
+
severity = SEVERITY_WARNING
|
|
16
|
+
risk = RISK_MEDIUM
|
|
17
|
+
|
|
18
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
19
|
+
for tool in doc.tools:
|
|
20
|
+
schema = tool.input_schema or {}
|
|
21
|
+
props = schema.get("properties") or {}
|
|
22
|
+
required = schema.get("required") or []
|
|
23
|
+
if props and not required:
|
|
24
|
+
yield Diagnostic(
|
|
25
|
+
self.id,
|
|
26
|
+
self.severity,
|
|
27
|
+
f"tool '{tool.name}': schema has no required parameters -- accepts anything",
|
|
28
|
+
tool=tool.name,
|
|
29
|
+
file=tool.source_path,
|
|
30
|
+
line=tool.line,
|
|
31
|
+
)
|
|
32
|
+
for pname, pschema in props.items():
|
|
33
|
+
if isinstance(pschema, dict) and not pschema:
|
|
34
|
+
yield Diagnostic(
|
|
35
|
+
self.id,
|
|
36
|
+
self.severity,
|
|
37
|
+
f"tool '{tool.name}': parameter '{pname}' has no type -- unvalidatable",
|
|
38
|
+
tool=tool.name,
|
|
39
|
+
file=tool.source_path,
|
|
40
|
+
line=tool.line,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@register_rule
|
|
45
|
+
class SchemaCompatibility(Rule):
|
|
46
|
+
id = "MCP106"
|
|
47
|
+
severity = SEVERITY_WARNING
|
|
48
|
+
risk = RISK_MEDIUM
|
|
49
|
+
|
|
50
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
51
|
+
for tool in doc.tools:
|
|
52
|
+
schema = tool.input_schema or {}
|
|
53
|
+
if not isinstance(schema, dict) or not schema:
|
|
54
|
+
continue
|
|
55
|
+
bad = next((k for k in _STRICT_INCOMPAT_KEYS if k in schema), None)
|
|
56
|
+
if bad:
|
|
57
|
+
yield Diagnostic(
|
|
58
|
+
self.id,
|
|
59
|
+
self.severity,
|
|
60
|
+
f"tool '{tool.name}': schema uses '{bad}', which strict clients "
|
|
61
|
+
f"(Cursor/ChatGPT) may reject",
|
|
62
|
+
tool=tool.name,
|
|
63
|
+
file=tool.source_path,
|
|
64
|
+
line=tool.line,
|
|
65
|
+
)
|
|
66
|
+
elif "type" not in schema and "properties" not in schema:
|
|
67
|
+
yield Diagnostic(
|
|
68
|
+
self.id,
|
|
69
|
+
self.severity,
|
|
70
|
+
f"tool '{tool.name}': schema declares no 'type'",
|
|
71
|
+
tool=tool.name,
|
|
72
|
+
file=tool.source_path,
|
|
73
|
+
line=tool.line,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register_rule
|
|
78
|
+
class SchemaImplDrift(Rule):
|
|
79
|
+
"""MCP105 — a tool's declared inputSchema disagrees with its handler.
|
|
80
|
+
|
|
81
|
+
Caught when the handler destructures its arguments (so we know what it reads):
|
|
82
|
+
a parameter the schema declares but the handler ignores (clients must send
|
|
83
|
+
data the tool never uses), or one the handler reads but the schema doesn't
|
|
84
|
+
declare (unvalidated input bypasses the schema). Skipped when
|
|
85
|
+
:attr:`Tool.handler_params` is ``None`` — i.e. a bare ``args`` handler,
|
|
86
|
+
Python (where the schema *is* the signature), or a manifest tool.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
id = "MCP105"
|
|
90
|
+
severity = SEVERITY_WARNING
|
|
91
|
+
risk = RISK_MEDIUM
|
|
92
|
+
|
|
93
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
94
|
+
for tool in doc.tools:
|
|
95
|
+
if tool.handler_params is None:
|
|
96
|
+
continue # can't compare — undeterminable handler signature
|
|
97
|
+
declared = set((tool.input_schema or {}).get("properties") or {})
|
|
98
|
+
used = set(tool.handler_params)
|
|
99
|
+
ignored = sorted(declared - used) # schema declares, handler doesn't read
|
|
100
|
+
unvalidated = sorted(used - declared) # handler reads, schema doesn't validate
|
|
101
|
+
if not (ignored or unvalidated):
|
|
102
|
+
continue
|
|
103
|
+
parts: list[str] = []
|
|
104
|
+
if ignored:
|
|
105
|
+
parts.append(f"schema declares {ignored} but the handler doesn't read them")
|
|
106
|
+
if unvalidated:
|
|
107
|
+
parts.append(f"handler reads {unvalidated} but the schema doesn't declare them")
|
|
108
|
+
yield Diagnostic(
|
|
109
|
+
self.id,
|
|
110
|
+
self.severity,
|
|
111
|
+
f"tool '{tool.name}': " + "; ".join(parts),
|
|
112
|
+
tool=tool.name,
|
|
113
|
+
file=tool.source_path,
|
|
114
|
+
line=tool.line,
|
|
115
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Source-scanning rules: secret exposure (MCP102) and dangerous capabilities (MCP103)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcpxray.ir import RISK_CRITICAL, RISK_HIGH, SEVERITY_ERROR, Diagnostic, McpServer
|
|
8
|
+
from mcpxray.rules.base import Rule, register_rule
|
|
9
|
+
|
|
10
|
+
# (pattern, human label). Tuned for precision — high-confidence secret shapes.
|
|
11
|
+
_SECRET_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
12
|
+
(
|
|
13
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----"),
|
|
14
|
+
"private key",
|
|
15
|
+
),
|
|
16
|
+
(re.compile(r"\bsk-[A-Za-z0-9]{20,}"), "OpenAI-style API key"),
|
|
17
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id"),
|
|
18
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "GitHub token"),
|
|
19
|
+
(re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"), "GitLab token"),
|
|
20
|
+
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "Slack token"),
|
|
21
|
+
(re.compile(r"\baiza[0-9A-Za-z_-]{35,}\b"), "Google API key"),
|
|
22
|
+
(
|
|
23
|
+
re.compile(
|
|
24
|
+
r"(?i)(?:api[_-]?key|secret|token|password|passwd)\s*[=:]\s*"
|
|
25
|
+
r"['\"]([A-Za-z0-9_\-+/=]{12,})['\"]"
|
|
26
|
+
),
|
|
27
|
+
"hardcoded credential",
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Dangerous primitives an MCP tool should rarely invoke directly.
|
|
32
|
+
_RCE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
33
|
+
(re.compile(r"\bos\.system\s*\("), "os.system (arbitrary shell command)"),
|
|
34
|
+
(re.compile(r"\bos\.popen\s*\("), "os.popen (arbitrary shell command)"),
|
|
35
|
+
(re.compile(r"(?<![\w.])eval\s*\("), "eval (arbitrary code execution)"),
|
|
36
|
+
(re.compile(r"(?<![\w.])exec\s*\("), "exec (arbitrary code execution)"),
|
|
37
|
+
(re.compile(r"\bpickle\.loads?\s*\("), "pickle deserialization (RCE risk)"),
|
|
38
|
+
(re.compile(r"\bmarshal\.loads?\s*\("), "marshal deserialization (RCE risk)"),
|
|
39
|
+
(
|
|
40
|
+
re.compile(
|
|
41
|
+
r"subprocess\.(?:run|Popen|call|check_output|check_call)"
|
|
42
|
+
r"\s*\([^)]*?shell\s*=\s*True",
|
|
43
|
+
re.S,
|
|
44
|
+
),
|
|
45
|
+
"subprocess with shell=True (shell injection risk)",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _line_of(text: str, offset: int) -> int:
|
|
51
|
+
return text.count("\n", 0, offset) + 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register_rule
|
|
55
|
+
class SecretExposure(Rule):
|
|
56
|
+
id = "MCP102"
|
|
57
|
+
severity = SEVERITY_ERROR
|
|
58
|
+
risk = RISK_CRITICAL
|
|
59
|
+
|
|
60
|
+
def applies(self, doc: McpServer) -> bool:
|
|
61
|
+
return bool(doc.sources)
|
|
62
|
+
|
|
63
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
64
|
+
for path, text in doc.sources.items():
|
|
65
|
+
for pattern, label in _SECRET_PATTERNS:
|
|
66
|
+
for m in pattern.finditer(text):
|
|
67
|
+
yield Diagnostic(
|
|
68
|
+
self.id,
|
|
69
|
+
self.severity,
|
|
70
|
+
f"possible {label} exposed in source",
|
|
71
|
+
file=path,
|
|
72
|
+
line=_line_of(text, m.start()),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@register_rule
|
|
77
|
+
class DangerousCapabilities(Rule):
|
|
78
|
+
id = "MCP103"
|
|
79
|
+
severity = SEVERITY_ERROR
|
|
80
|
+
risk = RISK_HIGH
|
|
81
|
+
|
|
82
|
+
def applies(self, doc: McpServer) -> bool:
|
|
83
|
+
return bool(doc.sources)
|
|
84
|
+
|
|
85
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
86
|
+
for path, text in doc.sources.items():
|
|
87
|
+
for pattern, label in _RCE_PATTERNS:
|
|
88
|
+
for m in pattern.finditer(text):
|
|
89
|
+
yield Diagnostic(
|
|
90
|
+
self.id,
|
|
91
|
+
self.severity,
|
|
92
|
+
f"dangerous capability: {label}",
|
|
93
|
+
file=path,
|
|
94
|
+
line=_line_of(text, m.start()),
|
|
95
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Supply-chain rule: unpinned dependencies without a lockfile (MCP108)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcpxray.fix import exact_pin
|
|
8
|
+
from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, Fix, McpServer, TextEdit
|
|
9
|
+
from mcpxray.rules.base import Rule, register_rule
|
|
10
|
+
|
|
11
|
+
# npm: an exact bare version is reproducible; carets/tilde/ranges/wildcards/tags
|
|
12
|
+
# drift. Pre-release/build suffixes (``1.2.3-beta.1``) still pin exactly. A bare
|
|
13
|
+
# package name (``flask``) or a pip range (``>=2``) deliberately fails to match.
|
|
14
|
+
_NPM_PIN_RE = re.compile(r"^[v=]?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_pinned(spec: str) -> bool:
|
|
18
|
+
"""True if ``spec`` makes an install reproducible.
|
|
19
|
+
|
|
20
|
+
Covers both ecosystems an MCP server can declare:
|
|
21
|
+
* **pip** — exact ``==1.2.3`` or compatible-release ``~=1.2``.
|
|
22
|
+
* **npm** — an exact bare version ``1.2.3`` / ``v1.2.3`` / ``=1.2.3``.
|
|
23
|
+
Ranges and floating tags (``>=``, ``^``, ``~``, ``*``, ``latest``, ``1.x``)
|
|
24
|
+
are *not* pinned — they resolve differently between installs.
|
|
25
|
+
"""
|
|
26
|
+
spec = spec.strip()
|
|
27
|
+
if not spec:
|
|
28
|
+
return False
|
|
29
|
+
if "==" in spec or spec.startswith("~="): # pip
|
|
30
|
+
return True
|
|
31
|
+
return bool(_NPM_PIN_RE.match(spec)) # npm exact version
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pin_edits(
|
|
35
|
+
dependencies: dict[str, str], unpinned: list[str], dep_file: str, *, pip: bool
|
|
36
|
+
) -> list[TextEdit]:
|
|
37
|
+
"""Literal ``TextEdit``\\s pinning each unpinned dep to its concrete floor.
|
|
38
|
+
|
|
39
|
+
Skips specs with no resolvable floor (``*``/``latest``/bare) or with extras
|
|
40
|
+
/ env markers (``name[extra]`` / ``name ; python>'3'``) — rewriting those
|
|
41
|
+
textually is unsafe, so they're left for manual pinning. Each edit anchors on
|
|
42
|
+
the dep name so the fix engine's uniqueness check lands on the right site.
|
|
43
|
+
"""
|
|
44
|
+
edits: list[TextEdit] = []
|
|
45
|
+
for name in unpinned:
|
|
46
|
+
spec = dependencies[name]
|
|
47
|
+
if "[" in spec or ";" in spec:
|
|
48
|
+
continue
|
|
49
|
+
pinned = exact_pin(spec, pip=pip)
|
|
50
|
+
if pinned is None:
|
|
51
|
+
continue
|
|
52
|
+
if pip: # pyproject array element: ``"name<spec>"``
|
|
53
|
+
edits.append(TextEdit(old=f"{name}{spec}", new=f"{name}{pinned}"))
|
|
54
|
+
else: # package.json pair: ``"name": "<spec>"``
|
|
55
|
+
edits.append(TextEdit(old=f'"{name}": "{spec}"', new=f'"{name}": "{pinned}"'))
|
|
56
|
+
return edits
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register_rule
|
|
60
|
+
class UnpinnedDependencies(Rule):
|
|
61
|
+
id = "MCP108"
|
|
62
|
+
severity = SEVERITY_WARNING
|
|
63
|
+
risk = RISK_MEDIUM
|
|
64
|
+
|
|
65
|
+
def applies(self, doc: McpServer) -> bool:
|
|
66
|
+
# A lockfile makes installs reproducible regardless of pin style.
|
|
67
|
+
return bool(doc.dependencies) and not doc.lockfiles
|
|
68
|
+
|
|
69
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
70
|
+
unpinned = sorted(n for n, spec in doc.dependencies.items() if not _is_pinned(spec))
|
|
71
|
+
if not unpinned:
|
|
72
|
+
return
|
|
73
|
+
shown = ", ".join(unpinned[:5])
|
|
74
|
+
more = f" (+{len(unpinned) - 5} more)" if len(unpinned) > 5 else ""
|
|
75
|
+
diag = Diagnostic(
|
|
76
|
+
self.id,
|
|
77
|
+
self.severity,
|
|
78
|
+
f"{len(unpinned)} unpinned dependencies and no lockfile: {shown}{more}",
|
|
79
|
+
file=doc.meta.path,
|
|
80
|
+
)
|
|
81
|
+
dep_file = doc.dep_file
|
|
82
|
+
if dep_file:
|
|
83
|
+
edits = _pin_edits(
|
|
84
|
+
doc.dependencies, unpinned, dep_file, pip=not dep_file.endswith("package.json")
|
|
85
|
+
)
|
|
86
|
+
if edits:
|
|
87
|
+
diag.fix = Fix(
|
|
88
|
+
description="pin unpinned dependencies to their declared floor version",
|
|
89
|
+
file=dep_file,
|
|
90
|
+
edits=edits,
|
|
91
|
+
)
|
|
92
|
+
yield diag
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Transport rule: insecure network transport without TLS / auth (MCP109)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcpxray.ir import RISK_MEDIUM, SEVERITY_WARNING, Diagnostic, McpServer
|
|
8
|
+
from mcpxray.rules.base import Rule, register_rule
|
|
9
|
+
|
|
10
|
+
# Construction of an HTTP/SSE-based MCP transport (stdio is local and safe).
|
|
11
|
+
# Covers the TS SDK (StreamableHTTP/SSE transports, ``transport: "http"|"sse"``)
|
|
12
|
+
# and Python FastMCP (``.run(transport="http"|"sse"|"streamable_http")``).
|
|
13
|
+
_HTTP_TRANSPORT_RE = re.compile(
|
|
14
|
+
r"StreamableHTTPServerTransport"
|
|
15
|
+
r"|SSEServerTransport"
|
|
16
|
+
r"|transport\s*[:=]\s*['\"](?:streamable_http|http|sse)['\"]"
|
|
17
|
+
r"|\.(?:connect|serve|start)\s*\(\s*['\"](?:http|sse)['\"]",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# A non-loopback plain-HTTP endpoint — transport without TLS.
|
|
22
|
+
_INSECURE_URL_RE = re.compile(r"http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)")
|
|
23
|
+
|
|
24
|
+
# Signals that transport is encrypted or authenticated (suppress MCP109).
|
|
25
|
+
_TLS_RE = re.compile(
|
|
26
|
+
r"https://|\bssl\b|\btls\b|createSecureContext|SecureServer|SSLContext",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
_AUTH_RE = re.compile(
|
|
30
|
+
r"authenticate|authorization|bearer|api[_-]?key|authmiddleware|verifytoken"
|
|
31
|
+
r"|requires_auth|httpbasic|oauth|verify_token|@authenticated",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@register_rule
|
|
37
|
+
class InsecureTransport(Rule):
|
|
38
|
+
"""MCP109 — an HTTP/SSE transport exposed without TLS or authentication.
|
|
39
|
+
|
|
40
|
+
Only inspected when the server actually constructs a network transport
|
|
41
|
+
(``stdio`` is local, so it's out of scope). Two independent warnings:
|
|
42
|
+
|
|
43
|
+
* a non-loopback ``http://`` endpoint and no TLS anywhere → *no encryption*;
|
|
44
|
+
* no auth primitive anywhere → *unauthenticated* (any reachable client can
|
|
45
|
+
invoke the tools).
|
|
46
|
+
|
|
47
|
+
Both are warnings: a reverse proxy may legitimately provide TLS/auth, so the
|
|
48
|
+
finding is surfaced for review rather than treated as a hard error.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id = "MCP109"
|
|
52
|
+
severity = SEVERITY_WARNING
|
|
53
|
+
risk = RISK_MEDIUM
|
|
54
|
+
|
|
55
|
+
def applies(self, doc: McpServer) -> bool:
|
|
56
|
+
return bool(doc.sources)
|
|
57
|
+
|
|
58
|
+
def check(self, doc: McpServer): # type: ignore[override]
|
|
59
|
+
blob = "\n".join(doc.sources.values())
|
|
60
|
+
if not _HTTP_TRANSPORT_RE.search(blob):
|
|
61
|
+
return # stdio / no transport — nothing to flag
|
|
62
|
+
has_tls = bool(_TLS_RE.search(blob))
|
|
63
|
+
has_auth = bool(_AUTH_RE.search(blob))
|
|
64
|
+
if _INSECURE_URL_RE.search(blob) and not has_tls:
|
|
65
|
+
yield Diagnostic(
|
|
66
|
+
self.id,
|
|
67
|
+
self.severity,
|
|
68
|
+
"HTTP transport endpoint without TLS — use https so tool traffic "
|
|
69
|
+
"isn't sent in the clear",
|
|
70
|
+
file=doc.meta.path,
|
|
71
|
+
)
|
|
72
|
+
if not has_auth:
|
|
73
|
+
yield Diagnostic(
|
|
74
|
+
self.id,
|
|
75
|
+
self.severity,
|
|
76
|
+
"network transport exposed without authentication — any reachable "
|
|
77
|
+
"client can invoke these tools",
|
|
78
|
+
file=doc.meta.path,
|
|
79
|
+
)
|