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 →