PII Redactor — Ruby source
Paste text and automatically detect and mask personal data — emails, phone numbers, IP addresses, SSNs, credit card numbers, and dates.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# PII Redactor — pure detection & redaction logic, deterministic.
#
# Language: Ruby (3.1+, standard library only)
# Source: CosmoDev polyglot showcase port of the PII Redactor tool, ported
# from src/lib/pii-redactor.ts (the canonical TypeScript
# implementation).
# License: display source — part of CosmoDev's polyglot tool 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 raises.
module PiiRedactor
# One detected personal-data item: where it is and what it was.
PiiMatch = Struct.new(:type, :start, :end, :original, keyword_init: true)
# All PII types, in display order.
PII_TYPES = %w[email phone ipv4 ipv6 ssn credit-card date].freeze
class << self
# Luhn checksum. +digits+ must be a non-empty string of 0-9 (any
# separators make it invalid - strip them first). Returns false
# otherwise.
def is_valid_luhn(digits)
return false unless digits.match?(/\A\d+\z/)
sum = 0
double = false
(digits.length - 1).downto(0) do |i|
d = digits.getbyte(i) - 48
if double
d *= 2
d -= 9 if d > 9
end
sum += d
double = !double
end
(sum % 10).zero?
end
# 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.
def detect_pii(text, types = nil)
source = text.to_s
active = types && types.to_a
candidates = []
DETECTORS.each do |det|
next if active && !active.include?(det[:type])
source.scan(det[:re]) do
m = Regexp.last_match
candidate = m[0]
next if candidate.empty? # zero-length safety; none of the patterns can
next if det[:validate] && !det[:validate].call(candidate)
candidates << PiiMatch.new(type: det[:type], start: m.begin(0),
end: m.end(0), original: candidate)
end
end
# Highest-priority (lowest number) candidates claim their span first.
candidates = candidates.sort_by { |c| [PRIORITY[c[:type]], c[:start]] }
kept = []
candidates.each do |c|
next if kept.any? { |k| c[:start] < k[:end] && k[:start] < c[:end] }
kept << c
end
kept.sort_by { |c| c[:start] }
end
# Redact personal data from +text+, replacing every detected span with
# +options[:mask]+ (default "[REDACTED]"). Accepts the same +types+
# subset as #detect_pii.
def redact_pii(text, options = {})
source = text.to_s
mask = options[:mask] || '[REDACTED]'
out = source
# Replace right-to-left so earlier indices stay valid.
detect_pii(source, options[:types]).reverse_each do |m|
out = out[0...m[:start]] + mask + out[m[:end]..]
end
out
end
private
# Octets 0-255 each; the regex already bounds the shape to a dotted quad.
def is_valid_ipv4(candidate)
candidate.split('.').all? { |o| o.to_i <= 255 }
end
# Full 8-group form, or a compressed `::` form expanding to exactly 8.
def is_valid_ipv6(candidate)
# Lone ":" / "::" (URL scheme separators like https://) carry no hex
# digits.
return false unless candidate.match?(/[A-Fa-f0-9]/)
groups = candidate.split(':')
if groups.include?('')
# Compressed: at most one "::", its sides together hold < 8 groups.
parts = candidate.split('::')
return false if parts.length > 2
left = !parts[0].to_s.empty? ? parts[0].split(':') : []
right = parts[1] && !parts[1].empty? ? parts[1].split(':') : []
return false if left.length + right.length > 7
(left + right).all? { |g| g.match?(/\A[A-Fa-f0-9]{1,4}\z/) }
else
groups.length == 8 && groups.all? { |g| g.match?(/\A[A-Fa-f0-9]{1,4}\z/) }
end
end
# ISO calendar plausibility: month 01-12, day 01-31.
def is_valid_date(candidate)
m = /\A(\d{4})-(\d{2})-(\d{2})\z/.match(candidate)
return false unless m
month = m[2].to_i
day = m[3].to_i
month >= 1 && month <= 12 && day >= 1 && day <= 31
end
# E.164 digit budget (7-15) and structural guards for the fuzzy phone
# shape.
def is_valid_phone(candidate)
digits = candidate.gsub(/\D/, '')
return false if digits.length < 7 || digits.length > 15
# 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.
return false if candidate.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
# YYYY-MM-DD shaped (even an impossible date) is never a phone number.
return false if candidate.match?(/\A\d{4}-\d{2}-\d{2}\z/)
true
end
# 13-19 digits with optional space/dash grouping, plus a Luhn checksum.
def is_valid_card(candidate)
digits = candidate.gsub(/\D/, '')
digits.length >= 13 && digits.length <= 19 && is_valid_luhn(digits)
end
end
# Detectors: a global candidate regex + an optional structural validator.
DETECTORS = [
{
# RFC 5322 simplified: local@domain.tld (letters-only TLD, 2+ chars).
type: 'email',
re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
},
{
# A maximal run of 12+ digits with single spaces/dashes as separators;
# is_valid_card then enforces 13-19 digits + Luhn on the whole run.
type: 'credit-card',
re: /\d(?:[ -]?\d){11,}/,
validate: ->(c) { is_valid_card(c) }
},
{
type: 'ssn',
re: /\b\d{3}-\d{2}-\d{4}\b/
},
{
# Hex groups joined by colons (>=2 colons); is_valid_ipv6 rejects prose
# like "10:30:45" (only 3 groups, no "::").
type: 'ipv6',
re: /(?<![:\w])[A-Fa-f0-9]{0,4}(?::[A-Fa-f0-9]{0,4}){1,7}(?![:\w])/,
validate: ->(c) { is_valid_ipv6(c) }
},
{
# 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: /(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\.?\d)(?!\w)/,
validate: ->(c) { is_valid_ipv4(c) }
},
{
type: 'date',
re: /(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)/,
validate: ->(c) { is_valid_date(c) }
},
{
# 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: /(?<![\d(])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)|\d{1,4})(?:[ .-]?\d{2,4}){1,4}(?!\d)/,
validate: ->(c) { is_valid_phone(c) }
}
].freeze
# 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.
PRIORITY = {
'email' => 0,
'credit-card' => 1,
'ssn' => 2,
'ipv6' => 3,
'ipv4' => 4,
'date' => 5,
'phone' => 6
}.freeze
end
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 →