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,741 @@
1
+ """LiveDashboard — вкладка «Live» в Dashboard с вау-эффектом.
2
+
3
+ Виджеты:
4
+ - TrafficSparkline — ASCII-sparkline RPS за последние 60 секунд
5
+ - BubbleChart — карта уязвимостей «пузырьки» по severity
6
+ - ScanSpeedometer — спидометр прогресса активного сканирования
7
+ - HeatmapWidget — тепловая карта scope-хостов
8
+ - EventFeed — лента событий в реальном времени
9
+ - ResourceMonitor — CPU + RAM термометры
10
+ - EmergencyStop — кнопка остановки всех задач с обратным отсчётом
11
+ - LiveDashboardTab — контейнер со всеми виджетами
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import time
18
+ from collections import deque
19
+ from typing import Callable
20
+
21
+ from textual.app import ComposeResult
22
+ from textual.containers import Horizontal, Vertical
23
+ from textual.reactive import reactive
24
+ from textual.widget import Widget
25
+ from textual.widgets import RichLog, Static
26
+
27
+ from pentool.tui.widgets.toolbar_button import ToolbarButton
28
+
29
+ # ── Вспомогательные функции ────────────────────────────────────────────────
30
+
31
+ _SPARK_CHARS = " ▁▂▃▄▅▆▇█"
32
+
33
+ def _sparkline(values: list[float], width: int = 30) -> str:
34
+ """Построить ASCII-sparkline фиксированной ширины."""
35
+ if not values:
36
+ return " " * width
37
+ # Берём последние `width` значений
38
+ data = list(values)[-width:]
39
+ if len(data) < width:
40
+ data = [0.0] * (width - len(data)) + data
41
+ max_val = max(data) or 1.0
42
+ chars = []
43
+ for v in data:
44
+ idx = int((v / max_val) * (len(_SPARK_CHARS) - 1))
45
+ idx = max(0, min(idx, len(_SPARK_CHARS) - 1))
46
+ chars.append(_SPARK_CHARS[idx])
47
+ return "".join(chars)
48
+
49
+ def _hbar(value: float, max_val: float = 100.0, width: int = 20) -> str:
50
+ """Горизонтальный прогресс-бар."""
51
+ ratio = min(1.0, max(0.0, value / (max_val or 1.0)))
52
+ filled = int(ratio * width)
53
+ return "█" * filled + "░" * (width - filled)
54
+
55
+ def _color_by_percent(pct: float) -> str:
56
+ """Цвет по проценту: green → yellow → red."""
57
+ if pct < 50:
58
+ return "green"
59
+ elif pct < 80:
60
+ return "yellow"
61
+ return "red"
62
+
63
+ # ── Виджет 1: Traffic Sparkline ────────────────────────────────────────────
64
+
65
+ class TrafficSparkline(Widget):
66
+ """ASCII-sparkline запросов в секунду за последние 60 секунд."""
67
+
68
+ DEFAULT_CSS = """
69
+ TrafficSparkline {
70
+ height: 5;
71
+ border: solid $primary-darken-3;
72
+ padding: 0 1;
73
+ }
74
+ """
75
+
76
+ def __init__(self, **kwargs) -> None:
77
+ super().__init__(**kwargs)
78
+ self._history: deque[float] = deque([0.0] * 60, maxlen=60)
79
+ self._last_count: int = 0
80
+ self._count: int = 0
81
+
82
+ def compose(self) -> ComposeResult:
83
+ yield Static("", id="sparkline-title")
84
+ yield Static("", id="sparkline-graph")
85
+ yield Static("", id="sparkline-stats")
86
+
87
+ def on_mount(self) -> None:
88
+ self.set_interval(1.0, self._tick)
89
+
90
+ def increment(self) -> None:
91
+ """Вызвать при каждом новом запросе."""
92
+ self._count += 1
93
+
94
+ def _tick(self) -> None:
95
+ rps = self._count - self._last_count
96
+ self._last_count = self._count
97
+ self._history.append(float(rps))
98
+ self._update_display()
99
+
100
+ def _update_display(self) -> None:
101
+ data = list(self._history)
102
+ max_rps = max(data) or 1
103
+ avg_rps = sum(data) / len(data)
104
+ current = data[-1]
105
+
106
+ spark = _sparkline(data, width=40)
107
+ color = "green" if current < avg_rps * 1.5 else "yellow"
108
+ if current > avg_rps * 2:
109
+ color = "red"
110
+
111
+ try:
112
+ self.query_one("#sparkline-title", Static).update(
113
+ f"[bold]┌─ TRAFFIC (RPS) ─[/bold]"
114
+ )
115
+ self.query_one("#sparkline-graph", Static).update(
116
+ f"[{color}]{spark}[/{color}]"
117
+ )
118
+ self.query_one("#sparkline-stats", Static).update(
119
+ f"cur:[bold]{current:.0f}[/bold] avg:[dim]{avg_rps:.1f}[/dim] max:[dim]{max_rps:.0f}[/dim]"
120
+ )
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ # ── Виджет 2: Bubble Chart ─────────────────────────────────────────────────
126
+
127
+ class BubbleChart(Widget):
128
+ """ASCII-карта уязвимостей по severity с анимацией при новом finding."""
129
+
130
+ DEFAULT_CSS = """
131
+ BubbleChart {
132
+ height: 8;
133
+ border: solid $primary-darken-3;
134
+ padding: 0 1;
135
+ }
136
+ """
137
+
138
+ # Символы «пузырьков» разного размера
139
+ _BUBBLES = {
140
+ 0: "●", # критично — большой
141
+ 1: "◉", # высокий
142
+ 2: "○", # средний
143
+ 3: "·", # низкий
144
+ 4: "·", # info
145
+ }
146
+ _COLORS = {
147
+ "critical": "bold red",
148
+ "high": "red",
149
+ "medium": "yellow",
150
+ "low": "green",
151
+ "info": "blue",
152
+ }
153
+
154
+ def __init__(self, **kwargs) -> None:
155
+ super().__init__(**kwargs)
156
+ self._counts: dict[str, int] = {
157
+ "critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0,
158
+ }
159
+ self._blink_severity: str | None = None
160
+ self._blink_until: float = 0.0
161
+
162
+ def compose(self) -> ComposeResult:
163
+ yield Static("[bold]┌─ VULNERABILITY MAP ─[/bold]")
164
+ yield Static("", id="bubble-body")
165
+
166
+ def on_mount(self) -> None:
167
+ self.set_interval(0.5, self._update_display)
168
+
169
+ def add_finding(self, severity: str) -> None:
170
+ """Добавить новый finding — пузырёк мигает."""
171
+ sev = severity.lower()
172
+ if sev in self._counts:
173
+ self._counts[sev] += 1
174
+ self._blink_severity = sev
175
+ self._blink_until = time.monotonic() + 2.0
176
+
177
+ def _update_display(self) -> None:
178
+ now = time.monotonic()
179
+ blink_active = self._blink_severity and now < self._blink_until
180
+ blink_on = blink_active and (int(now * 4) % 2 == 0)
181
+
182
+ lines = []
183
+ for i, (sev, label) in enumerate([
184
+ ("critical", "Critical"),
185
+ ("high", "High "),
186
+ ("medium", "Medium "),
187
+ ("low", "Low "),
188
+ ("info", "Info "),
189
+ ]):
190
+ count = self._counts[sev]
191
+ bubble = self._BUBBLES.get(i, "●")
192
+ color = self._COLORS[sev]
193
+ is_blink = blink_active and self._blink_severity == sev
194
+ if is_blink and blink_on:
195
+ color = "bold white on red" if sev == "critical" else "bold white"
196
+ lines.append(f"[{color}]{bubble} {label}[/{color}] [bold]{count:>3}[/bold]")
197
+
198
+ try:
199
+ self.query_one("#bubble-body", Static).update("\n".join(lines))
200
+ except Exception:
201
+ pass
202
+
203
+
204
+ # ── Виджет 3: Scan Speedometer ─────────────────────────────────────────────
205
+
206
+ class ScanSpeedometer(Widget):
207
+ """ASCII-спидометр прогресса активного сканирования."""
208
+
209
+ DEFAULT_CSS = """
210
+ ScanSpeedometer {
211
+ height: 7;
212
+ border: solid $primary-darken-3;
213
+ padding: 0 1;
214
+ }
215
+ """
216
+
217
+ def __init__(self, **kwargs) -> None:
218
+ super().__init__(**kwargs)
219
+ self._progress: float = 0.0
220
+ self._findings: int = 0
221
+ self._scanning: bool = False
222
+ self._start_time: float = 0.0
223
+
224
+ def compose(self) -> ComposeResult:
225
+ yield Static("[bold]┌─ SCAN PROGRESS ─[/bold]")
226
+ yield Static("", id="speedo-arc")
227
+ yield Static("", id="speedo-stats")
228
+
229
+ def update_progress(self, done: int, total: int, scanning: bool) -> None:
230
+ self._progress = (done / total * 100) if total > 0 else 0
231
+ self._scanning = scanning
232
+ if scanning and self._start_time == 0.0:
233
+ self._start_time = time.monotonic()
234
+ elif not scanning:
235
+ self._start_time = 0.0
236
+ self._update_display()
237
+
238
+ def add_finding(self) -> None:
239
+ self._findings += 1
240
+ self._update_display()
241
+
242
+ def _update_display(self) -> None:
243
+ pct = self._progress
244
+ # ASCII дуга спидометра (0–100% → левая половина → правая половина)
245
+ arc_len = 20
246
+ filled = int(pct / 100 * arc_len)
247
+ arc = "[green]" + "▓" * filled + "░" * (arc_len - filled) + "[/green]"
248
+
249
+ color = _color_by_percent(pct)
250
+ status = "SCANNING" if self._scanning else "IDLE"
251
+
252
+ # ETA
253
+ eta_str = ""
254
+ if self._scanning and pct > 0 and self._start_time > 0:
255
+ elapsed = time.monotonic() - self._start_time
256
+ total_est = elapsed * 100 / pct
257
+ remaining = max(0, total_est - elapsed)
258
+ m, s = int(remaining // 60), int(remaining % 60)
259
+ eta_str = f" ETA: {m}m {s}s"
260
+
261
+ try:
262
+ self.query_one("#speedo-arc", Static).update(
263
+ f"[{color}]({arc} {pct:.0f}%)[/{color}]"
264
+ )
265
+ self.query_one("#speedo-stats", Static).update(
266
+ f"[dim]{status}[/dim] findings:[bold]{self._findings}[/bold]{eta_str}"
267
+ )
268
+ except Exception:
269
+ pass
270
+
271
+
272
+ # ── Виджет 4: Heatmap ─────────────────────────────────────────────────────
273
+
274
+ class HeatmapWidget(Widget):
275
+ """Тепловая карта scope-хостов по частоте запросов."""
276
+
277
+ DEFAULT_CSS = """
278
+ HeatmapWidget {
279
+ height: 8;
280
+ border: solid $primary-darken-3;
281
+ padding: 0 1;
282
+ }
283
+ """
284
+
285
+ def __init__(self, **kwargs) -> None:
286
+ super().__init__(**kwargs)
287
+ self._host_counts: dict[str, int] = {}
288
+
289
+ def compose(self) -> ComposeResult:
290
+ yield Static("[bold]┌─ HOST HEATMAP ─[/bold]")
291
+ yield Static("", id="heatmap-body")
292
+
293
+ def increment_host(self, host: str) -> None:
294
+ self._host_counts[host] = self._host_counts.get(host, 0) + 1
295
+ self._update_display()
296
+
297
+ def _update_display(self) -> None:
298
+ if not self._host_counts:
299
+ try:
300
+ self.query_one("#heatmap-body", Static).update("[dim]no traffic yet[/dim]")
301
+ except Exception:
302
+ pass
303
+ return
304
+
305
+ total = max(self._host_counts.values())
306
+ # Топ 6 хостов
307
+ sorted_hosts = sorted(self._host_counts.items(), key=lambda x: -x[1])[:6]
308
+
309
+ lines = []
310
+ for host, count in sorted_hosts:
311
+ ratio = count / total
312
+ bar_len = int(ratio * 18)
313
+ # Цвет от зелёного к красному
314
+ if ratio < 0.3:
315
+ color = "green"
316
+ elif ratio < 0.6:
317
+ color = "yellow"
318
+ else:
319
+ color = "red"
320
+ bar = "█" * bar_len + "░" * (18 - bar_len)
321
+ pct = int(ratio * 100)
322
+ short_host = host[:20] if len(host) > 20 else host
323
+ lines.append(f"[{color}]{bar}[/{color}] [dim]{short_host}[/dim] [bold]{pct}%[/bold]")
324
+
325
+ try:
326
+ self.query_one("#heatmap-body", Static).update("\n".join(lines))
327
+ except Exception:
328
+ pass
329
+
330
+
331
+ # ── Виджет 5: Event Feed ───────────────────────────────────────────────────
332
+
333
+ class EventFeed(Widget):
334
+ """Лента событий в реальном времени."""
335
+
336
+ DEFAULT_CSS = """
337
+ EventFeed {
338
+ height: 12;
339
+ border: solid $primary-darken-3;
340
+ padding: 0 1;
341
+ }
342
+ """
343
+
344
+ def compose(self) -> ComposeResult:
345
+ yield Static("[bold]┌─ LIVE EVENT FEED ─[/bold]")
346
+ yield RichLog(
347
+ id="event-feed-log",
348
+ highlight=True,
349
+ markup=True,
350
+ wrap=False,
351
+ max_lines=200,
352
+ )
353
+
354
+ def add_event(self, event_type: str, message: str) -> None:
355
+ """Добавить событие в ленту."""
356
+ colors = {
357
+ "request": "dim cyan",
358
+ "intercept": "bold yellow",
359
+ "finding": "bold red",
360
+ "scan": "green",
361
+ "error": "red",
362
+ "info": "dim",
363
+ }
364
+ color = colors.get(event_type, "white")
365
+ ts = time.strftime("%H:%M:%S")
366
+ try:
367
+ log = self.query_one("#event-feed-log", RichLog)
368
+ log.write(f"[dim]{ts}[/dim] [{color}]{message}[/{color}]")
369
+ except Exception:
370
+ pass
371
+
372
+
373
+ # ── Виджет 6: Resource Monitor ─────────────────────────────────────────────
374
+
375
+ class ResourceMonitor(Widget):
376
+ """CPU + RAM термометры."""
377
+
378
+ DEFAULT_CSS = """
379
+ ResourceMonitor {
380
+ height: 6;
381
+ border: solid $primary-darken-3;
382
+ padding: 0 1;
383
+ }
384
+ """
385
+
386
+ def compose(self) -> ComposeResult:
387
+ yield Static("[bold]┌─ RESOURCES ─[/bold]")
388
+ yield Static("", id="res-cpu")
389
+ yield Static("", id="res-ram")
390
+ yield Static("", id="res-extra")
391
+
392
+ def on_mount(self) -> None:
393
+ self.set_interval(2.0, self._update)
394
+
395
+ def _update(self) -> None:
396
+ try:
397
+ import psutil
398
+ cpu = psutil.cpu_percent(interval=None)
399
+ mem = psutil.virtual_memory()
400
+ ram = mem.percent
401
+
402
+ cpu_color = _color_by_percent(cpu)
403
+ ram_color = _color_by_percent(ram)
404
+ cpu_bar = _hbar(cpu, 100, 18)
405
+ ram_bar = _hbar(ram, 100, 18)
406
+
407
+ blink = "" if cpu < 90 else " [bold red]![/bold red]"
408
+ cpu_line = f"CPU [{cpu_color}]{cpu_bar}[/{cpu_color}] [bold]{cpu:.0f}%[/bold]{blink}"
409
+ ram_line = f"RAM [{ram_color}]{ram_bar}[/{ram_color}] [bold]{ram:.0f}%[/bold]"
410
+
411
+ self.query_one("#res-cpu", Static).update(cpu_line)
412
+ self.query_one("#res-ram", Static).update(ram_line)
413
+ # Доп инфо: количество потоков процесса
414
+ try:
415
+ import os
416
+ proc = psutil.Process(os.getpid())
417
+ threads = proc.num_threads()
418
+ self.query_one("#res-extra", Static).update(
419
+ f"[dim]threads: {threads}[/dim]"
420
+ )
421
+ except Exception:
422
+ pass
423
+ except ImportError:
424
+ try:
425
+ self.query_one("#res-cpu", Static).update("[dim]psutil not installed[/dim]")
426
+ except Exception:
427
+ pass
428
+ except Exception:
429
+ pass
430
+
431
+
432
+ # ── Виджет 7: Emergency Stop ───────────────────────────────────────────────
433
+
434
+ class EmergencyStop(Widget):
435
+ """Кнопка Emergency Stop с 3-секундным обратным отсчётом."""
436
+
437
+ DEFAULT_CSS = """
438
+ EmergencyStop {
439
+ height: 5;
440
+ border: solid $error;
441
+ padding: 0 1;
442
+ align: center middle;
443
+ }
444
+ EmergencyStop #stop-countdown {
445
+ color: $error;
446
+ text-align: center;
447
+ }
448
+ """
449
+
450
+ def __init__(self, on_stop: Callable | None = None, **kwargs) -> None:
451
+ super().__init__(**kwargs)
452
+ self._on_stop = on_stop
453
+ self._countdown: int = 0
454
+ self._timer = None
455
+ self._armed: bool = False
456
+
457
+ def compose(self) -> ComposeResult:
458
+ yield ToolbarButton("🛑 EMERGENCY STOP", "btn-estop", classes="inactive")
459
+ yield Static("", id="stop-countdown")
460
+
461
+ @staticmethod
462
+ def _format_countdown(n: int) -> str:
463
+ blocks = "█" * n + "░" * (3 - n)
464
+ return f"[bold red]STOPPING IN {n}... [{blocks}][/bold red]"
465
+
466
+ def on_toolbar_button_pressed(self, event: ToolbarButton.Pressed) -> None:
467
+ if event.button.id == "btn-estop":
468
+ if not self._armed:
469
+ self._arm()
470
+ else:
471
+ self._disarm()
472
+
473
+ def _arm(self) -> None:
474
+ self._armed = True
475
+ self._countdown = 3
476
+ self._render_countdown()
477
+ self._timer = self.set_interval(1.0, self._tick_countdown)
478
+
479
+ def _disarm(self) -> None:
480
+ self._armed = False
481
+ if self._timer:
482
+ self._timer.stop()
483
+ self._timer = None
484
+ self._countdown = 0
485
+ try:
486
+ self.query_one("#stop-countdown", Static).update("[dim]cancelled[/dim]")
487
+ except Exception:
488
+ pass
489
+ self.set_timer(1.0, lambda: self.query_one("#stop-countdown", Static).update(""))
490
+
491
+ def _tick_countdown(self) -> None:
492
+ self._countdown -= 1
493
+ if self._countdown <= 0:
494
+ if self._timer:
495
+ self._timer.stop()
496
+ self._timer = None
497
+ self._armed = False
498
+ self._execute_stop()
499
+ else:
500
+ self._render_countdown()
501
+
502
+ def _render_countdown(self) -> None:
503
+ try:
504
+ self.query_one("#stop-countdown", Static).update(
505
+ self._format_countdown(self._countdown)
506
+ )
507
+ except Exception:
508
+ pass
509
+
510
+ def _execute_stop(self) -> None:
511
+ try:
512
+ self.query_one("#stop-countdown", Static).update("[bold red]STOPPED[/bold red]")
513
+ except Exception:
514
+ pass
515
+ if self._on_stop:
516
+ try:
517
+ self._on_stop()
518
+ except Exception:
519
+ pass
520
+ # Отправить EmergencyStop через EventBus
521
+ try:
522
+ from pentool.core.event_bus import get_event_bus
523
+ from pentool.core.events import AppEvent
524
+ import dataclasses
525
+
526
+ @dataclasses.dataclass
527
+ class EmergencyStopEvent(AppEvent):
528
+ pass
529
+
530
+ get_event_bus().emit(EmergencyStopEvent(source="dashboard"))
531
+ except Exception:
532
+ pass
533
+
534
+
535
+ # ── Mini Site Map ──────────────────────────────────────────────────────────
536
+
537
+ class MiniSiteMap(Widget):
538
+ """Компактная карта сайта с прогресс-барами."""
539
+
540
+ DEFAULT_CSS = """
541
+ MiniSiteMap {
542
+ height: 10;
543
+ border: solid $primary-darken-3;
544
+ padding: 0 1;
545
+ }
546
+ """
547
+
548
+ def __init__(self, **kwargs) -> None:
549
+ super().__init__(**kwargs)
550
+ self._paths: dict[str, int] = {} # path → count
551
+
552
+ def compose(self) -> ComposeResult:
553
+ yield Static("[bold]┌─ SITE MAP ─[/bold]")
554
+ yield Static("", id="sitemap-body")
555
+
556
+ def add_url(self, url: str) -> None:
557
+ """Добавить URL в мини-карту."""
558
+ try:
559
+ from urllib.parse import urlparse
560
+ parsed = urlparse(url)
561
+ # Первые 2 сегмента пути
562
+ parts = [p for p in parsed.path.split("/") if p]
563
+ key = "/" + "/".join(parts[:2]) if parts else "/"
564
+ self._paths[key] = self._paths.get(key, 0) + 1
565
+ self._update_display()
566
+ except Exception:
567
+ pass
568
+
569
+ def _update_display(self) -> None:
570
+ if not self._paths:
571
+ try:
572
+ self.query_one("#sitemap-body", Static).update("[dim]no URLs yet[/dim]")
573
+ except Exception:
574
+ pass
575
+ return
576
+
577
+ total = max(self._paths.values())
578
+ sorted_paths = sorted(self._paths.items(), key=lambda x: -x[1])[:8]
579
+ lines = []
580
+ for i, (path, count) in enumerate(sorted_paths):
581
+ prefix = "├─" if i < len(sorted_paths) - 1 else "└─"
582
+ bar = _hbar(count, total, 10)
583
+ lines.append(
584
+ f"[dim]{prefix}[/dim] [cyan]{path[:18]}[/cyan] [green]{bar}[/green] [dim]{count}[/dim]"
585
+ )
586
+
587
+ try:
588
+ self.query_one("#sitemap-body", Static).update("\n".join(lines))
589
+ except Exception:
590
+ pass
591
+
592
+
593
+ # ── Контейнер: LiveDashboardTab ────────────────────────────────────────────
594
+
595
+ class LiveDashboardTab(Widget):
596
+ """Вкладка «Live» — весь новый дашборд в одном виджете."""
597
+
598
+ DEFAULT_CSS = """
599
+ LiveDashboardTab {
600
+ layout: vertical;
601
+ height: 1fr;
602
+ overflow-y: auto;
603
+ }
604
+ LiveDashboardTab #live-row1 {
605
+ height: auto;
606
+ layout: horizontal;
607
+ }
608
+ LiveDashboardTab #live-row2 {
609
+ height: auto;
610
+ layout: horizontal;
611
+ }
612
+ LiveDashboardTab #live-row3 {
613
+ height: auto;
614
+ layout: horizontal;
615
+ }
616
+ LiveDashboardTab TrafficSparkline {
617
+ width: 1fr;
618
+ }
619
+ LiveDashboardTab EventFeed {
620
+ width: 2fr;
621
+ }
622
+ LiveDashboardTab BubbleChart {
623
+ width: 1fr;
624
+ }
625
+ LiveDashboardTab ScanSpeedometer {
626
+ width: 1fr;
627
+ }
628
+ LiveDashboardTab HeatmapWidget {
629
+ width: 2fr;
630
+ }
631
+ LiveDashboardTab MiniSiteMap {
632
+ width: 1fr;
633
+ }
634
+ LiveDashboardTab ResourceMonitor {
635
+ width: 1fr;
636
+ }
637
+ LiveDashboardTab EmergencyStop {
638
+ width: 1fr;
639
+ }
640
+ """
641
+
642
+ def compose(self) -> ComposeResult:
643
+ with Horizontal(id="live-row1"):
644
+ yield TrafficSparkline(id="live-sparkline")
645
+ yield EventFeed(id="live-feed")
646
+ yield BubbleChart(id="live-bubbles")
647
+ with Horizontal(id="live-row2"):
648
+ yield ScanSpeedometer(id="live-speedo")
649
+ yield HeatmapWidget(id="live-heatmap")
650
+ yield MiniSiteMap(id="live-sitemap")
651
+ with Horizontal(id="live-row3"):
652
+ yield ResourceMonitor(id="live-resources")
653
+ yield EmergencyStop(id="live-estop")
654
+
655
+ def on_mount(self) -> None:
656
+ """Подписаться на EventBus."""
657
+ try:
658
+ from pentool.core.event_bus import get_event_bus
659
+ from pentool.core.events import (
660
+ FindingDiscovered,
661
+ ProxyRequestDoneEvent,
662
+ ScanProgressEvent,
663
+ UrlCrawled,
664
+ )
665
+ bus = get_event_bus()
666
+ bus.subscribe(ProxyRequestDoneEvent, self._on_request)
667
+ bus.subscribe(FindingDiscovered, self._on_finding)
668
+ bus.subscribe(ScanProgressEvent, self._on_scan_progress)
669
+ bus.subscribe(UrlCrawled, self._on_url_crawled)
670
+ except Exception:
671
+ pass
672
+
673
+ def _on_request(self, event) -> None:
674
+ try:
675
+ self.app.call_from_thread(self._handle_request, event)
676
+ except Exception:
677
+ pass
678
+
679
+ def _handle_request(self, event) -> None:
680
+ try:
681
+ self.query_one("#live-sparkline", TrafficSparkline).increment()
682
+ url = getattr(event, "url", "") or ""
683
+ if url:
684
+ from urllib.parse import urlparse
685
+ host = urlparse(url).netloc or url
686
+ self.query_one("#live-heatmap", HeatmapWidget).increment_host(host)
687
+ self.query_one("#live-sitemap", MiniSiteMap).add_url(url)
688
+ self.query_one("#live-feed", EventFeed).add_event(
689
+ "request", f"→ {getattr(event, 'method', 'GET')} {url[:60]}"
690
+ )
691
+ except Exception:
692
+ pass
693
+
694
+ def _on_finding(self, event) -> None:
695
+ try:
696
+ self.app.call_from_thread(self._handle_finding, event)
697
+ except Exception:
698
+ pass
699
+
700
+ def _handle_finding(self, event) -> None:
701
+ try:
702
+ finding = event.finding
703
+ sev = getattr(finding, "severity", "info")
704
+ url = getattr(finding, "url", "?")
705
+ ftype = getattr(finding, "type", "?")
706
+ self.query_one("#live-bubbles", BubbleChart).add_finding(sev)
707
+ self.query_one("#live-speedo", ScanSpeedometer).add_finding()
708
+ self.query_one("#live-feed", EventFeed).add_event(
709
+ "finding", f"[{sev.upper()}] {ftype} → {url[:50]}"
710
+ )
711
+ except Exception:
712
+ pass
713
+
714
+ def _on_scan_progress(self, event) -> None:
715
+ try:
716
+ self.app.call_from_thread(self._handle_scan_progress, event)
717
+ except Exception:
718
+ pass
719
+
720
+ def _handle_scan_progress(self, event) -> None:
721
+ try:
722
+ done = getattr(event, "done", 0)
723
+ total = getattr(event, "total", 0)
724
+ scanning = getattr(event, "scanning", True)
725
+ self.query_one("#live-speedo", ScanSpeedometer).update_progress(done, total, scanning)
726
+ except Exception:
727
+ pass
728
+
729
+ def _on_url_crawled(self, event) -> None:
730
+ try:
731
+ self.app.call_from_thread(self._handle_url_crawled, event)
732
+ except Exception:
733
+ pass
734
+
735
+ def _handle_url_crawled(self, event) -> None:
736
+ try:
737
+ url = getattr(event, "url", "")
738
+ self.query_one("#live-sitemap", MiniSiteMap).add_url(url)
739
+ self.query_one("#live-feed", EventFeed).add_event("scan", f"crawled: {url[:60]}")
740
+ except Exception:
741
+ pass