What happened
src/vouch/secrets.py's _ASSIGNMENT regex wraps its credential-keyword
alternation in \b...\b word boundaries:
_ASSIGNMENT = re.compile(
r"(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b"
r"([\"']?\s*[:=]\s*)"
...
)
In Python's re, \b is a transition between a \w character and a
non-\w character, and _ counts as \w. So \btoken\b matches the
standalone word token, but never matches token inside access_token,
refresh_token, or GITHUB_TOKEN — the _↔t transition is \w→\w,
never a boundary. Same for secret inside client_secret /
AWS_SECRET_ACCESS_KEY, and password inside DB_PASSWORD /
my_password.
Since snake_case / SCREAMING_SNAKE_CASE is the standard convention for
credential env-vars (.env files, shell export, docker-compose,
JSON/YAML config), this excludes the majority of real-world credential
shapes from masking — only a bare token= / secret= / password= (no
prefix/suffix) is caught.
This regex backs two live call sites:
capture.py calls mask_secrets() on session summaries/commands before
they land in the capture buffer — the module's own docstring states "a
secret that reaches it is permanent" once captured.
lifecycle.redact() uses the same pattern as "the backstop for a
credential that reached a durable claim" — so even the manual
remediation path (vouch redact) fails to strip these secrets from an
already-durable claim.
What you expected
mask_secrets() should mask credential values regardless of whether the
key name is a bare word or has a snake_case prefix/suffix, since that's
the dominant real-world naming convention for exactly the credentials this
function exists to catch.
Reproduction
from vouch.secrets import mask_secrets
for text in [
"access_token=abcdefghij1234567890",
"client_secret=abcdefghij1234567890",
"DB_PASSWORD=hunter2superlongpassword",
"AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYabcdefg",
]:
print(text, "->", mask_secrets(text))
Output (ran against current test HEAD):
access_token=abcdefghij1234567890 -> access_token=abcdefghij1234567890
client_secret=abcdefghij1234567890 -> client_secret=abcdefghij1234567890
DB_PASSWORD=hunter2superlongpassword -> DB_PASSWORD=hunter2superlongpassword
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYabcdefg -> AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYabcdefg
None of these are masked — the values pass through verbatim. For
comparison, mask_secrets("token=abcdefghij1234567890") correctly
produces token=[redacted-secret], confirming the boundary is the only
thing standing between working and broken.
tests/test_secrets.py has no coverage of underscore-adjacent key names
— every existing assignment case uses a bare keyword (PASSWORD=,
token=, "password":, 'api_key':).
Environment
- vouch version:
test branch @ current HEAD
- Python version: 3.11+ (repro is pure stdlib
re behavior, version-independent)
- OS: any
- Host: any — triggered via capture (any adapter) or
vouch redact
.vouch/ state
Not required to reproduce — mask_secrets() is a pure string function.
Anything else
#549/#550/#551 fixed a different aspect of this same regex (JSON/quoted-key
delimiter handling — "password": "..." used to break the match on the
closing quote). This is a separate, still-open defect in the keyword
boundary itself, not the delimiter handling those PRs touched.
Suggested fix: replace the \b boundaries with explicit alphanumeric
lookaround, e.g.
(?<![A-Za-z0-9])(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)(?![A-Za-z0-9]),
so underscore-delimited segments (access_token, DB_PASSWORD) match
while true false-positive substrings (tokenized, passwordless) stay
excluded.
What happened
src/vouch/secrets.py's_ASSIGNMENTregex wraps its credential-keywordalternation in
\b...\bword boundaries:In Python's
re,\bis a transition between a\wcharacter and anon-
\wcharacter, and_counts as\w. So\btoken\bmatches thestandalone word
token, but never matchestokeninsideaccess_token,refresh_token, orGITHUB_TOKEN— the_↔ttransition is\w→\w,never a boundary. Same for
secretinsideclient_secret/AWS_SECRET_ACCESS_KEY, andpasswordinsideDB_PASSWORD/my_password.Since snake_case / SCREAMING_SNAKE_CASE is the standard convention for
credential env-vars (
.envfiles, shellexport, docker-compose,JSON/YAML config), this excludes the majority of real-world credential
shapes from masking — only a bare
token=/secret=/password=(noprefix/suffix) is caught.
This regex backs two live call sites:
capture.pycallsmask_secrets()on session summaries/commands beforethey land in the capture buffer — the module's own docstring states "a
secret that reaches it is permanent" once captured.
lifecycle.redact()uses the same pattern as "the backstop for acredential that reached a durable claim" — so even the manual
remediation path (
vouch redact) fails to strip these secrets from analready-durable claim.
What you expected
mask_secrets()should mask credential values regardless of whether thekey name is a bare word or has a snake_case prefix/suffix, since that's
the dominant real-world naming convention for exactly the credentials this
function exists to catch.
Reproduction
Output (ran against current
testHEAD):None of these are masked — the values pass through verbatim. For
comparison,
mask_secrets("token=abcdefghij1234567890")correctlyproduces
token=[redacted-secret], confirming the boundary is the onlything standing between working and broken.
tests/test_secrets.pyhas no coverage of underscore-adjacent key names— every existing assignment case uses a bare keyword (
PASSWORD=,token=,"password":,'api_key':).Environment
testbranch @ current HEADrebehavior, version-independent)vouch redact.vouch/stateNot required to reproduce —
mask_secrets()is a pure string function.Anything else
#549/#550/#551 fixed a different aspect of this same regex (JSON/quoted-key
delimiter handling —
"password": "..."used to break the match on theclosing quote). This is a separate, still-open defect in the keyword
boundary itself, not the delimiter handling those PRs touched.
Suggested fix: replace the
\bboundaries with explicit alphanumericlookaround, e.g.
(?<![A-Za-z0-9])(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)(?![A-Za-z0-9]),so underscore-delimited segments (
access_token,DB_PASSWORD) matchwhile true false-positive substrings (
tokenized,passwordless) stayexcluded.