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.
- pypproxy/__init__.py +0 -0
- pypproxy/api/__init__.py +0 -0
- pypproxy/api/server.py +427 -0
- pypproxy/bulk/__init__.py +0 -0
- pypproxy/bulk/sender.py +97 -0
- pypproxy/cert/__init__.py +0 -0
- pypproxy/cert/ca.py +144 -0
- pypproxy/cert/client_cert.py +65 -0
- pypproxy/codec.py +176 -0
- pypproxy/config/__init__.py +0 -0
- pypproxy/config/config.py +106 -0
- pypproxy/dns/__init__.py +0 -0
- pypproxy/dns/server.py +149 -0
- pypproxy/exporter/__init__.py +0 -0
- pypproxy/exporter/exporter.py +122 -0
- pypproxy/exporter/importer.py +169 -0
- pypproxy/graphql/__init__.py +0 -0
- pypproxy/graphql/detector.py +76 -0
- pypproxy/graphql/introspection.py +217 -0
- pypproxy/graphql/modifier.py +98 -0
- pypproxy/graphql/schema_store.py +33 -0
- pypproxy/intercept/__init__.py +0 -0
- pypproxy/intercept/manager.py +142 -0
- pypproxy/interceptor/__init__.py +0 -0
- pypproxy/interceptor/interceptor.py +172 -0
- pypproxy/proto/__init__.py +0 -0
- pypproxy/proto/grpc.py +48 -0
- pypproxy/proto/mqtt.py +119 -0
- pypproxy/proto/ws.py +120 -0
- pypproxy/proto/ws_intercept.py +117 -0
- pypproxy/proxy/__init__.py +0 -0
- pypproxy/proxy/proxy.py +407 -0
- pypproxy/replay/__init__.py +0 -0
- pypproxy/replay/replay.py +77 -0
- pypproxy/rule/__init__.py +0 -0
- pypproxy/rule/rule.py +198 -0
- pypproxy/scan/__init__.py +0 -0
- pypproxy/scan/scanner.py +296 -0
- pypproxy/script/__init__.py +0 -0
- pypproxy/script/engine.py +49 -0
- pypproxy/security/__init__.py +0 -0
- pypproxy/security/header_checker.py +308 -0
- pypproxy/security/int_overflow.py +193 -0
- pypproxy/security/jwt_checker.py +273 -0
- pypproxy/security/plugin.py +152 -0
- pypproxy/security/randomness.py +165 -0
- pypproxy/store/__init__.py +0 -0
- pypproxy/store/db.py +189 -0
- pypproxy/store/filter_parser.py +181 -0
- pypproxy/store/fts.py +105 -0
- pypproxy/store/models.py +81 -0
- pypproxy/store/scope.py +63 -0
- pypproxy/store/store.py +120 -0
- pypproxy/ui/__init__.py +0 -0
- pypproxy/ui/app.py +386 -0
- pypproxy/ui/bulk_sender_ui.py +125 -0
- pypproxy/ui/cui.py +162 -0
- pypproxy/ui/detail.py +179 -0
- pypproxy/ui/diff_view.py +118 -0
- pypproxy/ui/graphql_tab.py +265 -0
- pypproxy/ui/import_tab.py +136 -0
- pypproxy/ui/intercept_dialog.py +74 -0
- pypproxy/ui/resender.py +140 -0
- pypproxy/ui/scan_tab.py +98 -0
- pypproxy/ui/security_tab.py +356 -0
- pypproxy/ui/settings.py +413 -0
- pypproxy/ui/theme.py +59 -0
- pypproxy-0.1.0.dist-info/METADATA +19 -0
- pypproxy-0.1.0.dist-info/RECORD +72 -0
- pypproxy-0.1.0.dist-info/WHEEL +4 -0
- pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
- pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
pypproxy/store/db.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import aiosqlite
|
|
10
|
+
|
|
11
|
+
from .models import Entry, Filter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_CREATE_TABLE = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
17
|
+
id INTEGER PRIMARY KEY,
|
|
18
|
+
created_at TEXT NOT NULL,
|
|
19
|
+
method TEXT NOT NULL DEFAULT '',
|
|
20
|
+
scheme TEXT NOT NULL DEFAULT '',
|
|
21
|
+
host TEXT NOT NULL DEFAULT '',
|
|
22
|
+
path TEXT NOT NULL DEFAULT '/',
|
|
23
|
+
query TEXT NOT NULL DEFAULT '',
|
|
24
|
+
req_headers TEXT NOT NULL DEFAULT '{}',
|
|
25
|
+
req_body BLOB NOT NULL DEFAULT '',
|
|
26
|
+
status_code INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
resp_headers TEXT NOT NULL DEFAULT '{}',
|
|
28
|
+
resp_body BLOB NOT NULL DEFAULT '',
|
|
29
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
protocol TEXT NOT NULL DEFAULT 'http',
|
|
31
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
32
|
+
modified INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
color TEXT NOT NULL DEFAULT ''
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_CREATE_INDEX = """
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_entries_host ON entries (host);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_entries_method ON entries (method);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_entries_protocol ON entries (protocol);
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Database:
|
|
45
|
+
def __init__(self, path: str) -> None:
|
|
46
|
+
self._path = path
|
|
47
|
+
self._db: aiosqlite.Connection | None = None
|
|
48
|
+
self._lock = asyncio.Lock()
|
|
49
|
+
|
|
50
|
+
async def open(self) -> None:
|
|
51
|
+
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self._db = await aiosqlite.connect(self._path)
|
|
53
|
+
self._db.row_factory = aiosqlite.Row
|
|
54
|
+
await self._db.execute(_CREATE_TABLE)
|
|
55
|
+
for stmt in _CREATE_INDEX.strip().split("\n"):
|
|
56
|
+
if stmt.strip():
|
|
57
|
+
await self._db.execute(stmt)
|
|
58
|
+
await self._db.commit()
|
|
59
|
+
# Initialize FTS index
|
|
60
|
+
from pypproxy.store.fts import setup_fts
|
|
61
|
+
|
|
62
|
+
await setup_fts(self._db)
|
|
63
|
+
logger.info("database opened: %s", self._path)
|
|
64
|
+
|
|
65
|
+
async def search(self, query: str, limit: int = 50) -> list:
|
|
66
|
+
from pypproxy.store.fts import search
|
|
67
|
+
|
|
68
|
+
if not self._db:
|
|
69
|
+
return []
|
|
70
|
+
return await search(self._db, query, limit)
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
if self._db:
|
|
74
|
+
await self._db.close()
|
|
75
|
+
|
|
76
|
+
async def insert(self, entry: Entry) -> None:
|
|
77
|
+
if not self._db:
|
|
78
|
+
return
|
|
79
|
+
async with self._lock:
|
|
80
|
+
await self._db.execute(
|
|
81
|
+
"""INSERT OR REPLACE INTO entries
|
|
82
|
+
(id, created_at, method, scheme, host, path, query,
|
|
83
|
+
req_headers, req_body, status_code, resp_headers, resp_body,
|
|
84
|
+
duration_ms, protocol, tags, modified, color)
|
|
85
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
86
|
+
_entry_to_row(entry),
|
|
87
|
+
)
|
|
88
|
+
await self._db.commit()
|
|
89
|
+
|
|
90
|
+
async def update(self, entry: Entry) -> None:
|
|
91
|
+
await self.insert(entry)
|
|
92
|
+
|
|
93
|
+
async def get(self, entry_id: int) -> Entry | None:
|
|
94
|
+
if not self._db:
|
|
95
|
+
return None
|
|
96
|
+
async with self._db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)) as cur:
|
|
97
|
+
row = await cur.fetchone()
|
|
98
|
+
return _row_to_entry(row) if row else None
|
|
99
|
+
|
|
100
|
+
async def list(self, f: Filter, offset: int = 0, limit: int = 100) -> tuple[list[Entry], int]:
|
|
101
|
+
if not self._db:
|
|
102
|
+
return [], 0
|
|
103
|
+
where, params = _build_where(f)
|
|
104
|
+
async with self._db.execute(f"SELECT COUNT(*) FROM entries {where}", params) as cur:
|
|
105
|
+
total = (await cur.fetchone())[0]
|
|
106
|
+
|
|
107
|
+
order = "ORDER BY id DESC"
|
|
108
|
+
q = f"SELECT * FROM entries {where} {order} LIMIT ? OFFSET ?"
|
|
109
|
+
async with self._db.execute(q, [*params, limit, offset]) as cur:
|
|
110
|
+
rows = await cur.fetchall()
|
|
111
|
+
return [_row_to_entry(r) for r in rows], total
|
|
112
|
+
|
|
113
|
+
async def clear(self) -> None:
|
|
114
|
+
if not self._db:
|
|
115
|
+
return
|
|
116
|
+
async with self._lock:
|
|
117
|
+
await self._db.execute("DELETE FROM entries")
|
|
118
|
+
await self._db.commit()
|
|
119
|
+
|
|
120
|
+
async def max_id(self) -> int:
|
|
121
|
+
if not self._db:
|
|
122
|
+
return 0
|
|
123
|
+
async with self._db.execute("SELECT MAX(id) FROM entries") as cur:
|
|
124
|
+
row = await cur.fetchone()
|
|
125
|
+
return row[0] or 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_where(f: Filter) -> tuple[str, list]:
|
|
129
|
+
clauses, params = [], []
|
|
130
|
+
if f.method:
|
|
131
|
+
clauses.append("method = ?")
|
|
132
|
+
params.append(f.method)
|
|
133
|
+
if f.host:
|
|
134
|
+
clauses.append("host = ?")
|
|
135
|
+
params.append(f.host)
|
|
136
|
+
if f.protocol:
|
|
137
|
+
clauses.append("protocol = ?")
|
|
138
|
+
params.append(f.protocol)
|
|
139
|
+
if f.search:
|
|
140
|
+
clauses.append("(host || path) LIKE ?")
|
|
141
|
+
params.append(f"%{f.search}%")
|
|
142
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
143
|
+
return where, params
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _entry_to_row(e: Entry) -> tuple:
|
|
147
|
+
return (
|
|
148
|
+
e.id,
|
|
149
|
+
e.created_at.isoformat(),
|
|
150
|
+
e.method,
|
|
151
|
+
e.scheme,
|
|
152
|
+
e.host,
|
|
153
|
+
e.path,
|
|
154
|
+
e.query,
|
|
155
|
+
json.dumps(e.req_headers),
|
|
156
|
+
e.req_body,
|
|
157
|
+
e.status_code,
|
|
158
|
+
json.dumps(e.resp_headers),
|
|
159
|
+
e.resp_body,
|
|
160
|
+
e.duration_ms,
|
|
161
|
+
e.protocol,
|
|
162
|
+
json.dumps(e.tags),
|
|
163
|
+
int(e.modified),
|
|
164
|
+
getattr(e, "color", ""),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _row_to_entry(row: aiosqlite.Row) -> Entry:
|
|
169
|
+
from datetime import datetime
|
|
170
|
+
|
|
171
|
+
e = Entry()
|
|
172
|
+
e.id = row["id"]
|
|
173
|
+
e.created_at = datetime.fromisoformat(row["created_at"]).replace(tzinfo=UTC)
|
|
174
|
+
e.method = row["method"]
|
|
175
|
+
e.scheme = row["scheme"]
|
|
176
|
+
e.host = row["host"]
|
|
177
|
+
e.path = row["path"]
|
|
178
|
+
e.query = row["query"]
|
|
179
|
+
e.req_headers = json.loads(row["req_headers"])
|
|
180
|
+
e.req_body = bytes(row["req_body"]) if row["req_body"] else b""
|
|
181
|
+
e.status_code = row["status_code"]
|
|
182
|
+
e.resp_headers = json.loads(row["resp_headers"])
|
|
183
|
+
e.resp_body = bytes(row["resp_body"]) if row["resp_body"] else b""
|
|
184
|
+
e.duration_ms = row["duration_ms"]
|
|
185
|
+
e.protocol = row["protocol"]
|
|
186
|
+
e.tags = json.loads(row["tags"])
|
|
187
|
+
e.modified = bool(row["modified"])
|
|
188
|
+
e.color = row["color"]
|
|
189
|
+
return e
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .models import Entry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Condition:
|
|
11
|
+
field: str
|
|
12
|
+
op: str # ==, !=, contains, ~ (regex)
|
|
13
|
+
value: str
|
|
14
|
+
|
|
15
|
+
def matches(self, entry: Entry) -> bool:
|
|
16
|
+
val = _extract(entry, self.field)
|
|
17
|
+
if self.op == "==":
|
|
18
|
+
result = val == self.value
|
|
19
|
+
elif self.op == "!=":
|
|
20
|
+
result = val != self.value
|
|
21
|
+
elif self.op == "contains":
|
|
22
|
+
result = self.value.lower() in val.lower()
|
|
23
|
+
elif self.op == "~":
|
|
24
|
+
try:
|
|
25
|
+
result = bool(re.search(self.value, val))
|
|
26
|
+
except re.error:
|
|
27
|
+
result = False
|
|
28
|
+
else:
|
|
29
|
+
result = self.value.lower() in val.lower()
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract(entry: Entry, field: str) -> str:
|
|
34
|
+
field = field.lower()
|
|
35
|
+
if field in ("host", "server"):
|
|
36
|
+
return entry.host
|
|
37
|
+
if field in ("path", "url"):
|
|
38
|
+
return entry.path
|
|
39
|
+
if field in ("method",):
|
|
40
|
+
return entry.method
|
|
41
|
+
if field in ("status", "status_code"):
|
|
42
|
+
return str(entry.status_code)
|
|
43
|
+
if field in ("protocol", "type"):
|
|
44
|
+
return entry.protocol
|
|
45
|
+
if field == "request":
|
|
46
|
+
hdrs = " ".join(f"{k}: {','.join(v)}" for k, v in entry.req_headers.items())
|
|
47
|
+
return f"{entry.method} {entry.path} {hdrs} {entry.req_body.decode(errors='replace')}"
|
|
48
|
+
if field == "response":
|
|
49
|
+
hdrs = " ".join(f"{k}: {','.join(v)}" for k, v in entry.resp_headers.items())
|
|
50
|
+
return f"{entry.status_code} {hdrs} {entry.resp_body.decode(errors='replace')}"
|
|
51
|
+
if field in ("full_text", "all"):
|
|
52
|
+
req = _extract(entry, "request")
|
|
53
|
+
resp = _extract(entry, "response")
|
|
54
|
+
return req + " " + resp
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FilterExpression:
|
|
59
|
+
"""
|
|
60
|
+
Parses PacketProxy-style filter expressions.
|
|
61
|
+
|
|
62
|
+
Syntax:
|
|
63
|
+
field == value
|
|
64
|
+
field != value
|
|
65
|
+
field contains value
|
|
66
|
+
field ~ regex
|
|
67
|
+
expr && expr
|
|
68
|
+
expr || expr
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
host == example.com && method == POST
|
|
72
|
+
request contains token || response contains error
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, expr: str) -> None:
|
|
76
|
+
self._expr = expr.strip()
|
|
77
|
+
self._compiled: list | None = None
|
|
78
|
+
if self._expr:
|
|
79
|
+
try:
|
|
80
|
+
self._compiled = _parse(self._expr)
|
|
81
|
+
except Exception:
|
|
82
|
+
self._compiled = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_empty(self) -> bool:
|
|
86
|
+
return not self._expr
|
|
87
|
+
|
|
88
|
+
def matches(self, entry: Entry) -> bool:
|
|
89
|
+
if not self._compiled:
|
|
90
|
+
return True # empty or invalid expression → match all
|
|
91
|
+
return _eval(self._compiled, entry)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- parser ---
|
|
95
|
+
|
|
96
|
+
_TOKEN_RE = re.compile(
|
|
97
|
+
r"(\|\||&&|\(|\)|"
|
|
98
|
+
r"(?:host|path|url|method|status|status_code|protocol|type|request|response|full_text|all)"
|
|
99
|
+
r"\s*(?:==|!=|contains|~)\s*[^\s()]+)",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_COND_RE = re.compile(
|
|
104
|
+
r"^([\w_]+)\s*(==|!=|contains|~)\s*(.+)$",
|
|
105
|
+
re.IGNORECASE,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse(expr: str) -> list:
|
|
110
|
+
"""Tokenize into an AST-like list: ['cond', Condition], ['and'/'or'], ['('], [')']"""
|
|
111
|
+
tokens = []
|
|
112
|
+
i = 0
|
|
113
|
+
expr = expr.strip()
|
|
114
|
+
|
|
115
|
+
while i < len(expr):
|
|
116
|
+
if expr[i : i + 2] == "&&":
|
|
117
|
+
tokens.append(("and",))
|
|
118
|
+
i += 2
|
|
119
|
+
elif expr[i : i + 2] == "||":
|
|
120
|
+
tokens.append(("or",))
|
|
121
|
+
i += 2
|
|
122
|
+
elif expr[i] == "(":
|
|
123
|
+
tokens.append(("lparen",))
|
|
124
|
+
i += 1
|
|
125
|
+
elif expr[i] == ")":
|
|
126
|
+
tokens.append(("rparen",))
|
|
127
|
+
i += 1
|
|
128
|
+
elif expr[i] == " ":
|
|
129
|
+
i += 1
|
|
130
|
+
else:
|
|
131
|
+
# find end of condition token (up to next operator or paren)
|
|
132
|
+
end = len(expr)
|
|
133
|
+
for marker in ["&&", "||", ")", "("]:
|
|
134
|
+
pos = expr.find(marker, i)
|
|
135
|
+
if pos != -1 and pos < end:
|
|
136
|
+
end = pos
|
|
137
|
+
token = expr[i:end].strip()
|
|
138
|
+
if token:
|
|
139
|
+
m = _COND_RE.match(token)
|
|
140
|
+
if m:
|
|
141
|
+
tokens.append(
|
|
142
|
+
(
|
|
143
|
+
"cond",
|
|
144
|
+
Condition(
|
|
145
|
+
field=m.group(1),
|
|
146
|
+
op=m.group(2).lower(),
|
|
147
|
+
value=m.group(3).strip(),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
i = end
|
|
152
|
+
|
|
153
|
+
return tokens
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _eval(tokens: list, entry: Entry) -> bool:
|
|
157
|
+
"""Evaluate token list with short-circuit && / ||."""
|
|
158
|
+
if not tokens:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
results = []
|
|
162
|
+
ops = []
|
|
163
|
+
|
|
164
|
+
for tok in tokens:
|
|
165
|
+
kind = tok[0]
|
|
166
|
+
if kind == "cond":
|
|
167
|
+
results.append(tok[1].matches(entry))
|
|
168
|
+
elif kind == "and":
|
|
169
|
+
ops.append("and")
|
|
170
|
+
elif kind == "or":
|
|
171
|
+
ops.append("or")
|
|
172
|
+
|
|
173
|
+
if not results:
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
result = results[0]
|
|
177
|
+
for i, op in enumerate(ops):
|
|
178
|
+
if i + 1 >= len(results):
|
|
179
|
+
break
|
|
180
|
+
result = result and results[i + 1] if op == "and" else result or results[i + 1]
|
|
181
|
+
return result
|
pypproxy/store/fts.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import aiosqlite
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_CREATE_FTS = """
|
|
11
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts
|
|
12
|
+
USING fts5(
|
|
13
|
+
host,
|
|
14
|
+
path,
|
|
15
|
+
req_body,
|
|
16
|
+
resp_body,
|
|
17
|
+
req_headers,
|
|
18
|
+
resp_headers,
|
|
19
|
+
content='entries',
|
|
20
|
+
content_rowid='id'
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_TRIGGER_INSERT = """
|
|
25
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_insert
|
|
26
|
+
AFTER INSERT ON entries BEGIN
|
|
27
|
+
INSERT INTO entries_fts(rowid, host, path, req_body, resp_body, req_headers, resp_headers)
|
|
28
|
+
VALUES (new.id, new.host, new.path, new.req_body, new.resp_body, new.req_headers, new.resp_headers);
|
|
29
|
+
END
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_TRIGGER_UPDATE = """
|
|
33
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_update
|
|
34
|
+
AFTER UPDATE ON entries BEGIN
|
|
35
|
+
INSERT INTO entries_fts(entries_fts, rowid, host, path, req_body, resp_body, req_headers, resp_headers)
|
|
36
|
+
VALUES ('delete', old.id, old.host, old.path, old.req_body, old.resp_body, old.req_headers, old.resp_headers);
|
|
37
|
+
INSERT INTO entries_fts(rowid, host, path, req_body, resp_body, req_headers, resp_headers)
|
|
38
|
+
VALUES (new.id, new.host, new.path, new.req_body, new.resp_body, new.req_headers, new.resp_headers);
|
|
39
|
+
END
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_TRIGGER_DELETE = """
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS entries_fts_delete
|
|
44
|
+
BEFORE DELETE ON entries BEGIN
|
|
45
|
+
INSERT INTO entries_fts(entries_fts, rowid, host, path, req_body, resp_body, req_headers, resp_headers)
|
|
46
|
+
VALUES ('delete', old.id, old.host, old.path, old.req_body, old.resp_body, old.req_headers, old.resp_headers);
|
|
47
|
+
END
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SearchResult:
|
|
53
|
+
entry_id: int
|
|
54
|
+
rank: float
|
|
55
|
+
snippet: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"entry_id": self.entry_id,
|
|
60
|
+
"rank": self.rank,
|
|
61
|
+
"snippet": self.snippet,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def setup_fts(db: aiosqlite.Connection) -> None:
|
|
66
|
+
"""Create FTS5 virtual table and triggers if not already present."""
|
|
67
|
+
await db.execute(_CREATE_FTS)
|
|
68
|
+
await db.execute(_TRIGGER_INSERT)
|
|
69
|
+
await db.execute(_TRIGGER_UPDATE)
|
|
70
|
+
await db.execute(_TRIGGER_DELETE)
|
|
71
|
+
await db.commit()
|
|
72
|
+
logger.info("FTS5 full-text search index ready")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def search(
|
|
76
|
+
db: aiosqlite.Connection,
|
|
77
|
+
query: str,
|
|
78
|
+
limit: int = 50,
|
|
79
|
+
) -> list[SearchResult]:
|
|
80
|
+
"""Search entries using FTS5. Returns matching entry IDs sorted by relevance."""
|
|
81
|
+
if not query.strip():
|
|
82
|
+
return []
|
|
83
|
+
try:
|
|
84
|
+
async with db.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT rowid, rank,
|
|
87
|
+
snippet(entries_fts, 0, '<b>', '</b>', '…', 10)
|
|
88
|
+
FROM entries_fts
|
|
89
|
+
WHERE entries_fts MATCH ?
|
|
90
|
+
ORDER BY rank
|
|
91
|
+
LIMIT ?
|
|
92
|
+
""",
|
|
93
|
+
(query, limit),
|
|
94
|
+
) as cur:
|
|
95
|
+
rows = await cur.fetchall()
|
|
96
|
+
return [SearchResult(entry_id=r[0], rank=r[1], snippet=r[2]) for r in rows]
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.debug("FTS search error: %s", e)
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def rebuild_index(db: aiosqlite.Connection) -> None:
|
|
103
|
+
"""Rebuild the FTS index from the entries table."""
|
|
104
|
+
await db.execute("INSERT INTO entries_fts(entries_fts) VALUES ('rebuild')")
|
|
105
|
+
await db.commit()
|
pypproxy/store/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Entry:
|
|
9
|
+
id: int = 0
|
|
10
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
11
|
+
|
|
12
|
+
# Request
|
|
13
|
+
method: str = ""
|
|
14
|
+
scheme: str = ""
|
|
15
|
+
host: str = ""
|
|
16
|
+
path: str = ""
|
|
17
|
+
query: str = ""
|
|
18
|
+
req_headers: dict[str, list[str]] = field(default_factory=dict)
|
|
19
|
+
req_body: bytes = b""
|
|
20
|
+
|
|
21
|
+
# Response
|
|
22
|
+
status_code: int = 0
|
|
23
|
+
resp_headers: dict[str, list[str]] = field(default_factory=dict)
|
|
24
|
+
resp_body: bytes = b""
|
|
25
|
+
duration_ms: int = 0
|
|
26
|
+
|
|
27
|
+
# Meta
|
|
28
|
+
protocol: str = "http"
|
|
29
|
+
tags: list[str] = field(default_factory=list)
|
|
30
|
+
modified: bool = False
|
|
31
|
+
color: str = "" # row highlight color (hex or name)
|
|
32
|
+
graphql_operation: str = "" # operation name if GraphQL
|
|
33
|
+
graphql_op_type: str = "" # query | mutation | subscription
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
import base64
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"id": self.id,
|
|
40
|
+
"created_at": self.created_at.isoformat(),
|
|
41
|
+
"method": self.method,
|
|
42
|
+
"scheme": self.scheme,
|
|
43
|
+
"host": self.host,
|
|
44
|
+
"path": self.path,
|
|
45
|
+
"query": self.query,
|
|
46
|
+
"req_headers": self.req_headers,
|
|
47
|
+
"req_body": base64.b64encode(self.req_body).decode() if self.req_body else "",
|
|
48
|
+
"status_code": self.status_code,
|
|
49
|
+
"resp_headers": self.resp_headers,
|
|
50
|
+
"resp_body": base64.b64encode(self.resp_body).decode() if self.resp_body else "",
|
|
51
|
+
"duration_ms": self.duration_ms,
|
|
52
|
+
"protocol": self.protocol,
|
|
53
|
+
"tags": self.tags,
|
|
54
|
+
"modified": self.modified,
|
|
55
|
+
"color": self.color,
|
|
56
|
+
"graphql_operation": self.graphql_operation,
|
|
57
|
+
"graphql_op_type": self.graphql_op_type,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Filter:
|
|
63
|
+
method: str = ""
|
|
64
|
+
host: str = ""
|
|
65
|
+
search: str = ""
|
|
66
|
+
protocol: str = ""
|
|
67
|
+
expression: str = "" # PacketProxy-style filter expression
|
|
68
|
+
|
|
69
|
+
def matches(self, entry: Entry) -> bool:
|
|
70
|
+
# Advanced expression takes precedence when set
|
|
71
|
+
if self.expression:
|
|
72
|
+
from pypproxy.store.filter_parser import FilterExpression
|
|
73
|
+
|
|
74
|
+
return FilterExpression(self.expression).matches(entry)
|
|
75
|
+
if self.method and entry.method != self.method:
|
|
76
|
+
return False
|
|
77
|
+
if self.host and entry.host != self.host:
|
|
78
|
+
return False
|
|
79
|
+
if self.protocol and entry.protocol != self.protocol:
|
|
80
|
+
return False
|
|
81
|
+
return not (self.search and self.search.lower() not in (entry.host + entry.path).lower())
|
pypproxy/store/scope.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ScopeRule:
|
|
11
|
+
pattern: str
|
|
12
|
+
mode: str = "glob" # glob or regex
|
|
13
|
+
enabled: bool = True
|
|
14
|
+
|
|
15
|
+
def matches(self, host: str) -> bool:
|
|
16
|
+
if not self.enabled:
|
|
17
|
+
return False
|
|
18
|
+
if self.mode == "regex":
|
|
19
|
+
try:
|
|
20
|
+
return bool(re.search(self.pattern, host))
|
|
21
|
+
except re.error:
|
|
22
|
+
return False
|
|
23
|
+
return fnmatch.fnmatch(host, self.pattern)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {"pattern": self.pattern, "mode": self.mode, "enabled": self.enabled}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScopeManager:
|
|
30
|
+
"""Controls which hosts are in-scope for capture."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._rules: list[ScopeRule] = []
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._enabled = False # when False, all hosts are in-scope
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def enabled(self) -> bool:
|
|
39
|
+
return self._enabled
|
|
40
|
+
|
|
41
|
+
def set_enabled(self, value: bool) -> None:
|
|
42
|
+
self._enabled = value
|
|
43
|
+
|
|
44
|
+
def add(self, rule: ScopeRule) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._rules.append(rule)
|
|
47
|
+
|
|
48
|
+
def remove(self, pattern: str) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._rules = [r for r in self._rules if r.pattern != pattern]
|
|
51
|
+
|
|
52
|
+
def list(self) -> list[ScopeRule]:
|
|
53
|
+
with self._lock:
|
|
54
|
+
return list(self._rules)
|
|
55
|
+
|
|
56
|
+
def is_in_scope(self, host: str) -> bool:
|
|
57
|
+
if not self._enabled:
|
|
58
|
+
return True
|
|
59
|
+
with self._lock:
|
|
60
|
+
rules = list(self._rules)
|
|
61
|
+
if not rules:
|
|
62
|
+
return True
|
|
63
|
+
return any(r.matches(host) for r in rules)
|