Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion litellm/_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ def _build_secret_patterns() -> re.Pattern:
r"(?<=://)[^\s'\"]*:[^\s'\"@]+(?=@)",
# Databricks personal access tokens
r"dapi[0-9a-f]{32}",
# ── Key-name-based redaction ──
# Catches secrets inside dicts/config dumps by matching on the KEY name
# regardless of what the value looks like.
# e.g. 'master_key': 'any-value-here', "database_url": "postgres://..."
r"(?:master_key|database_url|db_url|connection_string|"
r"private_key|signing_key|encryption_key|"
r"auth_token|access_token|refresh_token|"
r"slack_webhook_url|webhook_url|"
r"database_connection_string|"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 database_connection_string is unreachable — subsumed by connection_string

Because there are no word-boundary anchors, the engine scans each character position in the input. When it reaches the c in database_connection_string: …, the earlier alternative connection_string already matches, so database_connection_string is never the winning alternative. The entry can be removed without any behavioral change.

Suggested change
r"database_connection_string|"
r"huggingface_token|jwt_secret)"

r"huggingface_token|jwt_secret)"
r"""['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+""",
Comment on lines +59 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Key-name pattern matches entire key: value pair, including the key name

The new pattern captures master_key': 'my-random-secret-key-1234 (key name + separator + value) and replaces the whole thing with REDACTED. This means the key name is erased from the log line along with the value, e.g.:

Before: {'master_key': 'my-random-secret-key-1234', 'enable_jwt_auth': True}
After:  {'REDACTED', 'enable_jwt_auth': True}

While this is secure, it makes logs less useful for debugging because you can't tell which field was redacted. A more targeted approach would capture and preserve the key name while replacing only the value, using a capture group:

r"((?:master_key|database_url|db_url|...)(?:['\"]?\s*[:=]\s*['\"]?))[^\s,'\"})\]{}>]+"

with a substitution like r"\1REDACTED". This is a non-blocking suggestion since the current approach is functionally correct for security.

Comment on lines +59 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unquoted values containing spaces are only partially redacted

The value-matching portion [^\s,'\"})\]{}>]+ stops at the first whitespace character. For a log line such as general_settings master_key: my random secret key, only my would be consumed and the rest stays visible in the log. In practice Python dict.__repr__ always quotes string values, but any ad-hoc f-string formatting where a secret value contains spaces (e.g. a passphrase) would partially leak.

An alternative branch that also matches fully-quoted values spanning spaces would guard against this:

r"""['\"]?\s*[:=]\s*(?:'[^']*'|\"[^\"]*\"|[^\s,'\"})\]{}>]+)"""

This matches either a single-quoted string, a double-quoted string, or the existing bare-token form.

Comment on lines +59 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Key-name alternation lacks word-boundary anchors

The alternation has no \b or negative lookbehind, so partial substring matches inside longer key names will corrupt log lines. For example, a config key named last_auth_token would match the auth_token substring, producing garbled output like 'lastREDACTED' instead of leaving the full key name intact. Similarly, my_private_key_id would partially consume private_key and leave 'myREDACTED_id' in the log.

Adding a negative lookbehind before the alternation prevents these false triggers:

r"(?<![A-Za-z_])(?:master_key|database_url|db_url|connection_string|"

]
return re.compile("|".join(patterns), re.IGNORECASE)

Expand Down Expand Up @@ -272,7 +283,7 @@ def async_json_exception_handler(loop, context):
verbose_router_logger = logging.getLogger("LiteLLM Router")
verbose_logger = logging.getLogger("LiteLLM")

# Add the handler to the logger
# Add the handler to the loggers
verbose_router_logger.addHandler(handler)
verbose_proxy_logger.addHandler(handler)
verbose_logger.addHandler(handler)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_litellm/test_secret_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,52 @@ def test_json_excepthook_redacts_traceback_secrets():
output = h.formatter.format(record)
assert SECRET not in output
assert "REDACTED" in output


def test_key_name_redaction_catches_secrets_in_dict_repr():
"""Secrets inside dict repr strings are redacted based on key names."""
cases = [
# Python dict repr (the exact leak format from the bug report)
"param_name=general_settings, param_value={'master_key': 'my-random-secret-key-1234', 'enable_jwt_auth': True}",
# database_url
"'database_url': 'postgres://admin:password@db.example.com:5432/litellm'",
# JSON format
'"database_url": "postgres://admin:password@db.example.com:5432/litellm"',
# access_token
"'access_token': 'some-opaque-token-value'",
# refresh_token
"refresh_token=my-refresh-tok-12345",
# auth_token
"'auth_token': 'random-auth-value'",
# slack_webhook_url
"'slack_webhook_url': 'https://hooks.slack.com/services/T00/B00/xxx'",
]
for secret_line in cases:
result = _redact_string(secret_line)
assert "REDACTED" in result, f"Key-name redaction missed: {secret_line!r}"

# Non-sensitive keys should NOT be redacted
safe = "'enable_jwt_auth': True, 'store_model_in_db': True"
assert _redact_string(safe) == safe
Comment on lines +218 to +242
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Several newly added key-name patterns have no test coverage

The patterns private_key, signing_key, encryption_key, webhook_url, jwt_secret, and huggingface_token are all present in _build_secret_patterns() but absent from the test cases in test_key_name_redaction_catches_secrets_in_dict_repr. If any of these patterns were accidentally broken (e.g., a typo in the alternation), the test suite would not catch the regression.

Consider adding at least one case per new pattern, for example:

"'private_key': 'BEGIN PRIVATE KEY...'",
"'signing_key': 'hmac-sha256-secret'",
"'encryption_key': 'aes256-key-value'",
"'webhook_url': 'https://example.com/hook/abc123'",
"'jwt_secret': 'super-secret-jwt'",
"'huggingface_token': 'hf_abcdefXYZ123'",



def test_key_name_redaction_in_general_settings_dict():
"""End-to-end: secrets inside a general_settings dict dump are redacted
when logged through the named litellm loggers."""

def log_messages():
general_settings = {
"master_key": "my-random-secret-key-1234",
"database_url": "postgres://admin:password@db.example.com:5432/litellm",
"enable_jwt_auth": True,
"store_model_in_db": True,
}
verbose_proxy_logger.debug(
f"param_name=general_settings, param_value={general_settings}"
)

output = _capture_logger_output(log_messages)
assert "my-random-secret-key-1234" not in output
assert "REDACTED" in output
# Non-sensitive values should survive
assert "enable_jwt_auth" in output
Loading