fix(hide-secrets): restore credential coverage lost to the 4.5 entropy limit

Shannon entropy is bounded by log2(length), so the 4.5 limit #39879 shipped
cannot score any value shorter than 23 characters, and it catches a random
32-character base64 credential only about two thirds of the time. A line like
REDIS_PASSWORD=aB3dE6gH9jK2mN5p therefore reaches the provider in the clear.

Add a keyword plugin that yields the credential-shaped value assigned to a
credential-named key, reusing detect_secrets' own maintained denylist so
camelCase, snake_case and SCREAMING_CASE all work with no local word list, and
re-run the assignment-quoting transform detect_secrets skips once its first
pass has matched.

The entropy limits are untouched, so #39879's false-positive fix still holds.
This commit is contained in:
Yucheng He 2026-09-07 13:09:07 -07:00
parent 1009976c49
commit e47f07e44c
3 changed files with 494 additions and 30 deletions

View file

@ -11,10 +11,13 @@ import sys
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import functools
import configparser
import contextlib
import re
import tempfile
from collections.abc import Generator, Sequence
from contextvars import ContextVar
from typing import TYPE_CHECKING, ClassVar, Literal, Optional
from typing import TYPE_CHECKING, ClassVar, Final, Literal, Optional
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
@ -433,12 +436,74 @@ _default_detect_secrets_config = {
"name": "ZendeskSecretKeyDetector",
"path": _custom_plugins_path + "/zendesk_secret_key.py",
},
{
"name": "CredentialKeywordDetector",
"path": _custom_plugins_path + "/credential_keyword.py",
},
{"name": "Base64HighEntropyString", "limit": 4.5},
{"name": "HexHighEntropyString", "limit": 3.0},
],
}
_CONFIG_SECTION: Final = "litellm-prompt"
# A .py suffix keeps detect_secrets' own config transformers off this file (they only fire on
# FileType.OTHER and FileType.YAML) while leaving every plugin's regex set unchanged.
_SCAN_SUFFIX: Final = ".py"
@contextlib.contextmanager
def _temp_file(text: str) -> Generator[str, None, None]:
temp_file: Final = tempfile.NamedTemporaryFile(suffix=_SCAN_SUFFIX, delete=False)
try:
temp_file.write(text.encode("utf-8"))
temp_file.close()
yield temp_file.name
finally:
temp_file.close()
os.remove(temp_file.name)
def _scan_lines(lines: Sequence[str]) -> frozenset[tuple[str, str]]:
from detect_secrets import SecretsCollection
secrets: Final = SecretsCollection()
with _temp_file("\n".join(lines)) as path:
secrets.scan_file(path)
return frozenset(
(found_secret.secret_value, found_secret.type)
for file in secrets.files
for found_secret in secrets[file]
if found_secret.secret_value is not None
)
def _quoted_assignments(text: str) -> tuple[str, ...]:
"""Rewrites the bare ``key = value`` assignments in ``text`` as quoted ones.
detect_secrets does this itself, but only when its first pass over the raw text found
nothing, so one vendor-prefixed key in a message hides every unquoted assignment beside
it. Interpolation stays off so that no emitted value is one the message never contained.
"""
parser: Final = configparser.ConfigParser(interpolation=None)
# Keys keep their case, exactly as detect_secrets' own parser does.
parser.optionxform = str # pyright: ignore[reportAttributeAccessIssue] # configparser types optionxform as a method
try:
parser.read_string(f"[{_CONFIG_SECTION}]\n{text}")
except (configparser.Error, UnicodeDecodeError):
return ()
return tuple(
f'{key} = "{value}"'
for section in parser
for key, values in parser.items(section)
for value in values.splitlines()
if value and '"' not in value
)
class _ENTERPRISE_SecretDetection(CustomGuardrail):
# Keeps proxied traffic on async_pre_call_hook (the unified apply_guardrail
# path skips should_run_check and never sees data["prompt"]).
@ -449,35 +514,21 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
super().__init__(**kwargs)
def scan_message_for_secrets(self, message_content: str):
from detect_secrets import SecretsCollection
from detect_secrets.settings import transient_settings
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.write(message_content.encode("utf-8"))
temp_file.close()
secrets = SecretsCollection()
detect_secrets_config = (
self.user_defined_detect_secrets_config or _default_detect_secrets_config
)
with transient_settings(detect_secrets_config):
secrets.scan_file(temp_file.name)
os.remove(temp_file.name)
found: Final = _scan_lines(
(*message_content.splitlines(), *_quoted_assignments(message_content))
)
return [
{"type": found_secret.type, "value": found_secret.secret_value}
for file in sorted(secrets.files)
for found_secret in sorted(
secrets[file],
key=lambda secret: (
-len(secret.secret_value or ""),
secret.type,
secret.secret_value or "",
),
{"type": secret_type, "value": value}
for value, secret_type in sorted(
found, key=lambda pair: (-len(pair[0]), pair[1], pair[0])
)
if found_secret.secret_value is not None
]
def redact_text(self, text: str, source: str = "message") -> str:
@ -490,15 +541,18 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
if counts is not None:
for secret in detected_secrets:
counts[secret["type"]] = counts.get(secret["type"], 0) + 1
secret_types = [secret["type"] for secret in detected_secrets]
secret_types: Final = sorted(
dict.fromkeys(secret["type"] for secret in detected_secrets)
)
verbose_proxy_logger.warning(
f"Detected and redacted secrets in {source}: {secret_types}"
"Detected and redacted secrets in %s: %s", source, secret_types
)
return functools.reduce(
lambda redacted, secret: redacted.replace(secret["value"], "[REDACTED]"),
detected_secrets,
text,
# detected_secrets is ordered longest value first, so the alternation redacts a
# secret that contains another one as a whole rather than in pieces.
pattern: Final = re.compile(
"|".join(re.escape(secret["value"]) for secret in detected_secrets)
)
return pattern.sub("[REDACTED]", text)
async def should_run_check(self, user_api_key_dict: UserAPIKeyAuth) -> bool:
if user_api_key_dict.permissions is not None:

View file

@ -0,0 +1,55 @@
"""
This plugin searches for credential-shaped values assigned to a credential-named key.
"""
import re
from collections.abc import Generator, Mapping
from typing import Final
from detect_secrets.plugins.keyword import KeywordDetector
_CREDENTIAL_VALUE: Final = re.compile(r"[A-Za-z0-9_.~+/-]+={0,2}")
_ENVIRONMENT_REFERENCE: Final = re.compile(r"os\.environ/\w+", re.IGNORECASE)
_ENVIRONMENT_VARIABLE_NAME: Final = re.compile(r"[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+")
_LOWERCASE_WORD_SEQUENCE: Final = re.compile(r"[a-z]+(?:[-._/][a-z]+)+")
class CredentialKeywordDetector(KeywordDetector): # pyright: ignore[reportUntypedBaseClass] # detect_secrets ships no type information
"""Yields the values ``KeywordDetector`` matches that are one credential-shaped token of
at least ``minimum_length`` characters, dropping the ones that name a credential rather
than holding one."""
secret_type = "Credential Keyword"
def __init__(
self, minimum_length: int = 12, keyword_exclude: str | None = None
) -> None:
if (
not isinstance(minimum_length, int) # pyright: ignore[reportUnnecessaryIsInstance] # the value comes from an operator's YAML
or minimum_length < 1
):
raise ValueError(
f"minimum_length must be a positive integer, got {minimum_length!r}"
)
super().__init__(keyword_exclude=keyword_exclude)
self.minimum_length = minimum_length
def _is_credential(self, value: str) -> bool:
return (
len(value) >= self.minimum_length
and _CREDENTIAL_VALUE.fullmatch(value) is not None
and _ENVIRONMENT_REFERENCE.fullmatch(value) is None
and _ENVIRONMENT_VARIABLE_NAME.fullmatch(value) is None
and _LOWERCASE_WORD_SEQUENCE.fullmatch(value) is None
)
def analyze_string(
self,
string: str,
denylist_regex_to_group: Mapping[re.Pattern[str], int] | None = None,
) -> Generator[str, None, None]:
yield from (
value
for value in super().analyze_string(string, denylist_regex_to_group)
if self._is_credential(value)
)

View file

@ -10,12 +10,15 @@ Covers the three defects from the ticket:
handling live only on the native path).
"""
import tempfile
import time
import pytest
from litellm_enterprise.enterprise_callbacks.secret_detection import (
_ENTERPRISE_SecretDetection,
_default_detect_secrets_config,
_masked_entity_count,
)
from litellm.caching.caching import DualCache
from litellm.proxy._types import UserAPIKeyAuth
@ -29,6 +32,13 @@ URL_ENCODED_KEY = "Bearer%20sk-Ab3dEf6Gh7Ij8Kl9Mn0Pq2Rs3Tu4Vw5X"
AWS_KEYS = [f"AKIAIOSFODNN7EXAMPL{suffix}" for suffix in "FEDCBA"]
@pytest.fixture(autouse=True)
def _isolate_masked_entity_count():
token = _masked_entity_count.set(None)
yield
_masked_entity_count.reset(token)
def _guardrail() -> _ENTERPRISE_SecretDetection:
return _ENTERPRISE_SecretDetection(guardrail_name="hide-secrets", event_hook="pre_call", default_on=True)
@ -58,6 +68,323 @@ def test_scan_message_preserves_quoted_benign_identifiers():
assert guardrail.redact_text(content) == content
@pytest.mark.parametrize(
"content,secret",
[
("REDIS_PASSWORD=aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
("SESSION_SECRET=Kp7Nq2Wz9Bt4Xr6Vm1Ls", "Kp7Nq2Wz9Bt4Xr6Vm1Ls"),
('{"db_password": "Tq8Zm2XpLv9KdNbRcYw3"}', "Tq8Zm2XpLv9KdNbRcYw3"),
("api_secret: Zx4Kp9Lm2Qr7Ns3Vt", "Zx4Kp9Lm2Qr7Ns3Vt"),
("password = hunter2brahms9x", "hunter2brahms9x"),
("client_secret=Hq7Zm3XkLp9Wd2Nb", "Hq7Zm3XkLp9Wd2Nb"),
('apiKey: "aB3dE6gH9jK2mN5p"', "aB3dE6gH9jK2mN5p"),
('{"clientSecret": "Kp7Nq2Wz9Bt4Xr6Vm1Ls"}', "Kp7Nq2Wz9Bt4Xr6Vm1Ls"),
('dbPassword = "Zx4Kp9Lm2Qr7Ns3Vt"', "Zx4Kp9Lm2Qr7Ns3Vt"),
("MY_APP_DB_PASSWORD=Kp7Nq2Wz9Bt4Xr6Vm1Ls", "Kp7Nq2Wz9Bt4Xr6Vm1Ls"),
("x_api_key: 8f3Kd9Lm2Qr7Ns3Vt", "8f3Kd9Lm2Qr7Ns3Vt"),
("password: Zm9vYmFyYmF6+abc/def123=", "Zm9vYmFyYmF6+abc/def123="),
("REDIS_PASSWORD=correcthorsebattery", "correcthorsebattery"),
('SECRET_KEY = "django-insecure-9v2xk4qw8z"', "django-insecure-9v2xk4qw8z"),
(
"aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
),
("password=aB3dE6gH9jK2", "aB3dE6gH9jK2"),
],
ids=[
"env-password",
"env-secret",
"json-field",
"yaml-field",
"bare-assignment",
"client-secret",
"camel-case-key",
"camel-case-secret",
"camel-case-password",
"namespaced-env",
"underscored-header",
"base64-padding",
"digit-free-value",
"django-secret-key",
"slashed-aws-secret",
"shortest-accepted-value",
],
)
def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, secret):
guardrail = _guardrail()
assert secret not in guardrail.redact_text(content)
@pytest.mark.parametrize(
"content",
[
"The user forgot their password and asked for a reset link",
"Rotate the client secret every 90 days",
"The secret: keep it quiet",
"My password: correct horse battery staple",
"secretary: Maria Gonzalez",
"password_reset_email: Please click the link below to reset",
'config = {"api_key": "YOUR_API_KEY_HERE"}',
"api_key: <your-key-here>",
'{"max_tokens": 4096, "model": "gpt-4o-mini"}',
'def get_api_key():\n return os.environ["OPENAI_API_KEY"]',
' valid_token = UserAPIKeyAuth(user_id="u1")',
'password = get_password(user, "prod")',
"monkey=aB3dE6gH9jK2mN5p",
"idempotency_key: req_2026090712000000",
'cache_key = "u1_user_api_key_user_id"',
"the key: 2026-09-07T12:00:00Z",
"api_key: os.environ/E2B_API_KEY",
"langfuse_secret: os.environ/LANGFUSE_PROJECT1_SECRET",
"api_key = OPENAI_API_KEY",
"password = pwd12345678",
"api_key: hunter2!brahms",
"model_key: gpt-4o-mini-2024-07-18",
"openrouter/anthropic/claude-3-5-sonnet-20240620",
'{"content-type": "application/json"}',
"passwordless_login: enabled-for-all-users",
"secret_sauce: tomatoes-basil-garlic-oregano",
"user_secret_question: what-was-your-first-pet",
"password_reset_url: example.com/reset-password/flow",
"private_key_path: keys/prod/server-cert.pem",
"litellm.completion(model=model, api_key=openai_api_key)",
"params['aws_secret_access_key'] = aws_secret_access_key",
],
ids=[
"prose-password",
"prose-secret",
"colon-prose-secret",
"colon-prose-password",
"secretary",
"sentence-after-keyword",
"uppercase-placeholder",
"templated-placeholder",
"max-tokens",
"code-paste",
"constructor-call",
"indirect-reference",
"word-ending-in-key",
"idempotency-key",
"cache-key",
"timestamp-after-key",
"env-reference",
"env-reference-nested",
"env-variable-name",
"below-minimum-length",
"non-credential-charset",
"model-name",
"namespaced-model-name",
"media-type",
"hyphenated-english",
"hyphenated-phrase",
"hyphenated-question",
"url-under-credential-key",
"path-under-credential-key",
"snake-case-argument",
"snake-case-assignment",
],
)
def test_scan_message_keeps_benign_values(content):
guardrail = _guardrail()
assert guardrail.scan_message_for_secrets(content) == []
assert guardrail.redact_text(content) == content
@pytest.mark.parametrize(
"value,redacted",
[("aB3dE6gH9jK2", True), ("aB3dE6gH9jK", False)],
ids=["at-minimum-length", "below-minimum-length"],
)
def test_credential_keyword_detector_honours_its_minimum_length(value, redacted):
guardrail = _guardrail()
assert (value not in guardrail.redact_text(f"password={value}")) is redacted
@pytest.mark.parametrize(
"value,redacted",
[("aB3dE6gH9jK2", True), ("aB3dE6gH9jK", False)],
ids=["at-default-minimum-length", "below-default-minimum-length"],
)
def test_credential_keyword_detector_defaults_its_minimum_length(value, redacted):
"""An operator config that names the plugin without sizing it keeps the same floor."""
guardrail = _ENTERPRISE_SecretDetection(
guardrail_name="hide-secrets",
event_hook="pre_call",
default_on=True,
detect_secrets_config={
"plugins_used": [
{key: setting for key, setting in plugin.items() if key != "minimum_length"}
for plugin in _default_detect_secrets_config["plugins_used"]
]
},
)
assert (value not in guardrail.redact_text(f"password={value}")) is redacted
@pytest.mark.parametrize("minimum_length", ["12", 0, -1, 1.5], ids=["string", "zero", "negative", "float"])
def test_credential_keyword_detector_rejects_an_unusable_minimum_length(minimum_length):
"""A bad value in an operator config has to fail while the guardrail is being built;
reaching the scan with one turns every single request into a 500."""
guardrail = _ENTERPRISE_SecretDetection(
guardrail_name="hide-secrets",
event_hook="pre_call",
default_on=True,
detect_secrets_config={
"plugins_used": [
{**plugin, "minimum_length": minimum_length}
if plugin["name"] == "CredentialKeywordDetector"
else plugin
for plugin in _default_detect_secrets_config["plugins_used"]
]
},
)
with pytest.raises(ValueError, match="minimum_length"):
guardrail.scan_message_for_secrets("password=aB3dE6gH9jK2mN5p")
@pytest.mark.parametrize(
"content,secret",
[
(
f"api_key: {OPENAI_KEY}\nREDIS_PASSWORD=aB3dE6gH9jK2mN5p",
"aB3dE6gH9jK2mN5p",
),
(
f"OPENAI_API_KEY={OPENAI_KEY}\nDB_PASSWORD=Kp7Nq2Wz9Bt4Xr6Vm1Ls",
"Kp7Nq2Wz9Bt4Xr6Vm1Ls",
),
(
f"api_key: {OPENAI_KEY}\npassword =\n Zx4Kp9Lm2Qr7Ns3Vt",
"Zx4Kp9Lm2Qr7Ns3Vt",
),
],
ids=["flat-assignment", "env-file", "continuation-line"],
)
def test_scan_message_still_sees_assignments_sharing_a_message_with_a_vendor_key(
content, secret
):
"""detect_secrets stops quoting assignments as soon as its first pass matches."""
guardrail = _guardrail()
redacted = guardrail.redact_text(content)
assert secret not in redacted
assert OPENAI_KEY not in redacted
def test_environment_reference_filter_only_drops_the_whole_value():
guardrail = _guardrail()
for reference in ("os.environ/OPENAI_API_KEY", "os.environ/e2b_api_key"):
assert guardrail.redact_text(f"password={reference}") == f"password={reference}"
assert guardrail.redact_text("password=notos.environ/OPENAI_API_KEY") == (
"password=[REDACTED]"
)
def test_environment_variable_names_are_dropped_only_for_the_keyword_plugin():
"""The exclusion lives on the plugin, so it cannot suppress a vendor detector's hit."""
guardrail = _guardrail()
assert guardrail.redact_text("password=REDIS_PASSWORD") == "password=REDIS_PASSWORD"
assert guardrail.scan_message_for_secrets('k = "ABCD1234_EFGH5678_IJKLMN"') == [
{"type": "Base64 High Entropy String", "value": "ABCD1234_EFGH5678_IJKLMN"}
]
def test_masked_entity_count_keeps_the_vendor_type_beside_the_entropy_type():
guardrail = _guardrail()
_masked_entity_count.set({})
guardrail.redact_text('k = "ghp_abcdefghijklmnopqrstuvwxyzABCDEF1234"')
assert _masked_entity_count.get() == {
"Base64 High Entropy String": 1,
"GitHub Token": 1,
}
@pytest.mark.parametrize(
"content",
[
f"api_key: '{OPENAI_KEY}'\n"
+ "a: &a [" + ", ".join(['"x"'] * 9) + "]\n"
+ "".join(
f"{chr(98 + i)}: &{chr(98 + i)} ["
+ ", ".join([f"*{chr(97 + i)}"] * 9)
+ "]\n"
for i in range(7)
),
f"api_key: '{OPENAI_KEY}'\ndeep: " + "[" * 400 + "]" * 400,
f"api_key: '{OPENAI_KEY}'\nbroken: [unclosed",
],
ids=["anchor-expansion", "deep-nesting", "unparseable"],
)
def test_scan_message_contains_hostile_config_text(content, monkeypatch, tmp_path):
"""The retry pass parses attacker-controlled text, so it must not raise, hang, or
leave the prompt behind in a temp file."""
guardrail = _guardrail()
monkeypatch.setenv("TMPDIR", str(tmp_path))
monkeypatch.setattr(tempfile, "tempdir", None)
started = time.perf_counter()
found = guardrail.scan_message_for_secrets(content)
assert time.perf_counter() - started < 10.0
assert OPENAI_KEY in [secret["value"] for secret in found]
assert list(tmp_path.iterdir()) == []
@pytest.mark.parametrize(
"content",
[
f"api_key = '{OPENAI_KEY}'\nbase = abcdefghijkl\npassword = x\n %(base)sZZZZQQQQ\n",
"base = abcdefghijkl\npassword = x\n %(base)sZZZZQQQQ\n",
f"api_key = '{OPENAI_KEY}'\nbase = Kp7Nq2Wz9Bt4\npassword = x\n"
" %(base)s-primary\nnote = Kp7Nq2Wz9Bt4-primary is the hostname\n",
'base = "abcdefghijkl"\npassword = "%(base)sZZZZQQQQ"\n',
],
ids=[
"vendor-key-present",
"no-vendor-key",
"value-echoed-elsewhere",
"quoted-interpolation",
],
)
def test_scan_message_never_reports_a_value_the_message_does_not_hold(content):
"""The rewritten copy is parsed with interpolation off, so no reported value can be one
the parser assembled rather than read; reporting one would mask unrelated text."""
guardrail = _guardrail()
for secret in guardrail.scan_message_for_secrets(content):
assert secret["value"] in content
def test_scan_message_leaves_unrelated_text_alone_when_a_value_is_echoed():
"""A value the parser could assemble also appears verbatim in a benign sentence; redacting
it would destroy the sentence while leaving the line it came from untouched."""
guardrail = _guardrail()
content = (
f"api_key = '{OPENAI_KEY}'\nbase = Kp7Nq2Wz9Bt4\npassword = x\n"
" %(base)s-primary\nnote = Kp7Nq2Wz9Bt4-primary is the hostname\n"
)
assert "note = Kp7Nq2Wz9Bt4-primary is the hostname" in guardrail.redact_text(content)
def test_masked_entity_count_counts_each_secret_once():
guardrail = _guardrail()
_masked_entity_count.set({})
guardrail.redact_text(f"first {OPENAI_KEY} second {OPENAI_KEY}")
assert _masked_entity_count.get() == {"Strict OpenAI API Key": 1}
def test_scan_message_redacts_every_openai_key_occurrence():
guardrail = _guardrail()
content = f"first {OPENAI_KEY}, second {OPENAI_KEY}"
@ -102,6 +429,34 @@ def test_scan_message_stays_linear_on_repeated_sk_separators():
assert time.perf_counter() - started < 2.0
@pytest.mark.parametrize(
"content",
[
f"api_key: '{OPENAI_KEY}'\npassword=" + "a-" * 10_000 + "!",
f"api_key: '{OPENAI_KEY}'\npassword:" + '"' * 20_000,
f"api_key: '{OPENAI_KEY}'\n" + "api_key:" * 10_000,
f"api_key: '{OPENAI_KEY}'\nsecret=" + "aB3dE6gH9jK2mN5p " * 2_000,
f"api_key: '{OPENAI_KEY}'\n"
+ "\n".join(f"password{i}=aB3dE6gH9jK2mN5p{i}" for i in range(3_000)),
],
ids=[
"value-run",
"quote-run",
"keyword-run",
"value-repeat",
"assignment-flood",
],
)
def test_scan_message_stays_linear_on_adversarial_credential_lines(content):
"""A backtracking blow-up on these runs takes minutes, so the bound is loose enough
to stay green on a loaded CI box."""
guardrail = _guardrail()
started = time.perf_counter()
guardrail.redact_text(content)
assert time.perf_counter() - started < 10.0
def test_scan_message_redacts_whole_stripe_live_key():
guardrail = _guardrail()
@ -119,8 +474,8 @@ def test_scan_message_replaces_longest_overlapping_match_first():
guardrail = _guardrail()
content = f'token = "{OPENAI_KEY}/extra"'
detected = guardrail.scan_message_for_secrets(content)
assert [secret["value"] for secret in detected] == [f"{OPENAI_KEY}/extra", OPENAI_KEY]
values = [secret["value"] for secret in guardrail.scan_message_for_secrets(content)]
assert values == [f"{OPENAI_KEY}/extra", OPENAI_KEY]
assert guardrail.redact_text(content) == 'token = "[REDACTED]"'