2026-06-27 16:26:03 +02:00
|
|
|
"""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.
|
2026-08-23 01:31:05 +02:00
|
|
|
* **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
|
2026-09-01 00:13:26 +02:00
|
|
|
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.
|
2026-06-27 16:26:03 +02:00
|
|
|
* **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
|
2026-08-23 01:31:05 +02:00
|
|
|
import shutil
|
|
|
|
|
import stat
|
2026-06-27 16:26:03 +02:00
|
|
|
import subprocess
|
2026-08-23 01:31:05 +02:00
|
|
|
import tempfile
|
2026-07-07 16:42:26 +02:00
|
|
|
from dataclasses import dataclass
|
2026-06-27 16:26:03 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
|
|
|
from warden.routing.models import RouteEntry
|
|
|
|
|
|
|
|
|
|
_PLACEHOLDER = re.compile(r"<[^>]+>")
|
2026-08-23 01:31:05 +02:00
|
|
|
_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"
|
2026-06-27 16:26:03 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 16:42:26 +02:00
|
|
|
@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")
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 16:26:03 +02:00
|
|
|
class ProxyError(Exception):
|
|
|
|
|
"""Raised when a proxy fetch cannot be performed safely."""
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 16:42:26 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 16:26:03 +02:00
|
|
|
def resolve_fetch_command(
|
|
|
|
|
entry: RouteEntry,
|
|
|
|
|
*,
|
|
|
|
|
domain: Optional[str] = None,
|
|
|
|
|
field: Optional[str] = None,
|
|
|
|
|
path: Optional[str] = None,
|
2026-07-07 16:42:26 +02:00
|
|
|
) -> ResolvedFetch:
|
|
|
|
|
"""Build the concrete fetch command for an entry, or raise if under-specified.
|
2026-06-27 16:26:03 +02:00
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
)
|
2026-07-07 16:42:26 +02:00
|
|
|
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))
|
2026-06-27 16:26:03 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-07-01 23:32:38 +02:00
|
|
|
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
|
2026-06-27 16:26:03 +02:00
|
|
|
return log_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _caller_env() -> dict:
|
|
|
|
|
"""The child environment = the caller's own env. warden adds no credential (G1)."""
|
|
|
|
|
return dict(os.environ)
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 16:42:26 +02:00
|
|
|
def proxy_fetch(resolved: ResolvedFetch) -> int:
|
2026-06-27 16:26:03 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-07-07 16:42:26 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-27 16:26:03 +02:00
|
|
|
return completed.returncode
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 01:31:05 +02:00
|
|
|
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,
|
2026-09-01 00:13:26 +02:00
|
|
|
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.
|
2026-08-23 01:31:05 +02:00
|
|
|
"""
|
|
|
|
|
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()
|
2026-09-01 00:24:18 +02:00
|
|
|
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)
|
2026-08-23 01:31:05 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-01 00:13:26 +02:00
|
|
|
if login.returncode != 0 or not helper_valid:
|
2026-08-23 01:31:05 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 14:51:56 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 14:54:55 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 16:42:26 +02:00
|
|
|
def proxy_exec(resolved: ResolvedFetch, *, env_var: str, child_argv: List[str]) -> int:
|
2026-06-27 16:26:03 +02:00
|
|
|
"""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")
|
|
|
|
|
|
2026-07-07 16:42:26 +02:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-27 16:26:03 +02:00
|
|
|
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]
|