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 +8 -0
- sproxy/__main__.py +4 -0
- sproxy/addon.py +468 -0
- sproxy/audit.py +88 -0
- sproxy/auth.py +34 -0
- sproxy/ca.py +69 -0
- sproxy/cli.py +293 -0
- sproxy/pg.py +637 -0
- sproxy/policy.py +274 -0
- sproxy/replacements.py +28 -0
- sproxy/runner.py +850 -0
- sproxy/scanner.py +109 -0
- sproxy/secrets_resolver.py +104 -0
- sproxy-0.1.0.dist-info/METADATA +197 -0
- sproxy-0.1.0.dist-info/RECORD +19 -0
- sproxy-0.1.0.dist-info/WHEEL +5 -0
- sproxy-0.1.0.dist-info/entry_points.txt +2 -0
- sproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- sproxy-0.1.0.dist-info/top_level.txt +1 -0
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)
|