mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-08-30 06:30:37 +02:00
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""Provider-neutral runtime credential resolution.
|
|
|
|
OBLITERATUS does not embed a client for any particular vault. Instead, each
|
|
credential keeps its existing environment-variable name and can be supplied by
|
|
an environment value, a mounted file, or a trusted executable broker. This
|
|
works with Vault/OpenBao Agent templates, Kubernetes and Docker secret mounts,
|
|
systemd credentials, and other secret managers without adding provider SDKs to
|
|
the application.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
_SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
_MAX_SECRET_BYTES = 64 * 1024
|
|
_DEFAULT_BROKER_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
class SecretResolutionError(RuntimeError):
|
|
"""A configured secret source could not be resolved safely."""
|
|
|
|
|
|
def _validate_name(name: str) -> str:
|
|
if not isinstance(name, str) or not _SECRET_NAME_RE.fullmatch(name):
|
|
raise ValueError("secret names must use uppercase environment-variable syntax")
|
|
return name
|
|
|
|
|
|
def _normalize_value(raw: str, *, source: str) -> str:
|
|
value = raw.rstrip("\r\n")
|
|
if not value:
|
|
raise SecretResolutionError(f"configured secret source is empty: {source}")
|
|
if "\x00" in value:
|
|
raise SecretResolutionError(f"configured secret source contains a NUL byte: {source}")
|
|
return value
|
|
|
|
|
|
def _read_secret_file(path: Path, *, source: str) -> str:
|
|
try:
|
|
resolved = path.expanduser().resolve(strict=True)
|
|
metadata = resolved.stat()
|
|
if not stat.S_ISREG(metadata.st_mode):
|
|
raise SecretResolutionError(f"configured secret source is not a file: {source}")
|
|
if metadata.st_size > _MAX_SECRET_BYTES:
|
|
raise SecretResolutionError(f"configured secret source exceeds 64 KiB: {source}")
|
|
raw = resolved.read_bytes()
|
|
if len(raw) > _MAX_SECRET_BYTES:
|
|
raise SecretResolutionError(f"configured secret source exceeds 64 KiB: {source}")
|
|
return _normalize_value(raw.decode("utf-8"), source=source)
|
|
except SecretResolutionError:
|
|
raise
|
|
except (OSError, UnicodeError) as exc:
|
|
raise SecretResolutionError(f"configured secret source is unreadable: {source}") from exc
|
|
|
|
|
|
def _directory_candidates(directory: str, name: str) -> tuple[Path, ...]:
|
|
root = Path(directory).expanduser()
|
|
normalized = name.lower().replace("_", "-")
|
|
return root / normalized, root / name
|
|
|
|
|
|
def _broker_timeout() -> float:
|
|
raw = os.environ.get("OBLITERATUS_SECRET_COMMAND_TIMEOUT", "").strip()
|
|
if not raw:
|
|
return _DEFAULT_BROKER_TIMEOUT_SECONDS
|
|
try:
|
|
timeout = float(raw)
|
|
except ValueError as exc:
|
|
raise SecretResolutionError(
|
|
"OBLITERATUS_SECRET_COMMAND_TIMEOUT must be a number",
|
|
) from exc
|
|
if not 0.1 <= timeout <= 30.0:
|
|
raise SecretResolutionError(
|
|
"OBLITERATUS_SECRET_COMMAND_TIMEOUT must be between 0.1 and 30 seconds",
|
|
)
|
|
return timeout
|
|
|
|
|
|
def _resolve_from_broker(name: str, command: str) -> str | None:
|
|
executable = Path(command).expanduser()
|
|
if not executable.is_absolute():
|
|
raise SecretResolutionError("OBLITERATUS_SECRET_COMMAND must be an absolute path")
|
|
try:
|
|
metadata = executable.stat()
|
|
except OSError as exc:
|
|
raise SecretResolutionError("OBLITERATUS_SECRET_COMMAND is unavailable") from exc
|
|
if not stat.S_ISREG(metadata.st_mode) or not os.access(executable, os.X_OK):
|
|
raise SecretResolutionError(
|
|
"OBLITERATUS_SECRET_COMMAND must be an executable regular file",
|
|
)
|
|
if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
raise SecretResolutionError("OBLITERATUS_SECRET_COMMAND must not be group/world writable")
|
|
|
|
try:
|
|
completed = subprocess.run(
|
|
[str(executable), name],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
close_fds=True,
|
|
timeout=_broker_timeout(),
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise SecretResolutionError("secret broker execution failed") from exc
|
|
|
|
# Exit 2 is the normalized "secret not found" result. Broker stderr is
|
|
# intentionally never included because provider errors may contain secrets.
|
|
if completed.returncode == 2:
|
|
return None
|
|
if completed.returncode != 0:
|
|
raise SecretResolutionError(
|
|
f"secret broker failed for {name} with exit status {completed.returncode}",
|
|
)
|
|
if len(completed.stdout) > _MAX_SECRET_BYTES:
|
|
raise SecretResolutionError("secret broker output exceeds 64 KiB")
|
|
try:
|
|
raw = completed.stdout.decode("utf-8")
|
|
except UnicodeError as exc:
|
|
raise SecretResolutionError("secret broker output is not UTF-8") from exc
|
|
return _normalize_value(raw, source=f"broker:{name}")
|
|
|
|
|
|
def resolve_secret(name: str, *, explicit: str | None = None) -> str | None:
|
|
"""Resolve one credential without coupling the app to a vault provider.
|
|
|
|
Resolution order is explicit value, existing environment value, ``NAME_FILE``,
|
|
``OBLITERATUS_SECRET_DIR``, systemd ``CREDENTIALS_DIRECTORY``, then
|
|
``OBLITERATUS_SECRET_COMMAND``. A configured but broken source fails closed.
|
|
The broker is executed directly (never through a shell) and receives only the
|
|
normalized credential name as its sole argument.
|
|
"""
|
|
|
|
name = _validate_name(name)
|
|
if explicit is not None and explicit.rstrip("\r\n"):
|
|
return _normalize_value(explicit, source="explicit value")
|
|
|
|
environment_value = os.environ.get(name)
|
|
if environment_value:
|
|
return _normalize_value(environment_value, source=name)
|
|
|
|
file_variable = f"{name}_FILE"
|
|
configured_file = os.environ.get(file_variable, "").strip()
|
|
if configured_file:
|
|
return _read_secret_file(Path(configured_file), source=file_variable)
|
|
|
|
for directory_variable in ("OBLITERATUS_SECRET_DIR", "CREDENTIALS_DIRECTORY"):
|
|
directory = os.environ.get(directory_variable, "").strip()
|
|
if not directory:
|
|
continue
|
|
for candidate in _directory_candidates(directory, name):
|
|
if candidate.exists():
|
|
return _read_secret_file(candidate, source=directory_variable)
|
|
|
|
command = os.environ.get("OBLITERATUS_SECRET_COMMAND", "").strip()
|
|
if command:
|
|
return _resolve_from_broker(name, command)
|
|
return None
|
|
|
|
|
|
def resolve_first(*names: str) -> str | None:
|
|
"""Return the first available credential in caller-defined priority order."""
|
|
|
|
for name in names:
|
|
value = resolve_secret(name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def secret_available(*names: str) -> bool:
|
|
"""Return whether any requested credential resolves successfully."""
|
|
|
|
return resolve_first(*names) is not None
|