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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +17 -0
- pentool/api/__init__.py +45 -0
- pentool/api/base_api.py +52 -0
- pentool/api/comparer_api.py +23 -0
- pentool/api/decoder_api.py +28 -0
- pentool/api/intruder_api.py +209 -0
- pentool/api/proxy_api.py +319 -0
- pentool/api/repeater_api.py +118 -0
- pentool/api/scanner_api.py +336 -0
- pentool/api/sequencer_api.py +21 -0
- pentool/api/spider_api.py +126 -0
- pentool/api/target_api.py +121 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +100 -0
- pentool/cli/project.py +90 -0
- pentool/cli/proxy.py +185 -0
- pentool/cli/scan.py +118 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +184 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +232 -0
- pentool/core/events.py +164 -0
- pentool/core/features.py +410 -0
- pentool/core/license.py +294 -0
- pentool/core/logging.py +59 -0
- pentool/core/plugin_manager.py +332 -0
- pentool/core/project.py +37 -0
- pentool/core/storage_interface.py +311 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +61 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +432 -0
- pentool/modules/intruder_turbo.py +190 -0
- pentool/modules/match_replace.py +146 -0
- pentool/modules/proxy.py +803 -0
- pentool/modules/repeater.py +237 -0
- pentool/modules/scanner/__init__.py +7 -0
- pentool/modules/scanner/ai_analyzer.py +230 -0
- pentool/modules/scanner/base.py +173 -0
- pentool/modules/scanner/baseline.py +101 -0
- pentool/modules/scanner/checks/__init__.py +47 -0
- pentool/modules/scanner/checks/broken_auth.py +231 -0
- pentool/modules/scanner/checks/cors.py +209 -0
- pentool/modules/scanner/checks/dom_xss.py +114 -0
- pentool/modules/scanner/checks/graphql.py +110 -0
- pentool/modules/scanner/checks/header_injection.py +262 -0
- pentool/modules/scanner/checks/headers.py +75 -0
- pentool/modules/scanner/checks/helpers.py +358 -0
- pentool/modules/scanner/checks/info_leak.py +68 -0
- pentool/modules/scanner/checks/jwt_none.py +238 -0
- pentool/modules/scanner/checks/lfi.py +186 -0
- pentool/modules/scanner/checks/nosql_injection.py +92 -0
- pentool/modules/scanner/checks/oauth.py +123 -0
- pentool/modules/scanner/checks/open_redirect.py +168 -0
- pentool/modules/scanner/checks/path_traversal.py +229 -0
- pentool/modules/scanner/checks/prototype_pollution.py +88 -0
- pentool/modules/scanner/checks/rce.py +338 -0
- pentool/modules/scanner/checks/sensitive_data.py +74 -0
- pentool/modules/scanner/checks/sqli.py +546 -0
- pentool/modules/scanner/checks/ssrf.py +273 -0
- pentool/modules/scanner/checks/ssti.py +353 -0
- pentool/modules/scanner/checks/xss.py +622 -0
- pentool/modules/scanner/checks/xxe.py +240 -0
- pentool/modules/scanner/engine.py +407 -0
- pentool/modules/scanner/fingerprint.py +169 -0
- pentool/modules/scanner/helpers.py +94 -0
- pentool/modules/scanner/mutator.py +378 -0
- pentool/modules/scanner/oob.py +104 -0
- pentool/modules/scanner/passive.py +114 -0
- pentool/modules/scanner/payloads.py +224 -0
- pentool/modules/scanner/report.py +117 -0
- pentool/modules/sequencer.py +500 -0
- pentool/modules/spider.py +702 -0
- pentool/modules/target.py +190 -0
- pentool/modules/websocket_handler.py +259 -0
- pentool/plugins/__init__.py +7 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +404 -0
- pentool/plugins/builtin/reports_pro.py +450 -0
- pentool/plugins/builtin/scanner_pro.py +308 -0
- pentool/plugins/example_plugin.py +75 -0
- pentool/project_to_txt.py +67 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +64 -0
- pentool/services/intruder_service.py +108 -0
- pentool/services/proxy_service.py +232 -0
- pentool/services/repeater_service.py +111 -0
- pentool/services/scan_service.py +340 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +522 -0
- pentool/storage/large_body_handler.py +54 -0
- pentool/storage/lru_cache.py +49 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1438 -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 +227 -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 +225 -0
- pentool/tui/messages.py +121 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +61 -0
- pentool/tui/mixins/dialog_cancel.py +25 -0
- pentool/tui/mixins/request_context_menu.py +285 -0
- pentool/tui/mixins/tab_rename.py +101 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +233 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +741 -0
- pentool/tui/screens/dashboard/screen.py +820 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +311 -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 +1362 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1664 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +646 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1984 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +520 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +543 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +385 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +361 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +181 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +206 -0
- pentool/tui/widgets/inspector_panel.py +112 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +73 -0
- pentool/tui/widgets/payload_drop_zone.py +66 -0
- pentool/tui/widgets/request_editor.py +504 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +113 -0
- pentool/tui/widgets/statusbar.py +99 -0
- pentool/tui/widgets/toolbar_button.py +76 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +361 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +251 -0
- pentool/utils/diff.py +91 -0
- pentool/utils/http_client.py +175 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +32 -0
- pentool-1.0.0.dist-info/METADATA +155 -0
- pentool-1.0.0.dist-info/RECORD +169 -0
- pentool-1.0.0.dist-info/WHEEL +5 -0
- pentool-1.0.0.dist-info/entry_points.txt +2 -0
- pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
- 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
|
pentool/core/database.py
ADDED
|
@@ -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-фреймов)
|