class CreditCardSanitizer

Constants

ACCEPTED_POSTFIX
ACCEPTED_PREFIX
ALPHANUMERIC
CARD_COMPANIES

github.com/Shopify/active_merchant/blob/master/lib/active_merchant/billing/credit_card_methods.rb#L5-L18

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 73
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 135
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

Returns a String of the redacted text if a credit card number was detected. Returns nil if no credit card numbers were detected.

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

  text.force_encoding(Encoding::UTF_8)
  text.scrub!('�')
  redacted = 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)
        redacted = true
        redact_numbers(candidate, options)
      else
        match
      end
    end
  end

  redacted && text
end

Private Instance Methods

find_company(numbers) click to toggle source
# File lib/credit_card_sanitizer.rb, line 145
def find_company(numbers)
  CARD_COMPANIES.each do |company, pattern|
    return company if numbers =~ pattern
  end
end
redact_numbers(candidate, options) click to toggle source
# File lib/credit_card_sanitizer.rb, line 190
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 168
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 141
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 176
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 151
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 172
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 185
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 180
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 200
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 204
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