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/core/updater.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Update checker — checks GitHub releases for a newer version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class UpdateInfo:
|
|
12
|
+
has_update: bool
|
|
13
|
+
latest_version: str
|
|
14
|
+
url: str
|
|
15
|
+
error: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_version(v: str) -> tuple[int, ...]:
|
|
19
|
+
"""Parse 'v1.2.3' or '1.2.3' into (1, 2, 3)."""
|
|
20
|
+
v = v.lstrip("v").strip()
|
|
21
|
+
try:
|
|
22
|
+
return tuple(int(x) for x in v.split(".")[:3])
|
|
23
|
+
except Exception:
|
|
24
|
+
return (0,)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def check_update_async(
|
|
28
|
+
owner: str = "sudores",
|
|
29
|
+
repo: str = "pentool",
|
|
30
|
+
timeout: float = 6.0,
|
|
31
|
+
) -> UpdateInfo:
|
|
32
|
+
"""Query GitHub releases API and compare with installed version."""
|
|
33
|
+
try:
|
|
34
|
+
from pentool import __version__ as current_version
|
|
35
|
+
except Exception:
|
|
36
|
+
current_version = "0.0.0"
|
|
37
|
+
|
|
38
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import aiohttp # type: ignore[import]
|
|
42
|
+
except ImportError:
|
|
43
|
+
return UpdateInfo(has_update=False, latest_version=current_version, url="",
|
|
44
|
+
error="aiohttp not installed")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
aio_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
48
|
+
async with aiohttp.ClientSession(timeout=aio_timeout) as session:
|
|
49
|
+
async with session.get(
|
|
50
|
+
url,
|
|
51
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
52
|
+
ssl=False,
|
|
53
|
+
) as resp:
|
|
54
|
+
if resp.status != 200:
|
|
55
|
+
return UpdateInfo(has_update=False, latest_version=current_version,
|
|
56
|
+
url="", error=f"HTTP {resp.status}")
|
|
57
|
+
data = await resp.json()
|
|
58
|
+
except asyncio.TimeoutError:
|
|
59
|
+
return UpdateInfo(has_update=False, latest_version=current_version,
|
|
60
|
+
url="", error="timeout")
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
return UpdateInfo(has_update=False, latest_version=current_version,
|
|
63
|
+
url="", error=str(exc))
|
|
64
|
+
|
|
65
|
+
latest_tag = data.get("tag_name", "")
|
|
66
|
+
html_url = data.get("html_url", "")
|
|
67
|
+
if not latest_tag:
|
|
68
|
+
return UpdateInfo(has_update=False, latest_version=current_version,
|
|
69
|
+
url="", error="no tag_name in response")
|
|
70
|
+
|
|
71
|
+
has_update = _parse_version(latest_tag) > _parse_version(current_version)
|
|
72
|
+
return UpdateInfo(has_update=has_update, latest_version=latest_tag, url=html_url)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_update_sync() -> UpdateInfo:
|
|
76
|
+
"""Synchronous wrapper for use outside an async context."""
|
|
77
|
+
try:
|
|
78
|
+
loop = asyncio.new_event_loop()
|
|
79
|
+
result = loop.run_until_complete(check_update_async())
|
|
80
|
+
loop.close()
|
|
81
|
+
return result
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
return UpdateInfo(has_update=False, latest_version="", url="", error=str(exc))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def do_pip_upgrade() -> bool:
|
|
87
|
+
"""Attempt to upgrade via pip. Returns True on success."""
|
|
88
|
+
import subprocess, sys
|
|
89
|
+
try:
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "pentool"],
|
|
92
|
+
capture_output=True, text=True, timeout=120,
|
|
93
|
+
)
|
|
94
|
+
return result.returncode == 0
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
pentool/core/utils.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""core/utils.py — утилиты общего назначения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Coroutine, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_async_sync(coro: Coroutine[Any, Any, T], timeout: float = 15.0) -> T:
|
|
13
|
+
result: list[Any] = []
|
|
14
|
+
exc_box: list[BaseException] = []
|
|
15
|
+
done_event = threading.Event()
|
|
16
|
+
|
|
17
|
+
def _run() -> None:
|
|
18
|
+
loop = asyncio.new_event_loop()
|
|
19
|
+
try:
|
|
20
|
+
result.append(loop.run_until_complete(coro))
|
|
21
|
+
except Exception as e:
|
|
22
|
+
exc_box.append(e)
|
|
23
|
+
finally:
|
|
24
|
+
loop.close()
|
|
25
|
+
done_event.set()
|
|
26
|
+
|
|
27
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
28
|
+
finished = done_event.wait(timeout=timeout)
|
|
29
|
+
if not finished:
|
|
30
|
+
raise TimeoutError(f"run_async_sync: timed out after {timeout}s")
|
|
31
|
+
if exc_box:
|
|
32
|
+
raise exc_box[0]
|
|
33
|
+
return result[0] if result else None # type: ignore[return-value]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["run_async_sync"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Пакет modules: логика модулей, независимая от интерфейса."""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Comparer — side-by-side diff двух текстов с подсветкой различий."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
__all__ = ["compare", "compare_lines", "CompareStats", "DiffLine", "DiffResult"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DiffLine:
|
|
13
|
+
"""Одна строка diff-результата."""
|
|
14
|
+
|
|
15
|
+
tag: str # "equal" | "replace" | "insert" | "delete"
|
|
16
|
+
left: str # текст левой стороны (пустая если insert)
|
|
17
|
+
right: str # текст правой стороны (пустая если delete)
|
|
18
|
+
line_left: int # номер строки слева (0 если нет)
|
|
19
|
+
line_right: int # номер строки справа (0 если нет)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CompareStats:
|
|
24
|
+
"""Статистика сравнения."""
|
|
25
|
+
|
|
26
|
+
total_left: int
|
|
27
|
+
total_right: int
|
|
28
|
+
equal_lines: int
|
|
29
|
+
added_lines: int
|
|
30
|
+
removed_lines: int
|
|
31
|
+
changed_lines: int
|
|
32
|
+
similarity: float # 0.0–1.0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def similarity_pct(self) -> int:
|
|
36
|
+
return int(self.similarity * 100)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DiffResult:
|
|
41
|
+
"""Результат сравнения: строки diff + статистика."""
|
|
42
|
+
|
|
43
|
+
lines: list[DiffLine]
|
|
44
|
+
stats: CompareStats
|
|
45
|
+
|
|
46
|
+
def rich_text(self) -> str:
|
|
47
|
+
"""Собрать Rich-markup строку для RichLog."""
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
for dl in self.lines:
|
|
50
|
+
if dl.tag == "equal":
|
|
51
|
+
parts.append(f"[dim] {dl.left}[/dim]")
|
|
52
|
+
elif dl.tag == "insert":
|
|
53
|
+
parts.append(f"[green]+ {dl.right}[/green]")
|
|
54
|
+
elif dl.tag == "delete":
|
|
55
|
+
parts.append(f"[red]- {dl.left}[/red]")
|
|
56
|
+
elif dl.tag == "replace":
|
|
57
|
+
parts.append(f"[red]- {dl.left}[/red]")
|
|
58
|
+
parts.append(f"[green]+ {dl.right}[/green]")
|
|
59
|
+
return "\n".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compare(left: str, right: str) -> DiffResult:
|
|
63
|
+
"""Сравнить два текста построчно.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
left: Левый текст.
|
|
67
|
+
right: Правый текст.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
DiffResult с построчными различиями и статистикой.
|
|
71
|
+
"""
|
|
72
|
+
left_lines = left.splitlines()
|
|
73
|
+
right_lines = right.splitlines()
|
|
74
|
+
return compare_lines(left_lines, right_lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compare_lines(left_lines: list[str], right_lines: list[str]) -> DiffResult:
|
|
78
|
+
"""Сравнить два списка строк.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
left_lines: Строки левой стороны.
|
|
82
|
+
right_lines: Строки правой стороны.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
DiffResult с построчными различиями и статистикой.
|
|
86
|
+
"""
|
|
87
|
+
matcher = difflib.SequenceMatcher(None, left_lines, right_lines, autojunk=False)
|
|
88
|
+
opcodes = matcher.get_opcodes()
|
|
89
|
+
|
|
90
|
+
diff_lines: list[DiffLine] = []
|
|
91
|
+
equal = added = removed = changed = 0
|
|
92
|
+
|
|
93
|
+
ln_left = 1
|
|
94
|
+
ln_right = 1
|
|
95
|
+
|
|
96
|
+
for tag, i1, i2, j1, j2 in opcodes:
|
|
97
|
+
if tag == "equal":
|
|
98
|
+
for k in range(i2 - i1):
|
|
99
|
+
diff_lines.append(DiffLine(
|
|
100
|
+
tag="equal",
|
|
101
|
+
left=left_lines[i1 + k],
|
|
102
|
+
right=right_lines[j1 + k],
|
|
103
|
+
line_left=ln_left + k,
|
|
104
|
+
line_right=ln_right + k,
|
|
105
|
+
))
|
|
106
|
+
equal += i2 - i1
|
|
107
|
+
ln_left += i2 - i1
|
|
108
|
+
ln_right += j2 - j1
|
|
109
|
+
|
|
110
|
+
elif tag == "replace":
|
|
111
|
+
# Показываем удалённые строки слева, добавленные справа
|
|
112
|
+
left_chunk = left_lines[i1:i2]
|
|
113
|
+
right_chunk = right_lines[j1:j2]
|
|
114
|
+
max_len = max(len(left_chunk), len(right_chunk))
|
|
115
|
+
for k in range(max_len):
|
|
116
|
+
l_text = left_chunk[k] if k < len(left_chunk) else ""
|
|
117
|
+
r_text = right_chunk[k] if k < len(right_chunk) else ""
|
|
118
|
+
if l_text and r_text:
|
|
119
|
+
diff_lines.append(DiffLine(
|
|
120
|
+
tag="replace",
|
|
121
|
+
left=l_text, right=r_text,
|
|
122
|
+
line_left=ln_left + k if k < len(left_chunk) else 0,
|
|
123
|
+
line_right=ln_right + k if k < len(right_chunk) else 0,
|
|
124
|
+
))
|
|
125
|
+
elif l_text:
|
|
126
|
+
diff_lines.append(DiffLine(
|
|
127
|
+
tag="delete", left=l_text, right="",
|
|
128
|
+
line_left=ln_left + k, line_right=0,
|
|
129
|
+
))
|
|
130
|
+
else:
|
|
131
|
+
diff_lines.append(DiffLine(
|
|
132
|
+
tag="insert", left="", right=r_text,
|
|
133
|
+
line_left=0, line_right=ln_right + k,
|
|
134
|
+
))
|
|
135
|
+
changed += max(len(left_chunk), len(right_chunk))
|
|
136
|
+
ln_left += i2 - i1
|
|
137
|
+
ln_right += j2 - j1
|
|
138
|
+
|
|
139
|
+
elif tag == "delete":
|
|
140
|
+
for k in range(i2 - i1):
|
|
141
|
+
diff_lines.append(DiffLine(
|
|
142
|
+
tag="delete",
|
|
143
|
+
left=left_lines[i1 + k], right="",
|
|
144
|
+
line_left=ln_left + k, line_right=0,
|
|
145
|
+
))
|
|
146
|
+
removed += i2 - i1
|
|
147
|
+
ln_left += i2 - i1
|
|
148
|
+
|
|
149
|
+
elif tag == "insert":
|
|
150
|
+
for k in range(j2 - j1):
|
|
151
|
+
diff_lines.append(DiffLine(
|
|
152
|
+
tag="insert",
|
|
153
|
+
left="", right=right_lines[j1 + k],
|
|
154
|
+
line_left=0, line_right=ln_right + k,
|
|
155
|
+
))
|
|
156
|
+
added += j2 - j1
|
|
157
|
+
ln_right += j2 - j1
|
|
158
|
+
|
|
159
|
+
similarity = matcher.ratio()
|
|
160
|
+
stats = CompareStats(
|
|
161
|
+
total_left=len(left_lines),
|
|
162
|
+
total_right=len(right_lines),
|
|
163
|
+
equal_lines=equal,
|
|
164
|
+
added_lines=added,
|
|
165
|
+
removed_lines=removed,
|
|
166
|
+
changed_lines=changed,
|
|
167
|
+
similarity=similarity,
|
|
168
|
+
)
|
|
169
|
+
return DiffResult(lines=diff_lines, stats=stats)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def compare_bytes(left: bytes, right: bytes) -> DiffResult:
|
|
173
|
+
"""Сравнить два байтовых потока (декодируются как UTF-8 с заменой)."""
|
|
174
|
+
return compare(
|
|
175
|
+
left.decode("utf-8", errors="replace"),
|
|
176
|
+
right.decode("utf-8", errors="replace"),
|
|
177
|
+
)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Decoder/Encoder — 15 операций encode/decode + хэширование."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OPERATIONS",
|
|
16
|
+
"decode_smart",
|
|
17
|
+
"encode_op",
|
|
18
|
+
"decode_op",
|
|
19
|
+
"run_chain",
|
|
20
|
+
"DecoderChain",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# ── Реестр операций ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def _url_encode(s: str) -> str:
|
|
26
|
+
return urllib.parse.quote(s, safe="")
|
|
27
|
+
|
|
28
|
+
def _url_decode(s: str) -> str:
|
|
29
|
+
return urllib.parse.unquote(s)
|
|
30
|
+
|
|
31
|
+
def _url_encode_all(s: str) -> str:
|
|
32
|
+
return urllib.parse.quote(s, safe="")
|
|
33
|
+
|
|
34
|
+
def _base64_encode(s: str) -> str:
|
|
35
|
+
return base64.b64encode(s.encode()).decode()
|
|
36
|
+
|
|
37
|
+
def _base64_decode(s: str) -> str:
|
|
38
|
+
# Добавляем паддинг если нужно
|
|
39
|
+
padded = s + "=" * (4 - len(s) % 4) if len(s) % 4 else s
|
|
40
|
+
return base64.b64decode(padded).decode("utf-8", errors="replace")
|
|
41
|
+
|
|
42
|
+
def _base64url_encode(s: str) -> str:
|
|
43
|
+
return base64.urlsafe_b64encode(s.encode()).decode().rstrip("=")
|
|
44
|
+
|
|
45
|
+
def _base64url_decode(s: str) -> str:
|
|
46
|
+
padded = s + "=" * (4 - len(s) % 4) if len(s) % 4 else s
|
|
47
|
+
return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
|
48
|
+
|
|
49
|
+
def _html_encode(s: str) -> str:
|
|
50
|
+
return html.escape(s, quote=True)
|
|
51
|
+
|
|
52
|
+
def _html_decode(s: str) -> str:
|
|
53
|
+
return html.unescape(s)
|
|
54
|
+
|
|
55
|
+
def _hex_encode(s: str) -> str:
|
|
56
|
+
return s.encode().hex()
|
|
57
|
+
|
|
58
|
+
def _hex_decode(s: str) -> str:
|
|
59
|
+
# Убираем разделители
|
|
60
|
+
clean = re.sub(r"[\s:%-]", "", s)
|
|
61
|
+
# Оставляем только hex-символы
|
|
62
|
+
hex_only = re.sub(r"[^0-9a-fA-F]", "", clean)
|
|
63
|
+
if not hex_only:
|
|
64
|
+
return s # не hex — возвращаем как есть
|
|
65
|
+
# Выравниваем до чётной длины
|
|
66
|
+
if len(hex_only) % 2:
|
|
67
|
+
hex_only = "0" + hex_only
|
|
68
|
+
return bytes.fromhex(hex_only).decode("utf-8", errors="replace")
|
|
69
|
+
|
|
70
|
+
def _unicode_encode(s: str) -> str:
|
|
71
|
+
result = ""
|
|
72
|
+
for ch in s:
|
|
73
|
+
cp = ord(ch)
|
|
74
|
+
if cp > 127:
|
|
75
|
+
result += f"\\u{cp:04x}"
|
|
76
|
+
else:
|
|
77
|
+
result += ch
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def _unicode_decode(s: str) -> str:
|
|
81
|
+
try:
|
|
82
|
+
return s.encode("raw_unicode_escape").decode("unicode_escape")
|
|
83
|
+
except Exception:
|
|
84
|
+
return re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), s)
|
|
85
|
+
|
|
86
|
+
def _jwt_decode(s: str) -> str:
|
|
87
|
+
"""Декодирует header + payload JWT без верификации подписи."""
|
|
88
|
+
parts = s.strip().split(".")
|
|
89
|
+
if len(parts) < 2:
|
|
90
|
+
return "Invalid JWT"
|
|
91
|
+
result = {}
|
|
92
|
+
for i, name in enumerate(("header", "payload")):
|
|
93
|
+
try:
|
|
94
|
+
part = parts[i]
|
|
95
|
+
padded = part + "=" * (4 - len(part) % 4) if len(part) % 4 else part
|
|
96
|
+
decoded = base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
|
97
|
+
result[name] = json.loads(decoded)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
result[name] = f"[error: {exc}]"
|
|
100
|
+
return json.dumps(result, indent=2, ensure_ascii=False)
|
|
101
|
+
|
|
102
|
+
def _md5(s: str) -> str:
|
|
103
|
+
return hashlib.md5(s.encode()).hexdigest()
|
|
104
|
+
|
|
105
|
+
def _sha1(s: str) -> str:
|
|
106
|
+
return hashlib.sha1(s.encode()).hexdigest()
|
|
107
|
+
|
|
108
|
+
def _sha256(s: str) -> str:
|
|
109
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
110
|
+
|
|
111
|
+
def _sha512(s: str) -> str:
|
|
112
|
+
return hashlib.sha512(s.encode()).hexdigest()
|
|
113
|
+
|
|
114
|
+
def _gzip_encode(s: str) -> str:
|
|
115
|
+
import gzip
|
|
116
|
+
compressed = gzip.compress(s.encode())
|
|
117
|
+
return base64.b64encode(compressed).decode()
|
|
118
|
+
|
|
119
|
+
def _gzip_decode(s: str) -> str:
|
|
120
|
+
import gzip
|
|
121
|
+
try:
|
|
122
|
+
padded = s + "=" * (4 - len(s) % 4) if len(s) % 4 else s
|
|
123
|
+
compressed = base64.b64decode(padded)
|
|
124
|
+
return gzip.decompress(compressed).decode("utf-8", errors="replace")
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
return f"[gzip error: {exc}]"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Операции: (label, fn, is_hash) ────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
OPERATIONS: list[tuple[str, Callable[[str], str], bool]] = [
|
|
132
|
+
("URL Encode", _url_encode, False),
|
|
133
|
+
("URL Decode", _url_decode, False),
|
|
134
|
+
("Base64 Encode", _base64_encode, False),
|
|
135
|
+
("Base64 Decode", _base64_decode, False),
|
|
136
|
+
("Base64URL Encode", _base64url_encode, False),
|
|
137
|
+
("Base64URL Decode", _base64url_decode, False),
|
|
138
|
+
("HTML Encode", _html_encode, False),
|
|
139
|
+
("HTML Decode", _html_decode, False),
|
|
140
|
+
("Hex Encode", _hex_encode, False),
|
|
141
|
+
("Hex Decode", _hex_decode, False),
|
|
142
|
+
("Unicode Encode", _unicode_encode, False),
|
|
143
|
+
("Unicode Decode", _unicode_decode, False),
|
|
144
|
+
("JWT Decode", _jwt_decode, False),
|
|
145
|
+
("Gzip+B64 Encode", _gzip_encode, False),
|
|
146
|
+
("Gzip+B64 Decode", _gzip_decode, False),
|
|
147
|
+
("MD5", _md5, True),
|
|
148
|
+
("SHA1", _sha1, True),
|
|
149
|
+
("SHA256", _sha256, True),
|
|
150
|
+
("SHA512", _sha512, True),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
_OP_MAP: dict[str, Callable[[str], str]] = {label: fn for label, fn, _ in OPERATIONS}
|
|
154
|
+
OP_LABELS: list[str] = [label for label, _, _ in OPERATIONS]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def encode_op(operation: str, text: str) -> str:
|
|
158
|
+
"""Применить операцию по имени. Raises KeyError если операция неизвестна."""
|
|
159
|
+
fn = _OP_MAP.get(operation)
|
|
160
|
+
if fn is None:
|
|
161
|
+
raise KeyError(f"Unknown operation: {operation!r}")
|
|
162
|
+
return fn(text)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def decode_op(operation: str, text: str) -> str:
|
|
166
|
+
"""Alias для encode_op (операции уже включают направление в названии)."""
|
|
167
|
+
return encode_op(operation, text)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_chain(operations: list[str], text: str) -> tuple[str, list[str]]:
|
|
171
|
+
"""Применить цепочку операций последовательно.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
(result, steps) — итоговый текст и список промежуточных значений.
|
|
175
|
+
"""
|
|
176
|
+
steps: list[str] = [text]
|
|
177
|
+
current = text
|
|
178
|
+
for op in operations:
|
|
179
|
+
try:
|
|
180
|
+
current = encode_op(op, current)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
current = f"[error in {op!r}: {exc}]"
|
|
183
|
+
steps.append(current)
|
|
184
|
+
return current, steps
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _detect_encoding(text: str) -> str | None:
|
|
188
|
+
"""Detect the most likely encoding of text. Returns operation label or None."""
|
|
189
|
+
# JWT
|
|
190
|
+
if re.match(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$", text):
|
|
191
|
+
return "JWT Decode"
|
|
192
|
+
# URL-encoded
|
|
193
|
+
if "%" in text and re.search(r"%[0-9a-fA-F]{2}", text):
|
|
194
|
+
return "URL Decode"
|
|
195
|
+
# HTML entities
|
|
196
|
+
if re.search(r"&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", text):
|
|
197
|
+
return "HTML Decode"
|
|
198
|
+
# Gzip+B64 (gzip magic bytes when decoded: 1f8b)
|
|
199
|
+
b64_clean = text.strip().rstrip("=")
|
|
200
|
+
if re.match(r"^[A-Za-z0-9+/]+$", b64_clean) and len(b64_clean) % 4 in (0, 2, 3):
|
|
201
|
+
try:
|
|
202
|
+
raw = base64.b64decode(text + "=" * (-len(text) % 4))
|
|
203
|
+
if raw[:2] == b"\x1f\x8b":
|
|
204
|
+
return "Gzip+B64 Decode"
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
# Hex
|
|
208
|
+
if re.match(r"^[0-9a-fA-F]+$", text) and len(text) % 2 == 0 and len(text) >= 4:
|
|
209
|
+
try:
|
|
210
|
+
decoded = bytes.fromhex(text).decode("utf-8", errors="strict")
|
|
211
|
+
if decoded.isprintable():
|
|
212
|
+
return "Hex Decode"
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
# Base64URL (no +/ chars)
|
|
216
|
+
if re.match(r"^[A-Za-z0-9_-]+=*$", text) and len(text) >= 8:
|
|
217
|
+
try:
|
|
218
|
+
decoded = _base64url_decode(text)
|
|
219
|
+
if len(decoded) > 0 and sum(c.isprintable() for c in decoded) / len(decoded) > 0.8:
|
|
220
|
+
return "Base64URL Decode"
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
# Base64 standard
|
|
224
|
+
if re.match(r"^[A-Za-z0-9+/]+=*$", text) and len(text) >= 8 and len(text) % 4 in (0, 2, 3):
|
|
225
|
+
try:
|
|
226
|
+
decoded = _base64_decode(text)
|
|
227
|
+
if len(decoded) > 0 and sum(c.isprintable() for c in decoded) / len(decoded) > 0.8:
|
|
228
|
+
return "Base64 Decode"
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def decode_smart(text: str, max_depth: int = 8) -> str:
|
|
235
|
+
"""Auto-detect encoding and apply chain decoding until no more layers found.
|
|
236
|
+
|
|
237
|
+
Tries up to max_depth decode operations. Shows the chain in the result
|
|
238
|
+
by prepending a header line when multiple steps were applied.
|
|
239
|
+
Returns the final decoded value.
|
|
240
|
+
"""
|
|
241
|
+
text = text.strip()
|
|
242
|
+
current = text
|
|
243
|
+
chain: list[str] = []
|
|
244
|
+
|
|
245
|
+
for _ in range(max_depth):
|
|
246
|
+
op = _detect_encoding(current)
|
|
247
|
+
if op is None:
|
|
248
|
+
break
|
|
249
|
+
try:
|
|
250
|
+
next_val = encode_op(op, current)
|
|
251
|
+
if next_val == current:
|
|
252
|
+
break
|
|
253
|
+
chain.append(op)
|
|
254
|
+
current = next_val
|
|
255
|
+
except Exception:
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if not chain:
|
|
259
|
+
return text
|
|
260
|
+
return current
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class DecoderChain:
|
|
265
|
+
"""Цепочка операций с историей."""
|
|
266
|
+
|
|
267
|
+
operations: list[str] = field(default_factory=list)
|
|
268
|
+
|
|
269
|
+
def add(self, op: str) -> None:
|
|
270
|
+
if op not in OP_LABELS:
|
|
271
|
+
raise KeyError(f"Unknown operation: {op!r}")
|
|
272
|
+
self.operations.append(op)
|
|
273
|
+
|
|
274
|
+
def remove(self, index: int) -> None:
|
|
275
|
+
del self.operations[index]
|
|
276
|
+
|
|
277
|
+
def clear(self) -> None:
|
|
278
|
+
self.operations.clear()
|
|
279
|
+
|
|
280
|
+
def run(self, text: str) -> tuple[str, list[str]]:
|
|
281
|
+
return run_chain(self.operations, text)
|