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 +1 -0
- keyfence/__main__.py +3 -0
- keyfence/addon.py +188 -0
- keyfence/cli.py +201 -0
- keyfence/config.py +90 -0
- keyfence/detectors.py +315 -0
- keyfence/importer.py +132 -0
- keyfence/notice.py +55 -0
- keyfence/rules/GITLEAKS-LICENSE +21 -0
- keyfence/rules/gitleaks.toml +3209 -0
- keyfence/rules.py +101 -0
- keyfence/runner.py +104 -0
- keyfence/streaming.py +137 -0
- keyfence/vault.py +109 -0
- keyfence-0.2.0.dist-info/METADATA +94 -0
- keyfence-0.2.0.dist-info/RECORD +21 -0
- keyfence-0.2.0.dist-info/WHEEL +5 -0
- keyfence-0.2.0.dist-info/entry_points.txt +2 -0
- keyfence-0.2.0.dist-info/licenses/LICENSE +21 -0
- keyfence-0.2.0.dist-info/licenses/keyfence/rules/GITLEAKS-LICENSE +21 -0
- keyfence-0.2.0.dist-info/top_level.txt +1 -0
keyfence/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
keyfence/__main__.py
ADDED
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
|