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
pypproxy/ui/cui.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from rich.console import Console
6
+ from rich.layout import Layout
7
+ from rich.live import Live
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from pypproxy.store.models import Entry
13
+ from pypproxy.store.store import Store
14
+
15
+ console = Console()
16
+
17
+ METHOD_STYLES: dict[str, str] = {
18
+ "GET": "bold blue",
19
+ "POST": "bold green",
20
+ "PUT": "bold yellow",
21
+ "PATCH": "bold magenta",
22
+ "DELETE": "bold red",
23
+ "HEAD": "dim",
24
+ "OPTIONS": "dim",
25
+ }
26
+
27
+
28
+ def _method_text(method: str) -> Text:
29
+ style = METHOD_STYLES.get(method.upper(), "white")
30
+ return Text(method, style=style)
31
+
32
+
33
+ def _status_text(code: int) -> Text:
34
+ if code == 0:
35
+ return Text("—", style="dim")
36
+ if 200 <= code < 300:
37
+ return Text(str(code), style="bold green")
38
+ if 300 <= code < 400:
39
+ return Text(str(code), style="bold cyan")
40
+ if 400 <= code < 500:
41
+ return Text(str(code), style="bold yellow")
42
+ return Text(str(code), style="bold red")
43
+
44
+
45
+ def _build_table(entries: list[Entry]) -> Table:
46
+ table = Table(
47
+ show_header=True,
48
+ header_style="bold bright_white",
49
+ box=None,
50
+ padding=(0, 1),
51
+ expand=True,
52
+ )
53
+ table.add_column("ID", style="dim", width=6, justify="right")
54
+ table.add_column("Method", width=8, justify="center")
55
+ table.add_column("Host", min_width=20)
56
+ table.add_column("Path", min_width=30, no_wrap=False)
57
+ table.add_column("Status", width=7, justify="center")
58
+ table.add_column("ms", style="dim", width=6, justify="right")
59
+ table.add_column("Tags", style="dim", width=14)
60
+
61
+ for e in entries[:200]:
62
+ tags = ",".join(e.tags) if e.tags else ""
63
+ row_style = ""
64
+ if "blocked" in e.tags:
65
+ row_style = "on dark_red"
66
+ elif e.modified:
67
+ row_style = "on dark_orange3"
68
+
69
+ table.add_row(
70
+ str(e.id),
71
+ _method_text(e.method),
72
+ e.host,
73
+ e.path + (f"?{e.query}" if e.query else ""),
74
+ _status_text(e.status_code),
75
+ str(e.duration_ms) if e.duration_ms else "—",
76
+ tags,
77
+ style=row_style,
78
+ )
79
+ return table
80
+
81
+
82
+ def _build_layout(entries: list[Entry], status: str) -> Layout:
83
+ layout = Layout()
84
+ layout.split_column(
85
+ Layout(name="header", size=3),
86
+ Layout(name="body"),
87
+ Layout(name="footer", size=1),
88
+ )
89
+
90
+ layout["header"].update(
91
+ Panel(
92
+ Text("paxy", style="bold white")
93
+ + Text(" MITM Proxy ", style="dim")
94
+ + Text(f"[{len(entries)} requests]", style="cyan"),
95
+ style="on #1a1a2e",
96
+ border_style="bright_blue",
97
+ )
98
+ )
99
+
100
+ layout["body"].update(
101
+ Panel(
102
+ _build_table(entries),
103
+ title="[bold]Traffic[/bold]",
104
+ border_style="bright_blue",
105
+ padding=(0, 1),
106
+ )
107
+ )
108
+
109
+ layout["footer"].update(
110
+ Text(f" {status} q: quit c: clear /: filter", style="dim on #16213e")
111
+ )
112
+
113
+ return layout
114
+
115
+
116
+ async def run_cui(store: Store, proxy_addr: str, ui_port: int) -> None:
117
+ entries: list[Entry] = []
118
+ status = f"proxy :{proxy_addr} API :http://localhost:{ui_port}/api"
119
+ running = True
120
+
121
+ q = store.subscribe()
122
+
123
+ with Live(console=console, refresh_per_second=4, screen=True) as live:
124
+
125
+ async def _updater() -> None:
126
+ while running:
127
+ try:
128
+ entry = q.get_nowait()
129
+ entries.insert(0, entry)
130
+ except asyncio.QueueEmpty:
131
+ pass
132
+ live.update(_build_layout(entries, status))
133
+ await asyncio.sleep(0.25)
134
+
135
+ async def _input_loop() -> None:
136
+ nonlocal running
137
+ loop = asyncio.get_event_loop()
138
+ import sys
139
+ import termios
140
+ import tty
141
+
142
+ fd = sys.stdin.fileno()
143
+ old = termios.tcgetattr(fd)
144
+ try:
145
+ tty.setraw(fd)
146
+ while running:
147
+ ch = await loop.run_in_executor(None, sys.stdin.read, 1)
148
+ if ch in ("q", "Q", "\x03"):
149
+ running = False
150
+ break
151
+ elif ch in ("c", "C"):
152
+ store.clear()
153
+ entries.clear()
154
+ finally:
155
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
156
+
157
+ try:
158
+ await asyncio.gather(_updater(), _input_loop())
159
+ except (KeyboardInterrupt, asyncio.CancelledError):
160
+ pass
161
+ finally:
162
+ store.unsubscribe(q)
pypproxy/ui/detail.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from nicegui import ui
6
+
7
+ from pypproxy.codec import decode_cbor, decode_msgpack, decode_protobuf_raw, sniff_content_type
8
+ from pypproxy.store.models import Entry
9
+
10
+ from .theme import method_badge, status_badge
11
+
12
+ _VIEW_MODES = ["Auto", "Text", "JSON", "Hex", "Protobuf", "MessagePack", "CBOR"]
13
+
14
+
15
+ def render_detail(entry: Entry | None, container: ui.element) -> None:
16
+ container.clear()
17
+ if entry is None:
18
+ with container:
19
+ ui.label("Select a request to inspect").classes("text-grey q-pa-md")
20
+ return
21
+
22
+ with container:
23
+ # --- request ---
24
+ with ui.expansion("Request", icon="upload", value=True).classes("w-full"):
25
+ with ui.row().classes("items-center q-mb-sm gap-2"):
26
+ method_badge(entry.method)
27
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
28
+ if entry.query:
29
+ url += "?" + entry.query
30
+ ui.label(url).classes("text-caption text-mono")
31
+
32
+ if entry.req_headers:
33
+ ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
34
+ _render_headers(entry.req_headers)
35
+
36
+ if entry.req_body:
37
+ ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
38
+ ct = entry.req_headers.get("content-type", [""])[0]
39
+ _render_body_with_selector(entry.req_body, ct)
40
+
41
+ ui.separator()
42
+
43
+ # --- response ---
44
+ with ui.expansion("Response", icon="download", value=True).classes("w-full"):
45
+ if entry.status_code:
46
+ with ui.row().classes("items-center gap-2 q-mb-sm"):
47
+ status_badge(entry.status_code)
48
+ ui.label(f"{entry.duration_ms} ms").classes("text-caption text-grey")
49
+
50
+ if entry.resp_headers:
51
+ ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
52
+ _render_headers(entry.resp_headers)
53
+
54
+ if entry.resp_body:
55
+ ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
56
+ ct = entry.resp_headers.get("content-type", [""])[0]
57
+ _render_body_with_selector(entry.resp_body, ct)
58
+
59
+ ui.separator()
60
+
61
+ # --- replay ---
62
+ with ui.row().classes("q-pa-sm"):
63
+ ui.button("Replay", icon="replay", on_click=lambda: _replay(entry)).props(
64
+ "color=primary size=sm"
65
+ )
66
+
67
+
68
+ def _render_body_with_selector(raw: bytes, content_type: str) -> None:
69
+ sniffed = sniff_content_type(raw, content_type)
70
+ default_mode = {
71
+ "json": "JSON",
72
+ "proto": "Protobuf",
73
+ "msgpack": "MessagePack",
74
+ "cbor": "CBOR",
75
+ "binary": "Hex",
76
+ }.get(sniffed, "Auto")
77
+
78
+ body_area = ui.element("div").classes("w-full")
79
+
80
+ def _update(mode: str) -> None:
81
+ body_area.clear()
82
+ with body_area:
83
+ text = _decode_as(raw, mode, content_type)
84
+ ui.element("pre").classes("paxy-body-pre").text = text
85
+
86
+ with ui.row().classes("items-center gap-2 q-mb-xs"):
87
+ view_select = (
88
+ ui.select(_VIEW_MODES, value=default_mode, label="View")
89
+ .props("dense outlined dark")
90
+ .classes("w-32")
91
+ )
92
+ view_select.on("update:model-value", lambda e: _update(e.args))
93
+
94
+ _update(default_mode)
95
+
96
+
97
+ def _decode_as(raw: bytes, mode: str, content_type: str) -> str:
98
+ if mode == "Hex":
99
+ return _to_hex(raw)
100
+ if mode == "Protobuf":
101
+ return decode_protobuf_raw(raw)
102
+ if mode == "MessagePack":
103
+ return decode_msgpack(raw)
104
+ if mode == "CBOR":
105
+ return decode_cbor(raw)
106
+ if mode == "JSON":
107
+ try:
108
+ return json.dumps(
109
+ json.loads(raw.decode("utf-8", errors="replace")), indent=2, ensure_ascii=False
110
+ )
111
+ except Exception:
112
+ return raw.decode("utf-8", errors="replace")
113
+ if mode == "Text":
114
+ return raw.decode("utf-8", errors="replace")
115
+ # Auto
116
+ return _smart_decode(raw, content_type)
117
+
118
+
119
+ def _smart_decode(raw: bytes, content_type: str) -> str:
120
+ sniffed = sniff_content_type(raw, content_type)
121
+ if sniffed == "json":
122
+ try:
123
+ return json.dumps(
124
+ json.loads(raw.decode("utf-8", errors="replace")), indent=2, ensure_ascii=False
125
+ )
126
+ except Exception:
127
+ pass
128
+ if sniffed == "proto":
129
+ return decode_protobuf_raw(raw)
130
+ if sniffed == "msgpack":
131
+ return decode_msgpack(raw)
132
+ if sniffed == "cbor":
133
+ return decode_cbor(raw)
134
+ if sniffed == "binary":
135
+ return _to_hex(raw)
136
+ try:
137
+ return raw.decode("utf-8", errors="replace")
138
+ except Exception:
139
+ return _to_hex(raw)
140
+
141
+
142
+ def _to_hex(raw: bytes) -> str:
143
+ lines: list[str] = []
144
+ for i in range(0, len(raw), 16):
145
+ chunk = raw[i : i + 16]
146
+ hex_part = " ".join(f"{b:02x}" for b in chunk)
147
+ ascii_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in chunk)
148
+ lines.append(f"{i:08x} {hex_part:<47} {ascii_part}")
149
+ return "\n".join(lines)
150
+
151
+
152
+ def _render_headers(headers: dict[str, list[str]]) -> None:
153
+ with ui.element("table").classes("paxy-header-table w-full"):
154
+ for k, vs in sorted(headers.items()):
155
+ with ui.element("tr"):
156
+ ui.element("td").style(
157
+ "color:#aaa; min-width:160px; padding:2px 8px; font-size:12px"
158
+ ).text = k
159
+ ui.element("td").style("padding:2px 8px; font-size:12px").text = ", ".join(vs)
160
+
161
+
162
+ async def _replay(entry: Entry) -> None:
163
+ import httpx
164
+
165
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
166
+ if entry.query:
167
+ url += "?" + entry.query
168
+ headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
169
+ try:
170
+ async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
171
+ resp = await client.request(
172
+ method=entry.method,
173
+ url=url,
174
+ headers=headers,
175
+ content=entry.req_body,
176
+ )
177
+ ui.notify(f"Replay: {resp.status_code} ({len(resp.content)} bytes)", type="positive")
178
+ except Exception as e:
179
+ ui.notify(f"Replay failed: {e}", type="negative")
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+
5
+ from nicegui import ui
6
+
7
+ from pypproxy.store.models import Entry
8
+
9
+
10
+ def build_diff_view(container: ui.element) -> dict:
11
+ """Build the diff view UI inside container. Returns state dict with set_entries method."""
12
+ state: dict = {"left": None, "right": None}
13
+
14
+ container.clear()
15
+ with container:
16
+ ui.label("Diff View").classes("text-subtitle2 q-mb-sm")
17
+ ui.label("Select two entries from the traffic list (right-click → Compare)").classes(
18
+ "text-grey text-caption"
19
+ )
20
+ diff_container = ui.element("div").classes("w-full")
21
+
22
+ def set_entries(left: Entry, right: Entry) -> None:
23
+ state["left"] = left
24
+ state["right"] = right
25
+ _render_diff(left, right, diff_container)
26
+
27
+ return {"set_entries": set_entries}
28
+
29
+
30
+ def _render_diff(left: Entry, right: Entry, container: ui.element) -> None:
31
+ container.clear()
32
+ with container:
33
+ with ui.tabs() as tabs:
34
+ req_tab = ui.tab("Request")
35
+ resp_tab = ui.tab("Response")
36
+ headers_tab = ui.tab("Headers")
37
+
38
+ with ui.tab_panels(tabs, value=req_tab).classes("w-full"):
39
+ with ui.tab_panel(req_tab):
40
+ _render_text_diff(
41
+ _entry_req_text(left),
42
+ _entry_req_text(right),
43
+ f"#{left.id} {left.method} {left.path}",
44
+ f"#{right.id} {right.method} {right.path}",
45
+ )
46
+ with ui.tab_panel(resp_tab):
47
+ _render_text_diff(
48
+ _decode(left.resp_body),
49
+ _decode(right.resp_body),
50
+ f"#{left.id} {left.status_code}",
51
+ f"#{right.id} {right.status_code}",
52
+ )
53
+ with ui.tab_panel(headers_tab):
54
+ _render_text_diff(
55
+ _headers_text(left.req_headers),
56
+ _headers_text(right.req_headers),
57
+ f"#{left.id} req headers",
58
+ f"#{right.id} req headers",
59
+ )
60
+
61
+
62
+ def _render_text_diff(a: str, b: str, label_a: str, label_b: str) -> None:
63
+ diff = list(
64
+ difflib.unified_diff(
65
+ a.splitlines(keepends=True),
66
+ b.splitlines(keepends=True),
67
+ fromfile=label_a,
68
+ tofile=label_b,
69
+ lineterm="",
70
+ )
71
+ )
72
+
73
+ if not diff:
74
+ ui.label("No differences").classes("text-grey q-pa-sm")
75
+ return
76
+
77
+ html_parts: list[str] = []
78
+ for line in diff:
79
+ if line.startswith("+++") or line.startswith("---") or line.startswith("@@"):
80
+ cls = "color:#888"
81
+ elif line.startswith("+"):
82
+ cls = "color:#4caf50;background:#1b2e1b"
83
+ elif line.startswith("-"):
84
+ cls = "color:#f44336;background:#2e1b1b"
85
+ else:
86
+ cls = "color:#ccc"
87
+ escaped = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
88
+ html_parts.append(f'<span style="{cls}">{escaped}</span>')
89
+
90
+ html = (
91
+ "<pre style='font-size:12px;line-height:1.4;overflow:auto;padding:8px;background:#111'>"
92
+ + "\n".join(html_parts)
93
+ + "</pre>"
94
+ )
95
+ ui.html(html).classes("w-full")
96
+
97
+
98
+ def _entry_req_text(e: Entry) -> str:
99
+ parts = [f"{e.method} {e.path} HTTP/1.1", f"Host: {e.host}"]
100
+ for k, vs in e.req_headers.items():
101
+ parts.append(f"{k}: {', '.join(vs)}")
102
+ parts.append("")
103
+ if e.req_body:
104
+ parts.append(_decode(e.req_body))
105
+ return "\n".join(parts)
106
+
107
+
108
+ def _headers_text(headers: dict) -> str:
109
+ return "\n".join(f"{k}: {', '.join(v)}" for k, v in sorted(headers.items()))
110
+
111
+
112
+ def _decode(b: bytes) -> str:
113
+ if not b:
114
+ return ""
115
+ try:
116
+ return b.decode("utf-8", errors="replace")
117
+ except Exception:
118
+ return b.hex()