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,336 @@
|
|
|
1
|
+
"""ScannerAPI — публичный интерфейс Scanner для TUI и CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from pentool.core.utils import run_async_sync
|
|
9
|
+
|
|
10
|
+
from pentool.core.logging import get_logger
|
|
11
|
+
from pentool.modules.scanner.base import BaseCheck, Finding
|
|
12
|
+
from pentool.modules.scanner.checks import (
|
|
13
|
+
InfoLeakCheck,
|
|
14
|
+
LFICheck,
|
|
15
|
+
MissingSecurityHeadersCheck,
|
|
16
|
+
OpenRedirectCheck,
|
|
17
|
+
PathTraversalCheck,
|
|
18
|
+
HeaderInjectionCheck,
|
|
19
|
+
RCECheck,
|
|
20
|
+
SQLiCheck,
|
|
21
|
+
SSTICheck,
|
|
22
|
+
SSRFCheck,
|
|
23
|
+
XSSCheck,
|
|
24
|
+
XXECheck,
|
|
25
|
+
CORSCheck,
|
|
26
|
+
BrokenAuthCheck,
|
|
27
|
+
JWTNoneCheck,
|
|
28
|
+
NoSQLInjectionCheck,
|
|
29
|
+
GraphQLCheck,
|
|
30
|
+
PrototypePollutionCheck,
|
|
31
|
+
DOMXSSCheck,
|
|
32
|
+
OAuthCheck,
|
|
33
|
+
SensitiveDataCheck,
|
|
34
|
+
)
|
|
35
|
+
from pentool.modules.scanner.checks.header_injection import HostHeaderInjectionCheck
|
|
36
|
+
from pentool.modules.scanner.checks.sqli import SQLiUnionCheck
|
|
37
|
+
from pentool.modules.scanner.engine import ScanEngine
|
|
38
|
+
from pentool.modules.scanner.passive import PassiveScanner
|
|
39
|
+
from pentool.modules.scanner.report import (
|
|
40
|
+
generate_csv_report,
|
|
41
|
+
generate_html_report,
|
|
42
|
+
generate_json_report,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
__all__ = ["ScannerAPI", "Finding", "BaseCheck"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ScannerAPI:
|
|
51
|
+
"""Фасад над ScanEngine + PassiveScanner."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, db_path: str = "", http_client=None) -> None:
|
|
54
|
+
self._db_path = db_path
|
|
55
|
+
self._http_client = http_client
|
|
56
|
+
self._engine: ScanEngine | None = None
|
|
57
|
+
self._passive: PassiveScanner | None = None
|
|
58
|
+
self._active_task: asyncio.Task | None = None
|
|
59
|
+
self._scan_id: str | None = None
|
|
60
|
+
|
|
61
|
+
def _get_engine(self) -> ScanEngine:
|
|
62
|
+
if self._engine is None:
|
|
63
|
+
self._engine = ScanEngine(
|
|
64
|
+
db_path=self._db_path,
|
|
65
|
+
http_client=self._http_client,
|
|
66
|
+
)
|
|
67
|
+
self._engine.register_checks([
|
|
68
|
+
MissingSecurityHeadersCheck(),
|
|
69
|
+
InfoLeakCheck(),
|
|
70
|
+
SQLiCheck(),
|
|
71
|
+
XSSCheck(),
|
|
72
|
+
SSTICheck(),
|
|
73
|
+
LFICheck(),
|
|
74
|
+
PathTraversalCheck(),
|
|
75
|
+
HeaderInjectionCheck(),
|
|
76
|
+
HostHeaderInjectionCheck(),
|
|
77
|
+
RCECheck(),
|
|
78
|
+
OpenRedirectCheck(),
|
|
79
|
+
SSRFCheck(),
|
|
80
|
+
XXECheck(),
|
|
81
|
+
CORSCheck(),
|
|
82
|
+
BrokenAuthCheck(),
|
|
83
|
+
JWTNoneCheck(),
|
|
84
|
+
NoSQLInjectionCheck(),
|
|
85
|
+
GraphQLCheck(),
|
|
86
|
+
PrototypePollutionCheck(),
|
|
87
|
+
DOMXSSCheck(),
|
|
88
|
+
OAuthCheck(),
|
|
89
|
+
SensitiveDataCheck(),
|
|
90
|
+
SQLiUnionCheck(),
|
|
91
|
+
])
|
|
92
|
+
return self._engine
|
|
93
|
+
|
|
94
|
+
async def start_active_scan(
|
|
95
|
+
self,
|
|
96
|
+
targets: list[str],
|
|
97
|
+
check_names: list[str] | None = None,
|
|
98
|
+
on_finding: Callable[[Finding | None, None]] = None,
|
|
99
|
+
on_progress: Callable[[int, int | None, None]] = None,
|
|
100
|
+
on_request: Callable[[str, str | None, None]] = None,
|
|
101
|
+
concurrency: int = 5,
|
|
102
|
+
request_delay: float = 0.0,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Запустить активное сканирование. Возвращает scan_id."""
|
|
105
|
+
import uuid
|
|
106
|
+
self._scan_id = str(uuid.uuid4())
|
|
107
|
+
engine = self._get_engine()
|
|
108
|
+
engine._concurrency = concurrency
|
|
109
|
+
engine._request_delay = request_delay
|
|
110
|
+
|
|
111
|
+
async def _run() -> None:
|
|
112
|
+
findings = await engine.run_active(
|
|
113
|
+
targets,
|
|
114
|
+
check_names=check_names,
|
|
115
|
+
on_finding=on_finding,
|
|
116
|
+
on_progress=on_progress,
|
|
117
|
+
on_request=on_request,
|
|
118
|
+
)
|
|
119
|
+
await engine.save_findings(findings)
|
|
120
|
+
|
|
121
|
+
self._active_task = asyncio.create_task(_run())
|
|
122
|
+
return self._scan_id
|
|
123
|
+
|
|
124
|
+
async def stop_scan(self) -> None:
|
|
125
|
+
"""Остановить активное сканирование."""
|
|
126
|
+
if self._active_task and not self._active_task.done():
|
|
127
|
+
self._active_task.cancel()
|
|
128
|
+
try:
|
|
129
|
+
await self._active_task
|
|
130
|
+
except asyncio.CancelledError:
|
|
131
|
+
pass
|
|
132
|
+
self._active_task = None
|
|
133
|
+
|
|
134
|
+
def is_scanning(self) -> bool:
|
|
135
|
+
"""Возвращает True если активное сканирование ещё выполняется."""
|
|
136
|
+
return bool(self._active_task and not self._active_task.done())
|
|
137
|
+
|
|
138
|
+
async def get_findings(self, limit: int = 200) -> list[Finding]:
|
|
139
|
+
"""Получить список findings из БД.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
limit: Максимальное количество возвращаемых записей.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Список объектов Finding, отсортированный по времени (новые первые).
|
|
146
|
+
"""
|
|
147
|
+
return await self._get_engine().get_findings(limit)
|
|
148
|
+
|
|
149
|
+
async def mark_false_positive(self, finding_id: str) -> None:
|
|
150
|
+
"""Пометить finding как ложное срабатывание (false positive).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
finding_id: UUID строка идентификатора finding.
|
|
154
|
+
"""
|
|
155
|
+
await self._get_engine().mark_false_positive(finding_id)
|
|
156
|
+
|
|
157
|
+
async def attach_passive(self, proxy_api=None) -> None:
|
|
158
|
+
"""Подключить пассивный сканер через EventBus (Sprint 3).
|
|
159
|
+
|
|
160
|
+
proxy_api оставлен для обратной совместимости сигнатуры,
|
|
161
|
+
но больше не используется — PassiveScanner подписывается на
|
|
162
|
+
ProxyRequestCompleted через EventBus напрямую.
|
|
163
|
+
"""
|
|
164
|
+
engine = self._get_engine()
|
|
165
|
+
self._passive = PassiveScanner(engine)
|
|
166
|
+
self._passive.attach_bus()
|
|
167
|
+
logger.info("Passive scanner attached via EventBus")
|
|
168
|
+
|
|
169
|
+
async def detach_passive(self) -> None:
|
|
170
|
+
"""Отключить пассивный сканер."""
|
|
171
|
+
if self._passive:
|
|
172
|
+
self._passive.detach_bus()
|
|
173
|
+
self._passive = None
|
|
174
|
+
|
|
175
|
+
def set_passive_callback(self, callback: Callable[[Finding], None]) -> None:
|
|
176
|
+
"""Установить коллбэк для пассивного сканера.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
callback: Функция вида callback(finding) — вызывается при каждом новом finding.
|
|
180
|
+
"""
|
|
181
|
+
if self._passive:
|
|
182
|
+
self._passive.on_finding = callback
|
|
183
|
+
|
|
184
|
+
async def generate_report(self, path: str, fmt: str = "html") -> None:
|
|
185
|
+
"""Сгенерировать отчёт. fmt: 'html' | 'json' | 'csv'."""
|
|
186
|
+
findings = await self.get_findings(limit=10000)
|
|
187
|
+
fmt = fmt.lower()
|
|
188
|
+
if fmt == "html":
|
|
189
|
+
generate_html_report(findings, path)
|
|
190
|
+
elif fmt == "json":
|
|
191
|
+
generate_json_report(findings, path)
|
|
192
|
+
elif fmt == "csv":
|
|
193
|
+
generate_csv_report(findings, path)
|
|
194
|
+
else:
|
|
195
|
+
raise ValueError(f"Unknown report format: {fmt}")
|
|
196
|
+
logger.info("Report generated: %s (%s)", path, fmt)
|
|
197
|
+
|
|
198
|
+
def register_check(self, check: BaseCheck) -> None:
|
|
199
|
+
"""Зарегистрировать дополнительный check в движке сканера.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
check: Экземпляр BaseCheck (или подкласса) для добавления.
|
|
203
|
+
"""
|
|
204
|
+
self._get_engine().register_check(check)
|
|
205
|
+
|
|
206
|
+
def get_registered_checks(self) -> list[BaseCheck]:
|
|
207
|
+
"""Получить список всех зарегистрированных checks.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Список объектов BaseCheck в порядке регистрации.
|
|
211
|
+
"""
|
|
212
|
+
return self._get_engine().get_registered_checks()
|
|
213
|
+
|
|
214
|
+
# ── Project persistence ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def export_project_data(self) -> dict:
|
|
217
|
+
"""Экспортировать findings в сериализуемый dict для сохранения проекта.
|
|
218
|
+
|
|
219
|
+
Читает findings из SQLite через синхронный вызов (блокирует ненадолго).
|
|
220
|
+
Используется из action_save_project в app.py (main thread, sync).
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
findings = run_async_sync(self.get_findings(limit=10_000), timeout=10)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.warning("export_project_data: %s", exc)
|
|
226
|
+
findings = []
|
|
227
|
+
return {"findings": [f.to_dict() for f in (findings or [])]}
|
|
228
|
+
|
|
229
|
+
def import_project_data(self, data: dict) -> int:
|
|
230
|
+
"""Импортировать findings из загруженного dict проекта.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
data: Блок "scanner" из project.json ({"findings": [...]}).
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Количество загруженных findings.
|
|
237
|
+
"""
|
|
238
|
+
findings_data = data.get("findings", [])
|
|
239
|
+
if not findings_data:
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
findings = []
|
|
243
|
+
for fd in findings_data:
|
|
244
|
+
try:
|
|
245
|
+
findings.append(Finding.from_dict(fd))
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
logger.warning("ScannerAPI.import_project_data: skip finding: %s", exc)
|
|
248
|
+
|
|
249
|
+
if not findings:
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
# Пишем в SQLite через save_findings (нужен async контекст)
|
|
253
|
+
engine = self._get_engine()
|
|
254
|
+
try:
|
|
255
|
+
run_async_sync(engine.save_findings(findings), timeout=15)
|
|
256
|
+
return len(findings)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.warning("import_project_data save_findings: %s", exc)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
async def get_stats(self) -> dict:
|
|
262
|
+
"""Агрегированная статистика для Dashboard.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
dict с ключами:
|
|
266
|
+
findings: dict[severity → count]
|
|
267
|
+
top_finding: str — тип последнего finding или "—"
|
|
268
|
+
"""
|
|
269
|
+
return await self._get_engine().get_stats()
|
|
270
|
+
|
|
271
|
+
async def get_host_count(self) -> int:
|
|
272
|
+
"""Количество уникальных хостов в истории HTTP-запросов."""
|
|
273
|
+
try:
|
|
274
|
+
from pentool.storage.http_storage import HttpStorage
|
|
275
|
+
storage = HttpStorage()
|
|
276
|
+
await storage.init_db(self._db_path)
|
|
277
|
+
try:
|
|
278
|
+
return await storage.count_distinct_hosts()
|
|
279
|
+
finally:
|
|
280
|
+
await storage.close()
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
logger.debug("ScannerAPI.get_host_count: %s", exc)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
async def get_history_requests(
|
|
286
|
+
self,
|
|
287
|
+
scope_host: str = "",
|
|
288
|
+
limit: int = 500,
|
|
289
|
+
) -> list:
|
|
290
|
+
"""Загрузить ParsedRequest из истории Proxy для скана.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
scope_host: если задан — фильтровать только по этому хосту.
|
|
294
|
+
limit: максимальное число запросов.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
list[ParsedRequest] — дедуплицированные по (method, path_template).
|
|
298
|
+
"""
|
|
299
|
+
from pentool.storage.http_storage import HttpStorage
|
|
300
|
+
from pentool.utils.parser import ParsedRequest
|
|
301
|
+
import re as _re
|
|
302
|
+
|
|
303
|
+
storage = HttpStorage()
|
|
304
|
+
try:
|
|
305
|
+
await storage.init_db(self._db_path)
|
|
306
|
+
entries = await storage.export_all_requests(limit=limit)
|
|
307
|
+
finally:
|
|
308
|
+
await storage.close()
|
|
309
|
+
|
|
310
|
+
seen: set[str] = set()
|
|
311
|
+
result: list[ParsedRequest] = []
|
|
312
|
+
|
|
313
|
+
for entry in entries:
|
|
314
|
+
url: str = entry.get("url", "") or ""
|
|
315
|
+
method: str = (entry.get("method", "GET") or "GET").upper()
|
|
316
|
+
if not url:
|
|
317
|
+
continue
|
|
318
|
+
if scope_host and scope_host not in url:
|
|
319
|
+
continue
|
|
320
|
+
# Дедупликация по (method, path без значений параметров)
|
|
321
|
+
path_tmpl = _re.sub(r"=[^&]*", "=", url.split("?")[0])
|
|
322
|
+
dedup_key = f"{method}:{path_tmpl}"
|
|
323
|
+
if dedup_key in seen:
|
|
324
|
+
continue
|
|
325
|
+
seen.add(dedup_key)
|
|
326
|
+
|
|
327
|
+
headers: dict = entry.get("request_headers", {}) or {}
|
|
328
|
+
body: str = entry.get("request_body", "") or ""
|
|
329
|
+
result.append(ParsedRequest(
|
|
330
|
+
method=method,
|
|
331
|
+
url=url,
|
|
332
|
+
headers=headers,
|
|
333
|
+
body=body,
|
|
334
|
+
))
|
|
335
|
+
|
|
336
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SequencerAPI — публичный интерфейс Sequencer для TUI.
|
|
2
|
+
|
|
3
|
+
TUI-экраны импортируют только этот модуль,
|
|
4
|
+
не modules/sequencer.py напрямую.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pentool.modules.sequencer import ( # noqa: F401
|
|
10
|
+
Sequencer,
|
|
11
|
+
SequencerReport,
|
|
12
|
+
token_entropy,
|
|
13
|
+
charset_size,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Sequencer",
|
|
18
|
+
"SequencerReport",
|
|
19
|
+
"token_entropy",
|
|
20
|
+
"charset_size",
|
|
21
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SpiderAPI — публичный интерфейс Spider-модуля для TUI и CLI.
|
|
2
|
+
|
|
3
|
+
Обёртка над AsyncSpider. TUI-экраны импортируют только этот модуль,
|
|
4
|
+
не modules/spider.py напрямую.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from pentool.core.logging import get_logger
|
|
13
|
+
from pentool.modules.spider import (
|
|
14
|
+
AsyncSpider,
|
|
15
|
+
SpiderEndpoint,
|
|
16
|
+
SpiderForm,
|
|
17
|
+
SpiderResult,
|
|
18
|
+
is_playwright_available,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Реэкспорт типов — TUI использует их отсюда
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SpiderAPI", "SpiderResult", "SpiderForm", "SpiderEndpoint", "SpiderConfig",
|
|
26
|
+
"is_playwright_available",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SpiderConfig:
|
|
32
|
+
"""Конфигурация краулера."""
|
|
33
|
+
max_depth: int = 3
|
|
34
|
+
max_pages: int = 100
|
|
35
|
+
concurrency: int = 5
|
|
36
|
+
timeout: float = 10.0
|
|
37
|
+
user_agent: str = "pentool/1.0"
|
|
38
|
+
respect_scope: bool = False
|
|
39
|
+
js_render: bool = False # Playwright JS-рендеринг (если установлен)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SpiderAPI:
|
|
43
|
+
"""Публичный интерфейс краулера.
|
|
44
|
+
|
|
45
|
+
Тонкая обёртка над AsyncSpider. Не знает о Textual.
|
|
46
|
+
Конфигурируется через SpiderConfig.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: SpiderConfig | None = None) -> None:
|
|
50
|
+
self._config = config or SpiderConfig()
|
|
51
|
+
self._spider: AsyncSpider | None = None
|
|
52
|
+
self._stop_requested = False
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_params(
|
|
56
|
+
cls,
|
|
57
|
+
max_depth: int = 3,
|
|
58
|
+
max_pages: int = 100,
|
|
59
|
+
concurrency: int = 5,
|
|
60
|
+
timeout: float = 10.0,
|
|
61
|
+
) -> "SpiderAPI":
|
|
62
|
+
"""Удобный фабричный метод."""
|
|
63
|
+
return cls(SpiderConfig(
|
|
64
|
+
max_depth=max_depth,
|
|
65
|
+
max_pages=max_pages,
|
|
66
|
+
concurrency=concurrency,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
async def crawl(
|
|
71
|
+
self,
|
|
72
|
+
url: str,
|
|
73
|
+
on_page: Callable[[str], None] | None = None,
|
|
74
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
75
|
+
extra_headers: dict | None = None,
|
|
76
|
+
) -> SpiderResult:
|
|
77
|
+
"""Запустить краулинг начиная с url.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
url: Стартовый URL.
|
|
81
|
+
on_page: Коллбэк на каждую найденную страницу.
|
|
82
|
+
on_progress: Коллбэк прогресса (done, total).
|
|
83
|
+
extra_headers: Cookie/Authorization из seed_request для auth-контекста.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
SpiderResult с pages, forms, endpoints, js_files, errors.
|
|
87
|
+
"""
|
|
88
|
+
self._stop_requested = False
|
|
89
|
+
cfg = self._config
|
|
90
|
+
self._spider = AsyncSpider(
|
|
91
|
+
max_depth=cfg.max_depth,
|
|
92
|
+
max_pages=cfg.max_pages,
|
|
93
|
+
concurrency=cfg.concurrency,
|
|
94
|
+
respect_scope=cfg.respect_scope,
|
|
95
|
+
js_render=cfg.js_render,
|
|
96
|
+
extra_headers=extra_headers or {},
|
|
97
|
+
)
|
|
98
|
+
if on_page:
|
|
99
|
+
self._spider.on_page = on_page
|
|
100
|
+
if on_progress:
|
|
101
|
+
self._spider.on_progress = on_progress
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = await self._spider.crawl(url)
|
|
105
|
+
logger.info(
|
|
106
|
+
"SpiderAPI.crawl: %s → %d pages, %d forms, %d endpoints",
|
|
107
|
+
url, len(result.pages), len(result.forms), len(result.endpoints),
|
|
108
|
+
)
|
|
109
|
+
return result
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.warning("SpiderAPI.crawl error for %s: %s", url, exc)
|
|
112
|
+
return SpiderResult(pages=[], forms=[], endpoints=[], js_files=[], errors=[str(exc)])
|
|
113
|
+
|
|
114
|
+
def stop(self) -> None:
|
|
115
|
+
"""Запросить остановку краулинга."""
|
|
116
|
+
self._stop_requested = True
|
|
117
|
+
if self._spider is not None:
|
|
118
|
+
try:
|
|
119
|
+
self._spider._stop_requested = True
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def config(self) -> SpiderConfig:
|
|
125
|
+
"""Текущая конфигурация краулера (max_depth, max_pages, concurrency)."""
|
|
126
|
+
return self._config
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""TargetAPI — публичный интерфейс Target/SiteMap для TUI и CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pentool.core.logging import get_logger
|
|
10
|
+
from pentool.modules.target import SiteMap, SiteNode
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pentool.utils.parser import ParsedRequest
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
__all__ = ["TargetAPI", "SiteNode", "SiteMap"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TargetAPI:
|
|
21
|
+
"""Фасад над SiteMap."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, db_path: str = "") -> None:
|
|
24
|
+
self._db_path = db_path
|
|
25
|
+
self._sitemap = SiteMap(db_path=db_path)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def sitemap(self) -> SiteMap:
|
|
29
|
+
"""Прямой доступ к объекту SiteMap."""
|
|
30
|
+
return self._sitemap
|
|
31
|
+
|
|
32
|
+
async def load(self) -> None:
|
|
33
|
+
"""Загрузить карту сайта из БД."""
|
|
34
|
+
await self._sitemap.load()
|
|
35
|
+
|
|
36
|
+
async def save(self) -> None:
|
|
37
|
+
"""Сохранить карту сайта в БД."""
|
|
38
|
+
await self._sitemap.save()
|
|
39
|
+
|
|
40
|
+
def add_request(self, req: "ParsedRequest") -> None:
|
|
41
|
+
"""Добавить запрос в карту (вызывается из on_request_done прокси)."""
|
|
42
|
+
self._sitemap.add_request(req)
|
|
43
|
+
|
|
44
|
+
async def get_tree(self) -> dict[str, list[SiteNode]]:
|
|
45
|
+
"""Получить дерево хостов → список SiteNode.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Словарь {host: [SiteNode, ...]}.
|
|
49
|
+
"""
|
|
50
|
+
return self._sitemap.get_tree()
|
|
51
|
+
|
|
52
|
+
def get_hosts(self) -> list[str]:
|
|
53
|
+
"""Получить список всех хостов в карте сайта."""
|
|
54
|
+
return self._sitemap.get_hosts()
|
|
55
|
+
|
|
56
|
+
def get_paths(self, host: str) -> list[SiteNode]:
|
|
57
|
+
"""Получить список SiteNode для указанного хоста.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
host: Имя хоста (например, "example.com").
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Список SiteNode с путями, найденными для этого хоста.
|
|
64
|
+
"""
|
|
65
|
+
return self._sitemap.get_paths(host)
|
|
66
|
+
|
|
67
|
+
async def set_in_scope(self, host: str, in_scope: bool) -> None:
|
|
68
|
+
"""Задать scope-флаг для хоста.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
host: Имя хоста.
|
|
72
|
+
in_scope: True — хост в scope, False — вне scope.
|
|
73
|
+
"""
|
|
74
|
+
self._sitemap.set_in_scope(host, in_scope)
|
|
75
|
+
|
|
76
|
+
async def get_scope(self) -> list[str]:
|
|
77
|
+
"""Получить список хостов, находящихся в scope.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Список строк — имён хостов с флагом in_scope=True.
|
|
81
|
+
"""
|
|
82
|
+
return self._sitemap.get_scope()
|
|
83
|
+
|
|
84
|
+
async def clear(self) -> None:
|
|
85
|
+
"""Очистить всю карту сайта (все хосты и пути)."""
|
|
86
|
+
self._sitemap.clear()
|
|
87
|
+
|
|
88
|
+
async def export_json(self, path: str) -> None:
|
|
89
|
+
"""Экспортировать карту в JSON-файл."""
|
|
90
|
+
data = self._sitemap.export_json()
|
|
91
|
+
Path(path).write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
92
|
+
logger.info("SiteMap exported to %s", path)
|
|
93
|
+
|
|
94
|
+
# ── Project persistence ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def export_project_data(self) -> dict:
|
|
97
|
+
"""Экспортировать SiteMap в сериализуемый dict для сохранения проекта."""
|
|
98
|
+
return {"sitemap": self._sitemap.export_json()}
|
|
99
|
+
|
|
100
|
+
def import_project_data(self, data: dict) -> int:
|
|
101
|
+
"""Импортировать SiteMap из загруженного dict проекта.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
data: Блок "target" из project.json ({"sitemap": {...}}).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Количество загруженных узлов.
|
|
108
|
+
"""
|
|
109
|
+
from pentool.modules.target import SiteNode
|
|
110
|
+
sitemap_data = data.get("sitemap", {})
|
|
111
|
+
self._sitemap.clear()
|
|
112
|
+
loaded = 0
|
|
113
|
+
for host, nodes in sitemap_data.items():
|
|
114
|
+
for node_dict in nodes:
|
|
115
|
+
try:
|
|
116
|
+
node = SiteNode.from_dict(node_dict)
|
|
117
|
+
self._sitemap._nodes.setdefault(host, {})[node.path] = node
|
|
118
|
+
loaded += 1
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.warning("TargetAPI.import_project_data: skip node: %s", exc)
|
|
121
|
+
return loaded
|
pentool/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Пакет cli: команды Click."""
|