cloak

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 10 Imported by: 0

README

cloak

lint test coverage Go Reference Go

A slog.Handler that masks personal data before it reaches your log sink.

logger := slog.New(cloak.New(slog.NewJSONHandler(os.Stdout, nil),
    cloak.WithDefaultPII()))

Cloak wraps the handler you already use. Nothing else in your code changes.

Why

Personal data gets into logs by accident, not by decision:

slog.Info("login", "email", user.Email)                 // someone forgets
slog.Info("user " + user.Email + " logged in")          // in the message
slog.Info("loaded", "user", user)                       // a whole struct
slog.WithGroup("req").Info("done", "cpf", doc.Number)   // nested
slog.InfoContext(ctx, "checkout started")               // carried in the context

Once written, it is already in your aggregator, your backups and someone else's dashboard. Scrubbing it afterwards is a project. Not writing it is one wrapper.

Cloak sits at the handler, so it covers every call site in the program — including the ones nobody reviewed.

Install

go get github.com/lrweck/cloak

Go 1.27 or newer. No dependencies.

Getting started

package main

import (
    "log/slog"
    "os"

    "github.com/lrweck/cloak"
)

func main() {
    slog.SetDefault(slog.New(cloak.New(slog.NewJSONHandler(os.Stdout, nil),
        cloak.WithDefaultPII())))

    slog.Info("payment", "email", "john@example.com", "amount", 1299)
}
{"time":"...", "level":"INFO", "msg":"payment", "email":"[REDACTED]", "amount":1299}

email is in the preset's key list, and a key rule outranks the detector that would otherwise have produced j***@example.com.

How it works

Every attribute goes through the same pipeline, and the first rule that matches wins:

key        WithKeys             exact match on the name
           WithKeyRegex        pattern against the name
           WithKeysContaining  substring of the name
tag        WithTag              the field carries this struct tag
type       WithType[T]          the value has this Go type
value      WithValueRule        your predicate accepts it
           WithContain          it contains a secret you know
           detectors            it looks like PAN, CPF, email, IP, UUID...

That order is fixed and does not depend on the order you pass options. It runs from most explicit to most inferred: a key rule names the field, a detector only guesses from the shape of a string.

Four consequences fall out of it:

  • A key rule beats a detector. WithKeys(KeepLast(4), "card_number") keeps the last four digits instead of the full redaction the preset would apply.
  • Rules reach inside data. Once a composite option is on, struct fields, map values and slice elements each go through the whole pipeline.
  • A name means the same thing wherever it lives. A struct field and a map entry walk through different code, but the same rules over the same name answer the same way — WithSkipValueScan("payload") is honoured in a map exactly as in a field. TestMapAndStructAgreeOnEveryRule compares the two paths so they cannot drift again.
  • slog.LogValuer is resolved first. A value that knows how to log itself has already decided what it exposes, so that is what gets masked.

How the options are shaped

Every option that takes a masker takes it first:

cloak.WithKeys(cloak.KeepLast(4), "card_number")
cloak.WithKeysContaining(cloak.Redact, "password")
cloak.WithKeyRegex(cloak.Redact, `^x-.*-token$`)
cloak.WithTag(cloak.Redact, "cloak", "secret")
cloak.WithValuePredicate(cloak.Redact, pred)

That is not taste, it is Go. The subject of a key rule is a variadic list, and a variadic parameter has to come last — so a masker in the final position is impossible for exactly the options people reach for most. First position gives one rule with nothing to remember.

An option that takes several subjects says so in its name: WithKeys, WithKeysContaining. One that takes a single pattern or a single tag does not.

Matching a key

The most direct rule, and the one to reach for when you know the field.

cloak.WithKeys(cloak.Redact, "password", "card_number")
cloak.WithKeys(cloak.KeepLast(4), "card_number")   // keep the last 4 characters

Names are normalized before lookup: case is folded, and _, -, spaces and tabs are ignored. So card_number, cardNumber, CARD-NUMBER and Card Number are one rule from one entry.

For qualified names a whole-key match cannot see:

cloak.WithKeysContaining(cloak.Redact, "password")   // db.password, user_password

It is looser — token also matches tokens_used — so prefer WithKeys unless you know the prefixes.

For an anchor, which a substring cannot express:

cloak.WithKeyRegex(cloak.Redact, `_key$`)          // api_key, x_api_key
cloak.WithKeyRegex(cloak.Redact, `^x-.*-token$`)   // x-auth-token

Patterns match the name as written. normalizeKey strips the underscores and hyphens a pattern usually anchors on, so _key$ would match nothing against the normalized form.

WithKeyRegex compiles with MustCompile, so a typo panics at startup instead of becoming a rule that quietly matches nothing. Use WithKeyRegexp to handle the compile error yourself.

Literal patterns are resolved when you build the handler, because Go's regexp is a backtracker and a case-insensitive alternation is expensive:

(?i)pass|secret|token     958 ns/op   resolved to three substring tests
(?i)(pass|secret|token)  1693 ns/op   the same pattern, left to regexp

A bare literal is left to regexp, which already handles those well — the resolution costs more than it saves when there is nothing to split.

Matching a value

Detectors

A cheap structural check runs first, then the exact validation the format requires. A string only pays for the expensive check when it could plausibly match, which is what keeps false positives down.

Detector Detects Validation
MaskPAN Card numbers Luhn, 13–19 digits
MaskCPF Brazilian CPF Both check digits
MaskCNPJ Brazilian CNPJ Both check digits
MaskSSN US SSN Structural ranges (no check digit exists)
MaskIBAN IBAN MOD-97 checksum
MaskEmail Email addresses Local part and domain syntax
MaskIPv4 IPv4 addresses Octet range, no leading zeros
MaskUUID Canonical UUIDs Dash placement, hex digits
MaskPhone Phone numbers 7–15 digits, heuristic

Detectors scan the whole string, so a value buried in a sentence is still caught:

"cpf 529.982.247-25"                 -> "cpf ***.***.***-**"
"user 42 cpf 529.982.247-25"         -> "user 42 cpf ***.***.***-**"
"card 4111 1111 1111 1111 order 42"  -> "card ****1111 order 42"

The order of the detectors matters for correctness, not speed. IPv4 runs before SSN because 192.168.1.42 is exactly nine digits and would otherwise be masked as a social security number. MaskPhone is last because it claims almost any run of digits.

Your own rules

A detector is any func(string) (string, bool):

cloak.WithValueFunc(func(s string) (string, bool) {
    if strings.HasPrefix(s, "sk_live_") {
        return "sk_live_***", true
    }
    return s, false
})

Detectors only ever see strings. To reach an int, a duration, a time or a bool, use a value rule:

cloak.WithValuePredicate(cloak.Redact, func(v slog.Value) bool {
        return v.Kind() == slog.KindInt64 && v.Int64() > 1_000_000_000_000
    })

Use WithValueRule when deciding and replacing are separate, or when you want to rewrite rather than only mask:

cloak.WithValueRule(func(v slog.Value) (slog.Value, bool) {
    if v.Kind() != slog.KindDuration {
        return v, false
    }
    return slog.StringValue("slow"), true
})

A value rule is not applied to a group, since replacing one would discard its structure rather than mask it.

Matching a type

The strongest rule, and the hardest to misuse. Define a type and it is masked wherever it appears:

type EmailAddr string
type Password  string

cloak.WithType[EmailAddr]()
cloak.WithType[Password]()
type Login struct {
    User     string
    Password Password
}
slog.Info("login", "user", "jane", "password", Password("hunter2"))
// password=[REDACTED]

The compiler rejects a type that does not exist, the value cannot be logged under a name nobody remembered to add to a preset, and there are no false positives.

It also reaches what the detectors cannot: slog stores a value of a named type as KindAny, not KindString, so MaskEmail and friends never see it.

Matching a tag

The most durable way to mark a field, since renaming it does not lose the rule:

type Account struct {
    ID       int
    Password string `cloak:"secret"`
}

cloak.WithTag(cloak.Redact, "cloak", "secret")

The tag key is part of the rule, so rules for different namespaces coexist.

Matching a secret you already know

For when you hold the secret but not the field it will turn up in:

cloak.WithContain("sk_live_51H8xQ2")

A token spliced into a URL, an error quoting a response body, an auth header assembled by a client — none carry a field name worth matching, and no format detector knows what your token looks like. It applies to the message and to every attribute. The whole value is replaced rather than the substring, since the rest of it may carry more of the same secret.

Reaching inside data

Groups

Groups are walked recursively, and every attribute is preserved, matched or not:

slog.Group("user",
    slog.Int("id", 7),                        // kept
    slog.String("email", "john@example.com"), // masked
    slog.String("name", "Jane"),              // kept,
)
// user.id=7 user.email=[REDACTED] user.name=Jane
Structs, maps and slices

slog.Any hands the handler one opaque value, so a struct's fields, a map's entries and a slice's elements are not reached by attribute rules. %+v does not save you: it renders []*User as [0xc000...] addresses, printing no PII but also masking nothing.

Reflection fixes that. One option per shape, plus a combined one:

cloak.WithStructScan()    // slog.Any("user", User{Email: ...})
cloak.WithMapScan()       // slog.Any("ctx", map[string]any{"email": ...})
cloak.WithSliceScan()     // slog.Any("emails", []string{...})
cloak.WithCompositeScan() // all three

Each is scoped to its shape, so WithStructScan() leaves slices alone. All of them follow pointers, since passing a struct by pointer is the common case.

What to expect:

  • The value keeps its type. Only the offending fields change, so the record still logs a structured object rather than a stringified blob.
  • Fields are matched by name first, then by value. A key rule always wins.
  • A copy is built only when something changed, so untouched values pass through with no allocation.
  • Nesting recurses: []map[string][]User and map[string]map[string]User both work.
  • Walking stops at 32 levels, so a self-referential structure cannot hang the logger.
  • []byte is skipped on purpose. It is data, not a container of things to log, and walking it would rewrite a payload into decimal digits.

Reflection runs only when a composite option is on. Without one, reflect is never called on the logging path. The same holds for every other optional feature: a rule that is not configured does not run. TestOptionalFeaturesOnlyRunWhenEnabled checks that both ways for each one, and TestNothingIsArmedWithoutOptions checks that nothing is armed to begin with.

This is not a capability every masking library has. Measured in bench/compare/, on the same record through each library:

Library Reaches a field inside slog.Any?
cloak yes, via WithStructScan()
masq yes, via WithFieldName
redactlog no — its path DSL walks slog.Group and stops there
go-slog-redact, sensitive, alesr/redact no — they match a top-level key against a string value

Given a struct, redactlog's only option is RedactPaths: ["user"], which masks the whole value. Nothing leaks and the user id goes with it.

LogValuer

slog.LogValuer takes precedence, because a value that knows how to log itself has already decided what it exposes. Cloak resolves it first, then masks what came out:

type Email string
func (Email) LogValue() slog.Value { return slog.StringValue("john@example.com") }

// with WithDefaultPIIValues() enabled
slog.Info("m", "mail", Email("x"))   // mail=j***@example.com

The result is resolved once, so the wrapped handler never evaluates LogValue() again.

The two places a value hides

In the log message

The message is free text that nothing marks as data, and slog.Info("user " + email) is routine.

cloak.WithMessageScan()

It is off by default because it rewrites text nobody asked to rewrite. Turn it on when the leak matters more than the log's readability.

In the context

A value in a context is attached once, usually far from the log call that exposes it:

ctx = context.WithValue(ctx, userKey, user)   // middleware, no logging in sight
slog.InfoContext(ctx, "checkout started")     // somewhere else, entirely

WithContextAttrs registers functions that pull values out, so no call site can leak one by forgetting a field:

// package auth — owns the unexported key
func LogAttrs(ctx context.Context) []slog.Attr {
    u, ok := userFrom(ctx)          // uses the private key
    if !ok {
        return nil
    }
    return []slog.Attr{slog.Any("user", u)}
}

// package main
cloak.New(next, cloak.WithCompositeScan(), cloak.WithContextAttrs(auth.LogAttrs))

Why a function and not a key. The idiomatic context key is an unexported type, so naming it from the package that configures logging does not compile — and exporting it just to configure a logger gives up the collision safety that makes the private type worth having. The closure lives where the key is reachable, so the key never leaves its package.

Whatever a pull returns goes through the same masking as an attribute you logged yourself. Return nil for anything absent, so a missing value is skipped rather than logged as null.

Presets

cloak.NewDefaultPII(next)   // the built-in key list plus the detectors
cloak.NewPCI(next)          // cardholder data, per PCI DSS
cloak.NewGDPR(next)         // personal data, per GDPR
cloak.NewLGPD(next)         // the same, under its Brazilian name

Each constructor is its option and nothing else, so a caller option still wins on any key the preset also names:

// the preset would redact card_number; this keeps the last four digits
cloak.NewPCI(next, cloak.WithKeys(cloak.KeepLast(4), "card_number"))

WithDefaultPII turns on 102 keys and 9 detectors, covering credentials, identity documents, contact details, addresses, birth dates, payment cards and bank identifiers. Generic keys like id, name and user are deliberately excluded — they carry more signal than personal data, and masking them would make the logs useless.

WithPCI covers what PCI DSS forbids in logs — PAN and its aliases, CVV, expiry, cardholder name, track data, PIN — and keeps amounts, currencies and merchant names, which are not cardholder data and are most of what makes a transaction log worth reading.

WithGDPR covers the personal-data categories including article 9: health, biometric, political, religious, sexual orientation and union membership. It leaves name alone on purpose.

All of them are starting points, not compliance claims. The regulation names categories; only your schema says which attribute holds each one.

The redaction message

What a full redaction is replaced with belongs to the handler, not to any rule:

cloak.NewPCI(next, cloak.WithRedactedValue("***"))
// pan=***, cvv=***

Whatever asks for the redaction gets that string: a key rule, a tag, a type, a pattern, WithContain, or a preset. Partial maskers keep their own output, since KeepLast and friends state exactly what they produce. Order does not matter, so the message may be set before or after the rules that use it.

Composing options

Options follows the shape encoding/json/v2 uses: opaque, and one value carries either a single option or a set of them.

// one option
opt := cloak.WithDefaultPII()

// a set, which can be named, stored and passed around
var MinhaPolitica = cloak.JoinOptions(opt, cloak.WithMessageScan(), cloak.WithStructScan())

cloak.New(next, MinhaPolitica)

That is what a slice of options cannot do: a policy you want to name is a value, not a function that returns one. Later options override earlier ones, as everywhere else here.

Reference

Option Effect
WithRedactedValue(msg) What a full redaction is replaced with
WithKeys(mask, keys...) Mask whole-key matches
WithKeysContaining(mask, keys...) Mask substring matches
WithKeyRegex(mask, pattern) Mask keys matching a pattern
WithKeyRegexp(mask, re) Same, for a pattern you compiled
WithType[T](maskers...) Mask values of Go type T
WithTag(mask, key, value) Mask struct fields carrying a tag
WithContain(secrets...) Mask any value containing a known secret
WithValueFunc(fn) Add a value detector
WithValueRule(fn) Add a value rule over any kind
WithValuePredicate(mask, pred) Mask a value of any kind when pred accepts
WithDefaultPII() Preset: keys plus detectors
WithDefaultPIIKeys() Preset: keys only
WithDefaultPIIValues() Preset: detectors only
WithPCI() / WithGDPR() / WithLGPD() Compliance presets
WithSkipValueScan(keys...) Never scan these keys' values
WithMessageScan() Also scan the log message
WithStructScan() Walk slog.Any structs
WithMapScan() Walk slog.Any maps
WithSliceScan() Walk slog.Any slices
WithCompositeScan() All three composite options
WithContextAttrs(pulls...) Copy masked context values onto every record
JoinOptions(opts...) Combine options into one value
New(next, opts...) Wrap a handler
New(nil, ...) Discard everything; useful in tests
NewDefaultPII(next, opts...) WithDefaultPII() as a constructor
NewPCI(next, opts...) WithPCI() as a constructor
NewGDPR(next, opts...) WithGDPR() as a constructor
NewLGPD(next, opts...) WithLGPD() as a constructor

The maskers decide what a matched rule produces:

Masker 4111111111111111
Redact [REDACTED], or WithRedactedValue
Fixed("***") ***
KeepLast(4) ************1111
KeepFirst(4) 4111************
KeepEnds(4, 4) 4111********1111
MaskMiddle(4, 4) alias of KeepEnds

A masker is func(slog.Value) slog.Value, so writing one is a single function:

func LastFour(v slog.Value) slog.Value {
    s := v.String()
    if len(s) <= 4 {
        return slog.StringValue("****")
    }
    return slog.StringValue("****" + s[len(s)-4:])
}

cloak.WithKeys(LastFour, "card_number")

Maskers operate on the string form of the value and are rune-aware, so multibyte text is not cut mid-character.

Performance

Full numbers by scenario in bench/, and a comparison against the other Go libraries that mask PII in bench/compare/.

The short version, each record measured against itself logged straight to slog:

bare slog with cloak delta
4 attributes, nothing to match 445 ns, 0 allocs 556 ns, 0 allocs +111 ns
key lookup in isolation — 9 ns, 0 allocs —
9 detectors over clean sentences — 175 ns, 0 allocs —
slog.Any with a struct 746 ns, 1 alloc 1449 ns, 6 allocs +703 ns

On that row cloak is 1.95 times cheaper than the one other library that can be configured for it, and allocates a twelfth as much — 1754 ns and 5 allocations against masq's 3424 ns and 63.

Three things to take from it:

  • The quiet path is cheap and allocation-free. About 111 ns over bare for a record where nothing matched, and nothing allocated. Key lookup is a single map hit, and a lookup that misses never materializes the folded key, so even snake_case names cost nothing. A walked struct or slice where nothing matched costs nothing either, at any width: the copy and the fallback are built only once something changes.
  • A rule that fires is not the expensive part. Finding the value was. What a match costs is the replacement itself — a new string is two allocations, which is the floor, and the detector and message rows sit exactly on it.
  • Reflection costs several times more and is opt-in for that reason. The 0.7–1.3 µs of the walk rows is the price of rebuilding the container: boxing each element out of reflection, the copy, and the masked strings.

Gotchas

Things that are true and easy to trip over.

A key rule masks the value stored under a matching map key, the same as it does for a struct field. Masking the key instead would protect nothing — map[[REDACTED]:hunter2] is the same leak wearing a hat. The key name survives, because it is the field name and not the secret.

An unexported field, or one tagged slog:"-", is left at its zero value when the struct is walked. It is not removed from the output — the key stays, holding an empty value. What never reaches the sink is the name and anything in it:

type acct struct {
    ID       int
    password string
}
type guest struct {
    ID    int    `slog:"-"`
    Email string `slog:"-"`
}

// TextHandler: acct -> a="{ID:7 password:}"      guest -> a="{ID:0 Email:}"
// JSONHandler:  acct -> "a":{"ID":7}              guest -> "a":{"ID":0,"Email":""}

encoding/json omits an unexported field outright, which is why the JSON column differs for acct. TextHandler renders a struct with %+v and prints everything, so copying a field through would hand it to the sink — hence the zero. A struct carrying one of these fields always rebuilds, even when no rule matched, because zeroing the field is itself the change. A struct with only exported fields passes through untouched.

Two things that tag does not do, both of which cost someone an afternoon:

  • json:"-" is not honoured by the walk. encoding/json honours it, so on JSONHandler the field disappears either way and you will not notice. On TextHandler, %+v ignores it and cloak does not look for it, so a="{ID:7 Email:a@b.com}" — the value printed. Writing slog:"-" is what stops that, and the difference only shows up on the text handler, which is why the JSON column above hides it.
  • The namespace is fixed, not configured. WithTag takes the key as an argument so that rules for different namespaces coexist, but the slog here is not routed through it. WithTag(Redact, "log", "secret") does not make log:"-" mean anything, and WithTag(Redact, "slog", "-") would add a masking rule for the literal key -.

A struct with a changed pointer field widens to a group on TextHandler. Cloak follows the pointer and rewrites the value behind it, but fmt renders a nested pointer as an address, so keeping the struct would reach the sink as 0xc000... with nothing showing a rule fired. Widening shows the pointed-to value instead:

slog.Info("m", "user", User{ID: 7, ShipTo: &Address{CEP: "01310-100"}})
// TextHandler: user.ID=7 user.ShipTo="&{Street:Rua X CEP:[REDACTED]}"
// JSONHandler: "user":{"ID":7,"ShipTo":{"CEP":"[REDACTED]"}}   <- unchanged

The masking is correct either way; only the text rendering changes, and JSON is untouched. What still shows an address is a pointer nothing matched: rebuilding for readability alone would break the passthrough the quiet path promises, and fmt behaves the same with no cloak involved. Maps and slices holding pointers have the same residual.

Masking a map key can merge two entries. If two distinct keys mask to the same text they become one entry. Nothing leaks, but an entry can be lost.

A struct map key works on TextHandler but not JSONHandler. slog's JSON handler rejects it outright — a Go limitation, present with or without cloak.

A masked value that cannot be represented in its original type widens the container. A []LogValuer, or a map keyed by a struct whose LogValue() returns something else, becomes []any or map[string]any rather than keeping the unmasked original.

MaskPhone is a heuristic and will mask any run of 7–15 digits, including IDs and timestamps. If that is a problem, use WithDefaultPIIKeys(), which masks by key only.

Composite walking needs its own option. A struct in a context or in a group is reached only with WithStructScan or WithCompositeScan; cloak does not turn reflection on implicitly.

Examples

Runnable programs in examples/:

Directory Shows
basic The smallest useful setup
keys Key, pattern and type rules, and how they interact
containers Structs, maps, slices and groups
pci A compliance preset with an override
context Pulling masked values out of a context with a private key
go run ./examples/basic

Testing

go test ./...
go test -bench . -benchmem ./...

License

MIT

Documentation

Overview

Package cloak provides a slog.Handler wrapper that masks sensitive values.

Key-based masking is exact and cheap. Optional value detectors use a two-stage strategy: a small structural pre-filter first, followed by the exact validation required by the detector (for example, Luhn for PAN or check digits for CPF/CNPJ/SSN). This keeps the common logging path inexpensive and reduces false positives from arbitrary strings.

Naming

Every option that takes a Masker takes it first:

cloak.WithKeys(cloak.KeepLast(4), "card_number")
cloak.WithKeysContaining(cloak.Redact, "password")
cloak.WithKeyRegex(cloak.Redact, `^x-.*-token$`)
cloak.WithTag(cloak.Redact, "cloak", "secret")
cloak.WithValuePredicate(cloak.Redact, pred)

The reason is Go, not taste: the subject of a key rule is a variadic list, and a variadic parameter has to come last. A Masker in the last position is therefore impossible for exactly the options people reach for most. Putting it first gives one rule with no exceptions to remember.

An option that takes several subjects says so in its name — WithKeys, WithKeysContaining. One that takes a single pattern or a single tag does not.

Index

Constants

View Source
const Placeholder = "[REDACTED]"

Placeholder is the replacement a Handler uses for a fully redacted value unless WithRedactedValue says otherwise.

Variables

View Source
var DefaultPIIKeys = []string{}/* 102 elements not displayed */
View Source
var GDPRKeys = []string{

	"email", "emailaddress", "contactemail", "phone", "phonenumber", "telephone",
	"telephonenumber", "mobile", "mobilenumber", "address", "addressline", "streetaddress",
	"postaladdress", "postcode", "zip", "zipcode", "postalcode", "cep", "geolocation",
	"latitude", "longitude", "gps", "ipaddress", "ip",

	"ssn", "socialsecuritynumber", "cpf", "cnpj", "document", "documentnumber", "taxid",
	"nationalid", "nationalidentifier", "passport", "passportnumber", "driverlicense",
	"driverslicense", "licensenumber", "birthdate", "dateofbirth", "dob",

	"bankaccount", "bankaccountnumber", "accountnumber", "iban", "routingnumber",
	"sortcode", "swift", "creditcard", "cardnumber", "pan", "cvv", "cvc",

	"health", "medical", "medicalrecord", "diagnosis", "prescription", "disability",
	"genetic", "biometric", "biometricdata", "political", "politicalopinion",
	"religion", "religious", "sexualorientation", "tradeunion", "unionmembership",
}

GDPRKeys are the personal-data categories of the GDPR, as attribute names.

The regulation names categories rather than fields, so this list is necessarily a guess about your schema. Treat it as a starting point and add your own: "name" is absent on purpose, since masking every name would make the logs useless.

View Source
var LGPDKeys = GDPRKeys

LGPDKeys is GDPRKeys under its Brazilian name. The two laws name the same categories, so this is the same list rather than a parallel one that could drift.

View Source
var PCIKeys = []string{
	"pan", "primaryaccountnumber", "cardnumber", "creditcardnumber",
	"ccnumber", "accountnumber", "cardholdername", "expmonth", "expyear", "expirationdate",
	"expiry", "cvv", "cvc", "cid", "csc", "cvv2", "securitycode", "cardsecuritycode",
	"pin", "pinblock", "track1", "track2", "tracks", "servicecode", "cryptogram",
	"emv", "chipdata", "magstripe", "signature",
}

PCIKeys are the cardholder-data fields named by PCI DSS: the data that must never reach a log, as distinct from the many fields that merely describe a transaction.

Functions

func MaskCNPJ

func MaskCNPJ(s string) (string, bool)

func MaskCPF

func MaskCPF(s string) (string, bool)

func MaskEmail

func MaskEmail(s string) (string, bool)

func MaskIBAN

func MaskIBAN(s string) (string, bool)

func MaskIPv4

func MaskIPv4(s string) (string, bool)

func MaskPAN

func MaskPAN(s string) (string, bool)

func MaskPhone

func MaskPhone(s string) (string, bool)

func MaskSSN

func MaskSSN(s string) (string, bool)

func MaskUUID

func MaskUUID(s string) (string, bool)

func New

func New(next slog.Handler, opts ...Options) slog.Handler

func NewDefaultPII

func NewDefaultPII(next slog.Handler, opts ...Options) slog.Handler

NewDefaultPII returns a handler masking the built-in key set and value detectors.

It is New(next, WithDefaultPII()) with the compliance choice visible at the call site, which matters more here than for the other options because "what is sensitive" is a decision a reader of the code should be able to see:

slog.SetDefault(slog.New(cloak.NewDefaultPII(os.Stdout)))

For a named regulation prefer NewPCI, NewGDPR or NewLGPD.

func NewGDPR

func NewGDPR(next slog.Handler, opts ...Options) slog.Handler

NewGDPR returns a handler masking the personal-data categories GDPR names.

cloak.NewGDPR(slog.NewJSONHandler(os.Stdout, nil))

func NewLGPD

func NewLGPD(next slog.Handler, opts ...Options) slog.Handler

NewLGPD is NewGDPR under its Brazilian name, for code that already speaks LGPD.

cloak.NewLGPD(slog.NewJSONHandler(os.Stdout, nil))

func NewPCI

func NewPCI(next slog.Handler, opts ...Options) slog.Handler

NewPCI returns a handler masking the cardholder data PCI DSS forbids in logs.

cloak.NewPCI(slog.NewJSONHandler(os.Stdout, nil), cloak.WithStructScan())

func Redact

func Redact(v slog.Value) slog.Value

Redact replaces a value entirely. It is the request for a full redaction rather than a message of its own: inside a handler the replacement is that handler's redacted value, which WithRedactedValue sets. Called outside one, it falls back to Placeholder.

Partial maskers like KeepLast are unaffected, since they state their own output.

Types

type Handler

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

func (*Handler) Enabled

func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool

func (*Handler) Handle

func (h *Handler) Handle(ctx context.Context, r slog.Record) error

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(as []slog.Attr) slog.Handler

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

type Masker

type Masker func(slog.Value) slog.Value

func Fixed

func Fixed(marker string) Masker

func KeepEnds

func KeepEnds(first, last int) Masker

func KeepFirst

func KeepFirst(n int) Masker

func KeepLast

func KeepLast(n int) Masker

func MaskMiddle

func MaskMiddle(first, last int) Masker

type Options

type Options interface {
	// contains filtered or unexported methods
}

Options configures a Handler. One Options value carries either a single option or a set of them, following the shape encoding/json/v2 uses: opaque, composable, and impossible to construct from outside the package.

opt := cloak.WithDefaultPII()                  // one option
policy := cloak.JoinOptions(opt, opt2, opt3)    // a set, storable as a value

Individual constructors return a single option; JoinOptions coalesces any number into one value that can be named, stored and passed around, which a slice of options cannot be. Properties set later override earlier ones, as everywhere else here.

func JoinOptions

func JoinOptions(opts ...Options) Options

JoinOptions coalesces options into a single Options value, applied in order.

func WithCompositeScan

func WithCompositeScan() Options

WithCompositeScan enables WithStructScan, WithMapScan and WithSliceScan at once, and is what you want unless you have a reason to scope the walk.

Pointers are always followed, since a pointer is just an address to the value behind it.

func WithContain

func WithContain(secrets ...string) Options

WithContain masks any value that contains one of the given strings, wherever it appears.

cloak.WithContain("s3cr3t-token")

It solves the problem the other rules cannot: you know the secret but not where it will be logged. A token spliced into a URL, an error message quoting a response body, an auth header assembled by a client — none of those carry a field name worth matching, and the value detectors have no idea what your token looks like.

It is a value rule, so it applies to the message, to attribute values, and everywhere the composite walk descends. Matching is case sensitive, since a secret is a specific byte sequence. The whole value is replaced rather than the substring, because the rest of the value may be sensitive too.

Empty secrets are ignored: an empty needle matches everything, which would silence every log line.

func WithContextAttrs

func WithContextAttrs(pulls ...func(context.Context) []slog.Attr) Options

WithContextAttrs registers functions that pull values out of the context and copy them onto every record, masked by the same rules as attributes you logged yourself.

It exists because a value carried in the context is attached once, usually far from the log call that will expose it:

ctx = context.WithValue(ctx, userKey, user)     // middleware, no logging in sight
slog.InfoContext(ctx, "loaded")                // somewhere else, entirely

A pull function rather than a key, because the idiomatic context key is a private type: naming it from outside the package that owns it does not compile, and exporting it just to configure a logger gives up the collision safety that makes the private type worth having. The closure lives where the key is reachable, so the key never leaves its package:

// package auth
func LogAttrs(ctx context.Context) []slog.Attr {
    u, ok := userFrom(ctx)          // uses the unexported key
    if !ok {
        return nil
    }
    return []slog.Attr{slog.Any("user", u)}
}

// package main
cloak.WithContextAttrs(auth.LogAttrs)

Masking is not special-cased: a key rule, a detector, a composite walk and LogValuer precedence all apply exactly as they would to an attribute you logged yourself. Return nil for anything absent, so a missing value is skipped rather than logged as null.

It also suits sources that are not context values at all, such as request-scoped state kept by a web framework.

func WithDefaultPII

func WithDefaultPII() Options

func WithDefaultPIIKeys

func WithDefaultPIIKeys() Options

func WithDefaultPIIValues

func WithDefaultPIIValues() Options

func WithGDPR

func WithGDPR() Options

WithGDPR enables the personal-data categories named by the GDPR.

It is a starting point rather than a compliance claim: the regulation names categories, and only your schema says which attribute holds each one. Review it, and add whatever your domain calls something else.

func WithKeyRegex

func WithKeyRegex(m Masker, pattern string) Options

WithKeyRegex masks attributes whose key matches the pattern, compiled with regexp.MustCompile.

It panics on an invalid pattern, which is the point: a typo would otherwise become a rule that silently matches nothing, and PII would leave the program with no signal. Failing at construction time means it fails at startup, where it is loud. Use WithKeyRegexp if you would rather handle the compile error yourself.

The pattern matches the attribute name **as written**, not the normalized key, so `_key$` and `^x-.*-token$` mean what they look like. normalizeKey strips the underscores and hyphens a pattern usually wants to anchor on.

cloak.WithKeyRegex(cloak.Redact, `_key$`)
cloak.WithKeyRegex(cloak.Redact, `^x-.*-token$`)

Precedence is key rule, then this, then WithKeysContaining: an exact key is the most specific statement of intent, a pattern is deliberate, and a substring is the loosest.

func WithKeyRegexp

func WithKeyRegexp(m Masker, re *regexp.Regexp) Options

WithKeyRegexp is WithKeyRegex for a pattern you compiled yourself, so a bad pattern is your error to handle rather than a panic.

pattern, err := regexp.Compile(os.Getenv("REDACT_PATTERN"))
if err != nil {
    return err
}
cloak.WithKeyRegexp(cloak.Redact, pattern)

func WithKeys

func WithKeys(m Masker, keys ...string) Options

WithKeys masks attributes whose normalized key exactly matches one of keys.

cloak.WithKeys(cloak.Redact, "password", "cpf")
cloak.WithKeys(cloak.KeepLast(4), "card_number")

Matching is on the normalized key, so card_number, cardNumber, CARD-NUMBER and "Card Number" are all the same rule from one entry.

func WithKeysContaining

func WithKeysContaining(m Masker, keys ...string) Options

WithKeysContaining masks attributes whose normalized key contains one of keys as a substring. It catches the qualified names that exact matching misses, such as "db.password" or "headers.Authorization".

cloak.WithKeysContaining(cloak.Redact, "password")

It is looser than WithKeys: "token" also matches "tokens_used". Keys holding unrelated data ("id", "name") make it a poor choice, so prefer WithKeys and reach for this only where the prefixes are known.

func WithLGPD

func WithLGPD() Options

WithLGPD is WithGDPR under its Brazilian name, for code that already speaks LGPD.

func WithMapScan

func WithMapScan() Options

WithMapScan walks map values logged through slog.Any, masking both keys and values. A key match wins over the value detectors, so map[string]any{"password": "x"} is caught by name while a bare address is caught as a value.

cloak.WithMapScan()   // slog.Any("ctx", map[string]any{"email": ...})

func WithMessageScan

func WithMessageScan() Options

WithMessageScan also runs the value detectors over the log message.

Attributes are opt-in per key, but the message is free text nobody reviews, and `slog.Info("login for "+email)` is a routine way to leak. It is off by default because rewriting the message surprises readers and costs a scan per record.

func WithPCI

func WithPCI() Options

WithPCI enables the cardholder-data fields PCI DSS forbids in logs, plus the PAN detector so a card number is caught even under a field name you did not anticipate.

It does not mask every field that mentions money — amounts, currencies and merchant descriptors are not cardholder data, and masking them would cost you the transaction history that makes a log useful.

func WithRedactedValue

func WithRedactedValue(msg string) Options

WithRedactedValue sets what a fully redacted value is replaced with.

cloak.NewPCI(next, cloak.WithRedactedValue("***"))

It is a property of the handler rather than of any rule: every request for a full redaction answers with this string, whatever asked for it — a key rule, a tag, a type, a pattern, or one of the presets. Partial maskers keep their own output, since KeepLast and friends state exactly what they produce.

func WithSkipValueScan

func WithSkipValueScan(keys ...string) Options

WithSkipValueScan keeps the value detectors off a set of keys, so a field that legitimately holds something shaped like PII keeps its payload readable.

cloak.WithSkipValueScan("raw_payload", "request_body")

The key names are matched like any other key rule, so the spelling does not have to match exactly. It applies wherever the name can appear: an attribute, a struct field, or an entry in a walked map.

An explicit rule beats the skip list, the same way it beats everything else — a key named here and also named by WithKeys or a preset is masked, since the explicit statement is the more specific one.

func WithSliceScan

func WithSliceScan() Options

WithSliceScan walks slice values logged through slog.Any, masking each element and recursing into nested containers.

[]byte is excluded: it is data, not a container of things to log, and walking it would rewrite a payload into decimal digits.

cloak.WithSliceScan()   // slog.Any("emails", []string{"a@example.com"})

func WithStructScan

func WithStructScan() Options

WithStructScan walks struct values logged through slog.Any, masking PII in their fields by field name and by value.

The value keeps its type: only the offending fields are replaced, so the record still logs a structured object.

cloak.WithStructScan()   // slog.Any("user", User{Email: ...})

func WithTag

func WithTag(m Masker, key, value string) Options

WithTag masks a struct field carrying the given struct tag.

type Account struct {
    ID       int
    Password string `cloak:"secret"`
}

cloak.WithTag(cloak.Redact, "cloak", "secret")

A tag is the most durable way to mark a field: renaming the field does not lose the rule, and the intent sits on the field rather than in the logger's configuration.

Rules for different tag keys can coexist, since the key is part of each rule. A field matching no rule is walked normally.

func WithType

func WithType[T any](maskers ...Masker) Options

WithType masks every value whose Go type is exactly T.

type EmailAddr string
type Password string

cloak.WithType[EmailAddr](), cloak.WithType[Password](cloak.KeepLast(0))

This is the strongest rule available and the cheapest to use correctly. The compiler refuses to pass a type that does not exist, the value cannot be logged under a name you forgot to add to a preset, and there are no false positives: a Password is masked wherever it is, and nothing else is.

It applies wherever the value appears: as an attribute, a struct field, a map value or a slice element. Values of a named type are not KindString to slog, so this also reaches types the value detectors cannot see.

With no masker, the value is fully redacted.

Precedence: an explicit key rule wins over a type rule, and a type rule wins over the value detectors.

func WithValueFunc

func WithValueFunc(f ValueFunc) Options

func WithValuePredicate

func WithValuePredicate(m Masker, pred func(slog.Value) bool) Options

WithValuePredicate masks a value of any kind when pred accepts it. It is WithValueRule for the common case where deciding and replacing are separate.

cloak.WithValuePredicate(cloak.Redact, func(v slog.Value) bool {
    return v.Kind() == slog.KindInt64 && v.Int64() > 1_000_000_000_000
})

func WithValueRule

func WithValueRule(r ValueRule) Options

WithValueRule registers a rule over values of any kind.

A rule runs after key, tag and type rules and before the format detectors, because a rule is a deliberate statement while a detector infers from the shape of a string. It is not applied to a group, since replacing one would discard its structure.

WithSkipValueScan governs value rules as it does the detectors.

type ValueFunc

type ValueFunc func(string) (string, bool)

func DefaultPIIValueFuncs

func DefaultPIIValueFuncs() []ValueFunc

DefaultPIIValueFuncs returns the built-in detectors, in the order they must run.

Two rules decide the order. Cheapest structural gate first, so a string that cannot possibly match costs one comparison. Most specific before least specific, because maskString chains: each detector sees the previous one's output.

That chaining is why IPv4 must precede SSN. An IPv4 address like "192.168.1.42" is exactly nine digits and passes the SSN shape check, so SSN first turns the most common private address into "***-**-****" and leaves IPv4 nothing to match. Phone is last because it claims almost any number, documents included.

type ValueRule

type ValueRule func(slog.Value) (slog.Value, bool)

ValueRule inspects a value of any kind and returns its replacement, or reports that it has nothing to say. It mirrors ValueFunc exactly — same shape, same direction — so the two are one idea rather than two.

Use it where a string detector cannot reach: an int, a duration, a time, a bool, or any value at all, including a struct reached through slog.Any.

cloak.WithValueRule(func(v slog.Value) (slog.Value, bool) {
    if v.Kind() != slog.KindInt64 {
        return v, false
    }
    n := v.Int64()
    if n < 1_000_000_000_000 {
        return v, false
    }
    return slog.StringValue("[large]"), true
})

Directories

Path Synopsis
examples
basic command
Command basic is the smallest setup that does anything useful: wrap the handler you already have and turn on the preset.
Command basic is the smallest setup that does anything useful: wrap the handler you already have and turn on the preset.
containers command
Command containers reaches inside the data slog hands over as one opaque value.
Command containers reaches inside the data slog hands over as one opaque value.
context command
Command context masks a value that was attached far from the log call that exposes it.
Command context masks a value that was attached far from the log call that exposes it.
context/auth
Package auth stands in for whatever package owns the identity.
Package auth stands in for whatever package owns the identity.
keys command
Command keys shows the three ways to name a field, and the fixed order they are tried in.
Command keys shows the three ways to name a field, and the fixed order they are tried in.
pci command
Command pci shows a compliance preset, and the one thing worth doing with it: overriding a rule when your schema disagrees with the default.
Command pci shows a compliance preset, and the one thing worth doing with it: overriding a rule when your schema disagrees with the default.

Jump to

Keyboard shortcuts

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