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,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
@@ -0,0 +1 @@
1
+ """Пакет cli: команды Click."""