# frozen_string_literal: true require "active_support/core_ext/object/duplicable" module ActiveSupport # +ParameterFilter+ allows you to specify keys for sensitive data from # hash-like object and replace corresponding value. Filtering only certain # sub-keys from a hash is possible by using the dot notation: # 'credit_card.number'. If a proc is given, each key and value of a hash and # all sub-hashes are passed to it, where the value or the key can be replaced # using String#replace or similar methods. # # ActiveSupport::ParameterFilter.new([:password]) # => replaces the value to all keys matching /password/i with "[FILTERED]" # # ActiveSupport::ParameterFilter.new([:foo, "bar"]) # => replaces the value to all keys matching /foo|bar/i with "[FILTERED]" # # ActiveSupport::ParameterFilter.new(["credit_card.code"]) # => replaces { credit_card: {code: "xxxx"} } with "[FILTERED]", does not # change { file: { code: "xxxx"} } # # ActiveSupport::ParameterFilter.new([-> (k, v) do # v.reverse! if /secret/i.match?(k) # end]) # => reverses the value to all keys matching /secret/i class ParameterFilter FILTERED = "[FILTERED]" # :nodoc: # Create instance with given filters. Supported type of filters are +String+, +Regexp+, and +Proc+. # Other types of filters are treated as +String+ using +to_s+. # For +Proc+ filters, key, value, and optional original hash is passed to block arguments. # # ==== Options # # * :mask - A replaced object when filtered. Defaults to "[FILTERED]". def initialize(filters = [], mask: FILTERED) @filters = filters @mask = mask end # Mask value of +params+ if key matches one of filters. def filter(params) compiled_filter.call(params) end # Returns filtered value for given key. For +Proc+ filters, third block argument is not populated. def filter_param(key, value) @filters.empty? ? value : compiled_filter.value_for_key(key, value) end private def compiled_filter @compiled_filter ||= CompiledFilter.compile(@filters, mask: @mask) end class CompiledFilter # :nodoc: def self.compile(filters, mask:) return lambda { |params| params.dup } if filters.empty? strings, regexps, blocks, deep_regexps, deep_strings = [], [], [], nil, nil filters.each do |item| case item when Proc blocks << item when Regexp if item.to_s.include?("\\.") (deep_regexps ||= []) << item else regexps << item end else s = Regexp.escape(item.to_s) if s.include?("\\.") (deep_strings ||= []) << s else strings << s end end end regexps << Regexp.new(strings.join("|"), true) unless strings.empty? (deep_regexps ||= []) << Regexp.new(deep_strings.join("|"), true) if deep_strings&.any? new regexps, deep_regexps, blocks, mask: mask end attr_reader :regexps, :deep_regexps, :blocks def initialize(regexps, deep_regexps, blocks, mask:) @regexps = regexps @deep_regexps = deep_regexps&.any? ? deep_regexps : nil @blocks = blocks @mask = mask end def call(params, parents = [], original_params = params) filtered_params = params.class.new params.each do |key, value| filtered_params[key] = value_for_key(key, value, parents, original_params) end filtered_params end def value_for_key(key, value, parents = [], original_params = nil) parents.push(key) if deep_regexps if regexps.any? { |r| r.match?(key.to_s) } value = @mask elsif deep_regexps && (joined = parents.join(".")) && deep_regexps.any? { |r| r.match?(joined) } value = @mask elsif value.is_a?(Hash) value = call(value, parents, original_params) elsif value.is_a?(Array) # If we don't pop the current parent it will be duplicated as we # process each array value. parents.pop if deep_regexps value = value.map { |v| value_for_key(key, v, parents, original_params) } # Restore the parent stack after processing the array. parents.push(key) if deep_regexps elsif blocks.any? key = key.dup if key.duplicable? value = value.dup if value.duplicable? blocks.each { |b| b.arity == 2 ? b.call(key, value) : b.call(key, value, original_params) } end parents.pop if deep_regexps value end end end end