# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'contrast/components/logger'
require 'contrast/agent/reporting/reporter'
require 'contrast/utils/object_share'
require 'contrast/agent/reporting/attack_result/response_type'
require 'contrast/agent/reporting/masker/masker_utils'

module Contrast
  module Agent
    module Reporting
      # This module duty is to mask any activity containing sensitive information - PII masking.
      module Masker
        MASK = 'contrast-redacted-'
        BODY_MASK = 'contrast-redacted-body'
        BODY_BINARY_MASK = BODY_MASK.bytes.to_s.cs__freeze

        class << self
          include Contrast::Components::Logger::InstanceMethods
          include Contrast::Utils::ObjectShare
          include Contrast::Agent::Reporting::MaskerUtils

          # Keyword dictionary, extracted from the TS response.
          #
          # @return Array<Contrast::Agent::Reporting::Settings::SensitiveDataMaskingRule>
          def dictionary
            @_dictionary ||= update_dictionary
          end

          # Mask sensitive data according to the contrast sensitive data rules.
          #
          # @param [Contrast::Api::Dtm::Activity]
          def mask activity
            return unless Contrast::Agent::Reporter.enabled?
            return unless activity

            logger.debug('Searching for sensitive data',
                         activity: activity.__id__,
                         request: activity.http_request&.uuid)
            mask_body(activity)
            mask_query_string(activity)
            mask_request_params(activity)
            mask_request_cookies(activity)
            mask_request_headers(activity)
          rescue StandardError => _e
            logger.debug('Could not mask activity!', activity: activity.__id__, request: activity.http_request&.uuid)
          end

          private

          # When called this will overwrite existing rules with those
          # in settings. Ideally we need to call this on init and when
          # the Agent receives new rules from TS response.This is called
          # from Contrast::Components::Settings.
          #
          # To make it save this is private method because if new
          # settings arrive and they might be empty as edge case,
          # this will produce dictionary with empty rules and this
          # is not desirable.
          def update_dictionary
            @_dictionary = Contrast::SETTINGS.sensitive_data_masking.rules
          end

          # Mask request body:
          #
          # @param activity [Contrast::Api::Dtm::Activity]
          # @return masked_body [String, nil]
          def mask_body activity
            return unless mask_body?

            body = activity.http_request.request_body
            return if body.nil? || body.empty?

            activity.http_request.request_body = BODY_MASK
            activity.http_request.request_body_binary = BODY_BINARY_MASK
          end

          # Mask request params.
          #
          # @param activity [Contrast::Api::Dtm::Activity]
          # @return masked_body [String, nil]
          def mask_request_params activity
            params = activity.http_request.normalized_request_params
            return unless params

            mask_with_dictionary(activity.results, params)
          end

          def mask_request_headers activity
            if activity.http_request.parsed_request_headers
              # Used normalized request_headers
              mask_with_dictionary(activity.results, activity.http_request.normalized_request_headers)
            else
              headers = activity.http_request.request_headers
              mask_field_hash(headers, activity.results)
            end
          end

          # Mask Cookies.
          #
          # @param activity [Contrast::Api::Dtm::Activity] Activity to mask
          # @return masked_values [Hash, nil]
          def mask_request_cookies activity
            cookies = activity.http_request.normalized_cookies
            return unless cookies

            mask_with_dictionary(activity.results, cookies)
          end

          # Mask request query string:
          # exp: password => sensitive to password => contrast-redacted-password
          #
          # @param activity [Contrast::Api::Dtm::Activity]
          # @return masked_query [String]
          def mask_query_string activity
            qs = activity.http_request.query_string
            return if qs.nil? || qs.empty?

            mask_field_hash(qs, activity.results) unless qs.cs__is_a?(String)
            mask_raw_query(qs, activity.results)
          end

          # Mask if the value in the passed hash are matched against dictionary
          # keyword. If the mask_attack_vector flag is set, this will also mask
          # any attack.
          #
          # @param results [Array<Contrast::Api::Dtm::AttackResults>]
          # results to match against.
          # @param hash [Hash] Normalized hash representing the key/val pair from
          # the activity's http request parameters.
          # @return nil | Protobuf::Field::FieldHash
          def mask_with_dictionary results, hash
            return if hash.nil? || hash.empty?

            hash.each do |key, val|
              match = dictionary_matcher(key)
              next unless match

              # The normalized values are paired.
              # key => Contrast::Api::Dtm::Pair (key, val<Values>).
              # try one level down
              if val.cs__respond_to?(:values)
                mask_values(key, val, results)
              else
                # Just assign keys.
                mask_hash(key, val, hash, results)
              end
            end
            hash
          end

          # Mask the values of DTM pair with attack vector condition check.
          # if the attack vector flag is set then mask the attack value.
          #
          # @param key [String] current iterable key from Protobuf::Field::FieldHash
          # pointing to Contrast::Api::Dtm::Pair<key, val>(holding the value to mask)
          # @param results [Array<Contrast::Api::Dtm::AttackResults>]
          # results to match against.
          # @param val [Contrast::Api::Dtm::Pair<Value>]
          def mask_values key, val, results
            val.values.each.with_index do |v, idx|
              # Mask the attack vector only if the flag is set.
              val.values[idx] = MASK + key.downcase if attack_vector?(results, v) && mask_attack_vector?
              # It is not attack vector and we mask it as normal.
              val.values[idx] = MASK + key.downcase unless attack_vector?(results, v)
            end
            val
          end

          # Handles the masking of Field hash with string values.
          # this case is used when called from #mask_field_hash
          # and #mask_raw_query helper methods. Since they dont
          # return values containing sub-values  (key, val<Values>).
          #
          # @param key [String] current iterable key from Protobuf::Field::FieldHash
          # @param val [String] normalized value to be matched against the results
          # and masked.
          # @param hash [Hash] Normalized hash representing the key/val pair.
          # @param results [Array<Contrast::Api::Dtm::AttackResults>]
          # results to match against.
          def mask_hash key, val, hash, results
            hash[key] = MASK + key.downcase if attack_vector?(results, val) && mask_attack_vector?
            hash[key] = MASK + key.downcase unless attack_vector?(results, val)
          end

          # Match to see if values matches input from AttackResults array.
          # If match is found and the attack result's response is any of
          # [BAP(Block At Perimeter), BLOCKED, PROBED] the return is true.
          #
          # @param results [Array<Contrast::Api::Dtm::AttackResults>]
          # results to match against.
          # @param value [String] Input to match.
          # @return true | false
          def attack_vector? results, value
            return false unless value && results

            results.each do |result|
              # Check samples Contrast::Api::Dtm::RaspRuleSample
              # is the value in sample and the response is valid?
              result.samples.any? do |sample|
                # Check user input Contrast::Api::Dtm::UserInput.
                match = sample.user_input.value == value.to_s &&
                    result.response&.name != Contrast::Agent::Reporting::ResponseType::NO_ACTION

                return match if match
              end
            end
            false
          end

          # Consult with our current settings state.
          #
          # @return true | false
          def mask_attack_vector?
            Contrast::SETTINGS.sensitive_data_masking.mask_attack_vector?
          end

          # Consult with our current settings state.
          #
          # @return true | false
          def mask_body?
            Contrast::SETTINGS.sensitive_data_masking.mask_http_body?
          end

          # Check to see if value is matched in the dictionary.
          #
          # @param value [String] Value to check.
          # @return match [String, nil] from the Dictionary, or nil.
          def dictionary_matcher value
            return unless @_dictionary

            @_dictionary.each do |rule|
              idx = rule.keywords.find_index(value.downcase)
              return rule.keywords[idx] if idx
            end
            nil
          end
        end
      end
    end
  end
end