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