pypproxy 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 (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import ssl
4
+ import threading
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class ClientCert:
10
+ name: str
11
+ cert_path: str
12
+ key_path: str
13
+ host_pattern: str = "*" # glob pattern, "*" means all hosts
14
+
15
+ def matches(self, host: str) -> bool:
16
+ import fnmatch
17
+
18
+ return fnmatch.fnmatch(host, self.host_pattern)
19
+
20
+ def to_dict(self) -> dict:
21
+ return {
22
+ "name": self.name,
23
+ "cert_path": self.cert_path,
24
+ "key_path": self.key_path,
25
+ "host_pattern": self.host_pattern,
26
+ }
27
+
28
+
29
+ class ClientCertManager:
30
+ def __init__(self) -> None:
31
+ self._certs: list[ClientCert] = []
32
+ self._lock = threading.Lock()
33
+
34
+ def add(self, cert: ClientCert) -> None:
35
+ with self._lock:
36
+ self._certs.append(cert)
37
+
38
+ def remove(self, name: str) -> None:
39
+ with self._lock:
40
+ self._certs = [c for c in self._certs if c.name != name]
41
+
42
+ def list(self) -> list[ClientCert]:
43
+ with self._lock:
44
+ return list(self._certs)
45
+
46
+ def get_for_host(self, host: str) -> ClientCert | None:
47
+ with self._lock:
48
+ for cert in self._certs:
49
+ if cert.matches(host):
50
+ return cert
51
+ return None
52
+
53
+ def ssl_context_for(self, host: str) -> ssl.SSLContext | None:
54
+ cert = self.get_for_host(host)
55
+ if cert is None:
56
+ return None
57
+ try:
58
+ ctx = ssl.create_default_context()
59
+ ctx.load_cert_chain(cert.cert_path, cert.key_path)
60
+ return ctx
61
+ except Exception:
62
+ return None
63
+
64
+ def to_list(self) -> list[dict]:
65
+ return [c.to_dict() for c in self.list()]
pypproxy/codec.py ADDED
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+ import logging
6
+ import struct
7
+ import zlib
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ # ---- content-encoding decode/encode ----
13
+
14
+
15
+ def decode_body(body: bytes, content_encoding: str) -> tuple[bytes, str]:
16
+ """Decompress body according to Content-Encoding.
17
+ Returns (decoded_bytes, applied_encoding). Falls back to original on error.
18
+ """
19
+ if not body or not content_encoding:
20
+ return body, ""
21
+ encoding = content_encoding.lower().strip()
22
+ try:
23
+ if encoding == "gzip":
24
+ return gzip.decompress(body), "gzip"
25
+ if encoding == "br":
26
+ import brotli # type: ignore[import-untyped]
27
+
28
+ return brotli.decompress(body), "br"
29
+ if encoding in ("deflate", "zlib"):
30
+ try:
31
+ return zlib.decompress(body), encoding
32
+ except zlib.error:
33
+ return zlib.decompress(body, -zlib.MAX_WBITS), encoding
34
+ except Exception as e:
35
+ logger.debug("decode_body failed (encoding=%s): %s", encoding, e)
36
+ return body, ""
37
+
38
+
39
+ def encode_body(body: bytes, encoding: str) -> bytes:
40
+ if not encoding:
41
+ return body
42
+ try:
43
+ if encoding == "gzip":
44
+ return gzip.compress(body)
45
+ if encoding == "br":
46
+ import brotli # type: ignore[import-untyped]
47
+
48
+ return brotli.compress(body)
49
+ if encoding in ("deflate", "zlib"):
50
+ return zlib.compress(body)
51
+ except Exception as e:
52
+ logger.debug("encode_body failed (encoding=%s): %s", encoding, e)
53
+ return body
54
+
55
+
56
+ # ---- binary protocol decode ----
57
+
58
+
59
+ def decode_msgpack(data: bytes) -> str:
60
+ """Decode MessagePack bytes to a pretty-printed JSON string."""
61
+ try:
62
+ import msgpack
63
+
64
+ obj = msgpack.unpackb(data, raw=False, strict_map_key=False)
65
+ return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
66
+ except Exception as e:
67
+ return f"<msgpack decode error: {e}>"
68
+
69
+
70
+ def decode_cbor(data: bytes) -> str:
71
+ """Decode CBOR bytes to a pretty-printed JSON string."""
72
+ try:
73
+ import cbor2
74
+
75
+ obj = cbor2.loads(data)
76
+ return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
77
+ except Exception as e:
78
+ return f"<cbor decode error: {e}>"
79
+
80
+
81
+ def decode_protobuf_raw(data: bytes) -> str:
82
+ """Decode raw protobuf bytes using wire-type heuristics (no schema needed)."""
83
+ try:
84
+ return _decode_proto_fields(data, indent=0)
85
+ except Exception as e:
86
+ return f"<protobuf decode error: {e}>"
87
+
88
+
89
+ def _decode_proto_fields(data: bytes, indent: int) -> str:
90
+ lines: list[str] = []
91
+ pos = 0
92
+ pad = " " * indent
93
+ while pos < len(data):
94
+ if pos >= len(data):
95
+ break
96
+ # read varint for tag+wire_type
97
+ tag_wire, pos = _read_varint(data, pos)
98
+ if tag_wire is None:
99
+ break
100
+ field_num = tag_wire >> 3
101
+ wire_type = tag_wire & 0x7
102
+ if wire_type == 0: # varint
103
+ val, pos = _read_varint(data, pos)
104
+ lines.append(f"{pad}field {field_num} (varint): {val}")
105
+ elif wire_type == 1: # 64-bit
106
+ if pos + 8 > len(data):
107
+ break
108
+ val = struct.unpack_from("<Q", data, pos)[0]
109
+ pos += 8
110
+ lines.append(f"{pad}field {field_num} (64-bit): {val}")
111
+ elif wire_type == 2: # length-delimited
112
+ length, pos = _read_varint(data, pos)
113
+ if length is None or pos + length > len(data):
114
+ break
115
+ payload = data[pos : pos + length]
116
+ pos += length
117
+ # try nested message
118
+ try:
119
+ nested = _decode_proto_fields(payload, indent + 1)
120
+ lines.append(f"{pad}field {field_num} (embedded):")
121
+ lines.append(nested)
122
+ except Exception:
123
+ try:
124
+ lines.append(f"{pad}field {field_num} (string): {payload.decode()!r}")
125
+ except Exception:
126
+ lines.append(f"{pad}field {field_num} (bytes): {payload.hex()}")
127
+ elif wire_type == 5: # 32-bit
128
+ if pos + 4 > len(data):
129
+ break
130
+ val = struct.unpack_from("<I", data, pos)[0]
131
+ pos += 4
132
+ lines.append(f"{pad}field {field_num} (32-bit): {val}")
133
+ else:
134
+ lines.append(f"{pad}field {field_num} (unknown wire type {wire_type})")
135
+ break
136
+ return "\n".join(lines)
137
+
138
+
139
+ def _read_varint(data: bytes, pos: int) -> tuple[int | None, int]:
140
+ result, shift = 0, 0
141
+ while pos < len(data):
142
+ b = data[pos]
143
+ pos += 1
144
+ result |= (b & 0x7F) << shift
145
+ shift += 7
146
+ if not (b & 0x80):
147
+ return result, pos
148
+ return None, pos
149
+
150
+
151
+ def sniff_content_type(body: bytes, content_type: str) -> str:
152
+ """Return a hint for how to display the body: json, xml, proto, msgpack, cbor, text, binary."""
153
+ ct = content_type.lower()
154
+ if "json" in ct:
155
+ return "json"
156
+ if "xml" in ct or "html" in ct:
157
+ return "xml"
158
+ if "grpc" in ct or "protobuf" in ct:
159
+ return "proto"
160
+ if "msgpack" in ct:
161
+ return "msgpack"
162
+ if "cbor" in ct:
163
+ return "cbor"
164
+ if not body:
165
+ return "text"
166
+ # heuristic: try JSON
167
+ try:
168
+ json.loads(body.decode("utf-8", errors="strict"))
169
+ return "json"
170
+ except Exception:
171
+ pass
172
+ # check binary
173
+ printable = sum(1 for b in body[:256] if 0x20 <= b < 0x7F or b in (9, 10, 13))
174
+ if len(body) > 0 and printable / min(len(body), 256) < 0.7:
175
+ return "binary"
176
+ return "text"
File without changes
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ @dataclass
10
+ class ProxyConfig:
11
+ addr: str = "0.0.0.0"
12
+ port: int = 8080
13
+ ignore: list[str] = field(default_factory=list)
14
+ max_body: int = 1024 * 1024 # 1MB
15
+
16
+
17
+ @dataclass
18
+ class CAConfig:
19
+ cert_path: str = ""
20
+ key_path: str = ""
21
+
22
+
23
+ @dataclass
24
+ class UIConfig:
25
+ addr: str = "0.0.0.0"
26
+ port: int = 8081
27
+
28
+
29
+ @dataclass
30
+ class ScriptConfig:
31
+ path: str = ""
32
+
33
+
34
+ @dataclass
35
+ class Config:
36
+ proxy: ProxyConfig = field(default_factory=ProxyConfig)
37
+ ca: CAConfig = field(default_factory=CAConfig)
38
+ ui: UIConfig = field(default_factory=UIConfig)
39
+ script: ScriptConfig = field(default_factory=ScriptConfig)
40
+
41
+ @classmethod
42
+ def default(cls) -> Config:
43
+ cfg = cls()
44
+ home = Path.home()
45
+ cfg.ca.cert_path = str(home / ".paxy" / "ca-cert.pem")
46
+ cfg.ca.key_path = str(home / ".paxy" / "ca-key.pem")
47
+ return cfg
48
+
49
+ @classmethod
50
+ def load(cls, path: str) -> Config:
51
+ cfg = cls.default()
52
+ p = Path(path)
53
+ if not p.exists():
54
+ return cfg
55
+ with p.open() as f:
56
+ data = yaml.safe_load(f) or {}
57
+
58
+ if proxy := data.get("proxy"):
59
+ if "addr" in proxy:
60
+ cfg.proxy.addr = proxy["addr"]
61
+ if "port" in proxy:
62
+ cfg.proxy.port = int(proxy["port"])
63
+ if "ignore" in proxy:
64
+ cfg.proxy.ignore = proxy["ignore"]
65
+ if "max_body" in proxy:
66
+ cfg.proxy.max_body = int(proxy["max_body"])
67
+
68
+ if ca := data.get("ca"):
69
+ if "cert_path" in ca:
70
+ cfg.ca.cert_path = str(Path(ca["cert_path"]).resolve())
71
+ if "key_path" in ca:
72
+ cfg.ca.key_path = str(Path(ca["key_path"]).resolve())
73
+
74
+ if ui := data.get("ui"):
75
+ if "addr" in ui:
76
+ cfg.ui.addr = ui["addr"]
77
+ if "port" in ui:
78
+ cfg.ui.port = int(ui["port"])
79
+
80
+ if (script := data.get("script")) and "path" in script:
81
+ cfg.script.path = str(Path(script["path"]).resolve())
82
+
83
+ return cfg
84
+
85
+ def save(self, path: str) -> None:
86
+ data = {
87
+ "proxy": {
88
+ "addr": self.proxy.addr,
89
+ "port": self.proxy.port,
90
+ "ignore": self.proxy.ignore,
91
+ "max_body": self.proxy.max_body,
92
+ },
93
+ "ca": {
94
+ "cert_path": self.ca.cert_path,
95
+ "key_path": self.ca.key_path,
96
+ },
97
+ "ui": {
98
+ "addr": self.ui.addr,
99
+ "port": self.ui.port,
100
+ },
101
+ "script": {
102
+ "path": self.script.path,
103
+ },
104
+ }
105
+ with open(path, "w") as f:
106
+ yaml.dump(data, f, default_flow_style=False)
File without changes
pypproxy/dns/server.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ import struct
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ DNS_PORT = 53153 # unprivileged default; use 53 with sudo
11
+
12
+
13
+ class DNSServer:
14
+ """
15
+ Minimal DNS server that spoofs configured domains to a target IP.
16
+ All other queries are forwarded to an upstream resolver.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ overrides: dict[str, str],
22
+ upstream: str = "8.8.8.8",
23
+ port: int = DNS_PORT,
24
+ ) -> None:
25
+ self._overrides = {k.lower().rstrip("."): v for k, v in overrides.items()}
26
+ self._upstream = upstream
27
+ self._port = port
28
+ self._transport: asyncio.BaseTransport | None = None
29
+
30
+ async def start(self) -> None:
31
+ loop = asyncio.get_event_loop()
32
+ self._transport, _ = await loop.create_datagram_endpoint(
33
+ lambda: _DNSProtocol(self._overrides, self._upstream),
34
+ local_addr=("0.0.0.0", self._port),
35
+ )
36
+ logger.info("DNS server listening on UDP :%d (upstream: %s)", self._port, self._upstream)
37
+
38
+ def stop(self) -> None:
39
+ if self._transport:
40
+ self._transport.close()
41
+
42
+ def set_overrides(self, overrides: dict[str, str]) -> None:
43
+ self._overrides = {k.lower().rstrip("."): v for k, v in overrides.items()}
44
+ if self._transport:
45
+ proto = self._transport.get_protocol()
46
+ if hasattr(proto, "overrides"):
47
+ proto.overrides = self._overrides # type: ignore[attr-defined]
48
+
49
+
50
+ class _DNSProtocol(asyncio.DatagramProtocol):
51
+ def __init__(self, overrides: dict[str, str], upstream: str) -> None:
52
+ self.overrides = overrides
53
+ self._upstream = upstream
54
+ self._transport: asyncio.DatagramTransport | None = None
55
+
56
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
57
+ self._transport = transport # type: ignore[assignment]
58
+
59
+ def datagram_received(self, data: bytes, addr: tuple) -> None:
60
+ asyncio.ensure_future(self._handle(data, addr))
61
+
62
+ async def _handle(self, data: bytes, addr: tuple) -> None:
63
+ try:
64
+ name, qtype = _parse_query(data)
65
+ except Exception:
66
+ return
67
+
68
+ name_lower = name.lower().rstrip(".")
69
+ logger.debug("DNS query: %s (type=%d) from %s", name, qtype, addr)
70
+
71
+ if qtype == 1 and name_lower in self.overrides:
72
+ ip = self.overrides[name_lower]
73
+ logger.info("DNS spoof: %s -> %s", name, ip)
74
+ reply = _build_reply(data, name, ip)
75
+ if self._transport:
76
+ self._transport.sendto(reply, addr)
77
+ return
78
+
79
+ # Forward to upstream
80
+ try:
81
+ reply = await _forward(data, self._upstream)
82
+ if self._transport and reply:
83
+ self._transport.sendto(reply, addr)
84
+ except Exception as e:
85
+ logger.debug("DNS forward error: %s", e)
86
+
87
+
88
+ def _parse_query(data: bytes) -> tuple[str, int]:
89
+ offset = 12 # skip header
90
+ labels: list[str] = []
91
+ while offset < len(data):
92
+ length = data[offset]
93
+ offset += 1
94
+ if length == 0:
95
+ break
96
+ labels.append(data[offset : offset + length].decode())
97
+ offset += length
98
+ qtype = struct.unpack_from(">H", data, offset)[0]
99
+ return ".".join(labels), qtype
100
+
101
+
102
+ def _build_reply(query: bytes, name: str, ip: str) -> bytes:
103
+ # Header: copy transaction ID, set QR=1, AA=1, QDCOUNT=1, ANCOUNT=1
104
+ tid = query[:2]
105
+ flags = b"\x81\x80"
106
+ counts = b"\x00\x01\x00\x01\x00\x00\x00\x00"
107
+ header = tid + flags + counts
108
+
109
+ # Question section (copy from query, up to and including QTYPE+QCLASS)
110
+ question_end = 12
111
+ while question_end < len(query):
112
+ length = query[question_end]
113
+ question_end += 1
114
+ if length == 0:
115
+ question_end += 4 # QTYPE + QCLASS
116
+ break
117
+ question_end += length
118
+ question = query[12:question_end]
119
+
120
+ # Answer: name pointer, A record, TTL=60, RDATA=ip
121
+ answer = b"\xc0\x0c" # pointer to question name
122
+ answer += b"\x00\x01" # TYPE A
123
+ answer += b"\x00\x01" # CLASS IN
124
+ answer += b"\x00\x00\x00\x3c" # TTL 60
125
+ answer += b"\x00\x04" # RDLENGTH 4
126
+ answer += socket.inet_aton(ip)
127
+
128
+ return header + question + answer
129
+
130
+
131
+ async def _forward(data: bytes, upstream: str) -> bytes | None:
132
+ loop = asyncio.get_event_loop()
133
+ fut: asyncio.Future[bytes] = loop.create_future()
134
+
135
+ class _Forwarder(asyncio.DatagramProtocol):
136
+ def datagram_received(self, d: bytes, _: tuple) -> None:
137
+ if not fut.done():
138
+ fut.set_result(d)
139
+
140
+ def error_received(self, exc: Exception) -> None:
141
+ if not fut.done():
142
+ fut.set_exception(exc)
143
+
144
+ transport, _ = await loop.create_datagram_endpoint(_Forwarder, remote_addr=(upstream, 53))
145
+ transport.sendto(data)
146
+ try:
147
+ return await asyncio.wait_for(fut, timeout=3)
148
+ finally:
149
+ transport.close()
File without changes
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from typing import Any
6
+
7
+ from pypproxy.rule.rule import RuleManager
8
+ from pypproxy.store.models import Entry
9
+
10
+
11
+ def export_entries(entries: list[Entry]) -> str:
12
+ """Export entries to JSON string."""
13
+ return json.dumps(
14
+ [e.to_dict() for e in entries],
15
+ indent=2,
16
+ ensure_ascii=False,
17
+ )
18
+
19
+
20
+ def export_rules(rules: RuleManager) -> str:
21
+ """Export rules to JSON string."""
22
+ return json.dumps(
23
+ [r.to_dict() for r in rules.list()],
24
+ indent=2,
25
+ ensure_ascii=False,
26
+ )
27
+
28
+
29
+ def export_all(entries: list[Entry], rules: RuleManager) -> str:
30
+ """Export everything to a single JSON string."""
31
+ return json.dumps(
32
+ {
33
+ "version": 1,
34
+ "entries": [e.to_dict() for e in entries],
35
+ "rules": [r.to_dict() for r in rules.list()],
36
+ },
37
+ indent=2,
38
+ ensure_ascii=False,
39
+ )
40
+
41
+
42
+ def import_rules(data: str, rules: RuleManager) -> int:
43
+ """Import rules from JSON string. Returns count of imported rules."""
44
+ from pypproxy.rule.rule import Rule
45
+
46
+ parsed: list[dict[str, Any]] = json.loads(data)
47
+ if isinstance(parsed, dict):
48
+ parsed = parsed.get("rules", [])
49
+ count = 0
50
+ for item in parsed:
51
+ rule = Rule.from_dict(item)
52
+ rules.add(rule)
53
+ count += 1
54
+ return count
55
+
56
+
57
+ def export_har(entries: list[Entry]) -> str:
58
+ """Export entries in HAR (HTTP Archive) format."""
59
+ har_entries = []
60
+ for e in entries:
61
+ req_headers = [{"name": k, "value": ", ".join(v)} for k, v in e.req_headers.items()]
62
+ resp_headers = [{"name": k, "value": ", ".join(v)} for k, v in e.resp_headers.items()]
63
+ body_text = ""
64
+ if e.resp_body:
65
+ try:
66
+ body_text = e.resp_body.decode("utf-8", errors="replace")
67
+ except Exception:
68
+ body_text = base64.b64encode(e.resp_body).decode()
69
+
70
+ url = f"{e.scheme}://{e.host}{e.path}"
71
+ if e.query:
72
+ url += f"?{e.query}"
73
+
74
+ har_entries.append(
75
+ {
76
+ "startedDateTime": e.created_at.isoformat(),
77
+ "time": e.duration_ms,
78
+ "request": {
79
+ "method": e.method,
80
+ "url": url,
81
+ "httpVersion": "HTTP/1.1",
82
+ "headers": req_headers,
83
+ "queryString": [],
84
+ "cookies": [],
85
+ "headersSize": -1,
86
+ "bodySize": len(e.req_body),
87
+ "postData": {
88
+ "mimeType": e.req_headers.get("content-type", [""])[0],
89
+ "text": e.req_body.decode("utf-8", errors="replace") if e.req_body else "",
90
+ },
91
+ },
92
+ "response": {
93
+ "status": e.status_code,
94
+ "statusText": "",
95
+ "httpVersion": "HTTP/1.1",
96
+ "headers": resp_headers,
97
+ "cookies": [],
98
+ "content": {
99
+ "size": len(e.resp_body),
100
+ "mimeType": e.resp_headers.get("content-type", [""])[0],
101
+ "text": body_text,
102
+ },
103
+ "redirectURL": "",
104
+ "headersSize": -1,
105
+ "bodySize": len(e.resp_body),
106
+ },
107
+ "cache": {},
108
+ "timings": {"send": 0, "wait": e.duration_ms, "receive": 0},
109
+ }
110
+ )
111
+
112
+ return json.dumps(
113
+ {
114
+ "log": {
115
+ "version": "1.2",
116
+ "creator": {"name": "paxy", "version": "0.1.0"},
117
+ "entries": har_entries,
118
+ }
119
+ },
120
+ indent=2,
121
+ ensure_ascii=False,
122
+ )