{"id":50723923,"url":"https://github.com/axonops/mask","last_synced_at":"2026-06-10T02:30:52.347Z","repository":{"id":352152751,"uuid":"1214029089","full_name":"axonops/mask","owner":"axonops","description":"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","archived":false,"fork":false,"pushed_at":"2026-05-14T14:21:44.000Z","size":3052,"stargazers_count":1,"open_issues_count":3,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-14T15:08:31.837Z","etag":null,"topics":["compliance","data-masking","gdpr","go","golang","hipaa","masking","observability","pci-dss","phi","pii","privacy","pseudonymization","redaction","security"],"latest_commit_sha":null,"homepage":"https://axonops.com","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/axonops.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":"governance_test.go","roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":"CLA.md"}},"created_at":"2026-04-18T03:15:24.000Z","updated_at":"2026-05-14T14:21:46.000Z","dependencies_parsed_at":"2026-05-14T15:03:50.806Z","dependency_job_id":null,"html_url":"https://github.com/axonops/mask","commit_stats":null,"previous_names":["axonops/mask"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/axonops/mask","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/axonops%2Fmask","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/axonops%2Fmask/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/axonops%2Fmask/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/axonops%2Fmask/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/axonops","download_url":"https://codeload.github.com/axonops/mask/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/axonops%2Fmask/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34134633,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-10T02:00:07.152Z","response_time":89,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["compliance","data-masking","gdpr","go","golang","hipaa","masking","observability","pci-dss","phi","pii","privacy","pseudonymization","redaction","security"],"created_at":"2026-06-10T02:30:48.311Z","updated_at":"2026-06-10T02:30:52.335Z","avatar_url":"https://github.com/axonops.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n  \u003cimg src=\".github/images/logo-readme.png\" alt=\"mask\" width=\"128\"\u003e\n\n  # mask\n\n  **String Masking for Go Services — PII, PCI, PHI, zero dependencies**\n\n  [![CI](https://github.com/axonops/mask/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/axonops/mask/actions/workflows/ci.yml)\n  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/axonops/mask/badge)](https://scorecard.dev/viewer/?uri=github.com/axonops/mask)\n  [![Go Reference](https://pkg.go.dev/badge/github.com/axonops/mask.svg)](https://pkg.go.dev/github.com/axonops/mask)\n  [![Go Report Card](https://goreportcard.com/badge/github.com/axonops/mask)](https://goreportcard.com/report/github.com/axonops/mask)\n  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE)\n  [![Release](https://img.shields.io/github/v/release/axonops/mask?label=release\u0026color=brightgreen)](https://github.com/axonops/mask/releases/latest)\n\n  [🚀 Quick Start](#-quick-start) | [✨ Features](#-key-features) | [📚 Built-in Rules](#-built-in-rules) | [🛠 Primitives](#-utility-primitives) | [📖 Docs](./docs/) | [📡 API Reference](https://pkg.go.dev/github.com/axonops/mask)\n\u003c/div\u003e\n\n---\n\n**Table of contents**\n\n- [⚠️ Status](#-status)\n- [🔍 Overview](#-overview)\n- [✨ Key Features](#-key-features)\n- [❓ Why mask?](#-why-mask)\n- [🚀 Quick Start](#-quick-start)\n- [📚 Built-in Rules](#-built-in-rules) — full catalogue in [`docs/rules.md`](./docs/rules.md)\n- [🛠 Utility Primitives](#-utility-primitives) — full reference in [`docs/extending.md`](./docs/extending.md)\n- [🧵 Thread Safety](#-thread-safety)\n- [🛡 Fail Closed](#-fail-closed)\n- [🔧 Configuration](#-configuration)\n- [🎯 Custom Rules](#-custom-rules) — regex and primitive patterns in [`docs/extending.md`](./docs/extending.md)\n- [🌍 Regulatory Context](#-regulatory-context)\n- [📖 API Reference](#-api-reference)\n- [🤖 For AI Assistants](#-for-ai-assistants)\n- [🤝 Contributing](#-contributing)\n- [💬 Support](#-support)\n- [🔐 Security](#-security)\n- [📄 Licence](#-licence)\n\n---\n\n## ✅ Status\n\n`mask` is **stable** from `v1.0.0` onwards and follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html): breaking changes to the public API only in a new major version. Pin a specific tag in your `go.mod` and review the [CHANGELOG](./CHANGELOG.md) on every upgrade.\n\n\u003e **What's new since the last release.** `url` now passes bare hosts through verbatim instead of fail-closing ([#88](https://github.com/axonops/mask/issues/88)). `connection_string` recognises the `pass` short-form key ([#82](https://github.com/axonops/mask/issues/82)) and Cassandra-family multi-contact-point authorities ([#89](https://github.com/axonops/mask/issues/89)). `database_dsn` accepts `tcp4` / `tcp6` / `unix` / `udp` protocols ([#83](https://github.com/axonops/mask/issues/83)). `date_of_birth` recognises `YYYY/MM/DD` ([#84](https://github.com/axonops/mask/issues/84)). `postal_code` accepts spaceless UK postcodes ([#85](https://github.com/axonops/mask/issues/85)). `mac_address` accepts Cisco-dotted format ([#86](https://github.com/axonops/mask/issues/86)). `phone_number` tolerates bodies with leading separators ([#87](https://github.com/axonops/mask/issues/87)). Full notes in the [CHANGELOG](./CHANGELOG.md).\n\n## 🔍 Overview\n\n\u003e **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.\n\nHand-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**.\n\n### What you get, out of the box\n\n- 🎯 **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.\n- 🛡 **Fail-closed, always** — unknown rule? `[REDACTED]`. Malformed input? Same-length mask. The original value is **never** echoed back. Not even once.\n- 🌍 **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.\n- ⚡ **Zero runtime dependencies** — stdlib only. No goroutines. No config files. No transitive-dependency CVEs.\n- 🧵 **Thread-safe like the stdlib** — register at init, apply concurrently forever after. Same contract as `database/sql.Register`.\n\n### See it in action\n\n```go\nmask.Apply(\"payment_card_pan\", \"4111-1111-1111-1111\") // \"4111-11**-****-1111\"\nmask.Apply(\"email_address\",    \"alice@example.com\")   // \"a****@example.com\"\nmask.Apply(\"us_ssn\",           \"123-45-6789\")         // \"***-**-6789\"\nmask.Apply(\"iban\",             \"GB82WEST12345698765432\") // \"GB82**************5432\"\nmask.Apply(\"no_such_rule\",     \"anything\")            // \"[REDACTED]\"   ← fail closed\n```\n\n\u003e **68 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.\n\n---\n\n## ✨ Key Features\n\n\u003cdiv align=\"center\"\u003e\n\n| Feature | Description | Docs |\n|---|---|---|\n| 📋 Rich built-in rule catalogue | 68 rules across identity, financial, health, technology, telecom, and country-specific categories | [Built-in Rules](#-built-in-rules) |\n| 🧩 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](#-utility-primitives) |\n| 🌍 Unicode correct | Rune-aware masking for international names, addresses, and free-text content | [Unicode correctness](#unicode-correctness) |\n| 🛡 Fail closed | Unknown rule returns `[REDACTED]`; malformed input returns a same-length mask; the original value is never echoed | [Fail Closed](#-fail-closed) |\n| 🔐 PCI / HIPAA / GDPR aware | Jurisdiction-qualified names and regulation references in the catalogue | [Regulatory Context](#-regulatory-context) |\n| ⚡ Zero dependencies | stdlib only at runtime | — |\n| 🏎 Hot-path safe | 1 alloc/op on every built-in rule; most rules run in 50-200 ns/op on an M2. Allocation regression guard in CI. | [Performance](./docs/performance.md) |\n| 🧵 Thread-safe after init | Register at startup; apply concurrently from any number of goroutines afterwards | [Thread Safety](#-thread-safety) |\n| 🔧 Configurable mask character | Global override via `SetMaskChar`; per-instance via `WithMaskChar` | [Configuration](#-configuration) |\n| 🧪 BDD-first testing | Every rule has a Gherkin feature file; consumer-language scenarios pin the contract | [Testing](./CONTRIBUTING.md#testing) |\n| 🎯 Custom rules in three lines | `mask.Register(\"my_rule\", func(v string) string { ... })` — then use it like any built-in | [Custom Rules](#-custom-rules) |\n\n\u003c/div\u003e\n\n## ❓ Why mask?\n\n\u003e **Because `strings.Replace` fails silently, and your production logs are the wrong place to find out.**\n\nEvery 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.\n\n\u003cdiv align=\"center\"\u003e\n\n| Approach | Format-aware | Unicode-correct | Built-in catalogue | Fails closed |\n|---|---|---|---|---|\n| Ad-hoc `strings.Replace` | No | N/A | No | No — original leaks through |\n| Hand-rolled regex | Partial — author-dependent | Partial | No | No — non-match returns original |\n| **`github.com/axonops/mask`** | **Yes** — 68 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 |\n\n\u003c/div\u003e\n\n## 🚀 Quick Start\n\n### Install\n\n```sh\ngo get github.com/axonops/mask\n```\n\nRequires Go 1.26 or later.\n\n### Hello world\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/axonops/mask\"\n)\n\nfunc main() {\n\tfmt.Println(mask.Apply(\"email_address\", \"alice@example.com\"))\n\t// Output: a****@example.com\n}\n```\n\n### Per-instance masker with a custom mask character\n\n```go\nm := mask.New(mask.WithMaskChar('#'))\nfmt.Println(m.Apply(\"email_address\", \"alice@example.com\"))\n// Output: a####@example.com\n```\n\n### Registering a custom rule\n\n```go\nfunc init() {\n\t_ = mask.Register(\"employee_id\", mask.KeepFirstNFunc(9))\n}\n\n// mask.Apply(\"employee_id\", \"EMP-ACME-12345\") → \"EMP-ACME-*****\"\n```\n\n\u003e `Register` returns an `error` when the name fails the `^[a-z][a-z0-9_]*$` pattern, when the `RuleFunc` is nil, or when the name is already registered. All three wrap `mask.ErrInvalidRule`. Safe to discard during `init()` if you know none apply; capture it in production code to surface registration bugs early.\n\n### Redacting an ad-hoc format with regex\n\nFor 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).\n\n```go\nfunc init() {\n\t// Any 6-or-more-digit run embedded in free-text becomes [REDACTED].\n\tr, err := mask.ReplaceRegexFunc(`\\d{6,}`, \"[REDACTED]\")\n\tif err != nil {\n\t\tlog.Fatalf(\"mask: compile free_text_digits: %v\", err)\n\t}\n\t_ = mask.Register(\"free_text_digits\", r)\n}\n\n// mask.Apply(\"free_text_digits\", \"Order #1234567 shipped\")\n//   → \"Order #[REDACTED] shipped\"\n```\n\nCapture 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`](./docs/extending.md#regex-based-rules).\n\n### Composing primitives directly\n\n```go\n// Keep the first and last 4 runes, mask the middle — one-off, no registration.\nout := mask.KeepFirstLast(\"SensitiveData\", 4, 4, '*')\n// out == \"Sens*****Data\"\n```\n\n### Discovering rules at runtime\n\n```go\nfor _, name := range mask.Rules() {\n\tinfo, _ := mask.Describe(name)\n\tfmt.Printf(\"%-25s %-10s %s\\n\", name, info.Category, info.Description)\n}\n```\n\n### Common tasks\n\nIf you are looking for the right rule for a common field, start here.\n\n| I want to mask... | Use rule | Example |\n|---|---|---|\n| An email address | [`email_address`](./docs/rules.md#identity) | `alice@example.com` → `a****@example.com` |\n| A credit card number | [`payment_card_pan`](./docs/rules.md#financial) | `4111-1111-1111-1111` → `4111-11**-****-1111` |\n| A US Social Security Number | [`us_ssn`](./docs/rules.md#country-specific-identity) | `123-45-6789` → `***-**-6789` |\n| A phone number | [`phone_number`](./docs/rules.md#telecom-and-location) | `+44 7911 123456` → `+44 **** **3456` |\n| An IPv4 address | [`ipv4_address`](./docs/rules.md#technology) | `192.168.1.42` → `192.168.*.*` |\n| A UUID | [`uuid`](./docs/rules.md#technology) | `550e8400-e29b-41d4-a716-446655440000` → `550e8400-****-****-****-********0000` |\n| An IBAN | [`iban`](./docs/rules.md#financial) | `GB82WEST12345698765432` → `GB82**************5432` |\n| A medical record number | [`medical_record_number`](./docs/rules.md#health) | `MRN-123456789` → `MRN-*****6789` |\n| A JWT | [`jwt_token`](./docs/rules.md#technology) | `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc` → `eyJh****.****.****.` |\n| A UK postcode | [`postal_code`](./docs/rules.md#telecom-and-location) | `SW1A 2AA` → `SW1A ***` |\n| A UK National Insurance Number | [`uk_nino`](./docs/rules.md#country-specific-identity) | `AB123456C` → `AB******C` |\n| Any free-text secret | [`full_redact`](./docs/rules.md#utility-primitives-rules) | anything → `[REDACTED]` |\n| A password field | [`password`](./docs/rules.md#technology) | any non-empty value → `********` |\n| An internal / bespoke ID | see [Custom rules](#-custom-rules) | compose with `KeepFirstN`, `KeepLastN`, `KeepFirstLast` |\n| A per-tenant / per-request mask | use `mask.New()` per scope | each `Masker` has its own registry and mask character — see [Multi-tenant isolation](#multi-tenant-isolation) |\n\nFor the full catalogue, see [Built-in Rules](#-built-in-rules) or call `mask.Rules()` at runtime.\n\n## 📚 Built-in Rules\n\n**68 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.\n\n| Category | Examples |\n|---|---|\n| Utility primitives | `full_redact`, `same_length_mask`, `nullify`, `deterministic_hash` |\n| Identity — global | `email_address`, `person_name`, `date_of_birth`, `passport_number` |\n| Identity — country-specific | `us_ssn`, `uk_nino`, `in_aadhaar`, `br_cpf`, `mx_curp` |\n| Financial | `payment_card_pan`, `iban`, `swift_bic`, `uk_sort_code` |\n| Health | `medical_record_number`, `diagnosis_code`, `prescription_text` |\n| Technology | `ipv4_address`, `url`, `jwt_token`, `uuid`, `password` |\n| Telecom + location | `phone_number`, `imei`, `msisdn`, `postal_code`, `geo_coordinates` |\n\n👉 **Full catalogue with `input → output` examples for every rule: [`docs/rules.md`](./docs/rules.md)**\n\nOr discover them at runtime:\n\n```go\nfor _, name := range mask.Rules() {\n    info, _ := mask.Describe(name)\n    fmt.Printf(\"%-25s %-10s %s\\n\", name, info.Category, info.Description)\n}\n```\n\n\u003e **💡 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](https://github.com/axonops/mask/issues/new?title=New%20built-in%20rule%3A%20%3Cname%3E\u0026labels=rule-request)** 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.\n\n## 🛠 Utility Primitives\n\nEvery primitive is exposed twice — as a Go helper (call it directly inside a custom `RuleFunc`) and as a factory (pass it to `Register`). The three quickest:\n\n```go\nmask.KeepFirstN(\"Sensitive\", 4, '*')          // \"Sens*****\"\nmask.KeepFirstLast(\"SensitiveData\", 4, 4, '*') // \"Sens*****Data\"\n\n_ = mask.Register(\"employee_id\", mask.KeepFirstNFunc(9)) // factory\n```\n\n👉 **Full primitive table (direct-call signatures, factory signatures, registered rule names) and custom-rule patterns: [`docs/extending.md`](./docs/extending.md)**\n\n## 🧵 Thread Safety\n\n`Register` (both the package-level function and `Masker.Register`) MUST NOT be called concurrently with `Apply`. The contract matches `database/sql.Register`:\n\n- Call `Register` during program initialisation, before any goroutine starts calling `Apply`.\n- Once every Register call has returned, the registry is read-only and `Apply` is safe for concurrent use by any number of goroutines.\n- Built-in rules are stateless pure functions. Custom `RuleFunc` implementations MUST satisfy the same contract.\n\nViolating 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.\n\nAs of `v1.0.1`, the package-level registry's lazy initialisation is also safe under concurrent first-call: `Apply` and `HasRule` / `Rules` / `Describe` on a zero-value or never-touched `Masker` from many goroutines simultaneously no longer race against the built-in registrar. Previously a parallel first-caller could observe the registry between the empty-map publish and the built-in registration, falling through to `[REDACTED]` for every rule. Regression tests `TestZeroValueMasker_ParallelFirstApply` and `TestZeroValueMasker_ParallelFirstHasRule` pin the fix.\n\n```go\n// Correct — register once at init time.\nfunc init() {\n\t_ = mask.Register(\"my_rule\", myMaskingFunc)\n}\n\n// Correct — isolated per-instance registry, no concurrency concerns.\nm := mask.New()\n_ = m.Register(\"tenant_rule\", tenantMaskingFunc)\n```\n\n## 🛡 Fail Closed\n\n`mask.Apply` always returns a string and never an error.\n\n- Unknown rule name → `[REDACTED]` (the value of `mask.FullRedactMarker`).\n- Known rule, malformed input → a same-length mask of the configured mask character.\n- Empty input → empty output (except for full-redact rules, which always return `[REDACTED]`).\n\nThis contract is uniform across every rule in the catalogue. Consumers can rely on it without per-rule knowledge.\n\n### Unicode correctness\n\nEvery 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.\n\n## 🔧 Configuration\n\n### Mask character\n\nThe default mask character is `*`. Override it globally (for the package-level registry) or per instance.\n\n```go\n// Global — mutates the package-level registry.\nmask.SetMaskChar('#')\n\n// Per instance — isolated to this Masker only.\nm := mask.New(mask.WithMaskChar('#'))\n```\n\nBuilt-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.\n\n\u003e **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`](./docs/extending.md#3-honour-per-instance-mask-character-config) for the pattern.\n\n### Multi-tenant isolation\n\nEach `*Masker` carries its own registry, mask character, and (where configured) hashing options. Construct one per isolation scope — per tenant, per request, per process — and the scopes cannot leak rules or configuration across one another.\n\n```go\n// A per-tenant Masker with a tenant-specific salt for deterministic hashing\n// and a custom mask character. The package-level registry is untouched.\nfunc newTenantMasker(tenantID string, salt string) *mask.Masker {\n\tm := mask.New(mask.WithMaskChar('•'))\n\t_ = m.Register(\n\t\t\"tenant_user_id\",\n\t\tmask.DeterministicHashFunc(\n\t\t\tmask.WithKeyedSalt(salt, \"v1\"),\n\t\t),\n\t)\n\treturn m\n}\n```\n\nPer-instance `Masker`s honour the same thread-safety contract as the package-level registry — register once during initialisation for that scope, then apply concurrently. Two `Masker`s registered at the same time from different goroutines do not race with each other.\n\n### Deterministic hashing (salt and version)\n\n`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:\n\n```go\nm := mask.New()\n_ = m.Register(\n\t\"user_id\",\n\tmask.DeterministicHashFunc(\n\t\tmask.WithKeyedSalt(os.Getenv(\"MASK_SALT\"), \"v1\"),\n\t),\n)\n```\n\nDo 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 `\u003calgo\u003e:\u003cversion\u003e:\u003chex16\u003e` output shape. The unsalted path (`DeterministicHashFunc()` with no options) emits `\u003calgo\u003e:\u003chex16\u003e` and is only suitable for development and smoke tests. See [SECURITY.md](./SECURITY.md) for the full salt-rotation and versioning policy.\n\n## 🎯 Custom Rules\n\nA 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.\n\n**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.\n\n```go\n// Redact any 6+ digit run embedded in free-text.\nr, err := mask.ReplaceRegexFunc(`\\d{6,}`, \"[REDACTED]\")\nif err != nil {\n    log.Fatalf(\"compile: %v\", err)\n}\n_ = mask.Register(\"free_text_digits\", r)\n// mask.Apply(\"free_text_digits\", \"Order #1234567 shipped\")\n//   → \"Order #[REDACTED] shipped\"\n```\n\n**Primitive factories** — for common \"keep N runes\" shapes:\n\n```go\nfunc init() {\n    _ = mask.Register(\"employee_id\", mask.KeepFirstNFunc(9))              // keep first 9\n    _ = mask.Register(\"account_id\",  mask.KeepFirstLastFunc(3, 4))        // keep 3+4\n    _ = mask.Register(\"internal_ref\", mask.KeepLastNFunc(4))              // keep last 4\n}\n// mask.Apply(\"account_id\", \"ACME-1234-5678\") → \"ACM********5678\"\n```\n\nFor 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`](./docs/extending.md).\n\n## 🌍 Regulatory Context\n\nMasking 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](./SECURITY.md) for the full threat model.\n\n| Use case | Fit | Notes |\n|---|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\n## 📖 API Reference\n\nFull API documentation: [pkg.go.dev/github.com/axonops/mask](https://pkg.go.dev/github.com/axonops/mask).\n\nA compact summary:\n\n| Function | Purpose |\n|---|---|\n| `mask.Apply(name, value)` | Apply a registered rule to a value. |\n| `mask.Register(name, fn)` | Register a custom rule on the package-level registry. |\n| `mask.Rules()` | Return the names of every registered rule. |\n| `mask.Describe(name)` | Return the `RuleInfo` for a rule (name, category, jurisdiction, description). |\n| `mask.SetMaskChar(c)` | Change the default mask character on the package-level registry. |\n| `mask.New(opts...)` | Construct an isolated `Masker`. Options: `mask.WithMaskChar`. |\n| `mask.HasRule(name)` | Check whether a rule is registered. |\n| `mask.DescribeAll()` | Return the `RuleInfo` metadata for every registered rule. |\n| `mask.MaskChar()` | Return the mask rune currently configured on the package-level registry. |\n\n## 🤖 For AI Assistants\n\nTwo files at the repository root are published specifically for AI coding assistants and automated documentation crawlers:\n\n- [`llms.txt`](./llms.txt) — a concise index (~1000 words) following the [llmstxt.org](https://llmstxt.org/) specification, with the core concepts, API surface, integration flow, and common mistakes.\n- [`llms-full.txt`](./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.\n\n## 🤝 Contributing\n\nContributions are welcome. See [CONTRIBUTING.md](./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.\n\nBefore opening your first pull request:\n\n- Sign the [Contributor License Agreement](./CLA.md) (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`](./CONTRIBUTORS.md).\n- Configure signed commits locally (GPG or SSH — see [§ Signing your commits](./CONTRIBUTING.md#signing-your-commits)). `main` requires signed commits and will reject unsigned merges.\n- Read the [Code of Conduct](./CODE_OF_CONDUCT.md).\n\n## 💬 Support\n\n- **Questions, ideas, show-and-tell:** [GitHub Discussions](https://github.com/axonops/mask/discussions).\n- **Bug reports / feature requests:** [open an issue](https://github.com/axonops/mask/issues/new/choose).\n- **Security:** see [SECURITY.md](./SECURITY.md) — vulnerabilities are reported privately, not via the public issue tracker.\n\n## 🔐 Security\n\nSee [SECURITY.md](./SECURITY.md) for the threat model, salt-rotation policy, and coordinated disclosure procedure. Security-sensitive issues should be reported privately per that document.\n\nReleases are signed with SLSA/Sigstore build-provenance attestations — verify with `gh attestation verify \u003cartefact\u003e --owner axonops`. See [SECURITY.md §Verifying a release](./SECURITY.md#verifying-a-release).\n\n## 📄 Licence\n\n[Apache Licence 2.0](./LICENSE) — Copyright © 2026 AxonOps Limited.\n\n---\n\n\u003cdiv align=\"center\"\u003e\n  \u003csub\u003eMade with ❤️ by \u003ca href=\"https://axonops.com\"\u003eAxonOps\u003c/a\u003e\u003c/sub\u003e\n\u003c/div\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faxonops%2Fmask","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faxonops%2Fmask","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faxonops%2Fmask/lists"}