"""Credential-only redaction for provider and connector diagnostics. Unlike :mod:`app.core.safe_logging`, this boundary deliberately preserves ordinary identifiers, email addresses, and line structure that an admin may need to diagnose an integration. It removes only credential-shaped values and bounds the result before it reaches an API response, database row, audit record, or log entry. """ from __future__ import annotations import re _SENSITIVE_KEY_PARTS = frozenset({ "authorization", "credential", "credentials", "cookie", "password", "secret", "signature", "token", }) _SENSITIVE_COMPACT_KEYS = frozenset({ "accesskey", "apikey", "clientsecret", "secretid", "secretkey", "xamzcredential", "xamzsecuritytoken", "xamzsignature", }) _CREDENTIAL_PATTERNS: tuple[re.Pattern[str], ...] = ( # Vendor access-key identifiers: AWS, Tencent, Aliyun, UCloud and GCP. re.compile( r"\b(?:AKIA|ASIA|AKID|LTAI|STS\.|UCloud|GOOG|NGAU)" r"[A-Za-z0-9/+=]{12,128}\b" ), # Signed-URL and query-parameter credentials. re.compile( r"(?i)(X-Amz-(?:Signature|Security-Token|Credential)|" r"Signature(?:V2|V4)?|AccessKeyId|SecurityToken|api[_-]?key|" r"access[_-]?token|bearer)\s*=\s*[^&\s\"'<>]{1,}" ), # Named fields, including Tencent's camel-case SecretId/SecretKey and # JSON / Python-repr variants. A named secret is sensitive even when a # fixture or expired credential is short, so there is no value-length # threshold here. re.compile( r"(?i)([\"']?(?:token[_-]?prefix|secret[_-]?access[_-]?key|" r"secret[_-]?(?:id|key)|client[_-]?secret|password|api[_-]?key)" r"[\"']?)\s*[:=]\s*[\"']?[^&\s\"'<>;,}\]]+[\"']?" ), # OSS canonical request echoes can contain the complete STS token. re.compile(r"(?i)(x-oss-security-token)\s*:\s*[^\s<&]{1,}"), # Header forms. Basic credentials need the same treatment as Bearer. re.compile( r"(?i)(authorization\s*:?\s*(?:Bearer|Basic))\s+" r"[A-Za-z0-9._\-+/=]{1,}" ), # Prefixless OpenAI / DashScope-style keys. re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"), # Private keys and JWTs. re.compile( r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?" r"-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL, ), re.compile(r"\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b", re.IGNORECASE), ) _LONG_BASE64_PATTERN = re.compile( r"(?:^|[^A-Za-z0-9+/=])([A-Za-z0-9+/]{120,}={0,2})" r"(?=$|[^A-Za-z0-9+/=])" ) def _replacement(match: re.Match[str]) -> str: """Preserve a useful field/header name while dropping its value.""" full = match.group(0) if match.lastindex is not None: captured = match.group(1) # The base64 pattern captures only the credential body and must keep # the boundary character around it. if len(captured) >= 120 and captured in full: return full.replace(captured, "") # The header pattern captures ``Authorization: Bearer`` / ``Basic`` # as the useful diagnostic prefix. Base64 padding in a Basic value # must not make the generic ``key=value`` branch rewrite the header # into the misleading ``Authorization: Basic=`` shape. if captured.lower().lstrip("\"'").startswith("authorization"): return f"{captured} " rest = full[len(captured):].lstrip() if rest.startswith(":"): return f"{captured}:" if rest.lower().startswith(("bearer", "basic")): return f"{captured} " if "=" in full: return f"{captured}=" return "" def redact_credentials( text: object, *, max_len: int = 300, redact_long_base64: bool = False, ) -> str: """Return a credential-safe, length-bounded diagnostic string.""" if text is None: return "" rendered = text if isinstance(text, str) else str(text) if isinstance(text, BaseException) and not rendered.strip(): rendered = type(text).__name__ for pattern in _CREDENTIAL_PATTERNS: rendered = pattern.sub(_replacement, rendered) if redact_long_base64: rendered = _LONG_BASE64_PATTERN.sub(_replacement, rendered) bounded_len = max(0, min(max_len, 10_000)) if len(rendered) > bounded_len: return rendered[:bounded_len] + "…" return rendered def is_credential_key(value: object) -> bool: """Recognize secret-bearing field names across common key styles.""" rendered = str(value).strip().lower() parts = {part for part in re.split(r"[^a-z0-9]+", rendered) if part} compact = "".join(character for character in rendered if character.isalnum()) return ( bool(parts & _SENSITIVE_KEY_PARTS) or compact in _SENSITIVE_COMPACT_KEYS or compact.endswith(("authorization", "credential", "password", "secret", "signature", "token")) )