Data Anonymization skill for Claude Code
Share data without sharing the people in it. Claude scans every column with a bundled PII detector, classifies direct and quasi-identifiers, and applies a recorded treatment per column: drop, keyed HMAC pseudonyms, generalization, consistent synthetic values or date shifting. It measures k-anonymity before release, builds referentially intact test subsets from production, and re-scans the output so nothing high-risk slips through.
- name
data-anonymization - size184 lines · ≈2,300 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Finds and de-identifies personal data in datasets and databases - PII discovery with a bundled column scanner, direct vs quasi-identifiers, masking, HMAC pseudonymization, tokenization, generalization and suppression, k-anonymity and l-diversity checks, free-text scrubbing, and building safe test or staging data from production with referential integrity preserved (PostgreSQL Anonymizer, Faker, subsetting). Use when copying production data to dev or staging, sharing a dataset with a vendor or analyst, preparing data for ML training, auditing tables for PII, or a request mentions masking, pseudonymization, or anonymized exports. Try asking
- Make a masked copy of the production customers database for staging
- Scan these CSV exports for personal data before we send them to a vendor
- Check whether this anonymized dataset can still identify individuals
Use it when
- Copying production data to development, staging, or a laptop
- Sharing a dataset with a vendor, researcher, or analyst
- Auditing tables or files for personal data
- Preparing training data that came from user records
Skip it when
- You are removing content from PDFs or Word files - use document-redaction
- You need consent, retention and DSAR flows - use gdpr-privacy
How to check the result
Every output column has a recorded treatment, scripts/pii_scan.py --fail-on-pii exits 0 on the output, and the smallest quasi-identifier group meets the agreed k.
The common mistake it prevents
Hashing emails or phone numbers with plain SHA-256 and calling it anonymized; anyone with a candidate list can hash and match them back to real people.
De-identification is done when every column has a recorded classification and treatment, direct identifiers are gone or keyed-hashed with a secret kept outside the dataset, quasi-identifiers are generalized until the smallest group meets the agreed k, free text has been scrubbed or dropped, and an independent re-scan of the output finds nothing high-risk. Pseudonymized data is still personal data under GDPR; only data that cannot reasonably be re-identified is anonymous. This is engineering guidance, not legal advice - the user’s privacy or legal team decides what is acceptable to share.
Terms that change the treatment
| Class | Examples | Default treatment |
|---|---|---|
| Direct identifier | name, email, phone, national ID, card number, IBAN, exact address, account login | drop, or pseudonymize if needed as a join key |
| Quasi-identifier | birth date, ZIP/postcode, gender, job title, rare diagnosis, precise timestamps, GPS | generalize until k-anonymous |
| Sensitive attribute | health, salary, religion, sexual orientation, criminal records | keep only if required; check l-diversity |
| Technical identifier | IP address, device ID, cookie ID, user agent | truncate, drop, or pseudonymize |
| Free text | notes, support messages, comments | drop, or scrub with NER and review |
| Non-personal | plan, SKU, aggregate counts | keep |
Classic result: ZIP code + birth date + gender uniquely identifies most people in the US. Removing names alone is not anonymization.
Workflow
- [ ] 1. Purpose: who receives the data, for what, for how long; minimum columns needed
- [ ] 2. Discover: scan schemas and samples for PII; classify every column
- [ ] 3. Choose a treatment per column (table below); get the plan approved
- [ ] 4. Implement in a repeatable job, never by hand-editing exports
- [ ] 5. Preserve referential integrity and realistic distributions where needed
- [ ] 6. Measure re-identification risk (k-anonymity, uniques, rare values)
- [ ] 7. Re-scan the output; document; deliver with access controls and expiry
2. Discovery
Scan every table or CSV, not only the ones that “obviously” have PII:
python3 scripts/pii_scan.py export/customers.csv export/orders.csv --rows 20000
python3 scripts/pii_scan.py anonymized/*.csv --fail-on-pii # exit 2 if high-risk PII remains
The scanner samples values and flags emails, phones, Luhn-valid card numbers, mod-97 valid IBANs, US SSNs, IP addresses, date-like and postcode-like values, header hints (name, address, dob, notes), and near-unique columns. It finds candidates; it does not prove absence. For databases, also query the catalog for suspicious column names:
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ~* '(name|email|phone|addr|street|zip|postcode|birth|dob|ssn|passport|iban|card|ip_|ip$|lat|lon|note|comment)'
AND table_schema NOT IN ('pg_catalog', 'information_schema');
Check JSON columns, logs tables, audit tables, and attachments - PII hides there.
3. Treatments
| Technique | What | Use when | Reversible? |
|---|---|---|---|
| Drop / suppress | remove column or rare rows | the recipient does not need it | no |
| Masking | j***@example.com, last 4 of a card | display or support views | no (but partial data leaks) |
| HMAC pseudonymization | HMAC-SHA256(secret, normalized_value) | stable join key across tables and refreshes | only with the secret |
| Tokenization | random token with a lookup vault | a controlled party must re-identify later | yes, via vault |
| Generalization | birth date to year, ZIP to 3 digits, age to 10-year bands, timestamps to day | quasi-identifiers needed for analysis | no |
| Synthetic replacement | Faker names/emails/addresses consistent per pseudonym | dev and test data that must look real | no |
| Noise / perturbation | add bounded noise to numerics, date shifting per person | aggregates must stay roughly correct | no |
| Aggregation | publish counts per group with a minimum cell size (e.g. 10) | reporting outside the org | no |
Pseudonymization rules:
- Use a keyed HMAC, never a bare hash. Plain SHA-256 of an email or phone is reversible by hashing candidate lists; phone number spaces are small enough to brute force.
- Normalize before hashing (lowercase and trim emails, E.164 phones) so the same person maps to the same pseudonym.
- Store the key in a secrets manager, separate from the data; rotating it breaks joins with older extracts, which is sometimes the point.
import hmac, hashlib, os
KEY = os.environ["PSEUDO_KEY"].encode()
def pseudo(value: str, field: str) -> str:
norm = value.strip().lower()
return hmac.new(KEY, f"{field}:{norm}".encode(), hashlib.sha256).hexdigest()[:20]
Date shifting: shift all dates for one person by the same random offset (e.g. +/- 180 days) so intervals between their events stay true; keep the offset table secret or discard it.
4-5. Safe test data from production
Prefer, in order: synthetic data from factories (see test-data-factories); a masked subset of production; a full masked copy. A full copy multiplies risk and storage for little gain.
- Subset: pick a sample of root entities (e.g. 2% of accounts) and follow foreign keys to pull related rows, so joins still work.
- Mask consistently: the same source email produces the same fake email everywhere it appears (seed Faker with the HMAC pseudonym).
- Keep uniqueness: generated emails must stay unique if the column has a unique constraint
(
user_<pseudo>@example.test). - Use reserved domains and numbers for fakes (
example.com,.test, 555-01xx numbers) so test emails and SMS never reach real people. - Run the masking inside the production boundary; raw data must never land on a laptop or in a staging bucket first.
PostgreSQL Anonymizer (anon extension) declares masking rules as security labels and
can produce masked dumps or dynamic masking for specific roles:
SECURITY LABEL FOR anon ON COLUMN customers.email IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customers.birth_date IS 'MASKED WITH FUNCTION anon.generalize_daterange(birth_date, ''year'')';
Check the extension version’s function names and availability on managed services before relying on it; otherwise implement masking in a SQL or Python job.
6. Re-identification risk
import pandas as pd
qi = ["birth_year", "zip3", "gender"]
sizes = df.groupby(qi, dropna=False).size()
k = sizes.min()
share_at_risk = (sizes[sizes < 5].sum()) / len(df)
print(f"k = {k}; rows in groups smaller than 5: {share_at_risk:.2%}")
- Agree a k with the data owner (5 is a common floor for internal sharing; 10+ for wider release). Generalize further or suppress rows in small groups until met.
- l-diversity: within each QI group, the sensitive attribute should take at least l distinct values, or everyone in the group shares the same diagnosis and k is useless.
- Watch outliers in numerics (the one $4M salary) and rare categories (job title “CEO”).
- Linkage risk depends on what the recipient already has; a dataset that is safe for an internal analyst may not be safe to publish.
Free text
Notes and messages contain names, emails, phone numbers, addresses, and health details. Default: drop free text. If needed, scrub with regex for structured PII plus NER (for example Microsoft Presidio), then manually review a random sample of at least 200 rows and report the miss rate. Never claim free-text scrubbing is complete.
Gotchas
- Masked columns in one table but raw copies in logs, search indexes, backups, or JSON blobs defeat the effort; include them in discovery.
- Unique IDs from production (user_id) can be joined to other leaked or internal data; pseudonymize them too when sharing outside the team.
- Small result cells in aggregate reports reveal individuals; suppress counts below the agreed threshold.
- Deterministic pseudonyms leak frequency (the most common pseudonym is likely the most active user); acceptable internally, think twice for external release.
- ML training sets can memorize rare values; apply the same treatment before training.
Verification
- Every column in the output appears in the classification table with a treatment.
scripts/pii_scan.py --fail-on-piion the output exits 0; manual sample of 50 rows reviewed.- k-anonymity and minimum group size meet the agreed threshold; numbers recorded.
- Foreign keys resolve in the masked dataset; unique constraints hold.
- Delivery note states purpose, recipient, retention period, and who approved it.
Output: de-identification record
**Dataset:** customers_2026-10 subset (2% of accounts, 41,203 rows) for vendor churn model
**Approved by:** privacy team (ticket PRIV-212), retention 90 days
| Column | Class | Treatment |
|---|---|---|
| email | direct | dropped; customer_key = HMAC(email), key in vault/pseudo-2026 |
| birth_date | quasi | year only |
| zip | quasi | first 3 digits; groups < 5 suppressed (312 rows) |
| notes | free text | dropped |
**Risk:** k = 7 over (birth_year, zip3, gender); scanner re-run clean. Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/pii_scan.py
#!/usr/bin/env python3
"""Scan CSV files for likely PII, column by column (stdlib only).
Usage:
python3 pii_scan.py data.csv [more.csv ...] [--rows 5000] [--threshold 0.05] [--fail-on-pii] [--json]
For each column it samples up to --rows values and reports:
- value detectors that fired (email, phone, card with Luhn, IBAN with mod-97,
US SSN, IPv4/IPv6, date-like, postcode-like) and the share of non-empty values matching
- header hints (name, address, dob, ...) from the column name
- a suggested treatment
Exit codes: 0 = scan finished (or no PII with --fail-on-pii), 2 = PII found with
--fail-on-pii (use on anonymized output in CI), 1 = usage or read error.
"""
import argparse
import csv
import ipaddress
import json
import re
import sys
csv.field_size_limit(10_000_000)
EMAIL = re.compile(r"^[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
PHONE = re.compile(r"^\+?[\d\s().-]{7,20}$")
SSN = re.compile(r"^(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}$")
CARD = re.compile(r"^(?:\d[ -]?){13,19}$")
IBAN = re.compile(r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$")
DATE = re.compile(r"^(\d{4}-\d{2}-\d{2}|\d{1,2}[/.]\d{1,2}[/.]\d{2,4})([ T]\d{2}:\d{2}(:\d{2})?)?$")
POSTCODE = re.compile(r"^(\d{5}(-\d{4})?|[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}|[A-Z]\d[A-Z] ?\d[A-Z]\d)$", re.I)
HEADER_HINTS = {
"name": ["name", "first", "last", "surname", "fullname", "given"],
"contact": ["email", "e_mail", "phone", "mobile", "tel", "fax"],
"address": ["address", "street", "city", "zip", "postcode", "postal", "addr"],
"birth": ["dob", "birth", "birthday", "age"],
"government_id": ["ssn", "nin", "passport", "license", "licence", "tax_id", "taxid", "national"],
"financial": ["iban", "card", "account", "acct", "bic", "swift", "salary", "income"],
"network": ["ip", "ip_address", "device_id", "mac", "user_agent", "cookie"],
"location": ["lat", "lon", "lng", "latitude", "longitude", "geo"],
"health": ["diagnosis", "medical", "health", "condition", "medication"],
"free_text": ["note", "notes", "comment", "comments", "description", "message", "body"],
}
SUGGEST = {
"email": "pseudonymize (HMAC) or replace with synthetic unique emails",
"phone": "mask or replace with synthetic numbers",
"card": "drop; never copy card numbers out of the PCI scope",
"iban": "drop or tokenize",
"ssn": "drop",
"ip": "truncate (IPv4 /24, IPv6 /48) or drop",
"date": "generalize (year or month) if it is a birth or event date tied to a person",
"postcode": "generalize (first 3 chars / 3-digit ZIP) and check k-anonymity",
}
def luhn_ok(s: str) -> bool:
digits = [int(c) for c in s if c.isdigit()]
if not 13 <= len(digits) <= 19:
return False
total = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def iban_ok(s: str) -> bool:
s = s.replace(" ", "").upper()
if not IBAN.match(s):
return False
moved = s[4:] + s[:4]
num = "".join(str(int(c, 36)) for c in moved)
return int(num) % 97 == 1
def is_ip(s: str) -> bool:
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def detect(v: str) -> set:
hits = set()
if EMAIL.match(v):
hits.add("email")
if SSN.match(v):
hits.add("ssn")
if CARD.match(v) and luhn_ok(v):
hits.add("card")
if iban_ok(v):
hits.add("iban")
if is_ip(v):
hits.add("ip")
if DATE.match(v):
hits.add("date")
if POSTCODE.match(v) and not v.isdigit() or re.fullmatch(r"\d{5}-\d{4}", v):
hits.add("postcode")
digits = sum(c.isdigit() for c in v)
if not hits & {"card", "date", "ip"} and PHONE.match(v) and 7 <= digits <= 15 and not v.isdigit():
hits.add("phone")
return hits
def header_hints(col: str) -> list:
tokens = re.split(r"[^a-z0-9]+", col.lower())
found = []
for cat, words in HEADER_HINTS.items():
if any(t in words for t in tokens) or any(w in col.lower() for w in words if len(w) > 4):
found.append(cat)
return found
def scan(path: str, rows: int, threshold: float) -> list:
with open(path, newline="", encoding="utf-8-sig", errors="replace") as fh:
sample = fh.read(65536)
fh.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(fh, dialect=dialect)
cols = reader.fieldnames or []
counts = {c: {} for c in cols}
nonempty = {c: 0 for c in cols}
distinct = {c: set() for c in cols}
n = 0
for row in reader:
n += 1
for c in cols:
v = (row.get(c) or "").strip()
if not v:
continue
nonempty[c] += 1
if len(distinct[c]) < 10001:
distinct[c].add(v)
for h in detect(v):
counts[c][h] = counts[c].get(h, 0) + 1
if n >= rows:
break
report = []
for c in cols:
ne = nonempty[c] or 1
detectors = {k: round(v / ne, 3) for k, v in sorted(counts[c].items(), key=lambda kv: -kv[1]) if v / ne >= threshold}
hints = header_hints(c)
uniq = len(distinct[c]) / ne if nonempty[c] else 0
risk = "high" if detectors.keys() & {"email", "ssn", "card", "iban", "phone", "ip"} else (
"medium" if hints or detectors else "low")
if risk == "low" and uniq > 0.9 and nonempty[c] > 50:
risk = "check" # near-unique column: possible identifier
suggestion = "; ".join(SUGGEST[d] for d in detectors if d in SUGGEST)
if not suggestion and hints:
suggestion = "review: header suggests " + ", ".join(hints)
report.append({"file": path, "column": c, "rows_sampled": n, "non_empty": nonempty[c],
"distinct_ratio": round(uniq, 3), "detectors": detectors,
"header_hints": hints, "risk": risk, "suggestion": suggestion})
return report
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+")
ap.add_argument("--rows", type=int, default=5000)
ap.add_argument("--threshold", type=float, default=0.05, help="min share of values matching a detector")
ap.add_argument("--fail-on-pii", action="store_true")
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
results = []
for f in a.files:
try:
results.extend(scan(f, a.rows, a.threshold))
except OSError as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 1
if a.json:
print(json.dumps(results, indent=2))
else:
order = {"high": 0, "medium": 1, "check": 2, "low": 3}
print(f"{'risk':7} {'column':28} {'detectors':34} hints / suggestion")
for r in sorted(results, key=lambda r: (order[r["risk"]], r["file"], r["column"])):
det = ",".join(f"{k}:{v:.0%}" for k, v in r["detectors"].items()) or "-"
extra = r["suggestion"] or ("near-unique values; possible identifier" if r["risk"] == "check" else "")
print(f"{r['risk']:7} {r['column'][:28]:28} {det[:34]:34} {extra}")
flagged = sum(r["risk"] in ("high", "medium") for r in results)
print(f"\n{flagged} of {len(results)} columns flagged high/medium.")
if a.fail_on_pii and any(r["risk"] == "high" for r in results):
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.