pii

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package pii decides which result cells must be masked: column rules kept in .locksql/pii.toml, rules proposed from the schema, value detectors (email, phone, IBAN, card, national ids) and the masking itself.

Index

Constants

View Source
const (
	// ModeRedact replaces the whole value: "<redacted>".
	ModeRedact = "redact"
	// ModePartial keeps the first character and the length: "j***(12)".
	ModePartial = "partial"
	// ModeEmail keeps the first character and the domain of an address:
	// "j***@example.com" (other values are masked as partial).
	ModeEmail = "email"
	// ModeHash replaces the value with a token, the same for the same value
	// within one console session: "tok_...". Joins, grouping, counting and
	// equality filters on tokens keep working; the value never leaves the
	// console. The token key changes with every session.
	ModeHash = "hash"
)

Mask modes.

View Source
const DetectLimit = 64 << 10

DetectLimit is how much of a text cell the detectors scan. A longer cell is cut there, and ends with "…", before detection: nothing past the scanned part can reach the output. The renderer cuts cells much earlier (max_cell_chars), so the cut is normally invisible; it bounds the time a huge value can cost.

View Source
const Redacted = "<redacted>"

Redacted is the cell of a column masked in mode redact.

View Source
const RulesFile = ".locksql/pii.toml"

RulesFile is the rules file, relative to the project root.

Variables

This section is empty.

Functions

func AliasViolation

func AliasViolation(st sqlclass.Statement, r Rules, d sqlclass.Dialect) error

AliasViolation is the fallback for result columns without an origin: it refuses a READ select in which a rule-matched column (matched by name, see Rules.MatchesName) is renamed or used inside an expression anywhere in a select list, since its values would then reach a label that no rule matches. Renaming covers "AS x", an implicit alias, a later UNION arm whose first arm labels the position differently, CTE column lists over a body that mentions a matched column or selects * from a source that may hold one, a * in a later arm over such a source, and table alias column lists ("t(x, y)"), which can rename any column. A PostgreSQL whole-row reference to a table that may hold such a column ("SELECT u", "row_to_json(u)", "to_jsonb(users.*)") is refused too, see wholeRow. A matched column or whole-row reference inside a VALUES list or the arguments of a FROM-clause function, and a TABLE arm after the first, are refused as well. Every named relation counts as a source that may hold a matched column, since it may be a view over a rule table. COUNT(...) is allowed. The error is a *sqlclass.Refusal that explains how to write the query.

A statement that is not a plain READ select is checked the same way when it may return rows: a RETURNING list counts as a select list, and a statement led by SELECT, WITH, VALUES or TABLE that the classifier made WRITE (a data-modifying CTE) is checked whole. A write without RETURNING is only checked by writtenValues. Other statements (SHOW, DESCRIBE, PRAGMA, DDL) pass.

On PostgreSQL, "rel.f" may be the call f(rel) on the whole row of rel when rel has no column f (attribute notation: b.row_to_json, t.name, big.record_out). Since a statement alone cannot tell such a call from a column, a qualified reference "rel.x" to a FROM-clause relation is refused wherever its value may reach the result (select lists, RETURNING, VALUES, FROM-clause function arguments), see attrCall. ResultAliasViolation accepts a plain qualified item of the outer select list whose result column carries an origin, which proves it is a base-table column.

A write, with or without RETURNING, must not copy a rule-matched column's values into another column: a rule-matched column, a whole row or a star over a source that may hold one is refused anywhere its value may be written (SET values, the select list or VALUES of an INSERT source), see writtenValues.

func MaskOutputs

func MaskOutputs(res *engine.Result, outs []sqlast.Output, r Rules, ds []Detector, tok *Tokens, origin bool) error

MaskOutputs masks res in place following the analysed outputs of its statement: each column whose resolved sources are under a rule is masked in the output's mode. As a second line of defence a column whose engine- reported origin matches a rule is masked too. The other text and integer cells go through the detectors.

The result must have exactly the analysed columns, and the columns the analysis expects by name (plain references, aliases, star expansions) must carry that label: otherwise masking by position could hit the wrong column, and the result is refused.

func MaskResult

func MaskResult(res *engine.Result, r Rules, ds []Detector, origin bool)

MaskResult masks res in place.

A column is masked whole when its origin matches a rule. Engines report an origin only when the catalog confirms it is a base-table column; a view, a derived table or a CTE alias never counts. A view column is therefore masked only by its own name: a view that renames a rule column (CREATE VIEW v AS SELECT firstname AS contact ...) needs a rule for that name (*.*.contact or db.v.contact). When origin is false (the session reports no origins), or a column has none (an expression, a UNION, an untrusted origin the engine blanked), the column label is matched by name instead, see Rules.MatchesName; the console must then also refuse renamed uses with AliasViolation (see NeedsAliasCheck). Binary cells under a rule become "<masked bytes:N>"; NULL stays NULL.

Every other text cell, and every integer (a national number or a card can be stored as one), goes through the detectors in place.

func MaskValue

func MaskValue(v any, mode string, tok *Tokens) any

MaskValue masks one non-NULL cell in mode.

func NeedsAliasCheck

func NeedsAliasCheck(res engine.Result, origin bool) bool

NeedsAliasCheck reports whether the statement behind res must pass AliasViolation (ResultAliasViolation, given res.Columns) before its rows may be shown: the session reports no origins, or some result column came back without one.

func PlanCheck

func PlanCheck(st sqlclass.Statement, r Rules, d sqlclass.Dialect, origin bool) error

PlanCheck is the PII check a statement must pass before it runs, unless it is an unmask run: StatsViolation, then AliasViolation for a statement that is not a plain READ select (it runs before the write, whose result may carry no origin) or when the session reports no origins. A plain READ select on an origin-reporting session is checked after it runs, only when some result column has no origin (see NeedsAliasCheck).

func Propose

func Propose(cols []engine.ColumnInfo) []string

Propose returns the "db.table.column" patterns for the columns that look like personal data from their name or type, sorted and de-duplicated. A name that would break the pattern syntax becomes '*'.

func RedactMessage

func RedactMessage(msg, sql string, ds []Detector) string

RedactMessage removes from a database error message the row values it may quote: a failed cast or function call in a WHERE clause echoes the value it choked on ("invalid input syntax for type integer: \"Smith\""), and that value reaches no result row, so no rule or detector masks it.

Everything from the first quote character to the last one is replaced, unless each quoted part is text of the statement itself (a syntax error, an unknown column) or a known server phrase, joined only by known server words: a value cannot shift the pairing of quotes, since its text would then sit in a gap. A single stray quote redacts the rest of the message. The detectors then run over what is left; an unquoted value they cannot recognise is not caught.

func ResultAliasViolation

func ResultAliasViolation(st sqlclass.Statement, r Rules, d sqlclass.Dialect, cols []engine.ResultColumn) error

ResultAliasViolation is AliasViolation once the statement has run, with the result columns it returned: a plain qualified PostgreSQL item of the outer select list ("SELECT b.id, ...") is accepted when its result column has an origin. With cols nil, every such item is refused.

func SaveRules

func SaveRules(root string, r Rules) error

SaveRules writes the rules to <root>/.locksql/pii.toml, sorted and de-duplicated, replacing the file atomically.

func StatsViolation

func StatsViolation(st sqlclass.Statement, r Rules, d sqlclass.Dialect) error

StatsViolation refuses, while mask rules exist, a statement that names a planner statistics relation (pg_stats, pg_statistic, mysql.column_stats, information_schema.COLUMN_STATISTICS, sqlite_stat4, ...): they hold real values of the columns, rule columns included, which no rule can match.

func ValidMode

func ValidMode(m string) bool

ValidMode reports whether m is a mask mode.

Types

type Detector

type Detector interface {
	Name() string
	// Mask returns s with every value the detector recognises replaced by
	// its masked form (first character + "***" + "(length)").
	Mask(s string) string
}

Detector masks personal values found inside a text, in place.

func Detectors

func Detectors(names []string) ([]Detector, error)

Detectors returns the named detectors, de-duplicated, in their fixed run order. Names: email, phone, iban, card, be_niss, fr_nir, nl_bsn, us_ssn.

type Rules

type Rules struct {
	Mask  []string
	Allow []string
	// Modes maps a Mask pattern to its mode when it is not ModePartial.
	Modes map[string]string
}

Rules are the column rules. Each pattern is "db.table.column", where any segment may be '*'. For PostgreSQL the db segment is the schema. Matching ignores case. An Allow pattern beats every Mask pattern.

func LoadRules

func LoadRules(projectRoot string) (Rules, error)

LoadRules reads <projectRoot>/.locksql/pii.toml. A missing file gives empty rules and no error.

func (*Rules) Add

func (r *Rules) Add(pattern string) error

Add adds a mask pattern after validating it. Adding an existing pattern is a no-op.

func (*Rules) AddMode

func (r *Rules) AddMode(pattern, mode string) error

AddMode adds a mask pattern with a mode ("" for the default). Adding an existing pattern sets its mode.

func (Rules) Covered

func (r Rules) Covered(db, table, column string) bool

Covered reports whether a mask or an allow pattern names the column: the human already decided about it.

func (Rules) Matches

func (r Rules) Matches(db, table, column string) bool

Matches reports whether the column db.table.column is masked: some Mask pattern matches it and no Allow pattern does.

func (Rules) MatchesName

func (r Rules) MatchesName(column string) bool

MatchesName reports whether a column known only by its name may be under a rule: some Mask pattern has that column segment (whatever its db and table) and no Allow pattern exempts the name everywhere ("*.*.name"). It is the conservative test used when a result column has no origin.

Servers resolve identifiers with their own collation (MySQL compares column names by collation weight, so "fírstname" and "fİrstname" name the column firstname), which no Go case folding reproduces. A name with a non-ASCII character therefore matches as soon as any Mask pattern exists, and a non-ASCII character of a pattern matches any character.

func (Rules) Mode

func (r Rules) Mode(db, table, column string) (string, bool)

Mode returns the mask mode of the column db.table.column and whether a rule masks it. Several matching rules with different modes give ModeRedact.

func (Rules) ModeByName

func (r Rules) ModeByName(column string) (string, bool)

ModeByName is Mode for a column known by its name only (MatchesName).

type Tokens

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

Tokens is the token table of one console session (mask mode "hash"): a keyed HMAC of the value, so the same value always gives the same token within the session, and a random key per session, so tokens cannot be correlated across sessions. It remembers which value each token stands for, so that an agent may filter on a token (WHERE email = 'tok_...'): the console substitutes the value in the statement that runs. Only equality leaks: no order, no prefix, no length.

func NewTokens

func NewTokens() *Tokens

NewTokens returns a token table with a fresh random key.

func (*Tokens) Lookup

func (t *Tokens) Lookup(s string) (value string, isToken, ok bool)

Lookup resolves a token issued in this session. isToken reports whether s has the token format at all.

func (*Tokens) Token

func (t *Tokens) Token(value string) string

Token returns the token of value.

Jump to

Keyboard shortcuts

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