fix: contain attended OpenBao login output

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0290b-3241-74c3-b868-6049545af836
This commit is contained in:
tegwick 2026-08-23 01:31:05 +02:00
parent 461f580813
commit 0fae0904ce
9 changed files with 497 additions and 75 deletions

View file

@ -8,11 +8,12 @@ intact. Three guardrails are enforced here in code:
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 transit only, no persistence/logging of values.** ``proxy_fetch`` runs the
tool with **inherited** stdout/stderr (never a pipe), so the value streams to the
caller and never enters warden's memory. ``proxy_exec`` reads the value solely to
place it in a child process's environment (the accepted proxy tradeoff) and never
writes it to disk or log. The audit record is metadata only.
* **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, permits no output, 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.
@ -24,7 +25,10 @@ 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
@ -33,6 +37,9 @@ 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)
@ -205,6 +212,267 @@ def proxy_fetch(resolved: ResolvedFetch) -> int:
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. Any non-empty output,
persistence defect, or non-zero result fails closed; 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()
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 login_output.strip() 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.