Skip to content

PII Redactor — TypeScript source

Paste text and automatically detect and mask personal data — emails, phone numbers, IP addresses, SSNs, credit card numbers, and dates.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure PII detection & redaction logic - no React, no DOM, deterministic.
// Regex-based detection for seven personal-data types; every regex candidate
// passes a structural validator (octet ranges, Luhn checksum, month/day
// bounds, E.164 digit count) to keep false positives low. Overlapping
// candidates resolve by type priority - unambiguous types (email, Luhn-passing
// card numbers, SSNs, IPs, dates) claim their span before the fuzzy phone
// pattern. Never throws.

/** The seven PII types the detector knows. */
export type PiiType = 'email' | 'phone' | 'ipv4' | 'ipv6' | 'ssn' | 'credit-card' | 'date';

/** One detected personal-data item: where it is and what it was. */
export interface PiiMatch {
  type: PiiType;
  start: number; // index of the first character in the input
  end: number; // index one past the last character
  original: string; // the matched substring, verbatim
}

/** All PII types, in display order. */
export const PII_TYPES: PiiType[] = ['email', 'phone', 'ipv4', 'ipv6', 'ssn', 'credit-card', 'date'];

/**
 * Luhn checksum. `digits` must be a non-empty string of 0-9 (any separators
 * make it invalid - strip them first). Returns false otherwise.
 */
export function isValidLuhn(digits: string): boolean {
  if (!/^\d+$/.test(digits)) return false;
  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let d = digits.charCodeAt(i) - 48;
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return sum % 10 === 0;
}

// --- Per-type structural validators (regex candidates pass through these) ---

/** Octets 0-255 each; the regex already bounds the shape to a dotted quad. */
function isValidIpv4(candidate: string): boolean {
  return candidate.split('.').every((o) => {
    const n = Number(o);
    return n <= 255;
  });
}

/** Full 8-group form, or a compressed `::` form expanding to exactly 8. */
function isValidIpv6(candidate: string): boolean {
  // Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
  if (!/[A-Fa-f0-9]/.test(candidate)) return false;
  const groups = candidate.split(':');
  if (groups.includes('')) {
    // Compressed: at most one "::", its sides together hold < 8 groups.
    const parts = candidate.split('::');
    if (parts.length > 2) return false;
    const left = parts[0] ? parts[0].split(':') : [];
    const right = parts[1] ? parts[1].split(':') : [];
    if (left.length + right.length > 7) return false;
    return [...left, ...right].every((g) => /^[A-Fa-f0-9]{1,4}$/.test(g));
  }
  return groups.length === 8 && groups.every((g) => /^[A-Fa-f0-9]{1,4}$/.test(g));
}

/** ISO calendar plausibility: month 01-12, day 01-31. */
function isValidDate(candidate: string): boolean {
  // Groups: [full, year, month, day] - skip full match + year.
  const [, , mm, dd] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(candidate) ?? [];
  if (mm === undefined) return false;
  const month = Number(mm);
  const day = Number(dd);
  return month >= 1 && month <= 12 && day >= 1 && day <= 31;
}

/** E.164 digit budget (7-15) and structural guards for the fuzzy phone shape. */
function isValidPhone(candidate: string): boolean {
  const digits = candidate.replace(/\D/g, '');
  if (digits.length < 7 || digits.length > 15) return false;
  // A dotted quad is IP-shaped: if it were a valid IP it was already claimed
  // by the ipv4 detector; an invalid one (999.x) is likelier a version string.
  if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(candidate)) return false;
  // YYYY-MM-DD shaped (even an impossible date) is never a phone number.
  if (/^\d{4}-\d{2}-\d{2}$/.test(candidate)) return false;
  return true;
}

/** 13-19 digits with optional space/dash grouping, plus a Luhn checksum. */
function isValidCard(candidate: string): boolean {
  const digits = candidate.replace(/\D/g, '');
  return digits.length >= 13 && digits.length <= 19 && isValidLuhn(digits);
}

// --- Detectors: a global candidate regex + an optional structural validator ---

interface Detector {
  type: PiiType;
  re: RegExp;
  validate?: (candidate: string) => boolean;
}

const DETECTORS: Detector[] = [
  {
    // RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
    type: 'email',
    re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
  },
  {
    // A maximal run of 12+ digits with single spaces/dashes as separators;
    // isValidCard then enforces 13-19 digits + Luhn on the whole run.
    type: 'credit-card',
    re: /\d(?:[ -]?\d){11,}/g,
    validate: isValidCard,
  },
  {
    type: 'ssn',
    re: /\b\d{3}-\d{2}-\d{4}\b/g,
  },
  {
    // Hex groups joined by colons (>=2 colons); isValidIpv6 rejects prose
    // like "10:30:45" (only 3 groups, no "::").
    type: 'ipv6',
    re: /(?<![:\w])[A-Fa-f0-9]{0,4}(?::[A-Fa-f0-9]{0,4}){1,7}(?![:\w])/g,
    validate: isValidIpv6,
  },
  {
    // Dotted quad; guards keep it out of versions ("v1.2.3.4") and longer
    // quintets ("1.2.3.4.5") while allowing sentence-final periods.
    type: 'ipv4',
    re: /(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\.?\d)(?!\w)/g,
    validate: isValidIpv4,
  },
  {
    type: 'date',
    re: /(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)/g,
    validate: isValidDate,
  },
  {
    // Optional +country, optional (area), then 1-4 groups of 2-4 digits
    // separated by spaces, dashes, or dots. Fuzziest pattern - lowest priority.
    type: 'phone',
    re: /(?<![\d(])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)|\d{1,4})(?:[ .-]?\d{2,4}){1,4}(?!\d)/g,
    validate: isValidPhone,
  },
];

// Overlap resolution: when two candidates cover the same span, the more
// specific type wins. Phone is deliberately last - a date, SSN, IP, or card
// number can all masquerade as one.
const PRIORITY: Record<PiiType, number> = {
  email: 0,
  'credit-card': 1,
  ssn: 2,
  ipv6: 3,
  ipv4: 4,
  date: 5,
  phone: 6,
};

/**
 * Detect personal data in `text`. Pass `types` to scan for a subset (the
 * per-type toggles); omit it to scan for everything. Returns matches in
 * document order, non-overlapping, with exact `start`/`end` indices.
 */
export function detectPii(text: string, types?: PiiType[]): PiiMatch[] {
  const source = text ?? '';
  const active = types ? new Set(types) : null;
  const candidates: PiiMatch[] = [];

  for (const det of DETECTORS) {
    if (active && !active.has(det.type)) continue;
    const re = new RegExp(det.re.source, det.re.flags); // fresh lastIndex per scan
    let m: RegExpExecArray | null;
    while ((m = re.exec(source)) !== null) {
      if (m[0] === '') break; // zero-length safety; none of the patterns can
      if (!det.validate || det.validate(m[0])) {
        candidates.push({ type: det.type, start: m.index, end: m.index + m[0].length, original: m[0] });
      }
    }
  }

  // Highest-priority (lowest number) candidates claim their span first.
  candidates.sort(
    (a, b) => PRIORITY[a.type] - PRIORITY[b.type] || a.start - b.start,
  );
  const kept: PiiMatch[] = [];
  for (const c of candidates) {
    if (kept.some((k) => c.start < k.end && k.start < c.end)) continue;
    kept.push(c);
  }
  kept.sort((a, b) => a.start - b.start);
  return kept;
}

/**
 * Redact personal data from `text`, replacing every detected span with `mask`
 * (default `[REDACTED]`). Accepts the same `types` subset as detectPii.
 */
export function redactPii(
  text: string,
  options?: { mask?: string; types?: PiiType[] },
): string {
  const source = text ?? '';
  const mask = options?.mask ?? '[REDACTED]';
  const matches = detectPii(source, options?.types);
  let out = source;
  // Replace right-to-left so earlier indices stay valid.
  for (let i = matches.length - 1; i >= 0; i--) {
    const m = matches[i];
    out = out.slice(0, m.start) + mask + out.slice(m.end);
  }
  return out;
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →