"""Secret-scrubbing for settings exposed to non-admin / unauthenticated callers. Deliberately dependency-light (stdlib only) and separate from ``routes/auth_routes.py`` so it can be imported and unit-tested without dragging in the FastAPI app / auth / database import chain. ``/api/auth/settings`` is auth-exempt — the frontend (and the pre-login page) read it for keybinds + TTS prefs, so non-admin and unauthenticated callers get a *scrubbed* copy. Secrets (provider API keys, IMAP/SMTP passwords, OAuth tokens) must NOT leak to them — load-bearing when the app is reachable over a Cloudflare tunnel / reverse proxy. Scrubbing is deep (recurses nested dicts/lists) and keyed on secret-shaped names. """ _SECRET_KEY_PATTERNS = ( "_api_key", "_apikey", "_password", "_passwd", "_pass", "_pwd", "_secret", "_client_secret", "_token", "_access_token", "_refresh_token", "_credential", "_credentials", "_key", ) _SECRET_KEY_ALLOW = ("google_pse_cx",) # public identifiers, not secrets def is_secret_key(name: str) -> bool: n = (name or "").lower() if n in _SECRET_KEY_ALLOW: return False return any(n.endswith(p) or n == p.lstrip("_") for p in _SECRET_KEY_PATTERNS) def _scrub_value(key, value): """Mask secret-shaped leaves, recursing into nested dicts/lists so a secret stored under a non-secret parent key (e.g. ``{"email_account": {"smtp_password": "..."}}``) is still blanked. Only non-empty *string* values are blanked; presence is preserved.""" if isinstance(value, dict): return { k: ("" if (is_secret_key(k) and isinstance(v, str) and v) else _scrub_value(k, v)) for k, v in value.items() } if isinstance(value, list): return [_scrub_value(key, item) for item in value] if is_secret_key(key) and isinstance(value, str) and value: return "" return value def scrub_settings(settings: dict) -> dict: """Return a copy of ``settings`` with secret-shaped values masked (deep).""" return {k: _scrub_value(k, v) for k, v in (settings or {}).items()}