WARDEN-WP-0036: restore fail-closed on non-allowlisted attended-login output

b4c1d39 dropped login output from the attended-login fail-closed condition,
so any stdout/stderr was ignored once the helper was populated (RPF-WP-0017
criterion 4 regression). Login output stays captured and never forwarded and
is now accepted only when empty or allowlisted by structure: one bao login
-format=json object on stdout, the OpenBao OIDC prompt on stderr. Anything
else self-revokes, cleans up, and exits 10/login_failed without retry.
Adds populated-helper tests for unexpected stdout, stderr, malformed and
multi-object JSON, and unexpected keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 680842@bnt-lap001
Assistant-Session: 54eb9e8e-7d6b-4f0c-b7a6-a6db62075883
This commit is contained in:
tegwick 2026-10-14 12:34:40 +02:00
parent 11b2a9d7bc
commit 244f1ae2ca
3 changed files with 311 additions and 11 deletions

View file

@ -12,8 +12,9 @@ intact. Three guardrails are enforced here in code:
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
an isolated helper session, never returns that output, fails closed on any
non-allowlisted login 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.
@ -44,6 +45,45 @@ _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"
# Attended-login output allowlist (RPF-WP-0017 criterion 4). Structure only: the
# values are never logged, returned, stored, or included in an exception.
#
# stdout: empty, or exactly one JSON object shaped like a ``bao login -format=json``
# response (api.Secret with an ``auth`` block).
_LOGIN_JSON_TOP_TYPES: dict = {
"request_id": (str,),
"lease_id": (str,),
"lease_duration": (int,),
"renewable": (bool,),
"data": (dict, type(None)),
"warnings": (list, type(None)),
"wrap_info": (dict, type(None)),
"mount_type": (str,),
"auth": (dict,),
}
_LOGIN_JSON_AUTH_TYPES: dict = {
"client_token": (str,),
"accessor": (str,),
"policies": (list, type(None)),
"token_policies": (list, type(None)),
"identity_policies": (list, type(None)),
"metadata": (dict, type(None)),
"orphan": (bool,),
"entity_id": (str,),
"lease_duration": (int,),
"renewable": (bool,),
"mfa_requirement": (dict, type(None)),
"num_uses": (int,),
}
# stderr: empty, or exactly the OpenBao OIDC CLI prompt (browser-launch or
# open-link variant, one https URL, optional wait line). Any other byte fails closed.
_LOGIN_OIDC_STDERR = re.compile(
r"\s*Complete the login via your OIDC provider\. "
r"(?:Launching browser to:|Open the following link in your browser:)"
r"[ \t]*\n\s*https://[^\s]+[ \t]*\n"
r"\s*(?:Waiting for OIDC authentication to complete\.\.\.[ \t]*\n?\s*)?"
)
@dataclass(frozen=True)
class ResolvedFetch:
@ -349,6 +389,66 @@ def _output_bytes(completed: subprocess.CompletedProcess) -> bytes:
return stdout + b"\n" + stderr
def _stream_bytes(value: object) -> bytes:
if isinstance(value, str):
return value.encode("utf-8", errors="replace")
return value if isinstance(value, bytes) else b""
def _reject_duplicate_keys(pairs: list) -> dict:
keys = [key for key, _ in pairs]
if len(keys) != len(set(keys)):
raise ValueError("duplicate key")
return dict(pairs)
def _typed_keys_allowed(obj: dict, allowed: dict) -> bool:
for key, value in obj.items():
types = allowed.get(key)
if types is None:
return False
# bool is an int subclass; never accept it where only int is expected.
if isinstance(value, bool) and bool not in types:
return False
if not isinstance(value, types):
return False
return True
def _login_stdout_allowed(raw: bytes) -> bool:
if not raw.strip():
return True
try:
parsed = json.loads(
raw.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys
)
except (UnicodeDecodeError, ValueError, RecursionError):
return False
if not isinstance(parsed, dict) or "auth" not in parsed:
return False
if not _typed_keys_allowed(parsed, _LOGIN_JSON_TOP_TYPES):
return False
auth = parsed["auth"]
return "client_token" in auth and _typed_keys_allowed(auth, _LOGIN_JSON_AUTH_TYPES)
def _login_stderr_allowed(raw: bytes) -> bool:
if not raw:
return True
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return False
return _LOGIN_OIDC_STDERR.fullmatch(text) is not None
def _login_output_allowed(completed: subprocess.CompletedProcess) -> bool:
"""True only for allowlisted login output; checks structure, never values."""
return _login_stdout_allowed(_stream_bytes(completed.stdout)) and (
_login_stderr_allowed(_stream_bytes(completed.stderr))
)
def _revoke_contained(
bao_binary: str,
*,
@ -397,8 +497,10 @@ def proxy_attended_login_exec(
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
proceeds only when the private helper is populated and its captured output is
empty or allowlisted (one ``-format=json`` login object on stdout, the OIDC
prompt on stderr); anything else, persistence defects, and non-zero results
fail closed with self-revocation. The reviewed child must
remain silent. Any possibly issued token is revoked before the isolated helper
directory is removed.
"""
@ -450,14 +552,19 @@ def proxy_attended_login_exec(
except (OSError, ProxyError):
helper_valid = False
if login.returncode != 0 or not helper_valid:
output_allowed = _login_output_allowed(login)
if login.returncode != 0 or not helper_valid or not output_allowed:
revoked = _revoke_contained(
resolved.argv[0], env=env, possible_output=login_output
)
status = "revoked" if revoked else "revocation could not be confirmed"
reason = (
"" if output_allowed
else " (login client produced non-allowlisted output)"
)
raise AttendedLoginError(
"attended login failed closed before command handoff; any possible "
f"issued session was contained and {status}",
"attended login failed closed before command handoff"
f"{reason}; any possible issued session was contained and {status}",
exit_code=10, outcome="login_failed",
)