Skip to content

PII Redactor — Kotlin source

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

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

// PII Redactor — pure personal-data detection & redaction. Deterministic, never throws.
// Language: Kotlin (JVM 17+), standard library only.
// Ported from src/lib/pii-redactor.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same outputs.
//
// 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.

/** The seven PII types the detector knows. */
enum class PiiType { EMAIL, PHONE, IPV4, IPV6, SSN, CREDIT_CARD, DATE }

/** One detected personal-data item: where it is and what it was. */
data class PiiMatch(
    val type: PiiType,
    val start: Int, // index of the first character in the input
    val end: Int, // index one past the last character
    val original: String, // the matched substring, verbatim
)

/** All PII types, in display order. */
val PII_TYPES = listOf(PiiType.EMAIL, PiiType.PHONE, PiiType.IPV4, PiiType.IPV6, PiiType.SSN, PiiType.CREDIT_CARD, PiiType.DATE)

/**
 * Luhn checksum. `digits` must be a non-empty string of 0-9 (any separators
 * make it invalid - strip them first). Returns false otherwise.
 */
fun isValidLuhn(digits: String): Boolean {
    if (!Regex("^\\d+$").matches(digits)) return false
    var sum = 0
    var double = false
    for (i in digits.length - 1 downTo 0) {
        var d = digits[i].code - 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. */
private fun isValidIpv4(candidate: String): Boolean =
    candidate.split('.').all { it.toInt() <= 255 }

/** Full 8-group form, or a compressed `::` form expanding to exactly 8. */
private fun isValidIpv6(candidate: String): Boolean {
    // Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
    if (!candidate.any { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return false
    val hexGroup = Regex("^[A-Fa-f0-9]{1,4}$")
    if (candidate.contains(':')) {
        val groups = candidate.split(':')
        if (groups.any { it.isEmpty() }) {
            // Compressed: at most one "::", its sides together hold < 8 groups.
            val parts = candidate.split("::")
            if (parts.size > 2) return false
            val left = if (parts[0].isNotEmpty()) parts[0].split(':') else emptyList()
            val right = if (parts[1].isNotEmpty()) parts[1].split(':') else emptyList()
            if (left.size + right.size > 7) return false
            return (left + right).all { hexGroup.matches(it) }
        }
        return groups.size == 8 && groups.all { hexGroup.matches(it) }
    }
    return false
}

/** ISO calendar plausibility: month 01-12, day 01-31. */
private fun isValidDate(candidate: String): Boolean {
    val m = Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(candidate) ?: return false
    val month = m.groupValues[2].toInt()
    val day = m.groupValues[3].toInt()
    return month in 1..12 && day in 1..31
}

/** E.164 digit budget (7-15) and structural guards for the fuzzy phone shape. */
private fun isValidPhone(candidate: String): Boolean {
    val digits = candidate.filter { it.isDigit() }
    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 (Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$").matches(candidate)) return false
    // YYYY-MM-DD shaped (even an impossible date) is never a phone number.
    if (Regex("^\\d{4}-\\d{2}-\\d{2}$").matches(candidate)) return false
    return true
}

/** 13-19 digits with optional space/dash grouping, plus a Luhn checksum. */
private fun isValidCard(candidate: String): Boolean {
    val digits = candidate.filter { it.isDigit() }
    return digits.length in 13..19 && isValidLuhn(digits)
}

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

private class Detector(
    val type: PiiType,
    val re: Regex,
    val validate: ((String) -> Boolean)? = null,
)

private val DETECTORS = listOf(
    // RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
    Detector(
        PiiType.EMAIL,
        Regex("[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.
    Detector(
        PiiType.CREDIT_CARD,
        Regex("\\d(?:[ -]?\\d){11,}"),
        ::isValidCard,
    ),
    Detector(PiiType.SSN, Regex("\\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 "::").
    Detector(
        PiiType.IPV6,
        Regex("(?<![:\\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.
    Detector(
        PiiType.IPV4,
        Regex("(?<![\\w.])(?:\\d{1,3}\\.){3}\\d{1,3}(?!\\.?\\d)(?!\\w)"),
        ::isValidIpv4,
    ),
    Detector(
        PiiType.DATE,
        Regex("(?<!\\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.
    Detector(
        PiiType.PHONE,
        Regex("(?<![\\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 val PRIORITY = mapOf(
    PiiType.EMAIL to 0,
    PiiType.CREDIT_CARD to 1,
    PiiType.SSN to 2,
    PiiType.IPV6 to 3,
    PiiType.IPV4 to 4,
    PiiType.DATE to 5,
    PiiType.PHONE to 6,
)

/**
 * Detect personal data in `text`. Pass `types` to scan for a subset (the
 * per-type toggles); omit it (null) to scan for everything. Returns matches in
 * document order, non-overlapping, with exact `start`/`end` indices.
 */
fun detectPii(text: String, types: Set<PiiType>? = null): List<PiiMatch> {
    val source = text
    val candidates = mutableListOf<PiiMatch>()

    for (det in DETECTORS) {
        if (types != null && det.type !in types) continue
        for (m in det.re.findAll(source)) {
            val value = m.value
            if (value.isEmpty()) continue // zero-length safety; none of the patterns can
            if (det.validate == null || det.validate(value)) {
                candidates.add(PiiMatch(det.type, m.range.first, m.range.last + 1, value))
            }
        }
    }

    // Highest-priority (lowest number) candidates claim their span first.
    candidates.sortWith(compareBy({ PRIORITY[it.type] ?: 0 }, { it.start }))
    val kept = mutableListOf<PiiMatch>()
    for (c in candidates) {
        if (kept.any { k -> c.start < k.end && k.start < c.end }) continue
        kept.add(c)
    }
    kept.sortBy { it.start }
    return kept
}

/**
 * Redact personal data from `text`, replacing every detected span with `mask`
 * (default `[REDACTED]`). Accepts the same `types` subset as [detectPii].
 */
fun redactPii(
    text: String,
    mask: String = "[REDACTED]",
    types: Set<PiiType>? = null,
): String {
    val matches = detectPii(text, types)
    var out = text
    // Replace right-to-left so earlier indices stay valid.
    for (i in matches.indices.reversed()) {
        val m = matches[i]
        out = out.substring(0, m.start) + mask + out.substring(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 →