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/__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
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
|