pentool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +16 -0
  3. pentool/api/__init__.py +41 -0
  4. pentool/api/base_api.py +48 -0
  5. pentool/api/comparer_api.py +19 -0
  6. pentool/api/decoder_api.py +24 -0
  7. pentool/api/intruder_api.py +171 -0
  8. pentool/api/proxy_api.py +210 -0
  9. pentool/api/repeater_api.py +57 -0
  10. pentool/api/scanner_api.py +266 -0
  11. pentool/api/sequencer_api.py +17 -0
  12. pentool/api/spider_api.py +106 -0
  13. pentool/api/target_api.py +87 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +98 -0
  16. pentool/cli/project.py +88 -0
  17. pentool/cli/proxy.py +173 -0
  18. pentool/cli/scan.py +115 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +171 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +207 -0
  24. pentool/core/events.py +160 -0
  25. pentool/core/features.py +373 -0
  26. pentool/core/license.py +274 -0
  27. pentool/core/logging.py +54 -0
  28. pentool/core/plugin_manager.py +312 -0
  29. pentool/core/project.py +31 -0
  30. pentool/core/storage_interface.py +307 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +36 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +428 -0
  37. pentool/modules/intruder_turbo.py +176 -0
  38. pentool/modules/match_replace.py +142 -0
  39. pentool/modules/proxy.py +797 -0
  40. pentool/modules/repeater.py +195 -0
  41. pentool/modules/sequencer.py +492 -0
  42. pentool/modules/spider.py +679 -0
  43. pentool/modules/target.py +185 -0
  44. pentool/modules/websocket_handler.py +249 -0
  45. pentool/plugins/__init__.py +1 -0
  46. pentool/plugins/builtin/__init__.py +1 -0
  47. pentool/plugins/builtin/payloads_pro.py +381 -0
  48. pentool/plugins/builtin/reports_pro.py +436 -0
  49. pentool/plugins/builtin/scanner_pro.py +291 -0
  50. pentool/plugins/example_plugin.py +39 -0
  51. pentool/services/__init__.py +1 -0
  52. pentool/services/base_service.py +60 -0
  53. pentool/services/intruder_service.py +94 -0
  54. pentool/services/proxy_service.py +178 -0
  55. pentool/services/repeater_service.py +85 -0
  56. pentool/services/scan_service.py +330 -0
  57. pentool/storage/__init__.py +0 -0
  58. pentool/storage/http_storage.py +506 -0
  59. pentool/storage/large_body_handler.py +50 -0
  60. pentool/storage/lru_cache.py +45 -0
  61. pentool/t.py +213 -0
  62. pentool/tui/__init__.py +1 -0
  63. pentool/tui/app.py +1429 -0
  64. pentool/tui/constants.py +79 -0
  65. pentool/tui/dialogs/__init__.py +0 -0
  66. pentool/tui/dialogs/cert_dialog.py +81 -0
  67. pentool/tui/dialogs/file_selector.py +226 -0
  68. pentool/tui/dialogs/load_from_proxy.py +125 -0
  69. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  70. pentool/tui/dialogs/project_dialog.py +82 -0
  71. pentool/tui/dialogs/scope_dialog.py +224 -0
  72. pentool/tui/messages.py +103 -0
  73. pentool/tui/mixins/__init__.py +0 -0
  74. pentool/tui/mixins/app_mixin.py +59 -0
  75. pentool/tui/mixins/dialog_cancel.py +21 -0
  76. pentool/tui/mixins/request_context_menu.py +247 -0
  77. pentool/tui/mixins/tab_rename.py +89 -0
  78. pentool/tui/screens/__init__.py +31 -0
  79. pentool/tui/screens/comparer/__init__.py +3 -0
  80. pentool/tui/screens/comparer/screen.py +229 -0
  81. pentool/tui/screens/dashboard/__init__.py +2 -0
  82. pentool/tui/screens/dashboard/live_dashboard.py +730 -0
  83. pentool/tui/screens/dashboard/screen.py +771 -0
  84. pentool/tui/screens/decoder/__init__.py +3 -0
  85. pentool/tui/screens/decoder/screen.py +306 -0
  86. pentool/tui/screens/extensions/__init__.py +3 -0
  87. pentool/tui/screens/extensions/screen.py +24 -0
  88. pentool/tui/screens/intruder/__init__.py +3 -0
  89. pentool/tui/screens/intruder/screen.py +1356 -0
  90. pentool/tui/screens/proxy/__init__.py +3 -0
  91. pentool/tui/screens/proxy/screen.py +1554 -0
  92. pentool/tui/screens/repeater/__init__.py +3 -0
  93. pentool/tui/screens/repeater/screen.py +621 -0
  94. pentool/tui/screens/scanner/__init__.py +3 -0
  95. pentool/tui/screens/scanner/screen.py +1956 -0
  96. pentool/tui/screens/sequencer/__init__.py +3 -0
  97. pentool/tui/screens/sequencer/screen.py +516 -0
  98. pentool/tui/screens/settings/__init__.py +5 -0
  99. pentool/tui/screens/settings/hotkeys.py +134 -0
  100. pentool/tui/screens/settings/screen.py +540 -0
  101. pentool/tui/screens/spider/__init__.py +3 -0
  102. pentool/tui/screens/spider/screen.py +380 -0
  103. pentool/tui/screens/target/__init__.py +4 -0
  104. pentool/tui/screens/target/screen.py +358 -0
  105. pentool/tui/screens/terminal/__init__.py +5 -0
  106. pentool/tui/screens/terminal/screen.py +179 -0
  107. pentool/tui/widgets/__init__.py +1 -0
  108. pentool/tui/widgets/context_menu.py +148 -0
  109. pentool/tui/widgets/filter_bar.py +204 -0
  110. pentool/tui/widgets/inspector_panel.py +111 -0
  111. pentool/tui/widgets/menu.py +86 -0
  112. pentool/tui/widgets/menu_bar.py +238 -0
  113. pentool/tui/widgets/module_tabs.py +68 -0
  114. pentool/tui/widgets/payload_drop_zone.py +65 -0
  115. pentool/tui/widgets/request_editor.py +495 -0
  116. pentool/tui/widgets/resize_handle.py +116 -0
  117. pentool/tui/widgets/search_bar.py +112 -0
  118. pentool/tui/widgets/statusbar.py +96 -0
  119. pentool/tui/widgets/toolbar_button.py +68 -0
  120. pentool/utils/__init__.py +1 -0
  121. pentool/utils/cert.py +314 -0
  122. pentool/utils/coder.py +170 -0
  123. pentool/utils/copy_as.py +250 -0
  124. pentool/utils/diff.py +82 -0
  125. pentool/utils/http_client.py +145 -0
  126. pentool/utils/parser.py +227 -0
  127. pentool/utils/terminal_check.py +31 -0
  128. pentool-0.1.0.dist-info/METADATA +231 -0
  129. pentool-0.1.0.dist-info/RECORD +133 -0
  130. pentool-0.1.0.dist-info/WHEEL +5 -0
  131. pentool-0.1.0.dist-info/entry_points.txt +2 -0
  132. pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
  133. pentool-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,250 @@
1
+ """Утилиты для генерации команд внешних инструментов из HTTP-запроса."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ from pentool.utils.parser import ParsedRequest
12
+
13
+
14
+ def copy_as_curl(req: ParsedRequest) -> str:
15
+ """Генерирует curl команду с флагами -X, -H, -d, --proxy."""
16
+ parts = ["curl", "-i", "-s", "-k"]
17
+
18
+ if req.method != "GET":
19
+ parts += ["-X", req.method]
20
+
21
+ for name, value in req.headers.items():
22
+ if name.lower() in ("content-length", "connection", "transfer-encoding"):
23
+ continue
24
+ parts += ["-H", f"{name}: {value}"]
25
+
26
+ if req.body:
27
+ body_str = req.body if isinstance(req.body, str) else req.body.decode("utf-8", errors="replace")
28
+ parts += ["--data-raw", body_str]
29
+
30
+ parts.append(req.url)
31
+ return " ".join(shlex.quote(p) for p in parts)
32
+
33
+
34
+ def copy_as_ffuf(req: ParsedRequest) -> str:
35
+ """Генерирует ffuf команду для фаззинга URL."""
36
+ url = req.url
37
+ # Если FUZZ ещё нет в URL — добавляем к последнему сегменту
38
+ if "FUZZ" not in url:
39
+ url = url.rstrip("/") + "/FUZZ"
40
+
41
+ parts = ["ffuf", "-u", url, "-w", "wordlist.txt:FUZZ"]
42
+
43
+ for name, value in req.headers.items():
44
+ if name.lower() in ("content-length", "connection"):
45
+ continue
46
+ parts += ["-H", f"{name}: {value}"]
47
+
48
+ if req.method != "GET":
49
+ parts += ["-X", req.method]
50
+
51
+ if req.body:
52
+ body_str = req.body if isinstance(req.body, str) else req.body.decode("utf-8", errors="replace")
53
+ parts += ["-d", body_str]
54
+
55
+ return " ".join(shlex.quote(p) for p in parts)
56
+
57
+
58
+ def copy_as_sqlmap(req: ParsedRequest, request_file: str = "request.txt") -> str:
59
+ """Генерирует sqlmap -r request.txt команду."""
60
+ parts = ["sqlmap", "-r", request_file, "--batch"]
61
+
62
+ # Если есть параметры — добавляем -p для явного указания
63
+ body_str = req.body if isinstance(req.body, str) else req.body.decode("utf-8", errors="replace")
64
+ if body_str and any(c in body_str for c in ("=", "&")):
65
+ parts += ["--data", body_str]
66
+
67
+ # Определяем dbms по заголовкам ответа если возможно
68
+ parts += ["--dbs"]
69
+
70
+ return " ".join(shlex.quote(p) for p in parts)
71
+
72
+
73
+ def copy_as_nmap(req: ParsedRequest) -> str:
74
+ """Генерирует nmap -sV -p PORT HOST команду."""
75
+ from urllib.parse import urlparse
76
+ parsed = urlparse(req.url)
77
+ host = parsed.hostname or "TARGET"
78
+ port = parsed.port
79
+ if port is None:
80
+ port = 443 if parsed.scheme == "https" else 80
81
+
82
+ parts = ["nmap", "-sV", "-sC", "-p", str(port), host]
83
+ return " ".join(shlex.quote(p) for p in parts)
84
+
85
+
86
+ def copy_as_jwt_tool(req: ParsedRequest) -> str:
87
+ """Если в заголовках есть JWT — генерирует jwt_tool команду."""
88
+ jwt_token: str | None = None
89
+
90
+ # Ищем JWT в Authorization: Bearer ...
91
+ auth = req.headers.get("Authorization", req.headers.get("authorization", ""))
92
+ if auth.lower().startswith("bearer "):
93
+ token = auth[7:].strip()
94
+ # Проверяем формат JWT (три части base64 через точки)
95
+ if re.match(r'^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$', token):
96
+ jwt_token = token
97
+
98
+ # Ищем JWT в Cookie
99
+ if jwt_token is None:
100
+ cookie_hdr = req.headers.get("Cookie", req.headers.get("cookie", ""))
101
+ for part in cookie_hdr.split(";"):
102
+ part = part.strip()
103
+ if "=" in part:
104
+ _, val = part.split("=", 1)
105
+ val = val.strip()
106
+ if re.match(r'^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$', val):
107
+ jwt_token = val
108
+ break
109
+
110
+ if jwt_token is None:
111
+ return f"# No JWT found in request headers\n# jwt_tool <token> -t {req.url}"
112
+
113
+ parts = ["jwt_tool", jwt_token, "-t", req.url]
114
+ return " ".join(shlex.quote(p) for p in parts)
115
+
116
+
117
+ def save_request_txt(req: ParsedRequest, path: str) -> None:
118
+ """Сохраняет raw HTTP request в файл для внешних инструментов (sqlmap и др.)."""
119
+ from urllib.parse import urlparse
120
+ parsed = urlparse(req.url)
121
+ path_qs = parsed.path
122
+ if parsed.query:
123
+ path_qs += "?" + parsed.query
124
+
125
+ http_ver = getattr(req, "http_version", "HTTP/1.1") or "HTTP/1.1"
126
+ lines = [f"{req.method} {path_qs} {http_ver}"]
127
+ for name, value in req.headers.items():
128
+ lines.append(f"{name}: {value}")
129
+ lines.append("")
130
+
131
+ if req.body:
132
+ body_str = req.body if isinstance(req.body, str) else req.body.decode("utf-8", errors="replace")
133
+ lines.append(body_str)
134
+
135
+ Path(path).write_text("\r\n".join(lines), encoding="utf-8")
136
+
137
+
138
+ def copy_as_fetch(req: ParsedRequest) -> str:
139
+ """Генерирует JavaScript fetch() вызов для DevTools Console."""
140
+ from urllib.parse import urlparse
141
+ import json as _json
142
+
143
+ headers_dict = {
144
+ k: v for k, v in req.headers.items()
145
+ if k.lower() not in ("content-length", "connection", "transfer-encoding", "host")
146
+ }
147
+
148
+ body_str: str | None = None
149
+ if req.body:
150
+ body_str = req.body if isinstance(req.body, str) else req.body.decode("utf-8", errors="replace")
151
+
152
+ opts: dict = {"method": req.method}
153
+ if headers_dict:
154
+ opts["headers"] = headers_dict
155
+ if body_str:
156
+ opts["body"] = body_str
157
+
158
+ opts_json = _json.dumps(opts, indent=2, ensure_ascii=False)
159
+ return f"fetch({_json.dumps(req.url)}, {opts_json});"
160
+
161
+
162
+ def open_in_browser(url: str) -> bool:
163
+ import webbrowser
164
+ try:
165
+ webbrowser.open(url)
166
+ return True
167
+ except Exception:
168
+ return False
169
+
170
+
171
+ def extract_url_from_raw(raw: str) -> str:
172
+ """Извлечь URL из сырого HTTP-запроса (первая строка + Host заголовок)."""
173
+ if not raw:
174
+ return ""
175
+ lines = raw.replace("\r\n", "\n").split("\n")
176
+ first = lines[0].strip()
177
+ parts = first.split(" ", 2)
178
+ if len(parts) < 2:
179
+ return ""
180
+ method_or_url = parts[0]
181
+ path = parts[1] if len(parts) >= 2 else "/"
182
+
183
+ # Если первая строка уже URL (не HTTP-метод)
184
+ if method_or_url.lower().startswith("http"):
185
+ return method_or_url
186
+
187
+ # Ищем Host заголовок
188
+ host = ""
189
+ scheme = "https"
190
+ for line in lines[1:]:
191
+ if not line.strip():
192
+ break
193
+ if line.lower().startswith("host:"):
194
+ host = line.split(":", 1)[1].strip()
195
+ if "x-forwarded-proto: http" in line.lower():
196
+ scheme = "http"
197
+
198
+ if not host:
199
+ return path
200
+
201
+ # Определяем схему по порту
202
+ if host.endswith(":80"):
203
+ scheme = "http"
204
+ elif host.endswith(":443"):
205
+ scheme = "https"
206
+ host = host[:-4]
207
+
208
+ return f"{scheme}://{host}{path}"
209
+
210
+
211
+ def copy_to_clipboard(text: str) -> bool:
212
+ """Копирует в буфер через xclip/xsel/wl-copy/GTK/pyperclip. Возвращает успех."""
213
+ for cmd in [
214
+ ["xclip", "-selection", "clipboard"],
215
+ ["xsel", "--clipboard", "--input"],
216
+ ["wl-copy"],
217
+ ]:
218
+ try:
219
+ result = subprocess.run(cmd, input=text.encode(), timeout=2, capture_output=True)
220
+ if result.returncode == 0:
221
+ return True
222
+ except (FileNotFoundError, subprocess.TimeoutExpired):
223
+ continue
224
+
225
+ # GTK clipboard (работает на X11 без xclip/xsel)
226
+ try:
227
+ gtk_script = (
228
+ "import gi; gi.require_version('Gtk','3.0'); "
229
+ "from gi.repository import Gtk,Gdk; "
230
+ "cb=Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD); "
231
+ f"cb.set_text({text!r},-1); cb.store()"
232
+ )
233
+ result = subprocess.run(
234
+ ["python3", "-c", gtk_script],
235
+ timeout=3, capture_output=True,
236
+ )
237
+ if result.returncode == 0:
238
+ return True
239
+ except (FileNotFoundError, subprocess.TimeoutExpired):
240
+ pass
241
+
242
+ # pyperclip как fallback
243
+ try:
244
+ import pyperclip # type: ignore[import]
245
+ pyperclip.copy(text)
246
+ return True
247
+ except Exception:
248
+ pass
249
+
250
+ return False
pentool/utils/diff.py ADDED
@@ -0,0 +1,82 @@
1
+ """Сравнение текстов (diff) с форматированием для Rich/Textual."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+
10
+ @dataclass
11
+ class DiffLine:
12
+ """Одна строка результата diff."""
13
+
14
+ type: Literal["+", "-", " ", "@"]
15
+ content: str
16
+
17
+
18
+ def diff_texts(text1: str, text2: str, context: int = 3) -> list[DiffLine]:
19
+ """Сравнить два текста и вернуть список строк diff (unified format).
20
+
21
+ Args:
22
+ text1: Исходный текст (левый / «до»).
23
+ text2: Изменённый текст (правый / «после»).
24
+ context: Количество контекстных строк вокруг изменений.
25
+
26
+ Returns:
27
+ Список объектов DiffLine.
28
+ """
29
+ lines1 = text1.splitlines(keepends=True)
30
+ lines2 = text2.splitlines(keepends=True)
31
+
32
+ result: list[DiffLine] = []
33
+ for line in difflib.unified_diff(lines1, lines2, n=context):
34
+ line_stripped = line.rstrip("\n")
35
+ if line_stripped.startswith("+++") or line_stripped.startswith("---"):
36
+ result.append(DiffLine(type="@", content=line_stripped))
37
+ elif line_stripped.startswith("@@"):
38
+ result.append(DiffLine(type="@", content=line_stripped))
39
+ elif line_stripped.startswith("+"):
40
+ result.append(DiffLine(type="+", content=line_stripped[1:]))
41
+ elif line_stripped.startswith("-"):
42
+ result.append(DiffLine(type="-", content=line_stripped[1:]))
43
+ else:
44
+ content = line_stripped[1:] if line_stripped.startswith(" ") else line_stripped
45
+ result.append(DiffLine(type=" ", content=content))
46
+
47
+ return result
48
+
49
+
50
+ def diff_to_rich(diff: list[DiffLine]) -> str:
51
+ """Форматировать diff в строку с Rich-разметкой (цвета).
52
+
53
+ Args:
54
+ diff: Список DiffLine от diff_texts().
55
+
56
+ Returns:
57
+ Строка с Rich markup для отображения в Textual/Rich.
58
+ """
59
+ lines: list[str] = []
60
+ for dl in diff:
61
+ escaped = dl.content.replace("[", "\\[")
62
+ if dl.type == "+":
63
+ lines.append(f"[green]+{escaped}[/green]")
64
+ elif dl.type == "-":
65
+ lines.append(f"[red]-{escaped}[/red]")
66
+ elif dl.type == "@":
67
+ lines.append(f"[cyan]{escaped}[/cyan]")
68
+ else:
69
+ lines.append(f" {escaped}")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def diff_summary(text1: str, text2: str) -> dict[str, int]:
74
+ added = removed = unchanged = 0
75
+ for dl in diff_texts(text1, text2):
76
+ if dl.type == "+":
77
+ added += 1
78
+ elif dl.type == "-":
79
+ removed += 1
80
+ elif dl.type == " ":
81
+ unchanged += 1
82
+ return {"added": added, "removed": removed, "unchanged": unchanged}
@@ -0,0 +1,145 @@
1
+ """Асинхронный HTTP-клиент на базе aiohttp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Callable
8
+
9
+ import aiohttp
10
+
11
+ from pentool.utils.parser import ParsedRequest, ParsedResponse
12
+
13
+
14
+ # Тип коллбэка: вызывается после каждого запроса
15
+ RequestCallback = Callable[[ParsedRequest, ParsedResponse], None]
16
+
17
+
18
+ class HTTPClient:
19
+ """Асинхронный HTTP-клиент для отправки перехваченных запросов.
20
+
21
+ Поддерживает прокси, таймауты, перенаправления и коллбэк логирования.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ proxy_url: str | None = None,
27
+ timeout: float = 30.0,
28
+ follow_redirects: bool = True,
29
+ verify_ssl: bool = False,
30
+ on_request_sent: RequestCallback | None = None,
31
+ ) -> None:
32
+ self._proxy_url = proxy_url
33
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
34
+ self._follow_redirects = follow_redirects
35
+ self._verify_ssl = verify_ssl
36
+ self._on_request_sent = on_request_sent
37
+ self._session: aiohttp.ClientSession | None = None
38
+
39
+ async def _get_session(self) -> aiohttp.ClientSession:
40
+ if self._session is None or self._session.closed:
41
+ connector = aiohttp.TCPConnector(ssl=self._verify_ssl)
42
+ self._session = aiohttp.ClientSession(
43
+ connector=connector,
44
+ timeout=self._timeout,
45
+ )
46
+ return self._session
47
+
48
+ async def send(self, request: ParsedRequest) -> ParsedResponse:
49
+ session = await self._get_session()
50
+
51
+ # Убрать hop-by-hop заголовки + заменить Accept-Encoding чтобы исключить br
52
+ send_headers = {}
53
+ for k, v in request.headers.items():
54
+ if k.lower() in ("host", "content-length", "transfer-encoding",
55
+ "connection", "keep-alive", "proxy-connection"):
56
+ continue
57
+ if k.lower() == "accept-encoding":
58
+ # Убрать brotli — aiohttp не умеет его декодировать
59
+ encodings = [e.strip() for e in v.split(",")
60
+ if e.strip().lower() not in ("br", "zstd")]
61
+ v = ", ".join(encodings) if encodings else "gzip, deflate"
62
+ send_headers[k] = v
63
+
64
+ # Inject scan marker header if configured
65
+ try:
66
+ from pentool.core.config import get_config
67
+ cfg = get_config()
68
+ if cfg.scan_marker_enabled and cfg.scan_marker_name:
69
+ send_headers[cfg.scan_marker_name] = cfg.scan_marker_value
70
+ except Exception:
71
+ pass
72
+
73
+ body_data = request.body.encode("utf-8") if request.body else None
74
+ kwargs: dict = {
75
+ "headers": send_headers,
76
+ "allow_redirects": self._follow_redirects,
77
+ "data": body_data,
78
+ }
79
+ if self._proxy_url:
80
+ kwargs["proxy"] = self._proxy_url
81
+
82
+ start = time.monotonic()
83
+ async with session.request(request.method, request.url, **kwargs) as resp:
84
+ # Читаем сырые байты — aiohttp сам декодирует gzip/deflate через read()
85
+ resp_body_bytes: bytes = await resp.read()
86
+ elapsed_ms = int((time.monotonic() - start) * 1000)
87
+
88
+ # Декодируем для хранения в ParsedResponse (для TUI/отчётов)
89
+ try:
90
+ resp_body = resp_body_bytes.decode(
91
+ resp.charset or "utf-8", errors="replace"
92
+ )
93
+ except (LookupError, TypeError):
94
+ resp_body = resp_body_bytes.decode("utf-8", errors="replace")
95
+
96
+ resp_headers = dict(resp.headers)
97
+ parsed_resp = ParsedResponse(
98
+ status=resp.status,
99
+ reason=resp.reason or "",
100
+ headers=resp_headers,
101
+ body=resp_body,
102
+ _raw_body=resp_body_bytes,
103
+ )
104
+
105
+ if self._on_request_sent:
106
+ self._on_request_sent(request, parsed_resp)
107
+
108
+ return parsed_resp
109
+
110
+ async def send_raw(self, raw_request: str) -> ParsedResponse:
111
+ from pentool.utils.parser import parse_http_request
112
+ req = parse_http_request(raw_request)
113
+ return await self.send(req)
114
+
115
+ async def close(self) -> None:
116
+ if self._session and not self._session.closed:
117
+ await self._session.close()
118
+
119
+ async def get(self, url: str, headers: dict | None = None) -> "ParsedResponse":
120
+ """Удобный метод: GET-запрос по URL."""
121
+ req = ParsedRequest(
122
+ method="GET",
123
+ url=url,
124
+ headers=headers or {},
125
+ body="",
126
+ )
127
+ return await self.send(req)
128
+
129
+ async def post(self, url: str, body: str = "", headers: dict | None = None) -> "ParsedResponse":
130
+ """Удобный метод: POST-запрос по URL."""
131
+ req = ParsedRequest(
132
+ method="POST",
133
+ url=url,
134
+ headers=headers or {"Content-Type": "application/x-www-form-urlencoded"},
135
+ body=body,
136
+ )
137
+ return await self.send(req)
138
+
139
+ async def __aenter__(self) -> "HTTPClient":
140
+ """Поддержка контекстного менеджера: вернуть self."""
141
+ return self
142
+
143
+ async def __aexit__(self, *_: object) -> None:
144
+ """Поддержка контекстного менеджера: закрыть сессию при выходе."""
145
+ await self.close()