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 →