fix(proxy): keep JSON nulls as null in audit log values (#46577)

LiteLLM_AuditLogs masked before_value and updated_values with
SensitiveDataMasker.mask_dict, which stringifies every non-native leaf, so a
null in the change was stored as the string "None" (or "" under a key-named
field). Walk audit values with mask_credentials_in_payload instead, injecting
the existing {"key"} masker so the masked format and scope stay the same.
This commit is contained in:
yuneng-jiang 2026-10-14 22:50:09 -07:00 • committed by GitHub
parent b025681bef
commit aa694f316d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 24 additions and 8 deletions

View file

@ -168,7 +168,7 @@ def mask_sensitive_structure(data: object) -> object:
return _error_masker.mask(data)
def mask_credentials_in_payload(data: object) -> object:
def mask_credentials_in_payload(data: object, masker: SensitiveDataMasker = _default_masker) -> object:
"""Return a copy of ``data`` where string values under sensitive-named keys
are masked but every other value (``None``, ``int``, ``float``, ``bool``,
``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by
@ -189,18 +189,19 @@ def mask_credentials_in_payload(data: object) -> object:
Sensitive-key detection is delegated to the shared
:class:`SensitiveDataMasker` so pattern updates stay in one place.
"""
return _PayloadWalker().walk(data, key_is_sensitive=False, depth=0)
return _PayloadWalker(masker).walk(data, key_is_sensitive=False, depth=0)
@dataclass(frozen=True, slots=True)
class _PayloadWalker:
masker: SensitiveDataMasker = _default_masker
_memo: dict[tuple[int, bool], tuple[object, object]] = field( # mutable-ok: memo of one walk, pins each keyed node
default_factory=dict
)
def walk(self, node: object, key_is_sensitive: bool, depth: int) -> object:
if not isinstance(node, (Mapping, list, tuple, BaseModel)):
return _default_masker.mask_value(node) if key_is_sensitive and isinstance(node, str) and node else node
return self.masker.mask_value(node) if key_is_sensitive and isinstance(node, str) and node else node
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return REDACTED
memo_key: Final = (id(node), key_is_sensitive and not isinstance(node, Mapping))
@ -218,7 +219,7 @@ class _PayloadWalker:
if isinstance(node, BaseModel):
return self.walk(node.model_dump(), key_is_sensitive, depth)
if isinstance(node, Mapping):
return {k: self.walk(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
return {k: self.walk(v, self.masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
if isinstance(node, tuple):
return tuple(self.walk(item, key_is_sensitive, depth + 1) for item in node)
return [self.walk(item, key_is_sensitive, depth + 1) for item in node]

View file

@ -3359,14 +3359,14 @@ class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
@model_validator(mode="after")
def mask_api_keys(self):
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker, mask_credentials_in_payload
masker: Final = SensitiveDataMasker(sensitive_patterns={"key"})
if self.before_value is not None:
self.before_value = json.dumps(masker.mask(self.before_value), default=str)
self.before_value = json.dumps(mask_credentials_in_payload(self.before_value, masker), default=str)
if self.updated_values is not None:
self.updated_values = json.dumps(masker.mask(self.updated_values), default=str)
self.updated_values = json.dumps(mask_credentials_in_payload(self.updated_values, masker), default=str)
return self

View file

@ -249,17 +249,32 @@ def test_an_audit_log_does_not_store_the_key_it_recorded_a_change_to(field):
@pytest.mark.parametrize("field", ("before_value", "updated_values"))
def test_an_audit_log_keeps_the_non_secret_fields_it_recorded(field):
sent = {"key": SECRET, "max_budget": 50, "models": ["gpt-4o"]}
sent = {"key": SECRET, "token": "88dc28d0f030c55ed4ab", "max_budget": 50, "models": ["gpt-4o"]}
log = _audit_log(**{field: json.dumps(sent)})
assert json.loads(getattr(log, field)) == {
"key": SECRET_MASKED,
"token": "88dc28d0f030c55ed4ab",
"max_budget": 50,
"models": ["gpt-4o"],
}
@pytest.mark.parametrize("field", ("before_value", "updated_values"))
def test_an_audit_log_stores_null_values_as_null(field):
sent = {
"key": SECRET,
"key_alias": None,
"metadata": {"ignored_until": None},
"tags": ["prod", None, {"owner": None}],
}
log = _audit_log(**{field: json.dumps(sent)})
assert json.loads(getattr(log, field)) == {**sent, "key": SECRET_MASKED}
@pytest.mark.parametrize("field", ("before_value", "updated_values"))
def test_an_audit_log_stores_a_list_of_changes_as_json_text_with_keys_masked(field):
sent = [{"id": "no_redis", "key": SECRET}, {"id": "detailed_debug"}]