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

* 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.

* fix(hide-secrets): read the assignments in a prompt that is mostly prose

configparser aborts the whole parse on the first line it cannot read, so a
message like "Here is my config, can you review it?" followed by
REDIS_PASSWORD=... lost every assignment to that one prose line. Hand the
parser only the lines it can read, dedent the assignments inside a pasted
config, and keep each key distinct by line number so a config naming api_key
once per model keeps every value instead of only the last.

* fix(hide-secrets): drop the plugin docstrings and pin the block-scalar shapes

* fix(hide-secrets): keep a comment or an indented header from closing an open value

* fix(hide-secrets): drop the explanatory comments from the new scan helpers

* fix(hide-secrets): accept punctuation in a credential value

The value filter only allowed the URL-safe Base64 alphabet, so a password
such as hunter2!brahms or p@ssw0rd!2026 passed through unredacted while
the upstream keyword plugin had already matched it. The filter now rejects
only whitespace and brackets, which keeps function calls, subscripts and
sentences out while letting symbol-heavy passwords through.

* fix(hide-secrets): redact every credential on a line and skip timestamps and plain urls

replaces the inherited first-match scan with finditer over every keyword
match, drops iso 8601 timestamps and userinfo-free urls from credential
values, and threads the parser's open-option state through
itertools.accumulate instead of rebinding it

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): scan the first token of an assignment and ignore surrounding punctuation

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(hide-secrets): drop the unreachable configparser error fallback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): keep prose after a credential key out of the keyword detector

A bare value followed by ordinary words (secret_sauce: Worcestershire sauce)
is prose, so the synthetic assignment is only built when the value stands
alone or is followed by a shell operator, comment, or another assignment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): scan the first token of shell-style assignments regardless of what follows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): keep spaced assignments in scope when shell text follows the value

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(hide-secrets): drop docstrings that restate the test names

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): stop reading a comparison operator as a trailing assignment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(hide-secrets): keep dashed flags as assignment trailers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng-berri 2026-09-11 16:49:48 -07:00 committed by GitHub
parent 359b7a8489
commit e4706fa409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 764 additions and 33 deletions

View file

@ -11,10 +11,14 @@ import sys
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import functools
import configparser
import contextlib
import itertools
import re
import tempfile
from collections.abc import Generator, Iterator, 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 +437,101 @@ _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"
_ASSIGNMENT_LINE: Final = re.compile(r"[^\s\[#;:=][^:=]*[:=]")
_SHELL_ASSIGNMENT: Final = re.compile(r"(?P<key>[^\s\[#;:=](?:[^:=]*[^\s:=])?)=(?P<value>\S+)")
_SHELL_OPERATORS: Final = ";&|"
_SHELL_TRAILER: Final = re.compile(r"\\|#.*|-*\w[\w.-]*=\S*")
_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 _classify_line(state: tuple[bool, str | None], numbered: tuple[int, str]) -> tuple[bool, str | None]:
open_option: Final = state[0]
number, line = numbered
stripped: Final = line.strip()
if not stripped or stripped[0] in "#;":
return open_option, None
shell_assignment: Final = _SHELL_ASSIGNMENT.match(stripped)
if shell_assignment is not None:
return True, f"{shell_assignment['key']}_{number}={shell_assignment['value']}"
assignment: Final = _ASSIGNMENT_LINE.match(stripped)
if assignment is not None:
return True, f"{assignment.group()[:-1].strip()}_{number}{stripped[assignment.end() - 1 :]}"
if line[0].isspace() and open_option:
return True, line
return False, None
def _parseable_lines(text: str) -> Iterator[str]:
states: Final = itertools.accumulate(enumerate(text.splitlines()), _classify_line, initial=(False, None))
return (line for _, line in states if line is not None)
def _lone_value(line: str) -> str | None:
tokens: Final = line.split()
if not tokens or '"' in tokens[0]:
return None
value: Final = tokens[0].rstrip(_SHELL_OPERATORS)
if len(tokens) == 1 or value != tokens[0] or _SHELL_TRAILER.fullmatch(tokens[1]) is not None:
return value
return None
def _quoted_assignments(text: str) -> tuple[str, ...]:
parser: Final = configparser.ConfigParser(interpolation=None)
parser.optionxform = str # pyright: ignore[reportAttributeAccessIssue] # configparser types optionxform as a method
parser.read_string(f"[{_CONFIG_SECTION}]\n" + "\n".join(_parseable_lines(text)))
return tuple(
f'{key} = "{value}"'
for section in parser
for key, values in parser.items(section)
for line in values.splitlines()
if (value := _lone_value(line)) is not None
)
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 +542,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 +569,16 @@ 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,
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,63 @@
import re
from collections.abc import Generator, Mapping
from string import punctuation
from typing import Final
from detect_secrets.plugins.keyword import (
QUOTES_REQUIRED_DENYLIST_REGEX_TO_GROUP,
KeywordDetector,
)
_CREDENTIAL_VALUE: Final = re.compile(r"[^\s()\[\]]+")
_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]+)+")
_ISO_8601_TIMESTAMP: Final = re.compile(
r"\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?"
)
_URL_WITHOUT_USERINFO_OR_QUERY: Final = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s@?]*")
_BENIGN_VALUES: Final = (
_ENVIRONMENT_REFERENCE,
_ENVIRONMENT_VARIABLE_NAME,
_LOWERCASE_WORD_SEQUENCE,
_ISO_8601_TIMESTAMP,
_URL_WITHOUT_USERINFO_OR_QUERY,
)
class CredentialKeywordDetector(KeywordDetector): # pyright: ignore[reportUntypedBaseClass] # detect_secrets ships no type information
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:
core: Final = value.strip(punctuation)
return (
len(value) >= self.minimum_length
and _CREDENTIAL_VALUE.fullmatch(value) is not None
and all(benign.fullmatch(core) is None for benign in _BENIGN_VALUES)
)
def analyze_string(
self,
string: str,
denylist_regex_to_group: Mapping[re.Pattern[str], int] | None = None,
) -> Generator[str, None, None]:
if self.keyword_exclude is not None and self.keyword_exclude.search(string):
return
regex_to_group: Final = (
QUOTES_REQUIRED_DENYLIST_REGEX_TO_GROUP if denylist_regex_to_group is None else denylist_regex_to_group
)
yield from (
match.group(group)
for regex, group in regex_to_group.items()
for match in regex.finditer(string)
if self._is_credential(match.group(group))
)

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,561 @@ 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"),
("api_key: hunter2!brahms", "hunter2!brahms"),
('db_password: "p@ssw0rd!2026"', "p@ssw0rd!2026"),
(
'url: "postgresql://user:s3cr3t@db-host:5432/app"',
"postgresql://user:s3cr3t@db-host:5432/app",
),
(
'db_password: "postgresql://user:s3cr3t@db-host:5432/app"',
"postgresql://user:s3cr3t@db-host:5432/app",
),
(
'signing_secret_url: "https://example.com/cb?sig=Zx4Kp9Lm2Qr7Ns3Vt"',
"https://example.com/cb?sig=Zx4Kp9Lm2Qr7Ns3Vt",
),
(
'redis_secret_url: "redis://:Zx4Kp9Lm2Qr7Ns3Vt@cache-host:6379/0"',
"Zx4Kp9Lm2Qr7Ns3Vt",
),
("password=2026-09-08T17:38:40Zbrahms", "2026-09-08T17:38:40Zbrahms"),
(
'{"password": "YOUR_API_KEY_HERE", "client_secret": "correcthorsebattery"}',
"correcthorsebattery",
),
("docker run -e REDIS_PASSWORD=aB3dE6gH9jK2mN5p \\\n -e REDIS_PORT=6379 redis", "aB3dE6gH9jK2mN5p"),
("DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt && echo done", "Zx4Kp9Lm2Qr7Ns3Vt"),
("password = Zx4Kp9Lm2Qr7Ns3Vt # rotate me", "Zx4Kp9Lm2Qr7Ns3Vt"),
("my db password: Zx4Kp9Lm2Qr7Ns3Vt.", "Zx4Kp9Lm2Qr7Ns3Vt"),
("export DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt DB_HOST=db.internal", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt; systemctl restart app", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt | tee creds.txt", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt > setup.log", "Zx4Kp9Lm2Qr7Ns3Vt"),
("docker run -e DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt --name app postgres", "Zx4Kp9Lm2Qr7Ns3Vt"),
("password=correcthorsebattery please", "correcthorsebattery"),
("DB_PASSWORD = Zx4Kp9Lm2Qr7Ns3Vt; systemctl restart app", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD = Zx4Kp9Lm2Qr7Ns3Vt \\", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD = Zx4Kp9Lm2Qr7Ns3Vt DB_HOST=db.internal", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD = Zx4Kp9Lm2Qr7Ns3Vt --db-host=db.internal", "Zx4Kp9Lm2Qr7Ns3Vt"),
("DB_PASSWORD = Zx4Kp9Lm2Qr7Ns3Vt DEBUG=", "Zx4Kp9Lm2Qr7Ns3Vt"),
],
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",
"punctuation-bearing-password",
"symbol-heavy-password",
"connection-string-under-a-url-key",
"connection-string-under-a-credential-key",
"signed-url-under-a-credential-key",
"password-only-url-under-a-credential-key",
"timestamp-prefixed-password",
"credential-after-a-rejected-placeholder",
"docker-flag-with-a-line-continuation",
"shell-command-after-the-value",
"inline-comment-after-the-value",
"sentence-ending-in-the-value",
"second-assignment-after-the-value",
"semicolon-after-the-value",
"pipe-after-the-value",
"redirect-after-the-value",
"docker-flag-after-the-value",
"prose-after-a-shell-assignment",
"spaced-assignment-then-a-shell-command",
"spaced-assignment-then-a-line-continuation",
"spaced-assignment-then-a-second-assignment",
"spaced-assignment-then-a-dashed-flag",
"spaced-assignment-then-an-empty-assignment",
],
)
def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, secret):
guardrail = _guardrail()
assert secret not in guardrail.redact_text(content)
def test_scan_message_redacts_only_the_first_token_of_a_shell_assignment():
guardrail = _guardrail()
content = "docker run -e REDIS_PASSWORD=aB3dE6gH9jK2mN5p \\\n -e REDIS_PORT=6379 redis && echo done"
assert (
guardrail.redact_text(content)
== "docker run -e REDIS_PASSWORD=[REDACTED] \\\n -e REDIS_PORT=6379 redis && echo done"
)
@pytest.mark.parametrize("operator", [";", "&&", "|"])
def test_scan_message_keeps_a_shell_operator_glued_to_the_value(operator):
guardrail = _guardrail()
assert (
guardrail.redact_text(f"DB_PASSWORD=Zx4Kp9Lm2Qr7Ns3Vt{operator} systemctl restart app")
== f"DB_PASSWORD=[REDACTED]{operator} systemctl restart app"
)
def test_scan_message_closes_a_yaml_block_at_the_next_unindented_line():
guardrail = _guardrail()
content = "api_key: >\n aB3dE6gH9jK2mN5p\nSteps\n Rotate-Before-Friday please"
assert guardrail.redact_text(content) == "api_key: >\n [REDACTED]\nSteps\n Rotate-Before-Friday please"
def test_scan_message_redacts_every_credential_on_one_line():
guardrail = _guardrail()
content = '{"db_password": "Tq8Zm2XpLv9KdNbRcYw3", "client_secret": "correcthorsebattery"}'
assert guardrail.redact_text(content) == '{"db_password": "[REDACTED]", "client_secret": "[REDACTED]"}'
@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",
"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",
'password: "I forgot mine, can you reset it"',
"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",
'api_key = "OPENAI_API_KEY"',
"model_list:\n - litellm_params:\n api_key: 'PERPLEXITY_API_KEY'",
'config = build(_provider("ve_missing", api_key_env="VE_MISSING_KEY"))',
"api_key = get_api_key_from_env()",
"api_key = get_secret_str(MISTRAL_OCR_API_KEY_ENV_VAR)",
"secret_manager = MagicMock(spec=BaseSecretManager)",
"api_key = self.resolve_server_api_key(",
"api_key = sys.argv[1]",
"password = credentials[environment]",
'api_key_created_at: "2026-09-08T17:38:40Z"',
'api_key_expires_at: "2026-09-08T17:38:40.123456+05:30"',
'password_reset_url: "https://example.com/reset-password/flow"',
'secret_docs_url: "https://example.com/reset-password/flow#step-2"',
'{"api_key_created_at": "2026-09-08T17:38:40Z", "password_reset_url": "https://example.com/reset/flow"}',
"secret_sauce: tomatoes-basil-garlic-oregano.",
"secret_docs_url: https://example.com/docs/keys, then rotate",
"api_key_created_at: 2026-09-08T17:38:40Z; api_key_env: OPENAI_API_KEY!",
"api_key: $OPENAI_API_KEY",
'api_key: "${OPENAI_API_KEY}"',
"private_key_path: /keys/prod/server-cert.pem",
"password_hint: your usual one followed by Ticket-LIT7049-Suffix",
"Translate this recipe note into French:\nsecret_sauce: Worcestershire sauce",
"api_key = Massachusetts (the state, not a key)",
"secret_sauce:Worcestershire sauce",
"password: correctHorseBattery != anotherValue",
],
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",
"model-name",
"namespaced-model-name",
"media-type",
"hyphenated-english",
"quoted-sentence-under-a-credential-key",
"hyphenated-phrase",
"hyphenated-question",
"url-under-credential-key",
"path-under-credential-key",
"snake-case-argument",
"snake-case-assignment",
"quoted-env-variable-name",
"quoted-env-name-in-a-config",
"quoted-env-name-in-a-code-paste",
"bare-call",
"call-with-an-argument",
"keyword-argument-call",
"unclosed-call",
"positional-subscript",
"keyed-subscript",
"timestamp-under-a-credential-key",
"offset-timestamp-under-a-credential-key",
"url-under-a-credential-key",
"fragment-url-under-a-credential-key",
"metadata-object-under-credential-keys",
"hyphenated-english-ending-a-sentence",
"url-followed-by-a-clause",
"timestamp-and-env-name-with-trailing-punctuation",
"shell-variable-reference",
"quoted-braced-shell-variable-reference",
"absolute-path-under-a-credential-key",
"sentence-holding-a-later-mixed-case-token",
"capitalized-word-starting-a-phrase",
"capitalized-word-before-a-parenthetical",
"yaml-scalar-without-a-space-after-the-colon",
"comparison-operator-after-the-value",
],
)
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):
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
def test_credential_keyword_detector_honours_keyword_exclude():
guardrail = _ENTERPRISE_SecretDetection(
guardrail_name="hide-secrets",
event_hook="pre_call",
default_on=True,
detect_secrets_config={
"plugins_used": [
{**plugin, "keyword_exclude": "fixture_"} if plugin["name"] == "CredentialKeywordDetector" else plugin
for plugin in _default_detect_secrets_config["plugins_used"]
]
},
)
content = "fixture_password=aB3dE6gH9jK2mN5p\npassword=Kp7Nq2Wz9Bt4Xr6Vm1Ls"
assert guardrail.redact_text(content) == "fixture_password=aB3dE6gH9jK2mN5p\npassword=[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):
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",
[
"[db\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"[\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"[note] have a look\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"]\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"[]\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
],
ids=["unclosed", "bare-bracket", "bracketed-prose", "stray-close", "empty-header"],
)
def test_scan_message_reads_a_config_with_a_broken_section_header(content):
guardrail = _guardrail()
assert "Zx4Kp9Lm2Qr7Ns3Vt" not in guardrail.redact_text(content)
@pytest.mark.parametrize(
"content",
[
"=orphan\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
" indented before any key\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"greeting = %(name)s\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
"token = a\x00b\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n",
],
ids=["empty-key", "leading-continuation", "interpolation", "nul-byte"],
)
def test_scan_message_reads_lines_that_a_stock_ini_parser_rejects(content):
guardrail = _guardrail()
assert "Zx4Kp9Lm2Qr7Ns3Vt" not in guardrail.redact_text(content)
def test_scan_message_reads_a_config_that_repeats_a_section():
guardrail = _guardrail()
content = "[db]\nhost = localhost\n[db]\npassword = Zx4Kp9Lm2Qr7Ns3Vt\n"
assert "Zx4Kp9Lm2Qr7Ns3Vt" not in guardrail.redact_text(content)
def test_scan_message_keeps_every_value_when_a_config_repeats_a_key():
guardrail = _guardrail()
content = (
"model_list:\n"
" - model_name: gpt-4o\n litellm_params:\n api_key: aB3dE6gH9jK2mN5p\n"
" - model_name: claude\n litellm_params:\n api_key: Kp7Nq2Wz9Bt4Xr6Vm1Ls\n"
)
redacted = guardrail.redact_text(content)
assert "aB3dE6gH9jK2mN5p" not in redacted
assert "Kp7Nq2Wz9Bt4Xr6Vm1Ls" not in redacted
@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",
),
(
"Here is my config, can you review it?\nREDIS_PASSWORD=aB3dE6gH9jK2mN5p",
"aB3dE6gH9jK2mN5p",
),
(
"REDIS_PASSWORD=aB3dE6gH9jK2mN5p\nCan you tell me what is wrong with it?",
"aB3dE6gH9jK2mN5p",
),
(
"Hi team\nplease rotate this before Friday\ndb_password=Zx4Kp9Lm2Qr7Ns3Vt\nthanks!",
"Zx4Kp9Lm2Qr7Ns3Vt",
),
(
"model_list:\n - model_name: gpt-4o\n litellm_params:\n api_key: aB3dE6gH9jK2mN5p\n",
"aB3dE6gH9jK2mN5p",
),
("api_key: >\n aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
("api_key: |-\n aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
("secret= \\\n aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
("password =\n# rotate me\n Zx4Kp9Lm2Qr7Ns3Vt", "Zx4Kp9Lm2Qr7Ns3Vt"),
("api_key =\n; rotate me\n aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
(" # pasted from the vault\napi_key=aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
(" [db]\napi_key=aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
(" pasted with a leading indent\napi_key=aB3dE6gH9jK2mN5p", "aB3dE6gH9jK2mN5p"),
],
ids=[
"flat-assignment",
"env-file",
"continuation-line",
"prose-before",
"prose-after",
"prose-both-sides",
"indented-config",
"yaml-folded-block",
"yaml-literal-block",
"backslash-continuation",
"comment-inside-a-value",
"semicolon-comment-inside-a-value",
"indented-comment-above",
"indented-section-header-above",
"indented-prose-above",
],
)
def test_scan_message_still_sees_assignments_sharing_a_message_with_a_vendor_key(content, secret):
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():
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):
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):
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():
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}"
@ -81,9 +646,7 @@ def test_scan_message_requires_ascii_digits_for_openai_like_values():
def test_scan_message_redacts_openai_key_after_separator():
guardrail = _guardrail()
assert guardrail.redact_text(f"openai_{OPENAI_KEY} key-{OPENAI_KEY}") == (
"openai_[REDACTED] key-[REDACTED]"
)
assert guardrail.redact_text(f"openai_{OPENAI_KEY} key-{OPENAI_KEY}") == ("openai_[REDACTED] key-[REDACTED]")
assert guardrail.redact_text(URL_ENCODED_KEY) == "Bearer%20[REDACTED]"
@ -102,6 +665,31 @@ 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):
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 +707,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]"'