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
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Экран Sequencer — анализ энтропии токенов."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import os
|
|
7
|
+
import math
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.widgets import Input, Label, RichLog, Static, TextArea
|
|
15
|
+
|
|
16
|
+
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
17
|
+
from pentool.tui.widgets.resize_handle import ResizeHandle
|
|
18
|
+
from pentool.core.logging import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
_SOURCE_OPTIONS = [
|
|
25
|
+
("Manual input", "manual"),
|
|
26
|
+
("Proxy param", "proxy"),
|
|
27
|
+
("Cookie header", "cookie"),
|
|
28
|
+
("Body regex", "body_regex"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SequencerScreen(Widget):
|
|
33
|
+
"""Захват и анализ энтропии токенов (session IDs, CSRF, JWT…)."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_CSS = _CSS
|
|
36
|
+
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
Binding("ctrl+enter", "analyze", "Analyze", show=True),
|
|
39
|
+
Binding("ctrl+l", "clear_tokens", "Clear", show=False),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(self, **kwargs) -> None:
|
|
43
|
+
super().__init__(**kwargs)
|
|
44
|
+
from pentool.api.sequencer_api import Sequencer
|
|
45
|
+
self._seq = Sequencer()
|
|
46
|
+
self._capturing = False # режим перехвата из прокси
|
|
47
|
+
self._capture_param: str = "" # имя параметра/cookie для захвата
|
|
48
|
+
self._proxy_hook = None # ссылка для unsubscribe
|
|
49
|
+
self._source: str = "manual" # текущий выбранный источник
|
|
50
|
+
|
|
51
|
+
def compose(self) -> ComposeResult:
|
|
52
|
+
# ── Тулбар ─────────────────────────────────────────────────────────────
|
|
53
|
+
with Horizontal(id="seq-toolbar"):
|
|
54
|
+
yield ToolbarButton("▶ Capture", "btn-seq-capture")
|
|
55
|
+
yield Static(" │ ", classes="seq-sep")
|
|
56
|
+
yield ToolbarButton("■ Stop", "btn-seq-stop", classes="disabled")
|
|
57
|
+
yield Static(" │ ", classes="seq-sep")
|
|
58
|
+
yield ToolbarButton("⚡ Analyze", "btn-seq-analyze")
|
|
59
|
+
yield Static(" │ ", classes="seq-sep")
|
|
60
|
+
yield ToolbarButton("📂 Load File", "btn-seq-load")
|
|
61
|
+
yield Static(" │ ", classes="seq-sep")
|
|
62
|
+
yield ToolbarButton("🗑 Clear", "btn-seq-clear")
|
|
63
|
+
yield Static(" │ ", classes="seq-sep")
|
|
64
|
+
yield ToolbarButton("📋 Copy", "btn-seq-copy")
|
|
65
|
+
yield Static(" │ ", classes="seq-sep")
|
|
66
|
+
yield ToolbarButton("💾 Export", "btn-seq-export")
|
|
67
|
+
|
|
68
|
+
# ── Config row ─────────────────────────────────────────────────────────
|
|
69
|
+
with Horizontal(id="seq-config-row"):
|
|
70
|
+
yield Label("Source:", classes="seq-cfg-label")
|
|
71
|
+
yield ToolbarButton("Manual input ▼", "btn-seq-source")
|
|
72
|
+
yield Label(" Param:", classes="seq-cfg-label")
|
|
73
|
+
yield Input(
|
|
74
|
+
placeholder="cookie/header param name",
|
|
75
|
+
id="seq-param-input",
|
|
76
|
+
compact=True,
|
|
77
|
+
)
|
|
78
|
+
yield Static("Captured: 0", id="seq-counter")
|
|
79
|
+
|
|
80
|
+
# ── Summary bar ────────────────────────────────────────────────────────
|
|
81
|
+
with Horizontal(id="seq-summary-bar"):
|
|
82
|
+
yield Static(
|
|
83
|
+
"[dim]— Add tokens manually or capture from Proxy, then press Analyze —[/dim]",
|
|
84
|
+
id="seq-summary", markup=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ── Main area ──────────────────────────────────────────────────────────
|
|
88
|
+
with Horizontal(id="seq-main-area"):
|
|
89
|
+
# Левая: ввод токенов (по одному на строку)
|
|
90
|
+
with Vertical(id="seq-input-col"):
|
|
91
|
+
yield Static("Tokens (one per line)", id="seq-tokens-label",
|
|
92
|
+
classes="seq-col-label")
|
|
93
|
+
yield TextArea(id="seq-token-area", language=None)
|
|
94
|
+
|
|
95
|
+
yield ResizeHandle("seq-input-col", "seq-analysis-col", id="seq-resize-h")
|
|
96
|
+
|
|
97
|
+
# Правая: результаты анализа
|
|
98
|
+
with Vertical(id="seq-analysis-col"):
|
|
99
|
+
yield Static("Analysis", id="seq-analysis-label",
|
|
100
|
+
classes="seq-col-label")
|
|
101
|
+
yield RichLog(id="seq-analysis-log", highlight=True, markup=True,
|
|
102
|
+
wrap=True, max_lines=500)
|
|
103
|
+
|
|
104
|
+
yield ResizeHandle("seq-main-area", "seq-gauge-area", vertical=True, id="seq-resize-v")
|
|
105
|
+
|
|
106
|
+
# ── Entropy gauge ──────────────────────────────────────────────────────
|
|
107
|
+
with Vertical(id="seq-gauge-area"):
|
|
108
|
+
yield Static("ENTROPY", id="seq-gauge-label")
|
|
109
|
+
yield Static(" ", id="seq-gauge")
|
|
110
|
+
yield Static(" ", id="seq-assessment")
|
|
111
|
+
yield Static(" ", id="seq-bits-label")
|
|
112
|
+
|
|
113
|
+
yield Static(
|
|
114
|
+
"▶ Capture: start live capture │ ⚡ Analyze: run entropy analysis"
|
|
115
|
+
" │ 📂 Load File: load tokens from file │ 💾 Export: save results",
|
|
116
|
+
id="status-bar",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ── Toolbar ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
@on(ToolbarButton.Pressed, "#btn-seq-capture")
|
|
122
|
+
def on_btn_seq_capture(self, _: ToolbarButton.Pressed) -> None:
|
|
123
|
+
self._start_capture()
|
|
124
|
+
|
|
125
|
+
@on(ToolbarButton.Pressed, "#btn-seq-stop")
|
|
126
|
+
def on_btn_seq_stop(self, _: ToolbarButton.Pressed) -> None:
|
|
127
|
+
self._stop_capture()
|
|
128
|
+
|
|
129
|
+
@on(ToolbarButton.Pressed, "#btn-seq-analyze")
|
|
130
|
+
def on_btn_seq_analyze(self, _: ToolbarButton.Pressed) -> None:
|
|
131
|
+
self.action_analyze()
|
|
132
|
+
|
|
133
|
+
@on(ToolbarButton.Pressed, "#btn-seq-load")
|
|
134
|
+
def on_btn_seq_load(self, _: ToolbarButton.Pressed) -> None:
|
|
135
|
+
self._load_file()
|
|
136
|
+
|
|
137
|
+
@on(ToolbarButton.Pressed, "#btn-seq-clear")
|
|
138
|
+
def on_btn_seq_clear(self, _: ToolbarButton.Pressed) -> None:
|
|
139
|
+
self.action_clear_tokens()
|
|
140
|
+
|
|
141
|
+
@on(ToolbarButton.Pressed, "#btn-seq-copy")
|
|
142
|
+
def on_btn_seq_copy(self, _: ToolbarButton.Pressed) -> None:
|
|
143
|
+
self._copy_report()
|
|
144
|
+
|
|
145
|
+
@on(ToolbarButton.Pressed, "#btn-seq-export")
|
|
146
|
+
def on_btn_seq_export(self, _: ToolbarButton.Pressed) -> None:
|
|
147
|
+
self._export_report()
|
|
148
|
+
|
|
149
|
+
@on(ToolbarButton.Pressed, "#btn-seq-source")
|
|
150
|
+
def on_btn_seq_source(self, event: ToolbarButton.Pressed) -> None:
|
|
151
|
+
self._open_source_menu(event.button)
|
|
152
|
+
|
|
153
|
+
def _open_source_menu(self, btn: ToolbarButton) -> None:
|
|
154
|
+
items = [
|
|
155
|
+
(val, ("✓ " if val == self._source else " ") + label)
|
|
156
|
+
for label, val in _SOURCE_OPTIONS
|
|
157
|
+
]
|
|
158
|
+
r = btn.region
|
|
159
|
+
self.app.show_context_menu(items, r.x, r.y + 1, callback=self._on_source_selected)
|
|
160
|
+
|
|
161
|
+
def _on_source_selected(self, val: str) -> None:
|
|
162
|
+
self._source = val
|
|
163
|
+
label = next((l for l, v in _SOURCE_OPTIONS if v == val), val)
|
|
164
|
+
try:
|
|
165
|
+
btn = self.query_one("#btn-seq-source", ToolbarButton)
|
|
166
|
+
btn.label = f"{label} ▼"
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def _start_capture(self) -> None:
|
|
171
|
+
"""Начать захват токенов из прокси."""
|
|
172
|
+
try:
|
|
173
|
+
src = self._source
|
|
174
|
+
param = self.query_one("#seq-param-input", Input).value.strip()
|
|
175
|
+
|
|
176
|
+
if src in ("proxy", "cookie", "body_regex"):
|
|
177
|
+
proxy_api = getattr(self.app, "_proxy_api", None)
|
|
178
|
+
if not proxy_api:
|
|
179
|
+
self.app.notify("Proxy not available", severity="warning")
|
|
180
|
+
return
|
|
181
|
+
if src in ("proxy", "cookie") and not param:
|
|
182
|
+
self.app.notify("Enter param/cookie name to capture", severity="warning")
|
|
183
|
+
return
|
|
184
|
+
if src == "body_regex" and not param:
|
|
185
|
+
self.app.notify("Enter regex pattern for body extraction", severity="warning")
|
|
186
|
+
return
|
|
187
|
+
self._capturing = True
|
|
188
|
+
self._capture_param = param
|
|
189
|
+
self._attach_proxy_hook(proxy_api, param, src)
|
|
190
|
+
src_labels = {"proxy": "param", "cookie": "cookie", "body_regex": "regex"}
|
|
191
|
+
self.app.notify(
|
|
192
|
+
f"Capturing '{param}' ({src_labels[src]}) from Proxy…", timeout=3
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
self.app.notify("Switch to Proxy/Cookie/Body source for live capture", severity="info")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
self.query_one("#btn-seq-capture", ToolbarButton).disabled = True
|
|
199
|
+
self.query_one("#btn-seq-stop", ToolbarButton).disabled = False
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.debug("_start_capture: %s", exc)
|
|
202
|
+
|
|
203
|
+
def _attach_proxy_hook(self, proxy_api, param: str, source: str = "proxy") -> None:
|
|
204
|
+
"""Подписаться на прокси-запросы для извлечения токенов.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
proxy_api: ProxyAPI-объект для поиска запросов.
|
|
208
|
+
param: Имя параметра/cookie или regex-паттерн.
|
|
209
|
+
source: "proxy" — любой заголовок/query; "cookie" — только Cookie;
|
|
210
|
+
"body_regex" — regex по телу ответа.
|
|
211
|
+
"""
|
|
212
|
+
from pentool.core.event_bus import get_event_bus
|
|
213
|
+
from pentool.core.events import ProxyRequestDoneEvent
|
|
214
|
+
|
|
215
|
+
def _on_proxy_request(event: ProxyRequestDoneEvent) -> None:
|
|
216
|
+
if not self._capturing:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
req = proxy_api.find_request(event.request_id)
|
|
220
|
+
if req is None:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
token = None
|
|
224
|
+
|
|
225
|
+
if source == "cookie":
|
|
226
|
+
# Только Cookie header
|
|
227
|
+
for hdr_name, hdr_val in (req.headers or {}).items():
|
|
228
|
+
if hdr_name.lower() == "cookie":
|
|
229
|
+
token = self._seq.extract_from_header(hdr_val, param)
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
elif source == "body_regex":
|
|
233
|
+
# Regex по телу ответа
|
|
234
|
+
resp = getattr(req, "response", None)
|
|
235
|
+
body = ""
|
|
236
|
+
if resp is not None:
|
|
237
|
+
body = getattr(resp, "body", "") or ""
|
|
238
|
+
if body:
|
|
239
|
+
try:
|
|
240
|
+
m = re.search(param, body)
|
|
241
|
+
if m:
|
|
242
|
+
token = m.group(1) if m.lastindex else m.group(0)
|
|
243
|
+
except re.error:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
else:
|
|
247
|
+
# "proxy" — Cookie + query string + response headers
|
|
248
|
+
for hdr_name, hdr_val in (req.headers or {}).items():
|
|
249
|
+
if hdr_name.lower() == "cookie":
|
|
250
|
+
token = self._seq.extract_from_header(hdr_val, param)
|
|
251
|
+
if token:
|
|
252
|
+
break
|
|
253
|
+
if not token:
|
|
254
|
+
# Попробуем response Set-Cookie
|
|
255
|
+
resp = getattr(req, "response", None)
|
|
256
|
+
if resp is not None:
|
|
257
|
+
for hdr_name, hdr_val in (getattr(resp, "headers", None) or {}).items():
|
|
258
|
+
if hdr_name.lower() in ("set-cookie", "authorization"):
|
|
259
|
+
token = self._seq.extract_from_header(hdr_val, param)
|
|
260
|
+
if token:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
if token:
|
|
264
|
+
self.call_from_thread(self._update_counter)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.debug("_on_proxy_request hook: %s", exc)
|
|
267
|
+
|
|
268
|
+
self._proxy_hook = _on_proxy_request
|
|
269
|
+
get_event_bus().subscribe(ProxyRequestDoneEvent, _on_proxy_request)
|
|
270
|
+
|
|
271
|
+
def _stop_capture(self) -> None:
|
|
272
|
+
self._capturing = False
|
|
273
|
+
try:
|
|
274
|
+
from pentool.core.event_bus import get_event_bus
|
|
275
|
+
from pentool.core.events import ProxyRequestDoneEvent
|
|
276
|
+
if hasattr(self, "_proxy_hook"):
|
|
277
|
+
get_event_bus().unsubscribe(ProxyRequestDoneEvent, self._proxy_hook)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
try:
|
|
281
|
+
self.query_one("#btn-seq-capture", ToolbarButton).disabled = False
|
|
282
|
+
self.query_one("#btn-seq-stop", ToolbarButton).disabled = True
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
self.app.notify("Capture stopped", timeout=2)
|
|
286
|
+
|
|
287
|
+
def _update_counter(self) -> None:
|
|
288
|
+
try:
|
|
289
|
+
self.query_one("#seq-counter", Static).update(
|
|
290
|
+
f"Captured: [bold]{self._seq.count}[/bold]"
|
|
291
|
+
)
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def _load_file(self) -> None:
|
|
296
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
297
|
+
|
|
298
|
+
def _on_path(path: str | None) -> None:
|
|
299
|
+
if not path or not os.path.exists(path):
|
|
300
|
+
return
|
|
301
|
+
try:
|
|
302
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
303
|
+
added = self._seq.add_from_text(text)
|
|
304
|
+
# Обновить TextArea
|
|
305
|
+
self.query_one("#seq-token-area", TextArea).load_text(
|
|
306
|
+
"\n".join(self._seq.tokens)
|
|
307
|
+
)
|
|
308
|
+
self._update_counter()
|
|
309
|
+
self.app.notify(f"Loaded {added} tokens from {os.path.basename(path)}", timeout=3)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
self.app.notify(f"Load failed: {exc}", severity="error")
|
|
312
|
+
|
|
313
|
+
self.app.push_screen(
|
|
314
|
+
FileSelectorDialog(mode=FileSelectorMode.OPEN, title="Load Tokens"),
|
|
315
|
+
_on_path,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _copy_report(self) -> None:
|
|
319
|
+
try:
|
|
320
|
+
from pentool.utils.copy_as import copy_to_clipboard
|
|
321
|
+
report = self._seq.analyze()
|
|
322
|
+
text = re.sub(r"\[/?[^\]]+\]", "", report.summary())
|
|
323
|
+
if copy_to_clipboard(text):
|
|
324
|
+
self.app.notify("Report copied", timeout=2)
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
self.app.notify(f"Copy failed: {exc}", severity="error")
|
|
327
|
+
|
|
328
|
+
def _export_report(self) -> None:
|
|
329
|
+
if self._seq.count == 0:
|
|
330
|
+
self.app.notify("No tokens to export", severity="warning")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
334
|
+
|
|
335
|
+
def _on_path(path: str | None) -> None:
|
|
336
|
+
if not path:
|
|
337
|
+
return
|
|
338
|
+
try:
|
|
339
|
+
report = self._seq.analyze()
|
|
340
|
+
lines: list[str] = []
|
|
341
|
+
lines.append("=" * 60)
|
|
342
|
+
lines.append("PENTOOL — Sequencer Analysis Report")
|
|
343
|
+
lines.append("=" * 60)
|
|
344
|
+
lines.append("")
|
|
345
|
+
lines.append(f"Token Count: {report.token_count}")
|
|
346
|
+
lines.append(f"Length: {report.min_length}–{report.max_length} (avg {report.avg_length:.1f})")
|
|
347
|
+
lines.append(f"Charset: ~{report.charset_estimate} chars")
|
|
348
|
+
lines.append(f"Entropy: {report.mean_entropy:.3f} bits/char")
|
|
349
|
+
lines.append(f"Total bits: {report.mean_total_bits:.1f}")
|
|
350
|
+
lines.append(f"Effective: {report.effective_bits:.1f} bits")
|
|
351
|
+
lines.append(f"Duplicates: {report.duplicates}")
|
|
352
|
+
lines.append(f"Assessment: {report.assessment}")
|
|
353
|
+
lines.append("")
|
|
354
|
+
lines.append("── FIPS 140-2 Statistical Tests ──")
|
|
355
|
+
if report.fips_results:
|
|
356
|
+
for r in report.fips_results:
|
|
357
|
+
lo = str(r.threshold_low) if r.threshold_low is not None else "—"
|
|
358
|
+
hi = str(r.threshold_high) if r.threshold_high is not None else "—"
|
|
359
|
+
lines.append(
|
|
360
|
+
f" {r.status:<10} {r.name:<22} value={r.value} range={lo}–{hi}"
|
|
361
|
+
)
|
|
362
|
+
all_pass = all(r.passed for r in report.fips_results)
|
|
363
|
+
lines.append(f"\n Overall: {'ALL PASS' if all_pass else 'SOME TESTS FAILED'}")
|
|
364
|
+
else:
|
|
365
|
+
lines.append(" (insufficient data)")
|
|
366
|
+
lines.append("")
|
|
367
|
+
lines.append("── Tokens ──")
|
|
368
|
+
for tok in report.tokens:
|
|
369
|
+
lines.append(f" {tok}")
|
|
370
|
+
import os
|
|
371
|
+
if not path.endswith(".txt"):
|
|
372
|
+
path = path + ".txt"
|
|
373
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
374
|
+
f.write("\n".join(lines))
|
|
375
|
+
self.app.notify(
|
|
376
|
+
f"Report exported → {os.path.basename(path)}", timeout=3
|
|
377
|
+
)
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
self.app.notify(f"Export failed: {exc}", severity="error")
|
|
380
|
+
|
|
381
|
+
self.app.push_screen(
|
|
382
|
+
FileSelectorDialog(mode=FileSelectorMode.SAVE, title="Export Report"),
|
|
383
|
+
_on_path,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# ── Analyze ───────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def action_analyze(self) -> None:
|
|
389
|
+
"""Синхронизировать токены из TextArea и запустить анализ."""
|
|
390
|
+
try:
|
|
391
|
+
# Синхронизировать ручной ввод в Sequencer
|
|
392
|
+
ta_text = self.query_one("#seq-token-area", TextArea).text.strip()
|
|
393
|
+
if ta_text:
|
|
394
|
+
self._seq.clear()
|
|
395
|
+
self._seq.add_from_text(ta_text)
|
|
396
|
+
|
|
397
|
+
if self._seq.count == 0:
|
|
398
|
+
self.app.notify("No tokens to analyze", severity="warning")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
self._update_counter()
|
|
402
|
+
report = self._seq.analyze()
|
|
403
|
+
self._render_report(report)
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
self.app.notify(f"Analyze error: {exc}", severity="error")
|
|
406
|
+
logger.debug("action_analyze: %s", exc)
|
|
407
|
+
|
|
408
|
+
def _render_report(self, report) -> None:
|
|
409
|
+
"""Отобразить отчёт в UI."""
|
|
410
|
+
try:
|
|
411
|
+
log = self.query_one("#seq-analysis-log", RichLog)
|
|
412
|
+
log.clear()
|
|
413
|
+
|
|
414
|
+
# Общая статистика
|
|
415
|
+
log.write(f"[bold cyan]── Token Analysis ── {report.token_count} tokens ──[/bold cyan]")
|
|
416
|
+
log.write("")
|
|
417
|
+
log.write(f"[bold]Count:[/bold] {report.token_count}")
|
|
418
|
+
log.write(f"[bold]Length:[/bold] {report.min_length}–{report.max_length} chars (avg {report.avg_length:.1f})")
|
|
419
|
+
log.write(f"[bold]Charset:[/bold] ~{report.charset_estimate} chars")
|
|
420
|
+
log.write(f"[bold]Entropy:[/bold] {report.mean_entropy:.3f} bits/char")
|
|
421
|
+
log.write(f"[bold]Total bits:[/bold] {report.mean_total_bits:.1f}")
|
|
422
|
+
log.write(f"[bold]Effective:[/bold] {report.effective_bits:.1f} bits")
|
|
423
|
+
if report.duplicates:
|
|
424
|
+
log.write(f"[bold red]Duplicates:[/bold red] [red]{report.duplicates}[/red]")
|
|
425
|
+
log.write("")
|
|
426
|
+
|
|
427
|
+
# Гистограмма длин
|
|
428
|
+
log.write(report.rich_histogram(width=25))
|
|
429
|
+
log.write("")
|
|
430
|
+
|
|
431
|
+
# Частота символов (топ 15)
|
|
432
|
+
log.write(report.rich_charfreq(top_n=15))
|
|
433
|
+
|
|
434
|
+
# FIPS 140-2 тесты
|
|
435
|
+
log.write("")
|
|
436
|
+
log.write(report.rich_fips())
|
|
437
|
+
|
|
438
|
+
# Аномалии по позициям (для fixed-length токенов)
|
|
439
|
+
log.write("")
|
|
440
|
+
log.write(report.rich_position_anomalies())
|
|
441
|
+
|
|
442
|
+
# Summary
|
|
443
|
+
log.write("")
|
|
444
|
+
log.write(f"[bold]Summary:[/bold] {report.summary()}")
|
|
445
|
+
|
|
446
|
+
# Gauge
|
|
447
|
+
self._render_gauge(report)
|
|
448
|
+
# Summary bar
|
|
449
|
+
self.query_one("#seq-summary", Static).update(report.summary())
|
|
450
|
+
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
logger.debug("_render_report: %s", exc)
|
|
453
|
+
|
|
454
|
+
def _render_gauge(self, report) -> None:
|
|
455
|
+
"""Отрисовать entropy gauge в нижней панели."""
|
|
456
|
+
try:
|
|
457
|
+
bits = report.effective_bits
|
|
458
|
+
max_bits = 256.0
|
|
459
|
+
width = 48
|
|
460
|
+
filled = int((bits / max_bits) * width)
|
|
461
|
+
filled = max(0, min(width, filled))
|
|
462
|
+
|
|
463
|
+
if bits < 32:
|
|
464
|
+
color = "bold red"
|
|
465
|
+
elif bits < 64:
|
|
466
|
+
color = "yellow"
|
|
467
|
+
elif bits < 128:
|
|
468
|
+
color = "green"
|
|
469
|
+
else:
|
|
470
|
+
color = "bold green"
|
|
471
|
+
|
|
472
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
473
|
+
self.query_one("#seq-gauge", Static).update(
|
|
474
|
+
f"[{color}]{bar}[/{color}] [{color}]{bits:.0f} bits[/{color}]"
|
|
475
|
+
)
|
|
476
|
+
assessment = report.assessment
|
|
477
|
+
a_color = "bold red" if "WEAK" in assessment else (
|
|
478
|
+
"yellow" if "MODERATE" in assessment else (
|
|
479
|
+
"bold green" if "STRONG" in assessment else "green"
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
self.query_one("#seq-assessment", Static).update(
|
|
483
|
+
f"[{a_color}]{assessment}[/{a_color}]"
|
|
484
|
+
)
|
|
485
|
+
self.query_one("#seq-bits-label", Static).update(
|
|
486
|
+
f"[dim]Entropy: {report.mean_entropy:.3f} bits/char × "
|
|
487
|
+
f"{report.avg_length:.1f} chars avg = {report.mean_total_bits:.1f} bits[/dim]"
|
|
488
|
+
)
|
|
489
|
+
except Exception as exc:
|
|
490
|
+
logger.debug("_render_gauge: %s", exc)
|
|
491
|
+
|
|
492
|
+
# ── Clear ─────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
def action_clear_tokens(self) -> None:
|
|
495
|
+
self._seq.clear()
|
|
496
|
+
try:
|
|
497
|
+
self.query_one("#seq-token-area", TextArea).load_text("")
|
|
498
|
+
self.query_one("#seq-analysis-log", RichLog).clear()
|
|
499
|
+
self.query_one("#seq-counter", Static).update("Captured: 0")
|
|
500
|
+
self.query_one("#seq-summary", Static).update(
|
|
501
|
+
"[dim]— Add tokens manually or capture from Proxy, then press Analyze —[/dim]"
|
|
502
|
+
)
|
|
503
|
+
self.query_one("#seq-gauge", Static).update(" ")
|
|
504
|
+
self.query_one("#seq-assessment", Static).update(" ")
|
|
505
|
+
self.query_one("#seq-bits-label", Static).update(" ")
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
logger.debug("action_clear_tokens: %s", exc)
|
|
508
|
+
|
|
509
|
+
def on_unmount(self) -> None:
|
|
510
|
+
self._stop_capture()
|
|
511
|
+
|
|
512
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
def add_token(self, token: str) -> None:
|
|
515
|
+
self._seq.add_token(token)
|
|
516
|
+
self.call_from_thread(self._update_counter)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Экран настройки горячих клавиш."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Button, DataTable, Input, Label, Static
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_CSS = (Path(__file__).parent / "hotkeys.tcss").read_text(encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
DEFAULT_HOTKEYS: dict[str, str] = {
|
|
14
|
+
"send_to_repeater": "ctrl+r",
|
|
15
|
+
"send_to_intruder": "ctrl+i",
|
|
16
|
+
"send_to_decoder": "ctrl+d",
|
|
17
|
+
"repeater_send": "ctrl+space",
|
|
18
|
+
"toggle_intercept": "ctrl+t",
|
|
19
|
+
"new_project": "ctrl+n",
|
|
20
|
+
"save_project": "ctrl+s",
|
|
21
|
+
"open_project": "ctrl+o",
|
|
22
|
+
"quit": "ctrl+q",
|
|
23
|
+
"toggle_inspector": "i",
|
|
24
|
+
"search": "ctrl+f",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_ACTION_LABELS: dict[str, str] = {
|
|
28
|
+
"send_to_repeater": "Send to Repeater",
|
|
29
|
+
"send_to_intruder": "Send to Intruder",
|
|
30
|
+
"send_to_decoder": "Send to Decoder",
|
|
31
|
+
"repeater_send": "Repeater: Send Request",
|
|
32
|
+
"toggle_intercept": "Toggle Intercept",
|
|
33
|
+
"new_project": "New Project",
|
|
34
|
+
"save_project": "Save Project",
|
|
35
|
+
"open_project": "Open Project",
|
|
36
|
+
"quit": "Quit",
|
|
37
|
+
"toggle_inspector": "Toggle Inspector",
|
|
38
|
+
"search": "Search (Ctrl+F)",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HotkeySettingsScreen(Widget):
|
|
43
|
+
"""Экран настройки горячих клавиш."""
|
|
44
|
+
|
|
45
|
+
DEFAULT_CSS = _CSS
|
|
46
|
+
|
|
47
|
+
def __init__(self, hotkeys: dict[str, str] | None = None, **kwargs) -> None:
|
|
48
|
+
super().__init__(**kwargs)
|
|
49
|
+
self._hotkeys: dict[str, str] = dict(hotkeys or DEFAULT_HOTKEYS)
|
|
50
|
+
self._editing_action: str | None = None
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
yield Static("Hotkeys", classes="section-title")
|
|
54
|
+
yield DataTable(id="hotkey-table", cursor_type="row", zebra_stripes=True)
|
|
55
|
+
with Horizontal(id="hotkey-edit-row"):
|
|
56
|
+
yield Label("New hotkey:", id="hk-edit-label")
|
|
57
|
+
yield Input(placeholder="e.g. ctrl+r", id="hk-edit-input", compact=True)
|
|
58
|
+
yield Button("OK", id="hk-edit-ok", variant="primary")
|
|
59
|
+
yield Button("Cancel", id="hk-edit-cancel")
|
|
60
|
+
with Horizontal(id="hotkey-buttons"):
|
|
61
|
+
yield Button("Reset to defaults", id="hk-reset")
|
|
62
|
+
yield Button("Save", id="hk-save", variant="primary")
|
|
63
|
+
|
|
64
|
+
def on_mount(self) -> None:
|
|
65
|
+
self._refresh_table()
|
|
66
|
+
|
|
67
|
+
def _refresh_table(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
table = self.query_one("#hotkey-table", DataTable)
|
|
70
|
+
table.clear(columns=True)
|
|
71
|
+
table.add_columns("Action", "Current Hotkey", "Default")
|
|
72
|
+
for action, current in self._hotkeys.items():
|
|
73
|
+
label = _ACTION_LABELS.get(action, action)
|
|
74
|
+
default = DEFAULT_HOTKEYS.get(action, "—")
|
|
75
|
+
marker = "" if current == default else " *"
|
|
76
|
+
table.add_row(label, f"{current}{marker}", default)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
81
|
+
actions = list(self._hotkeys.keys())
|
|
82
|
+
if 0 <= event.cursor_row < len(actions):
|
|
83
|
+
self._start_edit(actions[event.cursor_row])
|
|
84
|
+
|
|
85
|
+
def _start_edit(self, action: str) -> None:
|
|
86
|
+
self._editing_action = action
|
|
87
|
+
try:
|
|
88
|
+
inp = self.query_one("#hk-edit-input", Input)
|
|
89
|
+
inp.value = self._hotkeys.get(action, "")
|
|
90
|
+
row = self.query_one("#hotkey-edit-row")
|
|
91
|
+
row.add_class("-visible")
|
|
92
|
+
inp.focus()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def _finish_edit(self) -> None:
|
|
97
|
+
try:
|
|
98
|
+
inp = self.query_one("#hk-edit-input", Input)
|
|
99
|
+
new_key = inp.value.strip()
|
|
100
|
+
if new_key and self._editing_action:
|
|
101
|
+
self._hotkeys[self._editing_action] = new_key
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
self._cancel_edit()
|
|
105
|
+
|
|
106
|
+
def _cancel_edit(self) -> None:
|
|
107
|
+
self._editing_action = None
|
|
108
|
+
try:
|
|
109
|
+
self.query_one("#hotkey-edit-row").remove_class("-visible")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
self._refresh_table()
|
|
113
|
+
|
|
114
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
115
|
+
bid = event.button.id
|
|
116
|
+
if bid == "hk-edit-ok":
|
|
117
|
+
self._finish_edit()
|
|
118
|
+
elif bid == "hk-edit-cancel":
|
|
119
|
+
self._cancel_edit()
|
|
120
|
+
elif bid == "hk-reset":
|
|
121
|
+
self._hotkeys = dict(DEFAULT_HOTKEYS)
|
|
122
|
+
self._refresh_table()
|
|
123
|
+
elif bid == "hk-save":
|
|
124
|
+
self._apply_hotkeys()
|
|
125
|
+
|
|
126
|
+
def _apply_hotkeys(self) -> None:
|
|
127
|
+
"""Применить хоткеи (сохранить в конфиг, rebind через app)."""
|
|
128
|
+
try:
|
|
129
|
+
self.app.notify("Hotkeys saved (restart required for some bindings)", timeout=3) # type: ignore[attr-defined]
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def get_hotkeys(self) -> dict[str, str]:
|
|
134
|
+
return dict(self._hotkeys)
|