Skip to content

security: mask_secrets misses underscore-adjacent credential key names (access_token, DB_PASSWORD, AWS_SECRET_ACCESS_KEY, ...) #646

Description

@philluiz2323

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions