Skip to content

PII Redactor — Swift source

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

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

// pii-redactor — PII detection & redaction.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/pii-redactor.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Pure logic - no UI, deterministic. 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.

import Foundation

// MARK: - Types

/// The seven PII types the detector knows.
enum PiiType: String, CaseIterable, Codable {
    case email
    case phone
    case ipv4
    case ipv6
    case ssn
    case creditCard = "credit-card"
    case date
}

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

/// All PII types, in display order.
let piiTypes: [PiiType] = [.email, .phone, .ipv4, .ipv6, .ssn, .creditCard, .date]

// MARK: - Luhn

/// Luhn checksum. `digits` must be a non-empty string of 0-9 (any separators
/// make it invalid - strip them first). Returns false otherwise.
func isValidLuhn(_ digits: String) -> Bool {
    guard !digits.isEmpty, digits.allSatisfy({ $0.isASCII && $0.isNumber }) else { return false }
    var sum = 0
    var double = false
    for ch in digits.reversed() {
        var d = ch.wholeNumberValue ?? 0
        if double {
            d *= 2
            if d > 9 { d -= 9 }
        }
        sum += d
        double.toggle()
    }
    return sum % 10 == 0
}

// MARK: - Per-type structural validators (regex candidates pass through these)

/// Octets 0-255 each; the regex already bounds the shape to a dotted quad.
func isValidIpv4(_ candidate: String) -> Bool {
    candidate.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { Int($0) != nil && Int($0)! <= 255 }
}

let hexGroup = try! NSRegularExpression(pattern: "^[A-Fa-f0-9]{1,4}$")

private func matchesHexGroup(_ g: String) -> Bool {
    hexGroup.firstMatch(in: g, range: NSRange(g.startIndex..., in: g)) != nil
}

/// Full 8-group form, or a compressed `::` form expanding to exactly 8.
func isValidIpv6(_ candidate: String) -> Bool {
    // Lone ":" / "::" (URL scheme separators like https://) carry no hex digits.
    guard candidate.contains(where: { $0.isHexDigit }) else { return false }
    let groups = candidate.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
    if groups.contains("") {
        // Compressed: at most one "::", its sides together hold < 8 groups.
        let parts = candidate.components(separatedBy: "::")
        if parts.count > 2 { return false }
        let left = parts[0].isEmpty ? [] : parts[0].split(separator: ":").map(String.init)
        let right = parts.count > 1 && !parts[1].isEmpty ? parts[1].split(separator: ":").map(String.init) : []
        if left.count + right.count > 7 { return false }
        return (left + right).allSatisfy(matchesHexGroup)
    }
    return groups.count == 8 && groups.allSatisfy(matchesHexGroup)
}

let isoDateShape = try! NSRegularExpression(pattern: "^(\\d{4})-(\\d{2})-(\\d{2})$")

/// ISO calendar plausibility: month 01-12, day 01-31.
func isValidDate(_ candidate: String) -> Bool {
    guard let m = isoDateShape.firstMatch(in: candidate, range: NSRange(candidate.startIndex..., in: candidate)),
          let mmRange = Range(m.range(at: 2), in: candidate),
          let ddRange = Range(m.range(at: 3), in: candidate) else { return false }
    let month = Int(candidate[mmRange]) ?? 0
    let day = Int(candidate[ddRange]) ?? 0
    return (1...12).contains(month) && (1...31).contains(day)
}

let dottedQuadShape = try! NSRegularExpression(pattern: "^\\d{1,3}(?:\\.\\d{1,3}){3}$")
let ymdShape = try! NSRegularExpression(pattern: "^\\d{4}-\\d{2}-\\d{2}$")
let nonDigits = try! NSRegularExpression(pattern: "\\D")

/// E.164 digit budget (7-15) and structural guards for the fuzzy phone shape.
func isValidPhone(_ candidate: String) -> Bool {
    let digits = nonDigits.stringByReplacingMatches(in: candidate, range: NSRange(candidate.startIndex..., in: candidate), withTemplate: "")
    if digits.count < 7 || digits.count > 15 { return false }
    let full = NSRange(candidate.startIndex..., in: candidate)
    // 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 dottedQuadShape.firstMatch(in: candidate, range: full) != nil { return false }
    // YYYY-MM-DD shaped (even an impossible date) is never a phone number.
    if ymdShape.firstMatch(in: candidate, range: full) != nil { return false }
    return true
}

/// 13-19 digits with optional space/dash grouping, plus a Luhn checksum.
func isValidCard(_ candidate: String) -> Bool {
    let digits = nonDigits.stringByReplacingMatches(in: candidate, range: NSRange(candidate.startIndex..., in: candidate), withTemplate: "")
    return (13...19).contains(digits.count) && isValidLuhn(digits)
}

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

struct Detector {
    let type: PiiType
    let re: NSRegularExpression
    let validate: ((String) -> Bool)?
}

let detectors: [Detector] = [
    Detector(
        // RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
        type: .email,
        re: try! NSRegularExpression(pattern: "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"),
        validate: nil
    ),
    Detector(
        // A maximal run of 12+ digits with single spaces/dashes as separators;
        // isValidCard then enforces 13-19 digits + Luhn on the whole run.
        type: .creditCard,
        re: try! NSRegularExpression(pattern: "\\d(?:[ -]?\\d){11,}"),
        validate: isValidCard
    ),
    Detector(
        type: .ssn,
        re: try! NSRegularExpression(pattern: "\\b\\d{3}-\\d{2}-\\d{4}\\b"),
        validate: nil
    ),
    Detector(
        // Hex groups joined by colons (>=2 colons); isValidIpv6 rejects prose
        // like "10:30:45" (only 3 groups, no "::").
        // ICU regex (NSRegularExpression) supports lookbehind, so the TS
        // patterns port verbatim.
        type: .ipv6,
        re: try! NSRegularExpression(pattern: "(?<![:\\w])[A-Fa-f0-9]{0,4}(?::[A-Fa-f0-9]{0,4}){1,7}(?![:\\w])"),
        validate: isValidIpv6
    ),
    Detector(
        // 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.
        type: .ipv4,
        re: try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d{1,3}\\.){3}\\d{1,3}(?!\\.?\\d)(?!\\w)"),
        validate: isValidIpv4
    ),
    Detector(
        type: .date,
        re: try! NSRegularExpression(pattern: "(?<!\\d)\\d{4}-\\d{2}-\\d{2}(?!\\d)"),
        validate: isValidDate
    ),
    Detector(
        // Optional +country, optional (area), then 1-4 groups of 2-4 digits
        // separated by spaces, dashes, or dots. Fuzziest pattern - lowest priority.
        type: .phone,
        re: try! NSRegularExpression(pattern: "(?<![\\d(])(?:\\+\\d{1,3}[ .-]?)?(?:\\(\\d{1,4}\\)|\\d{1,4})(?:[ .-]?\\d{2,4}){1,4}(?!\\d)"),
        validate: 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.
func priority(_ type: PiiType) -> Int {
    switch type {
    case .email: return 0
    case .creditCard: return 1
    case .ssn: return 2
    case .ipv6: return 3
    case .ipv4: return 4
    case .date: return 5
    case .phone: return 6
    }
}

// MARK: - Detection

/**
 * Detect personal data in `text`. Pass `types` to scan for a subset (the
 * per-type toggles); pass nil to scan for everything. Returns matches in
 * document order, non-overlapping, with exact `start`/`end` indices.
 */
func detectPii(_ text: String, _ types: [PiiType]? = nil) -> [PiiMatch] {
    let source = text
    let active = types.map(Set.init)
    var candidates: [PiiMatch] = []

    for det in detectors {
        if let active = active, !active.contains(det.type) { continue }
        let full = NSRange(source.startIndex..., in: source)
        det.re.enumerateMatches(in: source, range: full) { m, _, _ in
            guard let m = m, let range = Range(m.range, in: source) else { return }
            let value = String(source[range])
            if value.isEmpty { return } // zero-length safety; none of the patterns can
            if det.validate == nil || det.validate!(value) {
                let start = source.distance(from: source.startIndex, to: range.lowerBound)
                let end = source.distance(from: source.startIndex, to: range.upperBound)
                candidates.append(PiiMatch(type: det.type, start: start, end: end, original: value))
            }
        }
    }

    // Highest-priority (lowest number) candidates claim their span first.
    candidates.sort { a, b in
        let pa = priority(a.type), pb = priority(b.type)
        return pa != pb ? pa < pb : a.start < b.start
    }
    var kept: [PiiMatch] = []
    for c in candidates {
        if kept.contains(where: { c.start < $0.end && $0.start < c.end }) { continue }
        kept.append(c)
    }
    kept.sort { $0.start < $1.start }
    return kept
}

// MARK: - Redaction

/**
 * Redact personal data from `text`, replacing every detected span with `mask`
 * (default `[REDACTED]`). Accepts the same `types` subset as detectPii.
 */
func redactPii(_ text: String, mask: String = "[REDACTED]", types: [PiiType]? = nil) -> String {
    let matches = detectPii(text, types)
    var out = Array(text)
    let maskChars = Array(mask)
    // Replace right-to-left so earlier indices stay valid.
    for m in matches.reversed() {
        out.replaceSubrange(m.start..<m.end, with: maskChars)
    }
    return String(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 →