kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Kwargs-based provider for direct secret injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
|
|
9
|
+
from kstlib.secrets.providers.base import SecretProvider
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
else: # pragma: no cover - runtime alias for typing constructs
|
|
14
|
+
Mapping = typing.Mapping
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KwargsProvider(SecretProvider):
|
|
18
|
+
"""Resolve secrets from explicitly provided keyword arguments.
|
|
19
|
+
|
|
20
|
+
This provider is useful for testing and temporary overrides. Secrets
|
|
21
|
+
passed via kwargs take precedence over all other providers.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> from kstlib.secrets.providers.kwargs import KwargsProvider
|
|
25
|
+
>>> provider = KwargsProvider({"api.key": "test-key"})
|
|
26
|
+
>>> from kstlib.secrets.models import SecretRequest
|
|
27
|
+
>>> record = provider.resolve(SecretRequest(name="api.key"))
|
|
28
|
+
>>> record.value
|
|
29
|
+
'test-key'
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name = "kwargs"
|
|
33
|
+
|
|
34
|
+
def __init__(self, secrets: Mapping[str, Any] | None = None) -> None:
|
|
35
|
+
"""Initialize with an optional mapping of secret names to values.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
secrets: Mapping of dotted secret names to their values.
|
|
39
|
+
"""
|
|
40
|
+
self._secrets: dict[str, Any] = dict(secrets) if secrets else {}
|
|
41
|
+
|
|
42
|
+
def configure(self, settings: Mapping[str, Any] | None = None) -> None:
|
|
43
|
+
"""Apply settings overrides (merges additional secrets).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
settings: Optional mapping that may provide additional secrets
|
|
47
|
+
under the ``secrets`` key.
|
|
48
|
+
"""
|
|
49
|
+
if not settings:
|
|
50
|
+
return
|
|
51
|
+
additional = settings.get("secrets")
|
|
52
|
+
if isinstance(additional, Mapping):
|
|
53
|
+
self._secrets.update(additional)
|
|
54
|
+
|
|
55
|
+
def resolve(self, request: SecretRequest) -> SecretRecord | None:
|
|
56
|
+
"""Resolve a secret from the provided kwargs.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
request: Secret descriptor with the name to look up.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A ``SecretRecord`` if the secret is found, otherwise ``None``.
|
|
63
|
+
"""
|
|
64
|
+
value = self._secrets.get(request.name)
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
return SecretRecord(
|
|
68
|
+
value=value,
|
|
69
|
+
source=SecretSource.KWARGS,
|
|
70
|
+
metadata={"provider": "kwargs"},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def set(self, name: str, value: Any) -> None:
|
|
74
|
+
"""Add or update a secret at runtime.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
name: The dotted secret name (e.g., "api.key").
|
|
78
|
+
value: The secret value.
|
|
79
|
+
"""
|
|
80
|
+
self._secrets[name] = value
|
|
81
|
+
|
|
82
|
+
def remove(self, name: str) -> bool:
|
|
83
|
+
"""Remove a secret by name.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
name: The dotted secret name to remove.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if the secret was removed, False if it didn't exist.
|
|
90
|
+
"""
|
|
91
|
+
if name in self._secrets:
|
|
92
|
+
del self._secrets[name]
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def clear(self) -> None:
|
|
97
|
+
"""Remove all secrets from this provider."""
|
|
98
|
+
self._secrets.clear()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["KwargsProvider"]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""SOPS-backed provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# pylint: disable=duplicate-code
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from subprocess import run as subprocess_run
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from kstlib.limits import HARD_MAX_SOPS_CACHE_ENTRIES, SopsLimits, get_sops_limits
|
|
19
|
+
from kstlib.secrets.exceptions import SecretDecryptionError
|
|
20
|
+
from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
|
|
21
|
+
from kstlib.secrets.providers.base import SecretProvider
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SOPSProvider(SecretProvider):
|
|
27
|
+
"""Load secrets from SOPS encrypted documents."""
|
|
28
|
+
|
|
29
|
+
name = "sops"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
path: str | Path | None = None,
|
|
35
|
+
binary: str = "sops",
|
|
36
|
+
document_format: str = "auto",
|
|
37
|
+
limits: SopsLimits | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Configure the provider with optional defaults."""
|
|
40
|
+
self._path = Path(path) if path else None
|
|
41
|
+
self._binary = binary
|
|
42
|
+
self._document_format = document_format
|
|
43
|
+
self._limits = limits or get_sops_limits()
|
|
44
|
+
self._max_cache_entries = self._limits.max_cache_entries
|
|
45
|
+
self._cache: OrderedDict[Path, tuple[float, Any]] = OrderedDict()
|
|
46
|
+
|
|
47
|
+
def configure(self, settings: Mapping[str, Any] | None = None) -> None:
|
|
48
|
+
"""Apply overrides supplied by configuration files."""
|
|
49
|
+
if not settings:
|
|
50
|
+
return
|
|
51
|
+
target = settings.get("path")
|
|
52
|
+
if target:
|
|
53
|
+
self._path = Path(target)
|
|
54
|
+
binary = settings.get("binary")
|
|
55
|
+
if binary:
|
|
56
|
+
self._binary = binary
|
|
57
|
+
fmt = settings.get("format") or settings.get("document_format")
|
|
58
|
+
if fmt:
|
|
59
|
+
self._document_format = fmt
|
|
60
|
+
max_entries = settings.get("max_cache_entries")
|
|
61
|
+
if max_entries is not None:
|
|
62
|
+
# Enforce hard limit for deep defense
|
|
63
|
+
self._max_cache_entries = min(int(max_entries), HARD_MAX_SOPS_CACHE_ENTRIES)
|
|
64
|
+
|
|
65
|
+
def resolve(self, request: SecretRequest) -> SecretRecord | None:
|
|
66
|
+
"""Resolve the requested secret from the encrypted document."""
|
|
67
|
+
path = self._resolve_path(request)
|
|
68
|
+
if path is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
document = self._load_document(path)
|
|
72
|
+
value = self._extract_value(document, request)
|
|
73
|
+
if value is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
metadata = {
|
|
77
|
+
"path": str(path),
|
|
78
|
+
"format": self._document_format,
|
|
79
|
+
"binary": self._binary,
|
|
80
|
+
}
|
|
81
|
+
return SecretRecord(value=value, source=SecretSource.SOPS, metadata=metadata)
|
|
82
|
+
|
|
83
|
+
def _resolve_path(self, request: SecretRequest) -> Path | None:
|
|
84
|
+
"""Determine the effective SOPS path for a secret request."""
|
|
85
|
+
candidate = request.metadata.get("path") if request.metadata else None
|
|
86
|
+
if candidate:
|
|
87
|
+
return Path(candidate)
|
|
88
|
+
return self._path
|
|
89
|
+
|
|
90
|
+
def _load_document(self, path: Path) -> Any:
|
|
91
|
+
"""Decrypt and parse the underlying SOPS document."""
|
|
92
|
+
try:
|
|
93
|
+
mtime = path.stat().st_mtime
|
|
94
|
+
except FileNotFoundError as exc: # pragma: no cover - defensive branch
|
|
95
|
+
raise SecretDecryptionError(f"SOPS file not found: {path}") from exc
|
|
96
|
+
|
|
97
|
+
cached = self._cache.get(path)
|
|
98
|
+
if cached and cached[0] == mtime:
|
|
99
|
+
# Move to end for LRU tracking
|
|
100
|
+
self._cache.move_to_end(path)
|
|
101
|
+
return cached[1]
|
|
102
|
+
|
|
103
|
+
binary_path = shutil.which(self._binary)
|
|
104
|
+
if binary_path is None:
|
|
105
|
+
raise SecretDecryptionError(
|
|
106
|
+
"SOPS binary not found. Install from https://github.com/getsops/sops or set 'binary' option.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
command = [binary_path, "--decrypt", str(path)]
|
|
110
|
+
process = subprocess_run(
|
|
111
|
+
command,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=False,
|
|
115
|
+
shell=False,
|
|
116
|
+
)
|
|
117
|
+
if process.returncode != 0:
|
|
118
|
+
diagnostic = process.stderr.strip() or process.stdout.strip()
|
|
119
|
+
if diagnostic:
|
|
120
|
+
logger.debug(
|
|
121
|
+
"SOPS decryption failed for %s: %s",
|
|
122
|
+
path,
|
|
123
|
+
self._redact_sensitive_output(diagnostic),
|
|
124
|
+
)
|
|
125
|
+
raise SecretDecryptionError(
|
|
126
|
+
f"Failed to decrypt secrets file '{path.name}'. Check SOPS configuration and file permissions."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
document = self._parse_document(process.stdout)
|
|
130
|
+
self._cache[path] = (mtime, document)
|
|
131
|
+
self._cache.move_to_end(path)
|
|
132
|
+
# Evict oldest entries (LRU) if cache exceeds limit
|
|
133
|
+
while len(self._cache) > self._max_cache_entries:
|
|
134
|
+
self._cache.popitem(last=False)
|
|
135
|
+
return document
|
|
136
|
+
|
|
137
|
+
def _parse_document(self, payload: str) -> Any:
|
|
138
|
+
"""Deserialize the decrypted payload according to the configured format."""
|
|
139
|
+
fmt = self._document_format.lower()
|
|
140
|
+
try:
|
|
141
|
+
if fmt == "json":
|
|
142
|
+
return json.loads(payload)
|
|
143
|
+
if fmt == "yaml":
|
|
144
|
+
return yaml.safe_load(payload)
|
|
145
|
+
if fmt == "text":
|
|
146
|
+
return payload
|
|
147
|
+
# auto-detect: try json then yaml, fallback to text
|
|
148
|
+
return json.loads(payload)
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
try:
|
|
151
|
+
return yaml.safe_load(payload)
|
|
152
|
+
except yaml.YAMLError as exc:
|
|
153
|
+
raise SecretDecryptionError("Unable to parse decrypted document as JSON or YAML") from exc
|
|
154
|
+
except yaml.YAMLError as exc:
|
|
155
|
+
raise SecretDecryptionError("Unable to parse decrypted document as YAML") from exc
|
|
156
|
+
|
|
157
|
+
def _extract_value(self, document: Any, request: SecretRequest) -> Any | None:
|
|
158
|
+
"""Extract the value identified by the request metadata or name."""
|
|
159
|
+
key_path = request.metadata.get("key_path") if request.metadata else None
|
|
160
|
+
if key_path is None:
|
|
161
|
+
key_path = request.name
|
|
162
|
+
|
|
163
|
+
if isinstance(key_path, str):
|
|
164
|
+
parts = [part for part in key_path.split(".") if part]
|
|
165
|
+
elif isinstance(key_path, Sequence):
|
|
166
|
+
parts = [str(part) for part in key_path]
|
|
167
|
+
else: # pragma: no cover - defensive guard
|
|
168
|
+
raise SecretDecryptionError("Invalid 'key_path' metadata; expected string or sequence of strings.")
|
|
169
|
+
|
|
170
|
+
current = document
|
|
171
|
+
for part in parts:
|
|
172
|
+
if isinstance(current, Mapping) and part in current:
|
|
173
|
+
current = current[part]
|
|
174
|
+
else:
|
|
175
|
+
return None
|
|
176
|
+
return current
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _redact_sensitive_output(message: str) -> str:
|
|
180
|
+
"""Redact known sensitive substrings from diagnostic output."""
|
|
181
|
+
patterns = [
|
|
182
|
+
re.compile(r"arn:aws:[^\s]+", re.IGNORECASE),
|
|
183
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
184
|
+
re.compile(r"(?:/home/|/Users/)[^\s]+"),
|
|
185
|
+
]
|
|
186
|
+
redacted = message
|
|
187
|
+
for pattern in patterns:
|
|
188
|
+
redacted = pattern.sub("[REDACTED]", redacted)
|
|
189
|
+
return redacted
|
|
190
|
+
|
|
191
|
+
def purge_cache(self, *, path: str | Path | None = None) -> None:
|
|
192
|
+
"""Clear decrypted document cache entries."""
|
|
193
|
+
if path is None:
|
|
194
|
+
self._cache.clear()
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
target = Path(path)
|
|
198
|
+
if target in self._cache:
|
|
199
|
+
self._cache.pop(target, None)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
resolved = target.resolve()
|
|
204
|
+
except OSError: # pragma: no cover - inaccessible paths
|
|
205
|
+
return
|
|
206
|
+
self._cache.pop(resolved, None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = ["SOPSProvider"]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""SecretResolver orchestrates provider lookups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from kstlib.config.loader import get_config
|
|
9
|
+
from kstlib.secrets.exceptions import SecretNotFoundError
|
|
10
|
+
from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
|
|
11
|
+
from kstlib.secrets.providers import configure_provider, get_provider
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from kstlib.secrets.providers.base import SecretProvider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SecretResolver:
|
|
18
|
+
"""Resolve secrets by delegating to a sequence of providers.
|
|
19
|
+
|
|
20
|
+
The resolver iterates through providers in order until one returns a value.
|
|
21
|
+
If no provider can resolve the secret and no default is given, a
|
|
22
|
+
``SecretNotFoundError`` is raised (when ``required=True``).
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> from kstlib.secrets.resolver import SecretResolver
|
|
26
|
+
>>> from kstlib.secrets.providers import get_provider
|
|
27
|
+
>>> from kstlib.secrets.models import SecretRequest
|
|
28
|
+
>>> resolver = SecretResolver([get_provider("environment")], name="app")
|
|
29
|
+
>>> # resolver.resolve(SecretRequest(name="API_KEY"))
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, providers: Sequence[SecretProvider], *, name: str | None = None) -> None:
|
|
33
|
+
"""Initialise the resolver with a provider cascade.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
providers: Ordered sequence of secret providers to query.
|
|
37
|
+
name: Human-readable name for this resolver (used in error messages).
|
|
38
|
+
"""
|
|
39
|
+
self._providers = list(providers)
|
|
40
|
+
self._name = name or "default"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def name(self) -> str:
|
|
44
|
+
"""Return the resolver name."""
|
|
45
|
+
return self._name
|
|
46
|
+
|
|
47
|
+
def resolve(self, request: SecretRequest) -> SecretRecord:
|
|
48
|
+
"""Resolve the secret using the configured provider cascade.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
request: The secret request to resolve.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A SecretRecord with the resolved value and metadata.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
SecretNotFoundError: If the secret is not found and required=True.
|
|
58
|
+
"""
|
|
59
|
+
for provider in self._providers:
|
|
60
|
+
record = provider.resolve(request)
|
|
61
|
+
if record is not None:
|
|
62
|
+
return record
|
|
63
|
+
if request.default is not None:
|
|
64
|
+
return self._default_record(request.default, is_async=False)
|
|
65
|
+
if request.required:
|
|
66
|
+
raise SecretNotFoundError(f"Secret '{request.name}' not found in resolver '{self._name}'")
|
|
67
|
+
return self._default_record(None, is_async=False)
|
|
68
|
+
|
|
69
|
+
async def resolve_async(self, request: SecretRequest) -> SecretRecord:
|
|
70
|
+
"""Async counterpart for ``resolve``.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
request: The secret request to resolve.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A SecretRecord with the resolved value and metadata.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SecretNotFoundError: If the secret is not found and required=True.
|
|
80
|
+
"""
|
|
81
|
+
for provider in self._providers:
|
|
82
|
+
record = await provider.resolve_async(request)
|
|
83
|
+
if record is not None:
|
|
84
|
+
return record
|
|
85
|
+
if request.default is not None:
|
|
86
|
+
return self._default_record(request.default, is_async=True)
|
|
87
|
+
if request.required:
|
|
88
|
+
raise SecretNotFoundError(f"Secret '{request.name}' not found in resolver '{self._name}'")
|
|
89
|
+
return self._default_record(None, is_async=True)
|
|
90
|
+
|
|
91
|
+
def _default_record(self, value: Any, *, is_async: bool) -> SecretRecord:
|
|
92
|
+
metadata: dict[str, Any] = {"resolver": self._name}
|
|
93
|
+
if is_async:
|
|
94
|
+
metadata["async"] = True
|
|
95
|
+
return SecretRecord(value=value, source=SecretSource.DEFAULT, metadata=metadata)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_secret_resolver(
|
|
99
|
+
config: Mapping[str, Any] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
secrets: Mapping[str, Any] | None = None,
|
|
102
|
+
) -> SecretResolver:
|
|
103
|
+
"""Build a resolver from configuration mapping.
|
|
104
|
+
|
|
105
|
+
When no explicit provider list is given, the default cascade is:
|
|
106
|
+
``(KwargsProvider if secrets) -> EnvironmentProvider -> KeyringProvider -> (SOPSProvider if configured)``.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
config: Optional mapping with ``providers`` list and/or ``sops`` settings.
|
|
110
|
+
When ``None``, uses the default provider chain.
|
|
111
|
+
secrets: Optional mapping of secret overrides to inject via KwargsProvider.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Configured ``SecretResolver`` instance.
|
|
115
|
+
|
|
116
|
+
Example:
|
|
117
|
+
>>> from kstlib.secrets.resolver import get_secret_resolver
|
|
118
|
+
>>> resolver = get_secret_resolver() # uses defaults
|
|
119
|
+
>>> resolver.name
|
|
120
|
+
'default'
|
|
121
|
+
"""
|
|
122
|
+
config = config or {}
|
|
123
|
+
providers: list[SecretProvider] = []
|
|
124
|
+
|
|
125
|
+
# KwargsProvider always comes first (highest priority)
|
|
126
|
+
if secrets:
|
|
127
|
+
providers.append(get_provider("kwargs", secrets=secrets))
|
|
128
|
+
|
|
129
|
+
provider_configs = config.get("providers", [])
|
|
130
|
+
if not provider_configs:
|
|
131
|
+
providers.extend([get_provider("environment"), get_provider("keyring")])
|
|
132
|
+
sops_config = config.get("sops")
|
|
133
|
+
if isinstance(sops_config, Mapping):
|
|
134
|
+
providers.append(_build_sops_provider(sops_config))
|
|
135
|
+
else:
|
|
136
|
+
for provider_cfg in provider_configs:
|
|
137
|
+
name = provider_cfg.get("name")
|
|
138
|
+
if not name:
|
|
139
|
+
raise ValueError("Provider configuration requires a 'name' field")
|
|
140
|
+
settings = provider_cfg.get("settings")
|
|
141
|
+
provider = get_provider(name, **(provider_cfg.get("options") or {}))
|
|
142
|
+
providers.append(configure_provider(provider, settings))
|
|
143
|
+
return SecretResolver(providers, name=config.get("name"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_secret(
|
|
147
|
+
name: str,
|
|
148
|
+
*,
|
|
149
|
+
config: Mapping[str, Any] | None = None,
|
|
150
|
+
secrets: Mapping[str, Any] | None = None,
|
|
151
|
+
**request_kwargs: Any,
|
|
152
|
+
) -> SecretRecord:
|
|
153
|
+
"""Resolve a secret by name using the global resolver cascade.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Identifier of the secret (``"smtp.password"`` for example).
|
|
157
|
+
config: Optional configuration mapping describing providers. When not
|
|
158
|
+
provided the function attempts to reuse the globally loaded config.
|
|
159
|
+
secrets: Optional mapping of secret overrides. These take precedence
|
|
160
|
+
over all other providers (useful for testing).
|
|
161
|
+
request_kwargs: Additional keyword arguments forwarded to
|
|
162
|
+
:class:`SecretRequest`. Supported keys are ``scope``, ``required``,
|
|
163
|
+
``default`` and ``metadata``.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
A ``SecretRecord`` describing the resolved secret and its provenance.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
SecretNotFoundError: If the secret is not found and required=True.
|
|
170
|
+
TypeError: If unsupported keyword arguments are provided.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> from kstlib.secrets.resolver import resolve_secret
|
|
174
|
+
>>> # Override for testing
|
|
175
|
+
>>> record = resolve_secret("api.key", secrets={"api.key": "test-value"})
|
|
176
|
+
>>> record.source
|
|
177
|
+
<SecretSource.KWARGS: 'kwargs'>
|
|
178
|
+
"""
|
|
179
|
+
if config is None:
|
|
180
|
+
global_config = get_config()
|
|
181
|
+
secrets_config = getattr(global_config, "secrets", None)
|
|
182
|
+
config = secrets_config.to_dict() if secrets_config is not None else None
|
|
183
|
+
|
|
184
|
+
allowed_keys = {"scope", "required", "default", "metadata"}
|
|
185
|
+
unexpected = set(request_kwargs) - allowed_keys
|
|
186
|
+
if unexpected:
|
|
187
|
+
unexpected_list = ", ".join(sorted(unexpected))
|
|
188
|
+
raise TypeError(f"Unsupported keyword arguments: {unexpected_list}")
|
|
189
|
+
|
|
190
|
+
resolver = get_secret_resolver(config, secrets=secrets)
|
|
191
|
+
scope = request_kwargs.get("scope")
|
|
192
|
+
required = request_kwargs.get("required", True)
|
|
193
|
+
default = request_kwargs.get("default")
|
|
194
|
+
metadata = request_kwargs.get("metadata")
|
|
195
|
+
request = SecretRequest(
|
|
196
|
+
name=name,
|
|
197
|
+
scope=scope,
|
|
198
|
+
required=required,
|
|
199
|
+
default=default,
|
|
200
|
+
metadata=dict(metadata) if metadata else {},
|
|
201
|
+
)
|
|
202
|
+
return resolver.resolve(request)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _build_sops_provider(config: Mapping[str, Any]) -> SecretProvider:
|
|
206
|
+
"""Instantiate a SOPS provider from a simple mapping."""
|
|
207
|
+
option_keys = {"path", "binary", "document_format", "format"}
|
|
208
|
+
raw_options = config.get("options")
|
|
209
|
+
options = (
|
|
210
|
+
{key: value for key, value in config.items() if key in option_keys}
|
|
211
|
+
if raw_options is None
|
|
212
|
+
else dict(raw_options)
|
|
213
|
+
)
|
|
214
|
+
if "format" in options and "document_format" not in options:
|
|
215
|
+
options["document_format"] = options.pop("format")
|
|
216
|
+
settings = config.get("settings")
|
|
217
|
+
provider = get_provider("sops", **options)
|
|
218
|
+
return configure_provider(provider, settings)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["SecretResolver", "get_secret_resolver"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Helpers that minimise the footprint of decrypted secrets.
|
|
2
|
+
|
|
3
|
+
The :func:`sensitive` context manager temporarily exposes a secret value and
|
|
4
|
+
then attempts to scrub it from memory, clear provider caches, and drop any
|
|
5
|
+
remaining references.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
12
|
+
from contextlib import contextmanager, suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from kstlib.secrets.models import SecretRecord
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
LegacyPurge = Callable[[], None]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CachePurgeProtocol(Protocol): # pylint: disable=too-few-public-methods
|
|
25
|
+
"""Protocol implemented by providers exposing a cache purge hook."""
|
|
26
|
+
|
|
27
|
+
def purge_cache(self, *, path: str | Path | None = None) -> None: # pragma: no cover - protocol stub
|
|
28
|
+
"""Clear cached decrypted material."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def sensitive(
|
|
33
|
+
record: SecretRecord,
|
|
34
|
+
*,
|
|
35
|
+
providers: Sequence[CachePurgeProtocol] | None = None,
|
|
36
|
+
) -> Iterator[Any]:
|
|
37
|
+
"""Temporarily expose a secret and scrub it afterwards.
|
|
38
|
+
|
|
39
|
+
The context manager yields the secret value so it can be used within the
|
|
40
|
+
protected block. On exit it attempts to overwrite mutable buffers in place,
|
|
41
|
+
clears provider caches when available, and replaces the value stored in the
|
|
42
|
+
:class:`SecretRecord` with ``None`` to break lingering references.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> from kstlib.secrets.models import SecretRecord, SecretSource
|
|
46
|
+
>>> from kstlib.secrets.sensitive import sensitive
|
|
47
|
+
>>> record = SecretRecord(value=bytearray(b"api-token"), source=SecretSource.SOPS)
|
|
48
|
+
>>> with sensitive(record) as secret:
|
|
49
|
+
... secret[:3] = b"***" # handle the secret
|
|
50
|
+
>>> record.value is None
|
|
51
|
+
True
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
record: Secret wrapped in a :class:`SecretRecord`.
|
|
55
|
+
providers: Optional providers whose caches should be purged after use.
|
|
56
|
+
|
|
57
|
+
Yields:
|
|
58
|
+
The decrypted secret value.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
yield record.value
|
|
62
|
+
finally:
|
|
63
|
+
_scrub_value(record.value)
|
|
64
|
+
record.value = None
|
|
65
|
+
metadata = record.metadata if isinstance(record.metadata, Mapping) else {}
|
|
66
|
+
_purge_providers(providers, metadata=metadata)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scrub_value(value: Any) -> None:
|
|
70
|
+
"""Best-effort scrubbing for mutable buffers."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return
|
|
73
|
+
if isinstance(value, bytearray):
|
|
74
|
+
value[:] = b"\x00" * len(value)
|
|
75
|
+
return
|
|
76
|
+
if isinstance(value, memoryview):
|
|
77
|
+
if not value.readonly:
|
|
78
|
+
value[:] = b"\x00" * len(value)
|
|
79
|
+
value.release()
|
|
80
|
+
return
|
|
81
|
+
if hasattr(value, "clear") and hasattr(value, "__setitem__"):
|
|
82
|
+
try:
|
|
83
|
+
length = len(value)
|
|
84
|
+
except TypeError: # pragma: no cover - objects without __len__
|
|
85
|
+
length = 0
|
|
86
|
+
for index in range(length):
|
|
87
|
+
if _try_assign(value, index, 0):
|
|
88
|
+
continue
|
|
89
|
+
_try_assign(value, index, None)
|
|
90
|
+
with suppress(AttributeError, TypeError, ValueError):
|
|
91
|
+
value.clear()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _purge_providers(
|
|
95
|
+
providers: Sequence[CachePurgeProtocol] | None,
|
|
96
|
+
*,
|
|
97
|
+
metadata: Mapping[str, Any] | None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Invoke cache purge hooks for the supplied providers."""
|
|
100
|
+
if not providers:
|
|
101
|
+
return
|
|
102
|
+
path_hint: str | Path | None = None
|
|
103
|
+
if metadata:
|
|
104
|
+
candidate = metadata.get("path")
|
|
105
|
+
if isinstance(candidate, str | Path):
|
|
106
|
+
path_hint = candidate
|
|
107
|
+
|
|
108
|
+
for provider in providers:
|
|
109
|
+
purge = getattr(provider, "purge_cache", None)
|
|
110
|
+
if not callable(purge):
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
purge(path=path_hint)
|
|
114
|
+
except TypeError:
|
|
115
|
+
legacy_purge = cast("LegacyPurge", purge)
|
|
116
|
+
legacy_purge()
|
|
117
|
+
except (AttributeError, OSError, RuntimeError, ValueError) as error: # pragma: no cover - defensive logging
|
|
118
|
+
logger.debug("Provider cache purge failed for %s", provider, exc_info=error)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _try_assign(target: Any, index: int, replacement: Any) -> bool:
|
|
122
|
+
"""Attempt to assign ``replacement`` at ``index`` and report success."""
|
|
123
|
+
try:
|
|
124
|
+
target[index] = replacement
|
|
125
|
+
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["CachePurgeProtocol", "sensitive"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Security helpers (filesystem guardrails, policies, and errors)."""
|
|
2
|
+
|
|
3
|
+
from kstlib.secure import fs as _fs
|
|
4
|
+
from kstlib.secure import permissions as _perms
|
|
5
|
+
|
|
6
|
+
RELAXED_POLICY = _fs.RELAXED_POLICY
|
|
7
|
+
STRICT_POLICY = _fs.STRICT_POLICY
|
|
8
|
+
GuardPolicy = _fs.GuardPolicy
|
|
9
|
+
PathGuardrails = _fs.PathGuardrails
|
|
10
|
+
PathSecurityError = _fs.PathSecurityError
|
|
11
|
+
|
|
12
|
+
DirectoryPermissions = _perms.DirectoryPermissions
|
|
13
|
+
FilePermissions = _perms.FilePermissions
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RELAXED_POLICY",
|
|
17
|
+
"STRICT_POLICY",
|
|
18
|
+
"DirectoryPermissions",
|
|
19
|
+
"FilePermissions",
|
|
20
|
+
"GuardPolicy",
|
|
21
|
+
"PathGuardrails",
|
|
22
|
+
"PathSecurityError",
|
|
23
|
+
]
|