diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 4928dd08386..b14e12de7cd 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -12,6 +12,7 @@ class SensitiveDataMasker: visible_prefix: int = 4, visible_suffix: int = 4, mask_char: str = "*", + mask_short_values: bool = True, ): self.sensitive_patterns = sensitive_patterns or { "password", @@ -38,12 +39,17 @@ class SensitiveDataMasker: self.visible_prefix = visible_prefix self.visible_suffix = visible_suffix self.mask_char = mask_char + self.mask_short_values = mask_short_values def _mask_value(self, value: str) -> str: - if not value or len(str(value)) < (self.visible_prefix + self.visible_suffix): - return value - value_str = str(value) + if not value_str: + return value + if len(value_str) <= (self.visible_prefix + self.visible_suffix): + return ( + self.mask_char * len(value_str) if self.mask_short_values else value_str + ) + masked_length = len(value_str) - (self.visible_prefix + self.visible_suffix) # Handle the case where visible_suffix is 0 to avoid showing the entire string diff --git a/litellm/router_utils/cooldown_cache.py b/litellm/router_utils/cooldown_cache.py index b210ea44596..dcfa44381c1 100644 --- a/litellm/router_utils/cooldown_cache.py +++ b/litellm/router_utils/cooldown_cache.py @@ -38,6 +38,7 @@ class CooldownCache: visible_prefix=50, # Show first 50 characters visible_suffix=0, # Show last 0 characters mask_char="*", # Use * for masking + mask_short_values=False, # Truncate long messages only; keep short ones readable ) def _common_add_cooldown_logic( diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 6808c4821c1..7239636fd48 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -126,6 +126,49 @@ def test_lists_with_sensitive_keys_are_masked(): assert masked["tags"] == ["prod", "test"] +def test_short_secrets_are_fully_masked(): + """ + Regression test: secrets at or below the reveal threshold (visible_prefix + + visible_suffix, 8 by default) were returned verbatim instead of masked. + An exactly-8-char value hit masked_length == 0 and round-tripped unchanged; + anything shorter hit the early return. Both leaked short credentials (e.g. an + 8-char redis password) in plaintext through mask_dict. + """ + masker = SensitiveDataMasker() + + # Boundary: exactly 8 chars previously returned verbatim. + assert masker._mask_value("abcd1234") == "********" + # Below threshold previously hit the early return and leaked verbatim. + assert masker._mask_value("sk-12") == "*****" + # Values above the threshold must still partially reveal, not over-mask. + assert masker._mask_value("abcd12345") == "abcd*2345" + + masked = masker.mask_dict({"redis_password": "pass1234", "api_key": "sk-7a"}) + assert masked["redis_password"] == "********" + assert masked["api_key"] == "*****" + + +def test_mask_short_values_false_keeps_short_values_readable(): + """ + mask_short_values=False opts out of full masking so short values are returned + as-is. This preserves the truncation use (e.g. CooldownCache shows the first 50 + 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 + ) + + short = "Test exception for structure validation" + assert masker._mask_value(short) == short + + long_value = "x" * 60 + masked = masker._mask_value(long_value) + assert masked.startswith("x" * 50) + assert masked.endswith("*" * 10) + assert len(masked) == 60 + + def test_cost_per_token_fields_not_masked(): """ Regression test: cost fields like input_cost_per_token contain "token" in their name diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_debug.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_debug.py index 468bd946ae9..d299239f68e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_debug.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_debug.py @@ -41,9 +41,13 @@ class TestMask: def test_empty_returns_none_label(self): assert MCPDebug._mask("") == "(none)" - def test_short_value_unchanged(self): - # visible_prefix=6 + visible_suffix=4 = 10, so <= 10 chars unchanged - assert MCPDebug._mask("sk-1234") == "sk-1234" + def test_short_value_masked(self): + # Short auth values must not be echoed verbatim in debug headers, even though + # visible_prefix + visible_suffix would otherwise reveal the whole value. + masked = MCPDebug._mask("sk-1234") + assert "sk-1234" not in masked + assert set(masked) == {"*"} + assert len(masked) == len("sk-1234") def test_long_value_masked(self): result = MCPDebug._mask("Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9")