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/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")