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 →