mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
658f50663d
commit
64601fd7ae
5 changed files with 124 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue