diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py index d6a5a832..d30dd22c 100644 --- a/src/vouch/secrets.py +++ b/src/vouch/secrets.py @@ -36,20 +36,37 @@ _BEARER = re.compile(r"(?i)\b(Bearer)\s+[A-Za-z0-9._~+/=-]{10,}") # key=value / key: value for sensitive-looking names — mask the value, keep the -# name so the redaction is legible. +# name so the redaction is legible. Optional quote after the name covers JSON / +# quoted-key shapes (`"password": "..."`). Quoted values are matched as a whole +# unit (whitespace + escapes allowed) and their delimiters are preserved so the +# redacted form stays structurally valid and cannot leak a trailing token. _ASSIGNMENT = re.compile( r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b" - r"(\s*[:=]\s*)" - r"[\"']?[^\s\"']{6,}[\"']?" + r"([\"']?\s*[:=]\s*)" + r"(?:" + r"\"((?:\\.|[^\"\\]){6,})\"" + r"|'((?:\\.|[^'\\]){6,})'" + r"|([^\s\"']{6,})" + r")" ) +def _redact_assignment(match: re.Match[str]) -> str: + """Keep key + separator; replace value, preserving quote delimiters.""" + head = f"{match.group(1)}{match.group(2)}" + if match.group(3) is not None: + return f'{head}"{REDACTION}"' + if match.group(4) is not None: + return f"{head}'{REDACTION}'" + return f"{head}{REDACTION}" + + def mask_secrets(text: str) -> str: """Return ``text`` with detected secrets replaced by :data:`REDACTION`.""" for pat in _SECRET_PATTERNS: text = pat.sub(REDACTION, text) text = _BEARER.sub(rf"\1 {REDACTION}", text) - text = _ASSIGNMENT.sub(rf"\1\2{REDACTION}", text) + text = _ASSIGNMENT.sub(_redact_assignment, text) return text diff --git a/tests/test_secrets.py b/tests/test_secrets.py index f293e108..f4be89e7 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -103,3 +103,32 @@ def test_cli_redact_command(tmp_path, monkeypatch) -> None: result = CliRunner().invoke(cli, ["redact", "c1"]) assert result.exit_code == 0, result.output assert "ghp_" not in store.get_claim("c1").text + + +def test_masks_json_quoted_key_credentials() -> None: + """JSON / quoted-key forms must not leak past the assignment mask (#549).""" + assert mask_secrets('"password": "hunter2secret"') == '"password": "[redacted-secret]"' + assert mask_secrets("'api_key': 'abcdefghij'") == "'api_key': '[redacted-secret]'" + assert mask_secrets('"token" : "abcdefghij"') == '"token" : "[redacted-secret]"' + + +def test_masks_quoted_value_with_whitespace_and_escapes() -> None: + """Quoted values are whole units — whitespace must not leak a trailing token.""" + text = '"password": "hunter2 secret"' + out = mask_secrets(text) + assert "hunter2 secret" not in out + assert "hunter2" not in out + assert out == '"password": "[redacted-secret]"' + + # Value body includes escaped quotes: say \"hi\" nowxx + escaped = '"password": "say \\"hi\\" nowxx"' + assert '\\"hi\\"' in escaped + out2 = mask_secrets(escaped) + assert "nowxx" not in out2 + assert out2 == '"password": "[redacted-secret]"' + + +def test_masks_plain_assignment_still_works() -> None: + assert "hunter2secret" not in mask_secrets("password=hunter2secret") + assert "hunter2secret" not in mask_secrets("password: hunter2secret") + assert mask_secrets("password=hunter2secret") == "password=[redacted-secret]"