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..1aa810d0 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}-----"