From 8da410ab52d76bee732d60a34c2c089b76d41220 Mon Sep 17 00:00:00 2001 From: marktech0813 Date: Fri, 24 Jul 2026 09:29:41 +0000 Subject: [PATCH 1/3] fix(secrets): mask JSON/quoted-key credentials Allow an optional closing quote after the sensitive key name so "password": "..." forms redact like bare key=value assignments. Closes #549 --- src/vouch/secrets.py | 2 +- tests/test_secrets.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py index d6a5a832..5726ebc3 100644 --- a/src/vouch/secrets.py +++ b/src/vouch/secrets.py @@ -39,7 +39,7 @@ # name so the redaction is legible. _ASSIGNMENT = re.compile( r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b" - r"(\s*[:=]\s*)" + r"([\"']?\s*[:=]\s*)" r"[\"']?[^\s\"']{6,}[\"']?" ) diff --git a/tests/test_secrets.py b/tests/test_secrets.py index f293e108..569b4f7e 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -103,3 +103,22 @@ 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).""" + cases = ( + '"password": "hunter2secret"', + "'api_key': 'abcdefghij'", + '"token" : "abcdefghij"', + ) + for text in cases: + out = mask_secrets(text) + assert "hunter2secret" not in out + assert "abcdefghij" not in out + assert REDACTION in out + + +def test_masks_plain_assignment_still_works() -> None: + assert "hunter2secret" not in mask_secrets("password=hunter2secret") + assert "hunter2secret" not in mask_secrets("password: hunter2secret") From 300c9d32cdd7e54b70788f3364d80af1def66926 Mon Sep 17 00:00:00 2001 From: marktech0813 Date: Fri, 24 Jul 2026 09:58:54 +0000 Subject: [PATCH 2/3] fix(secrets): redact quoted credential values as whole units Match double/single-quoted assignment values including whitespace and escapes, and keep the surrounding quotes in the redaction so JSON-shaped output stays valid and cannot leak a trailing token. --- src/vouch/secrets.py | 23 ++++++++++++++++++++--- tests/test_secrets.py | 28 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py index 5726ebc3..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"(?:" + 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 569b4f7e..0ba660e1 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -107,18 +107,26 @@ def test_cli_redact_command(tmp_path, monkeypatch) -> None: def test_masks_json_quoted_key_credentials() -> None: """JSON / quoted-key forms must not leak past the assignment mask (#549).""" - cases = ( - '"password": "hunter2secret"', - "'api_key': 'abcdefghij'", - '"token" : "abcdefghij"', - ) - for text in cases: - out = mask_secrets(text) - assert "hunter2secret" not in out - assert "abcdefghij" not in out - assert REDACTION in out + 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]"' + + escaped = '"password": "say \"hi\" nowxx"' + 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]" From 0ab722d19d89322cc2a87ac47b89c35d5039dc5f Mon Sep 17 00:00:00 2001 From: marktech0813 Date: Fri, 24 Jul 2026 09:59:28 +0000 Subject: [PATCH 3/3] test(secrets): fix escaped-quote regression string --- tests/test_secrets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 0ba660e1..f4be89e7 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -120,7 +120,9 @@ def test_masks_quoted_value_with_whitespace_and_escapes() -> None: assert "hunter2" not in out assert out == '"password": "[redacted-secret]"' - escaped = '"password": "say \"hi\" nowxx"' + # 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]"'