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.
Files changed (133) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +16 -0
  3. pentool/api/__init__.py +41 -0
  4. pentool/api/base_api.py +48 -0
  5. pentool/api/comparer_api.py +19 -0
  6. pentool/api/decoder_api.py +24 -0
  7. pentool/api/intruder_api.py +171 -0
  8. pentool/api/proxy_api.py +210 -0
  9. pentool/api/repeater_api.py +57 -0
  10. pentool/api/scanner_api.py +266 -0
  11. pentool/api/sequencer_api.py +17 -0
  12. pentool/api/spider_api.py +106 -0
  13. pentool/api/target_api.py +87 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +98 -0
  16. pentool/cli/project.py +88 -0
  17. pentool/cli/proxy.py +173 -0
  18. pentool/cli/scan.py +115 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +171 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +207 -0
  24. pentool/core/events.py +160 -0
  25. pentool/core/features.py +373 -0
  26. pentool/core/license.py +274 -0
  27. pentool/core/logging.py +54 -0
  28. pentool/core/plugin_manager.py +312 -0
  29. pentool/core/project.py +31 -0
  30. pentool/core/storage_interface.py +307 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +36 -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 +428 -0
  37. pentool/modules/intruder_turbo.py +176 -0
  38. pentool/modules/match_replace.py +142 -0
  39. pentool/modules/proxy.py +797 -0
  40. pentool/modules/repeater.py +195 -0
  41. pentool/modules/sequencer.py +492 -0
  42. pentool/modules/spider.py +679 -0
  43. pentool/modules/target.py +185 -0
  44. pentool/modules/websocket_handler.py +249 -0
  45. pentool/plugins/__init__.py +1 -0
  46. pentool/plugins/builtin/__init__.py +1 -0
  47. pentool/plugins/builtin/payloads_pro.py +381 -0
  48. pentool/plugins/builtin/reports_pro.py +436 -0
  49. pentool/plugins/builtin/scanner_pro.py +291 -0
  50. pentool/plugins/example_plugin.py +39 -0
  51. pentool/services/__init__.py +1 -0
  52. pentool/services/base_service.py +60 -0
  53. pentool/services/intruder_service.py +94 -0
  54. pentool/services/proxy_service.py +178 -0
  55. pentool/services/repeater_service.py +85 -0
  56. pentool/services/scan_service.py +330 -0
  57. pentool/storage/__init__.py +0 -0
  58. pentool/storage/http_storage.py +506 -0
  59. pentool/storage/large_body_handler.py +50 -0
  60. pentool/storage/lru_cache.py +45 -0
  61. pentool/t.py +213 -0
  62. pentool/tui/__init__.py +1 -0
  63. pentool/tui/app.py +1429 -0
  64. pentool/tui/constants.py +79 -0
  65. pentool/tui/dialogs/__init__.py +0 -0
  66. pentool/tui/dialogs/cert_dialog.py +81 -0
  67. pentool/tui/dialogs/file_selector.py +226 -0
  68. pentool/tui/dialogs/load_from_proxy.py +125 -0
  69. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  70. pentool/tui/dialogs/project_dialog.py +82 -0
  71. pentool/tui/dialogs/scope_dialog.py +224 -0
  72. pentool/tui/messages.py +103 -0
  73. pentool/tui/mixins/__init__.py +0 -0
  74. pentool/tui/mixins/app_mixin.py +59 -0
  75. pentool/tui/mixins/dialog_cancel.py +21 -0
  76. pentool/tui/mixins/request_context_menu.py +247 -0
  77. pentool/tui/mixins/tab_rename.py +89 -0
  78. pentool/tui/screens/__init__.py +31 -0
  79. pentool/tui/screens/comparer/__init__.py +3 -0
  80. pentool/tui/screens/comparer/screen.py +229 -0
  81. pentool/tui/screens/dashboard/__init__.py +2 -0
  82. pentool/tui/screens/dashboard/live_dashboard.py +730 -0
  83. pentool/tui/screens/dashboard/screen.py +771 -0
  84. pentool/tui/screens/decoder/__init__.py +3 -0
  85. pentool/tui/screens/decoder/screen.py +306 -0
  86. pentool/tui/screens/extensions/__init__.py +3 -0
  87. pentool/tui/screens/extensions/screen.py +24 -0
  88. pentool/tui/screens/intruder/__init__.py +3 -0
  89. pentool/tui/screens/intruder/screen.py +1356 -0
  90. pentool/tui/screens/proxy/__init__.py +3 -0
  91. pentool/tui/screens/proxy/screen.py +1554 -0
  92. pentool/tui/screens/repeater/__init__.py +3 -0
  93. pentool/tui/screens/repeater/screen.py +621 -0
  94. pentool/tui/screens/scanner/__init__.py +3 -0
  95. pentool/tui/screens/scanner/screen.py +1956 -0
  96. pentool/tui/screens/sequencer/__init__.py +3 -0
  97. pentool/tui/screens/sequencer/screen.py +516 -0
  98. pentool/tui/screens/settings/__init__.py +5 -0
  99. pentool/tui/screens/settings/hotkeys.py +134 -0
  100. pentool/tui/screens/settings/screen.py +540 -0
  101. pentool/tui/screens/spider/__init__.py +3 -0
  102. pentool/tui/screens/spider/screen.py +380 -0
  103. pentool/tui/screens/target/__init__.py +4 -0
  104. pentool/tui/screens/target/screen.py +358 -0
  105. pentool/tui/screens/terminal/__init__.py +5 -0
  106. pentool/tui/screens/terminal/screen.py +179 -0
  107. pentool/tui/widgets/__init__.py +1 -0
  108. pentool/tui/widgets/context_menu.py +148 -0
  109. pentool/tui/widgets/filter_bar.py +204 -0
  110. pentool/tui/widgets/inspector_panel.py +111 -0
  111. pentool/tui/widgets/menu.py +86 -0
  112. pentool/tui/widgets/menu_bar.py +238 -0
  113. pentool/tui/widgets/module_tabs.py +68 -0
  114. pentool/tui/widgets/payload_drop_zone.py +65 -0
  115. pentool/tui/widgets/request_editor.py +495 -0
  116. pentool/tui/widgets/resize_handle.py +116 -0
  117. pentool/tui/widgets/search_bar.py +112 -0
  118. pentool/tui/widgets/statusbar.py +96 -0
  119. pentool/tui/widgets/toolbar_button.py +68 -0
  120. pentool/utils/__init__.py +1 -0
  121. pentool/utils/cert.py +314 -0
  122. pentool/utils/coder.py +170 -0
  123. pentool/utils/copy_as.py +250 -0
  124. pentool/utils/diff.py +82 -0
  125. pentool/utils/http_client.py +145 -0
  126. pentool/utils/parser.py +227 -0
  127. pentool/utils/terminal_check.py +31 -0
  128. pentool-0.1.0.dist-info/METADATA +231 -0
  129. pentool-0.1.0.dist-info/RECORD +133 -0
  130. pentool-0.1.0.dist-info/WHEEL +5 -0
  131. pentool-0.1.0.dist-info/entry_points.txt +2 -0
  132. pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
  133. 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)