secretref 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- secretref/__init__.py +99 -0
- secretref/__main__.py +11 -0
- secretref/auditors/__init__.py +56 -0
- secretref/auditors/auditor.py +185 -0
- secretref/auditors/known_format.py +42 -0
- secretref/auditors/placeholder.py +41 -0
- secretref/auditors/secret_key.py +25 -0
- secretref/cache.py +130 -0
- secretref/cli/__init__.py +145 -0
- secretref/cli/audit_command.py +151 -0
- secretref/cli/exit_codes.py +29 -0
- secretref/cli/policy_command.py +34 -0
- secretref/cli/show_command.py +109 -0
- secretref/constants.py +104 -0
- secretref/exceptions/__init__.py +36 -0
- secretref/exceptions/base.py +11 -0
- secretref/exceptions/command.py +18 -0
- secretref/exceptions/config.py +13 -0
- secretref/exceptions/environment.py +16 -0
- secretref/exceptions/file.py +16 -0
- secretref/exceptions/resolution.py +18 -0
- secretref/models/__init__.py +21 -0
- secretref/models/config.py +181 -0
- secretref/models/finding.py +47 -0
- secretref/models/reference.py +315 -0
- secretref/models/severity.py +55 -0
- secretref/models/source.py +20 -0
- secretref/parsers/__init__.py +29 -0
- secretref/parsers/base.py +52 -0
- secretref/parsers/json_parser.py +39 -0
- secretref/parsers/yaml_parser.py +42 -0
- secretref/patterns.py +101 -0
- secretref/policies/__init__.py +36 -0
- secretref/policies/command.py +281 -0
- secretref/policies/environment.py +153 -0
- secretref/policies/file.py +276 -0
- secretref/policies/settings/__init__.py +14 -0
- secretref/policies/settings/command.py +45 -0
- secretref/policies/settings/environment.py +37 -0
- secretref/policies/settings/file.py +40 -0
- secretref/py.typed +0 -0
- secretref/resolvers/__init__.py +26 -0
- secretref/resolvers/base.py +35 -0
- secretref/resolvers/command.py +91 -0
- secretref/resolvers/environment.py +21 -0
- secretref/resolvers/file.py +21 -0
- secretref/resolvers/literal.py +20 -0
- secretref/utils/__init__.py +16 -0
- secretref/utils/repository.py +46 -0
- secretref/utils/tree.py +137 -0
- secretref/utils/ttl_store.py +87 -0
- secretref-0.1.0.dist-info/METADATA +613 -0
- secretref-0.1.0.dist-info/RECORD +56 -0
- secretref-0.1.0.dist-info/WHEEL +4 -0
- secretref-0.1.0.dist-info/entry_points.txt +3 -0
- secretref-0.1.0.dist-info/licenses/LICENSE +21 -0
secretref/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Config files that name their secrets instead of containing them.
|
|
2
|
+
|
|
3
|
+
An `.env` file works until the config needs a list, a nested section, or a
|
|
4
|
+
number that is really a number. JSON and YAML handle all three — but then the
|
|
5
|
+
secrets are sitting in a file next to the code, which is how they end up in a
|
|
6
|
+
repository.
|
|
7
|
+
|
|
8
|
+
So a secret in a config file here is a *reference*:
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"providers": [
|
|
12
|
+
{"name": "openai", "api_key": {"env": "OPENAI_API_KEY"}},
|
|
13
|
+
{"name": "anthropic", "api_key": {"file": "/run/secrets/anthropic"}},
|
|
14
|
+
{"name": "internal", "api_key": {"command": "op read op://vault/key"}}
|
|
15
|
+
],
|
|
16
|
+
"timeout_seconds": 30,
|
|
17
|
+
"languages": ["en", "fil"]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
The file is committable, because it says where each secret lives rather than
|
|
21
|
+
what it is. Resolution is lazy, so a config naming twenty secrets reads none
|
|
22
|
+
of them at load — which is what lets one file serve development and
|
|
23
|
+
production.
|
|
24
|
+
|
|
25
|
+
from secretref import load
|
|
26
|
+
|
|
27
|
+
config = load("config.json")
|
|
28
|
+
key = config.secret("providers.0.api_key").resolve()
|
|
29
|
+
|
|
30
|
+
And the part that makes it worth a library rather than a helper function:
|
|
31
|
+
|
|
32
|
+
from secretref import audit
|
|
33
|
+
|
|
34
|
+
for finding in audit(config):
|
|
35
|
+
print(finding)
|
|
36
|
+
# error: providers.0.api_key: a literal secret inside a repository
|
|
37
|
+
# try: move it to an environment variable and reference it
|
|
38
|
+
|
|
39
|
+
`audit` reports a literal secret as an **error** when it finds a repository
|
|
40
|
+
above the file, because such a value is one commit from a remote. Outside a
|
|
41
|
+
repository the same value is a warning. That check is the reason this exists:
|
|
42
|
+
the failure it prevents is not a crash but a leak, which no test catches.
|
|
43
|
+
|
|
44
|
+
No dependencies. A library that reads your secrets should be auditable in an
|
|
45
|
+
afternoon.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from secretref.auditors import audit, repository_root
|
|
49
|
+
from secretref.cache import ResolutionCache
|
|
50
|
+
from secretref.constants import REDACTED
|
|
51
|
+
from secretref.exceptions import (
|
|
52
|
+
CommandNotAllowedError,
|
|
53
|
+
ConfigError,
|
|
54
|
+
FileNotAllowedError,
|
|
55
|
+
ResolutionError,
|
|
56
|
+
SecretrefError,
|
|
57
|
+
VariableNotAllowedError,
|
|
58
|
+
)
|
|
59
|
+
from secretref.models import (
|
|
60
|
+
Config,
|
|
61
|
+
Finding,
|
|
62
|
+
SecretRef,
|
|
63
|
+
Severity,
|
|
64
|
+
Source,
|
|
65
|
+
)
|
|
66
|
+
from secretref.policies import CommandPolicy, EnvironmentPolicy, FilePolicy
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"REDACTED",
|
|
70
|
+
"CommandNotAllowedError",
|
|
71
|
+
"CommandPolicy",
|
|
72
|
+
"Config",
|
|
73
|
+
"ConfigError",
|
|
74
|
+
"EnvironmentPolicy",
|
|
75
|
+
"FileNotAllowedError",
|
|
76
|
+
"FilePolicy",
|
|
77
|
+
"Finding",
|
|
78
|
+
"ResolutionCache",
|
|
79
|
+
"ResolutionError",
|
|
80
|
+
"SecretRef",
|
|
81
|
+
"SecretrefError",
|
|
82
|
+
"Severity",
|
|
83
|
+
"Source",
|
|
84
|
+
"VariableNotAllowedError",
|
|
85
|
+
"__version__",
|
|
86
|
+
"audit",
|
|
87
|
+
"find_config",
|
|
88
|
+
"load",
|
|
89
|
+
"loads",
|
|
90
|
+
"repository_root",
|
|
91
|
+
]
|
|
92
|
+
__version__ = "0.1.0"
|
|
93
|
+
|
|
94
|
+
# The public API stays functions, because `load("config.json")` is what a
|
|
95
|
+
# caller wants to write. Config holds the implementation, so the pieces live
|
|
96
|
+
# with the type they build rather than in a module of their own.
|
|
97
|
+
load = Config.load
|
|
98
|
+
loads = Config.loads
|
|
99
|
+
find_config = Config.find
|
secretref/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Lets the tool run as `python -m secretref`, not only as `secretref`.
|
|
2
|
+
|
|
3
|
+
Useful when the package is installed somewhere the console script is not on
|
|
4
|
+
PATH, which is common in a container.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from secretref.cli import run
|
|
10
|
+
|
|
11
|
+
run()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Checking a config file for secrets it should not be holding.
|
|
2
|
+
|
|
3
|
+
The failure this prevents is not a crash. A hardcoded key works perfectly —
|
|
4
|
+
right up until the repository is public. Nothing breaks, no test fails, and
|
|
5
|
+
the problem is discovered by someone else.
|
|
6
|
+
|
|
7
|
+
So the central question is not "is this value valid" but "could this value
|
|
8
|
+
already have been published". That is why the severity depends on whether
|
|
9
|
+
there is a repository above the file:
|
|
10
|
+
|
|
11
|
+
ERROR a literal secret inside a working copy. One commit from a
|
|
12
|
+
remote, and git history keeps it after the fix.
|
|
13
|
+
WARNING a literal secret outside one. Careless, but contained.
|
|
14
|
+
NOTE a reference that currently resolves to nothing. Usually a typo
|
|
15
|
+
in a variable name, occasionally a deliberately absent option.
|
|
16
|
+
|
|
17
|
+
What this is not: a repository scanner. It reads one config file and knows
|
|
18
|
+
nothing about history, other files, or anything already committed. For that,
|
|
19
|
+
gitleaks and detect-secrets answer a different question and answer it better.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from secretref.auditors.auditor import Auditor
|
|
25
|
+
from secretref.auditors.placeholder import Placeholder
|
|
26
|
+
from secretref.auditors.secret_key import SecretKey
|
|
27
|
+
from secretref.models.config import Config
|
|
28
|
+
from secretref.models.finding import Finding
|
|
29
|
+
from secretref.utils.repository import Repository
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Auditor",
|
|
33
|
+
"Placeholder",
|
|
34
|
+
"SecretKey",
|
|
35
|
+
"audit",
|
|
36
|
+
"repository_root",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def audit(config: Config, *, resolve: bool = False) -> list[Finding]:
|
|
41
|
+
"""Every problem in a config file, most severe first.
|
|
42
|
+
|
|
43
|
+
Does not resolve by default, which is a security boundary rather than a
|
|
44
|
+
performance one: resolving a command reference runs it, and auditing a
|
|
45
|
+
config you did not write is the main use for this. A CI check on a pull
|
|
46
|
+
request would otherwise have been remote code execution.
|
|
47
|
+
|
|
48
|
+
Pass `resolve=True` for your own config at startup, where confirming
|
|
49
|
+
that a secret is actually reachable is worth more than the caution.
|
|
50
|
+
"""
|
|
51
|
+
return Auditor(config, resolve=resolve).run()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# `repository_root` was the audit's own helper before the VCS walk was
|
|
55
|
+
# separated out. Kept as a name here because it is public API.
|
|
56
|
+
repository_root = Repository.root
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Running the checks over one config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from secretref.auditors.known_format import KnownFormat
|
|
6
|
+
from secretref.auditors.placeholder import Placeholder
|
|
7
|
+
from secretref.auditors.secret_key import SecretKey
|
|
8
|
+
from secretref.models.config import Config
|
|
9
|
+
from secretref.models.finding import Finding, Severity
|
|
10
|
+
from secretref.models.source import Source
|
|
11
|
+
from secretref.utils.repository import Repository
|
|
12
|
+
from secretref.utils.tree import Tree
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Auditor:
|
|
16
|
+
"""Carries the config and the repository verdict through the checks.
|
|
17
|
+
|
|
18
|
+
A class because every check needs both, and threading them through
|
|
19
|
+
arguments made the functions longer than the checks they performed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Config, *, resolve: bool = False) -> None:
|
|
23
|
+
"""Audit a config, optionally checking that its secrets resolve.
|
|
24
|
+
|
|
25
|
+
`resolve` is off by default, and that default is a security
|
|
26
|
+
boundary rather than a performance one. Resolving a command
|
|
27
|
+
reference *runs* it, so an audit that resolved would execute
|
|
28
|
+
whatever the config asked for — and auditing a config you did not
|
|
29
|
+
write is the main use for this. A CI check on a pull request would
|
|
30
|
+
have been remote code execution.
|
|
31
|
+
|
|
32
|
+
Turn it on for your own config at startup, where knowing a secret
|
|
33
|
+
is actually reachable is worth more than the caution.
|
|
34
|
+
"""
|
|
35
|
+
self._config = config
|
|
36
|
+
self._resolve = resolve
|
|
37
|
+
self._repository = Repository.root(config.path) if config.path else None
|
|
38
|
+
|
|
39
|
+
def run(self) -> list[Finding]:
|
|
40
|
+
"""Run every check and order the results by severity."""
|
|
41
|
+
findings = [*self._reference_findings(), *self._literal_findings()]
|
|
42
|
+
order = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.NOTE: 2}
|
|
43
|
+
findings.sort(key=lambda finding: (order[finding.severity], finding.path))
|
|
44
|
+
return findings
|
|
45
|
+
|
|
46
|
+
def _may_check(self, reference) -> bool:
|
|
47
|
+
"""Whether this reference can be verified without doing anything.
|
|
48
|
+
|
|
49
|
+
A reference with side effects is only resolved when the caller has
|
|
50
|
+
said the config is theirs.
|
|
51
|
+
"""
|
|
52
|
+
return self._resolve or not reference.has_side_effects()
|
|
53
|
+
|
|
54
|
+
# -- checks -----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _reference_findings(self) -> list[Finding]:
|
|
57
|
+
"""Problems with values that are already references."""
|
|
58
|
+
findings = []
|
|
59
|
+
for path, reference in self._config.secrets().items():
|
|
60
|
+
if reference.source is Source.LITERAL:
|
|
61
|
+
findings.append(self._literal_finding(path, reference.locator))
|
|
62
|
+
elif broken := reference.malformed():
|
|
63
|
+
# Checked first: a reference that cannot be parsed will not
|
|
64
|
+
# work whatever the policy says, and naming the syntax error
|
|
65
|
+
# is more useful than reporting it as unverified.
|
|
66
|
+
findings.append(
|
|
67
|
+
Finding(
|
|
68
|
+
severity=Severity.ERROR,
|
|
69
|
+
path=path,
|
|
70
|
+
message=f"{reference.described()} is {broken}",
|
|
71
|
+
remedy="fix the reference in the config",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
elif refused := reference.refusal():
|
|
75
|
+
# A policy refusal is a config error, not a deployment one:
|
|
76
|
+
# the config asks for something it is not permitted to do,
|
|
77
|
+
# and no amount of setting variables will change that. An
|
|
78
|
+
# error whatever the repository, because the application
|
|
79
|
+
# will not start.
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(
|
|
82
|
+
severity=Severity.ERROR,
|
|
83
|
+
path=path,
|
|
84
|
+
message=f"refused by policy — {refused.rstrip('.')}",
|
|
85
|
+
remedy="widen the policy, or point the reference somewhere it permits",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
elif not self._may_check(reference):
|
|
89
|
+
findings.append(
|
|
90
|
+
Finding(
|
|
91
|
+
severity=Severity.NOTE,
|
|
92
|
+
path=path,
|
|
93
|
+
message=f"{reference.described()} was not verified",
|
|
94
|
+
remedy=(
|
|
95
|
+
"resolving it would run a command, which an audit "
|
|
96
|
+
"does not do. Pass resolve=True if this config is "
|
|
97
|
+
"yours and you want it checked"
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
elif not reference.is_resolvable():
|
|
102
|
+
findings.append(
|
|
103
|
+
Finding(
|
|
104
|
+
severity=Severity.NOTE,
|
|
105
|
+
path=path,
|
|
106
|
+
message=f"{reference.described()} resolves to nothing",
|
|
107
|
+
remedy='set it, or add "required": false if it is optional',
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return findings
|
|
111
|
+
|
|
112
|
+
def _literal_findings(self) -> list[Finding]:
|
|
113
|
+
"""Plain strings that hold a secret.
|
|
114
|
+
|
|
115
|
+
Two independent checks, because each catches what the other misses.
|
|
116
|
+
A published credential format is recognized from the value, whatever
|
|
117
|
+
the key is called — which is the case key names cannot cover, since
|
|
118
|
+
nothing stops a live key sitting under `tracking.ref`. A
|
|
119
|
+
secret-looking key name catches the rest: an internal password has no
|
|
120
|
+
published shape to match.
|
|
121
|
+
"""
|
|
122
|
+
findings = []
|
|
123
|
+
for path, value in Tree.strings(self._config.data):
|
|
124
|
+
if identified := KnownFormat.identify(value):
|
|
125
|
+
findings.append(self._known_format_finding(path, identified))
|
|
126
|
+
elif self._looks_like_a_secret(path, value):
|
|
127
|
+
findings.append(self._literal_finding(path, value))
|
|
128
|
+
return findings
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _looks_like_a_secret(path: str, value: str) -> bool:
|
|
132
|
+
"""Whether a key name suggests a credential and the value could be one."""
|
|
133
|
+
if not SecretKey.matches(path):
|
|
134
|
+
return False
|
|
135
|
+
return not (Placeholder.too_short_to_judge(value) or Placeholder.matches(value))
|
|
136
|
+
|
|
137
|
+
def _known_format_finding(self, path: str, identified: str) -> Finding:
|
|
138
|
+
"""One credential matching a published format.
|
|
139
|
+
|
|
140
|
+
Always an error, and always regardless of the key name or whether a
|
|
141
|
+
repository was found. A value matching an issuer's published shape is
|
|
142
|
+
a real credential — there is no reading of it as a placeholder, and
|
|
143
|
+
the right response is to rotate it rather than to move it.
|
|
144
|
+
"""
|
|
145
|
+
inside = (
|
|
146
|
+
" inside a repository, so git history keeps it"
|
|
147
|
+
if self._repository is not None
|
|
148
|
+
else " in a config file"
|
|
149
|
+
)
|
|
150
|
+
return Finding(
|
|
151
|
+
severity=Severity.ERROR,
|
|
152
|
+
path=path,
|
|
153
|
+
message=f"{identified}{inside}",
|
|
154
|
+
remedy=(
|
|
155
|
+
"rotate this credential, then reference it instead of storing "
|
|
156
|
+
'it: {"env": "YOUR_VAR"}'
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _literal_finding(self, path: str, value: str) -> Finding:
|
|
161
|
+
"""One literal, with a severity that depends on the repository."""
|
|
162
|
+
if Placeholder.is_noise(value):
|
|
163
|
+
return Finding(
|
|
164
|
+
severity=Severity.NOTE,
|
|
165
|
+
path=path,
|
|
166
|
+
message="a literal value, which looks like a placeholder",
|
|
167
|
+
remedy="replace it with a reference before deploying",
|
|
168
|
+
)
|
|
169
|
+
if self._repository is not None:
|
|
170
|
+
return Finding(
|
|
171
|
+
severity=Severity.ERROR,
|
|
172
|
+
path=path,
|
|
173
|
+
message="a literal secret inside a repository",
|
|
174
|
+
remedy=(
|
|
175
|
+
"move it to an environment variable and reference it: "
|
|
176
|
+
'{"env": "YOUR_VAR"} — and rotate it, since git history '
|
|
177
|
+
"keeps the old value"
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
return Finding(
|
|
181
|
+
severity=Severity.WARNING,
|
|
182
|
+
path=path,
|
|
183
|
+
message="a literal secret in a config file",
|
|
184
|
+
remedy='reference it instead of storing it: {"env": "YOUR_VAR"}',
|
|
185
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Whether a value is a credential whose issuer publishes its shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from secretref.patterns import KNOWN_SECRET_FORMATS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KnownFormat:
|
|
12
|
+
"""Whether a value matches a published credential format.
|
|
13
|
+
|
|
14
|
+
The one kind of secret that can be identified from the value alone. Every
|
|
15
|
+
other check in this package starts from the key name, which is the thing
|
|
16
|
+
an audit cannot depend on: a live Stripe key under `payments.api_key` is
|
|
17
|
+
found either way, while the same key under `tracking.ref` is found only
|
|
18
|
+
here.
|
|
19
|
+
|
|
20
|
+
A match is reported as an error regardless of the key, and regardless of
|
|
21
|
+
whether the config is inside a repository — a key matching a published
|
|
22
|
+
format is a real credential, not a placeholder someone typed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
FORMATS: ClassVar[tuple[tuple[str, re.Pattern[str]], ...]] = KNOWN_SECRET_FORMATS
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def identify(cls, value: str) -> str:
|
|
29
|
+
"""Name the credential format this value matches, or return empty.
|
|
30
|
+
|
|
31
|
+
The first match wins. The patterns are specific enough that two
|
|
32
|
+
matching the same value would mean one of them is wrong.
|
|
33
|
+
"""
|
|
34
|
+
for name, pattern in cls.FORMATS:
|
|
35
|
+
if pattern.search(value):
|
|
36
|
+
return name
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def matches(cls, value: str) -> bool:
|
|
41
|
+
"""Whether this value matches any published credential format."""
|
|
42
|
+
return bool(cls.identify(value))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Whether a value is filler rather than a real secret."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from secretref.constants import MIN_SECRET_LENGTH
|
|
9
|
+
from secretref.patterns import PLACEHOLDERS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Placeholder:
|
|
13
|
+
"""Whether a value is filler rather than a real secret."""
|
|
14
|
+
|
|
15
|
+
PATTERNS: ClassVar[tuple[re.Pattern[str], ...]] = PLACEHOLDERS
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def matches(cls, value: str) -> bool:
|
|
19
|
+
"""Whether a value is obviously filler.
|
|
20
|
+
|
|
21
|
+
Generous on purpose. A false negative here costs one spurious error,
|
|
22
|
+
which a reader dismisses. A false positive reports a real secret as
|
|
23
|
+
filler and hides it, which is the failure that matters.
|
|
24
|
+
"""
|
|
25
|
+
candidate = value.strip()
|
|
26
|
+
return any(pattern.search(candidate) for pattern in cls.PATTERNS)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def too_short_to_judge(cls, value: str) -> bool:
|
|
30
|
+
"""Whether a value is too short to be worth reporting.
|
|
31
|
+
|
|
32
|
+
A short string under a secret-looking key is more often a flag or an
|
|
33
|
+
abbreviation than a credential, and reporting those is how an audit
|
|
34
|
+
trains people to ignore it.
|
|
35
|
+
"""
|
|
36
|
+
return len(value.strip()) < MIN_SECRET_LENGTH
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def is_noise(cls, value: str) -> bool:
|
|
40
|
+
"""Whether a value should be a note rather than an error."""
|
|
41
|
+
return cls.matches(value) or cls.too_short_to_judge(value)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Whether a config path names a credential."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from secretref.patterns import SECRET_KEY
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecretKey:
|
|
12
|
+
"""Whether a config path names a credential."""
|
|
13
|
+
|
|
14
|
+
PATTERN: ClassVar[re.Pattern[str]] = SECRET_KEY
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def matches(cls, dotted: str) -> bool:
|
|
18
|
+
"""Whether a path's final segment names a credential.
|
|
19
|
+
|
|
20
|
+
Only the final segment, so "providers.0.api_key" is judged on
|
|
21
|
+
"api_key". A parent path saying "providers" tells you nothing about
|
|
22
|
+
whether this particular leaf holds a secret.
|
|
23
|
+
"""
|
|
24
|
+
last = dotted.rsplit(".", 1)[-1]
|
|
25
|
+
return bool(cls.PATTERN.search(last))
|
secretref/cache.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""One shared store of already-resolved values.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over `utils.TtlStore`, which holds the mechanics. What is left
|
|
4
|
+
here is the part that is actually about secrets:
|
|
5
|
+
|
|
6
|
+
the key is (source, locator), so an env var and a file path that happen
|
|
7
|
+
to share a name do not collide
|
|
8
|
+
|
|
9
|
+
an empty resolution is never stored, because caching "this resolved to
|
|
10
|
+
nothing" would hide a secret that appears later — the opposite of helpful
|
|
11
|
+
during a deployment
|
|
12
|
+
|
|
13
|
+
it is a singleton, because two references to the same variable should not
|
|
14
|
+
each spawn their own subprocess, and a per-reference cache caches nothing
|
|
15
|
+
|
|
16
|
+
That last point is the only place in this library where a singleton is the
|
|
17
|
+
right answer, and the contrast is the useful part. `Tree`, `Repository` and
|
|
18
|
+
the parsers are classmethods on classes nobody constructs — stronger than a
|
|
19
|
+
singleton, since there is no instance to mutate. `Config`, `SecretRef` and
|
|
20
|
+
`Finding` must not be singletons at all, because more than one is the normal
|
|
21
|
+
case. Here the shared state *is* the feature.
|
|
22
|
+
|
|
23
|
+
Off by default, because caching changes two things a caller should decide:
|
|
24
|
+
|
|
25
|
+
a secret stays in memory after the only read that needed it
|
|
26
|
+
|
|
27
|
+
a rotated secret keeps serving the old value until the entry expires,
|
|
28
|
+
which is the usual reason to set a ttl rather than leave it unbounded
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from threading import Lock
|
|
34
|
+
from typing import ClassVar
|
|
35
|
+
|
|
36
|
+
from secretref.utils.ttl_store import TtlStore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResolutionCache:
|
|
40
|
+
"""A process-wide store of resolved secrets, keyed by reference."""
|
|
41
|
+
|
|
42
|
+
_instance: ClassVar[ResolutionCache | None] = None
|
|
43
|
+
_singleton_lock: ClassVar[Lock] = Lock()
|
|
44
|
+
|
|
45
|
+
def __new__(cls) -> ResolutionCache:
|
|
46
|
+
"""Return the one instance, creating it on first use.
|
|
47
|
+
|
|
48
|
+
Double-checked under a lock, because two threads resolving at startup
|
|
49
|
+
would otherwise each build a store and one would win — leaving the
|
|
50
|
+
other thread's writes in an object nothing else can see.
|
|
51
|
+
"""
|
|
52
|
+
if cls._instance is None:
|
|
53
|
+
with cls._singleton_lock:
|
|
54
|
+
if cls._instance is None:
|
|
55
|
+
instance = super().__new__(cls)
|
|
56
|
+
instance._initialize()
|
|
57
|
+
cls._instance = instance
|
|
58
|
+
return cls._instance
|
|
59
|
+
|
|
60
|
+
def _initialize(self) -> None:
|
|
61
|
+
"""Set up the store. Called once, by __new__."""
|
|
62
|
+
self._store: TtlStore[tuple[str, str], str] = TtlStore()
|
|
63
|
+
self._enabled = False
|
|
64
|
+
|
|
65
|
+
# -- configuration -----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def enable(self, ttl_seconds: float | None = None) -> None:
|
|
68
|
+
"""Start caching, optionally expiring entries after `ttl_seconds`.
|
|
69
|
+
|
|
70
|
+
A ttl is how a long-running process picks up a rotated secret without
|
|
71
|
+
restarting. Without one an entry lives until the cache is cleared,
|
|
72
|
+
which suits a short-lived process and not much else.
|
|
73
|
+
"""
|
|
74
|
+
self._enabled = True
|
|
75
|
+
self._store.ttl = ttl_seconds
|
|
76
|
+
|
|
77
|
+
def disable(self) -> None:
|
|
78
|
+
"""Stop caching and drop everything held."""
|
|
79
|
+
self._enabled = False
|
|
80
|
+
self._store.clear()
|
|
81
|
+
|
|
82
|
+
def clear(self) -> None:
|
|
83
|
+
"""Drop everything held, without changing whether caching is on.
|
|
84
|
+
|
|
85
|
+
What to call after rotating a secret, and what a test fixture calls
|
|
86
|
+
so one test cannot see another's values.
|
|
87
|
+
"""
|
|
88
|
+
self._store.clear()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def enabled(self) -> bool:
|
|
92
|
+
"""Whether resolved values are being kept."""
|
|
93
|
+
return self._enabled
|
|
94
|
+
|
|
95
|
+
# -- use ---------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def get(self, source: str, locator: str) -> str | None:
|
|
98
|
+
"""Return a cached value for this reference, or None.
|
|
99
|
+
|
|
100
|
+
None means "resolve it" rather than "it resolved to nothing" — an
|
|
101
|
+
empty resolution is never stored, so the two cannot be confused.
|
|
102
|
+
"""
|
|
103
|
+
if not self._enabled:
|
|
104
|
+
return None
|
|
105
|
+
return self._store.get((source, locator))
|
|
106
|
+
|
|
107
|
+
def put(self, source: str, locator: str, value: str) -> None:
|
|
108
|
+
"""Store a resolved value.
|
|
109
|
+
|
|
110
|
+
An empty value is not stored. Caching "this resolved to nothing"
|
|
111
|
+
would make a secret that appears later invisible until the cache is
|
|
112
|
+
cleared, which is the opposite of helpful during a deployment.
|
|
113
|
+
"""
|
|
114
|
+
if not self._enabled or not value:
|
|
115
|
+
return
|
|
116
|
+
self._store.put((source, locator), value)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def hits(self) -> int:
|
|
120
|
+
"""Lookups served from the store."""
|
|
121
|
+
return self._store.hits
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def misses(self) -> int:
|
|
125
|
+
"""Lookups that had to resolve."""
|
|
126
|
+
return self._store.misses
|
|
127
|
+
|
|
128
|
+
def stats(self) -> dict[str, int | bool | float | None]:
|
|
129
|
+
"""Counters for logging. Contains no values and no locators."""
|
|
130
|
+
return {"enabled": self._enabled, **self._store.counters()}
|