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:
mateo-berri 2026-09-16 15:07:22 -07:00
parent 04eae31769
commit d5570d04d9
2 changed files with 65 additions and 7 deletions

View file

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

View file

@ -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."""