fix(masker): memoize shared nodes and fail closed past the depth cap

This commit is contained in:
mateo-berri 2026-09-19 03:28:50 -07:00
parent 5fc510a6fd
commit 7edafd1715
2 changed files with 178 additions and 30 deletions

View file

@ -1,5 +1,6 @@
from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass, field
from typing import Any, Final
from pydantic import BaseModel
@ -176,26 +177,47 @@ def mask_credentials_in_payload(data: object) -> object:
config-dump semantics (``None`` -> ``"None"``, tuples stringified,
objects flattened via ``__dict__``) would silently distort the record.
A container referenced from several places in ``data`` is rebuilt once and
referenced from the same places in the copy, so a shared subtree never
fans out into independent copies. A container nested past
``DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER`` is replaced by
``REDACTED`` rather than returned unmasked.
Sensitive-key detection is delegated to the shared
:class:`SensitiveDataMasker` so pattern updates stay in one place.
"""
return _walk_payload(data, key_is_sensitive=False, depth=0)
return _PayloadWalker().walk(data, key_is_sensitive=False, depth=0)
def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object:
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return node
if isinstance(node, Mapping):
return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
if isinstance(node, list):
return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node]
if isinstance(node, tuple):
return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node)
if isinstance(node, BaseModel):
return _walk_payload(node.model_dump(), key_is_sensitive, depth)
if key_is_sensitive and isinstance(node, str) and node:
return _default_masker._mask_value(node)
return node
@dataclass(frozen=True, slots=True)
class _PayloadWalker:
_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
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return REDACTED
memo_key: Final = (id(node), key_is_sensitive and not isinstance(node, Mapping))
cached: Final = self._memo.get(memo_key)
if cached is not None:
return cached[1]
rebuilt: Final = self._rebuild(node, key_is_sensitive, depth)
self._memo[memo_key] = (node, rebuilt)
return rebuilt
def _rebuild(
self, node: Mapping[str, object] | Sequence[object] | BaseModel, key_is_sensitive: bool, depth: int
) -> object:
if isinstance(node, BaseModel):
return self._rebuild(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()}
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]
def mask_sensitive_keys(data: dict[str, Any], sensitive_fields: set[str]) -> dict[str, Any]:

View file

@ -2,11 +2,11 @@
Unit tests for SensitiveDataMasker - List Preservation
"""
from functools import reduce
from typing import Final
import pytest
# Add the parent directory to the system path
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
@ -152,9 +152,7 @@ def test_mask_short_values_false_keeps_short_values_readable():
chars of an exception and only masks longer tails), while longer values are still
partially masked.
"""
masker = SensitiveDataMasker(
visible_prefix=50, visible_suffix=0, mask_short_values=False
)
masker = SensitiveDataMasker(visible_prefix=50, visible_suffix=0, mask_short_values=False)
short = "Test exception for structure validation"
assert masker._mask_value(short) == short
@ -202,9 +200,7 @@ def test_mask_sensitive_structure_passes_through_plain_topology_names():
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure
assert mask_sensitive_structure(["gpt-4", "claude-3-haiku"]) == ["gpt-4", "claude-3-haiku"]
assert mask_sensitive_structure([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) == [
{"gpt-3.5-turbo": ["claude-3-haiku"]}
]
assert mask_sensitive_structure([{"gpt-3.5-turbo": ["claude-3-haiku"]}]) == [{"gpt-3.5-turbo": ["claude-3-haiku"]}]
assert mask_sensitive_structure(None) is None
@ -233,9 +229,7 @@ def test_mask_sensitive_structure_masks_credentials_nested_in_config_shape():
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure
secret = "sk-NESTEDINLINESECRET0987654321"
masked = mask_sensitive_structure(
[{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}]
)
masked = mask_sensitive_structure([{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}])
assert secret not in str(masked)
@ -282,10 +276,7 @@ def test_mask_credentials_in_payload_masks_inside_pydantic_models():
auth_dict = result["user_api_key_auth"]
assert isinstance(auth_dict, dict)
assert auth_dict["team_alias"] == "acme"
assert (
auth_dict["token"]
!= "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
)
assert auth_dict["token"] != "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
assert "*" in auth_dict["token"]
@ -314,6 +305,140 @@ def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves():
assert masked.endswith(plaintext[-4:])
def _unique_dict_ids(node: object) -> frozenset[int]:
if isinstance(node, dict):
return frozenset((id(node),)).union(*(_unique_dict_ids(value) for value in node.values()))
if isinstance(node, list):
return frozenset().union(*(_unique_dict_ids(value) for value in node))
return frozenset()
def _nested_under_levels(leaf: object, levels: int) -> object:
return reduce(lambda inner, level: {f"l{level}": inner}, range(levels, 0, -1), leaf)
def test_mask_credentials_in_payload_keeps_a_shared_dict_shared():
"""One dict referenced twice comes back as one masked dict referenced
twice. Rebuilding each reference separately is what turned an aliased
retry breadcrumb graph exponential in the v1.100.0 OOM."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
shared: Final = {"api_key": "sk-shared-1234567890abcdef", "model": "gpt-4o-mini"}
result: Final = mask_credentials_in_payload({"first": shared, "second": shared})
assert result["first"] is result["second"]
assert result["first"]["model"] == "gpt-4o-mini"
assert result["first"]["api_key"] != "sk-shared-1234567890abcdef"
def test_mask_credentials_in_payload_walks_each_dag_node_once():
"""A DAG of 9 dicts where every level references the level below three
times stays 9 dicts after masking, instead of fanning out to 3**8."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
root: Final = reduce(
lambda inner, _: {"a": inner, "b": inner, "c": inner}, range(8), {"api_key": "sk-leaf-1234567890abcdef"}
)
result: Final = mask_credentials_in_payload(root)
assert len(_unique_dict_ids(root)) == 9
assert len(_unique_dict_ids(result)) == 9
assert "sk-leaf-1234567890abcdef" not in str(result)
def test_mask_credentials_in_payload_masks_a_shared_list_only_under_a_sensitive_key():
"""The same list reached under a plain key and under a sensitive key is
masked in the sensitive spot only, whichever reference the walk meets
first, so the memo can neither leak a secret nor mask a plain value."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
shared: Final = ["sk-list-1234567890abcdef"]
plain_first: Final = mask_credentials_in_payload({"tags": shared, "api_key": shared})
assert plain_first["tags"] == ["sk-list-1234567890abcdef"]
assert plain_first["api_key"] != ["sk-list-1234567890abcdef"]
sensitive_first: Final = mask_credentials_in_payload({"api_key": shared, "tags": shared})
assert sensitive_first["api_key"] != ["sk-list-1234567890abcdef"]
assert sensitive_first["tags"] == ["sk-list-1234567890abcdef"]
def test_mask_credentials_in_payload_masks_a_shared_root_model_list_only_under_a_sensitive_key():
"""A pydantic model that dumps to a list is a list once walked, so the
memo must keep its plain and sensitive rebuilds apart the same way, or
the reference met first decides what the other one shows."""
from pydantic import RootModel
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
shared: Final = RootModel[list[str]](["sk-root-1234567890abcdef"])
plain_first: Final = mask_credentials_in_payload({"tags": shared, "api_key": shared})
assert plain_first["tags"] == ["sk-root-1234567890abcdef"]
assert plain_first["api_key"] != ["sk-root-1234567890abcdef"]
sensitive_first: Final = mask_credentials_in_payload({"api_key": shared, "tags": shared})
assert sensitive_first["api_key"] != ["sk-root-1234567890abcdef"]
assert sensitive_first["tags"] == ["sk-root-1234567890abcdef"]
def test_mask_credentials_in_payload_hides_containers_past_the_depth_cap():
"""A dict nested past DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER is
replaced by the REDACTED marker instead of coming back unmasked, while the
strings sitting exactly at the cap still get the normal per-key treatment:
a sensitive one is masked and a plain one survives verbatim."""
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
from litellm.litellm_core_utils.secret_redaction import REDACTED
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
secret: Final = "sk-deep-1234567890abcdef"
cap: Final = DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
result: Final = mask_credentials_in_payload(_nested_under_levels({"api_key": secret}, cap))
assert secret not in str(result)
at_cap: Final = reduce(lambda node, level: node[f"l{level}"], range(1, cap), result)
assert at_cap == {f"l{cap}": REDACTED}
strings_at_cap: Final = reduce(
lambda node, level: node[f"l{level}"],
range(1, cap),
mask_credentials_in_payload(_nested_under_levels({"api_key": secret, "model": "gpt-5.4-mini"}, cap - 1)),
)
assert strings_at_cap["model"] == "gpt-5.4-mini"
assert strings_at_cap["api_key"] != secret
assert strings_at_cap["api_key"].startswith("sk-d")
def test_mask_credentials_in_payload_keeps_sibling_models_apart():
"""Two models of the same shape dump into temporaries whose ids CPython
reuses as soon as the first is freed, so an id-keyed memo that does not
pin what it keys hands the second model the first one's masked copy."""
from pydantic import BaseModel
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
class Inner(BaseModel):
label: str
api_key: str
class Outer(BaseModel):
inner: Inner
result: Final = mask_credentials_in_payload(
{
"first": Outer(inner=Inner(label="one", api_key="sk-first-1234567890abcdef")),
"second": Outer(inner=Inner(label="two", api_key="sk-second-1234567890abcdef")),
}
)
assert result["first"]["inner"]["label"] == "one"
assert result["second"]["inner"]["label"] == "two"
assert "sk-second-1234567890abcdef" not in str(result)
assert result["second"]["inner"]["api_key"].startswith("sk-s")
def test_extra_sensitive_patterns_add_to_the_defaults():
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
@ -344,6 +469,7 @@ def test_the_second_positional_argument_is_still_the_override_set():
assert masker.is_sensitive_key("session_token") is False
assert masker.is_sensitive_key("auth_token") is True
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