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/shell.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""MCPPT Interactive Shell — gobuster/ffuf-style REPL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import cmd
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
console = Console(legacy_windows=False, force_terminal=True, highlight=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _strip(markup: str) -> str:
|
|
23
|
+
return re.sub(r"\[/?[^\[\]]*\]", "", markup)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _print_findings(findings: list) -> None:
|
|
27
|
+
if not findings:
|
|
28
|
+
console.print(" [dim]No findings yet.[/]")
|
|
29
|
+
return
|
|
30
|
+
counts = Counter(f.severity for f in findings)
|
|
31
|
+
console.print(
|
|
32
|
+
f"\n [bold red]CRITICAL {counts.get('CRITICAL',0)}[/] "
|
|
33
|
+
f"[bold yellow]HIGH {counts.get('HIGH',0)}[/] "
|
|
34
|
+
f"[yellow]MEDIUM {counts.get('MEDIUM',0)}[/] "
|
|
35
|
+
f"[cyan]LOW {counts.get('LOW',0)}[/]"
|
|
36
|
+
)
|
|
37
|
+
tbl = Table(box=box.SIMPLE, header_style="bold dim", show_header=True)
|
|
38
|
+
tbl.add_column("#", width=4, justify="right")
|
|
39
|
+
tbl.add_column("Severity", width=10)
|
|
40
|
+
tbl.add_column("Check", width=16)
|
|
41
|
+
tbl.add_column("Title")
|
|
42
|
+
SEV = {"CRITICAL": "bold red", "HIGH": "bold yellow", "MEDIUM": "yellow", "LOW": "cyan"}
|
|
43
|
+
for i, f in enumerate(findings, 1):
|
|
44
|
+
s = SEV.get(f.severity, "white")
|
|
45
|
+
tbl.add_row(str(i), f"[{s}]{f.severity}[/]", f"[dim]{f.check}[/]", f.title)
|
|
46
|
+
console.print(tbl)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── shell ─────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class MCPPTShell(cmd.Cmd):
|
|
52
|
+
intro = ""
|
|
53
|
+
prompt = "\n[mcppt]> "
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.url: Optional[str] = None
|
|
58
|
+
self.token: Optional[str] = None
|
|
59
|
+
self.token2: Optional[str] = None
|
|
60
|
+
self.no_verify: bool = False
|
|
61
|
+
self.proxy: Optional[str] = None
|
|
62
|
+
self.findings: list = []
|
|
63
|
+
self.tools_cache: list = []
|
|
64
|
+
self.ai_key: Optional[str] = None
|
|
65
|
+
self.ai_provider: str = "claude"
|
|
66
|
+
|
|
67
|
+
# ── prompt override (add colour) ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def cmdloop(self, intro=None):
|
|
70
|
+
_banner()
|
|
71
|
+
try:
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
console.print(
|
|
75
|
+
"\n[bold red]mcppt[/][dim]>[/] ",
|
|
76
|
+
end="",
|
|
77
|
+
)
|
|
78
|
+
line = input()
|
|
79
|
+
except EOFError:
|
|
80
|
+
break
|
|
81
|
+
if line.strip():
|
|
82
|
+
self.onecmd(line.strip())
|
|
83
|
+
except KeyboardInterrupt:
|
|
84
|
+
console.print("\n[dim]Use 'exit' to quit.[/]")
|
|
85
|
+
|
|
86
|
+
# ── target ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def do_target(self, arg: str):
|
|
89
|
+
"""Set MCP server URL. target https://your-server.com/mcp"""
|
|
90
|
+
arg = arg.strip()
|
|
91
|
+
if not arg:
|
|
92
|
+
console.print(f" [dim]Current target:[/] {self.url or '[not set]'}")
|
|
93
|
+
return
|
|
94
|
+
self.url = arg
|
|
95
|
+
console.print(f" [green]Target set:[/] {self.url}")
|
|
96
|
+
|
|
97
|
+
# ── token ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def do_token(self, arg: str):
|
|
100
|
+
"""Set primary bearer token. token eyJ..."""
|
|
101
|
+
arg = arg.strip()
|
|
102
|
+
if not arg:
|
|
103
|
+
console.print(f" [dim]Token:[/] {'***set***' if self.token else '[not set]'}")
|
|
104
|
+
return
|
|
105
|
+
self.token = arg
|
|
106
|
+
console.print(f" [green]Token set[/] ({len(arg)} chars)")
|
|
107
|
+
|
|
108
|
+
def do_token2(self, arg: str):
|
|
109
|
+
"""Set second user token for IDOR/scope/tenant checks. token2 eyJ..."""
|
|
110
|
+
arg = arg.strip()
|
|
111
|
+
if not arg:
|
|
112
|
+
console.print(f" [dim]Token2:[/] {'***set***' if self.token2 else '[not set]'}")
|
|
113
|
+
return
|
|
114
|
+
self.token2 = arg
|
|
115
|
+
console.print(f" [green]Token2 set[/] ({len(arg)} chars)")
|
|
116
|
+
|
|
117
|
+
# ── noverify / proxy ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def do_noverify(self, _):
|
|
120
|
+
"""Toggle SSL certificate verification skip."""
|
|
121
|
+
self.no_verify = not self.no_verify
|
|
122
|
+
state = "[yellow]DISABLED[/]" if self.no_verify else "[green]enabled[/]"
|
|
123
|
+
console.print(f" SSL verify: {state}")
|
|
124
|
+
|
|
125
|
+
def do_proxy(self, arg: str):
|
|
126
|
+
"""Set or clear Burp proxy. proxy http://127.0.0.1:8080 | proxy off"""
|
|
127
|
+
arg = arg.strip()
|
|
128
|
+
if arg in ("off", "none", "clear", ""):
|
|
129
|
+
self.proxy = None
|
|
130
|
+
console.print(" [dim]Proxy cleared[/]")
|
|
131
|
+
else:
|
|
132
|
+
self.proxy = arg
|
|
133
|
+
console.print(f" [green]Proxy set:[/] {self.proxy}")
|
|
134
|
+
|
|
135
|
+
# ── status ────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def do_status(self, _):
|
|
138
|
+
"""Show current configuration."""
|
|
139
|
+
console.print()
|
|
140
|
+
console.print(f" [dim]Target [/] {self.url or '[bold red]not set[/]'}")
|
|
141
|
+
console.print(f" [dim]Token [/] {'***' if self.token else '[yellow]not set[/]'}")
|
|
142
|
+
console.print(f" [dim]Token2 [/] {'***' if self.token2 else '[dim]not set[/]'}")
|
|
143
|
+
console.print(f" [dim]SSL [/] {'[yellow]verify OFF[/]' if self.no_verify else 'verify on'}")
|
|
144
|
+
console.print(f" [dim]Proxy [/] {self.proxy or 'none'}")
|
|
145
|
+
console.print(f" [dim]AI [/] {self.ai_provider + ' (key set)' if self.ai_key else '[dim]not configured[/]'}")
|
|
146
|
+
console.print(f" [dim]Findings[/] {len(self.findings)}")
|
|
147
|
+
|
|
148
|
+
# ── scan ──────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def do_scan(self, arg: str):
|
|
151
|
+
"""Run security scan. scan [checks] e.g. scan all | scan auth ssrf idor"""
|
|
152
|
+
if not self.url:
|
|
153
|
+
console.print(" [red]Set target first:[/] target https://your-server.com/mcp")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
from .core import configure
|
|
157
|
+
from .checks import ScanState, run_scan, ALL_CHECKS
|
|
158
|
+
|
|
159
|
+
configure(no_verify=self.no_verify, proxy=self.proxy)
|
|
160
|
+
|
|
161
|
+
parts = arg.strip().split() if arg.strip() else ["all"]
|
|
162
|
+
checks = parts if parts[0] != "all" else ["all"]
|
|
163
|
+
run_all = "all" in checks
|
|
164
|
+
total = len(ALL_CHECKS) if run_all else len([c for c in checks if c in ALL_CHECKS])
|
|
165
|
+
|
|
166
|
+
state = ScanState(
|
|
167
|
+
url=self.url,
|
|
168
|
+
token=self.token,
|
|
169
|
+
token2=self.token2,
|
|
170
|
+
checks_total=total,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
console.print(f"\n [bold]Scanning[/] [cyan]{self.url}[/] checks=[yellow]{','.join(checks)}[/]")
|
|
174
|
+
console.print(" [dim]─────────────────────────────────[/]")
|
|
175
|
+
|
|
176
|
+
seen = [0]
|
|
177
|
+
|
|
178
|
+
def _stream():
|
|
179
|
+
while not state.done:
|
|
180
|
+
with state._lock:
|
|
181
|
+
new = state.log_lines[seen[0]:]
|
|
182
|
+
seen[0] = len(state.log_lines)
|
|
183
|
+
for line in new:
|
|
184
|
+
clean = _strip(line)
|
|
185
|
+
if "CHECK" in clean:
|
|
186
|
+
console.print(f" [bold white]{clean.replace('[CHECK]','').strip()}[/]")
|
|
187
|
+
elif "PASS" in clean:
|
|
188
|
+
console.print(f" [green] PASS[/] {clean.replace('[PASS]','').strip()}")
|
|
189
|
+
elif "INFO" in clean:
|
|
190
|
+
console.print(f" [dim] INFO {clean.replace('[INFO]','').strip()}[/]")
|
|
191
|
+
elif "CRIT" in clean:
|
|
192
|
+
console.print(f" [bold red] CRIT[/] {clean.replace('CRIT','').strip()}")
|
|
193
|
+
elif "HIGH" in clean:
|
|
194
|
+
console.print(f" [bold yellow] HIGH[/] {clean.replace('HIGH','').strip()}")
|
|
195
|
+
elif "MED" in clean:
|
|
196
|
+
console.print(f" [yellow] MED[/] {clean.replace('MED ','').strip()}")
|
|
197
|
+
elif "LOW" in clean:
|
|
198
|
+
console.print(f" [cyan] LOW[/] {clean.replace('LOW ','').strip()}")
|
|
199
|
+
time.sleep(0.1)
|
|
200
|
+
|
|
201
|
+
t = threading.Thread(target=run_scan, args=(state, checks), daemon=True)
|
|
202
|
+
s = threading.Thread(target=_stream, daemon=True)
|
|
203
|
+
t.start()
|
|
204
|
+
s.start()
|
|
205
|
+
t.join()
|
|
206
|
+
time.sleep(0.3)
|
|
207
|
+
s.join(timeout=1)
|
|
208
|
+
|
|
209
|
+
self.findings = state.findings
|
|
210
|
+
counts = Counter(f.severity for f in state.findings)
|
|
211
|
+
console.print("\n [dim]─────────────────────────────────[/]")
|
|
212
|
+
console.print(
|
|
213
|
+
f" Done in {state.elapsed:.1f}s | "
|
|
214
|
+
f"[bold red]{counts.get('CRITICAL',0)} CRITICAL[/] "
|
|
215
|
+
f"[bold yellow]{counts.get('HIGH',0)} HIGH[/] "
|
|
216
|
+
f"[yellow]{counts.get('MEDIUM',0)} MEDIUM[/] "
|
|
217
|
+
f"[cyan]{counts.get('LOW',0)} LOW[/]"
|
|
218
|
+
)
|
|
219
|
+
if state.findings:
|
|
220
|
+
console.print(" [dim]Run 'findings' to see details, 'analyze' for AI analysis.[/]")
|
|
221
|
+
|
|
222
|
+
# ── list ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def do_list(self, _):
|
|
225
|
+
"""Enumerate tools on the target MCP server."""
|
|
226
|
+
if not self.url:
|
|
227
|
+
console.print(" [red]Set target first.[/]")
|
|
228
|
+
return
|
|
229
|
+
from .core import configure, mcp_init, rpc
|
|
230
|
+
configure(no_verify=self.no_verify, proxy=self.proxy)
|
|
231
|
+
mcp_init(self.url, self.token)
|
|
232
|
+
r = rpc(self.url, "tools/list", {}, token=self.token)
|
|
233
|
+
tools = r["body"].get("result", {}).get("tools", []) if r["status"] == 200 else []
|
|
234
|
+
if not tools:
|
|
235
|
+
console.print(f" [yellow]No tools returned (HTTP {r['status']})[/]")
|
|
236
|
+
return
|
|
237
|
+
self.tools_cache = tools
|
|
238
|
+
console.print(f"\n [bold]{len(tools)} tools[/] on [cyan]{self.url}[/]\n")
|
|
239
|
+
for t in tools:
|
|
240
|
+
name = t.get("name", "?")
|
|
241
|
+
desc = t.get("description", "").split("\n")[0][:70]
|
|
242
|
+
props = t.get("inputSchema", {}).get("properties", {})
|
|
243
|
+
req = t.get("inputSchema", {}).get("required", [])
|
|
244
|
+
args_str = " ".join(
|
|
245
|
+
f"[{'red' if f in req else 'dim'}]{f}[/]({m.get('type','?')})"
|
|
246
|
+
for f, m in props.items()
|
|
247
|
+
)
|
|
248
|
+
console.print(f" [bold cyan]{name}[/] [dim]{desc}[/]")
|
|
249
|
+
if args_str:
|
|
250
|
+
console.print(f" args: {args_str}")
|
|
251
|
+
|
|
252
|
+
# ── call ──────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def do_call(self, arg: str):
|
|
255
|
+
"""Call a tool. call <tool_name> [json_args]
|
|
256
|
+
Examples:
|
|
257
|
+
call get_notes
|
|
258
|
+
call get_user {"id": 1}
|
|
259
|
+
call save_note {"text": "hello"}"""
|
|
260
|
+
if not self.url:
|
|
261
|
+
console.print(" [red]Set target first.[/]")
|
|
262
|
+
return
|
|
263
|
+
parts = arg.strip().split(None, 1)
|
|
264
|
+
if not parts:
|
|
265
|
+
console.print(" [dim]Usage: call <tool_name> [json_args][/]")
|
|
266
|
+
return
|
|
267
|
+
tool_name = parts[0]
|
|
268
|
+
raw_args = parts[1] if len(parts) > 1 else "{}"
|
|
269
|
+
try:
|
|
270
|
+
tool_args = json.loads(raw_args)
|
|
271
|
+
except json.JSONDecodeError as e:
|
|
272
|
+
console.print(f" [red]Invalid JSON:[/] {e}")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
from .core import configure, mcp_init, rpc
|
|
276
|
+
configure(no_verify=self.no_verify, proxy=self.proxy)
|
|
277
|
+
mcp_init(self.url, self.token)
|
|
278
|
+
r = rpc(self.url, "tools/call", {"name": tool_name, "arguments": tool_args}, token=self.token)
|
|
279
|
+
status_col = "green" if r["status"] == 200 else "red"
|
|
280
|
+
console.print(f"\n [{status_col}]HTTP {r['status']}[/] tool=[cyan]{tool_name}[/]")
|
|
281
|
+
|
|
282
|
+
body = r["body"]
|
|
283
|
+
result = body.get("result", {})
|
|
284
|
+
content = result.get("content", [])
|
|
285
|
+
if content:
|
|
286
|
+
for item in content:
|
|
287
|
+
if item.get("type") == "text":
|
|
288
|
+
try:
|
|
289
|
+
parsed = json.loads(item["text"])
|
|
290
|
+
console.print_json(json.dumps(parsed))
|
|
291
|
+
except Exception:
|
|
292
|
+
console.print(f" {item['text']}")
|
|
293
|
+
elif "error" in body:
|
|
294
|
+
console.print(f" [red]Error:[/] {body['error']}")
|
|
295
|
+
else:
|
|
296
|
+
console.print_json(json.dumps(body))
|
|
297
|
+
|
|
298
|
+
# ── findings ──────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def do_findings(self, _):
|
|
301
|
+
"""Show all findings from the last scan."""
|
|
302
|
+
_print_findings(self.findings)
|
|
303
|
+
|
|
304
|
+
def do_clear(self, _):
|
|
305
|
+
"""Clear current findings."""
|
|
306
|
+
self.findings = []
|
|
307
|
+
console.print(" [dim]Findings cleared.[/]")
|
|
308
|
+
|
|
309
|
+
# ── report ────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def do_report(self, arg: str):
|
|
312
|
+
"""Export findings to file. report [filename]
|
|
313
|
+
Examples:
|
|
314
|
+
report → report.md (default)
|
|
315
|
+
report out.json → JSON format
|
|
316
|
+
report pentest.md → Markdown"""
|
|
317
|
+
if not self.findings:
|
|
318
|
+
console.print(" [yellow]No findings to export. Run 'scan' first.[/]")
|
|
319
|
+
return
|
|
320
|
+
from .report import save_json, save_markdown
|
|
321
|
+
from .checks import ScanState
|
|
322
|
+
state = ScanState(url=self.url or "unknown", token=self.token, token2=self.token2)
|
|
323
|
+
state.findings = self.findings
|
|
324
|
+
|
|
325
|
+
path = arg.strip() or "report.md"
|
|
326
|
+
p = save_json(state, path) if path.endswith(".json") else save_markdown(state, path)
|
|
327
|
+
console.print(f" [green]Report saved:[/] {p}")
|
|
328
|
+
|
|
329
|
+
# ── AI ────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def do_ai(self, arg: str):
|
|
332
|
+
"""Set AI provider + API key for finding analysis.
|
|
333
|
+
Usage:
|
|
334
|
+
ai claude sk-ant-api03-... → Claude (default)
|
|
335
|
+
ai openai sk-... → OpenAI GPT-4
|
|
336
|
+
ai off → disable AI"""
|
|
337
|
+
parts = arg.strip().split(None, 1)
|
|
338
|
+
if not parts or parts[0] == "off":
|
|
339
|
+
self.ai_key = None
|
|
340
|
+
console.print(" [dim]AI analysis disabled.[/]")
|
|
341
|
+
return
|
|
342
|
+
if len(parts) == 1:
|
|
343
|
+
# just a key, assume claude
|
|
344
|
+
self.ai_provider = "claude"
|
|
345
|
+
self.ai_key = parts[0]
|
|
346
|
+
else:
|
|
347
|
+
self.ai_provider = parts[0].lower()
|
|
348
|
+
self.ai_key = parts[1]
|
|
349
|
+
console.print(f" [green]AI set:[/] {self.ai_provider} key=***{self.ai_key[-6:]}")
|
|
350
|
+
|
|
351
|
+
def do_analyze(self, _):
|
|
352
|
+
"""Send findings to Claude/OpenAI for attack narrative + remediation priority."""
|
|
353
|
+
if not self.findings:
|
|
354
|
+
console.print(" [yellow]No findings. Run 'scan' first.[/]")
|
|
355
|
+
return
|
|
356
|
+
if not self.ai_key:
|
|
357
|
+
console.print(" [yellow]No AI key. Run: ai claude sk-ant-...[/]")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
findings_text = "\n".join(
|
|
361
|
+
f"- [{f.severity}] [{f.check}] {f.title}: {f.detail}"
|
|
362
|
+
for f in self.findings
|
|
363
|
+
)
|
|
364
|
+
prompt = f"""You are a security analyst reviewing findings from an automated MCP server security scan.
|
|
365
|
+
|
|
366
|
+
Target: {self.url}
|
|
367
|
+
|
|
368
|
+
Findings:
|
|
369
|
+
{findings_text}
|
|
370
|
+
|
|
371
|
+
Provide:
|
|
372
|
+
1. Attack chain narrative — how an attacker would chain these findings together
|
|
373
|
+
2. Top 3 findings to fix first (with one-line reason why)
|
|
374
|
+
3. Overall risk rating (CRITICAL / HIGH / MEDIUM / LOW) with one sentence justification
|
|
375
|
+
|
|
376
|
+
Be concise. Use bullet points. No preamble."""
|
|
377
|
+
|
|
378
|
+
console.print("\n [dim]Sending to AI for analysis...[/]")
|
|
379
|
+
try:
|
|
380
|
+
response = _call_ai(self.ai_provider, self.ai_key, prompt)
|
|
381
|
+
console.print(f"\n[bold]AI Analysis ({self.ai_provider})[/]")
|
|
382
|
+
console.rule(style="dim")
|
|
383
|
+
console.print(response)
|
|
384
|
+
console.rule(style="dim")
|
|
385
|
+
except Exception as e:
|
|
386
|
+
console.print(f" [red]AI error:[/] {e}")
|
|
387
|
+
|
|
388
|
+
# ── help ──────────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
def do_help(self, _):
|
|
391
|
+
console.print("""
|
|
392
|
+
[bold]MCPPT Interactive Shell[/] -- commands:
|
|
393
|
+
|
|
394
|
+
[bold cyan]Setup[/]
|
|
395
|
+
target <url> Set MCP server URL
|
|
396
|
+
token <bearer> Set primary auth token
|
|
397
|
+
token2 <bearer> Set second token (IDOR/scope/tenant checks)
|
|
398
|
+
noverify Toggle SSL verification skip
|
|
399
|
+
proxy <url|off> Set/clear Burp proxy
|
|
400
|
+
status Show current configuration
|
|
401
|
+
|
|
402
|
+
[bold cyan]Scan[/]
|
|
403
|
+
scan [checks] Run security scan
|
|
404
|
+
Examples:
|
|
405
|
+
scan (all 16 checks)
|
|
406
|
+
scan auth ssrf
|
|
407
|
+
scan idor scope tenant
|
|
408
|
+
Checks: enum auth idor injection schema ssrf publish
|
|
409
|
+
rate stored scope replay context_overflow
|
|
410
|
+
poison_all tenant session rug_pull
|
|
411
|
+
headers error_disclosure tool_poisoning
|
|
412
|
+
resources cmd_injection path_traversal
|
|
413
|
+
jwt_audit oauth_discovery secret_scan
|
|
414
|
+
tool_shadowing
|
|
415
|
+
|
|
416
|
+
[bold cyan]Explore[/]
|
|
417
|
+
list Enumerate tools + schemas
|
|
418
|
+
call <tool> [json] Call a tool manually
|
|
419
|
+
Examples:
|
|
420
|
+
call get_notes
|
|
421
|
+
call get_user {"id": 1}
|
|
422
|
+
|
|
423
|
+
[bold cyan]Results[/]
|
|
424
|
+
findings Show findings from last scan
|
|
425
|
+
clear Clear findings
|
|
426
|
+
report [file.md|.json] Export report (default: report.md)
|
|
427
|
+
|
|
428
|
+
[bold cyan]AI Analysis[/]
|
|
429
|
+
ai claude <sk-ant-key> Configure Claude for analysis
|
|
430
|
+
ai openai <sk-key> Configure OpenAI GPT-4
|
|
431
|
+
ai off Disable AI
|
|
432
|
+
analyze Analyze findings with AI
|
|
433
|
+
|
|
434
|
+
[bold cyan]Shell[/]
|
|
435
|
+
help This menu
|
|
436
|
+
exit / quit / q Exit
|
|
437
|
+
""")
|
|
438
|
+
|
|
439
|
+
def do_exit(self, _):
|
|
440
|
+
"""Exit the shell."""
|
|
441
|
+
console.print("\n [dim]Bye.[/]\n")
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
do_quit = do_exit
|
|
445
|
+
do_q = do_exit
|
|
446
|
+
|
|
447
|
+
def default(self, line: str):
|
|
448
|
+
console.print(f" [red]Unknown command:[/] {line.split()[0]} (type 'help')")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ── AI backend ────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
def _call_ai(provider: str, key: str, prompt: str) -> str:
|
|
454
|
+
if provider == "claude":
|
|
455
|
+
try:
|
|
456
|
+
import anthropic
|
|
457
|
+
client = anthropic.Anthropic(api_key=key)
|
|
458
|
+
msg = client.messages.create(
|
|
459
|
+
model="claude-opus-4-8",
|
|
460
|
+
max_tokens=1024,
|
|
461
|
+
messages=[{"role": "user", "content": prompt}],
|
|
462
|
+
)
|
|
463
|
+
return msg.content[0].text
|
|
464
|
+
except ImportError:
|
|
465
|
+
raise RuntimeError("Install anthropic SDK: pip install anthropic")
|
|
466
|
+
|
|
467
|
+
elif provider in ("openai", "gpt"):
|
|
468
|
+
try:
|
|
469
|
+
from openai import OpenAI
|
|
470
|
+
client = OpenAI(api_key=key)
|
|
471
|
+
resp = client.chat.completions.create(
|
|
472
|
+
model="gpt-4o",
|
|
473
|
+
messages=[{"role": "user", "content": prompt}],
|
|
474
|
+
max_tokens=1024,
|
|
475
|
+
)
|
|
476
|
+
return resp.choices[0].message.content
|
|
477
|
+
except ImportError:
|
|
478
|
+
raise RuntimeError("Install openai SDK: pip install openai")
|
|
479
|
+
|
|
480
|
+
else:
|
|
481
|
+
raise RuntimeError(f"Unknown provider '{provider}'. Use: claude or openai")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ── banner ────────────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
def _banner():
|
|
487
|
+
_ART = [
|
|
488
|
+
r" __ __ ___ ___ _____ ____ ___ _____ _____ ___ ___",
|
|
489
|
+
r" | \/ | / __|| _ \|_ _| _ \ / _ \_ _|_ _| __| _ \ ",
|
|
490
|
+
r" | |\/| || (__| _/ | | | / | (_) || | | | | _|| /",
|
|
491
|
+
r" |_| |_| \___||_| |_| |_|_\ \___/ |_| |_| |___|_|_\ ",
|
|
492
|
+
]
|
|
493
|
+
console.print()
|
|
494
|
+
for line in _ART:
|
|
495
|
+
console.print(Text(line, style="bold red"))
|
|
496
|
+
console.print()
|
|
497
|
+
console.print(Text(" MCP Pentest Tool v2.3 -- 28 automated security checks", style="dim"))
|
|
498
|
+
console.print()
|
|
499
|
+
console.print(Text(" by Gurudeep Mallam", style="bold white"))
|
|
500
|
+
console.print(Text(" github : https://github.com/gurudeepmallam-cmd", style="dim cyan"))
|
|
501
|
+
console.print(Text(" linkedin: https://in.linkedin.com/in/mallam-gurudeep-7734941aa", style="dim cyan"))
|
|
502
|
+
console.print()
|
|
503
|
+
console.print(Text(" type 'help' for commands, 'exit' to quit", style="dim"))
|
|
504
|
+
console.print()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def launch_shell():
|
|
508
|
+
MCPPTShell().cmdloop()
|
mcppt/tui.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Rich live TUI for MCPPT scan display."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import Optional, Callable
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.layout import Layout
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from .checks import ScanState
|
|
18
|
+
|
|
19
|
+
# force_terminal + legacy_windows=False: use ANSI escape codes, not the legacy
|
|
20
|
+
# Win32 console API that is limited to CP1252 and can't render box-drawing chars.
|
|
21
|
+
console = Console(highlight=False, legacy_windows=False, force_terminal=True)
|
|
22
|
+
|
|
23
|
+
SEV_STYLE = {
|
|
24
|
+
"CRITICAL": "bold red",
|
|
25
|
+
"HIGH": "bold yellow",
|
|
26
|
+
"MEDIUM": "yellow",
|
|
27
|
+
"LOW": "cyan",
|
|
28
|
+
}
|
|
29
|
+
# ASCII-safe severity indicators (no emoji — works on all terminals/encodings)
|
|
30
|
+
SEV_ICON = {
|
|
31
|
+
"CRITICAL": "[CRIT]",
|
|
32
|
+
"HIGH": "[HIGH]",
|
|
33
|
+
"MEDIUM": "[MED] ",
|
|
34
|
+
"LOW": "[LOW] ",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_scan_start: float = 0.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Layout builders ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def _header(url: str, token: Optional[str]) -> Panel:
|
|
43
|
+
auth = "[green]token provided[/]" if token else "[yellow]unauthenticated[/]"
|
|
44
|
+
t = Text()
|
|
45
|
+
t.append("MCPPT", style="bold red")
|
|
46
|
+
t.append(" v2.0 -- MCP Pentest Tool\n", style="white")
|
|
47
|
+
t.append("Target ", style="dim")
|
|
48
|
+
t.append(url, style="bold cyan")
|
|
49
|
+
t.append(f" | Auth {auth}")
|
|
50
|
+
return Panel(t, style="red", padding=(0, 2))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _findings_panel(state: ScanState) -> Panel:
|
|
54
|
+
counts = Counter(f.severity for f in state.findings)
|
|
55
|
+
|
|
56
|
+
summary = Table(show_header=False, box=None, padding=(0, 1))
|
|
57
|
+
summary.add_column(width=14)
|
|
58
|
+
summary.add_column(justify="right", width=4)
|
|
59
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
60
|
+
n = counts.get(sev, 0)
|
|
61
|
+
style = SEV_STYLE[sev] if n else "dim"
|
|
62
|
+
summary.add_row(f"{SEV_ICON[sev]} {sev}", f"[{style}]{n}[/]")
|
|
63
|
+
|
|
64
|
+
details = Text()
|
|
65
|
+
for f in state.findings[-12:]:
|
|
66
|
+
s = SEV_STYLE.get(f.severity, "white")
|
|
67
|
+
details.append(f"\n{SEV_ICON.get(f.severity,'')} ", style=s)
|
|
68
|
+
details.append(f.title[:52], style=s)
|
|
69
|
+
|
|
70
|
+
return Panel(Group(summary, details), title="[bold]Findings[/]", border_style="red")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _log_panel(state: ScanState) -> Panel:
|
|
74
|
+
lines = state.log_lines[-28:]
|
|
75
|
+
t = Text()
|
|
76
|
+
for line in lines:
|
|
77
|
+
t.append(line + "\n")
|
|
78
|
+
return Panel(t, title="[bold]Live Output[/]", border_style="dim white")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _footer(state: ScanState) -> Panel:
|
|
82
|
+
done = state.checks_done
|
|
83
|
+
total = max(state.checks_total, 1)
|
|
84
|
+
pct = done / total
|
|
85
|
+
filled = int(pct * 38)
|
|
86
|
+
bar = "[green]" + "█" * filled + "[/][dim]" + "░" * (38 - filled) + "[/]"
|
|
87
|
+
elapsed = int(state.elapsed) if state.done else int(time.time() - _scan_start)
|
|
88
|
+
status = "[green]Complete ✓[/]" if state.done else f"[yellow]{state.current_check}[/]"
|
|
89
|
+
t = Text.from_markup(f"{bar} {done}/{total} · {elapsed}s · {status}")
|
|
90
|
+
return Panel(t, style="dim", padding=(0, 1))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def run_tui(state: ScanState, scan_fn: Callable, *args) -> None:
|
|
96
|
+
"""Run scan_fn(*args) in a background thread, render live TUI until done."""
|
|
97
|
+
global _scan_start
|
|
98
|
+
_scan_start = time.time()
|
|
99
|
+
|
|
100
|
+
thread = threading.Thread(target=scan_fn, args=args, daemon=True)
|
|
101
|
+
thread.start()
|
|
102
|
+
|
|
103
|
+
layout = Layout()
|
|
104
|
+
layout.split_column(
|
|
105
|
+
Layout(name="header", size=5),
|
|
106
|
+
Layout(name="body"),
|
|
107
|
+
Layout(name="footer", size=3),
|
|
108
|
+
)
|
|
109
|
+
layout["body"].split_row(
|
|
110
|
+
Layout(name="findings", ratio=2),
|
|
111
|
+
Layout(name="log", ratio=3),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
with Live(layout, refresh_per_second=4, console=console, screen=False):
|
|
115
|
+
while not state.done:
|
|
116
|
+
_update(layout, state)
|
|
117
|
+
time.sleep(0.25)
|
|
118
|
+
_update(layout, state) # final render
|
|
119
|
+
|
|
120
|
+
thread.join(timeout=5)
|
|
121
|
+
_print_summary(state)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _update(layout: Layout, state: ScanState) -> None:
|
|
125
|
+
layout["header"].update(_header(state.url, state.token))
|
|
126
|
+
layout["body"]["findings"].update(_findings_panel(state))
|
|
127
|
+
layout["body"]["log"].update(_log_panel(state))
|
|
128
|
+
layout["footer"].update(_footer(state))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Post-scan summary ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _print_summary(state: ScanState) -> None:
|
|
134
|
+
counts = Counter(f.severity for f in state.findings)
|
|
135
|
+
console.print()
|
|
136
|
+
console.rule("[bold red]SCAN COMPLETE[/]")
|
|
137
|
+
console.print(f" [dim]Target:[/] [cyan]{state.url}[/]")
|
|
138
|
+
console.print(
|
|
139
|
+
f" [dim]Duration:[/] {state.elapsed:.1f}s "
|
|
140
|
+
f"[dim]Findings:[/] "
|
|
141
|
+
f"[bold red]{counts.get('CRITICAL',0)} CRITICAL[/] "
|
|
142
|
+
f"[bold yellow]{counts.get('HIGH',0)} HIGH[/] "
|
|
143
|
+
f"[yellow]{counts.get('MEDIUM',0)} MEDIUM[/] "
|
|
144
|
+
f"[cyan]{counts.get('LOW',0)} LOW[/]"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if state.findings:
|
|
148
|
+
console.print()
|
|
149
|
+
tbl = Table(box=box.SIMPLE, header_style="bold dim", show_header=True)
|
|
150
|
+
tbl.add_column("Severity", width=10)
|
|
151
|
+
tbl.add_column("Check", width=16)
|
|
152
|
+
tbl.add_column("Title")
|
|
153
|
+
for f in state.findings:
|
|
154
|
+
s = SEV_STYLE.get(f.severity, "white")
|
|
155
|
+
tbl.add_row(f"[{s}]{f.severity}[/]", f"[dim]{f.check}[/]", f.title)
|
|
156
|
+
console.print(tbl)
|
|
157
|
+
else:
|
|
158
|
+
console.print("\n [green]✓ All checks passed — no findings detected.[/]\n")
|
|
159
|
+
|
|
160
|
+
console.rule(style="dim")
|