#!/usr/bin/env python3 """Credential redaction and output bounding for the handoff acceptance harness. Every string the harness records — command output, failure reasons, evidence values — passes through :func:`redact` before it reaches a report. Screening is a fail-closed accident barrier, not proof that text is secret-free: a short secret under an innocuous key is not reliably distinguishable from prose. The harness therefore also never asks for a secret in the first place. Probes read names, shapes, and status fields; they never read environment values, token files, private keys, cookies, or Vault responses. Bounding is part of the same barrier. An unbounded capture is how a debug dump or a stack trace carrying an authorization header ends up in an artifact, so captures are truncated at a byte budget before they are screened. """ from __future__ import annotations import re from typing import Any REDACTED = "[redacted]" DEFAULT_MAX_BYTES = 64 * 1024 TRUNCATION_NOTE = "[truncated]" # Keys whose value is never safe to record, matched against a normalised # (lowercased, separator-stripped) form of the key. SENSITIVE_KEY_SUFFIXES = ( "accesskey", "accesskeyid", "apikey", "authtoken", "clientsecret", "connectionstring", "cookie", "credential", "credentials", "passphrase", "passwd", "password", "privatekey", "secret", "secretkey", "sessionkey", "signature", "signingkey", "token", "webhook", ) SENSITIVE_KEY_EXACT = frozenset( { "auth", "authorization", "bearer", "pat", "pw", "sas", "setcookie", } ) # A bare `key:` is a structural field name across Kubernetes and TOML far more # often than it is a credential, so it is screened by value shape and by the # token patterns rather than by name. The compound forms above still match. # Values that cannot hold a credential: booleans, numbers, and anything too # short to be one. Screening them corrupts structured evidence — a redacted # `automountServiceAccountToken` is exactly the fact a reader needs — without # protecting anything. MIN_SECRET_LENGTH = 8 NON_SECRET_LITERALS = frozenset({"true", "false", "null", "none", "yes", "no", "0", "1", ""}) _KEY_NOISE_RE = re.compile(r"[^a-z0-9]+") # Ordered: structural matches first so a nested token is not partially rewritten # by a looser rule. _PATTERNS: tuple[re.Pattern[str], ...] = ( re.compile( r"-----BEGIN[ A-Z0-9-]{0,64}-----.*?-----END[ A-Z0-9-]{0,64}-----", re.DOTALL, ), re.compile(r"(?i)\b(?:authorization|proxy-authorization)\s*:\s*\S.*"), re.compile(r"(?i)\b(?:set-)?cookie\s*:\s*\S.*"), re.compile(r"(?i)\b(?:bearer|basic|token)\s+[A-Za-z0-9+/_=.:~-]{8,}"), re.compile(r"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s/@:]{1,128}:[^\s/@]{1,256}@"), re.compile(r"\bssh-(?:rsa|ed25519|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/]{16,}={0,3}"), re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}"), re.compile(r"(?["']?) (?P[A-Za-z][A-Za-z0-9_.\ -]{0,63}) (?P=quote) (?P\s{0,8}[:=]\s{0,8}) (?P"[^"\n]{0,4096}"|'[^'\n]{0,4096}'|[^\s,;}\n][^,;}\n]{0,4096}) """ ) # Long, mixed-class runs. Deliberately conservative: prose and hex digests of # fewer than 44 characters are left alone so commit SHAs stay readable. _ENTROPY_RE = re.compile(r"(? str: """Return a key lowercased with separators removed, for suffix matching.""" return _KEY_NOISE_RE.sub("", key.lower()) def is_sensitive_key(key: str) -> bool: """Report whether a key name means its value must never be recorded.""" compact = normalise_key(key) if not compact: return False if compact in SENSITIVE_KEY_EXACT: return True return compact.endswith(SENSITIVE_KEY_SUFFIXES) def _is_high_entropy(candidate: str) -> bool: """Report whether a long run mixes enough character classes to be a token.""" classes = sum( ( any(character.islower() for character in candidate), any(character.isupper() for character in candidate), any(character.isdigit() for character in candidate), any(character in "+/_=-" for character in candidate), ) ) return classes >= 3 def could_hold_secret(value: str) -> bool: """Report whether a raw value is long and opaque enough to be a credential.""" stripped = value.strip().strip("\"'") if stripped.lower() in NON_SECRET_LITERALS or len(stripped) < MIN_SECRET_LENGTH: return False try: float(stripped) except ValueError: return True return False def _redact_assignment(match: re.Match[str]) -> str: value = match.group("value") if not is_sensitive_key(match.group("key")) or not could_hold_secret(value): return match.group(0) # Preserve the original quoting so screened JSON stays parsable JSON. quoted = value.strip()[:1] in {'"', "'"} replacement = f'"{REDACTED}"' if quoted else REDACTED key_quote = match.group("quote") return f"{key_quote}{match.group('key')}{key_quote}{match.group('gap')}{replacement}" def _redact_entropy(match: re.Match[str]) -> str: return REDACTED if _is_high_entropy(match.group(0)) else match.group(0) def redact(text: str) -> str: """Return ``text`` with credential-shaped substrings replaced.""" if not text: return text for pattern in _PATTERNS: text = pattern.sub(REDACTED, text) text = _ASSIGNMENT_RE.sub(_redact_assignment, text) return _ENTROPY_RE.sub(_redact_entropy, text) def bound(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]: """Truncate ``text`` to a UTF-8 byte budget, reporting whether it was cut.""" if max_bytes <= 0: return ("", bool(text)) encoded = text.encode("utf-8", "replace") if len(encoded) <= max_bytes: return (text, False) kept = encoded[:max_bytes].decode("utf-8", "ignore") return (f"{kept}\n{TRUNCATION_NOTE}", True) def safe_text(text: str, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]: """Bound then redact, in that order, so a huge dump cannot outrun screening.""" bounded, truncated = bound(text, max_bytes) return (redact(bounded), truncated) def scrub(value: Any, max_bytes: int = DEFAULT_MAX_BYTES) -> Any: """Recursively bound and redact a JSON-shaped value. Mapping keys are preserved (names are evidence); values under a sensitive key are dropped outright rather than pattern-screened, because a short secret would survive screening. """ if isinstance(value, str): return safe_text(value, max_bytes)[0] if isinstance(value, dict): return { str(key): REDACTED if is_sensitive_key(str(key)) and could_hold_secret(str(item)) else scrub(item, max_bytes) for key, item in value.items() } if isinstance(value, (list, tuple)): return [scrub(item, max_bytes) for item in value] return value def contains_credential_shape(text: str) -> bool: """Report whether screening would rewrite ``text``. Used by the harness self-test to prove a rendered report is clean before it is written, and by the unit suite as an assertion helper. """ return redact(text) != text