From f298c9f6960ba53fe8ab045f7a3b362ef7e383ca Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Mon, 28 Sep 2026 14:39:53 -0700 Subject: [PATCH] fix(proxy): keep request-body aws credentials out of stored spend-log requests --- litellm/proxy/litellm_pre_call_utils.py | 2 ++ .../spend_tracking/spend_tracking_utils.py | 14 ++++++-- litellm/types/llms/bedrock.py | 3 ++ .../test_spend_tracking_utils.py | 36 +++++++++++++++++++ .../proxy/test_litellm_pre_call_utils.py | 31 ++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index d48451de6b1..9ab651748df 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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 diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 1c51fb21d6e..ec3f57d3bed 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -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 diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index e4c41c3ee5b..41327c95ce2 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -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): diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 00223f192ec..204cc427829 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -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): """ diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 84266325226..7c5ce0c6dc3 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -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(