keyfence 0.2.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.
keyfence/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
keyfence/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
keyfence/addon.py ADDED
@@ -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()]
keyfence/cli.py ADDED
@@ -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())
keyfence/config.py ADDED
@@ -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