Skip to content

PII Redactor — C++ source

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

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

// pii-redactor — PII detection & redaction: email, phone, IPv4, IPv6, SSN,
//               credit card, ISO dates.
//
// Language: C++ (C++17, standard library only)
// Ported from src/lib/pii-redactor.ts (the canonical TypeScript implementation).
// display source — part of CosmoDev's polyglot tool pages.
//
// Detection mirrors the TS reference's regex candidates + structural
// validators (octet ranges, Luhn checksum, month/day bounds, E.164 digit
// count) to keep false positives low. The TS regexes lean on lookbehind,
// which std::regex's ECMAScript grammar does not support, so this port
// expresses the same shapes as small hand-rolled scanners with explicit
// boundary checks — same candidates, same priority resolution. 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.

#include <algorithm>
#include <cctype>
#include <optional>
#include <string>
#include <vector>

namespace pii_redactor {

/** The seven PII types the detector knows. */
enum class PiiType { Email, Phone, Ipv4, Ipv6, Ssn, CreditCard, Date };

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

/** Lowercase display name for a type (matches the TS union's string values). */
const char* piiTypeName(PiiType type) {
  switch (type) {
    case PiiType::Email: return "email";
    case PiiType::Phone: return "phone";
    case PiiType::Ipv4: return "ipv4";
    case PiiType::Ipv6: return "ipv6";
    case PiiType::Ssn: return "ssn";
    case PiiType::CreditCard: return "credit-card";
    case PiiType::Date: return "date";
  }
  return "";
}

// --- character classes ---------------------------------------------------------

static bool isDigit(char c) { return c >= '0' && c <= '9'; }
static bool isHexDigit(char c) {
  return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
static bool isWordChar(char c) {
  return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
static bool isLocalChar(char c) {  // email local part: [A-Za-z0-9._%+-]
  return std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' ||
         c == '%' || c == '+' || c == '-';
}
static bool isDomainChar(char c) {  // email domain: [A-Za-z0-9.-]
  return std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '-';
}

/** The char at `i`, or '\0' when out of range. */
static char at(const std::string& s, std::ptrdiff_t i) {
  return i >= 0 && i < static_cast<std::ptrdiff_t>(s.size()) ? s[i] : '\0';
}

// --- structural validators (same contract as the TS validators) ----------------

/**
 * Luhn checksum. `digits` must be a non-empty string of 0-9 (any separators
 * make it invalid - strip them first). Returns false otherwise.
 */
bool isValidLuhn(const std::string& digits) {
  if (digits.empty()) return false;
  int sum = 0;
  bool doubleIt = false;
  for (std::ptrdiff_t i = static_cast<std::ptrdiff_t>(digits.size()) - 1; i >= 0; i--) {
    if (!isDigit(digits[i])) return false;
    int d = digits[i] - '0';
    if (doubleIt) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    doubleIt = !doubleIt;
  }
  return sum % 10 == 0;
}

/** Octets 0-255 each; the scanner already bounds the shape to a dotted quad. */
static bool isValidIpv4(const std::string& candidate) {
  std::size_t pos = 0;
  for (int octet = 0; octet < 4; octet++) {
    std::size_t start = pos;
    while (pos < candidate.size() && isDigit(candidate[pos])) pos++;
    if (pos == start || pos - start > 3) return false;
    int value = 0;
    for (std::size_t i = start; i < pos; i++) value = value * 10 + (candidate[i] - '0');
    if (value > 255) return false;
    if (octet < 3) {
      if (pos >= candidate.size() || candidate[pos] != '.') return false;
      pos++;
    }
  }
  return pos == candidate.size();
}

static bool isHexGroup(const std::string& g, std::size_t maxLen) {
  return !g.empty() && g.size() <= maxLen &&
         std::all_of(g.begin(), g.end(), [](char c) { return isHexDigit(c); });
}

/** Full 8-group form, or a compressed `::` form expanding to exactly 8. */
static bool isValidIpv6(const std::string& candidate) {
  // Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
  if (!std::any_of(candidate.begin(), candidate.end(),
                   [](char c) { return isHexDigit(c); })) {
    return false;
  }
  const std::size_t dbl = candidate.find("::");
  if (dbl != std::string::npos) {
    // Compressed: at most one "::", its sides together hold < 8 groups.
    if (candidate.find("::", dbl + 1) != std::string::npos) return false;
    std::vector<std::string> groups;
    auto splitSide = [&](const std::string& side) {
      if (side.empty()) return true;
      std::size_t pos = 0;
      while (pos <= side.size()) {
        const std::size_t colon = side.find(':', pos);
        const std::string g = side.substr(pos, colon == std::string::npos
                                                  ? std::string::npos : colon - pos);
        if (!isHexGroup(g, 4)) return false;
        groups.push_back(g);
        if (colon == std::string::npos) break;
        pos = colon + 1;
      }
      return true;
    };
    if (!splitSide(candidate.substr(0, dbl))) return false;
    if (!splitSide(candidate.substr(dbl + 2))) return false;
    if (groups.size() > 7) return false;
    return true;
  }
  // Uncompressed: exactly 8 colon-separated hex groups of 1-4 digits.
  std::size_t pos = 0;
  int count = 0;
  while (pos <= candidate.size()) {
    const std::size_t colon = candidate.find(':', pos);
    const std::string g = candidate.substr(pos, colon == std::string::npos
                                                ? std::string::npos : colon - pos);
    if (!isHexGroup(g, 4)) return false;
    count++;
    if (colon == std::string::npos) break;
    pos = colon + 1;
  }
  return count == 8;
}

/** ISO calendar plausibility: month 01-12, day 01-31. */
static bool isValidDate(const std::string& candidate) {
  if (candidate.size() != 10 || candidate[4] != '-' || candidate[7] != '-') return false;
  for (std::size_t i = 0; i < 10; i++) {
    if (i == 4 || i == 7) continue;
    if (!isDigit(candidate[i])) return false;
  }
  const int month = (candidate[5] - '0') * 10 + (candidate[6] - '0');
  const int day = (candidate[8] - '0') * 10 + (candidate[9] - '0');
  return month >= 1 && month <= 12 && day >= 1 && day <= 31;
}

/** Count the digits in a candidate (the TS `replace(/\D/g, '')` length). */
static std::size_t digitCount(const std::string& candidate) {
  return static_cast<std::size_t>(std::count_if(
      candidate.begin(), candidate.end(), [](char c) { return isDigit(c); }));
}

/** Digits-only copy of a candidate (the TS `replace(/\D/g, '')`). */
static std::string digitsOf(const std::string& candidate) {
  std::string out;
  for (char c : candidate) {
    if (isDigit(c)) out += c;
  }
  return out;
}

/** E.164 digit budget (7-15) and structural guards for the fuzzy phone shape. */
static bool isValidPhone(const std::string& candidate) {
  const std::size_t digits = digitCount(candidate);
  if (digits < 7 || digits > 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.
  bool ipShaped = true;
  {
    // ^\d{1,3}(\.\d{1,3}){3}$ — exact-shape check.
    std::size_t p = 0;
    int parts = 0;
    while (p < candidate.size()) {
      std::size_t start = p;
      while (p < candidate.size() && isDigit(candidate[p])) p++;
      if (p == start || p - start > 3) { ipShaped = false; break; }
      parts++;
      if (p < candidate.size()) {
        if (candidate[p] != '.') { ipShaped = false; break; }
        p++;
      }
    }
    if (parts != 4) ipShaped = false;
  }
  if (ipShaped) return false;
  // YYYY-MM-DD shaped (even an impossible date) is never a phone number.
  bool dateShaped = candidate.size() == 11 && candidate[4] == '-' && candidate[7] == '-' &&
                    isDigit(candidate[0]) && isDigit(candidate[1]) && isDigit(candidate[2]) &&
                    isDigit(candidate[3]) && isDigit(candidate[5]) && isDigit(candidate[6]) &&
                    isDigit(candidate[8]) && isDigit(candidate[9]) && isDigit(candidate[10]);
  if (dateShaped) return false;
  return true;
}

/** 13-19 digits with optional space/dash grouping, plus a Luhn checksum. */
static bool isValidCard(const std::string& candidate) {
  const std::string digits = digitsOf(candidate);
  return digits.size() >= 13 && digits.size() <= 19 && isValidLuhn(digits);
}

// --- detectors: scan for candidates + run the structural validator --------------

struct Candidate {
  PiiType type;
  std::size_t start;
  std::size_t end;
};

/** RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars). */
static void scanEmails(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i < s.size(); i++) {
    if (s[i] != '@') continue;
    // grow the local part left over [A-Za-z0-9._%+-]+
    std::size_t ls = i;
    while (ls > 0 && isLocalChar(s[ls - 1])) ls--;
    if (ls == i) continue;
    // grow the domain right over [A-Za-z0-9.-]+ \. [A-Za-z]{2,}
    std::size_t de = i + 1;
    while (de < s.size() && isDomainChar(s[de])) de++;
    // the TLD is the trailing run of letters after the last dot
    std::size_t tld = de;
    while (tld > i + 1 && std::isalpha(static_cast<unsigned char>(s[tld - 1]))) tld--;
    if (tld == de || tld <= i + 1 || s[tld - 1] != '.') continue;
    if (de - tld < 2) continue;  // TLD must be 2+ letters
    out.push_back({PiiType::Email, ls, de});
    i = de - 1;
  }
}

/**
 * A maximal run of 12+ digits with single spaces/dashes as separators;
 * isValidCard then enforces 13-19 digits + Luhn on the whole run.
 */
static void scanCreditCards(const std::string& s, std::vector<Candidate>& out) {
  std::size_t i = 0;
  while (i < s.size()) {
    if (!isDigit(s[i])) { i++; continue; }
    std::size_t end = i + 1;
    while (end < s.size()) {
      if (isDigit(s[end])) { end++; continue; }
      if ((s[end] == ' ' || s[end] == '-') && end + 1 < s.size() && isDigit(s[end + 1])) {
        end += 2;
        continue;
      }
      break;
    }
    const std::string run = s.substr(i, end - i);
    if (run.size() >= 13 && isValidCard(run)) {  // 12+ digits => 13+ chars min
      out.push_back({PiiType::CreditCard, i, end});
    }
    i = end;
  }
}

/** \b\d{3}-\d{2}-\d{4}\b */
static void scanSsns(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i + 11 <= s.size(); i++) {
    if (isWordChar(at(s, i - 1)) || isWordChar(s[i + 11])) continue;
    const std::string c = s.substr(i, 11);
    if (c[3] == '-' && c[6] == '-' &&
        std::all_of(c.begin(), c.end(), [](char ch) {
          return isDigit(ch) || ch == '-';
        })) {
      out.push_back({PiiType::Ssn, i, i + 11});
      i += 10;
    }
  }
}

/**
 * Hex groups joined by colons (>=2 colons); isValidIpv6 rejects prose like
 * "10:30:45" (only 3 groups, no "::"). Mirrors the TS lookbehind pattern
 * (?<![:\w])hex{0,4}(:hex{0,4}){1,7}(?![:\w]).
 */
static void scanIpv6s(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i < s.size(); i++) {
    // candidates must start at a hex digit or ':' (after "::" handling below)
    if (!isHexDigit(s[i]) && s[i] != ':') continue;
    // boundary: previous char must not be ':' or a word char (lookbehind)
    const char prev = at(s, i - 1);
    if (prev == ':' || isWordChar(prev)) continue;
    // The TS pattern may start with up to 4 hex digits; leading ':' only
    // occurs inside a "::", which the pattern reaches via the empty first
    // group - cover it by starting hex runs at every hex digit instead.
    if (s[i] == ':') continue;
    std::size_t end = i;
    while (end < s.size() && isHexDigit(s[end])) end++;
    if (end == i) continue;
    std::size_t colons = 0;
    std::size_t scan = end;
    while (scan < s.size() && colons < 8) {
      // single ':' or '::' both advance; count colon marks
      if (s[scan] != ':') break;
      std::size_t next = scan + 1;
      while (next < s.size() && s[next] == ':') next++;
      if (next - scan > 2) break;  // ':::' is not a thing
      std::size_t hexStart = next;
      while (next < s.size() && isHexDigit(s[next])) next++;
      if (next - hexStart > 4) break;
      colons += next - scan;
      scan = next;
      end = next;
    }
    if (colons < 2) continue;
    // trim a trailing ':' run from `end` (the loop advanced past groups only)
    while (end > i && s[end - 1] == ':') end--;
    // trailing boundary: next char must not be ':' or a word char
    const char nextCh = at(s, end);
    if (nextCh == ':' || isWordChar(nextCh)) continue;
    const std::string candidate = s.substr(i, end - i);
    if (isValidIpv6(candidate)) {
      out.push_back({PiiType::Ipv6, i, end});
      i = end - 1;
    }
  }
}

/**
 * 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. Mirrors
 * (?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\.?\d)(?!\w).
 */
static void scanIpv4s(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i < s.size(); i++) {
    if (!isDigit(s[i])) continue;
    const char prev = at(s, i - 1);
    if (isWordChar(prev) || prev == '.') continue;  // (?<![\w.])
    std::size_t end = i;
    bool quad = true;
    for (int octet = 0; octet < 4; octet++) {
      std::size_t start = end;
      while (end < s.size() && isDigit(s[end])) end++;
      if (end == start || end - start > 3) { quad = false; break; }
      if (octet < 3) {
        if (end >= s.size() || s[end] != '.') { quad = false; break; }
        end++;
      }
    }
    if (!quad) continue;
    // (?!\.?\d): not followed by an optional dot + digit...
    if (isDigit(at(s, end))) continue;
    if (at(s, end) == '.' && isDigit(at(s, end + 1))) continue;
    // ...and (?!\w): not followed by a word char
    if (isWordChar(at(s, end))) continue;
    const std::string candidate = s.substr(i, end - i);
    if (isValidIpv4(candidate)) {
      out.push_back({PiiType::Ipv4, i, end});
      i = end - 1;
    }
  }
}

/** (?<!\d)\d{4}-\d{2}-\d{2}(?!\d) */
static void scanDates(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i + 10 <= s.size(); i++) {
    if (isDigit(at(s, i - 1)) || isDigit(at(s, i + 10))) continue;
    const std::string c = s.substr(i, 10);
    if (isValidDate(c)) {
      out.push_back({PiiType::Date, i, i + 10});
      i += 9;
    }
  }
}

/**
 * Optional +country, optional (area), then 1-4 groups of 2-4 digits
 * separated by spaces, dashes, or dots. Fuzziest pattern - lowest priority.
 * Mirrors (?<![\d(])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)|\d{1,4})
 *          (?:[ .-]?\d{2,4}){1,4}(?!\d).
 */
static void scanPhones(const std::string& s, std::vector<Candidate>& out) {
  for (std::size_t i = 0; i < s.size(); i++) {
    const char prev = at(s, i - 1);
    if (isDigit(prev) || prev == '(') continue;  // (?<![\d(])

    std::size_t p = i;
    // optional +country: '+' 1-3 digits + optional single separator
    bool startsPlus = false;
    if (s[p] == '+') {
      std::size_t q = p + 1;
      std::size_t dstart = q;
      while (q < s.size() && isDigit(s[q])) q++;
      if (q - dstart >= 1 && q - dstart <= 3) {
        if (q < s.size() && (s[q] == ' ' || s[q] == '.' || s[q] == '-')) q++;
        p = q;
        startsPlus = true;
      }
    }
    // first group: (area) of 1-4 digits, or bare 1-4 digits
    std::size_t end;
    if (s[p] == '(') {
      std::size_t q = p + 1;
      while (q < s.size() && isDigit(s[q])) q++;
      if (q - (p + 1) >= 1 && q - (p + 1) <= 4 && q < s.size() && s[q] == ')') {
        end = q + 1;
      } else if (!startsPlus) {
        continue;
      } else {
        continue;
      }
    } else {
      std::size_t q = p;
      while (q < s.size() && isDigit(s[q])) q++;
      const std::size_t n = q - p;
      if (n < 1 || n > 4) continue;
      end = q;
    }
    // 1-4 more groups of 2-4 digits, each preceded by an optional separator
    int extra = 0;
    while (extra < 4) {
      std::size_t q = end;
      if (q < s.size() && (s[q] == ' ' || s[q] == '.' || s[q] == '-')) q++;
      std::size_t gs = q;
      while (q < s.size() && isDigit(s[q])) q++;
      if (q - gs < 2 || q - gs > 4) break;
      end = q;
      extra++;
    }
    if (extra < 1) continue;
    if (isDigit(at(s, end))) continue;  // (?!\d)
    const std::string candidate = s.substr(i, end - i);
    if (isValidPhone(candidate)) {
      out.push_back({PiiType::Phone, i, end});
      i = end - 1;
    }
  }
}

// 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.
static int priorityOf(PiiType type) {
  switch (type) {
    case PiiType::Email: return 0;
    case PiiType::CreditCard: return 1;
    case PiiType::Ssn: return 2;
    case PiiType::Ipv6: return 3;
    case PiiType::Ipv4: return 4;
    case PiiType::Date: return 5;
    case PiiType::Phone: return 6;
  }
  return 99;
}

/** True when `type` is one of the `types` filter list. */
static bool isSelected(PiiType type, const std::vector<PiiType>* types) {
  if (types == nullptr) return true;
  return std::find(types->begin(), types->end(), type) != types->end();
}

/**
 * Detect personal data in `text`. Pass `types` to scan for a subset (the
 * per-type toggles); pass nullptr to scan for everything. Returns matches in
 * document order, non-overlapping, with exact start/end indices.
 */
std::vector<PiiMatch> detectPii(const std::string& text,
                                const std::vector<PiiType>* types = nullptr) {
  std::vector<Candidate> candidates;
  if (isSelected(PiiType::Email, types)) scanEmails(text, candidates);
  if (isSelected(PiiType::CreditCard, types)) scanCreditCards(text, candidates);
  if (isSelected(PiiType::Ssn, types)) scanSsns(text, candidates);
  if (isSelected(PiiType::Ipv6, types)) scanIpv6s(text, candidates);
  if (isSelected(PiiType::Ipv4, types)) scanIpv4s(text, candidates);
  if (isSelected(PiiType::Date, types)) scanDates(text, candidates);
  if (isSelected(PiiType::Phone, types)) scanPhones(text, candidates);

  // Highest-priority (lowest number) candidates claim their span first.
  std::stable_sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) {
    const int pa = priorityOf(a.type);
    const int pb = priorityOf(b.type);
    if (pa != pb) return pa < pb;
    return a.start < b.start;
  });
  std::vector<Candidate> kept;
  for (const auto& c : candidates) {
    const bool overlaps =
        std::any_of(kept.begin(), kept.end(), [&](const Candidate& k) {
          return c.start < k.end && k.start < c.end;
        });
    if (overlaps) continue;
    kept.push_back(c);
  }
  std::sort(kept.begin(), kept.end(),
            [](const Candidate& a, const Candidate& b) { return a.start < b.start; });

  std::vector<PiiMatch> out;
  out.reserve(kept.size());
  for (const auto& c : kept) {
    out.push_back({c.type, c.start, c.end, text.substr(c.start, c.end - c.start)});
  }
  return out;
}

/** Redaction options: replacement mask + optional per-type filter. */
struct RedactOptions {
  std::string mask = "[REDACTED]";
  const std::vector<PiiType>* types = nullptr;
};

/**
 * Redact personal data from `text`, replacing every detected span with `mask`
 * (default [REDACTED]). Accepts the same types subset as detectPii.
 */
std::string redactPii(const std::string& text, const RedactOptions& options = {}) {
  const std::vector<PiiMatch> matches = detectPii(text, options.types);
  std::string out = text;
  // Replace right-to-left so earlier indices stay valid.
  for (std::size_t i = matches.size(); i-- > 0;) {
    const PiiMatch& m = matches[i];
    out.replace(m.start, m.end - m.start, options.mask);
  }
  return out;
}

}  // namespace pii_redactor

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 →