pentool 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ from pentool.tui.screens.repeater.screen import RepeaterScreen
2
+
3
+ __all__ = ["RepeaterScreen"]
@@ -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 ""
@@ -0,0 +1,3 @@
1
+ from pentool.tui.screens.scanner.screen import ScannerScreen
2
+
3
+ __all__ = ["ScannerScreen"]