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,142 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @dataclass
11
+ class PendingRequest:
12
+ id: int
13
+ method: str
14
+ scheme: str
15
+ host: str
16
+ path: str
17
+ headers: dict[str, list[str]]
18
+ body: bytes
19
+ # modified versions (user edits)
20
+ edited_headers: dict[str, list[str]] = field(default_factory=dict)
21
+ edited_body: bytes = b""
22
+ _event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
23
+ _decision: str = "forward" # forward | drop
24
+
25
+ def to_dict(self) -> dict:
26
+ import base64
27
+
28
+ return {
29
+ "id": self.id,
30
+ "method": self.method,
31
+ "scheme": self.scheme,
32
+ "host": self.host,
33
+ "path": self.path,
34
+ "headers": self.headers,
35
+ "body": base64.b64encode(self.body).decode() if self.body else "",
36
+ }
37
+
38
+
39
+ class InterceptManager:
40
+ """Controls whether requests are paused for manual review."""
41
+
42
+ def __init__(self) -> None:
43
+ self._enabled = False
44
+ self._pending: dict[int, PendingRequest] = {}
45
+ self._counter = 0
46
+ self._lock = asyncio.Lock()
47
+ self._subscribers: list[asyncio.Queue] = []
48
+
49
+ @property
50
+ def enabled(self) -> bool:
51
+ return self._enabled
52
+
53
+ def set_enabled(self, value: bool) -> None:
54
+ self._enabled = value
55
+ if not value:
56
+ # release all pending requests
57
+ for req in list(self._pending.values()):
58
+ req._decision = "forward"
59
+ req._event.set()
60
+
61
+ async def intercept(
62
+ self,
63
+ method: str,
64
+ scheme: str,
65
+ host: str,
66
+ path: str,
67
+ headers: dict[str, list[str]],
68
+ body: bytes,
69
+ ) -> tuple[dict[str, list[str]], bytes, bool]:
70
+ """Pause the request for manual review.
71
+
72
+ Returns (headers, body, drop) where drop=True means the request should be blocked.
73
+ If interception is disabled, returns immediately with original values.
74
+ """
75
+ if not self._enabled:
76
+ return headers, body, False
77
+
78
+ async with self._lock:
79
+ self._counter += 1
80
+ req_id = self._counter
81
+
82
+ req = PendingRequest(
83
+ id=req_id,
84
+ method=method,
85
+ scheme=scheme,
86
+ host=host,
87
+ path=path,
88
+ headers=dict(headers),
89
+ body=body,
90
+ edited_headers=dict(headers),
91
+ edited_body=body,
92
+ )
93
+
94
+ async with self._lock:
95
+ self._pending[req_id] = req
96
+ self._notify(req)
97
+
98
+ logger.debug("intercept: waiting for decision on %s %s%s", method, host, path)
99
+ await req._event.wait()
100
+
101
+ async with self._lock:
102
+ self._pending.pop(req_id, None)
103
+
104
+ drop = req._decision == "drop"
105
+ return req.edited_headers, req.edited_body, drop
106
+
107
+ def forward(self, req_id: int, headers: dict | None = None, body: bytes | None = None) -> None:
108
+ req = self._pending.get(req_id)
109
+ if req:
110
+ if headers is not None:
111
+ req.edited_headers = headers
112
+ if body is not None:
113
+ req.edited_body = body
114
+ req._decision = "forward"
115
+ req._event.set()
116
+
117
+ def drop(self, req_id: int) -> None:
118
+ req = self._pending.get(req_id)
119
+ if req:
120
+ req._decision = "drop"
121
+ req._event.set()
122
+
123
+ def list_pending(self) -> list[PendingRequest]:
124
+ return list(self._pending.values())
125
+
126
+ def subscribe(self) -> asyncio.Queue:
127
+ q: asyncio.Queue = asyncio.Queue(maxsize=64)
128
+ self._subscribers.append(q)
129
+ return q
130
+
131
+ def unsubscribe(self, q: asyncio.Queue) -> None:
132
+ import contextlib
133
+
134
+ with contextlib.suppress(ValueError):
135
+ self._subscribers.remove(q)
136
+
137
+ def _notify(self, req: PendingRequest) -> None:
138
+ import contextlib
139
+
140
+ for q in self._subscribers:
141
+ with contextlib.suppress(asyncio.QueueFull):
142
+ q.put_nowait(req)
File without changes
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from pypproxy.rule.rule import Action, MatchContext, Modification, RuleManager
6
+ from pypproxy.store.models import Entry
7
+ from pypproxy.store.scope import ScopeManager
8
+ from pypproxy.store.store import Store
9
+
10
+
11
+ class Interceptor:
12
+ def __init__(
13
+ self,
14
+ rules: RuleManager,
15
+ store: Store,
16
+ scope: ScopeManager | None = None,
17
+ ) -> None:
18
+ self._rules = rules
19
+ self._store = store
20
+ self._scope = scope
21
+
22
+ def process_request(
23
+ self,
24
+ method: str,
25
+ scheme: str,
26
+ host: str,
27
+ path: str,
28
+ query: str,
29
+ headers: dict[str, list[str]],
30
+ body: bytes,
31
+ ) -> tuple[Entry, bool]:
32
+ # Scope check — skip recording if host is out of scope
33
+ if self._scope and not self._scope.is_in_scope(host):
34
+ entry = Entry(
35
+ method=method,
36
+ scheme=scheme,
37
+ host=host,
38
+ path=path,
39
+ query=query,
40
+ req_headers=dict(headers),
41
+ req_body=body,
42
+ protocol=scheme,
43
+ )
44
+ return entry, False # pass through without recording
45
+
46
+ entry = Entry(
47
+ method=method,
48
+ scheme=scheme,
49
+ host=host,
50
+ path=path,
51
+ query=query,
52
+ req_headers=dict(headers),
53
+ req_body=body,
54
+ protocol=scheme,
55
+ )
56
+
57
+ # GraphQL detection
58
+ from pypproxy.graphql.detector import (
59
+ extract_operation_name,
60
+ extract_operation_type,
61
+ is_graphql,
62
+ parse_operation,
63
+ )
64
+
65
+ if is_graphql(entry):
66
+ op = parse_operation(body)
67
+ entry.graphql_op_type = extract_operation_type(op["query"])
68
+ entry.graphql_operation = extract_operation_name(op["query"]) or op.get(
69
+ "operationName", ""
70
+ )
71
+ entry.tags.append("graphql")
72
+ entry.protocol = "graphql"
73
+
74
+ ctx = MatchContext(
75
+ method=method,
76
+ host=host,
77
+ path=path,
78
+ headers=headers,
79
+ body=body,
80
+ )
81
+
82
+ rule = self._rules.match(ctx)
83
+ blocked = False
84
+
85
+ if rule:
86
+ if rule.action == Action.BLOCK:
87
+ entry.tags.append("blocked")
88
+ blocked = True
89
+ elif rule.action == Action.MODIFY:
90
+ headers, body = _apply_request_mods(headers, body, rule.modifications)
91
+ entry.req_headers = dict(headers)
92
+ entry.req_body = body
93
+ entry.modified = True
94
+ elif rule.action == Action.REDIRECT:
95
+ entry.tags.append("redirected")
96
+
97
+ self._store.add(entry)
98
+ return entry, blocked
99
+
100
+ def process_response(
101
+ self,
102
+ entry: Entry,
103
+ status_code: int,
104
+ headers: dict[str, list[str]],
105
+ body: bytes,
106
+ start_time: float,
107
+ ) -> tuple[dict[str, list[str]], bytes]:
108
+ entry.status_code = status_code
109
+ entry.resp_headers = dict(headers)
110
+ entry.resp_body = body
111
+ entry.duration_ms = int((time.monotonic() - start_time) * 1000)
112
+
113
+ ctx = MatchContext(
114
+ method=entry.method,
115
+ host=entry.host,
116
+ path=entry.path,
117
+ headers=headers,
118
+ body=body,
119
+ )
120
+
121
+ rule = self._rules.match(ctx)
122
+ if rule and rule.action == Action.MODIFY:
123
+ headers, body = _apply_response_mods(headers, body, rule.modifications)
124
+ entry.resp_headers = dict(headers)
125
+ entry.resp_body = body
126
+ entry.modified = True
127
+
128
+ self._store.update(entry)
129
+ return headers, body
130
+
131
+
132
+ def _apply_request_mods(
133
+ headers: dict[str, list[str]],
134
+ body: bytes,
135
+ mods: list[Modification],
136
+ ) -> tuple[dict[str, list[str]], bytes]:
137
+ headers = dict(headers)
138
+ for m in mods:
139
+ if m.target == "req_header":
140
+ headers = _apply_header_mod(headers, m)
141
+ elif m.target == "req_body" and m.operation == "replace":
142
+ body = m.value.encode()
143
+ return headers, body
144
+
145
+
146
+ def _apply_response_mods(
147
+ headers: dict[str, list[str]],
148
+ body: bytes,
149
+ mods: list[Modification],
150
+ ) -> tuple[dict[str, list[str]], bytes]:
151
+ headers = dict(headers)
152
+ for m in mods:
153
+ if m.target == "resp_header":
154
+ headers = _apply_header_mod(headers, m)
155
+ elif m.target == "resp_body":
156
+ if m.operation == "replace":
157
+ body = m.value.encode()
158
+ elif m.operation == "find_replace":
159
+ body = body.replace(m.find.encode(), m.replace.encode())
160
+ return headers, body
161
+
162
+
163
+ def _apply_header_mod(headers: dict[str, list[str]], m: Modification) -> dict[str, list[str]]:
164
+ h = {k.lower(): v for k, v in headers.items()}
165
+ key = m.key.lower()
166
+ if m.operation == "set":
167
+ h[key] = [m.value]
168
+ elif m.operation == "delete":
169
+ h.pop(key, None)
170
+ elif m.operation == "append":
171
+ h.setdefault(key, []).append(m.value)
172
+ return h
File without changes
pypproxy/proto/grpc.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import struct
5
+ from typing import NamedTuple
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class GrpcFrame(NamedTuple):
11
+ compressed: bool
12
+ data: bytes
13
+
14
+
15
+ def is_grpc(headers: dict) -> bool:
16
+ ct = headers.get("content-type", [""])[0]
17
+ return ct.startswith("application/grpc")
18
+
19
+
20
+ def decode_frames(data: bytes) -> list[GrpcFrame]:
21
+ frames: list[GrpcFrame] = []
22
+ offset = 0
23
+ while offset + 5 <= len(data):
24
+ compressed = bool(data[offset])
25
+ length = struct.unpack_from(">I", data, offset + 1)[0]
26
+ offset += 5
27
+ if offset + length > len(data):
28
+ break
29
+ frames.append(GrpcFrame(compressed=compressed, data=data[offset : offset + length]))
30
+ offset += length
31
+ return frames
32
+
33
+
34
+ def encode_frame(frame: GrpcFrame) -> bytes:
35
+ header = struct.pack(">BI", int(frame.compressed), len(frame.data))
36
+ return header + frame.data
37
+
38
+
39
+ def log_frames(entry_id: int, direction: str, data: bytes) -> None:
40
+ for i, frame in enumerate(decode_frames(data)):
41
+ logger.debug(
42
+ "grpc frame entry=%d dir=%s index=%d compressed=%s len=%d",
43
+ entry_id,
44
+ direction,
45
+ i,
46
+ frame.compressed,
47
+ len(frame.data),
48
+ )
pypproxy/proto/mqtt.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import struct
5
+ from typing import NamedTuple
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ PACKET_TYPES = {
10
+ 1: "CONNECT",
11
+ 2: "CONNACK",
12
+ 3: "PUBLISH",
13
+ 4: "PUBACK",
14
+ 5: "PUBREC",
15
+ 6: "PUBREL",
16
+ 7: "PUBCOMP",
17
+ 8: "SUBSCRIBE",
18
+ 9: "SUBACK",
19
+ 10: "UNSUBSCRIBE",
20
+ 11: "UNSUBACK",
21
+ 12: "PINGREQ",
22
+ 13: "PINGRESP",
23
+ 14: "DISCONNECT",
24
+ }
25
+
26
+
27
+ class MQTTFrame(NamedTuple):
28
+ packet_type: int
29
+ packet_name: str
30
+ flags: int
31
+ payload: bytes
32
+ topic: str
33
+ qos: int
34
+
35
+
36
+ def is_mqtt(data: bytes) -> bool:
37
+ """Heuristic: check if data looks like an MQTT CONNECT packet."""
38
+ if len(data) < 10:
39
+ return False
40
+ packet_type = (data[0] >> 4) & 0x0F
41
+ if packet_type != 1:
42
+ return False
43
+ try:
44
+ proto_len = struct.unpack_from(">H", data, 2)[0]
45
+ proto_name = data[4 : 4 + proto_len]
46
+ return proto_name in (b"MQTT", b"MQIsdp")
47
+ except Exception:
48
+ return False
49
+
50
+
51
+ def decode_frames(data: bytes) -> list[MQTTFrame]:
52
+ frames: list[MQTTFrame] = []
53
+ offset = 0
54
+ while offset < len(data):
55
+ result = _read_frame(data, offset)
56
+ if result is None:
57
+ break
58
+ f, consumed = result
59
+ frames.append(f)
60
+ offset += consumed
61
+ return frames
62
+
63
+
64
+ def _read_frame(data: bytes, offset: int) -> tuple[MQTTFrame, int] | None:
65
+ if offset >= len(data):
66
+ return None
67
+ byte0 = data[offset]
68
+ packet_type = (byte0 >> 4) & 0x0F
69
+ flags = byte0 & 0x0F
70
+ offset += 1
71
+
72
+ remaining_length = 0
73
+ multiplier = 1
74
+ len_bytes = 0
75
+ for _ in range(4):
76
+ if offset >= len(data):
77
+ return None
78
+ b = data[offset]
79
+ offset += 1
80
+ len_bytes += 1
81
+ remaining_length += (b & 0x7F) * multiplier
82
+ multiplier *= 128
83
+ if not (b & 0x80):
84
+ break
85
+
86
+ if offset + remaining_length > len(data):
87
+ return None
88
+
89
+ payload = data[offset : offset + remaining_length]
90
+ consumed = 1 + len_bytes + remaining_length
91
+
92
+ topic = ""
93
+ qos = (flags >> 1) & 0x03
94
+ if packet_type == 3 and len(payload) >= 2:
95
+ topic_len = struct.unpack_from(">H", payload, 0)[0]
96
+ if 2 + topic_len <= len(payload):
97
+ topic = payload[2 : 2 + topic_len].decode(errors="replace")
98
+
99
+ return MQTTFrame(
100
+ packet_type=packet_type,
101
+ packet_name=PACKET_TYPES.get(packet_type, f"UNKNOWN({packet_type})"),
102
+ flags=flags,
103
+ payload=payload,
104
+ topic=topic,
105
+ qos=qos,
106
+ ), consumed
107
+
108
+
109
+ def log_frames(entry_id: int, direction: str, data: bytes) -> None:
110
+ for frame in decode_frames(data):
111
+ logger.debug(
112
+ "mqtt frame entry=%d dir=%s type=%s topic=%r qos=%d len=%d",
113
+ entry_id,
114
+ direction,
115
+ frame.packet_name,
116
+ frame.topic,
117
+ frame.qos,
118
+ len(frame.payload),
119
+ )
pypproxy/proto/ws.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import struct
6
+
7
+ from pypproxy.store.models import Entry
8
+ from pypproxy.store.store import Store
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ OPCODE_TEXT = 0x1
13
+ OPCODE_BINARY = 0x2
14
+ OPCODE_CLOSE = 0x8
15
+ OPCODE_PING = 0x9
16
+ OPCODE_PONG = 0xA
17
+
18
+
19
+ def is_upgrade(headers: dict) -> bool:
20
+ upgrade = headers.get("upgrade", [""])[0].lower()
21
+ return upgrade == "websocket"
22
+
23
+
24
+ async def relay_frames(
25
+ client_reader: asyncio.StreamReader,
26
+ client_writer: asyncio.StreamWriter,
27
+ server_reader: asyncio.StreamReader,
28
+ server_writer: asyncio.StreamWriter,
29
+ entry: Entry,
30
+ store: Store,
31
+ ) -> None:
32
+ async def _relay(
33
+ src_r: asyncio.StreamReader,
34
+ dst_w: asyncio.StreamWriter,
35
+ direction: str,
36
+ ) -> None:
37
+ try:
38
+ while True:
39
+ frame = await read_frame(src_r)
40
+ if frame is None:
41
+ break
42
+ fin, opcode, payload = frame
43
+ log_frame(entry.id, direction, opcode, payload)
44
+ await write_frame(dst_w, fin, opcode, payload, mask=False)
45
+ if opcode == OPCODE_CLOSE:
46
+ break
47
+ except (asyncio.IncompleteReadError, ConnectionResetError):
48
+ pass
49
+
50
+ await asyncio.gather(
51
+ _relay(client_reader, server_writer, "client"),
52
+ _relay(server_reader, client_writer, "server"),
53
+ )
54
+
55
+
56
+ async def read_frame(
57
+ reader: asyncio.StreamReader,
58
+ ) -> tuple[bool, int, bytes] | None:
59
+ try:
60
+ header = await reader.readexactly(2)
61
+ except asyncio.IncompleteReadError:
62
+ return None
63
+
64
+ fin = bool(header[0] & 0x80)
65
+ opcode = header[0] & 0x0F
66
+ masked = bool(header[1] & 0x80)
67
+ payload_len = header[1] & 0x7F
68
+
69
+ if payload_len == 126:
70
+ ext = await reader.readexactly(2)
71
+ payload_len = struct.unpack(">H", ext)[0]
72
+ elif payload_len == 127:
73
+ ext = await reader.readexactly(8)
74
+ payload_len = struct.unpack(">Q", ext)[0]
75
+
76
+ mask_key = b""
77
+ if masked:
78
+ mask_key = await reader.readexactly(4)
79
+
80
+ payload = await reader.readexactly(payload_len)
81
+ if masked:
82
+ payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
83
+
84
+ return fin, opcode, payload
85
+
86
+
87
+ async def write_frame(
88
+ writer: asyncio.StreamWriter,
89
+ fin: bool,
90
+ opcode: int,
91
+ payload: bytes,
92
+ mask: bool = False,
93
+ ) -> None:
94
+ b0 = (0x80 if fin else 0x00) | opcode
95
+ length = len(payload)
96
+
97
+ if length < 126:
98
+ b1 = length
99
+ header = bytes([b0, b1])
100
+ elif length < 65536:
101
+ b1 = 126
102
+ header = bytes([b0, b1]) + struct.pack(">H", length)
103
+ else:
104
+ b1 = 127
105
+ header = bytes([b0, b1]) + struct.pack(">Q", length)
106
+
107
+ writer.write(header + payload)
108
+ await writer.drain()
109
+
110
+
111
+ def log_frame(entry_id: int, direction: str, opcode: int, payload: bytes) -> None:
112
+ if opcode == OPCODE_TEXT:
113
+ logger.debug(
114
+ "ws frame entry=%d dir=%s text=%s",
115
+ entry_id,
116
+ direction,
117
+ payload.decode(errors="replace")[:200],
118
+ )
119
+ elif opcode == OPCODE_BINARY:
120
+ logger.debug("ws frame entry=%d dir=%s binary len=%d", entry_id, direction, len(payload))