From 3feb2bf27c2af2ca03571466f23debeb5fe0df54 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:18:43 +0000 Subject: [PATCH] fix(logging): keep SGR colour codes when sanitizing verbose stdout --- litellm/_logging.py | 15 +++++++++------ tests/test_litellm/test_secret_redaction.py | 15 +++++++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/litellm/_logging.py b/litellm/_logging.py index fcce6cf4234..32fd517c4f6 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -44,16 +44,18 @@ def redact_secrets(value: str) -> str: return _redact_string(value) -_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f\u2028\u2029]") +_SGR_COLOR_OR_CONTROL_CHAR_RE = re.compile(r"(\x1b\[[0-9;]{0,16}m)|([\x00-\x08\x0a-\x1f\x7f-\x9f\u2028\u2029])") _CONTROL_CHAR_NAMES = {"\n": "\\n", "\r": "\\r", "\x0b": "\\v", "\x0c": "\\f", "\x08": "\\b", "\x07": "\\a"} def _escape_control_character(match: "re.Match[str]") -> str: - char = match.group() - named = _CONTROL_CHAR_NAMES.get(char) + sgr_color_sequence, control_char = match.groups() + if sgr_color_sequence is not None: + return sgr_color_sequence + named = _CONTROL_CHAR_NAMES.get(control_char) if named is not None: return named - codepoint = ord(char) + codepoint = ord(control_char) return f"\\x{codepoint:02x}" if codepoint < 0x100 else f"\\u{codepoint:04x}" @@ -61,9 +63,10 @@ def sanitize_control_characters(value: str) -> str: """Escape characters that let untrusted content forge log lines or drive the terminal. Covers C0/C1 controls (CR, LF, ESC, ...) and the Unicode line/paragraph - separators; tab is left alone since it cannot start a new log record. + separators. Tab survives since it cannot start a new log record, and so do + SGR colour sequences, which litellm itself emits to colourise verbose output. """ - return _CONTROL_CHAR_RE.sub(_escape_control_character, value) + return _SGR_COLOR_OR_CONTROL_CHAR_RE.sub(_escape_control_character, value) def redact_and_sanitize(print_statement: object) -> str: diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 8393df98bdb..d2b95401ace 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -392,20 +392,27 @@ def test_normal_vertex_log_not_redacted(): def test_sanitize_control_characters_escapes_injection_vectors(): - raw = "line1\r\n2026-01-01 INFO forged\x1b[31mred\x1b[0m\u2028\u2029\x00 tab\there" + raw = "line1\r\n2026-01-01 INFO forged\x1b]0;window title\x07\x1b[2J\u2028\u2029\x00 tab\there" result = sanitize_control_characters(raw) assert "\n" not in result assert "\r" not in result assert "\x1b" not in result + assert "\x07" not in result assert "\u2028" not in result assert "\u2029" not in result assert "\x00" not in result assert "\\r\\n" in result - assert "\\x1b[31mred" in result + assert "\\x1b]0;window title\\a\\x1b[2J" in result assert "\\u2028\\u2029\\x00" in result assert "tab\there" in result +def test_sanitize_control_characters_keeps_litellm_colour_codes(): + """litellm colourises its own verbose prints; SGR sequences stay intact.""" + coloured = "\x1b[92mRequest to litellm:\x1b[0m" + assert sanitize_control_characters(coloured) == coloured + + def test_redact_and_sanitize_masks_secrets_and_control_chars(): result = redact_and_sanitize({"api_key": SECRET, "model": "gpt-5", "note": "a\nb"}) assert SECRET not in result @@ -438,7 +445,7 @@ def test_print_verbose_stdout_is_redacted_and_sanitized(module_path, attribute_p module = importlib.import_module(module_path) attribute_names = attribute_path.split(".") print_verbose = functools.reduce(getattr, attribute_names, module) - payload = f"api_key={SECRET} chunk=gpt-5\r\n2026-01-01 INFO forged line \x1b[31mred\x1b[0m" + payload = f"api_key={SECRET} chunk=gpt-5\r\n2026-01-01 INFO forged line \x1b]0;pwned\x07" arguments = (None, payload) if len(attribute_names) > 1 else (payload,) with patch.object(litellm, "set_verbose", True): @@ -452,4 +459,4 @@ def test_print_verbose_stdout_is_redacted_and_sanitized(module_path, attribute_p assert "\x1b" not in out assert out.count("\n") == 1 assert "\\r\\n" in out - assert "\\x1b[31mred" in out + assert "\\x1b]0;pwned\\a" in out