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,1664 @@
1
+ """Полноценный экран прокси-сервера."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+ import asyncio
7
+ import datetime
8
+ import json as _json
9
+ import os
10
+ import time
11
+
12
+ import pyarrow as pa
13
+ from textual.app import ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical
16
+ from textual.message import Message
17
+ from textual.widget import Widget
18
+ from pathlib import Path
19
+
20
+ _CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
21
+ from textual.widgets import (
22
+ Label,
23
+ Static,
24
+ TabPane,
25
+ TabbedContent,
26
+ TextArea,
27
+ )
28
+ from textual_fastdatatable import DataTable as _BaseDataTable, ArrowBackend
29
+
30
+ from pentool.api.proxy_api import InterceptedRequest, MatchReplaceRule
31
+ from pentool.core.logging import get_logger
32
+ from pentool.tui.messages import SendToRepeater, SendToIntruder, SendToTarget, SyncScopeToTarget
33
+ from pentool.storage.http_storage import HttpStorage
34
+ from pentool.tui.widgets.context_menu import ContextMenu
35
+ from pentool.tui.widgets.filter_bar import FilterBar
36
+ from pentool.tui.widgets.inspector_panel import InspectorPanel
37
+ from pentool.tui.widgets.request_editor import HttpView
38
+ from pentool.tui.widgets.resize_handle import ResizeHandle
39
+ from pentool.tui.mixins.app_mixin import AppMixin
40
+ from pentool.tui.mixins.request_context_menu import RequestContextMenuMixin
41
+
42
+ logger = get_logger(__name__)
43
+
44
+ # Колонки таблицы HTTP History
45
+ _COL_NAMES = ["ID", "Host", "Method", "URL", "Status", "Size", "Time"]
46
+
47
+ def _make_empty_table() -> pa.Table:
48
+ """Пустая Arrow-таблица с нужными колонками."""
49
+ return pa.table({
50
+ "ID": pa.array([], type=pa.int64()),
51
+ "Host": pa.array([], type=pa.string()),
52
+ "Method": pa.array([], type=pa.string()),
53
+ "URL": pa.array([], type=pa.string()),
54
+ "Status": pa.array([], type=pa.string()),
55
+ "Size": pa.array([], type=pa.string()),
56
+ "Time": pa.array([], type=pa.string()),
57
+ })
58
+
59
+ def _rows_to_arrow(rows: list[dict]) -> pa.Table:
60
+ """Преобразовать список dict из HttpStorage в Arrow-таблицу."""
61
+ ids, hosts, methods, urls, statuses, sizes, times = [], [], [], [], [], [], []
62
+ for r in rows:
63
+ ids.append(r.get("id", 0))
64
+ host = str(r.get("host", "") or "")
65
+ # Минимальная ширина Host — 30 символов, чтобы колонка не была узкой
66
+ hosts.append(host)
67
+ methods.append(str(r.get("method", "") or ""))
68
+ url = str(r.get("url", "") or "")
69
+ urls.append(url[:80] + "…" if len(url) > 80 else url)
70
+ status = r.get("status_code")
71
+ statuses.append(str(status) if status is not None else "-")
72
+ length = r.get("length")
73
+ sizes.append(str(length) if length is not None else "-")
74
+ ts = r.get("timestamp")
75
+ if ts:
76
+ try:
77
+ dt = datetime.datetime.fromtimestamp(ts)
78
+ times.append(dt.strftime("%H:%M:%S"))
79
+ except Exception:
80
+ times.append("-")
81
+ else:
82
+ times.append("-")
83
+ # Если таблица пустая — добавляем фиктивную строку-заглушку для ширины колонок,
84
+ # но только если данных нет вообще
85
+ if not rows:
86
+ return pa.table({
87
+ "ID": pa.array([], type=pa.int64()),
88
+ "Host": pa.array([], type=pa.string()),
89
+ "Method": pa.array([], type=pa.string()),
90
+ "URL": pa.array([], type=pa.string()),
91
+ "Status": pa.array([], type=pa.string()),
92
+ "Size": pa.array([], type=pa.string()),
93
+ "Time": pa.array([], type=pa.string()),
94
+ })
95
+ return pa.table({
96
+ "ID": pa.array(ids, type=pa.int64()),
97
+ "Host": pa.array(hosts, type=pa.string()),
98
+ "Method": pa.array(methods, type=pa.string()),
99
+ "URL": pa.array(urls, type=pa.string()),
100
+ "Status": pa.array(statuses, type=pa.string()),
101
+ "Size": pa.array(sizes, type=pa.string()),
102
+ "Time": pa.array(times, type=pa.string()),
103
+ })
104
+
105
+ from pentool.tui.widgets.toolbar_button import ToolbarButton
106
+
107
+ from textual import events as _events, on
108
+
109
+ class _ProxyDataTable(_BaseDataTable):
110
+ """DataTable для Proxy HTTP History.
111
+
112
+ Для Ctrl+левой кнопки публикуем собственное сообщение ContextMenuRequest,
113
+ чтобы ProxyScreen мог открыть контекстное меню без зависимости от пузырения.
114
+ (Правая кнопка button=3 не доходит до Textual в VTE-терминале.)
115
+ """
116
+
117
+ class ContextMenuRequest(Message):
118
+ """Запрос открытия контекстного меню от DataTable."""
119
+ def __init__(self, screen_x: int, screen_y: int) -> None:
120
+ super().__init__()
121
+ self.screen_x = screen_x
122
+ self.screen_y = screen_y
123
+
124
+ async def on_event(self, event: _events.Event) -> None:
125
+ if isinstance(event, _events.MouseDown) and (
126
+ event.button == 3 or (event.button == 1 and event.ctrl)
127
+ ):
128
+ # Сначала вызываем базовый обработчик (двигаем курсор к строке)
129
+ await super().on_event(event)
130
+ # Публикуем собственное сообщение — оно всегда поднимается к родителю
131
+ self.post_message(self.ContextMenuRequest(event.screen_x, event.screen_y))
132
+ else:
133
+ await super().on_event(event)
134
+
135
+ DataTable = _ProxyDataTable
136
+
137
+ class ProxyScreen(RequestContextMenuMixin, AppMixin, Widget):
138
+ """Полный экран модуля Proxy."""
139
+
140
+ DEFAULT_CSS = _CSS
141
+
142
+ BINDINGS = [
143
+ Binding("i", "toggle_inspector", "Inspector", show=False),
144
+ Binding("h", "focus_tab_history", "HTTP History", show=False),
145
+ Binding("n", "focus_tab_intercept","Intercept", show=False),
146
+ Binding("w", "focus_tab_ws", "WS History", show=False),
147
+ Binding("ctrl+h", "focus_tab_history", "HTTP History", show=False),
148
+ Binding("ctrl+n", "focus_tab_intercept","Intercept", show=False),
149
+ Binding("ctrl+w", "focus_tab_ws", "WS History", show=False),
150
+ ]
151
+
152
+ # Для совместимости с тестами (test_stage8_5)
153
+ _COL_LABELS = ["ID", "Mth", "URL", "St", "Size"]
154
+
155
+ # ── RequestContextMenuMixin config ────────────────────────────────────────
156
+ _cm_show_nmap = True
157
+ _cm_show_send_scanner = True
158
+ _cm_show_send_decoder = True
159
+ _cm_show_send_comparer = True
160
+
161
+ def __init__(self, **kwargs) -> None:
162
+ super().__init__(**kwargs)
163
+ self._selected_req_id: int | None = None
164
+ self._storage: HttpStorage = HttpStorage()
165
+ self._storage_ready: bool = False
166
+ self._rows_cache: list[dict] = [] # кэш последних загруженных строк
167
+ self._ws_rows_cache: list[dict] = [] # кэш WS-запросов
168
+ self._sort_col: int | None = None
169
+ self._sort_reverse: bool = False
170
+ self._inspector_visible: bool = False
171
+ self._current_filters: dict | None = None
172
+ # req.id (str) → storage row_id (int) — используем req.id, не id(req)
173
+ self._pending_req_ids: dict[str, int] = {}
174
+ # Очередь запросов, пришедших до готовности storage
175
+ self._pre_storage_queue: list[InterceptedRequest] = []
176
+ # Текущий перехваченный запрос (ожидает Forward/Drop)
177
+ self._intercept_req: InterceptedRequest | None = None
178
+ # Очередь ожидающих показа запросов — TUI показывает по одному
179
+ self._intercept_pending: list[InterceptedRequest] = []
180
+
181
+ def compose(self) -> ComposeResult:
182
+ # Toolbar (снаружи SubTabs — все btn-* ID всегда в DOM)
183
+ with Horizontal(id="toolbar"):
184
+ yield ToolbarButton("○ Proxy", "btn-proxy", classes="inactive")
185
+ yield Static(" │ ", classes="toolbar-sep")
186
+ yield ToolbarButton("○ Intercept", "btn-intercept", classes="inactive")
187
+ yield Static(" │ ", classes="toolbar-sep")
188
+ yield ToolbarButton("Scope", "btn-scope")
189
+ yield Static(" │ ", classes="toolbar-sep")
190
+ yield ToolbarButton("M/R", "btn-mr")
191
+ yield Static(" │ ", classes="toolbar-sep")
192
+ yield ToolbarButton("Load History","btn-load-history")
193
+ yield Static(" │ ", classes="toolbar-sep")
194
+ yield ToolbarButton("Clear", "btn-clear")
195
+
196
+ # Под-вкладки прокси
197
+ with TabbedContent(id="proxy-subtabs"):
198
+ with TabPane("Intercept", id="tab-intercept"):
199
+ with Horizontal(id="intercept-toolbar"):
200
+ yield ToolbarButton("⏩ Forward", "btn-forward", classes="disabled")
201
+ yield ToolbarButton("✖ Drop", "btn-drop", classes="disabled")
202
+ yield Static(" │ ", classes="toolbar-sep")
203
+ yield Label("(enable Intercept to capture requests)", id="intercept-hint")
204
+ with Vertical(id="intercept-req-area"):
205
+ yield Static("", id="intercept-headers-preview", markup=True)
206
+ yield TextArea(
207
+ "(No requests waiting for intercept)",
208
+ id="intercept-editor",
209
+ read_only=False,
210
+ )
211
+ yield ResizeHandle(
212
+ "intercept-req-area", "intercept-bottom-area",
213
+ vertical=True,
214
+ id="resize-intercept",
215
+ )
216
+ with Horizontal(id="intercept-bottom-area"):
217
+ with Vertical(id="intercept-sent-panel"):
218
+ yield Static("Sent Request", classes="panel-title")
219
+ yield HttpView(id="intercept-sent-req")
220
+ yield ResizeHandle(
221
+ "intercept-sent-panel", "intercept-resp-panel",
222
+ id="resize-intercept-sent-resp",
223
+ )
224
+ with Vertical(id="intercept-resp-panel"):
225
+ yield Static("Response", classes="panel-title")
226
+ yield HttpView(id="intercept-resp-viewer")
227
+
228
+ with TabPane("HTTP History", id="tab-http-history"):
229
+ with Horizontal(id="body"):
230
+ with Vertical(id="main-panel"):
231
+ # Верхняя часть: FilterBar + DataTable
232
+ with Vertical(id="table-area"):
233
+ yield FilterBar(id="filter-bar")
234
+ yield DataTable(
235
+ backend=ArrowBackend(_make_empty_table()),
236
+ id="request-list",
237
+ cursor_type="row",
238
+ zebra_stripes=True,
239
+ max_column_content_width=120,
240
+ column_widths=[5, 20, 8, 60, 6, 8, 8],
241
+ )
242
+ # ResizeHandle между таблицей и детальной панелью
243
+ yield ResizeHandle(
244
+ "table-area", "detail-area",
245
+ vertical=True,
246
+ id="resize-table-detail",
247
+ )
248
+ # Нижняя часть: Request | ResizeHandle | Response
249
+ with Horizontal(id="detail-area"):
250
+ with Vertical(id="req-panel"):
251
+ yield Static("Request", classes="panel-title")
252
+ yield HttpView(id="req-editor")
253
+ yield ResizeHandle(
254
+ "req-panel", "resp-panel",
255
+ id="resize-req-resp",
256
+ )
257
+ with Vertical(id="resp-panel"):
258
+ yield Static("Response", classes="panel-title")
259
+ yield HttpView(id="resp-viewer")
260
+ # Inspector (скрыт по умолчанию)
261
+ yield InspectorPanel(id="inspector-panel")
262
+
263
+ with TabPane("WS History", id="tab-ws-history"):
264
+ with Horizontal(id="ws-body"):
265
+ with Vertical(id="ws-main-panel"):
266
+ with Vertical(id="ws-table-area"):
267
+ yield DataTable(
268
+ backend=ArrowBackend(_make_empty_table()),
269
+ id="ws-request-list",
270
+ cursor_type="row",
271
+ zebra_stripes=True,
272
+ column_widths=[5, 20, 8, 60, 6, 8, 8],
273
+ )
274
+ yield ResizeHandle(
275
+ "ws-table-area", "ws-detail-area",
276
+ vertical=True,
277
+ id="resize-ws-table-detail",
278
+ )
279
+ with Horizontal(id="ws-detail-area"):
280
+ with Vertical(id="ws-req-panel"):
281
+ yield Static("Request", classes="panel-title")
282
+ yield HttpView(id="ws-req-editor")
283
+ yield ResizeHandle(
284
+ "ws-req-panel", "ws-resp-panel",
285
+ id="resize-ws-req-resp",
286
+ )
287
+ with Vertical(id="ws-resp-panel"):
288
+ yield Static("Response", classes="panel-title")
289
+ yield HttpView(id="ws-resp-viewer")
290
+ yield ResizeHandle(
291
+ "ws-detail-area", "ws-messages-area",
292
+ vertical=True,
293
+ id="resize-ws-detail-msg",
294
+ )
295
+ with Vertical(id="ws-messages-area"):
296
+ yield Static(
297
+ "WebSocket Messages",
298
+ id="ws-msg-label",
299
+ classes="panel-title",
300
+ )
301
+ from textual.widgets import RichLog
302
+ yield RichLog(
303
+ id="ws-msg-log",
304
+ highlight=True,
305
+ markup=True,
306
+ wrap=True,
307
+ max_lines=1000,
308
+ )
309
+
310
+ yield Static(
311
+ "Ctrl+R: Repeater │ Ctrl+T: Target │ Ctrl+U: Copy URL │ M: Context menu │ I: Inspector │ H: HTTP History │ N: Intercept │ W: WS History",
312
+ id="status-bar",
313
+ )
314
+
315
+ def on_mount(self) -> None:
316
+ self._sync_proxy_button()
317
+ self._sync_intercept_button()
318
+ self._setup_tooltips()
319
+ self.run_worker(self._init_storage())
320
+ # Установить начальное состояние ScopeToggle из конфига
321
+ try:
322
+ from pentool.core.config import get_config
323
+ from pentool.tui.widgets.filter_bar import FilterBar, ScopeToggle
324
+ scope = get_config().scope
325
+ filter_bar = self.query_one("#filter-bar", FilterBar)
326
+ filter_bar.query_one("#fb-scope", ScopeToggle).set_scope_empty(not bool(scope))
327
+ except Exception:
328
+ pass
329
+ # Подписаться на WS-фреймы
330
+ try:
331
+ from pentool.core.event_bus import get_event_bus
332
+ from pentool.core.events import WebSocketFrameEvent
333
+ get_event_bus().subscribe(WebSocketFrameEvent, self._on_ws_frame_event)
334
+ except Exception:
335
+ pass
336
+
337
+ def _on_ws_frame_event(self, event) -> None:
338
+ """Получен WS-фрейм — добавить в лог (thread-safe)."""
339
+ self.call_from_thread(self._append_ws_frame, event)
340
+
341
+ def _append_ws_frame(self, event) -> None:
342
+ """Добавить WS-фрейм в RichLog."""
343
+ try:
344
+ from textual.widgets import RichLog
345
+ log = self.query_one("#ws-msg-log", RichLog)
346
+ opcode = getattr(event, "opcode", 0x1)
347
+ direction = getattr(event, "direction", "")
348
+ payload_text = getattr(event, "payload_text", "")
349
+ payload = getattr(event, "payload", b"")
350
+
351
+ _OPCODE_NAMES = {0x1: "TEXT", 0x2: "BIN", 0x8: "CLOSE", 0x9: "PING", 0xA: "PONG"}
352
+ op_name = _OPCODE_NAMES.get(opcode, f"0x{opcode:X}")
353
+
354
+ if direction.startswith("client"):
355
+ dir_color = "cyan"
356
+ dir_arrow = "→"
357
+ else:
358
+ dir_color = "green"
359
+ dir_arrow = "←"
360
+
361
+ if opcode == 0x1:
362
+ body = payload_text[:200] + ("…" if len(payload_text) > 200 else "")
363
+ log.write(
364
+ f"[{dir_color}]{dir_arrow} {op_name}[/{dir_color}] "
365
+ f"[dim]{getattr(event, 'request_id', '')}[/dim] "
366
+ f"{body}"
367
+ )
368
+ elif opcode == 0x2:
369
+ log.write(
370
+ f"[{dir_color}]{dir_arrow} {op_name}[/{dir_color}] "
371
+ f"[dim]{getattr(event, 'request_id', '')}[/dim] "
372
+ f"[dim]{len(payload)} bytes[/dim]"
373
+ )
374
+ else:
375
+ log.write(
376
+ f"[{dir_color}]{dir_arrow} {op_name}[/{dir_color}] "
377
+ f"[dim]{getattr(event, 'request_id', '')}[/dim]"
378
+ )
379
+ except Exception:
380
+ pass
381
+
382
+ def on_unmount(self) -> None:
383
+ """Отписаться от EventBus при удалении виджета."""
384
+ try:
385
+ from pentool.core.event_bus import get_event_bus
386
+ from pentool.core.events import WebSocketFrameEvent
387
+ get_event_bus().unsubscribe(WebSocketFrameEvent, self._on_ws_frame_event)
388
+ except Exception:
389
+ pass
390
+
391
+ async def _init_storage(self) -> None:
392
+ """Открыть БД и загрузить историю."""
393
+ try:
394
+ db_path = self.app.db_path # type: ignore[attr-defined]
395
+ except Exception:
396
+ db_path = os.path.expanduser("~/.config/pentool/pentool.db")
397
+ try:
398
+ await self._storage.init_db(db_path)
399
+ self._storage_ready = True
400
+ logger.info("PROXY SCREEN: storage ready at %s", db_path)
401
+ # Обработать запросы, пришедшие до готовности storage
402
+ if self._pre_storage_queue:
403
+ logger.info("PROXY SCREEN: flushing %d pre-storage requests", len(self._pre_storage_queue))
404
+ for queued_req in self._pre_storage_queue:
405
+ await self._store_request(queued_req)
406
+ self._pre_storage_queue.clear()
407
+ await self._reload_table(None)
408
+ except Exception as exc:
409
+ logger.error("HttpStorage init failed: %s", exc)
410
+
411
+ async def _reload_table(self, filters: dict | None = None) -> None:
412
+ """Загрузить/перезагрузить данные в DataTable из storage."""
413
+ if not self._storage_ready:
414
+ return
415
+ try:
416
+ logger.info("PROXY SCREEN: _reload_table called, filters=%s", filters)
417
+ # Если scope_only=True — подставить список хостов из scope
418
+ effective_filters = dict(filters) if filters else {}
419
+ if effective_filters.pop("scope_only", False):
420
+ proxy = self._get_proxy()
421
+ scope_hosts = proxy.scope if proxy else []
422
+ if scope_hosts:
423
+ effective_filters["hosts"] = scope_hosts
424
+ else:
425
+ # Scope пуст — показываем всё
426
+ pass
427
+
428
+ # HTTP History показывает только НЕ-WebSocket запросы
429
+ effective_filters["is_websocket"] = False
430
+
431
+ rows = await self._storage.get_metadata_batch(
432
+ offset=0, limit=2000, filters=effective_filters if effective_filters else None
433
+ )
434
+ logger.info("PROXY SCREEN: _reload_table loaded %d rows (effective_filters=%s)", len(rows), effective_filters)
435
+ self._rows_cache = rows
436
+ self._current_filters = filters
437
+ arrow = _rows_to_arrow(rows)
438
+ table = self.query_one("#request-list", DataTable)
439
+ table.backend = ArrowBackend(arrow)
440
+ # Сбрасываем кэш колонок — _clear_caches() его не трогает
441
+ table._ordered_columns = None
442
+ # Принудительно расширяем колонку Host
443
+ try:
444
+ for col in table.ordered_columns:
445
+ if str(col.label).strip().startswith("Host"):
446
+ col.width = 30
447
+ col.auto_width = False
448
+ break
449
+ except Exception:
450
+ pass
451
+ table._clear_caches()
452
+ table._require_update_dimensions = True
453
+ table.refresh()
454
+ # Скролл к первой строке (новые запросы идут сверху)
455
+ if rows:
456
+ table.move_cursor(row=0)
457
+ except Exception as exc:
458
+ logger.error("_reload_table failed: %s", exc)
459
+
460
+ async def _reload_ws_table(self) -> None:
461
+ """Загрузить/перезагрузить WebSocket-запросы в WS History таблицу."""
462
+ if not self._storage_ready:
463
+ return
464
+ try:
465
+ logger.info("PROXY SCREEN: _reload_ws_table called")
466
+ rows = await self._storage.get_metadata_batch(
467
+ offset=0, limit=2000, filters={"is_websocket": True}
468
+ )
469
+ logger.info("PROXY SCREEN: _reload_ws_table loaded %d WS rows", len(rows))
470
+ self._ws_rows_cache = rows
471
+ arrow = _rows_to_arrow(rows)
472
+ try:
473
+ table = self.query_one("#ws-request-list", DataTable)
474
+ except Exception:
475
+ return
476
+ table.backend = ArrowBackend(arrow)
477
+ table._ordered_columns = None
478
+ try:
479
+ for col in table.ordered_columns:
480
+ if str(col.label).strip().startswith("Host"):
481
+ col.width = 30
482
+ col.auto_width = False
483
+ break
484
+ except Exception:
485
+ pass
486
+ table._clear_caches()
487
+ table._require_update_dimensions = True
488
+ table.refresh()
489
+ if rows:
490
+ table.move_cursor(row=0)
491
+ except Exception as exc:
492
+ logger.error("_reload_ws_table failed: %s", exc)
493
+
494
+ def _setup_tooltips(self) -> None:
495
+ tooltips = {
496
+ "btn-proxy": "Toggle proxy server",
497
+ "btn-forward": "Forward intercepted request",
498
+ "btn-drop": "Drop intercepted request",
499
+ "btn-intercept": "Toggle intercept mode",
500
+ "btn-scope": "Configure scope settings",
501
+ "btn-mr": "Match and Replace rules",
502
+ "btn-ca-cert": "Install CA certificate",
503
+ "btn-clear": "Clear request history",
504
+ }
505
+ for btn_id, tip in tooltips.items():
506
+ try:
507
+ self.query_one(f"#{btn_id}", ToolbarButton).tooltip = tip
508
+ except Exception:
509
+ pass
510
+
511
+ def load_from_project(self) -> None:
512
+ """Вызывается после смены проекта — перезагружает таблицу из storage (уже переключённого)."""
513
+ try:
514
+ from pentool.core.config import get_config
515
+ from pentool.tui.widgets.filter_bar import FilterBar, ScopeToggle
516
+ scope = get_config().scope
517
+ filter_bar = self.query_one("#filter-bar", FilterBar)
518
+ filter_bar.query_one("#fb-scope", ScopeToggle).set_scope_empty(not bool(scope))
519
+ except Exception:
520
+ pass
521
+ # Storage уже переключён на новую БД — просто перезагружаем таблицу.
522
+ # НЕ вызываем clear_all/get_requests — данные уже лежат в новой БД.
523
+ self.run_worker(self._reload_from_storage())
524
+
525
+ async def _reload_from_proxy(self) -> None:
526
+ """Синхронизировать storage из in-memory прокси (для случая очистки/сброса истории)."""
527
+ for _ in range(50):
528
+ if self._storage_ready:
529
+ break
530
+ await asyncio.sleep(0.1)
531
+ if not self._storage_ready:
532
+ logger.error("_reload_from_proxy: storage not ready")
533
+ return
534
+ proxy = self._get_proxy()
535
+ await self._storage.clear_all()
536
+ if proxy is not None:
537
+ for req in proxy.get_requests(limit=100_000):
538
+ parsed = req.to_parsed_request()
539
+ await self._storage.add_request(
540
+ parsed, req.response,
541
+ is_websocket=getattr(req, "is_websocket", False),
542
+ )
543
+ await self._reload_table()
544
+
545
+ async def _reload_from_storage(self) -> None:
546
+ """Перезагрузить таблицу из текущего storage без сброса данных.
547
+
548
+ Используется при смене проекта: storage уже переключён на новую БД,
549
+ данные в ней есть — нужно только обновить таблицу.
550
+ """
551
+ for _ in range(60):
552
+ if self._storage_ready:
553
+ break
554
+ await asyncio.sleep(0.1)
555
+ if not self._storage_ready:
556
+ logger.error("_reload_from_storage: storage not ready after 6s")
557
+ return
558
+ await self._reload_table()
559
+ await self._reload_ws_table()
560
+
561
+ def add_request_row(self, req: object) -> None:
562
+ """Вызывается из app при поступлении нового запроса (без ответа ещё).
563
+
564
+ Только регистрируем в storage без reload — reload будет в update_request_row.
565
+ """
566
+ if not isinstance(req, InterceptedRequest):
567
+ return
568
+ # Пропускаем если уже зарегистрирован (защита от дублей)
569
+ if req.id in self._pending_req_ids:
570
+ logger.debug("PROXY SCREEN: add_request_row: duplicate req.id=%s, skipping", req.id)
571
+ return
572
+ logger.info("PROXY SCREEN: add_request_row: %s %s (id=%s, ws=%s)", req.method, req.url, req.id, req.is_websocket)
573
+ # Помечаем как "в процессе" сразу (sentinel -1)
574
+ self._pending_req_ids[req.id] = -1
575
+ self.run_worker(self._store_request(req))
576
+
577
+ def update_request_row(self, req: object) -> None:
578
+ """Вызывается из app когда запрос полностью завершён (с ответом).
579
+
580
+ Обновляем запись в storage и делаем reload таблицы.
581
+ """
582
+ if not isinstance(req, InterceptedRequest):
583
+ return
584
+ status = req.response.status if req.response else None
585
+ logger.info("PROXY SCREEN: update_request_row: %s %s → %s (id=%s)", req.method, req.url, status, req.id)
586
+ self.run_worker(self._update_and_reload(req))
587
+
588
+ async def _store_request(self, req: InterceptedRequest) -> None:
589
+ """Сохранить запрос без ответа. Reload НЕ делаем — ждём ответа."""
590
+ if not self._storage_ready:
591
+ logger.info("PROXY SCREEN: _store_request: storage not ready, queuing %s", req.id)
592
+ self._pre_storage_queue.append(req)
593
+ return
594
+ parsed = req.to_parsed_request()
595
+ row_id = await self._storage.add_request(
596
+ parsed, None, is_websocket=req.is_websocket
597
+ )
598
+ logger.info("PROXY SCREEN: _store_request: saved req.id=%s as row_id=%d", req.id, row_id)
599
+ self._pending_req_ids[req.id] = row_id
600
+
601
+ async def _wait_for_row_id(self, req: InterceptedRequest) -> int | None:
602
+ """Ждать пока _store_request заменит sentinel -1 на реальный row_id.
603
+
604
+ Защита от race condition: _update_and_reload может прийти
605
+ раньше чем add_request завершился в _store_request.
606
+ Возвращает row_id или None если запрос не был зарегистрирован.
607
+ """
608
+ for _ in range(50): # max 5 сек
609
+ row_id = self._pending_req_ids.get(req.id)
610
+ if row_id is not None and row_id != -1:
611
+ return row_id
612
+ if req.id not in self._pending_req_ids:
613
+ return None # запрос не был зарегистрирован вообще
614
+ await asyncio.sleep(0.1)
615
+ return self._pending_req_ids.get(req.id)
616
+
617
+ async def _persist_response(self, req: InterceptedRequest, row_id: int | None) -> None:
618
+ """Сохранить ответ в storage для данного запроса.
619
+
620
+ Если row_id известен — обновляем существующую запись.
621
+ Если нет — вставляем запрос с ответом целиком (fallback).
622
+ """
623
+ if row_id is not None and row_id != -1:
624
+ if req.response is not None:
625
+ logger.info("PROXY SCREEN: _persist_response: update row_id=%d for req.id=%s", row_id, req.id)
626
+ await self._storage.update_response(row_id, req.response)
627
+ elif row_id is None:
628
+ if req.response is not None:
629
+ logger.info("PROXY SCREEN: _persist_response: no pending row for %s, inserting fresh", req.id)
630
+ parsed = req.to_parsed_request()
631
+ await self._storage.add_request(
632
+ parsed, req.response, is_websocket=req.is_websocket
633
+ )
634
+ else:
635
+ logger.warning("PROXY SCREEN: _persist_response: row_id=-1 (store still pending?) for req.id=%s", req.id)
636
+
637
+ def _append_row_to_table(self, req: InterceptedRequest, row_id: int) -> None:
638
+ """Инкрементально добавить одну строку в таблицу без полного reload (R-17).
639
+
640
+ Вместо перезагрузки всех строк — добавляем одну новую запись
641
+ в _rows_cache и перестраиваем ArrowBackend только из кэша.
642
+ Используется когда запрос завершён и фильтры не заданы.
643
+ """
644
+ # Строим dict в том же формате что и storage.get_metadata_batch()
645
+ parsed = req.to_parsed_request()
646
+ url = parsed.url or ""
647
+ ts = time.time()
648
+ row: dict = {
649
+ "id": row_id,
650
+ "host": parsed.headers.get("Host", "").split(":")[0] or url.split("/")[2] if "://" in url else url,
651
+ "method": req.method or "",
652
+ "url": url,
653
+ "status_code": req.response.status if req.response else None,
654
+ "length": len((req.response.body or "").encode("utf-8")) if req.response else None,
655
+ "timestamp": ts,
656
+ "is_websocket": req.is_websocket,
657
+ }
658
+ self._rows_cache.insert(0, row) # новые запросы сверху
659
+ try:
660
+ arrow = _rows_to_arrow(self._rows_cache)
661
+ table = self.query_one("#request-list", DataTable)
662
+ table.backend = ArrowBackend(arrow)
663
+ table._ordered_columns = None
664
+ table._clear_caches()
665
+ table._require_update_dimensions = True
666
+ table.refresh()
667
+ except Exception as exc:
668
+ logger.debug("PROXY SCREEN: _append_row_to_table: %s", exc)
669
+
670
+ async def _update_and_reload(self, req: InterceptedRequest) -> None:
671
+ """Обновить запись с ответом и перезагрузить/инкрементально обновить таблицу."""
672
+ if not self._storage_ready:
673
+ return
674
+ row_id = await self._wait_for_row_id(req)
675
+ actual_row_id = self._pending_req_ids.pop(req.id, None)
676
+ await self._persist_response(req, actual_row_id)
677
+ # Выбираем стратегию обновления таблицы:
678
+ # — если фильтры заданы или WebSocket — полный reload (нужна сортировка/фильтрация)
679
+ # — иначе — инкрементальный append (быстрее, нет мерцания)
680
+ if req.is_websocket:
681
+ await self._reload_ws_table()
682
+ elif self._current_filters:
683
+ await self._reload_table(self._current_filters)
684
+ elif actual_row_id and actual_row_id != -1:
685
+ self._append_row_to_table(req, actual_row_id)
686
+ else:
687
+ await self._reload_table(None)
688
+
689
+ def _select_row(self, row_idx: int) -> None:
690
+ if 0 <= row_idx < len(self._rows_cache):
691
+ row = self._rows_cache[row_idx]
692
+ self._selected_req_id = row.get("id")
693
+ self.run_worker(self._load_row_details(self._selected_req_id))
694
+
695
+ def _select_ws_row(self, row_idx: int) -> None:
696
+ """Выбор строки в WS History — загружает детали в ws-панели."""
697
+ if 0 <= row_idx < len(self._ws_rows_cache):
698
+ row = self._ws_rows_cache[row_idx]
699
+ row_id = row.get("id")
700
+ if row_id is not None:
701
+ self.run_worker(self._load_ws_row_details(row_id))
702
+
703
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
704
+ if event.data_table.id == "ws-request-list":
705
+ self._select_ws_row(event.cursor_row)
706
+ else:
707
+ self._select_row(event.cursor_row)
708
+
709
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
710
+ if event.data_table.id == "ws-request-list":
711
+ self._select_ws_row(event.cursor_row)
712
+ else:
713
+ self._select_row(event.cursor_row)
714
+
715
+ def on_data_table_cell_highlighted(self, event: DataTable.CellHighlighted) -> None:
716
+ """Даём фокус DataTable при любом движении курсора — нужно для скролла мышью."""
717
+ try:
718
+ event.data_table.focus()
719
+ except Exception:
720
+ pass
721
+
722
+ async def _load_row_details(self, row_id: int | None) -> None:
723
+ if row_id is None or not self._storage_ready:
724
+ return
725
+ entry = await self._storage.get_full_entry(row_id)
726
+ if entry is None:
727
+ return
728
+ self.call_after_refresh(self._load_entry_details, entry)
729
+
730
+ async def _load_ws_row_details(self, row_id: int) -> None:
731
+ """Загрузить детали WS-запроса в ws-панели."""
732
+ if not self._storage_ready:
733
+ return
734
+ entry = await self._storage.get_full_entry(row_id)
735
+ if entry is None:
736
+ return
737
+ self.call_after_refresh(self._load_ws_entry_details, entry)
738
+
739
+ def _load_ws_entry_details(self, entry: dict) -> None:
740
+ """Показать детали WS-запроса в WS-панелях."""
741
+ from pentool.utils.parser import ParsedRequest, ParsedResponse
742
+
743
+ req_headers = entry.get("request_headers") or {}
744
+ parsed_req = ParsedRequest(
745
+ method=entry.get("method", "GET"),
746
+ url=entry.get("url", ""),
747
+ headers=req_headers if isinstance(req_headers, dict) else {},
748
+ body=entry.get("request_body", "") or "",
749
+ )
750
+ try:
751
+ from pentool.utils.parser import build_http_request
752
+ raw_req = build_http_request(parsed_req)
753
+ self.query_one("#ws-req-editor", HttpView).load_raw_http(raw_req)
754
+ except Exception:
755
+ pass
756
+
757
+ resp_headers = entry.get("response_headers") or {}
758
+ status = entry.get("status_code")
759
+ resp_body = entry.get("response_body", "") or ""
760
+ try:
761
+ view = self.query_one("#ws-resp-viewer", HttpView)
762
+ if status is not None:
763
+ headers_text = "\r\n".join(f"{k}: {v}" for k, v in (resp_headers if isinstance(resp_headers, dict) else {}).items())
764
+ raw = f"HTTP/1.1 {status}\r\n{headers_text}\r\n\r\n{resp_body}"
765
+ view.load_raw_http(raw)
766
+ else:
767
+ view.load_raw_http("(no response)")
768
+ except Exception:
769
+ pass
770
+
771
+ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
772
+ idx = event.column_index
773
+ self._sort_reverse = (self._sort_col == idx) and not self._sort_reverse
774
+ self._sort_col = idx
775
+ col_name = _COL_NAMES[idx] if idx < len(_COL_NAMES) else ""
776
+ if col_name:
777
+ direction = "descending" if self._sort_reverse else "ascending"
778
+ event.data_table.sort(by=[(col_name, direction)])
779
+ # Обновить метки колонок — показать стрелку у активной
780
+ try:
781
+ for i, name in enumerate(_COL_NAMES):
782
+ col = event.data_table.ordered_columns[i]
783
+ if i == idx:
784
+ arrow = "▼" if self._sort_reverse else "▲"
785
+ col.label = f"{name} {arrow}"
786
+ else:
787
+ col.label = name
788
+ event.data_table.refresh()
789
+ except Exception:
790
+ pass
791
+
792
+ def on_filter_bar_filter_changed(self, event: FilterBar.FilterChanged) -> None:
793
+ filters = event.filters if event.filters else None
794
+ self.run_worker(self._reload_table(filters))
795
+
796
+ def action_toggle_inspector(self) -> None:
797
+ self._inspector_visible = not self._inspector_visible
798
+ try:
799
+ panel = self.query_one("#inspector-panel", InspectorPanel)
800
+ if self._inspector_visible:
801
+ panel.add_class("-visible")
802
+ else:
803
+ panel.remove_class("-visible")
804
+ except Exception:
805
+ pass
806
+
807
+ def _switch_proxy_tab(self, tab_id: str) -> None:
808
+ """Переключиться на вкладку Proxy и поставить фокус на таблицу."""
809
+ try:
810
+ from textual.widgets import TabbedContent
811
+ tabs = self.query_one(TabbedContent)
812
+ tabs.active = tab_id
813
+ # Ставим фокус на DataTable в нужной вкладке
814
+ self.call_after_refresh(self._focus_tab_table, tab_id)
815
+ except Exception:
816
+ pass
817
+
818
+ def _focus_tab_table(self, tab_id: str) -> None:
819
+ """Поставить фокус на DataTable в указанной вкладке."""
820
+ try:
821
+ from textual.widgets import DataTable
822
+ from textual_fastdatatable import DataTable as FastDT
823
+ pane = self.query_one(f"#{tab_id}")
824
+ for cls in (FastDT, DataTable):
825
+ try:
826
+ table = pane.query_one(cls)
827
+ table.focus()
828
+ return
829
+ except Exception:
830
+ pass
831
+ except Exception:
832
+ pass
833
+
834
+ def action_focus_tab_history(self) -> None:
835
+ """Переключиться на HTTP History."""
836
+ self._switch_proxy_tab("tab-http-history")
837
+
838
+ def action_focus_tab_intercept(self) -> None:
839
+ """Переключиться на Intercept."""
840
+ self._switch_proxy_tab("tab-intercept")
841
+
842
+ def action_focus_tab_ws(self) -> None:
843
+ """Переключиться на WS History."""
844
+ self._switch_proxy_tab("tab-ws-history")
845
+
846
+ def on_key(self, event) -> None:
847
+ if event.key == "ctrl+r":
848
+ self.action_send_to_repeater()
849
+ event.prevent_default()
850
+ elif event.key == "ctrl+u":
851
+ self._copy_selected_url()
852
+ event.prevent_default()
853
+ elif event.key == "ctrl+t":
854
+ self._send_to_target()
855
+ event.prevent_default()
856
+ elif event.key == "m":
857
+ self._show_context_menu_at_cursor()
858
+ event.prevent_default()
859
+
860
+ def _show_context_menu_at_cursor(self) -> None:
861
+ try:
862
+ table = self.query_one("#request-list", DataTable)
863
+ r = table.region
864
+ x = r.x + 2
865
+ y = r.y + 1 + table.cursor_row
866
+ except Exception:
867
+ x, y = 10, 5
868
+ self._open_context_menu(x, y)
869
+
870
+ def action_send_to_repeater(self) -> None:
871
+ if self._selected_req_id is None:
872
+ return
873
+ self.run_worker(self._do_send_to("repeater"))
874
+
875
+ def _send_to_intruder(self) -> None:
876
+ if self._selected_req_id is None:
877
+ return
878
+ self.run_worker(self._do_send_to("intruder"))
879
+
880
+ async def _do_send_to(self, target: str) -> None:
881
+ if not self._storage_ready or self._selected_req_id is None:
882
+ return
883
+ entry = await self._storage.get_full_entry(self._selected_req_id)
884
+ if entry is None:
885
+ return
886
+ from pentool.utils.parser import ParsedRequest, build_http_request
887
+ parsed = ParsedRequest(
888
+ method=entry.get("method", "GET"),
889
+ url=entry.get("url", ""),
890
+ headers=entry.get("request_headers") or {},
891
+ body=entry.get("request_body", "") or "",
892
+ )
893
+ raw = build_http_request(parsed)
894
+ # Авто-добавление хоста в Target при любой отправке из Proxy (баг 2.7)
895
+ self._auto_add_host_to_target(parsed.url)
896
+ if target == "repeater":
897
+ self.app.post_message(SendToRepeater(raw)) # type: ignore[attr-defined]
898
+ elif target == "intruder":
899
+ self.app.post_message(SendToIntruder(raw)) # type: ignore[attr-defined]
900
+
901
+ def _auto_add_host_to_target(self, url: str) -> None:
902
+ """Автоматически добавить хост из URL в Target sitemap."""
903
+ try:
904
+ from pentool.utils.parser import ParsedRequest
905
+ parsed = ParsedRequest(method="GET", url=url, headers={}, body="")
906
+ from pentool.tui.messages import SendToTarget
907
+ self.app.post_message(SendToTarget(parsed)) # type: ignore[attr-defined]
908
+ except Exception:
909
+ pass
910
+
911
+ def _copy_selected_url(self) -> None:
912
+ if self._selected_req_id is None:
913
+ return
914
+ self.run_worker(self._do_copy_url())
915
+
916
+ async def _do_copy_url(self) -> None:
917
+ parsed = await self._get_selected_parsed()
918
+ if parsed is None:
919
+ return
920
+ from pentool.utils.copy_as import copy_to_clipboard
921
+ ok = copy_to_clipboard(parsed.url)
922
+ if ok:
923
+ self.app.notify("URL copied", timeout=2)
924
+ else:
925
+ self.app.notify("Could not copy to clipboard", severity="warning", timeout=3)
926
+
927
+ async def _get_selected_parsed(self):
928
+ if self._selected_req_id is None or not self._storage_ready:
929
+ return None
930
+ entry = await self._storage.get_full_entry(self._selected_req_id)
931
+ if entry is None:
932
+ return None
933
+ from pentool.utils.parser import ParsedRequest
934
+ return ParsedRequest(
935
+ method=entry.get("method", "GET"),
936
+ url=entry.get("url", ""),
937
+ headers=entry.get("request_headers") or {},
938
+ body=entry.get("request_body", "") or "",
939
+ )
940
+
941
+ def _copy_as(self, action: str) -> None:
942
+ self.run_worker(self._do_copy_as(action))
943
+
944
+ async def _do_copy_as(self, action: str) -> None:
945
+ parsed = await self._get_selected_parsed()
946
+ if parsed is None:
947
+ return
948
+ from pentool.utils.copy_as import (
949
+ copy_as_curl, copy_as_fetch, copy_as_ffuf, copy_as_sqlmap,
950
+ copy_as_nmap, copy_as_jwt_tool, copy_to_clipboard,
951
+ )
952
+ if action == "copy_curl":
953
+ text, label = copy_as_curl(parsed), "curl"
954
+ elif action == "copy_fetch":
955
+ text, label = copy_as_fetch(parsed), "fetch()"
956
+ elif action == "copy_ffuf":
957
+ text, label = copy_as_ffuf(parsed), "ffuf"
958
+ elif action == "copy_sqlmap":
959
+ text, label = copy_as_sqlmap(parsed), "sqlmap"
960
+ elif action == "copy_nmap":
961
+ text, label = copy_as_nmap(parsed), "nmap"
962
+ elif action == "copy_jwt":
963
+ text, label = copy_as_jwt_tool(parsed), "jwt_tool"
964
+ else:
965
+ return
966
+ ok = copy_to_clipboard(text)
967
+ if ok:
968
+ self.app.notify(f"Copied as {label}", timeout=2)
969
+ else:
970
+ self.app.notify(
971
+ f"Clipboard unavailable. {label}:\n{text[:80]}",
972
+ severity="warning", timeout=5
973
+ )
974
+
975
+ def _open_in_browser(self) -> None:
976
+ self.run_worker(self._do_open_in_browser())
977
+
978
+ async def _do_open_in_browser(self) -> None:
979
+ parsed = await self._get_selected_parsed()
980
+ if parsed is None:
981
+ return
982
+ from pentool.utils.copy_as import open_in_browser
983
+ url = parsed.url
984
+ if url:
985
+ open_in_browser(url)
986
+ self.app.notify(f"Opening: {url[:60]}", timeout=2)
987
+
988
+ def _save_request_txt(self) -> None:
989
+ self.run_worker(self._do_save_request_txt())
990
+
991
+ async def _do_save_request_txt(self) -> None:
992
+ parsed = await self._get_selected_parsed()
993
+ if parsed is None:
994
+ self.app.notify("No request selected", severity="warning", timeout=3)
995
+ return
996
+ from pentool.utils.copy_as import save_request_txt
997
+ path = os.path.expanduser("~/request.txt")
998
+ try:
999
+ save_request_txt(parsed, path)
1000
+ self.app.notify(f"Saved to {path}", timeout=3)
1001
+ except Exception as exc:
1002
+ self.app.notify(f"Save failed: {exc}", severity="error", timeout=4)
1003
+
1004
+ def _delete_selected_request(self) -> None:
1005
+ self.run_worker(self._do_delete_selected())
1006
+
1007
+ async def _do_delete_selected(self) -> None:
1008
+ if self._selected_req_id is None or not self._storage_ready:
1009
+ return
1010
+ try:
1011
+ await self._storage.delete(self._selected_req_id)
1012
+ self._selected_req_id = None
1013
+ await self._reload_table(self._current_filters)
1014
+ except Exception as exc:
1015
+ logger.error("Delete failed: %s", exc)
1016
+
1017
+ def _load_entry_details(self, entry: dict) -> None:
1018
+ from pentool.utils.parser import ParsedRequest, ParsedResponse
1019
+
1020
+ req_headers = entry.get("request_headers") or {}
1021
+ parsed_req = ParsedRequest(
1022
+ method=entry.get("method", "GET"),
1023
+ url=entry.get("url", ""),
1024
+ headers=req_headers if isinstance(req_headers, dict) else {},
1025
+ body=entry.get("request_body", "") or "",
1026
+ )
1027
+
1028
+ try:
1029
+ from pentool.utils.parser import build_http_request
1030
+ raw_req = build_http_request(parsed_req)
1031
+ self.query_one("#req-editor", HttpView).load_raw_http(raw_req)
1032
+ except Exception:
1033
+ pass
1034
+
1035
+ resp_headers = entry.get("response_headers") or {}
1036
+ status = entry.get("status_code")
1037
+ resp_body = entry.get("response_body", "") or ""
1038
+ try:
1039
+ view = self.query_one("#resp-viewer", HttpView)
1040
+ if status is not None:
1041
+ headers_text = "\r\n".join(f"{k}: {v}" for k, v in (resp_headers if isinstance(resp_headers, dict) else {}).items())
1042
+ raw = f"HTTP/1.1 {status}\r\n{headers_text}\r\n\r\n{resp_body}"
1043
+ view.load_raw_http(raw)
1044
+ else:
1045
+ view.load_raw_http("(no response)")
1046
+ except Exception:
1047
+ pass
1048
+
1049
+ try:
1050
+ panel = self.query_one("#inspector-panel", InspectorPanel)
1051
+ parsed_resp_for_inspector = None
1052
+ if status is not None:
1053
+ from pentool.utils.parser import ParsedResponse
1054
+ parsed_resp_for_inspector = ParsedResponse(
1055
+ status=status,
1056
+ headers=resp_headers if isinstance(resp_headers, dict) else {},
1057
+ body=resp_body,
1058
+ )
1059
+ panel.load(parsed_req, parsed_resp_for_inspector)
1060
+ except Exception:
1061
+ pass
1062
+
1063
+ @on(ToolbarButton.Pressed, "#btn-proxy")
1064
+ def on_btn_proxy(self, _: ToolbarButton.Pressed) -> None:
1065
+ self.action_toggle_proxy()
1066
+
1067
+ @on(ToolbarButton.Pressed, "#btn-forward")
1068
+ def on_btn_forward(self, _: ToolbarButton.Pressed) -> None:
1069
+ self.action_forward()
1070
+
1071
+ @on(ToolbarButton.Pressed, "#btn-drop")
1072
+ def on_btn_drop(self, _: ToolbarButton.Pressed) -> None:
1073
+ self.action_drop()
1074
+
1075
+ @on(ToolbarButton.Pressed, "#btn-intercept")
1076
+ def on_btn_intercept(self, _: ToolbarButton.Pressed) -> None:
1077
+ self.action_toggle_intercept()
1078
+
1079
+ @on(ToolbarButton.Pressed, "#btn-scope")
1080
+ def on_btn_scope(self, _: ToolbarButton.Pressed) -> None:
1081
+ self.action_open_scope()
1082
+
1083
+ @on(ToolbarButton.Pressed, "#btn-mr")
1084
+ def on_btn_mr(self, _: ToolbarButton.Pressed) -> None:
1085
+ self.action_open_mr()
1086
+
1087
+ @on(ToolbarButton.Pressed, "#btn-load-history")
1088
+ def on_btn_load_history(self, _: ToolbarButton.Pressed) -> None:
1089
+ self.action_load_history()
1090
+
1091
+ @on(ToolbarButton.Pressed, "#btn-clear")
1092
+ def on_btn_clear(self, _: ToolbarButton.Pressed) -> None:
1093
+ self.action_clear_list()
1094
+
1095
+ def action_load_history(self) -> None:
1096
+ """Загрузить историю запросов из БД по нажатию кнопки."""
1097
+ self.run_worker(self._reload_table(self._current_filters))
1098
+
1099
+ def action_forward(self) -> None:
1100
+ proxy = self._get_proxy()
1101
+ if proxy is None or self._intercept_req is None:
1102
+ return
1103
+ req = self._intercept_req
1104
+ try:
1105
+ editor = self.query_one("#intercept-editor", TextArea)
1106
+ modified = editor.text
1107
+ except Exception:
1108
+ modified = None
1109
+ # Показать отправленный запрос в левой нижней панели
1110
+ sent_text = modified if modified and modified.strip() else ""
1111
+ try:
1112
+ self.query_one("#intercept-sent-req", HttpView).load_raw_http(sent_text)
1113
+ except Exception:
1114
+ pass
1115
+ # Очистить правую панель ответа — ждём ответ от сервера
1116
+ try:
1117
+ self.query_one("#intercept-resp-viewer", HttpView).clear()
1118
+ except Exception:
1119
+ pass
1120
+ proxy.forward(req.id, modified if modified and modified.strip() else None)
1121
+ self._intercept_req = None
1122
+ # Если есть ожидающие — сразу показать следующий
1123
+ if self._intercept_pending:
1124
+ next_req = self._intercept_pending.pop(0)
1125
+ self._display_intercept_req(next_req)
1126
+ else:
1127
+ # Дизейблим кнопки — ответ появится асинхронно через show_intercept_response
1128
+ self._disable_intercept_buttons(hint="⏳ Forwarded — waiting for response…")
1129
+
1130
+ def action_drop(self) -> None:
1131
+ proxy = self._get_proxy()
1132
+ if proxy is None or self._intercept_req is None:
1133
+ return
1134
+ proxy.drop(self._intercept_req.id)
1135
+ self._intercept_req = None
1136
+ # Если есть ожидающие — сразу показать следующий
1137
+ if self._intercept_pending:
1138
+ next_req = self._intercept_pending.pop(0)
1139
+ self._display_intercept_req(next_req)
1140
+ return
1141
+ self._disable_intercept_buttons(hint="✖ Dropped")
1142
+ # При Drop очищаем верхний редактор и обе нижние панели
1143
+ try:
1144
+ self.query_one("#intercept-editor", TextArea).load_text(
1145
+ "(No requests waiting for intercept)"
1146
+ )
1147
+ except Exception:
1148
+ pass
1149
+ try:
1150
+ preview = self.query_one("#intercept-headers-preview", Static)
1151
+ preview.update("")
1152
+ preview.display = False
1153
+ except Exception:
1154
+ pass
1155
+ try:
1156
+ self.query_one("#intercept-sent-req", HttpView).clear()
1157
+ except Exception:
1158
+ pass
1159
+ try:
1160
+ self.query_one("#intercept-resp-viewer", HttpView).clear()
1161
+ except Exception:
1162
+ pass
1163
+
1164
+ def _disable_intercept_buttons(self, hint: str = "") -> None:
1165
+ """Дизейблить Forward/Drop и обновить подсказку."""
1166
+ try:
1167
+ self.query_one("#btn-forward", ToolbarButton).disabled = True
1168
+ self.query_one("#btn-drop", ToolbarButton).disabled = True
1169
+ except Exception:
1170
+ pass
1171
+ if hint:
1172
+ try:
1173
+ self.query_one("#intercept-hint", Label).update(hint)
1174
+ except Exception:
1175
+ pass
1176
+
1177
+ def show_intercepted_request(self, req: InterceptedRequest) -> None:
1178
+ """Вызывается из app при перехвате запроса — показывает в Intercept Tab.
1179
+
1180
+ Если сейчас уже ожидает другой запрос (Forward/Drop ещё не нажато),
1181
+ новый запрос встаёт в очередь. Так пользователь видит запросы по одному,
1182
+ а не теряет их (и proxy корректно блокирует каждый до решения).
1183
+ """
1184
+ if self._intercept_req is not None:
1185
+ # Уже показываем запрос — встаём в очередь
1186
+ self._intercept_pending.append(req)
1187
+ try:
1188
+ self.query_one("#intercept-hint", Label).update(
1189
+ f"⏸ {req.method} {req.url} (+{len(self._intercept_pending)} queued)"
1190
+ )
1191
+ except Exception:
1192
+ pass
1193
+ return
1194
+ self._display_intercept_req(req)
1195
+
1196
+ def _display_intercept_req(self, req: InterceptedRequest) -> None:
1197
+ """Отобразить запрос в Intercept Tab (используется при показе и при переходе к следующему)."""
1198
+ self._intercept_req = req
1199
+ try:
1200
+ from pentool.utils.parser import build_http_request
1201
+ raw = build_http_request(req.to_parsed_request())
1202
+ except Exception:
1203
+ raw = f"{req.method} {req.url}\n\n(could not render request)"
1204
+ try:
1205
+ editor = self.query_one("#intercept-editor", TextArea)
1206
+ editor.load_text(raw)
1207
+ except Exception:
1208
+ pass
1209
+ # Подсветка заголовков над редактором
1210
+ try:
1211
+ from pentool.tui.widgets.request_editor import _render_headers_rich
1212
+ parsed = req.to_parsed_request()
1213
+ request_line = f"{parsed.method} {parsed.path} HTTP/1.1"
1214
+ markup = _render_headers_rich(request_line, parsed.headers)
1215
+ preview = self.query_one("#intercept-headers-preview", Static)
1216
+ preview.update(markup)
1217
+ preview.display = True
1218
+ except Exception:
1219
+ pass
1220
+ # Очищаем только панель ответа — Sent Request не трогаем
1221
+ # (он обновляется только в action_forward/action_drop)
1222
+ try:
1223
+ self.query_one("#intercept-resp-viewer", HttpView).clear()
1224
+ except Exception:
1225
+ pass
1226
+ try:
1227
+ self.query_one("#btn-forward", ToolbarButton).disabled = False
1228
+ self.query_one("#btn-drop", ToolbarButton).disabled = False
1229
+ except Exception:
1230
+ pass
1231
+ queued = len(self._intercept_pending)
1232
+ hint = f"⏸ Intercepted: {req.method} {req.url}"
1233
+ if queued:
1234
+ hint += f" (+{queued} queued)"
1235
+ try:
1236
+ self.query_one("#intercept-hint", Label).update(hint)
1237
+ except Exception:
1238
+ pass
1239
+ # Переключиться на вкладку Intercept
1240
+ try:
1241
+ tabs = self.query_one("#proxy-subtabs", TabbedContent)
1242
+ tabs.active = "tab-intercept"
1243
+ except Exception:
1244
+ pass
1245
+
1246
+ def show_intercept_response(self, req: InterceptedRequest) -> None:
1247
+ """Показать ответ сервера в панели Intercept после Forward."""
1248
+ if req.response is None:
1249
+ return
1250
+ try:
1251
+ resp = req.response
1252
+ status_line = f"HTTP/1.1 {resp.status} {resp.reason}"
1253
+ headers = "\r\n".join(f"{k}: {v}" for k, v in resp.headers.items())
1254
+ body = resp.body or ""
1255
+ raw = f"{status_line}\r\n{headers}\r\n\r\n{body}"
1256
+ self.query_one("#intercept-resp-viewer", HttpView).load_raw_http(raw)
1257
+ except Exception:
1258
+ pass
1259
+ try:
1260
+ self.query_one("#intercept-hint", Label).update(
1261
+ f"✓ Response: {req.response.status} — {req.method} {req.url}"
1262
+ )
1263
+ except Exception:
1264
+ pass
1265
+
1266
+ def action_toggle_intercept(self) -> None:
1267
+ self.app.action_toggle_intercept() # type: ignore[attr-defined]
1268
+ self._sync_intercept_button()
1269
+ # При выключении перехвата — сбросить текущий и очередь,
1270
+ # иначе все накопленные запросы выскочат при следующем включении
1271
+ proxy = self._get_proxy()
1272
+ if proxy and not proxy.intercept_enabled:
1273
+ self._intercept_req = None
1274
+ self._intercept_pending.clear()
1275
+ self._disable_intercept_buttons(hint="(Intercept disabled)")
1276
+
1277
+ def action_toggle_proxy(self) -> None:
1278
+ proxy = self._get_proxy()
1279
+ # Мгновенный визуальный отклик — ещё до того как поток запустится/остановится
1280
+ if proxy and not proxy.is_running:
1281
+ try:
1282
+ btn = self.query_one("#btn-proxy", ToolbarButton)
1283
+ btn.label = "⏳ Starting..."
1284
+ btn.remove_class("inactive")
1285
+ btn.add_class("active")
1286
+ except Exception:
1287
+ pass
1288
+ port = proxy.port if proxy else self.app._cfg.proxy_port # type: ignore[attr-defined]
1289
+ self.app.notify( # type: ignore[attr-defined]
1290
+ f"Starting proxy on :{port}...", timeout=3
1291
+ )
1292
+ elif proxy and proxy.is_running:
1293
+ try:
1294
+ btn = self.query_one("#btn-proxy", ToolbarButton)
1295
+ btn.label = "⏳ Stopping..."
1296
+ btn.remove_class("active")
1297
+ btn.add_class("inactive")
1298
+ except Exception:
1299
+ pass
1300
+ self.app.notify("Stopping proxy...", timeout=2) # type: ignore[attr-defined]
1301
+ self.app.action_toggle_proxy() # type: ignore[attr-defined]
1302
+ self.call_after_refresh(self._sync_proxy_button)
1303
+
1304
+ def action_clear_list(self) -> None:
1305
+ proxy = self._get_proxy()
1306
+ if proxy:
1307
+ proxy.clear_requests()
1308
+ self.run_worker(self._do_clear_table())
1309
+ try:
1310
+ self.query_one("#req-editor", HttpView).clear()
1311
+ self.query_one("#resp-viewer", HttpView).clear()
1312
+ except Exception:
1313
+ pass
1314
+ self._selected_req_id = None
1315
+
1316
+ async def _do_clear_table(self) -> None:
1317
+ if self._storage_ready:
1318
+ await self._storage.clear_all()
1319
+ self._rows_cache = []
1320
+ self._current_filters = None
1321
+ try:
1322
+ table = self.query_one("#request-list", DataTable)
1323
+ table.backend = ArrowBackend(_make_empty_table())
1324
+ table.refresh()
1325
+ except Exception:
1326
+ pass
1327
+
1328
+ def action_open_scope(self) -> None:
1329
+ proxy = self._get_proxy()
1330
+ current = proxy.scope if proxy else []
1331
+ from pentool.tui.dialogs.scope_dialog import ScopeDialog
1332
+
1333
+ def _apply(result: list[str] | None) -> None:
1334
+ if result is not None and proxy is not None:
1335
+ proxy.set_scope(result)
1336
+ # Синхронизируем scope в Config и сохраняем на диск
1337
+ try:
1338
+ from pentool.core.config import get_config
1339
+ cfg = get_config()
1340
+ cfg.scope = list(result)
1341
+ cfg.save()
1342
+ except Exception as e:
1343
+ logger.warning("action_open_scope: failed to save scope to config: %s", e)
1344
+ # Обновить состояние ScopeToggle в FilterBar
1345
+ scope_toggle_was_active = False
1346
+ try:
1347
+ from pentool.tui.widgets.filter_bar import FilterBar, ScopeToggle
1348
+ filter_bar = self.query_one("#filter-bar", FilterBar)
1349
+ st = filter_bar.query_one("#fb-scope", ScopeToggle)
1350
+ scope_toggle_was_active = st.active
1351
+ st.set_scope_empty(not bool(result))
1352
+ except Exception:
1353
+ pass
1354
+ # Если ScopeToggle уже был активен — перезагрузить таблицу с новым scope
1355
+ if scope_toggle_was_active and result:
1356
+ self.run_worker(self._reload_table({"scope_only": True}))
1357
+ elif not result:
1358
+ # Scope очищен — снять фильтр и показать всё
1359
+ self.run_worker(self._reload_table(None))
1360
+ if result is not None:
1361
+ n = len(result)
1362
+ self.app.notify(
1363
+ f"Scope updated: {n} host{'s' if n != 1 else ''}",
1364
+ timeout=3,
1365
+ )
1366
+
1367
+ self.app.push_screen(ScopeDialog(current), _apply)
1368
+
1369
+ def action_open_mr(self) -> None:
1370
+ proxy = self._get_proxy()
1371
+ rules = proxy.match_replace_rules if proxy else []
1372
+ from pentool.tui.dialogs.match_replace_dialog import MatchReplaceDialog
1373
+
1374
+ def _apply(result: list[MatchReplaceRule] | None) -> None:
1375
+ if result is not None and proxy is not None:
1376
+ proxy.match_replace_rules = result
1377
+
1378
+ self.app.push_screen(MatchReplaceDialog(rules), _apply)
1379
+
1380
+ def action_open_ca_cert(self) -> None:
1381
+ from pentool.core.config import get_config
1382
+ from pentool.tui.dialogs.cert_dialog import CertInstallDialog
1383
+ ca_path = str(get_config().cert_dir) + "/ca.crt"
1384
+ self.app.push_screen(CertInstallDialog(ca_path))
1385
+
1386
+ def _sync_proxy_button(self) -> None:
1387
+ proxy = self._get_proxy()
1388
+ try:
1389
+ btn = self.query_one("#btn-proxy", ToolbarButton)
1390
+ except Exception:
1391
+ return
1392
+ if proxy and proxy.is_running:
1393
+ btn.label = f"● Proxy:{proxy.port}"
1394
+ btn.remove_class("inactive")
1395
+ btn.add_class("active")
1396
+ else:
1397
+ btn.label = "○ Proxy"
1398
+ btn.remove_class("active")
1399
+ btn.add_class("inactive")
1400
+
1401
+ def _sync_intercept_button(self) -> None:
1402
+ proxy = self._get_proxy()
1403
+ try:
1404
+ btn = self.query_one("#btn-intercept", ToolbarButton)
1405
+ except Exception:
1406
+ return
1407
+ enabled = proxy and proxy.intercept_enabled
1408
+ if enabled:
1409
+ btn.label = "● Intercept"
1410
+ btn.remove_class("inactive")
1411
+ btn.add_class("active")
1412
+ else:
1413
+ btn.label = "○ Intercept"
1414
+ btn.remove_class("active")
1415
+ btn.add_class("inactive")
1416
+
1417
+ def update_proxy_label(self, running: bool, port: int) -> None:
1418
+ self._sync_proxy_button()
1419
+
1420
+ def update_intercept_label(self, enabled: bool) -> None:
1421
+ self._sync_intercept_button()
1422
+
1423
+ # ID контейнеров, которые относятся к текстовым панелям (Request/Response)
1424
+ _TEXT_PANEL_IDS = frozenset({
1425
+ "req-panel", "resp-panel",
1426
+ "ws-req-panel", "ws-resp-panel",
1427
+ "req-editor", "resp-viewer",
1428
+ "ws-req-editor", "ws-resp-viewer",
1429
+ "intercept-editor", "intercept-sent-req", "intercept-resp-viewer",
1430
+ "intercept-req-area", "intercept-sent-panel", "intercept-resp-panel",
1431
+ # внутренние виджеты HttpView
1432
+ "http-headers", "http-body",
1433
+ })
1434
+
1435
+ def _is_in_text_panel(self, widget) -> bool:
1436
+ """Проверить: виджет или один из его предков — текстовая панель."""
1437
+ try:
1438
+ from pentool.tui.widgets.request_editor import HttpView
1439
+ node = widget
1440
+ while node is not None and node is not self:
1441
+ nid = getattr(node, "id", None) or ""
1442
+ if nid in self._TEXT_PANEL_IDS:
1443
+ return True
1444
+ # HttpView — всегда текстовая панель
1445
+ if isinstance(node, HttpView):
1446
+ return True
1447
+ node = getattr(node, "parent", None)
1448
+ except Exception:
1449
+ pass
1450
+ return False
1451
+
1452
+ def on__proxy_data_table_context_menu_request(self, event: _ProxyDataTable.ContextMenuRequest) -> None:
1453
+ """Обработка запроса контекстного меню от DataTable (Ctrl+click или правая кнопка)."""
1454
+ try:
1455
+ table = self.query_one("#request-list", DataTable)
1456
+ cursor_row = table.cursor_row
1457
+ if 0 <= cursor_row < len(self._rows_cache):
1458
+ self._selected_req_id = self._rows_cache[cursor_row].get("id")
1459
+ except Exception:
1460
+ pass
1461
+ self._open_context_menu(event.screen_x, event.screen_y)
1462
+
1463
+ def on__base_http_widget_context_menu_request(self, event) -> None:
1464
+ """Ctrl+клик / правая кнопка на любом _BaseHttpWidget → контекстное меню."""
1465
+ self.cm_open_text_menu(event.screen_x, event.screen_y)
1466
+
1467
+ def on_mouse_down(self, event) -> None:
1468
+ # Правая кнопка (button=3) или Ctrl+левая — контекстное меню
1469
+ if not ((event.button == 3) or (event.button == 1 and event.ctrl)):
1470
+ return
1471
+
1472
+ widget = event.widget
1473
+
1474
+ if self._is_in_text_panel(widget):
1475
+ # Клик в области Request / Response → текстовое меню
1476
+ self.cm_open_text_menu(event.screen_x, event.screen_y)
1477
+ event.stop()
1478
+ return
1479
+
1480
+ # Клик в DataTable (#request-list) — обрабатывается через
1481
+ # on__proxy_data_table_context_menu_request, поэтому здесь
1482
+ # открываем меню только если событие пришло НЕ из таблицы
1483
+ try:
1484
+ table = self.query_one("#request-list", DataTable)
1485
+ node = widget
1486
+ while node is not None:
1487
+ if node is table:
1488
+ # Это событие из DataTable — игнорируем (обработает ContextMenuRequest)
1489
+ return
1490
+ node = getattr(node, "parent", None)
1491
+ except Exception:
1492
+ pass
1493
+ # Клик вне text panel и вне DataTable — открываем меню таблицы (общее)
1494
+ self._open_context_menu(event.screen_x, event.screen_y)
1495
+ event.stop()
1496
+
1497
+ def on_click(self, event) -> None:
1498
+ # Двойной клик — загрузить детали в Request/Response панели
1499
+ if event.chain == 2:
1500
+ try:
1501
+ table = self.query_one("#request-list", DataTable)
1502
+ self._select_row(table.cursor_row)
1503
+ except Exception:
1504
+ pass
1505
+
1506
+ def _open_context_menu(self, x: int, y: int) -> None:
1507
+ """Контекстное меню для строки в таблице HTTP History."""
1508
+ selected_host = self._get_selected_host_sync()
1509
+ proxy = self._get_proxy()
1510
+ in_scope = proxy.is_in_scope(selected_host) if (proxy and selected_host) else False
1511
+ scope_is_empty = not bool(proxy.scope) if proxy else True
1512
+
1513
+ items = [
1514
+ ("send_repeater", "Send to Repeater"),
1515
+ ("send_intruder", "Send to Intruder"),
1516
+ ("send_scanner", "Send to Scanner"),
1517
+ ("send_target", "Send to Target"),
1518
+ ("-", ""),
1519
+ ]
1520
+ if selected_host and not in_scope:
1521
+ items.append(("add_scope", f"Add {selected_host} to Scope"))
1522
+ elif selected_host and in_scope and not scope_is_empty:
1523
+ items.append(("remove_scope", f"Remove {selected_host} from Scope"))
1524
+ else:
1525
+ items.append(("add_scope", "Add to Scope"))
1526
+ items += [
1527
+ ("-", ""),
1528
+ ("delete_req", "Delete"),
1529
+ ]
1530
+
1531
+ self.app.show_context_menu(items, x, y, callback=self._on_ctx_action)
1532
+
1533
+ # ── RequestContextMenuMixin impl ──────────────────────────────────────────
1534
+
1535
+ def _cm_get_raw_request(self) -> str:
1536
+ """Raw HTTP из текстовой панели Request."""
1537
+ try:
1538
+ from pentool.tui.widgets.request_editor import RequestEditor
1539
+ editor = self.query_one(RequestEditor)
1540
+ return editor.get_text()
1541
+ except Exception:
1542
+ return ""
1543
+
1544
+ def _get_selected_host_sync(self) -> str:
1545
+ """Синхронно получить хост выбранного запроса из кэша."""
1546
+ # Сначала ищем по cursor_row (актуальнее чем _selected_req_id)
1547
+ try:
1548
+ table = self.query_one("#request-list", DataTable)
1549
+ row_idx = table.cursor_row
1550
+ if 0 <= row_idx < len(self._rows_cache):
1551
+ host = str(self._rows_cache[row_idx].get("host", ""))
1552
+ if host:
1553
+ return host
1554
+ except Exception:
1555
+ pass
1556
+ # Запасной вариант — по _selected_req_id
1557
+ if self._selected_req_id is None or not self._rows_cache:
1558
+ return ""
1559
+ for row in self._rows_cache:
1560
+ if row.get("id") == self._selected_req_id:
1561
+ return str(row.get("host", ""))
1562
+ return ""
1563
+
1564
+ def _on_ctx_action(self, action: str) -> None:
1565
+ if action == "send_repeater":
1566
+ self.action_send_to_repeater()
1567
+ elif action == "send_intruder":
1568
+ self._send_to_intruder()
1569
+ elif action == "send_decoder":
1570
+ self.app.action_switch_module("decoder") # type: ignore[attr-defined]
1571
+ elif action == "send_scanner":
1572
+ host = self._get_selected_host_sync()
1573
+ if host:
1574
+ from pentool.tui.messages import SendHostToScanner
1575
+ self.app.post_message(SendHostToScanner(host))
1576
+ elif action == "send_target":
1577
+ self._send_to_target()
1578
+ elif action == "add_scope":
1579
+ self._scope_action_for_selected(add=True)
1580
+ elif action == "remove_scope":
1581
+ self._scope_action_for_selected(add=False)
1582
+ elif action in ("copy_curl", "copy_ffuf", "copy_sqlmap", "copy_nmap", "copy_jwt"):
1583
+ self._copy_as(action)
1584
+ elif action == "save_req_txt":
1585
+ self._save_request_txt()
1586
+ elif action == "delete_req":
1587
+ self._delete_selected_request()
1588
+
1589
+ def _scope_action_for_selected(self, add: bool) -> None:
1590
+ """Добавить/убрать хост выбранного запроса из scope."""
1591
+ if self._selected_req_id is None:
1592
+ return
1593
+ self.run_worker(self._do_scope_action(add))
1594
+
1595
+ async def _do_scope_action(self, add: bool) -> None:
1596
+ if not self._storage_ready or self._selected_req_id is None:
1597
+ return
1598
+ entry = await self._storage.get_full_entry(self._selected_req_id)
1599
+ if entry is None:
1600
+ return
1601
+ url = entry.get("url", "")
1602
+ try:
1603
+ host = urlparse(url).netloc or urlparse(url).path.split("/")[0]
1604
+ except Exception:
1605
+ host = url
1606
+ if not host:
1607
+ return
1608
+ proxy = self._get_proxy()
1609
+ if proxy is None:
1610
+ return
1611
+ scope = list(proxy.scope)
1612
+ if add:
1613
+ if host not in scope:
1614
+ scope.append(host)
1615
+ proxy.set_scope(scope)
1616
+ self.app.notify(f"★ {host} добавлен в scope", severity="information", timeout=2)
1617
+ self._sync_target_host_scope(host, True)
1618
+ else:
1619
+ self.app.notify(f"{host} уже в scope", timeout=2)
1620
+ else:
1621
+ if host in scope:
1622
+ scope.remove(host)
1623
+ proxy.set_scope(scope)
1624
+ self.app.notify(f"{host} убран из scope", severity="information", timeout=2)
1625
+ self._sync_target_host_scope(host, False)
1626
+ else:
1627
+ self.app.notify(f"{host} не в scope", timeout=2)
1628
+ # Обновить состояние кнопки ★ Scope в FilterBar
1629
+ try:
1630
+ from pentool.tui.widgets.filter_bar import FilterBar, ScopeToggle
1631
+ st = self.query_one("#filter-bar", FilterBar).query_one("#fb-scope", ScopeToggle)
1632
+ st.set_scope_empty(not bool(scope))
1633
+ except Exception:
1634
+ pass
1635
+
1636
+ def _sync_target_host_scope(self, host: str, in_scope: bool) -> None:
1637
+ """Обновить scope хоста в TargetAPI и обновить дерево."""
1638
+ self.app.post_message(SyncScopeToTarget(host, in_scope)) # type: ignore[attr-defined]
1639
+
1640
+ def _send_to_target(self) -> None:
1641
+ """Добавить выбранный запрос в Target/SiteMap."""
1642
+ if self._selected_req_id is None:
1643
+ return
1644
+ self.run_worker(self._do_send_to_target())
1645
+
1646
+ async def _do_send_to_target(self) -> None:
1647
+ if not self._storage_ready or self._selected_req_id is None:
1648
+ return
1649
+ entry = await self._storage.get_full_entry(self._selected_req_id)
1650
+ if entry is None:
1651
+ return
1652
+ from pentool.utils.parser import ParsedRequest
1653
+ parsed = ParsedRequest(
1654
+ method=entry.get("method", "GET"),
1655
+ url=entry.get("url", ""),
1656
+ headers=entry.get("request_headers") or {},
1657
+ body=entry.get("request_body", "") or "",
1658
+ )
1659
+ self.app.post_message(SendToTarget(parsed)) # type: ignore[attr-defined]
1660
+ self.app.notify("Added to Target", severity="information", timeout=2) # type: ignore[attr-defined]
1661
+
1662
+ def on_context_menu_item_selected(self, event: ContextMenu.ItemSelected) -> None:
1663
+ self._on_ctx_action(event.action)
1664
+