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/runner.py ADDED
@@ -0,0 +1,850 @@
1
+ """Orchestration: launch mitmdump with the guard addon, then run the agent.
2
+
3
+ Trust boundary: this process is trusted and resolves the real secret values. It
4
+ writes them to a 0600 file that only the *mitmdump* subprocess is told about
5
+ (via ``SPROXY_POLICY``). The agent child process is given the proxy/CA env plus
6
+ opaque, per-session replacement handles, but never real secret values or that
7
+ policy path. Any env-sourced secrets are scrubbed before those handles are
8
+ added. PostgreSQL passwords likewise stay in this process: the child only
9
+ receives local gateway endpoints and a per-session token (see :mod:`sproxy.pg`).
10
+
11
+ The policy file is watched while the session runs, so editing it takes effect
12
+ without a restart (see :class:`LiveSession`). A reload changes the *rules*, never
13
+ the session's identity: every handle already issued - secret handles, the
14
+ postgres token, the local gateway ports - outlives it, because a child process's
15
+ environment is frozen at exec time and revoking it would break a running agent.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import shlex
25
+ import shutil
26
+ import signal
27
+ import socket
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ from collections.abc import Iterable
34
+ from pathlib import Path
35
+
36
+ from sproxy import ca, pg
37
+ from sproxy.policy import Policy, load
38
+ from sproxy.secrets_resolver import SecretResolutionError, resolve
39
+
40
+ _PKG_DIR = Path(__file__).resolve().parent
41
+ _ADDON = _PKG_DIR / "addon.py"
42
+ _PKG_PARENT = str(_PKG_DIR.parent)
43
+ _ENV_VAR_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
44
+ _SESSION_TOKEN_PREFIX = "sproxy_v1_"
45
+
46
+
47
+ class RunnerError(RuntimeError):
48
+ pass
49
+
50
+
51
+ class SessionIdentity:
52
+ """The handles a session hands out, which a policy reload must not rotate.
53
+
54
+ The guarded process reads its credentials from its environment once, at
55
+ startup. So the *names* stay valid for the whole session and only what they
56
+ unlock changes: a reloaded policy re-resolves a secret's value behind the
57
+ same handle, and a postgres profile keeps its token and local port. A secret
58
+ dropped from the policy has its handle revoked rather than forgotten - see
59
+ :meth:`revoked_handles`.
60
+ """
61
+
62
+ def __init__(self) -> None:
63
+ self._pg_token = ""
64
+ self.secret_tokens: dict[str, str] = {}
65
+ self.pg_ports: dict[str, int] = {}
66
+
67
+ @property
68
+ def pg_token(self) -> str:
69
+ """The postgres gateway password, minted once per session on first use."""
70
+ if not self._pg_token:
71
+ self._pg_token = secrets.token_urlsafe(24)
72
+ return self._pg_token
73
+
74
+ def secret_token(self, name: str) -> str:
75
+ token = self.secret_tokens.get(name)
76
+ if token is None:
77
+ token = _SESSION_TOKEN_PREFIX + secrets.token_urlsafe(32)
78
+ self.secret_tokens[name] = token
79
+ return token
80
+
81
+ def revoked_handles(self, live: Iterable[str]) -> list[dict]:
82
+ """Handles issued for secrets the policy no longer defines.
83
+
84
+ The guarded process still has them in its environment, so the guard
85
+ keeps recognising them - they just unlock nothing now, and turning up in
86
+ a request counts as a leak.
87
+ """
88
+ current = set(live)
89
+ return [
90
+ {"name": name, "session_token": token}
91
+ for name, token in self.secret_tokens.items()
92
+ if name not in current
93
+ ]
94
+
95
+ def remember_ports(self, endpoints: list[pg.Endpoint]) -> None:
96
+ for endpoint in endpoints:
97
+ if endpoint.bound_port:
98
+ self.pg_ports[endpoint.profile.name] = endpoint.bound_port
99
+
100
+
101
+ def find_mitmdump(override: str | None = None) -> str:
102
+ path = override or shutil.which("mitmdump")
103
+ if not path:
104
+ raise RunnerError(
105
+ "mitmdump not found on PATH. Install mitmproxy "
106
+ "(https://mitmproxy.org/) or `pip install \"mitmproxy>=11\"`, "
107
+ "or pass --mitmdump /path/to/mitmdump."
108
+ )
109
+ return path
110
+
111
+
112
+ def resolve_policy(policy: Policy, identity: SessionIdentity | None = None) -> dict:
113
+ """Resolve secret sources to values, returning the dict handed to the addon."""
114
+ identity = identity or SessionIdentity()
115
+ resolved_secrets = []
116
+ for spec in policy.secrets:
117
+ try:
118
+ value = resolve(spec.source)
119
+ except SecretResolutionError as exc:
120
+ raise RunnerError(f"secret {spec.name!r}: {exc}") from exc
121
+ resolved_secret = {
122
+ "name": spec.name,
123
+ "value": value,
124
+ "allow_hosts": spec.allow_hosts,
125
+ }
126
+ # Conventional SDKs read a configured environment variable rather than
127
+ # a literal request placeholder. Give valid variable names an opaque,
128
+ # per-session handle that the addon can replace in transit. The handle
129
+ # belongs to the session, so a reload re-resolves the value behind it.
130
+ if _ENV_VAR_NAME.fullmatch(spec.name):
131
+ resolved_secret["session_token"] = identity.secret_token(spec.name)
132
+ resolved_secrets.append(resolved_secret)
133
+ return {
134
+ "mode": policy.mode,
135
+ "on_leak": policy.on_leak,
136
+ "detect_patterns": policy.detect_patterns,
137
+ "audit_log": str(Path(policy.audit_log).expanduser()) if policy.audit_log else "",
138
+ "watch_values": policy.watch_values,
139
+ "passthrough_hosts": policy.passthrough_hosts,
140
+ "secrets": resolved_secrets,
141
+ "revoked_handles": identity.revoked_handles(s.name for s in policy.secrets),
142
+ }
143
+
144
+
145
+ def resolve_postgres(
146
+ policy: Policy, identity: SessionIdentity | None = None
147
+ ) -> list[pg.Endpoint]:
148
+ """Resolve each profile's password source. Values never leave this process."""
149
+ endpoints = []
150
+ for profile in policy.postgres:
151
+ try:
152
+ password = resolve(profile.password_source)
153
+ except SecretResolutionError as exc:
154
+ raise RunnerError(f"postgres profile {profile.name!r}: {exc}") from exc
155
+ endpoint = pg.Endpoint(profile, password)
156
+ if identity is not None:
157
+ # The port this profile last served on, so a profile that is removed
158
+ # from the policy and put back keeps the endpoint already advertised.
159
+ endpoint.bound_port = identity.pg_ports.get(profile.name)
160
+ endpoints.append(endpoint)
161
+ return endpoints
162
+
163
+
164
+ def _pg_audit_path(audit_log: str) -> str:
165
+ """Sibling audit file for the gateway - each hash chain has one writer."""
166
+ if not audit_log:
167
+ return ""
168
+ p = Path(audit_log).expanduser()
169
+ return str(p.with_name(p.stem + "-postgres" + p.suffix))
170
+
171
+
172
+ def _write_policy_file(path: Path, resolved: dict) -> None:
173
+ """Publish the resolved policy for the addon: 0600, and atomically.
174
+
175
+ The addon re-reads this file whenever it changes, so the swap must be a
176
+ rename - a guard that read a half-written file would be a guard with no
177
+ rules. The temporary file is created in the same private directory.
178
+ """
179
+ tmp = path.with_name(path.name + ".new")
180
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
181
+ try:
182
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
183
+ json.dump(resolved, fh)
184
+ fh.flush()
185
+ os.fsync(fh.fileno())
186
+ os.replace(tmp, path)
187
+ except BaseException:
188
+ tmp.unlink(missing_ok=True)
189
+ raise
190
+
191
+
192
+ def _file_stamp(path: str | Path | None) -> tuple[int, int] | None:
193
+ """Cheap change detector: (mtime_ns, size), or None when unreadable."""
194
+ if not path:
195
+ return None
196
+ try:
197
+ st = os.stat(path)
198
+ except OSError:
199
+ return None
200
+ return st.st_mtime_ns, st.st_size
201
+
202
+
203
+ def session_env_path() -> Path:
204
+ """Well-known location of the live session's environment file.
205
+
206
+ Lets shells that were not started by ``sproxy run`` join the session
207
+ (``eval "$(sproxy env)"``) - the ssh-agent pattern. XDG_RUNTIME_DIR is a
208
+ per-user tmpfs cleaned on logout; the home fallback covers systems
209
+ without it.
210
+ """
211
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
212
+ base = Path(runtime) / "sproxy" if runtime else Path.home() / ".sproxy"
213
+ return base / "session.env"
214
+
215
+
216
+ def _write_session_env(
217
+ path: Path,
218
+ port: int,
219
+ additions: dict[str, str],
220
+ scrub: set[str],
221
+ replacements: list[str] | None = None,
222
+ session_handles: set[str] | None = None,
223
+ ) -> str:
224
+ """Write the session's env additions as shell export lines, mode 0600.
225
+
226
+ ``scrub`` names env-sourced secrets: joining shells may still carry the
227
+ real values that ``sproxy run`` strips from its own child, so the file
228
+ unsets them. ``replacements`` are rendered as comments so ``sproxy env``
229
+ can advertise the request placeholders without exposing resolved values or
230
+ breaking ``eval "$(sproxy env)"``. ``session_handles`` are environment
231
+ variable names whose exported values are temporary local capabilities.
232
+ """
233
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
234
+ lines = [
235
+ '# sproxy session environment - join with: eval "$(sproxy env)"',
236
+ f"# sproxy-port: {port}",
237
+ ]
238
+ replacements = replacements or []
239
+ if replacements:
240
+ lines.append("# sproxy replacements:")
241
+ for token in replacements:
242
+ # A TOML quoted key can technically contain newlines. Keep every
243
+ # placeholder on its comment line so sourcing the file stays safe.
244
+ safe_token = token.replace("\r", r"\r").replace("\n", r"\n")
245
+ lines.append(f"# {safe_token}")
246
+ else:
247
+ lines.append("# sproxy replacements: (none)")
248
+ session_handles = session_handles or set()
249
+ if session_handles:
250
+ lines.append("# sproxy temporary secret handles:")
251
+ for name in sorted(session_handles):
252
+ lines.append(f"# {name} (session-only)")
253
+ for name in sorted(scrub):
254
+ lines.append(f"unset {name}")
255
+ for key, value in additions.items():
256
+ lines.append(f"export {key}={shlex.quote(value)}")
257
+ content = "\n".join(lines) + "\n"
258
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
259
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
260
+ fh.write(content)
261
+ return content
262
+
263
+
264
+ def _print_session_env(content: str, *, stream=None) -> None:
265
+ """Print the exact shell exports available from ``sproxy env``."""
266
+ destination = stream or sys.stderr
267
+ print("sproxy: session environment (same as `sproxy env`):", file=destination)
268
+ print(content, end="" if content.endswith("\n") else "\n", file=destination, flush=True)
269
+
270
+
271
+ def _env_sourced_names(policy: Policy) -> set[str]:
272
+ names = set()
273
+ for spec in policy.secrets:
274
+ backend, _, path = spec.source.partition(":")
275
+ if backend == "env" and path:
276
+ names.add(path)
277
+ return names
278
+
279
+
280
+ def _session_secret_env(resolved: dict) -> dict[str, str]:
281
+ """Environment variables exposing opaque handles for SDK-style clients."""
282
+ return {
283
+ secret["name"]: secret["session_token"]
284
+ for secret in resolved.get("secrets", [])
285
+ if isinstance(secret.get("name"), str)
286
+ and _ENV_VAR_NAME.fullmatch(secret["name"])
287
+ and isinstance(secret.get("session_token"), str)
288
+ and secret["session_token"]
289
+ }
290
+
291
+
292
+ def _replacement_tokens(policy: Policy) -> list[str]:
293
+ """Return the public placeholders accepted by the request injector."""
294
+ return [f"{{{{{spec.name}}}}}" for spec in policy.secrets]
295
+
296
+
297
+ def _plus_minus(added: Iterable[str], removed: Iterable[str],
298
+ changed: Iterable[str] = ()) -> str:
299
+ return " ".join(
300
+ [f"+{name}" for name in added]
301
+ + [f"-{name}" for name in removed]
302
+ + [f"~{name}" for name in changed]
303
+ )
304
+
305
+
306
+ def _restart_only_notes(live: Policy, new: Policy) -> list[str]:
307
+ """Fields the session is built around and cannot adopt while running."""
308
+ notes = []
309
+ if new.port != live.port:
310
+ notes.append(f"port {live.port} -> {new.port} needs a restart (still on {live.port})")
311
+ if new.confdir != live.confdir:
312
+ notes.append("confdir change needs a restart (CA is already in use)")
313
+ return notes
314
+
315
+
316
+ def _policy_changes(live: Policy, new: Policy) -> list[str]:
317
+ """Human-readable diff of what a reload actually changed. Never values."""
318
+ notes = []
319
+ for field in ("mode", "on_leak", "detect_patterns", "audit_log"):
320
+ before, after = getattr(live, field), getattr(new, field)
321
+ if before != after:
322
+ notes.append(f"{field}: {before} -> {after}")
323
+ before, after = live.passthrough_hosts, new.passthrough_hosts
324
+ summary = _plus_minus(
325
+ [h for h in after if h not in before], [h for h in before if h not in after]
326
+ )
327
+ if summary:
328
+ notes.append(f"passthrough_hosts: {summary}")
329
+ if len(live.watch_values) != len(new.watch_values):
330
+ # Values are secrets in their own right; report the count only.
331
+ notes.append(f"watch_values: {len(live.watch_values)} -> {len(new.watch_values)}")
332
+
333
+ before_secrets = {s.name: s for s in live.secrets}
334
+ after_secrets = {s.name: s for s in new.secrets}
335
+ summary = _plus_minus(
336
+ [n for n in after_secrets if n not in before_secrets],
337
+ [n for n in before_secrets if n not in after_secrets],
338
+ [
339
+ n for n, spec in after_secrets.items()
340
+ if n in before_secrets
341
+ and (
342
+ spec.source != before_secrets[n].source
343
+ or spec.allow_hosts != before_secrets[n].allow_hosts
344
+ )
345
+ ],
346
+ )
347
+ if summary:
348
+ notes.append(f"secrets: {summary}")
349
+ return notes
350
+
351
+
352
+ def _postgres_notes(changes: dict[str, list[str]], gateway) -> list[str]:
353
+ """Spell out what a postgres change means for credentials already issued."""
354
+ notes = []
355
+ ports = {}
356
+ if gateway is not None:
357
+ ports = {e.profile.name: e.bound_port for e in gateway.gateway.endpoints}
358
+ for name in changes.get("added", []):
359
+ notes.append(
360
+ f"postgres +{name}: listening on 127.0.0.1:{ports.get(name)} - shells "
361
+ 'already running cannot see it; join again with eval "$(sproxy env)"'
362
+ )
363
+ for name in changes.get("updated", []):
364
+ notes.append(f"postgres ~{name}: same endpoint and token, applies to the next connection")
365
+ for name in changes.get("rebound", []):
366
+ notes.append(
367
+ f"postgres >{name}: moved to 127.0.0.1:{ports.get(name)} - the endpoint "
368
+ "issued earlier is stale, rejoin the session"
369
+ )
370
+ for name in changes.get("removed", []):
371
+ notes.append(
372
+ f"postgres -{name}: no new connections accepted; open ones keep running"
373
+ )
374
+ return notes
375
+
376
+
377
+ class LiveSession:
378
+ """A running session whose policy file can be re-read on the fly.
379
+
380
+ Holds everything a reload is allowed to touch, and knows what it must not:
381
+ the listen port, the CA, the postgres session token and every secret handle
382
+ already exported into the guarded process are fixed for the session.
383
+ """
384
+
385
+ def __init__(
386
+ self,
387
+ policy: Policy,
388
+ identity: SessionIdentity,
389
+ resolved: dict,
390
+ *,
391
+ config_path: str | Path | None,
392
+ policy_file: Path,
393
+ cert: Path,
394
+ port: int,
395
+ reserved_env: set[str],
396
+ gateway: pg.GatewayThread | None = None,
397
+ stream=None,
398
+ ):
399
+ self.policy = policy
400
+ self.identity = identity
401
+ self.resolved = resolved
402
+ self.config_path = config_path
403
+ self.policy_file = policy_file
404
+ self.cert = cert
405
+ self.port = port
406
+ self.gateway = gateway
407
+ self.session_file: Path | None = None
408
+ self._reserved_env = reserved_env
409
+ self._stream = stream
410
+ self._lock = threading.Lock()
411
+ self._closed = False
412
+
413
+ # -- session environment --------------------------------------------
414
+
415
+ def session_secrets(self) -> dict[str, str]:
416
+ """Handles to export, minus names the guarded environment already uses."""
417
+ reserved = self._reserved_env - _env_sourced_names(self.policy)
418
+ return {
419
+ name: value
420
+ for name, value in _session_secret_env(self.resolved).items()
421
+ if name not in reserved
422
+ }
423
+
424
+ def write_session_env(self) -> str:
425
+ """(Re)write the file that ``sproxy env`` hands to joining shells."""
426
+ additions = ca.proxy_env(self.port, self.cert, base={})
427
+ additions.update(self.session_secrets())
428
+ if self.gateway is not None:
429
+ additions.update(self.gateway.child_env())
430
+ path = session_env_path()
431
+ try:
432
+ content = _write_session_env(
433
+ path,
434
+ self.port,
435
+ additions,
436
+ _env_sourced_names(self.policy),
437
+ _replacement_tokens(self.policy),
438
+ set(self.session_secrets()),
439
+ )
440
+ except OSError as exc:
441
+ self._say(f"sproxy: could not write session env file: {exc}")
442
+ self.session_file = None
443
+ return ""
444
+ self.session_file = path
445
+ return content
446
+
447
+ # -- reload ----------------------------------------------------------
448
+
449
+ def reload(self) -> bool:
450
+ """Re-read the policy file and apply it. True when it took effect.
451
+
452
+ Nothing is applied unless everything resolves: a policy that no longer
453
+ parses, a secret backend that is down or a local port that is taken
454
+ leaves the session running exactly as it was.
455
+ """
456
+ if self.config_path is None:
457
+ return False
458
+ with self._lock:
459
+ if self._closed:
460
+ return False
461
+ try:
462
+ return self._reload()
463
+ except Exception as exc: # a bad edit must never end the session
464
+ self._say(f"sproxy: reload failed, keeping the running policy: {exc}")
465
+ return False
466
+
467
+ def close(self) -> None:
468
+ """Refuse further reloads, waiting for one already under way.
469
+
470
+ Taking the lock is the point: teardown must not race a reload that is
471
+ part-way through binding listeners it would then leave running.
472
+ """
473
+ with self._lock:
474
+ self._closed = True
475
+
476
+ def _reload(self) -> bool:
477
+ new_policy = load(self.config_path)
478
+ pinned = _restart_only_notes(self.policy, new_policy)
479
+ resolved = resolve_policy(new_policy, self.identity)
480
+ endpoints = resolve_postgres(new_policy, self.identity)
481
+ resolved["watch_values"] = [
482
+ *resolved["watch_values"], *(e.password for e in endpoints)
483
+ ]
484
+ # Bind first: a port collision must abort while the old rules still hold.
485
+ pg_changes = self._apply_postgres(endpoints, new_policy)
486
+ self.identity.remember_ports(endpoints)
487
+ # The addon is watching this file - this line is where the new rules
488
+ # start applying to traffic.
489
+ _write_policy_file(self.policy_file, resolved)
490
+
491
+ previous, self.policy, self.resolved = self.policy, new_policy, resolved
492
+ self.write_session_env()
493
+ self._report(_policy_changes(previous, new_policy) + pinned
494
+ + _postgres_notes(pg_changes, self.gateway))
495
+ return True
496
+
497
+ def _apply_postgres(
498
+ self, endpoints: list[pg.Endpoint], new_policy: Policy
499
+ ) -> dict[str, list[str]]:
500
+ audit_log = _pg_audit_path(new_policy.audit_log)
501
+ if self.gateway is None:
502
+ if not endpoints:
503
+ return {}
504
+ # The session started without any database; stand the gateway up now.
505
+ gateway = pg.GatewayThread(
506
+ pg.PostgresGateway(endpoints, self.identity.pg_token, audit_log=audit_log)
507
+ )
508
+ # Keep the thread even if binding fails: it is empty but usable, so
509
+ # the next reload can retry instead of leaking another thread.
510
+ self.gateway = gateway
511
+ gateway.start()
512
+ return {"added": [e.profile.name for e in endpoints]}
513
+ return self.gateway.apply(endpoints, audit_log=audit_log)
514
+
515
+ def _report(self, notes: list[str]) -> None:
516
+ header = f"sproxy: reloaded {self.config_path}"
517
+ if not notes:
518
+ self._say(f"{header} (no effective changes)")
519
+ return
520
+ self._say("\n".join([header] + [f"sproxy: {note}" for note in notes]))
521
+
522
+ def _say(self, message: str) -> None:
523
+ print(message, file=self._stream or sys.stderr, flush=True)
524
+
525
+
526
+ class ConfigWatcher:
527
+ """Reload a :class:`LiveSession` when its policy file changes, or on SIGHUP.
528
+
529
+ Polling rather than inotify: sproxy has no third-party dependencies here,
530
+ and a policy file is often a bind mount or a projected secret that inotify
531
+ would never report a change for.
532
+ """
533
+
534
+ interval = 1.0
535
+ settle = 0.3
536
+
537
+ def __init__(self, session: LiveSession):
538
+ self._session = session
539
+ self._stop = threading.Event()
540
+ self._hup = threading.Event()
541
+ self._previous_sighup = None
542
+ self._thread = threading.Thread(
543
+ target=self._watch, name="sproxy-config", daemon=True
544
+ )
545
+
546
+ def start(self) -> None:
547
+ sighup = getattr(signal, "SIGHUP", None)
548
+ if sighup is not None:
549
+ try:
550
+ self._previous_sighup = signal.signal(sighup, self._on_sighup)
551
+ except ValueError: # not the main thread (tests)
552
+ self._previous_sighup = None
553
+ self._thread.start()
554
+
555
+ def stop(self, timeout: float = 2.0) -> None:
556
+ self._stop.set()
557
+ if self._thread.is_alive():
558
+ self._thread.join(timeout)
559
+ sighup = getattr(signal, "SIGHUP", None)
560
+ if sighup is not None and self._previous_sighup is not None:
561
+ try:
562
+ signal.signal(sighup, self._previous_sighup)
563
+ except ValueError:
564
+ pass
565
+
566
+ def _on_sighup(self, signum, frame) -> None:
567
+ # Signal handlers run on the main thread, where the session may be
568
+ # mid-reload; just ask the watcher to do it.
569
+ self._hup.set()
570
+
571
+ def _watch(self) -> None:
572
+ path = self._session.config_path
573
+ stamp = _file_stamp(path)
574
+ while not self._stop.wait(self.interval):
575
+ if self._hup.is_set():
576
+ self._hup.clear()
577
+ self._session.reload()
578
+ stamp = _file_stamp(path)
579
+ continue
580
+ current = _file_stamp(path)
581
+ # A missing file is treated as "no change": an editor that replaces
582
+ # the file has a moment where it does not exist, and a policy the
583
+ # user deleted should not silently disarm the guard.
584
+ if current is None or current == stamp:
585
+ continue
586
+ if self._stop.wait(self.settle):
587
+ return
588
+ if _file_stamp(path) != current:
589
+ continue # still being written; the next tick picks it up
590
+ stamp = current
591
+ self._session.reload()
592
+
593
+
594
+ def _port_open(port: int, host: str = "127.0.0.1") -> bool:
595
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
596
+ s.settimeout(0.25)
597
+ return s.connect_ex((host, port)) == 0
598
+
599
+
600
+ def _wait_port(port: int, proc: subprocess.Popen, timeout: float = 15.0) -> None:
601
+ deadline = time.monotonic() + timeout
602
+ while time.monotonic() < deadline:
603
+ if proc.poll() is not None:
604
+ raise RunnerError(
605
+ f"mitmdump exited early (code {proc.returncode}); "
606
+ f"is port {port} already in use?"
607
+ )
608
+ if _port_open(port):
609
+ return
610
+ time.sleep(0.1)
611
+ raise RunnerError(f"proxy did not start listening on port {port} in time")
612
+
613
+
614
+ def run(policy: Policy, command: list[str], *, insecure: bool = False,
615
+ mitmdump: str | None = None, log_file: str | None = None,
616
+ config_path: str | Path | None = None, port: int | None = None,
617
+ watch_config: bool = True) -> int:
618
+ """Run ``command`` behind the guard, returning its exit code.
619
+
620
+ With no command, hold the session open until Ctrl-C so that other shells
621
+ can join it via ``eval "$(sproxy env)"``.
622
+
623
+ ``config_path`` is the policy file this session was loaded from; while it is
624
+ given and ``watch_config`` is set, edits to it are applied to the running
625
+ session (and so is SIGHUP). ``port`` overrides the policy's listen port for
626
+ this session only, which keeps ``policy.port`` free to track the file.
627
+ """
628
+ mitmdump_bin = find_mitmdump(mitmdump)
629
+ listen_port = port or policy.port
630
+ identity = SessionIdentity()
631
+ resolved = resolve_policy(policy, identity)
632
+ pg_endpoints = resolve_postgres(policy, identity)
633
+ # A database password showing up in HTTP traffic is a leak - have the
634
+ # addon watch for the resolved values like any other guarded secret.
635
+ resolved["watch_values"] = [
636
+ *resolved["watch_values"], *(e.password for e in pg_endpoints)
637
+ ]
638
+ confdir = ca.default_confdir() if not policy.confdir else Path(policy.confdir).expanduser()
639
+ cert = ca.cert_path(confdir)
640
+
641
+ run_dir = Path(tempfile.mkdtemp(prefix="sproxy-"))
642
+ os.chmod(run_dir, 0o700)
643
+ policy_file = run_dir / "policy.json"
644
+ _write_policy_file(policy_file, resolved)
645
+
646
+ mitm_env = dict(os.environ)
647
+ mitm_env["SPROXY_POLICY"] = str(policy_file)
648
+ mitm_env["SPROXY_PKG"] = _PKG_PARENT
649
+
650
+ mitm_cmd = [
651
+ mitmdump_bin,
652
+ "-q",
653
+ "--set", f"confdir={confdir}",
654
+ "--listen-host", "127.0.0.1",
655
+ "--listen-port", str(listen_port),
656
+ "--set", "connection_strategy=lazy", # needed for WebFetch-style tools
657
+ "--set", "flow_detail=0",
658
+ "-s", str(_ADDON),
659
+ ]
660
+ if insecure:
661
+ mitm_cmd += ["--set", "ssl_insecure=true"]
662
+
663
+ log = None
664
+ if log_file:
665
+ path = Path(log_file).expanduser()
666
+ try:
667
+ path.parent.mkdir(parents=True, exist_ok=True)
668
+ # The proxy log can include URLs and host names, so keep it private
669
+ # by default. Append so a separate `tail -f` session survives runs.
670
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
671
+ log = os.fdopen(fd, "a", encoding="utf-8", buffering=1)
672
+ except OSError as exc:
673
+ raise RunnerError(f"could not open proxy log {path}: {exc}") from exc
674
+
675
+ proxy = subprocess.Popen(
676
+ mitm_cmd,
677
+ env=mitm_env,
678
+ stdout=log if log is not None else None,
679
+ stderr=log if log is not None else None,
680
+ )
681
+ child: subprocess.Popen | None = None
682
+ gateway: pg.GatewayThread | None = None
683
+ session: LiveSession | None = None
684
+ watcher: ConfigWatcher | None = None
685
+
686
+ def _terminate(signum, frame):
687
+ raise KeyboardInterrupt
688
+
689
+ try:
690
+ # A plain `kill` (systemd, pod eviction) must clean up like Ctrl-C -
691
+ # especially in no-command mode, where nothing else ends the session.
692
+ previous_sigterm = signal.signal(signal.SIGTERM, _terminate)
693
+ except ValueError: # not the main thread (tests)
694
+ previous_sigterm = None
695
+ try:
696
+ _wait_port(listen_port, proxy)
697
+ if not ca.wait_for_cert(cert):
698
+ raise RunnerError(
699
+ f"mitmproxy CA cert not found at {cert}. "
700
+ f"Try running `mitmdump` once, or set confdir in your policy."
701
+ )
702
+
703
+ child_env = ca.proxy_env(listen_port, cert)
704
+ for name in _env_sourced_names(policy): # don't leak env secrets to the agent
705
+ child_env.pop(name, None)
706
+ for var in ("SPROXY_POLICY", "SPROXY_PKG"):
707
+ child_env.pop(var, None)
708
+
709
+ if pg_endpoints:
710
+ gateway = pg.GatewayThread(
711
+ pg.PostgresGateway(
712
+ pg_endpoints,
713
+ identity.pg_token,
714
+ audit_log=_pg_audit_path(policy.audit_log),
715
+ )
716
+ )
717
+ try:
718
+ gateway.start()
719
+ except pg.PgError as exc:
720
+ raise RunnerError(str(exc)) from exc
721
+ child_env.update(gateway.child_env())
722
+ identity.remember_ports(pg_endpoints)
723
+
724
+ # Do not override the proxy/CA environment. A secret with a reserved
725
+ # name remains available through its {{NAME}} request placeholder.
726
+ session = LiveSession(
727
+ policy,
728
+ identity,
729
+ resolved,
730
+ config_path=config_path,
731
+ policy_file=policy_file,
732
+ cert=cert,
733
+ port=listen_port,
734
+ reserved_env=set(child_env),
735
+ gateway=gateway,
736
+ stream=log,
737
+ )
738
+ session_secrets = session.session_secrets()
739
+ child_env.update(session_secrets)
740
+ session_env_content = session.write_session_env()
741
+
742
+ _print_banner(
743
+ policy,
744
+ cert,
745
+ stream=log,
746
+ gateway=gateway,
747
+ session_secrets=session_secrets,
748
+ port=listen_port,
749
+ config_path=config_path,
750
+ )
751
+ if session.session_file is not None:
752
+ _print_session_env(session_env_content, stream=log)
753
+ print(
754
+ 'sproxy: other shells can join this session: eval "$(sproxy env)"',
755
+ file=log or sys.stderr,
756
+ flush=True,
757
+ )
758
+ if watch_config and config_path is not None:
759
+ watcher = ConfigWatcher(session)
760
+ watcher.start()
761
+ print(
762
+ f"sproxy: watching {config_path} - edits apply live "
763
+ "(session tokens and database endpoints stay valid)",
764
+ file=log or sys.stderr,
765
+ flush=True,
766
+ )
767
+ if not command:
768
+ print(
769
+ "sproxy: no command given - holding the session open (Ctrl-C to stop)",
770
+ file=log or sys.stderr,
771
+ flush=True,
772
+ )
773
+ while proxy.poll() is None:
774
+ time.sleep(0.5)
775
+ raise RunnerError(f"mitmdump exited unexpectedly (code {proxy.returncode})")
776
+ child = subprocess.Popen(command, env=child_env)
777
+ return child.wait()
778
+ except KeyboardInterrupt:
779
+ if child is not None:
780
+ child.wait()
781
+ return 130
782
+ return 0 # Ctrl-C is the normal way to stop a held-open session
783
+ finally:
784
+ if watcher is not None:
785
+ # Stop reloading before tearing anything down, so a config change
786
+ # landing during shutdown cannot resurrect a listener.
787
+ watcher.stop()
788
+ if session is not None:
789
+ session.close()
790
+ for proc in (child, proxy):
791
+ if proc is not None and proc.poll() is None:
792
+ proc.terminate()
793
+ try:
794
+ proc.wait(timeout=5)
795
+ except subprocess.TimeoutExpired:
796
+ proc.kill()
797
+ # A reload may have created the gateway after this session started.
798
+ gateway = session.gateway if session is not None else gateway
799
+ if gateway is not None:
800
+ try:
801
+ gateway.stop()
802
+ except Exception:
803
+ pass
804
+ if session is not None and session.session_file is not None:
805
+ session.session_file.unlink(missing_ok=True)
806
+ if previous_sigterm is not None:
807
+ signal.signal(signal.SIGTERM, previous_sigterm)
808
+ if log is not None:
809
+ log.close()
810
+ shutil.rmtree(run_dir, ignore_errors=True)
811
+
812
+
813
+ def _print_banner(
814
+ policy: Policy,
815
+ cert: Path,
816
+ *,
817
+ stream=None,
818
+ gateway: pg.GatewayThread | None = None,
819
+ session_secrets: dict[str, str] | None = None,
820
+ port: int | None = None,
821
+ config_path: str | Path | None = None,
822
+ ) -> None:
823
+ mode = policy.mode.upper()
824
+ lines = [
825
+ f"sproxy: guarding on 127.0.0.1:{port or policy.port} mode={mode} on_leak={policy.on_leak}",
826
+ # Which file is in force decides every rule below, and ./sproxy.toml
827
+ # quietly wins over the user-level config - so never leave it implicit.
828
+ f"sproxy: policy: {Path(config_path).resolve() if config_path else '(built-in monitor default - no policy file found)'}",
829
+ f"sproxy: {len(policy.secrets)} secret(s) managed, audit -> {policy.audit_log or 'disabled'}",
830
+ ]
831
+ replacements = _replacement_tokens(policy)
832
+ if replacements:
833
+ lines.append("sproxy: replacements:")
834
+ lines.extend(f"sproxy: {token}" for token in replacements)
835
+ else:
836
+ lines.append("sproxy: replacements: (none)")
837
+ if session_secrets:
838
+ lines.append("sproxy: temporary secret environment variables:")
839
+ lines.extend(
840
+ f"sproxy: {name} (session-only handle)"
841
+ for name in sorted(session_secrets)
842
+ )
843
+ if gateway is not None:
844
+ for endpoint in gateway.gateway.endpoints:
845
+ profile = endpoint.profile
846
+ lines.append(
847
+ f"sproxy: postgres {profile.name} ({profile.host}) -> "
848
+ f"127.0.0.1:{endpoint.bound_port} connect: psql \"${profile.env_var}\""
849
+ )
850
+ print("\n".join(lines), file=stream or sys.stderr, flush=True)