diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index f82d0acb581..08432ba20c6 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -226,30 +226,30 @@ def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, ob and non-string secrets are covered too, which is what a payload rendered straight to stdout needs. ``None`` is preserved so an unset credential still reads as unset, and lists and tuples are rebuilt element by element so a - credential nested inside one is caught as well. + credential nested inside one is caught as well. A container sitting at the + recursion limit is replaced wholesale rather than passed through, so nesting a + payload deeper than the limit hides it instead of exposing it. """ return _redact_mapping(data, 0) def _redact_mapping(data: Mapping[str, object], depth: int) -> Mapping[str, object]: - if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: - return data return {key: _redact_entry(key, value, depth) for key, value in data.items()} def _redact_entry(key: str, value: object, depth: int) -> object: if value is not None and _default_masker.is_sensitive_key(key): return REDACTED + if not isinstance(value, (Mapping, list, tuple)): + return value + if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + return REDACTED if isinstance(value, Mapping): return _redact_mapping(value, depth + 1) - if isinstance(value, (list, tuple)): - return _redact_sequence(value, depth + 1) - return value + return _redact_sequence(value, depth + 1) def _redact_sequence(values: Sequence[object], depth: int) -> Sequence[object]: - if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: - return values redacted: Final = tuple(_redact_entry("", item, depth) for item in values) return redacted if isinstance(values, tuple) else list(redacted) diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 26fb7674cb6..917ec1fced8 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -372,3 +372,24 @@ def test_redact_credentials_in_payload_reaches_credentials_nested_in_sequences() assert result["metadata"]["upstreams"][0]["aws_secret_access_key"] == "REDACTED" assert isinstance(result["metadata"]["upstreams"], tuple) assert result["messages"] == [{"role": "user", "content": "hello"}] + + +@pytest.mark.parametrize("wrap", ["mapping", "sequence"]) +def test_redact_credentials_in_payload_hides_containers_at_the_recursion_limit(wrap): + """The recursion limit exists to bound the walk, not to grant an exemption, so a caller who + buries a credential deeper than the limit must get the container hidden rather than handed + back verbatim. Nesting through lists costs depth twice as fast as nesting through mappings, + so both shapes are pushed well past the limit here.""" + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + fake_key = "sk-fake-lit6835-past-the-limit" + node = {"api_key": fake_key} + for _ in range(2 * DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER + 1): + node = {"extra_body": node} if wrap == "mapping" else {"providers": [node]} + + result = redact_credentials_in_payload({**node, "max_tokens": 17}) + + assert fake_key not in str(result) + assert "REDACTED" in str(result) + assert result["max_tokens"] == 17