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,646 @@
|
|
|
1
|
+
"""Экран Repeater — ручная отправка HTTP-запросов."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.widget import Widget
|
|
13
|
+
from textual.widgets import Input, Static, TabPane, TabbedContent
|
|
14
|
+
from textual.widgets.text_area import Selection
|
|
15
|
+
|
|
16
|
+
from pentool.core.logging import get_logger
|
|
17
|
+
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
from pentool.tui.widgets.request_editor import RequestEditor, ResponseViewer
|
|
25
|
+
from pentool.tui.widgets.resize_handle import ResizeHandle
|
|
26
|
+
from pentool.tui.widgets.search_bar import SearchBar
|
|
27
|
+
from pentool.tui.mixins.app_mixin import AppMixin
|
|
28
|
+
from pentool.tui.mixins.tab_rename import TabRenameMixin
|
|
29
|
+
from pentool.tui.mixins.request_context_menu import RequestContextMenuMixin
|
|
30
|
+
|
|
31
|
+
class _TabState:
|
|
32
|
+
"""Состояние одной вкладки Repeater."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, tab_id: str, name: str) -> None:
|
|
35
|
+
self.tab_id = tab_id
|
|
36
|
+
self.name = name
|
|
37
|
+
self.request_text: str = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
|
38
|
+
self.response_text: str = ""
|
|
39
|
+
self.sending: bool = False
|
|
40
|
+
|
|
41
|
+
class RepeaterScreen(TabRenameMixin, RequestContextMenuMixin, AppMixin, Widget):
|
|
42
|
+
"""Экран модуля Repeater."""
|
|
43
|
+
|
|
44
|
+
DEFAULT_CSS = _CSS
|
|
45
|
+
|
|
46
|
+
BINDINGS = [
|
|
47
|
+
Binding("ctrl+space", "send", "Send", show=True, priority=True),
|
|
48
|
+
Binding("ctrl+f", "toggle_search", "Search", show=False, priority=True),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_sort_col_idx: int | None = None
|
|
52
|
+
_sort_reverse: bool = False
|
|
53
|
+
_HIST_COL_LABELS = ["ID", "Method", "URL", "Status", "Length", "Time"]
|
|
54
|
+
|
|
55
|
+
# TabRenameMixin config
|
|
56
|
+
_rename_input_id: str = "rename-input"
|
|
57
|
+
_rename_tab_prefix: str = "tab-"
|
|
58
|
+
_rename_tabs_widget_id: str = "repeater-tabs"
|
|
59
|
+
|
|
60
|
+
# RequestContextMenuMixin config — Repeater: без Send-to модулей (только copy)
|
|
61
|
+
_cm_show_nmap = False
|
|
62
|
+
|
|
63
|
+
def __init__(self, **kwargs) -> None:
|
|
64
|
+
super().__init__(**kwargs)
|
|
65
|
+
self._tabs: list[_TabState] = []
|
|
66
|
+
self._tab_counter: int = 0
|
|
67
|
+
self._active_tab_id: str | None = None
|
|
68
|
+
self._sending: bool = False
|
|
69
|
+
self._follow_redirects: bool = True
|
|
70
|
+
self._show_special_chars: bool = False
|
|
71
|
+
self._search_matches: list[int] = []
|
|
72
|
+
self._search_current: int = 0
|
|
73
|
+
self._search_query: str = ""
|
|
74
|
+
self._search_regex: bool = False
|
|
75
|
+
self._tab_click_time: float = 0.0
|
|
76
|
+
self._tab_click_id: str | None = None
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
with Horizontal(id="top-bar"):
|
|
80
|
+
yield ToolbarButton("⚡ Send", "btn-send")
|
|
81
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
82
|
+
yield ToolbarButton("✖ Cancel", "btn-cancel", classes="disabled")
|
|
83
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
84
|
+
yield ToolbarButton("↪ Follow: ON", "btn-follow", classes="active")
|
|
85
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
86
|
+
yield ToolbarButton("Scope", "btn-scope")
|
|
87
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
88
|
+
yield ToolbarButton("➕ New Tab", "btn-new-tab")
|
|
89
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
90
|
+
yield ToolbarButton("➖ Close Tab", "btn-close-tab")
|
|
91
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
92
|
+
yield ToolbarButton("↩ Load from Proxy", "btn-load-proxy")
|
|
93
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
94
|
+
yield ToolbarButton("⏎ Special: OFF", "btn-special-chars")
|
|
95
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
96
|
+
yield ToolbarButton("→ Decoder", "btn-send-decoder")
|
|
97
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
98
|
+
yield ToolbarButton("→ Comparer", "btn-send-comparer")
|
|
99
|
+
yield Static(" │ ", classes="toolbar-sep")
|
|
100
|
+
yield ToolbarButton("→ Intruder", "btn-send-intruder")
|
|
101
|
+
|
|
102
|
+
with Vertical(id="tabs-area"):
|
|
103
|
+
yield TabbedContent(id="repeater-tabs")
|
|
104
|
+
|
|
105
|
+
yield SearchBar(id="repeater-search-bar")
|
|
106
|
+
yield Static("Ctrl+Space: Send │ Ctrl+F: Search │ Double-click tab to rename", id="status-bar")
|
|
107
|
+
|
|
108
|
+
def on_mount(self) -> None:
|
|
109
|
+
self.action_new_tab()
|
|
110
|
+
|
|
111
|
+
def action_new_tab(self) -> None:
|
|
112
|
+
self._tab_counter += 1
|
|
113
|
+
tab_id = f"tab-{self._tab_counter}"
|
|
114
|
+
name = f"Tab {self._tab_counter}"
|
|
115
|
+
state = _TabState(tab_id, name)
|
|
116
|
+
self._tabs.append(state)
|
|
117
|
+
|
|
118
|
+
tabs = self.query_one("#repeater-tabs", TabbedContent)
|
|
119
|
+
pane = TabPane(name, id=tab_id)
|
|
120
|
+
tabs.add_pane(pane)
|
|
121
|
+
tabs.active = tab_id
|
|
122
|
+
self._active_tab_id = tab_id
|
|
123
|
+
self.call_after_refresh(self._mount_tab_widgets, tab_id, state)
|
|
124
|
+
|
|
125
|
+
def _mount_tab_widgets(self, tab_id: str, state: _TabState) -> None:
|
|
126
|
+
try:
|
|
127
|
+
pane = self.query_one(f"#{tab_id}", TabPane)
|
|
128
|
+
body = Vertical(classes="tab-body")
|
|
129
|
+
pane.mount(body)
|
|
130
|
+
self.call_after_refresh(self._fill_tab_body, tab_id, state, body)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def _fill_tab_body(self, tab_id: str, state: _TabState, body: Vertical) -> None:
|
|
135
|
+
try:
|
|
136
|
+
row = Horizontal(classes="req-resp-row", id=f"req-resp-row-{tab_id}")
|
|
137
|
+
log_area = Static("", classes="log-area", id=f"log-area-{tab_id}")
|
|
138
|
+
body.mount(row)
|
|
139
|
+
body.mount(ResizeHandle(
|
|
140
|
+
f"req-resp-row-{tab_id}", f"log-area-{tab_id}",
|
|
141
|
+
vertical=True,
|
|
142
|
+
id=f"resize-bottom-{tab_id}",
|
|
143
|
+
))
|
|
144
|
+
body.mount(log_area)
|
|
145
|
+
self.call_after_refresh(self._fill_req_resp, tab_id, state, row)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def _fill_req_resp(self, tab_id: str, state: _TabState, row: Horizontal) -> None:
|
|
150
|
+
try:
|
|
151
|
+
req_panel = Vertical(classes="req-panel", id=f"req-panel-{tab_id}")
|
|
152
|
+
resp_panel = Vertical(classes="resp-panel", id=f"resp-panel-{tab_id}")
|
|
153
|
+
row.mount(req_panel)
|
|
154
|
+
row.mount(ResizeHandle(
|
|
155
|
+
f"req-panel-{tab_id}", f"resp-panel-{tab_id}",
|
|
156
|
+
id=f"resize-{tab_id}",
|
|
157
|
+
))
|
|
158
|
+
row.mount(resp_panel)
|
|
159
|
+
self.call_after_refresh(self._fill_panels, tab_id, state, req_panel, resp_panel)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
def _fill_panels(
|
|
164
|
+
self,
|
|
165
|
+
tab_id: str,
|
|
166
|
+
state: _TabState,
|
|
167
|
+
req_panel: Vertical,
|
|
168
|
+
resp_panel: Vertical,
|
|
169
|
+
) -> None:
|
|
170
|
+
try:
|
|
171
|
+
editor = RequestEditor(label="Request Editor", id=f"req-editor-{tab_id}")
|
|
172
|
+
req_panel.mount(editor)
|
|
173
|
+
|
|
174
|
+
viewer = ResponseViewer(id=f"resp-viewer-{tab_id}")
|
|
175
|
+
resp_panel.mount(viewer)
|
|
176
|
+
|
|
177
|
+
self.call_after_refresh(self._init_tab_content, tab_id, state)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def _init_tab_content(self, tab_id: str, state: _TabState) -> None:
|
|
182
|
+
try:
|
|
183
|
+
editor = self.query_one(f"#req-editor-{tab_id}", RequestEditor)
|
|
184
|
+
editor.load_raw(state.request_text)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def action_close_tab(self) -> None:
|
|
189
|
+
if len(self._tabs) <= 1:
|
|
190
|
+
return
|
|
191
|
+
tab_id = self._active_tab_id
|
|
192
|
+
if tab_id is None:
|
|
193
|
+
return
|
|
194
|
+
tabs = self.query_one("#repeater-tabs", TabbedContent)
|
|
195
|
+
tabs.remove_pane(tab_id)
|
|
196
|
+
self._tabs = [t for t in self._tabs if t.tab_id != tab_id]
|
|
197
|
+
if self._tabs:
|
|
198
|
+
self._active_tab_id = self._tabs[-1].tab_id
|
|
199
|
+
tabs.active = self._active_tab_id
|
|
200
|
+
|
|
201
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
202
|
+
if event.tabbed_content.id != "repeater-tabs":
|
|
203
|
+
return
|
|
204
|
+
pane = event.pane
|
|
205
|
+
if pane is None:
|
|
206
|
+
return
|
|
207
|
+
self._save_current_tab_state()
|
|
208
|
+
self._active_tab_id = pane.id
|
|
209
|
+
|
|
210
|
+
def _start_rename(self, tab_id: str) -> None:
|
|
211
|
+
"""Показать Input для переименования вкладки."""
|
|
212
|
+
state = self._get_tab_state(tab_id)
|
|
213
|
+
if state is None:
|
|
214
|
+
return
|
|
215
|
+
try:
|
|
216
|
+
try:
|
|
217
|
+
inp = self.query_one("#rename-input", Input)
|
|
218
|
+
except Exception:
|
|
219
|
+
inp = Input(id="rename-input", placeholder="Tab name...", compact=True)
|
|
220
|
+
self.query_one("#tabs-area").mount(inp)
|
|
221
|
+
inp.value = state.name
|
|
222
|
+
inp.display = True
|
|
223
|
+
inp.focus()
|
|
224
|
+
inp.action_select_all()
|
|
225
|
+
inp._rename_tab_id = tab_id # type: ignore[attr-defined]
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def _rename_tab(self, tab_id: str, new_name: str) -> None:
|
|
230
|
+
state = self._get_tab_state(tab_id)
|
|
231
|
+
if state is None:
|
|
232
|
+
return
|
|
233
|
+
state.name = new_name
|
|
234
|
+
try:
|
|
235
|
+
tabs = self.query_one("#repeater-tabs", TabbedContent)
|
|
236
|
+
tab_widget = tabs.get_tab(tab_id)
|
|
237
|
+
if tab_widget is not None:
|
|
238
|
+
tab_widget.label = new_name
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def action_toggle_search(self) -> None:
|
|
243
|
+
try:
|
|
244
|
+
bar = self.query_one("#repeater-search-bar", SearchBar)
|
|
245
|
+
if bar.display:
|
|
246
|
+
bar.hide()
|
|
247
|
+
else:
|
|
248
|
+
bar.show()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
def on_search_bar_search(self, event: SearchBar.Search) -> None:
|
|
253
|
+
"""Пользователь ввёл запрос в SearchBar."""
|
|
254
|
+
self._search_query = event.query
|
|
255
|
+
self._search_regex = event.regex
|
|
256
|
+
self._run_search(event.direction)
|
|
257
|
+
|
|
258
|
+
def on_search_bar_closed(self, event: SearchBar.Closed) -> None:
|
|
259
|
+
self._search_matches = []
|
|
260
|
+
self._search_current = 0
|
|
261
|
+
|
|
262
|
+
def _run_search(self, direction: int = 1) -> None:
|
|
263
|
+
"""Найти совпадения в активном TextArea и перейти к следующему."""
|
|
264
|
+
text = self._get_active_text()
|
|
265
|
+
if not text or not self._search_query:
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
if self._search_regex:
|
|
269
|
+
matches = [m.start() for m in re.finditer(self._search_query, text, re.IGNORECASE)]
|
|
270
|
+
else:
|
|
271
|
+
q = self._search_query.lower()
|
|
272
|
+
t = text.lower()
|
|
273
|
+
matches = []
|
|
274
|
+
start = 0
|
|
275
|
+
while True:
|
|
276
|
+
idx = t.find(q, start)
|
|
277
|
+
if idx == -1:
|
|
278
|
+
break
|
|
279
|
+
matches.append(idx)
|
|
280
|
+
start = idx + 1
|
|
281
|
+
except Exception:
|
|
282
|
+
matches = []
|
|
283
|
+
|
|
284
|
+
self._search_matches = matches
|
|
285
|
+
if not matches:
|
|
286
|
+
try:
|
|
287
|
+
self.query_one("#repeater-search-bar", SearchBar).set_count(0, 0)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if direction == 1:
|
|
293
|
+
self._search_current = (self._search_current + 1) % len(matches)
|
|
294
|
+
else:
|
|
295
|
+
self._search_current = (self._search_current - 1) % len(matches)
|
|
296
|
+
|
|
297
|
+
self._jump_to_match(text, matches[self._search_current])
|
|
298
|
+
try:
|
|
299
|
+
self.query_one("#repeater-search-bar", SearchBar).set_count(
|
|
300
|
+
self._search_current + 1, len(matches)
|
|
301
|
+
)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
def _get_active_text(self) -> str:
|
|
306
|
+
"""Получить текст из активного RequestEditor."""
|
|
307
|
+
if self._active_tab_id is None:
|
|
308
|
+
return ""
|
|
309
|
+
try:
|
|
310
|
+
editor = self.query_one(f"#req-editor-{self._active_tab_id}", RequestEditor)
|
|
311
|
+
return editor.get_text()
|
|
312
|
+
except Exception:
|
|
313
|
+
return ""
|
|
314
|
+
|
|
315
|
+
def _jump_to_match(self, text: str, offset: int) -> None:
|
|
316
|
+
"""Переместить курсор TextArea к найденному совпадению."""
|
|
317
|
+
if self._active_tab_id is None:
|
|
318
|
+
return
|
|
319
|
+
try:
|
|
320
|
+
from textual.widgets import TextArea
|
|
321
|
+
editor = self.query_one(f"#req-editor-{self._active_tab_id}", RequestEditor)
|
|
322
|
+
area = editor.query_one("#editor-area", TextArea)
|
|
323
|
+
lines = text[:offset].split("\n")
|
|
324
|
+
row = len(lines) - 1
|
|
325
|
+
col = len(lines[-1])
|
|
326
|
+
q_len = len(self._search_query)
|
|
327
|
+
end_lines = text[:offset + q_len].split("\n")
|
|
328
|
+
end_row = len(end_lines) - 1
|
|
329
|
+
end_col = len(end_lines[-1])
|
|
330
|
+
area.move_cursor((row, col), center=True)
|
|
331
|
+
area.selection = Selection((row, col), (end_row, end_col))
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
def _save_current_tab_state(self) -> None:
|
|
336
|
+
if self._active_tab_id is None:
|
|
337
|
+
return
|
|
338
|
+
state = self._get_tab_state(self._active_tab_id)
|
|
339
|
+
if state is None:
|
|
340
|
+
return
|
|
341
|
+
try:
|
|
342
|
+
editor = self.query_one(f"#req-editor-{self._active_tab_id}", RequestEditor)
|
|
343
|
+
state.request_text = editor.get_text()
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
def _get_tab_state(self, tab_id: str) -> _TabState | None:
|
|
348
|
+
for t in self._tabs:
|
|
349
|
+
if t.tab_id == tab_id:
|
|
350
|
+
return t
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def action_send(self) -> None:
|
|
354
|
+
if self._sending:
|
|
355
|
+
return
|
|
356
|
+
tab_id = self._active_tab_id
|
|
357
|
+
if tab_id is None:
|
|
358
|
+
return
|
|
359
|
+
try:
|
|
360
|
+
editor = self.query_one(f"#req-editor-{tab_id}", RequestEditor)
|
|
361
|
+
raw = editor.get_text()
|
|
362
|
+
except Exception:
|
|
363
|
+
return
|
|
364
|
+
if not raw.strip():
|
|
365
|
+
return
|
|
366
|
+
self._sending = True
|
|
367
|
+
self._set_status("Sending...")
|
|
368
|
+
try:
|
|
369
|
+
self.query_one("#btn-cancel", ToolbarButton).disabled = False
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
self.run_worker(self._do_send(tab_id, raw), exclusive=True)
|
|
373
|
+
|
|
374
|
+
async def _do_send(self, tab_id: str, raw: str) -> None:
|
|
375
|
+
from pentool.utils.parser import parse_http_request
|
|
376
|
+
from pentool.api.repeater_api import RepeaterAPI
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
req = parse_http_request(raw)
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
logger.error("REPEATER: parse error: %s", exc)
|
|
382
|
+
self._set_status(f"[red]Parse error: {exc}[/red]")
|
|
383
|
+
self._sending = False
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
logger.info("REPEATER: send %s %s (follow_redirects=%s)", req.method, req.url, self._follow_redirects)
|
|
387
|
+
db_path = self._get_db_path()
|
|
388
|
+
repeater = RepeaterAPI(db_path=db_path) if db_path else None
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
follow = self._follow_redirects
|
|
392
|
+
except Exception:
|
|
393
|
+
follow = True
|
|
394
|
+
|
|
395
|
+
t0 = time.monotonic()
|
|
396
|
+
try:
|
|
397
|
+
if repeater:
|
|
398
|
+
state = self._get_tab_state(tab_id)
|
|
399
|
+
tab_name = state.name if state else "Tab"
|
|
400
|
+
resp = await repeater.send(req, tab_name=tab_name)
|
|
401
|
+
else:
|
|
402
|
+
from pentool.utils.http_client import HTTPClient
|
|
403
|
+
async with HTTPClient() as client:
|
|
404
|
+
resp = await client.send(req)
|
|
405
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
406
|
+
logger.info("REPEATER: response %d (%d bytes, %dms)", resp.status, len(resp.body) if resp.body else 0, elapsed_ms)
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
logger.error("REPEATER: request failed: %s", exc)
|
|
409
|
+
self._set_status(f"[red]Error: {exc}[/red]")
|
|
410
|
+
self._sending = False
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
viewer = self.query_one(f"#resp-viewer-{tab_id}", ResponseViewer)
|
|
415
|
+
viewer.load_response(resp)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
self._set_status(
|
|
420
|
+
f"[green]HTTP {resp.status}[/green] — {len(resp.body)} bytes — {elapsed_ms}ms"
|
|
421
|
+
)
|
|
422
|
+
self._sending = False
|
|
423
|
+
try:
|
|
424
|
+
self.query_one("#btn-cancel", ToolbarButton).disabled = True
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
self.app.notify(f"HTTP {resp.status} — {elapsed_ms}ms", timeout=2)
|
|
428
|
+
|
|
429
|
+
def action_load_from_proxy(self) -> None:
|
|
430
|
+
from pentool.tui.dialogs.load_from_proxy import LoadFromProxyDialog
|
|
431
|
+
|
|
432
|
+
def _on_selected(raw: str | None) -> None:
|
|
433
|
+
if raw and self._active_tab_id:
|
|
434
|
+
try:
|
|
435
|
+
editor = self.query_one(
|
|
436
|
+
f"#req-editor-{self._active_tab_id}", RequestEditor
|
|
437
|
+
)
|
|
438
|
+
editor.load_raw(raw)
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
proxy = self._get_proxy()
|
|
443
|
+
requests = proxy.get_requests(limit=100) if proxy else []
|
|
444
|
+
self.app.push_screen(LoadFromProxyDialog(requests), _on_selected)
|
|
445
|
+
|
|
446
|
+
def action_clear(self) -> None:
|
|
447
|
+
tab_id = self._active_tab_id
|
|
448
|
+
if tab_id is None:
|
|
449
|
+
return
|
|
450
|
+
try:
|
|
451
|
+
self.query_one(f"#req-editor-{tab_id}", RequestEditor).clear()
|
|
452
|
+
self.query_one(f"#resp-viewer-{tab_id}", ResponseViewer).clear()
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
self._set_status("Cleared")
|
|
456
|
+
|
|
457
|
+
@on(ToolbarButton.Pressed, "#btn-send")
|
|
458
|
+
def on_btn_send(self, _: ToolbarButton.Pressed) -> None:
|
|
459
|
+
self.action_send()
|
|
460
|
+
|
|
461
|
+
@on(ToolbarButton.Pressed, "#btn-follow")
|
|
462
|
+
def on_btn_follow(self, event: ToolbarButton.Pressed) -> None:
|
|
463
|
+
self._follow_redirects = not self._follow_redirects
|
|
464
|
+
btn = event.button
|
|
465
|
+
if self._follow_redirects:
|
|
466
|
+
btn.update("↪ Follow: ON")
|
|
467
|
+
btn.add_class("active")
|
|
468
|
+
else:
|
|
469
|
+
btn.update("↪ Follow: OFF")
|
|
470
|
+
btn.remove_class("active")
|
|
471
|
+
|
|
472
|
+
@on(ToolbarButton.Pressed, "#btn-new-tab")
|
|
473
|
+
def on_btn_new_tab(self, _: ToolbarButton.Pressed) -> None:
|
|
474
|
+
self.action_new_tab()
|
|
475
|
+
|
|
476
|
+
@on(ToolbarButton.Pressed, "#btn-close-tab")
|
|
477
|
+
def on_btn_close_tab(self, _: ToolbarButton.Pressed) -> None:
|
|
478
|
+
self.action_close_tab()
|
|
479
|
+
|
|
480
|
+
@on(ToolbarButton.Pressed, "#btn-load-proxy")
|
|
481
|
+
def on_btn_load_proxy(self, _: ToolbarButton.Pressed) -> None:
|
|
482
|
+
self.action_load_from_proxy()
|
|
483
|
+
|
|
484
|
+
@on(ToolbarButton.Pressed, "#btn-special-chars")
|
|
485
|
+
def on_btn_special_chars(self, event: ToolbarButton.Pressed) -> None:
|
|
486
|
+
self._toggle_special_chars(event.button)
|
|
487
|
+
|
|
488
|
+
@on(ToolbarButton.Pressed, "#btn-send-decoder")
|
|
489
|
+
def on_btn_send_decoder(self, _: ToolbarButton.Pressed) -> None:
|
|
490
|
+
self._send_to_decoder(self._get_active_text())
|
|
491
|
+
|
|
492
|
+
@on(ToolbarButton.Pressed, "#btn-send-comparer")
|
|
493
|
+
def on_btn_send_comparer(self, _: ToolbarButton.Pressed) -> None:
|
|
494
|
+
self._send_to_comparer(self._get_active_text(), label="Repeater Request")
|
|
495
|
+
|
|
496
|
+
@on(ToolbarButton.Pressed, "#btn-send-intruder")
|
|
497
|
+
def on_btn_send_intruder(self, _: ToolbarButton.Pressed) -> None:
|
|
498
|
+
self._send_to_intruder()
|
|
499
|
+
|
|
500
|
+
@on(ToolbarButton.Pressed, "#btn-scope")
|
|
501
|
+
def on_btn_scope(self, _: ToolbarButton.Pressed) -> None:
|
|
502
|
+
"""Открыть диалог управления scope (общий с Proxy)."""
|
|
503
|
+
try:
|
|
504
|
+
proxy = self._get_proxy()
|
|
505
|
+
current = list(proxy.scope) if proxy else []
|
|
506
|
+
from pentool.tui.dialogs.scope_dialog import ScopeDialog
|
|
507
|
+
|
|
508
|
+
def _on_scope_result(result: list[str] | None) -> None:
|
|
509
|
+
if result is None:
|
|
510
|
+
return
|
|
511
|
+
if proxy:
|
|
512
|
+
proxy.set_scope(result)
|
|
513
|
+
try:
|
|
514
|
+
from pentool.tui.app import PentoolApp
|
|
515
|
+
cfg = getattr(self.app, "_config", None)
|
|
516
|
+
if cfg is not None:
|
|
517
|
+
cfg.scope = list(result)
|
|
518
|
+
cfg.save()
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
self.app.notify(
|
|
522
|
+
f"Scope updated: {len(result)} rule(s)", timeout=2
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
self.app.push_screen(ScopeDialog(current_scope=current), _on_scope_result)
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
self.app.notify(f"Scope error: {exc}", severity="error")
|
|
528
|
+
|
|
529
|
+
def _send_to_intruder(self) -> None:
|
|
530
|
+
"""Отправить текст запроса в Intruder."""
|
|
531
|
+
try:
|
|
532
|
+
from pentool.tui.messages import SendToIntruder
|
|
533
|
+
text = self._get_active_text()
|
|
534
|
+
if not text:
|
|
535
|
+
self.app.notify("No request text to send", severity="warning")
|
|
536
|
+
return
|
|
537
|
+
self.app.post_message(SendToIntruder(text))
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
logger.debug("_send_to_intruder: %s", exc)
|
|
540
|
+
self.app.notify(f"Could not send to Intruder: {exc}", severity="error")
|
|
541
|
+
|
|
542
|
+
def on_key(self, event) -> None:
|
|
543
|
+
if event.key == "ctrl+j":
|
|
544
|
+
self.action_send()
|
|
545
|
+
event.prevent_default()
|
|
546
|
+
|
|
547
|
+
def load_request(self, raw: str) -> None:
|
|
548
|
+
"""Загрузить сырой запрос в текущую вкладку (вызывается снаружи)."""
|
|
549
|
+
if self._active_tab_id is None:
|
|
550
|
+
return
|
|
551
|
+
try:
|
|
552
|
+
editor = self.query_one(f"#req-editor-{self._active_tab_id}", RequestEditor)
|
|
553
|
+
editor.load_raw(raw)
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
def _toggle_special_chars(self, btn: ToolbarButton) -> None:
|
|
558
|
+
"""Включить/выключить отображение спецсимволов \r\n в редакторе."""
|
|
559
|
+
# Синхронизируем _raw_body из текущего состояния TextArea перед переключением
|
|
560
|
+
self._sync_raw_body_from_editor()
|
|
561
|
+
self._show_special_chars = not self._show_special_chars
|
|
562
|
+
if self._show_special_chars:
|
|
563
|
+
btn.update("⏎ Special: ON")
|
|
564
|
+
btn.add_class("active")
|
|
565
|
+
else:
|
|
566
|
+
btn.update("⏎ Special: OFF")
|
|
567
|
+
btn.remove_class("active")
|
|
568
|
+
self._apply_special_chars_to_active_tab()
|
|
569
|
+
|
|
570
|
+
def _sync_raw_body_from_editor(self) -> None:
|
|
571
|
+
"""Зафиксировать текущий текст как _raw_full перед переключением режима."""
|
|
572
|
+
tab_id = self._active_tab_id
|
|
573
|
+
if tab_id is None:
|
|
574
|
+
return
|
|
575
|
+
try:
|
|
576
|
+
editor = self.query_one(f"#req-editor-{tab_id}", RequestEditor)
|
|
577
|
+
editor._raw_full = editor.get_text()
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
def _apply_special_chars_to_active_tab(self) -> None:
|
|
582
|
+
"""Обновить отображение спецсимволов в активной вкладке."""
|
|
583
|
+
tab_id = self._active_tab_id
|
|
584
|
+
if tab_id is None:
|
|
585
|
+
return
|
|
586
|
+
try:
|
|
587
|
+
from textual.widgets import TextArea
|
|
588
|
+
editor = self.query_one(f"#req-editor-{tab_id}", RequestEditor)
|
|
589
|
+
area = editor.query_one("#editor-area", TextArea)
|
|
590
|
+
if self._show_special_chars:
|
|
591
|
+
raw = editor._raw_full or editor.get_text()
|
|
592
|
+
displayed = self._visualize_special_chars(raw)
|
|
593
|
+
editor._special_chars_mode = True
|
|
594
|
+
area.load_text(displayed)
|
|
595
|
+
else:
|
|
596
|
+
decoded = RequestEditor._decode_special_chars(area.text)
|
|
597
|
+
editor._raw_full = decoded
|
|
598
|
+
editor._special_chars_mode = False
|
|
599
|
+
editor.load_raw(decoded)
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def _visualize_special_chars(text: str) -> str:
|
|
605
|
+
"""Заменить \r и \n на литеральные строки \\r\\n, сохраняя реальный \n для переноса строки."""
|
|
606
|
+
result = []
|
|
607
|
+
i = 0
|
|
608
|
+
while i < len(text):
|
|
609
|
+
ch = text[i]
|
|
610
|
+
if ch == '\r' and i + 1 < len(text) and text[i + 1] == '\n':
|
|
611
|
+
result.append("\\r\\n\n")
|
|
612
|
+
i += 2
|
|
613
|
+
elif ch == '\r':
|
|
614
|
+
result.append("\\r\n")
|
|
615
|
+
i += 1
|
|
616
|
+
elif ch == '\n':
|
|
617
|
+
result.append("\\n\n")
|
|
618
|
+
i += 1
|
|
619
|
+
else:
|
|
620
|
+
result.append(ch)
|
|
621
|
+
i += 1
|
|
622
|
+
return "".join(result)
|
|
623
|
+
|
|
624
|
+
def _set_status(self, msg: str) -> None:
|
|
625
|
+
try:
|
|
626
|
+
bar = self.query_one("#status-bar", Static)
|
|
627
|
+
bar.update(msg)
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
# ── context menu ───────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
def on__base_http_widget_context_menu_request(self, event) -> None:
|
|
634
|
+
"""Ctrl+клик / правая кнопка на любом _BaseHttpWidget → контекстное меню."""
|
|
635
|
+
self.cm_open_text_menu(event.screen_x, event.screen_y)
|
|
636
|
+
|
|
637
|
+
def _cm_get_raw_request(self) -> str:
|
|
638
|
+
"""Raw HTTP из активного RequestEditor."""
|
|
639
|
+
tab_id = self._active_tab_id
|
|
640
|
+
if not tab_id:
|
|
641
|
+
return ""
|
|
642
|
+
try:
|
|
643
|
+
from pentool.tui.widgets.request_editor import RequestEditor
|
|
644
|
+
return self.query_one(f"#req-editor-{tab_id}", RequestEditor).get_text()
|
|
645
|
+
except Exception:
|
|
646
|
+
return ""
|