mcppt 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.
- mcppt/__init__.py +1 -0
- mcppt/checks.py +1720 -0
- mcppt/cli.py +243 -0
- mcppt/core.py +169 -0
- mcppt/report.py +105 -0
- mcppt/server.py +254 -0
- mcppt/shell.py +508 -0
- mcppt/tui.py +160 -0
- mcppt-1.0.0.dist-info/METADATA +432 -0
- mcppt-1.0.0.dist-info/RECORD +13 -0
- mcppt-1.0.0.dist-info/WHEEL +4 -0
- mcppt-1.0.0.dist-info/entry_points.txt +2 -0
- mcppt-1.0.0.dist-info/licenses/LICENSE +21 -0
mcppt/cli.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""MCPPT CLI — entry point for `mcppt` command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .core import configure, mcp_init, rpc
|
|
9
|
+
|
|
10
|
+
CHECKS = [
|
|
11
|
+
"enum", "auth", "idor", "injection", "schema", "ssrf", "publish",
|
|
12
|
+
"rate", "stored", "scope", "replay", "context_overflow", "poison_all",
|
|
13
|
+
"tenant", "session", "rug_pull",
|
|
14
|
+
"headers", "error_disclosure", "tool_poisoning", "resources",
|
|
15
|
+
"cmd_injection", "path_traversal", "jwt_audit", "oauth_discovery",
|
|
16
|
+
"secret_scan", "tool_shadowing",
|
|
17
|
+
"sampling", "schema_leak",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
EPILOG = """
|
|
21
|
+
commands:
|
|
22
|
+
scan Run all/selected security checks with live TUI
|
|
23
|
+
list Enumerate tools and parameter schemas
|
|
24
|
+
call Call a single tool with custom JSON args
|
|
25
|
+
shell Launch interactive REPL (default when run with no args)
|
|
26
|
+
serve-mcp Expose MCPTROTTER itself as an MCP server
|
|
27
|
+
|
|
28
|
+
examples:
|
|
29
|
+
mcppt <- interactive shell
|
|
30
|
+
mcppt scan --url https://target.com/mcp --token eyJ...
|
|
31
|
+
mcppt scan --url https://target.com/mcp --token t1 --token2 t2 --checks idor,scope
|
|
32
|
+
mcppt scan --url https://target.com/mcp --no-verify --output report.md
|
|
33
|
+
mcppt scan --url https://target.com/mcp --proxy http://127.0.0.1:8080 --checks all
|
|
34
|
+
mcppt list --url https://target.com/mcp --token eyJ...
|
|
35
|
+
mcppt call --url https://target.com/mcp --token eyJ... --tool get_user --args '{"id":1}'
|
|
36
|
+
mcppt serve-mcp --port 8899
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── scan ──────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def cmd_scan(args: argparse.Namespace) -> None:
|
|
43
|
+
from .checks import ScanState, run_scan, ALL_CHECKS
|
|
44
|
+
from .tui import run_tui
|
|
45
|
+
from .report import save_json, save_markdown
|
|
46
|
+
|
|
47
|
+
configure(no_verify=args.no_verify, proxy=args.proxy or None)
|
|
48
|
+
|
|
49
|
+
checks = [c.strip() for c in args.checks.split(",")]
|
|
50
|
+
run_all = "all" in checks
|
|
51
|
+
total = len(ALL_CHECKS) if run_all else len([c for c in checks if c in ALL_CHECKS])
|
|
52
|
+
|
|
53
|
+
state = ScanState(
|
|
54
|
+
url=args.url,
|
|
55
|
+
token=args.token or None,
|
|
56
|
+
token2=args.token2 or None,
|
|
57
|
+
checks_total=total,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
run_tui(state, run_scan, state, checks)
|
|
61
|
+
|
|
62
|
+
if args.output:
|
|
63
|
+
p = (
|
|
64
|
+
save_markdown(state, args.output)
|
|
65
|
+
if args.output.endswith(".md")
|
|
66
|
+
else save_json(state, args.output)
|
|
67
|
+
)
|
|
68
|
+
print(f"\n Report saved → {p}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── list ──────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
74
|
+
from rich import box
|
|
75
|
+
from rich.console import Console
|
|
76
|
+
from rich.table import Table
|
|
77
|
+
|
|
78
|
+
configure(no_verify=args.no_verify, proxy=args.proxy or None)
|
|
79
|
+
console = Console()
|
|
80
|
+
|
|
81
|
+
mcp_init(args.url, args.token or None)
|
|
82
|
+
r = rpc(args.url, "tools/list", {}, token=args.token or None)
|
|
83
|
+
if r["status"] != 200:
|
|
84
|
+
console.print(f"[red]tools/list failed: HTTP {r['status']}[/]")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
tools = r["body"].get("result", {}).get("tools", [])
|
|
88
|
+
if not tools:
|
|
89
|
+
console.print("[yellow]No tools returned.[/]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
console.print(f"\n[bold]{len(tools)} tools on [cyan]{args.url}[/][/]\n")
|
|
93
|
+
for t in tools:
|
|
94
|
+
name = t.get("name", "?")
|
|
95
|
+
desc = t.get("description", "").split("\n")[0][:80]
|
|
96
|
+
props = t.get("inputSchema", {}).get("properties", {})
|
|
97
|
+
required = t.get("inputSchema", {}).get("required", [])
|
|
98
|
+
|
|
99
|
+
console.print(f"[bold cyan]{name}[/] [dim]{desc}[/]")
|
|
100
|
+
if props:
|
|
101
|
+
tbl = Table(show_header=True, box=box.SIMPLE, header_style="bold dim", padding=(0, 1))
|
|
102
|
+
tbl.add_column("Field")
|
|
103
|
+
tbl.add_column("Type")
|
|
104
|
+
tbl.add_column("Req", width=4)
|
|
105
|
+
tbl.add_column("Description")
|
|
106
|
+
for field, meta in props.items():
|
|
107
|
+
req = "[red]*[/]" if field in required else ""
|
|
108
|
+
tbl.add_row(
|
|
109
|
+
field,
|
|
110
|
+
meta.get("type", "any"),
|
|
111
|
+
req,
|
|
112
|
+
meta.get("description", "")[:70],
|
|
113
|
+
)
|
|
114
|
+
console.print(tbl)
|
|
115
|
+
console.print()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── call ──────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def cmd_call(args: argparse.Namespace) -> None:
|
|
121
|
+
from rich.console import Console
|
|
122
|
+
|
|
123
|
+
configure(no_verify=args.no_verify, proxy=args.proxy or None)
|
|
124
|
+
console = Console()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
tool_args = json.loads(args.args)
|
|
128
|
+
except json.JSONDecodeError as e:
|
|
129
|
+
console.print(f"[red]Invalid JSON args: {e}[/]")
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
mcp_init(args.url, args.token or None)
|
|
133
|
+
console.print(f"\nCalling [cyan]{args.tool}[/] on [cyan]{args.url}[/]...")
|
|
134
|
+
r = rpc(
|
|
135
|
+
args.url,
|
|
136
|
+
"tools/call",
|
|
137
|
+
{"name": args.tool, "arguments": tool_args},
|
|
138
|
+
token=args.token or None,
|
|
139
|
+
)
|
|
140
|
+
status_style = "green" if r["status"] == 200 else "red"
|
|
141
|
+
console.print(f"[{status_style}]HTTP {r['status']}[/]")
|
|
142
|
+
|
|
143
|
+
body = r["body"]
|
|
144
|
+
result = body.get("result", {})
|
|
145
|
+
content = result.get("content", [])
|
|
146
|
+
if content:
|
|
147
|
+
for item in content:
|
|
148
|
+
if item.get("type") == "text":
|
|
149
|
+
try:
|
|
150
|
+
console.print_json(json.dumps(json.loads(item["text"])))
|
|
151
|
+
except Exception:
|
|
152
|
+
console.print(item["text"])
|
|
153
|
+
elif "error" in body:
|
|
154
|
+
console.print(f"[red]Error:[/] {body['error']}")
|
|
155
|
+
else:
|
|
156
|
+
console.print_json(json.dumps(body))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── arg parsing ───────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
162
|
+
p.add_argument("--url", required=True, help="MCP server endpoint URL")
|
|
163
|
+
p.add_argument("--token", default=None, help="Bearer token (primary)")
|
|
164
|
+
p.add_argument("--no-verify", action="store_true", help="Skip SSL certificate verification")
|
|
165
|
+
p.add_argument("--proxy", default=None, help="Proxy URL e.g. http://127.0.0.1:8080")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ensure_utf8() -> None:
|
|
169
|
+
"""Reconfigure stdout/stderr to UTF-8 on Windows so Rich box-drawing chars encode."""
|
|
170
|
+
import sys
|
|
171
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
172
|
+
try:
|
|
173
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
174
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_shell(_args=None) -> None:
|
|
180
|
+
from .shell import launch_shell
|
|
181
|
+
launch_shell()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_serve_mcp(args: argparse.Namespace) -> None:
|
|
185
|
+
from .server import serve
|
|
186
|
+
serve(port=args.port)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main() -> None:
|
|
190
|
+
_ensure_utf8()
|
|
191
|
+
parser = argparse.ArgumentParser(
|
|
192
|
+
prog="mcppt",
|
|
193
|
+
description="MCPTROTTER v2.3 — MCP Pentest Tool | 28 automated security checks",
|
|
194
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
195
|
+
epilog=EPILOG,
|
|
196
|
+
)
|
|
197
|
+
sub = parser.add_subparsers(dest="command")
|
|
198
|
+
|
|
199
|
+
# scan
|
|
200
|
+
p_scan = sub.add_parser("scan", help="Run security scan (live TUI)")
|
|
201
|
+
_add_common(p_scan)
|
|
202
|
+
p_scan.add_argument("--token2", default=None, help="Second user token (IDOR/scope/tenant checks)")
|
|
203
|
+
p_scan.add_argument("--checks", default="all",
|
|
204
|
+
help=f"Comma-separated checks or 'all'. Options: {','.join(CHECKS)}")
|
|
205
|
+
p_scan.add_argument("--output", default=None,
|
|
206
|
+
help="Save report to file (report.json or report.md)")
|
|
207
|
+
|
|
208
|
+
# list
|
|
209
|
+
p_list = sub.add_parser("list", help="Enumerate tools and schemas")
|
|
210
|
+
_add_common(p_list)
|
|
211
|
+
|
|
212
|
+
# call
|
|
213
|
+
p_call = sub.add_parser("call", help="Call a single tool")
|
|
214
|
+
_add_common(p_call)
|
|
215
|
+
p_call.add_argument("--tool", required=True, help="Tool name to call")
|
|
216
|
+
p_call.add_argument("--args", default="{}", help="JSON arguments (default: {})")
|
|
217
|
+
|
|
218
|
+
# shell
|
|
219
|
+
sub.add_parser("shell", help="Launch interactive REPL (gobuster/ffuf-style)")
|
|
220
|
+
|
|
221
|
+
# serve-mcp
|
|
222
|
+
p_serve = sub.add_parser("serve-mcp", help="Expose MCPTROTTER as an MCP server")
|
|
223
|
+
p_serve.add_argument("--port", type=int, default=8899, help="Port to listen on (default: 8899)")
|
|
224
|
+
|
|
225
|
+
args = parser.parse_args()
|
|
226
|
+
|
|
227
|
+
# default: no subcommand → launch interactive shell
|
|
228
|
+
if not args.command:
|
|
229
|
+
cmd_shell()
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
dispatch = {
|
|
233
|
+
"scan": cmd_scan,
|
|
234
|
+
"list": cmd_list,
|
|
235
|
+
"call": cmd_call,
|
|
236
|
+
"shell": cmd_shell,
|
|
237
|
+
"serve-mcp": cmd_serve_mcp,
|
|
238
|
+
}
|
|
239
|
+
dispatch[args.command](args)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
mcppt/core.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Low-level JSON-RPC transport for MCP Streamable HTTP servers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import ssl
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
_SESSION_ID: Optional[str] = None
|
|
12
|
+
_SSL_CTX: Optional[ssl.SSLContext] = None
|
|
13
|
+
_PROXY_HANDLER: Any = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure(no_verify: bool = False, proxy: Optional[str] = None) -> None:
|
|
17
|
+
global _SSL_CTX, _PROXY_HANDLER
|
|
18
|
+
if no_verify:
|
|
19
|
+
ctx = ssl.create_default_context()
|
|
20
|
+
ctx.check_hostname = False
|
|
21
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
22
|
+
_SSL_CTX = ctx
|
|
23
|
+
else:
|
|
24
|
+
_SSL_CTX = None
|
|
25
|
+
_PROXY_HANDLER = (
|
|
26
|
+
urllib.request.ProxyHandler({"http": proxy, "https": proxy}) if proxy else None
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reset_session() -> None:
|
|
31
|
+
global _SESSION_ID
|
|
32
|
+
_SESSION_ID = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_session_id() -> Optional[str]:
|
|
36
|
+
return _SESSION_ID
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_session_id(sid: Optional[str]) -> None:
|
|
40
|
+
global _SESSION_ID
|
|
41
|
+
_SESSION_ID = sid
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rpc(
|
|
45
|
+
url: str,
|
|
46
|
+
method: str,
|
|
47
|
+
params: dict,
|
|
48
|
+
token: Optional[str] = None,
|
|
49
|
+
req_id: int = 1,
|
|
50
|
+
) -> dict:
|
|
51
|
+
global _SESSION_ID
|
|
52
|
+
payload = json.dumps(
|
|
53
|
+
{"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
|
|
54
|
+
).encode()
|
|
55
|
+
headers = {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Accept": "application/json, text/event-stream",
|
|
58
|
+
}
|
|
59
|
+
if token:
|
|
60
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
61
|
+
if _SESSION_ID:
|
|
62
|
+
headers["mcp-session-id"] = _SESSION_ID
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
|
66
|
+
if _PROXY_HANDLER:
|
|
67
|
+
extra = (
|
|
68
|
+
urllib.request.HTTPSHandler(context=_SSL_CTX)
|
|
69
|
+
if _SSL_CTX
|
|
70
|
+
else urllib.request.HTTPSHandler()
|
|
71
|
+
)
|
|
72
|
+
opener = urllib.request.build_opener(_PROXY_HANDLER, extra)
|
|
73
|
+
elif _SSL_CTX:
|
|
74
|
+
opener = urllib.request.build_opener(
|
|
75
|
+
urllib.request.HTTPSHandler(context=_SSL_CTX)
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
opener = urllib.request.build_opener()
|
|
79
|
+
|
|
80
|
+
with opener.open(req, timeout=15) as resp:
|
|
81
|
+
sid = resp.headers.get("mcp-session-id")
|
|
82
|
+
if sid and not _SESSION_ID:
|
|
83
|
+
_SESSION_ID = sid
|
|
84
|
+
return {
|
|
85
|
+
"status": resp.status,
|
|
86
|
+
"body": _parse_sse(resp.read().decode(errors="replace")),
|
|
87
|
+
}
|
|
88
|
+
except urllib.error.HTTPError as e:
|
|
89
|
+
try:
|
|
90
|
+
body = _parse_sse(e.read().decode(errors="replace"))
|
|
91
|
+
except Exception:
|
|
92
|
+
body = {"raw": str(e)}
|
|
93
|
+
return {"status": e.code, "body": body}
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return {"status": 0, "body": {"error": str(e)}}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_sse(raw: str) -> dict:
|
|
99
|
+
raw = raw.strip()
|
|
100
|
+
if not raw:
|
|
101
|
+
return {"error": "Empty response"}
|
|
102
|
+
data_lines = [
|
|
103
|
+
line[5:].strip() for line in raw.splitlines() if line.startswith("data:")
|
|
104
|
+
]
|
|
105
|
+
if data_lines:
|
|
106
|
+
try:
|
|
107
|
+
return json.loads("\n".join(data_lines))
|
|
108
|
+
except Exception:
|
|
109
|
+
return {"raw_sse": "\n".join(data_lines)}
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(raw)
|
|
112
|
+
except Exception:
|
|
113
|
+
return {"raw": raw[:500]}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def mcp_init(url: str, token: Optional[str]) -> bool:
|
|
117
|
+
reset_session()
|
|
118
|
+
r = rpc(
|
|
119
|
+
url,
|
|
120
|
+
"initialize",
|
|
121
|
+
{
|
|
122
|
+
"protocolVersion": "2024-11-05",
|
|
123
|
+
"capabilities": {},
|
|
124
|
+
"clientInfo": {"name": "mcppt", "version": "2.0"},
|
|
125
|
+
},
|
|
126
|
+
token=token,
|
|
127
|
+
)
|
|
128
|
+
if r["status"] == 200 and "result" in r["body"]:
|
|
129
|
+
rpc(url, "notifications/initialized", {}, token=token, req_id=2)
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decode_jwt(token: str) -> dict:
|
|
135
|
+
try:
|
|
136
|
+
parts = token.split(".")
|
|
137
|
+
if len(parts) != 3:
|
|
138
|
+
return {}
|
|
139
|
+
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
140
|
+
return json.loads(
|
|
141
|
+
base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
|
142
|
+
)
|
|
143
|
+
except Exception:
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def jsonrpc_succeeded(body: dict) -> bool:
|
|
148
|
+
"""True if the response indicates actual tool execution, not an auth rejection."""
|
|
149
|
+
if "result" not in body:
|
|
150
|
+
return False
|
|
151
|
+
content_text = "".join(
|
|
152
|
+
i.get("text", "").lower() for i in body["result"].get("content", [])
|
|
153
|
+
)
|
|
154
|
+
AUTH_KEYWORDS = [
|
|
155
|
+
"unauthorized", "forbidden", "401", "403", "access denied",
|
|
156
|
+
"not authenticated", "invalid token", "token expired",
|
|
157
|
+
]
|
|
158
|
+
return not any(k in content_text for k in AUTH_KEYWORDS)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_auth_error(body: dict) -> bool:
|
|
162
|
+
err = body.get("error", {})
|
|
163
|
+
code = err.get("code", 0)
|
|
164
|
+
msg = str(err.get("message", "")).lower()
|
|
165
|
+
AUTH_KEYWORDS = [
|
|
166
|
+
"unauthorized", "forbidden", "authentication",
|
|
167
|
+
"not authenticated", "invalid token", "access denied",
|
|
168
|
+
]
|
|
169
|
+
return code in (401, 403) or any(k in msg for k in AUTH_KEYWORDS)
|
mcppt/report.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Generate JSON and Markdown reports from a completed ScanState."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .checks import ScanState
|
|
9
|
+
|
|
10
|
+
SEV_ICON = {"CRITICAL": "[CRIT]", "HIGH": "[HIGH]", "MEDIUM": "[MED]", "LOW": "[LOW]"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def save_json(state: ScanState, path: str) -> Path:
|
|
14
|
+
out = Path(path)
|
|
15
|
+
data = {
|
|
16
|
+
"tool": "MCPPT",
|
|
17
|
+
"version": "2.0.0",
|
|
18
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
19
|
+
"target": state.url,
|
|
20
|
+
"elapsed_seconds": round(state.elapsed, 1),
|
|
21
|
+
"summary": {
|
|
22
|
+
sev: sum(1 for f in state.findings if f.severity == sev)
|
|
23
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
|
|
24
|
+
},
|
|
25
|
+
"findings": [
|
|
26
|
+
{
|
|
27
|
+
"check": f.check,
|
|
28
|
+
"severity": f.severity,
|
|
29
|
+
"title": f.title,
|
|
30
|
+
"detail": f.detail,
|
|
31
|
+
}
|
|
32
|
+
for f in state.findings
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_markdown(state: ScanState, path: str) -> Path:
|
|
40
|
+
out = Path(path)
|
|
41
|
+
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
|
42
|
+
|
|
43
|
+
lines = [
|
|
44
|
+
"# MCPPT Security Scan Report",
|
|
45
|
+
"",
|
|
46
|
+
f"**Target:** `{state.url}` ",
|
|
47
|
+
f"**Date:** {ts} ",
|
|
48
|
+
f"**Duration:** {state.elapsed:.1f}s ",
|
|
49
|
+
"",
|
|
50
|
+
"## Summary",
|
|
51
|
+
"",
|
|
52
|
+
"| Severity | Count |",
|
|
53
|
+
"| --- | --- |",
|
|
54
|
+
]
|
|
55
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
56
|
+
count = sum(1 for f in state.findings if f.severity == sev)
|
|
57
|
+
lines.append(f"| {SEV_ICON[sev]} {sev} | {count} |")
|
|
58
|
+
|
|
59
|
+
lines += ["", "## Findings", ""]
|
|
60
|
+
|
|
61
|
+
if not state.findings:
|
|
62
|
+
lines.append("_No findings detected — all checks passed._")
|
|
63
|
+
else:
|
|
64
|
+
for i, f in enumerate(state.findings, 1):
|
|
65
|
+
icon = SEV_ICON.get(f.severity, "⚪")
|
|
66
|
+
lines += [
|
|
67
|
+
f"### {i}. {icon} [{f.severity}] {f.title}",
|
|
68
|
+
"",
|
|
69
|
+
f"**Check:** `{f.check}` ",
|
|
70
|
+
f"**Severity:** {f.severity} ",
|
|
71
|
+
"",
|
|
72
|
+
f.detail,
|
|
73
|
+
"",
|
|
74
|
+
"---",
|
|
75
|
+
"",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
lines += [
|
|
79
|
+
"## Remediation Reference",
|
|
80
|
+
"",
|
|
81
|
+
"| Check | Risk | Fix |",
|
|
82
|
+
"| --- | --- | --- |",
|
|
83
|
+
"| `enum` | Info disclosure | Require auth on `tools/list` |",
|
|
84
|
+
"| `auth` | Auth bypass | Validate Bearer token server-side on every tool call |",
|
|
85
|
+
"| `idor` | Data exposure | Scope resource access to the authenticated user |",
|
|
86
|
+
"| `injection` | Prompt injection | Sanitise/escape all user-supplied strings before returning to LLM |",
|
|
87
|
+
"| `schema` | Input validation | Enforce strict type validation at the MCP server layer |",
|
|
88
|
+
"| `ssrf` | SSRF | Block RFC-1918/link-local URLs; use allowlist for external fetches |",
|
|
89
|
+
"| `publish` | Unconfirmed action | Enforce confirmation gate in MCP layer, not only in agent prompt |",
|
|
90
|
+
"| `rate` | DoS | Add rate limiting per token/IP |",
|
|
91
|
+
"| `stored` | Stored injection | Escape stored content before returning in tool responses |",
|
|
92
|
+
"| `scope` | Privilege escalation | Enforce token scopes server-side per tool |",
|
|
93
|
+
"| `replay` | Replay | Add per-request nonce or timestamp window validation |",
|
|
94
|
+
"| `context_overflow` | Context hijack | Enforce max field length at ingestion |",
|
|
95
|
+
"| `poison_all` | Injection | Sanitise every response field, not just primary content |",
|
|
96
|
+
"| `tenant` | Data leak | Scope cache keys and storage to tenant/user ID |",
|
|
97
|
+
"| `session` | Session hijack | Use CSPRNG ≥128-bit entropy for session IDs (UUID v4) |",
|
|
98
|
+
"| `rug_pull` | Supply chain | Pin tool versions; alert on description changes |",
|
|
99
|
+
"",
|
|
100
|
+
"---",
|
|
101
|
+
"_Generated by [MCPPT](https://github.com/gurudeepmallam-cmd/mcppt) v2.0_",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
out.write_text("\n".join(lines), encoding="utf-8")
|
|
105
|
+
return out
|