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 Detection & Redaction — pure logic, no DOM, deterministic.
// C# 12 / .NET 8 — ported from src/lib/pii-redactor.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// 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.
using System.Text;
using System.Text.RegularExpressions;
/// <summary>The seven PII types the detector knows.</summary>
public enum PiiType
{
Email,
Phone,
Ipv4,
Ipv6,
Ssn,
CreditCard,
Date,
}
/// <summary>One detected personal-data item: where it is and what it was.</summary>
/// <param name="Type">Which detector produced the match.</param>
/// <param name="Start">Index of the first character in the input.</param>
/// <param name="End">Index one past the last character.</param>
/// <param name="Original">The matched substring, verbatim.</param>
public sealed record PiiMatch(PiiType Type, int Start, int End, string Original);
public static partial class PiiRedactor
{
/// <summary>All PII types, in display order.</summary>
public static readonly PiiType[] AllTypes =
[PiiType.Email, PiiType.Phone, PiiType.Ipv4, PiiType.Ipv6, PiiType.Ssn, PiiType.CreditCard, PiiType.Date];
/// <summary>
/// Luhn checksum. <paramref name="digits"/> must be a non-empty string of
/// 0-9 (any separators make it invalid — strip them first).
/// </summary>
public static bool IsValidLuhn(string digits)
{
if (digits.Length == 0 || digits.Any(c => c is < '0' or > '9')) return false;
var sum = 0;
var doubleNext = false;
for (var i = digits.Length - 1; i >= 0; i--)
{
var d = digits[i] - '0';
if (doubleNext)
{
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
doubleNext = !doubleNext;
}
return sum % 10 == 0;
}
// --- Per-type structural validators (regex candidates pass through these) ---
/// <summary>Octets 0-255 each; the regex already bounds the shape to a dotted quad.</summary>
private static bool IsValidIpv4(string candidate) =>
candidate.Split('.').All(o => int.Parse(o) <= 255);
private static readonly Regex HexGroup = Compile(@"^[A-Fa-f0-9]{1,4}$");
/// <summary>Full 8-group form, or a compressed "::" form expanding to exactly 8.</summary>
private static bool IsValidIpv6(string candidate)
{
// Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
if (!candidate.Any(c => Uri.IsHexDigit(c))) return false;
var groups = candidate.Split(':');
if (groups.Contains(""))
{
// Compressed: at most one "::", its sides together hold < 8 groups.
var parts = candidate.Split("::"); // both sides kept, empty strings included
if (parts.Length > 2) return false;
var left = parts[0].Length > 0 ? parts[0].Split(':') : [];
var right = parts[1].Length > 0 ? parts[1].Split(':') : [];
if (left.Length + right.Length > 7) return false;
return left.Concat(right).All(g => HexGroup.IsMatch(g));
}
return groups.Length == 8 && groups.All(g => HexGroup.IsMatch(g));
}
private static readonly Regex IsoDate = Compile(@"^(\d{4})-(\d{2})-(\d{2})$");
/// <summary>ISO calendar plausibility: month 01-12, day 01-31.</summary>
private static bool IsValidDate(string candidate)
{
var m = IsoDate.Match(candidate);
if (!m.Success) return false;
var month = int.Parse(m.Groups[2].Value);
var day = int.Parse(m.Groups[3].Value);
return month is >= 1 and <= 12 && day is >= 1 and <= 31;
}
private static readonly Regex NonDigit = Compile(@"\D");
private static readonly Regex DottedQuad = Compile(@"^\d{1,3}(?:\.\d{1,3}){3}$");
private static readonly Regex DateShaped = Compile(@"^\d{4}-\d{2}-\d{2}$");
/// <summary>E.164 digit budget (7-15) and structural guards for the fuzzy phone shape.</summary>
private static bool IsValidPhone(string candidate)
{
var digits = NonDigit.Replace(candidate, "");
if (digits.Length is < 7 or > 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 (DottedQuad.IsMatch(candidate)) return false;
// YYYY-MM-DD shaped (even an impossible date) is never a phone number.
if (DateShaped.IsMatch(candidate)) return false;
return true;
}
/// <summary>13-19 digits with optional space/dash grouping, plus a Luhn checksum.</summary>
private static bool IsValidCard(string candidate)
{
var digits = NonDigit.Replace(candidate, "");
return digits.Length is >= 13 and <= 19 && IsValidLuhn(digits);
}
// --- Detectors: a global candidate regex + an optional structural validator ---
private sealed record Detector(PiiType Type, Regex Re, Func<string, bool>? Validate = null);
private static readonly Detector[] Detectors =
[
// RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
new(PiiType.Email, Compile(@"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")),
// A maximal run of 12+ digits with single spaces/dashes as separators;
// IsValidCard then enforces 13-19 digits + Luhn on the whole run.
new(PiiType.CreditCard, Compile(@"\d(?:[ -]?\d){11,}"), IsValidCard),
new(PiiType.Ssn, Compile(@"\b\d{3}-\d{2}-\d{4}\b")),
// Hex groups joined by colons (>=2 colons); IsValidIpv6 rejects prose
// like "10:30:45" (only 3 groups, no "::").
new(PiiType.Ipv6, Compile(@"(?<![:\w])[A-Fa-f0-9]{0,4}(?::[A-Fa-f0-9]{0,4}){1,7}(?![:\w])"), 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.
new(PiiType.Ipv4, Compile(@"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\.?\d)(?!\w)"), IsValidIpv4),
new(PiiType.Date, Compile(@"(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)"), IsValidDate),
// Optional +country, optional (area), then 1-4 groups of 2-4 digits
// separated by spaces, dashes, or dots. Fuzziest pattern - lowest priority.
new(PiiType.Phone,
Compile(@"(?<![\d(])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)|\d{1,4})(?:[ .-]?\d{2,4}){1,4}(?!\d)"),
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.
private static readonly Dictionary<PiiType, int> Priority = new()
{
[PiiType.Email] = 0,
[PiiType.CreditCard] = 1,
[PiiType.Ssn] = 2,
[PiiType.Ipv6] = 3,
[PiiType.Ipv4] = 4,
[PiiType.Date] = 5,
[PiiType.Phone] = 6,
};
/// <summary>
/// Detect personal data in <paramref name="text"/>. Pass
/// <paramref name="types"/> to scan for a subset (the per-type toggles);
/// pass null to scan for everything. Returns matches in document order,
/// non-overlapping, with exact <see cref="PiiMatch.Start"/>/<see cref="PiiMatch.End"/> indices.
/// </summary>
public static List<PiiMatch> DetectPii(string? text, IReadOnlySet<PiiType>? types = null)
{
var source = text ?? "";
var candidates = new List<PiiMatch>();
foreach (var det in Detectors)
{
if (types is not null && !types.Contains(det.Type)) continue;
foreach (Match m in det.Re.Matches(source))
{
if (m.Value.Length == 0) continue; // zero-length safety; none of the patterns can
if (det.Validate is null || det.Validate(m.Value))
{
candidates.Add(new PiiMatch(det.Type, m.Index, m.Index + m.Value.Length, m.Value));
}
}
}
// Highest-priority (lowest number) candidates claim their span first.
candidates.Sort((a, b) => Priority[a.Type] - Priority[b.Type] || a.Start - b.Start);
var kept = new List<PiiMatch>();
foreach (var c in candidates)
{
if (kept.Any(k => c.Start < k.End && k.Start < c.End)) continue;
kept.Add(c);
}
kept.Sort((a, b) => a.Start - b.Start);
return kept;
}
/// <summary>
/// Redact personal data from <paramref name="text"/>, replacing every
/// detected span with <paramref name="mask"/> (default "[REDACTED]").
/// Accepts the same <paramref name="types"/> subset as <see cref="DetectPii"/>.
/// </summary>
public static string RedactPii(string? text, string? mask = null, IReadOnlySet<PiiType>? types = null)
{
var source = text ?? "";
mask ??= "[REDACTED]";
var matches = DetectPii(source, types);
var sb = new StringBuilder(source);
// Replace right-to-left so earlier indices stay valid.
for (var i = matches.Count - 1; i >= 0; i--)
{
var m = matches[i];
sb.Remove(m.Start, m.End - m.Start);
sb.Insert(m.Start, mask);
}
return sb.ToString();
}
private static Regex Compile(string pattern) => new(pattern, RegexOptions.Compiled);
}
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 →