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
pentool/core/logging.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Настройка системы логирования."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging(log_file: str, level: str = "INFO") -> logging.Logger:
|
|
11
|
+
"""Настроить логирование: запись в файл (DEBUG) и в консоль (указанный уровень).
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
log_file: Путь к лог-файлу.
|
|
15
|
+
level: Уровень логирования для консоли (DEBUG/INFO/WARNING/ERROR).
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Настроенный корневой логгер.
|
|
19
|
+
"""
|
|
20
|
+
log_path = Path(log_file)
|
|
21
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("pentool")
|
|
26
|
+
logger.setLevel(logging.DEBUG)
|
|
27
|
+
|
|
28
|
+
# Очистить существующие хендлеры, чтобы избежать дублирования
|
|
29
|
+
logger.handlers.clear()
|
|
30
|
+
|
|
31
|
+
# FileHandler — пишет всё (DEBUG и выше), дописывает в лог (не очищает)
|
|
32
|
+
fmt = logging.Formatter(
|
|
33
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
34
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
35
|
+
)
|
|
36
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8", mode="a")
|
|
37
|
+
file_handler.setLevel(logging.DEBUG)
|
|
38
|
+
file_handler.setFormatter(fmt)
|
|
39
|
+
|
|
40
|
+
# StreamHandler — пишет в stderr на указанном уровне
|
|
41
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
42
|
+
console_handler.setLevel(numeric_level)
|
|
43
|
+
console_handler.setFormatter(
|
|
44
|
+
logging.Formatter("[%(levelname)s] %(message)s")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
logger.addHandler(file_handler)
|
|
48
|
+
logger.addHandler(console_handler)
|
|
49
|
+
|
|
50
|
+
return logger
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_logger(name: str = "pentool") -> logging.Logger:
|
|
54
|
+
"""Получить логгер по имени.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
name: Имя логгера (по умолчанию корневой логгер pentool).
|
|
58
|
+
"""
|
|
59
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Система плагинов Pentool: базовые классы и менеджер загрузки."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Текущая версия Plugin API. Плагины с api_version > CURRENT несовместимы.
|
|
18
|
+
CURRENT_API_VERSION = 1
|
|
19
|
+
|
|
20
|
+
# Директория пользовательских плагинов (PRO-плагины устанавливаются сюда)
|
|
21
|
+
USER_PLUGINS_DIR = Path.home() / ".pentool" / "plugins"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BasePlugin:
|
|
25
|
+
"""Базовый класс для всех плагинов Pentool.
|
|
26
|
+
|
|
27
|
+
Каждый плагин должен объявить атрибуты класса:
|
|
28
|
+
name — уникальный ID (snake_case)
|
|
29
|
+
version — строка версии ("1.0")
|
|
30
|
+
author — имя автора
|
|
31
|
+
description — краткое описание
|
|
32
|
+
api_version — версия Plugin API (сейчас 1)
|
|
33
|
+
required_feature — строка feature из LicenseInfo.features, либо ""
|
|
34
|
+
(пустая строка = бесплатный плагин, не требует PRO)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str = ""
|
|
38
|
+
version: str = "0.1"
|
|
39
|
+
author: str = ""
|
|
40
|
+
description: str = ""
|
|
41
|
+
api_version: int = CURRENT_API_VERSION
|
|
42
|
+
required_feature: str = "" # "" = free; "scanner_pro" / "reports_pro" / ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseCheck:
|
|
46
|
+
"""Базовый класс для отдельной проверки (активной или пассивной).
|
|
47
|
+
|
|
48
|
+
Используется в modules/scanner/ и в плагинах-сканерах.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: str = ""
|
|
52
|
+
description: str = ""
|
|
53
|
+
severity: str = "info" # critical | high | medium | low | info
|
|
54
|
+
passive: bool = False # True → запускается на каждый прокси-запрос
|
|
55
|
+
|
|
56
|
+
async def scan(self, target: Any, http_client: Any, **kwargs) -> list:
|
|
57
|
+
"""Запустить проверку. Возвращает список Finding."""
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BaseScanner(BasePlugin):
|
|
62
|
+
"""Базовый класс для плагина-сканера.
|
|
63
|
+
|
|
64
|
+
Плагин-сканер объединяет несколько BaseCheck под одним именем.
|
|
65
|
+
Пример: XSSScanner регистрирует reflected_xss, dom_xss и т.д.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
checks: list[type[BaseCheck]] = []
|
|
69
|
+
|
|
70
|
+
async def scan(self, target: Any, http_client: Any, **kwargs) -> list:
|
|
71
|
+
"""Запустить все checks и вернуть объединённый список Finding."""
|
|
72
|
+
results = []
|
|
73
|
+
for check_cls in self.checks:
|
|
74
|
+
check = check_cls()
|
|
75
|
+
try:
|
|
76
|
+
findings = await check.scan(target, http_client, **kwargs)
|
|
77
|
+
results.extend(findings)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.warning("Check %s failed: %s", check_cls.name, exc)
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class PluginScreen:
|
|
85
|
+
"""Экран, зарегистрированный плагином."""
|
|
86
|
+
name: str
|
|
87
|
+
widget_class: type
|
|
88
|
+
hotkey: str | None = None
|
|
89
|
+
plugin_name: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class PluginCommand:
|
|
94
|
+
"""CLI-команда, зарегистрированная плагином."""
|
|
95
|
+
group_name: str
|
|
96
|
+
command: Any # click.Command
|
|
97
|
+
plugin_name: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PluginHook:
|
|
101
|
+
"""Объект, передаваемый в register(hook) каждого плагина.
|
|
102
|
+
|
|
103
|
+
Через него плагин объявляет свои вклады в TUI, CLI и сканер.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, plugin_name: str) -> None:
|
|
107
|
+
self._plugin_name = plugin_name
|
|
108
|
+
self._screens: list[PluginScreen] = []
|
|
109
|
+
self._commands: list[PluginCommand] = []
|
|
110
|
+
self._scanners: list[type[BaseScanner]] = []
|
|
111
|
+
self._passive_checks: list[type[BaseCheck]] = []
|
|
112
|
+
|
|
113
|
+
def register_screen(
|
|
114
|
+
self,
|
|
115
|
+
name: str,
|
|
116
|
+
widget_class: type,
|
|
117
|
+
hotkey: str | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Добавить экран в боковое меню TUI.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
name: Название в меню.
|
|
123
|
+
widget_class: Класс Widget (не Screen!).
|
|
124
|
+
hotkey: Опциональная горячая клавиша, например "S+0".
|
|
125
|
+
"""
|
|
126
|
+
self._screens.append(
|
|
127
|
+
PluginScreen(name=name, widget_class=widget_class,
|
|
128
|
+
hotkey=hotkey, plugin_name=self._plugin_name)
|
|
129
|
+
)
|
|
130
|
+
logger.debug("Plugin '%s': registered screen '%s'", self._plugin_name, name)
|
|
131
|
+
|
|
132
|
+
def register_cli_command(self, group_name: str, command: Any) -> None:
|
|
133
|
+
"""Добавить click-команду в CLI-группу.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
group_name: Имя группы ("proxy", "scan", ...).
|
|
137
|
+
command: Объект @click.command().
|
|
138
|
+
"""
|
|
139
|
+
self._commands.append(
|
|
140
|
+
PluginCommand(group_name=group_name, command=command,
|
|
141
|
+
plugin_name=self._plugin_name)
|
|
142
|
+
)
|
|
143
|
+
logger.debug("Plugin '%s': registered CLI command '%s' → group '%s'",
|
|
144
|
+
self._plugin_name, command.name, group_name)
|
|
145
|
+
|
|
146
|
+
def register_scanner(self, scanner_class: type[BaseScanner]) -> None:
|
|
147
|
+
"""Зарегистрировать активный сканер."""
|
|
148
|
+
self._scanners.append(scanner_class)
|
|
149
|
+
logger.debug("Plugin '%s': registered scanner '%s'",
|
|
150
|
+
self._plugin_name, scanner_class.name)
|
|
151
|
+
|
|
152
|
+
def register_passive_check(self, check_class: type[BaseCheck]) -> None:
|
|
153
|
+
"""Зарегистрировать пассивную проверку (на каждый прокси-запрос)."""
|
|
154
|
+
self._passive_checks.append(check_class)
|
|
155
|
+
logger.debug("Plugin '%s': registered passive check '%s'",
|
|
156
|
+
self._plugin_name, check_class.name)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class PluginMeta:
|
|
161
|
+
"""Метаданные о загруженном плагине."""
|
|
162
|
+
name: str
|
|
163
|
+
path: str
|
|
164
|
+
version: str = "?"
|
|
165
|
+
author: str = ""
|
|
166
|
+
description: str = ""
|
|
167
|
+
required_feature: str = "" # "" = free
|
|
168
|
+
loaded: bool = True # False = заблокирован лицензией
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PluginManager:
|
|
172
|
+
"""Загружает и хранит все зарегистрированные плагины."""
|
|
173
|
+
|
|
174
|
+
def __init__(self) -> None:
|
|
175
|
+
self._screens: list[PluginScreen] = []
|
|
176
|
+
self._commands: list[PluginCommand] = []
|
|
177
|
+
self._scanners: list[type[BaseScanner]] = []
|
|
178
|
+
self._passive_checks: list[type[BaseCheck]] = []
|
|
179
|
+
self._loaded: list[str] = []
|
|
180
|
+
self._meta: list[PluginMeta] = []
|
|
181
|
+
|
|
182
|
+
# ── Загрузка ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def load_plugins(self, dirs: list[str], *, warn_untrusted: bool = True) -> None:
|
|
185
|
+
"""Сканирует директории и загружает плагины.
|
|
186
|
+
|
|
187
|
+
Плагины с required_feature проверяются через get_session_license().
|
|
188
|
+
Если лицензия не покрывает feature — плагин пропускается с логом WARNING.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
dirs: Список путей к директориям с плагинами.
|
|
192
|
+
warn_untrusted: Логировать предупреждение для нестандартных путей.
|
|
193
|
+
"""
|
|
194
|
+
builtin_dir = str(
|
|
195
|
+
Path(__file__).parent.parent / "plugins" / "builtin"
|
|
196
|
+
)
|
|
197
|
+
for dir_path in dirs:
|
|
198
|
+
is_builtin = Path(dir_path).resolve() == Path(builtin_dir).resolve()
|
|
199
|
+
for py_file in sorted(Path(dir_path).glob("*.py")):
|
|
200
|
+
if py_file.name.startswith("_"):
|
|
201
|
+
continue
|
|
202
|
+
if warn_untrusted and not is_builtin:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"Loading plugin from untrusted source: %s", py_file
|
|
205
|
+
)
|
|
206
|
+
self._load_file(py_file)
|
|
207
|
+
|
|
208
|
+
def load_user_plugins(self) -> None:
|
|
209
|
+
"""Загрузить плагины из ~/.pentool/plugins/ (PRO-плагины пользователя)."""
|
|
210
|
+
if USER_PLUGINS_DIR.exists():
|
|
211
|
+
self.load_plugins([str(USER_PLUGINS_DIR)], warn_untrusted=False)
|
|
212
|
+
else:
|
|
213
|
+
logger.debug("User plugins dir not found: %s", USER_PLUGINS_DIR)
|
|
214
|
+
|
|
215
|
+
def _load_file(self, path: Path) -> None:
|
|
216
|
+
module_name = f"_pentool_plugin_{path.stem}"
|
|
217
|
+
try:
|
|
218
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
219
|
+
if spec is None or spec.loader is None:
|
|
220
|
+
logger.warning("Cannot load plugin: %s", path)
|
|
221
|
+
return
|
|
222
|
+
module = importlib.util.module_from_spec(spec)
|
|
223
|
+
sys.modules[module_name] = module
|
|
224
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
225
|
+
|
|
226
|
+
if not hasattr(module, "register"):
|
|
227
|
+
logger.warning("Plugin has no register(): %s", path)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Определяем имя и метаданные плагина
|
|
231
|
+
plugin_name = path.stem
|
|
232
|
+
plugin_cls = self._find_plugin_class(module)
|
|
233
|
+
version = getattr(plugin_cls, "version", "?") if plugin_cls else "?"
|
|
234
|
+
author = getattr(plugin_cls, "author", "") if plugin_cls else ""
|
|
235
|
+
description = getattr(plugin_cls, "description", "") if plugin_cls else ""
|
|
236
|
+
required_feature = getattr(plugin_cls, "required_feature", "") if plugin_cls else ""
|
|
237
|
+
|
|
238
|
+
# Проверка совместимости api_version
|
|
239
|
+
if plugin_cls is not None:
|
|
240
|
+
api_ver = getattr(plugin_cls, "api_version", 1)
|
|
241
|
+
if api_ver > CURRENT_API_VERSION:
|
|
242
|
+
logger.warning(
|
|
243
|
+
"Plugin '%s' requires api_version=%d, current=%d — skipped",
|
|
244
|
+
plugin_name, api_ver, CURRENT_API_VERSION,
|
|
245
|
+
)
|
|
246
|
+
self._meta.append(PluginMeta(
|
|
247
|
+
name=plugin_name, path=str(path),
|
|
248
|
+
version=version, author=author,
|
|
249
|
+
description=f"Requires Plugin API v{api_ver}",
|
|
250
|
+
required_feature=required_feature, loaded=False,
|
|
251
|
+
))
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Проверка лицензии для PRO-плагинов
|
|
255
|
+
if required_feature:
|
|
256
|
+
if not self._check_license_feature(required_feature):
|
|
257
|
+
logger.warning(
|
|
258
|
+
"Plugin '%s' requires license feature '%s' — skipped (license not active)",
|
|
259
|
+
plugin_name, required_feature,
|
|
260
|
+
)
|
|
261
|
+
self._meta.append(PluginMeta(
|
|
262
|
+
name=plugin_name, path=str(path),
|
|
263
|
+
version=version, author=author,
|
|
264
|
+
description=description,
|
|
265
|
+
required_feature=required_feature, loaded=False,
|
|
266
|
+
))
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
hook = PluginHook(plugin_name)
|
|
270
|
+
module.register(hook)
|
|
271
|
+
|
|
272
|
+
self._screens.extend(hook._screens)
|
|
273
|
+
self._commands.extend(hook._commands)
|
|
274
|
+
self._scanners.extend(hook._scanners)
|
|
275
|
+
self._passive_checks.extend(hook._passive_checks)
|
|
276
|
+
self._loaded.append(str(path))
|
|
277
|
+
self._meta.append(PluginMeta(
|
|
278
|
+
name=plugin_name, path=str(path),
|
|
279
|
+
version=version, author=author, description=description,
|
|
280
|
+
required_feature=required_feature, loaded=True,
|
|
281
|
+
))
|
|
282
|
+
logger.info("Loaded plugin: %s", path.name)
|
|
283
|
+
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.error("Failed to load plugin %s: %s", path, exc)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _find_plugin_class(module: Any) -> type | None:
|
|
289
|
+
"""Найти первый подкласс BasePlugin в модуле."""
|
|
290
|
+
for cls_name in dir(module):
|
|
291
|
+
cls = getattr(module, cls_name)
|
|
292
|
+
if (isinstance(cls, type)
|
|
293
|
+
and issubclass(cls, BasePlugin)
|
|
294
|
+
and cls is not BasePlugin
|
|
295
|
+
and cls is not BaseScanner):
|
|
296
|
+
return cls
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _check_license_feature(feature: str) -> bool:
|
|
301
|
+
"""Проверить что текущая лицензия покрывает указанную feature."""
|
|
302
|
+
try:
|
|
303
|
+
from pentool.core.license import get_session_license
|
|
304
|
+
info = get_session_license()
|
|
305
|
+
return info.has_feature(feature)
|
|
306
|
+
except Exception:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# ── Геттеры ───────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def get_screens(self) -> list[PluginScreen]:
|
|
312
|
+
return list(self._screens)
|
|
313
|
+
|
|
314
|
+
def get_commands(self) -> list[PluginCommand]:
|
|
315
|
+
return list(self._commands)
|
|
316
|
+
|
|
317
|
+
def get_scanners(self) -> list[type[BaseScanner]]:
|
|
318
|
+
return list(self._scanners)
|
|
319
|
+
|
|
320
|
+
def get_passive_checks(self) -> list[type[BaseCheck]]:
|
|
321
|
+
return list(self._passive_checks)
|
|
322
|
+
|
|
323
|
+
def loaded_plugins(self) -> list[str]:
|
|
324
|
+
return list(self._loaded)
|
|
325
|
+
|
|
326
|
+
def get_meta(self) -> list[PluginMeta]:
|
|
327
|
+
"""Список метаданных всех плагинов (загруженных и заблокированных)."""
|
|
328
|
+
return list(self._meta)
|
|
329
|
+
|
|
330
|
+
def is_feature_available(self, feature: str) -> bool:
|
|
331
|
+
"""Проверить доступность PRO-фичи через лицензию (удобный хелпер)."""
|
|
332
|
+
return self._check_license_feature(feature)
|
pentool/core/project.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Сохранение/загрузка проекта в JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def save_project(path: str | Path, project_data: dict[str, Any]) -> None:
|
|
12
|
+
"""Сохранить проект в JSON."""
|
|
13
|
+
path = Path(path)
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
data = {
|
|
16
|
+
"saved_at": datetime.now(timezone.utc).isoformat(),
|
|
17
|
+
**project_data,
|
|
18
|
+
}
|
|
19
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
20
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_project(path: str | Path) -> tuple[dict[str, Any], str]:
|
|
24
|
+
"""Загрузить проект из JSON.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
(data, error_message) — пустой dict при ошибке, пустая строка если OK.
|
|
28
|
+
"""
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return {}, f"File not found: {path}"
|
|
32
|
+
try:
|
|
33
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return {}, f"JSON parse error: {e}"
|
|
37
|
+
return data, ""
|