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", "alice@example.com") // "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", "alice@example.com"))
// Output: a****@example.com
}m := mask.New(mask.WithMaskChar('#'))
fmt.Println(m.Apply("email_address", "alice@example.com"))
// 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 |
alice@example.com β 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.