"""Shared credential-rejection guard for web UI registries (#427, #798). Registries are operator-editable declarative files that the web UI loads and, for the worker registry, writes back. None of them may ever carry a secret: credentials belong in the keychain and reach worker processes through environment injection, never through a file the browser layer can read. The check is structural rather than value-based on purpose. A value scanner has to guess what a secret looks like; a key scanner refuses the *shape* of a credential field, so an operator cannot introduce one by accident and a later loader cannot silently pass one through. """ from __future__ import annotations from typing import Any _FORBIDDEN_EXACT_KEYS = frozenset({ "token", "password", "secret", "credential", "auth", "api_key", "api-key", }) _FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_") _FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth") def is_forbidden_key(key: str) -> bool: """Return True when *key* names a credential field.""" lowered = key.lower() if lowered in _FORBIDDEN_EXACT_KEYS: return True return ( lowered.startswith(_FORBIDDEN_KEY_PREFIXES) or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES) ) def reject_credential_keys(obj: Any, *, path: str = "", subject: str = "registry") -> None: """Raise ValueError when *obj* carries a credential-shaped key at any depth.""" if isinstance(obj, dict): for key, value in obj.items(): key_path = f"{path}.{key}" if path else key if is_forbidden_key(key): raise ValueError(f"{subject} must not store credentials ({key_path})") reject_credential_keys(value, path=key_path, subject=subject) elif isinstance(obj, list): for index, item in enumerate(obj): reject_credential_keys(item, path=f"{path}[{index}]", subject=subject)