keyfence 0.2.0__tar.gz

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.
Files changed (36) hide show
  1. keyfence-0.2.0/LICENSE +21 -0
  2. keyfence-0.2.0/PKG-INFO +94 -0
  3. keyfence-0.2.0/README.md +66 -0
  4. keyfence-0.2.0/keyfence/__init__.py +1 -0
  5. keyfence-0.2.0/keyfence/__main__.py +3 -0
  6. keyfence-0.2.0/keyfence/addon.py +188 -0
  7. keyfence-0.2.0/keyfence/cli.py +201 -0
  8. keyfence-0.2.0/keyfence/config.py +90 -0
  9. keyfence-0.2.0/keyfence/detectors.py +315 -0
  10. keyfence-0.2.0/keyfence/importer.py +132 -0
  11. keyfence-0.2.0/keyfence/notice.py +55 -0
  12. keyfence-0.2.0/keyfence/rules/GITLEAKS-LICENSE +21 -0
  13. keyfence-0.2.0/keyfence/rules/gitleaks.toml +3209 -0
  14. keyfence-0.2.0/keyfence/rules.py +101 -0
  15. keyfence-0.2.0/keyfence/runner.py +104 -0
  16. keyfence-0.2.0/keyfence/streaming.py +137 -0
  17. keyfence-0.2.0/keyfence/vault.py +109 -0
  18. keyfence-0.2.0/keyfence.egg-info/PKG-INFO +94 -0
  19. keyfence-0.2.0/keyfence.egg-info/SOURCES.txt +34 -0
  20. keyfence-0.2.0/keyfence.egg-info/dependency_links.txt +1 -0
  21. keyfence-0.2.0/keyfence.egg-info/entry_points.txt +2 -0
  22. keyfence-0.2.0/keyfence.egg-info/requires.txt +6 -0
  23. keyfence-0.2.0/keyfence.egg-info/top_level.txt +1 -0
  24. keyfence-0.2.0/pyproject.toml +58 -0
  25. keyfence-0.2.0/setup.cfg +4 -0
  26. keyfence-0.2.0/tests/test_addon.py +312 -0
  27. keyfence-0.2.0/tests/test_bench.py +61 -0
  28. keyfence-0.2.0/tests/test_cli.py +173 -0
  29. keyfence-0.2.0/tests/test_config.py +91 -0
  30. keyfence-0.2.0/tests/test_detectors.py +369 -0
  31. keyfence-0.2.0/tests/test_importer.py +135 -0
  32. keyfence-0.2.0/tests/test_notice.py +68 -0
  33. keyfence-0.2.0/tests/test_rules.py +91 -0
  34. keyfence-0.2.0/tests/test_runner.py +139 -0
  35. keyfence-0.2.0/tests/test_streaming.py +160 -0
  36. keyfence-0.2.0/tests/test_vault.py +124 -0
keyfence-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amanda Souza
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,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: keyfence
3
+ Version: 0.2.0
4
+ Summary: Local proxy that keeps your API keys, passwords and secrets out of LLM requests.
5
+ Author: Amanda Souza
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aminueza/keyfence
8
+ Project-URL: Documentation, https://github.com/aminueza/keyfence/tree/main/docs
9
+ Project-URL: Repository, https://github.com/aminueza/keyfence
10
+ Project-URL: Issues, https://github.com/aminueza/keyfence/issues
11
+ Keywords: llm,secrets,proxy,redaction,security,claude-code,coding-agents
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ License-File: keyfence/rules/GITLEAKS-LICENSE
22
+ Requires-Dist: mitmproxy>=10.0
23
+ Requires-Dist: PyYAML>=6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: pytest-cov>=5; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # keyfence
30
+
31
+ [![CI](https://github.com/aminueza/keyfence/actions/workflows/ci.yml/badge.svg)](https://github.com/aminueza/keyfence/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/aminueza/keyfence/blob/main/LICENSE)
33
+ ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)
34
+
35
+ A local proxy that stops secrets from reaching LLM APIs. It checks every
36
+ request to an AI provider before it leaves your machine and blocks, redacts
37
+ or placeholder-swaps API keys, passwords and other secrets. Works with Claude
38
+ Code, Cursor, Codex, Aider, curl and anything else that speaks HTTP.
39
+
40
+ ![How keyfence sits between your tools and the provider](https://raw.githubusercontent.com/aminueza/keyfence/main/docs/keyfence-flow.png)
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install keyfence
46
+ ```
47
+
48
+ Python 3.12 or newer. mitmproxy comes as a dependency.
49
+
50
+ ## Use
51
+
52
+ ```bash
53
+ keyfence import # register your secrets from .env and credential files (hashes only)
54
+ keyfence exec -- claude # run a tool through the proxy
55
+ keyfence canary .env # plant a fake secret; if a tool ever sends it, you will know
56
+ ```
57
+
58
+ On first run mitmproxy creates a CA certificate in `~/.mitmproxy/`. Trust it
59
+ once so HTTPS can be inspected (macOS shown, other systems in the
60
+ [setup guide](https://github.com/aminueza/keyfence/blob/main/docs/setup.md)):
61
+
62
+ ```bash
63
+ sudo security add-trusted-cert -d -p ssl \
64
+ -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem
65
+ ```
66
+
67
+ ## Modes
68
+
69
+ | mode | behaviour |
70
+ |---|---|
71
+ | `block` | request gets a 403 and is not sent |
72
+ | `redact` (default) | secret becomes `[REDACTED:<kind>]` |
73
+ | `placeholder` | secret becomes `<<SECRET_id>>` and the real value is restored in the response, streaming included |
74
+
75
+ ## Documentation
76
+
77
+ - [Setup](https://github.com/aminueza/keyfence/blob/main/docs/setup.md): CA
78
+ certificate, manual proxy setup, Docker, all commands.
79
+ - [Detection](https://github.com/aminueza/keyfence/blob/main/docs/detection.md):
80
+ the vault, pattern rules, entropy check, what is excluded, the audit log.
81
+ - [Configuration](https://github.com/aminueza/keyfence/blob/main/docs/configuration.md):
82
+ every option, environment variables, the system prompt notice.
83
+ - [Benchmark](https://github.com/aminueza/keyfence/blob/main/docs/benchmark.md):
84
+ recall by secret format and false positive rate by content type, against
85
+ gitleaks, reproducible with `python bench/run.py`.
86
+ - [Limitations](https://github.com/aminueza/keyfence/blob/main/docs/limitations.md):
87
+ what keyfence does not cover and what to combine it with.
88
+ - [Development](https://github.com/aminueza/keyfence/blob/main/docs/development.md):
89
+ tests, coverage gate, integration script.
90
+
91
+ ## License
92
+
93
+ MIT. Bundled detection rules come from [gitleaks](https://github.com/gitleaks/gitleaks),
94
+ also MIT.
@@ -0,0 +1,66 @@
1
+ # keyfence
2
+
3
+ [![CI](https://github.com/aminueza/keyfence/actions/workflows/ci.yml/badge.svg)](https://github.com/aminueza/keyfence/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/aminueza/keyfence/blob/main/LICENSE)
5
+ ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)
6
+
7
+ A local proxy that stops secrets from reaching LLM APIs. It checks every
8
+ request to an AI provider before it leaves your machine and blocks, redacts
9
+ or placeholder-swaps API keys, passwords and other secrets. Works with Claude
10
+ Code, Cursor, Codex, Aider, curl and anything else that speaks HTTP.
11
+
12
+ ![How keyfence sits between your tools and the provider](https://raw.githubusercontent.com/aminueza/keyfence/main/docs/keyfence-flow.png)
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install keyfence
18
+ ```
19
+
20
+ Python 3.12 or newer. mitmproxy comes as a dependency.
21
+
22
+ ## Use
23
+
24
+ ```bash
25
+ keyfence import # register your secrets from .env and credential files (hashes only)
26
+ keyfence exec -- claude # run a tool through the proxy
27
+ keyfence canary .env # plant a fake secret; if a tool ever sends it, you will know
28
+ ```
29
+
30
+ On first run mitmproxy creates a CA certificate in `~/.mitmproxy/`. Trust it
31
+ once so HTTPS can be inspected (macOS shown, other systems in the
32
+ [setup guide](https://github.com/aminueza/keyfence/blob/main/docs/setup.md)):
33
+
34
+ ```bash
35
+ sudo security add-trusted-cert -d -p ssl \
36
+ -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem
37
+ ```
38
+
39
+ ## Modes
40
+
41
+ | mode | behaviour |
42
+ |---|---|
43
+ | `block` | request gets a 403 and is not sent |
44
+ | `redact` (default) | secret becomes `[REDACTED:<kind>]` |
45
+ | `placeholder` | secret becomes `<<SECRET_id>>` and the real value is restored in the response, streaming included |
46
+
47
+ ## Documentation
48
+
49
+ - [Setup](https://github.com/aminueza/keyfence/blob/main/docs/setup.md): CA
50
+ certificate, manual proxy setup, Docker, all commands.
51
+ - [Detection](https://github.com/aminueza/keyfence/blob/main/docs/detection.md):
52
+ the vault, pattern rules, entropy check, what is excluded, the audit log.
53
+ - [Configuration](https://github.com/aminueza/keyfence/blob/main/docs/configuration.md):
54
+ every option, environment variables, the system prompt notice.
55
+ - [Benchmark](https://github.com/aminueza/keyfence/blob/main/docs/benchmark.md):
56
+ recall by secret format and false positive rate by content type, against
57
+ gitleaks, reproducible with `python bench/run.py`.
58
+ - [Limitations](https://github.com/aminueza/keyfence/blob/main/docs/limitations.md):
59
+ what keyfence does not cover and what to combine it with.
60
+ - [Development](https://github.com/aminueza/keyfence/blob/main/docs/development.md):
61
+ tests, coverage gate, integration script.
62
+
63
+ ## License
64
+
65
+ MIT. Bundled detection rules come from [gitleaks](https://github.com/gitleaks/gitleaks),
66
+ also MIT.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ _PKG_PARENT = str(Path(__file__).resolve().parent.parent)
11
+ if _PKG_PARENT not in sys.path:
12
+ sys.path.insert(0, _PKG_PARENT)
13
+
14
+ from mitmproxy import http # noqa: E402
15
+
16
+ from keyfence.config import Config # noqa: E402
17
+ from keyfence.detectors import Finding, scan # noqa: E402
18
+ from keyfence.notice import add_notice # noqa: E402
19
+ from keyfence.streaming import SSERestorer, restore # noqa: E402
20
+ from keyfence.vault import Vault # noqa: E402
21
+
22
+ log = logging.getLogger("keyfence")
23
+ ENV_VAULT_VAR = "KEYFENCE_ENV_VAULT"
24
+ MAPPING_KEY = "keyfence_mapping"
25
+ STREAMED_KEY = "keyfence_streamed"
26
+ AUDIT_PREVIEW_LIMIT = 50
27
+ PLACEHOLDER_ID_LENGTH = 10
28
+
29
+
30
+ class KeyFence:
31
+ def __init__(self):
32
+ self.config = Config.load()
33
+ self.vault = self._load_vault()
34
+ self._vault_mtime = self._mtime(self.vault.path)
35
+ self.stats = {"scanned": 0, "findings": 0, "blocked": 0, "errors": 0, "canaries": 0}
36
+
37
+ @staticmethod
38
+ def _mtime(path: Path) -> float:
39
+ try:
40
+ return Path(path).stat().st_mtime
41
+ except OSError:
42
+ return 0.0
43
+
44
+ def _load_vault(self) -> Vault:
45
+ vault = Vault()
46
+ vault.ensure_saved()
47
+ extra = os.environ.get(ENV_VAULT_VAR)
48
+ if extra and Path(extra).exists():
49
+ vault.merge(Vault(path=Path(extra)))
50
+ return vault
51
+
52
+ def _maybe_reload_vault(self) -> None:
53
+ mtime = self._mtime(self.vault.path)
54
+ if mtime != self._vault_mtime:
55
+ self.vault = self._load_vault()
56
+ self._vault_mtime = mtime
57
+ log.info("vault reloaded: %d secret(s)", self.vault.count())
58
+
59
+ def load(self, loader):
60
+ log.info("mode=%s | %d hosts monitored | %d rules | vault with %d secret(s)",
61
+ self.config.mode, len(self.config.hosts),
62
+ len(self.config.scan.rules), self.vault.count())
63
+
64
+ def request(self, flow: http.HTTPFlow) -> None:
65
+ host = flow.request.pretty_host
66
+ if not self.config.host_matches(host):
67
+ return
68
+ try:
69
+ self._inspect(flow, host)
70
+ except Exception as exc:
71
+ self.stats["errors"] += 1
72
+ log.error("detector failure, failing closed for %s: %r", host, exc)
73
+ flow.response = self._blocked_response(
74
+ f"keyfence internal error ({type(exc).__name__}); request not sent.")
75
+
76
+ def _inspect(self, flow: http.HTTPFlow, host: str) -> None:
77
+ text = flow.request.get_text(strict=False)
78
+ if not text:
79
+ return
80
+ self._maybe_reload_vault()
81
+ self.stats["scanned"] += 1
82
+ findings = scan(text, vault=self.vault, config=self.config.scan)
83
+ if not findings:
84
+ return
85
+
86
+ self.stats["findings"] += len(findings)
87
+ self._audit(flow, findings)
88
+ tripped = sorted({self.vault.canary_label(f.value) for f in findings if f.kind == "canary"})
89
+ if tripped:
90
+ self.stats["canaries"] += len(tripped)
91
+ log.warning("CANARY tripped -> %s: %s was read and sent", host, ", ".join(tripped))
92
+
93
+ if self.config.mode == "block":
94
+ self.stats["blocked"] += 1
95
+ kinds = sorted({f.kind for f in findings})
96
+ flow.response = self._blocked_response(
97
+ f"Request blocked by keyfence: {len(findings)} secret(s) detected "
98
+ f"({', '.join(kinds)}). Nothing was sent to the provider.")
99
+ log.warning("BLOCKED -> %s: %d secret(s)", host, len(findings))
100
+ return
101
+
102
+ mapping: dict[str, str] = {}
103
+ new_text = text
104
+ for f in sorted(findings, key=lambda f: f.start, reverse=True):
105
+ if self.config.mode == "placeholder":
106
+ token = self._placeholder(f.value, mapping)
107
+ mapping[token] = f.value
108
+ else:
109
+ token = f"[REDACTED:{f.kind}]"
110
+ new_text = new_text[:f.start] + token + new_text[f.end:]
111
+
112
+ if self.config.notice:
113
+ new_text = add_notice(new_text, host)
114
+ flow.request.set_text(new_text)
115
+ if mapping:
116
+ flow.metadata[MAPPING_KEY] = mapping
117
+ flow.request.headers["accept-encoding"] = "identity"
118
+ log.warning("%s -> %s: %d secret(s) removed from request",
119
+ self.config.mode.upper(), host, len(findings))
120
+
121
+ def _placeholder(self, value: str, mapping: dict[str, str]) -> str:
122
+ digest = self.vault.placeholder_digest(value)
123
+ length = PLACEHOLDER_ID_LENGTH
124
+ token = f"<<SECRET_{digest[:length]}>>"
125
+ while token in mapping and mapping[token] != value:
126
+ length += 2
127
+ token = f"<<SECRET_{digest[:length]}>>"
128
+ return token
129
+
130
+ @staticmethod
131
+ def _blocked_response(message: str) -> http.Response:
132
+ return http.Response.make(
133
+ 403,
134
+ json.dumps({"error": {"type": "keyfence_blocked", "message": message}},
135
+ ensure_ascii=False),
136
+ {"Content-Type": "application/json"},
137
+ )
138
+
139
+ def responseheaders(self, flow: http.HTTPFlow) -> None:
140
+ if flow.response is None:
141
+ return
142
+ mapping = flow.metadata.get(MAPPING_KEY)
143
+ if not mapping:
144
+ flow.response.stream = True
145
+ return
146
+ content_type = flow.response.headers.get("content-type", "")
147
+ encoding = flow.response.headers.get("content-encoding", "identity")
148
+ if "text/event-stream" in content_type and encoding in ("identity", ""):
149
+ flow.response.stream = SSERestorer(mapping).feed
150
+ flow.metadata[STREAMED_KEY] = True
151
+
152
+ def response(self, flow: http.HTTPFlow) -> None:
153
+ mapping = flow.metadata.get(MAPPING_KEY)
154
+ if not mapping or flow.metadata.get(STREAMED_KEY) or flow.response is None:
155
+ return
156
+ text = flow.response.get_text(strict=False)
157
+ if not text:
158
+ return
159
+ restored = restore(text, mapping)
160
+ if restored != text:
161
+ flow.response.set_text(restored)
162
+ log.info("placeholders restored in response")
163
+
164
+ def _audit_finding(self, f: Finding) -> dict:
165
+ entry = {"kind": f.kind, "preview": f.masked, "key": f.key}
166
+ if f.kind == "canary":
167
+ entry["label"] = self.vault.canary_label(f.value)
168
+ return entry
169
+
170
+ def _audit(self, flow: http.HTTPFlow, findings: list[Finding]) -> None:
171
+ try:
172
+ path = Path(self.config.audit_log)
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ entry = {
175
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
176
+ "host": flow.request.pretty_host,
177
+ "path": flow.request.path.split("?")[0],
178
+ "mode": self.config.mode,
179
+ "count": len(findings),
180
+ "findings": [self._audit_finding(f) for f in findings[:AUDIT_PREVIEW_LIMIT]],
181
+ }
182
+ with path.open("a") as fh:
183
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
184
+ except OSError as exc:
185
+ log.warning("could not write audit log: %s", exc)
186
+
187
+
188
+ addons = [KeyFence()]
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import subprocess
10
+ import sys
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+ from . import runner
15
+ from .config import Config
16
+ from .detectors import scan
17
+ from .importer import default_paths, env_values, import_files
18
+ from .vault import DEFAULT_DIR, Vault
19
+
20
+
21
+ def cmd_add_secret(_args) -> int:
22
+ vault = Vault()
23
+ print("Paste the secret to protect (hidden; only its hash is stored):")
24
+ value = getpass.getpass("> ")
25
+ if not value.strip():
26
+ print("Nothing entered, aborting.")
27
+ return 1
28
+ if not vault.add(value):
29
+ print(f"Secret too short (minimum {vault.min_length} characters); "
30
+ "shorter values would cause too many false positives.")
31
+ return 1
32
+ print(f"OK. Vault now holds {vault.count()} secret(s) (hashes only, in {vault.path}).")
33
+ return 0
34
+
35
+
36
+ def cmd_import(args) -> int:
37
+ vault = Vault()
38
+ paths = [Path(p).expanduser() for p in args.paths] or default_paths()
39
+ missing = [p for p in paths if not p.is_file()]
40
+ for p in missing:
41
+ print(f"skip: {p} (not a file)")
42
+ paths = [p for p in paths if p.is_file()]
43
+ if not paths and not args.env:
44
+ print("Nothing to import. Pass file paths, or use --env to import from the environment.")
45
+ return 1
46
+ total = 0
47
+ for path, added in import_files(vault, paths, everything=args.all):
48
+ total += added
49
+ print(f"{path}: {added} new secret(s)")
50
+ if args.env:
51
+ added = vault.add_many(env_values(os.environ, vault.min_length, everything=args.all))
52
+ total += added
53
+ print(f"environment: {added} new secret(s)")
54
+ print(f"Done. {total} new secret(s); vault now holds {vault.count()} (hashes only).")
55
+ return 0
56
+
57
+
58
+ def cmd_canary(args) -> int:
59
+ path = Path(args.file)
60
+ name = args.name
61
+ existing = path.read_text(errors="replace") if path.exists() else ""
62
+ if re.search(rf"^\s*(?:export\s+)?{re.escape(name)}\s*=", existing, re.MULTILINE):
63
+ print(f"{path} already defines {name}. Pick another name with --name.")
64
+ return 1
65
+ value = secrets.token_urlsafe(24)
66
+ vault = Vault()
67
+ vault.add_canary(value, str(path.resolve()))
68
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
69
+ with path.open("a") as fh:
70
+ fh.write(f"{prefix}{name}={value}\n")
71
+ print(f"Canary planted in {path} as {name} and registered in the vault (hash only).")
72
+ print("If it ever shows up in a request, keyfence logs a 'canary' detection with this file's path.")
73
+ return 0
74
+
75
+
76
+ def cmd_scan(args) -> int:
77
+ if args.file:
78
+ text = Path(args.file).read_text(errors="replace")
79
+ elif args.text:
80
+ text = args.text
81
+ else:
82
+ text = sys.stdin.read()
83
+ cfg = Config.load()
84
+ findings = scan(text, vault=Vault(), config=cfg.scan)
85
+ if not findings:
86
+ print("No secrets detected.")
87
+ return 0
88
+ print(f"{len(findings)} secret(s) detected:")
89
+ for f in findings:
90
+ where = f" in \"{f.key}\"" if f.key else ""
91
+ print(f" - [{f.kind}] {f.masked} (offset {f.start}-{f.end}{where})")
92
+ return 2
93
+
94
+
95
+ def cmd_run(args) -> int:
96
+ print(f"Starting keyfence on http://127.0.0.1:{args.port}")
97
+ print("Point your tools at it, e.g.:")
98
+ print(f" export HTTPS_PROXY=http://127.0.0.1:{args.port}")
99
+ print(f" export HTTP_PROXY=http://127.0.0.1:{args.port}")
100
+ print("or run them through it directly: keyfence exec -- <command>")
101
+ print("(Ctrl+C to stop)\n")
102
+ try:
103
+ return subprocess.call(runner.proxy_command(args.port))
104
+ except FileNotFoundError:
105
+ print("mitmdump not found. Install it with: pip install mitmproxy")
106
+ return 1
107
+
108
+
109
+ def cmd_exec(args) -> int:
110
+ command = list(args.argv)
111
+ if command and command[0] == "--":
112
+ command = command[1:]
113
+ if not command:
114
+ print("usage: keyfence exec [-p PORT] [--all-env] -- <command> [args...]")
115
+ return 1
116
+ return runner.run(command, args.port, everything=args.all_env)
117
+
118
+
119
+ def cmd_status(_args) -> int:
120
+ cfg = Config.load()
121
+ vault = Vault()
122
+ print(f"Mode: {cfg.mode}")
123
+ print(f"Hosts: {len(cfg.hosts)} monitored"
124
+ + (" (intercepting ALL hosts)" if cfg.intercept_all_hosts else ""))
125
+ print(f"Vault: {vault.count()} secret(s), {vault.canary_count()} canary(ies) in {vault.path}")
126
+ print(f"Rules: {len(cfg.scan.rules)} gitleaks rules"
127
+ + ("" if cfg.scan.gitleaks else " (disabled)"))
128
+ print(f"Entropy: {'on' if cfg.scan.entropy_enabled else 'off'}"
129
+ f" (min_len={cfg.scan.entropy_min_length}, threshold={cfg.scan.entropy_threshold})")
130
+ print(f"Audit log: {cfg.audit_log}")
131
+ log = Path(cfg.audit_log)
132
+ if log.exists():
133
+ lines = log.read_text().strip().splitlines()[-5:]
134
+ if lines:
135
+ print("\nRecent detections:")
136
+ for line in lines:
137
+ try:
138
+ e = json.loads(line)
139
+ counts = Counter(f["kind"] for f in e["findings"])
140
+ kinds = ", ".join(
141
+ f"{kind} x{n}" if n > 1 else kind for kind, n in counts.most_common())
142
+ total = e.get("count", len(e["findings"]))
143
+ suffix = f" {total} total" if total > len(e["findings"]) else ""
144
+ print(f" {e['ts']} {e['host']} [{kinds}]{suffix} ({e['mode']})")
145
+ except (json.JSONDecodeError, KeyError):
146
+ pass
147
+ return 0
148
+
149
+
150
+ def build_parser() -> argparse.ArgumentParser:
151
+ parser = argparse.ArgumentParser(
152
+ prog="keyfence",
153
+ description="Local proxy that keeps your secrets out of LLM requests.")
154
+ sub = parser.add_subparsers(dest="command", required=True)
155
+
156
+ sub.add_parser("add-secret", help="register one of your secrets in the vault (hash only)")
157
+
158
+ p_import = sub.add_parser(
159
+ "import", help="register secrets found in .env files, credential stores or the environment")
160
+ p_import.add_argument("paths", nargs="*", help="files to read (default: .env* and common credential files)")
161
+ p_import.add_argument("--env", action="store_true", help="also import values from environment variables")
162
+ p_import.add_argument("--all", action="store_true", help="import every value, not only secret-looking ones")
163
+
164
+ p_canary = sub.add_parser(
165
+ "canary", help="plant a fake secret in a file; keyfence reports if a tool ever sends it")
166
+ p_canary.add_argument("file", nargs="?", default=".env", help="file to append to (default: .env)")
167
+ p_canary.add_argument("--name", default="INTERNAL_API_TOKEN", help="variable name to use")
168
+
169
+ p_scan = sub.add_parser("scan", help="test detection on text, a file or stdin")
170
+ p_scan.add_argument("text", nargs="?", help="text to scan")
171
+ p_scan.add_argument("-f", "--file", help="file to scan")
172
+
173
+ p_run = sub.add_parser("run", help="start the proxy")
174
+ p_run.add_argument("-p", "--port", type=int, default=8888)
175
+
176
+ p_exec = sub.add_parser("exec", help="run a command with the proxy already wired in")
177
+ p_exec.add_argument("-p", "--port", type=int, default=8888)
178
+ p_exec.add_argument("--all-env", action="store_true",
179
+ help="treat every environment variable value as a secret, not only secret-looking names")
180
+ p_exec.add_argument("argv", nargs=argparse.REMAINDER, metavar="command")
181
+
182
+ sub.add_parser("status", help="show configuration and recent detections")
183
+ return parser
184
+
185
+
186
+ def main(argv: list[str] | None = None) -> int:
187
+ args = build_parser().parse_args(argv)
188
+ DEFAULT_DIR.mkdir(parents=True, exist_ok=True)
189
+ return {
190
+ "add-secret": cmd_add_secret,
191
+ "import": cmd_import,
192
+ "canary": cmd_canary,
193
+ "scan": cmd_scan,
194
+ "run": cmd_run,
195
+ "exec": cmd_exec,
196
+ "status": cmd_status,
197
+ }[args.command](args)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ raise SystemExit(main())
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from .detectors import ScanConfig
11
+ from .rules import DEFAULT_DISABLED, load_rules
12
+ from .vault import DEFAULT_DIR
13
+
14
+ DEFAULT_AI_HOSTS = [
15
+ "api.openai.com",
16
+ "api.anthropic.com",
17
+ "generativelanguage.googleapis.com",
18
+ "api.mistral.ai",
19
+ "api.groq.com",
20
+ "api.cohere.com",
21
+ "api.together.xyz",
22
+ "api.deepseek.com",
23
+ "api.x.ai",
24
+ "openrouter.ai",
25
+ "api.perplexity.ai",
26
+ "api.fireworks.ai",
27
+ "bedrock-runtime.*.amazonaws.com",
28
+ "*.openai.azure.com",
29
+ ]
30
+
31
+ MODES = ("block", "redact", "placeholder")
32
+
33
+
34
+ @dataclass
35
+ class Config:
36
+ mode: str = "redact"
37
+ hosts: list[str] = field(default_factory=lambda: list(DEFAULT_AI_HOSTS))
38
+ intercept_all_hosts: bool = False
39
+ notice: bool = True
40
+ scan: ScanConfig = field(default_factory=ScanConfig)
41
+ audit_log: Path = field(default_factory=lambda: DEFAULT_DIR / "audit.log")
42
+
43
+ @classmethod
44
+ def load(cls, path: str | os.PathLike | None = None) -> "Config":
45
+ cfg_path = Path(
46
+ path
47
+ or os.environ.get("KEYFENCE_CONFIG", DEFAULT_DIR / "config.yaml"))
48
+ cfg = cls()
49
+ if cfg_path.exists():
50
+ data = yaml.safe_load(cfg_path.read_text()) or {}
51
+ cfg.mode = data.get("mode", cfg.mode)
52
+ if data.get("hosts"):
53
+ cfg.hosts = list(data["hosts"])
54
+ if data.get("extra_hosts"):
55
+ cfg.hosts.extend(data["extra_hosts"])
56
+ cfg.intercept_all_hosts = bool(
57
+ data.get("intercept_all_hosts", cfg.intercept_all_hosts))
58
+ cfg.notice = bool(data.get("notice", cfg.notice))
59
+ scan = data.get("scan") or {}
60
+ cfg.scan = ScanConfig(
61
+ patterns_enabled=scan.get("patterns", True),
62
+ entropy_enabled=scan.get("entropy", True),
63
+ entropy_min_length=scan.get("entropy_min_length", 24),
64
+ entropy_threshold=scan.get("entropy_threshold", 4.5),
65
+ entropy_max_length=scan.get("entropy_max_length", 512),
66
+ allowlist=scan.get("allowlist") or [],
67
+ gitleaks=scan.get("gitleaks", True),
68
+ gitleaks_rules=scan.get("gitleaks_rules"),
69
+ gitleaks_disabled=list(scan.get("gitleaks_disabled", DEFAULT_DISABLED)),
70
+ )
71
+ if data.get("audit_log"):
72
+ cfg.audit_log = Path(data["audit_log"]).expanduser()
73
+ if cfg.mode not in MODES:
74
+ raise ValueError(f"invalid mode: {cfg.mode!r} (expected one of {', '.join(MODES)})")
75
+ if cfg.scan.gitleaks:
76
+ cfg.scan.rules = load_rules(cfg.scan.gitleaks_rules, cfg.scan.gitleaks_disabled)
77
+ return cfg
78
+
79
+ def host_matches(self, host: str) -> bool:
80
+ if self.intercept_all_hosts:
81
+ return True
82
+ host = host.lower()
83
+ for pattern in self.hosts:
84
+ p = pattern.lower()
85
+ if "*" in p:
86
+ if fnmatch.fnmatch(host, p):
87
+ return True
88
+ elif host == p or host.endswith("." + p):
89
+ return True
90
+ return False