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

This commit is contained in:
Yucheng He 2026-09-28 14:39:53 -07:00
parent 7e383c9f6a
commit f298c9f696
5 changed files with 83 additions and 3 deletions

View file

@ -186,6 +186,7 @@ def _sanitize_for_log(value: object) -> str:
from litellm.router import Router
from litellm.secret_managers.main import get_secret_bool
from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS
from litellm.types.llms.bedrock import AWS_CREDENTIAL_VALUE_PARAM_KEYS
from litellm.types.services import ServiceTypes
from litellm.types.utils import (
CustomPricingLiteLLMParams,
@ -1964,6 +1965,7 @@ def refresh_proxy_server_request_body_snapshot(
frozenset({"secret_fields", "proxy_server_request", "litellm_logging_obj"})
| _TRANSPORT_ONLY_CREDENTIAL_KEYS
| _CALLBACK_CREDENTIAL_KEYS
| AWS_CREDENTIAL_VALUE_PARAM_KEYS
)
body: Final = { # mutable-ok: audit JSON serialization requires a dict with shared nested messages
k: v for k, v in data.items() if k not in _body_snapshot_exclude

View file

@ -48,6 +48,7 @@ from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsR
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,
@ -1094,8 +1095,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 masks string values of AWS
credential keys with REDACTED_BY_LITELM_STRING.
"""
from litellm.constants import (
LITELLM_TRUNCATED_PAYLOAD_FIELD,
@ -1152,7 +1154,13 @@ 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 k in AWS_CREDENTIAL_VALUE_PARAM_KEYS and isinstance(v, str)
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

@ -1132,6 +1132,9 @@ class AwsAuthParams(BaseModel):
AWS_AUTH_PARAM_KEYS: Final[tuple[str, ...]] = tuple(AwsAuthParams.model_fields)
AWS_CREDENTIAL_VALUE_PARAM_KEYS: Final = frozenset(
{"aws_access_key_id", "aws_secret_access_key", "aws_session_token", "aws_web_identity_token"}
)
class BedrockCreateBatchRequest(TypedDict, total=False):

View file

@ -2691,6 +2691,42 @@ 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_excludes_secret_fields(mock_should_store):
"""

View file

@ -895,6 +895,37 @@ def test_body_snapshot_excludes_team_callback_credentials() -> None:
}, proxy_request
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_body_snapshot_excludes_aws_credentials() -> None:
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
from litellm.types.llms.bedrock import AWS_CREDENTIAL_VALUE_PARAM_KEYS
aws_credentials: Final = {name: f"canary-{name}" for name in AWS_CREDENTIAL_VALUE_PARAM_KEYS}
data: Final = {
"model": "bedrock-claude",
"messages": [{"role": "user", "content": "hello"}],
"aws_region_name": "us-east-1",
"aws_role_name": "arn:aws:iam::123456789012:role/bedrock",
**aws_credentials,
}
updated: Final = await add_litellm_data_to_request(
data=data,
request=_make_request_mock("/v1/chat/completions", {"Content-Type": "application/json"}),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", user_id="test-user"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
snapshot_body: Final = updated["proxy_server_request"]["body"]
assert "canary-" not in json.dumps(snapshot_body, default=str)
assert snapshot_body["aws_region_name"] == "us-east-1"
assert snapshot_body["aws_role_name"] == "arn:aws:iam::123456789012:role/bedrock"
assert snapshot_body["messages"] == [{"role": "user", "content": "hello"}]
assert {name: updated[name] for name in AWS_CREDENTIAL_VALUE_PARAM_KEYS} == aws_credentials
@pytest.mark.asyncio
@pytest.mark.parametrize("pre_call_ran", [False, True])
async def test_post_guardrail_snapshot_preserves_logging_only_masking_in_spend_logs(