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.
Files changed (133) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +16 -0
  3. pentool/api/__init__.py +41 -0
  4. pentool/api/base_api.py +48 -0
  5. pentool/api/comparer_api.py +19 -0
  6. pentool/api/decoder_api.py +24 -0
  7. pentool/api/intruder_api.py +171 -0
  8. pentool/api/proxy_api.py +210 -0
  9. pentool/api/repeater_api.py +57 -0
  10. pentool/api/scanner_api.py +266 -0
  11. pentool/api/sequencer_api.py +17 -0
  12. pentool/api/spider_api.py +106 -0
  13. pentool/api/target_api.py +87 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +98 -0
  16. pentool/cli/project.py +88 -0
  17. pentool/cli/proxy.py +173 -0
  18. pentool/cli/scan.py +115 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +171 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +207 -0
  24. pentool/core/events.py +160 -0
  25. pentool/core/features.py +373 -0
  26. pentool/core/license.py +274 -0
  27. pentool/core/logging.py +54 -0
  28. pentool/core/plugin_manager.py +312 -0
  29. pentool/core/project.py +31 -0
  30. pentool/core/storage_interface.py +307 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +36 -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 +428 -0
  37. pentool/modules/intruder_turbo.py +176 -0
  38. pentool/modules/match_replace.py +142 -0
  39. pentool/modules/proxy.py +797 -0
  40. pentool/modules/repeater.py +195 -0
  41. pentool/modules/sequencer.py +492 -0
  42. pentool/modules/spider.py +679 -0
  43. pentool/modules/target.py +185 -0
  44. pentool/modules/websocket_handler.py +249 -0
  45. pentool/plugins/__init__.py +1 -0
  46. pentool/plugins/builtin/__init__.py +1 -0
  47. pentool/plugins/builtin/payloads_pro.py +381 -0
  48. pentool/plugins/builtin/reports_pro.py +436 -0
  49. pentool/plugins/builtin/scanner_pro.py +291 -0
  50. pentool/plugins/example_plugin.py +39 -0
  51. pentool/services/__init__.py +1 -0
  52. pentool/services/base_service.py +60 -0
  53. pentool/services/intruder_service.py +94 -0
  54. pentool/services/proxy_service.py +178 -0
  55. pentool/services/repeater_service.py +85 -0
  56. pentool/services/scan_service.py +330 -0
  57. pentool/storage/__init__.py +0 -0
  58. pentool/storage/http_storage.py +506 -0
  59. pentool/storage/large_body_handler.py +50 -0
  60. pentool/storage/lru_cache.py +45 -0
  61. pentool/t.py +213 -0
  62. pentool/tui/__init__.py +1 -0
  63. pentool/tui/app.py +1429 -0
  64. pentool/tui/constants.py +79 -0
  65. pentool/tui/dialogs/__init__.py +0 -0
  66. pentool/tui/dialogs/cert_dialog.py +81 -0
  67. pentool/tui/dialogs/file_selector.py +226 -0
  68. pentool/tui/dialogs/load_from_proxy.py +125 -0
  69. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  70. pentool/tui/dialogs/project_dialog.py +82 -0
  71. pentool/tui/dialogs/scope_dialog.py +224 -0
  72. pentool/tui/messages.py +103 -0
  73. pentool/tui/mixins/__init__.py +0 -0
  74. pentool/tui/mixins/app_mixin.py +59 -0
  75. pentool/tui/mixins/dialog_cancel.py +21 -0
  76. pentool/tui/mixins/request_context_menu.py +247 -0
  77. pentool/tui/mixins/tab_rename.py +89 -0
  78. pentool/tui/screens/__init__.py +31 -0
  79. pentool/tui/screens/comparer/__init__.py +3 -0
  80. pentool/tui/screens/comparer/screen.py +229 -0
  81. pentool/tui/screens/dashboard/__init__.py +2 -0
  82. pentool/tui/screens/dashboard/live_dashboard.py +730 -0
  83. pentool/tui/screens/dashboard/screen.py +771 -0
  84. pentool/tui/screens/decoder/__init__.py +3 -0
  85. pentool/tui/screens/decoder/screen.py +306 -0
  86. pentool/tui/screens/extensions/__init__.py +3 -0
  87. pentool/tui/screens/extensions/screen.py +24 -0
  88. pentool/tui/screens/intruder/__init__.py +3 -0
  89. pentool/tui/screens/intruder/screen.py +1356 -0
  90. pentool/tui/screens/proxy/__init__.py +3 -0
  91. pentool/tui/screens/proxy/screen.py +1554 -0
  92. pentool/tui/screens/repeater/__init__.py +3 -0
  93. pentool/tui/screens/repeater/screen.py +621 -0
  94. pentool/tui/screens/scanner/__init__.py +3 -0
  95. pentool/tui/screens/scanner/screen.py +1956 -0
  96. pentool/tui/screens/sequencer/__init__.py +3 -0
  97. pentool/tui/screens/sequencer/screen.py +516 -0
  98. pentool/tui/screens/settings/__init__.py +5 -0
  99. pentool/tui/screens/settings/hotkeys.py +134 -0
  100. pentool/tui/screens/settings/screen.py +540 -0
  101. pentool/tui/screens/spider/__init__.py +3 -0
  102. pentool/tui/screens/spider/screen.py +380 -0
  103. pentool/tui/screens/target/__init__.py +4 -0
  104. pentool/tui/screens/target/screen.py +358 -0
  105. pentool/tui/screens/terminal/__init__.py +5 -0
  106. pentool/tui/screens/terminal/screen.py +179 -0
  107. pentool/tui/widgets/__init__.py +1 -0
  108. pentool/tui/widgets/context_menu.py +148 -0
  109. pentool/tui/widgets/filter_bar.py +204 -0
  110. pentool/tui/widgets/inspector_panel.py +111 -0
  111. pentool/tui/widgets/menu.py +86 -0
  112. pentool/tui/widgets/menu_bar.py +238 -0
  113. pentool/tui/widgets/module_tabs.py +68 -0
  114. pentool/tui/widgets/payload_drop_zone.py +65 -0
  115. pentool/tui/widgets/request_editor.py +495 -0
  116. pentool/tui/widgets/resize_handle.py +116 -0
  117. pentool/tui/widgets/search_bar.py +112 -0
  118. pentool/tui/widgets/statusbar.py +96 -0
  119. pentool/tui/widgets/toolbar_button.py +68 -0
  120. pentool/utils/__init__.py +1 -0
  121. pentool/utils/cert.py +314 -0
  122. pentool/utils/coder.py +170 -0
  123. pentool/utils/copy_as.py +250 -0
  124. pentool/utils/diff.py +82 -0
  125. pentool/utils/http_client.py +145 -0
  126. pentool/utils/parser.py +227 -0
  127. pentool/utils/terminal_check.py +31 -0
  128. pentool-0.1.0.dist-info/METADATA +231 -0
  129. pentool-0.1.0.dist-info/RECORD +133 -0
  130. pentool-0.1.0.dist-info/WHEEL +5 -0
  131. pentool-0.1.0.dist-info/entry_points.txt +2 -0
  132. pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
  133. 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())
@@ -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
@@ -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))