pentool 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.
Files changed (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,820 @@
1
+ """Dashboard — стартовый экран приложения."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import time
8
+ from collections import deque
9
+ from datetime import datetime
10
+
11
+ from textual import on, work
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical
15
+ from textual.timer import Timer
16
+ from textual.widget import Widget
17
+ from pathlib import Path
18
+
19
+ _CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
20
+
21
+ from textual.widgets import (
22
+ RichLog,
23
+ Static,
24
+ TabbedContent,
25
+ TabPane,
26
+ Tree,
27
+ )
28
+
29
+ from pentool.tui.widgets.toolbar_button import ToolbarButton
30
+ from pentool.tui.screens.dashboard.live_dashboard import LiveDashboardTab
31
+
32
+ from pentool.core.logging import get_logger
33
+
34
+ logger = get_logger(__name__)
35
+
36
+ # Модульные константы
37
+ _LOGO = """\
38
+ [bold green] ██████╗ [/][bold cyan]███████╗[/][bold green]███╗ ██╗[/][bold cyan]████████╗[/][bold green] ██████╗ ██████╗ ██╗[/]
39
+ [bold green] ██╔══██╗[/][bold cyan]██╔════╝[/][bold green]████╗ ██║[/][bold cyan]╚══██╔══╝[/][bold green]██╔═══██╗██╔═══██╗██║[/]
40
+ [bold green] ██████╔╝[/][bold cyan]█████╗ [/][bold green]██╔██╗ ██║[/][bold cyan] ██║ [/][bold green]██║ ██║██║ ██║██║[/]
41
+ [bold green] ██╔═══╝ [/][bold cyan]██╔══╝ [/][bold green]██║╚██╗██║[/][bold cyan] ██║ [/][bold green]██║ ██║██║ ██║██║[/]
42
+ [bold green] ██║ [/][bold cyan]███████╗[/][bold green]██║ ╚████║[/][bold cyan] ██║ [/][bold green]╚██████╔╝╚██████╔╝███████╗[/]
43
+ [bold green] ╚═╝ [/][bold cyan]╚══════╝[/][bold green]╚═╝ ╚═══╝[/][bold cyan] ╚═╝ [/][bold green] ╚═════╝ ╚═════╝ ╚══════╝[/]
44
+ [dim green]──────────────────── Web Security Testing Platform v1.0 ─────────────────────[/]
45
+ [dim] by @sudores (aka DoctorX) [/]"""
46
+
47
+ _BOOT_LINES = [
48
+ ("[dim green]", "> Mounting kernel modules..."),
49
+ ("[dim green]", "> Loading OWASP Top 10 payload database..."),
50
+ ("[bold green]", "> [OK] SQLi / XSS / SSTI / LFI / RCE / SSRF engine READY"),
51
+ ("[dim green]", "> Initializing passive scanner hooks..."),
52
+ ("[bold green]", "> [OK] Passive scanner ARMED"),
53
+ ("[dim green]", "> Starting spider engine (depth=3, pages=100)..."),
54
+ ("[bold green]", "> [OK] Spider engine READY"),
55
+ ("[dim green]", "> Loading certificate authority..."),
56
+ ("[bold green]", "> [OK] MITM proxy engine READY"),
57
+ ("[bold green]", "> All systems GO. Happy hacking! 🔒"),
58
+ ]
59
+
60
+ _SPARK_CHARS = " ▁▂▃▄▅▆▇█"
61
+
62
+ _SEV_COLORS = {
63
+ "critical": "bold red",
64
+ "high": "red",
65
+ "medium": "yellow",
66
+ "low": "green",
67
+ "info": "blue",
68
+ }
69
+ _SEV_ICONS = {
70
+ "critical": "💀",
71
+ "high": "🔴",
72
+ "medium": "🟡",
73
+ "low": "🟢",
74
+ "info": "🔵",
75
+ }
76
+
77
+ _THREAT_CHARS = "█"
78
+ _THREAT_COLORS = ["green", "green", "yellow", "yellow", "red", "red", "bold red", "bold red", "bold red", "bold red"]
79
+
80
+ def _sparkline(values: list[int], width: int = 30, max_val: int | None = None) -> str:
81
+ """Строит sparkline из значений."""
82
+ if not values:
83
+ return " " * width
84
+ mv = max_val or max(values) or 1
85
+ vals = list(values)[-width:]
86
+ if len(vals) < width:
87
+ vals = [0] * (width - len(vals)) + vals
88
+ result = ""
89
+ for v in vals:
90
+ idx = int((v / mv) * (len(_SPARK_CHARS) - 1))
91
+ result += _SPARK_CHARS[min(idx, len(_SPARK_CHARS) - 1)]
92
+ return result
93
+
94
+ def _bar(value: int, max_val: int, width: int = 20, color: str = "green") -> str:
95
+ """Горизонтальный bar с цветом."""
96
+ if max_val == 0:
97
+ filled = 0
98
+ else:
99
+ filled = int((value / max_val) * width)
100
+ filled = max(0, min(width, filled))
101
+ bar = "█" * filled + "░" * (width - filled)
102
+ return f"[{color}]{bar}[/{color}]"
103
+
104
+ def _threat_gauge(level: int, width: int = 40) -> str:
105
+ """Threat level gauge (0–100)."""
106
+ level = max(0, min(100, level))
107
+ filled = int((level / 100) * width)
108
+ color = _THREAT_COLORS[min(9, level // 10)]
109
+ bar = "█" * filled + "░" * (width - filled)
110
+ label = f" {level:3d}%"
111
+ if level == 0:
112
+ label_color = "dim"
113
+ threat_word = "NONE"
114
+ elif level < 20:
115
+ label_color = "green"
116
+ threat_word = "LOW"
117
+ elif level < 40:
118
+ label_color = "yellow"
119
+ threat_word = "MEDIUM"
120
+ elif level < 70:
121
+ label_color = "red"
122
+ threat_word = "HIGH"
123
+ else:
124
+ label_color = "bold red"
125
+ threat_word = "CRITICAL"
126
+ return f"[{color}]{bar}[/{color}][{label_color}]{label} [{threat_word}][/{label_color}]"
127
+
128
+ def _matrix_cell(count: int) -> str:
129
+ """Ячейка матрицы severity для heatmap."""
130
+ if count == 0:
131
+ return "[dim]·[/dim]"
132
+ elif count < 3:
133
+ return "[green]▪[/green]"
134
+ elif count < 10:
135
+ return "[yellow]▪[/yellow]"
136
+ elif count < 30:
137
+ return "[red]▪[/red]"
138
+ else:
139
+ return "[bold red]▪[/bold red]"
140
+
141
+ class LiveChart(Vertical):
142
+ """ASCII sparkline-график с живым обновлением."""
143
+
144
+ DEFAULT_CSS = _CSS
145
+
146
+ def __init__(self, title: str, color: str = "green", unit: str = "req/s", chart_id: str = "", **kwargs):
147
+ super().__init__(id=chart_id or None, **kwargs)
148
+ self._title = title
149
+ self._color = color
150
+ self._unit = unit
151
+ self._history: deque[int] = deque([0] * 60, maxlen=60)
152
+ self._total = 0
153
+ self._peak = 0
154
+ self._last_rate = 0
155
+ self._summary: str = "" # дополнительная информационная строка
156
+
157
+ def compose(self) -> ComposeResult:
158
+ yield Static(f"┌─ {self._title} ─", id="chart-title")
159
+ yield Static(" ", id="chart-spark")
160
+ yield Static(" ", id="chart-axis")
161
+ yield Static(" ", id="chart-stats")
162
+ yield Static(" ", id="chart-meta")
163
+ yield Static(" ", id="chart-summary")
164
+
165
+ def set_summary(self, text: str) -> None:
166
+ """Установить дополнительную строку с агрегированной информацией."""
167
+ self._summary = text
168
+ try:
169
+ self.query_one("#chart-summary", Static).update(text)
170
+ except Exception:
171
+ pass
172
+
173
+ def on_mount(self) -> None:
174
+ self.call_after_refresh(self._render_chart)
175
+
176
+ def push(self, value: int) -> None:
177
+ """Добавить новое значение и перерисовать."""
178
+ self._history.append(value)
179
+ self._total += value
180
+ if value > self._peak:
181
+ self._peak = value
182
+ self._last_rate = value
183
+ self._render_chart()
184
+
185
+ def _render_chart(self) -> None:
186
+ vals = list(self._history)
187
+ mv = max(vals) if vals else 1
188
+ spark = _sparkline(vals, width=50, max_val=max(mv, 1))
189
+ axis = "[dim]└" + "─" * 48 + "60s[/dim]"
190
+ avg = sum(vals) / max(len([v for v in vals if v > 0]), 1)
191
+ stats = (
192
+ f"[{self._color}]now:[/{self._color}][bold] {self._last_rate:4d}[/bold] "
193
+ f"[dim]avg:[/dim][bold] {avg:5.1f}[/bold] "
194
+ f"[dim]peak:[/dim][bold] {self._peak:4d}[/bold] "
195
+ f"[dim]total:[/dim][bold] {self._total:,}[/bold] {self._unit}"
196
+ )
197
+ try:
198
+ self.query_one("#chart-spark", Static).update(f"[{self._color}]{spark}[/{self._color}]")
199
+ self.query_one("#chart-axis", Static).update(axis)
200
+ self.query_one("#chart-stats", Static).update(stats)
201
+ self.query_one("#chart-meta", Static).update(
202
+ f"[dim]scale: 0–{mv} {self._unit} │ window: 60s[/dim]"
203
+ )
204
+ except Exception:
205
+ pass
206
+
207
+ class ThreatMeter(Vertical):
208
+ """Threat Level визуальный gauge."""
209
+
210
+ DEFAULT_CSS = _CSS
211
+
212
+ def __init__(self, **kwargs):
213
+ super().__init__(**kwargs)
214
+ self._level = 0
215
+ self._counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
216
+ self._top_finding = "—"
217
+
218
+ def compose(self) -> ComposeResult:
219
+ yield Static("┌─ THREAT LEVEL ─", id="tm-title")
220
+ yield Static(" ", id="tm-gauge")
221
+ yield Static(" ", id="tm-breakdown")
222
+ yield Static(" ", id="tm-top")
223
+ yield Static("[dim]Based on active findings in current session[/dim]", id="tm-hint")
224
+
225
+ def on_mount(self) -> None:
226
+ self.call_after_refresh(self._refresh_display)
227
+
228
+ def update_findings(self, counts: dict[str, int], top_finding: str = "") -> None:
229
+ """Обновить с новыми данными о находках."""
230
+ self._counts = counts
231
+ self._top_finding = top_finding or "—"
232
+ level = (
233
+ counts.get("critical", 0) * 25 +
234
+ counts.get("high", 0) * 10 +
235
+ counts.get("medium", 0) * 3 +
236
+ counts.get("low", 0) * 1
237
+ )
238
+ self._level = min(100, level)
239
+ self._refresh_display()
240
+
241
+ def _refresh_display(self) -> None:
242
+ try:
243
+ gauge = _threat_gauge(self._level, width=42)
244
+ c = self._counts
245
+ breakdown = (
246
+ f"[bold red]CRIT:{c.get('critical',0):3d}[/bold red] "
247
+ f"[red]HIGH:{c.get('high',0):3d}[/red] "
248
+ f"[yellow]MED:{c.get('medium',0):4d}[/yellow] "
249
+ f"[green]LOW:{c.get('low',0):4d}[/green] "
250
+ f"[blue]INFO:{c.get('info',0):3d}[/blue]"
251
+ )
252
+ top = f"[dim]Top finding: [/dim][yellow]{self._top_finding[:55]}[/yellow]"
253
+ self.query_one("#tm-gauge", Static).update(gauge)
254
+ self.query_one("#tm-breakdown", Static).update(breakdown)
255
+ self.query_one("#tm-top", Static).update(top)
256
+ except Exception:
257
+ pass
258
+
259
+ class SeverityMatrix(Vertical):
260
+ """Heatmap матрица находок по типу × severity."""
261
+
262
+ DEFAULT_CSS = _CSS
263
+
264
+ _VULN_TYPES = ["SQLi", "XSS", "SSTI", "LFI", "RCE", "Redir", "SSRF", "XXE", "Hdrs", "Info"]
265
+ _SEV_KEYS = ["critical", "high", "medium", "low", "info"]
266
+
267
+ def __init__(self, **kwargs):
268
+ super().__init__(**kwargs)
269
+ self._matrix: dict[str, dict[str, int]] = {
270
+ vt: {s: 0 for s in self._SEV_KEYS}
271
+ for vt in self._VULN_TYPES
272
+ }
273
+
274
+ def compose(self) -> ComposeResult:
275
+ yield Static(" ", id="mx-body", markup=True)
276
+
277
+ def on_mount(self) -> None:
278
+ self.call_after_refresh(self._refresh_display)
279
+
280
+ def add_finding(self, vuln_type: str, severity: str) -> None:
281
+ type_map = {
282
+ "sqli": "SQLi", "xss": "XSS", "ssti": "SSTI",
283
+ "lfi": "LFI", "rce": "RCE", "open_redirect": "Redir", "ssrf": "SSRF",
284
+ "xxe": "XXE",
285
+ "missing_security_header": "Hdrs", "missing_security_headers": "Hdrs",
286
+ "info_leak": "Info",
287
+ }
288
+ vt_key = type_map.get(vuln_type.lower(), "Info")
289
+ if vt_key in self._matrix and severity in self._matrix[vt_key]:
290
+ self._matrix[vt_key][severity] += 1
291
+ self._refresh_display()
292
+
293
+ def set_matrix(self, matrix: dict) -> None:
294
+ for vt, counts in matrix.items():
295
+ if vt in self._matrix:
296
+ self._matrix[vt].update(counts)
297
+ self._refresh_display()
298
+
299
+ def _build_text(self) -> str:
300
+ lines = []
301
+ lines.append("[bold yellow]┌─ VULNERABILITY MATRIX ─[/bold yellow]")
302
+ lines.append("[dim]TYPE CRIT HIGH MED LOW INFO TOTAL[/dim]")
303
+ lines.append("[dim]" + "─" * 56 + "[/dim]")
304
+ for vt in self._VULN_TYPES:
305
+ counts = self._matrix.get(vt, {})
306
+ total = sum(counts.values())
307
+ cells = ""
308
+ for sev in self._SEV_KEYS:
309
+ cnt = counts.get(sev, 0)
310
+ cells += f" {_matrix_cell(cnt)}[dim]{cnt:3d}[/dim] "
311
+ tc = "bold red" if counts.get("critical", 0) > 0 else (
312
+ "red" if counts.get("high", 0) > 0 else (
313
+ "yellow" if counts.get("medium", 0) > 0 else "dim"))
314
+ lines.append(f"[cyan]{vt:<6}[/cyan]{cells}[{tc}]{total:3d}[/{tc}]")
315
+ lines.append("[dim]· none [/dim][green]▪ 1–2 [/green][yellow]▪ 3–9 [/yellow][red]▪ 10–29 [/red][bold red]▪ 30+[/bold red]")
316
+ return "\n".join(lines)
317
+
318
+ def _refresh_display(self) -> None:
319
+ try:
320
+ self.query_one("#mx-body", Static).update(self._build_text())
321
+ except Exception:
322
+ pass
323
+
324
+ class DashboardScreen(Widget):
325
+ """Dashboard — стартовый экран приложения."""
326
+
327
+ DEFAULT_CSS = _CSS
328
+
329
+ BINDINGS = [
330
+ Binding("r", "refresh_dash", "Refresh", show=True),
331
+ ]
332
+
333
+ def __init__(self, **kwargs) -> None:
334
+ super().__init__(**kwargs)
335
+ self._stats = {"requests": 0, "findings": 0, "hosts": 0, "req_rate": 0}
336
+ self._finding_counts: dict[str, int] = {
337
+ "critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0
338
+ }
339
+ self._top_finding = "—"
340
+ self._req_history: list[int] = []
341
+ self._find_history: list[int] = []
342
+ self._ticker: Timer | None = None
343
+ self._req_bucket = 0 # запросы за текущую секунду
344
+ self._find_bucket = 0 # находки за текущую секунду
345
+
346
+ def compose(self) -> ComposeResult:
347
+ with TabbedContent(id="dashboard-tabs"):
348
+ with TabPane("Overview", id="dash-tab-overview"):
349
+ yield from self._compose_overview()
350
+ with TabPane("🔴 Live", id="dash-tab-live"):
351
+ yield LiveDashboardTab(id="live-dashboard")
352
+
353
+ def _compose_overview(self) -> ComposeResult:
354
+ with Horizontal(id="logo-bar"):
355
+ yield Static(_LOGO, id="logo", markup=True)
356
+
357
+ with Horizontal(id="top-row"):
358
+ with Horizontal(id="charts-col"):
359
+ yield LiveChart(
360
+ "HTTP REQUESTS / sec",
361
+ color="green",
362
+ unit="req/s",
363
+ chart_id="chart-requests",
364
+ )
365
+ yield LiveChart(
366
+ "FINDINGS / sec",
367
+ color="red",
368
+ unit="findings",
369
+ chart_id="chart-findings",
370
+ )
371
+ yield ThreatMeter(id="threat-meter")
372
+
373
+ with Horizontal(id="main-area"):
374
+ # Левая колонка — Projects (высокая)
375
+ with Vertical(id="left-pane"):
376
+ yield Static("┌─ PROJECTS ─", id="pp-title")
377
+ yield Tree("Recent projects:", id="project-tree")
378
+ with Horizontal(id="pp-actions"):
379
+ yield ToolbarButton("+ New", "btn-new-project")
380
+ yield ToolbarButton("📂 Open", "btn-open-project")
381
+ yield ToolbarButton("💾 Save", "btn-save-project")
382
+ yield Static(
383
+ "[dim]"
384
+ "Shift+P Proxy Shift+R Repeater Shift+I Intruder\n"
385
+ "Shift+S Scanner Shift+T Target Shift+L Spider\n"
386
+ "Shift+D Decoder Shift+C Comparer Shift+E Settings\n"
387
+ "Ctrl+N New Ctrl+O Open Ctrl+S Save Ctrl+Q Quit"
388
+ "[/dim]",
389
+ id="hk-panel",
390
+ )
391
+
392
+ # Правая колонка — Feed+Status вверху, Matrix внизу
393
+ with Vertical(id="right-col"):
394
+ with Horizontal(id="mid-row"):
395
+ with Vertical(id="feed-panel"):
396
+ yield Static("┌─ LIVE FEED ─", id="feed-panel-title")
397
+ yield RichLog(id="feed-log", highlight=True, markup=True, wrap=False, max_lines=300)
398
+ with Vertical(id="status-panel"):
399
+ yield Static("┌─ STATUS ─", id="status-panel-title")
400
+ yield Static("[dim]●[/dim] Proxy: [dim]STOPPED[/dim]", id="led-proxy-bar", classes="led-item")
401
+ yield Static("[dim]●[/dim] Passive: [dim]OFF[/dim]", id="led-passive-bar", classes="led-item")
402
+ yield Static("[dim]●[/dim] Active: [dim]IDLE[/dim]", id="led-scan-bar", classes="led-item")
403
+ yield Static("[dim]●[/dim] Spider: [dim]IDLE[/dim]", id="led-spider-bar", classes="led-item")
404
+ yield Static("[dim]●[/dim] Threads: [dim]—[/dim]", id="led-threads-bar", classes="led-item")
405
+ with Vertical(id="matrix-col"):
406
+ yield SeverityMatrix(id="vuln-matrix")
407
+
408
+ def on_mount(self) -> None:
409
+ self._ticker = self.set_interval(1.0, self._tick)
410
+ try:
411
+ feed = self.query_one("#feed-log", RichLog)
412
+ feed.write("[dim cyan]ℹ Live feed ready. Waiting for events...[/dim cyan]")
413
+ except Exception:
414
+ pass
415
+ self._load_stats_bg()
416
+ self._populate_projects()
417
+ self._boot_animate()
418
+
419
+ @on(ToolbarButton.Pressed, "#btn-new-project")
420
+ def on_btn_new_project(self, _: ToolbarButton.Pressed) -> None:
421
+ self._new_project_dialog()
422
+
423
+ @on(ToolbarButton.Pressed, "#btn-open-project")
424
+ def on_btn_open_project(self, _: ToolbarButton.Pressed) -> None:
425
+ self._open_project_dialog()
426
+
427
+ @on(ToolbarButton.Pressed, "#btn-save-project")
428
+ def on_btn_save_project(self, _: ToolbarButton.Pressed) -> None:
429
+ self._save_project_dialog()
430
+
431
+ def _populate_projects(self) -> None:
432
+ try:
433
+ tree = self.query_one("#project-tree", Tree)
434
+ tree.clear()
435
+ root = tree.root
436
+ root.label = "[dim]Recent projects:[/dim]"
437
+ cfg = getattr(self.app, "_cfg", None)
438
+ recent = (getattr(cfg, "recent_projects", None) or []) if cfg else []
439
+ current = getattr(self.app, "_project_path", None)
440
+ for path in recent[:8]:
441
+ basename = os.path.basename(path)
442
+ proj_name = os.path.splitext(basename)[0]
443
+ ts = ""
444
+ try:
445
+ mtime = os.path.getmtime(path)
446
+ ts = datetime.fromtimestamp(mtime).strftime("%m/%d %H:%M")
447
+ except Exception:
448
+ pass
449
+ exists = os.path.exists(path)
450
+ is_active = (path == current)
451
+
452
+ if is_active:
453
+ node_label = f"[bold cyan]{proj_name}[/bold cyan] [bold green]●[/bold green]" + (f" [dim]{ts}[/dim]" if ts else "")
454
+ elif exists:
455
+ node_label = f"[cyan]{proj_name}[/cyan]" + (f" [dim]{ts}[/dim]" if ts else "")
456
+ else:
457
+ node_label = f"[dim]{proj_name} (missing)[/dim]"
458
+
459
+ # Добавляем как узел с дочерним листом-путём
460
+ node = root.add(node_label, data=path)
461
+ node.add_leaf(f"[dim]{path}[/dim]", data=path)
462
+ if is_active:
463
+ node.expand()
464
+
465
+ if not root.children:
466
+ root.add_leaf("[dim]No recent projects — use Open to load[/dim]")
467
+ root.expand()
468
+ except Exception:
469
+ pass
470
+
471
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
472
+ """Открыть проект при выборе из дерева."""
473
+ node = event.node
474
+ path = getattr(node, "data", None)
475
+ if not path or not isinstance(path, str):
476
+ return
477
+ if not os.path.exists(path):
478
+ self.app.notify(f"File not found: {path}", severity="warning", timeout=3)
479
+ return
480
+ switch_fn = getattr(self.app, "_switch_project_db", None)
481
+ if switch_fn:
482
+ switch_fn(path, is_new=False)
483
+ else:
484
+ self.app.notify(f"Opened: {os.path.basename(path)}", timeout=3)
485
+
486
+ @work(thread=True)
487
+ def _boot_animate(self) -> None:
488
+ time.sleep(0.2)
489
+ for color, line in _BOOT_LINES:
490
+ time.sleep(0.12)
491
+ try:
492
+ self.app.call_from_thread(self._boot_write, color, line)
493
+ except Exception:
494
+ break
495
+ time.sleep(0.3)
496
+ try:
497
+ self.app.call_from_thread(self._boot_write, "[dim]", "─" * 48)
498
+ except Exception:
499
+ pass
500
+
501
+ def _boot_write(self, color: str, line: str) -> None:
502
+ ts = datetime.now().strftime("%H:%M:%S")
503
+ try:
504
+ log = self.query_one("#feed-log", RichLog)
505
+ log.write(f"[dim green]{ts}[/dim green] {color}{line}[/]")
506
+ except Exception:
507
+ pass
508
+
509
+ def _tick(self) -> None:
510
+ """Каждую секунду пушим данные в графики."""
511
+ try:
512
+ rps = self._req_bucket
513
+ fps = self._find_bucket
514
+ self._req_bucket = 0
515
+ self._find_bucket = 0
516
+
517
+ self.query_one("#chart-requests", LiveChart).push(rps)
518
+ self.query_one("#chart-findings", LiveChart).push(fps)
519
+ except Exception:
520
+ pass
521
+
522
+ @work
523
+ async def _load_stats_bg(self) -> None:
524
+ try:
525
+ db_path = getattr(self.app, "_db_path", "") or ""
526
+ if not db_path:
527
+ return
528
+ data = await self._fetch_stats(db_path)
529
+ self._apply_stats(data)
530
+ except Exception as exc:
531
+ logger.debug("_load_stats_bg: %s", exc)
532
+
533
+ async def _fetch_stats(self, db_path: str) -> dict:
534
+ from pentool.api.scanner_api import ScannerAPI
535
+ from pentool.storage.http_storage import HttpStorage
536
+ result: dict = {
537
+ "requests": 0, "hosts": 0,
538
+ "findings": {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0},
539
+ "top_finding": "—",
540
+ }
541
+ try:
542
+ storage = HttpStorage()
543
+ await storage.init_db(db_path)
544
+ try:
545
+ result["requests"] = await storage.count()
546
+ finally:
547
+ await storage.close()
548
+ except Exception as exc:
549
+ logger.debug("_fetch_stats http_history: %s", exc)
550
+ try:
551
+ api = ScannerAPI(db_path=db_path)
552
+ result["hosts"] = await api.get_host_count()
553
+ scanner_stats = await api.get_stats()
554
+ result["findings"] = scanner_stats["findings"]
555
+ result["top_finding"] = scanner_stats["top_finding"]
556
+ except Exception as exc:
557
+ logger.debug("_fetch_stats scanner_stats: %s", exc)
558
+ return result
559
+
560
+ def _apply_stats(self, data: dict) -> None:
561
+ """Применить загруженную статистику к виджетам."""
562
+ self._finding_counts = data.get("findings", self._finding_counts)
563
+ self._top_finding = data.get("top_finding", "—")
564
+
565
+ try:
566
+ self.query_one("#threat-meter", ThreatMeter).update_findings(
567
+ self._finding_counts, self._top_finding
568
+ )
569
+ except Exception as exc:
570
+ logger.debug("_apply_stats ThreatMeter: %s", exc)
571
+
572
+ try:
573
+ matrix = self.query_one("#vuln-matrix", SeverityMatrix)
574
+ matrix._matrix = {
575
+ vt: {s: 0 for s in SeverityMatrix._SEV_KEYS}
576
+ for vt in SeverityMatrix._VULN_TYPES
577
+ }
578
+ matrix._refresh_display()
579
+ except Exception as exc:
580
+ logger.debug("_apply_stats SeverityMatrix: %s", exc)
581
+
582
+ total = data.get("requests", 0)
583
+ hosts = data.get("hosts", 0)
584
+ total_findings = sum(self._finding_counts.values())
585
+ self.log_activity(
586
+ f"Session: {total:,} requests · {hosts} hosts · {total_findings} findings",
587
+ "ok"
588
+ )
589
+
590
+ try:
591
+ fc = self._finding_counts
592
+ self.query_one("#chart-requests", LiveChart).set_summary(
593
+ f"[dim]Total: [/dim][bold]{total:,}[/bold][dim] reqs · [/dim][bold]{hosts}[/bold][dim] hosts[/dim]"
594
+ )
595
+ self.query_one("#chart-findings", LiveChart).set_summary(
596
+ f"[bold red]{fc.get('critical',0)}[/bold red][dim] crit [/dim]"
597
+ f"[red]{fc.get('high',0)}[/red][dim] high [/dim]"
598
+ f"[yellow]{fc.get('medium',0)}[/yellow][dim] med [/dim]"
599
+ f"[green]{fc.get('low',0)}[/green][dim] low [/dim]"
600
+ f"[blue]{fc.get('info',0)}[/blue][dim] info[/dim]"
601
+ )
602
+ except Exception as exc:
603
+ logger.debug("_apply_stats chart_summary: %s", exc)
604
+
605
+ def action_refresh_dash(self) -> None:
606
+ """Обновить всю статистику с диска."""
607
+ self._load_stats_bg()
608
+ self.log_activity("Dashboard refreshed", "info")
609
+
610
+ def _feed_write(self, text: str) -> None:
611
+ """Запись в RichLog live-ленты."""
612
+ try:
613
+ self.query_one("#feed-log", RichLog).write(text)
614
+ except Exception:
615
+ pass
616
+
617
+ def _set_led_bar(self, widget_id: str, color: str, label: str) -> None:
618
+ try:
619
+ self.query_one(f"#{widget_id}", Static).update(
620
+ f"[{color}]●[/{color}] {label}"
621
+ )
622
+ except Exception:
623
+ pass
624
+
625
+ def push_request(self, method: str, url: str, status: int = 0) -> None:
626
+ """Зарегистрировать HTTP-запрос (из прокси)."""
627
+ self._req_bucket += 1
628
+ self._stats["requests"] += 1
629
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
630
+ method_color = {"GET": "green", "POST": "yellow", "PUT": "cyan", "DELETE": "red", "PATCH": "magenta"}.get(method.upper(), "white")
631
+ status_color = "green" if 200 <= status < 300 else ("yellow" if 300 <= status < 400 else ("red" if status >= 400 else "dim"))
632
+ status_str = f"[{status_color}]{status}[/{status_color}]" if status else "[dim]···[/dim]"
633
+ short_url = url[:60] + "…" if len(url) > 60 else url
634
+ self._feed_write(
635
+ f"[dim]{ts}[/dim] [{method_color}]{method:<7}[/{method_color}] "
636
+ f"{status_str} [dim]{short_url}[/dim]"
637
+ )
638
+
639
+ def add_finding(self, finding) -> None:
640
+ """Добавить finding (из пассивного/активного сканера)."""
641
+ self._find_bucket += 1
642
+ sev = getattr(finding, "severity", "info")
643
+ vuln_type = getattr(finding, "type", "unknown")
644
+ url = getattr(finding, "url", "")
645
+ name = getattr(finding, "name", vuln_type)
646
+
647
+ self._finding_counts[sev] = self._finding_counts.get(sev, 0) + 1
648
+ self._top_finding = name
649
+
650
+ try:
651
+ self.query_one("#threat-meter", ThreatMeter).update_findings(
652
+ self._finding_counts, self._top_finding
653
+ )
654
+ except Exception:
655
+ pass
656
+ try:
657
+ self.query_one("#vuln-matrix", SeverityMatrix).add_finding(vuln_type, sev)
658
+ except Exception:
659
+ pass
660
+
661
+ try:
662
+ fc = self._finding_counts
663
+ self.query_one("#chart-findings", LiveChart).set_summary(
664
+ f"[bold red]{fc.get('critical',0)}[/bold red][dim] crit [/dim]"
665
+ f"[red]{fc.get('high',0)}[/red][dim] high [/dim]"
666
+ f"[yellow]{fc.get('medium',0)}[/yellow][dim] med [/dim]"
667
+ f"[green]{fc.get('low',0)}[/green][dim] low [/dim]"
668
+ f"[blue]{fc.get('info',0)}[/blue][dim] info[/dim]"
669
+ )
670
+ except Exception:
671
+ pass
672
+
673
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
674
+ icon = _SEV_ICONS.get(sev, "●")
675
+ color = _SEV_COLORS.get(sev, "white")
676
+ short_url = url[:45] + "…" if len(url) > 45 else url
677
+ self._feed_write(
678
+ f"[dim]{ts}[/dim] {icon} [{color}]{sev.upper():<8}[/{color}] "
679
+ f"[bold]{vuln_type}[/bold] [dim]{short_url}[/dim]"
680
+ )
681
+
682
+ def log_activity(self, message: str, level: str = "info") -> None:
683
+ """Системное сообщение в live feed."""
684
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
685
+ colors = {"info": "cyan", "warning": "yellow", "error": "red", "ok": "bold green"}
686
+ color = colors.get(level, "white")
687
+ prefix = {"info": "ℹ", "warning": "⚠", "error": "✗", "ok": "✓"}.get(level, "·")
688
+ self._feed_write(f"[dim]{ts}[/dim] [{color}]{prefix} {message}[/{color}]")
689
+
690
+ def update_proxy_status(self, running: bool, port: int = 8080) -> None:
691
+ """Обновить статус прокси."""
692
+ if running:
693
+ self._set_led_bar("led-proxy-bar", "bold green", f"Proxy: [bold green]:{port}[/bold green]")
694
+ else:
695
+ self._set_led_bar("led-proxy-bar", "dim", f"Proxy: [dim]STOPPED[/dim]")
696
+ msg = f"Proxy {'started' if running else 'stopped'} on :{port}"
697
+ self.log_activity(msg, "ok" if running else "warning")
698
+
699
+ def update_passive_status(self, enabled: bool) -> None:
700
+ if enabled:
701
+ self._set_led_bar("led-passive-bar", "bold cyan", "Passive: [bold cyan]ARMED[/bold cyan]")
702
+ else:
703
+ self._set_led_bar("led-passive-bar", "dim", "Passive: [dim]OFF[/dim]")
704
+ self.log_activity(f"Passive scanner {'armed' if enabled else 'disabled'}", "ok" if enabled else "warning")
705
+
706
+ def update_scan_status(self, scanning: bool, progress: int = 0, threads: int = 0) -> None:
707
+ if scanning:
708
+ self._set_led_bar("led-scan-bar", "bold yellow", f"Active: [bold yellow]SCANNING {progress}%[/bold yellow]")
709
+ if threads > 0:
710
+ self._set_led_bar("led-threads-bar", "cyan", f"Threads: [cyan]{threads} active[/cyan]")
711
+ else:
712
+ self._set_led_bar("led-scan-bar", "dim", "Active: [dim]IDLE[/dim]")
713
+ self._set_led_bar("led-threads-bar", "dim", "Threads: [dim]—[/dim]")
714
+
715
+ def update_spider_status(self, running: bool, pages: int = 0) -> None:
716
+ if running:
717
+ self._set_led_bar("led-spider-bar", "bold magenta", f"Spider: [bold magenta]CRAWLING {pages}p[/bold magenta]")
718
+ else:
719
+ self._set_led_bar("led-spider-bar", "dim", "Spider: [dim]IDLE[/dim]")
720
+
721
+ def refresh_stats(self) -> None:
722
+ self._load_stats_bg()
723
+
724
+ def _new_project_dialog(self) -> None:
725
+ """Создать новый проект — выбрать путь для новой .db базы данных."""
726
+ from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
727
+
728
+ def _on_path(path: str | None) -> None:
729
+ if not path:
730
+ return
731
+ if not path.endswith(".db"):
732
+ path = path + ".db"
733
+ switch_fn = getattr(self.app, "_switch_project_db", None)
734
+ if switch_fn:
735
+ switch_fn(path, is_new=True)
736
+ self._populate_projects()
737
+
738
+ self.app.push_screen(
739
+ FileSelectorDialog(
740
+ mode=FileSelectorMode.SAVE,
741
+ title="New Project — Choose Location",
742
+ start_dir=os.path.expanduser("~"),
743
+ ),
744
+ _on_path,
745
+ )
746
+
747
+ def _open_project_dialog(self) -> None:
748
+ """Открыть существующую .db базу данных как проект."""
749
+ from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
750
+ current = getattr(self.app, "_project_path", None)
751
+ start_dir = os.path.dirname(current) if current else os.path.expanduser("~")
752
+
753
+ def _on_path(path: str | None) -> None:
754
+ if not path:
755
+ return
756
+ if not os.path.exists(path):
757
+ self.app.notify(f"File not found: {path}", severity="error", timeout=4)
758
+ return
759
+ switch_fn = getattr(self.app, "_switch_project_db", None)
760
+ if switch_fn:
761
+ switch_fn(path, is_new=False)
762
+ self._populate_projects()
763
+
764
+ self.app.push_screen(
765
+ FileSelectorDialog(
766
+ mode=FileSelectorMode.OPEN,
767
+ title="Open Project",
768
+ start_dir=start_dir,
769
+ filter_ext=[".db"],
770
+ ),
771
+ _on_path,
772
+ )
773
+
774
+ def _save_project_dialog(self) -> None:
775
+ """Сохранить (скопировать) текущую .db в другое место."""
776
+ from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
777
+ current = getattr(self.app, "_project_path", None)
778
+ cfg = getattr(self.app, "_cfg", None)
779
+ src_db = (current or (cfg.db_path if cfg else None) or os.path.expanduser("~"))
780
+ start_dir = os.path.dirname(src_db) if src_db else os.path.expanduser("~")
781
+
782
+ def _on_path(path: str | None) -> None:
783
+ if not path:
784
+ return
785
+ if not path.endswith(".db"):
786
+ path = path + ".db"
787
+ import shutil
788
+ src = (cfg.db_path if cfg else None) or src_db
789
+ try:
790
+ shutil.copy2(src, path)
791
+ self.app._project_path = path # type: ignore
792
+ update_fn = getattr(self.app, "_update_project_name", None)
793
+ if update_fn:
794
+ update_fn(path)
795
+ self.app.notify(f"Saved to {os.path.basename(path)}", timeout=3)
796
+ self._populate_projects()
797
+ self.log_activity(f"Project saved: {path}", "ok")
798
+ except Exception as e:
799
+ self.app.notify(f"Save failed: {e}", severity="error", timeout=4)
800
+
801
+ self.app.push_screen(
802
+ FileSelectorDialog(
803
+ mode=FileSelectorMode.SAVE,
804
+ title="Save Project As",
805
+ start_dir=start_dir,
806
+ ),
807
+ _on_path,
808
+ )
809
+
810
+ def action_new_project(self) -> None:
811
+ try:
812
+ self.app.action_new_project() # type: ignore
813
+ except Exception:
814
+ pass
815
+
816
+ def action_save_project(self) -> None:
817
+ try:
818
+ self.app.action_save_project() # type: ignore
819
+ except Exception:
820
+ pass