fix(proxy): stop the decrypt-failure debug log from leaking the raw value

decrypt_value_helper logged `Unable to decrypt value={value}` at DEBUG, which
printed the raw secret whenever decryption failed (for example after a salt or
master key change). This is the same environment_variables config path the
db-config redaction covers, so a DATABASE_URL connection string could still
leak here when the module regex scrubber is bypassed. Drop the value; the key
already identifies the failing pair.

Regression forces a decrypt failure with the redaction filter disabled and
asserts the raw value never reaches a log record while the key stays visible.
This commit is contained in:
yucheng-berriai 2026-07-02 13:32:24 -07:00
parent 89d3f2a7b8
commit bd6ae9effa
2 changed files with 52 additions and 13 deletions

View file

@ -150,7 +150,7 @@ def decrypt_value_helper(
verbose_proxy_logger.debug(error_message)
return value if return_original_value else None
verbose_proxy_logger.debug(f"Unable to decrypt value={value} for key: {key}, returning None")
verbose_proxy_logger.debug(f"Unable to decrypt value for key: {key}, returning None")
if return_original_value:
return value
else:

View file

@ -18,9 +18,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
def _use_aes(monkeypatch):
"""Flip the write-time algorithm to AES-256-GCM for the duration of a test."""
monkeypatch.setattr(
proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"}
)
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "aes-256-gcm"})
@pytest.fixture(autouse=True)
@ -96,12 +94,7 @@ def test_aes_decrypt_failure_returns_original_when_requested(monkeypatch):
_use_aes(monkeypatch)
garbled = _V2_GCM_PREFIX + "###"
assert (
decrypt_value_helper(
garbled, key="t", exception_type="debug", return_original_value=True
)
== garbled
)
assert decrypt_value_helper(garbled, key="t", exception_type="debug", return_original_value=True) == garbled
def test_empty_string_round_trips_under_aes(monkeypatch):
@ -139,10 +132,56 @@ def test_callback_prefix_composes_with_v2(monkeypatch):
def test_unknown_algorithm_falls_back_to_legacy(monkeypatch):
"""An unrecognized encryption_algorithm value does not produce v2 writes."""
monkeypatch.setattr(
proxy_server, "general_settings", {"encryption_algorithm": "rot13"}
)
monkeypatch.setattr(proxy_server, "general_settings", {"encryption_algorithm": "rot13"})
ct = encrypt_value_helper("secret")
assert not ct.startswith(_V2_GCM_PREFIX)
assert decrypt_value_helper(ct, key="t") == "secret"
def test_decrypt_failure_debug_log_omits_raw_value(monkeypatch):
"""Regression for LIT-4152: the decrypt-failure debug breadcrumb must not
embed the raw value.
A DB ``environment_variables`` secret (e.g. a ``DATABASE_URL`` connection
string) reaches this path when it cannot be decrypted, for example after a
salt or master key change, and previously printed in cleartext when the
module regex scrubber was bypassed. The failing key still names the pair so
the breadcrumb keeps its debugging value. Uses a dedicated handler rather
than caplog because caplog is unreliable under pytest-xdist.
"""
import logging
import litellm._logging as _logging_module
from litellm._logging import verbose_proxy_logger
monkeypatch.setattr(_logging_module, "_ENABLE_SECRET_REDACTION", False)
secret = "postgresql://leak_user:leak_pw_decrypt@leak-host:5432/leak_db"
class LogRecordHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
handler = LogRecordHandler()
handler.setLevel(logging.DEBUG)
original_level = verbose_proxy_logger.level
verbose_proxy_logger.setLevel(logging.DEBUG)
verbose_proxy_logger.addHandler(handler)
try:
result = decrypt_value_helper(secret, key="DATABASE_URL", return_original_value=True)
rendered = " ".join(record.getMessage() for record in handler.records)
finally:
verbose_proxy_logger.removeHandler(handler)
verbose_proxy_logger.setLevel(original_level)
assert secret not in rendered, f"raw value leaked in decrypt-failure log: {rendered!r}"
assert "leak_pw_decrypt" not in rendered
assert any("DATABASE_URL" in record.getMessage() for record in handler.records), (
"the failing key should still be named in the breadcrumb"
)
assert result == secret