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
pentool/cli/proxy.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""CLI-команды для управления прокси-сервером."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from pentool.core.config import get_config
|
|
14
|
+
from pentool.core.logging import get_logger, setup_logging
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def proxy() -> None:
|
|
21
|
+
"""Управление HTTP/HTTPS прокси-сервером."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@proxy.command("start")
|
|
25
|
+
@click.option("--port", default=8080, show_default=True, help="Порт прокси.")
|
|
26
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Адрес для прослушивания.")
|
|
27
|
+
@click.option("--cert-dir", default=None, help="Директория для CA-сертификатов.")
|
|
28
|
+
@click.option("--db", "db_path", default=None, help="Путь к SQLite БД для логирования.")
|
|
29
|
+
@click.option("--intercept", is_flag=True, default=False, help="Включить интерактивный перехват.")
|
|
30
|
+
@click.option("--scope", default="", help="Список хостов через запятую (пусто = все).")
|
|
31
|
+
def proxy_start(
|
|
32
|
+
port: int,
|
|
33
|
+
host: str,
|
|
34
|
+
cert_dir: str | None,
|
|
35
|
+
db_path: str | None,
|
|
36
|
+
intercept: bool,
|
|
37
|
+
scope: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
cfg = get_config()
|
|
40
|
+
setup_logging(cfg.log_file, cfg.log_level)
|
|
41
|
+
|
|
42
|
+
_cert_dir = cert_dir or cfg.cert_dir
|
|
43
|
+
_db_path = db_path or cfg.db_path
|
|
44
|
+
|
|
45
|
+
from pentool.api.proxy_api import ProxyAPI
|
|
46
|
+
|
|
47
|
+
api = ProxyAPI()
|
|
48
|
+
server = api.create_proxy(
|
|
49
|
+
host=host,
|
|
50
|
+
port=port,
|
|
51
|
+
cert_dir=_cert_dir,
|
|
52
|
+
db_path=_db_path,
|
|
53
|
+
)
|
|
54
|
+
server.intercept_enabled = intercept
|
|
55
|
+
|
|
56
|
+
if scope:
|
|
57
|
+
server.set_scope([h.strip() for h in scope.split(",") if h.strip()])
|
|
58
|
+
|
|
59
|
+
click.echo(f"Starting proxy on {host}:{port}")
|
|
60
|
+
click.echo(f" CA certs : {_cert_dir}")
|
|
61
|
+
click.echo(f" Database : {_db_path}")
|
|
62
|
+
click.echo(f" Intercept : {'ON' if intercept else 'OFF'}")
|
|
63
|
+
if server.scope:
|
|
64
|
+
click.echo(f" Scope : {', '.join(server.scope)}")
|
|
65
|
+
else:
|
|
66
|
+
click.echo(" Scope : ALL hosts")
|
|
67
|
+
click.echo("Press Ctrl+C to stop.\n")
|
|
68
|
+
|
|
69
|
+
# Инициализировать БД если нужно
|
|
70
|
+
from pentool.core.database import init_db_sync
|
|
71
|
+
try:
|
|
72
|
+
init_db_sync(_db_path)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
click.echo(f"Warning: could not init database: {exc}", err=True)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
asyncio.run(server.serve_forever())
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
click.echo("\nProxy stopped.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@proxy.command("status")
|
|
83
|
+
@click.option("--port", default=8080, show_default=True, help="Порт для проверки.")
|
|
84
|
+
def proxy_status(port: int) -> None:
|
|
85
|
+
"""Проверить, слушает ли прокси на указанном порту."""
|
|
86
|
+
import socket
|
|
87
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
88
|
+
s.settimeout(1.0)
|
|
89
|
+
result = s.connect_ex(("127.0.0.1", port))
|
|
90
|
+
if result == 0:
|
|
91
|
+
click.echo(f"Proxy is RUNNING on port {port}")
|
|
92
|
+
else:
|
|
93
|
+
click.echo(f"Proxy is NOT running on port {port}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@proxy.command("history")
|
|
97
|
+
@click.option("--db", "db_path", default=None, help="Путь к SQLite БД.")
|
|
98
|
+
@click.option("--limit", default=20, show_default=True, help="Количество записей.")
|
|
99
|
+
@click.option("--method", default=None, help="Фильтр по методу (GET, POST, ...).")
|
|
100
|
+
@click.option("--host", default=None, help="Фильтр по хосту (подстрока URL).")
|
|
101
|
+
def proxy_history(
|
|
102
|
+
db_path: str | None,
|
|
103
|
+
limit: int,
|
|
104
|
+
method: str | None,
|
|
105
|
+
host: str | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
cfg = get_config()
|
|
108
|
+
_db_path = db_path or cfg.db_path
|
|
109
|
+
|
|
110
|
+
if not Path(_db_path).exists():
|
|
111
|
+
click.echo(f"Database not found: {_db_path}", err=True)
|
|
112
|
+
raise SystemExit(1)
|
|
113
|
+
|
|
114
|
+
async def _fetch() -> list:
|
|
115
|
+
from pentool.storage.http_storage import HttpStorage
|
|
116
|
+
storage = HttpStorage()
|
|
117
|
+
await storage.init_db(_db_path)
|
|
118
|
+
filters: dict = {}
|
|
119
|
+
if method:
|
|
120
|
+
filters["method"] = [method.upper()]
|
|
121
|
+
if host:
|
|
122
|
+
filters["host"] = host
|
|
123
|
+
return await storage.get_metadata_batch(
|
|
124
|
+
offset=0,
|
|
125
|
+
limit=limit,
|
|
126
|
+
filters=filters if filters else None,
|
|
127
|
+
order_by="id",
|
|
128
|
+
desc=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
rows = asyncio.run(_fetch())
|
|
132
|
+
|
|
133
|
+
if not rows:
|
|
134
|
+
click.echo("No requests found.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
click.echo(f"{'ID':<6} {'METHOD':<8} {'STATUS':<8} {'URL'}")
|
|
138
|
+
click.echo("-" * 80)
|
|
139
|
+
for row in rows:
|
|
140
|
+
rid = row["id"]
|
|
141
|
+
meth = row.get("method", "-")
|
|
142
|
+
url = row.get("url", "-")
|
|
143
|
+
status = row.get("status_code")
|
|
144
|
+
status_str = str(status) if status else "-"
|
|
145
|
+
url_short = url[:60] + "..." if len(url) > 60 else url
|
|
146
|
+
click.echo(f"{rid:<6} {meth:<8} {status_str:<8} {url_short}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@proxy.command("ca-info")
|
|
150
|
+
@click.option("--cert-dir", default=None, help="Директория CA-сертификатов.")
|
|
151
|
+
def proxy_ca_info(cert_dir: str | None) -> None:
|
|
152
|
+
cfg = get_config()
|
|
153
|
+
_cert_dir = cert_dir or cfg.cert_dir
|
|
154
|
+
ca_cert = Path(_cert_dir) / "ca.crt"
|
|
155
|
+
|
|
156
|
+
if ca_cert.exists():
|
|
157
|
+
click.echo(f"CA certificate: {ca_cert}")
|
|
158
|
+
else:
|
|
159
|
+
from pentool.utils.cert import generate_ca_cert
|
|
160
|
+
cert_path, _ = generate_ca_cert(_cert_dir)
|
|
161
|
+
click.echo(f"CA certificate generated: {cert_path}")
|
|
162
|
+
|
|
163
|
+
click.echo("\nTo trust the CA certificate:")
|
|
164
|
+
click.echo("")
|
|
165
|
+
click.echo(" Linux (Ubuntu/Debian):")
|
|
166
|
+
click.echo(f" sudo cp {ca_cert} /usr/local/share/ca-certificates/pentool-ca.crt")
|
|
167
|
+
click.echo(" sudo update-ca-certificates")
|
|
168
|
+
click.echo("")
|
|
169
|
+
click.echo(" macOS:")
|
|
170
|
+
click.echo(f" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {ca_cert}")
|
|
171
|
+
click.echo("")
|
|
172
|
+
click.echo(" Firefox / Chrome:")
|
|
173
|
+
click.echo(" Settings → Certificates → Import → select ca.crt → Trust for websites")
|
pentool/cli/scan.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""CLI-команды сканирования: pentool scan active / passive / report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group("scan")
|
|
12
|
+
def scan() -> None:
|
|
13
|
+
"""Модуль Scanner: сканирование уязвимостей."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@scan.command("active")
|
|
17
|
+
@click.option("--url", "urls", required=True, multiple=True, help="Целевой URL (можно несколько).")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--checks", "check_names", default=None,
|
|
20
|
+
help="Проверки через запятую: missing_security_headers,info_leak",
|
|
21
|
+
)
|
|
22
|
+
@click.option("--output", "output", default=None, help="Сохранить findings в файл (json/csv/html).")
|
|
23
|
+
@click.option("--concurrency", default=5, show_default=True, help="Параллельных потоков.")
|
|
24
|
+
@click.option("--delay", default=0.0, show_default=True, help="Задержка между запросами (сек).")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def scan_active(
|
|
27
|
+
ctx: click.Context,
|
|
28
|
+
urls: tuple[str, ...],
|
|
29
|
+
check_names: str | None,
|
|
30
|
+
output: str | None,
|
|
31
|
+
concurrency: int,
|
|
32
|
+
delay: float,
|
|
33
|
+
) -> None:
|
|
34
|
+
from pentool.api.scanner_api import ScannerAPI
|
|
35
|
+
from pentool.core.config import get_config
|
|
36
|
+
|
|
37
|
+
cfg = (ctx.obj or {}).get("config") or get_config()
|
|
38
|
+
names = [c.strip() for c in check_names.split(",")] if check_names else None
|
|
39
|
+
|
|
40
|
+
api = ScannerAPI(db_path=cfg.db_path)
|
|
41
|
+
|
|
42
|
+
findings = []
|
|
43
|
+
|
|
44
|
+
def on_finding(f) -> None:
|
|
45
|
+
findings.append(f)
|
|
46
|
+
sev = f.severity.upper()
|
|
47
|
+
click.echo(f" [{sev}] {f.name} — {f.url}")
|
|
48
|
+
|
|
49
|
+
def on_progress(done: int, total: int) -> None:
|
|
50
|
+
click.echo(f"\r Progress: {done}/{total}", nl=False)
|
|
51
|
+
|
|
52
|
+
async def _run() -> None:
|
|
53
|
+
await api.start_active_scan(
|
|
54
|
+
list(urls),
|
|
55
|
+
check_names=names,
|
|
56
|
+
on_finding=on_finding,
|
|
57
|
+
on_progress=on_progress,
|
|
58
|
+
concurrency=concurrency,
|
|
59
|
+
request_delay=delay,
|
|
60
|
+
)
|
|
61
|
+
# Ждём завершения задачи
|
|
62
|
+
if api._active_task:
|
|
63
|
+
await api._active_task
|
|
64
|
+
|
|
65
|
+
click.echo(f"Starting active scan on {len(urls)} target(s)...")
|
|
66
|
+
asyncio.run(_run())
|
|
67
|
+
click.echo(f"\nDone. Found {len(findings)} finding(s).")
|
|
68
|
+
|
|
69
|
+
if output:
|
|
70
|
+
fmt = "html"
|
|
71
|
+
if output.endswith(".json"):
|
|
72
|
+
fmt = "json"
|
|
73
|
+
elif output.endswith(".csv"):
|
|
74
|
+
fmt = "csv"
|
|
75
|
+
asyncio.run(api.generate_report(output, fmt))
|
|
76
|
+
click.echo(f"Report saved: {output}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@scan.command("passive")
|
|
80
|
+
@click.option("--scope", default=None, help="Фильтр хоста (например *.example.com).")
|
|
81
|
+
@click.pass_context
|
|
82
|
+
def scan_passive(ctx: click.Context, scope: str | None) -> None:
|
|
83
|
+
click.echo(
|
|
84
|
+
"Passive scanning runs automatically while the proxy intercepts traffic.\n"
|
|
85
|
+
"Use the TUI (Scanner tab) to enable Passive mode and view findings."
|
|
86
|
+
)
|
|
87
|
+
if scope:
|
|
88
|
+
click.echo(f"Scope filter: {scope}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@scan.command("report")
|
|
92
|
+
@click.option("--output", required=True, help="Путь к файлу отчёта.")
|
|
93
|
+
@click.option(
|
|
94
|
+
"--format", "fmt", default="html",
|
|
95
|
+
type=click.Choice(["html", "json", "csv"], case_sensitive=False),
|
|
96
|
+
show_default=True,
|
|
97
|
+
help="Формат отчёта.",
|
|
98
|
+
)
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def scan_report(ctx: click.Context, output: str, fmt: str) -> None:
|
|
101
|
+
from pentool.api.scanner_api import ScannerAPI
|
|
102
|
+
from pentool.core.config import get_config
|
|
103
|
+
|
|
104
|
+
cfg = (ctx.obj or {}).get("config") or get_config()
|
|
105
|
+
api = ScannerAPI(db_path=cfg.db_path)
|
|
106
|
+
|
|
107
|
+
async def _run() -> None:
|
|
108
|
+
findings = await api.get_findings(limit=10000)
|
|
109
|
+
if not findings:
|
|
110
|
+
click.echo("No findings in database.")
|
|
111
|
+
return
|
|
112
|
+
await api.generate_report(output, fmt)
|
|
113
|
+
click.echo(f"Report saved: {output} ({len(findings)} findings)")
|
|
114
|
+
|
|
115
|
+
asyncio.run(_run())
|
pentool/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Пакет core: конфигурация, логирование, БД."""
|
pentool/core/config.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Управление конфигурацией приложения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "pentool"
|
|
14
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
|
|
15
|
+
|
|
16
|
+
# Тип колбэка: вызывается при изменении конфига
|
|
17
|
+
ConfigObserver = Callable[[dict], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Config:
|
|
22
|
+
"""Конфигурация приложения с значениями по умолчанию."""
|
|
23
|
+
|
|
24
|
+
proxy_host: str = "127.0.0.1"
|
|
25
|
+
proxy_port: int = 8080
|
|
26
|
+
cert_dir: str = field(default_factory=lambda: str(DEFAULT_CONFIG_DIR / "certs"))
|
|
27
|
+
db_path: str = field(default_factory=lambda: str(DEFAULT_CONFIG_DIR / "pentool.db"))
|
|
28
|
+
log_file: str = field(default_factory=lambda: str(DEFAULT_CONFIG_DIR / "pentool.log"))
|
|
29
|
+
log_level: str = "INFO"
|
|
30
|
+
plugins_dir: str = field(default_factory=lambda: str(DEFAULT_CONFIG_DIR / "plugins"))
|
|
31
|
+
scope: list[str] = field(default_factory=list)
|
|
32
|
+
intercept_enabled: bool = False
|
|
33
|
+
recent_projects: list[str] = field(default_factory=list)
|
|
34
|
+
auto_save_enabled: bool = False
|
|
35
|
+
auto_save_interval: int = 5 # минуты
|
|
36
|
+
# ── Network / Scanner settings (Блок 4.10) ─────────────────────────────
|
|
37
|
+
default_user_agent: str = (
|
|
38
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
39
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
40
|
+
"Chrome/122.0.0.0 Safari/537.36"
|
|
41
|
+
)
|
|
42
|
+
request_timeout: int = 15 # секунды
|
|
43
|
+
connect_timeout: int = 10 # секунды
|
|
44
|
+
collaborator_url: str = "" # Burp Collaborator / interactsh URL
|
|
45
|
+
max_redirects: int = 10
|
|
46
|
+
verify_ssl: bool = False # проверка SSL-сертификатов в сканере
|
|
47
|
+
# ── Scanner request marking ─────────────────────────────────────────────
|
|
48
|
+
scan_marker_enabled: bool = False
|
|
49
|
+
scan_marker_name: str = "X-Scanner"
|
|
50
|
+
scan_marker_value: str = "pentool/1.0"
|
|
51
|
+
|
|
52
|
+
# Список наблюдателей — не сериализуется (R-16)
|
|
53
|
+
_observers: list[ConfigObserver] = field(default_factory=list, init=False, repr=False, compare=False)
|
|
54
|
+
|
|
55
|
+
def add_observer(self, cb: ConfigObserver) -> None:
|
|
56
|
+
"""Подписаться на изменения конфигурации.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
cb: Функция вида cb(changed_fields: dict) → None.
|
|
60
|
+
"""
|
|
61
|
+
if cb not in self._observers:
|
|
62
|
+
self._observers.append(cb)
|
|
63
|
+
|
|
64
|
+
def remove_observer(self, cb: ConfigObserver) -> None:
|
|
65
|
+
"""Отписаться от изменений конфигурации."""
|
|
66
|
+
try:
|
|
67
|
+
self._observers.remove(cb)
|
|
68
|
+
except ValueError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def notify_observers(self, changed_fields: dict) -> None:
|
|
72
|
+
"""Уведомить всех наблюдателей об изменениях.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
changed_fields: Словарь изменённых полей {name: new_value}.
|
|
76
|
+
"""
|
|
77
|
+
for cb in list(self._observers):
|
|
78
|
+
try:
|
|
79
|
+
cb(changed_fields)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def update(self, **kwargs: Any) -> None:
|
|
84
|
+
changed: dict = {}
|
|
85
|
+
for key, value in kwargs.items():
|
|
86
|
+
if hasattr(self, key) and not key.startswith("_"):
|
|
87
|
+
if getattr(self, key) != value:
|
|
88
|
+
setattr(self, key, value)
|
|
89
|
+
changed[key] = value
|
|
90
|
+
if changed:
|
|
91
|
+
self.notify_observers(changed)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
"""Преобразовать в словарь для сериализации."""
|
|
95
|
+
return {
|
|
96
|
+
"proxy_host": self.proxy_host,
|
|
97
|
+
"proxy_port": self.proxy_port,
|
|
98
|
+
"cert_dir": self.cert_dir,
|
|
99
|
+
"db_path": self.db_path,
|
|
100
|
+
"log_file": self.log_file,
|
|
101
|
+
"log_level": self.log_level,
|
|
102
|
+
"plugins_dir": self.plugins_dir,
|
|
103
|
+
"scope": self.scope,
|
|
104
|
+
"intercept_enabled": self.intercept_enabled,
|
|
105
|
+
"recent_projects": self.recent_projects,
|
|
106
|
+
"auto_save_enabled": self.auto_save_enabled,
|
|
107
|
+
"auto_save_interval": self.auto_save_interval,
|
|
108
|
+
"default_user_agent": self.default_user_agent,
|
|
109
|
+
"request_timeout": self.request_timeout,
|
|
110
|
+
"connect_timeout": self.connect_timeout,
|
|
111
|
+
"collaborator_url": self.collaborator_url,
|
|
112
|
+
"max_redirects": self.max_redirects,
|
|
113
|
+
"verify_ssl": self.verify_ssl,
|
|
114
|
+
"scan_marker_enabled": self.scan_marker_enabled,
|
|
115
|
+
"scan_marker_name": self.scan_marker_name,
|
|
116
|
+
"scan_marker_value": self.scan_marker_value,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def add_recent_project(self, path: str) -> None:
|
|
120
|
+
path = str(path)
|
|
121
|
+
if path in self.recent_projects:
|
|
122
|
+
self.recent_projects.remove(path)
|
|
123
|
+
self.recent_projects.insert(0, path)
|
|
124
|
+
self.recent_projects = self.recent_projects[:10]
|
|
125
|
+
try:
|
|
126
|
+
self.save()
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def save(self, path: str | Path = DEFAULT_CONFIG_PATH) -> None:
|
|
131
|
+
path = Path(path)
|
|
132
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
134
|
+
yaml.dump(self.to_dict(), f, default_flow_style=False, allow_unicode=True)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def load(cls, path: str | Path = DEFAULT_CONFIG_PATH) -> "Config":
|
|
138
|
+
path = Path(path)
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return cls()
|
|
141
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
142
|
+
data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
143
|
+
cfg = cls()
|
|
144
|
+
for key, value in data.items():
|
|
145
|
+
if hasattr(cfg, key) and not key.startswith("_"):
|
|
146
|
+
setattr(cfg, key, value)
|
|
147
|
+
# Удалить несуществующие пути из recent_projects при загрузке
|
|
148
|
+
before = len(cfg.recent_projects)
|
|
149
|
+
cfg.recent_projects = [p for p in cfg.recent_projects if os.path.exists(p)]
|
|
150
|
+
if len(cfg.recent_projects) != before:
|
|
151
|
+
try:
|
|
152
|
+
cfg.save()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return cfg
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Глобальный экземпляр конфигурации (инициализируется при запуске)
|
|
159
|
+
_config: Config | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_config() -> Config:
|
|
163
|
+
global _config
|
|
164
|
+
if _config is None:
|
|
165
|
+
_config = Config.load()
|
|
166
|
+
return _config
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def set_config(config: Config) -> None:
|
|
170
|
+
global _config
|
|
171
|
+
_config = config
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Crash reporter — collects and sends anonymous crash reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_metadata() -> dict[str, Any]:
|
|
14
|
+
"""Collect anonymous metadata for crash report."""
|
|
15
|
+
try:
|
|
16
|
+
from pentool import __version__ as version
|
|
17
|
+
except Exception:
|
|
18
|
+
version = "unknown"
|
|
19
|
+
return {
|
|
20
|
+
"version": version,
|
|
21
|
+
"python": sys.version.split()[0],
|
|
22
|
+
"os": platform.system(),
|
|
23
|
+
"os_version": platform.release(),
|
|
24
|
+
"arch": platform.machine(),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _anonymize(text: str) -> str:
|
|
29
|
+
"""Remove paths and potential tokens from traceback text."""
|
|
30
|
+
import re
|
|
31
|
+
# Remove absolute paths — keep only filename
|
|
32
|
+
text = re.sub(r' File "([^"]+)"', lambda m: f' File "{os.path.basename(m.group(1))}"', text)
|
|
33
|
+
# Remove anything that looks like a token/key (long hex strings)
|
|
34
|
+
text = re.sub(r'\b[A-Fa-f0-9]{32,}\b', '<TOKEN>', text)
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def send_crash_async(exc: BaseException, endpoint: str = "https://pentool.dev/api/crash") -> None:
|
|
39
|
+
"""Send crash report asynchronously. Silently ignores any errors."""
|
|
40
|
+
try:
|
|
41
|
+
import aiohttp # type: ignore[import]
|
|
42
|
+
except ImportError:
|
|
43
|
+
return # aiohttp not available — skip silently
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
47
|
+
tb_text = _anonymize("".join(tb_lines))
|
|
48
|
+
payload = {
|
|
49
|
+
"traceback": tb_text,
|
|
50
|
+
"exception": type(exc).__name__,
|
|
51
|
+
**_get_metadata(),
|
|
52
|
+
}
|
|
53
|
+
timeout = aiohttp.ClientTimeout(total=8)
|
|
54
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
55
|
+
await session.post(endpoint, json=payload, ssl=False)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass # never propagate errors from crash reporter
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def send_crash(exc: BaseException) -> None:
|
|
61
|
+
"""Fire-and-forget crash report from sync context."""
|
|
62
|
+
try:
|
|
63
|
+
from pentool.core.config import get_config
|
|
64
|
+
cfg = get_config()
|
|
65
|
+
if not getattr(cfg, "send_crash_reports", True):
|
|
66
|
+
return
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
loop = asyncio.new_event_loop()
|
|
72
|
+
loop.run_until_complete(send_crash_async(exc))
|
|
73
|
+
loop.close()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
pentool/core/database.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Работа с базой данных SQLite через aiosqlite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncIterator
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# DDL для создания таблиц
|
|
14
|
+
_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
path TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
settings_json TEXT DEFAULT '{}'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS repeater_entries (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
|
26
|
+
tab_name TEXT NOT NULL DEFAULT 'Tab',
|
|
27
|
+
method TEXT NOT NULL,
|
|
28
|
+
url TEXT NOT NULL,
|
|
29
|
+
request_headers TEXT DEFAULT '',
|
|
30
|
+
request_body TEXT DEFAULT '',
|
|
31
|
+
response_status INTEGER DEFAULT NULL,
|
|
32
|
+
response_headers TEXT DEFAULT '',
|
|
33
|
+
response_body TEXT DEFAULT '',
|
|
34
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS intruder_results (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
|
40
|
+
attack_id TEXT NOT NULL,
|
|
41
|
+
payload_values TEXT DEFAULT '[]',
|
|
42
|
+
request_raw TEXT DEFAULT '',
|
|
43
|
+
response_status INTEGER DEFAULT NULL,
|
|
44
|
+
response_length INTEGER DEFAULT NULL,
|
|
45
|
+
response_time_ms INTEGER DEFAULT NULL,
|
|
46
|
+
error TEXT DEFAULT NULL,
|
|
47
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
|
53
|
+
type TEXT NOT NULL,
|
|
54
|
+
name TEXT NOT NULL DEFAULT '',
|
|
55
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
56
|
+
host TEXT NOT NULL,
|
|
57
|
+
url TEXT NOT NULL,
|
|
58
|
+
parameter TEXT DEFAULT '',
|
|
59
|
+
payload TEXT DEFAULT '',
|
|
60
|
+
evidence TEXT DEFAULT '',
|
|
61
|
+
description TEXT DEFAULT '',
|
|
62
|
+
cwe TEXT DEFAULT '',
|
|
63
|
+
remediation TEXT DEFAULT '',
|
|
64
|
+
mitre_attack TEXT DEFAULT '',
|
|
65
|
+
request_raw TEXT DEFAULT '',
|
|
66
|
+
response_raw TEXT DEFAULT '',
|
|
67
|
+
false_positive INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS site_map (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
host TEXT NOT NULL,
|
|
74
|
+
path TEXT NOT NULL,
|
|
75
|
+
methods TEXT NOT NULL DEFAULT '[]',
|
|
76
|
+
request_count INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
last_seen TEXT NOT NULL,
|
|
78
|
+
in_scope INTEGER NOT NULL DEFAULT 0
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_site_map_host_path ON site_map (host, path);
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def init_db(db_path: str) -> None:
|
|
86
|
+
"""Создать таблицы БД, если они не существуют.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
db_path: Путь к файлу SQLite.
|
|
90
|
+
"""
|
|
91
|
+
path = Path(db_path)
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
async with aiosqlite.connect(db_path) as db:
|
|
94
|
+
await db.executescript(_SCHEMA)
|
|
95
|
+
await db.commit()
|
|
96
|
+
# Миграция: добавляем новые колонки в vulnerabilities (для старых БД)
|
|
97
|
+
_new_vuln_cols = [
|
|
98
|
+
("name", "TEXT NOT NULL DEFAULT ''"),
|
|
99
|
+
("payload", "TEXT DEFAULT ''"),
|
|
100
|
+
("description", "TEXT DEFAULT ''"),
|
|
101
|
+
("cwe", "TEXT DEFAULT ''"),
|
|
102
|
+
("remediation", "TEXT DEFAULT ''"),
|
|
103
|
+
("mitre_attack", "TEXT DEFAULT ''"),
|
|
104
|
+
("request_raw", "TEXT DEFAULT ''"),
|
|
105
|
+
("response_raw", "TEXT DEFAULT ''"),
|
|
106
|
+
]
|
|
107
|
+
for col_name, col_def in _new_vuln_cols:
|
|
108
|
+
try:
|
|
109
|
+
await db.execute(
|
|
110
|
+
f"ALTER TABLE vulnerabilities ADD COLUMN {col_name} {col_def}"
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass # Колонка уже существует — ожидаемо
|
|
114
|
+
await db.commit()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def get_db(db_path: str) -> AsyncIterator[aiosqlite.Connection]:
|
|
119
|
+
"""Контекстный менеджер для получения соединения с БД.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
db_path: Путь к файлу SQLite.
|
|
123
|
+
|
|
124
|
+
Yields:
|
|
125
|
+
Объект соединения aiosqlite.Connection.
|
|
126
|
+
"""
|
|
127
|
+
async with aiosqlite.connect(db_path) as db:
|
|
128
|
+
db.row_factory = aiosqlite.Row
|
|
129
|
+
await db.execute("PRAGMA foreign_keys = ON")
|
|
130
|
+
yield db
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def init_db_sync(db_path: str) -> None:
|
|
134
|
+
"""Синхронная обёртка над init_db для использования в CLI.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
db_path: Путь к файлу SQLite.
|
|
138
|
+
"""
|
|
139
|
+
asyncio.run(init_db(db_path))
|