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.
- keyfence-0.2.0/LICENSE +21 -0
- keyfence-0.2.0/PKG-INFO +94 -0
- keyfence-0.2.0/README.md +66 -0
- keyfence-0.2.0/keyfence/__init__.py +1 -0
- keyfence-0.2.0/keyfence/__main__.py +3 -0
- keyfence-0.2.0/keyfence/addon.py +188 -0
- keyfence-0.2.0/keyfence/cli.py +201 -0
- keyfence-0.2.0/keyfence/config.py +90 -0
- keyfence-0.2.0/keyfence/detectors.py +315 -0
- keyfence-0.2.0/keyfence/importer.py +132 -0
- keyfence-0.2.0/keyfence/notice.py +55 -0
- keyfence-0.2.0/keyfence/rules/GITLEAKS-LICENSE +21 -0
- keyfence-0.2.0/keyfence/rules/gitleaks.toml +3209 -0
- keyfence-0.2.0/keyfence/rules.py +101 -0
- keyfence-0.2.0/keyfence/runner.py +104 -0
- keyfence-0.2.0/keyfence/streaming.py +137 -0
- keyfence-0.2.0/keyfence/vault.py +109 -0
- keyfence-0.2.0/keyfence.egg-info/PKG-INFO +94 -0
- keyfence-0.2.0/keyfence.egg-info/SOURCES.txt +34 -0
- keyfence-0.2.0/keyfence.egg-info/dependency_links.txt +1 -0
- keyfence-0.2.0/keyfence.egg-info/entry_points.txt +2 -0
- keyfence-0.2.0/keyfence.egg-info/requires.txt +6 -0
- keyfence-0.2.0/keyfence.egg-info/top_level.txt +1 -0
- keyfence-0.2.0/pyproject.toml +58 -0
- keyfence-0.2.0/setup.cfg +4 -0
- keyfence-0.2.0/tests/test_addon.py +312 -0
- keyfence-0.2.0/tests/test_bench.py +61 -0
- keyfence-0.2.0/tests/test_cli.py +173 -0
- keyfence-0.2.0/tests/test_config.py +91 -0
- keyfence-0.2.0/tests/test_detectors.py +369 -0
- keyfence-0.2.0/tests/test_importer.py +135 -0
- keyfence-0.2.0/tests/test_notice.py +68 -0
- keyfence-0.2.0/tests/test_rules.py +91 -0
- keyfence-0.2.0/tests/test_runner.py +139 -0
- keyfence-0.2.0/tests/test_streaming.py +160 -0
- 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.
|
keyfence-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/aminueza/keyfence/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/aminueza/keyfence/blob/main/LICENSE)
|
|
33
|
+

|
|
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
|
+

|
|
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.
|
keyfence-0.2.0/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# keyfence
|
|
2
|
+
|
|
3
|
+
[](https://github.com/aminueza/keyfence/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/aminueza/keyfence/blob/main/LICENSE)
|
|
5
|
+

|
|
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
|
+

|
|
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,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
|