pentool 0.1.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 +16 -0
- pentool/api/__init__.py +41 -0
- pentool/api/base_api.py +48 -0
- pentool/api/comparer_api.py +19 -0
- pentool/api/decoder_api.py +24 -0
- pentool/api/intruder_api.py +171 -0
- pentool/api/proxy_api.py +210 -0
- pentool/api/repeater_api.py +57 -0
- pentool/api/scanner_api.py +266 -0
- pentool/api/sequencer_api.py +17 -0
- pentool/api/spider_api.py +106 -0
- pentool/api/target_api.py +87 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +98 -0
- pentool/cli/project.py +88 -0
- pentool/cli/proxy.py +173 -0
- pentool/cli/scan.py +115 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +171 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +207 -0
- pentool/core/events.py +160 -0
- pentool/core/features.py +373 -0
- pentool/core/license.py +274 -0
- pentool/core/logging.py +54 -0
- pentool/core/plugin_manager.py +312 -0
- pentool/core/project.py +31 -0
- pentool/core/storage_interface.py +307 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +36 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +428 -0
- pentool/modules/intruder_turbo.py +176 -0
- pentool/modules/match_replace.py +142 -0
- pentool/modules/proxy.py +797 -0
- pentool/modules/repeater.py +195 -0
- pentool/modules/sequencer.py +492 -0
- pentool/modules/spider.py +679 -0
- pentool/modules/target.py +185 -0
- pentool/modules/websocket_handler.py +249 -0
- pentool/plugins/__init__.py +1 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +381 -0
- pentool/plugins/builtin/reports_pro.py +436 -0
- pentool/plugins/builtin/scanner_pro.py +291 -0
- pentool/plugins/example_plugin.py +39 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +60 -0
- pentool/services/intruder_service.py +94 -0
- pentool/services/proxy_service.py +178 -0
- pentool/services/repeater_service.py +85 -0
- pentool/services/scan_service.py +330 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +506 -0
- pentool/storage/large_body_handler.py +50 -0
- pentool/storage/lru_cache.py +45 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1429 -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 +226 -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 +224 -0
- pentool/tui/messages.py +103 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +59 -0
- pentool/tui/mixins/dialog_cancel.py +21 -0
- pentool/tui/mixins/request_context_menu.py +247 -0
- pentool/tui/mixins/tab_rename.py +89 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +229 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +730 -0
- pentool/tui/screens/dashboard/screen.py +771 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +306 -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 +1356 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1554 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +621 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1956 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +516 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +540 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +380 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +358 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +179 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +204 -0
- pentool/tui/widgets/inspector_panel.py +111 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +68 -0
- pentool/tui/widgets/payload_drop_zone.py +65 -0
- pentool/tui/widgets/request_editor.py +495 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +112 -0
- pentool/tui/widgets/statusbar.py +96 -0
- pentool/tui/widgets/toolbar_button.py +68 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +314 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +250 -0
- pentool/utils/diff.py +82 -0
- pentool/utils/http_client.py +145 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +31 -0
- pentool-0.1.0.dist-info/METADATA +231 -0
- pentool-0.1.0.dist-info/RECORD +133 -0
- pentool-0.1.0.dist-info/WHEEL +5 -0
- pentool-0.1.0.dist-info/entry_points.txt +2 -0
- pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
- pentool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
"""Экран Scanner — пассивное и активное сканирование."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from urllib.parse import urlparse, urlunparse
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
|
|
17
|
+
from textual.widgets import (
|
|
18
|
+
Checkbox,
|
|
19
|
+
Input,
|
|
20
|
+
Label,
|
|
21
|
+
ProgressBar,
|
|
22
|
+
RichLog,
|
|
23
|
+
Static,
|
|
24
|
+
TabPane,
|
|
25
|
+
TabbedContent,
|
|
26
|
+
TextArea,
|
|
27
|
+
)
|
|
28
|
+
import pyarrow as pa
|
|
29
|
+
from textual_fastdatatable import DataTable
|
|
30
|
+
from textual_fastdatatable.backend import ArrowBackend
|
|
31
|
+
|
|
32
|
+
from pentool.core.logging import get_logger
|
|
33
|
+
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
34
|
+
from pentool.tui.widgets.request_editor import HttpView
|
|
35
|
+
from pentool.tui.widgets.resize_handle import ResizeHandle
|
|
36
|
+
from pentool.tui.mixins.tab_rename import TabRenameMixin
|
|
37
|
+
from pentool.tui.mixins.request_context_menu import RequestContextMenuMixin
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
class _ScanTabState:
|
|
42
|
+
"""Состояние одной вкладки Scanner."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, tab_id: str, name: str) -> None:
|
|
45
|
+
self.tab_id = tab_id
|
|
46
|
+
self.name = name
|
|
47
|
+
# Данные сканирования
|
|
48
|
+
self.rows: list = []
|
|
49
|
+
self.row_sources: list[str] = []
|
|
50
|
+
self.scanner_api = None
|
|
51
|
+
# Флаги
|
|
52
|
+
self.scanning: bool = False
|
|
53
|
+
self.stop_requested: bool = False
|
|
54
|
+
self.paused: bool = False
|
|
55
|
+
self.detail_panel_open: bool = False
|
|
56
|
+
self.current_finding = None
|
|
57
|
+
# Отложенный URL цели — применяется в _fill_settings после монтирования Input
|
|
58
|
+
self.pending_target: str = ""
|
|
59
|
+
# Оригинальные запросы из Proxy/History для сканирования
|
|
60
|
+
self.seed_requests: list = [] # list[ParsedRequest]
|
|
61
|
+
self.pending_request = None # ParsedRequest | None, ждёт монтирования
|
|
62
|
+
# True если вкладка открыта для нового таргета — не грузить старые findings из БД
|
|
63
|
+
self.skip_db_load: bool = False
|
|
64
|
+
# Сохранённые параметры (для Resume)
|
|
65
|
+
self.last_targets: list[str] | None = None
|
|
66
|
+
self.last_check_names: list[str] | None = None
|
|
67
|
+
self.last_threads: int = 5
|
|
68
|
+
# Ссылка на активный ScanService (для request_stop)
|
|
69
|
+
self.active_service = None
|
|
70
|
+
self.last_delay_sec: float = 0.0
|
|
71
|
+
self.last_depth: int = 3
|
|
72
|
+
self.last_pages: int = 100
|
|
73
|
+
# URL, собранные краулером — используются при Resume (пропуск повторного краулинга)
|
|
74
|
+
self.last_crawled_targets: list[str] = []
|
|
75
|
+
# Live-счётчики для статус-панели
|
|
76
|
+
self.req_sent: int = 0
|
|
77
|
+
self.threads_active: int = 0
|
|
78
|
+
self.req_bucket: int = 0 # запросов за текущее окно (для req/s)
|
|
79
|
+
self.req_window_start: float = 0.0
|
|
80
|
+
self.req_per_sec: float = 0.0
|
|
81
|
+
self._last_ui_update: float = 0.0 # throttle: не чаще чем раз в 150ms
|
|
82
|
+
|
|
83
|
+
class ScannerScreen(TabRenameMixin, RequestContextMenuMixin, Widget):
|
|
84
|
+
"""Пассивное и активное сканирование уязвимостей — единый экран."""
|
|
85
|
+
|
|
86
|
+
DEFAULT_CSS = _CSS
|
|
87
|
+
|
|
88
|
+
BINDINGS = [
|
|
89
|
+
Binding("f5", "start_scan", "Start Scan", show=False, priority=True),
|
|
90
|
+
Binding("f6", "stop_scan", "Stop", show=False, priority=True),
|
|
91
|
+
Binding("r", "send_to_repeater", "→ Repeater", show=False, priority=True),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# TabRenameMixin config
|
|
95
|
+
_rename_input_id: str = "scan-rename-input"
|
|
96
|
+
_rename_tab_prefix: str = "scan-tab-"
|
|
97
|
+
_rename_tabs_widget_id: str = "scanner-tabs"
|
|
98
|
+
|
|
99
|
+
# RequestContextMenuMixin config — Scanner: curl + browser + send to Repeater
|
|
100
|
+
_cm_show_copy_url = True
|
|
101
|
+
_cm_show_ffuf = False
|
|
102
|
+
_cm_show_sqlmap = False
|
|
103
|
+
_cm_show_jwt = False
|
|
104
|
+
_cm_show_save_txt = False
|
|
105
|
+
_cm_show_send_repeater = True
|
|
106
|
+
|
|
107
|
+
def __init__(self, **kwargs) -> None:
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
self._passive_enabled = False
|
|
110
|
+
# ── Вкладки ──────────────────────────────────────────────────────────
|
|
111
|
+
self._tabs: list[_ScanTabState] = []
|
|
112
|
+
self._tab_counter: int = 0
|
|
113
|
+
self._active_tab_id: str | None = None
|
|
114
|
+
# ── Двойной клик (state хранится в TabRenameMixin) ────────────────────
|
|
115
|
+
self._tab_click_time: float = 0.0
|
|
116
|
+
self._tab_click_id: str | None = None
|
|
117
|
+
# ── Обратная совместимость (свойства активной вкладки) ────────────────
|
|
118
|
+
# Прокси к self._active_tab, чтобы не ломать остальной код
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def _active_tab(self) -> _ScanTabState | None:
|
|
122
|
+
for t in self._tabs:
|
|
123
|
+
if t.tab_id == self._active_tab_id:
|
|
124
|
+
return t
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# ── Обратная совместимость — свойства делегируются активной вкладке ──────
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def _scanner_api(self):
|
|
131
|
+
t = self._active_tab
|
|
132
|
+
return t.scanner_api if t else None
|
|
133
|
+
|
|
134
|
+
@_scanner_api.setter
|
|
135
|
+
def _scanner_api(self, v):
|
|
136
|
+
t = self._active_tab
|
|
137
|
+
if t:
|
|
138
|
+
t.scanner_api = v
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def _rows(self) -> list:
|
|
142
|
+
t = self._active_tab
|
|
143
|
+
return t.rows if t else []
|
|
144
|
+
|
|
145
|
+
@_rows.setter
|
|
146
|
+
def _rows(self, v: list):
|
|
147
|
+
t = self._active_tab
|
|
148
|
+
if t:
|
|
149
|
+
t.rows = v
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def _row_sources(self) -> list:
|
|
153
|
+
t = self._active_tab
|
|
154
|
+
return t.row_sources if t else []
|
|
155
|
+
|
|
156
|
+
@_row_sources.setter
|
|
157
|
+
def _row_sources(self, v: list):
|
|
158
|
+
t = self._active_tab
|
|
159
|
+
if t:
|
|
160
|
+
t.row_sources = v
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def _scanning(self) -> bool:
|
|
164
|
+
t = self._active_tab
|
|
165
|
+
return t.scanning if t else False
|
|
166
|
+
|
|
167
|
+
@_scanning.setter
|
|
168
|
+
def _scanning(self, v: bool):
|
|
169
|
+
t = self._active_tab
|
|
170
|
+
if t:
|
|
171
|
+
t.scanning = v
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def _stop_requested(self) -> bool:
|
|
175
|
+
t = self._active_tab
|
|
176
|
+
return t.stop_requested if t else False
|
|
177
|
+
|
|
178
|
+
@_stop_requested.setter
|
|
179
|
+
def _stop_requested(self, v: bool):
|
|
180
|
+
t = self._active_tab
|
|
181
|
+
if t:
|
|
182
|
+
t.stop_requested = v
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def _active_service(self):
|
|
186
|
+
t = self._active_tab
|
|
187
|
+
return t.active_service if t else None
|
|
188
|
+
|
|
189
|
+
@_active_service.setter
|
|
190
|
+
def _active_service(self, v) -> None:
|
|
191
|
+
t = self._active_tab
|
|
192
|
+
if t:
|
|
193
|
+
t.active_service = v
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def _paused(self) -> bool:
|
|
197
|
+
t = self._active_tab
|
|
198
|
+
return t.paused if t else False
|
|
199
|
+
|
|
200
|
+
@_paused.setter
|
|
201
|
+
def _paused(self, v: bool):
|
|
202
|
+
t = self._active_tab
|
|
203
|
+
if t:
|
|
204
|
+
t.paused = v
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def _detail_panel_open(self) -> bool:
|
|
208
|
+
t = self._active_tab
|
|
209
|
+
return t.detail_panel_open if t else False
|
|
210
|
+
|
|
211
|
+
@_detail_panel_open.setter
|
|
212
|
+
def _detail_panel_open(self, v: bool):
|
|
213
|
+
t = self._active_tab
|
|
214
|
+
if t:
|
|
215
|
+
t.detail_panel_open = v
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def _current_finding(self):
|
|
219
|
+
t = self._active_tab
|
|
220
|
+
return t.current_finding if t else None
|
|
221
|
+
|
|
222
|
+
@_current_finding.setter
|
|
223
|
+
def _current_finding(self, v):
|
|
224
|
+
t = self._active_tab
|
|
225
|
+
if t:
|
|
226
|
+
t.current_finding = v
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def _last_targets(self):
|
|
230
|
+
t = self._active_tab
|
|
231
|
+
return t.last_targets if t else None
|
|
232
|
+
|
|
233
|
+
@_last_targets.setter
|
|
234
|
+
def _last_targets(self, v):
|
|
235
|
+
t = self._active_tab
|
|
236
|
+
if t:
|
|
237
|
+
t.last_targets = v
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def _last_check_names(self):
|
|
241
|
+
t = self._active_tab
|
|
242
|
+
return t.last_check_names if t else None
|
|
243
|
+
|
|
244
|
+
@_last_check_names.setter
|
|
245
|
+
def _last_check_names(self, v):
|
|
246
|
+
t = self._active_tab
|
|
247
|
+
if t:
|
|
248
|
+
t.last_check_names = v
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def _last_threads(self) -> int:
|
|
252
|
+
t = self._active_tab
|
|
253
|
+
return t.last_threads if t else 5
|
|
254
|
+
|
|
255
|
+
@_last_threads.setter
|
|
256
|
+
def _last_threads(self, v: int):
|
|
257
|
+
t = self._active_tab
|
|
258
|
+
if t:
|
|
259
|
+
t.last_threads = v
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def _last_delay_sec(self) -> float:
|
|
263
|
+
t = self._active_tab
|
|
264
|
+
return t.last_delay_sec if t else 0.0
|
|
265
|
+
|
|
266
|
+
@_last_delay_sec.setter
|
|
267
|
+
def _last_delay_sec(self, v: float):
|
|
268
|
+
t = self._active_tab
|
|
269
|
+
if t:
|
|
270
|
+
t.last_delay_sec = v
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def _last_depth(self) -> int:
|
|
274
|
+
t = self._active_tab
|
|
275
|
+
return t.last_depth if t else 3
|
|
276
|
+
|
|
277
|
+
@_last_depth.setter
|
|
278
|
+
def _last_depth(self, v: int):
|
|
279
|
+
t = self._active_tab
|
|
280
|
+
if t:
|
|
281
|
+
t.last_depth = v
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def _last_pages(self) -> int:
|
|
285
|
+
t = self._active_tab
|
|
286
|
+
return t.last_pages if t else 100
|
|
287
|
+
|
|
288
|
+
@_last_pages.setter
|
|
289
|
+
def _last_pages(self, v: int):
|
|
290
|
+
t = self._active_tab
|
|
291
|
+
if t:
|
|
292
|
+
t.last_pages = v
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def _last_crawled_targets(self) -> list:
|
|
296
|
+
t = self._active_tab
|
|
297
|
+
return t.last_crawled_targets if t else []
|
|
298
|
+
|
|
299
|
+
@_last_crawled_targets.setter
|
|
300
|
+
def _last_crawled_targets(self, v: list):
|
|
301
|
+
t = self._active_tab
|
|
302
|
+
if t:
|
|
303
|
+
t.last_crawled_targets = v
|
|
304
|
+
|
|
305
|
+
def compose(self) -> ComposeResult:
|
|
306
|
+
# ── Тулбар ─────────────────────────────────────────────────────────────
|
|
307
|
+
with Horizontal(id="toolbar"):
|
|
308
|
+
yield ToolbarButton("▶ Start", "btn-start")
|
|
309
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
310
|
+
yield ToolbarButton("■ Stop", "btn-stop", classes="disabled")
|
|
311
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
312
|
+
yield ToolbarButton("→ Repeater", "btn-send-repeater",classes="disabled")
|
|
313
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
314
|
+
yield ToolbarButton("📄 Report", "btn-report")
|
|
315
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
316
|
+
yield ToolbarButton("🔒 PRO Report", "btn-pro-report", classes="pro-locked")
|
|
317
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
318
|
+
yield ToolbarButton("🗑 Clear", "btn-clear")
|
|
319
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
320
|
+
yield ToolbarButton("● Passive: OFF","btn-passive")
|
|
321
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
322
|
+
yield ToolbarButton("⚑ False+", "btn-fp")
|
|
323
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
324
|
+
yield ToolbarButton("⬇ History", "btn-from-history")
|
|
325
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
326
|
+
yield ToolbarButton("➕ Tab", "btn-new-tab")
|
|
327
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
328
|
+
yield ToolbarButton("➖ Tab", "btn-close-tab")
|
|
329
|
+
|
|
330
|
+
# ── TabbedContent — основной контейнер ─────────────────────────────────
|
|
331
|
+
with Vertical(id="scanner-tabs-area"):
|
|
332
|
+
yield TabbedContent(id="scanner-tabs")
|
|
333
|
+
|
|
334
|
+
yield Static(
|
|
335
|
+
"Ctrl+J: Start │ Ctrl+P: Pause/Resume │ Ctrl+R: Send to Repeater"
|
|
336
|
+
" │ M: Context menu │ Double-click tab to rename",
|
|
337
|
+
id="status-bar",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# ── mount ──────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def on_mount(self) -> None:
|
|
343
|
+
self.action_new_tab()
|
|
344
|
+
|
|
345
|
+
# ── Управление вкладками ──────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
def action_new_tab(self, initial_url: str = "", seed_request=None, seed_requests=None) -> None:
|
|
348
|
+
|
|
349
|
+
self._tab_counter += 1
|
|
350
|
+
tab_id = f"scan-tab-{self._tab_counter}"
|
|
351
|
+
name = f"Scan {self._tab_counter}"
|
|
352
|
+
state = _ScanTabState(tab_id, name)
|
|
353
|
+
|
|
354
|
+
if seed_requests is not None:
|
|
355
|
+
state.seed_requests = list(seed_requests)
|
|
356
|
+
url = getattr(seed_requests[0], "url", "") if seed_requests else initial_url
|
|
357
|
+
state.pending_target = url or initial_url
|
|
358
|
+
state.skip_db_load = True
|
|
359
|
+
elif seed_request is not None:
|
|
360
|
+
state.seed_requests = [seed_request]
|
|
361
|
+
state.pending_request = seed_request
|
|
362
|
+
url = getattr(seed_request, "url", "") or initial_url
|
|
363
|
+
state.pending_target = url
|
|
364
|
+
state.skip_db_load = True
|
|
365
|
+
else:
|
|
366
|
+
state.pending_target = initial_url
|
|
367
|
+
if initial_url:
|
|
368
|
+
state.skip_db_load = True
|
|
369
|
+
|
|
370
|
+
self._tabs.append(state)
|
|
371
|
+
|
|
372
|
+
tabs = self.query_one("#scanner-tabs", TabbedContent)
|
|
373
|
+
pane = TabPane(name, id=tab_id)
|
|
374
|
+
tabs.add_pane(pane)
|
|
375
|
+
tabs.active = tab_id
|
|
376
|
+
self._active_tab_id = tab_id
|
|
377
|
+
self.call_after_refresh(self._mount_tab_content, tab_id, state)
|
|
378
|
+
|
|
379
|
+
def _mount_tab_content(self, tab_id: str, state: _ScanTabState) -> None:
|
|
380
|
+
"""Смонтировать содержимое вкладки Scanner."""
|
|
381
|
+
try:
|
|
382
|
+
pane = self.query_one(f"#{tab_id}", TabPane)
|
|
383
|
+
body = Vertical(classes="scan-tab-body", id=f"scan-body-{tab_id}")
|
|
384
|
+
pane.mount(body)
|
|
385
|
+
self.call_after_refresh(self._fill_tab_body, tab_id, state, body)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
logger.debug("_mount_tab_content: %s", exc)
|
|
388
|
+
|
|
389
|
+
def _apply_pending_target(self, state: _ScanTabState) -> None:
|
|
390
|
+
"""Применить pending_target в уже смонтированный Input (при переиспользовании вкладки)."""
|
|
391
|
+
try:
|
|
392
|
+
inp = self.query_one(f"#target-input-{state.tab_id}", Input)
|
|
393
|
+
inp.value = state.pending_target
|
|
394
|
+
state.pending_target = ""
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
def _fill_tab_body(self, tab_id: str, state: _ScanTabState, body: Vertical) -> None:
|
|
399
|
+
"""Заполнить тело вкладки виджетами Scanner."""
|
|
400
|
+
try:
|
|
401
|
+
# Settings area
|
|
402
|
+
settings = Vertical(
|
|
403
|
+
classes="tab-settings-area",
|
|
404
|
+
id=f"settings-area-{tab_id}",
|
|
405
|
+
)
|
|
406
|
+
# Results area
|
|
407
|
+
results = Vertical(
|
|
408
|
+
classes="tab-results-area",
|
|
409
|
+
id=f"results-area-{tab_id}",
|
|
410
|
+
)
|
|
411
|
+
body.mount(settings)
|
|
412
|
+
body.mount(ResizeHandle(
|
|
413
|
+
f"settings-area-{tab_id}", f"results-area-{tab_id}",
|
|
414
|
+
vertical=True,
|
|
415
|
+
id=f"resize-settings-results-{tab_id}",
|
|
416
|
+
))
|
|
417
|
+
body.mount(results)
|
|
418
|
+
self.call_after_refresh(self._fill_settings, tab_id, state, settings)
|
|
419
|
+
self.call_after_refresh(self._fill_results, tab_id, state, results)
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
logger.debug("_fill_tab_body: %s", exc)
|
|
422
|
+
|
|
423
|
+
def _fill_settings(self, tab_id: str, state: _ScanTabState, settings: Vertical) -> None:
|
|
424
|
+
"""Заполнить settings area вкладки."""
|
|
425
|
+
try:
|
|
426
|
+
target_row = Horizontal(
|
|
427
|
+
Label("Target:", classes="tab-target-label"),
|
|
428
|
+
Input(
|
|
429
|
+
placeholder="https://example.com",
|
|
430
|
+
id=f"target-input-{tab_id}",
|
|
431
|
+
classes="tab-target-input",
|
|
432
|
+
value=state.pending_target,
|
|
433
|
+
compact=True,
|
|
434
|
+
),
|
|
435
|
+
id=f"target-row-{tab_id}",
|
|
436
|
+
classes="tab-target-row",
|
|
437
|
+
)
|
|
438
|
+
state.pending_target = "" # сбрасываем — уже применён
|
|
439
|
+
checks_row = Horizontal(
|
|
440
|
+
Label("Checks:", classes="tab-checks-label"),
|
|
441
|
+
Checkbox("SQLi", value=True, id=f"chk-sqli-{tab_id}", classes="chk"),
|
|
442
|
+
Checkbox("XSS", value=True, id=f"chk-xss-{tab_id}", classes="chk"),
|
|
443
|
+
Checkbox("SSTI", value=True, id=f"chk-ssti-{tab_id}", classes="chk"),
|
|
444
|
+
Checkbox("LFI", value=True, id=f"chk-lfi-{tab_id}", classes="chk"),
|
|
445
|
+
Checkbox("RCE", value=True, id=f"chk-rce-{tab_id}", classes="chk"),
|
|
446
|
+
Checkbox("Redirect", value=True, id=f"chk-redirect-{tab_id}", classes="chk"),
|
|
447
|
+
Checkbox("SSRF", value=True, id=f"chk-ssrf-{tab_id}", classes="chk"),
|
|
448
|
+
Checkbox("XXE", value=True, id=f"chk-xxe-{tab_id}", classes="chk"),
|
|
449
|
+
Checkbox("CORS", value=True, id=f"chk-cors-{tab_id}", classes="chk"),
|
|
450
|
+
Checkbox("PathTraversal",value=True, id=f"chk-pathtraversal-{tab_id}",classes="chk"),
|
|
451
|
+
Checkbox("HeaderInj", value=True, id=f"chk-headerinj-{tab_id}", classes="chk"),
|
|
452
|
+
Checkbox("BrokenAuth", value=True, id=f"chk-brokenauth-{tab_id}", classes="chk"),
|
|
453
|
+
Checkbox("JWT", value=True, id=f"chk-jwt-{tab_id}", classes="chk"),
|
|
454
|
+
id=f"checks-row-{tab_id}",
|
|
455
|
+
classes="tab-checks-row",
|
|
456
|
+
)
|
|
457
|
+
opts_row = Horizontal(
|
|
458
|
+
Label("Threads:", classes="opt-label"),
|
|
459
|
+
Input("5", id=f"opt-threads-{tab_id}", classes="opt-input", compact=True),
|
|
460
|
+
Label("Delay (ms):", classes="opt-label"),
|
|
461
|
+
Input("0", id=f"opt-delay-{tab_id}", classes="opt-input", compact=True),
|
|
462
|
+
Label("Depth:", classes="opt-label"),
|
|
463
|
+
Input("3", id=f"opt-depth-{tab_id}", classes="opt-input", compact=True),
|
|
464
|
+
Label("Pages:", classes="opt-label"),
|
|
465
|
+
Input("100", id=f"opt-pages-{tab_id}", classes="opt-input", compact=True),
|
|
466
|
+
id=f"scan-options-row-{tab_id}",
|
|
467
|
+
classes="tab-opts-row",
|
|
468
|
+
)
|
|
469
|
+
prog_row = Horizontal(
|
|
470
|
+
ProgressBar(total=100, id=f"scan-progress-{tab_id}", show_eta=False,
|
|
471
|
+
classes="tab-progress-bar"),
|
|
472
|
+
Static("—", id=f"progress-label-{tab_id}", classes="tab-progress-label"),
|
|
473
|
+
id=f"progress-row-{tab_id}",
|
|
474
|
+
classes="tab-progress-row",
|
|
475
|
+
)
|
|
476
|
+
live_row = Horizontal(
|
|
477
|
+
Static(
|
|
478
|
+
"[dim]Requests:[/dim] [bold]0[/bold]"
|
|
479
|
+
" [dim]│[/dim] [dim]Speed:[/dim] [bold]0[/bold][dim] req/s[/dim]"
|
|
480
|
+
" [dim]│[/dim] [dim]Threads:[/dim] [bold]0[/bold]"
|
|
481
|
+
" [dim]│[/dim] [dim]Idle[/dim]",
|
|
482
|
+
id=f"scan-live-status-{tab_id}",
|
|
483
|
+
classes="tab-live-status",
|
|
484
|
+
markup=True,
|
|
485
|
+
),
|
|
486
|
+
id=f"live-row-{tab_id}",
|
|
487
|
+
classes="tab-live-row",
|
|
488
|
+
)
|
|
489
|
+
settings.mount(target_row)
|
|
490
|
+
settings.mount(checks_row)
|
|
491
|
+
settings.mount(opts_row)
|
|
492
|
+
settings.mount(prog_row)
|
|
493
|
+
settings.mount(live_row)
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
logger.debug("_fill_settings: %s", exc)
|
|
496
|
+
|
|
497
|
+
def _fill_results(self, tab_id: str, state: _ScanTabState, results: Vertical) -> None:
|
|
498
|
+
"""Заполнить results area вкладки."""
|
|
499
|
+
try:
|
|
500
|
+
upper = Horizontal(
|
|
501
|
+
id=f"upper-row-{tab_id}",
|
|
502
|
+
classes="tab-upper-row",
|
|
503
|
+
)
|
|
504
|
+
detail = Horizontal(
|
|
505
|
+
id=f"detail-panel-{tab_id}",
|
|
506
|
+
classes="tab-detail-panel",
|
|
507
|
+
)
|
|
508
|
+
results.mount(upper)
|
|
509
|
+
results.mount(ResizeHandle(
|
|
510
|
+
f"upper-row-{tab_id}", f"detail-panel-{tab_id}",
|
|
511
|
+
vertical=True,
|
|
512
|
+
id=f"resize-upper-detail-{tab_id}",
|
|
513
|
+
))
|
|
514
|
+
results.mount(detail)
|
|
515
|
+
self.call_after_refresh(self._fill_upper_row, tab_id, state, upper)
|
|
516
|
+
self.call_after_refresh(self._fill_detail_panel, tab_id, state, detail)
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
logger.debug("_fill_results: %s", exc)
|
|
519
|
+
|
|
520
|
+
def _fill_upper_row(self, tab_id: str, state: _ScanTabState, upper: Horizontal) -> None:
|
|
521
|
+
try:
|
|
522
|
+
findings_panel = Vertical(
|
|
523
|
+
Static("Findings (passive / active)",
|
|
524
|
+
id=f"findings-label-{tab_id}",
|
|
525
|
+
classes="tab-findings-label"),
|
|
526
|
+
DataTable(id=f"findings-table-{tab_id}", classes="tab-findings-table"),
|
|
527
|
+
id=f"findings-panel-{tab_id}",
|
|
528
|
+
classes="tab-findings-panel",
|
|
529
|
+
)
|
|
530
|
+
log_panel = Vertical(
|
|
531
|
+
Static("Scan Log",
|
|
532
|
+
id=f"log-label-{tab_id}",
|
|
533
|
+
classes="tab-log-label"),
|
|
534
|
+
RichLog(id=f"scan-log-{tab_id}", highlight=True, markup=True,
|
|
535
|
+
wrap=True, max_lines=1000, classes="tab-scan-log"),
|
|
536
|
+
id=f"log-panel-{tab_id}",
|
|
537
|
+
classes="tab-log-panel",
|
|
538
|
+
)
|
|
539
|
+
upper.mount(findings_panel)
|
|
540
|
+
upper.mount(ResizeHandle(
|
|
541
|
+
f"findings-panel-{tab_id}", f"log-panel-{tab_id}",
|
|
542
|
+
id=f"resize-find-log-{tab_id}",
|
|
543
|
+
))
|
|
544
|
+
upper.mount(log_panel)
|
|
545
|
+
# Инициализируем таблицу
|
|
546
|
+
self.call_after_refresh(self._setup_tab_table, tab_id)
|
|
547
|
+
# Загружаем findings из БД
|
|
548
|
+
self.call_after_refresh(self._load_tab_passive_findings, tab_id, state)
|
|
549
|
+
self.call_after_refresh(
|
|
550
|
+
lambda: self._tab_log(tab_id,
|
|
551
|
+
"Scanner ready. Enter target URL and press [bold]▶ Start[/bold] or F5.")
|
|
552
|
+
)
|
|
553
|
+
except Exception as exc:
|
|
554
|
+
logger.debug("_fill_upper_row: %s", exc)
|
|
555
|
+
|
|
556
|
+
def _fill_detail_panel(self, tab_id: str, state: _ScanTabState, detail: Horizontal) -> None:
|
|
557
|
+
try:
|
|
558
|
+
req_col = Vertical(
|
|
559
|
+
Static("Request", id=f"detail-request-label-{tab_id}",
|
|
560
|
+
classes="tab-detail-label"),
|
|
561
|
+
HttpView(id=f"detail-request-{tab_id}", classes="tab-detail-view"),
|
|
562
|
+
id=f"detail-request-col-{tab_id}",
|
|
563
|
+
classes="tab-detail-req-col",
|
|
564
|
+
)
|
|
565
|
+
proof_col = Vertical(
|
|
566
|
+
Static("Proof", id=f"detail-proof-label-{tab_id}",
|
|
567
|
+
classes="tab-detail-label"),
|
|
568
|
+
TextArea("", read_only=True, id=f"detail-proof-{tab_id}",
|
|
569
|
+
soft_wrap=True, classes="tab-detail-proof"),
|
|
570
|
+
id=f"detail-proof-col-{tab_id}",
|
|
571
|
+
classes="tab-detail-proof-col",
|
|
572
|
+
)
|
|
573
|
+
resp_col = Vertical(
|
|
574
|
+
Static("Response", id=f"detail-response-label-{tab_id}",
|
|
575
|
+
classes="tab-detail-label"),
|
|
576
|
+
HttpView(id=f"detail-response-{tab_id}", classes="tab-detail-view"),
|
|
577
|
+
id=f"detail-response-col-{tab_id}",
|
|
578
|
+
classes="tab-detail-resp-col",
|
|
579
|
+
)
|
|
580
|
+
detail.mount(req_col)
|
|
581
|
+
detail.mount(ResizeHandle(
|
|
582
|
+
f"detail-request-col-{tab_id}", f"detail-proof-col-{tab_id}",
|
|
583
|
+
id=f"resize-req-proof-{tab_id}",
|
|
584
|
+
))
|
|
585
|
+
detail.mount(proof_col)
|
|
586
|
+
detail.mount(ResizeHandle(
|
|
587
|
+
f"detail-proof-col-{tab_id}", f"detail-response-col-{tab_id}",
|
|
588
|
+
id=f"resize-proof-resp-{tab_id}",
|
|
589
|
+
))
|
|
590
|
+
detail.mount(resp_col)
|
|
591
|
+
except Exception as exc:
|
|
592
|
+
logger.debug("_fill_detail_panel: %s", exc)
|
|
593
|
+
|
|
594
|
+
def action_close_tab(self) -> None:
|
|
595
|
+
if len(self._tabs) <= 1:
|
|
596
|
+
self.app.notify("Cannot close the last tab", severity="warning")
|
|
597
|
+
return
|
|
598
|
+
tab_id = self._active_tab_id
|
|
599
|
+
if not tab_id:
|
|
600
|
+
return
|
|
601
|
+
# Найти соседнюю вкладку
|
|
602
|
+
idx = next((i for i, t in enumerate(self._tabs) if t.tab_id == tab_id), -1)
|
|
603
|
+
self._tabs = [t for t in self._tabs if t.tab_id != tab_id]
|
|
604
|
+
# Активируем соседнюю
|
|
605
|
+
new_idx = max(0, idx - 1)
|
|
606
|
+
new_tab = self._tabs[new_idx]
|
|
607
|
+
self._active_tab_id = new_tab.tab_id
|
|
608
|
+
try:
|
|
609
|
+
tabs = self.query_one("#scanner-tabs", TabbedContent)
|
|
610
|
+
tabs.remove_pane(tab_id)
|
|
611
|
+
tabs.active = new_tab.tab_id
|
|
612
|
+
except Exception as exc:
|
|
613
|
+
logger.debug("action_close_tab: %s", exc)
|
|
614
|
+
self.call_after_refresh(self._sync_toolbar_to_tab)
|
|
615
|
+
|
|
616
|
+
@on(TabbedContent.TabActivated)
|
|
617
|
+
def on_scanner_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
618
|
+
"""При смене вкладки обновляем активное состояние и синхронизируем toolbar."""
|
|
619
|
+
if event.tabbed_content.id != "scanner-tabs":
|
|
620
|
+
return
|
|
621
|
+
tab_id = event.pane.id if event.pane else None
|
|
622
|
+
if not tab_id:
|
|
623
|
+
return
|
|
624
|
+
self._active_tab_id = tab_id
|
|
625
|
+
self.call_after_refresh(self._sync_toolbar_to_tab)
|
|
626
|
+
|
|
627
|
+
def _sync_toolbar_to_tab(self) -> None:
|
|
628
|
+
t = self._active_tab
|
|
629
|
+
if t is None:
|
|
630
|
+
return
|
|
631
|
+
try:
|
|
632
|
+
btn_start = self.query_one("#btn-start", ToolbarButton)
|
|
633
|
+
btn_stop = self.query_one("#btn-stop", ToolbarButton)
|
|
634
|
+
if t.scanning:
|
|
635
|
+
btn_start.label = "▶ Start"
|
|
636
|
+
btn_start.disabled = True
|
|
637
|
+
btn_stop.disabled = False
|
|
638
|
+
elif t.paused and t.last_targets:
|
|
639
|
+
btn_start.label = "▶ Resume"
|
|
640
|
+
btn_start.disabled = False
|
|
641
|
+
btn_stop.disabled = True
|
|
642
|
+
else:
|
|
643
|
+
btn_start.label = "▶ Start"
|
|
644
|
+
btn_start.disabled = False
|
|
645
|
+
btn_stop.disabled = True
|
|
646
|
+
except Exception:
|
|
647
|
+
pass
|
|
648
|
+
|
|
649
|
+
def _start_rename(self, tab_id: str) -> None:
|
|
650
|
+
state = next((t for t in self._tabs if t.tab_id == tab_id), None)
|
|
651
|
+
logger.debug("_start_rename: tab_id=%r state=%r", tab_id, state)
|
|
652
|
+
if state is None:
|
|
653
|
+
return
|
|
654
|
+
try:
|
|
655
|
+
try:
|
|
656
|
+
inp = self.query_one("#scan-rename-input", Input)
|
|
657
|
+
logger.debug("_start_rename: found existing input")
|
|
658
|
+
except Exception:
|
|
659
|
+
logger.debug("_start_rename: creating new input, mounting to #scanner-tabs-area")
|
|
660
|
+
inp = Input(id="scan-rename-input", placeholder="Tab name...", compact=True)
|
|
661
|
+
self.query_one("#scanner-tabs-area").mount(inp)
|
|
662
|
+
inp.value = state.name
|
|
663
|
+
inp.display = True
|
|
664
|
+
inp.focus()
|
|
665
|
+
inp.action_select_all()
|
|
666
|
+
inp._rename_tab_id = tab_id # type: ignore[attr-defined]
|
|
667
|
+
logger.debug("_start_rename: input shown, value=%r", inp.value)
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logger.debug("_start_rename: EXCEPTION: %s", e)
|
|
670
|
+
|
|
671
|
+
def _rename_tab(self, tab_id: str, new_name: str) -> None:
|
|
672
|
+
state = next((t for t in self._tabs if t.tab_id == tab_id), None)
|
|
673
|
+
if state is None:
|
|
674
|
+
return
|
|
675
|
+
state.name = new_name
|
|
676
|
+
try:
|
|
677
|
+
tabs = self.query_one("#scanner-tabs", TabbedContent)
|
|
678
|
+
tab_widget = tabs.get_tab(tab_id)
|
|
679
|
+
if tab_widget is not None:
|
|
680
|
+
tab_widget.label = new_name
|
|
681
|
+
except Exception:
|
|
682
|
+
pass
|
|
683
|
+
|
|
684
|
+
# ── Helpers: вспомогательные методы работающие с tab_id ──────────────────
|
|
685
|
+
|
|
686
|
+
def _tab_widget(self, widget_id: str, tab_id: str | None = None, cls=None):
|
|
687
|
+
tid = tab_id or self._active_tab_id or ""
|
|
688
|
+
try:
|
|
689
|
+
wid = f"#{widget_id}-{tid}"
|
|
690
|
+
return self.query_one(wid) if cls is None else self.query_one(wid, cls)
|
|
691
|
+
except Exception:
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
def _tab_log(self, tab_id: str | None, msg: str, color: str = "dim") -> None:
|
|
695
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
696
|
+
tid = tab_id or self._active_tab_id or ""
|
|
697
|
+
try:
|
|
698
|
+
log = self.query_one(f"#scan-log-{tid}", RichLog)
|
|
699
|
+
log.write(f"[{color}]{ts}[/{color}] {msg}")
|
|
700
|
+
except Exception:
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
def _setup_tab_table(self, tab_id: str | None = None) -> None:
|
|
704
|
+
tid = tab_id or self._active_tab_id or ""
|
|
705
|
+
try:
|
|
706
|
+
table = self.query_one(f"#findings-table-{tid}", DataTable)
|
|
707
|
+
table.cursor_type = "row"
|
|
708
|
+
table.max_column_content_width = 80
|
|
709
|
+
# column_widths: _#, Source, Type, Severity, URL, Param, Evidence
|
|
710
|
+
table.column_widths = [4, 10, 16, 10, 50, 14, 30]
|
|
711
|
+
empty = pa.Table.from_pylist([], schema=self._FINDINGS_SCHEMA)
|
|
712
|
+
table.backend = ArrowBackend(empty)
|
|
713
|
+
table._ordered_columns = None
|
|
714
|
+
table._clear_caches()
|
|
715
|
+
table._require_update_dimensions = True
|
|
716
|
+
except Exception as exc:
|
|
717
|
+
logger.debug("_setup_tab_table: %s", exc)
|
|
718
|
+
|
|
719
|
+
def _load_tab_passive_findings(self, tab_id: str, state: _ScanTabState) -> None:
|
|
720
|
+
try:
|
|
721
|
+
# Если вкладка открыта для нового таргета — не грузить старые findings из БД
|
|
722
|
+
if state.skip_db_load:
|
|
723
|
+
state.skip_db_load = False # сброс — при следующей загрузке работает нормально
|
|
724
|
+
return
|
|
725
|
+
db_path = getattr(self.app, "db_path", None) or getattr(self.app, "_db_path", "")
|
|
726
|
+
# Не грузить findings если нет открытого проекта (БД не выбрана)
|
|
727
|
+
if not db_path:
|
|
728
|
+
return
|
|
729
|
+
# Не грузить findings для дефолтной вкладки при старте без явного проекта
|
|
730
|
+
project_path = getattr(self.app, "_project_path", None)
|
|
731
|
+
if not project_path:
|
|
732
|
+
return # проект не открыт — не тянуть данные из дефолтной БД
|
|
733
|
+
state.scanner_api = self._get_or_create_api_for(state, db_path)
|
|
734
|
+
self._load_findings_worker_for(tab_id, state)
|
|
735
|
+
except Exception as exc:
|
|
736
|
+
logger.warning("_load_tab_passive_findings: %s", exc)
|
|
737
|
+
|
|
738
|
+
def _get_or_create_api_for(self, state: _ScanTabState, db_path: str):
|
|
739
|
+
from pentool.api.scanner_api import ScannerAPI
|
|
740
|
+
from pentool.utils.http_client import HTTPClient
|
|
741
|
+
if state.scanner_api is None:
|
|
742
|
+
http_client = HTTPClient(timeout=20.0, follow_redirects=True, verify_ssl=False)
|
|
743
|
+
state.scanner_api = ScannerAPI(db_path=db_path, http_client=http_client)
|
|
744
|
+
return state.scanner_api
|
|
745
|
+
|
|
746
|
+
@work
|
|
747
|
+
async def _load_findings_worker_for(self, tab_id: str, state: _ScanTabState) -> None:
|
|
748
|
+
try:
|
|
749
|
+
findings = await state.scanner_api.get_findings()
|
|
750
|
+
self._populate_tab_from_db(tab_id, state, findings)
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
logger.warning("_load_findings_worker_for: %s", exc)
|
|
753
|
+
|
|
754
|
+
def _populate_tab_from_db(self, tab_id: str, state: _ScanTabState, findings) -> None:
|
|
755
|
+
# Не перезаписывать таблицу если скан активен — иначе findings задублируются
|
|
756
|
+
if state.scanning:
|
|
757
|
+
return
|
|
758
|
+
state.rows = []
|
|
759
|
+
state.row_sources = []
|
|
760
|
+
for f in findings:
|
|
761
|
+
state.rows.append(f)
|
|
762
|
+
state.row_sources.append("passive")
|
|
763
|
+
self._rebuild_table_backend(tab_id, state)
|
|
764
|
+
self._update_label(tab_id, state)
|
|
765
|
+
if findings:
|
|
766
|
+
self._tab_log(tab_id, f"Loaded [bold]{len(findings)}[/bold] findings from DB.")
|
|
767
|
+
|
|
768
|
+
def _setup_table(self) -> None:
|
|
769
|
+
"""Инициализация таблицы активной вкладки — делегирует в _setup_tab_table."""
|
|
770
|
+
self._setup_tab_table(self._active_tab_id)
|
|
771
|
+
|
|
772
|
+
_FINDINGS_SCHEMA = pa.schema([
|
|
773
|
+
("_#", pa.string()),
|
|
774
|
+
("Source", pa.string()),
|
|
775
|
+
("Type", pa.string()),
|
|
776
|
+
("Severity", pa.string()),
|
|
777
|
+
("URL", pa.string()),
|
|
778
|
+
("Param", pa.string()),
|
|
779
|
+
("Evidence", pa.string()),
|
|
780
|
+
])
|
|
781
|
+
|
|
782
|
+
def _reset_table(self) -> None:
|
|
783
|
+
"""Сбросить таблицу активной вкладки."""
|
|
784
|
+
tid = self._active_tab_id or ""
|
|
785
|
+
try:
|
|
786
|
+
table = self.query_one(f"#findings-table-{tid}", DataTable)
|
|
787
|
+
empty = pa.Table.from_pylist([], schema=self._FINDINGS_SCHEMA)
|
|
788
|
+
table.backend = ArrowBackend(empty)
|
|
789
|
+
table._ordered_columns = None
|
|
790
|
+
table._clear_caches()
|
|
791
|
+
table._require_update_dimensions = True
|
|
792
|
+
table.refresh()
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
logger.debug("_reset_table: %s", exc)
|
|
795
|
+
|
|
796
|
+
def _log(self, msg: str, color: str = "dim") -> None:
|
|
797
|
+
"""Лог в активную вкладку."""
|
|
798
|
+
self._tab_log(self._active_tab_id, msg, color)
|
|
799
|
+
|
|
800
|
+
# ── helpers ────────────────────────────────────────────────────────────────
|
|
801
|
+
|
|
802
|
+
def _get_opt_int(self, widget_id: str, default: int) -> int:
|
|
803
|
+
tid = self._active_tab_id or ""
|
|
804
|
+
try:
|
|
805
|
+
val = self.query_one(f"#{widget_id}-{tid}", Input).value.strip()
|
|
806
|
+
v = int(val)
|
|
807
|
+
return v if v > 0 else default
|
|
808
|
+
except Exception:
|
|
809
|
+
return default
|
|
810
|
+
|
|
811
|
+
def _get_opt_float(self, widget_id: str, default: float) -> float:
|
|
812
|
+
tid = self._active_tab_id or ""
|
|
813
|
+
try:
|
|
814
|
+
val = self.query_one(f"#{widget_id}-{tid}", Input).value.strip()
|
|
815
|
+
v = float(val)
|
|
816
|
+
return v if v >= 0 else default
|
|
817
|
+
except Exception:
|
|
818
|
+
return default
|
|
819
|
+
|
|
820
|
+
# ── DB load ────────────────────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
def _load_passive_findings(self) -> None:
|
|
823
|
+
t = self._active_tab
|
|
824
|
+
if t is None:
|
|
825
|
+
return
|
|
826
|
+
try:
|
|
827
|
+
db_path = getattr(self.app, "db_path", None) or getattr(self.app, "_db_path", "")
|
|
828
|
+
if not db_path:
|
|
829
|
+
return
|
|
830
|
+
t.scanner_api = self._get_or_create_api_for(t, db_path)
|
|
831
|
+
self._load_findings_worker_for(t.tab_id, t)
|
|
832
|
+
except Exception as exc:
|
|
833
|
+
logger.warning("ScannerScreen._load_passive_findings: %s", exc)
|
|
834
|
+
|
|
835
|
+
def _get_or_create_api(self, db_path: str):
|
|
836
|
+
t = self._active_tab
|
|
837
|
+
if t is None:
|
|
838
|
+
return None
|
|
839
|
+
return self._get_or_create_api_for(t, db_path)
|
|
840
|
+
|
|
841
|
+
@work
|
|
842
|
+
async def _load_findings_worker(self) -> None:
|
|
843
|
+
t = self._active_tab
|
|
844
|
+
if t is None or t.scanner_api is None:
|
|
845
|
+
return
|
|
846
|
+
tab_id = t.tab_id
|
|
847
|
+
state = t
|
|
848
|
+
try:
|
|
849
|
+
findings = await state.scanner_api.get_findings()
|
|
850
|
+
self._populate_tab_from_db(tab_id, state, findings)
|
|
851
|
+
except Exception as exc:
|
|
852
|
+
logger.warning("_load_findings_worker: %s", exc)
|
|
853
|
+
|
|
854
|
+
def _populate_from_db(self, findings) -> None:
|
|
855
|
+
"""Делегируем в _populate_tab_from_db для активной вкладки."""
|
|
856
|
+
t = self._active_tab
|
|
857
|
+
if t is None:
|
|
858
|
+
return
|
|
859
|
+
self._populate_tab_from_db(t.tab_id, t, findings)
|
|
860
|
+
|
|
861
|
+
def _add_row_to_table(self, table: DataTable, finding, source: str) -> None:
|
|
862
|
+
self._rows.append(finding)
|
|
863
|
+
self._row_sources.append(source)
|
|
864
|
+
# Добавляем одну строку к существующему ArrowBackend (быстро)
|
|
865
|
+
try:
|
|
866
|
+
new_row = self._finding_to_row(finding, len(self._rows), source)
|
|
867
|
+
new_arrow = pa.Table.from_pylist([new_row], schema=self._FINDINGS_SCHEMA)
|
|
868
|
+
if hasattr(table, "backend") and table.backend is not None:
|
|
869
|
+
existing = table.backend.source_data
|
|
870
|
+
combined = pa.concat_tables([existing, new_arrow])
|
|
871
|
+
else:
|
|
872
|
+
combined = new_arrow
|
|
873
|
+
table.backend = ArrowBackend(combined)
|
|
874
|
+
table._ordered_columns = None
|
|
875
|
+
table._clear_caches()
|
|
876
|
+
table._require_update_dimensions = True
|
|
877
|
+
table.refresh()
|
|
878
|
+
except Exception as exc:
|
|
879
|
+
logger.debug("_add_row_to_table: %s", exc)
|
|
880
|
+
|
|
881
|
+
def _finding_to_row(self, finding, n: int, source: str) -> dict:
|
|
882
|
+
sev = getattr(finding, "severity", "info").upper()
|
|
883
|
+
ftype = getattr(finding, "type", getattr(finding, "name", "?"))
|
|
884
|
+
url = getattr(finding, "url", "?")
|
|
885
|
+
param = getattr(finding, "parameter", None) or "—"
|
|
886
|
+
evidence = getattr(finding, "evidence", "") or "—"
|
|
887
|
+
fp_prefix = "[FP] " if getattr(finding, "false_positive", False) else ""
|
|
888
|
+
|
|
889
|
+
sev_color = {
|
|
890
|
+
"CRITICAL": "bold red", "HIGH": "red",
|
|
891
|
+
"MEDIUM": "yellow", "LOW": "green", "INFO": "blue",
|
|
892
|
+
}.get(sev, "white")
|
|
893
|
+
return {
|
|
894
|
+
"_#": str(n),
|
|
895
|
+
"Source": source,
|
|
896
|
+
"Type": fp_prefix + ftype,
|
|
897
|
+
"Severity": f"[{sev_color}]{sev}[/{sev_color}]",
|
|
898
|
+
"URL": url[:55],
|
|
899
|
+
"Param": param[:20],
|
|
900
|
+
"Evidence": evidence[:50],
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
def _rebuild_table_backend(self, tab_id: str | None = None,
|
|
904
|
+
state: _ScanTabState | None = None) -> None:
|
|
905
|
+
"""Перестроить ArrowBackend из rows/row_sources вкладки."""
|
|
906
|
+
tid = tab_id or self._active_tab_id or ""
|
|
907
|
+
st = state or self._active_tab
|
|
908
|
+
if st is None:
|
|
909
|
+
return
|
|
910
|
+
try:
|
|
911
|
+
table = self.query_one(f"#findings-table-{tid}", DataTable)
|
|
912
|
+
rows = [
|
|
913
|
+
self._finding_to_row(f, i + 1, src)
|
|
914
|
+
for i, (f, src) in enumerate(zip(st.rows, st.row_sources))
|
|
915
|
+
]
|
|
916
|
+
arrow = pa.Table.from_pylist(rows, schema=self._FINDINGS_SCHEMA)
|
|
917
|
+
table.backend = ArrowBackend(arrow)
|
|
918
|
+
table._ordered_columns = None
|
|
919
|
+
table._clear_caches()
|
|
920
|
+
table._require_update_dimensions = True
|
|
921
|
+
table.refresh()
|
|
922
|
+
except Exception as exc:
|
|
923
|
+
logger.debug("_rebuild_table_backend: %s", exc)
|
|
924
|
+
|
|
925
|
+
def _update_label(self, tab_id: str | None = None, state: _ScanTabState | None = None) -> None:
|
|
926
|
+
tid = tab_id or self._active_tab_id or ""
|
|
927
|
+
st = state or self._active_tab
|
|
928
|
+
if st is None:
|
|
929
|
+
return
|
|
930
|
+
try:
|
|
931
|
+
n = len(st.rows)
|
|
932
|
+
seed_info = f" │ [dim]{len(st.seed_requests)} seed req[/dim]" if st.seed_requests else ""
|
|
933
|
+
self.query_one(f"#findings-label-{tid}", Static).update(
|
|
934
|
+
f"Findings: [bold]{n}[/bold] (passive + active){seed_info}"
|
|
935
|
+
)
|
|
936
|
+
except Exception:
|
|
937
|
+
pass
|
|
938
|
+
|
|
939
|
+
# ── public API ─────────────────────────────────────────────────────────────
|
|
940
|
+
|
|
941
|
+
def add_finding(self, finding) -> None:
|
|
942
|
+
self.call_after_refresh(self._add_finding_ui, finding)
|
|
943
|
+
|
|
944
|
+
def _add_finding_ui(self, finding) -> None:
|
|
945
|
+
try:
|
|
946
|
+
tid = self._active_tab_id or ""
|
|
947
|
+
table = self.query_one(f"#findings-table-{tid}", DataTable)
|
|
948
|
+
self._add_row_to_table(table, finding, source="passive")
|
|
949
|
+
self._update_label()
|
|
950
|
+
sev = getattr(finding, "severity", "info")
|
|
951
|
+
ftype = getattr(finding, "type", "?")
|
|
952
|
+
url = getattr(finding, "url", "?")
|
|
953
|
+
self._log(f"[yellow]PASSIVE[/yellow] [{sev}] {ftype} → {url}")
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
|
|
957
|
+
def set_target(self, url: str) -> None:
|
|
958
|
+
tid = self._active_tab_id or ""
|
|
959
|
+
try:
|
|
960
|
+
self.query_one(f"#target-input-{tid}", Input).value = url
|
|
961
|
+
except Exception:
|
|
962
|
+
pass
|
|
963
|
+
|
|
964
|
+
# ── row selection → detail panel ──────────────────────────────────────────
|
|
965
|
+
|
|
966
|
+
def on_data_table_row_highlighted(self, event) -> None:
|
|
967
|
+
"""При перемещении курсора в таблице — показать request/response.
|
|
968
|
+
Срабатывает только при cursor_type='row'."""
|
|
969
|
+
idx = event.cursor_row
|
|
970
|
+
if idx < 0 or idx >= len(self._rows):
|
|
971
|
+
return
|
|
972
|
+
self._show_detail(self._rows[idx])
|
|
973
|
+
|
|
974
|
+
def on_data_table_row_selected(self, event) -> None:
|
|
975
|
+
"""Enter на строке — тоже показывает detail."""
|
|
976
|
+
idx = event.cursor_row
|
|
977
|
+
if idx < 0 or idx >= len(self._rows):
|
|
978
|
+
return
|
|
979
|
+
self._show_detail(self._rows[idx])
|
|
980
|
+
|
|
981
|
+
def _show_detail(self, finding) -> None:
|
|
982
|
+
self._current_finding = finding
|
|
983
|
+
tid = self._active_tab_id or ""
|
|
984
|
+
|
|
985
|
+
req_raw = getattr(finding, "request_raw", "") or ""
|
|
986
|
+
resp_raw = getattr(finding, "response_raw", "") or ""
|
|
987
|
+
|
|
988
|
+
# Если request_raw пустой — строим минимальный запрос из url
|
|
989
|
+
if not req_raw.strip():
|
|
990
|
+
req_raw = self._build_http_raw(finding)
|
|
991
|
+
|
|
992
|
+
try:
|
|
993
|
+
panel = self.query_one(f"#detail-panel-{tid}")
|
|
994
|
+
panel.styles.height = 18
|
|
995
|
+
panel.display = True
|
|
996
|
+
self._detail_panel_open = True
|
|
997
|
+
except Exception as exc:
|
|
998
|
+
logger.debug("_show_detail panel: %s", exc)
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
proof_area = self.query_one(f"#detail-proof-{tid}", TextArea)
|
|
1002
|
+
proof_area.load_text(self._render_proof(finding))
|
|
1003
|
+
except Exception:
|
|
1004
|
+
pass
|
|
1005
|
+
|
|
1006
|
+
# Собираем highlight-термины: payload и/или xsspwn-маркер
|
|
1007
|
+
payload = getattr(finding, "payload", "") or ""
|
|
1008
|
+
highlight_terms: list[str] = []
|
|
1009
|
+
if payload:
|
|
1010
|
+
import re as _re
|
|
1011
|
+
m = _re.search(r"xsspwn[0-9a-f]{8}", payload)
|
|
1012
|
+
if m:
|
|
1013
|
+
highlight_terms.append(m.group(0))
|
|
1014
|
+
# Также добавляем сам payload (укороченный до 80 символов)
|
|
1015
|
+
core = payload.split("<!--")[0].strip()
|
|
1016
|
+
if len(core) > 4 and core not in highlight_terms:
|
|
1017
|
+
highlight_terms.append(core[:80])
|
|
1018
|
+
|
|
1019
|
+
# Грузим req/resp — с задержкой, чтобы layout успел пересчитаться
|
|
1020
|
+
self.call_after_refresh(self._load_detail_content, req_raw, resp_raw, highlight_terms)
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
self.query_one("#btn-send-repeater", ToolbarButton).disabled = False
|
|
1024
|
+
except Exception:
|
|
1025
|
+
pass
|
|
1026
|
+
|
|
1027
|
+
def _load_detail_content(self, req_raw: str, resp_raw: str,
|
|
1028
|
+
highlight_terms: list[str] | None = None) -> None:
|
|
1029
|
+
tid = self._active_tab_id or ""
|
|
1030
|
+
try:
|
|
1031
|
+
req_view = self.query_one(f"#detail-request-{tid}", HttpView)
|
|
1032
|
+
req_view.load_raw_http(req_raw)
|
|
1033
|
+
except Exception:
|
|
1034
|
+
pass
|
|
1035
|
+
try:
|
|
1036
|
+
resp_view = self.query_one(f"#detail-response-{tid}", HttpView)
|
|
1037
|
+
resp_view.load_raw_http(resp_raw, highlight_terms=highlight_terms)
|
|
1038
|
+
except Exception:
|
|
1039
|
+
pass
|
|
1040
|
+
|
|
1041
|
+
@staticmethod
|
|
1042
|
+
def _render_proof(finding) -> str:
|
|
1043
|
+
"""Сформировать plain-текст доказательства для TextArea."""
|
|
1044
|
+
sev = getattr(finding, "severity", "info").upper()
|
|
1045
|
+
name = getattr(finding, "name", "") or getattr(finding, "type", "?")
|
|
1046
|
+
|
|
1047
|
+
name = name
|
|
1048
|
+
ftype = getattr(finding, "type", "?")
|
|
1049
|
+
parameter = getattr(finding, "parameter", "") or "—"
|
|
1050
|
+
payload = getattr(finding, "payload", "") or "—"
|
|
1051
|
+
evidence = getattr(finding, "evidence", "") or "—"
|
|
1052
|
+
desc = getattr(finding, "description", "") or "—"
|
|
1053
|
+
cwe = getattr(finding, "cwe", "") or "—"
|
|
1054
|
+
mitre = getattr(finding, "mitre_attack", "") or "—"
|
|
1055
|
+
remediation = getattr(finding, "remediation", "") or "—"
|
|
1056
|
+
url = getattr(finding, "url", "") or "—"
|
|
1057
|
+
|
|
1058
|
+
lines: list[str] = [
|
|
1059
|
+
f"▶ {name}",
|
|
1060
|
+
"",
|
|
1061
|
+
f"Type: {ftype}",
|
|
1062
|
+
f"Severity: {sev}",
|
|
1063
|
+
f"URL: {url}",
|
|
1064
|
+
f"Parameter: {parameter}",
|
|
1065
|
+
"",
|
|
1066
|
+
"── Payload ──────────────────────",
|
|
1067
|
+
payload,
|
|
1068
|
+
"",
|
|
1069
|
+
"── Evidence (matched) ───────────",
|
|
1070
|
+
evidence,
|
|
1071
|
+
"",
|
|
1072
|
+
]
|
|
1073
|
+
if desc and desc != "—":
|
|
1074
|
+
lines += ["── Description ──────────────────", desc, ""]
|
|
1075
|
+
if cwe != "—" or mitre != "—":
|
|
1076
|
+
lines.append("── References ───────────────────")
|
|
1077
|
+
if cwe != "—":
|
|
1078
|
+
lines.append(f"CWE: {cwe}")
|
|
1079
|
+
if mitre != "—":
|
|
1080
|
+
lines.append(f"MITRE: {mitre}")
|
|
1081
|
+
lines.append("")
|
|
1082
|
+
if remediation != "—":
|
|
1083
|
+
lines += ["── Remediation ──────────────────", remediation]
|
|
1084
|
+
return "\n".join(lines)
|
|
1085
|
+
|
|
1086
|
+
@staticmethod
|
|
1087
|
+
def _build_http_raw(finding) -> str:
|
|
1088
|
+
"""Построить полноценный HTTP/1.1 запрос из данных finding.
|
|
1089
|
+
|
|
1090
|
+
request_raw уже содержит полный HTTP-запрос с пейлоадом (заполняется
|
|
1091
|
+
хелперами format_request_raw в checks). Если его нет — строим минимальный
|
|
1092
|
+
запрос из url.
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
raw = getattr(finding, "request_raw", "") or ""
|
|
1096
|
+
if raw.strip():
|
|
1097
|
+
return raw
|
|
1098
|
+
|
|
1099
|
+
# Fallback: строим минимальный GET-запрос из url
|
|
1100
|
+
url = getattr(finding, "url", "") or ""
|
|
1101
|
+
if not url:
|
|
1102
|
+
return "GET / HTTP/1.1\r\nHost: unknown\r\nConnection: close\r\n\r\n"
|
|
1103
|
+
|
|
1104
|
+
try:
|
|
1105
|
+
parsed = urlparse(url)
|
|
1106
|
+
host = parsed.netloc or parsed.hostname or "unknown"
|
|
1107
|
+
path = parsed.path or "/"
|
|
1108
|
+
if parsed.query:
|
|
1109
|
+
path = path + "?" + parsed.query
|
|
1110
|
+
except Exception:
|
|
1111
|
+
host = "unknown"
|
|
1112
|
+
path = "/"
|
|
1113
|
+
|
|
1114
|
+
return (
|
|
1115
|
+
f"GET {path} HTTP/1.1\r\n"
|
|
1116
|
+
f"Host: {host}\r\n"
|
|
1117
|
+
"User-Agent: pentool/1.0\r\n"
|
|
1118
|
+
"Accept: */*\r\n"
|
|
1119
|
+
"Connection: close\r\n"
|
|
1120
|
+
"\r\n"
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# ── context menu (detail panel) ───────────────────────────────────────────
|
|
1124
|
+
|
|
1125
|
+
def on__base_http_widget_context_menu_request(self, event) -> None:
|
|
1126
|
+
"""Ctrl+клик / правая кнопка на любом _BaseHttpWidget → контекстное меню."""
|
|
1127
|
+
self.cm_open_text_menu(event.screen_x, event.screen_y)
|
|
1128
|
+
|
|
1129
|
+
def _cm_get_raw_request(self) -> str:
|
|
1130
|
+
"""Raw HTTP из текущего finding."""
|
|
1131
|
+
return self._build_http_raw(self._current_finding) if self._current_finding else ""
|
|
1132
|
+
|
|
1133
|
+
# ── toolbar ────────────────────────────────────────────────────────────────
|
|
1134
|
+
|
|
1135
|
+
@on(ToolbarButton.Pressed, "#btn-start")
|
|
1136
|
+
def on_btn_start(self, _: ToolbarButton.Pressed) -> None:
|
|
1137
|
+
if self._paused:
|
|
1138
|
+
self.action_resume_scan()
|
|
1139
|
+
else:
|
|
1140
|
+
self.action_start_scan()
|
|
1141
|
+
|
|
1142
|
+
@on(ToolbarButton.Pressed, "#btn-stop")
|
|
1143
|
+
def on_btn_stop(self, _: ToolbarButton.Pressed) -> None:
|
|
1144
|
+
self.action_stop_scan()
|
|
1145
|
+
|
|
1146
|
+
@on(ToolbarButton.Pressed, "#btn-send-repeater")
|
|
1147
|
+
def on_btn_send_repeater(self, _: ToolbarButton.Pressed) -> None:
|
|
1148
|
+
self.action_send_to_repeater()
|
|
1149
|
+
|
|
1150
|
+
@on(ToolbarButton.Pressed, "#btn-report")
|
|
1151
|
+
def on_btn_report(self, _: ToolbarButton.Pressed) -> None:
|
|
1152
|
+
self.action_generate_report()
|
|
1153
|
+
|
|
1154
|
+
@on(ToolbarButton.Pressed, "#btn-pro-report")
|
|
1155
|
+
def on_btn_pro_report(self, _: ToolbarButton.Pressed) -> None:
|
|
1156
|
+
self.action_generate_pro_report()
|
|
1157
|
+
|
|
1158
|
+
@on(ToolbarButton.Pressed, "#btn-clear")
|
|
1159
|
+
def on_btn_clear(self, _: ToolbarButton.Pressed) -> None:
|
|
1160
|
+
self.action_clear()
|
|
1161
|
+
|
|
1162
|
+
@on(ToolbarButton.Pressed, "#btn-passive")
|
|
1163
|
+
def on_btn_passive(self, _: ToolbarButton.Pressed) -> None:
|
|
1164
|
+
self.action_toggle_passive()
|
|
1165
|
+
|
|
1166
|
+
@on(ToolbarButton.Pressed, "#btn-fp")
|
|
1167
|
+
def on_btn_fp(self, _: ToolbarButton.Pressed) -> None:
|
|
1168
|
+
self.action_mark_false_positive()
|
|
1169
|
+
|
|
1170
|
+
@on(ToolbarButton.Pressed, "#btn-new-tab")
|
|
1171
|
+
def on_btn_new_tab(self, _: ToolbarButton.Pressed) -> None:
|
|
1172
|
+
self.action_new_tab()
|
|
1173
|
+
|
|
1174
|
+
@on(ToolbarButton.Pressed, "#btn-from-history")
|
|
1175
|
+
def on_btn_from_history(self, _: ToolbarButton.Pressed) -> None:
|
|
1176
|
+
self.action_scan_from_history()
|
|
1177
|
+
|
|
1178
|
+
@on(ToolbarButton.Pressed, "#btn-close-tab")
|
|
1179
|
+
def on_btn_close_tab(self, _: ToolbarButton.Pressed) -> None:
|
|
1180
|
+
self.action_close_tab()
|
|
1181
|
+
|
|
1182
|
+
# ── send to repeater ───────────────────────────────────────────────────────
|
|
1183
|
+
|
|
1184
|
+
def action_send_to_repeater(self) -> None:
|
|
1185
|
+
finding = self._current_finding
|
|
1186
|
+
if finding is None:
|
|
1187
|
+
self.app.notify("Select a finding first", severity="warning")
|
|
1188
|
+
return
|
|
1189
|
+
|
|
1190
|
+
raw = self._build_http_raw(finding)
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
from pentool.tui.messages import SendToRepeater
|
|
1194
|
+
self.app.post_message(SendToRepeater(raw))
|
|
1195
|
+
except Exception as exc:
|
|
1196
|
+
self.app.notify(f"Send to Repeater failed: {exc}", severity="error")
|
|
1197
|
+
|
|
1198
|
+
# ── from history ──────────────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
def action_scan_from_history(self) -> None:
|
|
1201
|
+
self._load_history_worker()
|
|
1202
|
+
|
|
1203
|
+
@work
|
|
1204
|
+
async def _load_history_worker(self) -> None:
|
|
1205
|
+
try:
|
|
1206
|
+
db_path = self.app.db_path
|
|
1207
|
+
except Exception:
|
|
1208
|
+
db_path = ""
|
|
1209
|
+
if not db_path:
|
|
1210
|
+
self.app.notify("No project open — open a project first", severity="warning")
|
|
1211
|
+
return
|
|
1212
|
+
try:
|
|
1213
|
+
from pentool.api.scanner_api import ScannerAPI
|
|
1214
|
+
api = ScannerAPI(db_path=db_path)
|
|
1215
|
+
reqs = await api.get_history_requests(limit=300)
|
|
1216
|
+
except Exception as exc:
|
|
1217
|
+
self.app.notify(f"History load failed: {exc}", severity="error")
|
|
1218
|
+
return
|
|
1219
|
+
if not reqs:
|
|
1220
|
+
self.app.notify("History is empty", severity="warning")
|
|
1221
|
+
return
|
|
1222
|
+
self.call_from_thread(self._open_history_tab, reqs)
|
|
1223
|
+
|
|
1224
|
+
def _open_history_tab(self, reqs: list) -> None:
|
|
1225
|
+
n = len(reqs)
|
|
1226
|
+
hosts: set[str] = set()
|
|
1227
|
+
for r in reqs:
|
|
1228
|
+
try:
|
|
1229
|
+
from urllib.parse import urlparse as _up
|
|
1230
|
+
h = _up(r.url).hostname or ""
|
|
1231
|
+
if h:
|
|
1232
|
+
hosts.add(h)
|
|
1233
|
+
except Exception:
|
|
1234
|
+
pass
|
|
1235
|
+
if len(hosts) == 1:
|
|
1236
|
+
label = f"History: {next(iter(hosts))}"
|
|
1237
|
+
else:
|
|
1238
|
+
label = f"History ({n})"
|
|
1239
|
+
first_urls = list({r.url.split('?')[0] for r in reqs[:10]})
|
|
1240
|
+
first_url = first_urls[0] if first_urls else ""
|
|
1241
|
+
self.app.notify(f"Loaded {n} requests from history", severity="information")
|
|
1242
|
+
self.action_new_tab(initial_url=first_url, seed_requests=reqs)
|
|
1243
|
+
|
|
1244
|
+
# ── start scan ─────────────────────────────────────────────────────────────
|
|
1245
|
+
|
|
1246
|
+
def action_start_scan(self) -> None:
|
|
1247
|
+
if self._scanning:
|
|
1248
|
+
self.app.notify("Scan already running", severity="warning")
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
tid = self._active_tab_id or ""
|
|
1252
|
+
try:
|
|
1253
|
+
target_input = self.query_one(f"#target-input-{tid}", Input)
|
|
1254
|
+
raw = target_input.value.strip()
|
|
1255
|
+
except Exception:
|
|
1256
|
+
raw = ""
|
|
1257
|
+
|
|
1258
|
+
if not raw:
|
|
1259
|
+
self.app.notify("Enter target URL", severity="warning")
|
|
1260
|
+
return
|
|
1261
|
+
|
|
1262
|
+
targets = [u.strip() for u in raw.replace(",", "\n").splitlines() if u.strip()]
|
|
1263
|
+
if not targets:
|
|
1264
|
+
self.app.notify("No valid targets", severity="warning")
|
|
1265
|
+
return
|
|
1266
|
+
|
|
1267
|
+
# Нормализация URL
|
|
1268
|
+
normalized = []
|
|
1269
|
+
for url in targets:
|
|
1270
|
+
try:
|
|
1271
|
+
parsed = urlparse(url)
|
|
1272
|
+
if not parsed.scheme:
|
|
1273
|
+
url = "https://" + url
|
|
1274
|
+
parsed = urlparse(url)
|
|
1275
|
+
if parsed.port == 443 and parsed.scheme == "https":
|
|
1276
|
+
url = urlunparse(parsed._replace(netloc=parsed.hostname or parsed.netloc))
|
|
1277
|
+
elif parsed.port == 80 and parsed.scheme == "http":
|
|
1278
|
+
url = urlunparse(parsed._replace(netloc=parsed.hostname or parsed.netloc))
|
|
1279
|
+
normalized.append(url)
|
|
1280
|
+
except Exception:
|
|
1281
|
+
normalized.append(url)
|
|
1282
|
+
targets = normalized
|
|
1283
|
+
|
|
1284
|
+
check_map = {
|
|
1285
|
+
"chk-sqli": "sqli",
|
|
1286
|
+
"chk-xss": "xss",
|
|
1287
|
+
"chk-ssti": "ssti",
|
|
1288
|
+
"chk-lfi": "lfi",
|
|
1289
|
+
"chk-rce": "rce",
|
|
1290
|
+
"chk-redirect": "open_redirect",
|
|
1291
|
+
"chk-ssrf": "ssrf",
|
|
1292
|
+
"chk-xxe": "xxe",
|
|
1293
|
+
"chk-cors": "cors",
|
|
1294
|
+
"chk-pathtraversal":"path_traversal",
|
|
1295
|
+
"chk-headerinj": "header_injection",
|
|
1296
|
+
"chk-brokenauth": "broken_auth",
|
|
1297
|
+
"chk-jwt": "jwt_none",
|
|
1298
|
+
}
|
|
1299
|
+
selected = []
|
|
1300
|
+
for chk_id, check_name in check_map.items():
|
|
1301
|
+
try:
|
|
1302
|
+
if self.query_one(f"#{chk_id}-{tid}", Checkbox).value:
|
|
1303
|
+
selected.append(check_name)
|
|
1304
|
+
except Exception:
|
|
1305
|
+
pass
|
|
1306
|
+
|
|
1307
|
+
threads = self._get_opt_int("opt-threads", 10)
|
|
1308
|
+
delay_ms = self._get_opt_float("opt-delay", 0.0)
|
|
1309
|
+
delay_sec = delay_ms / 1000.0
|
|
1310
|
+
depth = self._get_opt_int("opt-depth", 3)
|
|
1311
|
+
pages = self._get_opt_int("opt-pages", 100)
|
|
1312
|
+
|
|
1313
|
+
try:
|
|
1314
|
+
db_path = self.app.db_path
|
|
1315
|
+
except Exception:
|
|
1316
|
+
db_path = ""
|
|
1317
|
+
|
|
1318
|
+
t = self._active_tab
|
|
1319
|
+
if t is None:
|
|
1320
|
+
return
|
|
1321
|
+
t.scanner_api = self._get_or_create_api_for(t, db_path)
|
|
1322
|
+
self._scanning = True
|
|
1323
|
+
self._stop_requested = False
|
|
1324
|
+
self._paused = False
|
|
1325
|
+
|
|
1326
|
+
# Сохраняем параметры для Resume
|
|
1327
|
+
self._last_targets = targets
|
|
1328
|
+
self._last_check_names = selected or None
|
|
1329
|
+
self._last_threads = threads
|
|
1330
|
+
self._last_delay_sec = delay_sec
|
|
1331
|
+
self._last_depth = depth
|
|
1332
|
+
self._last_pages = pages
|
|
1333
|
+
self._last_crawled_targets = list(targets) # стартовая точка — расширится в on_url_crawled
|
|
1334
|
+
|
|
1335
|
+
try:
|
|
1336
|
+
progress = self.query_one(f"#scan-progress-{tid}", ProgressBar)
|
|
1337
|
+
progress.update(total=1000, progress=0) # indeterminate-style: total обновится в on_progress
|
|
1338
|
+
self.query_one(f"#progress-label-{tid}", Static).update("0 req")
|
|
1339
|
+
except Exception:
|
|
1340
|
+
pass
|
|
1341
|
+
|
|
1342
|
+
self.query_one("#btn-start", ToolbarButton).disabled = True
|
|
1343
|
+
self.query_one("#btn-stop", ToolbarButton).disabled = False
|
|
1344
|
+
|
|
1345
|
+
self._log(
|
|
1346
|
+
f"[bold green]START[/bold green] {len(targets)} target(s), "
|
|
1347
|
+
f"{len(selected) or 'all'} checks │ "
|
|
1348
|
+
f"threads={threads} delay={delay_ms:.0f}ms "
|
|
1349
|
+
f"depth={depth} pages={pages}"
|
|
1350
|
+
)
|
|
1351
|
+
for target in targets:
|
|
1352
|
+
self._log(f" → {target}")
|
|
1353
|
+
|
|
1354
|
+
self._run_active_scan(targets, selected or None, threads, delay_sec, depth, pages)
|
|
1355
|
+
|
|
1356
|
+
@work
|
|
1357
|
+
async def _run_active_scan(
|
|
1358
|
+
self,
|
|
1359
|
+
targets: list[str],
|
|
1360
|
+
check_names: list[str] | None = None,
|
|
1361
|
+
threads: int = 5,
|
|
1362
|
+
delay_sec: float = 0.0,
|
|
1363
|
+
max_depth: int = 3,
|
|
1364
|
+
max_pages: int = 100,
|
|
1365
|
+
resume: bool = False,
|
|
1366
|
+
) -> None:
|
|
1367
|
+
from pentool.api.spider_api import SpiderAPI
|
|
1368
|
+
from pentool.core.event_bus import get_event_bus
|
|
1369
|
+
from pentool.services.scan_service import ScanConfig, ScanService
|
|
1370
|
+
|
|
1371
|
+
# Фиксируем tab_id в момент старта — не читаем _active_tab_id из коллбэков
|
|
1372
|
+
# (пользователь может переключить вкладку во время скана)
|
|
1373
|
+
scan_tab_id = self._active_tab_id or ""
|
|
1374
|
+
|
|
1375
|
+
# Сбрасываем live-счётчики
|
|
1376
|
+
self._reset_live_status(scan_tab_id)
|
|
1377
|
+
|
|
1378
|
+
# Коллбэк лога — доставляет сообщения в TUI
|
|
1379
|
+
def on_log(msg: str) -> None:
|
|
1380
|
+
self._tab_log(scan_tab_id, msg)
|
|
1381
|
+
|
|
1382
|
+
# Коллбэк на каждый найденный URL → отправляем в Target + запоминаем для Resume
|
|
1383
|
+
def on_url_crawled(event) -> None:
|
|
1384
|
+
self._send_url_to_target(event.url)
|
|
1385
|
+
t = next((x for x in self._tabs if x.tab_id == scan_tab_id), None)
|
|
1386
|
+
if t and event.url not in t.last_crawled_targets:
|
|
1387
|
+
t.last_crawled_targets.append(event.url)
|
|
1388
|
+
|
|
1389
|
+
# Коллбэк finding → добавляем в таблицу TUI конкретной вкладки скана
|
|
1390
|
+
def on_finding_discovered(event) -> None:
|
|
1391
|
+
if not self._stop_requested:
|
|
1392
|
+
self._on_active_finding_for_tab(event.finding, scan_tab_id)
|
|
1393
|
+
|
|
1394
|
+
# Коллбэк прогресса
|
|
1395
|
+
def on_scan_progress(event) -> None:
|
|
1396
|
+
self._on_progress(event.done, event.total)
|
|
1397
|
+
|
|
1398
|
+
# Коллбэк каждого HTTP-запроса → обновляем live-статус + Dashboard
|
|
1399
|
+
def on_request_sent(req_sent: int, threads_active: int,
|
|
1400
|
+
check_name: str, param_name: str, url: str) -> None:
|
|
1401
|
+
self._on_request_sent(scan_tab_id, req_sent, threads_active,
|
|
1402
|
+
check_name, param_name, url)
|
|
1403
|
+
# Также пушим в Dashboard live-feed
|
|
1404
|
+
self._push_request_to_dashboard(url, threads_active)
|
|
1405
|
+
|
|
1406
|
+
bus = get_event_bus()
|
|
1407
|
+
from pentool.core.events import FindingDiscovered, ScanProgressEvent, UrlCrawled
|
|
1408
|
+
bus.subscribe(UrlCrawled, on_url_crawled)
|
|
1409
|
+
bus.subscribe(FindingDiscovered, on_finding_discovered)
|
|
1410
|
+
bus.subscribe(ScanProgressEvent, on_scan_progress)
|
|
1411
|
+
|
|
1412
|
+
try:
|
|
1413
|
+
self._log("[cyan]CRAWL[/cyan] Discovering endpoints…")
|
|
1414
|
+
self._update_dashboard_scan(True, 0, threads)
|
|
1415
|
+
|
|
1416
|
+
spider_api = SpiderAPI.from_params(
|
|
1417
|
+
max_depth=max_depth,
|
|
1418
|
+
max_pages=max_pages,
|
|
1419
|
+
concurrency=min(threads, 10),
|
|
1420
|
+
)
|
|
1421
|
+
service = ScanService(
|
|
1422
|
+
scanner_api=self._scanner_api,
|
|
1423
|
+
spider_api=spider_api,
|
|
1424
|
+
event_bus=bus,
|
|
1425
|
+
tui_loop=None,
|
|
1426
|
+
on_log=on_log,
|
|
1427
|
+
)
|
|
1428
|
+
# Сохраняем ссылку на service — чтобы action_stop_scan мог вызвать request_stop()
|
|
1429
|
+
self._active_service = service
|
|
1430
|
+
|
|
1431
|
+
try:
|
|
1432
|
+
db_path = self.app.db_path
|
|
1433
|
+
except Exception:
|
|
1434
|
+
db_path = ""
|
|
1435
|
+
|
|
1436
|
+
tab_state = next((x for x in self._tabs if x.tab_id == scan_tab_id), None)
|
|
1437
|
+
seed_reqs = list(tab_state.seed_requests) if tab_state else []
|
|
1438
|
+
resume_targets = list(tab_state.last_crawled_targets) if (tab_state and resume) else []
|
|
1439
|
+
config = ScanConfig(
|
|
1440
|
+
targets=targets,
|
|
1441
|
+
seed_requests=seed_reqs,
|
|
1442
|
+
check_names=check_names,
|
|
1443
|
+
threads=threads,
|
|
1444
|
+
delay_sec=delay_sec,
|
|
1445
|
+
max_depth=max_depth,
|
|
1446
|
+
max_pages=max_pages,
|
|
1447
|
+
db_path=db_path,
|
|
1448
|
+
resume=resume,
|
|
1449
|
+
resume_targets=resume_targets,
|
|
1450
|
+
on_request_sent=on_request_sent,
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
await service.run(config)
|
|
1454
|
+
|
|
1455
|
+
except Exception as exc:
|
|
1456
|
+
logger.error("_run_active_scan error: %s", exc)
|
|
1457
|
+
self._log(f"[red]ERROR:[/red] {exc}")
|
|
1458
|
+
finally:
|
|
1459
|
+
# Отписываемся от временных обработчиков
|
|
1460
|
+
bus.unsubscribe(UrlCrawled, on_url_crawled)
|
|
1461
|
+
bus.unsubscribe(FindingDiscovered, on_finding_discovered)
|
|
1462
|
+
bus.unsubscribe(ScanProgressEvent, on_scan_progress)
|
|
1463
|
+
self._active_service = None # очищаем ссылку на завершённый service
|
|
1464
|
+
self._on_scan_done()
|
|
1465
|
+
|
|
1466
|
+
def _on_active_finding_for_tab(self, finding, tab_id: str) -> None:
|
|
1467
|
+
try:
|
|
1468
|
+
state = next((t for t in self._tabs if t.tab_id == tab_id), None)
|
|
1469
|
+
if state is None:
|
|
1470
|
+
return
|
|
1471
|
+
# Дедупликация по finding.id — защита от повторной подписки / race condition
|
|
1472
|
+
finding_id = getattr(finding, "id", None)
|
|
1473
|
+
if finding_id is not None:
|
|
1474
|
+
if any(getattr(r, "id", None) == finding_id for r in state.rows):
|
|
1475
|
+
return
|
|
1476
|
+
table = self.query_one(f"#findings-table-{tab_id}", DataTable)
|
|
1477
|
+
state.rows.append(finding)
|
|
1478
|
+
state.row_sources.append("active")
|
|
1479
|
+
new_row = self._finding_to_row(finding, len(state.rows), "active")
|
|
1480
|
+
new_arrow = pa.Table.from_pylist([new_row], schema=self._FINDINGS_SCHEMA)
|
|
1481
|
+
try:
|
|
1482
|
+
backend = getattr(table, "backend", None)
|
|
1483
|
+
if backend is not None and hasattr(backend, "source_data"):
|
|
1484
|
+
existing = backend.source_data
|
|
1485
|
+
combined = pa.concat_tables([existing, new_arrow])
|
|
1486
|
+
else:
|
|
1487
|
+
combined = new_arrow
|
|
1488
|
+
except Exception:
|
|
1489
|
+
combined = new_arrow
|
|
1490
|
+
table.backend = ArrowBackend(combined)
|
|
1491
|
+
try:
|
|
1492
|
+
table._ordered_columns = None
|
|
1493
|
+
except Exception:
|
|
1494
|
+
pass
|
|
1495
|
+
try:
|
|
1496
|
+
table._clear_caches()
|
|
1497
|
+
except Exception:
|
|
1498
|
+
pass
|
|
1499
|
+
try:
|
|
1500
|
+
table._require_update_dimensions = True
|
|
1501
|
+
except Exception:
|
|
1502
|
+
pass
|
|
1503
|
+
table.refresh()
|
|
1504
|
+
self._update_label(tab_id, state)
|
|
1505
|
+
sev = getattr(finding, "severity", "info")
|
|
1506
|
+
ftype = getattr(finding, "type", "?")
|
|
1507
|
+
url = getattr(finding, "url", "?")
|
|
1508
|
+
param = getattr(finding, "parameter", None) or ""
|
|
1509
|
+
param_str = f" param=[cyan]{param}[/cyan]" if param else ""
|
|
1510
|
+
self._tab_log(tab_id, f"[bold red]FOUND[/bold red] [{sev}] {ftype} → {url}{param_str}")
|
|
1511
|
+
except Exception as exc:
|
|
1512
|
+
logger.debug("_on_active_finding_for_tab: %s", exc)
|
|
1513
|
+
|
|
1514
|
+
def _send_url_to_target(self, url: str) -> None:
|
|
1515
|
+
try:
|
|
1516
|
+
from pentool.tui.messages import SendUrlToTarget
|
|
1517
|
+
from pentool.utils.parser import ParsedRequest
|
|
1518
|
+
req = ParsedRequest(method="GET", url=url, headers={}, body="")
|
|
1519
|
+
self.app.post_message(SendUrlToTarget(req))
|
|
1520
|
+
except Exception:
|
|
1521
|
+
pass
|
|
1522
|
+
|
|
1523
|
+
def _update_dashboard_scan(self, scanning: bool, pct: int = 0,
|
|
1524
|
+
threads: int = 0) -> None:
|
|
1525
|
+
try:
|
|
1526
|
+
from pentool.core.event_bus import get_event_bus
|
|
1527
|
+
from pentool.core.events import ScanStarted, ScanFinished
|
|
1528
|
+
bus = get_event_bus()
|
|
1529
|
+
if scanning:
|
|
1530
|
+
bus.emit(ScanStarted(source="scanner"))
|
|
1531
|
+
else:
|
|
1532
|
+
bus.emit(ScanFinished(total_findings=len(self._rows), source="scanner"))
|
|
1533
|
+
except Exception:
|
|
1534
|
+
pass
|
|
1535
|
+
# Также обновляем Dashboard напрямую если он смонтирован
|
|
1536
|
+
try:
|
|
1537
|
+
from pentool.tui.screens.dashboard.screen import DashboardScreen
|
|
1538
|
+
dash = self.app.query_one(DashboardScreen)
|
|
1539
|
+
dash.update_scan_status(scanning=scanning, progress=pct, threads=threads)
|
|
1540
|
+
except Exception:
|
|
1541
|
+
pass
|
|
1542
|
+
|
|
1543
|
+
def _push_request_to_dashboard(self, url: str, threads_active: int = 0) -> None:
|
|
1544
|
+
"""Пушит HTTP-запрос в Dashboard live-feed (throttle: каждый 10-й запрос)."""
|
|
1545
|
+
t = self._active_tab
|
|
1546
|
+
if t is None:
|
|
1547
|
+
return
|
|
1548
|
+
# Throttle: пушим в Dashboard каждый 10-й запрос, чтобы не перегружать TUI
|
|
1549
|
+
if t.req_sent % 10 != 1:
|
|
1550
|
+
return
|
|
1551
|
+
try:
|
|
1552
|
+
from pentool.tui.screens.dashboard.screen import DashboardScreen
|
|
1553
|
+
dash = self.app.query_one(DashboardScreen)
|
|
1554
|
+
dash.push_request("SCAN", url, 0)
|
|
1555
|
+
# Обновляем threads на Dashboard
|
|
1556
|
+
if threads_active > 0:
|
|
1557
|
+
dash.update_scan_status(
|
|
1558
|
+
scanning=True, progress=0, threads=threads_active
|
|
1559
|
+
)
|
|
1560
|
+
except Exception:
|
|
1561
|
+
pass
|
|
1562
|
+
|
|
1563
|
+
def _on_progress(self, done: int, total: int) -> None:
|
|
1564
|
+
tid = self._active_tab_id or ""
|
|
1565
|
+
try:
|
|
1566
|
+
progress = self.query_one(f"#scan-progress-{tid}", ProgressBar)
|
|
1567
|
+
progress.update(total=max(total, 1), progress=done)
|
|
1568
|
+
# Показываем tasks прогресс в label только если нет live req-счётчика
|
|
1569
|
+
t = self._active_tab
|
|
1570
|
+
if t is not None and t.req_sent == 0:
|
|
1571
|
+
self.query_one(f"#progress-label-{tid}", Static).update(
|
|
1572
|
+
f"tasks {done}/{total}"
|
|
1573
|
+
)
|
|
1574
|
+
except Exception:
|
|
1575
|
+
pass
|
|
1576
|
+
|
|
1577
|
+
def _on_request_sent(self, tab_id: str, req_sent: int, threads_active: int,
|
|
1578
|
+
check_name: str, param_name: str, url: str) -> None:
|
|
1579
|
+
t = next((x for x in self._tabs if x.tab_id == tab_id), None)
|
|
1580
|
+
if t is None:
|
|
1581
|
+
return
|
|
1582
|
+
|
|
1583
|
+
# Всегда обновляем счётчики в состоянии вкладки
|
|
1584
|
+
t.req_sent = req_sent
|
|
1585
|
+
t.threads_active = threads_active
|
|
1586
|
+
|
|
1587
|
+
# req/s: скользящее окно 2s без list comprehension
|
|
1588
|
+
now = time.monotonic()
|
|
1589
|
+
if now - t.req_window_start >= 2.0:
|
|
1590
|
+
t.req_per_sec = t.req_bucket / max(now - t.req_window_start, 0.001)
|
|
1591
|
+
t.req_bucket = 0
|
|
1592
|
+
t.req_window_start = now
|
|
1593
|
+
t.req_bucket += 1
|
|
1594
|
+
|
|
1595
|
+
# Throttle UI: не чаще 150ms, только активная вкладка
|
|
1596
|
+
if tab_id != self._active_tab_id:
|
|
1597
|
+
return
|
|
1598
|
+
if now - t._last_ui_update < 0.15:
|
|
1599
|
+
return
|
|
1600
|
+
t._last_ui_update = now
|
|
1601
|
+
|
|
1602
|
+
try:
|
|
1603
|
+
# Прогресс-лейбл — показывает реальные запросы
|
|
1604
|
+
self.query_one(f"#progress-label-{tab_id}", Static).update(
|
|
1605
|
+
f"[bold]{req_sent:,}[/bold] req"
|
|
1606
|
+
)
|
|
1607
|
+
# Live-статус строка
|
|
1608
|
+
short_url = url[-40:] if len(url) > 40 else url
|
|
1609
|
+
check_display = check_name.replace("_", " ").upper()
|
|
1610
|
+
param_display = param_name if param_name and param_name != "—" else ""
|
|
1611
|
+
if param_display:
|
|
1612
|
+
kind_part = param_display.split(":")[0] if ":" in param_display else "param"
|
|
1613
|
+
name_part = param_display.split(":", 1)[1] if ":" in param_display else param_display
|
|
1614
|
+
status_check = (
|
|
1615
|
+
f"[cyan]{check_display}[/cyan] "
|
|
1616
|
+
f"[dim]{kind_part}:[/dim][bold]{name_part}[/bold]"
|
|
1617
|
+
)
|
|
1618
|
+
else:
|
|
1619
|
+
status_check = f"[cyan]{check_display}[/cyan]"
|
|
1620
|
+
self.query_one(f"#scan-live-status-{tab_id}", Static).update(
|
|
1621
|
+
f"[dim]Requests:[/dim] [bold green]{req_sent:,}[/bold green]"
|
|
1622
|
+
f" [dim]│[/dim] [dim]Speed:[/dim] [bold]{t.req_per_sec:.1f}[/bold][dim] req/s[/dim]"
|
|
1623
|
+
f" [dim]│[/dim] [dim]Threads:[/dim] [bold cyan]{threads_active}[/bold cyan]"
|
|
1624
|
+
f" [dim]│[/dim] {status_check}"
|
|
1625
|
+
f" [dim]{short_url}[/dim]"
|
|
1626
|
+
)
|
|
1627
|
+
except Exception:
|
|
1628
|
+
pass
|
|
1629
|
+
|
|
1630
|
+
def _reset_live_status(self, tab_id: str) -> None:
|
|
1631
|
+
"""Сбросить live-статус при старте/завершении скана."""
|
|
1632
|
+
t = next((x for x in self._tabs if x.tab_id == tab_id), None)
|
|
1633
|
+
if t is not None:
|
|
1634
|
+
t.req_sent = 0
|
|
1635
|
+
t.threads_active = 0
|
|
1636
|
+
t.req_bucket = 0
|
|
1637
|
+
t.req_window_start = 0.0
|
|
1638
|
+
t.req_per_sec = 0.0
|
|
1639
|
+
t._last_ui_update = 0.0
|
|
1640
|
+
try:
|
|
1641
|
+
self.query_one(f"#scan-live-status-{tab_id}", Static).update(
|
|
1642
|
+
"[dim]Requests:[/dim] [bold]0[/bold]"
|
|
1643
|
+
" [dim]│[/dim] [dim]Speed:[/dim] [bold]0[/bold][dim] req/s[/dim]"
|
|
1644
|
+
" [dim]│[/dim] [dim]Threads:[/dim] [bold]0[/bold]"
|
|
1645
|
+
" [dim]│[/dim] [dim]Idle[/dim]"
|
|
1646
|
+
)
|
|
1647
|
+
self.query_one(f"#progress-label-{tab_id}", Static).update("—")
|
|
1648
|
+
except Exception:
|
|
1649
|
+
pass
|
|
1650
|
+
|
|
1651
|
+
def _on_scan_done(self) -> None:
|
|
1652
|
+
self._scanning = False
|
|
1653
|
+
tid = self._active_tab_id or ""
|
|
1654
|
+
t = self._active_tab
|
|
1655
|
+
total_req = t.req_sent if t else 0
|
|
1656
|
+
logger.info("SCANNER: scan done, findings=%d req_sent=%d", len(self._rows), total_req)
|
|
1657
|
+
try:
|
|
1658
|
+
btn_start = self.query_one("#btn-start", ToolbarButton)
|
|
1659
|
+
btn_stop = self.query_one("#btn-stop", ToolbarButton)
|
|
1660
|
+
if self._paused:
|
|
1661
|
+
# Остановлен пользователем — показываем Resume
|
|
1662
|
+
btn_start.label = "▶ Resume"
|
|
1663
|
+
btn_start.disabled = False
|
|
1664
|
+
btn_stop.disabled = True
|
|
1665
|
+
self._log(
|
|
1666
|
+
f"[yellow]PAUSED[/yellow] Scan paused after [bold]{total_req:,}[/bold] requests. "
|
|
1667
|
+
f"Press [bold]▶ Resume[/bold] to continue."
|
|
1668
|
+
)
|
|
1669
|
+
self.app.notify("Scan paused — press Resume to continue", severity="warning")
|
|
1670
|
+
else:
|
|
1671
|
+
# Завершён нормально
|
|
1672
|
+
btn_start.label = "▶ Start"
|
|
1673
|
+
btn_start.disabled = False
|
|
1674
|
+
btn_stop.disabled = True
|
|
1675
|
+
self._log(
|
|
1676
|
+
f"[bold green]DONE[/bold green] Scan complete. "
|
|
1677
|
+
f"[bold]{total_req:,}[/bold] requests sent. "
|
|
1678
|
+
f"Findings: [bold]{len(self._rows)}[/bold]"
|
|
1679
|
+
)
|
|
1680
|
+
self.app.notify(
|
|
1681
|
+
f"Scan complete — {len(self._rows)} findings ({total_req:,} req)",
|
|
1682
|
+
severity="information",
|
|
1683
|
+
)
|
|
1684
|
+
except Exception:
|
|
1685
|
+
pass
|
|
1686
|
+
self._stop_requested = False
|
|
1687
|
+
self._update_dashboard_scan(False, 100)
|
|
1688
|
+
# Финальный live-статус — показываем итог, не сбрасываем в 0
|
|
1689
|
+
try:
|
|
1690
|
+
self.query_one(f"#scan-live-status-{tid}", Static).update(
|
|
1691
|
+
f"[dim]Total requests:[/dim] [bold green]{total_req:,}[/bold green]"
|
|
1692
|
+
f" [dim]│[/dim] [dim]Findings:[/dim] [bold red]{len(self._rows)}[/bold red]"
|
|
1693
|
+
f" [dim]│[/dim] [dim]Threads:[/dim] [bold]0[/bold]"
|
|
1694
|
+
f" [dim]│[/dim] [bold green]DONE[/bold green]"
|
|
1695
|
+
)
|
|
1696
|
+
except Exception:
|
|
1697
|
+
pass
|
|
1698
|
+
|
|
1699
|
+
# ── stop / resume ──────────────────────────────────────────────────────────
|
|
1700
|
+
|
|
1701
|
+
def action_stop_scan(self) -> None:
|
|
1702
|
+
if not self._scanning:
|
|
1703
|
+
# Если уже на паузе и нажали Stop — сбрасываем паузу
|
|
1704
|
+
if self._paused:
|
|
1705
|
+
self._paused = False
|
|
1706
|
+
try:
|
|
1707
|
+
btn = self.query_one("#btn-start", ToolbarButton)
|
|
1708
|
+
btn.label = "▶ Start"
|
|
1709
|
+
except Exception:
|
|
1710
|
+
pass
|
|
1711
|
+
return
|
|
1712
|
+
self._paused = True
|
|
1713
|
+
self._stop_requested = True
|
|
1714
|
+
# Останавливаем ScanService (содержит ссылки на spider + engine)
|
|
1715
|
+
try:
|
|
1716
|
+
svc = self._active_service
|
|
1717
|
+
if svc is not None:
|
|
1718
|
+
svc.request_stop()
|
|
1719
|
+
except Exception:
|
|
1720
|
+
pass
|
|
1721
|
+
# Fallback: прямая остановка engine если service не доступен
|
|
1722
|
+
try:
|
|
1723
|
+
engine = self._scanner_api._get_engine()
|
|
1724
|
+
engine.request_stop()
|
|
1725
|
+
except Exception:
|
|
1726
|
+
pass
|
|
1727
|
+
self._log("[yellow]STOP[/yellow] Stop requested — finishing current tasks…")
|
|
1728
|
+
|
|
1729
|
+
def action_resume_scan(self) -> None:
|
|
1730
|
+
"""Продолжить сканирование с теми же параметрами."""
|
|
1731
|
+
if self._scanning:
|
|
1732
|
+
return
|
|
1733
|
+
if not self._paused or self._last_targets is None:
|
|
1734
|
+
self.app.notify("No paused scan to resume", severity="warning")
|
|
1735
|
+
return
|
|
1736
|
+
|
|
1737
|
+
self._paused = False
|
|
1738
|
+
self._scanning = True
|
|
1739
|
+
self._stop_requested = False
|
|
1740
|
+
|
|
1741
|
+
try:
|
|
1742
|
+
self.query_one("#btn-start", ToolbarButton).disabled = True
|
|
1743
|
+
self.query_one("#btn-stop", ToolbarButton).disabled = False
|
|
1744
|
+
except Exception:
|
|
1745
|
+
pass
|
|
1746
|
+
|
|
1747
|
+
self._log("[bold green]RESUME[/bold green] Continuing scan…")
|
|
1748
|
+
self._run_active_scan(
|
|
1749
|
+
self._last_targets,
|
|
1750
|
+
self._last_check_names,
|
|
1751
|
+
self._last_threads,
|
|
1752
|
+
self._last_delay_sec,
|
|
1753
|
+
self._last_depth,
|
|
1754
|
+
self._last_pages,
|
|
1755
|
+
resume=True,
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
# ── passive toggle ─────────────────────────────────────────────────────────
|
|
1759
|
+
|
|
1760
|
+
def action_toggle_passive(self) -> None:
|
|
1761
|
+
btn = self.query_one("#btn-passive", ToolbarButton)
|
|
1762
|
+
if self._passive_enabled:
|
|
1763
|
+
self._passive_enabled = False
|
|
1764
|
+
btn.update("● Passive: OFF")
|
|
1765
|
+
btn.remove_class("passive-on")
|
|
1766
|
+
self._detach_passive()
|
|
1767
|
+
self._log("[dim]Passive scanner disabled.[/dim]")
|
|
1768
|
+
self.app.notify("Passive scanner OFF", timeout=2)
|
|
1769
|
+
self._notify_dashboard_passive(False)
|
|
1770
|
+
else:
|
|
1771
|
+
self._passive_enabled = True
|
|
1772
|
+
btn.update("● Passive: ON")
|
|
1773
|
+
btn.add_class("passive-on")
|
|
1774
|
+
self._attach_passive()
|
|
1775
|
+
self._log("[green]Passive scanner ENABLED[/green] — monitoring proxy traffic.")
|
|
1776
|
+
self.app.notify("Passive scanner ON", timeout=2)
|
|
1777
|
+
self._notify_dashboard_passive(True)
|
|
1778
|
+
|
|
1779
|
+
def _notify_dashboard_passive(self, enabled: bool) -> None:
|
|
1780
|
+
"""Уведомить Dashboard о смене статуса пассивного сканера через EventBus."""
|
|
1781
|
+
try:
|
|
1782
|
+
from pentool.core.event_bus import get_event_bus
|
|
1783
|
+
from pentool.core.events import PassiveScanToggled
|
|
1784
|
+
get_event_bus().emit(PassiveScanToggled(enabled=enabled, source="scanner"))
|
|
1785
|
+
except Exception:
|
|
1786
|
+
pass
|
|
1787
|
+
|
|
1788
|
+
def _attach_passive(self) -> None:
|
|
1789
|
+
try:
|
|
1790
|
+
db_path = self.app.db_path
|
|
1791
|
+
proxy_api = self.app.get_proxy_api()
|
|
1792
|
+
self._scanner_api = self._get_or_create_api(db_path)
|
|
1793
|
+
self._scanner_api.set_passive_callback(self.add_finding)
|
|
1794
|
+
self._attach_passive_worker(proxy_api)
|
|
1795
|
+
except Exception as exc:
|
|
1796
|
+
logger.warning("_attach_passive: %s", exc)
|
|
1797
|
+
|
|
1798
|
+
@work
|
|
1799
|
+
async def _attach_passive_worker(self, proxy_api) -> None:
|
|
1800
|
+
try:
|
|
1801
|
+
await self._scanner_api.attach_passive(proxy_api)
|
|
1802
|
+
except Exception as exc:
|
|
1803
|
+
logger.warning("_attach_passive_worker: %s", exc)
|
|
1804
|
+
|
|
1805
|
+
def _detach_passive(self) -> None:
|
|
1806
|
+
if self._scanner_api:
|
|
1807
|
+
self._detach_passive_worker()
|
|
1808
|
+
|
|
1809
|
+
@work
|
|
1810
|
+
async def _detach_passive_worker(self) -> None:
|
|
1811
|
+
try:
|
|
1812
|
+
await self._scanner_api.detach_passive()
|
|
1813
|
+
except Exception:
|
|
1814
|
+
pass
|
|
1815
|
+
|
|
1816
|
+
# ── report ─────────────────────────────────────────────────────────────────
|
|
1817
|
+
|
|
1818
|
+
def action_generate_report(self) -> None:
|
|
1819
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
1820
|
+
|
|
1821
|
+
def _on_path(path: str | None) -> None:
|
|
1822
|
+
if not path:
|
|
1823
|
+
return
|
|
1824
|
+
fmt = "html"
|
|
1825
|
+
if path.endswith(".json"):
|
|
1826
|
+
fmt = "json"
|
|
1827
|
+
elif path.endswith(".csv"):
|
|
1828
|
+
fmt = "csv"
|
|
1829
|
+
self._generate_report_worker(path, fmt)
|
|
1830
|
+
|
|
1831
|
+
self.app.push_screen(
|
|
1832
|
+
FileSelectorDialog(mode=FileSelectorMode.SAVE, title="Save Report"),
|
|
1833
|
+
_on_path,
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
@work
|
|
1837
|
+
async def _generate_report_worker(self, path: str, fmt: str) -> None:
|
|
1838
|
+
try:
|
|
1839
|
+
db_path = self.app.db_path
|
|
1840
|
+
self._scanner_api = self._get_or_create_api(db_path)
|
|
1841
|
+
await self._scanner_api.generate_report(path, fmt)
|
|
1842
|
+
self.app.notify(f"Report saved: {path}", severity="information")
|
|
1843
|
+
except Exception as exc:
|
|
1844
|
+
self.app.notify(f"Report failed: {exc}", severity="error")
|
|
1845
|
+
|
|
1846
|
+
# ── clear ──────────────────────────────────────────────────────────────────
|
|
1847
|
+
|
|
1848
|
+
def action_generate_pro_report(self) -> None:
|
|
1849
|
+
from pentool.core.license import get_session_license
|
|
1850
|
+
info = get_session_license()
|
|
1851
|
+
if not info.has_feature("reports_pro"):
|
|
1852
|
+
self.app.notify( # type: ignore[attr-defined]
|
|
1853
|
+
"🔒 PRO Report requires PRO license — go to Settings → License",
|
|
1854
|
+
severity="warning",
|
|
1855
|
+
timeout=4,
|
|
1856
|
+
)
|
|
1857
|
+
return
|
|
1858
|
+
|
|
1859
|
+
if not self._rows:
|
|
1860
|
+
self.app.notify("No findings to report", severity="warning", timeout=3) # type: ignore[attr-defined]
|
|
1861
|
+
return
|
|
1862
|
+
|
|
1863
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
1864
|
+
|
|
1865
|
+
def _on_path(path: str | None) -> None:
|
|
1866
|
+
if not path:
|
|
1867
|
+
return
|
|
1868
|
+
self._generate_pro_report_worker(path)
|
|
1869
|
+
|
|
1870
|
+
self.app.push_screen( # type: ignore[attr-defined]
|
|
1871
|
+
FileSelectorDialog(mode=FileSelectorMode.SAVE, title="Save PRO Report (HTML)"),
|
|
1872
|
+
_on_path,
|
|
1873
|
+
)
|
|
1874
|
+
|
|
1875
|
+
@work
|
|
1876
|
+
async def _generate_pro_report_worker(self, path: str) -> None:
|
|
1877
|
+
try:
|
|
1878
|
+
from pentool.plugins.builtin.reports_pro import generate_bulk_report
|
|
1879
|
+
generate_bulk_report(self._rows, template="Generic", output_path=path)
|
|
1880
|
+
self.app.notify( # type: ignore[attr-defined]
|
|
1881
|
+
f"✓ PRO Report saved: {path}",
|
|
1882
|
+
severity="information",
|
|
1883
|
+
timeout=5,
|
|
1884
|
+
)
|
|
1885
|
+
except Exception as exc:
|
|
1886
|
+
self.app.notify(f"PRO Report failed: {exc}", severity="error", timeout=5) # type: ignore[attr-defined]
|
|
1887
|
+
|
|
1888
|
+
# ── clear ──────────────────────────────────────────────────────────────────
|
|
1889
|
+
|
|
1890
|
+
def action_clear(self) -> None:
|
|
1891
|
+
t = self._active_tab
|
|
1892
|
+
if t is None:
|
|
1893
|
+
return
|
|
1894
|
+
t.rows.clear()
|
|
1895
|
+
t.row_sources.clear()
|
|
1896
|
+
t.current_finding = None
|
|
1897
|
+
t.paused = False
|
|
1898
|
+
t.last_targets = None
|
|
1899
|
+
t.detail_panel_open = False
|
|
1900
|
+
tid = t.tab_id
|
|
1901
|
+
try:
|
|
1902
|
+
self._reset_table()
|
|
1903
|
+
self.query_one(f"#progress-label-{tid}", Static).update("—")
|
|
1904
|
+
self.query_one(f"#scan-progress-{tid}", ProgressBar).update(progress=0)
|
|
1905
|
+
self.query_one(f"#scan-log-{tid}", RichLog).clear()
|
|
1906
|
+
try:
|
|
1907
|
+
self.query_one(f"#detail-request-{tid}", HttpView).clear()
|
|
1908
|
+
except Exception:
|
|
1909
|
+
pass
|
|
1910
|
+
try:
|
|
1911
|
+
self.query_one(f"#detail-proof-{tid}", TextArea).load_text("")
|
|
1912
|
+
except Exception:
|
|
1913
|
+
pass
|
|
1914
|
+
try:
|
|
1915
|
+
self.query_one(f"#detail-response-{tid}", HttpView).clear()
|
|
1916
|
+
except Exception:
|
|
1917
|
+
pass
|
|
1918
|
+
try:
|
|
1919
|
+
panel = self.query_one(f"#detail-panel-{tid}")
|
|
1920
|
+
panel.styles.height = 0
|
|
1921
|
+
panel.display = False
|
|
1922
|
+
except Exception:
|
|
1923
|
+
pass
|
|
1924
|
+
self.query_one("#btn-send-repeater", ToolbarButton).disabled = True
|
|
1925
|
+
btn_start = self.query_one("#btn-start", ToolbarButton)
|
|
1926
|
+
btn_start.label = "▶ Start"
|
|
1927
|
+
btn_start.disabled = False
|
|
1928
|
+
self.query_one("#btn-stop", ToolbarButton).disabled = True
|
|
1929
|
+
self._update_label()
|
|
1930
|
+
self._log("Cleared all findings and log.")
|
|
1931
|
+
except Exception:
|
|
1932
|
+
pass
|
|
1933
|
+
|
|
1934
|
+
# ── false positive ─────────────────────────────────────────────────────────
|
|
1935
|
+
|
|
1936
|
+
def action_mark_false_positive(self) -> None:
|
|
1937
|
+
tid = self._active_tab_id or ""
|
|
1938
|
+
try:
|
|
1939
|
+
table = self.query_one(f"#findings-table-{tid}", DataTable)
|
|
1940
|
+
idx = table.cursor_row
|
|
1941
|
+
if 0 <= idx < len(self._rows):
|
|
1942
|
+
finding = self._rows[idx]
|
|
1943
|
+
self._mark_fp_worker(finding.id)
|
|
1944
|
+
except Exception:
|
|
1945
|
+
pass
|
|
1946
|
+
|
|
1947
|
+
@work
|
|
1948
|
+
async def _mark_fp_worker(self, finding_id: str) -> None:
|
|
1949
|
+
if not self._scanner_api:
|
|
1950
|
+
return
|
|
1951
|
+
try:
|
|
1952
|
+
await self._scanner_api.mark_false_positive(finding_id)
|
|
1953
|
+
self._load_passive_findings()
|
|
1954
|
+
self.app.notify("Marked as false positive", severity="information")
|
|
1955
|
+
except Exception as exc:
|
|
1956
|
+
logger.warning("_mark_fp_worker: %s", exc)
|