"""Recursive redaction and leak checks for evidence values.""" from __future__ import annotations import re from collections.abc import Mapping from typing import Any, Iterable, Pattern _REDACTED = "[REDACTED]" _REDACTED_NAME = "[REDACTED_CREDENTIAL_NAME]" DEFAULT_CREDENTIAL_NAME_PATTERNS = ( r"(? tuple[Pattern[str], ...]: selected = (*DEFAULT_CREDENTIAL_NAME_PATTERNS, *(patterns or ())) result: list[Pattern[str]] = [] for pattern in selected: result.append(re.compile(pattern) if isinstance(pattern, str) else pattern) return tuple(result) def _is_credential_name(key: object, patterns: tuple[Pattern[str], ...]) -> bool: if not isinstance(key, str): return False if _DEFAULT_KEY_PATTERN.fullmatch(key): return True return any(pattern.fullmatch(key) is not None for pattern in patterns) def _redact_text( value: str, canaries: tuple[str, ...], credential_patterns: tuple[Pattern[str], ...], ) -> str: # Protect our own marker while applying user-supplied patterns. A pattern # such as ``CREDENTIAL`` must not recursively redact the marker itself. marker_sentinel = "\x00NONO_REDACTED_CREDENTIAL_NAME\x00" result = value.replace(_REDACTED_NAME, marker_sentinel) # Longer values first prevents a short canary from obscuring a longer one in # the common case where one test fixture intentionally nests both. for canary in sorted((item for item in canaries if item), key=len, reverse=True): result = result.replace(canary, _REDACTED) for pattern in credential_patterns: result = pattern.sub(_REDACTED_NAME, result) return result.replace(marker_sentinel, _REDACTED_NAME) def redact( value: Any, *, canaries: Iterable[str] = (), credential_name_patterns: Iterable[str | Pattern[str]] | None = None, ) -> Any: """Return a recursively redacted copy of JSON-like evidence. Credential patterns match mapping keys in full, and match credential-name tokens in free text. This distinction protects safe field names from an over-broad substring replacement. """ canary_values = tuple(str(item) for item in canaries if str(item)) patterns = _patterns(credential_name_patterns) def visit(item: Any) -> Any: if isinstance(item, Mapping): result: dict[Any, Any] = {} for key, child in item.items(): if _is_credential_name(key, patterns): result[_REDACTED_NAME] = _REDACTED else: safe_key = visit(key) if isinstance(key, (str, int, float, bool)) else str(key) result[safe_key] = visit(child) return result if isinstance(item, list): return [visit(child) for child in item] if isinstance(item, tuple): return [visit(child) for child in item] if isinstance(item, set): return [visit(child) for child in sorted(item, key=str)] if isinstance(item, BaseException): return _redact_text(str(item), canary_values, patterns) if isinstance(item, str): return _redact_text(item, canary_values, patterns) return item return visit(value) def scan_for_leaks( value: Any, *, canaries: Iterable[str] = (), credential_name_patterns: Iterable[str | Pattern[str]] | None = None, ) -> list[str]: """Return JSON-style paths where an unredacted canary/name is present.""" canary_values = tuple(str(item) for item in canaries if str(item)) patterns = _patterns(credential_name_patterns) leaks: list[str] = [] def inspect(item: Any, path: str) -> None: if isinstance(item, Mapping): for key, child in item.items(): key_path = f"{path}.{key}" if path else str(key) if _is_credential_name(key, patterns): leaks.append(key_path) if isinstance(key, str) and any( canary and canary in key for canary in canary_values ): leaks.append(key_path) inspect(child, key_path) return if isinstance(item, (list, tuple, set)): for index, child in enumerate(item): inspect(child, f"{path}[{index}]") return if isinstance(item, BaseException): inspect(str(item), path) return if isinstance(item, str): if any(canary and canary in item for canary in canary_values): leaks.append(path or "$") # Do not rediscover the marker emitted by this module when a # generic configured pattern includes the word CREDENTIAL. scan_text = item.replace(_REDACTED_NAME, "") if any(pattern.search(scan_text) is not None for pattern in patterns): leaks.append(path or "$") inspect(value, "") return leaks def assert_no_leaks( value: Any, *, canaries: Iterable[str] = (), credential_name_patterns: Iterable[str | Pattern[str]] | None = None, ) -> None: leaks = scan_for_leaks( value, canaries=canaries, credential_name_patterns=credential_name_patterns, ) if leaks: raise RedactionError("unredacted evidence at " + ", ".join(leaks)) # Explicit aliases make call sites read naturally and preserve a stable harness API. redact_recursive = redact redact_evidence = redact leak_scan = scan_for_leaks assert_clean = assert_no_leaks __all__ = [ "DEFAULT_CREDENTIAL_NAME_PATTERNS", "RedactionError", "assert_clean", "assert_no_leaks", "leak_scan", "redact", "redact_evidence", "redact_recursive", "scan_for_leaks", ]