fix(utils): redact credential kwargs from the set_verbose request line

`litellm.set_verbose = True` printed the caller's kwargs verbatim to stdout, so
`api_key` and its siblings landed in terminals and container log drains in
plaintext while the same statement's logger emission was already redacted.

Mask the kwargs at the source with a shared helper in
`litellm_core_utils/sensitive_data_masker.py`, reusing the existing
`SensitiveDataMasker` key classification and the `REDACTED` marker
`secret_redaction.py` already owns, so both debug surfaces agree.
This commit is contained in:
mateo-berri 2026-09-03 01:55:56 -07:00
parent 658f50663d
commit 64601fd7ae
5 changed files with 124 additions and 5 deletions

View file

@ -11,7 +11,7 @@ from typing import Final
from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH
_REDACTED: Final = "REDACTED"
REDACTED: Final = "REDACTED"
def _build_secret_patterns() -> "re.Pattern[str]":
@ -89,7 +89,7 @@ _SECRET_RE: Final = _build_secret_patterns()
def redact_string(value: str) -> str:
"""Scrub known secret/credential patterns from *value* and return the result."""
return _SECRET_RE.sub(_REDACTED, value)
return _SECRET_RE.sub(REDACTED, value)
_UNIX_SYSTEM_PATH: Final = r"/(?:etc|var|opt|usr|home|root|private|Users|tmp|mnt|srv)/[^\s'\"\)\]}>,]+"
@ -110,7 +110,7 @@ def redact_internal_details(value: str) -> str:
on top of redact_string(). For client-facing messages only: server logs keep this detail."""
marker_index: Final = value.find(_TRACEBACK_MARKER)
without_traceback: Final = value[:marker_index].rstrip() if marker_index != -1 else value
return _INTERNAL_DETAIL_RE.sub(_REDACTED, redact_string(without_traceback))
return _INTERNAL_DETAIL_RE.sub(REDACTED, redact_string(without_traceback))
def redact_structured_value(key: str | None, value: str) -> str:
@ -126,4 +126,4 @@ def redact_structured_value(key: str | None, value: str) -> str:
if scrubbed != value or key is None:
return scrubbed
rendered: Final = f"'{key}': '{value}'"
return _REDACTED if redact_string(rendered) != rendered else value
return REDACTED if redact_string(rendered) != rendered else value

View file

@ -4,6 +4,7 @@ from typing import Any, Final
from pydantic import BaseModel
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
from litellm.litellm_core_utils.secret_redaction import REDACTED
class SensitiveDataMasker:
@ -214,6 +215,36 @@ def mask_sensitive_keys(data: dict[str, Any], sensitive_fields: set[str]) -> dic
return masked
def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, object]:
"""Return a copy of ``data`` where every value under a credential-named key is
replaced by the shared ``REDACTED`` marker, nested mappings are recursed into,
and every other value is preserved by identity.
Sensitive-key detection is delegated to the shared :class:`SensitiveDataMasker`,
so the credential names stay in one place. Unlike
: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.
"""
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 isinstance(value, Mapping):
return _redact_mapping(value, depth + 1)
return value
# Usage example:
"""
masker = SensitiveDataMasker()

View file

@ -83,6 +83,7 @@ from litellm.constants import (
from litellm.litellm_core_utils.fallback_generalizations import (
match_capability_generalizations,
)
from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload
_CachingHandlerResponse = None
_LLMCachingHandler = None
@ -7459,7 +7460,8 @@ def print_args_passed_to_litellm(original_function, args, kwargs):
return
args_str: Final = ", ".join(map(repr, args))
kwargs_str: Final = ", ".join(f"{key}={value!r}" for key, value in kwargs.items())
redacted_kwargs: Final = redact_credentials_in_payload(kwargs)
kwargs_str: Final = ", ".join(f"{key}={value!r}" for key, value in redacted_kwargs.items())
print_verbose(
"\n",
) # new line before

View file

@ -312,3 +312,39 @@ def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves():
assert masked != plaintext
assert masked.startswith(plaintext[:4])
assert masked.endswith(plaintext[-4:])
def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret():
"""A payload rendered straight to stdout cannot afford the partial reveal
mask_credentials_in_payload leaves, so every credential-named value is replaced
whole, nested header dicts included, while ordinary params survive verbatim."""
from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload
fake_key = "sk-fake-lit6823-0000000000000000"
fake_token = "fake-azure-ad-token-0000"
result = redact_credentials_in_payload(
{
"api_key": fake_key,
"azure_ad_token": fake_token,
"aws_secret_access_key": "fake-aws-secret-0000",
"vertex_credentials": {"private_key": "fake-pem"},
"extra_headers": {"Authorization": "Bearer fake-bearer-0000", "x-request-id": "abc123"},
"model": "gpt-4o-mini",
"max_tokens": 17,
"temperature": 0.25,
"api_base": None,
}
)
assert fake_key not in str(result)
assert fake_token not in str(result)
assert "fake-aws-secret-0000" not in str(result)
assert "fake-pem" not in str(result)
assert "fake-bearer-0000" not in str(result)
assert result["api_key"] == "REDACTED"
assert result["extra_headers"]["Authorization"] == "REDACTED"
assert result["extra_headers"]["x-request-id"] == "abc123"
assert result["model"] == "gpt-4o-mini"
assert result["max_tokens"] == 17
assert result["temperature"] == 0.25
assert result["api_base"] is None

View file

@ -5805,3 +5805,53 @@ class TestIsVisionExplicitlyDisabled:
is_vision_explicitly_disabled("fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731") is True
)
assert is_vision_explicitly_disabled("anthropic/claude-sonnet-4-5") is False
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."""
FAKE_API_KEY: Final = "sk-fake-lit6823-0000000000000000"
def _verbose_stdout(self, capsys, monkeypatch, **kwargs) -> str:
monkeypatch.setattr(litellm, "set_verbose", True)
monkeypatch.setattr("litellm._logging.set_verbose", True)
capsys.readouterr()
litellm.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "hello"}],
mock_response="hi",
**kwargs,
)
captured: Final = capsys.readouterr()
return captured.out + captured.err
def test_api_key_never_reaches_stdout(self, capsys, monkeypatch):
printed: Final = self._verbose_stdout(capsys, monkeypatch, api_key=self.FAKE_API_KEY)
assert "Request to litellm:" 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(
capsys,
monkeypatch,
api_key=self.FAKE_API_KEY,
extra_headers={"Authorization": "Bearer fake-lit6823-header", "x-request-id": "abc123"},
)
assert "fake-lit6823-header" not in printed
assert "'Authorization': 'REDACTED'" in printed
assert "'x-request-id': 'abc123'" in printed
def test_ordinary_params_still_printed(self, capsys, monkeypatch):
printed: Final = self._verbose_stdout(
capsys, monkeypatch, api_key=self.FAKE_API_KEY, max_tokens=17, temperature=0.25
)
assert "model='gpt-3.5-turbo'" in printed
assert "max_tokens=17" in printed
assert "temperature=0.25" in printed