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
@@ -0,0 +1,373 @@
1
+ """Feature flags и лимиты для лицензионной модели Pentool."""
2
+
3
+ from enum import Enum
4
+ from dataclasses import dataclass
5
+
6
+
7
+ class FeatureStatus(str, Enum):
8
+ """Статус фичи."""
9
+ STABLE = "stable" # Стабильная, готова к production
10
+ BETA = "beta" # Стабильная, но в beta-тестировании
11
+ ALPHA = "alpha" # Экспериментальная, может быть нестабильной
12
+
13
+
14
+ @dataclass
15
+ class Feature:
16
+ """Описание одной фичи."""
17
+ name: str
18
+ description: str
19
+ status: FeatureStatus
20
+ required_plan: str # "free", "lite", "medium", "full"
21
+
22
+
23
+ # ═══════════════════════════════════════════════════════════════════════
24
+ # FEATURE CONSTANTS — строковые идентификаторы для require_feature
25
+ # ═══════════════════════════════════════════════════════════════════════
26
+
27
+ # Scanner PRO checks
28
+ FEATURE_SCANNER_ADVANCED_WAF = "scanner_advanced_waf"
29
+ FEATURE_SCANNER_OOB = "scanner_oob"
30
+ FEATURE_SCANNER_SSRF_PORTSCAN = "scanner_ssrf_portscan"
31
+ FEATURE_SCANNER_SQLI_UNION = "scanner_sqli_union"
32
+ FEATURE_SCANNER_SQLI_OOB = "scanner_sqli_oob"
33
+ FEATURE_SCANNER_LFI_LOG_POISON = "scanner_lfi_log_poison"
34
+ FEATURE_SCANNER_XSS_STORED = "scanner_xss_stored"
35
+ FEATURE_SCANNER_DOM_XSS_ACTIVE = "scanner_dom_xss_active"
36
+ FEATURE_SCANNER_DESER = "scanner_deser"
37
+ FEATURE_SCANNER_SMUGGLING = "scanner_smuggling"
38
+ FEATURE_SCANNER_FILE_UPLOAD = "scanner_file_upload"
39
+ FEATURE_SCANNER_IDOR = "scanner_idor"
40
+ FEATURE_SCANNER_PROMPT_INJECT = "scanner_prompt_inject"
41
+ FEATURE_SCANNER_RCE_OOB = "scanner_rce_oob"
42
+
43
+ # AI features
44
+ FEATURE_AI_ANALYSIS = "ai_analysis"
45
+ FEATURE_AI_STRATEGY = "ai_strategy"
46
+
47
+ # Reports
48
+ FEATURE_REPORTS_PRO = "pro_reports"
49
+
50
+
51
+ # ═══════════════════════════════════════════════════════════════════════
52
+ # FEATURES — доступность фич по планам
53
+ # ═══════════════════════════════════════════════════════════════════════
54
+
55
+ FEATURES = {
56
+ # ────────────────────────────────────────────────────────────────────
57
+ # FREE PLAN — базовые инструменты
58
+ # ────────────────────────────────────────────────────────────────────
59
+ "proxy": Feature(
60
+ "proxy",
61
+ "HTTP/HTTPS Proxy с перехватом",
62
+ FeatureStatus.STABLE,
63
+ "free"
64
+ ),
65
+ "repeater": Feature(
66
+ "repeater",
67
+ "Repeater — повтор и модификация запросов",
68
+ FeatureStatus.STABLE,
69
+ "free"
70
+ ),
71
+ "intruder_basic": Feature(
72
+ "intruder_basic",
73
+ "Intruder (Sniper, Battering Ram)",
74
+ FeatureStatus.BETA,
75
+ "free"
76
+ ),
77
+ "decoder": Feature(
78
+ "decoder",
79
+ "Decoder — кодирование/декодирование данных",
80
+ FeatureStatus.STABLE,
81
+ "free"
82
+ ),
83
+ "comparer": Feature(
84
+ "comparer",
85
+ "Comparer — сравнение запросов/ответов",
86
+ FeatureStatus.STABLE,
87
+ "free"
88
+ ),
89
+ "httpql": Feature(
90
+ "httpql",
91
+ "HTTPQL — фильтрация истории запросов",
92
+ FeatureStatus.BETA,
93
+ "free"
94
+ ),
95
+
96
+ # ────────────────────────────────────────────────────────────────────
97
+ # LITE PLAN — расширенные возможности
98
+ # ────────────────────────────────────────────────────────────────────
99
+ "scanner_extended": Feature(
100
+ "scanner_extended",
101
+ "Scanner — расширенные проверки безопасности",
102
+ FeatureStatus.ALPHA,
103
+ "lite"
104
+ ),
105
+ "intruder_all_types": Feature(
106
+ "intruder_all_types",
107
+ "Intruder (Pitchfork, Cluster Bomb)",
108
+ FeatureStatus.ALPHA,
109
+ "lite"
110
+ ),
111
+ "spider": Feature(
112
+ "spider",
113
+ "Spider — автоматическое сканирование сайта",
114
+ FeatureStatus.BETA,
115
+ "lite"
116
+ ),
117
+ "match_replace": Feature(
118
+ "match_replace",
119
+ "Match & Replace — авто-модификация запросов",
120
+ FeatureStatus.BETA,
121
+ "lite"
122
+ ),
123
+ "target_scope": Feature(
124
+ "target_scope",
125
+ "Target Scope — управление областью тестирования",
126
+ FeatureStatus.STABLE,
127
+ "lite"
128
+ ),
129
+
130
+ # ────────────────────────────────────────────────────────────────────
131
+ # MEDIUM PLAN — профессиональные инструменты
132
+ # ────────────────────────────────────────────────────────────────────
133
+ "oob_detection": Feature(
134
+ "oob_detection",
135
+ "Out-of-Band детекция уязвимостей",
136
+ FeatureStatus.ALPHA,
137
+ "medium"
138
+ ),
139
+ "websocket_intercept": Feature(
140
+ "websocket_intercept",
141
+ "WebSocket перехват и модификация",
142
+ FeatureStatus.ALPHA,
143
+ "medium"
144
+ ),
145
+ "plugins": Feature(
146
+ "plugins",
147
+ "Система плагинов",
148
+ FeatureStatus.ALPHA,
149
+ "medium"
150
+ ),
151
+ "sequencer": Feature(
152
+ "sequencer",
153
+ "Sequencer — анализ случайности токенов",
154
+ FeatureStatus.BETA,
155
+ "medium"
156
+ ),
157
+ "passive_scanner": Feature(
158
+ "passive_scanner",
159
+ "Passive Scanner — фоновый анализ трафика",
160
+ FeatureStatus.BETA,
161
+ "medium"
162
+ ),
163
+ "turbo_mode": Feature(
164
+ "turbo_mode",
165
+ "Turbo Mode Intruder (10x ускорение)",
166
+ FeatureStatus.BETA,
167
+ "medium"
168
+ ),
169
+
170
+ # ────────────────────────────────────────────────────────────────────
171
+ # FULL PLAN — enterprise возможности
172
+ # ────────────────────────────────────────────────────────────────────
173
+ "ai_analysis": Feature(
174
+ "ai_analysis",
175
+ "AI-анализ уязвимостей (GPT-4)",
176
+ FeatureStatus.ALPHA,
177
+ "full"
178
+ ),
179
+ "pro_reports": Feature(
180
+ "pro_reports",
181
+ "PRO отчёты (HTML/PDF/JSON)",
182
+ FeatureStatus.ALPHA,
183
+ "full"
184
+ ),
185
+ "collaboration": Feature(
186
+ "collaboration",
187
+ "Совместная работа над проектами",
188
+ FeatureStatus.ALPHA,
189
+ "full"
190
+ ),
191
+ "api_access": Feature(
192
+ "api_access",
193
+ "REST API для автоматизации",
194
+ FeatureStatus.BETA,
195
+ "full"
196
+ ),
197
+ "custom_scanner_checks": Feature(
198
+ "custom_scanner_checks",
199
+ "Кастомные проверки Scanner",
200
+ FeatureStatus.ALPHA,
201
+ "full"
202
+ ),
203
+ }
204
+
205
+
206
+ # ═══════════════════════════════════════════════════════════════════════
207
+ # LIMITS — лимиты по планам
208
+ # ═══════════════════════════════════════════════════════════════════════
209
+
210
+ LIMITS = {
211
+ # История запросов
212
+ "history_max_entries": {
213
+ "free": 500,
214
+ "lite": 5000,
215
+ "medium": 50000,
216
+ "full": -1, # unlimited
217
+ },
218
+
219
+ # Intruder
220
+ "intruder_max_threads": {
221
+ "free": 10,
222
+ "lite": 20,
223
+ "medium": 50,
224
+ "full": 100,
225
+ },
226
+ "intruder_max_requests": {
227
+ "free": 1000,
228
+ "lite": 10000,
229
+ "medium": 100000,
230
+ "full": -1, # unlimited
231
+ },
232
+
233
+ # Scanner
234
+ "scanner_max_depth": {
235
+ "free": 2,
236
+ "lite": 5,
237
+ "medium": 10,
238
+ "full": -1, # unlimited
239
+ },
240
+ "scanner_max_pages": {
241
+ "free": 20,
242
+ "lite": 100,
243
+ "medium": 1000,
244
+ "full": -1, # unlimited
245
+ },
246
+ "scanner_threads": {
247
+ "free": 5,
248
+ "lite": 10,
249
+ "medium": 20,
250
+ "full": 50,
251
+ },
252
+
253
+ # Spider
254
+ "spider_max_depth": {
255
+ "free": 2,
256
+ "lite": 5,
257
+ "medium": 10,
258
+ "full": -1, # unlimited
259
+ },
260
+ "spider_max_pages": {
261
+ "free": 50,
262
+ "lite": 200,
263
+ "medium": 1000,
264
+ "full": -1, # unlimited
265
+ },
266
+
267
+ # Проекты
268
+ "projects_max": {
269
+ "free": 5,
270
+ "lite": 20,
271
+ "medium": 100,
272
+ "full": -1, # unlimited
273
+ },
274
+
275
+ # Export
276
+ "export_formats": {
277
+ "free": ["txt"],
278
+ "lite": ["txt", "csv", "json"],
279
+ "medium": ["txt", "csv", "json", "html"],
280
+ "full": ["txt", "csv", "json", "html", "pdf"],
281
+ },
282
+ }
283
+
284
+
285
+ # ═══════════════════════════════════════════════════════════════════════
286
+ # PLAN NAMES — человекочитаемые названия планов
287
+ # ═══════════════════════════════════════════════════════════════════════
288
+
289
+ PLAN_NAMES = {
290
+ "free": "Free",
291
+ "lite": "Lite",
292
+ "medium": "Medium",
293
+ "full": "Full (PRO)",
294
+ }
295
+
296
+
297
+ PLAN_DESCRIPTIONS = {
298
+ "free": "Базовые инструменты для тестирования",
299
+ "lite": "Расширенные возможности для профессионалов",
300
+ "medium": "Продвинутые инструменты + плагины",
301
+ "full": "Enterprise функции + AI + API",
302
+ }
303
+
304
+
305
+ # ═══════════════════════════════════════════════════════════════════════
306
+ # HELPER FUNCTIONS
307
+ # ═══════════════════════════════════════════════════════════════════════
308
+
309
+ def has_feature(feature_name: str, plan: str) -> bool:
310
+ """Проверить, доступна ли фича для указанного плана.
311
+
312
+ Args:
313
+ feature_name: Название фичи (ключ из FEATURES)
314
+ plan: Название плана ("free", "lite", "medium", "full")
315
+
316
+ Returns:
317
+ True если фича доступна для этого плана
318
+ """
319
+ feature = FEATURES.get(feature_name)
320
+ if not feature:
321
+ return False
322
+
323
+ # Порядок планов от младшего к старшему
324
+ plan_order = ["free", "lite", "medium", "full"]
325
+
326
+ # Если текущий план >= требуемого плана, фича доступна
327
+ try:
328
+ current_level = plan_order.index(plan.lower())
329
+ required_level = plan_order.index(feature.required_plan)
330
+ return current_level >= required_level
331
+ except (ValueError, AttributeError):
332
+ return False
333
+
334
+
335
+ def get_limit(limit_name: str, plan: str, default: int = 0) -> int | list:
336
+ limits = LIMITS.get(limit_name, {})
337
+ return limits.get(plan.lower(), default)
338
+
339
+
340
+ def get_feature_status(feature_name: str) -> str:
341
+ feature = FEATURES.get(feature_name)
342
+ return feature.status.value if feature else "unknown"
343
+
344
+
345
+ def get_features_for_plan(plan: str) -> list[Feature]:
346
+ return [
347
+ feature
348
+ for feature in FEATURES.values()
349
+ if has_feature(feature.name, plan)
350
+ ]
351
+
352
+
353
+ def get_plan_info(plan: str) -> dict:
354
+ features = get_features_for_plan(plan)
355
+
356
+ return {
357
+ "plan": plan,
358
+ "name": PLAN_NAMES.get(plan, plan.capitalize()),
359
+ "description": PLAN_DESCRIPTIONS.get(plan, ""),
360
+ "features_count": len(features),
361
+ "features": [
362
+ {
363
+ "name": f.name,
364
+ "description": f.description,
365
+ "status": f.status.value,
366
+ }
367
+ for f in features
368
+ ],
369
+ "limits": {
370
+ limit_name: get_limit(limit_name, plan)
371
+ for limit_name in LIMITS.keys()
372
+ },
373
+ }
@@ -0,0 +1,274 @@
1
+ """Система лицензирования Pentool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import platform
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+
12
+ from pathlib import Path
13
+
14
+
15
+ _LICENSE_FILE = Path.home() / ".pentool" / "license.dat"
16
+ _GRACE_PERIOD_DAYS = 7
17
+
18
+
19
+ @dataclass
20
+ class LicenseInfo:
21
+ """Информация о текущей лицензии."""
22
+
23
+ valid: bool = False
24
+ plan: str = "free" # "free" | "pro" | "enterprise"
25
+ features: list[str] = field(default_factory=list)
26
+ expires: str | None = None # ISO date или None (permanent)
27
+ machine_id: str = ""
28
+ license_key: str = ""
29
+ last_check: float | None = None # timestamp последней онлайн-проверки
30
+ error: str = "" # сообщение об ошибке если not valid
31
+
32
+ def has_feature(self, feature: str) -> bool:
33
+ return self.valid and feature in self.features
34
+
35
+ def is_pro(self) -> bool:
36
+ return self.valid and self.plan in ("pro", "enterprise")
37
+
38
+ @property
39
+ def status_text(self) -> str:
40
+ if not self.valid:
41
+ return "FREE"
42
+ return self.plan.upper()
43
+
44
+ @property
45
+ def expires_text(self) -> str:
46
+ if not self.expires:
47
+ return "Lifetime"
48
+ return self.expires
49
+
50
+
51
+ def get_machine_id() -> str:
52
+ try:
53
+ import socket
54
+ hostname = socket.gethostname()
55
+ # Получить MAC первого интерфейса через uuid
56
+ mac = hex(uuid.getnode())[2:]
57
+ raw = f"{hostname}:{mac}"
58
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
59
+ except Exception:
60
+ return hashlib.sha256(platform.node().encode()).hexdigest()[:32]
61
+
62
+
63
+ def _load_cached() -> dict | None:
64
+ try:
65
+ if _LICENSE_FILE.exists():
66
+ data = json.loads(_LICENSE_FILE.read_text(encoding="utf-8"))
67
+ return data
68
+ except Exception:
69
+ pass
70
+ return None
71
+
72
+
73
+ def _save_cached(data: dict) -> None:
74
+ try:
75
+ _LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
76
+ _LICENSE_FILE.write_text(
77
+ json.dumps(data, indent=2, ensure_ascii=False),
78
+ encoding="utf-8",
79
+ )
80
+ except Exception:
81
+ pass
82
+
83
+
84
+ def get_license() -> LicenseInfo:
85
+ cached = _load_cached()
86
+ if cached is None:
87
+ return LicenseInfo(valid=False, plan="free", machine_id=get_machine_id())
88
+
89
+ last_check = cached.get("last_check", 0)
90
+ grace_seconds = _GRACE_PERIOD_DAYS * 24 * 3600
91
+ if (time.time() - last_check) > grace_seconds:
92
+ # Grace period истёк — деактивируем
93
+ return LicenseInfo(
94
+ valid=False,
95
+ plan="free",
96
+ machine_id=get_machine_id(),
97
+ license_key=cached.get("license_key", ""),
98
+ error=f"Grace period expired. Please reconnect to validate license.",
99
+ )
100
+
101
+ return LicenseInfo(
102
+ valid=cached.get("valid", False),
103
+ plan=cached.get("plan", "free"),
104
+ features=cached.get("features", []),
105
+ expires=cached.get("expires"),
106
+ machine_id=cached.get("machine_id", get_machine_id()),
107
+ license_key=cached.get("license_key", ""),
108
+ last_check=last_check,
109
+ )
110
+
111
+
112
+ # Session-level license cache (avoids repeated file reads per check)
113
+ _session_license: "LicenseInfo | None" = None
114
+
115
+
116
+ def get_session_license() -> "LicenseInfo":
117
+ """Return cached session license (refreshed once per process)."""
118
+ global _session_license
119
+ if _session_license is None:
120
+ _session_license = get_license()
121
+ return _session_license
122
+
123
+
124
+ def invalidate_session_license() -> None:
125
+ """Force re-read on next get_session_license() call."""
126
+ global _session_license
127
+ _session_license = None
128
+
129
+
130
+ class FeatureNotAvailable(Exception):
131
+ """Feature not available in the current plan."""
132
+ def __init__(self, feature: str, plan_required: str = "pro"):
133
+ self.feature = feature
134
+ self.plan_required = plan_required
135
+ super().__init__(
136
+ f"Feature '{feature}' requires {plan_required.upper()} plan. "
137
+ f"Upgrade at https://pentool.dev/upgrade"
138
+ )
139
+
140
+
141
+ def require_feature(feature: str, plan_required: str = "pro"):
142
+ """Decorator: raise FeatureNotAvailable if feature is not licensed."""
143
+ def decorator(func):
144
+ import functools
145
+ import asyncio as _asyncio
146
+
147
+ @functools.wraps(func)
148
+ async def async_wrapper(*args, **kwargs):
149
+ if not get_session_license().has_feature(feature):
150
+ raise FeatureNotAvailable(feature, plan_required)
151
+ return await func(*args, **kwargs)
152
+
153
+ @functools.wraps(func)
154
+ def sync_wrapper(*args, **kwargs):
155
+ if not get_session_license().has_feature(feature):
156
+ raise FeatureNotAvailable(feature, plan_required)
157
+ return func(*args, **kwargs)
158
+
159
+ if _asyncio.iscoroutinefunction(func):
160
+ return async_wrapper
161
+ return sync_wrapper
162
+ return decorator
163
+
164
+
165
+ async def activate_license(key: str) -> LicenseInfo:
166
+ """Активировать лицензию онлайн (license.pentool.dev/api/validate).
167
+
168
+ Returns:
169
+ LicenseInfo с результатом активации.
170
+ """
171
+ key = key.strip().upper()
172
+ machine_id = get_machine_id()
173
+
174
+ if not key:
175
+ return LicenseInfo(
176
+ valid=False, plan="free",
177
+ machine_id=machine_id,
178
+ error="License key is empty",
179
+ )
180
+
181
+ # Попытка реальной онлайн-проверки
182
+ try:
183
+ import aiohttp
184
+ async with aiohttp.ClientSession(
185
+ timeout=aiohttp.ClientTimeout(total=10)
186
+ ) as session:
187
+ async with session.post(
188
+ "https://license.pentool.dev/api/validate",
189
+ json={"key": key, "machine_id": machine_id},
190
+ ssl=False,
191
+ ) as resp:
192
+ if resp.status == 200:
193
+ data = await resp.json()
194
+ info = LicenseInfo(
195
+ valid=data.get("valid", False),
196
+ plan=data.get("plan", "free"),
197
+ features=data.get("features", []),
198
+ expires=data.get("expires"),
199
+ machine_id=machine_id,
200
+ license_key=key,
201
+ last_check=time.time(),
202
+ )
203
+ if info.valid:
204
+ _save_cached({
205
+ "valid": True,
206
+ "plan": info.plan,
207
+ "features": info.features,
208
+ "expires": info.expires,
209
+ "machine_id": machine_id,
210
+ "license_key": key,
211
+ "last_check": time.time(),
212
+ })
213
+ return info
214
+ except Exception:
215
+ pass # Сервер недоступен — fallback к локальной проверке
216
+
217
+ # Оффлайн-fallback: проверяем формат ключа
218
+ # Формат: XXXX-XXXX-XXXX-XXXX (16 hex символов через дефисы)
219
+ import re
220
+ if re.match(r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$', key):
221
+ if key.startswith("DEMO"):
222
+ info = LicenseInfo(
223
+ valid=True,
224
+ plan="pro",
225
+ features=["scanner_pro", "reports_pro", "payloads_pro"],
226
+ expires=None,
227
+ machine_id=machine_id,
228
+ license_key=key,
229
+ last_check=time.time(),
230
+ )
231
+ _save_cached({
232
+ "valid": True,
233
+ "plan": "pro",
234
+ "features": ["scanner_pro", "reports_pro", "payloads_pro"],
235
+ "expires": None,
236
+ "machine_id": machine_id,
237
+ "license_key": key,
238
+ "last_check": time.time(),
239
+ })
240
+ return info
241
+
242
+ return LicenseInfo(
243
+ valid=False,
244
+ plan="free",
245
+ machine_id=machine_id,
246
+ license_key=key,
247
+ error="License server unavailable. Key format invalid or not activated.",
248
+ )
249
+
250
+
251
+ def deactivate_license() -> None:
252
+ """Деактивировать лицензию (удалить кэш)."""
253
+ try:
254
+ if _LICENSE_FILE.exists():
255
+ _LICENSE_FILE.unlink()
256
+ except Exception:
257
+ pass
258
+
259
+
260
+ # Глобальный кэш лицензии для текущей сессии
261
+ _session_license: LicenseInfo | None = None
262
+
263
+
264
+ def get_session_license() -> LicenseInfo:
265
+ global _session_license
266
+ if _session_license is None:
267
+ _session_license = get_license()
268
+ return _session_license
269
+
270
+
271
+ def refresh_session_license(info: LicenseInfo | None = None) -> LicenseInfo:
272
+ global _session_license
273
+ _session_license = info or get_license()
274
+ return _session_license
@@ -0,0 +1,54 @@
1
+ """Настройка системы логирования."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def setup_logging(log_file: str, level: str = "INFO") -> logging.Logger:
11
+ """Настроить логирование: запись в файл (DEBUG) и в консоль (указанный уровень).
12
+
13
+ Args:
14
+ log_file: Путь к лог-файлу.
15
+ level: Уровень логирования для консоли (DEBUG/INFO/WARNING/ERROR).
16
+
17
+ Returns:
18
+ Настроенный корневой логгер.
19
+ """
20
+ log_path = Path(log_file)
21
+ log_path.parent.mkdir(parents=True, exist_ok=True)
22
+
23
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
24
+
25
+ logger = logging.getLogger("pentool")
26
+ logger.setLevel(logging.DEBUG)
27
+
28
+ # Очистить существующие хендлеры, чтобы избежать дублирования
29
+ logger.handlers.clear()
30
+
31
+ # FileHandler — пишет всё (DEBUG и выше), дописывает в лог (не очищает)
32
+ fmt = logging.Formatter(
33
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
+ datefmt="%Y-%m-%d %H:%M:%S",
35
+ )
36
+ file_handler = logging.FileHandler(log_file, encoding="utf-8", mode="a")
37
+ file_handler.setLevel(logging.DEBUG)
38
+ file_handler.setFormatter(fmt)
39
+
40
+ # StreamHandler — пишет в stderr на указанном уровне
41
+ console_handler = logging.StreamHandler(sys.stderr)
42
+ console_handler.setLevel(numeric_level)
43
+ console_handler.setFormatter(
44
+ logging.Formatter("[%(levelname)s] %(message)s")
45
+ )
46
+
47
+ logger.addHandler(file_handler)
48
+ logger.addHandler(console_handler)
49
+
50
+ return logger
51
+
52
+
53
+ def get_logger(name: str = "pentool") -> logging.Logger:
54
+ return logging.getLogger(name)