diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py index 52dbf78c95f..f726dd390b2 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py @@ -513,8 +513,9 @@ def _quoted_assignments(text: str) -> tuple[str, ...]: 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 + for line in values.splitlines() + for value in line.split()[:1] + if '"' not in value ) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/credential_keyword.py b/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/credential_keyword.py index 4a33093c305..b69e347ded5 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/credential_keyword.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/credential_keyword.py @@ -1,5 +1,6 @@ import re from collections.abc import Generator, Mapping +from string import punctuation from typing import Final from detect_secrets.plugins.keyword import ( @@ -15,6 +16,13 @@ _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 @@ -30,14 +38,11 @@ class CredentialKeywordDetector(KeywordDetector): # pyright: ignore[reportUntyp 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 _ENVIRONMENT_REFERENCE.fullmatch(value) is None - and _ENVIRONMENT_VARIABLE_NAME.fullmatch(value) is None - and _LOWERCASE_WORD_SEQUENCE.fullmatch(value) is None - and _ISO_8601_TIMESTAMP.fullmatch(value) is None - and _URL_WITHOUT_USERINFO_OR_QUERY.fullmatch(value) is None + and all(benign.fullmatch(core) is None for benign in _BENIGN_VALUES) ) def analyze_string( diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py index 6e5ab18cb7a..3c90fdad91a 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py @@ -113,6 +113,10 @@ def test_scan_message_preserves_quoted_benign_identifiers(): '{"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"), ], ids=[ "env-password", @@ -139,6 +143,10 @@ def test_scan_message_preserves_quoted_benign_identifiers(): "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", ], ) def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, secret): @@ -147,6 +155,23 @@ def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, s 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" + ) + + +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(): """detect_secrets keeps the first hit per pattern, so a JSON object holding two credentials would leave the second one in the prompt.""" @@ -204,6 +229,13 @@ def test_scan_message_redacts_every_credential_on_one_line(): '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", ], ids=[ "prose-password", @@ -251,6 +283,13 @@ def test_scan_message_redacts_every_credential_on_one_line(): "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", ], ) def test_scan_message_keeps_benign_values(content):