From 0a62195db25dabfd980fbd0fa50d5b0f4a33f624 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:11:32 -0700 Subject: [PATCH] fix(utils): redact credentials nested inside lists and tuples redact_credentials_in_payload only recursed into mappings, so a credential-named key one level inside a list or tuple, the shape extra_body and metadata routinely carry, still reached stdout under set_verbose. Rebuild sequences element by element too, keeping the container's own type so the printed repr is unchanged apart from the secret. --- .../sensitive_data_masker.py | 15 ++++++-- .../test_sensitive_data_masker.py | 24 +++++++++++++ tests/test_litellm/test_utils.py | 36 ++++++++++++------- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 149ec4d5365..f82d0acb581 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, Final from pydantic import BaseModel @@ -225,8 +225,8 @@ def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, ob :func:`mask_credentials_in_payload`, no prefix or suffix of the secret survives 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 non-mapping containers are left alone so the caller's - ``repr`` is unchanged. + reads as unset, and lists and tuples are rebuilt element by element so a + credential nested inside one is caught as well. """ return _redact_mapping(data, 0) @@ -242,9 +242,18 @@ def _redact_entry(key: str, value: object, depth: int) -> object: 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 +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) + + # Usage example: """ masker = SensitiveDataMasker() 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 690a5fa79d9..26fb7674cb6 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 @@ -348,3 +348,27 @@ def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret(): assert result["max_tokens"] == 17 assert result["temperature"] == 0.25 assert result["api_base"] is None + + +def test_redact_credentials_in_payload_reaches_credentials_nested_in_sequences(): + """Free-form kwargs like extra_body and metadata routinely carry lists of dicts, so a + credential hiding one level inside a list or tuple must be replaced too, while the + surrounding container keeps its type and every ordinary element stays verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + result = redact_credentials_in_payload( + { + "extra_body": {"providers": [{"name": "openai", "api_key": "sk-fake-lit6823-in-a-list"}]}, + "metadata": {"upstreams": ({"aws_secret_access_key": "fake-aws-in-a-tuple"},)}, + "messages": [{"role": "user", "content": "hello"}], + } + ) + + assert "sk-fake-lit6823-in-a-list" not in str(result) + assert "fake-aws-in-a-tuple" not in str(result) + assert result["extra_body"]["providers"][0]["api_key"] == "REDACTED" + assert result["extra_body"]["providers"][0]["name"] == "openai" + assert isinstance(result["extra_body"]["providers"], list) + assert result["metadata"]["upstreams"][0]["aws_secret_access_key"] == "REDACTED" + assert isinstance(result["metadata"]["upstreams"], tuple) + assert result["messages"] == [{"role": "user", "content": "hello"}] diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 142511b161c..59d5902e138 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -5808,14 +5808,15 @@ class TestIsVisionExplicitlyDisabled: class TestVerboseRequestLineRedaction: - """`litellm.set_verbose = True` echoes the caller's kwargs to stdout, so a credential - kwarg lands in whatever collects stdout: a terminal, a container log drain, a CI job - log. Credential-named kwargs must not survive that echo, while ordinary params still - must, or the line stops telling the developer what they called.""" + """`litellm.set_verbose = True` echoes the caller's kwargs back as a `litellm.completion(...)` + line on stdout, so a credential kwarg lands in whatever collects stdout: a terminal, a + container log drain, a CI job log. Credential-named kwargs must not survive that echo, + at any nesting depth, while ordinary params still must, or the line stops telling the + developer what they called.""" FAKE_API_KEY: Final = "sk-fake-lit6823-0000000000000000" - def _verbose_stdout(self, capsys, monkeypatch, **kwargs) -> str: + def _verbose_request_line(self, capsys, monkeypatch, **kwargs) -> str: monkeypatch.setattr(litellm, "set_verbose", True) monkeypatch.setattr("litellm._logging.set_verbose", True) capsys.readouterr() @@ -5826,17 +5827,17 @@ class TestVerboseRequestLineRedaction: **kwargs, ) captured: Final = capsys.readouterr() - return captured.out + captured.err + return "\n".join(line for line in (captured.out + captured.err).splitlines() if "litellm.completion(" in line) - def test_api_key_never_reaches_stdout(self, capsys, monkeypatch): - printed: Final = self._verbose_stdout(capsys, monkeypatch, api_key=self.FAKE_API_KEY) + def test_api_key_never_reaches_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line(capsys, monkeypatch, api_key=self.FAKE_API_KEY) - assert "Request to litellm:" in printed + assert "litellm.completion(" in printed assert self.FAKE_API_KEY not in printed assert "api_key='REDACTED'" in printed - def test_credential_headers_never_reach_stdout(self, capsys, monkeypatch): - printed: Final = self._verbose_stdout( + def test_credential_headers_never_reach_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line( capsys, monkeypatch, api_key=self.FAKE_API_KEY, @@ -5847,8 +5848,19 @@ class TestVerboseRequestLineRedaction: assert "'Authorization': 'REDACTED'" in printed assert "'x-request-id': 'abc123'" in printed + def test_credentials_nested_in_a_list_never_reach_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line( + capsys, + monkeypatch, + api_key=self.FAKE_API_KEY, + extra_body={"providers": [{"name": "openai", "api_key": "sk-fake-lit6823-nested"}]}, + ) + + assert "sk-fake-lit6823-nested" not in printed + assert "'name': 'openai'" in printed + def test_ordinary_params_still_printed(self, capsys, monkeypatch): - printed: Final = self._verbose_stdout( + printed: Final = self._verbose_request_line( capsys, monkeypatch, api_key=self.FAKE_API_KEY, max_tokens=17, temperature=0.25 )