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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """sproxy - local egress guard for coding agents.
2
+
3
+ Keep this module import-light: it is imported both by the CLI (user's Python)
4
+ and by the mitmproxy addon (mitmdump's bundled interpreter). Only the standard
5
+ library may be imported at package import time.
6
+ """
7
+
8
+ __version__ = "0.1.0"
sproxy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from sproxy.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
sproxy/addon.py ADDED
@@ -0,0 +1,468 @@
1
+ """mitmproxy addon: the egress guard.
2
+
3
+ Loaded by ``mitmdump -s`` inside mitmproxy's bundled interpreter. It reads a
4
+ *resolved* policy (secret values already filled in by the trusted CLI) from the
5
+ file named in ``SPROXY_POLICY`` and adds ``SPROXY_PKG`` to ``sys.path`` so it can
6
+ import the pure-Python ``sproxy`` modules.
7
+
8
+ Per request it:
9
+ 1. injects secrets into requests bound for hosts they're allowed to reach,
10
+ 2. blocks/redacts/warns when a known secret heads to a host it may not,
11
+ 3. runs credential pattern detection (warn/discovery),
12
+ 4. records every request in the tamper-evident audit log.
13
+
14
+ The resolved policy is re-read when the CLI rewrites it (a hot reload of the
15
+ user's policy file). All of the rules live in one immutable :class:`_State`
16
+ object that a reload swaps in wholesale, and each request reads that object
17
+ once, so traffic is never guarded by a half-applied policy.
18
+
19
+ Everything runs under a broad try/except: a bug in the guard must never take
20
+ down the user's agent session, so on error we log and let traffic through.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import os
28
+ import sys
29
+ from datetime import datetime, timezone
30
+ from urllib.parse import urlsplit
31
+
32
+ # How often to check the resolved policy file for a rewrite by the CLI.
33
+ POLICY_POLL_SECONDS = 1.0
34
+
35
+ # Make the installed sproxy package importable inside mitmdump's interpreter.
36
+ _pkg = os.environ.get("SPROXY_PKG")
37
+ if _pkg and _pkg not in sys.path:
38
+ sys.path.insert(0, _pkg)
39
+
40
+ from mitmproxy import http # noqa: E402 (only available inside mitmdump)
41
+
42
+ from sproxy import policy as _policy # noqa: E402
43
+ from sproxy.audit import AuditLog # noqa: E402
44
+ from sproxy.auth import decode_basic_authorization, inject_basic_placeholder # noqa: E402
45
+ from sproxy.replacements import ( # noqa: E402
46
+ has_session_token,
47
+ replacement_tokens,
48
+ replace_request_tokens,
49
+ )
50
+ from sproxy.scanner import Scanner # noqa: E402
51
+
52
+
53
+ def _now() -> str:
54
+ return datetime.now(timezone.utc).isoformat()
55
+
56
+
57
+ def _file_stamp(path: str) -> tuple[int, int] | None:
58
+ """Cheap change detector: (mtime_ns, size), or None when unreadable."""
59
+ if not path:
60
+ return None
61
+ try:
62
+ st = os.stat(path)
63
+ except OSError:
64
+ return None
65
+ return st.st_mtime_ns, st.st_size
66
+
67
+
68
+ class _Secret:
69
+ __slots__ = ("name", "value", "allow_hosts", "session_token", "anywhere")
70
+
71
+ def __init__(
72
+ self,
73
+ name: str,
74
+ value: str,
75
+ allow_hosts: list[str],
76
+ session_token: str = "",
77
+ anywhere: bool = False,
78
+ ):
79
+ self.name = name
80
+ self.value = value
81
+ self.allow_hosts = allow_hosts
82
+ self.session_token = session_token
83
+ self.anywhere = anywhere
84
+
85
+ def allowed_to(self, host: str) -> bool:
86
+ return self.anywhere or _policy.host_matches(host, self.allow_hosts)
87
+
88
+
89
+ class _State:
90
+ """One immutable snapshot of the rules: everything a reload can change."""
91
+
92
+ __slots__ = (
93
+ "mode", "on_leak", "detect_patterns",
94
+ "passthrough_hosts", "secrets", "values", "scanner",
95
+ "managed", "watched", "revoked",
96
+ )
97
+
98
+ def __init__(self, resolved: dict):
99
+ self.mode = resolved.get("mode", _policy.MODE_MONITOR)
100
+ self.on_leak = resolved.get("on_leak", _policy.ON_LEAK_BLOCK)
101
+ self.detect_patterns = bool(resolved.get("detect_patterns", True))
102
+ # Hosts to tunnel WITHOUT TLS interception (e.g. certificate pinning).
103
+ # sproxy cannot see inside these connections - they are audited as
104
+ # uninspected.
105
+ self.passthrough_hosts = resolved.get("passthrough_hosts", [])
106
+
107
+ self.secrets = [
108
+ _Secret(
109
+ s["name"],
110
+ s["value"],
111
+ s.get("allow_hosts", []),
112
+ s.get("session_token", ""),
113
+ anywhere=not s.get("allow_hosts"),
114
+ )
115
+ for s in resolved.get("secrets", [])
116
+ if s.get("value")
117
+ ]
118
+ self.managed = len(self.secrets)
119
+ # watch_values are secrets that may go nowhere: any appearance is a leak.
120
+ for i, v in enumerate(resolved.get("watch_values", [])):
121
+ if v:
122
+ self.secrets.append(_Secret(f"watch_{i}", v, []))
123
+ self.watched = len(self.secrets) - self.managed
124
+ # Handles for secrets a reload dropped from the policy. The guarded
125
+ # process still holds them in its environment, so they must keep being
126
+ # recognised - but they now unlock nothing and may go nowhere.
127
+ self.revoked = 0
128
+ for handle in resolved.get("revoked_handles", []):
129
+ token = handle.get("session_token")
130
+ if token:
131
+ self.secrets.append(
132
+ _Secret(f"revoked:{handle.get('name', '?')}", "", [], token)
133
+ )
134
+ self.revoked += 1
135
+
136
+ self.values = {s.name: s.value for s in self.secrets}
137
+ self.scanner = Scanner(detect_patterns=self.detect_patterns)
138
+
139
+ def summary(self) -> str:
140
+ parts = [
141
+ f"mode={self.mode}",
142
+ f"on_leak={self.on_leak}",
143
+ f"secrets={self.managed}",
144
+ ]
145
+ if self.watched:
146
+ parts.append(f"watched={self.watched}")
147
+ if self.revoked:
148
+ parts.append(f"revoked={self.revoked}")
149
+ return " ".join(parts)
150
+
151
+
152
+ class EgressGuard:
153
+ def __init__(self, resolved: dict, policy_path: str = ""):
154
+ self._state = _State(resolved)
155
+ self._policy_path = policy_path
156
+ self._stamp = _file_stamp(policy_path)
157
+ self._poller: asyncio.Task | None = None
158
+
159
+ self._audit_path = resolved.get("audit_log") or ""
160
+ self.audit = AuditLog(self._audit_path) if self._audit_path else None
161
+
162
+ # -- reload ----------------------------------------------------------
163
+
164
+ def running(self) -> None:
165
+ """mitmproxy lifecycle hook: start watching the resolved policy file."""
166
+ if not self._policy_path or self._poller is not None:
167
+ return
168
+ try:
169
+ self._poller = asyncio.ensure_future(self._poll_policy())
170
+ except RuntimeError: # no event loop (unit tests)
171
+ self._poller = None
172
+
173
+ def done(self) -> None:
174
+ if self._poller is not None:
175
+ self._poller.cancel()
176
+ self._poller = None
177
+
178
+ async def _poll_policy(self) -> None:
179
+ """Poll rather than watch: the file lives in a private temp dir that may
180
+ sit on a filesystem inotify does not report (containers, tmpfs mounts)."""
181
+ while True:
182
+ await asyncio.sleep(POLICY_POLL_SECONDS)
183
+ try:
184
+ self.maybe_reload()
185
+ except Exception as exc: # never let the watcher die
186
+ print(f"sproxy: policy poll error: {exc}", file=sys.stderr, flush=True)
187
+
188
+ def maybe_reload(self) -> bool:
189
+ """Swap in a rewritten policy file. True when new rules took effect."""
190
+ stamp = _file_stamp(self._policy_path)
191
+ if stamp is None or stamp == self._stamp:
192
+ return False
193
+ # Record the stamp either way: a file we cannot use must not be
194
+ # retried (and re-logged) on every poll.
195
+ self._stamp = stamp
196
+ try:
197
+ with open(self._policy_path, encoding="utf-8") as fh:
198
+ resolved = json.load(fh)
199
+ state = _State(resolved)
200
+ except Exception as exc:
201
+ print(
202
+ f"sproxy: ignoring unreadable policy update, keeping the "
203
+ f"previous rules: {exc}",
204
+ file=sys.stderr,
205
+ flush=True,
206
+ )
207
+ return False
208
+ audit_path = resolved.get("audit_log") or ""
209
+ if audit_path != self._audit_path:
210
+ try:
211
+ self.audit = AuditLog(audit_path) if audit_path else None
212
+ self._audit_path = audit_path
213
+ except OSError as exc:
214
+ print(f"sproxy: keeping the previous audit log: {exc}",
215
+ file=sys.stderr, flush=True)
216
+ # One rebind, so a request in flight keeps the rules it started with.
217
+ self._state = state
218
+ print(f"sproxy: rules reloaded ({state.summary()})", file=sys.stderr, flush=True)
219
+ return True
220
+
221
+ # -- helpers ---------------------------------------------------------
222
+
223
+ def _enforcing(self, state: _State) -> bool:
224
+ return state.mode == _policy.MODE_ENFORCE
225
+
226
+ def _safe_path(self, url: str, state: _State | None = None) -> str:
227
+ state = self._state if state is None else state
228
+ try:
229
+ path = urlsplit(url).path
230
+ for s in state.secrets:
231
+ for value in (s.value, s.session_token):
232
+ if value:
233
+ path = path.replace(value, f"[sproxy:redacted:{s.name}]")
234
+ return path
235
+ except Exception:
236
+ return ""
237
+
238
+ def _emit(self, *, decision: str, method: str, host: str, path: str = "",
239
+ scheme: str = "https", nbytes: int = 0, rules: list[str] | None = None,
240
+ mode: str = "") -> None:
241
+ rules = rules or []
242
+ line = f"sproxy: {decision.upper():11} {method} {host}{path}"
243
+ if rules:
244
+ line += f" [{', '.join(rules)}]"
245
+ print(line, file=sys.stderr, flush=True)
246
+ if self.audit is None:
247
+ return
248
+ try:
249
+ self.audit.append(
250
+ {
251
+ "ts": _now(),
252
+ "mode": mode or self._state.mode,
253
+ "method": method,
254
+ "scheme": scheme,
255
+ "host": host,
256
+ "path": path,
257
+ "bytes": nbytes,
258
+ "decision": decision,
259
+ "rules": rules,
260
+ }
261
+ )
262
+ except Exception as exc: # never let audit failure break traffic
263
+ print(f"sproxy: audit write failed: {exc}", file=sys.stderr, flush=True)
264
+
265
+ def _log(self, flow: http.HTTPFlow, decision: str, rules: list[str],
266
+ state: _State | None = None, path: str | None = None) -> None:
267
+ state = self._state if state is None else state
268
+ req = flow.request
269
+ self._emit(
270
+ decision=decision, method=req.method, host=req.pretty_host,
271
+ path=self._safe_path(req.pretty_url, state) if path is None else path,
272
+ scheme=req.scheme,
273
+ nbytes=len(req.raw_content or b""), rules=rules, mode=state.mode,
274
+ )
275
+
276
+ def _block(self, flow: http.HTTPFlow, reason: str, rules: list[str],
277
+ state: _State | None = None, path: str | None = None) -> None:
278
+ """Refuse the request with 407 Proxy Authentication Required.
279
+ """
280
+ payload = json.dumps(
281
+ {"error": "blocked_by_sproxy", "reason": reason, "rules": rules}
282
+ )
283
+ # RFC 9110 requires a challenge on a 407. The scheme is deliberately one
284
+ # no client can satisfy: the request is refused, not retryable with
285
+ # credentials. It also carries the reason for clients that only surface
286
+ # headers.
287
+ quoted = reason.replace('"', "'")
288
+ challenge = f'SProxy realm="sproxy", reason="{quoted}"'
289
+ flow.response = http.Response.make(
290
+ 407,
291
+ payload.encode(),
292
+ {
293
+ "content-type": "application/json",
294
+ "x-sproxy": "blocked",
295
+ "proxy-authenticate": challenge,
296
+ },
297
+ )
298
+ self._log(flow, "blocked", rules, state, path)
299
+
300
+ # -- injection -------------------------------------------------------
301
+
302
+ def _inject(self, flow: http.HTTPFlow, host: str,
303
+ state: _State | None = None) -> None:
304
+ state = self._state if state is None else state
305
+ req = flow.request
306
+ for s in state.secrets:
307
+ if not s.value or not s.allowed_to(host):
308
+ continue
309
+ tokens = replacement_tokens(s.name, s.session_token)
310
+
311
+ for key, val in list(req.headers.items()):
312
+ val = replace_request_tokens(val, s.name, s.value, s.session_token)
313
+ for token in tokens:
314
+ # curl's --user option sends a placeholder as a
315
+ # Base64-encoded HTTP Basic Authorization header.
316
+ val = inject_basic_placeholder(val, token, s.value)
317
+ req.headers[key] = val
318
+ # POC-compatible: a header literally named after the secret.
319
+ if s.name in req.headers:
320
+ req.headers[s.name] = s.value
321
+
322
+ for key, val in list(req.query.items()):
323
+ req.query[key] = replace_request_tokens(
324
+ val, s.name, s.value, s.session_token
325
+ )
326
+ if s.name in req.query:
327
+ req.query[s.name] = s.value
328
+
329
+ text = req.get_text(strict=False)
330
+ if text:
331
+ req.set_text(
332
+ replace_request_tokens(text, s.name, s.value, s.session_token)
333
+ )
334
+
335
+ # -- egress scanning -------------------------------------------------
336
+
337
+ def _surface(self, flow: http.HTTPFlow) -> str:
338
+ req = flow.request
339
+ parts = [req.pretty_url]
340
+ for _, val in req.headers.items():
341
+ parts.append(val)
342
+ # Include Basic credentials in the scan. This preserves the
343
+ # per-secret host guard even when a client has Base64-encoded them.
344
+ credentials = decode_basic_authorization(val)
345
+ if credentials is not None:
346
+ parts.append(credentials)
347
+ text = req.get_text(strict=False)
348
+ if text:
349
+ parts.append(text)
350
+ return "\n".join(parts)
351
+
352
+ def _leaked_secrets(self, surface: str, host: str,
353
+ state: _State | None = None) -> list[str]:
354
+ """Secret values or session handles bound for the wrong host."""
355
+ state = self._state if state is None else state
356
+ leaked = []
357
+ for s in state.secrets:
358
+ secret_present = s.value and s.value in surface
359
+ handle_present = has_session_token(surface, s.session_token)
360
+ if (secret_present or handle_present) and not s.allowed_to(host):
361
+ leaked.append(s.name)
362
+ return leaked
363
+
364
+ def _pattern_findings(self, surface: str,
365
+ state: _State | None = None) -> list[str]:
366
+ state = self._state if state is None else state
367
+ # Remove known secret values first so injected/known secrets aren't
368
+ # double-reported as generic pattern hits.
369
+ cleaned = surface
370
+ for v in state.values.values():
371
+ if v:
372
+ cleaned = cleaned.replace(v, "")
373
+ for s in state.secrets:
374
+ if s.session_token:
375
+ cleaned = cleaned.replace(s.session_token, "")
376
+ return [f"pattern:{f.name}" for f in state.scanner.scan(cleaned) if f.kind == "pattern"]
377
+
378
+ def _redact(self, flow: http.HTTPFlow, names: list[str],
379
+ state: _State | None = None) -> None:
380
+ state = self._state if state is None else state
381
+ wanted = {
382
+ s.name: tuple(value for value in (s.value, s.session_token) if value)
383
+ for s in state.secrets
384
+ if s.name in names
385
+ }
386
+ marker = lambda n: f"[sproxy:redacted:{n}]" # noqa: E731
387
+ req = flow.request
388
+
389
+ def scrub(text: str) -> str:
390
+ for n, values in wanted.items():
391
+ for value in values:
392
+ if value in text:
393
+ text = text.replace(value, marker(n))
394
+ return text
395
+
396
+ for key, val in list(req.headers.items()):
397
+ req.headers[key] = scrub(val)
398
+ for key, val in list(req.query.items()):
399
+ req.query[key] = scrub(val)
400
+ text = req.get_text(strict=False)
401
+ if text:
402
+ req.set_text(scrub(text))
403
+
404
+ # -- mitmproxy hooks -------------------------------------------------
405
+
406
+ def tls_clienthello(self, data) -> None:
407
+ """Tunnel configured hosts without interception (for pinned clients).
408
+
409
+ The connection is allowed, but its contents cannot be inspected, so we
410
+ record it as uninspected rather than pretend it was guarded.
411
+ """
412
+ try:
413
+ sni = getattr(getattr(data, "client_hello", None), "sni", None) or ""
414
+ if sni and _policy.host_matches(sni, self._state.passthrough_hosts):
415
+ data.ignore_connection = True
416
+ self._emit(decision="passthrough", method="CONNECT", host=sni,
417
+ rules=["tls:uninspected"])
418
+ except Exception as exc: # fail open
419
+ print(f"sproxy: tls_clienthello error: {exc}", file=sys.stderr, flush=True)
420
+
421
+ def request(self, flow: http.HTTPFlow) -> None:
422
+ try:
423
+ self._handle(flow)
424
+ except Exception as exc: # fail open: never break the agent's session
425
+ print(f"sproxy: internal error, allowing request: {exc}", file=sys.stderr, flush=True)
426
+
427
+ def _handle(self, flow: http.HTTPFlow) -> None:
428
+ # Take the rules once: a policy reload landing mid-request must not
429
+ # inject under one policy and then judge the result under another.
430
+ state = self._state
431
+ host = flow.request.pretty_host
432
+
433
+ # 1. Inject secrets destined for hosts that are allowed to receive them.
434
+ self._inject(flow, host, state)
435
+
436
+ # 2+3. Scan the (post-injection) request for leaks and credential shapes.
437
+ surface = self._surface(flow)
438
+ leaked = self._leaked_secrets(surface, host, state)
439
+ patterns = self._pattern_findings(surface, state)
440
+ rules = [f"secret:{n}" for n in leaked] + patterns
441
+
442
+ # 4. Decide.
443
+ if leaked and self._enforcing(state):
444
+ if state.on_leak == _policy.ON_LEAK_BLOCK:
445
+ self._block(
446
+ flow, f"secret(s) {leaked} not allowed to {host}", rules, state
447
+ )
448
+ return
449
+ if state.on_leak == _policy.ON_LEAK_REDACT:
450
+ self._redact(flow, leaked, state)
451
+ self._log(flow, "redacted", rules, state)
452
+ return
453
+ # on_leak == warn falls through to allow-with-warning.
454
+
455
+ decision = "warn" if rules else "allow"
456
+ self._log(flow, decision, rules, state)
457
+
458
+
459
+ def _load_guard() -> EgressGuard:
460
+ path = os.environ.get("SPROXY_POLICY")
461
+ if not path:
462
+ print("sproxy: SPROXY_POLICY not set; running in pass-through monitor mode", file=sys.stderr)
463
+ return EgressGuard({"mode": _policy.MODE_MONITOR, "detect_patterns": True})
464
+ with open(path, encoding="utf-8") as fh:
465
+ return EgressGuard(json.load(fh), policy_path=path)
466
+
467
+
468
+ addons = [_load_guard()]
sproxy/audit.py ADDED
@@ -0,0 +1,88 @@
1
+ """Tamper-evident JSONL audit log.
2
+
3
+ Each line is one request decision. Lines are chained: every entry stores the
4
+ SHA-256 of the previous entry, and its own hash covers that link plus all its
5
+ own fields. Deleting, reordering, or editing any line breaks the chain from
6
+ that point on, which ``verify`` detects. This is the artifact a security team
7
+ wants after an incident: a log that can't be quietly doctored.
8
+
9
+ Secret *values* are never written here - only rule names (see scanner.Finding).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from pathlib import Path
17
+
18
+ GENESIS = "0" * 64
19
+
20
+
21
+ def _canonical(entry: dict) -> str:
22
+ return json.dumps(entry, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
23
+
24
+
25
+ def _hash(prev: str, core: dict) -> str:
26
+ return hashlib.sha256((prev + _canonical(core)).encode("utf-8")).hexdigest()
27
+
28
+
29
+ class AuditLog:
30
+ def __init__(self, path: str | Path):
31
+ self.path = Path(path)
32
+ self._seq, self._prev = self._resume()
33
+
34
+ def _resume(self) -> tuple[int, str]:
35
+ """Continue an existing chain, or start a fresh one."""
36
+ if not self.path.exists():
37
+ self.path.parent.mkdir(parents=True, exist_ok=True)
38
+ return 0, GENESIS
39
+ last = None
40
+ with self.path.open("r", encoding="utf-8") as fh:
41
+ for line in fh:
42
+ line = line.strip()
43
+ if line:
44
+ last = line
45
+ if last is None:
46
+ return 0, GENESIS
47
+ entry = json.loads(last)
48
+ return entry["seq"] + 1, entry["hash"]
49
+
50
+ def append(self, fields: dict) -> dict:
51
+ """Append one decision. ``fields`` must already be free of secret values."""
52
+ core = {"seq": self._seq, "prev": self._prev, **fields}
53
+ digest = _hash(self._prev, core)
54
+ entry = {**core, "hash": digest}
55
+ with self.path.open("a", encoding="utf-8") as fh:
56
+ fh.write(_canonical(entry) + "\n")
57
+ self._seq += 1
58
+ self._prev = digest
59
+ return entry
60
+
61
+
62
+ def verify(path: str | Path) -> tuple[bool, int, str]:
63
+ """Re-walk the chain. Returns (ok, entries_checked, message)."""
64
+ p = Path(path)
65
+ if not p.exists():
66
+ return False, 0, f"no such audit log: {p}"
67
+ prev = GENESIS
68
+ count = 0
69
+ with p.open("r", encoding="utf-8") as fh:
70
+ for lineno, raw in enumerate(fh, start=1):
71
+ raw = raw.strip()
72
+ if not raw:
73
+ continue
74
+ try:
75
+ entry = json.loads(raw)
76
+ except json.JSONDecodeError as exc:
77
+ return False, count, f"line {lineno}: invalid JSON ({exc})"
78
+ stored = entry.get("hash")
79
+ core = {k: v for k, v in entry.items() if k != "hash"}
80
+ if core.get("seq") != count:
81
+ return False, count, f"line {lineno}: seq {core.get('seq')} != expected {count}"
82
+ if core.get("prev") != prev:
83
+ return False, count, f"line {lineno}: broken chain (prev mismatch)"
84
+ if _hash(prev, core) != stored:
85
+ return False, count, f"line {lineno}: hash mismatch (entry was altered)"
86
+ prev = stored
87
+ count += 1
88
+ return True, count, f"OK: {count} entries, chain intact"
sproxy/auth.py ADDED
@@ -0,0 +1,34 @@
1
+ """Small, dependency-free helpers for HTTP authentication headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+
8
+
9
+ def decode_basic_authorization(value: str) -> str | None:
10
+ """Return Basic-auth credentials as text, or ``None`` for another scheme."""
11
+ parts = value.split(None, 1)
12
+ if len(parts) != 2 or parts[0].lower() != "basic":
13
+ return None
14
+ try:
15
+ return base64.b64decode(parts[1], validate=True).decode("utf-8")
16
+ except (binascii.Error, UnicodeDecodeError):
17
+ return None
18
+
19
+
20
+ def inject_basic_placeholder(value: str, placeholder: str, secret: str) -> str:
21
+ """Replace a placeholder inside an HTTP Basic-auth header, if present.
22
+
23
+ Clients such as curl Base64-encode ``--user`` before the proxy receives the
24
+ request, so ordinary text replacement cannot see a placeholder there.
25
+ Invalid/non-Basic values are returned unchanged.
26
+ """
27
+ credentials = decode_basic_authorization(value)
28
+ if credentials is None or placeholder not in credentials:
29
+ return value
30
+ scheme = value.split(None, 1)[0]
31
+ encoded = base64.b64encode(
32
+ credentials.replace(placeholder, secret).encode("utf-8")
33
+ ).decode("ascii")
34
+ return f"{scheme} {encoded}"
sproxy/ca.py ADDED
@@ -0,0 +1,69 @@
1
+ """mitmproxy CA certificate discovery and the environment a proxied child needs.
2
+
3
+ mitmproxy generates its CA on first run under ``confdir`` (default ``~/.mitmproxy``)
4
+ as ``mitmproxy-ca-cert.pem``. Coding agents and their tools verify TLS against
5
+ their own trust stores, so we point the well-known CA env vars at that file
6
+ rather than touching the system trust store (which would need sudo).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ CERT_NAME = "mitmproxy-ca-cert.pem"
16
+
17
+
18
+ def default_confdir() -> Path:
19
+ return Path.home() / ".mitmproxy"
20
+
21
+
22
+ def cert_path(confdir: str | Path | None = None) -> Path:
23
+ base = Path(confdir) if confdir else default_confdir()
24
+ return base.expanduser() / CERT_NAME
25
+
26
+
27
+ def wait_for_cert(path: str | Path, timeout: float = 15.0) -> bool:
28
+ """Poll until the CA cert exists and is non-empty."""
29
+ p = Path(path)
30
+ deadline = time.monotonic() + timeout
31
+ while time.monotonic() < deadline:
32
+ if p.exists() and p.stat().st_size > 0:
33
+ return True
34
+ time.sleep(0.1)
35
+ return p.exists() and p.stat().st_size > 0
36
+
37
+
38
+ def proxy_env(port: int, cert: str | Path, base: dict | None = None) -> dict:
39
+ """Return the environment for a child process routed through the proxy.
40
+
41
+ Covers Node/Claude Code (``NODE_EXTRA_CA_CERTS``), Python requests/httpx,
42
+ curl and git, and both upper/lower-case proxy variables. Node 24's native
43
+ HTTP clients only honour proxy environment variables when
44
+ ``NODE_USE_ENV_PROXY`` is enabled.
45
+ """
46
+ env = dict(os.environ if base is None else base)
47
+ url = f"http://127.0.0.1:{port}"
48
+ cert = str(cert)
49
+ env.update(
50
+ {
51
+ "HTTP_PROXY": url,
52
+ "HTTPS_PROXY": url,
53
+ "http_proxy": url,
54
+ "https_proxy": url,
55
+ "ALL_PROXY": url,
56
+ # Enables HTTP(S)_PROXY support in Node's built-in fetch/http
57
+ # clients (used by tools such as the bundled Sentry CLI).
58
+ "NODE_USE_ENV_PROXY": "1",
59
+ "NODE_EXTRA_CA_CERTS": cert,
60
+ "SSL_CERT_FILE": cert,
61
+ "REQUESTS_CA_BUNDLE": cert,
62
+ "CURL_CA_BUNDLE": cert,
63
+ "GIT_SSL_CAINFO": cert,
64
+ # Don't route localhost through the proxy.
65
+ "NO_PROXY": "localhost,127.0.0.1,::1",
66
+ "no_proxy": "localhost,127.0.0.1,::1",
67
+ }
68
+ )
69
+ return env