String Masking for Go Services — PII, PCI, PHI, zero dependencies
🚀 Quick Start | ✨ Features | 📚 Built-in Rules | 🛠 Primitives | 📖 Docs | 📡 API Reference
Table of contents
⚠️ Status- 🔍 Overview
- ✨ Key Features
- ❓ Why mask?
- 🚀 Quick Start
- 📚 Built-in Rules — full catalogue in
docs/rules.md - 🛠 Utility Primitives — full reference in
docs/extending.md - 🧵 Thread Safety
- 🛡 Fail Closed
- 🔧 Configuration
- 🎯 Custom Rules — regex and primitive patterns in
docs/extending.md - 🌍 Regulatory Context
- 📖 API Reference
- 🤖 For AI Assistants
- 🤝 Contributing
- 🔐 Security
- 📄 Licence
mask is stable from v1.0.0 onwards and follows Semantic Versioning: breaking changes to the public API only in a new major version. Pin a specific tag in your go.mod and review the CHANGELOG on every upgrade.
Stop leaking PII through half-baked regexes.
maskis the drop-in redaction library every Go service on the hot path of a log, trace, or audit stream was missing. One import. One call. The original value never reaches the outside world.
Hand-rolled regexes work on the inputs you tested. They leak on the ones you didn't — the email with a + alias, the PAN with an extra space, the phone number from a country you forgot existed, the unicode address your byte-indexed slice chopped mid-character. mask is built so reality can disagree with the pattern and the library still fails safe.
- 🎯 Format-aware by design — preserves PAN separators, email domains, IBAN check digits, phone country codes, and geographic precision so masked fields stay useful for debugging, diffing, and support tickets.
- 🛡 Fail-closed, always — unknown rule?
[REDACTED]. Malformed input? Same-length mask. The original value is never echoed back. Not even once. - 🌍 Unicode-safe from day one — rune-aware so multi-byte UTF-8 is never split mid-character. International names, CJK addresses, emoji in free-text — all handled.
- ⚡ Zero runtime dependencies — stdlib only. No goroutines. No config files. No transitive-dependency CVEs.
- 🧵 Thread-safe like the stdlib — register at init, apply concurrently forever after. Same contract as
database/sql.Register.
mask.Apply("payment_card_pan", "4111-1111-1111-1111") // "4111-11**-****-1111"
mask.Apply("email_address", "[email protected]") // "a****@example.com"
mask.Apply("us_ssn", "123-45-6789") // "***-**-6789"
mask.Apply("iban", "GB82WEST12345698765432") // "GB82**************5432"
mask.Apply("no_such_rule", "anything") // "[REDACTED]" ← fail closed60+ built-in rules across seven categories, covering identifiers in more than a dozen jurisdictions. PCI DSS display modes for PANs. HIPAA pseudonymisation caveats for clinical identifiers. GDPR Art. 4(5) salted hashing for user IDs. Every regulation-aware rule is documented next to the code that delivers it — no spelunking required.
| Feature | Description | Docs |
|---|---|---|
| 📋 Rich built-in rule catalogue | 60+ rules across identity, financial, health, technology, telecom, and country-specific categories | Built-in Rules |
| 🧩 Composable primitives | KeepFirstN, KeepLastN, KeepFirstLast, DeterministicHash, ReplaceRegex, ReducePrecision, and more — every primitive is exposed both as a direct-call helper and as a factory RuleFunc |
Primitives |
| 🌍 Unicode correct | Rune-aware masking for international names, addresses, and free-text content | Unicode correctness |
| 🛡 Fail closed | Unknown rule returns [REDACTED]; malformed input returns a same-length mask; the original value is never echoed |
Fail Closed |
| 🔐 PCI / HIPAA / GDPR aware | Jurisdiction-qualified names and regulation references in the catalogue | Regulatory Context |
| ⚡ Zero dependencies | stdlib only at runtime | — |
| 🧵 Thread-safe after init | Register at startup; apply concurrently from any number of goroutines afterwards | Thread Safety |
| 🔧 Configurable mask character | Global override via SetMaskChar; per-instance via WithMaskChar |
Configuration |
| 🧪 BDD-first testing | Every rule has a Gherkin feature file; consumer-language scenarios pin the contract | Testing |
| 🎯 Custom rules in three lines | mask.Register("my_rule", func(v string) string { ... }) — then use it like any built-in |
Custom Rules |
Because
strings.Replacefails silently, and your production logs are the wrong place to find out.
Every Go project starts with a one-line regex and a TODO. Three outages and an audit later, it becomes a 400-line helper package nobody understands. mask is what that package wants to be when it grows up — fewer bugs, broader coverage, unicode-correct by default, and a fail-closed contract you can actually rely on.
| Approach | Format-aware | Unicode-correct | Built-in catalogue | Fails closed |
|---|---|---|---|---|
Ad-hoc strings.Replace |
No | N/A | No | No — original leaks through |
| Hand-rolled regex | Partial — author-dependent | Partial | No | No — non-match returns original |
github.com/axonops/mask |
Yes — 60+ format-specific rules | Yes — rune-aware by default | Yes — identity, financial, health, tech, telecom, country-specific | Yes — unknown rule ⇒ [REDACTED], malformed input ⇒ same-length mask |
go get github.com/axonops/maskRequires Go 1.26 or later.
package main
import (
"fmt"
"github.com/axonops/mask"
)
func main() {
fmt.Println(mask.Apply("email_address", "[email protected]"))
// Output: a****@example.com
}m := mask.New(mask.WithMaskChar('#'))
fmt.Println(m.Apply("email_address", "[email protected]"))
// Output: a####@example.comfunc init() {
_ = mask.Register("employee_id", mask.KeepFirstNFunc(9))
}
// mask.Apply("employee_id", "EMP-ACME-12345") → "EMP-ACME-*****"For anything with a predictable textual shape that isn't in the built-in catalogue — internal IDs, tokens embedded in log lines, tenant-scoped identifiers — reach for ReplaceRegexFunc. It compiles the pattern once at init and returns a ready-to-register rule; Go's regexp is RE2-backed so there is no ReDoS risk even on adversarial input (with the RE2 feature trade-offs — no backreferences, no lookahead / lookbehind — covered in the full guide).
func init() {
// Any 6-or-more-digit run embedded in free-text becomes [REDACTED].
r, err := mask.ReplaceRegexFunc(`\d{6,}`, "[REDACTED]")
if err != nil {
log.Fatalf("mask: compile free_text_digits: %v", err)
}
_ = mask.Register("free_text_digits", r)
}
// mask.Apply("free_text_digits", "Order #1234567 shipped")
// → "Order #[REDACTED] shipped"Capture groups can preserve context around the secret — (Bearer\s+)[\w-]+ with replacement ${1}**** keeps the scheme and masks the token. The full regex guide (capture groups, a cookbook of patterns, compilation caching, ReDoS safety, when NOT to use regex) lives in docs/extending.md#regex-based-rules.
// Keep the first and last 4 runes, mask the middle — one-off, no registration.
out := mask.KeepFirstLast("SensitiveData", 4, 4, '*')
// out == "Sens*****Data"for _, name := range mask.Rules() {
info, _ := mask.Describe(name)
fmt.Printf("%-25s %-10s %s\n", name, info.Category, info.Description)
}If you are looking for the right rule for a common field, start here.
| I want to mask... | Use rule | Example |
|---|---|---|
| An email address | email_address |
[email protected] → a****@example.com |
| A credit card number | payment_card_pan |
4111-1111-1111-1111 → 4111-11**-****-1111 |
| A US Social Security Number | us_ssn |
123-45-6789 → ***-**-6789 |
| A phone number | phone_number |
+44 7911 123456 → +44 **** **3456 |
| An IPv4 address | ipv4_address |
192.168.1.42 → 192.168.*.* |
| A UUID | uuid |
550e8400-e29b-41d4-a716-446655440000 → 550e8400-****-****-****-********0000 |
| An IBAN | iban |
GB82WEST12345698765432 → GB82**************5432 |
| A medical record number | medical_record_number |
MRN-123456789 → MRN-*****6789 |
| A JWT | jwt_token |
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc → eyJh****.****.****. |
| A UK postcode | postal_code |
SW1A 2AA → SW1A *** |
| A UK National Insurance Number | uk_nino |
AB123456C → AB******C |
| Any free-text secret | full_redact |
anything → [REDACTED] |
| A password field | password |
any non-empty value → ******** |
| An internal / bespoke ID | see Custom rules | compose with KeepFirstN, KeepLastN, KeepFirstLast |
For the full catalogue, see Built-in Rules or call mask.Rules() at runtime.
60+ rules registered out of the box across seven categories. Every rule is fail-closed, honours the configured mask character, and has a concrete input → output example in its godoc.
| Category | Examples |
|---|---|
| Utility primitives | full_redact, same_length_mask, nullify, deterministic_hash |
| Identity — global | email_address, person_name, date_of_birth, passport_number |
| Identity — country-specific | us_ssn, uk_nino, in_aadhaar, br_cpf, mx_curp |
| Financial | payment_card_pan, iban, swift_bic, uk_sort_code |
| Health | medical_record_number, diagnosis_code, prescription_text |
| Technology | ipv4_address, url, jwt_token, uuid, password |
| Telecom + location | phone_number, imei, msisdn, postal_code, geo_coordinates |
👉 Full catalogue with input → output examples for every rule: docs/rules.md
Or discover them at runtime:
for _, name := range mask.Rules() {
info, _ := mask.Describe(name)
fmt.Printf("%-25s %-10s %s\n", name, info.Category, info.Description)
}💡 Missing a rule? If your organisation masks a data type that isn't in this catalogue — a national identifier, a financial code, a telecom format, a sector-specific identifier — open an issue and tell us what it looks like. The catalogue grew from real services; we'd rather add a rule once than have every consumer hand-roll it.
Every primitive is exposed twice — as a Go helper (call it directly inside a custom RuleFunc) and as a factory (pass it to Register). Three the quick ones:
mask.KeepFirstN("Sensitive", 4, '*') // "Sens*****"
mask.KeepFirstLast("SensitiveData", 4, 4, '*') // "Sens*****Data"
_ = mask.Register("employee_id", mask.KeepFirstNFunc(9)) // factory👉 Full primitive table (direct-call signatures, factory signatures, registered rule names) and custom-rule patterns: docs/extending.md
Register (both the package-level function and Masker.Register) MUST NOT be called concurrently with Apply. The contract matches database/sql.Register:
- Call
Registerduring program initialisation, before any goroutine starts callingApply. - Once every Register call has returned, the registry is read-only and
Applyis safe for concurrent use by any number of goroutines. - Built-in rules are stateless pure functions. Custom
RuleFuncimplementations MUST satisfy the same contract.
Violating this contract is a data race and will be reported by the Go race detector (go test -race). The library does NOT defer recover() around custom RuleFunc calls — a panic in a custom rule propagates out of Apply, by design. Custom rules MUST NOT panic; treat a panic as a programmer error and fix it at source.
// Correct — register once at init time.
func init() {
_ = mask.Register("my_rule", myMaskingFunc)
}
// Correct — isolated per-instance registry, no concurrency concerns.
m := mask.New()
_ = m.Register("tenant_rule", tenantMaskingFunc)mask.Apply always returns a string and never an error.
- Unknown rule name →
[REDACTED](the value ofmask.FullRedactMarker). - Known rule, malformed input → a same-length mask of the configured mask character.
- Empty input → empty output (except for full-redact rules, which always return
[REDACTED]).
This contract is uniform across every rule in the catalogue. Consumers can rely on it without per-rule knowledge.
Every built-in rule walks the input as runes, not bytes. Multi-byte UTF-8 sequences (CJK street addresses, emoji in free-text fields, accented Latin letters stored as precomposed code points) are never split mid-character, and output is guaranteed to be valid UTF-8. This matters for dashboards, log viewers, and downstream tooling that may itself panic on invalid UTF-8. Decomposed forms (for example e followed by U+0301 combining acute) are masked rune-by-rune — the library does not run full grapheme-cluster segmentation; if your data stores decomposed diacritics and you need the base letter masked together with its combining mark, normalise to NFC before masking.
The default mask character is *. Override it globally (for the package-level registry) or per instance.
// Global — mutates the package-level registry.
mask.SetMaskChar('#')
// Per instance — isolated to this Masker only.
m := mask.New(mask.WithMaskChar('#'))Built-in rules read the configured character at apply time, so changes are picked up on the next call. The password rule honours the configured character for the 8-rune mask output.
Factory vs. closure for custom rules. Factories such as
KeepFirstNFunc,KeepLastNFunc, andKeepFirstLastFunccaptureDefaultMaskCharat construction time and ignore laterSetMaskChar/WithMaskCharoverrides. If your custom rule must react to the configured character, register a closure that readsm.MaskChar()(or the package-levelmask.MaskChar()) at apply time. Seedocs/extending.mdfor the pattern.
deterministic_hash is registered by default with no salt. For production pseudonymisation you MUST configure keyed hashing via WithKeyedSalt(salt, version) — the salt and version are validated atomically, so you cannot accidentally ship with one half configured:
m := mask.New()
_ = m.Register(
"user_id",
mask.DeterministicHashFunc(
mask.WithKeyedSalt(os.Getenv("MASK_SALT"), "v1"),
),
)Do not hard-code the salt — load it from a secret store or environment variable. Rotate the salt and bump the version together; downstream consumers can tell hashes from different generations apart by the <algo>:<version>:<hex16> output shape. The unsalted path (DeterministicHashFunc() with no options) emits <algo>:<hex16> and is only suitable for development and smoke tests. See SECURITY.md for the full salt-rotation and versioning policy.
A custom rule is a func(string) string registered under a name. Regex is the default extension path and handles most ad-hoc formats; primitive factories cover the remaining "keep N runes" shapes in a one-liner.
Regex — reach for this first when your data has a predictable textual shape the built-in catalogue doesn't cover. ReplaceRegexFunc compiles the pattern once at init and returns a ready-to-register rule; Go's regexp is RE2-backed so there is no ReDoS risk.
// Redact any 6+ digit run embedded in free-text.
r, err := mask.ReplaceRegexFunc(`\d{6,}`, "[REDACTED]")
if err != nil {
log.Fatalf("compile: %v", err)
}
_ = mask.Register("free_text_digits", r)
// mask.Apply("free_text_digits", "Order #1234567 shipped")
// → "Order #[REDACTED] shipped"Primitive factories — for common "keep N runes" shapes:
func init() {
_ = mask.Register("employee_id", mask.KeepFirstNFunc(9)) // keep first 9
_ = mask.Register("account_id", mask.KeepFirstLastFunc(3, 4)) // keep 3+4
_ = mask.Register("internal_ref", mask.KeepLastNFunc(4)) // keep last 4
}
// mask.Apply("account_id", "ACME-1234-5678") → "ACM********5678"For the full regex guide (capture groups, common patterns, compilation caching, when NOT to use regex) and the other patterns (closures, per-instance mask-char, deterministic hashing, fully custom RuleFunc), see docs/extending.md.
Masking is one control in a broader compliance strategy — it is not a substitute for access control, encryption, or retention policy. The table below summarises where the library fits against common regulatory regimes. See SECURITY.md for the full threat model.
| Use case | Fit | Notes |
|---|---|---|
| PCI DSS display modes for PAN | Yes | payment_card_pan, payment_card_pan_first6, payment_card_pan_last4 match the three common display modes. payment_card_cvv is same-length — CVV is Sensitive Authentication Data that MUST NOT be retained post-authorisation. |
| HIPAA Safe Harbor de-identification | No | Identifier rules (including medical_record_number, health_plan_beneficiary_id) are pseudonymisation, not de-identification. Retained trailing digits combined with a date or ZIP remain re-identifiable. Register full_redact under the same rule name if you need Safe Harbor. |
| GDPR pseudonymisation (Art. 4(5)) | Yes, with configured salt | deterministic_hash with WithKeyedSalt(salt, version) meets the GDPR definition. Salt management, rotation, and additional access controls are the operator's responsibility. |
| GDPR anonymisation | No | No rule in this library is anonymisation — all preserved-window rules leak structure, and deterministic_hash is reversible given the input space. |
Full API documentation: pkg.go.dev/github.com/axonops/mask.
A compact summary:
| Function | Purpose |
|---|---|
mask.Apply(name, value) |
Apply a registered rule to a value. |
mask.Register(name, fn) |
Register a custom rule on the package-level registry. |
mask.Rules() |
Return the names of every registered rule. |
mask.Describe(name) |
Return the RuleInfo for a rule (name, category, jurisdiction, description). |
mask.SetMaskChar(c) |
Change the default mask character on the package-level registry. |
mask.New(opts...) |
Construct an isolated Masker. Options: mask.WithMaskChar. |
mask.HasRule(name) |
Check whether a rule is registered. |
mask.DescribeAll() |
Return the RuleInfo metadata for every registered rule. |
mask.MaskChar() |
Return the mask rune currently configured on the package-level registry. |
Two files at the repository root are published specifically for AI coding assistants and automated documentation crawlers:
llms.txt— a concise index (~1000 words) following the llmstxt.org specification, with the core concepts, API surface, integration flow, and common mistakes.llms-full.txt— the complete documentation corpus (llms.txt+ README + godoc + contributing + security + requirements + generated godoc reference) concatenated in a stable order. Regenerated viamake llms-full; CI fails if it drifts.
Contributions are welcome. See CONTRIBUTING.md for branching, commit, PR, testing and release guidance — every masking rule requires a unit test AND a BDD scenario, and coverage is held at 90 % or higher.
Before opening your first pull request:
- Sign the Contributor License Agreement (one-time, done via a PR comment; the CLA Assistant bot walks you through it). The current list of signatories is maintained at
CONTRIBUTORS.md. - Configure signed commits locally (GPG or SSH — see § Signing your commits).
mainrequires signed commits and will reject unsigned merges. - Read the Code of Conduct.
See SECURITY.md for the threat model, salt-rotation policy, and coordinated disclosure procedure. Security-sensitive issues should be reported privately per that document.
Apache Licence 2.0 — Copyright © 2026 AxonOps Limited.