From fc0f18aa5c24247c66ebf89f8e02cf5f3de4da5f Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 16 Jul 2026 14:54:55 +0200 Subject: [PATCH] WARDEN-WP-0026 T03: masking display filter (defense-in-depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - warden/mask.py: fingerprint()/mask_value() — presence, length, 8-char sha256 prefix; never the value. - proxy.proxy_fetch_fingerprint + `warden access --fingerprint`: masked status view (presence/length/hash) that emits no value, so it bypasses the T02 stdout guard. Lets two parties compare sha256 prefixes to confirm a shared value without seeing it (e.g. rotation landed). - documented as defense-in-depth (raw bao bypasses it) in OperatorAccessAssist.md and the module docstring. - tests: tests/test_mask.py + CLI fingerprint test. 299 pass. Co-Authored-By: Claude Opus 4.8 --- src/warden/cli.py | 20 +++++++- src/warden/mask.py | 46 +++++++++++++++++++ src/warden/proxy.py | 16 +++++++ tests/test_mask.py | 33 +++++++++++++ tests/test_proxy.py | 19 ++++++++ wiki/OperatorAccessAssist.md | 8 ++++ ...N-WP-0026-credential-disclosure-hygiene.md | 11 ++++- 7 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/warden/mask.py create mode 100644 tests/test_mask.py diff --git a/src/warden/cli.py b/src/warden/cli.py index 1b5fe74..fa2f7ea 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -960,6 +960,7 @@ def _access_proxy( wrap: bool = False, wrap_ttl: str = "5m", unsafe_stdout: bool = False, + fingerprint: bool = False, ) -> None: """Proxy a non-SSH credential fetch as the caller (WP-0014 T3). @@ -973,6 +974,7 @@ def _access_proxy( caller_auth_present, proxy_exec, proxy_fetch, + proxy_fetch_fingerprint, proxy_fetch_to_file, proxy_fetch_wrapped, resolve_fetch_command, @@ -1061,7 +1063,7 @@ def _access_proxy( # 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: + if not is_login and not do_exec and not wrap and not out_path and not fingerprint: import sys as _sys if not _sys.stdout.isatty() and not unsafe_stdout: @@ -1098,6 +1100,15 @@ def _access_proxy( elif out_path: rc = proxy_fetch_to_file(resolved, Path(out_path)) err.print(f"[dim]value written to {out_path} (mode 0600); not shown[/dim]") + elif fingerprint: + fp = proxy_fetch_fingerprint(resolved) + # Masked fingerprint only — presence, length, short hash; never the value. + print(fp.render()) + err.print( + "[dim]masked fingerprint (defense-in-depth; not the value). Compare " + "sha256 prefixes to confirm two parties hold the same secret.[/dim]" + ) + rc = 0 else: rc = proxy_fetch(resolved) except ProxyError as e: @@ -1160,6 +1171,10 @@ def access( bool, typer.Option("--unsafe-stdout", help="Acknowledge streaming a value to a captured/piped stdout (anti-pattern)"), ] = False, + fingerprint: Annotated[ + bool, + typer.Option("--fingerprint", help="Show a masked fingerprint (presence, length, short hash) — never the value"), + ] = False, no_policy: Annotated[ bool, typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"), @@ -1195,7 +1210,7 @@ def access( entry = matches[0] - if do_fetch or do_exec or out_path or wrap: + if do_fetch or do_exec or out_path or wrap or fingerprint: _access_proxy( entry, domain=domain, @@ -1208,6 +1223,7 @@ def access( wrap=wrap, wrap_ttl=wrap_ttl, unsafe_stdout=unsafe_stdout, + fingerprint=fingerprint, ) return diff --git a/src/warden/mask.py b/src/warden/mask.py new file mode 100644 index 0000000..bd8a462 --- /dev/null +++ b/src/warden/mask.py @@ -0,0 +1,46 @@ +"""Masking display filter for KV values (WARDEN-WP-0026 T03). + +Defense-in-depth, **not a boundary**: any place warden would otherwise render a +secret value for a human (a status/listing view) shows a *fingerprint* instead — +presence, length, and a short non-reversible hash. Two operators can compare +fingerprints to confirm they hold the same value (e.g. that a rotation landed the +expected token) without either seeing it, and a fingerprint in a transcript +discloses nothing. + +Limitation (documented, by design): raw `bao kv get ` bypasses this entirely +— warden only masks *warden-mediated* output. The real boundary is OpenBao policy +plus the T01 capabilities-safe verify and T02 no-stdout transports. +""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +# Short, non-reversible hash: first 8 hex chars of SHA-256. Not a value, and a +# collision is irrelevant for the "same/different?" comparison this supports. +_HASH_PREFIX_LEN = 8 + + +@dataclass(frozen=True) +class Fingerprint: + present: bool + length: int + sha256_prefix: str # "" when the value is empty/absent + + def render(self) -> str: + if not self.present: + return "‹absent›" + return f"‹hidden len={self.length} sha256:{self.sha256_prefix}›" + + +def fingerprint(value: str | None) -> Fingerprint: + """Compute a non-reversible fingerprint of a value. Never returns the value.""" + if not value: + return Fingerprint(present=False, length=0, sha256_prefix="") + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:_HASH_PREFIX_LEN] + return Fingerprint(present=True, length=len(value), sha256_prefix=digest) + + +def mask_value(value: str | None) -> str: + """Render a value as its masked fingerprint string. Never emits the value.""" + return fingerprint(value).render() diff --git a/src/warden/proxy.py b/src/warden/proxy.py index eb91f4a..208463a 100644 --- a/src/warden/proxy.py +++ b/src/warden/proxy.py @@ -301,6 +301,22 @@ def proxy_fetch_wrapped(resolved: ResolvedFetch) -> str: return str(token) +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 + + def proxy_exec(resolved: ResolvedFetch, *, env_var: str, child_argv: List[str]) -> int: """Fetch the value and inject it into a child command's environment only. diff --git a/tests/test_mask.py b/tests/test_mask.py new file mode 100644 index 0000000..d690edb --- /dev/null +++ b/tests/test_mask.py @@ -0,0 +1,33 @@ +"""Tests for the masking display filter (WARDEN-WP-0026 T03).""" +from warden.mask import fingerprint, mask_value +from warden.proxy import ResolvedFetch, proxy_fetch_fingerprint + + +def test_mask_never_contains_the_value(): + secret = "ghp_realtokenvalue1234567890abcdef" + masked = mask_value(secret) + assert secret not in masked + assert "hidden" in masked and "len=" in masked and "sha256:" in masked + + +def test_fingerprint_reports_presence_and_length(): + fp = fingerprint("abcd") + assert fp.present is True and fp.length == 4 + assert len(fp.sha256_prefix) == 8 + + +def test_absent_value_renders_absent(): + assert mask_value("") == "‹absent›" + assert mask_value(None) == "‹absent›" + assert fingerprint(None).present is False + + +def test_fingerprint_is_stable_and_discriminating(): + assert fingerprint("token-A").sha256_prefix == fingerprint("token-A").sha256_prefix + assert fingerprint("token-A").sha256_prefix != fingerprint("token-B").sha256_prefix + + +def test_proxy_fingerprint_returns_mask_not_value(): + fp = proxy_fetch_fingerprint(ResolvedFetch(shell_cmd="printf 'the-secret-value'")) + assert fp.present and fp.length == len("the-secret-value") + assert "the-secret-value" not in fp.render() diff --git a/tests/test_proxy.py b/tests/test_proxy.py index a6aa937..0f7bbcd 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -364,3 +364,22 @@ def test_access_fetch_to_nonterminal_stdout_is_refused(tmp_path, monkeypatch): r = runner.invoke(app, ["access", "whynot-design-npm-publish", "--fetch", "--no-policy"]) assert r.exit_code == 6 assert "sanctioned transport" in r.output.lower() or "refusing" in r.output.lower() + + +def test_access_fingerprint_masks_and_bypasses_stdout_guard(monkeypatch, tmp_path): + """--fingerprint prints a masked fingerprint (never the value) even to captured stdout.""" + _proxy_env(monkeypatch, tmp_path) + monkeypatch.setenv("VAULT_TOKEN", "caller-token") + + class _Fake: + returncode = 0 + stdout = "top-secret-token-value" + + monkeypatch.setattr("warden.proxy.subprocess.run", lambda *a, **k: _Fake()) + r = runner.invoke( + app, + ["access", "whynot-design-npm-publish", "--fingerprint", "--no-policy"], + ) + assert r.exit_code == 0 + assert "top-secret-token-value" not in r.output # value never shown + assert "hidden" in r.output and "sha256:" in r.output diff --git a/wiki/OperatorAccessAssist.md b/wiki/OperatorAccessAssist.md index c7a7113..201dec7 100644 --- a/wiki/OperatorAccessAssist.md +++ b/wiki/OperatorAccessAssist.md @@ -31,12 +31,20 @@ $ warden access "npm token" --field NPM_AUTH_TOKEN --exec -- npm publish $ warden access "npm token" --path

--wrap # then: bao unwrap # interactive login (login lane): no token required, no secret-read gate $ warden access "login oidc" --domain coulomb_social --fetch +# masked status: presence, length, short hash — never the value (WP-0026 T03) +$ warden access "npm token" --path

--fingerprint # ‹hidden len=40 sha256:1a2b3c4d› ``` > **Raw `--fetch` to stdout is the anti-pattern.** It is refused when stdout is > captured or piped (a logged-context disclosure risk); pass `--unsafe-stdout` only > for an interactive human terminal. Prefer `--out` / `--exec` / `--wrap`. +> **`--fingerprint` is defense-in-depth, not a boundary.** It masks warden-mediated +> output so two parties can compare `sha256` prefixes to confirm they hold the same +> value (e.g. that a rotation landed) without either seeing it. It only masks +> *warden's* output — raw `bao kv get ` bypasses it entirely. The real boundary +> is OpenBao policy plus capabilities-safe verify (T01) and the no-stdout transports. + `--json` gives a stable, secret-free shape for agentic operators. --- diff --git a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md index 5b2ca1b..f82db7f 100644 --- a/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md +++ b/workplans/WARDEN-WP-0026-credential-disclosure-hygiene.md @@ -115,11 +115,20 @@ the anti-pattern is documented fleet-wide. ```task id: WARDEN-WP-0026-T03 -status: todo +status: done priority: medium state_hub_task_id: "21ab08d5-7782-4567-a08d-980211dd7851" ``` +Done 2026-07-16: `warden/mask.py` (`fingerprint`/`mask_value` — presence, length, +8-char sha256 prefix; never the value) + `proxy_fetch_fingerprint` and a +`warden access … --fingerprint` masked status view (bypasses the stdout guard +because it emits no value). Lets two parties compare sha256 prefixes to confirm a +shared value (e.g. rotation landed) without disclosure. Explicitly labelled +defense-in-depth — raw `bao kv get` bypasses it — in `wiki/OperatorAccessAssist.md` +and the module docstring. Tests in `tests/test_mask.py` + a CLI test in +`tests/test_proxy.py`. + In the warden wrapper, mask KV data values by default when any listing/status is shown — display presence, length, and a short non-reversible hash instead of the value. Explicitly labelled as defense-in-depth (raw bao bypasses it).