pii-masking-pipeline-builder
Build-an-X workflow that owns the full detect → mask → verify pipeline for PII in test data. Walks the author through (1) classifying each field against the cross-regime PII catalog (GDPR / CCPA-CPRA / NIST SP 800-122 / HIPAA, in references/pii-categories.md), (2) picking a masking operator from the techniques catalog (seven canonical operators + Presidio operators + privacy models, in references/masking-techniques.md), (3) deciding pseudonymisation (reversible, in GDPR scope) vs anonymisation (irreversible, out of scope), (4) ordering the pipeline (detect → operator → audit) and emitting a deployable YAML config for Presidio + Faker + Synthea wrappers (Faker-as-masking-operator detail in references/faker-masking-operators.md), and (5) running the adversarial verification pass that re-detects PII in the masked output and blocks promotion on a leak. Use when non-production environments need masked production data - from field classification through runnable masking config to the leak audit.
Install with skills.sh (any agent)
npx skills add testland/qa --skill pii-masking-pipeline-builderpii-masking-pipeline-builder
Overview
Authoring a masking pipeline requires three classifications per field (regulatory regime, operator, reversibility) and one global decision (pipeline ordering + audit hooks). This workflow produces a deployable YAML spec that downstream tools execute:
When to use
Step 1 - Inventory the source
Enumerate every column / field in the source dataset. For each, record:
| Column | Type | Sample value | Cardinality | Cross-table join? |
|---|---|---|---|---|
users.email | string | alice@acme.com | high | yes (joins events) |
users.ssn | string | 123-45-6789 | high | no |
users.dob | date | 1985-03-14 | medium | no |
users.zip | string | 02139 | low | no |
users.country | string | US | very low | no |
A schema introspector can produce the first columns; cardinality and join graph need a quick analytical pass.
Step 2 - Classify each field
Look up each column in the cross-regime catalog (references/pii-categories.md) and record which regulatory regime(s) apply. Include linkable fields explicitly (NIST 800-122 §2.2).
| Column | GDPR | CPRA SPI | NIST | HIPAA | Risk |
|---|---|---|---|---|---|
users.email | ✓ | - | ✓ | ✓ #6 | direct |
users.ssn | ✓ | ✓ | ✓ | ✓ #7 | direct, high-sensitivity |
users.dob | linkable | - | linkable | ✓ #3 | linkable |
users.zip | linkable | - | linkable | ✓ #2 (sub-state) | linkable |
users.country | - | - | - | - | non-PII |
Any field marked direct OR linkable enters the masking scope. A field marked only "linkable" still gets masked because it identifies in combination with others (Sweeney 87% rule, see references/pii-categories.md).
Step 3 - Pick an operator per field
Match each field to a technique in the techniques catalog (references/masking-techniques.md). Decision tree:
| Column | Operator | Rationale | Reversible? |
|---|---|---|---|
users.email | Faker substitution (deterministic via hash-seed) | Joins across tables; need referential integrity | Yes (via salt vault) |
users.ssn | Tokenisation (vault) | Strict regulator scope; round-trip needed for auth | Yes (via vault) |
users.dob | Generalisation to year | Analytics needs age bracket, not exact DOB | No |
users.zip | Truncation to first 3 digits | HIPAA Safe Harbor #2 rule (>20k pop only) | No |
users.country | Pass-through | Not PII | n/a |
Step 4 - Pseudonymisation vs anonymisation gate
For each masked field, mark whether the result remains personal data under GDPR Art. 4(5):
Document the gate decision per dataset:
output_classification: pseudonymised # GDPR scope retained
gdpr_lawful_basis: Article 6(1)(f) legitimate interests
retention: 90 days
access_control: only-dev-environment-teamvs.
output_classification: anonymised
gdpr_lawful_basis: out-of-scope per Recital 26
retention: indefinite
access_control: openThe author cannot claim "anonymised" if any reversible technique is in the pipeline.
Step 5 - Compose the pipeline
A standard order:
Step 6 - Emit the YAML spec
Recommended shape - consumable by a generic pipeline runner:
pipeline:
name: users-staging-refresh
source:
type: postgres
connection: $PROD_RO_DSN
schema: public
table: users
classification:
output: pseudonymised
regimes: [gdpr, cpra, hipaa]
fields:
- column: email
operator: deterministic_substitution
provider: faker
provider_method: internet.email
seed_strategy: hash(salt + value)
salt_ref: vault://masking/users.email
- column: ssn
operator: tokenisation
vault: vault://masking/users.ssn
- column: dob
operator: generalisation
params:
granularity: year
- column: zip
operator: truncation
params:
keep_chars: 3
from: start
- column: country
operator: passthrough
free_text_columns:
- notes
- support_message
free_text_detector:
type: presidio
language: en
score_threshold: 0.45
entities: [PERSON, EMAIL_ADDRESS, PHONE_NUMBER, US_SSN, CREDIT_CARD, IP_ADDRESS]
on_detect: replace
audit:
sample_rows: 100
fail_on_critic_block: true
output:
type: postgres
connection: $STAGING_RW_DSN
schema: public
table: users
manifest:
write_to: s3://masking-manifests/${run_id}.jsonStep 7 - Worked example
A SaaS app refreshes its staging from prod nightly. Source has 4M users with 22 columns, 3 of which are free-text. Synthesised spec:
pipeline:
name: prod-to-staging-nightly
source: { type: postgres, table: users }
classification: { output: pseudonymised, regimes: [gdpr, cpra] }
fields:
- { column: user_id, operator: passthrough } # internal opaque ID
- { column: email, operator: deterministic_substitution,
provider: faker, provider_method: internet.email,
seed_strategy: hash(salt + value), salt_ref: vault://prod/email }
- { column: full_name, operator: substitution,
provider: faker, provider_method: name }
- { column: phone, operator: substitution,
provider: faker, provider_method: phone_number }
- { column: address_line1, operator: substitution,
provider: faker, provider_method: address }
- { column: country, operator: passthrough }
- { column: language, operator: passthrough }
- { column: created_at, operator: passthrough }
- { column: last_login_at, operator: passthrough }
- { column: signup_ip, operator: encryption,
params: { algo: fpe-ff1 }, key_ref: vault://prod/ip-fpe }
- { column: notes, operator: free_text_mask }
free_text_detector:
type: presidio
language: en
score_threshold: 0.5
on_detect: replace
audit: { sample_rows: 100, fail_on_critic_block: true }Pipeline classification: pseudonymised (email is deterministic, IP is FPE-encrypted with key retained). The user explicitly accepts that this output remains in GDPR scope.
Verification pass - auditing the masked output
Every pipeline run ends with an adversarial leak audit that re-detects PII in the masked output and challenges the pipeline's "clean" claim. Audit checklist:
BLOCK if any hit is:
- A CPRA SPI / GDPR Art. 9 / HIPAA Safe Harbor identifier
- A direct identifier in a column where the pipeline declared
"anonymised" output
- A hit in a column the pipeline didn't classify
PASS-WITH-CAVEATS if:
- Only linkable (not direct) leaks remain
- The pipeline output is declared "pseudonymised" (GDPR scope
retained, so linkable hits are tolerable when access-controlled)
PASS if:
- Zero hits, OR
- Only false-positive hits that the analyst flags as
Presidio-noise (e.g., a fake-shaped string that's actually a
UUID)The audit refuses to mark a run "pass" if any CPRA SPI / GDPR Art. 9 / HIPAA Safe Harbor identifier appears unmasked, if the spec lacks a manifest (no provenance = no audit trail), or on a "we'll fix it next time" promise - leaks block the promotion. Findings are suppressed only via an explicit per-row waiver. Re-audit on every pipeline-spec change; detection is heuristic (Presidio's recogniser ceiling), so custom PatternRecognizers cover in-house ID formats and full-dataset scans replace sampling for comprehensive audits.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Per-column operator without referential check | Joins break after masking | Group columns that share keys; apply deterministic operators consistently |
| Free-text columns skipped | Embedded PII (user-typed emails) leaks | Always run Presidio on any string column > ~50 chars |
| Claiming "anonymised" when any reversible op is in the pipeline | False GDPR compliance claim | Audit the pipeline; pseudonymised if any operator is reversible |
| No audit step | Operator failure or recogniser drift goes unnoticed | Always sample output and run the verification pass |
| Salt vault key shared across pipelines | Salt-rotation breaks every downstream pipeline at once | Per-pipeline salt; rotate independently |
| No manifest | Cannot reproduce a past run; auditors can't trace lineage | Always emit manifest with version IDs |
| Pipeline runs on prod-write connection | Risk of writing masked data back over prod | Strict source = read-only DSN; output = staging-write DSN |
Limitations
References
Faker as a masking substitution operator
View source (opens in new window)Faker as a masking substitution operator
Companion reference for pii-masking-pipeline-builder - using the Faker family to put believable stand-in values back into a dataset a masking step has just de-identified. Library mechanics for fixture-style fake data built from nothing (install, provider catalogue, locales, factories) live in the qa-test-data plugin's faker-data skill - this reference deliberately does not repeat them.
The axis this reference owns
A de-identification step has already run: direct identifiers are nulled, redacted, or replaced with a placeholder. The dataset is private but broken, because joins fail and validators reject empty fields. This reference covers putting usable values back and the privacy decisions that forces: keeping a shared identifier consistent so joins survive, deterministic versus random substitution, preserving a validated field shape, and proving the result is safe rather than merely realistic.
It deliberately does not cover the generic mechanics of a fake-data library: installation, the provider catalogue, per-locale tours, or factory patterns for building fixtures from nothing. Nor does it cover detecting PII or choosing between masking operators such as hashing, tokenisation, and nulling. Both happen before this step.
Minimum library surface
The worked example below uses only Faker.seed(n) (run reproducibility, a class method) and fake.unique.<method>() (injective values, raising UniquenessException once the value space is exhausted). The full mechanic table - instance vs class seeding, JS refDate, multiple-locale weighting, and Presidio's custom operator for driving substitution from an anonymiser - is in the "Library surface and seed-exposure detail" section at the end of this file.
Step 1 - Build the substitution map before substituting anything
The masking literature requires masked values to stay consistent across databases that share a data element, and to be repeatable (the same input always yields the same output) yet not reversible to the original (en.wikipedia.org/wiki/Data_masking (opens in new window)).
So do not substitute row by row while streaming. Collect the distinct real values first, build one map, apply it everywhere. Two properties must hold:
The map is the sensitive artifact. Retaining it where the recipient can reach it makes the output pseudonymised, not anonymised: GDPR Article 4(5) treats data attributable to a subject only via separately-kept additional information as pseudonymised (gdpr-info.eu (opens in new window)), and Recital 26 counts data re-attributable through such additional information as personal data (gdpr-info.eu (opens in new window)). Keeping the map is a legitimate choice. Calling the result anonymous afterwards is not.
Step 2 - What a seed guarantees, and what it does not
Does. Reproduce the same generator sequence on a later run, for the same library version. Faker warns that "as we keep updating datasets, results are not guaranteed to be consistent across patch versions" (faker.readthedocs.io (opens in new window)); the JavaScript port warns "you may get different values for the same seed" after an upgrade (fakerjs.dev (opens in new window)).
Does not. Act as a key. Python Faker draws from a random.Random (faker.readthedocs.io (opens in new window)), and the standard library warns that the module's generators are not for security use and points to the secrets module for cryptographic uses (docs.python.org (opens in new window)). A seed offers no resistance to anyone holding it.
The four seeding strategies (per-value derived seed, global seed plus a stored map, global seed with the map discarded, unseeded random) are compared by re-identification exposure in the seed-exposure matrix at the end of this file. The per-value derived seed is the pattern to avoid. It needs no stored map, and it fails for the reason unsalted hashing of a low-entropy field fails: the input space is enumerable, so the mapping is rebuildable. If a stored map is unacceptable, the answer is a keyed transform, not a derived seed.
Step 3 - Preserve the shape the downstream system validates
Check-digit values. Payment card numbers, and national identifiers including Canadian social insurance numbers, Israeli and South African ID numbers, and Swedish personal identity numbers, carry a Luhn ("mod 10") check digit specified in ISO/IEC 7812-1 (en.wikipedia.org/wiki/Luhn_algorithm (opens in new window)). Faker exposes credit_card_number(card_type=...) accepting 'amex', 'visa', 'mastercard', 'discover', 'diners', 'jcb', and 'maestro' among others, but does not state whether the output carries a valid check digit (faker.readthedocs.io (opens in new window)), so assert it rather than assume it:
def luhn_ok(number: str) -> bool:
digits = [int(d) for d in number if d.isdigit()][::-1]
return sum(d if i % 2 == 0 else sum(divmod(d * 2, 10))
for i, d in enumerate(digits)) % 10 == 0
assert luhn_ok(fake.credit_card_number(card_type="visa"))Luhn "was designed to protect against accidental errors, not malicious attacks" (en.wikipedia.org/wiki/Luhn_algorithm (opens in new window)), so passing it proves the field parses, not that the value is safe.
Locale-bound formats. Phone numbers and postal codes are validated against the row's country, so pin one locale per row from that country column instead of relying on multiple-locale mode's weighted random draw.
Format plus reversibility. If the receiving system needs the original back later and the shape must survive, substitution is the wrong operator: NIST SP 800-38G, "Recommendation for Block Cipher Modes of Operation: Methods for Format-Preserving Encryption", specifies FF1 and FF3 for that case (csrc.nist.gov (opens in new window)).
Step 4 - Realistic is not safe, so measure the result
Residual quasi-identifiers. Substitution removes only the direct identifiers actually substituted. Birth date, postal code, sex, and admission date usually stay because they carry the analytical value, and in combination they still identify people. NIST SP 800-188, "De-Identifying Government Datasets: Techniques and Governance" (September 2023), treats transforming quasi-identifiers and running re-identification studies as part of de-identification, not optional extras (csrc.nist.gov (opens in new window)). The exit criterion is therefore a measurement over the remaining quasi-identifier columns (smallest equivalence-class size, count of unique rows), against Recital 26's test of whether means are "reasonably likely to be used", accounting for "the costs of and the amount of time required for identification" (gdpr-info.eu (opens in new window)).
Accidental collisions. Generators draw from fixed data, described in the JavaScript port's guide as "lists of names, words etc" (fakerjs.dev (opens in new window)), so a generated value can coincide with a real person's, including one in the source file. Diff substitutes against source values and fail on any intersection.
Worked example - two joined files
customers.csv holds email; orders.csv holds billing_email and joins on it. A masking step already dropped ssn and full_name.
import csv
from faker import Faker
fake = Faker("en_US")
Faker.seed(0) # run reproducibility only: not a secret, not derived from the data
def load(path):
with open(path, newline="") as f:
return list(csv.DictReader(f))
customers, orders = load("customers.csv"), load("orders.csv")
real = {c["email"] for c in customers} | {o["billing_email"] for o in orders}
email_map = {v: fake.unique.email() for v in sorted(real)}
assert len(set(email_map.values())) == len(email_map) # injective
assert not (set(email_map.values()) & real) # no collision with source
before = len({c["email"] for c in customers} & {o["billing_email"] for o in orders})
for c in customers:
c["email"] = email_map[c["email"]]
for o in orders:
o["billing_email"] = email_map[o["billing_email"]]
after = len({c["email"] for c in customers} & {o["billing_email"] for o in orders})
assert before == after, f"join key cardinality changed: {before} -> {after}"
print(f"substituted {len(email_map)} identifiers, join keys preserved: {after}")Discard email_map, or store it under separate access control. Never ship it beside the substituted files.
Expected output shape
| File | Column | Before | After |
|---|---|---|---|
| customers.csv | alice.tan@acme.example | xrogers@example.org | |
| orders.csv | billing_email | alice.tan@acme.example | xrogers@example.org |
| customers.csv | postcode | SW1A 1AA | SW1A 1AA (quasi-identifier, unchanged) |
substituted 12480 identifiers, join keys preserved: 9317
collision check vs source values: 0
residual quasi-identifiers not substituted: postcode, birth_date, sexThe third line is a handoff to a re-identification measurement: an open item, not a pass.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Seeding the generator from the real value before each call | The mapping is rebuildable by anyone with the code and a candidate value list | One global seed plus an access-controlled map, or a keyed transform |
| Substituting row by row while streaming, with no map | The same person gets a different substitute per occurrence and every join breaks | Collect distinct values, build the map once, then apply |
| Calling the extract anonymous while retaining the map | The map is the "additional information" of GDPR Article 4(5); the output is still personal data | Destroy the map, or label the output pseudonymised |
| Substituting a checksum-bearing field without asserting the check digit | Downstream validators reject rows, so the environment looks broken rather than masked | Assert Luhn or the field's own check rule before writing |
| Multiple-locale mode on a dataset with a country column | Locale is a weighted random draw per call, so a French row gets a Japanese postcode | Instantiate one locale per row from that row's country |
| Declaring done once the direct identifiers look fake | Birth date plus postcode plus sex still identify people | Measure equivalence-class sizes over the remaining quasi-identifiers before release |
| Never diffing substitutes against source values | Fixed name lists mean a substitute can be a real person in the same file | Fail the run on any intersection |
Limitations
Library surface and seed-exposure detail
| Mechanic | Behaviour | Source |
|---|---|---|
Faker.seed(n) | Class method; seeds the shared random.Random across all internal generators. Calling .seed() on an instance raises TypeError | faker.readthedocs.io (opens in new window) |
fake.seed_instance(n) | Creates and seeds a unique random.Random for one instance | faker.readthedocs.io (opens in new window) |
fake.unique.<method>() | Tracks values already returned; raises UniquenessException after repeated failures to find a new one | faker.readthedocs.io (opens in new window) |
| Multiple-locale mode | The proxy "randomly select[s] a generator using a distribution defined by the provided weights", so locale varies per call | faker.readthedocs.io (opens in new window) |
faker.seed(123) (JS) | Fixes the sequence; setting the seed again resets it. Date helpers also need refDate or faker.setDefaultRefDate() | fakerjs.dev (opens in new window) |
To drive substitution from an anonymiser, Microsoft Presidio exposes a custom operator taking a "lambda to execute on the PII data. The lambda return type must be a string", passed as OperatorConfig("replace", {"new_value": "BIP"})-style entries in the operators dict (presidio.dataprivacystack.org (opens in new window)).
Seed exposure matrix
The four seeding strategies referenced from Step 2 above, ranked by the re-identification exposure each one leaves:
| Mechanism | Joins survive | Re-identification exposure |
|---|---|---|
| Seed derived per value, reseeding from the real value before each call | Yes | High. Anyone with the code, the library version, and a candidate list of real values re-runs the derivation and rebuilds the map. The seed reproduces the substitute; it does not conceal the input |
| One global seed plus a stored map | Yes | Equal to the exposure of the map. Control access to the map, not the seed |
| One global seed, map discarded after the run | Yes, within the run | Low from the seed alone: the substitute follows call order, not the input value, so the seed reconstructs nothing without the source data |
| Unseeded, random per occurrence | No | Low, but the dataset is unusable for anything relational |
Data-masking techniques and privacy models
View source (opens in new window)Data-masking techniques and privacy models
Companion reference for pii-masking-pipeline-builder (Step 3 operator selection). Also the rule book the verification pass draws from.
Overview
Masking is the act of transforming a real value into a substitute that breaks the link to the original subject while preserving testable properties (format, distribution, referential integrity). Which technique is correct depends on three things: whether the result must be reversible, whether the field is referentially shared across tables, and what privacy model the dataset must satisfy.
This is the pure reference that the pipeline builder and leak-detection audits draw from to choose operators per field.
When to use
How to use this reference
The seven canonical masking techniques
Drawing from the Wikipedia data-masking taxonomy (en.wikipedia.org/wiki/Data_masking (opens in new window)) and ISO/IEC 20889:2018 (cite by stable ID; standard text behind paywall):
1. Substitution
Replace the real value with an authentic-looking value from a lookup table - "John Smith" → "Maria Garcia."
2. Shuffling
Randomly rearrange values within a column - salaries column gets shuffled, each row keeps a real salary but no longer the right person's salary.
3. Number / date variance
Apply a bounded random offset: salary ± 10 %, dates ± 120 days (Wikipedia data-masking page).
4. Encryption
Apply a cryptographic algorithm with a key. Two sub-variants:
5. Hashing
Apply a one-way hash (SHA-256 / SHA-512) with optional salt.
6. Nulling out / deletion
Replace the value with NULL or remove the column entirely.
7. Masking-out / character scrambling
Show partial value - credit card "**** **** **** 1234," email "j***@example.com."
Additional techniques
Tokenisation
Replace the real value with a token (random opaque string) and store the real-value → token map in a separate, access-controlled vault.
Redaction
Remove the value entirely (no placeholder, no length signal).
Synthetic substitution
Replace with a synthetically generated value preserving distribution / format (faker-masking-operators.md (opens in new window); synthea-healthcare-data for health records).
Microsoft Presidio anonymizer operators
Per presidio.dataprivacystack.org/anonymizer (opens in new window), the Presidio Anonymizer engine supports six built-in operators:
| Operator | Parameters | Reversible | Maps to canonical technique |
|---|---|---|---|
replace | new_value (defaults to <entity_type>) | No (random) / Yes (deterministic substitution) | #1 Substitution |
redact | - | No | Redaction |
mask | chars_to_mask, masking_char, from_end | No | #7 Masking-out |
hash | hash_type (sha256 / sha512), salt | No (one-way) | #5 Hashing |
encrypt | key | Yes (with key) | #4 Encryption |
custom | lambda | Depends on lambda | (caller-defined) |
Invocation: engine.anonymize(text=, analyzer_results=, operators={"PERSON": OperatorConfig("replace", {"new_value": "BIP"})}).
OperatorConfig constructor signature: OperatorConfig(operator_name, params={}) (Presidio docs).
Reversible vs irreversible - pseudonymisation vs anonymisation
GDPR Art. 4(5) defines pseudonymisation as "processing of personal data in such a manner that the personal data can no longer be attributed to a specific data subject without the use of additional information, provided that such additional information is kept separately" (gdpr-info.eu/art-4-gdpr/ (opens in new window)).
| Technique | Pseudonymisation? | Anonymisation? |
|---|---|---|
| Deterministic substitution (same input → same output) | ✓ | - |
| Random substitution | - | ✓ |
| Shuffling | - | ✓ (when distribution-only) |
| Number / date variance | - | ✓ if variance ≥ identifying granularity |
| General encryption (key kept) | ✓ | - |
| FPE (key kept) | ✓ | - |
| Salted hashing (salt kept separately) | ✓ | - |
| Unsalted hashing of low-entropy field | ✗ (re-identifiable by enumeration) | ✗ |
| Nulling | - | ✓ |
| Masking-out (partial) | depends on revealed chars | depends |
| Tokenisation (vault kept) | ✓ | - |
| Tokenisation + vault destroyed | - | ✓ |
| Redaction | - | ✓ |
| Synthetic substitution | - | ✓ |
Implication: A "masking pipeline" output that uses reversible techniques is still personal data under GDPR - it remains in scope. Only fully irreversible output is out of GDPR scope per Recital 26.
Privacy models - NIST SP 800-188
NIST SP 800-188:2023 formalises statistical privacy models that sit above the per-field operators - pick one for the whole dataset's disclosure risk once quasi-identifiers remain after masking:
Full definitions, achievement methods, weaknesses, and ε / k guidance (with NIST + primary-source citations): privacy-models.md (opens in new window).
Picking a technique per field
| Field characteristic | Recommended technique | Privacy model layer |
|---|---|---|
| Must round-trip for authorised consumer (payment processing) | Tokenisation (vault) or FPE | none (reversible) |
| Must join across tables, opaque value OK | Deterministic substitution / salted hashing | k-anonymity on quasi-identifiers |
| Free-text PII inside a log line | Redaction or replace-with-<TYPE> (Presidio analyzer + anonymizer) | - |
| Continuous numeric for analytics | Number variance | t-closeness if sensitive attribute |
| Categorical demographic (race, etc.) for analytics | Generalisation + l-diversity | l-diversity |
| Statistical query release | Differential privacy mechanism | DP |
| Demo / training, no analytics utility needed | Synthetic substitution (Faker / Synthea) | n/a (no real data) |
Worked example - masking a non-prod customers table
An analytics team needs a non-prod copy of a customers table. Walk each field through the steps in "How to use this reference":
| Field | Need | Operator | Scope outcome |
|---|---|---|---|
customer_id (FK, joined across tables) | Opaque but joinable | Deterministic substitution / salted hashing (#1 / #5) | Pseudonymised - reversible via key |
full_name | No analytics value | Random substitution (Faker) | Anonymised |
email | Support must recognise own value | Masking-out j***@example.com (#7) | Partial - depends on revealed chars |
national_id (SSN) | No analytics value; enumerable format | Nulling out (#6) - never unsalted hashing | Anonymised |
date_of_birth | Age band useful | Generalise to a band (age 47 → "40 - 50") | Anonymised (k-anonymity input) |
salary | Distribution useful | Number variance ± 10 % (#3) | Anonymised - t-closeness if sensitive |
auth_token | No analytics value | Nulling out / deletion (#6) | Anonymised |
Resulting scope: because customer_id uses a reversible deterministic map, the output is pseudonymised - still personal data under GDPR Recital 26. To move the dataset out of scope, destroy the substitution key so customer_id can no longer be re-linked. The remaining quasi-identifiers (date_of_birth band, salary bracket) then need a dataset privacy model - see privacy-models.md (opens in new window).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Unsalted hashing of SSN | SSN format is enumerable (~10⁹); attacker rebuilds the mapping table in minutes. | Salt + key per tenant; or tokenise via vault. |
| FPE for an analytics dataset | Format preservation lets a join attack with another dataset recover identity. | Use random substitution for analytics datasets that don't need format round-trip. |
| "GDPR-compliant" pseudonymisation claim | GDPR pseudonymised data is still personal data - Article 4(5) is explicit. | Either mark output pseudonymised (in scope) or fully anonymise (out of scope). |
| k = 2 anonymity | Re-identification probability is 50 % for the equivalence class. | k ≥ 5 typical; k = 10+ for high-risk datasets. |
| Shuffling a rare-value column | Outliers identify themselves regardless of position. | Combine shuffling with generalisation or suppression of outliers. |
| Number variance ± 1 % on salaries | The variance is smaller than the precision needed to identify; effectively no masking. | Variance must exceed the identifying granularity - ± 10 % minimum for salary. |
| Tokenisation without vault access controls | The vault becomes the single point of failure. | Strict access control + audit logging + separate key custody. |
| Differential privacy with ε = 100 | Useless budget; no privacy guarantee. | ε ≤ 1 typical for strong privacy; ε ≤ 10 for relaxed cases. |
Limitations
References
PII categories - cross-regime catalog
View source (opens in new window)PII categories - cross-regime catalog
Companion reference for pii-masking-pipeline-builder (Step 2 field classification). Also the scoping source for presidio-pii-detection recogniser sets.
Overview
This is the canonical category catalog that the masking pipeline and detectors reference for scope. It enumerates four regimes:
This is a pure reference - no execution steps. Workflow skills consume it.
When to use
Per-regime identifier catalogs
The full enumerations - GDPR Art. 4(1) identifiers and Art. 9 special categories with the Art. 4(5) pseudonymisation distinction, the CCPA/CPRA statutory categories and CPRA sensitive personal information, NIST SP 800-122 linked-vs-linkable and the six confidentiality-impact factors, and the HIPAA Safe Harbor 18 identifiers - are in regime-catalogs.md (opens in new window). The cross-jurisdiction map below is the fast scoping tool; consult the catalogs for the per-regime detail behind each column.
Cross-jurisdiction map
The fastest way to scope a masking pipeline is to enumerate fields present in the dataset and look up which regimes flag each:
| Field | GDPR Art. 4(1) | GDPR Art. 9 | CCPA/CPRA | CPRA SPI | NIST 800-122 | HIPAA Safe Harbor |
|---|---|---|---|---|---|---|
| Full name | ✓ | - | ✓ (A) | - | ✓ | ✓ (#1) |
| ✓ | - | ✓ (A) | - | ✓ | ✓ (#6) | |
| Phone | ✓ | - | ✓ (A) | - | ✓ | ✓ (#4) |
| SSN | ✓ | - | ✓ (A, B) | ✓ | ✓ | ✓ (#7) |
| Passport / driver's licence | ✓ | - | ✓ (A) | ✓ | ✓ | ✓ (#11) |
| IP address | ✓ (Recital 30) | - | ✓ (A) | - | linkable | ✓ (#15) |
| Cookie / device ID | ✓ | - | ✓ (A) | - | linkable | ✓ (#13) |
| Birth date | linkable | - | ✓ (A) | - | linkable | ✓ (#3 - months/days) |
| Precise geolocation | ✓ | - | ✓ (G) | ✓ | ✓ | ✓ (#2 - sub-state) |
| Race / ethnicity | ✓ | ✓ | ✓ (C) | ✓ | - | - |
| Religion | ✓ | ✓ | ✓ (C) | ✓ | - | - |
| Sexual orientation | ✓ | ✓ | ✓ (C) | ✓ | - | - |
| Health condition | ✓ | ✓ (Art. 4(15)) | ✓ (B) | ✓ | ✓ | - (covered by PHI rules) |
| Genetic data | ✓ | ✓ (Art. 4(13)) | ✓ (B) | ✓ | - | - |
| Biometric (face, fingerprint) | ✓ | ✓ (Art. 4(14)) | ✓ (E) | ✓ (if uniquely identifying) | ✓ | ✓ (#16, #17) |
| Account login + password | ✓ | - | ✓ (A) | ✓ | ✓ | ✓ (#10) |
| Credit-card / IBAN | ✓ | - | ✓ (A, D) | ✓ | ✓ | ✓ (#10) |
| Medical record number | ✓ | - (covered in B) | ✓ (B) | ✓ (health subset) | ✓ | ✓ (#8) |
| Browsing history | ✓ | - | ✓ (F) | - | ✓ | ✓ (#14) |
| Purchase records | ✓ | - | ✓ (D) | - | ✓ | - |
| Inferred profile / score | ✓ | - | ✓ (K) | - | linkable | - |
"linkable" = field alone may not identify, but combined with other fields it does (NIST §2.2).
Common confusions
| Confusion | Reality |
|---|---|
| "PII = SSN, name, email." | These are subsets. GDPR personal data includes online identifiers, location, biometrics, inferences. Use the full Art. 4(1) list. |
| "CCPA only covers consumers." | CCPA "consumer" includes employees and job applicants under CPRA (Cal. Civ. Code § 1798.140(i)). |
| "HIPAA only covers hospitals." | HIPAA covers covered entities (providers, plans, clearinghouses) and business associates. Business associates inherit HIPAA obligations via BAAs. |
| "IP address isn't personal data." | GDPR Recital 30 lists IP addresses as online identifiers. CJEU Breyer (C-582/14) confirmed dynamic IPs are personal data when linkable. |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Single-list scoping | Only catches one regime's identifiers; leaks the others. | Use the cross-jurisdiction map above as the union scope. |
| Treating PHI as "just sensitive PII" | HIPAA Safe Harbor has 18 specific identifiers - birth date months, vehicle IDs, certificate numbers - that GDPR lists don't enumerate. | Apply HIPAA Safe Harbor when the dataset is PHI. |
| Mapping CCPA to GDPR Art. 9 only | CPRA SPI includes financial + government identifiers Art. 9 doesn't. | Apply CPRA SPI as a separate scope layer. |
| Stopping at "direct identifiers" | NIST §2.2 says linkable info is PII. Date-of-birth + ZIP + sex re-identifies most individuals. | Include linkable fields in scope. |
| Pseudonymisation = anonymisation | GDPR Art. 4(5) keeps pseudonymised data personal. | Document which masking outputs are pseudonymised (in scope) vs anonymised (out of scope). |
| Ignoring inferred profiles | CCPA category K covers inferences. A "risk score" derived from PII is itself PII. | Treat inferred / derived fields the same as their sources. |
Limitations
References
Privacy models - NIST SP 800-188
View source (opens in new window)Privacy models - NIST SP 800-188
Deep reference behind masking-techniques.md (opens in new window). Consult after per-field operators are chosen, when quasi-identifiers remain and the whole dataset's disclosure risk must be bounded.
NIST SP 800-188:2023 ("De-Identifying Government Datasets", csrc.nist.gov/pubs/sp/800/188/final (opens in new window)) formalises statistical privacy models layered above the per-field techniques.
k-anonymity
A dataset is k-anonymous if every record is indistinguishable from at least k - 1 other records when projected on the quasi-identifiers (Sweeney 2002, cited in NIST 800-188).
l-diversity
Strengthens k-anonymity by requiring at least l well-represented values of the sensitive attribute within each equivalence class (Machanavajjhala et al. 2007).
t-closeness
Strengthens l-diversity by requiring the distribution of the sensitive attribute in each equivalence class be close (within t, by Earth Mover's Distance) to the distribution in the overall dataset (Li et al. 2007).
Differential privacy
A formal mathematical guarantee: the probability of any output changes by at most a multiplicative factor (e^ε) when a single record is added/removed. ε (epsilon) is the privacy budget - lower ε = stronger privacy.
Picking the model
References
Per-regime identifier catalogs
View source (opens in new window)Per-regime identifier catalogs
Full per-regime enumerations behind pii-categories.md (opens in new window). The cross-jurisdiction map there is the fast scoping tool; this file holds the detail behind each column.
GDPR - personal data (Article 4(1))
Definition (Article 4(1)): "any information relating to an identified or identifiable natural person ('data subject')" (gdpr-info.eu/art-4-gdpr/ (opens in new window)).
The article enumerates identifiers that make a person identifiable:
| Identifier class | Examples |
|---|---|
| Name | Given name, surname, full name, online aliases linked to the person |
| Identification number | National ID, passport, driver's licence, tax ID, employee ID |
| Location data | GPS coordinates, IP-derived city/region, cell-tower triangulation |
| Online identifier | IP address, cookie ID, device fingerprint, advertising ID (per Recital 30) |
| Physical/physiological factor | Height, weight, eye colour, fingerprint, gait |
| Genetic factor | DNA-derived information (further defined in Art. 4(13)) |
| Mental factor | Diagnosed mental-health conditions, IQ test results |
| Economic factor | Salary, credit score, transaction history, account balances |
| Cultural factor | Language, religion, ethnic background |
| Social factor | Marital status, family relationships, social-network connections |
Source: Article 4(1) GDPR (gdpr-info.eu/art-4-gdpr/ (opens in new window)).
GDPR Article 9 - special categories of personal data
Article 9(1) lists categories whose processing is prohibited by default unless one of the Article 9(2) exceptions applies:
A masking pipeline for an EU dataset must apply at least the broader Art. 4(1) rules and stricter rules to any field falling under Art. 9 (special categories carry higher fines and must be either redacted or fully anonymised, not merely pseudonymised).
GDPR Article 4(5) - pseudonymisation vs anonymisation
"Pseudonymisation" (Art. 4(5)) keeps data attributable to a subject with additional information, kept separately. Pseudonymised data is still personal data under GDPR - it remains in scope.
Anonymised data (no longer linkable to a subject under any reasonably likely method, per Recital 26) falls out of GDPR scope. The masking pipeline must mark which output is which (masking-techniques.md (opens in new window) explains the techniques).
CCPA / CPRA - personal information
Definition (Cal. Civ. Code § 1798.140(v)(1), as amended by CPRA): "information that identifies, relates to, describes, is reasonably capable of being associated with, or could reasonably be linked, directly or indirectly, with a particular consumer or household" (oag.ca.gov/privacy/ccpa (opens in new window)).
Statutory categories enumerated in § 1798.140(v)(1)(A) - (K):
| # | Category | Examples |
|---|---|---|
| A | Identifiers | Name, postal address, email, IP address, account name, SSN, driver's licence, passport |
| B | Customer records | Records covered by Cal. Civ. Code § 1798.80(e) - name, signature, education, employment, financial info, medical, health-insurance, with paper/electronic regardless of storage medium |
| C | Protected classifications | Race, religion, gender, sexual orientation, age, national origin, disability, marital status (under California or federal law) |
| D | Commercial information | Purchases, products considered, consuming history |
| E | Biometric information | Fingerprints, retina, hand prints, voice recordings, keystroke patterns |
| F | Internet/network activity | Browsing history, search history, interaction with a website or app |
| G | Geolocation data | Physical location, movements, especially "precise geolocation" (CPRA refinement) |
| H | Sensory data | Audio, electronic, visual, thermal, olfactory recordings |
| I | Professional/employment | Job titles, salaries, employment records |
| J | Education | Education records as defined in 20 USC § 1232g (FERPA) |
| K | Inferences | Profile drawn from any of A - J to predict preferences, characteristics, predispositions, behaviour |
CPRA - sensitive personal information (SPI)
CPRA added a subcategory of personal information requiring extra protection (Cal. Civ. Code § 1798.140(ae)):
Citation: oag.ca.gov/privacy/ccpa "Sensitive Personal Information" (oag.ca.gov/privacy/ccpa (opens in new window)).
NIST SP 800-122 - PII
Definition (citing OMB Memorandum 07-16, reproduced in NIST SP 800-122 Section 2.1): "information which can be used to distinguish or trace an individual's identity, such as their name, social security number, biometric records, etc., alone, or when combined with other personal or identifying information which is linked or linkable to a specific individual, such as date and place of birth, mother's maiden name, etc."
Citation: NIST SP 800-122:2010 §2.1, fetched from csrc.nist.gov/pubs/sp/800/122/final (opens in new window).
Linked vs linkable
NIST 800-122 §2.2 introduces a crucial distinction:
A masking pipeline must consider linkable fields (e.g., birth date alone isn't identifying, but date + zip + sex is - the Sweeney 87 % rule). The pipeline shouldn't only protect direct identifiers.
Confidentiality impact levels
NIST 800-122 §3 names six factors that drive the PII confidentiality impact level (low / moderate / high):
Masking aggressiveness scales with impact level.
HIPAA Safe Harbor - 18 identifiers (45 CFR § 164.514(b)(2))
For health data (PHI), the HIPAA Privacy Rule defines two de-identification methods (Expert Determination, 45 CFR § 164.514(b)(1), and Safe Harbor, 45 CFR § 164.514(b)(2)). Safe Harbor requires removing all of these 18 identifiers (per HHS guidance, hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification (opens in new window)):
A masking pipeline operating on health data must catch all 18; a detector configured only for GDPR's broader categories will miss HIPAA-required identifiers (e.g., medical record number is not explicit in GDPR Art. 4(1) - covered by "identification number" but a detector may not flag it without a HIPAA-specific recogniser).
Related skills
k-anonymity-verifier
Verifies that a masked dataset satisfies k-anonymity, l-diversity, and t-closeness by computing equivalence classes over chosen quasi-identifiers and reporting re-identification risk. Covers quasi-identifier selection heuristics, threshold guidance, pycanon API (k_anonymity / l_diversity / t_closeness / report), ARX Java API and GUI workflow, SmartNoise for differential-privacy comparison, and CI-gate integration. Distinct from pii-masking-pipeline-builder's masking-techniques catalog (which lists masking operators but defers k-anonymity measurement to dedicated tooling) and from presidio-pii-detection (which detects PII spans but offers no equivalence-class analysis). Use when you need to confirm whether a masked dataset meets a stated k, l, or t threshold before promoting it to a non-production environment.
presidio-pii-detection
Author and run Microsoft Presidio PII detection - wraps presidio-analyzer (PII detector) + presidio-anonymizer (replace/redact/mask/hash/encrypt operators) for scanning datasets, log streams, and free-text fields. Covers AnalyzerEngine + AnonymizerEngine setup, built-in recognizers (PERSON, EMAIL_ADDRESS, CREDIT_CARD, US_SSN, IBAN_CODE, country-specific IDs across US/UK/Spain/Italy/Poland/Singapore/Australia/India and more), custom PatternRecognizer authoring, score thresholds, and CI gating. Use when scanning *existing* data for PII (vs synthesising fresh fixtures with synthetic-pii-generator).
synthea-healthcare-data
Author and run Synthea (MITRE's open-source synthetic patient population simulator) to produce HIPAA-safe synthetic medical records for testing health IT systems. Covers Gradle build, population-size and state-specific generation, FHIR R4 / STU3 / DSTU2 / C-CDA / CSV / CPCDS output formats, disease-module customisation, and the lifecycle-simulation approach (birth-through-death patient journeys with realistic demographics). Use when testing FHIR servers, EHR integrations, claims processing, or any health IT system that needs realistic patient records without HIPAA exposure (distinct from the generic Faker family - qa-test-data faker-data for fixtures, the pii-masking-pipeline-builder faker-masking-operators reference for masking substitution; this is health-domain-specific).