pentool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +16 -0
  3. pentool/api/__init__.py +41 -0
  4. pentool/api/base_api.py +48 -0
  5. pentool/api/comparer_api.py +19 -0
  6. pentool/api/decoder_api.py +24 -0
  7. pentool/api/intruder_api.py +171 -0
  8. pentool/api/proxy_api.py +210 -0
  9. pentool/api/repeater_api.py +57 -0
  10. pentool/api/scanner_api.py +266 -0
  11. pentool/api/sequencer_api.py +17 -0
  12. pentool/api/spider_api.py +106 -0
  13. pentool/api/target_api.py +87 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +98 -0
  16. pentool/cli/project.py +88 -0
  17. pentool/cli/proxy.py +173 -0
  18. pentool/cli/scan.py +115 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +171 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +207 -0
  24. pentool/core/events.py +160 -0
  25. pentool/core/features.py +373 -0
  26. pentool/core/license.py +274 -0
  27. pentool/core/logging.py +54 -0
  28. pentool/core/plugin_manager.py +312 -0
  29. pentool/core/project.py +31 -0
  30. pentool/core/storage_interface.py +307 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +36 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +428 -0
  37. pentool/modules/intruder_turbo.py +176 -0
  38. pentool/modules/match_replace.py +142 -0
  39. pentool/modules/proxy.py +797 -0
  40. pentool/modules/repeater.py +195 -0
  41. pentool/modules/sequencer.py +492 -0
  42. pentool/modules/spider.py +679 -0
  43. pentool/modules/target.py +185 -0
  44. pentool/modules/websocket_handler.py +249 -0
  45. pentool/plugins/__init__.py +1 -0
  46. pentool/plugins/builtin/__init__.py +1 -0
  47. pentool/plugins/builtin/payloads_pro.py +381 -0
  48. pentool/plugins/builtin/reports_pro.py +436 -0
  49. pentool/plugins/builtin/scanner_pro.py +291 -0
  50. pentool/plugins/example_plugin.py +39 -0
  51. pentool/services/__init__.py +1 -0
  52. pentool/services/base_service.py +60 -0
  53. pentool/services/intruder_service.py +94 -0
  54. pentool/services/proxy_service.py +178 -0
  55. pentool/services/repeater_service.py +85 -0
  56. pentool/services/scan_service.py +330 -0
  57. pentool/storage/__init__.py +0 -0
  58. pentool/storage/http_storage.py +506 -0
  59. pentool/storage/large_body_handler.py +50 -0
  60. pentool/storage/lru_cache.py +45 -0
  61. pentool/t.py +213 -0
  62. pentool/tui/__init__.py +1 -0
  63. pentool/tui/app.py +1429 -0
  64. pentool/tui/constants.py +79 -0
  65. pentool/tui/dialogs/__init__.py +0 -0
  66. pentool/tui/dialogs/cert_dialog.py +81 -0
  67. pentool/tui/dialogs/file_selector.py +226 -0
  68. pentool/tui/dialogs/load_from_proxy.py +125 -0
  69. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  70. pentool/tui/dialogs/project_dialog.py +82 -0
  71. pentool/tui/dialogs/scope_dialog.py +224 -0
  72. pentool/tui/messages.py +103 -0
  73. pentool/tui/mixins/__init__.py +0 -0
  74. pentool/tui/mixins/app_mixin.py +59 -0
  75. pentool/tui/mixins/dialog_cancel.py +21 -0
  76. pentool/tui/mixins/request_context_menu.py +247 -0
  77. pentool/tui/mixins/tab_rename.py +89 -0
  78. pentool/tui/screens/__init__.py +31 -0
  79. pentool/tui/screens/comparer/__init__.py +3 -0
  80. pentool/tui/screens/comparer/screen.py +229 -0
  81. pentool/tui/screens/dashboard/__init__.py +2 -0
  82. pentool/tui/screens/dashboard/live_dashboard.py +730 -0
  83. pentool/tui/screens/dashboard/screen.py +771 -0
  84. pentool/tui/screens/decoder/__init__.py +3 -0
  85. pentool/tui/screens/decoder/screen.py +306 -0
  86. pentool/tui/screens/extensions/__init__.py +3 -0
  87. pentool/tui/screens/extensions/screen.py +24 -0
  88. pentool/tui/screens/intruder/__init__.py +3 -0
  89. pentool/tui/screens/intruder/screen.py +1356 -0
  90. pentool/tui/screens/proxy/__init__.py +3 -0
  91. pentool/tui/screens/proxy/screen.py +1554 -0
  92. pentool/tui/screens/repeater/__init__.py +3 -0
  93. pentool/tui/screens/repeater/screen.py +621 -0
  94. pentool/tui/screens/scanner/__init__.py +3 -0
  95. pentool/tui/screens/scanner/screen.py +1956 -0
  96. pentool/tui/screens/sequencer/__init__.py +3 -0
  97. pentool/tui/screens/sequencer/screen.py +516 -0
  98. pentool/tui/screens/settings/__init__.py +5 -0
  99. pentool/tui/screens/settings/hotkeys.py +134 -0
  100. pentool/tui/screens/settings/screen.py +540 -0
  101. pentool/tui/screens/spider/__init__.py +3 -0
  102. pentool/tui/screens/spider/screen.py +380 -0
  103. pentool/tui/screens/target/__init__.py +4 -0
  104. pentool/tui/screens/target/screen.py +358 -0
  105. pentool/tui/screens/terminal/__init__.py +5 -0
  106. pentool/tui/screens/terminal/screen.py +179 -0
  107. pentool/tui/widgets/__init__.py +1 -0
  108. pentool/tui/widgets/context_menu.py +148 -0
  109. pentool/tui/widgets/filter_bar.py +204 -0
  110. pentool/tui/widgets/inspector_panel.py +111 -0
  111. pentool/tui/widgets/menu.py +86 -0
  112. pentool/tui/widgets/menu_bar.py +238 -0
  113. pentool/tui/widgets/module_tabs.py +68 -0
  114. pentool/tui/widgets/payload_drop_zone.py +65 -0
  115. pentool/tui/widgets/request_editor.py +495 -0
  116. pentool/tui/widgets/resize_handle.py +116 -0
  117. pentool/tui/widgets/search_bar.py +112 -0
  118. pentool/tui/widgets/statusbar.py +96 -0
  119. pentool/tui/widgets/toolbar_button.py +68 -0
  120. pentool/utils/__init__.py +1 -0
  121. pentool/utils/cert.py +314 -0
  122. pentool/utils/coder.py +170 -0
  123. pentool/utils/copy_as.py +250 -0
  124. pentool/utils/diff.py +82 -0
  125. pentool/utils/http_client.py +145 -0
  126. pentool/utils/parser.py +227 -0
  127. pentool/utils/terminal_check.py +31 -0
  128. pentool-0.1.0.dist-info/METADATA +231 -0
  129. pentool-0.1.0.dist-info/RECORD +133 -0
  130. pentool-0.1.0.dist-info/WHEEL +5 -0
  131. pentool-0.1.0.dist-info/entry_points.txt +2 -0
  132. pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
  133. pentool-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ from pentool.tui.screens.sequencer.screen import SequencerScreen
2
+
3
+ __all__ = ["SequencerScreen"]
@@ -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,5 @@
1
+ """Settings screen package."""
2
+
3
+ from pentool.tui.screens.settings.screen import SettingsScreen, UIMode
4
+
5
+ __all__ = ["SettingsScreen", "UIMode"]
@@ -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)