diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py index 420b80af907..bd03e278679 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py @@ -451,9 +451,9 @@ _CONFIG_SECTION: Final = "litellm-prompt" _ASSIGNMENT_LINE: Final = re.compile(r"[^\s\[#;:=][^:=]*[:=]") -_SHELL_OPERATORS: Final = ";&|" +_SHELL_ASSIGNMENT: Final = re.compile(r"(?P[^\s\[#;:=](?:[^:=]*[^\s:=])?)=(?P\S+)") -_SHELL_TRAILER: Final = re.compile(rf"\\|[{_SHELL_OPERATORS}]+|#.*|\S+=\S*") +_SHELL_OPERATORS: Final = ";&|" _SCAN_SUFFIX: Final = ".py" @@ -491,6 +491,9 @@ def _classify_line(state: tuple[bool, str | None], numbered: tuple[int, str]) -> 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 :]}" @@ -505,14 +508,10 @@ def _parseable_lines(text: str) -> Iterator[str]: def _lone_value(line: str) -> str | None: - tokens: Final = line.split(maxsplit=2) - if not tokens or '"' in tokens[0]: + tokens: Final = line.split() + if not tokens or '"' in tokens[0] or (len(tokens) > 1 and not tokens[1].startswith("#")): return None - value: Final = tokens[0].rstrip(_SHELL_OPERATORS) - trailer: Final = tokens[1] if len(tokens) > 1 else "" - if value != tokens[0] or not trailer or _SHELL_TRAILER.fullmatch(trailer) is not None: - return value - return None + return tokens[0].rstrip(_SHELL_OPERATORS) def _quoted_assignments(text: str) -> tuple[str, ...]: 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 bbee6d04e8b..ae1693adf1e 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py @@ -120,6 +120,9 @@ def test_scan_message_preserves_quoted_benign_identifiers(): ("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"), ], ids=[ "env-password", @@ -153,6 +156,9 @@ def test_scan_message_preserves_quoted_benign_identifiers(): "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", ], ) def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, secret): @@ -171,6 +177,16 @@ def test_scan_message_redacts_only_the_first_token_of_a_shell_assignment(): ) +@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" @@ -244,6 +260,7 @@ def test_scan_message_redacts_every_credential_on_one_line(): "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", ], ids=[ "prose-password", @@ -300,6 +317,7 @@ def test_scan_message_redacts_every_credential_on_one_line(): "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", ], ) def test_scan_message_keeps_benign_values(content):