From 9b0a0e34665c1573cad38b47f6ca310ee3b59497 Mon Sep 17 00:00:00 2001 From: boskodev790 Date: Thu, 23 Jul 2026 14:31:13 -0400 Subject: [PATCH 1/2] fix(secrets): mask JSON/quoted-key credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the assignment regex expected the separator (: or =) immediately after a sensitive-looking name, so a quoted key put its closing quote between the name and the separator and broke the match. "password": "..." and 'api_key': '...' — the most common structured secret shape, and exactly the file family vouch writes (settings.json, quoted yaml) — slipped through mask_secrets into the gitignored capture buffer that rolls into a committed session page and the append-only audit log. allow an optional closing quote after the name, folded into the captured separator group so the quote is preserved in the output. every currently-masked case still masks; no new false positives (no separator, value under the 6-char floor, or a quoted word without an assignment stay untouched). --- CHANGELOG.md | 7 +++++++ src/vouch/secrets.py | 9 +++++++-- tests/test_secrets.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a01124b..19ec5e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -273,6 +273,13 @@ All notable changes to vouch are documented here. Format follows in config.yaml (#476). ### Fixed +- secret masking now catches JSON/quoted-key credentials + (`"password": "..."`, `'api_key': '...'`). the key's closing quote sat + between the name and the `:` and broke the assignment regex, so the most + common structured secret shape — and exactly the file family vouch writes + (settings.json, quoted yaml) — slipped through `capture.observe` into the + gitignored buffer that rolls into a committed session page and the + append-only audit log. the value is masked and the key/quotes kept legible (#549). - approve/reject/expire record the audit event *before* moving the proposal to decided/. a crash between the two used to leave a durable decision with no authoritative history; it now leaves a pending diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py index d6a5a832..df189ee4 100644 --- a/src/vouch/secrets.py +++ b/src/vouch/secrets.py @@ -36,10 +36,15 @@ _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. The optional quote after the name captures a +# JSON/quoted-key shape (`"password": "..."`, `'api_key': '...'`) — the single +# most common structured form a pasted credential takes, and exactly the file +# family (settings.json, quoted YAML) this codebase writes. Without it the key's +# closing quote sat between the name and the `:` and broke the match, so those +# leaked. It is inside the captured separator group so the quote is preserved. _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..a51e5e24 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -40,6 +40,35 @@ def test_masks_key_value_assignment_but_keeps_the_key_name() -> None: assert "PASSWORD" in out +def test_masks_json_and_quoted_key_credentials() -> None: + """A quoted key — JSON `"password": "..."` or quoted-YAML/py — is the most + common structured shape a pasted credential takes, and exactly what this + codebase writes (settings.json). The key's closing quote used to sit + between the name and the `:` and break the match, so these leaked.""" + for text, secret in ( + ('"password": "hunter2supersecret"', "hunter2supersecret"), + ("'api_key': 'swordfishalpha'", "swordfishalpha"), + ('"secret":"nowhitespacehere"', "nowhitespacehere"), + ): + out = mask_secrets(text) + assert secret not in out, text + assert REDACTION in out + assert contains_secret(text) is True + # the key name and its quotes stay legible + assert '"password":' in mask_secrets('"password": "hunter2supersecret"') + + +def test_quoted_key_masking_no_false_positive() -> None: + """The quoted-key change must not mask a sensitive-looking word that has + no assignment (no separator, or a too-short value).""" + for text in ( + '"password" is required for login', # quote but no `:`/`=` + "password: hi", # value under the 6-char floor + ): + assert mask_secrets(text) == text + assert contains_secret(text) is False + + def test_masks_private_key_block() -> None: begin = f"-----BEGIN RSA {_PK}-----" end = f"-----END RSA {_PK}-----" From ee800120f3f7dbc79ac5b9c15a37ee53538018c5 Mon Sep 17 00:00:00 2001 From: boskodev790 Date: Fri, 24 Jul 2026 13:33:08 -0400 Subject: [PATCH 2/2] style(secrets): lowercase assignment-comment prose the added assignment-masking comment started sentences with capitals and used JSON/YAML uppercase; src/vouch/** requires lowercase comment prose. no behaviour change. --- src/vouch/secrets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vouch/secrets.py b/src/vouch/secrets.py index df189ee4..1aa810d0 100644 --- a/src/vouch/secrets.py +++ b/src/vouch/secrets.py @@ -36,12 +36,12 @@ _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. The optional quote after the name captures a -# JSON/quoted-key shape (`"password": "..."`, `'api_key': '...'`) — the single +# name so the redaction is legible. the optional quote after the name captures a +# json/quoted-key shape (`"password": "..."`, `'api_key': '...'`) — the single # most common structured form a pasted credential takes, and exactly the file -# family (settings.json, quoted YAML) this codebase writes. Without it the key's +# family (settings.json, quoted yaml) this codebase writes. without it the key's # closing quote sat between the name and the `:` and broke the match, so those -# leaked. It is inside the captured separator group so the quote is preserved. +# leaked. it is inside the captured separator group so the quote is preserved. _ASSIGNMENT = re.compile( r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b" r"([\"']?\s*[:=]\s*)"