Secrets
Helpers for wrapping credential values in a pydantic
SecretStr
and for scrubbing secrets out of arbitrary text (e.g. logs).
Reveal surface
With default Pydantic serialization, nonempty SecretStr values display
as **********; empty values display as an empty string. model_dump()
retains a SecretStr object, while JSON serialization masks it. Custom
serializers can reveal it, and get_secret_value() returns plaintext.
This is display masking, not encryption or memory isolation.
as_secret
Coerce a raw secret string into a SecretStr. None passes through
unchanged (for optional secrets), and an existing SecretStr is returned
as-is (idempotent).
from axm_vault import as_secret
token = as_secret("s3cr3t")
repr(token) # "SecretStr('**********')" — no plaintext
token.get_secret_value() # "s3cr3t" (the only reveal surface)
as_secret(None) # None
as_secret(token) is token # True (idempotent)
With default serializers, a model field typed as SecretStr is masked on dump:
from pydantic import BaseModel, SecretStr
from axm_vault import as_secret
class Config(BaseModel):
token: SecretStr
m = Config(token=as_secret("s3cr3t"))
m.model_dump() # {"token": SecretStr('**********')}
m.model_dump_json() # '{"token":"**********"}'
redact
Mask occurrences of known secret substrings in text for log scrubbing.
Each qualifying secret is replaced by ******** (the exported MASK
constant). A SecretStr argument is accepted and unwrapped via
get_secret_value(), so a redaction site can pass the live secret without
ever holding its plaintext itself.
The pass is hardened against two failure modes:
- Longest-first — secrets are masked in descending length order, so a short secret that is a prefix of a longer one can never mask only the prefix and leave the longer secret's tail exposed.
- Minimum length — secrets shorter than
MIN_REDACT_LEN(4) — and empty ones — are ignored, since masking a tiny substring over-redacts the surrounding text without protecting anything.
from pydantic import SecretStr
from axm_vault import redact
redact("auth=s3cr3t now", "s3cr3t") # "auth=******** now"
redact("nothing to hide") # "nothing to hide"
redact("X=abcdef", "abc", "abcdef") # "X=********" (longest-first, no tail leak)
redact("auth=s3cr3t", SecretStr("s3cr3t")) # "auth=********" (SecretStr unwrapped)
Best-effort, not a security boundary
redact only masks the exact substrings it is given, in the casing it is
given; it cannot catch transformed, encoded, or partial echoes. It is
best-effort log scrubbing. SecretStr also offers masking only; neither
helper redacts arbitrary exception messages automatically.
MASK
The replacement string used by redact: "********".