diff --git a/agent/redact.py b/agent/redact.py index 2bd631228e..6408b8c276 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -312,7 +312,12 @@ _STRONG_KEY_KEYWORD_RE = re.compile( # ``/usr/...`` or ``~/...`` references a variable or a path, not a credential, even under a strong key # (``SSH_AUTH_SOCK=$HOME/.ssh/agent.sock``, ``DOCKER_AUTH_CONFIG=/home/u/.docker``). _PASSWORD_KEY_RE = re.compile(r"passwd|password|pass|pw", re.IGNORECASE) -_PATH_OR_VAR_VALUE_RE = re.compile(r"[$/~]") +# Anchored on both ends: the whole value must be a ``$VAR``/``${VAR}`` reference, a ``~/`` +# path, or an absolute path — not merely a string whose FIRST character is one of those. +# A 40-char AWS secret key starts with '/' ~1 in 64 times and argon2/bcrypt digests always +# start with '$'; an unanchored class let those secrets skip every check below. +_PATH_OR_VAR_VALUE_RE = re.compile( + r"^(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?[/:.\w-]*|~/[\w./-]*|/(?:[\w.-]+/)*[\w.-]*)$") def _is_word_start(s: str, i: int) -> bool: @@ -381,7 +386,13 @@ def _should_redact_assignment(key: str, value: str, *, check_keyword: bool) -> b # A shell rc's ``SSH_AUTH_SOCK=$HOME/.ssh/agent.sock`` is configuration the agent must keep # readable; only password-class keys mask a path/variable reference. if _PATH_OR_VAR_VALUE_RE.match(value) and not _has_word_bounded_keyword(key, _PASSWORD_KEY_RE): - return False + # ``$VAR`` and ``~/`` are unambiguous references. A bare ``/...`` is not: + # ``/home/u/.docker`` and ``/8f3kd9sKd0als...`` have the same shape, so a + # single-segment absolute path still has to clear the opaque-credential bar + # before it is treated as configuration. + if not (value.startswith("/") and "/" not in value[1:] + and _looks_like_opaque_credential(value)): + return False return (_has_word_bounded_keyword(key, _STRONG_KEY_KEYWORD_RE) or _looks_like_opaque_credential(value)) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index f7cd435e1b..d6719d0a21 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -140,7 +140,39 @@ class TestEnvAssignments: ): assert cleartext not in redact_sensitive_text(text, force=True) + @pytest.mark.parametrize( + "text, cleartext", + [ + # AWS secret access keys are 40 chars of base64: ~1 in 64 begin with '/'. + ("AWS_SECRET_ACCESS_KEY=/wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY", + "wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY"), + # argon2/bcrypt digests always begin with '$'. + ("API_SECRET=$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaHZhbHVl", + "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaHZhbHVl"), + ("SESSION_SECRET=/8f3kd9sKd0alsKDJ2mfkeisl3kdMc9dksla", + "8f3kd9sKd0alsKDJ2mfkeisl3kdMc9dksla"), + ], + ) + def test_secret_that_only_starts_like_a_path_or_var_still_redacts( + self, text, cleartext + ): + # The rc-readability exemption must fire only on a value that IS a complete + # $VAR / ~/path / /abs/path reference — not on a secret that merely begins with + # one of those characters. Before the exemption was anchored it returned ahead + # of the strong-key and opaque-credential checks, leaking these verbatim. + assert cleartext not in redact_sensitive_text(text, force=True) + @pytest.mark.parametrize( + "text", + [ + "SSH_AUTH_SOCK=$HOME/.ssh/agent.sock", + "DOCKER_AUTH_CONFIG=/home/u/.docker", + "MY_KEY_PATH=~/.ssh/id_rsa", + "SECRET_DIR=/etc", + ], + ) + def test_real_path_and_var_references_stay_readable(self, text): + assert redact_sensitive_text(text, force=True) == text