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.

  • namedata-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

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.

~/.claude/skills/data-anonymization/SKILL.md

--- name: data-anonymization description: Finds and de-identifies personal data in datasets and databases - PII discovery with a bundled column scanner, direct vs quasi-identifiers,… license: MIT ---

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

ClassExamplesDefault treatment
Direct identifiername, email, phone, national ID, card number, IBAN, exact address, account logindrop, or pseudonymize if needed as a join key
Quasi-identifierbirth date, ZIP/postcode, gender, job title, rare diagnosis, precise timestamps, GPSgeneralize until k-anonymous
Sensitive attributehealth, salary, religion, sexual orientation, criminal recordskeep only if required; check l-diversity
Technical identifierIP address, device ID, cookie ID, user agenttruncate, drop, or pseudonymize
Free textnotes, support messages, commentsdrop, or scrub with NER and review
Non-personalplan, SKU, aggregate countskeep

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

TechniqueWhatUse whenReversible?
Drop / suppressremove column or rare rowsthe recipient does not need itno
Maskingj***@example.com, last 4 of a carddisplay or support viewsno (but partial data leaks)
HMAC pseudonymizationHMAC-SHA256(secret, normalized_value)stable join key across tables and refreshesonly with the secret
Tokenizationrandom token with a lookup vaulta controlled party must re-identify lateryes, via vault
Generalizationbirth date to year, ZIP to 3 digits, age to 10-year bands, timestamps to dayquasi-identifiers needed for analysisno
Synthetic replacementFaker names/emails/addresses consistent per pseudonymdev and test data that must look realno
Noise / perturbationadd bounded noise to numerics, date shifting per personaggregates must stay roughly correctno
Aggregationpublish counts per group with a minimum cell size (e.g. 10)reporting outside the orgno

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-pii on 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 198 lines · 7.6 KB
#!/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.

esc