Skip to content

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 →