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/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()
@@ -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())
@@ -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)