Discover, mask, and verify sensitive data in SQL databases — an auditable scan → mask → validate workflow for safe database copies.
134
stars
61
commits
Python
primary language
Sep 10, 2026
updated
Discover which columns hold sensitive data, mask them with realistic deterministic fakes, then verify the masking actually happened — one auditable workflow for making safe copies of SQL databases.
pip install dbmask
Production data constantly leaks into places with weaker controls: dev and
test systems, demo environments, analytics warehouses, vendor handoffs, AI
pipelines. dbmask is for the moment you copy that data: it finds the
sensitive columns, rewrites them with consistent fakes, and then checks its
own work row by row.
Everything below runs locally against a throwaway SQLite file (bash syntax; use your own database URL for the real thing).
# 0. A demo database
python -c "
import sqlite3
db = sqlite3.connect('demo.db')
db.executescript('''
CREATE TABLE customers (id INTEGER PRIMARY KEY, full_name TEXT, email TEXT);
INSERT INTO customers (full_name, email) VALUES
('Mary Johnson', 'mary.johnson@corp.example'),
('Robert Smith', 'robert.smith@corp.example'),
('Linda Davis', 'linda.davis@corp.example');
'''); db.commit()"
# 1. A minimal config
cat > dbmask.yaml <<'EOF'
database:
url: sqlite:///demo.db
source_database:
url: sqlite:///demo_original.db # untouched copy, used by `validate`
detection:
skip_column_patterns: ["^id$"] # surrogate keys aren't sensitive
masking:
seed: pick-a-private-seed
EOF
# 2. Which columns are sensitive? (read-only)
dbmask scan --config dbmask.yaml
# [ok ] main.customers.id (skip, conf=1.00)
# [SENSITIVE] main.customers.full_name -> full_name (pattern, conf=0.90)
# [SENSITIVE] main.customers.email -> email (pattern, conf=1.00)
# 3. Preview the changes (dry run; originals are shown redacted)
dbmask mask --config dbmask.yaml
# 4. Keep an untouched copy, then actually mask
cp demo.db demo_original.db
dbmask mask --config dbmask.yaml --apply
# 5. Prove it worked: row counts, schema, and per-row value comparison
dbmask validate --config dbmask.yaml --strict
# RESULT: PASSED ✓
Prefer code over a shell? python examples/quickstart.py runs the same story
end-to-end, and the library API
mirrors the CLI.
These are behaviors, not aspirations — each one has a regression test:
mask never writes without --apply. The flag is the single source of
truth; a config file cannot turn a preview into a write.mask refuses to run (exit 2) rather than silently leaving that column
unmasked. --allow-partial is the explicit escape hatch.UNKNOWN, are never masked, never persisted, and both scan
and mask tell you to review them.NOT MASKED
warning instead of a preview that pretends otherwise.***-**) in output
by default (--show-values to reveal), dry runs have no side effects, and
validation reports carry shape-redacted samples only.validate exits non-zero on failure;
--strict also fails on anything it could not verify.For each column, manual overrides take precedence. Historical decisions are reused only after review and while their type, expiry and strategy remain applicable. Machine suggestions stay pending until an analyst imports a reviewed file.
flowchart TD
A[Column] --> B{Manual override?}
B -->|yes| Z[Decision]
B -->|no| C{Imported history?}
C -->|approved and applicable| Z
C -->|pending or invalid| R[Human review required]
C -->|none| D[Patterns and optional LLM]
D --> S[Pending suggestions]
S --> H[Human review and file import]
H --> C
config/dbmask.fields.yaml): a human decision always wins.pip install "dbmask[excel]"), or a strict Markdown
table. Set history.source_file to keep that original file authoritative;
history-writeback merges human-approved review rows back into it with a
backup and conflict checks. See the history workflow.llm.send_values: false
restricts even a remote provider to column names only. The CLI warns
explicitly before any values would leave the machine.Deterministic by construction: the same input always maps to the same output
(seeded from masking.seed), so Tesla masks identically in every table and
joins survive.
| Strategy | Output | Valid for its type? |
|---|---|---|
fake_name / fake_first_name / fake_last_name | consistent fake from bundled dictionaries | text |
fake_city | another real US city | text |
fake_email | first.last123@example.invalid — reserved TLD, can never deliver | ✓ |
fake_email_keep_domain | same, but keeps the original domain (identifiable — opt-in) | ✓ |
fake_uuid | a real, deterministic v4 UUID | ✓ |
fake_ip | valid IPv4 octets / IPv6 hex, grouping kept | ✓ |
fake_phone / fake_ssn | constrained phone/SSN formats, separators kept | ✓ |
fake_credit_card | same brand/length, separators kept, Luhn-valid | ✓ |
fake_date | ±30–730-day deterministic shift — always a real calendar date | ✓ |
format_random | same length & character classes (Ab3-9z → Qf7-2k) | typed values stay typed |
shuffle | characters permuted in place | typed values stay typed |
redact | ****, separators kept | text |
null / blank | SQL NULL / empty string | ✓ |
Typed Python values (int, float, Decimal, date, datetime, UUID, bool) come
back as their own type and valid for it — a masked DATE column never
receives 8342-73-51. Register your own with register_strategy(...) and
register_dictionary(...).
Which strategy applies? Per-column override → your rule mapping →
built-in default for the detected rule → masking.default_strategy. Long
free-text fields (notes, comments) are exactly where you should decide
yourself — blank, redact, or format_random:
masking:
column_strategies:
notes: blank # used when no reviewed history strategy is set
rule_strategies:
email: fake_email # per detected rule
default_strategy: format_random
Determinism alone drifts: reorder a dictionary file, or change the seed, and
every recomputed mapping silently changes. The seed map (on by default)
writes each original → masked pair down the first time it is used — keyed
by a salted hash, never the original value — and reuses it forever after.
Last month's masked snapshot and today's agree; joins across databases stay
intact.
masking:
seed_map:
enabled: true
url: # blank = sqlite:///dbmask_seedmap.db
salt: ${DBMASK_SEED_SALT} # keep the salt out of the store (recommended)
Details, threat model, and the pair-tracking CLI (dbmask seeds):
docs/seed-map.
dbmask validate compares the masked database against the untouched source
and exits non-zero for CI gates:
| Check | What it proves |
|---|---|
| Row counts | masking changed values — never added or dropped rows |
| Schema elements | columns/types, PK, indexes, FKs, constraints all match |
| Masking completeness | per-row: no sensitive value survived unchanged |
Completeness is primary-key aligned: source and target rows are matched
key-by-key and the sensitive column compared value-by-value, which catches a
row where one field survived unmasked even though others changed. Tables
without a usable key fall back to a documented heuristic whose clean result
is a warning, not a pass — --strict turns any "could not verify" into a
failure. Reports state their coverage explicitly.
The connector is a single SQLAlchemy code path, so PostgreSQL, MySQL/MariaDB,
SQL Server, Oracle, SQLite and anything else with a SQLAlchemy dialect are
wired up (pip install "dbmask[postgres]" etc.).
Honesty about testing: the automated suite currently exercises SQLite on CPython 3.9–3.14 (Linux + Windows). PostgreSQL and MySQL integration tests are the next roadmap item — until they land, treat those engines as "supported by construction, verified by early adopters", and please report anything that misbehaves.
Masking reduces exposure; it is not anonymization, and dbmask does not pretend otherwise:
masking.seed (or the default — the CLI warns) can
recompute the mapping for values they can guess. Choose a private seed,
and set the seed-map salt from the environment.notes is usually blank/redact,
not clever faking.UNKNOWN columns,
keep overrides for what matters, and treat validate --strict as the gate.--apply at the primary.Found a hole in any of these guarantees? That's a security report: SECURITY.md.
Different tools solve adjacent problems — this table is about workflow shape, not maturity (several of these are excellent and far more battle-tested):
| Tool | Shape | Where dbmask differs |
|---|---|---|
| Presidio | PII detection/de-id framework (text, images) | dbmask is an end-to-end database workflow: discover → mask → validate on live connections |
| Greenmask | PostgreSQL dump anonymizer (Go) | cross-engine via SQLAlchemy; live DBs, not dumps; built-in discovery & validation |
| pynonymizer | dump anonymizer, hand-written column list | dbmask discovers columns and verifies the result |
| PostgreSQL Anonymizer | in-database extension (PG only) | no extension install needed; works where you only have a connection string, and across engines |
| Tonic / Gretel | commercial platforms | open source, pip-installable, config-in-git |
If you need heavy-duty subsetting, synthesis, or enterprise scale today, those tools may serve you better — dbmask optimizes for one auditable pipeline you can read in an afternoon.
Two YAML files (copy the *.example.yaml from config/, drop the
.example): the main config (connection, detection, masking, validation —
secrets via ${ENV_VAR}) and the field-override file (your manual
sensitive/safe toggles). Every option is commented in the examples;
full reference: docs/configuration.
0.1.x — young and moving fast. The current release focused on making the
safety envelope real (fail-closed scanning, PK-aligned verification, valid
typed output, no PII in previews/logs — see the
changelog). Near-term roadmap:
scan / apply / verify) with a
deprecation policyUsing dbmask anywhere real? Add yourself to ADOPTERS.md or file adopter feedback — including "we chose something else because…". It steers the roadmap.
git clone https://github.com/sealandseacat/dbmask.git
cd dbmask
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
Every bug fix ships with a regression test that fails on the old code — the test suite doubles as documented history of every sharp edge found so far. See CONTRIBUTING.md.
If dbmask is useful in your work, cite it via the repository's CITATION.cff (GitHub's "Cite this repository" button).
53 commits
8 commits
Python
100.0%
Discover, mask, and verify sensitive data in SQL databases — an auditable scan → mask → validate workflow for safe database copies.
134
stars
61
commits
Python
primary language
Sep 10, 2026
updated
Discover which columns hold sensitive data, mask them with realistic deterministic fakes, then verify the masking actually happened — one auditable workflow for making safe copies of SQL databases.
pip install dbmask
Production data constantly leaks into places with weaker controls: dev and
test systems, demo environments, analytics warehouses, vendor handoffs, AI
pipelines. dbmask is for the moment you copy that data: it finds the
sensitive columns, rewrites them with consistent fakes, and then checks its
own work row by row.
Everything below runs locally against a throwaway SQLite file (bash syntax; use your own database URL for the real thing).
# 0. A demo database
python -c "
import sqlite3
db = sqlite3.connect('demo.db')
db.executescript('''
CREATE TABLE customers (id INTEGER PRIMARY KEY, full_name TEXT, email TEXT);
INSERT INTO customers (full_name, email) VALUES
('Mary Johnson', 'mary.johnson@corp.example'),
('Robert Smith', 'robert.smith@corp.example'),
('Linda Davis', 'linda.davis@corp.example');
'''); db.commit()"
# 1. A minimal config
cat > dbmask.yaml <<'EOF'
database:
url: sqlite:///demo.db
source_database:
url: sqlite:///demo_original.db # untouched copy, used by `validate`
detection:
skip_column_patterns: ["^id$"] # surrogate keys aren't sensitive
masking:
seed: pick-a-private-seed
EOF
# 2. Which columns are sensitive? (read-only)
dbmask scan --config dbmask.yaml
# [ok ] main.customers.id (skip, conf=1.00)
# [SENSITIVE] main.customers.full_name -> full_name (pattern, conf=0.90)
# [SENSITIVE] main.customers.email -> email (pattern, conf=1.00)
# 3. Preview the changes (dry run; originals are shown redacted)
dbmask mask --config dbmask.yaml
# 4. Keep an untouched copy, then actually mask
cp demo.db demo_original.db
dbmask mask --config dbmask.yaml --apply
# 5. Prove it worked: row counts, schema, and per-row value comparison
dbmask validate --config dbmask.yaml --strict
# RESULT: PASSED ✓
Prefer code over a shell? python examples/quickstart.py runs the same story
end-to-end, and the library API
mirrors the CLI.
These are behaviors, not aspirations — each one has a regression test:
mask never writes without --apply. The flag is the single source of
truth; a config file cannot turn a preview into a write.mask refuses to run (exit 2) rather than silently leaving that column
unmasked. --allow-partial is the explicit escape hatch.UNKNOWN, are never masked, never persisted, and both scan
and mask tell you to review them.NOT MASKED
warning instead of a preview that pretends otherwise.***-**) in output
by default (--show-values to reveal), dry runs have no side effects, and
validation reports carry shape-redacted samples only.validate exits non-zero on failure;
--strict also fails on anything it could not verify.For each column, manual overrides take precedence. Historical decisions are reused only after review and while their type, expiry and strategy remain applicable. Machine suggestions stay pending until an analyst imports a reviewed file.
flowchart TD
A[Column] --> B{Manual override?}
B -->|yes| Z[Decision]
B -->|no| C{Imported history?}
C -->|approved and applicable| Z
C -->|pending or invalid| R[Human review required]
C -->|none| D[Patterns and optional LLM]
D --> S[Pending suggestions]
S --> H[Human review and file import]
H --> C
config/dbmask.fields.yaml): a human decision always wins.pip install "dbmask[excel]"), or a strict Markdown
table. Set history.source_file to keep that original file authoritative;
history-writeback merges human-approved review rows back into it with a
backup and conflict checks. See the history workflow.llm.send_values: false
restricts even a remote provider to column names only. The CLI warns
explicitly before any values would leave the machine.Deterministic by construction: the same input always maps to the same output
(seeded from masking.seed), so Tesla masks identically in every table and
joins survive.
| Strategy | Output | Valid for its type? |
|---|---|---|
fake_name / fake_first_name / fake_last_name | consistent fake from bundled dictionaries | text |
fake_city | another real US city | text |
fake_email | first.last123@example.invalid — reserved TLD, can never deliver | ✓ |
fake_email_keep_domain | same, but keeps the original domain (identifiable — opt-in) | ✓ |
fake_uuid | a real, deterministic v4 UUID | ✓ |
fake_ip | valid IPv4 octets / IPv6 hex, grouping kept | ✓ |
fake_phone / fake_ssn | constrained phone/SSN formats, separators kept | ✓ |
fake_credit_card | same brand/length, separators kept, Luhn-valid | ✓ |
fake_date | ±30–730-day deterministic shift — always a real calendar date | ✓ |
format_random | same length & character classes (Ab3-9z → Qf7-2k) | typed values stay typed |
shuffle | characters permuted in place | typed values stay typed |
redact | ****, separators kept | text |
null / blank | SQL NULL / empty string | ✓ |
Typed Python values (int, float, Decimal, date, datetime, UUID, bool) come
back as their own type and valid for it — a masked DATE column never
receives 8342-73-51. Register your own with register_strategy(...) and
register_dictionary(...).
Which strategy applies? Per-column override → your rule mapping →
built-in default for the detected rule → masking.default_strategy. Long
free-text fields (notes, comments) are exactly where you should decide
yourself — blank, redact, or format_random:
masking:
column_strategies:
notes: blank # used when no reviewed history strategy is set
rule_strategies:
email: fake_email # per detected rule
default_strategy: format_random
Determinism alone drifts: reorder a dictionary file, or change the seed, and
every recomputed mapping silently changes. The seed map (on by default)
writes each original → masked pair down the first time it is used — keyed
by a salted hash, never the original value — and reuses it forever after.
Last month's masked snapshot and today's agree; joins across databases stay
intact.
masking:
seed_map:
enabled: true
url: # blank = sqlite:///dbmask_seedmap.db
salt: ${DBMASK_SEED_SALT} # keep the salt out of the store (recommended)
Details, threat model, and the pair-tracking CLI (dbmask seeds):
docs/seed-map.
dbmask validate compares the masked database against the untouched source
and exits non-zero for CI gates:
| Check | What it proves |
|---|---|
| Row counts | masking changed values — never added or dropped rows |
| Schema elements | columns/types, PK, indexes, FKs, constraints all match |
| Masking completeness | per-row: no sensitive value survived unchanged |
Completeness is primary-key aligned: source and target rows are matched
key-by-key and the sensitive column compared value-by-value, which catches a
row where one field survived unmasked even though others changed. Tables
without a usable key fall back to a documented heuristic whose clean result
is a warning, not a pass — --strict turns any "could not verify" into a
failure. Reports state their coverage explicitly.
The connector is a single SQLAlchemy code path, so PostgreSQL, MySQL/MariaDB,
SQL Server, Oracle, SQLite and anything else with a SQLAlchemy dialect are
wired up (pip install "dbmask[postgres]" etc.).
Honesty about testing: the automated suite currently exercises SQLite on CPython 3.9–3.14 (Linux + Windows). PostgreSQL and MySQL integration tests are the next roadmap item — until they land, treat those engines as "supported by construction, verified by early adopters", and please report anything that misbehaves.
Masking reduces exposure; it is not anonymization, and dbmask does not pretend otherwise:
masking.seed (or the default — the CLI warns) can
recompute the mapping for values they can guess. Choose a private seed,
and set the seed-map salt from the environment.notes is usually blank/redact,
not clever faking.UNKNOWN columns,
keep overrides for what matters, and treat validate --strict as the gate.--apply at the primary.Found a hole in any of these guarantees? That's a security report: SECURITY.md.
Different tools solve adjacent problems — this table is about workflow shape, not maturity (several of these are excellent and far more battle-tested):
| Tool | Shape | Where dbmask differs |
|---|---|---|
| Presidio | PII detection/de-id framework (text, images) | dbmask is an end-to-end database workflow: discover → mask → validate on live connections |
| Greenmask | PostgreSQL dump anonymizer (Go) | cross-engine via SQLAlchemy; live DBs, not dumps; built-in discovery & validation |
| pynonymizer | dump anonymizer, hand-written column list | dbmask discovers columns and verifies the result |
| PostgreSQL Anonymizer | in-database extension (PG only) | no extension install needed; works where you only have a connection string, and across engines |
| Tonic / Gretel | commercial platforms | open source, pip-installable, config-in-git |
If you need heavy-duty subsetting, synthesis, or enterprise scale today, those tools may serve you better — dbmask optimizes for one auditable pipeline you can read in an afternoon.
Two YAML files (copy the *.example.yaml from config/, drop the
.example): the main config (connection, detection, masking, validation —
secrets via ${ENV_VAR}) and the field-override file (your manual
sensitive/safe toggles). Every option is commented in the examples;
full reference: docs/configuration.
0.1.x — young and moving fast. The current release focused on making the
safety envelope real (fail-closed scanning, PK-aligned verification, valid
typed output, no PII in previews/logs — see the
changelog). Near-term roadmap:
scan / apply / verify) with a
deprecation policyUsing dbmask anywhere real? Add yourself to ADOPTERS.md or file adopter feedback — including "we chose something else because…". It steers the roadmap.
git clone https://github.com/sealandseacat/dbmask.git
cd dbmask
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
Every bug fix ships with a regression test that fails on the old code — the test suite doubles as documented history of every sharp edge found so far. See CONTRIBUTING.md.
If dbmask is useful in your work, cite it via the repository's CITATION.cff (GitHub's "Cite this repository" button).
53 commits
8 commits
Python
100.0%