class CreditCardSanitizer

Constants

ACCEPTED_POSTFIX
ACCEPTED_PREFIX
ALPHANUMERIC
CARD_COMPANIES
CARD_NUMBER_GROUPINGS
Candidate
DEFAULT_OPTIONS
EXPIRATION_DATE
LINE_NOISE
LINE_NOISE_CHAR
NONEMPTY_LINE_NOISE
NUMBERS_WITH_LINE_NOISE
SCHEME_OR_PLUS
VALID_COMPANY_PREFIXES

Attributes

settings[R]

Public Class Methods

new(options = {}) click to toggle source

Create a new CreditCardSanitizer

Options

:replacement_character - the character that will replace digits for redaction. :expose_first - the number of leading digits that will not be redacted. :expose_last - the number of ending digits that will not be redacted. :use_groupings - require card number groupings to match to redact. :exclude_tracking_numbers - do not redact valid shipping company tracking numbers.

# File lib/credit_card_sanitizer.rb, line 82
def initialize(options = {})
  @settings = DEFAULT_OPTIONS.merge(options)
end
parameter_filter(options = {}) click to toggle source

A proc that can be used

text - the text containing potential credit card numbers

Examples

Rails.app.config.filter_parameters = [:password, CreditCardSanitizer.parameter_filter]

env = {
  "action_dispatch.request.parameters" => {"credit_card_number" => "4111 1111 1111 1111", "password" => "123"},
  "action_dispatch.parameter_filter" => Rails.app.config.filter_parameters
}

>> ActionDispatch::Request.new(env).filtered_parameters
=> {"credit_card_number" => "4111 11▇▇ ▇▇▇▇ 1111", "password" => "[FILTERED]"}

Returns a Proc that takes the key/value of the request parameter.

# File lib/credit_card_sanitizer.rb, line 153
def self.parameter_filter(options = {})
  proc { |_, value| new(options).sanitize!(value) if value.is_a?(String) }
end

Public Instance Methods

sanitize!(text, options = {}) click to toggle source

Finds credit card numbers and redacts digits from them

text - the text containing potential credit card numbers

Examples

# If the text contains a credit card number:
sanitize!("4111 1111 1111 1111")
#=> "4111 11▇▇ ▇▇▇▇ 1111"

# If the text does not contain a credit card number:
sanitize!("I want all your credit card numbers!")
#=> nil

If options is false, returns nil if no redaction happened, else the full text after redaction.

If options is true, returns nil if no redaction happened, else an array of [old_text, new_text] indicating what substrings were redacted.

# File lib/credit_card_sanitizer.rb, line 105
def sanitize!(text, options = {})
  options = @settings.merge(options)

  text.force_encoding(Encoding::UTF_8)
  text.scrub!("�")
  changes = nil

  without_expiration(text) do
    text.gsub!(NUMBERS_WITH_LINE_NOISE) do |match|
      next match if $1

      candidate = Candidate.new(match, match.tr("^0-9", ""), $`, $')

      if valid_context?(candidate, options) && valid_numbers?(candidate, options)
        redact_numbers(candidate, options).tap do |redacted_text|
          changes ||= []
          changes << [candidate.text, redacted_text]
        end
      else
        match
      end
    end
  end

  if options[:return_changes]
    changes
  else
    changes && text
  end
end

Private Instance Methods

find_company(numbers) click to toggle source
# File lib/credit_card_sanitizer.rb, line 163
def find_company(numbers)
  CARD_COMPANIES.each do |company, pattern|
    return company if pattern.match?(numbers)
  end
end
redact_numbers(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 208
def redact_numbers(candidate, options)
  candidate.text.gsub(/\d/).with_index do |number, digit_index|
    if within_redaction_range?(candidate, digit_index, options)
      options[:replacement_token]
    else
      number
    end
  end
end
tracking?(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 186
def tracking?(candidate, options)
  options[:exclude_tracking_numbers] && TrackingNumber.new(candidate.numbers).valid?
end
valid_company_prefix?(numbers) click to toggle source
# File lib/credit_card_sanitizer.rb, line 159
def valid_company_prefix?(numbers)
  !!(numbers =~ VALID_COMPANY_PREFIXES)
end
valid_context?(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 194
def valid_context?(candidate, options)
  !options[:parse_flanking] || valid_prefix?(candidate.prefix) && valid_postfix?(candidate.postfix)
end
valid_grouping?(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 169
def valid_grouping?(candidate, options)
  if options[:use_groupings]
    if (company = find_company(candidate.numbers))
      groupings = candidate.text.split(NONEMPTY_LINE_NOISE).map(&:length)
      return true if groupings.length == 1
      if (company_groupings = CARD_NUMBER_GROUPINGS[company])
        company_groupings.each do |company_grouping|
          return true if groupings.take(company_grouping.length) == company_grouping
        end
      end
    end
    false
  else
    true
  end
end
valid_numbers?(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 190
def valid_numbers?(candidate, options)
  LuhnChecksum.valid?(candidate.numbers) && valid_company_prefix?(candidate.numbers) && valid_grouping?(candidate, options) && !tracking?(candidate, options)
end
valid_postfix?(postfix) click to toggle source
# File lib/credit_card_sanitizer.rb, line 203
def valid_postfix?(postfix)
  return true if postfix.nil? || !!ACCEPTED_POSTFIX.match(postfix)
  !ALPHANUMERIC.match(postfix[0])
end
valid_prefix?(prefix) click to toggle source
# File lib/credit_card_sanitizer.rb, line 198
def valid_prefix?(prefix)
  return true if prefix.nil? || !!ACCEPTED_PREFIX.match(prefix)
  !ALPHANUMERIC.match(prefix[-1])
end
within_redaction_range?(candidate, digit_index, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 218
def within_redaction_range?(candidate, digit_index, options)
  digit_index >= options[:expose_first] && digit_index < candidate.numbers.size - options[:expose_last]
end
without_expiration(text) { || ... } click to toggle source
# File lib/credit_card_sanitizer.rb, line 222
def without_expiration(text)
  expiration_date_boundary = SecureRandom.hex.tr("0123456789", "ABCDEFGHIJ")
  text.gsub!(EXPIRATION_DATE) do |expiration_date|
    match = expiration_date.match(/(?<whitespace>\s*)(?<rest>.*)/m)
    "#{match[:whitespace]}#{expiration_date_boundary}#{match[:rest]}#{expiration_date_boundary}"
  end
  yield
  text.gsub!(expiration_date_boundary, "")
end