Skip to content

axonops/mask

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

33 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
mask

mask

String Masking for Go Services β€” PII, PCI, PHI, zero dependencies

CI Go Reference Go Report Card License Status

πŸš€ Quick Start | ✨ Features | πŸ“š Built-in Rules | πŸ›  Primitives | πŸ“– Docs | πŸ“‘ API Reference


Table of contents


βœ… Status

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.

πŸ” Overview

Stop leaking PII through half-baked regexes. mask is 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.

What you get, out of the box

  • 🎯 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.

See it in action

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 closed

60+ 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.


✨ Key Features

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

❓ Why mask?

Because strings.Replace fails 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

πŸš€ Quick Start

Install

go get github.com/axonops/mask

Requires Go 1.26 or later.

Hello world

package main

import (
	"fmt"

	"github.com/axonops/mask"
)

func main() {
	fmt.Println(mask.Apply("email_address", "alice@example.com"))
	// Output: a****@example.com
}

Per-instance masker with a custom mask character

m := mask.New(mask.WithMaskChar('#'))
fmt.Println(m.Apply("email_address", "alice@example.com"))
// Output: a####@example.com

Registering a custom rule

func init() {
	_ = mask.Register("employee_id", mask.KeepFirstNFunc(9))
}

// mask.Apply("employee_id", "EMP-ACME-12345") β†’ "EMP-ACME-*****"

Redacting an ad-hoc format with regex

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.

Composing primitives directly

// Keep the first and last 4 runes, mask the middle β€” one-off, no registration.
out := mask.KeepFirstLast("SensitiveData", 4, 4, '*')
// out == "Sens*****Data"

Discovering rules at runtime

for _, name := range mask.Rules() {
	info, _ := mask.Describe(name)
	fmt.Printf("%-25s %-10s %s\n", name, info.Category, info.Description)
}

Common tasks

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.

πŸ“š Built-in Rules

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.

πŸ›  Utility Primitives

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

🧡 Thread Safety

Register (both the package-level function and Masker.Register) MUST NOT be called concurrently with Apply. The contract matches database/sql.Register:

  • Call Register during program initialisation, before any goroutine starts calling Apply.
  • Once every Register call has returned, the registry is read-only and Apply is safe for concurrent use by any number of goroutines.
  • Built-in rules are stateless pure functions. Custom RuleFunc implementations 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)

πŸ›‘ Fail Closed

mask.Apply always returns a string and never an error.

  • Unknown rule name β†’ [REDACTED] (the value of mask.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.

Unicode correctness

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.

πŸ”§ Configuration

Mask character

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, and KeepFirstLastFunc capture DefaultMaskChar at construction time and ignore later SetMaskChar / WithMaskChar overrides. If your custom rule must react to the configured character, register a closure that reads m.MaskChar() (or the package-level mask.MaskChar()) at apply time. See docs/extending.md for the pattern.

Deterministic hashing (salt and version)

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.

🎯 Custom Rules

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.

🌍 Regulatory Context

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.

πŸ“– API Reference

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.

πŸ€– For AI Assistants

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 via make llms-full; CI fails if it drifts.

🀝 Contributing

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:

πŸ” Security

See SECURITY.md for the threat model, salt-rotation policy, and coordinated disclosure procedure. Security-sensitive issues should be reported privately per that document.

πŸ“„ Licence

Apache Licence 2.0 β€” Copyright Β© 2026 AxonOps Limited.


Made with ❀️ by AxonOps

About

Fail-closed PII, PCI, and PHI masking for Go - 60+ built-in rules (email, PAN, SSN, IBAN, IMEI, and more), rune-aware UTF-8, zero runtime dependencies

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors