fix(proxy): redact every credential-named request-body field in stored spend-log requests

Replace the hard-coded AWS key check in the spend-log request-body sanitizer with
SensitiveDataMasker's key classification, so Azure, Vertex, watsonx, OCI, GigaChat,
Gemini and header credentials are redacted too. Proxy-stamped key identity metadata
is kept.
This commit is contained in:
Yucheng He 2026-09-29 15:04:17 -07:00
parent f298c9f696
commit 2b75e89231
2 changed files with 84 additions and 8 deletions

View file

@ -44,11 +44,11 @@ from litellm.litellm_core_utils.litellm_logging import (
)
from litellm.litellm_core_utils.ptu_pricing import azure_spillover
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.llms.bedrock import AWS_CREDENTIAL_VALUE_PARAM_KEYS
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params
from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
@ -61,6 +61,7 @@ from litellm.types.utils import (
StandardLoggingModelInformation,
StandardLoggingPayload,
StandardLoggingPayloadErrorInformation,
StandardLoggingUserAPIKeyMetadata,
StandardLoggingVectorStoreRequest,
VectorStoreSearchResponse,
)
@ -1084,20 +1085,32 @@ def _get_messages_for_spend_logs_payload(
_SENSITIVE_REQUEST_BODY_KEYS: Final = frozenset({"secret_fields"})
_REQUEST_BODY_CREDENTIAL_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset({"apikey"}))
_PROXY_KEY_IDENTITY_FIELDS: Final = frozenset(StandardLoggingUserAPIKeyMetadata.__annotations__) | {"user_api_key"}
def _is_request_body_credential(key: str, value: object) -> bool:
return (
isinstance(value, str)
and key not in _PROXY_KEY_IDENTITY_FIELDS
and _REQUEST_BODY_CREDENTIAL_MASKER.is_sensitive_key(key)
)
def _sanitize_request_body_for_spend_logs_payload(
request_body: Mapping[str, object],
visited: set | None = None,
max_string_length_prompt_in_db: int | None = None,
redact_credentials: bool = False,
) -> dict:
"""
Recursively sanitize request body to prevent logging large base64 strings or other large values.
Truncates strings longer than MAX_STRING_LENGTH_PROMPT_IN_DB characters and handles nested dictionaries.
At every nesting level, also strips keys listed in _SENSITIVE_REQUEST_BODY_KEYS (e.g. secret_fields,
which holds raw HTTP headers including Authorization tokens) and masks string values of AWS
credential keys with REDACTED_BY_LITELM_STRING.
which holds raw HTTP headers including Authorization tokens). With ``redact_credentials``, string
values under keys SensitiveDataMasker classifies as credentials are replaced with
REDACTED_BY_LITELM_STRING.
"""
from litellm.constants import (
LITELLM_TRUNCATED_PAYLOAD_FIELD,
@ -1117,7 +1130,9 @@ def _sanitize_request_body_for_spend_logs_payload(
def _sanitize_value(value: object) -> object:
if isinstance(value, Mapping):
return _sanitize_request_body_for_spend_logs_payload(value, visited, max_string_length_prompt_in_db)
return _sanitize_request_body_for_spend_logs_payload(
value, visited, max_string_length_prompt_in_db, redact_credentials
)
elif isinstance(value, list):
return [_sanitize_value(item) for item in value]
elif isinstance(value, str):
@ -1155,9 +1170,7 @@ def _sanitize_request_body_for_spend_logs_payload(
return value
return {
k: REDACTED_BY_LITELM_STRING
if k in AWS_CREDENTIAL_VALUE_PARAM_KEYS and isinstance(v, str)
else _sanitize_value(v)
k: REDACTED_BY_LITELM_STRING if redact_credentials and _is_request_body_credential(k, v) else _sanitize_value(v)
for k, v in request_body.items()
if k not in _SENSITIVE_REQUEST_BODY_KEYS
}
@ -1575,7 +1588,7 @@ def _get_proxy_server_request_for_spend_logs_payload(
_request_body = _convert_mapping_to_json_serializable(without_classifier_audit(_request_body))
perform_redaction(model_call_details=_request_body, result=None)
_request_body = _sanitize_request_body_for_spend_logs_payload(_request_body)
_request_body = _sanitize_request_body_for_spend_logs_payload(_request_body, redact_credentials=True)
_request_body_json_str: Final = safe_dumps(_request_body)
if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str:
verbose_proxy_logger.info(

View file

@ -2727,6 +2727,69 @@ def test_proxy_server_request_payload_strips_nested_aws_credentials(mock_should_
assert parsed["messages"] == [{"role": "user", "content": "hello"}]
@patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs")
def test_proxy_server_request_payload_redacts_provider_credentials(mock_should_store: MagicMock) -> None:
mock_should_store.return_value = True
credentials: Final = {
"azure_password": "canary-azure-password",
"client_secret": "canary-client-secret",
"azure_ad_token": "canary-azure-ad-token",
"vertex_credentials": "canary-vertex-credentials",
"s3_secret_access_key": "canary-s3-secret",
"token": "canary-watsonx-token",
"apikey": "canary-watsonx-apikey",
"zen_api_key": "canary-zen-api-key",
"gemini_api_key": "canary-gemini-api-key",
"gigachat_access_token": "canary-gigachat-token",
"oci_key": "canary-oci-key",
}
identity_metadata: Final = {
"user_api_key": "hashed-key",
"user_api_key_alias": "team-a-key",
"user_api_key_team_id": "team-a",
"user_api_key_user_id": "user-a",
}
tool_parameters: Final = {"type": "object", "properties": {"client_secret": {"type": "string"}}}
litellm_params: Final = {
"proxy_server_request": {
"body": {
"model": "azure-gpt",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 10,
"vertex_credentials": {"private_key": "canary-private-key", "client_email": "sa@example.com"},
"extra_headers": {"Authorization": "Bearer canary-extra-header"},
"tools": [
{"type": "function", "function": {"name": "f", "parameters": tool_parameters}},
{"type": "mcp", "server_url": "https://mcp.example.com", "headers": {"Authorization": "canary-mcp"}},
],
"fallbacks": [{"model": "azure-b", **credentials}],
"metadata": identity_metadata,
**credentials,
}
}
}
parsed: Final = json.loads(
_get_proxy_server_request_for_spend_logs_payload(metadata={}, litellm_params=litellm_params, kwargs={})
)
assert "canary" not in json.dumps(parsed)
assert {name: parsed[name] for name in credentials} == dict.fromkeys(credentials, REDACTED_BY_LITELM_STRING)
assert parsed["vertex_credentials"] == REDACTED_BY_LITELM_STRING
assert parsed["extra_headers"] == {"Authorization": REDACTED_BY_LITELM_STRING}
assert parsed["tools"][0]["function"]["parameters"] == tool_parameters
assert parsed["tools"][1]["server_url"] == "https://mcp.example.com"
assert parsed["metadata"] == identity_metadata
assert parsed["max_tokens"] == 10
assert parsed["messages"] == [{"role": "user", "content": "hello"}]
def test_sanitize_request_body_keeps_credential_named_fields_by_default() -> None:
response: Final = {"system_fingerprint": "fp_123", "usage": {"prompt_tokens": 1}}
assert _sanitize_request_body_for_spend_logs_payload({"response": response}) == {"response": response}
@patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs")
def test_proxy_server_request_payload_excludes_secret_fields(mock_should_store):
"""