sproxy 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.
sproxy/policy.py ADDED
@@ -0,0 +1,274 @@
1
+ """Policy schema, loading, and host matching.
2
+
3
+ A policy describes what a proxied agent session is allowed to do:
4
+
5
+ * which secrets to inject and where they may travel (``[secrets]``),
6
+ * what to do when a credential is detected leaving to a host it is not
7
+ allowed to reach (``on_leak``),
8
+ * whether to run built-in credential pattern detection,
9
+ * where to write the audit log.
10
+
11
+ Only the standard library is used here so the module can be imported inside
12
+ mitmdump's bundled interpreter as well as under pytest.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import re
19
+ import tomllib
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ # Enforcement mode for the whole session.
24
+ MODE_ENFORCE = "enforce" # block/redact per policy
25
+ MODE_MONITOR = "monitor" # never modify traffic, only audit
26
+ MODES = (MODE_ENFORCE, MODE_MONITOR)
27
+
28
+ # Action taken when a secret/credential is found heading to a host it is not
29
+ # allowed to reach.
30
+ ON_LEAK_BLOCK = "block"
31
+ ON_LEAK_REDACT = "redact"
32
+ ON_LEAK_WARN = "warn"
33
+ ON_LEAK_ACTIONS = (ON_LEAK_BLOCK, ON_LEAK_REDACT, ON_LEAK_WARN)
34
+
35
+
36
+ class PolicyError(ValueError):
37
+ """Raised when a policy file is malformed."""
38
+
39
+
40
+ @dataclass
41
+ class SecretSpec:
42
+ """A single secret to inject and guard.
43
+
44
+ ``source`` is a backend spec such as ``gopass:personal/github/token``,
45
+ ``env:GITHUB_TOKEN``, ``pass:work/api``, ``file:/path`` or ``literal:...``.
46
+
47
+ ``allow_hosts`` are the only hosts this secret's value may be sent to. It is
48
+ injected only for requests to these hosts, and its appearance in a request
49
+ to any other host is treated as a leak. An empty list means the secret is
50
+ unrestricted: injected for every host and never treated as a leak.
51
+ """
52
+
53
+ name: str
54
+ source: str
55
+ allow_hosts: list[str] = field(default_factory=list)
56
+
57
+
58
+ PG_SSLMODES = ("verify-full", "disable")
59
+
60
+
61
+ @dataclass
62
+ class PostgresProfile:
63
+ """One ``[postgres.NAME]`` table: a database the session may reach.
64
+
65
+ The profile pins the *destination* and holds the password source; the
66
+ connecting client still chooses everything else. Optional ``user`` and
67
+ ``database`` are defaults advertised with the endpoint, not restrictions -
68
+ what a role may do is enforced by the database itself, which only accepts
69
+ this profile's credential for its own role. ``listen`` is the local
70
+ gateway port - 0 means
71
+ pick a free one, which is fine because the endpoint is advertised to the
72
+ session through an environment variable, not hardcoded anywhere.
73
+ """
74
+
75
+ name: str
76
+ host: str
77
+ password_source: str
78
+ port: int = 5432
79
+ listen: int = 0
80
+ user: str = ""
81
+ database: str = ""
82
+ # Upstream TLS. "verify-full" encrypts and verifies the server identity;
83
+ # "disable" is for local development servers only.
84
+ sslmode: str = "verify-full"
85
+ sslrootcert: str = "" # CA bundle for upstream verification; "" = system store
86
+
87
+ @property
88
+ def env_var(self) -> str:
89
+ """Name of the variable that advertises this endpoint to the session."""
90
+ return "SPROXY_POSTGRES_" + re.sub(r"[^A-Za-z0-9]+", "_", self.name).upper()
91
+
92
+
93
+ @dataclass
94
+ class Policy:
95
+ mode: str = MODE_MONITOR
96
+ port: int = 8080
97
+ on_leak: str = ON_LEAK_BLOCK
98
+ detect_patterns: bool = True
99
+ audit_log: str = ".sproxy/audit.jsonl"
100
+ watch_values: list[str] = field(default_factory=list)
101
+ # Hosts tunneled without TLS interception (certificate pinning). Their
102
+ # contents are not inspected.
103
+ passthrough_hosts: list[str] = field(default_factory=list)
104
+ secrets: list[SecretSpec] = field(default_factory=list)
105
+ postgres: list[PostgresProfile] = field(default_factory=list)
106
+ # confdir for the mitmproxy CA; empty means the mitmproxy default (~/.mitmproxy)
107
+ confdir: str = ""
108
+
109
+ def validate(self) -> None:
110
+ if self.mode not in MODES:
111
+ raise PolicyError(f"mode must be one of {MODES}, got {self.mode!r}")
112
+ if self.on_leak not in ON_LEAK_ACTIONS:
113
+ raise PolicyError(
114
+ f"on_leak must be one of {ON_LEAK_ACTIONS}, got {self.on_leak!r}"
115
+ )
116
+ if not (1 <= self.port <= 65535):
117
+ raise PolicyError(f"port out of range: {self.port}")
118
+ names = [s.name for s in self.secrets]
119
+ dupes = {n for n in names if names.count(n) > 1}
120
+ if dupes:
121
+ raise PolicyError(f"duplicate secret names: {sorted(dupes)}")
122
+ listens = [p.listen for p in self.postgres if p.listen] + [self.port]
123
+ dupe_ports = {p for p in listens if listens.count(p) > 1}
124
+ if dupe_ports:
125
+ raise PolicyError(f"postgres listen ports collide: {sorted(dupe_ports)}")
126
+ env_vars = [p.env_var for p in self.postgres]
127
+ dupe_vars = {v for v in env_vars if env_vars.count(v) > 1}
128
+ if dupe_vars:
129
+ raise PolicyError(
130
+ f"postgres profile names collide after env-var mangling: {sorted(dupe_vars)}"
131
+ )
132
+
133
+
134
+ def host_matches(host: str, patterns: list[str]) -> bool:
135
+ """True if ``host`` matches any glob pattern in ``patterns``.
136
+
137
+ ``*.github.com`` matches ``api.github.com`` but not the bare ``github.com``;
138
+ add ``github.com`` explicitly (or use ``*github.com``) to cover the apex.
139
+ Matching is case-insensitive and ignores any trailing dot.
140
+ """
141
+ if not host:
142
+ return False
143
+ h = host.lower().rstrip(".")
144
+ for pat in patterns:
145
+ p = pat.lower().rstrip(".")
146
+ if fnmatch.fnmatch(h, p):
147
+ return True
148
+ return False
149
+
150
+
151
+ def _as_str_list(value: object, ctx: str) -> list[str]:
152
+ if value is None:
153
+ return []
154
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
155
+ raise PolicyError(f"{ctx} must be a list of strings")
156
+ return list(value)
157
+
158
+
159
+ def _parse_secret(name: str, raw: object) -> SecretSpec:
160
+ """A secret entry is either a bare source string or a table with ``source``
161
+ and optional ``allow_hosts``. No ``allow_hosts`` means no host restriction."""
162
+ if isinstance(raw, str):
163
+ return SecretSpec(name=name, source=raw)
164
+ if isinstance(raw, dict):
165
+ source = raw.get("source")
166
+ if not isinstance(source, str) or not source:
167
+ raise PolicyError(f"secret {name!r} needs a non-empty 'source'")
168
+ allow = _as_str_list(raw.get("allow_hosts"), f"secret {name!r} allow_hosts")
169
+ return SecretSpec(name=name, source=source, allow_hosts=allow)
170
+ raise PolicyError(f"secret {name!r} must be a string or a table")
171
+
172
+
173
+ def _parse_postgres(name: str, raw: object) -> PostgresProfile:
174
+ if not isinstance(raw, dict):
175
+ raise PolicyError(f"[postgres.{name}] must be a table")
176
+ ctx = f"postgres profile {name!r}"
177
+
178
+ def _str(key: str, default: str = "") -> str:
179
+ value = raw.get(key, default)
180
+ if not isinstance(value, str):
181
+ raise PolicyError(f"{ctx}: {key} must be a string")
182
+ return value
183
+
184
+ def _port(key: str, default: int, minimum: int) -> int:
185
+ value = raw.get(key, default)
186
+ if not isinstance(value, int) or isinstance(value, bool) or not minimum <= value <= 65535:
187
+ raise PolicyError(f"{ctx}: {key} must be a port number")
188
+ return value
189
+
190
+ host = _str("host")
191
+ if not host:
192
+ raise PolicyError(f"{ctx} needs a non-empty 'host'")
193
+ password_source = _str("password_source")
194
+ if not password_source:
195
+ raise PolicyError(f"{ctx} needs a non-empty 'password_source'")
196
+ sslmode = _str("sslmode", "verify-full")
197
+ if sslmode not in PG_SSLMODES:
198
+ raise PolicyError(f"{ctx}: sslmode must be one of {PG_SSLMODES}, got {sslmode!r}")
199
+ known = {"host", "port", "listen", "password_source", "user", "database", "sslmode", "sslrootcert"}
200
+ unknown = set(raw) - known
201
+ if unknown:
202
+ raise PolicyError(f"{ctx}: unknown keys {sorted(unknown)}")
203
+ return PostgresProfile(
204
+ name=name,
205
+ host=host,
206
+ password_source=password_source,
207
+ port=_port("port", 5432, minimum=1),
208
+ listen=_port("listen", 0, minimum=0),
209
+ user=_str("user"),
210
+ database=_str("database"),
211
+ sslmode=sslmode,
212
+ sslrootcert=_str("sslrootcert"),
213
+ )
214
+
215
+
216
+ def from_dict(data: dict) -> Policy:
217
+ secrets_raw = data.get("secrets") or {}
218
+ if not isinstance(secrets_raw, dict):
219
+ raise PolicyError("[secrets] must be a table")
220
+ secrets = [_parse_secret(name, raw) for name, raw in secrets_raw.items()]
221
+ postgres_raw = data.get("postgres") or {}
222
+ if not isinstance(postgres_raw, dict):
223
+ raise PolicyError("[postgres] must be a table of profiles")
224
+ postgres = [_parse_postgres(name, raw) for name, raw in postgres_raw.items()]
225
+ policy = Policy(
226
+ mode=str(data.get("mode", MODE_MONITOR)),
227
+ port=int(data.get("port", 8080)),
228
+ on_leak=str(data.get("on_leak", ON_LEAK_BLOCK)),
229
+ detect_patterns=bool(data.get("detect_patterns", True)),
230
+ audit_log=str(data.get("audit_log", ".sproxy/audit.jsonl")),
231
+ watch_values=_as_str_list(data.get("watch_values"), "watch_values"),
232
+ passthrough_hosts=_as_str_list(data.get("passthrough_hosts"), "passthrough_hosts"),
233
+ secrets=secrets,
234
+ postgres=postgres,
235
+ confdir=str(data.get("confdir", "")),
236
+ )
237
+ policy.validate()
238
+ return policy
239
+
240
+
241
+ def load(path: str | Path) -> Policy:
242
+ p = Path(path)
243
+ try:
244
+ data = tomllib.loads(p.read_text(encoding="utf-8"))
245
+ except FileNotFoundError:
246
+ raise
247
+ except (tomllib.TOMLDecodeError, OSError) as exc:
248
+ raise PolicyError(f"could not read policy {p}: {exc}") from exc
249
+ policy = from_dict(data)
250
+ _anchor_paths(policy, p.resolve().parent)
251
+ return policy
252
+
253
+
254
+ def _anchor_paths(policy: Policy, base: Path) -> None:
255
+ """Resolve config-relative paths against the policy file's directory, so a
256
+ policy behaves the same no matter where sproxy is launched from."""
257
+
258
+ def anchor(value: str) -> str:
259
+ expanded = Path(value).expanduser()
260
+ return str(expanded if expanded.is_absolute() else base / expanded)
261
+
262
+ if policy.audit_log:
263
+ policy.audit_log = anchor(policy.audit_log)
264
+ if policy.confdir:
265
+ policy.confdir = anchor(policy.confdir)
266
+ for profile in policy.postgres:
267
+ if profile.sslrootcert:
268
+ profile.sslrootcert = anchor(profile.sslrootcert)
269
+
270
+
271
+ def default_policy() -> Policy:
272
+ """Safe fallback when no policy file is present: audit everything, block
273
+ nothing, run pattern detection so the user still sees leaks."""
274
+ return Policy(mode=MODE_MONITOR, detect_patterns=True)
sproxy/replacements.py ADDED
@@ -0,0 +1,28 @@
1
+ """Public request placeholders and private per-session replacement handles."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def replacement_tokens(name: str, session_token: str = "") -> tuple[str, ...]:
7
+ """Tokens sproxy may replace with a configured secret's real value.
8
+
9
+ The ``{{NAME}}`` form is stable for hand-crafted requests. A non-empty
10
+ session token is an opaque, per-run value supplied to conventional SDKs
11
+ through their environment variable.
12
+ """
13
+ static = f"{{{{{name}}}}}"
14
+ return (static, session_token) if session_token else (static,)
15
+
16
+
17
+ def replace_request_tokens(
18
+ text: str, name: str, value: str, session_token: str = ""
19
+ ) -> str:
20
+ """Replace every supported request token in ``text`` with ``value``."""
21
+ for token in replacement_tokens(name, session_token):
22
+ text = text.replace(token, value)
23
+ return text
24
+
25
+
26
+ def has_session_token(text: str, session_token: str) -> bool:
27
+ """True when a private per-session handle appears in request text."""
28
+ return bool(session_token) and session_token in text