mcp-runtime-audit 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.
- mcp_audit/__init__.py +4 -0
- mcp_audit/cli.py +173 -0
- mcp_audit/cve_db.py +215 -0
- mcp_audit/scanners.py +502 -0
- mcp_runtime_audit-0.1.0.dist-info/METADATA +173 -0
- mcp_runtime_audit-0.1.0.dist-info/RECORD +10 -0
- mcp_runtime_audit-0.1.0.dist-info/WHEEL +5 -0
- mcp_runtime_audit-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_runtime_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_runtime_audit-0.1.0.dist-info/top_level.txt +1 -0
mcp_audit/__init__.py
ADDED
mcp_audit/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""mcp-audit CLI: scan local MCP configs and optionally fix them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .scanners import (
|
|
11
|
+
agent_guard_link,
|
|
12
|
+
apply_fixes,
|
|
13
|
+
discover_configs,
|
|
14
|
+
load_config,
|
|
15
|
+
plan_fixes,
|
|
16
|
+
run_all_scans,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_FINDINGS = 1
|
|
21
|
+
EXIT_ERROR = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_args(argv):
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="mcp-audit",
|
|
27
|
+
description="Local runtime security scanner for MCP servers.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
scan = sub.add_parser("scan", help="scan MCP configs for security issues")
|
|
33
|
+
scan.add_argument("--config", action="append", dest="configs",
|
|
34
|
+
metavar="PATH", default=[],
|
|
35
|
+
help="config file to scan (repeatable; defaults to "
|
|
36
|
+
"~/.claude.json and other known locations)")
|
|
37
|
+
scan.add_argument("--format", choices=["text", "json"], default="text",
|
|
38
|
+
help="output format")
|
|
39
|
+
scan.add_argument("--probe", action="store_true",
|
|
40
|
+
help="send one unauthenticated GET to each http/sse "
|
|
41
|
+
"endpoint to confirm reachability")
|
|
42
|
+
scan.add_argument("--timeout", type=float, default=4.0,
|
|
43
|
+
help="probe timeout in seconds (default 4.0)")
|
|
44
|
+
scan.add_argument("--link-agent-guard", action="store_true",
|
|
45
|
+
help="write critical servers into agent-guard's "
|
|
46
|
+
"blocklist when it is installed")
|
|
47
|
+
|
|
48
|
+
fix = sub.add_parser("fix", help="print or apply one-click fixes")
|
|
49
|
+
fix.add_argument("--config", action="append", dest="configs",
|
|
50
|
+
metavar="PATH", default=[],
|
|
51
|
+
help="config file to fix (repeatable)")
|
|
52
|
+
fix.add_argument("--apply", action="store_true",
|
|
53
|
+
help="write the fixes to the config file (backs up "
|
|
54
|
+
"first); without it only prints the plan")
|
|
55
|
+
return parser.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_all(paths):
|
|
59
|
+
servers, errors = [], []
|
|
60
|
+
for path in paths:
|
|
61
|
+
svcs, err = load_config(path)
|
|
62
|
+
if err:
|
|
63
|
+
errors.append(err)
|
|
64
|
+
servers.extend(svcs)
|
|
65
|
+
return servers, errors
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_text(findings, errors, n_servers, n_configs):
|
|
69
|
+
lines = []
|
|
70
|
+
if errors:
|
|
71
|
+
for e in errors:
|
|
72
|
+
lines.append("error: %s" % e)
|
|
73
|
+
if not findings:
|
|
74
|
+
lines.append("mcp-audit: %d server(s) in %d config(s): no findings"
|
|
75
|
+
% (n_servers, n_configs))
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
sev_max = findings[0].severity
|
|
78
|
+
lines.append("mcp-audit: %d finding(s) across %d server(s) "
|
|
79
|
+
"(worst: %s):" % (len(findings), n_servers, sev_max))
|
|
80
|
+
for f in findings:
|
|
81
|
+
cve = " [%s]" % f.cve_id if f.cve_id else ""
|
|
82
|
+
lines.append("[%s]%s %s (%s): %s"
|
|
83
|
+
% (f.severity.upper(), cve, f.rule_id, f.server, f.evidence))
|
|
84
|
+
lines.append(" fix: %s" % f.fix)
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_scan(args):
|
|
89
|
+
paths = discover_configs(args.configs)
|
|
90
|
+
if not paths:
|
|
91
|
+
msg = {"findings": [], "errors": ["no config files found"],
|
|
92
|
+
"summary": {"servers": 0, "configs": 0}}
|
|
93
|
+
if args.format == "json":
|
|
94
|
+
print(json.dumps(msg, indent=2))
|
|
95
|
+
else:
|
|
96
|
+
print("mcp-audit: no MCP config files found "
|
|
97
|
+
"(checked ~/.claude.json, ~/.codex/config.json, "
|
|
98
|
+
"~/.config/mcp/servers.json, ~/.mcp.json); "
|
|
99
|
+
"pass --config PATH to scan a specific file")
|
|
100
|
+
return EXIT_ERROR
|
|
101
|
+
servers, errors = _load_all(paths)
|
|
102
|
+
findings = run_all_scans(servers, probe=args.probe, timeout=args.timeout)
|
|
103
|
+
if args.link_agent_guard and findings:
|
|
104
|
+
linked, msg = agent_guard_link(findings)
|
|
105
|
+
if args.format == "json":
|
|
106
|
+
pass # message folded into output below
|
|
107
|
+
else:
|
|
108
|
+
print(msg)
|
|
109
|
+
if args.format == "json":
|
|
110
|
+
print(json.dumps({
|
|
111
|
+
"findings": [f.to_dict() for f in findings],
|
|
112
|
+
"errors": errors,
|
|
113
|
+
"summary": {
|
|
114
|
+
"servers": len(servers),
|
|
115
|
+
"configs": len(paths),
|
|
116
|
+
"findings": len(findings),
|
|
117
|
+
"worst": findings[0].severity if findings else None,
|
|
118
|
+
},
|
|
119
|
+
}, indent=2))
|
|
120
|
+
else:
|
|
121
|
+
print(_format_text(findings, errors, len(servers), len(paths)))
|
|
122
|
+
if errors and not servers:
|
|
123
|
+
return EXIT_ERROR
|
|
124
|
+
return EXIT_FINDINGS if findings else EXIT_OK
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_fix(args):
|
|
128
|
+
paths = discover_configs(args.configs)
|
|
129
|
+
if not paths:
|
|
130
|
+
print("mcp-audit: no MCP config files found; pass --config PATH")
|
|
131
|
+
return EXIT_ERROR
|
|
132
|
+
rc = EXIT_OK
|
|
133
|
+
for path in paths:
|
|
134
|
+
servers, errors = _load_all([path])
|
|
135
|
+
findings = run_all_scans(servers)
|
|
136
|
+
plan, missing = plan_fixes(path, findings) if not errors else ([], [])
|
|
137
|
+
if args.apply:
|
|
138
|
+
if errors:
|
|
139
|
+
print("%s: error: %s" % (path, errors[0]))
|
|
140
|
+
rc = EXIT_ERROR
|
|
141
|
+
continue
|
|
142
|
+
if not plan:
|
|
143
|
+
print("%s: nothing fixable" % path)
|
|
144
|
+
continue
|
|
145
|
+
backup, applied = apply_fixes(path, findings)
|
|
146
|
+
print("%s: applied %d fix(es); backup at %s"
|
|
147
|
+
% (path, applied, backup))
|
|
148
|
+
else:
|
|
149
|
+
print("%s:" % path)
|
|
150
|
+
if not plan and not missing:
|
|
151
|
+
print(" nothing fixable")
|
|
152
|
+
for item in plan:
|
|
153
|
+
print(" - [%s] %s: %s" % (
|
|
154
|
+
item["rule_id"], item["server"], item["action"]))
|
|
155
|
+
for name in missing:
|
|
156
|
+
print(" - %s: server not found in config (skipped)" % name)
|
|
157
|
+
if plan:
|
|
158
|
+
print(" re-run with --apply to write these fixes "
|
|
159
|
+
"(config is backed up first)")
|
|
160
|
+
return rc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main(argv=None):
|
|
164
|
+
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
165
|
+
if args.command == "scan":
|
|
166
|
+
return cmd_scan(args)
|
|
167
|
+
if args.command == "fix":
|
|
168
|
+
return cmd_fix(args)
|
|
169
|
+
return EXIT_ERROR
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
sys.exit(main())
|
mcp_audit/cve_db.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Bundled known-CVE table for MCP servers and MCP-adjacent tooling.
|
|
2
|
+
|
|
3
|
+
This is a best-effort advisory snapshot (bundled 2026-10). Affected
|
|
4
|
+
version ranges come from public advisories; re-check NVD / vendor
|
|
5
|
+
releases before using these for compliance reporting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
CVE_DB = [
|
|
11
|
+
{
|
|
12
|
+
"id": "CVE-2025-49596",
|
|
13
|
+
"title": "MCP Inspector proxy unauthenticated RCE",
|
|
14
|
+
"cvss": 9.4,
|
|
15
|
+
"severity": "critical",
|
|
16
|
+
"products": ["@modelcontextprotocol/inspector", "mcp-inspector"],
|
|
17
|
+
"affected": "<0.14.1",
|
|
18
|
+
"fixed_in": "0.14.1",
|
|
19
|
+
"description": (
|
|
20
|
+
"The MCP Inspector proxy (default port 6277) accepted an "
|
|
21
|
+
"unauthenticated request to /sse with transportType=stdio and "
|
|
22
|
+
"arbitrary command/args, spawning the command as a local "
|
|
23
|
+
"subprocess. A malicious web page could drive it cross-origin "
|
|
24
|
+
"(0.0.0.0-day / DNS rebinding), so visiting a page was enough "
|
|
25
|
+
"to execute code on the developer's machine."
|
|
26
|
+
),
|
|
27
|
+
"references": [
|
|
28
|
+
"https://nvd.nist.gov/vuln/detail/CVE-2025-49596",
|
|
29
|
+
"https://github.com/modelcontextprotocol/inspector/security/advisories/GHSA-7f8r-222p-6f5g",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "CVE-2026-76404",
|
|
34
|
+
"title": "Splunk MCP Server unsafe deserialization RCE",
|
|
35
|
+
"cvss": 9.1,
|
|
36
|
+
"severity": "critical",
|
|
37
|
+
"products": ["splunk-mcp-server", "@splunk/mcp-server"],
|
|
38
|
+
"affected": "<1.2.1",
|
|
39
|
+
"fixed_in": "1.2.1",
|
|
40
|
+
"description": (
|
|
41
|
+
"The Splunk MCP Server app's credential-management component "
|
|
42
|
+
"deserialized stored data without type validation (CWE-502). "
|
|
43
|
+
"A user holding the Splunk admin role could execute arbitrary "
|
|
44
|
+
"OS commands on the host. Splunk advisory SVD-2026-0808."
|
|
45
|
+
),
|
|
46
|
+
"references": [
|
|
47
|
+
"https://nvd.nist.gov/vuln/detail/CVE-2026-76404",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "CVE-2026-58201",
|
|
52
|
+
"title": "Lokka Microsoft 365 MCP Server SSRF",
|
|
53
|
+
"cvss": None,
|
|
54
|
+
"severity": "high",
|
|
55
|
+
"products": ["lokka", "lokka-microsoft"],
|
|
56
|
+
"affected": "<2.1.2",
|
|
57
|
+
"fixed_in": "2.1.2",
|
|
58
|
+
"description": (
|
|
59
|
+
"Lokka's Lokka-Microsoft tool built Azure Resource Manager "
|
|
60
|
+
"request URLs by concatenating a user-supplied path onto the "
|
|
61
|
+
"management.azure.com base string (CWE-918). A crafted path "
|
|
62
|
+
"could redirect a valid ARM Bearer token to an "
|
|
63
|
+
"attacker-controlled host."
|
|
64
|
+
),
|
|
65
|
+
"references": [
|
|
66
|
+
"https://nvd.nist.gov/vuln/detail/CVE-2026-58201",
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "CVE-2026-105697",
|
|
71
|
+
"title": "Langflow MCP stdio transport RCE (bash -c wrapper)",
|
|
72
|
+
"cvss": 9.9,
|
|
73
|
+
"severity": "critical",
|
|
74
|
+
"products": ["langflow", "langflow-base", "lfx"],
|
|
75
|
+
"affected": "<1.10.3",
|
|
76
|
+
"fixed_in": "1.10.3",
|
|
77
|
+
"description": (
|
|
78
|
+
"Before 1.10.3 the MCP stdio transport launched whatever "
|
|
79
|
+
"command/args a user put in an MCP server configuration with "
|
|
80
|
+
"no allowlist, wrapped in `bash -c \"exec {command} ...\"` "
|
|
81
|
+
"(CWE-78). With the default LANGFLOW_AUTO_LOGIN=true, an "
|
|
82
|
+
"unauthenticated attacker could add a malicious MCP server and "
|
|
83
|
+
"get remote code execution on the Langflow host."
|
|
84
|
+
),
|
|
85
|
+
"references": [
|
|
86
|
+
"https://nvd.nist.gov/vuln/detail/CVE-2026-105697",
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "CVE-2026-89039",
|
|
91
|
+
"title": "MCP server RCE (advisory, see NVD for product mapping)",
|
|
92
|
+
"cvss": None,
|
|
93
|
+
"severity": "high",
|
|
94
|
+
"products": [],
|
|
95
|
+
"affected": None,
|
|
96
|
+
"fixed_in": None,
|
|
97
|
+
"description": (
|
|
98
|
+
"Known CVE in the MCP ecosystem. Product/version mapping not "
|
|
99
|
+
"bundled in this snapshot; check the NVD entry and vendor "
|
|
100
|
+
"advisories for affected versions."
|
|
101
|
+
),
|
|
102
|
+
"references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-89039"],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "CVE-2026-105793",
|
|
106
|
+
"title": "MCP server vulnerability (advisory, see NVD for product mapping)",
|
|
107
|
+
"cvss": None,
|
|
108
|
+
"severity": "high",
|
|
109
|
+
"products": [],
|
|
110
|
+
"affected": None,
|
|
111
|
+
"fixed_in": None,
|
|
112
|
+
"description": (
|
|
113
|
+
"Known CVE in the MCP ecosystem. Product/version mapping not "
|
|
114
|
+
"bundled in this snapshot; check the NVD entry and vendor "
|
|
115
|
+
"advisories for affected versions."
|
|
116
|
+
),
|
|
117
|
+
"references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-105793"],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "CVE-2026-94486",
|
|
121
|
+
"title": "MCP server vulnerability (advisory, see NVD for product mapping)",
|
|
122
|
+
"cvss": None,
|
|
123
|
+
"severity": "high",
|
|
124
|
+
"products": [],
|
|
125
|
+
"affected": None,
|
|
126
|
+
"fixed_in": None,
|
|
127
|
+
"description": (
|
|
128
|
+
"Known CVE in the MCP ecosystem. Product/version mapping not "
|
|
129
|
+
"bundled in this snapshot; check the NVD entry and vendor "
|
|
130
|
+
"advisories for affected versions."
|
|
131
|
+
),
|
|
132
|
+
"references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-94486"],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "CVE-2026-19807",
|
|
136
|
+
"title": "MCP server vulnerability (advisory, see NVD for product mapping)",
|
|
137
|
+
"cvss": None,
|
|
138
|
+
"severity": "high",
|
|
139
|
+
"products": [],
|
|
140
|
+
"affected": None,
|
|
141
|
+
"fixed_in": None,
|
|
142
|
+
"description": (
|
|
143
|
+
"Known CVE in the MCP ecosystem. Product/version mapping not "
|
|
144
|
+
"bundled in this snapshot; check the NVD entry and vendor "
|
|
145
|
+
"advisories for affected versions."
|
|
146
|
+
),
|
|
147
|
+
"references": ["https://nvd.nist.gov/vuln/detail/CVE-2026-19807"],
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_cve(cve_id: str):
|
|
153
|
+
"""Return the CVE entry for *cve_id*, or None."""
|
|
154
|
+
for entry in CVE_DB:
|
|
155
|
+
if entry["id"] == cve_id:
|
|
156
|
+
return entry
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _version_tuple(version: str):
|
|
161
|
+
"""Convert a version string to a comparable tuple of ints.
|
|
162
|
+
|
|
163
|
+
Non-numeric parts are dropped, so "1.10.3a" -> (1, 10, 3).
|
|
164
|
+
"""
|
|
165
|
+
parts = []
|
|
166
|
+
for chunk in str(version).split("."):
|
|
167
|
+
digits = "".join(c for c in chunk if c.isdigit())
|
|
168
|
+
if digits:
|
|
169
|
+
parts.append(int(digits))
|
|
170
|
+
else:
|
|
171
|
+
parts.append(0)
|
|
172
|
+
return tuple(parts)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def version_matches_affected(version: str, affected: str) -> bool:
|
|
176
|
+
"""Evaluate a constraint like '<0.14.1' against a version string."""
|
|
177
|
+
for op in ("<=", ">=", "==", "<", ">"):
|
|
178
|
+
if affected.startswith(op):
|
|
179
|
+
bound = affected[len(op):].strip()
|
|
180
|
+
v, b = _version_tuple(version), _version_tuple(bound)
|
|
181
|
+
# Pad shorter tuple so (1, 2) compares sanely with (1, 2, 1).
|
|
182
|
+
width = max(len(v), len(b))
|
|
183
|
+
v += (0,) * (width - len(v))
|
|
184
|
+
b += (0,) * (width - len(b))
|
|
185
|
+
if op == "<":
|
|
186
|
+
return v < b
|
|
187
|
+
if op == "<=":
|
|
188
|
+
return v <= b
|
|
189
|
+
if op == ">":
|
|
190
|
+
return v > b
|
|
191
|
+
if op == ">=":
|
|
192
|
+
return v >= b
|
|
193
|
+
return v == b
|
|
194
|
+
raise ValueError("unsupported constraint: %r" % affected)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def affected_cves(product: str, version: str):
|
|
198
|
+
"""Return CVE entries whose product list contains *product* and whose
|
|
199
|
+
version range covers *version*. Matching is case-insensitive."""
|
|
200
|
+
hits = []
|
|
201
|
+
if not product or not version:
|
|
202
|
+
return hits
|
|
203
|
+
name = product.strip().lower()
|
|
204
|
+
for entry in CVE_DB:
|
|
205
|
+
if entry["affected"] is None:
|
|
206
|
+
continue
|
|
207
|
+
aliases = [p.lower() for p in entry.get("products", [])]
|
|
208
|
+
if name not in aliases:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
if version_matches_affected(version, entry["affected"]):
|
|
212
|
+
hits.append(entry)
|
|
213
|
+
except (ValueError, TypeError):
|
|
214
|
+
continue
|
|
215
|
+
return hits
|
mcp_audit/scanners.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Config discovery, parsing, and security scanners for mcp-audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from .cve_db import affected_cves
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------- config I/O
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG_PATHS = [
|
|
20
|
+
os.path.expanduser("~/.claude.json"),
|
|
21
|
+
os.path.expanduser("~/.codex/config.json"),
|
|
22
|
+
os.path.expanduser("~/.config/mcp/servers.json"),
|
|
23
|
+
os.path.expanduser("~/.mcp.json"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class McpServer:
|
|
29
|
+
"""One server entry parsed from a config file."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
transport: str # "stdio" | "sse" | "http" | "unknown"
|
|
33
|
+
command: str = ""
|
|
34
|
+
args: list = field(default_factory=list)
|
|
35
|
+
env: dict = field(default_factory=dict)
|
|
36
|
+
url: str = ""
|
|
37
|
+
headers: dict = field(default_factory=dict)
|
|
38
|
+
version: str = ""
|
|
39
|
+
package: str = ""
|
|
40
|
+
source_file: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Finding:
|
|
45
|
+
rule_id: str
|
|
46
|
+
cve_id: str
|
|
47
|
+
severity: str # critical | high | medium | low | info
|
|
48
|
+
server: str
|
|
49
|
+
evidence: str
|
|
50
|
+
fix: str
|
|
51
|
+
source_file: str = ""
|
|
52
|
+
|
|
53
|
+
def to_dict(self):
|
|
54
|
+
return {
|
|
55
|
+
"rule_id": self.rule_id,
|
|
56
|
+
"cve_id": self.cve_id,
|
|
57
|
+
"severity": self.severity,
|
|
58
|
+
"server": self.server,
|
|
59
|
+
"evidence": self.evidence,
|
|
60
|
+
"fix": self.fix,
|
|
61
|
+
"source_file": self.source_file,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _servers_from_entry(name, entry, source_file=""):
|
|
66
|
+
srv = McpServer(name=name, transport="unknown", source_file=source_file)
|
|
67
|
+
if not isinstance(entry, dict):
|
|
68
|
+
return srv
|
|
69
|
+
srv.command = str(entry.get("command", "") or "")
|
|
70
|
+
args = entry.get("args", [])
|
|
71
|
+
srv.args = [str(a) for a in args] if isinstance(args, list) else []
|
|
72
|
+
env = entry.get("env", {})
|
|
73
|
+
srv.env = dict(env) if isinstance(env, dict) else {}
|
|
74
|
+
srv.url = str(entry.get("url", "") or "")
|
|
75
|
+
headers = entry.get("headers", {})
|
|
76
|
+
srv.headers = dict(headers) if isinstance(headers, dict) else {}
|
|
77
|
+
srv.version = str(entry.get("version", "") or "")
|
|
78
|
+
|
|
79
|
+
if srv.url:
|
|
80
|
+
parsed = urllib.parse.urlparse(srv.url)
|
|
81
|
+
if parsed.scheme in ("http", "https"):
|
|
82
|
+
srv.transport = "sse" if parsed.path.endswith("/sse") or "sse" in parsed.path else "http"
|
|
83
|
+
elif srv.command:
|
|
84
|
+
srv.transport = "stdio"
|
|
85
|
+
|
|
86
|
+
srv.package, srv.version = _infer_package(srv) or (srv.package, srv.version or "")
|
|
87
|
+
return srv
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_PKG_RE = re.compile(r"^([@a-zA-Z0-9_.\-/]+?)(?:@([0-9][0-9A-Za-z.\-]*))?$")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _infer_package(srv):
|
|
94
|
+
"""Guess (package, version) from an npx/uvx/node invocation.
|
|
95
|
+
|
|
96
|
+
Handles args like ["-y", "@modelcontextprotocol/inspector@0.13.0"]
|
|
97
|
+
or ["mcp-server-foo@1.2.3", ...].
|
|
98
|
+
"""
|
|
99
|
+
tokens = [srv.command] + list(srv.args)
|
|
100
|
+
for tok in tokens:
|
|
101
|
+
tok = tok.strip()
|
|
102
|
+
if tok.startswith("-"):
|
|
103
|
+
continue
|
|
104
|
+
m = _PKG_RE.match(tok)
|
|
105
|
+
if not m:
|
|
106
|
+
continue
|
|
107
|
+
name, ver = m.group(1), m.group(2)
|
|
108
|
+
if "/" in name or name.startswith("@") or re.fullmatch(r"[a-zA-Z0-9_.\-]+", name):
|
|
109
|
+
if ver:
|
|
110
|
+
return name, ver
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _walk_mcp_servers(obj, source_file=""):
|
|
115
|
+
"""Extract mcpServers dicts from a parsed JSON config."""
|
|
116
|
+
found = []
|
|
117
|
+
if isinstance(obj, dict):
|
|
118
|
+
if "mcpServers" in obj and isinstance(obj["mcpServers"], dict):
|
|
119
|
+
for name, entry in obj["mcpServers"].items():
|
|
120
|
+
found.append(_servers_from_entry(name, entry, source_file))
|
|
121
|
+
for key in ("servers", "mcp_servers"):
|
|
122
|
+
if key in obj and isinstance(obj[key], dict):
|
|
123
|
+
for name, entry in obj[key].items():
|
|
124
|
+
found.append(_servers_from_entry(name, entry, source_file))
|
|
125
|
+
# Recurse into nested dicts (e.g. Claude Code's per-project blocks
|
|
126
|
+
# inside ~/.claude.json) to avoid missing nested mcpServers.
|
|
127
|
+
for key, value in obj.items():
|
|
128
|
+
if key in ("mcpServers", "servers", "mcp_servers"):
|
|
129
|
+
continue
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
found.extend(_walk_mcp_servers(value, source_file))
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_config(path):
|
|
136
|
+
"""Load one config file, return (servers, error_message)."""
|
|
137
|
+
try:
|
|
138
|
+
with open(path, encoding="utf-8") as fh:
|
|
139
|
+
data = json.load(fh)
|
|
140
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
141
|
+
return [], "cannot read %s: %s" % (path, exc)
|
|
142
|
+
return _walk_mcp_servers(data, path), ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def discover_configs(explicit=None):
|
|
146
|
+
"""Return list of config paths: explicit ones, else existing defaults."""
|
|
147
|
+
if explicit:
|
|
148
|
+
return list(explicit)
|
|
149
|
+
return [p for p in DEFAULT_CONFIG_PATHS if os.path.isfile(p)]
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------ rule 1: stdio
|
|
152
|
+
|
|
153
|
+
# Shell-wrapper patterns that turn a stdio config into command injection.
|
|
154
|
+
SHELL_PATTERNS = [
|
|
155
|
+
(re.compile(r"(^|\s)bash\s+-c(\s|$)", re.I), "bash -c"),
|
|
156
|
+
(re.compile(r"(^|\s)sh\s+-c(\s|$)", re.I), "sh -c"),
|
|
157
|
+
(re.compile(r"(^|\s)zsh\s+-c(\s|$)", re.I), "zsh -c"),
|
|
158
|
+
(re.compile(r"(^|\s)(cmd|powershell|pwsh)(\.exe)?\s+(/[cC]|-c|-Command)(\s|$)"), "cmd /c"),
|
|
159
|
+
(re.compile(r"&&|\|\||;\s*(curl|wget|rm|chmod|nc|ncat)\b", re.I), "command chaining"),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def scan_dangerous_stdio(servers):
|
|
164
|
+
"""Flag stdio servers whose command/args wrap execution in a shell."""
|
|
165
|
+
findings = []
|
|
166
|
+
for srv in servers:
|
|
167
|
+
if srv.transport != "stdio":
|
|
168
|
+
continue
|
|
169
|
+
invocation = " ".join([srv.command] + list(srv.args))
|
|
170
|
+
for pattern, label in SHELL_PATTERNS:
|
|
171
|
+
if pattern.search(invocation):
|
|
172
|
+
findings.append(
|
|
173
|
+
Finding(
|
|
174
|
+
rule_id="STDIO-SHELL",
|
|
175
|
+
cve_id="CVE-2026-105697",
|
|
176
|
+
severity="high",
|
|
177
|
+
server=srv.name,
|
|
178
|
+
evidence="stdio server %r runs through shell wrapper %s: %s"
|
|
179
|
+
% (srv.name, label, invocation[:200]),
|
|
180
|
+
fix="Remove the shell wrapper: set command to the real "
|
|
181
|
+
"executable and pass each argument separately in args. "
|
|
182
|
+
"Never pass untrusted input into a shell -c string.",
|
|
183
|
+
source_file=srv.source_file,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
break
|
|
187
|
+
return findings
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------- rule 2: unauth endpoints
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _probe_endpoint(url, timeout=4.0):
|
|
193
|
+
"""Send one unauthenticated GET. Returns (reachable, status, detail).
|
|
194
|
+
|
|
195
|
+
reachable=True means the endpoint answered *without* any credentials —
|
|
196
|
+
the unauthenticated-reachability signal this scanner is built to detect.
|
|
197
|
+
"""
|
|
198
|
+
req = urllib.request.Request(url, headers={"User-Agent": "mcp-audit/0.1.0"})
|
|
199
|
+
try:
|
|
200
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
201
|
+
return True, resp.status, "HTTP %s" % resp.status
|
|
202
|
+
except urllib.error.HTTPError as exc:
|
|
203
|
+
if exc.code in (401, 403, 407):
|
|
204
|
+
return False, exc.code, "HTTP %s (auth required)" % exc.code
|
|
205
|
+
return True, exc.code, "HTTP %s (reachable without auth)" % exc.code
|
|
206
|
+
except urllib.error.URLError as exc:
|
|
207
|
+
return False, 0, "unreachable: %s" % exc.reason
|
|
208
|
+
except (socket.timeout, OSError) as exc:
|
|
209
|
+
return False, 0, "unreachable: %s" % exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def scan_unauthenticated_endpoints(servers, timeout=4.0, probe=False):
|
|
213
|
+
"""Flag http/sse servers reachable with no authentication.
|
|
214
|
+
|
|
215
|
+
probe=False (default): static config check — flags non-loopback URLs
|
|
216
|
+
with no Authorization/Api-Key header. probe=True: also sends one
|
|
217
|
+
unauthenticated GET to confirm the endpoint answers.
|
|
218
|
+
"""
|
|
219
|
+
findings = []
|
|
220
|
+
for srv in servers:
|
|
221
|
+
if srv.transport not in ("sse", "http") or not srv.url:
|
|
222
|
+
continue
|
|
223
|
+
host = urllib.parse.urlparse(srv.url).hostname or ""
|
|
224
|
+
has_auth = any(
|
|
225
|
+
k.lower() in ("authorization", "x-api-key", "x-auth-token", "cookie")
|
|
226
|
+
for k in srv.headers
|
|
227
|
+
)
|
|
228
|
+
evidence_bits = []
|
|
229
|
+
if probe:
|
|
230
|
+
reachable, status, detail = _probe_endpoint(srv.url, timeout=timeout)
|
|
231
|
+
if reachable:
|
|
232
|
+
evidence_bits.append("unauthenticated GET -> %s" % detail)
|
|
233
|
+
else:
|
|
234
|
+
reachable = host not in ("", "127.0.0.1", "::1", "localhost")
|
|
235
|
+
evidence_bits.append("no auth header configured")
|
|
236
|
+
if reachable and not has_auth:
|
|
237
|
+
findings.append(
|
|
238
|
+
Finding(
|
|
239
|
+
rule_id="UNAUTH-ENDPOINT",
|
|
240
|
+
cve_id="CVE-2025-49596",
|
|
241
|
+
severity="critical" if probe else "high",
|
|
242
|
+
server=srv.name,
|
|
243
|
+
evidence="server %r exposes %s: %s"
|
|
244
|
+
% (srv.name, srv.url, "; ".join(evidence_bits)),
|
|
245
|
+
fix="Require authentication: put the endpoint behind an "
|
|
246
|
+
"auth proxy or add headers={\"Authorization\": \"Bearer ${MCP_TOKEN}\"} "
|
|
247
|
+
"to the config and rotate the token.",
|
|
248
|
+
source_file=srv.source_file,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return findings
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------ rule 3: bind address
|
|
254
|
+
|
|
255
|
+
BIND_RE = re.compile(r"--?(?:bind|host|listen)(?:-address)?[= ]\s*([0-9a-zA-Z.:]+)")
|
|
256
|
+
PUBLIC_HOSTS = {"0.0.0.0", "::"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def scan_bind_address(servers):
|
|
260
|
+
"""Flag servers bound to all interfaces (0.0.0.0 / ::)."""
|
|
261
|
+
findings = []
|
|
262
|
+
for srv in servers:
|
|
263
|
+
hits = []
|
|
264
|
+
if srv.url:
|
|
265
|
+
host = urllib.parse.urlparse(srv.url).hostname or ""
|
|
266
|
+
if host in PUBLIC_HOSTS:
|
|
267
|
+
hits.append("url host %s" % host)
|
|
268
|
+
invocation = " ".join([srv.command] + list(srv.args))
|
|
269
|
+
for m in BIND_RE.finditer(invocation):
|
|
270
|
+
if m.group(1) in PUBLIC_HOSTS:
|
|
271
|
+
hits.append("flag %s=%s" % (m.group(0), m.group(1)))
|
|
272
|
+
for value in srv.env.values():
|
|
273
|
+
if str(value).strip() in PUBLIC_HOSTS:
|
|
274
|
+
hits.append("env value %s" % value)
|
|
275
|
+
for hit in hits:
|
|
276
|
+
findings.append(
|
|
277
|
+
Finding(
|
|
278
|
+
rule_id="BIND-ADDR",
|
|
279
|
+
cve_id="CVE-2025-49596",
|
|
280
|
+
severity="high",
|
|
281
|
+
server=srv.name,
|
|
282
|
+
evidence="server %r binds to all interfaces (%s); "
|
|
283
|
+
"reachable beyond localhost" % (srv.name, hit),
|
|
284
|
+
fix="Bind to 127.0.0.1 instead of 0.0.0.0: e.g. replace "
|
|
285
|
+
"--host 0.0.0.0 with --host 127.0.0.1. "
|
|
286
|
+
"mcp-audit fix --apply does this automatically.",
|
|
287
|
+
source_file=srv.source_file,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
return findings
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------ rule 4: known CVEs
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def scan_known_cves(servers):
|
|
296
|
+
"""Flag servers running package versions hit by the bundled CVE table."""
|
|
297
|
+
findings = []
|
|
298
|
+
for srv in servers:
|
|
299
|
+
if not srv.package:
|
|
300
|
+
continue
|
|
301
|
+
for entry in affected_cves(srv.package, srv.version):
|
|
302
|
+
findings.append(
|
|
303
|
+
Finding(
|
|
304
|
+
rule_id="KNOWN-CVE",
|
|
305
|
+
cve_id=entry["id"],
|
|
306
|
+
severity=entry["severity"],
|
|
307
|
+
server=srv.name,
|
|
308
|
+
evidence="server %r runs %s@%s, affected by %s (%s, CVSS %s)"
|
|
309
|
+
% (
|
|
310
|
+
srv.name,
|
|
311
|
+
srv.package,
|
|
312
|
+
srv.version,
|
|
313
|
+
entry["id"],
|
|
314
|
+
entry["title"],
|
|
315
|
+
entry.get("cvss") or "?",
|
|
316
|
+
),
|
|
317
|
+
fix="Upgrade to %s or later, then restart the server. "
|
|
318
|
+
"Refs: %s"
|
|
319
|
+
% (entry.get("fixed_in") or "a patched version",
|
|
320
|
+
", ".join(entry.get("references", [])) or "see NVD"),
|
|
321
|
+
source_file=srv.source_file,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
return findings
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------ orchestration
|
|
327
|
+
|
|
328
|
+
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def run_all_scans(servers, probe=False, timeout=4.0):
|
|
332
|
+
findings = []
|
|
333
|
+
findings += scan_dangerous_stdio(servers)
|
|
334
|
+
findings += scan_unauthenticated_endpoints(servers, timeout=timeout, probe=probe)
|
|
335
|
+
findings += scan_bind_address(servers)
|
|
336
|
+
findings += scan_known_cves(servers)
|
|
337
|
+
findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 5), f.server))
|
|
338
|
+
return findings
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------ one-click fix
|
|
341
|
+
|
|
342
|
+
FIXABLE_RULES = {"BIND-ADDR", "UNAUTH-ENDPOINT"}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _apply_bind_fix(entry):
|
|
346
|
+
"""Rewrite 0.0.0.0 / :: bindings to 127.0.0.1 in url, args, env."""
|
|
347
|
+
changed = False
|
|
348
|
+
if isinstance(entry.get("url"), str):
|
|
349
|
+
url = entry["url"]
|
|
350
|
+
new_url = re.sub(r"://(0\.0\.0\.0|::)(:|/)", r"://127.0.0.1\2", url)
|
|
351
|
+
if new_url != url:
|
|
352
|
+
entry["url"] = new_url
|
|
353
|
+
changed = True
|
|
354
|
+
args = entry.get("args")
|
|
355
|
+
if isinstance(args, list):
|
|
356
|
+
i = 0
|
|
357
|
+
while i < len(args):
|
|
358
|
+
arg = args[i]
|
|
359
|
+
if isinstance(arg, str):
|
|
360
|
+
if re.fullmatch(r"--?(?:host|bind|listen)(?:-address)?", arg):
|
|
361
|
+
nxt = args[i + 1] if i + 1 < len(args) else None
|
|
362
|
+
if nxt in ("0.0.0.0", "::"):
|
|
363
|
+
args[i + 1] = "127.0.0.1"
|
|
364
|
+
changed = True
|
|
365
|
+
else:
|
|
366
|
+
fixed = re.sub(
|
|
367
|
+
r"^(--?(?:host|bind|listen)(?:-address)?)=(0\.0\.0\.0|::)$",
|
|
368
|
+
r"\1=127.0.0.1",
|
|
369
|
+
arg,
|
|
370
|
+
)
|
|
371
|
+
if fixed != arg:
|
|
372
|
+
args[i] = fixed
|
|
373
|
+
changed = True
|
|
374
|
+
i += 1
|
|
375
|
+
env = entry.get("env")
|
|
376
|
+
if isinstance(env, dict):
|
|
377
|
+
for k, v in env.items():
|
|
378
|
+
if str(v).strip() in PUBLIC_HOSTS:
|
|
379
|
+
env[k] = "127.0.0.1"
|
|
380
|
+
changed = True
|
|
381
|
+
return changed
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _apply_auth_fix(entry):
|
|
385
|
+
if not isinstance(entry.get("headers"), dict):
|
|
386
|
+
entry["headers"] = {}
|
|
387
|
+
if not any(
|
|
388
|
+
k.lower() in ("authorization", "x-api-key", "x-auth-token", "cookie")
|
|
389
|
+
for k in entry["headers"]
|
|
390
|
+
):
|
|
391
|
+
entry["headers"]["Authorization"] = "Bearer ${MCP_TOKEN}"
|
|
392
|
+
return True
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def plan_fixes(path, findings):
|
|
397
|
+
"""Build a fix plan for one config file. Returns (plan, missing_servers)."""
|
|
398
|
+
with open(path, encoding="utf-8") as fh:
|
|
399
|
+
data = json.load(fh)
|
|
400
|
+
containers = []
|
|
401
|
+
for key in ("mcpServers", "servers", "mcp_servers"):
|
|
402
|
+
if key in data and isinstance(data[key], dict):
|
|
403
|
+
containers.append((key, data[key]))
|
|
404
|
+
|
|
405
|
+
plan = []
|
|
406
|
+
done = set()
|
|
407
|
+
for f in findings:
|
|
408
|
+
if f.rule_id not in FIXABLE_RULES:
|
|
409
|
+
continue
|
|
410
|
+
for ckey, container in containers:
|
|
411
|
+
if f.server in container and (f.server, f.rule_id) not in done:
|
|
412
|
+
plan.append(
|
|
413
|
+
{
|
|
414
|
+
"server": f.server,
|
|
415
|
+
"rule_id": f.rule_id,
|
|
416
|
+
"container": ckey,
|
|
417
|
+
"action": (
|
|
418
|
+
"bind 0.0.0.0 -> 127.0.0.1"
|
|
419
|
+
if f.rule_id == "BIND-ADDR"
|
|
420
|
+
else 'add Authorization: Bearer ${MCP_TOKEN} placeholder header'
|
|
421
|
+
),
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
done.add((f.server, f.rule_id))
|
|
425
|
+
missing = [f.server for f in findings
|
|
426
|
+
if f.rule_id in FIXABLE_RULES and (f.server, f.rule_id) not in done]
|
|
427
|
+
return plan, missing
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def apply_fixes(path, findings):
|
|
431
|
+
"""Apply fixes to a config file. Returns (backup_path, applied_count)."""
|
|
432
|
+
with open(path, encoding="utf-8") as fh:
|
|
433
|
+
data = json.load(fh)
|
|
434
|
+
|
|
435
|
+
plan, _ = plan_fixes(path, findings)
|
|
436
|
+
if not plan:
|
|
437
|
+
return "", 0
|
|
438
|
+
|
|
439
|
+
backup_path = "%s.bak-%s" % (path, time.strftime("%Y%m%d-%H%M%S"))
|
|
440
|
+
with open(backup_path, "w", encoding="utf-8") as fh:
|
|
441
|
+
json.dump(data, fh, indent=2)
|
|
442
|
+
|
|
443
|
+
applied = 0
|
|
444
|
+
for item in plan:
|
|
445
|
+
container = data.get(item["container"], {})
|
|
446
|
+
entry = container.get(item["server"])
|
|
447
|
+
if not isinstance(entry, dict):
|
|
448
|
+
continue
|
|
449
|
+
if item["rule_id"] == "BIND-ADDR" and _apply_bind_fix(entry):
|
|
450
|
+
applied += 1
|
|
451
|
+
elif item["rule_id"] == "UNAUTH-ENDPOINT" and _apply_auth_fix(entry):
|
|
452
|
+
applied += 1
|
|
453
|
+
|
|
454
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
455
|
+
json.dump(data, fh, indent=2)
|
|
456
|
+
fh.write("\n")
|
|
457
|
+
return backup_path, applied
|
|
458
|
+
|
|
459
|
+
# -------------------------------------------------- agent-guard integration
|
|
460
|
+
|
|
461
|
+
AGENT_GUARD_CONFIG_PATHS = [
|
|
462
|
+
os.path.expanduser("~/.agent-guard/config.json"),
|
|
463
|
+
os.path.expanduser("~/.config/agent-guard/config.json"),
|
|
464
|
+
os.path.expanduser("~/.agent-guard.json"),
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def agent_guard_link(findings):
|
|
469
|
+
"""Link critical findings into agent-guard's blocklist.
|
|
470
|
+
|
|
471
|
+
Returns (linked, message): linked=True when an agent-guard config was
|
|
472
|
+
found and updated; otherwise prints the manual linking instruction.
|
|
473
|
+
"""
|
|
474
|
+
critical = sorted({f.server for f in findings if f.severity == "critical"})
|
|
475
|
+
if not critical:
|
|
476
|
+
return False, "no critical servers to link"
|
|
477
|
+
for path in AGENT_GUARD_CONFIG_PATHS:
|
|
478
|
+
if not os.path.isfile(path):
|
|
479
|
+
continue
|
|
480
|
+
try:
|
|
481
|
+
with open(path, encoding="utf-8") as fh:
|
|
482
|
+
cfg = json.load(fh)
|
|
483
|
+
except (OSError, json.JSONDecodeError):
|
|
484
|
+
continue
|
|
485
|
+
blocked = cfg.setdefault("blocked_servers", [])
|
|
486
|
+
added = 0
|
|
487
|
+
for name in critical:
|
|
488
|
+
if name not in blocked:
|
|
489
|
+
blocked.append(name)
|
|
490
|
+
added += 1
|
|
491
|
+
if added:
|
|
492
|
+
backup = "%s.bak-%s" % (path, time.strftime("%Y%m%d-%H%M%S"))
|
|
493
|
+
os.replace(path, backup)
|
|
494
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
495
|
+
json.dump(cfg, fh, indent=2)
|
|
496
|
+
return True, "linked %d critical server(s) to %s: %s" % (
|
|
497
|
+
added, path, ", ".join(critical))
|
|
498
|
+
return False, (
|
|
499
|
+
"agent-guard config not found. Manual link: add %s to "
|
|
500
|
+
"agent-guard's blocked_servers list (or run "
|
|
501
|
+
"'agent-guard block %s')." % (", ".join(critical), critical[0])
|
|
502
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-runtime-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local runtime security scanner for MCP servers — known-CVE checks, one-click fixes
|
|
5
|
+
Author-email: hao li <haoli5933@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/mcp-audit
|
|
8
|
+
Keywords: mcp,security,scanner,cli,cve
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# mcp-audit
|
|
18
|
+
|
|
19
|
+
A local runtime security scanner for MCP servers. Point it at your MCP client
|
|
20
|
+
configs and it flags the misconfigurations and known vulnerabilities that
|
|
21
|
+
actually get exploited — then offers one-click fixes. Zero third-party
|
|
22
|
+
dependencies, Python >= 3.9.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install mcp-runtime-audit
|
|
26
|
+
mcp-audit scan
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why this exists
|
|
30
|
+
|
|
31
|
+
MCP servers run with your user's privileges and are driven by an AI agent on
|
|
32
|
+
your behalf — which makes them a very different threat surface from a library
|
|
33
|
+
you import. The evidence so far is not reassuring:
|
|
34
|
+
|
|
35
|
+
- **The official tooling itself has shipped RCE.** CVE-2025-49596 hit
|
|
36
|
+
Anthropic's MCP Inspector (CVSS 9.4): its local proxy accepted an
|
|
37
|
+
unauthenticated `/sse` request with `transportType=stdio&command=<cmd>` and
|
|
38
|
+
spawned it as a subprocess — visiting a malicious page was enough to run
|
|
39
|
+
code on the developer's machine. Fixed in Inspector 0.14.1.
|
|
40
|
+
- **Enterprise MCP apps are not immune.** CVE-2026-76404 (Splunk MCP Server
|
|
41
|
+
< 1.2.1, CVSS 9.1): unsafe deserialization in credential management let an
|
|
42
|
+
admin-role user execute arbitrary OS commands (Splunk advisory
|
|
43
|
+
SVD-2026-0808).
|
|
44
|
+
- **Anthropic treats stdio risk as "expected"** — the position is that the
|
|
45
|
+
security burden sits with whoever deploys the server, not the protocol.
|
|
46
|
+
That leaves every local config file as the real security boundary, and
|
|
47
|
+
those files are hand-edited JSON.
|
|
48
|
+
- **Unpatched servers are out there in production.** Five US federal
|
|
49
|
+
government MCP servers were found running unpatched, and internet-wide
|
|
50
|
+
scans keep turning up thousands of exposed MCP endpoints, the vast majority
|
|
51
|
+
without any authentication.
|
|
52
|
+
|
|
53
|
+
Nobody audits their own `~/.claude.json` by hand. mcp-audit does it in a
|
|
54
|
+
second, in CI-friendly form.
|
|
55
|
+
|
|
56
|
+
## How it differs
|
|
57
|
+
|
|
58
|
+
| Tool | Known-CVE version checks | One-click fixes | Focus |
|
|
59
|
+
|------|--------------------------|-----------------|-------|
|
|
60
|
+
| **mcp-audit** | ✅ bundled CVE table | ✅ `fix --apply` | runtime security |
|
|
61
|
+
| mcpscan | ❌ | ❌ | static config scanning |
|
|
62
|
+
| graygnatconsole mcp-audit-tool | ❌ | ❌ | config auditing |
|
|
63
|
+
| mcp-lint | n/a | n/a | **design quality** linting, not runtime security — no overlap |
|
|
64
|
+
|
|
65
|
+
mcpscan and graygnatconsole's mcp-audit-tool scan configs but neither maps
|
|
66
|
+
your installed server versions against known CVEs, and neither writes fixes
|
|
67
|
+
back. mcp-lint is a design-quality linter for MCP servers; mcp-audit is a
|
|
68
|
+
runtime security scanner for the servers you run — different layer, no
|
|
69
|
+
overlap.
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install mcp-runtime-audit
|
|
75
|
+
|
|
76
|
+
# scan default locations (~/.claude.json, ~/.codex/config.json,
|
|
77
|
+
# ~/.config/mcp/servers.json, ~/.mcp.json)
|
|
78
|
+
mcp-audit scan
|
|
79
|
+
|
|
80
|
+
# scan a specific file, JSON output for CI
|
|
81
|
+
mcp-audit scan --config ~/my-claude.json --format json
|
|
82
|
+
|
|
83
|
+
# confirm unauthenticated reachability with a live probe
|
|
84
|
+
# (one unauthenticated GET per endpoint)
|
|
85
|
+
mcp-audit scan --probe
|
|
86
|
+
|
|
87
|
+
# preview fixes (dry run), then apply them (backs up first)
|
|
88
|
+
mcp-audit fix --config ~/.claude.json
|
|
89
|
+
mcp-audit fix --config ~/.claude.json --apply
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Exit codes: `0` clean, `1` findings, `2` errors — CI-ready.
|
|
93
|
+
|
|
94
|
+
## What it checks
|
|
95
|
+
|
|
96
|
+
1. **Dangerous stdio commands (`STDIO-SHELL`, high).** A stdio entry wrapping
|
|
97
|
+
execution in `bash -c` / `sh -c` / `cmd /c` is command injection by
|
|
98
|
+
design — this is the Langflow CVE-2026-105697 pattern (CVSS 9.9). Flags
|
|
99
|
+
the server and shows the exact invocation.
|
|
100
|
+
2. **Unauthenticated SSE/HTTP endpoints (`UNAUTH-ENDPOINT`,
|
|
101
|
+
critical with `--probe`).** Endpoints with no `Authorization`/`X-API-Key`
|
|
102
|
+
header. `--probe` sends one unauthenticated GET to confirm the endpoint
|
|
103
|
+
actually answers — the CVE-2025-49596 shape.
|
|
104
|
+
3. **Bind addresses (`BIND-ADDR`, high).** Servers bound to `0.0.0.0` or `::`
|
|
105
|
+
in URLs, `--host` flags, or env vars are reachable beyond localhost.
|
|
106
|
+
4. **Known-CVE version checks (`KNOWN-CVE`, per-CVE severity).** Extracts
|
|
107
|
+
`package@version` from `npx`/`uvx` invocations and matches against the
|
|
108
|
+
bundled CVE table below.
|
|
109
|
+
|
|
110
|
+
Every finding carries a rule id, CVE id, severity, the evidence, and the fix
|
|
111
|
+
command — in both text and JSON output.
|
|
112
|
+
|
|
113
|
+
### One-click fix
|
|
114
|
+
|
|
115
|
+
`mcp-audit fix` defaults to a dry-run plan. With `--apply` it:
|
|
116
|
+
|
|
117
|
+
- rewrites `0.0.0.0` → `127.0.0.1` in URLs, `--host` flags, and env values,
|
|
118
|
+
- adds an `Authorization: Bearer ${MCP_TOKEN}` placeholder header to
|
|
119
|
+
unauthenticated endpoints (you fill in a real token),
|
|
120
|
+
- backs up the config to `<file>.bak-<timestamp>` before writing,
|
|
121
|
+
- links critical servers into agent-guard's `blocked_servers` list when an
|
|
122
|
+
agent-guard config is detected (see below).
|
|
123
|
+
|
|
124
|
+
### agent-guard integration
|
|
125
|
+
|
|
126
|
+
`mcp-audit scan --link-agent-guard` writes every critical server into
|
|
127
|
+
agent-guard's blocklist (config backed up first). If agent-guard isn't
|
|
128
|
+
installed, it prints the manual linking instruction instead.
|
|
129
|
+
|
|
130
|
+
## CVE table
|
|
131
|
+
|
|
132
|
+
Bundled advisory snapshot (2026-10). Re-check NVD / vendor releases before
|
|
133
|
+
using these for compliance reporting.
|
|
134
|
+
|
|
135
|
+
| CVE | Product | Affected | Fix | Class |
|
|
136
|
+
|-----|---------|----------|-----|-------|
|
|
137
|
+
| CVE-2025-49596 | @modelcontextprotocol/inspector | < 0.14.1 | 0.14.1 | Unauthenticated RCE, CVSS 9.4 |
|
|
138
|
+
| CVE-2026-76404 | splunk-mcp-server | < 1.2.1 | 1.2.1 | Unsafe deserialization RCE, CVSS 9.1 |
|
|
139
|
+
| CVE-2026-58201 | lokka (M365 MCP) | < 2.1.2 | 2.1.2 | SSRF leaking ARM bearer token |
|
|
140
|
+
| CVE-2026-105697 | langflow / langflow-base / lfx | < 1.10.3 | 1.10.3 | MCP stdio `bash -c` RCE, CVSS 9.9 |
|
|
141
|
+
| CVE-2026-89039 | (product mapping pending) | — | — | See NVD |
|
|
142
|
+
| CVE-2026-105793 | (product mapping pending) | — | — | See NVD |
|
|
143
|
+
| CVE-2026-94486 | (product mapping pending) | — | — | See NVD |
|
|
144
|
+
| CVE-2026-19807 | (product mapping pending) | — | — | See NVD |
|
|
145
|
+
|
|
146
|
+
## CI example
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
# .github/workflows/mcp-audit.yml
|
|
150
|
+
- uses: actions/setup-python@v5
|
|
151
|
+
with: { python-version: "3.12" }
|
|
152
|
+
- run: pip install mcp-runtime-audit
|
|
153
|
+
- run: mcp-audit scan --config ./mcp-servers.json --format json
|
|
154
|
+
# exits 1 on findings -> fails the build
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Limitations
|
|
158
|
+
|
|
159
|
+
- Version detection reads `package@version` from your config's command/args
|
|
160
|
+
(npx/uvx/node style). Servers installed another way, or without a pinned
|
|
161
|
+
version, won't match the CVE table.
|
|
162
|
+
- `--probe` sends one unauthenticated GET per endpoint — no auth bypass
|
|
163
|
+
attempts, no payloads, and it never mutates anything. It still touches
|
|
164
|
+
the network; don't run it against hosts you don't own.
|
|
165
|
+
- The CVE table is a bundled snapshot, not a live feed. New CVEs need a new
|
|
166
|
+
release.
|
|
167
|
+
- Static checks can't see what a server does after it starts (dynamic tool
|
|
168
|
+
behavior, prompt injection in tool outputs). This is a config/runtime
|
|
169
|
+
surface scanner, not a runtime behavior monitor.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mcp_audit/__init__.py,sha256=jUN7XHOBjzPLz6d_Svx255fSoae4MdxYU5GKgOxT4yQ,114
|
|
2
|
+
mcp_audit/cli.py,sha256=WZN85swe6uy3QnJAYOKgmmrvathi1nsAciN1UpsXl3Q,6249
|
|
3
|
+
mcp_audit/cve_db.py,sha256=KBWKd6ds1dt7HdShhezZU8Opr80PZ6zu11OK4F1SIpc,7925
|
|
4
|
+
mcp_audit/scanners.py,sha256=l72OJYhi4hKqj1cb_ert3Pef4EGm81wpCp34hNRBaT0,18508
|
|
5
|
+
mcp_runtime_audit-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
6
|
+
mcp_runtime_audit-0.1.0.dist-info/METADATA,sha256=SpP_Xw8gNwaSoDnHpIcjj7s72XxD3P93UEdh7hh0M9Q,7246
|
|
7
|
+
mcp_runtime_audit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
mcp_runtime_audit-0.1.0.dist-info/entry_points.txt,sha256=gNxxZPRoXCWu3pELYwSNWSoyuBdKWbh153_eozNW5Jg,49
|
|
9
|
+
mcp_runtime_audit-0.1.0.dist-info/top_level.txt,sha256=YZ4Gy-ehEA074nfIt4cMSAS6gAJDYcWfX0gu468CZVE,10
|
|
10
|
+
mcp_runtime_audit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_audit
|