anon-kit
v0.1.5
Published
Mask sensitive data in any Postgres database.
Downloads
244
Maintainers
Readme
anon-kit
Mask sensitive data in any Postgres database.
Point anon-kit at a copy of production, set a masking strategy per column, and apply. Real names, emails, and identifiers come out masked and verified by leak checks — a database you can hand to development, testing, or analytics.
Safety model
- 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
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.
Get a connection string to the database to mask — a copy of production (see Getting a copy to mask).
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 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.
The copy now holds masked data — hand it to development, testing, or analytics.
Getting a copy to mask
apply masks whatever ANON_KIT_DATABASE_URL points at, so the first step is a disposable copy of production:
- Neon — create a database branch of production in the console or with
neon branches create, and use the branch's connection string. - Databricks Lakebase — create a database branch in the console or with the Databricks CLI, and use the branch's connection string with an OAuth token (
databricks auth token) as the password. - Any Postgres — restore a dump into a scratch database (
pg_dumpproduction,pg_restoreinto the copy).
On Neon and Lakebase the copy is instant at any database size: a database branch is born with production's schema and data, and a fresh one is a single command away. The payoff compounds after masking: development, testing, and analytics branch instantly off the one masked branch instead of each masking their own copy — masking a large database takes time, branching takes none. To refresh a masked copy, recreate the branch and run apply again.
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; 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.
--version
npx anon-kit --versionPrints the version. -v is the short form.
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
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.
