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
25 changes: 21 additions & 4 deletions src/vouch/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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