ops-warden/src/warden/proxy.py
tegwick 8f01eefb1e
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Preserve Warden config in attended child
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a058f3-8ba0-7692-a042-9a870fc3d663
2026-09-01 00:24:18 +02:00

647 lines
24 KiB
Python

"""Operator access proxy — transparent, audited fetch of a non-SSH credential.
WP-0014 T3. ops-warden does not own these secrets; the proxy lane lets an operator
obtain one *through* the `warden access` front door while keeping the security model
intact. Three guardrails are enforced here in code:
* **G1 — caller identity, never warden's.** The proxy runs the owner's tool with the
caller's own environment. ops-warden injects no token of its own; if the caller has
no credential, the underlying tool fails and we surface the auth pointer. We never
add a `*_TOKEN` warden owns to the child environment.
* **G2 — bounded transports, no logging of values.** Ordinary ``proxy_fetch`` runs
the tool with inherited stdout/stderr so the value never enters warden's memory;
sanctioned exec/file transports hold it only for their bounded handoff. The
high-risk attended-login lane is stricter: it captures every client byte inside
an isolated helper session, never returns that output, requires successful
persistence to a private token helper, self-revokes, and cleans up. Audit records
are metadata only.
* **G3 — policy gate before fetch.** The CLI runs ``check_fetch_policy`` before
calling anything here; this module refuses to run an unresolved command template.
This module shells out but never *interprets* secret bytes in the ``--fetch`` path.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from warden.routing.models import RouteEntry
_PLACEHOLDER = re.compile(r"<[^>]+>")
_OPENBAO_TOKEN = re.compile(rb"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{8,}\b")
_ATTENDED_LOGIN_ROOT = ".warden-attended-login"
_TOKEN_HELPER_NAME = ".vault-token"
@dataclass(frozen=True)
class ResolvedFetch:
"""A catalog fetch command ready to run — either argv or a shell pipeline."""
argv: Optional[List[str]] = None
shell_cmd: Optional[str] = None
def __post_init__(self) -> None:
if bool(self.argv) == bool(self.shell_cmd):
raise ValueError("exactly one of argv or shell_cmd must be set")
class ProxyError(Exception):
"""Raised when a proxy fetch cannot be performed safely."""
def _has_shell_pipe(cmd: str) -> bool:
"""True when ``cmd`` contains an unquoted shell pipe operator."""
in_single = in_double = False
for ch in cmd:
if ch == "'" and not in_double:
in_single = not in_single
elif ch == '"' and not in_single:
in_double = not in_double
elif ch == "|" and not in_single and not in_double:
return True
return False
def resolve_fetch_command(
entry: RouteEntry,
*,
domain: Optional[str] = None,
field: Optional[str] = None,
path: Optional[str] = None,
) -> ResolvedFetch:
"""Build the concrete fetch command for an entry, or raise if under-specified.
Starts from the catalog ``fetch_command`` template (with ``<path_template>``
inlined), substitutes ``<domain>``/``<FIELD>`` and an explicit ``--path`` override,
then **refuses** if any ``<…>`` placeholder remains. We never run a half-templated
command — an unresolved placeholder means the operator has not named the owner-side
resource, and guessing it is exactly the failure mode we avoid.
"""
if not entry.exec_capable or not entry.fetch_command:
raise ProxyError(
f"{entry.id!r} is not exec_capable — it has no proxyable fetch command. "
"Use `warden access` (advisory) and obtain it from the owner directly."
)
cmd = entry.fetch_command
if entry.path_template and "<path_template>" in cmd:
cmd = cmd.replace("<path_template>", path or entry.path_template)
elif path:
# No <path_template> token but caller supplied a path — append/override is
# ambiguous, so require the template to carry the token.
raise ProxyError(
f"{entry.id!r} fetch_command has no <path_template> token to override with --path."
)
if domain:
cmd = cmd.replace("<domain>", domain)
if field:
cmd = cmd.replace("<FIELD>", field)
leftover = _PLACEHOLDER.findall(cmd)
if leftover:
raise ProxyError(
f"unresolved placeholder(s) {', '.join(sorted(set(leftover)))} in fetch command. "
"Supply --domain/--field (and --path for owner-side names) — warden will not "
"guess owner-confirmed resource names."
)
if _has_shell_pipe(cmd):
# Catalog-reviewed pipelines (e.g. kubectl | base64 -d) need a shell.
return ResolvedFetch(shell_cmd=cmd)
return ResolvedFetch(argv=shlex.split(cmd))
def caller_auth_present(token_envs: tuple[str, ...] = ("VAULT_TOKEN", "BAO_TOKEN")) -> bool:
"""True if the *caller* appears to hold an auth token (G1 sanity check).
Best-effort: also accepts a ``~/.vault-token`` file. We do not validate it — the
owner's tool does that — we only avoid proxying when the caller clearly has no
credential, so the failure is a clear auth pointer rather than a confusing tool error.
"""
if any(os.environ.get(e, "").strip() for e in token_envs):
return True
return (Path.home() / ".vault-token").exists()
def write_audit(
state_dir: Path,
*,
need_id: str,
owner_repo: str,
domain: Optional[str],
action: str,
decision_id: Optional[str],
exit_code: Optional[int] = None,
) -> Path:
"""Append a metadata-only audit record. Never contains a secret value (G2)."""
state_dir.mkdir(parents=True, exist_ok=True)
log_path = state_dir / "access-audit.log"
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": action, # "fetch" | "exec"
"need_id": need_id,
"owner_repo": owner_repo,
"domain": domain,
"subject": os.environ.get("WARDEN_POLICY_SUBJECT", "").strip() or "operator",
"policy_decision_id": decision_id,
"exit_code": exit_code,
}
with log_path.open("a") as f:
f.write(json.dumps(record) + "\n")
try:
from warden.audit import record_event
record_event(
state_dir,
kind="access",
action=action,
subject=record["subject"],
target=need_id,
decision_id=decision_id,
outcome="ok" if exit_code in (None, 0) else "error",
source="access",
owner_repo=owner_repo,
domain=domain,
)
except Exception:
pass
return log_path
def _caller_env() -> dict:
"""The child environment = the caller's own env. warden adds no credential (G1)."""
return dict(os.environ)
def proxy_fetch(resolved: ResolvedFetch) -> int:
"""Run the owner's tool, streaming its output straight to the caller.
stdout/stderr are **inherited** (``None``), never piped — the secret value flows
subsystem → caller and is never read into warden's memory, buffer, or log (G2).
Returns the tool's exit code.
"""
env = _caller_env()
if resolved.argv is not None:
completed = subprocess.run( # noqa: S603 — argv is shlex-split from a validated template
resolved.argv,
stdout=None,
stderr=None,
stdin=None,
env=env,
check=False,
)
else:
completed = subprocess.run( # noqa: S602 — shell_cmd is catalog-reviewed, not user input
resolved.shell_cmd,
shell=True,
stdout=None,
stderr=None,
stdin=None,
env=env,
check=False,
)
return completed.returncode
def _assert_owned_mode(path: Path, *, mode: int, directory: bool) -> None:
"""Require a caller-owned, non-symlink path with an exact private mode."""
info = path.lstat()
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
if not expected_type(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise ProxyError("attended login private storage has an unsafe path type")
if hasattr(os, "getuid") and info.st_uid != os.getuid():
raise ProxyError("attended login private storage is not caller-owned")
if stat.S_IMODE(info.st_mode) != mode:
raise ProxyError("attended login private storage has an unsafe mode")
def _prepare_attended_login_home() -> tuple[Path, Path, bool]:
"""Create and prove an isolated token-helper home before authentication."""
home = Path.home()
try:
home_info = home.lstat()
except OSError as exc:
raise ProxyError(
"attended login requires a usable writable default home before OIDC"
) from exc
if (
not stat.S_ISDIR(home_info.st_mode)
or stat.S_ISLNK(home_info.st_mode)
or stat.S_IMODE(home_info.st_mode) & 0o222 == 0
):
raise ProxyError(
"attended login requires a usable writable default home before OIDC"
)
# Prove the default home itself is writable. A pre-existing writable child must
# not let a newly read-only HOME reach the OIDC process.
probe_fd = -1
probe_path: Path | None = None
probe_cleanup_error: OSError | None = None
try:
probe_fd, probe_name = tempfile.mkstemp(prefix=".warden-home-probe-", dir=home)
probe_path = Path(probe_name)
os.write(probe_fd, b"preflight")
os.fsync(probe_fd)
except OSError as exc:
raise ProxyError(
"attended login requires a usable writable default home before OIDC"
) from exc
finally:
if probe_fd >= 0:
os.close(probe_fd)
if probe_path is not None:
try:
probe_path.unlink()
except OSError as exc:
probe_cleanup_error = exc
if probe_cleanup_error is not None:
raise ProxyError("attended login home preflight cleanup failed") from probe_cleanup_error
root = home / _ATTENDED_LOGIN_ROOT
root_created = False
try:
root.mkdir(mode=0o700)
root_created = True
except FileExistsError:
pass
except OSError as exc:
raise ProxyError("could not establish attended login private storage") from exc
_assert_owned_mode(root, mode=0o700, directory=True)
try:
session = Path(tempfile.mkdtemp(prefix="session-", dir=root))
session.chmod(0o700)
_assert_owned_mode(session, mode=0o700, directory=True)
helper = session / _TOKEN_HELPER_NAME
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(helper, flags, 0o600)
try:
# Exercise persistence before auth, then leave the helper empty for bao.
os.write(fd, b"preflight")
os.fsync(fd)
os.ftruncate(fd, 0)
finally:
os.close(fd)
_assert_owned_mode(helper, mode=0o600, directory=False)
except (OSError, ProxyError) as exc:
if "session" in locals():
shutil.rmtree(session, ignore_errors=True)
if root_created:
try:
root.rmdir()
except OSError:
pass
if isinstance(exc, ProxyError):
raise
raise ProxyError("could not establish attended login private storage") from exc
return root, session, root_created
def _contained_run(argv: List[str], *, env: dict) -> subprocess.CompletedProcess:
"""Run with both output streams captured and never forwarded."""
return subprocess.run( # noqa: S603
argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=None,
env=env,
check=False,
)
def _output_bytes(completed: subprocess.CompletedProcess) -> bytes:
stdout = completed.stdout
stderr = completed.stderr
if isinstance(stdout, str):
stdout = stdout.encode("utf-8", errors="replace")
if isinstance(stderr, str):
stderr = stderr.encode("utf-8", errors="replace")
stdout = stdout if isinstance(stdout, bytes) else b""
stderr = stderr if isinstance(stderr, bytes) else b""
return stdout + b"\n" + stderr
def _revoke_contained(
bao_binary: str,
*,
env: dict,
possible_output: bytes,
) -> bool:
"""Attempt self-revocation without exposing helper or captured output."""
revoke_env = dict(env)
try:
first = _contained_run(
[bao_binary, "token", "revoke", "-self"], env=revoke_env
)
except OSError:
return False
if first.returncode == 0:
return True
# A helper-persistence failure can leave the issued token only in contained
# client output. Use it solely for immediate self-revocation, never for a log,
# return value, hash, fingerprint, file, or argv.
match = _OPENBAO_TOKEN.search(possible_output)
if match is None:
return False
token = match.group(0).decode("ascii")
revoke_env["BAO_TOKEN"] = token
revoke_env.pop("VAULT_TOKEN", None)
try:
try:
second = _contained_run(
[bao_binary, "token", "revoke", "-self"], env=revoke_env
)
except OSError:
return False
return second.returncode == 0
finally:
revoke_env.pop("BAO_TOKEN", None)
token = "" # noqa: F841 - best-effort release of the credential reference
def proxy_attended_login_exec(
resolved: ResolvedFetch,
*,
child_argv: List[str],
) -> int:
"""Run an attended login and one silent child inside a private helper home.
The default home is proven writable before the OIDC client starts. Login,
child, and revocation output are captured and discarded. A successful login
may return client output only after the private helper has been populated;
persistence defects and non-zero results fail closed. The reviewed child must
remain silent. Any possibly issued token is revoked before the isolated helper
directory is removed.
"""
if not child_argv:
raise ProxyError(
"attended login requires --exec -- <reviewed-command>; a persistent "
"login-only handoff is not permitted"
)
if (
resolved.argv is None
or len(resolved.argv) < 2
or Path(resolved.argv[0]).name != "bao"
or resolved.argv[1] != "login"
):
raise ProxyError("attended login requires a direct bao login argv")
root, session, root_created = _prepare_attended_login_home()
helper = session / _TOKEN_HELPER_NAME
env = _caller_env()
if not env.get("WARDEN_CONFIG"):
caller_config = Path.home() / ".config" / "warden" / "warden.yaml"
if caller_config.is_file():
env["WARDEN_CONFIG"] = str(caller_config)
env["HOME"] = str(session)
env.pop("BAO_TOKEN", None)
env.pop("VAULT_TOKEN", None)
for key in (
"BAO_LOG_LEVEL",
"BAO_LOG_FORMAT",
"VAULT_LOG_LEVEL",
"VAULT_LOG_FORMAT",
):
env.pop(key, None)
login_argv = list(resolved.argv)
if not any(arg == "-format" or arg.startswith("-format=") for arg in login_argv):
login_argv.append("-format=json")
try:
try:
login = _contained_run(login_argv, env=env)
except OSError as exc:
raise ProxyError("attended login client could not start before OIDC") from exc
login_output = _output_bytes(login)
helper_valid = False
try:
_assert_owned_mode(helper, mode=0o600, directory=False)
helper_valid = helper.stat().st_size > 0
except (OSError, ProxyError):
helper_valid = False
if login.returncode != 0 or not helper_valid:
revoked = _revoke_contained(
resolved.argv[0], env=env, possible_output=login_output
)
status = "revoked" if revoked else "revocation could not be confirmed"
raise ProxyError(
"attended login failed closed before command handoff; any possible "
f"issued session was contained and {status}"
)
try:
child = _contained_run(child_argv, env=env)
except OSError as exc:
revoked = _revoke_contained(
resolved.argv[0], env=env, possible_output=b""
)
status = "revoked" if revoked else "revocation could not be confirmed"
raise ProxyError(
"attended command could not start; the login session was " + status
) from exc
child_output = _output_bytes(child)
revoked = _revoke_contained(
resolved.argv[0], env=env, possible_output=child_output
)
if child.returncode != 0 or child_output.strip():
status = "revoked" if revoked else "revocation could not be confirmed"
raise ProxyError(
"attended command failed closed because it returned a failure or "
f"unexpected output; the login session was {status}"
)
if not revoked:
raise ProxyError(
"attended command completed but session revocation could not be confirmed"
)
return 0
finally:
try:
shutil.rmtree(session)
if root_created:
root.rmdir()
except OSError as exc:
raise ProxyError("attended login private storage cleanup failed") from exc
def _capture_value(resolved: ResolvedFetch) -> str:
"""Run the fetch and return its stdout (the value) minus one trailing newline.
The value transits warden's memory (the accepted proxy tradeoff for the
non-stdout transports) but is never written to disk or log by this function.
"""
env = _caller_env()
if resolved.argv is not None:
fetched = subprocess.run( # noqa: S603
resolved.argv, stdout=subprocess.PIPE, stderr=None, stdin=None,
env=env, check=False, text=True,
)
else:
fetched = subprocess.run( # noqa: S602
resolved.shell_cmd, shell=True, stdout=subprocess.PIPE, stderr=None,
stdin=None, env=env, check=False, text=True,
)
if fetched.returncode != 0:
raise ProxyError(
f"fetch failed (exit {fetched.returncode}) — check caller auth and the path."
)
value = fetched.stdout
if value.endswith("\n"):
value = value[:-1]
return value
def proxy_fetch_to_file(resolved: ResolvedFetch, out_path: Path) -> int:
"""Fetch the value and write it to ``out_path`` at mode 0600 — never to stdout.
A sanctioned transport (WP-0026 T02): the value goes to a private file the
caller controls, not a terminal or a logged stream. The file is created with
O_EXCL semantics widened to truncate-if-owned so a re-fetch overwrites, but the
mode is forced to 0600 before any bytes are written.
"""
value = _capture_value(resolved)
# Open with restrictive mode from the start; do not echo the value anywhere.
fd = os.open(str(out_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.chmod(out_path, 0o600) # enforce even if the file pre-existed with looser mode
with os.fdopen(fd, "w") as fh:
fh.write(value)
finally:
value = "" # noqa: F841 — best-effort scrub of the local reference
return 0
def is_bao_kv_fetch(entry: RouteEntry) -> bool:
"""True when a lane's fetch is a plain ``bao kv get`` (wrappable, WP-0026 T02)."""
return bool(entry.fetch_command and entry.fetch_command.strip().startswith("bao kv get"))
def build_wrapped_fetch(
entry: RouteEntry, *, path: Optional[str] = None, ttl: str = "5m"
) -> ResolvedFetch:
"""Build a response-wrapping fetch: ``bao kv get -wrap-ttl=<ttl> -format=json <path>``.
Response wrapping returns a single-use, short-TTL *wrapping token* instead of the
secret value — the sanctioned way to move a value between processes (WP-0026 T02).
The caller unwraps it in their own context (`bao unwrap`). Only valid for plain
``bao kv get`` lanes; the whole secret is wrapped (a per-field ``-field`` read
cannot be wrapped).
"""
if not is_bao_kv_fetch(entry):
raise ProxyError(
f"{entry.id!r} is not a plain `bao kv get` lane — response wrapping "
"(--wrap) is unavailable. Use --out FILE or --exec instead."
)
target = path or entry.path_template
if not target or _PLACEHOLDER.search(target):
raise ProxyError(
"--wrap needs a concrete path — supply --path or a resolved path_template."
)
return ResolvedFetch(argv=["bao", "kv", "get", f"-wrap-ttl={ttl}", "-format=json", target])
def proxy_fetch_wrapped(resolved: ResolvedFetch) -> str:
"""Run a wrapping fetch and return the wrapping *token* (not the secret value).
The token is single-use and short-lived; it is not itself the credential, so it
is safe to hand back on stdout. Parses OpenBao's ``-format=json`` wrap_info.
"""
raw = _capture_value(resolved)
try:
data = json.loads(raw)
token = data["wrap_info"]["token"]
except (json.JSONDecodeError, KeyError, TypeError) as e:
raise ProxyError(
"could not parse a wrapping token from the fetch output "
"(is response wrapping supported for this path?)."
) from e
if not token:
raise ProxyError("empty wrapping token returned.")
return str(token)
def proxy_fetch_fingerprint(resolved: ResolvedFetch):
"""Fetch the value and return a masked fingerprint — never the value (T03).
Defense-in-depth status view: lets an operator confirm presence/length and
compare a short non-reversible hash without disclosing the secret. The value
transits warden's memory only to be hashed, and is scrubbed immediately.
"""
from warden.mask import fingerprint
value = _capture_value(resolved)
try:
return fingerprint(value)
finally:
value = "" # noqa: F841 — best-effort scrub
def proxy_exec(resolved: ResolvedFetch, *, env_var: str, child_argv: List[str]) -> int:
"""Fetch the value and inject it into a child command's environment only.
The value transits warden's memory here (the accepted proxy tradeoff for `--exec`)
but is never written to disk or log and never enters the caller's own shell env.
Captures the fetch tool's stdout to obtain the value, strips a single trailing
newline, and runs ``child_argv`` with ``env_var`` set in its environment.
"""
if not env_var:
raise ProxyError("--exec requires --field (the env var name to inject), e.g. NPM_AUTH_TOKEN")
env = _caller_env()
if resolved.argv is not None:
fetched = subprocess.run( # noqa: S603
resolved.argv,
stdout=subprocess.PIPE,
stderr=None,
stdin=None,
env=env,
check=False,
text=True,
)
else:
fetched = subprocess.run( # noqa: S602
resolved.shell_cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=None,
stdin=None,
env=env,
check=False,
text=True,
)
if fetched.returncode != 0:
raise ProxyError(
f"fetch failed (exit {fetched.returncode}) — check caller auth and the path."
)
value = fetched.stdout
if value.endswith("\n"):
value = value[:-1]
child_env = _caller_env()
child_env[env_var] = value
try:
child = subprocess.run( # noqa: S603
child_argv, stdout=None, stderr=None, stdin=None, env=child_env, check=False
)
return child.returncode
finally:
# Best-effort scrub of the local reference; do not log it.
value = "" # noqa: F841
del child_env[env_var]