fix(proxy): keep request-body credentials out of stored spend-log requests (#43635)

* fix(proxy): keep request-body aws credentials out of stored spend-log requests

* 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.

* fix(proxy): keep request identifiers named like keys in stored spend-log requests

* refactor(proxy): drop the AWS-only snapshot exclusion now that spend-log redaction is name-based

* refactor(proxy): use SensitiveDataMasker's key classification without an exclusion list

* refactor(proxy): always redact credential-named fields in stored spend-log payloads
This commit is contained in:
yucheng-berri 2026-09-30 10:31:48 -07:00 • committed by GitHub
parent 025292e75b
commit 7204942756
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 115 additions and 6 deletions

View file

@ -44,6 +44,7 @@ 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
@ -1083,6 +1084,11 @@ 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"}))
def _is_request_body_credential(key: str, value: object) -> bool:
return isinstance(value, str) and _REQUEST_BODY_CREDENTIAL_MASKER.is_sensitive_key(key)
def _sanitize_request_body_for_spend_logs_payload(
@ -1094,8 +1100,9 @@ def _sanitize_request_body_for_spend_logs_payload(
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.
Also strips keys listed in _SENSITIVE_REQUEST_BODY_KEYS (e.g. secret_fields
which contains raw HTTP headers including Authorization tokens).
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 replaces string values under keys
SensitiveDataMasker classifies as credentials with REDACTED_BY_LITELM_STRING.
"""
from litellm.constants import (
LITELLM_TRUNCATED_PAYLOAD_FIELD,
@ -1152,7 +1159,11 @@ def _sanitize_request_body_for_spend_logs_payload(
return value
return value
return {k: _sanitize_value(v) for k, v in request_body.items() if k not in _SENSITIVE_REQUEST_BODY_KEYS}
return {
k: REDACTED_BY_LITELM_STRING if _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
}
# Quoted-key form: ``"input"`` / ``'messages'`` / ``"prompt"`` followed by

View file

@ -612,7 +612,7 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types():
request_body = {
"text": long_string,
"number": 42,
"nested": {"list": ["short", long_string], "dict": {"key": long_string}},
"nested": {"list": ["short", long_string], "dict": {"value": long_string}},
}
sanitized = _sanitize_request_body_for_spend_logs_payload(request_body)
@ -631,7 +631,7 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types():
assert sanitized["number"] == 42
assert sanitized["nested"]["list"][0] == "short"
assert len(sanitized["nested"]["list"][1]) == expected_length
assert len(sanitized["nested"]["dict"]["key"]) == expected_length
assert len(sanitized["nested"]["dict"]["value"]) == expected_length
def test_sanitize_request_body_for_spend_logs_payload_uses_runtime_env_override(
@ -1207,7 +1207,7 @@ def test_get_logging_payload_placeholders_the_metadata_copied_into_the_stored_re
stored_request_body: Final = json.loads(payload["proxy_server_request"])
assert stored_request_body["metadata"]["model_group"] == expected_stored_model_group
assert stored_request_body["metadata"]["error_information"]["error_message"] == expected_stored_error_message
assert stored_request_body["metadata"]["user_api_key"] == "sk-test"
assert stored_request_body["metadata"]["user_api_key"] == REDACTED_BY_LITELM_STRING
assert ("medical records" in payload["proxy_server_request"]) == bool(deployment_info)
@ -2691,6 +2691,104 @@ def test_sanitize_request_body_strips_secret_fields():
assert sanitized["messages"] == [{"role": "user", "content": "hi"}]
@patch("litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs")
def test_proxy_server_request_payload_strips_nested_aws_credentials(mock_should_store: MagicMock) -> None:
mock_should_store.return_value = True
credentials: Final = {
"aws_access_key_id": "AKIA-canary",
"aws_secret_access_key": "secret-canary",
"aws_session_token": "token-canary",
"aws_web_identity_token": "wit-canary",
}
tool_parameters: Final = {"type": "object", "properties": {"aws_secret_access_key": {"type": "string"}}}
litellm_params: Final = {
"proxy_server_request": {
"body": {
"model": "bedrock-claude",
"messages": [{"role": "user", "content": "hello"}],
"fallbacks": [{"model": "bedrock-b", "aws_region_name": "us-west-2", **credentials}],
"extra_body": {"aws_role_name": "arn:aws:iam::123456789012:role/r", **credentials},
"tools": [{"type": "function", "function": {"name": "f", "parameters": tool_parameters}}],
**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)
masked: Final = dict.fromkeys(credentials, REDACTED_BY_LITELM_STRING)
assert parsed["fallbacks"] == [{"model": "bedrock-b", "aws_region_name": "us-west-2", **masked}]
assert parsed["extra_body"] == {"aws_role_name": "arn:aws:iam::123456789012:role/r", **masked}
assert {name: parsed[name] for name in credentials} == masked
assert parsed["tools"][0]["function"]["parameters"] == tool_parameters
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",
}
metadata: Final = {"user_api_key": "custom-auth-raw-key", "requester_ip_address": "10.0.0.1"}
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,
"prompt_cache_key": "user-123-cache",
"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": 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"] == {"user_api_key": REDACTED_BY_LITELM_STRING, "requester_ip_address": "10.0.0.1"}
assert parsed["max_tokens"] == 10
assert parsed["prompt_cache_key"] == REDACTED_BY_LITELM_STRING
assert parsed["messages"] == [{"role": "user", "content": "hello"}]
def test_sanitize_response_redacts_credential_named_fields() -> None:
response: Final = {"access_token": "canary-oauth-token", "usage": {"prompt_tokens": 1}}
assert _sanitize_request_body_for_spend_logs_payload({"response": response}) == {
"response": {"access_token": REDACTED_BY_LITELM_STRING, "usage": {"prompt_tokens": 1}}
}
@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):
"""