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:
Yucheng He 2026-09-07 20:28:33 -07:00
parent e47f07e44c
commit 3b9742a34a
2 changed files with 115 additions and 3 deletions

View file

@ -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
) )

View file

@ -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