Security · locksql
How the PII analysis works
This note is for contributors: it explains how the console decides what a statement may do with personal data and which cells it masks, and where the code lives. The rules themselves, with their reasons, are in security-model.md; the user's view is in usage.md.
The design in one sentence: a read is parsed in full, every output column is traced back to the base columns it comes from, and masking is decided on those sources, never on the column's label. What cannot be traced is refused.
The pipeline #
A query.plan request goes through these steps in the console
(internal/console/handlers.go, readpath.go):
- Classify (
internal/sqlclass). A lexer per dialect splits the statement outside literals and comments, refuses several statements, comments, variables, bind parameters and unbalanced quotes, and gives it a class (read, write, ddl, admin) compared with the profile's tier. Reads must beSELECT,WITH ... SELECTorEXPLAIN SELECTwith aLIMITwithinmax_rows. - Parse (
sqlast.Parse,parser.go). A hand-written recursive-descent parser for the read grammar of MariaDB/MySQL, PostgreSQL and SQLite builds an AST (ast.go). It is fail-closed: a construct outside the supported grammar is a refusal, not a guess. Nesting is bounded (maxDepth). - Analyze (
sqlast.Analyze,analyze.go), with anEnv: aCatalogthat resolves table names from the session's column catalog (console/readpath.go),Rule, which says whether a base column is under a mask rule and in which mode. A statement holding a placeholder or a cell reference ('${...}') is refused first (E_PLACEHOLDER): neither is supported. The result is anAnalysis(below). - Weigh. The console runs
EXPLAINon the statement and on each k-anonymity count, andinternal/weightturns the plans into a verdict. - Count. Before the ticket goes to lockhub, the k-anonymity counts run
(
console.kCount). Fewer thank_anonymityrows is a warning, never a refusal. - Approve. The human sees, on the ticket in lockhub, the statement
(every literal masked), the PII columns it touches (
Analysis.Uses), the masked outputs, the counts and ak-anonymitywarning for each count under k; the warnings go to the audit records too. - Run and mask (
pii.MaskOutputs,modes.go). The rows are masked column by column from the analysis, then go through the value detectors, then through the output caps (internal/render).
A write never reaches sqlast: the human's approval is its gate, and
pii.PlanCheck and pii.AliasViolation (mask.go) refuse a write whose
RETURNING list or data-modifying CTE would move a rule column under
another label, or that copies a rule column into another column or plants
an agent value into one. Its result, if any, is masked by origin and by
name (pii.MaskResult).
Provenance #
The core of analyze.go is Prov, the provenance of a value:
| Field | Meaning |
|---|---|
Sources |
the base columns (db.table.column, Source) the value derives from |
Kind |
how: KindConst (no column), KindCount (a row count), KindIdentity (a value of one source: a plain reference, through aliases, subqueries, CTEs and set operations, ANY_VALUE, or MIN/MAX of an unmasked column), KindAggregate, KindExpr |
Sensitive |
a source is under a mask rule and the value reveals source values (identity, aggregate or expression) |
Modes |
the mask modes of the sensitive sources |
Lit |
some values may be literals of the statement (UNION ALL SELECT 'x') |
w |
a bound on the value's size (width.go) |
The analyzer walks the query with scopes (scope, relation, column):
- Base tables resolve through the catalog; an unknown table or column
is refused. A view's columns are
Source{View: true}: rules match them by column name only, since the analyzer does not see through a view. - Derived tables, CTEs and set operations pass the provenance of their
columns up. A
UNIONmerges the provenance of its arms column by column. A recursive CTE is analysed to a fixpoint: the analyzer re-runs its body in "dry" mode until the columns' provenance stops changing, and refuses one that does not converge. *andUSING/NATURALjoins expand to the columns the engine will return, in its order, so that each output position has a known provenance.- Expressions combine the provenance of their operands. A function
must be on the per-dialect allowlist (
funcs.go): pure functions on values, the usual aggregates and window functions, nothing that reads files, opens connections, sleeps, locks, changes the session or reads server metadata.COUNTgivesKindCount;ANY_VALUEkeeps identity, and so doMINandMAXof an unmasked column;MINandMAXof a PII column, whose result depends on how the values order, and other aggregates giveKindAggregate.
Each output column becomes an Output with its provenance, the label the
engine must report (an alias or a plain column name, or "" when the
engine names it) and its mask (maskOf): none when it is not sensitive;
the rule's mode for an identity value under one mode; redact for an
aggregate, an expression or several modes.
The usage rules #
With masking on (every plan but an approved unmask), the analyzer also
refuses what would turn a masked column into an oracle. The checks are in
filter, comparison, inFilter, joinEquality, setOpCompare,
orderItem and call; the refusal messages say what to write instead.
- Filters (
WHERE,HAVING,ON). A PII column may only appear in a positive atom reached throughAND: equality with a literal (=,IN (literals),IS NULL) inWHEREorHAVING, or equality with another PII column (a join). UnderNOT,ORorXOR, or with<>,NOT IN,IS NOT NULL, the atom could select rows the k-anonymity counts do not cover.LIKE, ranges and functions over a PII column are refused: they compare it character by character. - Joins must pair two plain PII columns: a PII column joined with an
unmasked one would copy its values where no mask applies. An accepted
PII equality (
ON,WHERE,IN (subquery),USING,NATURAL, and in a write's filters too) is recorded inPIIJoinsasdb.table.col = db.table.col: it runs no count, but the console warns the human on the ticket, hides the row estimates from the agent and refuses itsEXPLAIN, and no grant covers it (see "Joins between PII columns" in the security model). - Set operations.
INTERSECT,EXCEPTand the duplicate removal ofUNIONare equality tests: a PII column may meet only PII columns there. - Ordering and windows.
ORDER BY,PARTITION BY,DISTINCT ONandFILTERover a PII column are refused (they rank values). EXPLAINof a statement that filters, groups, aggregates or joins PII is refused: the planner's row estimates would answer the question the counts guard.
k-anonymity counts #
A filter, grouping, DISTINCT or aggregate on a PII column needs row counts, planned
by the analyzer (KCheck) and run by the console before the approval
prompt (console.kCount):
kCheckbuildsSELECT COUNT(*)from the statement's ownFROM,WHERE,GROUP BYandHAVINGtext (the smallest group for a grouped query). A correlated subquery, a recursive CTE or aSELECTwithoutFROMcannot be counted on its own and is refused.constFilteradds, for each constant compared with a PII column, a count of that value's subjects in the column's own base table, so that a join cannot inflate it. AnINlist counts each value apart and checks the smallest.
A count under k_anonymity (or one that cannot run) does not refuse the
statement. kCount returns it as a warning: the console adds it to the
ticket and to the audit records' warnings, and the human decides. Before v0.5.2 it was a refusal (E_K_ANON, now retired). The
agent learns nothing of a count: its answer is the same either way. The
structural rules that make a count meaningful (equality only, AND only,
no self-comparison, no view column under a constant filter) still refuse.
PIIFilter marks a statement whose row estimates must stay on the
console; so does a non-empty PIIJoins.
Masking the result #
pii.MaskOutputs (modes.go) checks the result against the analysis
before it masks anything: the column count must match and every labelled
output must carry its expected label, or the result is dropped, since
masking by position could hit the wrong column. Then, per column:
- the analysed mask applies (
redact,partial,email;rules.go); - as a second line of defence, a column whose engine-reported origin
(
OriginColumns, when the driver reports one) matches a rule is masked even if the analysis did not mask it; - every other text or integer cell, and every integral float (
|f| < 1e19, a PostgreSQLfloat4/float8), goes through the value detectors (lockkit'spiipackage: email with Unicode local parts and domains, IBAN and card with their checksums, Belgian, French, Dutch and US national numbers, phone numbers, typographic spaces and full-width digits), which mask what they find inside the text.
A redact cell is plain <redacted>: no cell carries a reference to its
value, and the console keeps no value of a result.
The rules and where they come from #
Rules live in named rule sets (pii.Rules), one file per set:
pii/<set>.toml next to the config (pii.SetPath). A profile's set is its
pii_rules key, by default its own name in lower case
(config.RuleSetOf); the console resolves it once at start
(console.resolveRuleSet) and stores it in the approved policy
(Profile.PIIRules), so config.Diff reports a switch of set as a
loosening. The legacy single pii.toml is moved once to the set default
(pii.MigrateLegacy), and the profiles approved before keep that set until
they name one. A set file holds [[mask]] patterns
db.table.column (any segment may be *, or a name with a leading or
trailing * such as *email*) with an optional mode, and
[[allow]] patterns that beat every mask. At every start the console
scans the schema and proposes rules for columns whose names look personal
(detect.go: tokens in English, French, Dutch, German and Spanish), and
quasi-identifiers apart. Adding a rule is a tightening any client may ask
for; removing one is a loosening the human confirms. The first segment is
the schema on PostgreSQL. A client's pii.add is resolved against the
catalog (console/piicheck.go): a pattern without * that names no column
is refused with the columns it may mean, since such a rule masks nothing
while the client believes the column protected; a glob that matches
nothing yet is accepted with a warning. pii.unmatched lists the dead
rules for pii list.
Where to start #
- A bypass: add the statement to
internal/sqlast/adversarial_test.go(or the classifier corpus,internal/sqlclass/corpus_test.go) and make it refuse. - A legitimate statement refused: find the refusal text in
analyze.goorparser.go; extend the grammar or the allowlist only where the provenance stays exact. - A masking question:
internal/pii/modes.goand its tests, then the console'sreadpath.go.
Known limits (views renaming rule columns, differencing attacks across queries, best-effort detectors) are listed in the security model's limitations.