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 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 →