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:
parent
11b2a9d7bc
commit
244f1ae2ca
3 changed files with 311 additions and 11 deletions
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue