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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +16 -0
- pentool/api/__init__.py +41 -0
- pentool/api/base_api.py +48 -0
- pentool/api/comparer_api.py +19 -0
- pentool/api/decoder_api.py +24 -0
- pentool/api/intruder_api.py +171 -0
- pentool/api/proxy_api.py +210 -0
- pentool/api/repeater_api.py +57 -0
- pentool/api/scanner_api.py +266 -0
- pentool/api/sequencer_api.py +17 -0
- pentool/api/spider_api.py +106 -0
- pentool/api/target_api.py +87 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +98 -0
- pentool/cli/project.py +88 -0
- pentool/cli/proxy.py +173 -0
- pentool/cli/scan.py +115 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +171 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +207 -0
- pentool/core/events.py +160 -0
- pentool/core/features.py +373 -0
- pentool/core/license.py +274 -0
- pentool/core/logging.py +54 -0
- pentool/core/plugin_manager.py +312 -0
- pentool/core/project.py +31 -0
- pentool/core/storage_interface.py +307 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +36 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +428 -0
- pentool/modules/intruder_turbo.py +176 -0
- pentool/modules/match_replace.py +142 -0
- pentool/modules/proxy.py +797 -0
- pentool/modules/repeater.py +195 -0
- pentool/modules/sequencer.py +492 -0
- pentool/modules/spider.py +679 -0
- pentool/modules/target.py +185 -0
- pentool/modules/websocket_handler.py +249 -0
- pentool/plugins/__init__.py +1 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +381 -0
- pentool/plugins/builtin/reports_pro.py +436 -0
- pentool/plugins/builtin/scanner_pro.py +291 -0
- pentool/plugins/example_plugin.py +39 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +60 -0
- pentool/services/intruder_service.py +94 -0
- pentool/services/proxy_service.py +178 -0
- pentool/services/repeater_service.py +85 -0
- pentool/services/scan_service.py +330 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +506 -0
- pentool/storage/large_body_handler.py +50 -0
- pentool/storage/lru_cache.py +45 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1429 -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 +226 -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 +224 -0
- pentool/tui/messages.py +103 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +59 -0
- pentool/tui/mixins/dialog_cancel.py +21 -0
- pentool/tui/mixins/request_context_menu.py +247 -0
- pentool/tui/mixins/tab_rename.py +89 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +229 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +730 -0
- pentool/tui/screens/dashboard/screen.py +771 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +306 -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 +1356 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1554 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +621 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1956 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +516 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +540 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +380 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +358 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +179 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +204 -0
- pentool/tui/widgets/inspector_panel.py +111 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +68 -0
- pentool/tui/widgets/payload_drop_zone.py +65 -0
- pentool/tui/widgets/request_editor.py +495 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +112 -0
- pentool/tui/widgets/statusbar.py +96 -0
- pentool/tui/widgets/toolbar_button.py +68 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +314 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +250 -0
- pentool/utils/diff.py +82 -0
- pentool/utils/http_client.py +145 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +31 -0
- pentool-0.1.0.dist-info/METADATA +231 -0
- pentool-0.1.0.dist-info/RECORD +133 -0
- pentool-0.1.0.dist-info/WHEEL +5 -0
- pentool-0.1.0.dist-info/entry_points.txt +2 -0
- pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
- pentool-0.1.0.dist-info/top_level.txt +1 -0
pentool/utils/copy_as.py
ADDED
|
@@ -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()
|