pentool 1.0.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 (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -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 +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,75 @@
1
+ """Crash reporter — collects and sends anonymous crash reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import platform
8
+ import sys
9
+ import traceback
10
+ from typing import Any
11
+
12
+
13
+ def _get_metadata() -> dict[str, Any]:
14
+ """Collect anonymous metadata for crash report."""
15
+ try:
16
+ from pentool import __version__ as version
17
+ except Exception:
18
+ version = "unknown"
19
+ return {
20
+ "version": version,
21
+ "python": sys.version.split()[0],
22
+ "os": platform.system(),
23
+ "os_version": platform.release(),
24
+ "arch": platform.machine(),
25
+ }
26
+
27
+
28
+ def _anonymize(text: str) -> str:
29
+ """Remove paths and potential tokens from traceback text."""
30
+ import re
31
+ # Remove absolute paths — keep only filename
32
+ text = re.sub(r' File "([^"]+)"', lambda m: f' File "{os.path.basename(m.group(1))}"', text)
33
+ # Remove anything that looks like a token/key (long hex strings)
34
+ text = re.sub(r'\b[A-Fa-f0-9]{32,}\b', '<TOKEN>', text)
35
+ return text
36
+
37
+
38
+ async def send_crash_async(exc: BaseException, endpoint: str = "https://pentool.dev/api/crash") -> None:
39
+ """Send crash report asynchronously. Silently ignores any errors."""
40
+ try:
41
+ import aiohttp # type: ignore[import]
42
+ except ImportError:
43
+ return # aiohttp not available — skip silently
44
+
45
+ try:
46
+ tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
47
+ tb_text = _anonymize("".join(tb_lines))
48
+ payload = {
49
+ "traceback": tb_text,
50
+ "exception": type(exc).__name__,
51
+ **_get_metadata(),
52
+ }
53
+ timeout = aiohttp.ClientTimeout(total=8)
54
+ async with aiohttp.ClientSession(timeout=timeout) as session:
55
+ await session.post(endpoint, json=payload, ssl=False)
56
+ except Exception:
57
+ pass # never propagate errors from crash reporter
58
+
59
+
60
+ def send_crash(exc: BaseException) -> None:
61
+ """Fire-and-forget crash report from sync context."""
62
+ try:
63
+ from pentool.core.config import get_config
64
+ cfg = get_config()
65
+ if not getattr(cfg, "send_crash_reports", True):
66
+ return
67
+ except Exception:
68
+ pass
69
+
70
+ try:
71
+ loop = asyncio.new_event_loop()
72
+ loop.run_until_complete(send_crash_async(exc))
73
+ loop.close()
74
+ except Exception:
75
+ pass
@@ -0,0 +1,139 @@
1
+ """Работа с базой данных SQLite через aiosqlite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import AsyncIterator
9
+
10
+ import aiosqlite
11
+
12
+
13
+ # DDL для создания таблиц
14
+ _SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS projects (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ name TEXT NOT NULL,
18
+ path TEXT NOT NULL,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ settings_json TEXT DEFAULT '{}'
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS repeater_entries (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
26
+ tab_name TEXT NOT NULL DEFAULT 'Tab',
27
+ method TEXT NOT NULL,
28
+ url TEXT NOT NULL,
29
+ request_headers TEXT DEFAULT '',
30
+ request_body TEXT DEFAULT '',
31
+ response_status INTEGER DEFAULT NULL,
32
+ response_headers TEXT DEFAULT '',
33
+ response_body TEXT DEFAULT '',
34
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS intruder_results (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
40
+ attack_id TEXT NOT NULL,
41
+ payload_values TEXT DEFAULT '[]',
42
+ request_raw TEXT DEFAULT '',
43
+ response_status INTEGER DEFAULT NULL,
44
+ response_length INTEGER DEFAULT NULL,
45
+ response_time_ms INTEGER DEFAULT NULL,
46
+ error TEXT DEFAULT NULL,
47
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS vulnerabilities (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
53
+ type TEXT NOT NULL,
54
+ name TEXT NOT NULL DEFAULT '',
55
+ severity TEXT NOT NULL DEFAULT 'info',
56
+ host TEXT NOT NULL,
57
+ url TEXT NOT NULL,
58
+ parameter TEXT DEFAULT '',
59
+ payload TEXT DEFAULT '',
60
+ evidence TEXT DEFAULT '',
61
+ description TEXT DEFAULT '',
62
+ cwe TEXT DEFAULT '',
63
+ remediation TEXT DEFAULT '',
64
+ mitre_attack TEXT DEFAULT '',
65
+ request_raw TEXT DEFAULT '',
66
+ response_raw TEXT DEFAULT '',
67
+ false_positive INTEGER NOT NULL DEFAULT 0,
68
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS site_map (
72
+ id TEXT PRIMARY KEY,
73
+ host TEXT NOT NULL,
74
+ path TEXT NOT NULL,
75
+ methods TEXT NOT NULL DEFAULT '[]',
76
+ request_count INTEGER NOT NULL DEFAULT 0,
77
+ last_seen TEXT NOT NULL,
78
+ in_scope INTEGER NOT NULL DEFAULT 0
79
+ );
80
+
81
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_site_map_host_path ON site_map (host, path);
82
+ """
83
+
84
+
85
+ async def init_db(db_path: str) -> None:
86
+ """Создать таблицы БД, если они не существуют.
87
+
88
+ Args:
89
+ db_path: Путь к файлу SQLite.
90
+ """
91
+ path = Path(db_path)
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ async with aiosqlite.connect(db_path) as db:
94
+ await db.executescript(_SCHEMA)
95
+ await db.commit()
96
+ # Миграция: добавляем новые колонки в vulnerabilities (для старых БД)
97
+ _new_vuln_cols = [
98
+ ("name", "TEXT NOT NULL DEFAULT ''"),
99
+ ("payload", "TEXT DEFAULT ''"),
100
+ ("description", "TEXT DEFAULT ''"),
101
+ ("cwe", "TEXT DEFAULT ''"),
102
+ ("remediation", "TEXT DEFAULT ''"),
103
+ ("mitre_attack", "TEXT DEFAULT ''"),
104
+ ("request_raw", "TEXT DEFAULT ''"),
105
+ ("response_raw", "TEXT DEFAULT ''"),
106
+ ]
107
+ for col_name, col_def in _new_vuln_cols:
108
+ try:
109
+ await db.execute(
110
+ f"ALTER TABLE vulnerabilities ADD COLUMN {col_name} {col_def}"
111
+ )
112
+ except Exception:
113
+ pass # Колонка уже существует — ожидаемо
114
+ await db.commit()
115
+
116
+
117
+ @asynccontextmanager
118
+ async def get_db(db_path: str) -> AsyncIterator[aiosqlite.Connection]:
119
+ """Контекстный менеджер для получения соединения с БД.
120
+
121
+ Args:
122
+ db_path: Путь к файлу SQLite.
123
+
124
+ Yields:
125
+ Объект соединения aiosqlite.Connection.
126
+ """
127
+ async with aiosqlite.connect(db_path) as db:
128
+ db.row_factory = aiosqlite.Row
129
+ await db.execute("PRAGMA foreign_keys = ON")
130
+ yield db
131
+
132
+
133
+ def init_db_sync(db_path: str) -> None:
134
+ """Синхронная обёртка над init_db для использования в CLI.
135
+
136
+ Args:
137
+ db_path: Путь к файлу SQLite.
138
+ """
139
+ asyncio.run(init_db(db_path))
@@ -0,0 +1,232 @@
1
+ """Event Bus — внутренняя шина событий приложения.
2
+
3
+ Thread-safe pub/sub с персистентной историей событий.
4
+ Поддерживает подписку, отписку, replay истории и thread-safe emit
5
+ из worker-потоков (для @work(thread=True) в Textual).
6
+
7
+ Использование:
8
+ from pentool.core.event_bus import get_event_bus
9
+ from pentool.core.events import FindingDiscovered
10
+
11
+ bus = get_event_bus()
12
+ bus.subscribe(FindingDiscovered, my_handler)
13
+ bus.emit(FindingDiscovered(finding=f, source="scanner"))
14
+ bus.emit_threadsafe(FindingDiscovered(finding=f), loop)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import threading
21
+ from collections import defaultdict, deque
22
+ from typing import Callable, TypeVar
23
+
24
+ from pentool.core.events import AppEvent
25
+ from pentool.core.logging import get_logger
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ T = TypeVar("T", bound=AppEvent)
30
+
31
+
32
+ class EventBus:
33
+ """Thread-safe шина событий с историей.
34
+
35
+ Attributes:
36
+ _subscribers: Словарь type → список обработчиков.
37
+ _history: Кольцевой буфер всех эмиченных событий.
38
+ _lock: RLock для thread-safe операций.
39
+ """
40
+
41
+ def __init__(self, max_history: int = 10_000) -> None:
42
+ self._subscribers: dict[type, list[Callable]] = defaultdict(list)
43
+ self._history: deque[AppEvent] = deque(maxlen=max_history)
44
+ self._lock = threading.RLock()
45
+
46
+ # ── subscribe / unsubscribe ────────────────────────────────────────────────
47
+
48
+ def subscribe(self, event_type: type[T], handler: Callable[[T], None]) -> None:
49
+ """Подписаться на события заданного типа.
50
+
51
+ Args:
52
+ event_type: Класс события (подкласс AppEvent).
53
+ handler: Функция-обработчик, принимающая экземпляр события.
54
+ """
55
+ with self._lock:
56
+ if handler not in self._subscribers[event_type]:
57
+ self._subscribers[event_type].append(handler)
58
+
59
+ def unsubscribe(self, event_type: type, handler: Callable) -> None:
60
+ """Отписаться от событий.
61
+
62
+ Безопасен при повторном вызове — не бросает исключение если
63
+ обработчик не был подписан.
64
+ """
65
+ with self._lock:
66
+ try:
67
+ self._subscribers[event_type].remove(handler)
68
+ except ValueError:
69
+ pass
70
+
71
+ def unsubscribe_all(self, handler: Callable) -> None:
72
+ """Отписать обработчик от всех типов событий сразу."""
73
+ with self._lock:
74
+ for handlers in self._subscribers.values():
75
+ try:
76
+ handlers.remove(handler)
77
+ except ValueError:
78
+ pass
79
+
80
+ # ── emit ──────────────────────────────────────────────────────────────────
81
+
82
+ def emit(self, event: AppEvent) -> None:
83
+ """Синхронный emit из основного потока.
84
+
85
+ Вызывает всех подписчиков немедленно (в том же потоке).
86
+ Сохраняет событие в историю.
87
+
88
+ Args:
89
+ event: Экземпляр события (подкласс AppEvent).
90
+ """
91
+ with self._lock:
92
+ self._history.append(event)
93
+ handlers = list(self._subscribers.get(type(event), []))
94
+
95
+ for handler in handlers:
96
+ try:
97
+ handler(event)
98
+ except Exception as exc:
99
+ logger.warning(
100
+ "EventBus: handler %s raised %s for event %s",
101
+ handler, exc, type(event).__name__,
102
+ )
103
+
104
+ def emit_threadsafe(
105
+ self,
106
+ event: AppEvent,
107
+ loop: asyncio.AbstractEventLoop,
108
+ ) -> None:
109
+ """Thread-safe emit из worker-потока.
110
+
111
+ Сохраняет событие в историю немедленно (под локом),
112
+ затем планирует вызов обработчиков в event loop через
113
+ call_soon_threadsafe.
114
+
115
+ Args:
116
+ event: Экземпляр события.
117
+ loop: Event loop основного потока (app._loop).
118
+ """
119
+ with self._lock:
120
+ self._history.append(event)
121
+ handlers = list(self._subscribers.get(type(event), []))
122
+
123
+ if not handlers:
124
+ return
125
+
126
+ def _dispatch() -> None:
127
+ for handler in handlers:
128
+ try:
129
+ handler(event)
130
+ except Exception as exc:
131
+ logger.warning(
132
+ "EventBus: threadsafe handler %s raised %s for %s",
133
+ handler, exc, type(event).__name__,
134
+ )
135
+
136
+ try:
137
+ loop.call_soon_threadsafe(_dispatch)
138
+ except RuntimeError:
139
+ # loop закрыт — приложение завершается
140
+ pass
141
+
142
+ # ── history / replay ──────────────────────────────────────────────────────
143
+
144
+ def get_history(
145
+ self,
146
+ event_type: type | None = None,
147
+ limit: int = 0,
148
+ ) -> list[AppEvent]:
149
+ """Получить историю событий.
150
+
151
+ Args:
152
+ event_type: Фильтр по типу (None = все типы).
153
+ limit: Максимум записей (0 = без ограничения).
154
+
155
+ Returns:
156
+ Список событий в хронологическом порядке.
157
+ """
158
+ with self._lock:
159
+ events = list(self._history)
160
+
161
+ if event_type is not None:
162
+ events = [e for e in events if isinstance(e, event_type)]
163
+
164
+ if limit > 0:
165
+ events = events[-limit:]
166
+
167
+ return events
168
+
169
+ def replay(
170
+ self,
171
+ handler: Callable,
172
+ event_type: type,
173
+ limit: int = 0,
174
+ ) -> None:
175
+ """Воспроизвести историю событий для нового подписчика.
176
+
177
+ Вызывает handler для каждого сохранённого события заданного типа.
178
+ Полезно при инициализации экрана, который подключился позже
179
+ (например, Dashboard после запуска сканирования).
180
+
181
+ Args:
182
+ handler: Функция-обработчик.
183
+ event_type: Тип событий для replay.
184
+ limit: Максимум событий (0 = все).
185
+ """
186
+ events = self.get_history(event_type=event_type, limit=limit)
187
+ for event in events:
188
+ try:
189
+ handler(event)
190
+ except Exception as exc:
191
+ logger.warning(
192
+ "EventBus.replay: handler %s raised %s", handler, exc
193
+ )
194
+
195
+ def clear_history(self) -> None:
196
+ """Очистить историю событий (например, при открытии нового проекта)."""
197
+ with self._lock:
198
+ self._history.clear()
199
+
200
+ # ── stats ─────────────────────────────────────────────────────────────────
201
+
202
+ def stats(self) -> dict[str, int]:
203
+ """Статистика для отладки."""
204
+ with self._lock:
205
+ return {
206
+ "history_size": len(self._history),
207
+ "subscriber_types": len(self._subscribers),
208
+ "total_handlers": sum(len(v) for v in self._subscribers.values()),
209
+ }
210
+
211
+
212
+ # ── Глобальный синглтон ────────────────────────────────────────────────────────
213
+
214
+ _bus: EventBus | None = None
215
+ _bus_lock = threading.Lock()
216
+
217
+
218
+ def get_event_bus() -> EventBus:
219
+ """Получить глобальный экземпляр EventBus (ленивая инициализация)."""
220
+ global _bus
221
+ if _bus is None:
222
+ with _bus_lock:
223
+ if _bus is None:
224
+ _bus = EventBus()
225
+ return _bus
226
+
227
+
228
+ def reset_event_bus() -> None:
229
+ """Сбросить глобальный синглтон (только для тестов)."""
230
+ global _bus
231
+ with _bus_lock:
232
+ _bus = None
pentool/core/events.py ADDED
@@ -0,0 +1,164 @@
1
+ """Типизированные события приложения для Event Bus.
2
+
3
+ Все события наследуются от AppEvent. Используются для межмодульной
4
+ коммуникации без прямых зависимостей между TUI-экранами.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class AppEvent:
16
+ """Базовый класс для всех событий."""
17
+ timestamp: float = field(default_factory=time.time)
18
+ source: str = "" # имя модуля-источника, для отладки
19
+
20
+
21
+ # ── Scanner events ─────────────────────────────────────────────────────────────
22
+
23
+ @dataclass
24
+ class ScanStarted(AppEvent):
25
+ """Сканирование запущено."""
26
+ targets: list[str] = field(default_factory=list)
27
+ checks: list[str] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class ScanFinished(AppEvent):
32
+ """Сканирование завершено (нормально или по стопу)."""
33
+ total_findings: int = 0
34
+ stopped_early: bool = False
35
+
36
+
37
+ @dataclass
38
+ class ScanProgressEvent(AppEvent):
39
+ """Прогресс сканирования."""
40
+ done: int = 0
41
+ total: int = 0
42
+ scanning: bool = True
43
+
44
+
45
+ @dataclass
46
+ class FindingDiscovered(AppEvent):
47
+ """Обнаружена уязвимость (активный или пассивный скан)."""
48
+ finding: Any = None
49
+ scan_source: str = "active" # "active" | "passive"
50
+
51
+
52
+ # ── Spider events ──────────────────────────────────────────────────────────────
53
+
54
+ @dataclass
55
+ class UrlCrawled(AppEvent):
56
+ """Spider нашёл новый URL."""
57
+ url: str = ""
58
+ base_target: str = ""
59
+
60
+
61
+ @dataclass
62
+ class SpiderFinished(AppEvent):
63
+ """Краулинг завершён."""
64
+ base_url: str = ""
65
+ pages_count: int = 0
66
+ forms_count: int = 0
67
+ endpoints_count: int = 0
68
+
69
+
70
+ # ── Intruder events ────────────────────────────────────────────────────────────
71
+
72
+ @dataclass
73
+ class IntruderResultAdded(AppEvent):
74
+ """Получен результат одного запроса атаки."""
75
+ result: Any = None # IntruderResult
76
+
77
+
78
+ @dataclass
79
+ class IntruderFinished(AppEvent):
80
+ """Атака завершена."""
81
+ total_results: int = 0
82
+ stopped_early: bool = False
83
+
84
+
85
+ # ── Proxy events ───────────────────────────────────────────────────────────────
86
+
87
+ @dataclass
88
+ class ProxyRequestCaptured(AppEvent):
89
+ """Прокси перехватил новый запрос.
90
+
91
+ request: полный объект InterceptedRequest (Any чтобы не создавать
92
+ циклических импортов modules → core).
93
+ """
94
+ request_id: str = ""
95
+ method: str = ""
96
+ url: str = ""
97
+ host: str = ""
98
+ request: Any = None # InterceptedRequest
99
+
100
+
101
+ @dataclass
102
+ class ProxyRequestCompleted(AppEvent):
103
+ """Запрос через прокси завершён (получен ответ).
104
+
105
+ request: полный объект InterceptedRequest (Any чтобы не создавать
106
+ циклических импортов modules → core).
107
+ """
108
+ request_id: str = ""
109
+ status_code: int = 0
110
+ request: Any = None # InterceptedRequest
111
+
112
+
113
+ # Алиас для обратной совместимости: Sequencer подписывается на это событие
114
+ ProxyRequestDoneEvent = ProxyRequestCompleted
115
+
116
+
117
+ # ── Target / SiteMap events ────────────────────────────────────────────────────
118
+
119
+ @dataclass
120
+ class TargetUrlAdded(AppEvent):
121
+ """URL добавлен в SiteMap/Target."""
122
+ url: str = ""
123
+ host: str = ""
124
+
125
+
126
+ # ── Project events ─────────────────────────────────────────────────────────────
127
+
128
+ @dataclass
129
+ class ProjectSaved(AppEvent):
130
+ """Проект сохранён."""
131
+ path: str = ""
132
+
133
+
134
+ @dataclass
135
+ class ProjectLoaded(AppEvent):
136
+ """Проект загружен."""
137
+ path: str = ""
138
+ findings_count: int = 0
139
+ history_count: int = 0
140
+
141
+
142
+ # ── Scanner passive events ──────────────────���───────────────────────────────────
143
+
144
+ @dataclass
145
+ class PassiveScanToggled(AppEvent):
146
+ """Пассивный скан включён/выключен."""
147
+ enabled: bool = False
148
+
149
+
150
+ # ── WebSocket events ───────────────────────────────────────────────────────────
151
+
152
+ @dataclass
153
+ class WebSocketFrameEvent(AppEvent):
154
+ """Перехвачен WebSocket-фрейм (отдельное сообщение).
155
+
156
+ direction: "client→server" или "server→client"
157
+ opcode: 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
158
+ payload: тело фрейма (уже без маски)
159
+ """
160
+ request_id: str = "" # ID родительского WS-соединения (upgrade-запрос)
161
+ direction: str = "" # "client→server" | "server→client"
162
+ opcode: int = 0x1 # 1=text, 2=binary, 8=close, 9=ping, 10=pong
163
+ payload: bytes = field(default_factory=bytes)
164
+ payload_text: str = "" # UTF-8 декодированный payload (для text-фреймов)