airbrake/airbrake-ruby

View on GitHub
lib/airbrake-ruby/filters/keys_filter.rb

Summary

Maintainability
A
1 hr
Test Coverage
module Airbrake
  # Namespace for all standard filters. Custom filters can also go under this
  # namespace.
  module Filters
    # This is a filter helper that endows a class ability to filter notices'
    # payload based on the return value of the +should_filter?+ method that a
    # class that includes this module must implement.
    #
    # @see Notice
    # @see KeysAllowlist
    # @see KeysBlocklist
    # @api private
    module KeysFilter
      # @return [String] The label to replace real values of filtered payload
      FILTERED = '[Filtered]'.freeze

      # @return [Array<String,Symbol,Regexp>] the array of classes instances of
      #   which can compared with payload keys
      VALID_PATTERN_CLASSES = [String, Symbol, Regexp].freeze

      # @return [Array<Symbol>] parts of a Notice's payload that can be modified
      #   by blocklist/allowlist filters
      FILTERABLE_KEYS = %i[environment session params].freeze

      # @return [Array<Symbol>] parts of a Notice's *context* payload that can
      #   be modified by blocklist/allowlist filters
      FILTERABLE_CONTEXT_KEYS = %i[
        user

        # Provided by Airbrake::Rack::HttpHeadersFilter
        headers
        referer
        httpMethod

        # Provided by Airbrake::Rack::ContextFilter
        userAddr
        userAgent
      ].freeze

      include Loggable

      # @return [Integer]
      attr_reader :weight

      # Creates a new KeysBlocklist or KeysAllowlist filter that uses the given
      # +patterns+ for filtering a notice's payload.
      #
      # @param [Array<String,Regexp,Symbol>] patterns
      def initialize(patterns)
        @patterns = patterns
        @valid_patterns = false
      end

      # @!macro call_filter
      #   This is a mandatory method required by any filter integrated with
      #   FilterChain.
      #
      #   @param [Notice] notice the notice to be filtered
      #   @return [void]
      #   @see FilterChain
      def call(notice)
        unless @valid_patterns
          eval_proc_patterns!
          validate_patterns
        end

        FILTERABLE_KEYS.each do |key|
          notice[key] = filter_hash(notice[key])
        end

        FILTERABLE_CONTEXT_KEYS.each { |key| filter_context_key(notice, key) }

        return unless notice[:context][:url]

        filter_url(notice)
      end

      # @raise [NotImplementedError] if called directly
      def should_filter?(_key)
        raise NotImplementedError, 'method must be implemented in the included class'
      end

      private

      def filter_hash(hash) # rubocop:disable Metrics/AbcSize
        return hash unless hash.is_a?(Hash)

        hash_copy = hash.dup

        hash.each_key do |key|
          if should_filter?(key.to_s)
            hash_copy[key] = FILTERED
          elsif hash_copy[key].is_a?(Hash)
            hash_copy[key] = filter_hash(hash_copy[key])
          elsif hash[key].is_a?(Array)
            hash_copy[key].each_with_index do |h, i|
              hash_copy[key][i] = filter_hash(h)
            end
          end
        end

        hash_copy
      end

      def filter_url_params(url)
        url.query = URI.decode_www_form(url.query).to_h.map do |key, val|
          should_filter?(key) ? "#{key}=[Filtered]" : "#{key}=#{val}"
        end.join('&')

        url.to_s
      end

      def filter_url(notice)
        begin
          url = URI(notice[:context][:url])
        rescue URI::InvalidURIError
          return
        end

        return unless url.query

        notice[:context][:url] = filter_url_params(url)
      end

      def eval_proc_patterns!
        return unless @patterns.any? { |pattern| pattern.is_a?(Proc) }

        @patterns = @patterns.flat_map do |pattern|
          next(pattern) unless pattern.respond_to?(:call)

          pattern.call
        end
      end

      def validate_patterns
        @valid_patterns = @patterns.all? do |pattern|
          VALID_PATTERN_CLASSES.any? { |c| pattern.is_a?(c) }
        end

        return if @valid_patterns

        logger.error(
          "#{LOG_LABEL} one of the patterns in #{self.class} is invalid. " \
          "Known patterns: #{@patterns}",
        )
      end

      def filter_context_key(notice, key)
        return unless notice[:context][key]
        return if notice[:context][key] == FILTERED
        unless should_filter?(key)
          return notice[:context][key] = filter_hash(notice[:context][key])
        end

        notice[:context][key] = FILTERED
      end
    end
  end
end