anon-kit
v0.1.6
Published
Mask sensitive data in any Postgres database.
Maintainers
Readme
Anon-kit
Mask sensitive data in any Postgres database for development, testing, or analytics. Point Anon-kit at a disposable copy created with a full clone, backup restore, or database branch.
Workflow
Anon-kit masks the Postgres database it connects to in place. It does not create database copies.
- Create a disposable copy of production.
- Mask and verify the production copy, then keep it unchanged as the baseline.
- Create copies from the masked baseline for development, testing, and analytics.
production (restricted)
└── verified masked baseline (restricted and kept unchanged)
├── developer copy
├── test copy
└── preview copyMasking rewrites the selected fields, so retaining a verified masked baseline avoids repeating that work for every environment.
Refresh the baseline by repeating the workflow with a new copy of current production.
Databricks Lakebase and Neon Postgres
Database branching makes both copy steps instant. Masking is the only step whose time grows with database size, so create a database branch of production and mask it once. Then create isolated developer, test, and preview database branches from the masked baseline.
Regular Postgres
Anon-kit works with regular Postgres too. Clone production or restore a backup, mask that copy once, then use it as the source for developer, test, and preview copies. Unlike database branches, each copy takes time and requires storage proportional to the database size, but you still avoid repeating the masking work.
Quick start with an agent
Paste this into your coding agent:
Install the Anon-kit skill with `npx skills add lirbank/anon-kit --skill anon-kit`, then use it to work with me to build a bespoke masking solution and masked-baseline workflow for my Postgres database.
The skill is the primary way to use Anon-kit. It guides the agent to study the reference implementation, then install, adapt, translate, or replace it with a masking solution that fits the repository and database. The resulting source becomes project-owned code.
The agent drafts the masking decisions and stops for review before changing data. After implementation it should document the bespoke solution's commands, strategies, limitations, and procedure for creating or refreshing copies.
Safety
Anon-kit is a starting point, not a guarantee that data is anonymous or compliant with any standard. It masks a database in place, so only point it at a disposable copy of production. Keep the copy restricted until your team has reviewed the masking decisions and verification results.
npm package
The npm package is a standalone CLI. Its documentation and source in this repository form the reference implementation agents study when building a bespoke solution. The commands, strategies, and limitations below describe the package exactly. An agent-built solution may differ.
Safety model
Anon-kit rewrites data in place. Point
ANON_KIT_DATABASE_URLat a copy of production, never at production itself — masking production destroys the real data.
- Every column gets a decision. Columns default to
keep, and leaving a column onkeepis an explicit claim that it is not sensitive. A column that appears in the live schema but not in the map failsapply— new columns can never slip through unmasked. - Leak checks prove the mask ran.
applyderives a verification query from the map. Every strategy whose output is recognizable contributes a check that must return zero rows. Any leak exits non-zero. - Masked values stay consistent. Hash-based strategies key off a single salt, so within one run the same input masks to the same output — duplicates stay duplicates, joins keep resolving. The salt is generated per run and discarded, so nothing links an entity across runs.
Usage
Create a disposable copy of production and get its connection string:
- Databricks Lakebase — create the masked baseline and its downstream copies as Lakebase Autoscaling branches, then use a Lakebase OAuth token as the password.
- Neon Postgres — create a database branch in the console or with
neon branches create. - Any Postgres — restore a dump into a scratch database with
pg_dumpandpg_restore.
Set
ANON_KIT_DATABASE_URL, in the environment or in a.envfile in the working directory:export ANON_KIT_DATABASE_URL="postgres://..."Generate the map:
npx anon-kit initinitintrospects the database and writesanon-kit.jsonwith every table and column listed.Edit
anon-kit.json: set a masking strategy on each sensitive column (see Masking strategies). A column left onkeepships its real values.Mask and verify the database:
npx anon-kit applyapplycompiles the map to SQL, asks you to confirm the target host, masks the database in place, and runs leak checks that must come back clean.
After the checks pass, keep the verified masked copy unchanged as the baseline, then create development, test, and analytics copies from it.
Commands
Both commands connect to the database at ANON_KIT_DATABASE_URL, read from the environment or from a .env file in the working directory. Running npx anon-kit with no command prints usage.
init
npx anon-kit initIntrospects ANON_KIT_DATABASE_URL and writes anon-kit.json. Every column starts as keep, and foreign keys are prefilled with follow_fk. The file's $schema reference gives editor autocomplete and typo-flagging while you edit. Refuses to overwrite an existing map.
apply
npx anon-kit apply [--compile-only] [--yes]Validates the map against the live schema, compiles it to .anon-kit/mask.sql and verify.sql, prints the target host for confirmation, masks the database, and runs the leak checks. Exits non-zero on any leak or on schema drift, so a bad copy never gets handed out.
--compile-only— write the generated SQL and stop, to review exactly what would run.--yes— skip the confirmation prompt, for CI where the URL is machine-placed.
Masking strategies
One masking strategy per column, in the map (anon-kit.json). init writes it, you edit it, apply compiles it to SQL.
{
"public.patients": {
"email": {
"strategy": "email",
"_pgType": "text",
"_nullable": false
},
"ssn": {
"strategy": "redact",
"sentinel": "XXX-XX-XXXX",
"_pgType": "text",
"_nullable": false
},
"dob": {
"strategy": "date_shift",
"key": "patient_id",
"_pgType": "date",
"_nullable": false
}
}
}A column entry is the strategy, whatever fields that strategy needs, and two machine-written schema facts (_pgType, _nullable) that init caches from the live schema. The underscore fields are not settings — apply errors when they go stale — but they let the editor flag an incompatible strategy (say, email on a date column) as you edit. "strategy": null means not decided yet. apply refuses to run while any column is null, unknown, or incompatible with its column type.
keep
Ships the real value untouched.
{ "strategy": "keep" }- The default
initwrites for every column. Choosing it is an explicit claim that the column is not sensitive. - Drift protection is what makes keep-by-default safe: a new live column missing from the map fails
apply, so every column gets a decision. - Leak check: none possible — keep is trust.
hash_id
Replaces an identifier with a salted SHA-256 hex string (64 chars). Every column that declares follow_fk against it is rewritten from an old→new map in the same transaction, with constraints deferred, so joins keep resolving.
{ "strategy": "hash_id" }- Goes on the referenced side (usually the PK). Columns pointing at it use
follow_fk. - Text and varchar columns only — integer ids can't hold 64 hex chars.
- Use it when the id itself is sensitive (MRNs, SSN-derived ids, external ids). A meaningless serial int can stay
keep. - Leak check: every value matches
^[0-9a-f]{64}$.
follow_fk
For columns that reference an id: the column takes whatever the referenced column got.
{ "strategy": "follow_fk", "references": "public.patients.patient_id" }references(required) —schema.table.columnof the id column this one points at.initprefills it for constraint-backed FKs. Add it by hand for soft FKs (no constraint in the schema), which introspection can't see.- The referenced column must be
hash_idorkeep. The rewrite is driven by these entries, so a soft FK masks exactly like a declared one. - Declared constraints can't be missed:
applyfails if an FK constraint points at ahash_idcolumn and the child column isn'tfollow_fkagainst it. - Leak check: inherits the
hash_idpattern when the referenced column ishash_id.
first_name / last_name
Fake names of the form Pat_a1b2c3d4 / Doe_a1b2c3d4, derived by hashing the original.
{ "strategy": "first_name" }- Deliberately obviously fake — masked data can never be mistaken for real.
- Same original name → same fake within a run, so name frequency survives. A rare surname's rarity is still a signal. Use
redactwhere that matters. - Leak check: every value matches
^Pat_[0-9a-f]{8}$/^Doe_[0-9a-f]{8}$.
Fake address at example.invalid. The local part is 16 hex chars hashed from the original.
{ "strategy": "email" }- Shape-valid so app-level validation keeps passing.
.invalidis a reserved TLD, so nothing can ever route there. - Leak check: every value ends in
@example.invalid.
phone
Fake number of the form 555-NNN-NNNN, digits hashed from the original.
{ "strategy": "phone" }- North-American shape only. Real formats vary per row (extensions, country codes) and are not preserved.
- Leak check: every value matches
^555-\d{3}-\d{4}$.
redact
Replaces every value with one sentinel, or NULL. The only strategy that leaves no per-row signal at all.
{ "strategy": "redact", "sentinel": "XXX-XX-XXXX" }sentinel(required) — the replacement string, ornullto null the column.nullneeds a nullable column. A sentinel keeps the schema identical to production, which is why it's the default recommendation on NOT NULL columns.applyprobes each sentinel against its live column up front, so one that can't be cast to the column type (say"abc"on an integer column) fails before anything is written.- The right default for anything devs don't actually need realistic values for. Reach for shape-preserving strategies only when something depends on the shape.
- Leak check: every value equals the sentinel (or IS NULL).
date_shift
Shifts all of an entity's dates by the same hashed offset (±1–364 days, never zero), so intervals between an entity's events hold.
{ "strategy": "date_shift", "key": "patient_id" }key(required) — a column in the same table holding the entity id. The shift is derived from the original key value (masking runs before the id rewrite), so all rows keying on the same entity shift identically — even across tables.- Preserves durations (admit → discharge) and rough seasonality. Does not hide the year reliably, and an attacker who knows one real date for an entity recovers the shift and with it every other date.
- Leak check: none — shifted dates are indistinguishable from real ones by pattern.
zip3
Keeps the first three zip digits, zeros the rest: 94301 → 94300.
{ "strategy": "zip3" }- Mirrors the HIPAA safe-harbor generalization, with one gap: safe harbor also requires fully zeroing zip3 areas with population under 20k, which this does not do. Not a compliance claim.
- Nine-digit zips are truncated to the 5-char form.
- Leak check: every value ends in
00.
scrub_text
Regex pass over free text replacing SSN, email, and phone patterns with [SSN], [EMAIL], [PHONE].
{ "strategy": "scrub_text" }- The weakest strategy: names and any other sensitive prose survive ("Patient Alice Garcia presented..." stays intact). Use it only when devs genuinely need the text. Otherwise, use
redact. - Leak check: the same three patterns return zero matches — it verifies the scrub ran, not that the text is clean.
Limitations
- Materialized views hold their own copy of the data, so masking the base tables leaves them intact.
applyrefuses to run while any exist — drop them on the copy and recreate them after masking, so they rebuild from masked tables. - Partitioned tables are untested. Introspection lists the parent and each partition separately, so a masked partitioned table may be rewritten twice — for
hash_idthat breaks join consistency. Review--compile-onlyoutput before relying on it. - Column length is not validated.
hash_idwrites 64 characters andemail32. On a shortervarchar(n)the mask fails mid-run. The transaction rolls back — nothing is left half-masked — but the error surfaces at runtime, not at validation. - Triggers fire during masking. A trigger that copies old row values (audit logging) reintroduces real data mid-run, and rows it inserts are not masked. Disable user triggers on the copy (
ALTER TABLE … DISABLE TRIGGER USER) beforeapplyif any trigger records row values. - Names containing a dot can't be represented: the map keys are
schema.table, so a table or column named with a literal.breaks the format.
Contributing
Setup
bun install
cp .env.example .env # set ANON_KIT_DATABASE_URL to a throwaway Postgres database
bun run seed # create and fill the demo tablesDev loop
bun test— no database neededbun src/cli.ts init— introspect the database and scaffold anon-kit.jsonbun src/cli.ts apply --compile-only— validate the map and write.anon-kit/mask.sql+verify.sqlbun src/cli.ts apply— mask the database and run the leak checksbunx tsc --noEmitandbun run formatbefore committing
How a strategy is built
A strategy is two files in src/strategies/. Take zip3 from the strategy list above as an example. zip3.ts is the descriptor: which column types the strategy accepts, how to build the masking SQL, and which leak check proves the mask ran. zip3.sql holds the Postgres function the masking SQL calls. Every descriptor field is documented in types.ts, and a strategy that doesn't need its own Postgres function, like redact, skips the second file.
Adding masking strategies
Create the two files, run bun run schema to regenerate the registry and the JSON schema, and add a fixture in core.test.ts — the typecheck fails until the fixture exists.
The anon extension's masking functions are a good source of ideas for new strategies.
Removing masking strategies
Delete the files, rerun bun run schema, and take it out of the tests and any map that uses it.
Cutting a release
Set version in package.json to the new version and push. The release workflow publishes it to npm and creates the matching GitHub release.
- Stable versions (
1.2.0) publish aslatest. They release from main only and must be newer than the currentlatest. - Prereleases (
1.2.0-beta.1) publish under thebetadist-tag (npm i anon-kit@beta). They release from any branch, so a beta line never blocks stable releases from main. - To graduate a beta, set any stable version (
1.2.0,1.3.0) and push to main.
The workflow releases only when package.json holds a version that is not yet on npm, so ordinary pushes publish nothing. A run that fails before publishing releases nothing either — fix and push, and the release completes on the next run.
Verify with npx anon-kit@latest from an empty directory.
