pentool 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,522 @@
1
+ """HttpStorage — SQLite-хранилище HTTP-запросов/ответов."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ import aiosqlite
12
+
13
+ from pentool.core.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ _SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS requests (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ timestamp REAL NOT NULL,
22
+ host TEXT NOT NULL DEFAULT '',
23
+ method TEXT NOT NULL DEFAULT '',
24
+ url TEXT NOT NULL DEFAULT '',
25
+ has_params INTEGER NOT NULL DEFAULT 0,
26
+ edited INTEGER NOT NULL DEFAULT 0,
27
+ status_code INTEGER,
28
+ length INTEGER,
29
+ mime_type TEXT,
30
+ extension TEXT,
31
+ is_websocket INTEGER NOT NULL DEFAULT 0,
32
+ request_headers TEXT,
33
+ response_headers TEXT,
34
+ request_body TEXT,
35
+ response_body TEXT,
36
+ request_body_ref TEXT,
37
+ response_body_ref TEXT
38
+ );
39
+ CREATE INDEX IF NOT EXISTS idx_host ON requests(host);
40
+ CREATE INDEX IF NOT EXISTS idx_status ON requests(status_code);
41
+ CREATE INDEX IF NOT EXISTS idx_ts ON requests(timestamp);
42
+ CREATE INDEX IF NOT EXISTS idx_method ON requests(method);
43
+ CREATE INDEX IF NOT EXISTS idx_ws ON requests(is_websocket);
44
+ CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts
45
+ USING fts5(url, host, request_body, response_body,
46
+ content='requests', content_rowid='id');
47
+
48
+ CREATE TRIGGER IF NOT EXISTS requests_ai AFTER INSERT ON requests BEGIN
49
+ INSERT INTO requests_fts(rowid, url, host, request_body, response_body)
50
+ VALUES (new.id, new.url, new.host,
51
+ COALESCE(new.request_body, ''),
52
+ COALESCE(new.response_body, ''));
53
+ END;
54
+ CREATE TRIGGER IF NOT EXISTS requests_ad AFTER DELETE ON requests BEGIN
55
+ INSERT INTO requests_fts(requests_fts, rowid, url, host, request_body, response_body)
56
+ VALUES ('delete', old.id, old.url, old.host,
57
+ COALESCE(old.request_body, ''),
58
+ COALESCE(old.response_body, ''));
59
+ END;
60
+ CREATE TRIGGER IF NOT EXISTS requests_au AFTER UPDATE ON requests BEGIN
61
+ INSERT INTO requests_fts(requests_fts, rowid, url, host, request_body, response_body)
62
+ VALUES ('delete', old.id, old.url, old.host,
63
+ COALESCE(old.request_body, ''),
64
+ COALESCE(old.response_body, ''));
65
+ INSERT INTO requests_fts(rowid, url, host, request_body, response_body)
66
+ VALUES (new.id, new.url, new.host,
67
+ COALESCE(new.request_body, ''),
68
+ COALESCE(new.response_body, ''));
69
+ END;
70
+ """
71
+
72
+ _LARGE_BODY_THRESHOLD = 1 * 1024 * 1024 # 1 МБ
73
+
74
+
75
+ class HttpStorage:
76
+ """Асинхронное SQLite-хранилище HTTP-записей.
77
+
78
+ Жизненный цикл:
79
+ storage = HttpStorage()
80
+ await storage.init_db("~/.config/pentool/history.db")
81
+ row_id = await storage.add_request(req, resp)
82
+ ...
83
+ await storage.close()
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self._db: aiosqlite.Connection | None = None
88
+ self._path: str = ""
89
+
90
+ async def init_db(self, path: str) -> None:
91
+ """Открыть/создать БД и применить схему."""
92
+ self._path = str(Path(path).expanduser())
93
+ logger.debug("HttpStorage: init_db at %s", self._path)
94
+ Path(self._path).parent.mkdir(parents=True, exist_ok=True)
95
+ self._db = await aiosqlite.connect(self._path)
96
+ self._db.row_factory = aiosqlite.Row
97
+ await self._db.executescript(_SCHEMA)
98
+ # Миграция: добавить is_websocket если отсутствует (для существующих БД)
99
+ try:
100
+ await self._db.execute(
101
+ "ALTER TABLE requests ADD COLUMN is_websocket INTEGER NOT NULL DEFAULT 0"
102
+ )
103
+ await self._db.commit()
104
+ except Exception:
105
+ pass # колонка уже существует — это нормально, не нужно логировать
106
+ await self._db.execute("PRAGMA journal_mode=WAL")
107
+ await self._db.execute("PRAGMA synchronous=NORMAL")
108
+ await self._db.execute("PRAGMA cache_size=-32000") # 32 МБ кэш
109
+ await self._db.commit()
110
+
111
+ async def close(self) -> None:
112
+ """Закрыть соединение с БД и освободить ресурсы."""
113
+ if self._db:
114
+ await self._db.close()
115
+ self._db = None
116
+
117
+ async def switch_db(self, path: str) -> None:
118
+ """Закрыть текущее соединение и открыть новое к другому .db файлу.
119
+
120
+ Используется при смене проекта (Recent Projects / Ctrl+O).
121
+ """
122
+ await self.close()
123
+ await self.init_db(path)
124
+ logger.info("HttpStorage: switched to %s", path)
125
+
126
+ async def add_request(self, req: Any, resp: Any = None, is_websocket: bool = False) -> int:
127
+ """Сохранить запрос (и опционально ответ). Возвращает row_id."""
128
+ assert self._db, "init_db() not called"
129
+
130
+ url = getattr(req, "url", "") or ""
131
+ parsed = urlparse(url)
132
+ host = parsed.netloc or getattr(req, "headers", {}).get("Host", "")
133
+ method = getattr(req, "method", "") or ""
134
+ has_params = 1 if parsed.query else 0
135
+ ext = Path(parsed.path).suffix.lstrip(".").lower() if parsed.path else ""
136
+ mime_type = ""
137
+
138
+ req_headers = getattr(req, "headers", {}) or {}
139
+ req_body = getattr(req, "body", None)
140
+ if isinstance(req_body, bytes):
141
+ req_body = req_body.decode("utf-8", errors="replace")
142
+
143
+ resp_headers: dict = {}
144
+ resp_body: str | None = None
145
+ status_code: int | None = None
146
+ length: int | None = None
147
+
148
+ if resp is not None:
149
+ resp_headers = dict(getattr(resp, "headers", {}) or {})
150
+ status_code = getattr(resp, "status", None)
151
+ rb = getattr(resp, "body", None)
152
+ if isinstance(rb, bytes):
153
+ rb = rb.decode("utf-8", errors="replace")
154
+ resp_body = rb
155
+ length = len(resp_body) if resp_body else 0
156
+ ct = resp_headers.get("Content-Type", resp_headers.get("content-type", ""))
157
+ mime_type = ct.split(";")[0].strip()
158
+
159
+ # Большие тела → на диск
160
+ req_body_ref: str | None = None
161
+ resp_body_ref: str | None = None
162
+
163
+ if req_body and len(req_body) > _LARGE_BODY_THRESHOLD:
164
+ req_body_ref = "__large__" # будет заменено после INSERT
165
+ req_body_store = None
166
+ else:
167
+ req_body_store = req_body
168
+
169
+ if resp_body and len(resp_body) > _LARGE_BODY_THRESHOLD:
170
+ resp_body_ref = "__large__"
171
+ resp_body_store = None
172
+ else:
173
+ resp_body_store = resp_body
174
+
175
+ async with self._db.execute(
176
+ """INSERT INTO requests
177
+ (timestamp, host, method, url, has_params, status_code, length,
178
+ mime_type, extension, is_websocket, request_headers, response_headers,
179
+ request_body, response_body, request_body_ref, response_body_ref)
180
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
181
+ (
182
+ time.time(), host, method, url, has_params,
183
+ status_code, length, mime_type, ext,
184
+ 1 if is_websocket else 0,
185
+ json.dumps(dict(req_headers)),
186
+ json.dumps(resp_headers),
187
+ req_body_store, resp_body_store,
188
+ None, None,
189
+ ),
190
+ ) as cur:
191
+ row_id = cur.lastrowid
192
+
193
+ # Шаг 1: commit сразу после INSERT — row_id зафиксирован.
194
+ # Если после этого процесс упадёт при записи файлов — строка в БД есть
195
+ # с ref=NULL. Нет ситуации "файл есть, строки нет".
196
+ await self._db.commit()
197
+
198
+ # Сохраняем большие тела с реальным row_id
199
+ from pentool.storage.large_body_handler import LargeBodyHandler
200
+ if req_body_ref == "__large__" and req_body:
201
+ ref = LargeBodyHandler.store(row_id, "req", req_body.encode())
202
+ await self._db.execute(
203
+ "UPDATE requests SET request_body_ref=? WHERE id=?", (ref, row_id)
204
+ )
205
+ if resp_body_ref == "__large__" and resp_body:
206
+ ref = LargeBodyHandler.store(row_id, "resp", resp_body.encode())
207
+ await self._db.execute(
208
+ "UPDATE requests SET response_body_ref=? WHERE id=?", (ref, row_id)
209
+ )
210
+
211
+ # Шаг 2: commit UPDATE если были большие тела
212
+ if (req_body_ref == "__large__" and req_body) or (resp_body_ref == "__large__" and resp_body):
213
+ await self._db.commit()
214
+ logger.debug("HttpStorage: add_request saved row_id=%d (%s %s, ws=%s, status=%s)", row_id, method, url, is_websocket, status_code)
215
+ return row_id
216
+
217
+ async def update_response(self, row_id: int, resp: Any) -> None:
218
+ """Обновить ответ для существующей записи."""
219
+ assert self._db
220
+ resp_headers = dict(getattr(resp, "headers", {}) or {})
221
+ status_code = getattr(resp, "status", None)
222
+ rb = getattr(resp, "body", None)
223
+ if isinstance(rb, bytes):
224
+ rb = rb.decode("utf-8", errors="replace")
225
+ length = len(rb) if rb else 0
226
+ ct = resp_headers.get("Content-Type", resp_headers.get("content-type", ""))
227
+ mime_type = ct.split(";")[0].strip()
228
+
229
+ await self._db.execute(
230
+ """UPDATE requests SET
231
+ status_code=?, length=?, mime_type=?,
232
+ response_headers=?, response_body=?
233
+ WHERE id=?""",
234
+ (status_code, length, mime_type, json.dumps(resp_headers), rb, row_id),
235
+ )
236
+ await self._db.commit()
237
+ logger.debug("HttpStorage: update_response row_id=%d status=%s", row_id, status_code)
238
+
239
+ async def delete(self, row_id: int) -> None:
240
+ """Удалить запись и её тела с диска (если есть)."""
241
+ assert self._db
242
+ # Удаляем тела с диска
243
+ async with self._db.execute(
244
+ "SELECT request_body_ref, response_body_ref FROM requests WHERE id=?",
245
+ (row_id,),
246
+ ) as cur:
247
+ row = await cur.fetchone()
248
+ if row:
249
+ from pentool.storage.large_body_handler import LargeBodyHandler
250
+ if row["request_body_ref"]:
251
+ LargeBodyHandler.delete(row["request_body_ref"])
252
+ if row["response_body_ref"]:
253
+ LargeBodyHandler.delete(row["response_body_ref"])
254
+
255
+ await self._db.execute("DELETE FROM requests WHERE id=?", (row_id,))
256
+ await self._db.commit()
257
+
258
+ async def get_metadata_batch(
259
+ self,
260
+ offset: int = 0,
261
+ limit: int = 200,
262
+ filters: dict | None = None,
263
+ order_by: str = "id",
264
+ desc: bool = True,
265
+ ) -> list[dict]:
266
+ """Загрузить метаданные страницы (без тел запросов)."""
267
+ assert self._db
268
+ where, params = self._build_where(filters)
269
+ direction = "DESC" if desc else "ASC"
270
+ # Валидация order_by против инъекций
271
+ valid_cols = {"id", "timestamp", "host", "method", "url",
272
+ "status_code", "length", "mime_type"}
273
+ if order_by not in valid_cols:
274
+ order_by = "id"
275
+
276
+ sql = f"""
277
+ SELECT id, timestamp, host, method, url, has_params, edited,
278
+ status_code, length, mime_type, extension
279
+ FROM requests
280
+ {where}
281
+ ORDER BY {order_by} {direction}
282
+ LIMIT ? OFFSET ?
283
+ """
284
+ async with self._db.execute(sql, params + [limit, offset]) as cur:
285
+ rows = await cur.fetchall()
286
+ return [dict(r) for r in rows]
287
+
288
+ async def get_full_entry(self, row_id: int) -> dict | None:
289
+ """Загрузить полную запись включая тела."""
290
+ assert self._db
291
+ async with self._db.execute(
292
+ "SELECT * FROM requests WHERE id=?", (row_id,)
293
+ ) as cur:
294
+ row = await cur.fetchone()
295
+ if not row:
296
+ return None
297
+
298
+ entry = dict(row)
299
+
300
+ # Подгружаем тела с диска если нужно
301
+ from pentool.storage.large_body_handler import LargeBodyHandler
302
+ if entry.get("request_body_ref"):
303
+ try:
304
+ entry["request_body"] = LargeBodyHandler.load(
305
+ entry["request_body_ref"]
306
+ ).decode("utf-8", errors="replace")
307
+ except Exception as e:
308
+ logger.warning("get_full_entry: failed to load req body ref=%s: %s", entry["request_body_ref"], e)
309
+ entry["request_body"] = "(could not load body)"
310
+
311
+ if entry.get("response_body_ref"):
312
+ try:
313
+ entry["response_body"] = LargeBodyHandler.load(
314
+ entry["response_body_ref"]
315
+ ).decode("utf-8", errors="replace")
316
+ except Exception as e:
317
+ logger.warning("get_full_entry: failed to load resp body ref=%s: %s", entry["response_body_ref"], e)
318
+ entry["response_body"] = "(could not load body)"
319
+
320
+ # Десериализуем JSON-заголовки
321
+ for key in ("request_headers", "response_headers"):
322
+ raw = entry.get(key)
323
+ if raw:
324
+ try:
325
+ entry[key] = json.loads(raw)
326
+ except Exception as e:
327
+ logger.warning("get_full_entry: bad JSON in %s (row_id=%s): %s", key, entry.get("id"), e)
328
+ entry[key] = {}
329
+ else:
330
+ entry[key] = {}
331
+
332
+ return entry
333
+
334
+ async def export_all_requests(self, limit: int = 10000) -> list[dict]:
335
+ """Экспортировать все запросы как список dict для сохранения в проект.
336
+
337
+ Читает из SQLite — в отличие от ProxyServer.get_requests() (in-memory).
338
+ Возвращает полные записи включая тела запросов/ответов.
339
+ """
340
+ if not self._db:
341
+ return []
342
+ try:
343
+ async with self._db.execute(
344
+ """SELECT id, timestamp, method, url, request_headers,
345
+ request_body, request_body_ref,
346
+ response_headers, response_body, response_body_ref,
347
+ status_code
348
+ FROM requests
349
+ ORDER BY id DESC
350
+ LIMIT ?""",
351
+ (limit,),
352
+ ) as cur:
353
+ rows = await cur.fetchall()
354
+ except Exception as exc:
355
+ logger.warning("export_all_requests: query failed: %s", exc)
356
+ return []
357
+
358
+ result = []
359
+ from pentool.storage.large_body_handler import LargeBodyHandler
360
+ for row in rows:
361
+ entry = dict(row)
362
+ # Загружаем большие тела
363
+ if entry.get("request_body_ref"):
364
+ try:
365
+ entry["request_body"] = LargeBodyHandler.load(
366
+ entry["request_body_ref"]
367
+ ).decode("utf-8", errors="replace")
368
+ except Exception:
369
+ entry["request_body"] = ""
370
+ if entry.get("response_body_ref"):
371
+ try:
372
+ entry["response_body"] = LargeBodyHandler.load(
373
+ entry["response_body_ref"]
374
+ ).decode("utf-8", errors="replace")
375
+ except Exception:
376
+ entry["response_body"] = ""
377
+ # Десериализуем заголовки
378
+ import json as _json
379
+ for key in ("request_headers", "response_headers"):
380
+ raw = entry.get(key)
381
+ if raw and isinstance(raw, str):
382
+ try:
383
+ entry[key] = _json.loads(raw)
384
+ except Exception:
385
+ entry[key] = {}
386
+ elif not raw:
387
+ entry[key] = {}
388
+ result.append(entry)
389
+
390
+ return result
391
+
392
+ async def count(self, filters: dict | None = None) -> int:
393
+ """Количество записей (с фильтром)."""
394
+ assert self._db
395
+ where, params = self._build_where(filters)
396
+ async with self._db.execute(
397
+ f"SELECT COUNT(*) FROM requests {where}", params
398
+ ) as cur:
399
+ row = await cur.fetchone()
400
+ return row[0] if row else 0
401
+
402
+ async def count_distinct_hosts(self) -> int:
403
+ """Количество уникальных хостов в истории."""
404
+ assert self._db
405
+ async with self._db.execute(
406
+ "SELECT COUNT(DISTINCT host) FROM requests"
407
+ ) as cur:
408
+ row = await cur.fetchone()
409
+ return row[0] if row else 0
410
+
411
+ async def search(self, query: str, limit: int = 100) -> list[dict]:
412
+ """Полнотекстовый поиск через FTS5."""
413
+ assert self._db
414
+ sql = """
415
+ SELECT r.id, r.timestamp, r.host, r.method, r.url,
416
+ r.status_code, r.length, r.mime_type
417
+ FROM requests r
418
+ JOIN requests_fts f ON r.id = f.rowid
419
+ WHERE requests_fts MATCH ?
420
+ ORDER BY r.id DESC
421
+ LIMIT ?
422
+ """
423
+ try:
424
+ async with self._db.execute(sql, (query, limit)) as cur:
425
+ rows = await cur.fetchall()
426
+ return [dict(r) for r in rows]
427
+ except Exception as e:
428
+ logger.warning("search: FTS query failed for %r: %s", query, e)
429
+ return []
430
+
431
+ async def clear_all(self) -> None:
432
+ """Удалить все записи и связанные файлы больших тел с диска."""
433
+ assert self._db
434
+ from pentool.storage.large_body_handler import LargeBodyHandler
435
+ # Сначала собираем все ref на файлы
436
+ async with self._db.execute(
437
+ "SELECT request_body_ref, response_body_ref FROM requests"
438
+ " WHERE request_body_ref IS NOT NULL OR response_body_ref IS NOT NULL"
439
+ ) as cur:
440
+ rows = await cur.fetchall()
441
+ # Удаляем файлы
442
+ for row in rows:
443
+ if row["request_body_ref"]:
444
+ try:
445
+ LargeBodyHandler.delete(row["request_body_ref"])
446
+ except Exception as e:
447
+ logger.warning("clear_all: failed to delete req body file %s: %s", row["request_body_ref"], e)
448
+ if row["response_body_ref"]:
449
+ try:
450
+ LargeBodyHandler.delete(row["response_body_ref"])
451
+ except Exception as e:
452
+ logger.warning("clear_all: failed to delete resp body file %s: %s", row["response_body_ref"], e)
453
+ # Теперь удаляем записи из БД
454
+ await self._db.execute("DELETE FROM requests")
455
+ await self._db.execute("DELETE FROM requests_fts")
456
+ await self._db.commit()
457
+ logger.info("HttpStorage: clear_all done, removed %d large body files", len(rows))
458
+
459
+ def _build_where(self, filters: dict | None) -> tuple[str, list]:
460
+ """Построить WHERE-clause из словаря фильтров."""
461
+ if not filters:
462
+ return "", []
463
+
464
+ clauses: list[str] = []
465
+ params: list = []
466
+
467
+ host = filters.get("host")
468
+ if host:
469
+ clauses.append("host LIKE ?")
470
+ params.append(f"%{host}%")
471
+
472
+ # Список хостов для фильтра "только in-scope"
473
+ # Используем LIKE чтобы учесть варианты с портом (example.com vs example.com:443)
474
+ hosts = filters.get("hosts")
475
+ if hosts and isinstance(hosts, (list, tuple)) and len(hosts) > 0:
476
+ sub = " OR ".join("(host = ? OR host LIKE ?)" for _ in hosts)
477
+ clauses.append(f"({sub})")
478
+ for h in hosts:
479
+ base = h.split(":")[0] # убрать порт если есть
480
+ params.append(base)
481
+ params.append(f"{base}:%")
482
+
483
+ methods = filters.get("method")
484
+ if methods:
485
+ if isinstance(methods, str):
486
+ methods = [methods]
487
+ placeholders = ",".join("?" * len(methods))
488
+ clauses.append(f"method IN ({placeholders})")
489
+ params.extend(methods)
490
+
491
+ status = filters.get("status_code")
492
+ if status:
493
+ if isinstance(status, (list, tuple)) and len(status) == 2:
494
+ clauses.append("status_code BETWEEN ? AND ?")
495
+ params.extend(status)
496
+ elif isinstance(status, int):
497
+ clauses.append("status_code = ?")
498
+ params.append(status)
499
+
500
+ mime = filters.get("mime_type")
501
+ if mime:
502
+ clauses.append("mime_type LIKE ?")
503
+ params.append(f"%{mime}%")
504
+
505
+ ext = filters.get("extension")
506
+ if ext:
507
+ clauses.append("extension = ?")
508
+ params.append(ext)
509
+
510
+ has_params = filters.get("has_params")
511
+ if has_params is not None:
512
+ clauses.append("has_params = ?")
513
+ params.append(1 if has_params else 0)
514
+
515
+ is_websocket = filters.get("is_websocket")
516
+ if is_websocket is not None:
517
+ clauses.append("is_websocket = ?")
518
+ params.append(1 if is_websocket else 0)
519
+
520
+ if not clauses:
521
+ return "", []
522
+ return "WHERE " + " AND ".join(clauses), params
@@ -0,0 +1,54 @@
1
+ """LargeBodyHandler — хранение тел > 1МБ на диске."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pentool.core.logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ THRESHOLD = 1 * 1024 * 1024 # 1 МБ
12
+
13
+
14
+ class LargeBodyHandler:
15
+ """Сохраняет/загружает тела запросов/ответов > THRESHOLD на диск.
16
+
17
+ Формат имени файла: <row_id>_<kind>.bin
18
+ где kind = 'req' | 'resp'
19
+ """
20
+
21
+ BASE_DIR: Path = Path("~/.config/pentool/bodies").expanduser()
22
+
23
+ @classmethod
24
+ def _path(cls, row_id: int, kind: str) -> Path:
25
+ return cls.BASE_DIR / f"{row_id}_{kind}.bin"
26
+
27
+ @classmethod
28
+ def store(cls, row_id: int, kind: str, data: bytes) -> str:
29
+ """Сохранить тело на диск. Возвращает абсолютный путь (для БД)."""
30
+ cls.BASE_DIR.mkdir(parents=True, exist_ok=True)
31
+ p = cls._path(row_id, kind)
32
+ p.write_bytes(data)
33
+ return str(p)
34
+
35
+ @classmethod
36
+ def load(cls, path: str) -> bytes:
37
+ """Загрузить тело с диска."""
38
+ return Path(path).read_bytes()
39
+
40
+ @classmethod
41
+ def delete(cls, path: str) -> None:
42
+ """Удалить файл тела (тихо, если не существует)."""
43
+ try:
44
+ Path(path).unlink(missing_ok=True)
45
+ except Exception as e:
46
+ logger.warning("LargeBodyHandler.delete: failed to delete %s: %s", path, e)
47
+
48
+ @classmethod
49
+ def is_large(cls, data: str | bytes | None) -> bool:
50
+ """Проверить превышает ли тело порог."""
51
+ if data is None:
52
+ return False
53
+ size = len(data) if isinstance(data, bytes) else len(data.encode())
54
+ return size > THRESHOLD
@@ -0,0 +1,49 @@
1
+ """LRUCache — кэш полных HTTP-записей в памяти."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from typing import Any
7
+
8
+
9
+ class LRUCache:
10
+ """In-memory LRU-кэш. Вытеснение по принципу Least Recently Used.
11
+
12
+ Используется в HttpStorage для кеширования get_full_entry():
13
+ повторный запрос той же строки не идёт в SQLite.
14
+ """
15
+
16
+ def __init__(self, max_size: int = 500) -> None:
17
+ self._max_size = max_size
18
+ self._cache: OrderedDict[int, Any] = OrderedDict()
19
+
20
+ def get(self, key: int) -> Any | None:
21
+ """Вернуть значение или None. Обновляет порядок (LRU)."""
22
+ if key not in self._cache:
23
+ return None
24
+ self._cache.move_to_end(key)
25
+ return self._cache[key]
26
+
27
+ def put(self, key: int, value: Any) -> None:
28
+ """Сохранить значение. Вытесняет старейший элемент при переполнении."""
29
+ if key in self._cache:
30
+ self._cache.move_to_end(key)
31
+ self._cache[key] = value
32
+ return
33
+ self._cache[key] = value
34
+ if len(self._cache) > self._max_size:
35
+ self._cache.popitem(last=False) # удаляет самый старый
36
+
37
+ def invalidate(self, key: int) -> None:
38
+ """Удалить ключ из кэша (при update/delete записи)."""
39
+ self._cache.pop(key, None)
40
+
41
+ def clear(self) -> None:
42
+ """Очистить весь кэш."""
43
+ self._cache.clear()
44
+
45
+ def __len__(self) -> int:
46
+ return len(self._cache)
47
+
48
+ def __contains__(self, key: int) -> bool:
49
+ return key in self._cache