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/cert.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Генерация и управление TLS-сертификатами для HTTPS-перехвата прокси."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
import ssl
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cryptography import x509
|
|
13
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
16
|
+
from cryptography.x509 import Certificate
|
|
17
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _generate_rsa_key(key_size: int = 2048) -> RSAPrivateKey:
|
|
21
|
+
return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _key_to_pem(key: RSAPrivateKey) -> bytes:
|
|
25
|
+
"""Сериализовать приватный ключ в PEM без пароля."""
|
|
26
|
+
return key.private_bytes(
|
|
27
|
+
encoding=serialization.Encoding.PEM,
|
|
28
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
29
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cert_to_pem(cert: Certificate) -> bytes:
|
|
34
|
+
"""Сериализовать сертификат в PEM."""
|
|
35
|
+
return cert.public_bytes(serialization.Encoding.PEM)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_ca_cert(cert_dir: str) -> tuple[str, str]:
|
|
39
|
+
dir_path = Path(cert_dir)
|
|
40
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
# Устанавливаем права 700 для директории с сертификатами
|
|
42
|
+
dir_path.chmod(0o700)
|
|
43
|
+
|
|
44
|
+
cert_path = dir_path / "ca.crt"
|
|
45
|
+
key_path = dir_path / "ca.key"
|
|
46
|
+
|
|
47
|
+
if cert_path.exists() and key_path.exists():
|
|
48
|
+
return str(cert_path), str(key_path)
|
|
49
|
+
|
|
50
|
+
key = _generate_rsa_key(4096)
|
|
51
|
+
now = datetime.now(timezone.utc)
|
|
52
|
+
|
|
53
|
+
subject = issuer = x509.Name([
|
|
54
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "Pentool CA"),
|
|
55
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Pentool"),
|
|
56
|
+
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Security Testing"),
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
cert = (
|
|
60
|
+
x509.CertificateBuilder()
|
|
61
|
+
.subject_name(subject)
|
|
62
|
+
.issuer_name(issuer)
|
|
63
|
+
.public_key(key.public_key())
|
|
64
|
+
.serial_number(x509.random_serial_number())
|
|
65
|
+
.not_valid_before(now)
|
|
66
|
+
.not_valid_after(now + timedelta(days=3650)) # 10 лет
|
|
67
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
68
|
+
.add_extension(
|
|
69
|
+
x509.KeyUsage(
|
|
70
|
+
digital_signature=True,
|
|
71
|
+
content_commitment=False,
|
|
72
|
+
key_encipherment=False,
|
|
73
|
+
data_encipherment=False,
|
|
74
|
+
key_agreement=False,
|
|
75
|
+
key_cert_sign=True,
|
|
76
|
+
crl_sign=True,
|
|
77
|
+
encipher_only=False,
|
|
78
|
+
decipher_only=False,
|
|
79
|
+
),
|
|
80
|
+
critical=True,
|
|
81
|
+
)
|
|
82
|
+
.add_extension(
|
|
83
|
+
x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
|
|
84
|
+
critical=False,
|
|
85
|
+
)
|
|
86
|
+
.sign(key, hashes.SHA256())
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
cert_path.write_bytes(_cert_to_pem(cert))
|
|
90
|
+
cert_path.chmod(0o644)
|
|
91
|
+
|
|
92
|
+
key_path.write_bytes(_key_to_pem(key))
|
|
93
|
+
key_path.chmod(0o600)
|
|
94
|
+
|
|
95
|
+
return str(cert_path), str(key_path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_or_create_ca(cert_dir: str) -> tuple[str, str]:
|
|
99
|
+
return generate_ca_cert(cert_dir)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def generate_domain_cert(
|
|
103
|
+
domain: str,
|
|
104
|
+
ca_cert_path: str,
|
|
105
|
+
ca_key_path: str,
|
|
106
|
+
) -> tuple[bytes, bytes]:
|
|
107
|
+
# Загрузить CA
|
|
108
|
+
ca_cert = x509.load_pem_x509_certificate(Path(ca_cert_path).read_bytes())
|
|
109
|
+
ca_key_data = Path(ca_key_path).read_bytes()
|
|
110
|
+
ca_key = serialization.load_pem_private_key(ca_key_data, password=None)
|
|
111
|
+
|
|
112
|
+
key = _generate_rsa_key(2048)
|
|
113
|
+
now = datetime.now(timezone.utc)
|
|
114
|
+
|
|
115
|
+
subject = x509.Name([
|
|
116
|
+
x509.NameAttribute(NameOID.COMMON_NAME, domain),
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
# Определить SAN: IP или DNS
|
|
120
|
+
san_list: list[x509.GeneralName] = []
|
|
121
|
+
try:
|
|
122
|
+
san_list.append(x509.IPAddress(ipaddress.ip_address(domain)))
|
|
123
|
+
except ValueError:
|
|
124
|
+
san_list.append(x509.DNSName(domain))
|
|
125
|
+
# Добавить wildcard для поддоменов
|
|
126
|
+
if "." in domain and not domain.startswith("*."):
|
|
127
|
+
san_list.append(x509.DNSName(f"*.{domain}"))
|
|
128
|
+
|
|
129
|
+
cert = (
|
|
130
|
+
x509.CertificateBuilder()
|
|
131
|
+
.subject_name(subject)
|
|
132
|
+
.issuer_name(ca_cert.subject)
|
|
133
|
+
.public_key(key.public_key())
|
|
134
|
+
.serial_number(x509.random_serial_number())
|
|
135
|
+
.not_valid_before(now)
|
|
136
|
+
.not_valid_after(now + timedelta(days=825)) # ~2 года 3 месяца
|
|
137
|
+
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
138
|
+
.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
|
|
139
|
+
.add_extension(
|
|
140
|
+
x509.KeyUsage(
|
|
141
|
+
digital_signature=True,
|
|
142
|
+
content_commitment=False,
|
|
143
|
+
key_encipherment=True,
|
|
144
|
+
data_encipherment=False,
|
|
145
|
+
key_agreement=False,
|
|
146
|
+
key_cert_sign=False,
|
|
147
|
+
crl_sign=False,
|
|
148
|
+
encipher_only=False,
|
|
149
|
+
decipher_only=False,
|
|
150
|
+
),
|
|
151
|
+
critical=True,
|
|
152
|
+
)
|
|
153
|
+
.add_extension(
|
|
154
|
+
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
|
|
155
|
+
critical=False,
|
|
156
|
+
)
|
|
157
|
+
.sign(ca_key, hashes.SHA256())
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return _cert_to_pem(cert), _key_to_pem(key)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_ssl_context_for_domain(
|
|
164
|
+
domain: str,
|
|
165
|
+
ca_cert_path: str,
|
|
166
|
+
ca_key_path: str,
|
|
167
|
+
cert_dir: str | None = None,
|
|
168
|
+
) -> ssl.SSLContext:
|
|
169
|
+
# ── Уровень 1: in-memory LRU ──────────────────────────────────────────────
|
|
170
|
+
cache_key = f"{domain}:{ca_cert_path}"
|
|
171
|
+
ctx = _ssl_ctx_cache.get(cache_key)
|
|
172
|
+
if ctx is not None:
|
|
173
|
+
return ctx
|
|
174
|
+
|
|
175
|
+
# ── Уровень 2: disk-cache ─────────────────────────────────────────────────
|
|
176
|
+
ctx = _load_ctx_from_disk(domain, ca_cert_path, cert_dir)
|
|
177
|
+
if ctx is not None:
|
|
178
|
+
_ssl_ctx_cache.put(cache_key, ctx)
|
|
179
|
+
return ctx
|
|
180
|
+
|
|
181
|
+
# ── Генерация нового сертификата ──────────────────────────────────────────
|
|
182
|
+
cert_pem, key_pem = generate_domain_cert(domain, ca_cert_path, ca_key_path)
|
|
183
|
+
|
|
184
|
+
# Сохранить на диск (если cert_dir задан)
|
|
185
|
+
_save_ctx_to_disk(domain, cert_pem, key_pem, cert_dir)
|
|
186
|
+
|
|
187
|
+
ctx = _build_ssl_ctx(cert_pem, key_pem)
|
|
188
|
+
_ssl_ctx_cache.put(cache_key, ctx)
|
|
189
|
+
return ctx
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Вспомогательные функции для disk-cache ────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def _domain_cache_path(domain: str, cert_dir: str) -> Path:
|
|
195
|
+
"""Путь к кэш-файлу для домена: {cert_dir}/domains/{sha256(domain)[:16]}.pem"""
|
|
196
|
+
import hashlib
|
|
197
|
+
key = hashlib.sha256(domain.encode()).hexdigest()[:16]
|
|
198
|
+
return Path(cert_dir) / "domains" / f"{key}.pem"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_ctx_from_disk(
|
|
202
|
+
domain: str, ca_cert_path: str, cert_dir: str | None
|
|
203
|
+
) -> ssl.SSLContext | None:
|
|
204
|
+
"""Попытаться загрузить сертификат из disk-cache.
|
|
205
|
+
|
|
206
|
+
Проверяет:
|
|
207
|
+
- файл существует
|
|
208
|
+
- CN сертификата совпадает с доменом
|
|
209
|
+
- срок действия не истекает в ближайшие 30 дней
|
|
210
|
+
"""
|
|
211
|
+
if not cert_dir:
|
|
212
|
+
return None
|
|
213
|
+
path = _domain_cache_path(domain, cert_dir)
|
|
214
|
+
if not path.exists():
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
pem_data = path.read_bytes()
|
|
218
|
+
# Файл содержит cert_pem + key_pem, разделённые маркером
|
|
219
|
+
sep = b"-----BEGIN RSA PRIVATE KEY-----"
|
|
220
|
+
sep2 = b"-----BEGIN PRIVATE KEY-----"
|
|
221
|
+
if sep in pem_data:
|
|
222
|
+
cert_pem, key_pem = pem_data.split(sep, 1)
|
|
223
|
+
key_pem = sep + key_pem
|
|
224
|
+
elif sep2 in pem_data:
|
|
225
|
+
cert_pem, key_pem = pem_data.split(sep2, 1)
|
|
226
|
+
key_pem = sep2 + key_pem
|
|
227
|
+
else:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# Проверить срок действия
|
|
231
|
+
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
232
|
+
expires = cert.not_valid_after_utc
|
|
233
|
+
margin = timedelta(days=30)
|
|
234
|
+
if expires - datetime.now(timezone.utc) < margin:
|
|
235
|
+
path.unlink(missing_ok=True)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
return _build_ssl_ctx(cert_pem, key_pem)
|
|
239
|
+
except Exception:
|
|
240
|
+
# Повреждённый кэш — удалить и регенерировать
|
|
241
|
+
try:
|
|
242
|
+
path.unlink(missing_ok=True)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _save_ctx_to_disk(
|
|
249
|
+
domain: str, cert_pem: bytes, key_pem: bytes, cert_dir: str | None
|
|
250
|
+
) -> None:
|
|
251
|
+
if not cert_dir:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
path = _domain_cache_path(domain, cert_dir)
|
|
255
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
path.write_bytes(cert_pem + key_pem)
|
|
257
|
+
path.chmod(0o600)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass # disk-cache не критичен — продолжаем без него
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _build_ssl_ctx(cert_pem: bytes, key_pem: bytes) -> ssl.SSLContext:
|
|
263
|
+
"""Собрать SSLContext из PEM-байтов через временные файлы."""
|
|
264
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
265
|
+
ctx.check_hostname = False
|
|
266
|
+
ctx.set_alpn_protocols(["http/1.1"])
|
|
267
|
+
|
|
268
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".crt") as cf:
|
|
269
|
+
cf.write(cert_pem)
|
|
270
|
+
cert_tmp = cf.name
|
|
271
|
+
|
|
272
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as kf:
|
|
273
|
+
kf.write(key_pem)
|
|
274
|
+
key_tmp = kf.name
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
ctx.load_cert_chain(cert_tmp, key_tmp)
|
|
278
|
+
finally:
|
|
279
|
+
os.unlink(cert_tmp)
|
|
280
|
+
os.unlink(key_tmp)
|
|
281
|
+
|
|
282
|
+
return ctx
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ── In-memory LRU cache для SSL-контекстов (1000 доменов) ────────────────────
|
|
286
|
+
|
|
287
|
+
class _SslCtxLRU:
|
|
288
|
+
"""Простой LRU-кэш для SSLContext объектов.
|
|
289
|
+
|
|
290
|
+
Ключ — строка "{domain}:{ca_cert_path}", значение — ssl.SSLContext.
|
|
291
|
+
Максимум 1000 записей — примерно 1–2 МБ памяти.
|
|
292
|
+
"""
|
|
293
|
+
def __init__(self, max_size: int = 1000) -> None:
|
|
294
|
+
from collections import OrderedDict
|
|
295
|
+
self._data: "OrderedDict[str, ssl.SSLContext]" = OrderedDict()
|
|
296
|
+
self._max = max_size
|
|
297
|
+
|
|
298
|
+
def get(self, key: str) -> ssl.SSLContext | None:
|
|
299
|
+
if key not in self._data:
|
|
300
|
+
return None
|
|
301
|
+
self._data.move_to_end(key)
|
|
302
|
+
return self._data[key]
|
|
303
|
+
|
|
304
|
+
def put(self, key: str, ctx: ssl.SSLContext) -> None:
|
|
305
|
+
if key in self._data:
|
|
306
|
+
self._data.move_to_end(key)
|
|
307
|
+
self._data[key] = ctx
|
|
308
|
+
return
|
|
309
|
+
self._data[key] = ctx
|
|
310
|
+
if len(self._data) > self._max:
|
|
311
|
+
self._data.popitem(last=False)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_ssl_ctx_cache = _SslCtxLRU(max_size=1000)
|
pentool/utils/coder.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Кодирование, декодирование и хэширование строк."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import html
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def url_encode(text: str) -> str:
|
|
13
|
+
"""URL-кодировать строку (все символы, кроме букв/цифр/'-._~')."""
|
|
14
|
+
return urllib.parse.quote(text, safe="")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def url_decode(text: str) -> str:
|
|
18
|
+
"""URL-декодировать строку."""
|
|
19
|
+
return urllib.parse.unquote(text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def url_encode_all(text: str) -> str:
|
|
23
|
+
"""URL-кодировать строку, включая пробелы как %20."""
|
|
24
|
+
return urllib.parse.quote(text, safe="")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def url_decode_plus(text: str) -> str:
|
|
28
|
+
"""URL-декодировать строку, обрабатывая '+' как пробел."""
|
|
29
|
+
return urllib.parse.unquote_plus(text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def base64_encode(text: str) -> str:
|
|
33
|
+
"""Закодировать строку в стандартный Base64."""
|
|
34
|
+
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def base64_decode(text: str) -> str:
|
|
38
|
+
"""Декодировать строку из стандартного Base64.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: Если строка не является корректным Base64.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
padded = text + "=" * (-len(text) % 4)
|
|
45
|
+
return base64.b64decode(padded).decode("utf-8", errors="replace")
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
raise ValueError(f"Invalid base64: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def base64url_encode(text: str) -> str:
|
|
51
|
+
"""Закодировать строку в URL-safe Base64 (без паддинга)."""
|
|
52
|
+
return base64.urlsafe_b64encode(text.encode("utf-8")).decode("ascii").rstrip("=")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def base64url_decode(text: str) -> str:
|
|
56
|
+
"""Декодировать строку из URL-safe Base64.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: Если строка не является корректным Base64url.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
padded = text + "=" * (-len(text) % 4)
|
|
63
|
+
return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise ValueError(f"Invalid base64url: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def html_encode(text: str) -> str:
|
|
69
|
+
"""HTML-кодировать строку (заменить спецсимволы на HTML-сущности)."""
|
|
70
|
+
return html.escape(text, quote=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def html_decode(text: str) -> str:
|
|
74
|
+
"""HTML-декодировать строку (заменить HTML-сущности на символы)."""
|
|
75
|
+
return html.unescape(text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hex_encode(text: str) -> str:
|
|
79
|
+
"""Закодировать строку в hex-представление байт (UTF-8)."""
|
|
80
|
+
return text.encode("utf-8").hex()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def hex_decode(text: str) -> str:
|
|
84
|
+
"""Декодировать hex-строку обратно в текст.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: Если строка не является корректным hex.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
clean = text.replace(" ", "").replace("\\x", "").replace("0x", "")
|
|
91
|
+
return bytes.fromhex(clean).decode("utf-8", errors="replace")
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
raise ValueError(f"Invalid hex: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unicode_escape(text: str) -> str:
|
|
97
|
+
"""Закодировать строку в Unicode escape (\\uXXXX для не-ASCII)."""
|
|
98
|
+
result = []
|
|
99
|
+
for ch in text:
|
|
100
|
+
code = ord(ch)
|
|
101
|
+
if code > 127:
|
|
102
|
+
result.append(f"\\u{code:04x}")
|
|
103
|
+
else:
|
|
104
|
+
result.append(ch)
|
|
105
|
+
return "".join(result)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def unicode_unescape(text: str) -> str:
|
|
109
|
+
"""Декодировать строку из Unicode escape."""
|
|
110
|
+
try:
|
|
111
|
+
return text.encode("utf-8").decode("unicode_escape")
|
|
112
|
+
except Exception:
|
|
113
|
+
import codecs
|
|
114
|
+
try:
|
|
115
|
+
return codecs.decode(text, "unicode_escape")
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise ValueError(f"Invalid unicode escape: {exc}") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def md5(text: str) -> str:
|
|
121
|
+
"""Вычислить MD5-хэш строки (hex-дайджест)."""
|
|
122
|
+
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sha1(text: str) -> str:
|
|
126
|
+
"""Вычислить SHA1-хэш строки (hex-дайджест)."""
|
|
127
|
+
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sha256(text: str) -> str:
|
|
131
|
+
"""Вычислить SHA256-хэш строки (hex-дайджест)."""
|
|
132
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
OPERATIONS: dict[str, Callable[[str], str]] = {
|
|
136
|
+
"url_encode": url_encode,
|
|
137
|
+
"url_decode": url_decode,
|
|
138
|
+
"base64_encode": base64_encode,
|
|
139
|
+
"base64_decode": base64_decode,
|
|
140
|
+
"base64url_encode": base64url_encode,
|
|
141
|
+
"base64url_decode": base64url_decode,
|
|
142
|
+
"html_encode": html_encode,
|
|
143
|
+
"html_decode": html_decode,
|
|
144
|
+
"hex_encode": hex_encode,
|
|
145
|
+
"hex_decode": hex_decode,
|
|
146
|
+
"unicode_escape": unicode_escape,
|
|
147
|
+
"unicode_unescape": unicode_unescape,
|
|
148
|
+
"md5": md5,
|
|
149
|
+
"sha1": sha1,
|
|
150
|
+
"sha256": sha256,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def apply_operation(operation: str, text: str) -> str:
|
|
155
|
+
"""Применить именованную операцию к тексту.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
operation: Имя операции из OPERATIONS.
|
|
159
|
+
text: Входная строка.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Результат операции.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValueError: Если операция неизвестна.
|
|
166
|
+
"""
|
|
167
|
+
func = OPERATIONS.get(operation)
|
|
168
|
+
if func is None:
|
|
169
|
+
raise ValueError(f"Unknown operation: {operation!r}. Available: {list(OPERATIONS)}")
|
|
170
|
+
return func(text)
|