Keep secrets out of your logs.
logmask brings the masking model proven by GitHub Actions (::add-mask::) and
Jenkins credential masking to Go applications as an embeddable library:
register every secret value your application knows, typically at
configuration load time, and logmask replaces those values, plus their
URL-encoded and base64 variants, anywhere they appear in log output.
go get github.com/ihopenre-eng/logmask
Requires Go 1.22 or newer. The only dependency is sirupsen/logrus, and only
for the logrusmask adapter package.
Generic detection of secrets in free-form text is a data-loss-prevention problem: it cannot be solved without false positives. Applications, however, usually know their secrets. They parsed them out of a config file, an environment variable, or a secrets manager. logmask exploits that: exact, cheap, zero-false-positive replacement for known values, plus a small opt-in set of structural detectors for credential shapes that are unambiguous.
m := logmask.New(logmask.WithDetectors(logmask.AllDetectors()...))
// Register secrets as you resolve them from config / env / secret stores.
m.Add(cfg.GitToken, cfg.RegistryPassword)
m.Mask(`pushing to https://ci:s3cr3t@git.example.com/repo.git`)
// pushing to https://***@git.example.com/repo.gitOne Masker is meant to be created once and shared. Mask is safe for
concurrent use, including concurrently with Add, so secrets discovered
mid-run (a token fetched lazily, a password read from a vault) can be
registered at any point.
logger := logrus.StandardLogger()
logger.AddHook(logrusmask.New(m))
// Every entry is masked before formatting, at every level:
logger.Errorf("clone failed for %s", remoteURL) // credentials in remoteURL: maskedlogmask.Writer is a line-buffered io.Writer for streams that bypass your
logger, such as the stdout/stderr of subprocesses:
cmd.Stdout = logmask.NewWriter(os.Stdout, m)
cmd.Stderr = logmask.NewWriter(os.Stderr, m)A secret split across multiple Write calls is still masked as long as it
does not span a newline.
Detectors cover credential shapes the process never handled as discrete
values. All are opt-in (WithDetectors) and scoped to near-zero
false-positive patterns:
| Detector | Redacts |
|---|---|
URLUserinfo() |
scheme://user:pass@host, scheme://token@host (whole userinfo) |
AuthPairs() |
Authorization: Bearer …, PRIVATE-TOKEN: …, api_key=…, password: …, and similar |
KnownTokens() |
GitHub/GitLab/Slack/npm/PyPI/Stripe tokens, AWS access key IDs, JWTs (prefix-identified) |
Explicit non-goals: entropy-based detection and PII heuristics. If you need those, you want a DLP scanner, not a logging library.
Mask is lock-free on the hot path: registered values (longest first) are
compiled into a single-pass strings.Replacer that is swapped atomically
whenever Add registers something new, so the core model costs no regexp
evaluation at all. Detectors are opt-in, and each one guards its precompiled
regexp behind a literal substring pre-filter, so lines that cannot contain a
match never pay regexp cost.
With 50 registered secrets on a typical 95-byte log line
(i5-1335U, go test -bench=. -benchmem):
BenchmarkMask-12 5504928 213.7 ns/op 216 B/op 3 allocs/op
BenchmarkMaskWithDetectors-12 1397176 1633 ns/op 216 B/op 3 allocs/op
Masking is a defense in depth measure, not a substitute for not logging secrets. It cannot help with a secret that was transformed before it reached the log line (hashed, chunked across a newline, re-encoded in a form logmask does not register), and it does not touch data already written to disk or shipped to a log aggregator. Treat it as the last line of defense.
MIT