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/scanner.py ADDED
@@ -0,0 +1,109 @@
1
+ """Outbound leak detection.
2
+
3
+ Two independent detectors:
4
+
5
+ * **Known values** - exact substring match against resolved secret values (and
6
+ any extra ``watch_values``). Zero false positives; this is the core guard.
7
+ * **Patterns** - regexes for well-known credential shapes (provider API keys,
8
+ cloud keys, tokens, JWTs, private keys). Higher recall, used to catch secrets
9
+ sproxy was never told about.
10
+
11
+ The scanner deliberately reports only *which rule* matched, never the matched
12
+ text, so findings are safe to write to the audit log.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+
20
+ # Cap how much of a body we scan so a huge upload can't stall the proxy.
21
+ MAX_SCAN_BYTES = 2 * 1024 * 1024
22
+
23
+ # (name, regex). Patterns are chosen for high signal / low false-positive rate.
24
+ _PATTERN_SPECS: list[tuple[str, str]] = [
25
+ ("anthropic_api_key", r"sk-ant-[A-Za-z0-9_-]{20,}"),
26
+ ("openai_api_key", r"sk-(?:proj-)?[A-Za-z0-9_-]{20,}"),
27
+ ("github_token", r"gh[pousr]_[A-Za-z0-9]{36,}"),
28
+ ("github_fine_grained_pat", r"github_pat_[A-Za-z0-9_]{22,}"),
29
+ ("aws_access_key_id", r"(?:AKIA|ASIA)[0-9A-Z]{16}"),
30
+ ("google_api_key", r"AIza[0-9A-Za-z_-]{35}"),
31
+ ("slack_token", r"xox[baprs]-[A-Za-z0-9-]{10,}"),
32
+ ("stripe_secret_key", r"(?:sk|rk)_live_[0-9a-zA-Z]{24,}"),
33
+ ("gitlab_pat", r"glpat-[A-Za-z0-9_-]{20,}"),
34
+ ("jwt", r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
35
+ ("private_key", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"),
36
+ ]
37
+
38
+ PATTERNS: list[tuple[str, re.Pattern]] = [
39
+ (name, re.compile(rx)) for name, rx in _PATTERN_SPECS
40
+ ]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Finding:
45
+ kind: str # "secret" or "pattern"
46
+ name: str # secret name, or pattern name
47
+
48
+ def __str__(self) -> str: # pragma: no cover - display only
49
+ return f"{self.kind}:{self.name}"
50
+
51
+
52
+ class Scanner:
53
+ def __init__(
54
+ self,
55
+ secret_values: dict[str, str] | None = None,
56
+ watch_values: list[str] | None = None,
57
+ detect_patterns: bool = True,
58
+ ):
59
+ # name -> value, and value -> name for reverse lookup during redaction.
60
+ self.secret_values = dict(secret_values or {})
61
+ for i, v in enumerate(watch_values or []):
62
+ if v:
63
+ self.secret_values.setdefault(f"watch_{i}", v)
64
+ self._value_to_name = {v: n for n, v in self.secret_values.items() if v}
65
+ self.detect_patterns = detect_patterns
66
+
67
+ def scan(self, text: str) -> list[Finding]:
68
+ """Return findings for a piece of outbound text (URL, header, body)."""
69
+ if not text:
70
+ return []
71
+ if len(text) > MAX_SCAN_BYTES:
72
+ text = text[:MAX_SCAN_BYTES]
73
+ findings: list[Finding] = []
74
+ seen: set[tuple[str, str]] = set()
75
+
76
+ for value, name in self._value_to_name.items():
77
+ if value in text and ("secret", name) not in seen:
78
+ findings.append(Finding("secret", name))
79
+ seen.add(("secret", name))
80
+
81
+ if self.detect_patterns:
82
+ for name, rx in PATTERNS:
83
+ if rx.search(text) and ("pattern", name) not in seen:
84
+ findings.append(Finding("pattern", name))
85
+ seen.add(("pattern", name))
86
+
87
+ return findings
88
+
89
+ def secret_names(self, findings: list[Finding]) -> set[str]:
90
+ return {f.name for f in findings if f.kind == "secret"}
91
+
92
+ def redact(self, text: str) -> tuple[str, list[Finding]]:
93
+ """Replace any known secret values and pattern matches in ``text`` with a
94
+ marker. Returns the redacted text and the findings that were redacted."""
95
+ if not text:
96
+ return text, []
97
+ findings: list[Finding] = []
98
+ out = text
99
+ for value, name in self._value_to_name.items():
100
+ if value and value in out:
101
+ out = out.replace(value, f"[sproxy:redacted:{name}]")
102
+ findings.append(Finding("secret", name))
103
+ if self.detect_patterns:
104
+ for name, rx in PATTERNS:
105
+ new, n = rx.subn(f"[sproxy:redacted:{name}]", out)
106
+ if n:
107
+ out = new
108
+ findings.append(Finding("pattern", name))
109
+ return out, findings
@@ -0,0 +1,104 @@
1
+ """Resolve secret source specs to their real values.
2
+
3
+ Sources are resolved once, up front, by the trusted CLI process - never by the
4
+ agent and never by the addon. Supported backends:
5
+
6
+ gopass:PATH `gopass show -o PATH`
7
+ pass:PATH `pass show PATH` (first line)
8
+ vault:PATH#FIELD `vault kv get -field=FIELD PATH` (FIELD defaults to "value")
9
+ env:VAR environment variable VAR
10
+ file:PATH contents of a file (stripped)
11
+ literal:VALUE the value itself (handy for tests/demos)
12
+
13
+ A resolver is a callable ``(path: str) -> str``; the defaults shell out, but
14
+ tests inject fakes.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ from collections.abc import Callable
22
+
23
+
24
+ class SecretResolutionError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _run(cmd: list[str]) -> str:
29
+ try:
30
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
31
+ except FileNotFoundError as exc:
32
+ raise SecretResolutionError(f"{cmd[0]} not found on PATH") from exc
33
+ except subprocess.CalledProcessError as exc:
34
+ stderr = (exc.stderr or "").strip()
35
+ raise SecretResolutionError(
36
+ f"{' '.join(cmd)} failed ({exc.returncode}): {stderr}"
37
+ ) from exc
38
+ return result.stdout
39
+
40
+
41
+ def _gopass(path: str) -> str:
42
+ return _run(["gopass", "show", "-o", path]).strip()
43
+
44
+
45
+ def _pass(path: str) -> str:
46
+ # `pass` prints the whole entry; the secret is conventionally the first line.
47
+ return _run(["pass", "show", path]).splitlines()[0] if path else ""
48
+
49
+
50
+ def _vault(spec: str) -> str:
51
+ # spec is "PATH#FIELD"; FIELD defaults to "value". The KV v2 CLI resolves
52
+ # the /data/ segment itself, so pass the logical path (e.g. secret/app).
53
+ # Auth is whatever the `vault` CLI already uses (VAULT_ADDR/VAULT_TOKEN/
54
+ # ~/.vault-token) - sproxy never handles Vault credentials itself.
55
+ path, _, field = spec.partition("#")
56
+ field = field or "value"
57
+ if not path:
58
+ raise SecretResolutionError("vault source needs a path, e.g. vault:secret/app#api_key")
59
+ return _run(["vault", "kv", "get", f"-field={field}", path]).strip()
60
+
61
+
62
+ def _env(var: str) -> str:
63
+ if var not in os.environ:
64
+ raise SecretResolutionError(f"environment variable {var} is not set")
65
+ return os.environ[var]
66
+
67
+
68
+ def _file(path: str) -> str:
69
+ try:
70
+ with open(os.path.expanduser(path), encoding="utf-8") as fh:
71
+ return fh.read().strip()
72
+ except OSError as exc:
73
+ raise SecretResolutionError(f"cannot read {path}: {exc}") from exc
74
+
75
+
76
+ DEFAULT_BACKENDS: dict[str, Callable[[str], str]] = {
77
+ "gopass": _gopass,
78
+ "pass": _pass,
79
+ "vault": _vault,
80
+ "env": _env,
81
+ "file": _file,
82
+ "literal": lambda v: v,
83
+ }
84
+
85
+
86
+ def resolve(source: str, backends: dict[str, Callable[[str], str]] | None = None) -> str:
87
+ """Resolve a ``backend:path`` spec to its value."""
88
+ backends = backends or DEFAULT_BACKENDS
89
+ if ":" not in source:
90
+ raise SecretResolutionError(
91
+ f"secret source {source!r} must look like 'backend:path' "
92
+ f"(one of: {', '.join(sorted(backends))})"
93
+ )
94
+ backend, _, path = source.partition(":")
95
+ fn = backends.get(backend)
96
+ if fn is None:
97
+ raise SecretResolutionError(
98
+ f"unknown secret backend {backend!r} in {source!r}; "
99
+ f"known backends: {', '.join(sorted(backends))}"
100
+ )
101
+ value = fn(path)
102
+ if value == "":
103
+ raise SecretResolutionError(f"secret source {source!r} resolved to an empty value")
104
+ return value
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: sproxy
3
+ Version: 0.1.0
4
+ Summary: Local egress guard for coding agents: inject secrets, block secret leaks to hosts they may not reach, and keep a tamper-evident audit log.
5
+ Author-email: Igor Pejic <hi@igorpejic.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/igorpejic/sproxy
8
+ Project-URL: Issues, https://github.com/igorpejic/sproxy/issues
9
+ Keywords: mitmproxy,proxy,secrets,dlp,egress,ai-agents,claude-code,security
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: mitmproxy>=11
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # sproxy (secure proxy)
24
+
25
+ ## Motivation
26
+ Do not send the secrets (API tokens, passwords) to LLM providers.
27
+ If the secrets are in the conversation context, you can consider them compromised.
28
+ So, don't let them ever enter the context.
29
+ sproxy helps with this.
30
+
31
+ ## How it works?
32
+
33
+ At request time, sproxy detects the secrets placeholders and injects the real
34
+ secrets to the outbound requests.
35
+ The LLMs only see the placeholder values at all times.
36
+
37
+ ```
38
+ placeholder {{GITHUB_TOKEN}} real secret
39
+ ┌────────┐ ───────────────────────► ┌────────┐ ───────────────► ┌──────────┐
40
+ │ LLM │ │ sproxy │ leak scan + │ approved │
41
+ │ agent │ ◄─────────────────────── │ │ audit │ host │
42
+ └────────┘ response └────────┘ └──────────┘
43
+ │
44
+ ├─► ✗ block leak to attacker domain
45
+ └─► 🧾 .sproxy/audit.jsonl
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - provides a list of placeholder env values to be used by the LLM
51
+ - configure specific domains to which the secret should be ingested
52
+ - monitor and audit all traffic originating from LLM
53
+ - works with authorization Headers, Parameters and Postgres protocol
54
+ - local-first and fully open-source
55
+ - works with Codex, Claude Code and other harnesses
56
+
57
+
58
+ ## Prerequisites
59
+
60
+ sproxy uses [mitmdump](https://docs.mitmproxy.org/stable/overview/installation/) and will
61
+ be pulled automatically by `pip install`:
62
+
63
+ ```bash
64
+ pip install sproxy
65
+ ```
66
+
67
+ If you prefer to install mitmproxy separately, use `pip install --no-deps sproxy`
68
+ and make sure `mitmdump` is available on your `PATH` (or pass `--mitmdump` to
69
+ `sproxy run`).
70
+
71
+
72
+ ## Quickstart
73
+
74
+ ```bash
75
+ # 1. Write a starter policy and edit it.
76
+ sproxy init # creates ./sproxy.toml
77
+
78
+ # 2. Run your agent behind the guard.
79
+
80
+ # Start sproxy
81
+ sproxy run
82
+ # And load the placeholder Env variables
83
+ eval $(sproxy env) && codex
84
+
85
+ # Or run the process together with sproxy
86
+ sproxy run -- codex
87
+
88
+ # 3. After a session, check the audit log wasn't touched.
89
+ sproxy audit verify .sproxy/audit.jsonl
90
+ ```
91
+
92
+ On first run, mitmproxy generates its CA at `~/.mitmproxy/mitmproxy-ca-cert.pem`.
93
+ sproxy points the agent's TLS trust env vars (`NODE_EXTRA_CA_CERTS`,
94
+ `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, …) at that file automatically.
95
+ Run `sproxy ca` to print the path.
96
+
97
+
98
+ To observe secret injection on your local machine, follow [QUICKSTART.md](./QUICKSTART.md).
99
+
100
+
101
+ ### Placeholder environment variables
102
+
103
+ When a secret name is a valid environment-variable name, sproxy also exports
104
+ that name to the guarded process as an per-session secret placedholer
105
+ starting with `sproxy`.
106
+ For example, with a `GITHUB_API_KEY` secret, an SDK sees:
107
+
108
+ ```text
109
+ GITHUB_API_KEY=sproxy_v1_<random-session-handle>
110
+ ```
111
+
112
+ sproxy replaces that handle with the real key only for the secret's allowed
113
+ hosts. The handle is different for every `sproxy run` session and stops working
114
+ when that session ends.
115
+
116
+ Secret backends supported:
117
+
118
+ - `gopass:PATH`,
119
+ - `pass:PATH`,
120
+ - `vault:PATH#FIELD` (HashiCorp Vault, via the `vault` CLI's existing auth)
121
+ - `env:VAR`
122
+ - `file:PATH`
123
+ - `literal:VALUE`
124
+
125
+
126
+ ### Config file
127
+
128
+ The policy is loaded from `-c PATH` config file if given, else `./sproxy.toml`, else the
129
+ user-level `~/.config/sproxy/sproxy.toml`.
130
+ Relative paths inside a policy (`audit_log`, `sslrootcert`, `confdir`) resolve
131
+ against the config file's own directory.
132
+
133
+ Editing the policy while `sproxy run` hot-reloads sproxy.
134
+ (Note that you might have to re-authorize loading the secrets).
135
+
136
+
137
+ ## PostgreSQL
138
+
139
+ The usual placeholder injection does not work for PostgreSQL like it does for http.
140
+ SCRAM authentication proves knowledge of the password inside the client,
141
+ so there is no request in flight to rewrite.
142
+ Instead, each `[postgres.NAME]` profile becomes a local endpoint that
143
+ terminates the wire protocol: the agent's client
144
+ authenticates to sproxy with a per-session token.
145
+ Sproxy opens the real connection to the host pinned in
146
+ policy. The real password never enters the agent's environment,
147
+ but it sees a fake placeholder which sproxy uses.
148
+
149
+ ```toml
150
+ [postgres.develop]
151
+ host = "db.example.com" # the real server
152
+ port = 5432
153
+ listen = 6432 # local endpoint port; omit for auto-assigned
154
+ password_source = "gopass:work/postgres/develop/password"
155
+ user = "app_readonly" # optional: default role
156
+ database = "appdb" # optional: default database
157
+ sslmode = "verify-full" # upstream TLS (default); "disable" only for local dev servers
158
+ sslrootcert = "" # CA bundle (e.g. the AWS RDS bundle); "" = system trust store
159
+ ```
160
+
161
+ Each endpoint is advertised to the session through an environment variable
162
+ named after the profile which will render after `sproxy run` or `sproxy env`.
163
+
164
+ ## The audit log
165
+
166
+ Stores one line per request captured.
167
+ Example:
168
+
169
+ ```json
170
+ {"seq":1,"ts":"...","host":"attacker.com","method":"POST","decision":"blocked","rules":["secret:GITHUB_TOKEN"], ...}
171
+ ```
172
+
173
+ ## TLS interception & certificate pinning
174
+
175
+ sproxy reads HTTPS by acting as a MITM with a trusted local CA. Clients that
176
+ pin certificates (require a specific cert, not merely a valid one) will
177
+ reject that CA and fail the handshake, and won't have their secrets replaced.
178
+ Use `passthrough_hosts` to whitelist such hosts:
179
+ ```toml
180
+ passthrough_hosts = ["api.vendor.com"] # tunneled without interception
181
+ ```
182
+
183
+ ## Commands
184
+
185
+ | Command | What it does |
186
+ | ---------------------------------------------------------------------------- | ------------------------------------------------ |
187
+ | `sproxy run [-c policy] [--port N] [--insecure] [--log-file path] [--no-reload] [-- <cmd…>]` | Run a command behind the guard; no command holds a session open. Policy edits apply live unless `--no-reload` |
188
+ | `sproxy init [-c path] [--force]` | Write a starter policy |
189
+ | `sproxy env` | Print exports that join this shell to the live session |
190
+ | `sproxy ca [-c policy]` | Print the mitmproxy CA cert path |
191
+ | `sproxy audit verify <log>` | Check the audit chain is intact |
192
+ | `sproxy version` | Print version |
193
+
194
+
195
+
196
+ ## License
197
+ MIT
@@ -0,0 +1,19 @@
1
+ sproxy/__init__.py,sha256=v5xSxVM3ALaddKBUapCk3jH2nbLNrTlLxPHtFirgcdA,283
2
+ sproxy/__main__.py,sha256=-qiWeoH9e6HFOq4x5ywHq5EWFMj6e6PaX4lXF0z0oWE,85
3
+ sproxy/addon.py,sha256=qAj1aOswGpmyfKzmv28Ekvxt_tpPpRq-nZ9r7zYGjbE,18686
4
+ sproxy/audit.py,sha256=hkrnpxBUM0yErv-dEZ4C_9wDW2uC7JlwDiK5YtVVsrw,3234
5
+ sproxy/auth.py,sha256=h2cZPYzNs35U8bNwsmexAprmmU4o-sJLSDuUxMtFO34,1234
6
+ sproxy/ca.py,sha256=EHtvWmjTqezQJYjPU2c4IyzD1nM1luFEDowLAA1sC54,2393
7
+ sproxy/cli.py,sha256=j41uRMgtv-_6GtUaoED-ll-HJi1S-YseJQJUC2AS7Vs,11178
8
+ sproxy/pg.py,sha256=LfTlVbQUiJVyFgTDpyvS0YaGyvwtpXonUm88Dui_uCU,25403
9
+ sproxy/policy.py,sha256=7q5rEnYAYqWE7Aopog9vK1-rXogkzxXeqKoqI5YZakw,10534
10
+ sproxy/replacements.py,sha256=SKA5Y0slNdVXu9Yhi8nJ_Havnk3l3EeMUrUWqbpEy14,1055
11
+ sproxy/runner.py,sha256=O2t0ofHvoomT3H8E0Iztlf6aikl1CWzwFjdqoMafknQ,33023
12
+ sproxy/scanner.py,sha256=n6x6G5T5GnjnwbtTsJtukDskMLT9wbFcqBuaYMGMd5c,4249
13
+ sproxy/secrets_resolver.py,sha256=0aYjC4dMqeOBVSONoQalkdvfRfPw57axdUQU9Lxt43A,3540
14
+ sproxy-0.1.0.dist-info/licenses/LICENSE,sha256=JoZ5Fxk5z-cnfUHtuYmWdF8nrZRaXneJQu6WBm8ycQY,1067
15
+ sproxy-0.1.0.dist-info/METADATA,sha256=WkFSuh_MwOvjOoX9gkv8VAGDFyms590YvBlI3eAKtpw,7844
16
+ sproxy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ sproxy-0.1.0.dist-info/entry_points.txt,sha256=AV_wKPkzkSin3W5AAF-6LBfIIT6WhQBpFfu6LmRBDxw,43
18
+ sproxy-0.1.0.dist-info/top_level.txt,sha256=KOGn-X9-FP91vvInt0Y2s3IFIKarKtTpRAJuwoDRkbU,7
19
+ sproxy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sproxy = sproxy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Pejic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sproxy