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.
Files changed (56) hide show
  1. secretref-0.1.0/LICENSE +21 -0
  2. secretref-0.1.0/PKG-INFO +613 -0
  3. secretref-0.1.0/README.md +577 -0
  4. secretref-0.1.0/pyproject.toml +145 -0
  5. secretref-0.1.0/pyproject.toml.orig +115 -0
  6. secretref-0.1.0/src/secretref/__init__.py +99 -0
  7. secretref-0.1.0/src/secretref/__main__.py +11 -0
  8. secretref-0.1.0/src/secretref/auditors/__init__.py +56 -0
  9. secretref-0.1.0/src/secretref/auditors/auditor.py +185 -0
  10. secretref-0.1.0/src/secretref/auditors/known_format.py +42 -0
  11. secretref-0.1.0/src/secretref/auditors/placeholder.py +41 -0
  12. secretref-0.1.0/src/secretref/auditors/secret_key.py +25 -0
  13. secretref-0.1.0/src/secretref/cache.py +130 -0
  14. secretref-0.1.0/src/secretref/cli/__init__.py +145 -0
  15. secretref-0.1.0/src/secretref/cli/audit_command.py +151 -0
  16. secretref-0.1.0/src/secretref/cli/exit_codes.py +29 -0
  17. secretref-0.1.0/src/secretref/cli/policy_command.py +34 -0
  18. secretref-0.1.0/src/secretref/cli/show_command.py +109 -0
  19. secretref-0.1.0/src/secretref/constants.py +104 -0
  20. secretref-0.1.0/src/secretref/exceptions/__init__.py +36 -0
  21. secretref-0.1.0/src/secretref/exceptions/base.py +11 -0
  22. secretref-0.1.0/src/secretref/exceptions/command.py +18 -0
  23. secretref-0.1.0/src/secretref/exceptions/config.py +13 -0
  24. secretref-0.1.0/src/secretref/exceptions/environment.py +16 -0
  25. secretref-0.1.0/src/secretref/exceptions/file.py +16 -0
  26. secretref-0.1.0/src/secretref/exceptions/resolution.py +18 -0
  27. secretref-0.1.0/src/secretref/models/__init__.py +21 -0
  28. secretref-0.1.0/src/secretref/models/config.py +181 -0
  29. secretref-0.1.0/src/secretref/models/finding.py +47 -0
  30. secretref-0.1.0/src/secretref/models/reference.py +315 -0
  31. secretref-0.1.0/src/secretref/models/severity.py +55 -0
  32. secretref-0.1.0/src/secretref/models/source.py +20 -0
  33. secretref-0.1.0/src/secretref/parsers/__init__.py +29 -0
  34. secretref-0.1.0/src/secretref/parsers/base.py +52 -0
  35. secretref-0.1.0/src/secretref/parsers/json_parser.py +39 -0
  36. secretref-0.1.0/src/secretref/parsers/yaml_parser.py +42 -0
  37. secretref-0.1.0/src/secretref/patterns.py +101 -0
  38. secretref-0.1.0/src/secretref/policies/__init__.py +36 -0
  39. secretref-0.1.0/src/secretref/policies/command.py +281 -0
  40. secretref-0.1.0/src/secretref/policies/environment.py +153 -0
  41. secretref-0.1.0/src/secretref/policies/file.py +276 -0
  42. secretref-0.1.0/src/secretref/policies/settings/__init__.py +14 -0
  43. secretref-0.1.0/src/secretref/policies/settings/command.py +45 -0
  44. secretref-0.1.0/src/secretref/policies/settings/environment.py +37 -0
  45. secretref-0.1.0/src/secretref/policies/settings/file.py +40 -0
  46. secretref-0.1.0/src/secretref/py.typed +0 -0
  47. secretref-0.1.0/src/secretref/resolvers/__init__.py +26 -0
  48. secretref-0.1.0/src/secretref/resolvers/base.py +35 -0
  49. secretref-0.1.0/src/secretref/resolvers/command.py +91 -0
  50. secretref-0.1.0/src/secretref/resolvers/environment.py +21 -0
  51. secretref-0.1.0/src/secretref/resolvers/file.py +21 -0
  52. secretref-0.1.0/src/secretref/resolvers/literal.py +20 -0
  53. secretref-0.1.0/src/secretref/utils/__init__.py +16 -0
  54. secretref-0.1.0/src/secretref/utils/repository.py +46 -0
  55. secretref-0.1.0/src/secretref/utils/tree.py +137 -0
  56. secretref-0.1.0/src/secretref/utils/ttl_store.py +87 -0
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/secretref)](https://pypi.org/project/secretref/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/secretref)](https://pypi.org/project/secretref/)
43
+ [![CI](https://github.com/janbalitaan/secretref/actions/workflows/ci.yml/badge.svg)](https://github.com/janbalitaan/secretref/actions/workflows/ci.yml)
44
+ [![License](https://img.shields.io/pypi/l/secretref)](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