Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/vouch/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,}[\"']?"
)

Expand Down
29 changes: 29 additions & 0 deletions tests/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}-----"
Expand Down
Loading