npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

anon-kit

v0.1.5

Published

Mask sensitive data in any Postgres database.

Downloads

244

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 on keep is an explicit claim that it is not sensitive. A column that appears in the live schema but not in the map fails apply — new columns can never slip through unmasked.
  • Leak checks prove the mask ran. apply derives 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_URL at a copy of production, never at production itself — masking production destroys the real data.

  1. Get a connection string to the database to mask — a copy of production (see Getting a copy to mask).

  2. Set ANON_KIT_DATABASE_URL, in the environment or in a .env file in the working directory:

    export ANON_KIT_DATABASE_URL="postgres://..."
  3. Generate the map:

    npx anon-kit init

    init introspects the database and writes anon-kit.json with every table and column listed.

  4. Edit anon-kit.json: set a masking strategy on each sensitive column (see Masking strategies). A column left on keep ships its real values.

  5. Mask the database:

    npx anon-kit apply

    apply compiles 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_dump production, pg_restore into 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 init

Introspects 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 --version

Prints 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 init writes 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.column of the id column this one points at. init prefills 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_id or keep. The rewrite is driven by these entries, so a soft FK masks exactly like a declared one.
  • Declared constraints can't be missed: apply fails if an FK constraint points at a hash_id column and the child column isn't follow_fk against it.
  • Leak check: inherits the hash_id pattern when the referenced column is hash_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 redact where that matters.
  • Leak check: every value matches ^Pat_[0-9a-f]{8}$ / ^Doe_[0-9a-f]{8}$.

email

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; .invalid is 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, or null to null the column. null needs a nullable column; a sentinel keeps the schema identical to production, which is why it's the default recommendation on NOT NULL columns.
  • apply probes 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: 9430194300.

{ "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. apply refuses 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_id that breaks join consistency. Review --compile-only output before relying on it.
  • Column length is not validated. hash_id writes 64 characters and email 32; on a shorter varchar(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) before apply if 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 tables

Dev loop

  • bun test — no database needed
  • bun src/cli.ts init — introspect the database and scaffold anon-kit.json
  • bun src/cli.ts apply --compile-only — validate the map and write .anon-kit/mask.sql + verify.sql
  • bun src/cli.ts apply — mask the database and run the leak checks
  • bunx tsc --noEmit and bun run format before 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 as latest. They release from main only and must be newer than the current latest.
  • Prereleases (1.2.0-beta.1) publish under the beta dist-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.