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