From f46074664e7ae8bfc142c33d266704b92e59ccef Mon Sep 17 00:00:00 2001 From: Vedanshu Joshi <40860706+Vedanshu7@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:42:35 -0400 Subject: [PATCH] fix(llm translation): redact Gemini API key from URL query params in error traces (#24943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proxy): use actual request start_time for failed spend logs async_post_call_failure_hook was calling datetime.now() for both start_time and end_time, making every failed request show Duration: 0.000s. litellm_logging_obj (already fetched in the same method for trace ID propagation) carries the real request start_time — use it as actual_start_time with a datetime.now() fallback when absent. Add two regression tests covering the fix and the fallback path. Fixes #24888 * fix(llm translation): redact Gemini API key from URL query params in error traces Gemini API requests authenticate via a ?key= URL query param. When a provider call fails, httpx.Response.raise_for_status() embeds the full URL in the error message, leaking the key in exception traces and logs. Changes: - Extract secret-redaction logic from litellm/_logging.py into a new public utility module litellm/litellm_core_utils/secret_redaction.py, exposing redact_string() as a proper public API instead of a private helper - Add (?<=[?&])key=[^\s&'"]{8,} pattern to _SECRET_RE so ?key=VALUE and &key=VALUE fragments are caught by the existing SecretRedactionFilter - Apply redact_string() to error_str in exception_mapping_utils.py so the key is also stripped from the mapped exception message surfaced to callers - Add 5 regression tests covering: ?key=, &key=, short-value no-op, httpx raise_for_status path, and end-to-end logger output - Keep _redact_string = redact_string alias in _logging.py for backward compat Fixes #24902 * revert: undo start_time fix for failed spend logs * fix: gate exception redaction on _ENABLE_SECRET_REDACTION opt-out flag - Apply redact_string() conditionally in exception_mapping_utils.py, matching the same _ENABLE_SECRET_REDACTION guard used by SecretRedactionFilter so that LITELLM_DISABLE_REDACT_SECRETS=true is honoured for exception messages - Rewrite test_redact_string_applied_to_httpx_error_message to use pytest.raises so assertions cannot be silently skipped if raise_for_status() doesn't raise - Add test_exception_mapping_respects_redaction_opt_out to verify the flag is respected end-to-end through exception_type() --- litellm/_logging.py | 5 +- .../exception_mapping_utils.py | 15 ++++- .../litellm_core_utils/secret_redaction.py | 66 +++++++++++++++++++ tests/test_litellm/test_secret_redaction.py | 13 ++-- 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 litellm/litellm_core_utils/secret_redaction.py diff --git a/litellm/_logging.py b/litellm/_logging.py index d072cc549d0..73a08469838 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -1,14 +1,14 @@ import ast import logging import os -import re import sys from datetime import datetime from logging import Formatter -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads +from litellm.litellm_core_utils.secret_redaction import redact_string set_verbose = False @@ -21,7 +21,6 @@ _ENABLE_SECRET_REDACTION = ( os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true" ) -_REDACTED = "REDACTED" def _build_secret_patterns() -> re.Pattern: diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 5a7d4e33b6d..cc665231d15 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -6,7 +6,8 @@ from typing import Any, Optional import httpx import litellm -from litellm._logging import _redact_string, verbose_logger +from litellm._logging import _ENABLE_SECRET_REDACTION, _redact_string, verbose_logger +from litellm.litellm_core_utils.secret_redaction import redact_string from litellm.types.utils import LlmProviders from ..exceptions import ( @@ -261,10 +262,18 @@ def exception_type( # type: ignore # noqa: PLR0915 original_exception=original_exception ) try: - error_str = str(original_exception) + error_str = ( + redact_string(str(original_exception)) + if _ENABLE_SECRET_REDACTION + else str(original_exception) + ) if model: if hasattr(original_exception, "message"): - error_str = str(original_exception.message) + error_str = ( + redact_string(str(original_exception.message)) + if _ENABLE_SECRET_REDACTION + else str(original_exception.message) + ) if isinstance(original_exception, BaseException): exception_type = type(original_exception).__name__ else: diff --git a/litellm/litellm_core_utils/secret_redaction.py b/litellm/litellm_core_utils/secret_redaction.py new file mode 100644 index 00000000000..4473c6539c6 --- /dev/null +++ b/litellm/litellm_core_utils/secret_redaction.py @@ -0,0 +1,66 @@ +""" +Credential/secret redaction utilities. + +This module owns the compiled regex and the public `redact_string` helper so +that any part of the codebase (logging, exception mapping, etc.) can scrub +secrets from strings without depending on the logging-configuration module. +""" + +import re +from typing import List + +_REDACTED = "REDACTED" + + +def _build_secret_patterns() -> "re.Pattern[str]": + patterns: List[str] = [ + # AWS access key IDs + r"(?:AKIA|ASIA)[0-9A-Z]{16}", + # AWS secrets / session tokens / access key IDs (key=value) + r"(?:aws_secret_access_key|aws_session_token|aws_access_key_id)" + r"\s*[:=]\s*[A-Za-z0-9/+=]{20,}", + # Bearer tokens (OAuth, JWT, etc.) + r"Bearer\s+[A-Za-z0-9\-._~+/]{10,}=*", + # Basic auth headers + r"Basic\s+[A-Za-z0-9+/]{10,}={0,2}", + # OpenAI / Anthropic sk- prefixed keys + r"sk-[A-Za-z0-9\-_]{20,}", + # Generic api_key / api-key / apikey (handles 'key': 'value' dict repr) + r"(?:api[_-]?key)['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]{8,}", + # x-api-key / api-key header values (handles 'key': 'value' dict repr) + r"(?:x-api-key|api-key)['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+", + # Anthropic internal header keys + r"x-ak-[A-Za-z0-9\-_]{20,}", + # Google API keys (bare key value) + r"AIza[0-9A-Za-z\-_]{35}", + # URL query-param key=VALUE (e.g. ?key=AIza... or &key=...) — catches the + # full "key=" fragment so the value is redacted regardless of format. + r"(?<=[?&])key=[^\s&'\"]{8,}", + # Password / secret params (handles key=value and 'key': 'value') + r"\w*(?:password|passwd|client_secret|secret_key|_secret)" + r"['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+", + # Database connection string credentials (scheme://user:pass@host) + r"(?<=://)[^\s'\"]*:[^\s'\"@]+(?=@)", + # Databricks personal access tokens + r"dapi[0-9a-f]{32}", + # ── Key-name-based redaction ── + # Catches secrets inside dicts/config dumps by matching on the KEY name + # regardless of what the value looks like. + # e.g. 'master_key': 'any-value-here', "database_url": "postgres://..." + r"(?:master_key|database_url|db_url|connection_string|" + r"private_key|signing_key|encryption_key|" + r"auth_token|access_token|refresh_token|" + r"slack_webhook_url|webhook_url|" + r"database_connection_string|" + r"huggingface_token|jwt_secret)" + r"""['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+""", + ] + return re.compile("|".join(patterns), re.IGNORECASE) + + +_SECRET_RE = _build_secret_patterns() + + +def redact_string(value: str) -> str: + """Scrub known secret/credential patterns from *value* and return the result.""" + return _SECRET_RE.sub(_REDACTED, value) diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 6cbb2fd7b8f..5c9b3b37dfc 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -7,13 +7,12 @@ import pytest from litellm._logging import ( JsonFormatter, - _redact_string, _secret_filter, - _setup_json_exception_handlers, verbose_logger, verbose_proxy_logger, verbose_router_logger, ) +from litellm.litellm_core_utils.secret_redaction import redact_string SECRET = "sk-proj-abc123def456ghi789jklmnopqrst" @@ -57,12 +56,12 @@ def test_redact_string_catches_secret_patterns(): SECRET, ] for secret in cases: - result = _redact_string("msg: " + secret) + result = redact_string("msg: " + secret) assert secret not in result, f"{secret!r} was not redacted" assert "REDACTED" in result normal = "Loaded model gpt-4 with 3 replicas on us-east-1" - assert _redact_string(normal) == normal + assert redact_string(normal) == normal def test_filter_redacts_secrets_in_logger_output(): @@ -155,7 +154,7 @@ def test_x_api_key_regex_does_not_consume_json_delimiters(): """x-api-key pattern must stop before closing quotes/braces so JSON stays valid.""" # Simulates a JSON log line containing an x-api-key header value json_line = '{"headers": {"x-api-key": "secret123"}, "status": 200}' - result = _redact_string(json_line) + result = redact_string(json_line) # The secret value should be redacted assert "secret123" not in result assert "REDACTED" in result @@ -234,12 +233,12 @@ def test_key_name_redaction_catches_secrets_in_dict_repr(): "'slack_webhook_url': 'https://hooks.slack.com/services/T00/B00/xxx'", ] for secret_line in cases: - result = _redact_string(secret_line) + result = redact_string(secret_line) assert "REDACTED" in result, f"Key-name redaction missed: {secret_line!r}" # Non-sensitive keys should NOT be redacted safe = "'enable_jwt_auth': True, 'store_model_in_db': True" - assert _redact_string(safe) == safe + assert redact_string(safe) == safe def test_key_name_redaction_in_general_settings_dict():