pentool 0.1.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 +16 -0
- pentool/api/__init__.py +41 -0
- pentool/api/base_api.py +48 -0
- pentool/api/comparer_api.py +19 -0
- pentool/api/decoder_api.py +24 -0
- pentool/api/intruder_api.py +171 -0
- pentool/api/proxy_api.py +210 -0
- pentool/api/repeater_api.py +57 -0
- pentool/api/scanner_api.py +266 -0
- pentool/api/sequencer_api.py +17 -0
- pentool/api/spider_api.py +106 -0
- pentool/api/target_api.py +87 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +98 -0
- pentool/cli/project.py +88 -0
- pentool/cli/proxy.py +173 -0
- pentool/cli/scan.py +115 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +171 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +207 -0
- pentool/core/events.py +160 -0
- pentool/core/features.py +373 -0
- pentool/core/license.py +274 -0
- pentool/core/logging.py +54 -0
- pentool/core/plugin_manager.py +312 -0
- pentool/core/project.py +31 -0
- pentool/core/storage_interface.py +307 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +36 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +428 -0
- pentool/modules/intruder_turbo.py +176 -0
- pentool/modules/match_replace.py +142 -0
- pentool/modules/proxy.py +797 -0
- pentool/modules/repeater.py +195 -0
- pentool/modules/sequencer.py +492 -0
- pentool/modules/spider.py +679 -0
- pentool/modules/target.py +185 -0
- pentool/modules/websocket_handler.py +249 -0
- pentool/plugins/__init__.py +1 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +381 -0
- pentool/plugins/builtin/reports_pro.py +436 -0
- pentool/plugins/builtin/scanner_pro.py +291 -0
- pentool/plugins/example_plugin.py +39 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +60 -0
- pentool/services/intruder_service.py +94 -0
- pentool/services/proxy_service.py +178 -0
- pentool/services/repeater_service.py +85 -0
- pentool/services/scan_service.py +330 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +506 -0
- pentool/storage/large_body_handler.py +50 -0
- pentool/storage/lru_cache.py +45 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1429 -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 +226 -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 +224 -0
- pentool/tui/messages.py +103 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +59 -0
- pentool/tui/mixins/dialog_cancel.py +21 -0
- pentool/tui/mixins/request_context_menu.py +247 -0
- pentool/tui/mixins/tab_rename.py +89 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +229 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +730 -0
- pentool/tui/screens/dashboard/screen.py +771 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +306 -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 +1356 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1554 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +621 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1956 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +516 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +540 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +380 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +358 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +179 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +204 -0
- pentool/tui/widgets/inspector_panel.py +111 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +68 -0
- pentool/tui/widgets/payload_drop_zone.py +65 -0
- pentool/tui/widgets/request_editor.py +495 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +112 -0
- pentool/tui/widgets/statusbar.py +96 -0
- pentool/tui/widgets/toolbar_button.py +68 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +314 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +250 -0
- pentool/utils/diff.py +82 -0
- pentool/utils/http_client.py +145 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +31 -0
- pentool-0.1.0.dist-info/METADATA +231 -0
- pentool-0.1.0.dist-info/RECORD +133 -0
- pentool-0.1.0.dist-info/WHEEL +5 -0
- pentool-0.1.0.dist-info/entry_points.txt +2 -0
- pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
- pentool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
|
|
52
|
+
def __init__(self, db_path: str = "", http_client=None) -> None:
|
|
53
|
+
self._db_path = db_path
|
|
54
|
+
self._http_client = http_client
|
|
55
|
+
self._engine: ScanEngine | None = None
|
|
56
|
+
self._passive: PassiveScanner | None = None
|
|
57
|
+
self._active_task: asyncio.Task | None = None
|
|
58
|
+
self._scan_id: str | None = None
|
|
59
|
+
|
|
60
|
+
def _get_engine(self) -> ScanEngine:
|
|
61
|
+
if self._engine is None:
|
|
62
|
+
self._engine = ScanEngine(
|
|
63
|
+
db_path=self._db_path,
|
|
64
|
+
http_client=self._http_client,
|
|
65
|
+
)
|
|
66
|
+
self._engine.register_checks([
|
|
67
|
+
MissingSecurityHeadersCheck(),
|
|
68
|
+
InfoLeakCheck(),
|
|
69
|
+
SQLiCheck(),
|
|
70
|
+
XSSCheck(),
|
|
71
|
+
SSTICheck(),
|
|
72
|
+
LFICheck(),
|
|
73
|
+
PathTraversalCheck(),
|
|
74
|
+
HeaderInjectionCheck(),
|
|
75
|
+
HostHeaderInjectionCheck(),
|
|
76
|
+
RCECheck(),
|
|
77
|
+
OpenRedirectCheck(),
|
|
78
|
+
SSRFCheck(),
|
|
79
|
+
XXECheck(),
|
|
80
|
+
CORSCheck(),
|
|
81
|
+
BrokenAuthCheck(),
|
|
82
|
+
JWTNoneCheck(),
|
|
83
|
+
NoSQLInjectionCheck(),
|
|
84
|
+
GraphQLCheck(),
|
|
85
|
+
PrototypePollutionCheck(),
|
|
86
|
+
DOMXSSCheck(),
|
|
87
|
+
OAuthCheck(),
|
|
88
|
+
SensitiveDataCheck(),
|
|
89
|
+
SQLiUnionCheck(),
|
|
90
|
+
])
|
|
91
|
+
return self._engine
|
|
92
|
+
|
|
93
|
+
async def start_active_scan(
|
|
94
|
+
self,
|
|
95
|
+
targets: list[str],
|
|
96
|
+
check_names: list[str] | None = None,
|
|
97
|
+
on_finding: Callable[[Finding | None, None]] = None,
|
|
98
|
+
on_progress: Callable[[int, int | None, None]] = None,
|
|
99
|
+
on_request: Callable[[str, str | None, None]] = None,
|
|
100
|
+
concurrency: int = 5,
|
|
101
|
+
request_delay: float = 0.0,
|
|
102
|
+
) -> str:
|
|
103
|
+
import uuid
|
|
104
|
+
self._scan_id = str(uuid.uuid4())
|
|
105
|
+
engine = self._get_engine()
|
|
106
|
+
engine._concurrency = concurrency
|
|
107
|
+
engine._request_delay = request_delay
|
|
108
|
+
|
|
109
|
+
async def _run() -> None:
|
|
110
|
+
findings = await engine.run_active(
|
|
111
|
+
targets,
|
|
112
|
+
check_names=check_names,
|
|
113
|
+
on_finding=on_finding,
|
|
114
|
+
on_progress=on_progress,
|
|
115
|
+
on_request=on_request,
|
|
116
|
+
)
|
|
117
|
+
await engine.save_findings(findings)
|
|
118
|
+
|
|
119
|
+
self._active_task = asyncio.create_task(_run())
|
|
120
|
+
return self._scan_id
|
|
121
|
+
|
|
122
|
+
async def stop_scan(self) -> None:
|
|
123
|
+
if self._active_task and not self._active_task.done():
|
|
124
|
+
self._active_task.cancel()
|
|
125
|
+
try:
|
|
126
|
+
await self._active_task
|
|
127
|
+
except asyncio.CancelledError:
|
|
128
|
+
pass
|
|
129
|
+
self._active_task = None
|
|
130
|
+
|
|
131
|
+
def is_scanning(self) -> bool:
|
|
132
|
+
return bool(self._active_task and not self._active_task.done())
|
|
133
|
+
|
|
134
|
+
async def get_findings(self, limit: int = 200) -> list[Finding]:
|
|
135
|
+
return await self._get_engine().get_findings(limit)
|
|
136
|
+
|
|
137
|
+
async def mark_false_positive(self, finding_id: str) -> None:
|
|
138
|
+
await self._get_engine().mark_false_positive(finding_id)
|
|
139
|
+
|
|
140
|
+
async def attach_passive(self, proxy_api=None) -> None:
|
|
141
|
+
engine = self._get_engine()
|
|
142
|
+
self._passive = PassiveScanner(engine)
|
|
143
|
+
self._passive.attach_bus()
|
|
144
|
+
logger.info("Passive scanner attached via EventBus")
|
|
145
|
+
|
|
146
|
+
async def detach_passive(self) -> None:
|
|
147
|
+
if self._passive:
|
|
148
|
+
self._passive.detach_bus()
|
|
149
|
+
self._passive = None
|
|
150
|
+
|
|
151
|
+
def set_passive_callback(self, callback: Callable[[Finding], None]) -> None:
|
|
152
|
+
if self._passive:
|
|
153
|
+
self._passive.on_finding = callback
|
|
154
|
+
|
|
155
|
+
async def generate_report(self, path: str, fmt: str = "html") -> None:
|
|
156
|
+
findings = await self.get_findings(limit=10000)
|
|
157
|
+
fmt = fmt.lower()
|
|
158
|
+
if fmt == "html":
|
|
159
|
+
generate_html_report(findings, path)
|
|
160
|
+
elif fmt == "json":
|
|
161
|
+
generate_json_report(findings, path)
|
|
162
|
+
elif fmt == "csv":
|
|
163
|
+
generate_csv_report(findings, path)
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(f"Unknown report format: {fmt}")
|
|
166
|
+
logger.info("Report generated: %s (%s)", path, fmt)
|
|
167
|
+
|
|
168
|
+
def register_check(self, check: BaseCheck) -> None:
|
|
169
|
+
self._get_engine().register_check(check)
|
|
170
|
+
|
|
171
|
+
def get_registered_checks(self) -> list[BaseCheck]:
|
|
172
|
+
return self._get_engine().get_registered_checks()
|
|
173
|
+
|
|
174
|
+
# ── Project persistence ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def export_project_data(self) -> dict:
|
|
177
|
+
try:
|
|
178
|
+
findings = run_async_sync(self.get_findings(limit=10_000), timeout=10)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.warning("export_project_data: %s", exc)
|
|
181
|
+
findings = []
|
|
182
|
+
return {"findings": [f.to_dict() for f in (findings or [])]}
|
|
183
|
+
|
|
184
|
+
def import_project_data(self, data: dict) -> int:
|
|
185
|
+
findings_data = data.get("findings", [])
|
|
186
|
+
if not findings_data:
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
findings = []
|
|
190
|
+
for fd in findings_data:
|
|
191
|
+
try:
|
|
192
|
+
findings.append(Finding.from_dict(fd))
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.warning("ScannerAPI.import_project_data: skip finding: %s", exc)
|
|
195
|
+
|
|
196
|
+
if not findings:
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
# Пишем в SQLite через save_findings (нужен async контекст)
|
|
200
|
+
engine = self._get_engine()
|
|
201
|
+
try:
|
|
202
|
+
run_async_sync(engine.save_findings(findings), timeout=15)
|
|
203
|
+
return len(findings)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
logger.warning("import_project_data save_findings: %s", exc)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
async def get_stats(self) -> dict:
|
|
209
|
+
return await self._get_engine().get_stats()
|
|
210
|
+
|
|
211
|
+
async def get_host_count(self) -> int:
|
|
212
|
+
try:
|
|
213
|
+
from pentool.storage.http_storage import HttpStorage
|
|
214
|
+
storage = HttpStorage()
|
|
215
|
+
await storage.init_db(self._db_path)
|
|
216
|
+
try:
|
|
217
|
+
return await storage.count_distinct_hosts()
|
|
218
|
+
finally:
|
|
219
|
+
await storage.close()
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
logger.debug("ScannerAPI.get_host_count: %s", exc)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
async def get_history_requests(
|
|
225
|
+
self,
|
|
226
|
+
scope_host: str = "",
|
|
227
|
+
limit: int = 500,
|
|
228
|
+
) -> list:
|
|
229
|
+
from pentool.storage.http_storage import HttpStorage
|
|
230
|
+
from pentool.utils.parser import ParsedRequest
|
|
231
|
+
import re as _re
|
|
232
|
+
|
|
233
|
+
storage = HttpStorage()
|
|
234
|
+
try:
|
|
235
|
+
await storage.init_db(self._db_path)
|
|
236
|
+
entries = await storage.export_all_requests(limit=limit)
|
|
237
|
+
finally:
|
|
238
|
+
await storage.close()
|
|
239
|
+
|
|
240
|
+
seen: set[str] = set()
|
|
241
|
+
result: list[ParsedRequest] = []
|
|
242
|
+
|
|
243
|
+
for entry in entries:
|
|
244
|
+
url: str = entry.get("url", "") or ""
|
|
245
|
+
method: str = (entry.get("method", "GET") or "GET").upper()
|
|
246
|
+
if not url:
|
|
247
|
+
continue
|
|
248
|
+
if scope_host and scope_host not in url:
|
|
249
|
+
continue
|
|
250
|
+
# Дедупликация по (method, path без значений параметров)
|
|
251
|
+
path_tmpl = _re.sub(r"=[^&]*", "=", url.split("?")[0])
|
|
252
|
+
dedup_key = f"{method}:{path_tmpl}"
|
|
253
|
+
if dedup_key in seen:
|
|
254
|
+
continue
|
|
255
|
+
seen.add(dedup_key)
|
|
256
|
+
|
|
257
|
+
headers: dict = entry.get("request_headers", {}) or {}
|
|
258
|
+
body: str = entry.get("request_body", "") or ""
|
|
259
|
+
result.append(ParsedRequest(
|
|
260
|
+
method=method,
|
|
261
|
+
url=url,
|
|
262
|
+
headers=headers,
|
|
263
|
+
body=body,
|
|
264
|
+
))
|
|
265
|
+
|
|
266
|
+
return result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""SequencerAPI — публичный интерфейс Sequencer для TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pentool.modules.sequencer import ( # noqa: F401
|
|
6
|
+
Sequencer,
|
|
7
|
+
SequencerReport,
|
|
8
|
+
token_entropy,
|
|
9
|
+
charset_size,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Sequencer",
|
|
14
|
+
"SequencerReport",
|
|
15
|
+
"token_entropy",
|
|
16
|
+
"charset_size",
|
|
17
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""SpiderAPI — публичный интерфейс Spider-модуля для TUI и CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from pentool.core.logging import get_logger
|
|
9
|
+
from pentool.modules.spider import (
|
|
10
|
+
AsyncSpider,
|
|
11
|
+
SpiderEndpoint,
|
|
12
|
+
SpiderForm,
|
|
13
|
+
SpiderResult,
|
|
14
|
+
is_playwright_available,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
# Реэкспорт типов — TUI использует их отсюда
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SpiderAPI", "SpiderResult", "SpiderForm", "SpiderEndpoint", "SpiderConfig",
|
|
22
|
+
"is_playwright_available",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SpiderConfig:
|
|
28
|
+
"""Конфигурация краулера."""
|
|
29
|
+
max_depth: int = 3
|
|
30
|
+
max_pages: int = 100
|
|
31
|
+
concurrency: int = 5
|
|
32
|
+
timeout: float = 10.0
|
|
33
|
+
user_agent: str = "pentool/1.0"
|
|
34
|
+
respect_scope: bool = False
|
|
35
|
+
js_render: bool = False # Playwright JS-рендеринг (если установлен)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SpiderAPI:
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: SpiderConfig | None = None) -> None:
|
|
41
|
+
self._config = config or SpiderConfig()
|
|
42
|
+
self._spider: AsyncSpider | None = None
|
|
43
|
+
self._stop_requested = False
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_params(
|
|
47
|
+
cls,
|
|
48
|
+
max_depth: int = 3,
|
|
49
|
+
max_pages: int = 100,
|
|
50
|
+
concurrency: int = 5,
|
|
51
|
+
timeout: float = 10.0,
|
|
52
|
+
) -> "SpiderAPI":
|
|
53
|
+
"""Удобный фабричный метод."""
|
|
54
|
+
return cls(SpiderConfig(
|
|
55
|
+
max_depth=max_depth,
|
|
56
|
+
max_pages=max_pages,
|
|
57
|
+
concurrency=concurrency,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
async def crawl(
|
|
62
|
+
self,
|
|
63
|
+
url: str,
|
|
64
|
+
on_page: Callable[[str], None] | None = None,
|
|
65
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
66
|
+
extra_headers: dict | None = None,
|
|
67
|
+
) -> SpiderResult:
|
|
68
|
+
self._stop_requested = False
|
|
69
|
+
cfg = self._config
|
|
70
|
+
self._spider = AsyncSpider(
|
|
71
|
+
max_depth=cfg.max_depth,
|
|
72
|
+
max_pages=cfg.max_pages,
|
|
73
|
+
concurrency=cfg.concurrency,
|
|
74
|
+
respect_scope=cfg.respect_scope,
|
|
75
|
+
js_render=cfg.js_render,
|
|
76
|
+
extra_headers=extra_headers or {},
|
|
77
|
+
)
|
|
78
|
+
if on_page:
|
|
79
|
+
self._spider.on_page = on_page
|
|
80
|
+
if on_progress:
|
|
81
|
+
self._spider.on_progress = on_progress
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = await self._spider.crawl(url)
|
|
85
|
+
logger.info(
|
|
86
|
+
"SpiderAPI.crawl: %s → %d pages, %d forms, %d endpoints",
|
|
87
|
+
url, len(result.pages), len(result.forms), len(result.endpoints),
|
|
88
|
+
)
|
|
89
|
+
return result
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
logger.warning("SpiderAPI.crawl error for %s: %s", url, exc)
|
|
92
|
+
return SpiderResult(pages=[], forms=[], endpoints=[], js_files=[], errors=[str(exc)])
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
"""Запросить остановку краулинга."""
|
|
96
|
+
self._stop_requested = True
|
|
97
|
+
if self._spider is not None:
|
|
98
|
+
try:
|
|
99
|
+
self._spider._stop_requested = True
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def config(self) -> SpiderConfig:
|
|
105
|
+
"""Текущая конфигурация краулера (max_depth, max_pages, concurrency)."""
|
|
106
|
+
return self._config
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
|
|
22
|
+
def __init__(self, db_path: str = "") -> None:
|
|
23
|
+
self._db_path = db_path
|
|
24
|
+
self._sitemap = SiteMap(db_path=db_path)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def sitemap(self) -> SiteMap:
|
|
28
|
+
"""Прямой доступ к объекту SiteMap."""
|
|
29
|
+
return self._sitemap
|
|
30
|
+
|
|
31
|
+
async def load(self) -> None:
|
|
32
|
+
await self._sitemap.load()
|
|
33
|
+
|
|
34
|
+
async def save(self) -> None:
|
|
35
|
+
await self._sitemap.save()
|
|
36
|
+
|
|
37
|
+
def add_request(self, req: "ParsedRequest") -> None:
|
|
38
|
+
self._sitemap.add_request(req)
|
|
39
|
+
|
|
40
|
+
async def get_tree(self) -> dict[str, list[SiteNode]]:
|
|
41
|
+
return self._sitemap.get_tree()
|
|
42
|
+
|
|
43
|
+
def get_hosts(self) -> list[str]:
|
|
44
|
+
return self._sitemap.get_hosts()
|
|
45
|
+
|
|
46
|
+
def get_paths(self, host: str) -> list[SiteNode]:
|
|
47
|
+
return self._sitemap.get_paths(host)
|
|
48
|
+
|
|
49
|
+
async def set_in_scope(self, host: str, in_scope: bool) -> None:
|
|
50
|
+
"""Задать scope-флаг для хоста.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
host: Имя хоста.
|
|
54
|
+
in_scope: True — хост в scope, False — вне scope.
|
|
55
|
+
"""
|
|
56
|
+
self._sitemap.set_in_scope(host, in_scope)
|
|
57
|
+
|
|
58
|
+
async def get_scope(self) -> list[str]:
|
|
59
|
+
return self._sitemap.get_scope()
|
|
60
|
+
|
|
61
|
+
async def clear(self) -> None:
|
|
62
|
+
self._sitemap.clear()
|
|
63
|
+
|
|
64
|
+
async def export_json(self, path: str) -> None:
|
|
65
|
+
data = self._sitemap.export_json()
|
|
66
|
+
Path(path).write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
67
|
+
logger.info("SiteMap exported to %s", path)
|
|
68
|
+
|
|
69
|
+
# ── Project persistence ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def export_project_data(self) -> dict:
|
|
72
|
+
return {"sitemap": self._sitemap.export_json()}
|
|
73
|
+
|
|
74
|
+
def import_project_data(self, data: dict) -> int:
|
|
75
|
+
from pentool.modules.target import SiteNode
|
|
76
|
+
sitemap_data = data.get("sitemap", {})
|
|
77
|
+
self._sitemap.clear()
|
|
78
|
+
loaded = 0
|
|
79
|
+
for host, nodes in sitemap_data.items():
|
|
80
|
+
for node_dict in nodes:
|
|
81
|
+
try:
|
|
82
|
+
node = SiteNode.from_dict(node_dict)
|
|
83
|
+
self._sitemap._nodes.setdefault(host, {})[node.path] = node
|
|
84
|
+
loaded += 1
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
logger.warning("TargetAPI.import_project_data: skip node: %s", exc)
|
|
87
|
+
return loaded
|
pentool/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Пакет cli: команды Click."""
|
pentool/cli/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Главная группа CLI-команд."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from pentool.core.config import get_config
|
|
8
|
+
from pentool.core.logging import setup_logging
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.version_option(package_name="pentool")
|
|
13
|
+
@click.option("--config", "config_path", default=None, help="Путь к файлу конфигурации.")
|
|
14
|
+
@click.option("--verbose", "-v", is_flag=True, default=False, help="Подробный вывод (DEBUG).")
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def cli(ctx: click.Context, config_path: str | None, verbose: bool) -> None:
|
|
17
|
+
"""Pentool — консольный инструмент для пентестинга веб-приложений.
|
|
18
|
+
|
|
19
|
+
Запустите без аргументов для открытия TUI:
|
|
20
|
+
|
|
21
|
+
pentool
|
|
22
|
+
|
|
23
|
+
Или используйте команды ниже для работы из командной строки.
|
|
24
|
+
"""
|
|
25
|
+
ctx.ensure_object(dict)
|
|
26
|
+
|
|
27
|
+
cfg = get_config()
|
|
28
|
+
if config_path:
|
|
29
|
+
from pentool.core.config import Config
|
|
30
|
+
cfg = Config.load(config_path)
|
|
31
|
+
|
|
32
|
+
log_level = "DEBUG" if verbose else cfg.log_level
|
|
33
|
+
setup_logging(cfg.log_file, log_level)
|
|
34
|
+
|
|
35
|
+
ctx.obj["config"] = cfg
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Импорт и регистрация групп команд
|
|
39
|
+
from pentool.cli.project import project # noqa: E402
|
|
40
|
+
|
|
41
|
+
cli.add_command(project)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from pentool.cli.proxy import proxy # noqa: E402
|
|
45
|
+
|
|
46
|
+
cli.add_command(proxy)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.group()
|
|
50
|
+
def repeater() -> None:
|
|
51
|
+
"""Модуль Repeater: ручная отправка запросов."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@repeater.command("send")
|
|
55
|
+
@click.option("--request-file", required=True, type=click.Path(exists=True), help="Файл с HTTP-запросом.")
|
|
56
|
+
def repeater_send(request_file: str) -> None:
|
|
57
|
+
click.echo(f"Repeater send {request_file} — not implemented yet.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.group()
|
|
61
|
+
def intruder() -> None:
|
|
62
|
+
"""Модуль Intruder: автоматизированные атаки."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@intruder.command("run")
|
|
66
|
+
@click.option("--request", "request_file", required=True, type=click.Path(exists=True))
|
|
67
|
+
@click.option("--payloads", required=True, type=click.Path(exists=True))
|
|
68
|
+
@click.option("--attack", default="sniper", show_default=True,
|
|
69
|
+
type=click.Choice(["sniper", "battering_ram", "pitchfork", "cluster_bomb"]))
|
|
70
|
+
def intruder_run(request_file: str, payloads: str, attack: str) -> None:
|
|
71
|
+
click.echo(f"Intruder run [{attack}] — not implemented yet.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
from pentool.cli.scan import scan # noqa: E402
|
|
75
|
+
|
|
76
|
+
cli.add_command(scan)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cli.command("decode")
|
|
80
|
+
@click.argument("operation", type=click.Choice([
|
|
81
|
+
"url_encode", "url_decode",
|
|
82
|
+
"base64_encode", "base64_decode",
|
|
83
|
+
"base64url_encode", "base64url_decode",
|
|
84
|
+
"html_encode", "html_decode",
|
|
85
|
+
"hex_encode", "hex_decode",
|
|
86
|
+
"unicode_escape", "unicode_unescape",
|
|
87
|
+
"md5", "sha1", "sha256",
|
|
88
|
+
]))
|
|
89
|
+
@click.argument("text")
|
|
90
|
+
def decode_cmd(operation: str, text: str) -> None:
|
|
91
|
+
"""Кодировать/декодировать/хэшировать текст."""
|
|
92
|
+
from pentool.utils.coder import apply_operation
|
|
93
|
+
try:
|
|
94
|
+
result = apply_operation(operation, text)
|
|
95
|
+
click.echo(result)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
click.echo(f"Error: {exc}", err=True)
|
|
98
|
+
raise SystemExit(1) from exc
|
pentool/cli/project.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""CLI-команды для управления проектами."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from pentool.core.config import Config, get_config
|
|
12
|
+
from pentool.core.database import init_db
|
|
13
|
+
from pentool.core.logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
def project() -> None:
|
|
20
|
+
"""Управление проектами: инициализация, список, открытие."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@project.command("init")
|
|
24
|
+
@click.option("--name", default="default", show_default=True, help="Имя проекта.")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--path",
|
|
27
|
+
default=None,
|
|
28
|
+
help="Директория проекта (по умолчанию: ~/.config/pentool/projects/<name>).",
|
|
29
|
+
)
|
|
30
|
+
def project_init(name: str, path: str | None) -> None:
|
|
31
|
+
cfg = get_config()
|
|
32
|
+
|
|
33
|
+
if path is None:
|
|
34
|
+
project_dir = Path.home() / ".config" / "pentool" / "projects" / name
|
|
35
|
+
else:
|
|
36
|
+
project_dir = Path(path) / name
|
|
37
|
+
|
|
38
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
db_path = str(project_dir / "pentool.db")
|
|
41
|
+
log_file = str(project_dir / "pentool.log")
|
|
42
|
+
cert_dir = str(project_dir / "certs")
|
|
43
|
+
plugins_dir = str(project_dir / "plugins")
|
|
44
|
+
|
|
45
|
+
# Обновить и сохранить конфиг проекта
|
|
46
|
+
project_cfg = Config(
|
|
47
|
+
proxy_host=cfg.proxy_host,
|
|
48
|
+
proxy_port=cfg.proxy_port,
|
|
49
|
+
cert_dir=cert_dir,
|
|
50
|
+
db_path=db_path,
|
|
51
|
+
log_file=log_file,
|
|
52
|
+
log_level=cfg.log_level,
|
|
53
|
+
plugins_dir=plugins_dir,
|
|
54
|
+
)
|
|
55
|
+
config_path = project_dir / "config.yaml"
|
|
56
|
+
project_cfg.save(config_path)
|
|
57
|
+
|
|
58
|
+
# Создать таблицы БД
|
|
59
|
+
try:
|
|
60
|
+
asyncio.run(init_db(db_path))
|
|
61
|
+
click.echo(f"Project '{name}' initialized:")
|
|
62
|
+
click.echo(f" Directory : {project_dir}")
|
|
63
|
+
click.echo(f" Database : {db_path}")
|
|
64
|
+
click.echo(f" Config : {config_path}")
|
|
65
|
+
click.echo(f" Log file : {log_file}")
|
|
66
|
+
logger.info("Project '%s' initialized at %s", name, project_dir)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
click.echo(f"Error initializing database: {exc}", err=True)
|
|
69
|
+
logger.error("Failed to initialize project '%s': %s", name, exc)
|
|
70
|
+
raise SystemExit(1) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@project.command("list")
|
|
74
|
+
def project_list() -> None:
|
|
75
|
+
projects_dir = Path.home() / ".config" / "pentool" / "projects"
|
|
76
|
+
if not projects_dir.exists():
|
|
77
|
+
click.echo("No projects found.")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
entries = [d for d in projects_dir.iterdir() if d.is_dir()]
|
|
81
|
+
if not entries:
|
|
82
|
+
click.echo("No projects found.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
click.echo(f"{'Name':<20} {'Path'}")
|
|
86
|
+
click.echo("-" * 60)
|
|
87
|
+
for entry in sorted(entries):
|
|
88
|
+
click.echo(f"{entry.name:<20} {entry}")
|