mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
fix(logging): keep a log extra only after inspecting every value it holds
The filter kept an extra as its original object whenever the plain and scrubbed safe_dumps renderings matched, but safe_dumps skips what it cannot render (non-string dict keys, anything past its depth limit, fields a repr hides), so a secret in those places rode the untouched object past the litellm_redacted stamp. The filter now walks JSON-native shapes itself (strings, scalars, string-keyed dicts, lists and tuples) and keeps the original only when every value it holds comes back unchanged from the redactor; anything else is handed to safe_dumps and the record carries the scrubbed JSON shape the formatter would have rendered
This commit is contained in:
parent
04eae31769
commit
d5570d04d9
2 changed files with 65 additions and 7 deletions
|
|
@ -13,6 +13,7 @@ from urllib.parse import unquote
|
|||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
DEFAULT_MAX_RECURSE_DEPTH,
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD,
|
||||
LITELLM_TRUNCATION_STDOUT_SAFEGUARD_NOTE,
|
||||
MAX_BASE64_LENGTH_STDOUT_LOG,
|
||||
|
|
@ -88,11 +89,25 @@ def _is_redacted(record: logging.LogRecord) -> bool:
|
|||
return getattr(record, _REDACTED_RECORD_ATTR, False) is True
|
||||
|
||||
|
||||
def _is_secret_free(key: str | None, value: object, depth: int) -> bool:
|
||||
if depth > DEFAULT_MAX_RECURSE_DEPTH:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return _redact_structured_value(key, value) == value
|
||||
if isinstance(value, _UNREDACTED_SCALAR_TYPES):
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return all(isinstance(k, str) and _is_secret_free(k, v, depth + 1) for k, v in value.items())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return all(_is_secret_free(key, item, depth + 1) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _redact_extra_value(key: str, value: object) -> object:
|
||||
if _is_secret_free(key, value, 1):
|
||||
return value
|
||||
try:
|
||||
rendered: Final = safe_dumps({key: value})
|
||||
scrubbed: Final = safe_dumps({key: value}, value_transform=_redact_structured_value)
|
||||
return value if scrubbed == rendered else json.loads(scrubbed)[key]
|
||||
return json.loads(safe_dumps({key: value}, value_transform=_redact_structured_value))[key]
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return _redact_string(str(value))
|
||||
|
||||
|
|
|
|||
|
|
@ -1029,17 +1029,23 @@ def test_unserializable_extra_never_breaks_the_filter(monkeypatch, extra):
|
|||
class _RequestExtra:
|
||||
model: str
|
||||
attempt: int
|
||||
api_key: str = dataclasses.field(default="", repr=False)
|
||||
|
||||
|
||||
def _nest(value: object, levels: int) -> object:
|
||||
return value if levels == 0 else _nest([value], levels - 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
(
|
||||
("gpt-4o", 2),
|
||||
{"gpt-4o", "gpt-4o-mini"},
|
||||
{"models": ("gpt-4o", "gpt-4o-mini")},
|
||||
_RequestExtra(model="gpt-4o", attempt=2),
|
||||
["gpt-4o", None, 1.5],
|
||||
{"models": ("gpt-4o", "gpt-4o-mini"), "attempt": 2},
|
||||
{"model": "gpt-4o", "status": "ok"},
|
||||
_nest("gpt-4o", 99),
|
||||
),
|
||||
ids=("tuple", "set", "nested_tuple", "dataclass"),
|
||||
ids=("tuple", "list", "nested_tuple", "dict", "deep_list"),
|
||||
)
|
||||
def test_secret_free_extra_keeps_its_original_object(monkeypatch, extra):
|
||||
"""A host application's own handler on a litellm logger reads extras by type, so a
|
||||
|
|
@ -1073,6 +1079,43 @@ def test_extra_that_carried_a_secret_comes_back_scrubbed(monkeypatch, extra):
|
|||
assert "REDACTED" in rendered
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
(
|
||||
{1: "sk-1234567890abcdefghij"},
|
||||
{"model": {1: "sk-1234567890abcdefghij"}},
|
||||
_nest("sk-1234567890abcdefghij", 101),
|
||||
_RequestExtra(model="gpt-4o", attempt=2, api_key="sk-1234567890abcdefghij"),
|
||||
{"gpt-4o", "sk-1234567890abcdefghij", 1},
|
||||
),
|
||||
ids=("int_key", "nested_int_key", "deeper_than_safe_dumps", "dataclass_hidden_field", "unsortable_set"),
|
||||
)
|
||||
def test_extra_the_filter_cannot_fully_inspect_never_keeps_its_secret(monkeypatch, extra):
|
||||
"""Whatever safe_dumps would skip (non-string keys, anything past its depth limit,
|
||||
fields a repr hides) must not ride the original object past the redacted stamp."""
|
||||
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
|
||||
record = _make_record(logging.WARNING, "request sent")
|
||||
record.payload = extra
|
||||
|
||||
assert SecretRedactionFilter().filter(record) is True
|
||||
rendered = JsonFormatter().format(record)
|
||||
|
||||
assert record.payload is not extra
|
||||
assert "sk-1234567890abcdefghij" not in str(record.payload)
|
||||
assert "sk-1234567890abcdefghij" not in rendered
|
||||
|
||||
|
||||
def test_secret_free_set_comes_back_as_its_json_shape(monkeypatch):
|
||||
monkeypatch.setattr("litellm._logging._ENABLE_SECRET_REDACTION", True)
|
||||
record = _make_record(logging.WARNING, "request sent")
|
||||
record.payload = {"gpt-4o", "gpt-4o-mini"}
|
||||
|
||||
assert SecretRedactionFilter().filter(record) is True
|
||||
|
||||
assert record.payload == ["gpt-4o", "gpt-4o-mini"]
|
||||
assert json.loads(JsonFormatter().format(record))["payload"] == ["gpt-4o", "gpt-4o-mini"]
|
||||
|
||||
|
||||
def test_unscrubbed_record_is_still_redacted_by_the_formatter(monkeypatch):
|
||||
"""Records that never met SecretRedactionFilter (uvicorn's, in JSON mode) keep
|
||||
their formatter-side redaction."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue