Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°61

PII Redactor

Medium FDESecurityCompliance

Before customer data touches a log line — or a third-party LLM — the personal bits have to go. Mask emails, SSNs, credit cards, and phone numbers with pattern matching, leaving the rest readable. It’s the redaction step that keeps you out of a compliance incident. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement redact(text): replace personal data with typed placeholders, in this order — emails → [EMAIL], US SSNs (###-##-####) → [SSN], 16-digit credit cards (optionally space/dash grouped) → [CARD], and phone numbers (###-###-#### or ###.###.####) → [PHONE]. Everything else is left untouched. Return the redacted string.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement redact(text) with four ordered regex replacements. Use word boundaries so you match whole tokens, and apply email first (its "@" is unambiguous) and card before phone (a 16-digit run must not be split into phone-shaped pieces).

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input text = 'mail a@b.com ssn 123-45-6789 ph 555-123-4567'
Output mail [EMAIL] ssn [SSN] ph [PHONE]
each pattern is masked with its type
EXAMPLE 2
Input text = 'a@b.com 123-45-6789 4111 1111 1111 1111 555-123-4567'
Output [EMAIL] [SSN] [CARD] [PHONE]
all four types, including a space-grouped card
EXAMPLE 3
Input text = 'call 555.123.4567 now'
Output call [PHONE] now
the dotted phone format, with the surrounding words kept
EXAMPLE 4
Input text = 'no pii here'
Output no pii here
edge case: text with nothing to redact passes through unchanged
CONSTRAINTS
  • Apply the patterns in the given order (email, SSN, card, phone) so a card isn’t mis-tagged as a phone.
  • Replace with the typed token ([EMAIL], [SSN], [CARD], [PHONE]) — keep the surrounding text.
  • Non-matching text passes through unchanged.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Email: [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} → [EMAIL].
  2. SSN: \b\d{3}-\d{2}-\d{4}\b → [SSN].
  3. Card: \b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b → [CARD] (before phone).
  4. Phone: \b\d{3}[-.]\d{3}[-.]\d{4}\b → [PHONE].
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach splits the text on whitespace, tests each token against each pattern with a full match, swaps matching tokens for their placeholder, and joins the tokens back with single spaces. It works for values that stand alone between spaces, and it costs O(n · p) time for p patterns and O(n) space for the token list. But it is wrong in exactly the ways that leak data: a space-grouped card such as 4111 1111 1111 1111 becomes four tokens, none of which is a card; (joe@x.io) carries its parentheses, so the full match fails; and joining with single spaces silently rewrites newlines and tabs in the text that was supposed to pass through unchanged.

The approach

Each kind of PII has a fixed textual shape, so a regular expression can find every occurrence anywhere in the string, in one left-to-right scan per pattern, and replace just the match while keeping the text around it. The \b word boundaries around the SSN, card and phone patterns anchor them to whole runs of digits, so a fragment of a longer number is not mistaken for one. The order that matters is email first: an address such as 123-45-6789@x.com contains a run of digits shaped exactly like an SSN, and replacing those digits first would leave the domain behind. With these patterns the SSN, card and phone shapes cannot overlap one another, so their relative order is a safety habit rather than a requirement.

The reference solution is four reassignments: text = re.sub(pattern, placeholder, text) for email, SSN, card and phone, then return text. The invariant after each pass is that every match of that pattern has become its placeholder, and because [EMAIL] and the others contain no digits and no @, later passes cannot match inside them.

Walk through an example

Trace the first example through the four passes, in the order the reference applies them.

steppassmatchestext afterward
1input—mail a@b.com ssn 123-45-6789 ph 555-123-4567
2emaila@b.commail [EMAIL] ssn 123-45-6789 ph 555-123-4567
3SSN123-45-6789mail [EMAIL] ssn [SSN] ph 555-123-4567
4cardnothing: no 16-digit rununchanged
5phone555-123-4567mail [EMAIL] ssn [SSN] ph [PHONE]

Complexity

Optimal: four re.sub passes, each O(n) on ordinary text, and O(n) space for the rebuilt strings. The email pattern can backtrack: on a long run of letters and digits with no @, every start position rescans the run, which is O(n²). Brute force (split on whitespace, test each token): O(n · p) time and O(n) space, but it misses values that span or touch other characters.

Edge cases to test

  • Email first matters: "123-45-6789@x.com" → "[EMAIL]", but running the SSN pass first gives "[SSN]@x.com", which leaks the domain.
  • Digits glued to letters: "SSN123-45-6789" and "_123-45-6789" come back unchanged, because \b treats letters, digits and underscore alike, so there is no boundary before the 1. "ID:123-45-6789" → "ID:[SSN]".
  • Formats the patterns do not cover pass straight through: "(555) 123-4567", "5551234567", the 15-digit Amex layout "3782 822463 10005" and a dot-grouped card are all returned unchanged.
  • A false positive: "order 1234567890123456" → "order [CARD]", because nothing checks the Luhn digit. For redaction that is usually the safer error.
  • Unicode digits: Python’s \d matches Arabic-Indic digits, so "١٢٣-٤٥-٦٧٨٩" becomes "[SSN]", while JavaScript’s \d is ASCII-only and leaves it unchanged.
  • Punctuation after an address survives: "mail joe@x.io." → "mail [EMAIL].", because the domain part backtracks until the final label is letters only.

Common mistakes

  • Over-broad regexes that redact things that are not PII (false positives).
  • Missing formats — international phone numbers, obfuscated emails, spaced-out card numbers.
  • Redacting after the data has already been logged instead of before it leaves your boundary.
  • Running a digit pattern before the email pattern, so part of an address is replaced and the rest of it leaks.

Follow-ups an interviewer asks next

How would you cut false positives on card numbers?
Validate each candidate with the Luhn checksum before replacing it: starting with the second digit from the right, double every other digit, subtract 9 from any result above 9, and add up all the digits; a valid number totals a multiple of 10. In Python, pass a function as the replacement to re.sub and return [CARD] only when the check passes, or the original text otherwise.
How do you redact names and addresses, which have no fixed format?
Patterns cannot find them. Use a named-entity recognition model, or a PII detection library such as Microsoft Presidio, which combines pattern-based recognizers, context words and NER models, each with a confidence score. Run it alongside the regexes rather than instead of them: regexes are exact and fast for structured identifiers, and the model covers the free-form ones.
Analysts still need to join records on the redacted email. What changes?
Replace each value with a stable pseudonym instead of one fixed token, such as [EMAIL_3f2a] derived from a keyed hash (an HMAC) of the value. The same address always maps to the same token, so joins and counts still work, and keeping the key inside your boundary stops anyone from reversing tokens by hashing a list of known addresses.
How would you make it fast and safe on large inputs?
Combine the four patterns into one alternation with named groups and a replacement function, so the text is scanned once instead of four times; put the email alternative first, because at each position the first alternative that matches wins. Then bound the backtracking: cap the local part, for example at {1,64} characters, so a long run of letters with no @ cannot make the scan quadratic.

Where this shows up

PII redaction is a compliance necessity wherever user data flows into logs, analytics, or LLM prompts — you scrub before the data crosses your trust boundary. It is especially critical when feeding user text to third-party AI tools, which is exactly the “never leak customer data” rule from working with AI safely.

Reference solution

Reference solution — Python
import re

def redact(text):
    text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[EMAIL]", text)
    text = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", text)
    text = re.sub(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "[CARD]", text)
    text = re.sub(r"\b\d{3}[-.]\d{3}[-.]\d{4}\b", "[PHONE]", text)
    return text


print(redact("mail a@b.com ssn 123-45-6789 ph 555-123-4567"))  # -> mail [EMAIL] ssn [SSN] ph [PHONE]
Reference solution — TypeScript
function redact(text: string): string {
  text = text.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[EMAIL]");
  text = text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]");
  text = text.replace(/\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, "[CARD]");
  text = text.replace(/\b\d{3}[-.]\d{3}[-.]\d{4}\b/g, "[PHONE]");
  return text;
}

console.log(redact("mail a@b.com ssn 123-45-6789 ph 555-123-4567")); // -> mail [EMAIL] ssn [SSN] ph [PHONE]

Questions about PII Redactor

Why does the order of PII regex replacements matter?

Because one pattern can match inside another kind of value. An email address can contain a run of digits shaped exactly like an SSN or a phone number, as in 123-45-6789@x.com. If the SSN pattern runs first, it replaces the digits and leaves the at sign and the domain behind, and the email pattern can no longer recognize what is left, so part of the address leaks. Running the email pattern first replaces the whole address. With the patterns in this challenge, the SSN, card and phone shapes cannot overlap each other, but keeping the most specific patterns first is still a safe habit.

Is regex good enough for PII redaction?

For structured identifiers with a fixed shape, such as emails, SSNs, card numbers and phone numbers in known formats, regular expressions are fast, predictable and easy to audit, which is why they are the first layer of most redaction pipelines. They cannot find unstructured PII like names, street addresses or medical details, and they miss any format nobody wrote a pattern for, such as a phone number in parentheses or a 15-digit card. Production systems add validation such as the Luhn check, context words near the match, and a named-entity recognition model for free text.

What does the word boundary do in these PII patterns?

The word boundary token matches a position between a word character and a non-word character, where word characters are letters, digits and underscore. Wrapping the SSN, card and phone patterns in word boundaries means they only match whole runs of digits, so the last nine digits of a longer number are not mistaken for an SSN. The side effect is that digits glued directly to letters are not redacted at all: SSN123-45-6789 has no boundary between the N and the 1, so it passes through unchanged, which matters in data that was concatenated without spaces.

How do you validate a credit card number?

Use the Luhn checksum, which payment card numbers are built to satisfy. Starting with the second digit from the right, double every other digit, and subtract 9 from any doubled value above 9. Add up all the digits, doubled and undoubled. If the total is a multiple of 10, the number passes. The check catches every single-digit typo and most swaps of adjacent digits, and a random 16-digit number passes only one time in ten, which makes it a cheap way to cut false positives such as order numbers before replacing a match with a card placeholder.

Finished this one? 0 / 76 Challenges done
NextCSV → Schema Mapper Next on the “Integrations & messy data” ladder · rung 7 of 10

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges