secretref 0.1.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.
- secretref-0.1.0/LICENSE +21 -0
- secretref-0.1.0/PKG-INFO +613 -0
- secretref-0.1.0/README.md +577 -0
- secretref-0.1.0/pyproject.toml +145 -0
- secretref-0.1.0/pyproject.toml.orig +115 -0
- secretref-0.1.0/src/secretref/__init__.py +99 -0
- secretref-0.1.0/src/secretref/__main__.py +11 -0
- secretref-0.1.0/src/secretref/auditors/__init__.py +56 -0
- secretref-0.1.0/src/secretref/auditors/auditor.py +185 -0
- secretref-0.1.0/src/secretref/auditors/known_format.py +42 -0
- secretref-0.1.0/src/secretref/auditors/placeholder.py +41 -0
- secretref-0.1.0/src/secretref/auditors/secret_key.py +25 -0
- secretref-0.1.0/src/secretref/cache.py +130 -0
- secretref-0.1.0/src/secretref/cli/__init__.py +145 -0
- secretref-0.1.0/src/secretref/cli/audit_command.py +151 -0
- secretref-0.1.0/src/secretref/cli/exit_codes.py +29 -0
- secretref-0.1.0/src/secretref/cli/policy_command.py +34 -0
- secretref-0.1.0/src/secretref/cli/show_command.py +109 -0
- secretref-0.1.0/src/secretref/constants.py +104 -0
- secretref-0.1.0/src/secretref/exceptions/__init__.py +36 -0
- secretref-0.1.0/src/secretref/exceptions/base.py +11 -0
- secretref-0.1.0/src/secretref/exceptions/command.py +18 -0
- secretref-0.1.0/src/secretref/exceptions/config.py +13 -0
- secretref-0.1.0/src/secretref/exceptions/environment.py +16 -0
- secretref-0.1.0/src/secretref/exceptions/file.py +16 -0
- secretref-0.1.0/src/secretref/exceptions/resolution.py +18 -0
- secretref-0.1.0/src/secretref/models/__init__.py +21 -0
- secretref-0.1.0/src/secretref/models/config.py +181 -0
- secretref-0.1.0/src/secretref/models/finding.py +47 -0
- secretref-0.1.0/src/secretref/models/reference.py +315 -0
- secretref-0.1.0/src/secretref/models/severity.py +55 -0
- secretref-0.1.0/src/secretref/models/source.py +20 -0
- secretref-0.1.0/src/secretref/parsers/__init__.py +29 -0
- secretref-0.1.0/src/secretref/parsers/base.py +52 -0
- secretref-0.1.0/src/secretref/parsers/json_parser.py +39 -0
- secretref-0.1.0/src/secretref/parsers/yaml_parser.py +42 -0
- secretref-0.1.0/src/secretref/patterns.py +101 -0
- secretref-0.1.0/src/secretref/policies/__init__.py +36 -0
- secretref-0.1.0/src/secretref/policies/command.py +281 -0
- secretref-0.1.0/src/secretref/policies/environment.py +153 -0
- secretref-0.1.0/src/secretref/policies/file.py +276 -0
- secretref-0.1.0/src/secretref/policies/settings/__init__.py +14 -0
- secretref-0.1.0/src/secretref/policies/settings/command.py +45 -0
- secretref-0.1.0/src/secretref/policies/settings/environment.py +37 -0
- secretref-0.1.0/src/secretref/policies/settings/file.py +40 -0
- secretref-0.1.0/src/secretref/py.typed +0 -0
- secretref-0.1.0/src/secretref/resolvers/__init__.py +26 -0
- secretref-0.1.0/src/secretref/resolvers/base.py +35 -0
- secretref-0.1.0/src/secretref/resolvers/command.py +91 -0
- secretref-0.1.0/src/secretref/resolvers/environment.py +21 -0
- secretref-0.1.0/src/secretref/resolvers/file.py +21 -0
- secretref-0.1.0/src/secretref/resolvers/literal.py +20 -0
- secretref-0.1.0/src/secretref/utils/__init__.py +16 -0
- secretref-0.1.0/src/secretref/utils/repository.py +46 -0
- secretref-0.1.0/src/secretref/utils/tree.py +137 -0
- secretref-0.1.0/src/secretref/utils/ttl_store.py +87 -0
secretref-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 janbalitaan
|
|
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.
|
secretref-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secretref
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Config files that name their secrets instead of containing them, with an audit that refuses a literal inside a repository
|
|
5
|
+
Keywords: config,configuration,secrets,credentials,env,dotenv,settings,security,json,yaml
|
|
6
|
+
Author: janbalitaan
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Classifier: Environment :: Console
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Dist: pydantic>=2.9 ; extra == 'pydantic'
|
|
26
|
+
Requires-Dist: pyyaml>=6.0 ; extra == 'yaml'
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Project-URL: Homepage, https://github.com/janbalitaan/secretref
|
|
29
|
+
Project-URL: Documentation, https://github.com/janbalitaan/secretref/blob/main/docs/README.md
|
|
30
|
+
Project-URL: Source, https://github.com/janbalitaan/secretref
|
|
31
|
+
Project-URL: Changelog, https://github.com/janbalitaan/secretref/blob/main/CHANGELOG.md
|
|
32
|
+
Project-URL: Issues, https://github.com/janbalitaan/secretref/issues
|
|
33
|
+
Provides-Extra: pydantic
|
|
34
|
+
Provides-Extra: yaml
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# secretref
|
|
38
|
+
|
|
39
|
+
Config files that name their secrets instead of containing them.
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/secretref/)
|
|
42
|
+
[](https://pypi.org/project/secretref/)
|
|
43
|
+
[](https://github.com/janbalitaan/secretref/actions/workflows/ci.yml)
|
|
44
|
+
[](https://github.com/janbalitaan/secretref/blob/main/LICENSE)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"database": {
|
|
49
|
+
"host": "db.internal",
|
|
50
|
+
"port": 5432,
|
|
51
|
+
"name": "orders",
|
|
52
|
+
"password": {"env": "DATABASE_PASSWORD"}
|
|
53
|
+
},
|
|
54
|
+
"cache": {
|
|
55
|
+
"url": "redis://cache.internal:6379",
|
|
56
|
+
"password": {"file": "/run/secrets/redis-password"}
|
|
57
|
+
},
|
|
58
|
+
"payments": {
|
|
59
|
+
"api_key": {"command": "op read op://production/stripe/api-key"},
|
|
60
|
+
"webhook_secret": {"env": "STRIPE_WEBHOOK_SECRET"}
|
|
61
|
+
},
|
|
62
|
+
"session": {
|
|
63
|
+
"signing_key": {"file": "/run/secrets/session-key"},
|
|
64
|
+
"timeout_minutes": 30
|
|
65
|
+
},
|
|
66
|
+
"region": {"env": "AWS_REGION", "default": "us-east-1"},
|
|
67
|
+
"smtp_password": {"env": "SMTP_PASSWORD", "required": false},
|
|
68
|
+
"features": ["checkout-v2", "new-dashboard"]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from secretref import audit, load
|
|
74
|
+
|
|
75
|
+
config = load("config.json")
|
|
76
|
+
|
|
77
|
+
config.get("database.host") # "db.internal"
|
|
78
|
+
config.get("database.port") # 5432, an int
|
|
79
|
+
config.get("features") # ["checkout-v2", ...]
|
|
80
|
+
config.get("session.timeout_minutes") # 30
|
|
81
|
+
|
|
82
|
+
config.secret("database.password").resolve() # reads DATABASE_PASSWORD
|
|
83
|
+
config.secret("cache.password").resolve() # reads the mounted file
|
|
84
|
+
|
|
85
|
+
for finding in audit(config):
|
|
86
|
+
print(finding)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each source is matched to where secrets actually come from: `env` for a
|
|
90
|
+
twelve-factor deployment, `file` for a Kubernetes or Docker secret mount,
|
|
91
|
+
`command` for a password manager. And the config carries a port that is a
|
|
92
|
+
number, a list of feature flags, and nested sections — the three things that
|
|
93
|
+
make `.env` start to hurt.
|
|
94
|
+
|
|
95
|
+
That file is committable. It says where each secret lives, not what it is.
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
Full documentation is in [`docs/`](https://github.com/janbalitaan/secretref/blob/main/docs/README.md):
|
|
100
|
+
|
|
101
|
+
| Page | |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| [Getting started](https://github.com/janbalitaan/secretref/blob/main/docs/getting-started.md) | Install, a first config, reading a value |
|
|
104
|
+
| [Reference forms](https://github.com/janbalitaan/secretref/blob/main/docs/reference-forms.md) | `env`, `file`, `command`, `literal` |
|
|
105
|
+
| [Policies](https://github.com/janbalitaan/secretref/blob/main/docs/policies.md) | Bounding what a config may reach |
|
|
106
|
+
| [Auditing](https://github.com/janbalitaan/secretref/blob/main/docs/auditing.md) | Finding secrets that ended up inline |
|
|
107
|
+
| [Caching](https://github.com/janbalitaan/secretref/blob/main/docs/caching.md) | When resolution is expensive enough to cache |
|
|
108
|
+
| [Deployment](https://github.com/janbalitaan/secretref/blob/main/docs/deployment.md) | Docker, Kubernetes, systemd, serverless, a laptop |
|
|
109
|
+
| [API reference](https://github.com/janbalitaan/secretref/blob/main/docs/api.md) | Every public name |
|
|
110
|
+
| [Security](https://github.com/janbalitaan/secretref/blob/main/docs/security.md) | What an attacker can do |
|
|
111
|
+
| [Performance](https://github.com/janbalitaan/secretref/blob/main/docs/performance.md) | Measured costs per reference form |
|
|
112
|
+
| [Limitations](https://github.com/janbalitaan/secretref/blob/main/docs/limitations.md) | What it does not do |
|
|
113
|
+
| [Comparison](https://github.com/janbalitaan/secretref/blob/main/docs/comparison.md) | Against OmegaConf, Dynaconf and himl |
|
|
114
|
+
| [Encryption](https://github.com/janbalitaan/secretref/blob/main/docs/encryption.md) | Why this does no cryptography |
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install secretref
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Python 3.10+. **No dependencies** — a library that reads your secrets should
|
|
123
|
+
be auditable in an afternoon, and every dependency is something else to
|
|
124
|
+
audit. `secretref[yaml]` adds YAML support, `secretref[pydantic]` an adapter.
|
|
125
|
+
|
|
126
|
+
## Why not `.env`
|
|
127
|
+
|
|
128
|
+
`.env` is fine until the config needs structure. A list of providers, a
|
|
129
|
+
nested section, a number that is really a number — all of it arrives as
|
|
130
|
+
strings that something has to split and coerce, and the coercion ends up
|
|
131
|
+
scattered across the codebase.
|
|
132
|
+
|
|
133
|
+
JSON and YAML handle structure natively. The problem they introduce is that
|
|
134
|
+
the secrets are then sitting in a file beside the code, which is how they
|
|
135
|
+
reach a repository. References fix that without giving up structure.
|
|
136
|
+
|
|
137
|
+
## From a shell
|
|
138
|
+
|
|
139
|
+
The audit is one line in a pipeline, and the exit status is the interface:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
secretref audit # find config.json and check it
|
|
143
|
+
secretref audit path/to/config.json
|
|
144
|
+
secretref audit --json # for a tool to consume
|
|
145
|
+
secretref audit --strict # warnings block as well as errors
|
|
146
|
+
secretref show # list what the config references
|
|
147
|
+
secretref policy # what the policies permit by default
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
| exit | means |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `0` | nothing blocking |
|
|
153
|
+
| `1` | a blocking finding |
|
|
154
|
+
| `2` | the command could not run — no config, bad arguments, unparseable file |
|
|
155
|
+
|
|
156
|
+
The last two are separate on purpose: a pipeline needs to tell "your config
|
|
157
|
+
has a problem" from "this tool was used wrongly".
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
# .github/workflows/ci.yml
|
|
161
|
+
- run: pip install secretref
|
|
162
|
+
- run: secretref audit --strict
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**It runs nothing.** A command reference is reported as `not verified` rather
|
|
166
|
+
than executed, because auditing a config from a pull request is the main use
|
|
167
|
+
and resolving one would run whatever the pull request contained. `--resolve
|
|
168
|
+
--allow-command op` opts in, for a config you wrote.
|
|
169
|
+
|
|
170
|
+
It also does not report a command reference as *refused*: this tool's policy
|
|
171
|
+
is not your application's, and it has no way to learn what yours permits, so
|
|
172
|
+
calling a working config broken would be a false alarm.
|
|
173
|
+
|
|
174
|
+
## The audit is the point
|
|
175
|
+
|
|
176
|
+
This is the part that justifies a library rather than a helper function:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
for finding in audit(config):
|
|
180
|
+
print(finding)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
error: payments.api_key: a literal secret inside a repository
|
|
185
|
+
try: move it to an environment variable and reference it: {"env": "..."},
|
|
186
|
+
and rotate it, since git history keeps the old value
|
|
187
|
+
warning: session.signing_key: a literal secret in a config file
|
|
188
|
+
try: reference it instead of storing it
|
|
189
|
+
error: cache.password: refused by policy — file is outside the permitted roots
|
|
190
|
+
try: widen the policy, or point the reference somewhere it permits
|
|
191
|
+
note: smtp_password: env 'SMTP_PASSWORD' resolves to nothing
|
|
192
|
+
try: set it, or add "required": false if it is optional
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Three severities, and the split that matters is *who has to fix it*:
|
|
196
|
+
|
|
197
|
+
| | means | fix |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `error` on a literal | it may already be published | the config, and rotate the secret |
|
|
200
|
+
| `error` on a refusal | the config asks for something policy forbids | the config or the policy |
|
|
201
|
+
| `note` | the secret is not where the config said | the deployment |
|
|
202
|
+
|
|
203
|
+
A refusal is an error whether or not there is a repository above the file,
|
|
204
|
+
unlike a literal. It has nothing to do with publishing — the application will
|
|
205
|
+
not start.
|
|
206
|
+
|
|
207
|
+
A policy refusal is never silenced by `required: false` or by a `default`.
|
|
208
|
+
Those say the secret may be *absent*; they do not say the config may ask for
|
|
209
|
+
something it is not permitted to do.
|
|
210
|
+
|
|
211
|
+
A literal secret is an **error** when there is a repository above the file,
|
|
212
|
+
because such a value is one commit from a remote. Outside a repository the
|
|
213
|
+
same value is a warning — careless, but contained.
|
|
214
|
+
|
|
215
|
+
The distinction matters because the failure being prevented is not a crash.
|
|
216
|
+
Nothing breaks when a key is hardcoded; it works perfectly, right up until
|
|
217
|
+
the repository is public. No test catches that, which is why it needs a check
|
|
218
|
+
of its own.
|
|
219
|
+
|
|
220
|
+
Wire it into CI and a literal cannot merge:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
import sys
|
|
224
|
+
from secretref import audit, load
|
|
225
|
+
|
|
226
|
+
findings = audit(load("config.json"))
|
|
227
|
+
for finding in findings:
|
|
228
|
+
print(finding, file=sys.stderr)
|
|
229
|
+
sys.exit(1 if any(f.blocking for f in findings) else 0)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Where a secret can come from
|
|
233
|
+
|
|
234
|
+
| form | reads from | for |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `{"env": "NAME"}` | an environment variable | most things |
|
|
237
|
+
| `{"file": "/path"}` | a file's contents | Docker, Kubernetes and systemd secret mounts |
|
|
238
|
+
| `{"command": "..."}` | a command's stdout | password managers and cloud secret stores |
|
|
239
|
+
| `"a string"` | itself | local experiments, and what the audit objects to |
|
|
240
|
+
|
|
241
|
+
Each accepts `default` and `required`:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{"smtp_password": {"env": "SMTP_PASSWORD", "required": false}}
|
|
245
|
+
{"region": {"env": "AWS_REGION", "default": "us-east-1"}}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`required: false` suits a feature that is off in development — no SMTP server,
|
|
249
|
+
no password, and nothing should fail for want of one. `default` suits a value
|
|
250
|
+
with a sensible fallback that a deployment may override.
|
|
251
|
+
|
|
252
|
+
### Local development, in one line
|
|
253
|
+
|
|
254
|
+
The only setup cost the policies impose, and the only place you are likely to
|
|
255
|
+
meet one:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from secretref import FilePolicy
|
|
259
|
+
|
|
260
|
+
FilePolicy().allow_anywhere() # secrets live wherever you keep them
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Nothing is needed for `{"env": ...}`, and nothing is needed for a file under
|
|
264
|
+
`/run/secrets` or `/run/credentials` — which is where Docker, Kubernetes and
|
|
265
|
+
systemd put them. So a container is confined with no configuration, and a
|
|
266
|
+
laptop costs one line.
|
|
267
|
+
|
|
268
|
+
A `{"command": ...}` reference costs one more, because it is the one form
|
|
269
|
+
that runs code:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
CommandPolicy().allow("op") # name what a config may run
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
That is the whole setup. If you meet a refusal, the message names the exact
|
|
276
|
+
call that changes it.
|
|
277
|
+
|
|
278
|
+
### Three policies, one per reference form
|
|
279
|
+
|
|
280
|
+
Two of the three forms reach outside the process, and the third can be
|
|
281
|
+
redirected. Each has a policy, all three are singletons, and only one is off
|
|
282
|
+
by default:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
from secretref import CommandPolicy, EnvironmentPolicy, FilePolicy
|
|
286
|
+
|
|
287
|
+
CommandPolicy().allow("op", "vault") # off by default
|
|
288
|
+
FilePolicy().restrict() # open by default
|
|
289
|
+
EnvironmentPolicy().restrict(prefixes=["MYAPP_"]) # open by default
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Only `CommandPolicy` starts closed, because only it turns a writable config
|
|
293
|
+
into code execution. The other two raise the cost of a mistake rather than
|
|
294
|
+
removing a capability, and a library that rejected working configs on upgrade
|
|
295
|
+
would get pinned to the version before it.
|
|
296
|
+
|
|
297
|
+
### Command references are off by default
|
|
298
|
+
|
|
299
|
+
A `command` reference is the most useful form and the only dangerous one. The
|
|
300
|
+
others read a variable or a file; this one starts a process, so **a config
|
|
301
|
+
file someone can write is a config file that can run code**.
|
|
302
|
+
|
|
303
|
+
So it does nothing until you say otherwise, naming what may run:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from secretref import CommandPolicy
|
|
307
|
+
|
|
308
|
+
CommandPolicy().allow("op", "vault")
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Without that, a command reference is refused with a message saying how to
|
|
312
|
+
enable it. With it, four things hold:
|
|
313
|
+
|
|
314
|
+
| | |
|
|
315
|
+
| --- | --- |
|
|
316
|
+
| **Allowlist** | only the executables you named; a config that gains `{"command": "curl ..."}` is refused |
|
|
317
|
+
| **Minimal environment** | only `PATH`, `HOME`, `LANG`, `TMPDIR` plus whatever you add. A subprocess given the parent's environment can read every other secret the process holds |
|
|
318
|
+
| **Output cap** | 64 KB by default. A secret is short; more is the wrong command or an attempt to exhaust memory |
|
|
319
|
+
| **No shell** | split with `shlex` and run directly, so a locator cannot chain commands or open a pipe |
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
CommandPolicy().allow(
|
|
323
|
+
"op",
|
|
324
|
+
timeout_seconds=5,
|
|
325
|
+
max_output_bytes=4096,
|
|
326
|
+
pass_environment=["OP_SERVICE_ACCOUNT_TOKEN"],
|
|
327
|
+
require_absolute=True, # a bare name resolves through PATH
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
A failing command's stderr is never included in an error message, because a
|
|
332
|
+
failing secret tool often prints the secret.
|
|
333
|
+
|
|
334
|
+
**What this does not do.** An attacker who can edit your config and who is
|
|
335
|
+
allowed to run `op` can read everything `op` can read. The policy narrows
|
|
336
|
+
what a mistake costs; it does not make a writable config trustworthy. If your
|
|
337
|
+
config is attacker-controlled, do not enable command references at all.
|
|
338
|
+
|
|
339
|
+
## Resolution is lazy, and optionally cached
|
|
340
|
+
|
|
341
|
+
Loading a config reads no secrets. A file naming twenty of them resolves none
|
|
342
|
+
until something asks, so a process that uses one does not need the other
|
|
343
|
+
nineteen to exist. That is what lets a single config file serve development
|
|
344
|
+
and production.
|
|
345
|
+
|
|
346
|
+
Every `resolve()` re-reads by default. For `{"env": ...}` that costs nothing;
|
|
347
|
+
for `{"command": "op read ..."}` it is a subprocess each time. Caching is
|
|
348
|
+
opt-in:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
from secretref import ResolutionCache
|
|
352
|
+
|
|
353
|
+
ResolutionCache().enable(ttl_seconds=300)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Off by default because it changes two things you should decide deliberately:
|
|
357
|
+
a secret stays in memory after the only read that needed it, and a **rotated
|
|
358
|
+
secret keeps serving the old value** until the entry expires. The ttl is how
|
|
359
|
+
a long-running process picks up a rotation without restarting;
|
|
360
|
+
`ResolutionCache().clear()` is how you force it.
|
|
361
|
+
|
|
362
|
+
Literals are never cached — reading one costs nothing, so holding it buys
|
|
363
|
+
nothing.
|
|
364
|
+
|
|
365
|
+
## Describing a secret without revealing it
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
ref = config.secret("database.password")
|
|
369
|
+
ref.described() # "env 'DATABASE_PASSWORD'"
|
|
370
|
+
repr(ref) # "SecretRef(source=env, locator='DATABASE_PASSWORD')"
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Safe for logs, error messages and anything a user sees. A literal is
|
|
374
|
+
described as `"a literal value"` and nothing more, because any part of a
|
|
375
|
+
literal is part of a secret. For the same reason `as_dict()` writes a literal
|
|
376
|
+
back as `[redacted]` rather than itself.
|
|
377
|
+
|
|
378
|
+
## Checking everything at once
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
missing = [path for path, ref in config.secrets().items() if not ref.is_resolvable()]
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Reports every missing secret at startup instead of failing on the first, so a
|
|
385
|
+
new deployment is one round of fixes rather than five.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## What someone who can edit your config can do
|
|
390
|
+
|
|
391
|
+
The policies assume an attacker can write the config file. None of them make
|
|
392
|
+
that safe — they bound what it costs.
|
|
393
|
+
|
|
394
|
+
### Auditing a config you did not write does not execute it
|
|
395
|
+
|
|
396
|
+
This is the one that matters most, because this README recommends wiring
|
|
397
|
+
`audit()` into CI — where the config comes from a pull request.
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
audit(config) # resolves nothing with side effects
|
|
401
|
+
audit(config, resolve=True) # for your own config, at startup
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
An earlier version resolved every reference to check it, and resolving a
|
|
405
|
+
command reference **runs** it. A CI check on a pull request was remote code
|
|
406
|
+
execution for anyone who had enabled command references for their own use.
|
|
407
|
+
|
|
408
|
+
Now a command reference is reported as `not verified` rather than run.
|
|
409
|
+
Policy refusals and syntax errors are still reported, because those are found
|
|
410
|
+
by asking the policy and parsing the string — neither starts anything.
|
|
411
|
+
|
|
412
|
+
### What each form costs an attacker
|
|
413
|
+
|
|
414
|
+
| they add | without a policy | with one |
|
|
415
|
+
| --- | --- | --- |
|
|
416
|
+
| `{"command": "curl ..."}` | runs | refused unless `curl` is allowed |
|
|
417
|
+
| `{"command": "op ..."}` where `op` is allowed | reads everything `op` can | **unchanged — this is the real limit** |
|
|
418
|
+
| `{"file": "/etc/shadow"}` | reads it | refused; only the secret mounts |
|
|
419
|
+
| `{"file": "/run/secrets/other-tenant"}` | reads it | **unchanged — everything in an allowed root is readable** |
|
|
420
|
+
| `{"env": "AWS_SECRET_ACCESS_KEY"}` on a rendered field | leaks it | refused if a prefix or allowlist is set |
|
|
421
|
+
| a 2 GB command output | read into memory | 64 KB cap |
|
|
422
|
+
|
|
423
|
+
The two unchanged rows are the honest limit. A policy narrows the blast
|
|
424
|
+
radius; it does not make a writable config trustworthy. **If your config is
|
|
425
|
+
attacker-controlled, do not enable command references at all.**
|
|
426
|
+
|
|
427
|
+
### If they can also set PATH
|
|
428
|
+
|
|
429
|
+
`CommandPolicy().allow("op")` used to permit whatever `op` meant on PATH at
|
|
430
|
+
resolution time, so prepending a directory substituted their own script.
|
|
431
|
+
Executables are now resolved **when the policy is configured** — while PATH
|
|
432
|
+
is still the one the operator started with — and a later substitution is
|
|
433
|
+
refused:
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
command 'mytool' resolves to /tmp/evil/mytool, which is not the file this
|
|
437
|
+
policy pinned when it was configured. PATH has changed since then.
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`require_absolute=True` is stricter still: the config itself must name a full
|
|
441
|
+
path.
|
|
442
|
+
|
|
443
|
+
### If they can write inside an allowed root
|
|
444
|
+
|
|
445
|
+
Then they control what is read, and a symlink out of the root is the obvious
|
|
446
|
+
move. Links are resolved before the root check, so that is refused — but an
|
|
447
|
+
attacker with write access to `/run/secrets` can read everything in it. That
|
|
448
|
+
is not a boundary this library can hold.
|
|
449
|
+
|
|
450
|
+
## Limitations
|
|
451
|
+
|
|
452
|
+
Read this before trusting the library with anything that matters.
|
|
453
|
+
|
|
454
|
+
### The audit cannot find a secret it does not recognize as one
|
|
455
|
+
|
|
456
|
+
Two checks run, and together they still leave gaps.
|
|
457
|
+
|
|
458
|
+
**By value.** A credential matching a format its issuer publishes — an AWS
|
|
459
|
+
access key ID, a GitHub or Stripe token, a private key block, a connection
|
|
460
|
+
string with a password in it — is reported whatever the key is called. These
|
|
461
|
+
are named in the finding, so you know what to rotate.
|
|
462
|
+
|
|
463
|
+
**By key name.** A value under a key like `password` or `api_key` is reported
|
|
464
|
+
even when its shape says nothing, which is the only way to catch an internal
|
|
465
|
+
credential that has no published format.
|
|
466
|
+
|
|
467
|
+
What neither finds: a secret with no recognizable shape under a key with no
|
|
468
|
+
recognizable name. A password split across two fields. One encoded, or
|
|
469
|
+
assembled at runtime. This is a check against carelessness, not against
|
|
470
|
+
determination.
|
|
471
|
+
|
|
472
|
+
The format list is in `patterns.py`, as `KNOWN_SECRET_FORMATS`. Adding one is
|
|
473
|
+
a line there and a case in `tests/detection/`.
|
|
474
|
+
|
|
475
|
+
For scanning a whole repository's *history*, use `gitleaks` or
|
|
476
|
+
`detect-secrets`. They answer a different question and answer it better —
|
|
477
|
+
this one looks at the config you are about to deploy.
|
|
478
|
+
|
|
479
|
+
### It does not encrypt anything
|
|
480
|
+
|
|
481
|
+
A resolved secret is a plain string in your process memory, as it would be
|
|
482
|
+
from any config library. This moves the secret out of the file; it does not
|
|
483
|
+
protect it once read.
|
|
484
|
+
|
|
485
|
+
### Environment references can be narrowed
|
|
486
|
+
|
|
487
|
+
The obvious objection: the process already holds every variable, so reading
|
|
488
|
+
one through a reference grants nothing new. True, and beside the point.
|
|
489
|
+
|
|
490
|
+
The threat is not reaching into the environment. It is redirecting a data
|
|
491
|
+
flow that already leaves it:
|
|
492
|
+
|
|
493
|
+
```json
|
|
494
|
+
{"support_email": {"env": "SUPPORT_EMAIL"}}
|
|
495
|
+
{"support_email": {"env": "AWS_SECRET_ACCESS_KEY"}}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Same process, same variables, no new access — and if the application renders
|
|
499
|
+
that field, a key is now on a public page. Every field a config exposes is a
|
|
500
|
+
channel: a log line, an error message, an HTTP response, a metrics label.
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
EnvironmentPolicy().restrict("DATABASE_PASSWORD", "STRIPE_KEY") # these only
|
|
504
|
+
EnvironmentPolicy().restrict(prefixes=["MYAPP_"]) # a namespace
|
|
505
|
+
EnvironmentPolicy().restrict(patterns=[r"_PASSWORD$"]) # a convention
|
|
506
|
+
EnvironmentPolicy().restrict() # off entirely
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`EnvironmentPolicy().visible()` lists what a config could currently reach, by
|
|
510
|
+
name and never by value — more useful at startup than discovering it from a
|
|
511
|
+
refusal.
|
|
512
|
+
|
|
513
|
+
### File references are confined to the secret mounts
|
|
514
|
+
|
|
515
|
+
By default a file reference may only read from `/run/secrets` and
|
|
516
|
+
`/run/credentials` — where Docker, Kubernetes and systemd actually mount
|
|
517
|
+
secrets. A container is therefore protected with no configuration, and still
|
|
518
|
+
works. A project keeping secrets elsewhere says so once:
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
from secretref import FilePolicy
|
|
522
|
+
|
|
523
|
+
FilePolicy().restrict(Path.home() / ".config" / "myapp") # add a root
|
|
524
|
+
FilePolicy().allow_anywhere() # give it up
|
|
525
|
+
FilePolicy().reset() # back to default
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`allow_anywhere()` is named for what it does rather than something softer,
|
|
529
|
+
because it is the one call that removes the protection and the call site
|
|
530
|
+
should say so.
|
|
531
|
+
|
|
532
|
+
Five things hold:
|
|
533
|
+
|
|
534
|
+
| | |
|
|
535
|
+
| --- | --- |
|
|
536
|
+
| **Allowed roots** | `{"file": "/etc/shadow"}` is refused rather than attempted |
|
|
537
|
+
| **Symlinks resolved first** | a link inside a root pointing outside it would otherwise defeat the check |
|
|
538
|
+
| **Owner-only available** | `restrict(require_private=True)` refuses a secret readable by group or world, with the `chmod` to fix it. Not the default, because a Kubernetes secret volume is mode 0644 unless the pod spec says otherwise — requiring it would refuse a standard deployment |
|
|
539
|
+
| **Size cap** | 64 KB. Pointing a reference at a log file fails rather than reading it |
|
|
540
|
+
| **One operation** | the file is opened once and checked through `fstat` on that descriptor, so the inode validated is the inode read |
|
|
541
|
+
|
|
542
|
+
Kubernetes projected volumes are symlinks, so a link *within* a root is still
|
|
543
|
+
read — refusing all links would refuse the main use case.
|
|
544
|
+
|
|
545
|
+
What this does not fix: an attacker who can write your config and who is
|
|
546
|
+
allowed to read `/run/secrets` can read everything in there.
|
|
547
|
+
|
|
548
|
+
### A `command` reference runs a command
|
|
549
|
+
|
|
550
|
+
It is split with `shlex` and run without a shell, so the locator cannot chain
|
|
551
|
+
commands. It is still a command from a config file, so treat a writable
|
|
552
|
+
config as equivalent to code execution.
|
|
553
|
+
|
|
554
|
+
### Repository detection is a heuristic
|
|
555
|
+
|
|
556
|
+
The audit looks for `.git`, `.hg` or `.svn` in the file's directory and its
|
|
557
|
+
parents, up to a limit. A bare checkout, an unusual VCS layout, or a config
|
|
558
|
+
outside the working copy will be judged wrong — in the safe direction, as a
|
|
559
|
+
warning rather than an error.
|
|
560
|
+
|
|
561
|
+
### It is not a schema library
|
|
562
|
+
|
|
563
|
+
No types, no validation, no coercion beyond what JSON already gives. If you
|
|
564
|
+
want those, keep using pydantic and let this resolve the secrets:
|
|
565
|
+
`secretref[pydantic]` exists for exactly that.
|
|
566
|
+
|
|
567
|
+
### `required: false` is quiet by design
|
|
568
|
+
|
|
569
|
+
A reference that resolves to nothing returns `""` rather than raising. That
|
|
570
|
+
is what makes an optional provider optional, and it means a typo in a
|
|
571
|
+
variable name looks the same as a deliberately absent one. `audit` reports
|
|
572
|
+
both, which is where to catch it.
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Contributing
|
|
577
|
+
|
|
578
|
+
The full guide is in
|
|
579
|
+
[CONTRIBUTING.md](https://github.com/janbalitaan/secretref/blob/main/CONTRIBUTING.md),
|
|
580
|
+
and security reports go through
|
|
581
|
+
[SECURITY.md](https://github.com/janbalitaan/secretref/blob/main/SECURITY.md)
|
|
582
|
+
rather than an issue.
|
|
583
|
+
|
|
584
|
+
Contributions are welcome. The most useful ones:
|
|
585
|
+
|
|
586
|
+
**Another source.** A secret store with a Python client rather than a CLI, as
|
|
587
|
+
an optional extra.
|
|
588
|
+
|
|
589
|
+
**A smarter audit.** Key names and value shapes that indicate a secret and
|
|
590
|
+
are not yet recognized — with the config that fooled it.
|
|
591
|
+
|
|
592
|
+
**A false positive you hit.** A real one, with the input, is worth more than
|
|
593
|
+
a hypothetical.
|
|
594
|
+
|
|
595
|
+
Every detector needs a test in **both** directions: something it must catch,
|
|
596
|
+
and something it must leave alone. A check with only positive tests is the
|
|
597
|
+
one that cries wolf in someone else's config.
|
|
598
|
+
|
|
599
|
+
```bash
|
|
600
|
+
git clone https://github.com/janbalitaan/secretref
|
|
601
|
+
cd secretref
|
|
602
|
+
uv sync
|
|
603
|
+
uv run pytest
|
|
604
|
+
uv run ruff check src tests
|
|
605
|
+
uv run ty check src
|
|
606
|
+
uvx complexipy src --max-complexity-allowed 15
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
All four must pass. No function may exceed cognitive complexity 15.
|
|
610
|
+
|
|
611
|
+
## License
|
|
612
|
+
|
|
613
|
+
MIT
|