"""Recursive sanitization for MAF roundtable events, payloads, and logs. Strips credentials, tokens, cookies, passwords, secrets, and API keys from nested data structures before they reach the event store, context builder, or log output. Applied uniformly to tool arguments, tool results, fact ledger entries, and event payloads. Design: - Pure functions, no I/O. - Recursive: handles nested dicts, lists, and strings. - Key-based: redacts values whose key matches a sensitive pattern. - Value-based: redacts string values that look like tokens/keys. - Never raises: malformed input passes through unchanged (best-effort). """ from __future__ import annotations import re from typing import Any _SENSITIVE_KEYS = frozenset({ "token", "access_token", "refresh_token", "id_token", "authorization", "auth", "auth_token", "authtoken", "cookie", "cookies", "set-cookie", "password", "passwd", "pwd", "secret", "secret_key", "client_secret", "api_key", "apikey", "api-key", "private_key", "private-key", "credential", "credentials", "session_id", "sessionid", "x-api-key", "x-auth-token", }) _KEY_PATTERN = re.compile( r"(token|authorization|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|credential|bearer)", re.IGNORECASE, ) _VALUE_PATTERNS = [ re.compile(r"^Bearer\s+\S+", re.IGNORECASE), re.compile(r"^(sk|pk|ak|rk)[_-][A-Za-z0-9]{16,}$"), re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), re.compile(r"^[A-Za-z0-9+/]{40,}={0,2}$"), ] _INLINE_SECRET_PATTERN = re.compile( r"(token|password|passwd|secret|api[_-]?key|authorization|cookie)\s*[=:]\s*\S+", re.IGNORECASE, ) _REDACTED = "***REDACTED***" _MAX_DEPTH = 12 def sanitize(data: Any, *, _depth: int = 0) -> Any: """Recursively sanitize sensitive fields from a data structure.""" if _depth > _MAX_DEPTH: return data if isinstance(data, dict): return _sanitize_dict(data, _depth) if isinstance(data, (list, tuple)): return [sanitize(item, _depth=_depth + 1) for item in data] if isinstance(data, str): return _sanitize_value_str(data) return data def sanitize_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: """Sanitize an event payload dict. Returns None if input is None.""" if payload is None: return None result = sanitize(payload) return result if isinstance(result, dict) else payload def _sanitize_dict(d: dict[str, Any], depth: int) -> dict[str, Any]: out: dict[str, Any] = {} for key, value in d.items(): key_lower = str(key).lower().replace("-", "_") if key_lower in _SENSITIVE_KEYS or _KEY_PATTERN.search(key_lower): out[key] = _REDACTED elif isinstance(value, dict): out[key] = _sanitize_dict(value, depth + 1) elif isinstance(value, (list, tuple)): out[key] = [sanitize(item, _depth=depth + 1) for item in value] elif isinstance(value, str): out[key] = _sanitize_value_str(value) else: out[key] = value return out def _sanitize_value_str(s: str) -> str: # Never skip sanitization based on length — long strings (error traces, # log dumps, seat-run errors) may embed credentials deep inside. for pat in _VALUE_PATTERNS: if pat.match(s): return _REDACTED if _INLINE_SECRET_PATTERN.search(s): return _INLINE_SECRET_PATTERN.sub( lambda m: m.group(0).split("=")[0].split(":")[0] + "=***REDACTED***", s ) return s