hexproxy 0.2.2__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.
- hexproxy/__init__.py +7 -0
- hexproxy/__main__.py +5 -0
- hexproxy/app.py +192 -0
- hexproxy/bodyview.py +435 -0
- hexproxy/certs.py +222 -0
- hexproxy/clipboard.py +89 -0
- hexproxy/extensions.py +739 -0
- hexproxy/mcp.py +2114 -0
- hexproxy/models.py +72 -0
- hexproxy/preferences.py +131 -0
- hexproxy/proxy.py +1178 -0
- hexproxy/store.py +1001 -0
- hexproxy/themes.py +274 -0
- hexproxy/tui.py +8796 -0
- hexproxy-0.2.2.dist-info/METADATA +556 -0
- hexproxy-0.2.2.dist-info/RECORD +20 -0
- hexproxy-0.2.2.dist-info/WHEEL +5 -0
- hexproxy-0.2.2.dist-info/entry_points.txt +2 -0
- hexproxy-0.2.2.dist-info/licenses/LICENSE +37 -0
- hexproxy-0.2.2.dist-info/top_level.txt +1 -0
hexproxy/store.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from threading import Event, Lock
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from .models import MatchReplaceRule, RequestData, ResponseData, TrafficEntry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PROJECT_VERSION = 1
|
|
19
|
+
INTERCEPT_MODES = ("off", "request", "response", "both")
|
|
20
|
+
VIEW_FILTER_QUERY_MODES = ("all", "with_query", "without_query")
|
|
21
|
+
VIEW_FILTER_FAILURE_MODES = ("all", "failures", "hide_failures", "client_errors", "server_errors", "connection_errors")
|
|
22
|
+
VIEW_FILTER_BODY_MODES = ("all", "with_body", "without_body")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class PendingInterception:
|
|
27
|
+
record_id: int
|
|
28
|
+
entry_id: int
|
|
29
|
+
phase: str
|
|
30
|
+
raw_text: str
|
|
31
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
32
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
33
|
+
decision: str = "pending"
|
|
34
|
+
active: bool = True
|
|
35
|
+
event: Event = field(default_factory=Event, repr=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class PendingInterceptionView:
|
|
40
|
+
record_id: int
|
|
41
|
+
entry_id: int
|
|
42
|
+
phase: str
|
|
43
|
+
raw_text: str
|
|
44
|
+
created_at: datetime
|
|
45
|
+
updated_at: datetime
|
|
46
|
+
decision: str
|
|
47
|
+
active: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class InterceptionResult:
|
|
52
|
+
entry_id: int
|
|
53
|
+
phase: str
|
|
54
|
+
decision: str
|
|
55
|
+
raw_text: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class ViewFilterSettings:
|
|
60
|
+
show_out_of_scope: bool = False
|
|
61
|
+
query_mode: str = "all"
|
|
62
|
+
failure_mode: str = "all"
|
|
63
|
+
body_mode: str = "all"
|
|
64
|
+
methods: list[str] = field(default_factory=list)
|
|
65
|
+
hidden_methods: list[str] = field(default_factory=list)
|
|
66
|
+
hidden_extensions: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TrafficStore:
|
|
70
|
+
def __init__(self, project_path: str | Path | None = None) -> None:
|
|
71
|
+
self._lock = Lock()
|
|
72
|
+
self._entries: list[TrafficEntry] = []
|
|
73
|
+
self._next_id = 1
|
|
74
|
+
self._project_path: Path | None = None
|
|
75
|
+
self._last_save_at: datetime | None = None
|
|
76
|
+
self._last_save_error = ""
|
|
77
|
+
self._intercept_mode = "off"
|
|
78
|
+
self._pending_interceptions: dict[int, PendingInterception] = {}
|
|
79
|
+
self._interception_log: list[PendingInterception] = []
|
|
80
|
+
self._next_interception_id = 1
|
|
81
|
+
self._match_replace_rules: list[MatchReplaceRule] = []
|
|
82
|
+
self._scope_hosts: list[str] = []
|
|
83
|
+
self._view_filters = ViewFilterSettings()
|
|
84
|
+
self._keybindings: dict[str, str] = {}
|
|
85
|
+
self._plugin_state: dict[str, dict[str, object]] = {}
|
|
86
|
+
if project_path is not None:
|
|
87
|
+
self.set_project_path(project_path)
|
|
88
|
+
|
|
89
|
+
def create_entry(self, client_addr: str) -> int:
|
|
90
|
+
project = None
|
|
91
|
+
with self._lock:
|
|
92
|
+
entry_id = self._next_id
|
|
93
|
+
self._next_id += 1
|
|
94
|
+
self._entries.append(TrafficEntry(id=entry_id, client_addr=client_addr))
|
|
95
|
+
project = self._build_project_locked()
|
|
96
|
+
self._autosave(project)
|
|
97
|
+
return entry_id
|
|
98
|
+
|
|
99
|
+
def mutate(self, entry_id: int, updater) -> None:
|
|
100
|
+
project = None
|
|
101
|
+
with self._lock:
|
|
102
|
+
entry = self._find_locked(entry_id)
|
|
103
|
+
updater(entry)
|
|
104
|
+
project = self._build_project_locked()
|
|
105
|
+
self._autosave(project)
|
|
106
|
+
|
|
107
|
+
def complete(self, entry_id: int) -> None:
|
|
108
|
+
finished_at = datetime.now(timezone.utc)
|
|
109
|
+
|
|
110
|
+
def _update(entry: TrafficEntry) -> None:
|
|
111
|
+
entry.finished_at = finished_at
|
|
112
|
+
entry.duration_ms = (finished_at - entry.started_at).total_seconds() * 1000
|
|
113
|
+
if entry.state == "pending":
|
|
114
|
+
entry.state = "complete"
|
|
115
|
+
|
|
116
|
+
self.mutate(entry_id, _update)
|
|
117
|
+
|
|
118
|
+
def snapshot(self) -> list[TrafficEntry]:
|
|
119
|
+
with self._lock:
|
|
120
|
+
return deepcopy(self._entries)
|
|
121
|
+
|
|
122
|
+
def visible_entries(self, scope_only: bool | None = None) -> list[TrafficEntry]:
|
|
123
|
+
with self._lock:
|
|
124
|
+
filters = deepcopy(self._view_filters)
|
|
125
|
+
if scope_only is not None:
|
|
126
|
+
filters.show_out_of_scope = not scope_only
|
|
127
|
+
visible = [entry for entry in self._entries if self._entry_visible_locked(entry, filters)]
|
|
128
|
+
return deepcopy(visible)
|
|
129
|
+
|
|
130
|
+
def save(self, path: str | Path | None = None) -> Path:
|
|
131
|
+
if path is not None:
|
|
132
|
+
self.set_project_path(path)
|
|
133
|
+
with self._lock:
|
|
134
|
+
if self._project_path is None:
|
|
135
|
+
raise ValueError("project path is not configured")
|
|
136
|
+
project_path = self._project_path
|
|
137
|
+
payload = self._build_project_locked()
|
|
138
|
+
if payload is None:
|
|
139
|
+
raise ValueError("project path is not configured")
|
|
140
|
+
self._write_project(project_path, payload)
|
|
141
|
+
return project_path
|
|
142
|
+
|
|
143
|
+
def load(self, path: str | Path) -> int:
|
|
144
|
+
project_path = Path(path)
|
|
145
|
+
payload = json.loads(project_path.read_text(encoding="utf-8"))
|
|
146
|
+
if payload.get("version") != PROJECT_VERSION:
|
|
147
|
+
raise ValueError(f"unsupported project version: {payload.get('version')!r}")
|
|
148
|
+
|
|
149
|
+
entries = [self._entry_from_dict(item) for item in payload.get("entries", [])]
|
|
150
|
+
next_id = max((entry.id for entry in entries), default=0) + 1
|
|
151
|
+
saved_at = payload.get("saved_at")
|
|
152
|
+
|
|
153
|
+
with self._lock:
|
|
154
|
+
self._entries = entries
|
|
155
|
+
self._next_id = max(next_id, int(payload.get("next_id", next_id)))
|
|
156
|
+
self._project_path = project_path
|
|
157
|
+
self._last_save_error = ""
|
|
158
|
+
self._last_save_at = self._parse_datetime(saved_at) if saved_at else None
|
|
159
|
+
self._pending_interceptions = {}
|
|
160
|
+
self._interception_log = []
|
|
161
|
+
self._next_interception_id = 1
|
|
162
|
+
self._match_replace_rules = self._rules_from_list(payload.get("match_replace_rules", []))
|
|
163
|
+
self._scope_hosts = self._scope_hosts_from_list(payload.get("scope_hosts", []))
|
|
164
|
+
self._view_filters = self._view_filters_from_dict(payload.get("view_filters", {}))
|
|
165
|
+
self._keybindings = self._keybindings_from_dict(payload.get("keybindings", {}))
|
|
166
|
+
self._plugin_state = self._plugin_state_from_dict(payload.get("plugin_state", {}))
|
|
167
|
+
return len(entries)
|
|
168
|
+
|
|
169
|
+
def set_project_path(self, path: str | Path) -> None:
|
|
170
|
+
with self._lock:
|
|
171
|
+
self._project_path = Path(path)
|
|
172
|
+
|
|
173
|
+
def project_path(self) -> Path | None:
|
|
174
|
+
with self._lock:
|
|
175
|
+
return self._project_path
|
|
176
|
+
|
|
177
|
+
def save_status(self) -> tuple[datetime | None, str]:
|
|
178
|
+
with self._lock:
|
|
179
|
+
return self._last_save_at, self._last_save_error
|
|
180
|
+
|
|
181
|
+
def get(self, entry_id: int) -> TrafficEntry | None:
|
|
182
|
+
with self._lock:
|
|
183
|
+
for entry in self._entries:
|
|
184
|
+
if entry.id == entry_id:
|
|
185
|
+
return deepcopy(entry)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def count(self) -> int:
|
|
189
|
+
with self._lock:
|
|
190
|
+
return len(self._entries)
|
|
191
|
+
|
|
192
|
+
def match_replace_rules(self) -> list[MatchReplaceRule]:
|
|
193
|
+
with self._lock:
|
|
194
|
+
return deepcopy(self._match_replace_rules)
|
|
195
|
+
|
|
196
|
+
def set_match_replace_rules(self, rules: list[MatchReplaceRule]) -> None:
|
|
197
|
+
self._validate_match_replace_rules(rules)
|
|
198
|
+
project = None
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._match_replace_rules = deepcopy(rules)
|
|
201
|
+
project = self._build_project_locked()
|
|
202
|
+
self._autosave(project)
|
|
203
|
+
|
|
204
|
+
def scope_hosts(self) -> list[str]:
|
|
205
|
+
with self._lock:
|
|
206
|
+
return list(self._scope_hosts)
|
|
207
|
+
|
|
208
|
+
def set_scope_hosts(self, hosts: list[str]) -> None:
|
|
209
|
+
normalized_hosts = self._scope_hosts_from_list(hosts)
|
|
210
|
+
project = None
|
|
211
|
+
with self._lock:
|
|
212
|
+
self._scope_hosts = normalized_hosts
|
|
213
|
+
project = self._build_project_locked()
|
|
214
|
+
self._autosave(project)
|
|
215
|
+
|
|
216
|
+
def view_filters(self) -> ViewFilterSettings:
|
|
217
|
+
with self._lock:
|
|
218
|
+
return deepcopy(self._view_filters)
|
|
219
|
+
|
|
220
|
+
def set_view_filters(self, filters: ViewFilterSettings) -> None:
|
|
221
|
+
normalized = self._normalize_view_filters(filters)
|
|
222
|
+
project = None
|
|
223
|
+
with self._lock:
|
|
224
|
+
self._view_filters = normalized
|
|
225
|
+
project = self._build_project_locked()
|
|
226
|
+
self._autosave(project)
|
|
227
|
+
|
|
228
|
+
def keybindings(self) -> dict[str, str]:
|
|
229
|
+
with self._lock:
|
|
230
|
+
return dict(self._keybindings)
|
|
231
|
+
|
|
232
|
+
def set_keybindings(self, bindings: dict[str, str]) -> None:
|
|
233
|
+
normalized = self._keybindings_from_dict(bindings)
|
|
234
|
+
project = None
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._keybindings = normalized
|
|
237
|
+
project = self._build_project_locked()
|
|
238
|
+
self._autosave(project)
|
|
239
|
+
|
|
240
|
+
def plugin_state(self, plugin_id: str | None = None) -> dict[str, object] | dict[str, dict[str, object]]:
|
|
241
|
+
with self._lock:
|
|
242
|
+
if plugin_id is None:
|
|
243
|
+
return {
|
|
244
|
+
name: deepcopy(values)
|
|
245
|
+
for name, values in self._plugin_state.items()
|
|
246
|
+
}
|
|
247
|
+
return deepcopy(self._plugin_state.get(str(plugin_id).strip(), {}))
|
|
248
|
+
|
|
249
|
+
def set_plugin_state(self, plugin_id: str, values: dict[str, object]) -> None:
|
|
250
|
+
normalized_id = str(plugin_id).strip()
|
|
251
|
+
if not normalized_id:
|
|
252
|
+
raise ValueError("plugin id must not be empty")
|
|
253
|
+
if not isinstance(values, dict):
|
|
254
|
+
raise ValueError("plugin state must be a dict")
|
|
255
|
+
project = None
|
|
256
|
+
with self._lock:
|
|
257
|
+
self._plugin_state[normalized_id] = deepcopy(values)
|
|
258
|
+
project = self._build_project_locked()
|
|
259
|
+
self._autosave(project)
|
|
260
|
+
|
|
261
|
+
def plugin_value(self, plugin_id: str, key: str, default: object = None) -> object:
|
|
262
|
+
with self._lock:
|
|
263
|
+
return deepcopy(
|
|
264
|
+
self._plugin_state.get(str(plugin_id).strip(), {}).get(str(key).strip(), default)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def set_plugin_value(self, plugin_id: str, key: str, value: object) -> None:
|
|
268
|
+
normalized_id = str(plugin_id).strip()
|
|
269
|
+
normalized_key = str(key).strip()
|
|
270
|
+
if not normalized_id or not normalized_key:
|
|
271
|
+
raise ValueError("plugin id and key must not be empty")
|
|
272
|
+
project = None
|
|
273
|
+
with self._lock:
|
|
274
|
+
bucket = deepcopy(self._plugin_state.get(normalized_id, {}))
|
|
275
|
+
bucket[normalized_key] = deepcopy(value)
|
|
276
|
+
self._plugin_state[normalized_id] = bucket
|
|
277
|
+
project = self._build_project_locked()
|
|
278
|
+
self._autosave(project)
|
|
279
|
+
|
|
280
|
+
def set_entry_plugin_metadata(
|
|
281
|
+
self,
|
|
282
|
+
entry_id: int,
|
|
283
|
+
plugin_id: str,
|
|
284
|
+
metadata: dict[str, str],
|
|
285
|
+
) -> None:
|
|
286
|
+
normalized_id = str(plugin_id).strip()
|
|
287
|
+
if not normalized_id:
|
|
288
|
+
raise ValueError("plugin id must not be empty")
|
|
289
|
+
|
|
290
|
+
def _update(entry: TrafficEntry) -> None:
|
|
291
|
+
entry.plugin_metadata[normalized_id] = {
|
|
292
|
+
str(key): str(value)
|
|
293
|
+
for key, value in metadata.items()
|
|
294
|
+
if str(key).strip()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
self.mutate(entry_id, _update)
|
|
298
|
+
|
|
299
|
+
def set_entry_plugin_findings(
|
|
300
|
+
self,
|
|
301
|
+
entry_id: int,
|
|
302
|
+
plugin_id: str,
|
|
303
|
+
findings: list[str],
|
|
304
|
+
) -> None:
|
|
305
|
+
normalized_id = str(plugin_id).strip()
|
|
306
|
+
if not normalized_id:
|
|
307
|
+
raise ValueError("plugin id must not be empty")
|
|
308
|
+
|
|
309
|
+
def _update(entry: TrafficEntry) -> None:
|
|
310
|
+
entry.plugin_findings[normalized_id] = [
|
|
311
|
+
str(item)
|
|
312
|
+
for item in findings
|
|
313
|
+
if str(item).strip()
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
self.mutate(entry_id, _update)
|
|
317
|
+
|
|
318
|
+
def set_intercept_enabled(self, enabled: bool) -> None:
|
|
319
|
+
self.set_intercept_mode("request" if enabled else "off")
|
|
320
|
+
|
|
321
|
+
def set_intercept_mode(self, mode: str) -> None:
|
|
322
|
+
if mode not in INTERCEPT_MODES:
|
|
323
|
+
raise ValueError(f"invalid intercept mode: {mode!r}")
|
|
324
|
+
with self._lock:
|
|
325
|
+
self._intercept_mode = mode
|
|
326
|
+
|
|
327
|
+
def intercept_enabled(self) -> bool:
|
|
328
|
+
with self._lock:
|
|
329
|
+
return self._intercept_mode != "off"
|
|
330
|
+
|
|
331
|
+
def intercept_mode(self) -> str:
|
|
332
|
+
with self._lock:
|
|
333
|
+
return self._intercept_mode
|
|
334
|
+
|
|
335
|
+
def should_intercept(self, phase: str, host: str | None = None) -> bool:
|
|
336
|
+
if phase not in {"request", "response"}:
|
|
337
|
+
raise ValueError(f"invalid interception phase: {phase!r}")
|
|
338
|
+
with self._lock:
|
|
339
|
+
if self._intercept_mode not in {phase, "both"}:
|
|
340
|
+
return False
|
|
341
|
+
return self._host_is_in_scope_locked(host or "")
|
|
342
|
+
|
|
343
|
+
def pending_interceptions(self) -> list[PendingInterceptionView]:
|
|
344
|
+
with self._lock:
|
|
345
|
+
return [self._view_interception(item) for item in self._pending_interceptions.values()]
|
|
346
|
+
|
|
347
|
+
def interception_history(self) -> list[PendingInterceptionView]:
|
|
348
|
+
with self._lock:
|
|
349
|
+
return [self._view_interception(item) for item in self._interception_log]
|
|
350
|
+
|
|
351
|
+
def get_pending_interception(self, entry_id: int) -> PendingInterceptionView | None:
|
|
352
|
+
with self._lock:
|
|
353
|
+
item = self._pending_interceptions.get(entry_id)
|
|
354
|
+
if item is None:
|
|
355
|
+
return None
|
|
356
|
+
return self._view_interception(item)
|
|
357
|
+
|
|
358
|
+
def get_pending_interception_record(self, record_id: int) -> PendingInterceptionView | None:
|
|
359
|
+
with self._lock:
|
|
360
|
+
pending = self._find_pending_by_record_locked(record_id)
|
|
361
|
+
if pending is None:
|
|
362
|
+
return None
|
|
363
|
+
return self._view_interception(pending)
|
|
364
|
+
|
|
365
|
+
def begin_interception(self, entry_id: int, phase: str, raw_text: str, host: str | None = None) -> bool:
|
|
366
|
+
project = None
|
|
367
|
+
with self._lock:
|
|
368
|
+
if phase not in {"request", "response"}:
|
|
369
|
+
raise ValueError(f"invalid interception phase: {phase!r}")
|
|
370
|
+
if self._intercept_mode not in {phase, "both"}:
|
|
371
|
+
return False
|
|
372
|
+
if not self._host_is_in_scope_locked(host or ""):
|
|
373
|
+
return False
|
|
374
|
+
pending = PendingInterception(
|
|
375
|
+
record_id=self._next_interception_id,
|
|
376
|
+
entry_id=entry_id,
|
|
377
|
+
phase=phase,
|
|
378
|
+
raw_text=raw_text,
|
|
379
|
+
)
|
|
380
|
+
self._next_interception_id += 1
|
|
381
|
+
self._pending_interceptions[entry_id] = pending
|
|
382
|
+
self._interception_log.append(pending)
|
|
383
|
+
entry = self._find_locked(entry_id)
|
|
384
|
+
entry.state = "intercepted"
|
|
385
|
+
project = self._build_project_locked()
|
|
386
|
+
self._autosave(project)
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
def update_pending_interception(self, entry_id: int, raw_text: str) -> None:
|
|
390
|
+
with self._lock:
|
|
391
|
+
pending = self._pending_interceptions.get(entry_id)
|
|
392
|
+
if pending is None:
|
|
393
|
+
raise KeyError(f"interception {entry_id} not found")
|
|
394
|
+
pending.raw_text = raw_text
|
|
395
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
396
|
+
|
|
397
|
+
def forward_pending_interception(self, entry_id: int) -> None:
|
|
398
|
+
with self._lock:
|
|
399
|
+
pending = self._pending_interceptions.get(entry_id)
|
|
400
|
+
if pending is None:
|
|
401
|
+
raise KeyError(f"interception {entry_id} not found")
|
|
402
|
+
pending.decision = "forward"
|
|
403
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
404
|
+
pending.event.set()
|
|
405
|
+
|
|
406
|
+
def update_pending_interception_record(self, record_id: int, raw_text: str) -> None:
|
|
407
|
+
with self._lock:
|
|
408
|
+
pending = self._find_pending_by_record_locked(record_id)
|
|
409
|
+
if pending is None:
|
|
410
|
+
raise KeyError(f"interception record {record_id} not found")
|
|
411
|
+
pending.raw_text = raw_text
|
|
412
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
413
|
+
|
|
414
|
+
def forward_pending_interception_record(self, record_id: int) -> None:
|
|
415
|
+
with self._lock:
|
|
416
|
+
pending = self._find_pending_by_record_locked(record_id)
|
|
417
|
+
if pending is None:
|
|
418
|
+
raise KeyError(f"interception record {record_id} not found")
|
|
419
|
+
pending.decision = "forward"
|
|
420
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
421
|
+
pending.event.set()
|
|
422
|
+
|
|
423
|
+
def drop_pending_interception(self, entry_id: int) -> None:
|
|
424
|
+
project = None
|
|
425
|
+
with self._lock:
|
|
426
|
+
pending = self._pending_interceptions.get(entry_id)
|
|
427
|
+
if pending is None:
|
|
428
|
+
raise KeyError(f"interception {entry_id} not found")
|
|
429
|
+
pending.decision = "drop"
|
|
430
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
431
|
+
pending.event.set()
|
|
432
|
+
entry = self._find_locked(entry_id)
|
|
433
|
+
entry.state = "dropped"
|
|
434
|
+
entry.error = f"{pending.phase} dropped by interceptor"
|
|
435
|
+
project = self._build_project_locked()
|
|
436
|
+
self._autosave(project)
|
|
437
|
+
|
|
438
|
+
def drop_pending_interception_record(self, record_id: int) -> None:
|
|
439
|
+
project = None
|
|
440
|
+
with self._lock:
|
|
441
|
+
pending = self._find_pending_by_record_locked(record_id)
|
|
442
|
+
if pending is None:
|
|
443
|
+
raise KeyError(f"interception record {record_id} not found")
|
|
444
|
+
pending.decision = "drop"
|
|
445
|
+
pending.updated_at = datetime.now(timezone.utc)
|
|
446
|
+
pending.event.set()
|
|
447
|
+
entry = self._find_locked(pending.entry_id)
|
|
448
|
+
entry.state = "dropped"
|
|
449
|
+
entry.error = f"{pending.phase} dropped by interceptor"
|
|
450
|
+
project = self._build_project_locked()
|
|
451
|
+
self._autosave(project)
|
|
452
|
+
|
|
453
|
+
def wait_for_interception(self, entry_id: int) -> InterceptionResult:
|
|
454
|
+
with self._lock:
|
|
455
|
+
pending = self._pending_interceptions.get(entry_id)
|
|
456
|
+
if pending is None:
|
|
457
|
+
raise KeyError(f"interception {entry_id} not found")
|
|
458
|
+
event = pending.event
|
|
459
|
+
|
|
460
|
+
event.wait()
|
|
461
|
+
|
|
462
|
+
with self._lock:
|
|
463
|
+
pending = self._pending_interceptions.pop(entry_id, None)
|
|
464
|
+
if pending is None:
|
|
465
|
+
raise KeyError(f"interception {entry_id} not found after release")
|
|
466
|
+
pending.active = False
|
|
467
|
+
return InterceptionResult(
|
|
468
|
+
entry_id=entry_id,
|
|
469
|
+
phase=pending.phase,
|
|
470
|
+
decision=pending.decision,
|
|
471
|
+
raw_text=pending.raw_text,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def release_pending_interceptions(self, reason: str = "proxy shutting down") -> None:
|
|
475
|
+
project = None
|
|
476
|
+
with self._lock:
|
|
477
|
+
if not self._pending_interceptions:
|
|
478
|
+
return
|
|
479
|
+
released_at = datetime.now(timezone.utc)
|
|
480
|
+
for entry_id, pending in self._pending_interceptions.items():
|
|
481
|
+
pending.decision = "drop"
|
|
482
|
+
pending.updated_at = released_at
|
|
483
|
+
pending.active = False
|
|
484
|
+
pending.event.set()
|
|
485
|
+
entry = self._find_locked(entry_id)
|
|
486
|
+
entry.state = "dropped"
|
|
487
|
+
entry.error = reason
|
|
488
|
+
project = self._build_project_locked()
|
|
489
|
+
self._autosave(project)
|
|
490
|
+
|
|
491
|
+
def _autosave(self, payload: dict[str, object] | None) -> None:
|
|
492
|
+
with self._lock:
|
|
493
|
+
project_path = self._project_path
|
|
494
|
+
if project_path is None or payload is None:
|
|
495
|
+
return
|
|
496
|
+
self._write_project(project_path, payload)
|
|
497
|
+
|
|
498
|
+
def _write_project(self, project_path: Path, payload: dict[str, object]) -> None:
|
|
499
|
+
project_path.parent.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
rendered = json.dumps(payload, indent=2, ensure_ascii=True) + "\n"
|
|
501
|
+
|
|
502
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{project_path.name}.", suffix=".tmp", dir=project_path.parent)
|
|
503
|
+
temp_path = Path(temp_name)
|
|
504
|
+
try:
|
|
505
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
506
|
+
handle.write(rendered)
|
|
507
|
+
handle.flush()
|
|
508
|
+
os.fsync(handle.fileno())
|
|
509
|
+
temp_path.replace(project_path)
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
temp_path.unlink(missing_ok=True)
|
|
512
|
+
with self._lock:
|
|
513
|
+
self._last_save_error = str(exc)
|
|
514
|
+
raise
|
|
515
|
+
|
|
516
|
+
with self._lock:
|
|
517
|
+
self._last_save_at = datetime.now(timezone.utc)
|
|
518
|
+
self._last_save_error = ""
|
|
519
|
+
|
|
520
|
+
def _build_project_locked(self) -> dict[str, object] | None:
|
|
521
|
+
if self._project_path is None:
|
|
522
|
+
return None
|
|
523
|
+
return {
|
|
524
|
+
"version": PROJECT_VERSION,
|
|
525
|
+
"saved_at": datetime.now(timezone.utc).isoformat(),
|
|
526
|
+
"next_id": self._next_id,
|
|
527
|
+
"entries": [self._entry_to_dict(entry) for entry in self._entries],
|
|
528
|
+
"match_replace_rules": [self._rule_to_dict(rule) for rule in self._match_replace_rules],
|
|
529
|
+
"scope_hosts": list(self._scope_hosts),
|
|
530
|
+
"view_filters": self._view_filters_to_dict(self._view_filters),
|
|
531
|
+
"keybindings": dict(self._keybindings),
|
|
532
|
+
"plugin_state": deepcopy(self._plugin_state),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
def _find_locked(self, entry_id: int) -> TrafficEntry:
|
|
536
|
+
for entry in self._entries:
|
|
537
|
+
if entry.id == entry_id:
|
|
538
|
+
return entry
|
|
539
|
+
raise KeyError(f"entry {entry_id} not found")
|
|
540
|
+
|
|
541
|
+
def _find_pending_by_record_locked(self, record_id: int) -> PendingInterception | None:
|
|
542
|
+
for pending in self._pending_interceptions.values():
|
|
543
|
+
if pending.record_id == record_id:
|
|
544
|
+
return pending
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _view_interception(item: PendingInterception) -> PendingInterceptionView:
|
|
549
|
+
return PendingInterceptionView(
|
|
550
|
+
record_id=item.record_id,
|
|
551
|
+
entry_id=item.entry_id,
|
|
552
|
+
phase=item.phase,
|
|
553
|
+
raw_text=item.raw_text,
|
|
554
|
+
created_at=item.created_at,
|
|
555
|
+
updated_at=item.updated_at,
|
|
556
|
+
decision=item.decision,
|
|
557
|
+
active=item.active,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def _entry_to_dict(entry: TrafficEntry) -> dict[str, object]:
|
|
562
|
+
return {
|
|
563
|
+
"id": entry.id,
|
|
564
|
+
"client_addr": entry.client_addr,
|
|
565
|
+
"started_at": entry.started_at.isoformat(),
|
|
566
|
+
"finished_at": entry.finished_at.isoformat() if entry.finished_at else None,
|
|
567
|
+
"duration_ms": entry.duration_ms,
|
|
568
|
+
"upstream_addr": entry.upstream_addr,
|
|
569
|
+
"error": entry.error,
|
|
570
|
+
"state": entry.state,
|
|
571
|
+
"plugin_metadata": {
|
|
572
|
+
str(plugin_id): {
|
|
573
|
+
str(key): str(value)
|
|
574
|
+
for key, value in values.items()
|
|
575
|
+
}
|
|
576
|
+
for plugin_id, values in entry.plugin_metadata.items()
|
|
577
|
+
},
|
|
578
|
+
"plugin_findings": {
|
|
579
|
+
str(plugin_id): [str(item) for item in values]
|
|
580
|
+
for plugin_id, values in entry.plugin_findings.items()
|
|
581
|
+
},
|
|
582
|
+
"request": {
|
|
583
|
+
"method": entry.request.method,
|
|
584
|
+
"target": entry.request.target,
|
|
585
|
+
"version": entry.request.version,
|
|
586
|
+
"headers": list(entry.request.headers),
|
|
587
|
+
"body_b64": TrafficStore._encode_bytes(entry.request.body),
|
|
588
|
+
"host": entry.request.host,
|
|
589
|
+
"port": entry.request.port,
|
|
590
|
+
"path": entry.request.path,
|
|
591
|
+
},
|
|
592
|
+
"response": {
|
|
593
|
+
"version": entry.response.version,
|
|
594
|
+
"status_code": entry.response.status_code,
|
|
595
|
+
"reason": entry.response.reason,
|
|
596
|
+
"headers": list(entry.response.headers),
|
|
597
|
+
"body_b64": TrafficStore._encode_bytes(entry.response.body),
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
@staticmethod
|
|
602
|
+
def _entry_from_dict(data: dict[str, object]) -> TrafficEntry:
|
|
603
|
+
request = data.get("request", {})
|
|
604
|
+
response = data.get("response", {})
|
|
605
|
+
started_at = TrafficStore._parse_datetime(data.get("started_at"))
|
|
606
|
+
if started_at is None:
|
|
607
|
+
started_at = datetime.now(timezone.utc)
|
|
608
|
+
return TrafficEntry(
|
|
609
|
+
id=int(data["id"]),
|
|
610
|
+
client_addr=str(data.get("client_addr", "-")),
|
|
611
|
+
started_at=started_at,
|
|
612
|
+
finished_at=TrafficStore._parse_datetime(data["finished_at"]),
|
|
613
|
+
duration_ms=data.get("duration_ms"),
|
|
614
|
+
upstream_addr=str(data.get("upstream_addr", "")),
|
|
615
|
+
error=str(data.get("error", "")),
|
|
616
|
+
state=str(data.get("state", "pending")),
|
|
617
|
+
plugin_metadata={
|
|
618
|
+
str(plugin_id): {
|
|
619
|
+
str(key): str(value)
|
|
620
|
+
for key, value in values.items()
|
|
621
|
+
}
|
|
622
|
+
for plugin_id, values in dict(data.get("plugin_metadata", {})).items()
|
|
623
|
+
if isinstance(values, dict) and str(plugin_id).strip()
|
|
624
|
+
},
|
|
625
|
+
plugin_findings={
|
|
626
|
+
str(plugin_id): [str(item) for item in values]
|
|
627
|
+
for plugin_id, values in dict(data.get("plugin_findings", {})).items()
|
|
628
|
+
if isinstance(values, list) and str(plugin_id).strip()
|
|
629
|
+
},
|
|
630
|
+
request=RequestData(
|
|
631
|
+
method=str(request.get("method", "")),
|
|
632
|
+
target=str(request.get("target", "")),
|
|
633
|
+
version=str(request.get("version", "HTTP/1.1")),
|
|
634
|
+
headers=[(str(name), str(value)) for name, value in request.get("headers", [])],
|
|
635
|
+
body=TrafficStore._decode_bytes(request.get("body_b64")),
|
|
636
|
+
host=str(request.get("host", "")),
|
|
637
|
+
port=int(request.get("port", 80)),
|
|
638
|
+
path=str(request.get("path", "/")),
|
|
639
|
+
),
|
|
640
|
+
response=ResponseData(
|
|
641
|
+
version=str(response.get("version", "HTTP/1.1")),
|
|
642
|
+
status_code=int(response.get("status_code", 0)),
|
|
643
|
+
reason=str(response.get("reason", "")),
|
|
644
|
+
headers=[(str(name), str(value)) for name, value in response.get("headers", [])],
|
|
645
|
+
body=TrafficStore._decode_bytes(response.get("body_b64")),
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
@staticmethod
|
|
650
|
+
def _encode_bytes(value: bytes) -> str:
|
|
651
|
+
return base64.b64encode(value).decode("ascii")
|
|
652
|
+
|
|
653
|
+
@staticmethod
|
|
654
|
+
def _decode_bytes(value: object) -> bytes:
|
|
655
|
+
if not value:
|
|
656
|
+
return b""
|
|
657
|
+
return base64.b64decode(str(value).encode("ascii"))
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _plugin_state_from_dict(values: object) -> dict[str, dict[str, object]]:
|
|
661
|
+
if not isinstance(values, dict):
|
|
662
|
+
raise ValueError("plugin_state must be a JSON object")
|
|
663
|
+
normalized: dict[str, dict[str, object]] = {}
|
|
664
|
+
for plugin_id, bucket in values.items():
|
|
665
|
+
plugin_name = str(plugin_id).strip()
|
|
666
|
+
if not plugin_name:
|
|
667
|
+
continue
|
|
668
|
+
if not isinstance(bucket, dict):
|
|
669
|
+
raise ValueError(f"plugin_state for {plugin_name!r} must be a JSON object")
|
|
670
|
+
normalized[plugin_name] = deepcopy(bucket)
|
|
671
|
+
return normalized
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _parse_datetime(value: object) -> datetime | None:
|
|
675
|
+
if value in (None, ""):
|
|
676
|
+
return None
|
|
677
|
+
return datetime.fromisoformat(str(value))
|
|
678
|
+
|
|
679
|
+
@staticmethod
|
|
680
|
+
def _rule_to_dict(rule: MatchReplaceRule) -> dict[str, object]:
|
|
681
|
+
return {
|
|
682
|
+
"enabled": rule.enabled,
|
|
683
|
+
"scope": rule.scope,
|
|
684
|
+
"mode": rule.mode,
|
|
685
|
+
"match": rule.match,
|
|
686
|
+
"replace": rule.replace,
|
|
687
|
+
"description": rule.description,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
@classmethod
|
|
691
|
+
def _rules_from_list(cls, values: object) -> list[MatchReplaceRule]:
|
|
692
|
+
if not isinstance(values, list):
|
|
693
|
+
raise ValueError("match_replace_rules must be a list")
|
|
694
|
+
|
|
695
|
+
rules = [
|
|
696
|
+
MatchReplaceRule(
|
|
697
|
+
enabled=bool(item.get("enabled", True)),
|
|
698
|
+
scope=str(item.get("scope", "request")),
|
|
699
|
+
mode=str(item.get("mode", "literal")),
|
|
700
|
+
match=str(item.get("match", "")),
|
|
701
|
+
replace=str(item.get("replace", "")),
|
|
702
|
+
description=str(item.get("description", "")),
|
|
703
|
+
)
|
|
704
|
+
for item in values
|
|
705
|
+
if isinstance(item, dict)
|
|
706
|
+
]
|
|
707
|
+
cls._validate_match_replace_rules(rules)
|
|
708
|
+
return rules
|
|
709
|
+
|
|
710
|
+
@classmethod
|
|
711
|
+
def _scope_hosts_from_list(cls, values: object) -> list[str]:
|
|
712
|
+
if not isinstance(values, list):
|
|
713
|
+
raise ValueError("scope_hosts must be a list")
|
|
714
|
+
hosts: list[str] = []
|
|
715
|
+
seen: set[str] = set()
|
|
716
|
+
for item in values:
|
|
717
|
+
host = cls._normalize_scope_pattern(str(item))
|
|
718
|
+
if not host or host in seen:
|
|
719
|
+
continue
|
|
720
|
+
hosts.append(host)
|
|
721
|
+
seen.add(host)
|
|
722
|
+
return hosts
|
|
723
|
+
|
|
724
|
+
@staticmethod
|
|
725
|
+
def _keybindings_from_dict(values: object) -> dict[str, str]:
|
|
726
|
+
if not isinstance(values, dict):
|
|
727
|
+
raise ValueError("keybindings must be an object")
|
|
728
|
+
normalized: dict[str, str] = {}
|
|
729
|
+
seen: set[str] = set()
|
|
730
|
+
for action, key in values.items():
|
|
731
|
+
action_name = str(action).strip()
|
|
732
|
+
key_name = str(key)
|
|
733
|
+
if not action_name:
|
|
734
|
+
continue
|
|
735
|
+
if len(key_name) != 1:
|
|
736
|
+
raise ValueError(f"keybinding {action_name!r}: key must be a single character")
|
|
737
|
+
if key_name in seen:
|
|
738
|
+
raise ValueError(f"duplicate keybinding detected for {key_name!r}")
|
|
739
|
+
normalized[action_name] = key_name
|
|
740
|
+
seen.add(key_name)
|
|
741
|
+
return normalized
|
|
742
|
+
|
|
743
|
+
@classmethod
|
|
744
|
+
def _view_filters_to_dict(cls, filters: ViewFilterSettings) -> dict[str, object]:
|
|
745
|
+
normalized = cls._normalize_view_filters(filters)
|
|
746
|
+
return {
|
|
747
|
+
"show_out_of_scope": normalized.show_out_of_scope,
|
|
748
|
+
"query_mode": normalized.query_mode,
|
|
749
|
+
"failure_mode": normalized.failure_mode,
|
|
750
|
+
"body_mode": normalized.body_mode,
|
|
751
|
+
"methods": list(normalized.methods),
|
|
752
|
+
"hidden_methods": list(normalized.hidden_methods),
|
|
753
|
+
"hidden_extensions": list(normalized.hidden_extensions),
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
@classmethod
|
|
757
|
+
def _view_filters_from_dict(cls, values: object) -> ViewFilterSettings:
|
|
758
|
+
if not isinstance(values, dict):
|
|
759
|
+
values = {}
|
|
760
|
+
return cls._normalize_view_filters(
|
|
761
|
+
ViewFilterSettings(
|
|
762
|
+
show_out_of_scope=bool(values.get("show_out_of_scope", False)),
|
|
763
|
+
query_mode=str(values.get("query_mode", "all")),
|
|
764
|
+
failure_mode=str(values.get("failure_mode", "all")),
|
|
765
|
+
body_mode=str(values.get("body_mode", "all")),
|
|
766
|
+
methods=list(values.get("methods", [])) if isinstance(values.get("methods", []), list) else [],
|
|
767
|
+
hidden_methods=(
|
|
768
|
+
list(values.get("hidden_methods", []))
|
|
769
|
+
if isinstance(values.get("hidden_methods", []), list)
|
|
770
|
+
else []
|
|
771
|
+
),
|
|
772
|
+
hidden_extensions=(
|
|
773
|
+
list(values.get("hidden_extensions", []))
|
|
774
|
+
if isinstance(values.get("hidden_extensions", []), list)
|
|
775
|
+
else []
|
|
776
|
+
),
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
@classmethod
|
|
781
|
+
def _normalize_view_filters(cls, filters: ViewFilterSettings) -> ViewFilterSettings:
|
|
782
|
+
query_mode = str(filters.query_mode).strip().lower() or "all"
|
|
783
|
+
if query_mode not in VIEW_FILTER_QUERY_MODES:
|
|
784
|
+
raise ValueError(f"invalid query_mode: {filters.query_mode!r}")
|
|
785
|
+
failure_mode = str(filters.failure_mode).strip().lower() or "all"
|
|
786
|
+
if failure_mode not in VIEW_FILTER_FAILURE_MODES:
|
|
787
|
+
raise ValueError(f"invalid failure_mode: {filters.failure_mode!r}")
|
|
788
|
+
body_mode = str(filters.body_mode).strip().lower() or "all"
|
|
789
|
+
if body_mode not in VIEW_FILTER_BODY_MODES:
|
|
790
|
+
raise ValueError(f"invalid body_mode: {filters.body_mode!r}")
|
|
791
|
+
methods: list[str] = []
|
|
792
|
+
seen_methods: set[str] = set()
|
|
793
|
+
for item in filters.methods:
|
|
794
|
+
method = str(item).strip().upper()
|
|
795
|
+
if not method or method in seen_methods:
|
|
796
|
+
continue
|
|
797
|
+
methods.append(method)
|
|
798
|
+
seen_methods.add(method)
|
|
799
|
+
hidden_methods: list[str] = []
|
|
800
|
+
seen_hidden_methods: set[str] = set()
|
|
801
|
+
for item in filters.hidden_methods:
|
|
802
|
+
method = str(item).strip().upper()
|
|
803
|
+
if not method or method in seen_hidden_methods:
|
|
804
|
+
continue
|
|
805
|
+
hidden_methods.append(method)
|
|
806
|
+
seen_hidden_methods.add(method)
|
|
807
|
+
hidden_extensions: list[str] = []
|
|
808
|
+
seen_extensions: set[str] = set()
|
|
809
|
+
for item in filters.hidden_extensions:
|
|
810
|
+
extension = cls._normalize_extension(str(item))
|
|
811
|
+
if not extension or extension in seen_extensions:
|
|
812
|
+
continue
|
|
813
|
+
hidden_extensions.append(extension)
|
|
814
|
+
seen_extensions.add(extension)
|
|
815
|
+
return ViewFilterSettings(
|
|
816
|
+
show_out_of_scope=bool(filters.show_out_of_scope),
|
|
817
|
+
query_mode=query_mode,
|
|
818
|
+
failure_mode=failure_mode,
|
|
819
|
+
body_mode=body_mode,
|
|
820
|
+
methods=methods,
|
|
821
|
+
hidden_methods=hidden_methods,
|
|
822
|
+
hidden_extensions=hidden_extensions,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
@staticmethod
|
|
826
|
+
def _validate_match_replace_rules(rules: list[MatchReplaceRule]) -> None:
|
|
827
|
+
for index, rule in enumerate(rules, start=1):
|
|
828
|
+
if rule.scope not in {"request", "response", "both"}:
|
|
829
|
+
raise ValueError(f"rule {index}: invalid scope {rule.scope!r}")
|
|
830
|
+
if rule.mode not in {"literal", "regex"}:
|
|
831
|
+
raise ValueError(f"rule {index}: invalid mode {rule.mode!r}")
|
|
832
|
+
if not rule.match:
|
|
833
|
+
raise ValueError(f"rule {index}: match must not be empty")
|
|
834
|
+
if rule.mode == "regex":
|
|
835
|
+
try:
|
|
836
|
+
re.compile(rule.match)
|
|
837
|
+
except re.error as exc:
|
|
838
|
+
raise ValueError(f"rule {index}: invalid regex: {exc}") from exc
|
|
839
|
+
|
|
840
|
+
@staticmethod
|
|
841
|
+
def _normalize_scope_host(value: str) -> str:
|
|
842
|
+
host = value.strip().lower()
|
|
843
|
+
if not host:
|
|
844
|
+
return ""
|
|
845
|
+
if "://" in host:
|
|
846
|
+
host = urlsplit(host).hostname or ""
|
|
847
|
+
else:
|
|
848
|
+
host = host.split("/", 1)[0]
|
|
849
|
+
if host.startswith("."):
|
|
850
|
+
host = host[1:]
|
|
851
|
+
if host.count(":") == 1:
|
|
852
|
+
head, _, tail = host.partition(":")
|
|
853
|
+
if tail.isdigit():
|
|
854
|
+
host = head
|
|
855
|
+
return host.rstrip(".")
|
|
856
|
+
|
|
857
|
+
@classmethod
|
|
858
|
+
def _normalize_scope_pattern(cls, value: str) -> str:
|
|
859
|
+
candidate = value.strip().lower()
|
|
860
|
+
if not candidate:
|
|
861
|
+
return ""
|
|
862
|
+
excluded = candidate.startswith("!")
|
|
863
|
+
if excluded:
|
|
864
|
+
candidate = candidate[1:].strip()
|
|
865
|
+
if not candidate:
|
|
866
|
+
return ""
|
|
867
|
+
if candidate == "*":
|
|
868
|
+
return "!*" if excluded else "*"
|
|
869
|
+
wildcard = candidate.startswith("*.")
|
|
870
|
+
if wildcard:
|
|
871
|
+
candidate = candidate[2:]
|
|
872
|
+
host = cls._normalize_scope_host(candidate)
|
|
873
|
+
if not host:
|
|
874
|
+
return ""
|
|
875
|
+
normalized = f"*.{host}" if wildcard else host
|
|
876
|
+
return f"!{normalized}" if excluded else normalized
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _split_scope_patterns(patterns: list[str]) -> tuple[list[str], list[str]]:
|
|
880
|
+
includes: list[str] = []
|
|
881
|
+
excludes: list[str] = []
|
|
882
|
+
for pattern in patterns:
|
|
883
|
+
if pattern.startswith("!"):
|
|
884
|
+
excludes.append(pattern[1:])
|
|
885
|
+
else:
|
|
886
|
+
includes.append(pattern)
|
|
887
|
+
return includes, excludes
|
|
888
|
+
|
|
889
|
+
@staticmethod
|
|
890
|
+
def _scope_matches(pattern: str, host: str) -> bool:
|
|
891
|
+
if pattern == "*":
|
|
892
|
+
return True
|
|
893
|
+
if pattern.startswith("*."):
|
|
894
|
+
suffix = pattern[2:]
|
|
895
|
+
return host.endswith(f".{suffix}")
|
|
896
|
+
return host == pattern or host.endswith(f".{pattern}")
|
|
897
|
+
|
|
898
|
+
def _host_is_in_scope_locked(self, host: str) -> bool:
|
|
899
|
+
if not self._scope_hosts:
|
|
900
|
+
return True
|
|
901
|
+
normalized_host = self._normalize_scope_host(host)
|
|
902
|
+
if not normalized_host:
|
|
903
|
+
return False
|
|
904
|
+
includes, excludes = self._split_scope_patterns(self._scope_hosts)
|
|
905
|
+
if includes and not any(self._scope_matches(pattern, normalized_host) for pattern in includes):
|
|
906
|
+
return False
|
|
907
|
+
if any(self._scope_matches(pattern, normalized_host) for pattern in excludes):
|
|
908
|
+
return False
|
|
909
|
+
return True
|
|
910
|
+
|
|
911
|
+
@staticmethod
|
|
912
|
+
def _normalize_extension(value: str) -> str:
|
|
913
|
+
extension = value.strip().lower()
|
|
914
|
+
if extension.startswith("."):
|
|
915
|
+
extension = extension[1:]
|
|
916
|
+
return extension
|
|
917
|
+
|
|
918
|
+
@staticmethod
|
|
919
|
+
def _header_value(headers: list[tuple[str, str]], name: str) -> str:
|
|
920
|
+
target = name.lower()
|
|
921
|
+
for header_name, value in headers:
|
|
922
|
+
if header_name.lower() == target:
|
|
923
|
+
return value
|
|
924
|
+
return ""
|
|
925
|
+
|
|
926
|
+
@classmethod
|
|
927
|
+
def _entry_extension_locked(cls, entry: TrafficEntry) -> str:
|
|
928
|
+
request_path = entry.request.path or entry.request.target or ""
|
|
929
|
+
parsed = urlsplit(request_path if "://" in request_path else f"http://placeholder{request_path}")
|
|
930
|
+
path = parsed.path or request_path
|
|
931
|
+
filename = Path(path).name
|
|
932
|
+
suffix = Path(filename).suffix
|
|
933
|
+
if suffix:
|
|
934
|
+
return cls._normalize_extension(suffix)
|
|
935
|
+
|
|
936
|
+
content_type = cls._header_value(entry.response.headers, "Content-Type").split(";", 1)[0].strip().lower()
|
|
937
|
+
return {
|
|
938
|
+
"image/jpeg": "jpg",
|
|
939
|
+
"image/png": "png",
|
|
940
|
+
"image/gif": "gif",
|
|
941
|
+
"image/webp": "webp",
|
|
942
|
+
"image/svg+xml": "svg",
|
|
943
|
+
"application/javascript": "js",
|
|
944
|
+
"text/javascript": "js",
|
|
945
|
+
"text/css": "css",
|
|
946
|
+
"application/json": "json",
|
|
947
|
+
"text/html": "html",
|
|
948
|
+
}.get(content_type, "")
|
|
949
|
+
|
|
950
|
+
@staticmethod
|
|
951
|
+
def _entry_has_query_locked(entry: TrafficEntry) -> bool:
|
|
952
|
+
request_target = entry.request.target or ""
|
|
953
|
+
request_path = entry.request.path or ""
|
|
954
|
+
if "?" in request_target or "?" in request_path:
|
|
955
|
+
return True
|
|
956
|
+
parsed = urlsplit(request_target if "://" in request_target else f"http://placeholder{request_target}")
|
|
957
|
+
return bool(parsed.query)
|
|
958
|
+
|
|
959
|
+
@staticmethod
|
|
960
|
+
def _entry_is_failure_locked(entry: TrafficEntry) -> bool:
|
|
961
|
+
if entry.error or entry.state == "error":
|
|
962
|
+
return True
|
|
963
|
+
return (entry.response.status_code or 0) >= 400
|
|
964
|
+
|
|
965
|
+
@staticmethod
|
|
966
|
+
def _entry_has_body_locked(entry: TrafficEntry) -> bool:
|
|
967
|
+
return bool(entry.request.body or entry.response.body)
|
|
968
|
+
|
|
969
|
+
def _entry_visible_locked(self, entry: TrafficEntry, filters: ViewFilterSettings | None = None) -> bool:
|
|
970
|
+
active_filters = filters or self._view_filters
|
|
971
|
+
if self._scope_hosts and not active_filters.show_out_of_scope:
|
|
972
|
+
host = self._normalize_scope_host(entry.request.host or entry.summary_host)
|
|
973
|
+
if not self._host_is_in_scope_locked(host):
|
|
974
|
+
return False
|
|
975
|
+
if active_filters.query_mode == "with_query" and not self._entry_has_query_locked(entry):
|
|
976
|
+
return False
|
|
977
|
+
if active_filters.query_mode == "without_query" and self._entry_has_query_locked(entry):
|
|
978
|
+
return False
|
|
979
|
+
if active_filters.failure_mode == "failures" and not self._entry_is_failure_locked(entry):
|
|
980
|
+
return False
|
|
981
|
+
if active_filters.failure_mode == "hide_failures" and self._entry_is_failure_locked(entry):
|
|
982
|
+
return False
|
|
983
|
+
if active_filters.failure_mode == "client_errors" and not (400 <= (entry.response.status_code or 0) <= 499):
|
|
984
|
+
return False
|
|
985
|
+
if active_filters.failure_mode == "server_errors" and not (500 <= (entry.response.status_code or 0) <= 599):
|
|
986
|
+
return False
|
|
987
|
+
if active_filters.failure_mode == "connection_errors" and not (entry.error or entry.state == "error"):
|
|
988
|
+
return False
|
|
989
|
+
if active_filters.body_mode == "with_body" and not self._entry_has_body_locked(entry):
|
|
990
|
+
return False
|
|
991
|
+
if active_filters.body_mode == "without_body" and self._entry_has_body_locked(entry):
|
|
992
|
+
return False
|
|
993
|
+
request_method = entry.request.method.upper()
|
|
994
|
+
if active_filters.methods and request_method not in active_filters.methods:
|
|
995
|
+
return False
|
|
996
|
+
if request_method in active_filters.hidden_methods:
|
|
997
|
+
return False
|
|
998
|
+
extension = self._entry_extension_locked(entry)
|
|
999
|
+
if extension and extension in active_filters.hidden_extensions:
|
|
1000
|
+
return False
|
|
1001
|
+
return True
|