WARDEN-WP-0026 finish Strand A (T04/T05/T07)
Promote railiance-backup-offsite-lane to active/resolvable after capabilities-safe re-verify. Add catalog risk=high, agent read-boundary (exit 7 + OpenBao policy companion), EXPOSED taint via warden taint, and close WP-0026.
This commit is contained in:
parent
7d0c7c7684
commit
b971403dad
16 changed files with 689 additions and 31 deletions
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Optional
|
||||
|
|
@ -630,6 +631,9 @@ def _entry_summary(entry) -> dict:
|
|||
"canon_ref": entry.canon_ref,
|
||||
"reviewed": entry.reviewed,
|
||||
"status": entry.status,
|
||||
# Agent read-boundary (WP-0026 T04) — high-risk lanes deny raw agent data reads.
|
||||
"risk": entry.risk,
|
||||
"high_risk": entry.is_high_risk,
|
||||
# Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation`
|
||||
# lets a caller gate before asking for the full block via `warden rotate-guide`.
|
||||
"has_rotation": entry.has_rotation,
|
||||
|
|
@ -803,6 +807,63 @@ def route_show(
|
|||
)
|
||||
|
||||
|
||||
@app.command("taint")
|
||||
def taint_show(
|
||||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
) -> None:
|
||||
"""Report whether a lane's OpenBao secret is marked EXPOSED (WP-0026 T05).
|
||||
|
||||
Reads KV v2 *metadata only* (custom_metadata: exposed_at, exposed_version, …).
|
||||
Never reads secret data. Advisory — does not rotate or clear taint.
|
||||
"""
|
||||
from warden.taint import TaintError, fetch_taint_status
|
||||
|
||||
catalog = _load_catalog()
|
||||
entry = catalog.get(entry_id)
|
||||
if entry is None:
|
||||
# Drafts are findable by exact id via get even when not listed.
|
||||
err.print(
|
||||
f"[red]Unknown routing id {entry_id!r}.[/red] Try: warden route find {entry_id!r} --all"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
status = fetch_taint_status(entry)
|
||||
except TaintError as e:
|
||||
err.print(f"[red]taint status unavailable:[/red] {e}")
|
||||
raise typer.Exit(2)
|
||||
|
||||
if output_json:
|
||||
print(json.dumps(status.to_dict(), indent=2))
|
||||
return
|
||||
|
||||
console.print(f"[bold]Taint status — {entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||||
console.print(f" path : {status.path}")
|
||||
if status.error:
|
||||
console.print(f" [yellow]query error[/yellow] : {status.error}")
|
||||
console.print(
|
||||
" [dim]Need caller OpenBao auth with metadata-read on the path "
|
||||
"(agent-high-risk-boundary allows metadata; workload-kv-read allows both).[/dim]"
|
||||
)
|
||||
raise typer.Exit(3)
|
||||
if status.tainted:
|
||||
console.print(" tainted : [red]yes (EXPOSED)[/red]")
|
||||
console.print(f" exposed_at : {status.exposed_at}")
|
||||
console.print(f" exposed_version : {status.exposed_version}")
|
||||
console.print(f" exposed_reason : {status.exposed_reason}")
|
||||
console.print(f" exposed_ref : {status.exposed_ref}")
|
||||
console.print(f" current_version : {status.current_version}")
|
||||
console.print(
|
||||
"\n[yellow]Advisory:[/yellow] rotate/re-establish per "
|
||||
f"`warden rotate-guide {entry.id}` then clear custom_metadata keys "
|
||||
"(exposed_at, exposed_version, …). No auto-rotation (Strand B)."
|
||||
)
|
||||
else:
|
||||
console.print(" tainted : [green]no[/green]")
|
||||
console.print(f" current_version : {status.current_version}")
|
||||
|
||||
|
||||
@app.command("rotate-guide")
|
||||
def rotate_guide(
|
||||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||||
|
|
@ -1059,11 +1120,30 @@ def _access_proxy(
|
|||
err.print(f"[red]{e}[/red]")
|
||||
raise typer.Exit(2)
|
||||
|
||||
# T04 — agent identity on a high-risk lane: never stream raw secret data.
|
||||
# Agents may use sanctioned transports (--out / --exec / --wrap / --fingerprint).
|
||||
agent_id = os.environ.get("WARDEN_AGENT_ID", "").strip()
|
||||
raw_value_stream = (
|
||||
not is_login and not do_exec and not wrap and not out_path and not fingerprint
|
||||
)
|
||||
if raw_value_stream and entry.is_high_risk and agent_id:
|
||||
err.print(
|
||||
f"[red]Agent read-boundary:[/red] {entry.id!r} is risk=high; "
|
||||
f"agent identity {agent_id!r} must not stream raw secret data.\n"
|
||||
"Use a sanctioned transport (value stays off the session transcript):\n"
|
||||
" --out FILE write to a mode-0600 file\n"
|
||||
" --exec -- CMD inject into a child process env only\n"
|
||||
" --wrap single-use OpenBao wrapping token (unwrap out-of-band)\n"
|
||||
" --fingerprint masked presence/length/hash only\n"
|
||||
"OpenBao policy `agent-high-risk-boundary` also denies data-read for agents."
|
||||
)
|
||||
raise typer.Exit(7)
|
||||
|
||||
# T02 — the sanctioned fetch transports (file / env / wrapping token) never put a
|
||||
# secret value on stdout. Streaming a value to stdout is the documented anti-pattern:
|
||||
# allowed only to an interactive terminal, and only with an explicit acknowledgment
|
||||
# when stdout is captured/piped (the logged-context disclosure risk).
|
||||
if not is_login and not do_exec and not wrap and not out_path and not fingerprint:
|
||||
if raw_value_stream:
|
||||
import sys as _sys
|
||||
|
||||
if not _sys.stdout.isatty() and not unsafe_stdout:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from typing import List, Optional
|
|||
|
||||
import yaml
|
||||
|
||||
from warden.routing.models import RotationGuide, RouteEntry
|
||||
from warden.routing.models import VALID_RISK, RotationGuide, RouteEntry
|
||||
|
||||
# Structured handoff string fields (WP-0014) — templates and pointers only.
|
||||
# Every one is scanned for accidental secret material; see _assert_no_secret_material.
|
||||
|
|
@ -303,6 +303,12 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
|
|||
f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})"
|
||||
)
|
||||
|
||||
risk = str(raw.get("risk", "standard")).strip() or "standard"
|
||||
if risk not in VALID_RISK:
|
||||
raise CatalogError(
|
||||
f"entry {entry_id!r} has invalid risk {risk!r} (expected one of {VALID_RISK})"
|
||||
)
|
||||
|
||||
return RouteEntry(
|
||||
id=entry_id,
|
||||
title=str(raw["title"]),
|
||||
|
|
@ -326,6 +332,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry:
|
|||
exec_command=handoff["exec_command"],
|
||||
pointer_command=handoff["pointer_command"],
|
||||
rotation=_parse_rotation(entry_id, raw.get("rotation")),
|
||||
risk=risk,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ class RotationGuide:
|
|||
automatable: bool = False
|
||||
|
||||
|
||||
# Risk classes for agent read-boundary (WARDEN-WP-0026 T04).
|
||||
# high — recovery escrow, upload tokens, admin PATs, high-spend provider keys.
|
||||
# Agent identities must not hold raw data-read (metadata/capabilities only).
|
||||
# standard — ordinary workload secrets (ESO-fed, non-escrow); normal least-privilege.
|
||||
VALID_RISK = ("standard", "high")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteEntry:
|
||||
id: str
|
||||
|
|
@ -69,11 +76,18 @@ class RouteEntry:
|
|||
pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --json"
|
||||
# Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values.
|
||||
rotation: Optional[RotationGuide] = None
|
||||
# Agent read-boundary risk class (WP-0026 T04). high → agents use wrap/out/exec only.
|
||||
risk: str = "standard" # "standard" | "high"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == "active"
|
||||
|
||||
@property
|
||||
def is_high_risk(self) -> bool:
|
||||
"""True when this lane is on the agent raw-read deny list (WP-0026 T04)."""
|
||||
return self.risk == "high"
|
||||
|
||||
@property
|
||||
def has_rotation(self) -> bool:
|
||||
"""True when this lane carries renewal guidance (WP-0026 T06)."""
|
||||
|
|
|
|||
149
src/warden/taint.py
Normal file
149
src/warden/taint.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""EXPOSED taint convention for OpenBao KV secrets (WARDEN-WP-0026 T05).
|
||||
|
||||
Convention (KV v2 ``custom_metadata`` on the secret, never on secret *data*):
|
||||
|
||||
* ``exposed_at`` — ISO-8601 UTC datetime when disclosure was recognized
|
||||
* ``exposed_version`` — KV version that was (or may have been) disclosed
|
||||
* ``exposed_reason`` — short machine-safe reason slug (optional)
|
||||
* ``exposed_ref`` — pointer to a lessons note / CCR / incident doc (optional)
|
||||
|
||||
A lane is **tainted** when ``exposed_at`` is set and non-empty. Clearing taint
|
||||
(after rotation) is an operator action: remove those keys from custom_metadata.
|
||||
ops-warden only *reports* taint — it never auto-rotates (Strand B / WP-0027).
|
||||
|
||||
This module only shells out to ``bao kv metadata get`` (or equivalent). It never
|
||||
reads secret data values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from warden.routing.models import RouteEntry
|
||||
|
||||
# Canonical custom_metadata keys (WP-0026 T05).
|
||||
EXPOSED_AT = "exposed_at"
|
||||
EXPOSED_VERSION = "exposed_version"
|
||||
EXPOSED_REASON = "exposed_reason"
|
||||
EXPOSED_REF = "exposed_ref"
|
||||
|
||||
_TAINT_KEYS = (EXPOSED_AT, EXPOSED_VERSION, EXPOSED_REASON, EXPOSED_REF)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaintStatus:
|
||||
"""Advisory taint view for a lane — no secret values."""
|
||||
|
||||
lane_id: str
|
||||
path: str
|
||||
tainted: bool
|
||||
exposed_at: Optional[str] = None
|
||||
exposed_version: Optional[str] = None
|
||||
exposed_reason: Optional[str] = None
|
||||
exposed_ref: Optional[str] = None
|
||||
current_version: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.lane_id,
|
||||
"path": self.path,
|
||||
"tainted": self.tainted,
|
||||
"exposed_at": self.exposed_at,
|
||||
"exposed_version": self.exposed_version,
|
||||
"exposed_reason": self.exposed_reason,
|
||||
"exposed_ref": self.exposed_ref,
|
||||
"current_version": self.current_version,
|
||||
**({"error": self.error} if self.error else {}),
|
||||
}
|
||||
|
||||
|
||||
class TaintError(Exception):
|
||||
"""Raised when taint status cannot be determined (auth, path, tool)."""
|
||||
|
||||
|
||||
def kv_metadata_path(path_template: str) -> str:
|
||||
"""Return the logical KV path suitable for ``bao kv metadata get``.
|
||||
|
||||
Catalog paths are logical (``platform/workloads/...``), not API data paths.
|
||||
"""
|
||||
return path_template.strip().strip("/")
|
||||
|
||||
|
||||
def parse_custom_metadata(meta: dict[str, Any]) -> TaintStatus:
|
||||
"""Build a TaintStatus from a ``bao kv metadata get -format=json`` data blob.
|
||||
|
||||
``meta`` is the ``data`` object (with ``custom_metadata``, ``current_version``).
|
||||
"""
|
||||
custom = meta.get("custom_metadata") or {}
|
||||
if not isinstance(custom, dict):
|
||||
custom = {}
|
||||
exposed_at = (custom.get(EXPOSED_AT) or "").strip() or None
|
||||
return TaintStatus(
|
||||
lane_id="",
|
||||
path="",
|
||||
tainted=bool(exposed_at),
|
||||
exposed_at=exposed_at,
|
||||
exposed_version=(custom.get(EXPOSED_VERSION) or "").strip() or None,
|
||||
exposed_reason=(custom.get(EXPOSED_REASON) or "").strip() or None,
|
||||
exposed_ref=(custom.get(EXPOSED_REF) or "").strip() or None,
|
||||
current_version=meta.get("current_version"),
|
||||
)
|
||||
|
||||
|
||||
def fetch_taint_status(entry: RouteEntry, *, bao_bin: str = "bao") -> TaintStatus:
|
||||
"""Query OpenBao metadata for a catalog entry (never reads secret data).
|
||||
|
||||
Uses the caller's ``BAO_TOKEN`` / ``VAULT_TOKEN`` / ``~/.vault-token`` — same
|
||||
G1 rule as the access proxy. Requires ``path_template`` on the entry.
|
||||
"""
|
||||
if not entry.path_template or "<" in entry.path_template:
|
||||
raise TaintError(
|
||||
f"{entry.id!r} has no concrete path_template — cannot query taint metadata."
|
||||
)
|
||||
path = kv_metadata_path(entry.path_template)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bao_bin, "kv", "metadata", "get", "-format=json", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise TaintError(f"{bao_bin!r} not found on PATH") from e
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "metadata get failed").strip().splitlines()
|
||||
# Never echo tokens if somehow present.
|
||||
safe = " ".join(err[:3])[:300]
|
||||
return TaintStatus(
|
||||
lane_id=entry.id,
|
||||
path=path,
|
||||
tainted=False,
|
||||
error=safe or f"bao exit {proc.returncode}",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
raise TaintError(f"invalid JSON from bao metadata get: {e}") from e
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
raise TaintError("bao metadata response missing data object")
|
||||
|
||||
status = parse_custom_metadata(data)
|
||||
return TaintStatus(
|
||||
lane_id=entry.id,
|
||||
path=path,
|
||||
tainted=status.tainted,
|
||||
exposed_at=status.exposed_at,
|
||||
exposed_version=status.exposed_version,
|
||||
exposed_reason=status.exposed_reason,
|
||||
exposed_ref=status.exposed_ref,
|
||||
current_version=status.current_version,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue