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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +17 -0
- pentool/api/__init__.py +45 -0
- pentool/api/base_api.py +52 -0
- pentool/api/comparer_api.py +23 -0
- pentool/api/decoder_api.py +28 -0
- pentool/api/intruder_api.py +209 -0
- pentool/api/proxy_api.py +319 -0
- pentool/api/repeater_api.py +118 -0
- pentool/api/scanner_api.py +336 -0
- pentool/api/sequencer_api.py +21 -0
- pentool/api/spider_api.py +126 -0
- pentool/api/target_api.py +121 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +100 -0
- pentool/cli/project.py +90 -0
- pentool/cli/proxy.py +185 -0
- pentool/cli/scan.py +118 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +184 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +232 -0
- pentool/core/events.py +164 -0
- pentool/core/features.py +410 -0
- pentool/core/license.py +294 -0
- pentool/core/logging.py +59 -0
- pentool/core/plugin_manager.py +332 -0
- pentool/core/project.py +37 -0
- pentool/core/storage_interface.py +311 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +61 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +432 -0
- pentool/modules/intruder_turbo.py +190 -0
- pentool/modules/match_replace.py +146 -0
- pentool/modules/proxy.py +803 -0
- pentool/modules/repeater.py +237 -0
- pentool/modules/scanner/__init__.py +7 -0
- pentool/modules/scanner/ai_analyzer.py +230 -0
- pentool/modules/scanner/base.py +173 -0
- pentool/modules/scanner/baseline.py +101 -0
- pentool/modules/scanner/checks/__init__.py +47 -0
- pentool/modules/scanner/checks/broken_auth.py +231 -0
- pentool/modules/scanner/checks/cors.py +209 -0
- pentool/modules/scanner/checks/dom_xss.py +114 -0
- pentool/modules/scanner/checks/graphql.py +110 -0
- pentool/modules/scanner/checks/header_injection.py +262 -0
- pentool/modules/scanner/checks/headers.py +75 -0
- pentool/modules/scanner/checks/helpers.py +358 -0
- pentool/modules/scanner/checks/info_leak.py +68 -0
- pentool/modules/scanner/checks/jwt_none.py +238 -0
- pentool/modules/scanner/checks/lfi.py +186 -0
- pentool/modules/scanner/checks/nosql_injection.py +92 -0
- pentool/modules/scanner/checks/oauth.py +123 -0
- pentool/modules/scanner/checks/open_redirect.py +168 -0
- pentool/modules/scanner/checks/path_traversal.py +229 -0
- pentool/modules/scanner/checks/prototype_pollution.py +88 -0
- pentool/modules/scanner/checks/rce.py +338 -0
- pentool/modules/scanner/checks/sensitive_data.py +74 -0
- pentool/modules/scanner/checks/sqli.py +546 -0
- pentool/modules/scanner/checks/ssrf.py +273 -0
- pentool/modules/scanner/checks/ssti.py +353 -0
- pentool/modules/scanner/checks/xss.py +622 -0
- pentool/modules/scanner/checks/xxe.py +240 -0
- pentool/modules/scanner/engine.py +407 -0
- pentool/modules/scanner/fingerprint.py +169 -0
- pentool/modules/scanner/helpers.py +94 -0
- pentool/modules/scanner/mutator.py +378 -0
- pentool/modules/scanner/oob.py +104 -0
- pentool/modules/scanner/passive.py +114 -0
- pentool/modules/scanner/payloads.py +224 -0
- pentool/modules/scanner/report.py +117 -0
- pentool/modules/sequencer.py +500 -0
- pentool/modules/spider.py +702 -0
- pentool/modules/target.py +190 -0
- pentool/modules/websocket_handler.py +259 -0
- pentool/plugins/__init__.py +7 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +404 -0
- pentool/plugins/builtin/reports_pro.py +450 -0
- pentool/plugins/builtin/scanner_pro.py +308 -0
- pentool/plugins/example_plugin.py +75 -0
- pentool/project_to_txt.py +67 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +64 -0
- pentool/services/intruder_service.py +108 -0
- pentool/services/proxy_service.py +232 -0
- pentool/services/repeater_service.py +111 -0
- pentool/services/scan_service.py +340 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +522 -0
- pentool/storage/large_body_handler.py +54 -0
- pentool/storage/lru_cache.py +49 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1438 -0
- pentool/tui/constants.py +79 -0
- pentool/tui/dialogs/__init__.py +0 -0
- pentool/tui/dialogs/cert_dialog.py +81 -0
- pentool/tui/dialogs/file_selector.py +227 -0
- pentool/tui/dialogs/load_from_proxy.py +125 -0
- pentool/tui/dialogs/match_replace_dialog.py +229 -0
- pentool/tui/dialogs/project_dialog.py +82 -0
- pentool/tui/dialogs/scope_dialog.py +225 -0
- pentool/tui/messages.py +121 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +61 -0
- pentool/tui/mixins/dialog_cancel.py +25 -0
- pentool/tui/mixins/request_context_menu.py +285 -0
- pentool/tui/mixins/tab_rename.py +101 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +233 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +741 -0
- pentool/tui/screens/dashboard/screen.py +820 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +311 -0
- pentool/tui/screens/extensions/__init__.py +3 -0
- pentool/tui/screens/extensions/screen.py +24 -0
- pentool/tui/screens/intruder/__init__.py +3 -0
- pentool/tui/screens/intruder/screen.py +1362 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1664 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +646 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1984 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +520 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +543 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +385 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +361 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +181 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +206 -0
- pentool/tui/widgets/inspector_panel.py +112 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +73 -0
- pentool/tui/widgets/payload_drop_zone.py +66 -0
- pentool/tui/widgets/request_editor.py +504 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +113 -0
- pentool/tui/widgets/statusbar.py +99 -0
- pentool/tui/widgets/toolbar_button.py +76 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +361 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +251 -0
- pentool/utils/diff.py +91 -0
- pentool/utils/http_client.py +175 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +32 -0
- pentool-1.0.0.dist-info/METADATA +155 -0
- pentool-1.0.0.dist-info/RECORD +169 -0
- pentool-1.0.0.dist-info/WHEEL +5 -0
- pentool-1.0.0.dist-info/entry_points.txt +2 -0
- pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
- 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
|