pentool 1.0.0__py3-none-any.whl

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