mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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.
This commit is contained in:
parent
e47f07e44c
commit
3b9742a34a
2 changed files with 115 additions and 3 deletions
|
|
@ -448,6 +448,8 @@ _default_detect_secrets_config = {
|
||||||
|
|
||||||
_CONFIG_SECTION: Final = "litellm-prompt"
|
_CONFIG_SECTION: Final = "litellm-prompt"
|
||||||
|
|
||||||
|
_ASSIGNMENT_LINE: Final = re.compile(r"[^\s\[#;:=][^:=]*[:=]")
|
||||||
|
|
||||||
# A .py suffix keeps detect_secrets' own config transformers off this file (they only fire on
|
# 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.
|
# FileType.OTHER and FileType.YAML) while leaving every plugin's regex set unchanged.
|
||||||
_SCAN_SUFFIX: Final = ".py"
|
_SCAN_SUFFIX: Final = ".py"
|
||||||
|
|
@ -480,6 +482,38 @@ def _scan_lines(lines: Sequence[str]) -> frozenset[tuple[str, str]]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parseable_lines(text: str) -> Generator[str, None, None]:
|
||||||
|
"""Yields the lines of ``text`` that configparser can read.
|
||||||
|
|
||||||
|
A prompt is mostly prose, and one unreadable line aborts the whole parse, so the prose
|
||||||
|
is dropped rather than allowed to take the assignments down with it.
|
||||||
|
"""
|
||||||
|
open_option = False
|
||||||
|
for number, line in enumerate(text.splitlines()):
|
||||||
|
stripped = line.strip()
|
||||||
|
assignment = _ASSIGNMENT_LINE.match(stripped)
|
||||||
|
if not stripped:
|
||||||
|
yield line
|
||||||
|
elif stripped[0] in "#;":
|
||||||
|
open_option = False
|
||||||
|
yield line
|
||||||
|
elif stripped[0] == "[":
|
||||||
|
open_option = False
|
||||||
|
# configparser needs a closing bracket and something inside it; without one
|
||||||
|
# it aborts the whole parse, taking every assignment below down with it.
|
||||||
|
if "]" in stripped[2:]:
|
||||||
|
yield line
|
||||||
|
elif assignment is not None:
|
||||||
|
open_option = True
|
||||||
|
# Dedenting reaches the assignments inside a pasted config, and the line number
|
||||||
|
# keeps every key distinct so a config repeating api_key per model keeps them all.
|
||||||
|
yield f"{assignment.group()[:-1].strip()}_{number}{stripped[assignment.end() - 1:]}"
|
||||||
|
elif line[0].isspace() and open_option:
|
||||||
|
yield line
|
||||||
|
else:
|
||||||
|
open_option = False
|
||||||
|
|
||||||
|
|
||||||
def _quoted_assignments(text: str) -> tuple[str, ...]:
|
def _quoted_assignments(text: str) -> tuple[str, ...]:
|
||||||
"""Rewrites the bare ``key = value`` assignments in ``text`` as quoted ones.
|
"""Rewrites the bare ``key = value`` assignments in ``text`` as quoted ones.
|
||||||
|
|
||||||
|
|
@ -487,11 +521,12 @@ def _quoted_assignments(text: str) -> tuple[str, ...]:
|
||||||
nothing, so one vendor-prefixed key in a message hides every unquoted assignment beside
|
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.
|
it. Interpolation stays off so that no emitted value is one the message never contained.
|
||||||
"""
|
"""
|
||||||
parser: Final = configparser.ConfigParser(interpolation=None)
|
parser: Final = configparser.ConfigParser(interpolation=None, strict=False)
|
||||||
# Keys keep their case, exactly as detect_secrets' own parser does.
|
# Keys keep their case, exactly as detect_secrets' own parser does.
|
||||||
parser.optionxform = str # pyright: ignore[reportAttributeAccessIssue] # configparser types optionxform as a method
|
parser.optionxform = str # pyright: ignore[reportAttributeAccessIssue] # configparser types optionxform as a method
|
||||||
|
body: Final = "\n".join(_parseable_lines(text))
|
||||||
try:
|
try:
|
||||||
parser.read_string(f"[{_CONFIG_SECTION}]\n{text}")
|
parser.read_string(f"[{_CONFIG_SECTION}]\n{body}")
|
||||||
except (configparser.Error, UnicodeDecodeError):
|
except (configparser.Error, UnicodeDecodeError):
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
@ -500,6 +535,8 @@ def _quoted_assignments(text: str) -> tuple[str, ...]:
|
||||||
for section in parser
|
for section in parser
|
||||||
for key, values in parser.items(section)
|
for key, values in parser.items(section)
|
||||||
for value in values.splitlines()
|
for value in values.splitlines()
|
||||||
|
# A quoted value is already visible to every plugin in the raw text, and
|
||||||
|
# re-quoting it here only invents matches the message never held.
|
||||||
if value and '"' not in value
|
if value and '"' not in value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,9 @@ def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, s
|
||||||
"private_key_path: keys/prod/server-cert.pem",
|
"private_key_path: keys/prod/server-cert.pem",
|
||||||
"litellm.completion(model=model, api_key=openai_api_key)",
|
"litellm.completion(model=model, api_key=openai_api_key)",
|
||||||
"params['aws_secret_access_key'] = aws_secret_access_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"))',
|
||||||
],
|
],
|
||||||
ids=[
|
ids=[
|
||||||
"prose-password",
|
"prose-password",
|
||||||
|
|
@ -183,6 +186,9 @@ def test_scan_message_redacts_credentials_assigned_to_credential_keys(content, s
|
||||||
"path-under-credential-key",
|
"path-under-credential-key",
|
||||||
"snake-case-argument",
|
"snake-case-argument",
|
||||||
"snake-case-assignment",
|
"snake-case-assignment",
|
||||||
|
"quoted-env-variable-name",
|
||||||
|
"quoted-env-name-in-a-config",
|
||||||
|
"quoted-env-name-in-a-code-paste",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_scan_message_keeps_benign_values(content):
|
def test_scan_message_keeps_benign_values(content):
|
||||||
|
|
@ -247,6 +253,49 @@ def test_credential_keyword_detector_rejects_an_unusable_minimum_length(minimum_
|
||||||
guardrail.scan_message_for_secrets("password=aB3dE6gH9jK2mN5p")
|
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):
|
||||||
|
"""A half-typed section header must not take the assignments below it down with it."""
|
||||||
|
guardrail = _guardrail()
|
||||||
|
|
||||||
|
assert "Zx4Kp9Lm2Qr7Ns3Vt" not in guardrail.redact_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_message_reads_a_config_that_repeats_a_section():
|
||||||
|
"""A pasted ini can name the same section twice, and refusing to parse it would drop
|
||||||
|
every assignment in the message, not just the repeated one."""
|
||||||
|
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():
|
||||||
|
"""A litellm config names api_key once per model, so keeping only the last one would
|
||||||
|
leave every earlier model's credential in the prompt."""
|
||||||
|
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(
|
@pytest.mark.parametrize(
|
||||||
"content,secret",
|
"content,secret",
|
||||||
[
|
[
|
||||||
|
|
@ -262,8 +311,34 @@ def test_credential_keyword_detector_rejects_an_unusable_minimum_length(minimum_
|
||||||
f"api_key: {OPENAI_KEY}\npassword =\n Zx4Kp9Lm2Qr7Ns3Vt",
|
f"api_key: {OPENAI_KEY}\npassword =\n Zx4Kp9Lm2Qr7Ns3Vt",
|
||||||
"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\n"
|
||||||
|
"db_password=Zx4Kp9Lm2Qr7Ns3Vt\nthanks!",
|
||||||
|
"Zx4Kp9Lm2Qr7Ns3Vt",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"model_list:\n - model_name: gpt-4o\n litellm_params:\n"
|
||||||
|
" api_key: aB3dE6gH9jK2mN5p\n",
|
||||||
|
"aB3dE6gH9jK2mN5p",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"flat-assignment",
|
||||||
|
"env-file",
|
||||||
|
"continuation-line",
|
||||||
|
"prose-before",
|
||||||
|
"prose-after",
|
||||||
|
"prose-both-sides",
|
||||||
|
"indented-config",
|
||||||
],
|
],
|
||||||
ids=["flat-assignment", "env-file", "continuation-line"],
|
|
||||||
)
|
)
|
||||||
def test_scan_message_still_sees_assignments_sharing_a_message_with_a_vendor_key(
|
def test_scan_message_still_sees_assignments_sharing_a_message_with_a_vendor_key(
|
||||||
content, secret
|
content, secret
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue