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.
This commit is contained in:
mateo-berri 2026-09-03 02:11:32 -07:00
parent 64601fd7ae
commit 0a62195db2
3 changed files with 60 additions and 15 deletions

View file

@ -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()

View file

@ -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"}]

View file

@ -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
)