mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
b025681bef
commit
aa694f316d
3 changed files with 24 additions and 8 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue