masker

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: MIT Imports: 20 Imported by: 0

README

go-masker

Go Reference CI OpenSSF Best Practices SLSA 3

go-masker is a Go library for fail-closed masking of sensitive data before it reaches logs, diagnostics, traces, or other observability systems.

One MaskJSON call on a payload, with the default policy and no configuration:

Field In Out
password hunter2 [REDACTED]
card 4111 1111 1111 1111 **** **** **** 1111
email alice@example.com a***@example.com
user_id 884213 **4213
amount 1499 1499

Each field got the treatment its name implies, amount was left alone, and nothing had to be listed by hand.

It provides one policy and rule model for:

  • strings and scalar values;
  • arbitrary nested Go values;
  • JSON documents and readers;
  • struct tags;
  • HTTP headers and URLs through httpmask;
  • log/slog attributes through slogmask;
  • JSON log lines from zerolog through zerologmask.

The core has no third-party runtime dependencies and does not depend on an HTTP framework or logging library.

Why a library instead of a field filter

A list of field names in a logger config covers the fields you remembered, at the depth you remembered them. This library is built around the cases that list misses:

  • Errors never fall back to the input. Every operation returns a safe marker on failure. A filter that errors typically logs the raw value, which is the moment you needed it least.
  • Depth is not special. The same policy applies to a nested JSON object, a struct field, a map inside a slice, a URL query parameter and an HTTP header.
  • Hostile input stays bounded. Traversal depth, visited nodes and input size are capped, and the depth limit itself cannot exceed 10,000, so deeply nested input cannot exhaust the goroutine stack.
  • Output does not drift. Masking a fixed corpus under Go 1.23 through 1.27 produces byte-identical results; the digests are recorded in PERFORMANCE.md.

It is not a substitute for not collecting the secret in the first place, and it cannot prove that a custom rule you wrote is safe.

Each of those claims is checked by the suite; see How it is tested.

Table of contents

Supported Go versions

The library requires Go 1.23 or newer and uses only the standard library. The CI workflow runs the tests, the race suite, the correctness matrix and a fuzz smoke pass on every supported minor release — 1.23.x, 1.24.x, 1.25.x, 1.26.x, 1.27.x — plus stable, so a new Go release is covered on the day it ships. A separate job runs govulncheck on every push.

Installation

go get github.com/icntswm/go-masker

Import the core package as masker:

import "github.com/icntswm/go-masker"

The adapters are separate packages in the same module:

import (
	"github.com/icntswm/go-masker/httpmask"    // HTTP headers and URLs
	"github.com/icntswm/go-masker/slogmask"    // log/slog attributes
	"github.com/icntswm/go-masker/zerologmask" // zerolog and other JSON-line loggers
)

Quick start

package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	value, err := m.MaskValue("password", "correct-horse-battery-staple")
	if err != nil {
		panic(err)
	}
	fmt.Println(value)
	// Output: [REDACTED]
}

Masker instances are immutable and safe for concurrent use after successful construction. Operations copy input containers and never mutate the source.

What is masked

DefaultPolicy matches complete field names case-insensitively. The comparison also ignores the separator characters _, -, and ., so accessToken, access-token, and ACCESS.TOKEN all match the access_token binding. Its default bindings are:

Keys Rule
password, passwd, passphrase full
token, access_token, refresh_token, api_key, apikey, secret, client_secret, id_token, private_key, session_id, credentials, auth_token token
email, e-mail email
phone, phone_number, mobile phone
id, user_id, customer_id ID
card, card_number, pan card
authorization, cookie, set-cookie, x-api-key, x-auth-token, proxy-authorization, x-csrf-token, cvv, cvc full

Built-in rules are available directly through PasswordRule, TokenRule, FullRule, EmailRule, PhoneRule, IDRule, and CardRule.

Partial rules preserve only a deliberately limited safe shape. Short or ambiguous phone/card values and values containing unexpected text are fully redacted.

Core concepts

Policies decide whether a field should be masked; rules transform the selected scalar value. A policy can be assembled from bindings or written as a function:

policy := masker.PolicyFunc(func(field masker.Field) (masker.Decision, error) {
	if field.Key == "tax_id" {
		return masker.Decision{Rule: masker.FullRule()}, nil
	}
	return masker.Decision{}, nil
})

m, err := masker.New(masker.Chain(masker.DefaultPolicy(), policy))

Policies in a Chain are evaluated in order. The first policy that gives an opinion wins. New rejects empty chains and chains containing nil policies.

A zero Decision means "no opinion". Decision{Omit: true} removes an object or map member entirely; array elements keep their position and become null, so the shape of a list is never altered.

Values are seen the way encoding/json would render them: a time.Time, net.IP, or any other encoding.TextMarshaler is masked as its text, and a []byte as its base64 form. A MarshalText error or panic fails closed, and the method runs on a copy of the value, so it cannot change your data; a marshaler that holds pointers, maps, or locks is walked field by field instead.

NewKeyPolicy rejects empty keys (including keys that are made of separator characters only), nil rules, and fold-equivalent keys bound to different rules, so an ambiguous configuration fails at construction instead of resolving silently at run time.

Named custom rules make the rule name visible in diagnostics:

rule, err := masker.NewRule("tenant-id", func(input masker.RuleInput) (string, error) {
	return input.Redaction, nil
})

Partial custom rules must slice on runes, not bytes: a byte offset can split a multi-byte character, and the library rejects rule output that is not valid UTF-8.

Option is intentionally a closed type. Use the exported With* constructors rather than implementing options against the internal configuration type.

Reflection results are normalized for logging: safe scalars become strings unless WithPreserveSafeTypes keeps booleans, integers, unsigned integers, floats, and strings in their concrete types. Sensitive values are always strings, and a non-sensitive json.Number is always retained as json.Number to avoid precision loss.

Runnable examples for every exported constructor, option, rule, and adapter are in the package documentation. They are executed and output-checked by go test, so they cannot drift from the implementation.

JSON

MaskJSON accepts exactly one valid JSON document and returns valid JSON. It uses json.Number semantics for safe numbers, preserves last-wins behavior for duplicate object keys, and uses a streaming walker with depth, node, and input limits.

input := []byte(`{"user":"alice","password":"secret","balance":9007199254740993}`)
output, err := m.MaskJSON(input)
if err != nil {
	// output is a safe JSON root fallback, never the original input.
}
fmt.Println(string(output))
// {"balance":9007199254740993,"password":"[REDACTED]","user":"alice"}

Object keys are sorted in the output, and balance keeps its exact value. That number is 2^53+1, which float64 rounds down to 2^53; safe numbers are carried through as json.Number instead, so the digits survive.

MaskJSONReader reads the complete reader into memory before processing. Use WithMaxInputBytes to bound accepted input:

m, err := masker.New(masker.DefaultPolicy(), masker.WithMaxInputBytes(2<<20))
output, err := m.MaskJSONReader(reader)

There is intentionally no writer API in the current release: a writer cannot retract an unsafe prefix if a later parse error is found.

Struct tags

Struct tags select a built-in rule explicitly:

type Event struct {
	User     string `json:"user"`
	Password string `mask:"password"`
	Internal string `json:"-"`
}

masked, err := m.MaskAny(Event{
	User: "alice", Password: "secret", Internal: "not logged",
})

Supported tag values are full, email, phone, id, card, password, token, and omit. The precedence is:

  1. omit;
  2. full or another explicit tag rule;
  3. the configured policy;
  4. ordinary safe traversal.

The tag grammar is strict: an unsupported value or a comma-separated option is a configuration error rather than a silent fallback. The result is normalized to map[string]any/[]any containers. Struct field promotion follows the relevant encoding/json rules, including same-depth tagged-field selection and ignored unexported embedded non-struct fields.

A custom rule can join the grammar through WithTagRule. The name must not be empty, contain a comma, a space, or a quote, and built-in names and omit cannot be redefined:

lastTwo, err := masker.NewRule("last2", func(input masker.RuleInput) (string, error) {
	runes := []rune(input.Value)
	if len(runes) < 2 {
		return "**", nil
	}
	return string(runes[len(runes)-2:]), nil
})
m, err := masker.New(masker.DefaultPolicy(), masker.WithTagRule("last2", lastTwo))

HTTP headers and URLs

The httpmask adapter copies headers and URLs before applying the core policy:

core, err := masker.New(masker.DefaultPolicy())
adapter, err := httpmask.New(core)

headers, err := adapter.Headers(http.Header{
	"Authorization": {"Bearer secret"},
	"X-Trace":       {"trace-id"},
})
maskedURL, err := adapter.URLString(
	"https://user:password@example.com/?token=secret&keep=value#fragment",
)

Cookie and Set-Cookie values and URL userinfo are always fully redacted. Sensitive query parameters use the core policy. Query keys are sorted and URL escaping may be normalized. URL fragments are redacted by default, because an OAuth implicit-flow token arrives there; WithPreserveFragment keeps them.

log/slog

The slogmask adapter plugs the core policy into any slog handler through ReplaceAttr:

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
	ReplaceAttr: slogmask.ReplaceAttr(m),
}))
logger.Info("login", "user", "alice", "password", "hunter2")
// {"time":"…","level":"INFO","msg":"login","user":"alice","password":"[REDACTED]"}

Attributes in groups are matched by their own key, with the group names in the policy path. Attributes added through logger.With and WithGroup, and the values returned by LogValue, are masked the same way. A safe scalar keeps its type, a masked one is logged as a string, an error is masked as its text, and an Omit decision drops the attribute. The built-in time, level, message, and source attributes, and the message text itself, are not masked: pass secrets as attributes, never in the message.

zerolog

The zerologmask adapter wraps the logger's writer, because zerolog serializes its fields as they are added and its hooks cannot change what is written:

logger := zerolog.New(zerologmask.NewWriter(os.Stdout, m))
logger.Info().Str("user", "alice").Str("password", "hunter2").Msg("login")
// {"level":"info","message":"login","password":"[REDACTED]","user":"alice"}

Masking the output line covers every field, including those added through Interface or RawJSON. The line is re-encoded, so its keys come out sorted, as in every JSON document the masker writes. Any logger that writes one JSON object per line works the same way. For human-readable output put the masking writer in front of zerolog.ConsoleWriter, so the console formats an already masked line. A writer that routes by level, such as a zerolog.MultiLevelWriter, loses that routing when wrapped: wrap each destination instead. A line the masker cannot parse is replaced by {"message":"[REDACTED]"}, and the message text itself is not masked: keep secrets out of the message.

Errors and fail-closed behavior

Every public operation returns a safe fallback on an error; it never returns the original unsafe value. Use errors.Is for a category and errors.As for safe operation context:

var detail *masker.MaskError
output, err := m.MaskJSON([]byte(`{"password":`))
if err != nil && errors.Is(err, masker.ErrInvalidJSON) {
	fmt.Println(string(output)) // [REDACTED] as a JSON string
}
if errors.As(err, &detail) {
	fmt.Println(detail.Code, detail.Path)
}

Exported Err* values identify categories such as ErrInvalidJSON, ErrInvalidUTF8, ErrDepthLimit, ErrNodeLimit, ErrCycle, and ErrPanic. ErrorCode constants use the corresponding Code* names.

Never substitute the original input for an error result in application code or in an adapter. The public operations already return a safe fallback, and replacing it is the one change that reintroduces the leak the library prevents.

Limits and security

Default limits are depth 32, 100,000 visited nodes, and 8 MiB of JSON input. Traversal recurses once per nesting level, so WithMaxDepth rejects values above 10,000: a Go stack overflow is fatal and could not fail closed. Input skipped after a limit trips is scanned iteratively. Memory still grows with the input because the public byte-slice and reader APIs retain the complete document during masking.

Review custom policies and rules as security-sensitive code. A custom rule's output is checked for valid UTF-8, but the library cannot prove that arbitrary custom logic removed every secret.

Read SECURITY.md for vulnerability reporting and THREAT_MODEL.md for the security assumptions and failure model.

How it is tested

A masking library is only worth what its test suite proves, so the evidence is listed rather than asserted. There are 6,914 lines of tests against 4,815 lines of shipped code.

Check Evidence
Masking scenarios 260 generated cases across JSON, reflection, URLs and headers; each checks the masked result, not just that nothing panicked
Security goldens 48 recorded decisions in 8 files, covering rules, key casing, limits, nesting, errors and URLs
Fuzzing 5 targets: JSON, strings, case-folded policy lookup, JSON/reflection parity, URLs
Logger adapters slogmask through the real log/slog handlers; zerologmask against the real zerolog in a separate test-only module, so the library keeps no dependency
Examples 29, executed and output-checked, so documentation cannot drift from behavior
Coverage 84.9% core, 90.7% httpmask, 88.6% slogmask, 94.9% zerologmask
Go versions tests, race suite, matrix and fuzz smoke on 1.23.x through 1.27.x plus stable
Supply chain govulncheck on every push, reporting standard-library advisories the code actually reaches

The version matrix earns its cost: it caught a change in encoding/json string escaping in Go 1.27 on the day stable moved. A separate check then confirmed what mattered — masking a fixed corpus under every supported release still produces byte-identical output.

How this was built

This library was written with AI assistance, using Anthropic's Claude Code and OpenAI's Codex. Everything above is how that is kept honest: a change has to survive the same suite on every supported Go release before it ships. Provenance is not a substitute for review - the maintainer answers for what is here regardless of how it was produced, and you should read it the way you would read any dependency that handles secrets.

Performance

The streaming JSON walker, bounded key cache, direct encoder, and per-masker reflection metadata cache are designed for predictable behavior on nested and wide payloads. On an M3 Pro, full redaction of a string costs 16 ns and allocates nothing, and a 10,000-record JSON document is masked at roughly 130 MB/s. Throughput stays flat as documents grow wider or longer, which matters more than the absolute numbers; the method and the full tables are in PERFORMANCE.md.

Run local checks and benchmarks with:

make test
make race
make bench
make bench-matrix

Releases

Releases carry a source archive and a SLSA build attestation, so the archive can be traced to the workflow and the tag that produced it:

slsa-verifier verify-artifact go-masker-vX.Y.Z.tar.gz \
  --provenance-path go-masker-vX.Y.Z.tar.gz.intoto.jsonl \
  --source-uri github.com/icntswm/go-masker --source-tag vX.Y.Z

Taking the module with go get needs none of this: the Go checksum database already verifies what you download. The attestation is for anyone who takes the archive instead.

Documentation map

The package godoc contains runnable examples for construction, JSON, reflection, custom rules, struct tags, and the HTTP and logger adapters.

Compatibility and stability

The project is pre-1.0. Public API changes will be called out in the changelog before the first stable release. The supported Go window is Go 1.23 and newer compatible releases.

Contributing

Bug reports and pull requests are welcome. Please read CONTRIBUTING.md before making changes. Security issues must be reported privately according to SECURITY.md.

License

This project is licensed under the MIT License.

Documentation

Overview

Package masker provides configurable, fail-closed masking of sensitive data.

A Masker is immutable: it does not mutate input values, returns normalized copies of supported containers, and is safe for concurrent use after construction. The package requires Go 1.23 and uses only the standard library.

Applications should use DefaultPolicy for the conservative built-in key bindings or provide a Policy that matches their own data model. Built-in rules include full, password, token, email, phone, ID, and card masking; struct tags can select them explicitly or omit a field.

MaskJSON preserves json.Number precision and MaskJSONReader reads the whole input subject to WithMaxInputBytes. Reflection operations return normalized copies, never mutate inputs, and fail closed on invalid UTF-8, unsupported values, callback errors, cycles, or resource limits. WithPreserveSafeTypes keeps safe primitive types in reflection results.

Error categories are available through errors.Is and the exported Err* values; detailed safe context is available through errors.As.

Subpackages apply the same policy at the edges: httpmask to headers and URLs, always fully redacting cookies and URL userinfo; slogmask to log/slog attributes; and zerologmask to the JSON lines written by zerolog or any other logger that writes one JSON object per line.

Example (CycleDetection)
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	node := map[string]any{"name": "root"}
	node["self"] = node

	masked, err := m.MaskAny(node)
	fmt.Println(masked, errors.Is(err, masker.ErrCycle))
}
Output:
[REDACTED] true
Example (ErrorCategories)
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	_, err = m.MaskJSON([]byte(`{"broken":`))

	// errors.Is matches the category, errors.As reaches the safe details.
	// Errors never carry the original value.
	var detail *masker.MaskError
	fmt.Println(errors.Is(err, masker.ErrInvalidJSON), errors.As(err, &detail), detail.Code, detail.Operation)
}
Output:
true true invalid_json mask_json
Example (OmitField)
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	// A policy can drop a field entirely instead of replacing its value.
	policy := masker.PolicyFunc(func(field masker.Field) (masker.Decision, error) {
		if field.Key == "internal_note" {
			return masker.Decision{Omit: true}, nil
		}
		return masker.Decision{}, nil
	})
	m, err := masker.New(policy)
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"internal_note":"debug only","user":"alice"}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"user":"alice"}
Example (StructTags)
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	type payload struct {
		Password string `mask:"password"`
		Name     string `json:"name"`
	}

	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}
	masked, err := m.MaskAny(payload{Password: "synthetic-secret", Name: "alice"})
	if err != nil {
		panic(err)
	}
	values := masked.(map[string]any)
	fmt.Println(values["Password"], values["name"])
}
Output:
[REDACTED] alice

Index

Examples

Constants

View Source
const (
	// DefaultRedactionMarker is used for sensitive and fail-closed values.
	DefaultRedactionMarker = "[REDACTED]"
	// DefaultStructTag is the default struct tag name.
	DefaultStructTag = "mask"
)

Variables

View Source
var (
	// ErrInvalidConfig reports invalid masker configuration.
	ErrInvalidConfig = errors.New("masker: invalid configuration")
	// ErrInvalidJSON reports invalid JSON syntax.
	ErrInvalidJSON = errors.New("masker: invalid JSON")
	// ErrInvalidUTF8 reports invalid UTF-8 input.
	ErrInvalidUTF8 = errors.New("masker: invalid UTF-8")
	// ErrInputLimit reports input exceeding the configured byte limit.
	ErrInputLimit = errors.New("masker: input limit exceeded")
	// ErrDepthLimit reports traversal exceeding the configured depth limit.
	ErrDepthLimit = errors.New("masker: depth limit exceeded")
	// ErrNodeLimit reports traversal exceeding the configured node limit.
	ErrNodeLimit = errors.New("masker: node limit exceeded")
	// ErrCycle reports an active traversal cycle.
	ErrCycle = errors.New("masker: cycle detected")
	// ErrUnsupportedType reports an unsupported input value type.
	ErrUnsupportedType = errors.New("masker: unsupported type")
	// ErrUnsupportedKey reports an unsupported map key type.
	ErrUnsupportedKey = errors.New("masker: unsupported map key")
	// ErrFieldConflict reports conflicting visible fields.
	ErrFieldConflict = errors.New("masker: field conflict")
	// ErrPolicyFailure reports a policy failure.
	ErrPolicyFailure = errors.New("masker: policy failure")
	// ErrRuleFailure reports a rule failure.
	ErrRuleFailure = errors.New("masker: rule failure")
	// ErrPanic reports a panic from a policy or rule callback.
	ErrPanic = errors.New("masker: callback panic")
)

Functions

This section is empty.

Types

type Binding

type Binding struct {
	Keys []string
	Rule Rule
}

Binding associates field keys with a rule. Keys compare equal when they differ only by Unicode case or by the separator characters "_", "-", and ".", so access_token, access-token, and accessToken are equivalent.

func DefaultBindings

func DefaultBindings() []Binding

DefaultBindings returns a defensive copy of the built-in key policy.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	// The built-in key bindings are public and inspectable; DefaultBindings
	// returns a copy that can be extended without touching the defaults.
	for _, binding := range masker.DefaultBindings() {
		fmt.Printf("%-8s %v\n", binding.Rule.Name(), binding.Keys)
	}

}
Output:
password [password passwd passphrase]
token    [token access_token refresh_token api_key apikey secret client_secret id_token private_key session_id credentials auth_token]
email    [email e-mail]
phone    [phone phone_number mobile]
id       [id user_id customer_id]
card     [card card_number pan]
full     [authorization cookie set-cookie x-api-key x-auth-token proxy-authorization x-csrf-token cvv cvc]

type Decision

type Decision struct {
	Rule Rule
	Omit bool
}

Decision is a policy decision. A zero Decision means no opinion.

type ErrorCode

type ErrorCode string

ErrorCode identifies a safe, non-sensitive masking failure category.

const (
	CodeInvalidConfig   ErrorCode = "invalid_config"      // Invalid configuration.
	CodeInvalidJSON     ErrorCode = "invalid_json"        // Invalid JSON syntax.
	CodeInvalidUTF8     ErrorCode = "invalid_utf8"        // Invalid UTF-8 input.
	CodeInputLimit      ErrorCode = "input_limit"         // Input exceeded its byte limit.
	CodeDepthLimit      ErrorCode = "depth_limit"         // Traversal exceeded its depth limit.
	CodeNodeLimit       ErrorCode = "node_limit"          // Traversal exceeded its node limit.
	CodeCycle           ErrorCode = "cycle"               // An active traversal cycle was found.
	CodeUnsupportedType ErrorCode = "unsupported_type"    // A value type is unsupported.
	CodeUnsupportedKey  ErrorCode = "unsupported_map_key" // A map key type is unsupported.
	CodeFieldConflict   ErrorCode = "field_conflict"      // Visible fields conflict.
	CodePolicyFailure   ErrorCode = "policy_failure"      // A policy failed.
	CodeRuleFailure     ErrorCode = "rule_failure"        // A rule failed.
	CodePanic           ErrorCode = "panic"               // A callback panicked.
)

type Field

type Field struct {
	Key    string
	Path   string
	Source Source
	Kind   ValueKind
}

Field is the diagnostic context passed to a Policy.

type KeyPolicy

type KeyPolicy struct {
	// contains filtered or unexported fields
}

KeyPolicy matches complete keys across all naming conventions: keys compare equal when they differ only by Unicode case or by the separator characters "_", "-", and ".".

func NewKeyPolicy

func NewKeyPolicy(bindings ...Binding) (*KeyPolicy, error)

NewKeyPolicy validates and compiles key bindings. Keys compare equal when they differ only by Unicode case or by the separator characters "_", "-", and "."; a key that becomes empty once those separators are removed is rejected as an empty key. Duplicate detection is a one-time cost paid here so conflicting fold-equivalent keys never reach the per-decision hot path. Such keys are accepted only when they refer to the same comparable Rule instance; RuleFunc values are not comparable, so repeated fold-equivalent keys using a custom callback are rejected even when the callback function is the same.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	policy, err := masker.NewKeyPolicy(
		masker.Binding{Keys: []string{"ssn", "tax_id"}, Rule: masker.FullRule()},
		masker.Binding{Keys: []string{"contact"}, Rule: masker.EmailRule()},
	)
	if err != nil {
		panic(err)
	}
	m, err := masker.New(policy)
	if err != nil {
		panic(err)
	}

	// Key matching is case-insensitive.
	masked, err := m.MaskJSON([]byte(`{"Tax_ID":"123-45-6789","contact":"alice@example.com","team":"core"}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"Tax_ID":"[REDACTED]","contact":"a***@example.com","team":"core"}

func (*KeyPolicy) Decide

func (p *KeyPolicy) Decide(field Field) (Decision, error)

Decide implements Policy.

type MaskError

type MaskError struct {
	Code             ErrorCode
	Operation        string
	Path             string
	Field            string
	ConflictingField string
	Depth            int
	Rule             string
}

MaskError describes one masking failure without exposing source values.

func (*MaskError) Error

func (e *MaskError) Error() string

Error returns a safe diagnostic that contains no input data.

func (*MaskError) Unwrap

func (e *MaskError) Unwrap() error

Unwrap makes errors.Is work for the corresponding safe category.

type MaskErrors

type MaskErrors struct {
	Items []*MaskError
}

MaskErrors aggregates local failures without retaining unsafe callback errors.

func (*MaskErrors) Error

func (e *MaskErrors) Error() string

Error returns a safe summary of the aggregate.

func (*MaskErrors) Unwrap

func (e *MaskErrors) Unwrap() []error

Unwrap exposes aggregate members to errors.Is and errors.As.

type Masker

type Masker struct {
	// contains filtered or unexported fields
}

Masker masks sensitive values according to an immutable policy.

func New

func New(policy Policy, opts ...Option) (*Masker, error)

New validates configuration and returns a concurrency-safe Masker.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	value, err := m.MaskValue("password", "synthetic-secret")
	if err != nil {
		panic(err)
	}
	fmt.Println(value)
}
Output:
[REDACTED]

func (*Masker) MaskAny

func (m *Masker) MaskAny(value any) (result any, err error)

MaskAny masks an arbitrary supported value.

Example (Errors)
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskAny(func() {})
	var maskErr *masker.MaskError
	fmt.Println(masked, errors.As(err, &maskErr), maskErr.Code)
}
Output:
[REDACTED] true unsupported_type

func (*Masker) MaskField

func (m *Masker) MaskField(field Field, value any) (result any, err error)

MaskField masks a value using an explicit field context.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	// The same key and value under two sources. Transport metadata is treated
	// paranoidly: a header is fully redacted even when the policy rule is
	// partial.
	body, err := m.MaskField(masker.Field{Key: "email", Source: masker.SourceJSON}, "alice@example.com")
	if err != nil {
		panic(err)
	}
	header, err := m.MaskField(masker.Field{Key: "email", Source: masker.SourceHeader}, "alice@example.com")
	if err != nil {
		panic(err)
	}
	fmt.Println(body)
	fmt.Println(header)

}
Output:
a***@example.com
[REDACTED]

func (*Masker) MaskJSON

func (m *Masker) MaskJSON(src []byte) (result []byte, err error)

MaskJSON masks exactly one JSON document and returns valid JSON on success.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"email":"alice@example.com","token":"synthetic-secret","count":42}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"count":42,"email":"a***@example.com","token":"[REDACTED]"}

func (*Masker) MaskJSONReader

func (m *Masker) MaskJSONReader(src io.Reader) (result []byte, err error)

MaskJSONReader reads, validates, masks, and encodes one JSON document. The reader is not closed and the complete input is held in memory.

Example
package main

import (
	"fmt"
	"strings"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	// The reader is read to completion and is not closed by the library.
	body := strings.NewReader(`{"api_key":"synthetic-key","page":2}`)
	masked, err := m.MaskJSONReader(body)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"api_key":"[REDACTED]","page":2}

func (*Masker) MaskString

func (m *Masker) MaskString(value string, rule Rule) (result string, err error)

MaskString applies a rule to one string and fails closed on rule errors.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}

	for _, c := range []struct {
		rule  masker.Rule
		value string
	}{
		{masker.EmailRule(), "alice@example.com"},
		{masker.PhoneRule(), "+1 (555) 123-4567"},
		{masker.CardRule(), "4111 1111 1111 1111"},
		{masker.IDRule(), "user-8891"},
		{masker.TokenRule(), "synthetic-token"},
	} {
		masked, err := m.MaskString(c.value, c.rule)
		if err != nil {
			panic(err)
		}
		fmt.Printf("%-8s %s\n", c.rule.Name(), masked)
	}

}
Output:
email    a***@example.com
phone    +* (***) ***-4567
card     **** **** **** 1111
id       *****8891
token    [REDACTED]

func (*Masker) MaskValue

func (m *Masker) MaskValue(key string, value any) (any, error)

MaskValue masks a value under a map-like key.

type Option

type Option func(*config) error

Option configures a Masker during construction. The option type is intentionally closed; callers use the exported With* constructors.

func WithMaxDepth

func WithMaxDepth(depth int) Option

WithMaxDepth limits recursive traversal depth. Zero permits root values only; values above 10000 are rejected.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	// Limits are fail-closed: exceeding one discards the whole result rather
	// than returning a partially masked document.
	m, err := masker.New(masker.DefaultPolicy(), masker.WithMaxDepth(1))
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"outer":{"inner":"value"}}`))
	fmt.Println(string(masked), errors.Is(err, masker.ErrDepthLimit))
}
Output:
"[REDACTED]" true

func WithMaxInputBytes

func WithMaxInputBytes(bytes int64) Option

WithMaxInputBytes limits input accepted by MaskJSON and MaskJSONReader.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy(), masker.WithMaxInputBytes(16))
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"password":"synthetic-secret"}`))
	fmt.Println(string(masked), errors.Is(err, masker.ErrInputLimit))
}
Output:
"[REDACTED]" true

func WithMaxNodes

func WithMaxNodes(nodes int) Option

WithMaxNodes limits the number of visited values in one operation.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy(), masker.WithMaxNodes(3))
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"a":1,"b":2,"c":3,"d":4}`))
	fmt.Println(string(masked), errors.Is(err, masker.ErrNodeLimit))
}
Output:
"[REDACTED]" true

func WithPreserveSafeTypes

func WithPreserveSafeTypes() Option

WithPreserveSafeTypes keeps safe primitive values in their concrete types.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy(), masker.WithPreserveSafeTypes())
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskAny(map[string]any{"enabled": true, "count": 3})
	if err != nil {
		panic(err)
	}
	value := masked.(map[string]any)
	fmt.Printf("%T %T %v %v\n", value["enabled"], value["count"], value["enabled"], value["count"])
}
Output:
bool int true 3

func WithRedaction

func WithRedaction(marker string) Option

WithRedaction sets the marker used for sensitive and fail-closed values.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	m, err := masker.New(masker.DefaultPolicy(), masker.WithRedaction("***"))
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"password":"synthetic-secret"}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"password":"***"}

func WithStructTag

func WithStructTag(name string) Option

WithStructTag changes the masking tag name. Empty selects the default name.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	type payload struct {
		Token string `secret:"token"`
		Team  string
	}

	m, err := masker.New(masker.DefaultPolicy(), masker.WithStructTag("secret"))
	if err != nil {
		panic(err)
	}
	masked, err := m.MaskAny(payload{Token: "synthetic-token", Team: "core"})
	if err != nil {
		panic(err)
	}
	values := masked.(map[string]any)
	fmt.Println(values["Token"], values["Team"])
}
Output:
[REDACTED] core

func WithTagRule added in v0.2.0

func WithTagRule(name string, rule Rule) Option

WithTagRule registers rule under name for the struct tag grammar, so a field tagged `mask:"name"` is masked by it. Built-in names and "omit" cannot be redefined: a tag that already means "hide this" must not quietly weaken.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	type payload struct {
		ID string `mask:"last2"`
	}

	last2, err := masker.NewRule("last2", func(input masker.RuleInput) (string, error) {
		runes := []rune(input.Value)
		if len(runes) < 2 {
			return "**", nil
		}
		return string(runes[len(runes)-2:]), nil
	})
	if err != nil {
		panic(err)
	}

	m, err := masker.New(masker.DefaultPolicy(), masker.WithTagRule("last2", last2))
	if err != nil {
		panic(err)
	}
	masked, err := m.MaskAny(payload{ID: "customer-8891"})
	if err != nil {
		panic(err)
	}
	values := masked.(map[string]any)
	fmt.Println(values["ID"])
}
Output:
91

type Policy

type Policy interface {
	Decide(Field) (Decision, error)
}

Policy decides how a field should be handled.

func Chain

func Chain(policies ...Policy) Policy

Chain evaluates policies in order until one gives an opinion.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	// The first policy with an opinion wins; the defaults act as a fallback.
	tenant := masker.PolicyFunc(func(field masker.Field) (masker.Decision, error) {
		if field.Key == "tenant" {
			return masker.Decision{Rule: masker.FullRule()}, nil
		}
		return masker.Decision{}, nil
	})
	m, err := masker.New(masker.Chain(tenant, masker.DefaultPolicy()))
	if err != nil {
		panic(err)
	}

	masked, err := m.MaskJSON([]byte(`{"tenant":"acme","password":"synthetic-secret","region":"eu"}`))
	if err != nil {
		panic(err)
	}
	fmt.Println(string(masked))
}
Output:
{"password":"[REDACTED]","region":"eu","tenant":"[REDACTED]"}

func DefaultPolicy

func DefaultPolicy() Policy

DefaultPolicy returns the standard sensitive-key policy.

type PolicyFunc

type PolicyFunc func(Field) (Decision, error)

PolicyFunc adapts a function to Policy.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	policy := masker.PolicyFunc(func(field masker.Field) (masker.Decision, error) {
		if field.Key == "session_id" {
			return masker.Decision{Rule: masker.FullRule()}, nil
		}
		return masker.Decision{}, nil
	})
	m, err := masker.New(policy)
	if err != nil {
		panic(err)
	}
	masked, err := m.MaskValue("session_id", "synthetic-session")
	if err != nil {
		panic(err)
	}
	fmt.Println(masked)
}
Output:
[REDACTED]

func (PolicyFunc) Decide

func (f PolicyFunc) Decide(field Field) (Decision, error)

Decide implements Policy.

type Rule

type Rule interface {
	Name() string
	Apply(RuleInput) (string, error)
}

Rule transforms one sensitive scalar into a safe string.

func CardRule

func CardRule() Rule

CardRule masks all but the last four ASCII digits.

func EmailRule

func EmailRule() Rule

EmailRule preserves a limited, safe email shape.

func FullRule

func FullRule() Rule

FullRule fully redacts a value.

func IDRule

func IDRule() Rule

IDRule masks all but the last four units.

func NewRule

func NewRule(name string, fn RuleFunc) (Rule, error)

NewRule validates and creates a named custom rule.

Example
package main

import (
	"fmt"

	"github.com/icntswm/go-masker"
)

func main() {
	rule, err := masker.NewRule("prefix", func(input masker.RuleInput) (string, error) {
		return "masked-" + input.Redaction, nil
	})
	if err != nil {
		panic(err)
	}

	m, err := masker.New(masker.DefaultPolicy())
	if err != nil {
		panic(err)
	}
	masked, err := m.MaskString("synthetic-secret", rule)
	if err != nil {
		panic(err)
	}
	fmt.Println(masked)
}
Output:
masked-[REDACTED]

func PasswordRule

func PasswordRule() Rule

PasswordRule fully redacts passwords.

func PhoneRule

func PhoneRule() Rule

PhoneRule masks all but the last four digits.

func TokenRule

func TokenRule() Rule

TokenRule fully redacts tokens and secrets.

type RuleFunc

type RuleFunc func(RuleInput) (string, error)

RuleFunc adapts a function to Rule. Its name is "custom".

func (RuleFunc) Apply

func (f RuleFunc) Apply(input RuleInput) (string, error)

Apply implements Rule.

func (RuleFunc) Name

func (f RuleFunc) Name() string

Name implements Rule.

type RuleInput

type RuleInput struct {
	Value     string
	Kind      ValueKind
	Redaction string
}

RuleInput is the safe context supplied to a Rule.

type Source

type Source uint8

Source identifies the input representation in a policy decision.

const (
	// SourceUnknown identifies an unspecified input source.
	SourceUnknown Source = iota
	// SourceAny identifies reflection-based arbitrary input.
	SourceAny
	// SourceMap identifies map-like input.
	SourceMap
	// SourceStruct identifies struct input.
	SourceStruct
	// SourceJSON identifies JSON input.
	SourceJSON
	// SourceHeader identifies an HTTP header.
	SourceHeader
	// SourceURLQuery identifies a URL query parameter.
	SourceURLQuery
	// SourceURLUserInfo identifies URL userinfo.
	SourceURLUserInfo
	// SourceURLFragment identifies a URL fragment.
	SourceURLFragment
)

type ValueKind

type ValueKind uint8

ValueKind is the normalized kind visible to policies and rules.

const (
	// KindInvalid identifies an unknown value kind.
	KindInvalid ValueKind = iota
	// KindNil identifies a nil value.
	KindNil
	// KindString identifies a string value.
	KindString
	// KindBool identifies a boolean value.
	KindBool
	// KindNumber identifies a numeric value.
	KindNumber
	// KindObject identifies an object or map value.
	KindObject
	// KindArray identifies an array or slice value.
	KindArray
)

Directories

Path Synopsis
Package httpmask adapts masker to HTTP headers and URLs.
Package httpmask adapts masker to HTTP headers and URLs.
internal
outputdigest command
Command outputdigest hashes the output of a fixed corpus so that masking output can be compared across Go toolchains.
Command outputdigest hashes the output of a fixed corpus so that masking output can be compared across Go toolchains.
Package slogmask adapts masker to log/slog.
Package slogmask adapts masker to log/slog.
Package zerologmask adapts masker to github.com/rs/zerolog.
Package zerologmask adapts masker to github.com/rs/zerolog.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL