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>
This commit is contained in:
yucheng 2026-09-09 07:19:28 +00:00
parent ecde7295d9
commit a74f12d846
3 changed files with 52 additions and 7 deletions

View file

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

View file

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

View file

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