# 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/components/scope'
require 'contrast/api/decorators/response_type'

module Contrast
  module Agent
    module Protect
      module Rule
        # This is a basic rule for Protect. It's the abstract class which all other
        # protect rules extend in order to function.
        #
        # @abstract Subclass and override {#prefilter}, {#infilter}, {#find_attacker}, {#postfilter} to implement
        class Base
          include Contrast::Components::Logger::InstanceMethods
          include Contrast::Components::Scope::InstanceMethods

          UNKNOWN_USER_INPUT = Contrast::Api::Dtm::UserInput.new.tap do |user_input|
            user_input.input_type = :UNKNOWN
          end

          BLOCKING_MODES = Set.new([
                                     Contrast::Api::Settings::ProtectionRule::Mode::BLOCK,
                                     Contrast::Api::Settings::ProtectionRule::Mode::BLOCK_AT_PERIMETER
                                   ]).cs__freeze
          POSTFILTER_MODES = Set.new([
                                       Contrast::Api::Settings::ProtectionRule::Mode::BLOCK,
                                       Contrast::Api::Settings::ProtectionRule::Mode::PERMIT,
                                       Contrast::Api::Settings::ProtectionRule::Mode::MONITOR
                                     ]).cs__freeze
          STACK_COLLECTION_RESULTS = Set.new([
                                               Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED,
                                               Contrast::Api::Dtm::AttackResult::ResponseType::MONITORED
                                             ]).cs__freeze

          attr_reader :mode

          def initialize
            ::Contrast::PROTECT.rules[rule_name] = self
            @mode = mode_from_settings
          end

          # Should return the name as it is known to Teamserver; defaults to class
          def rule_name
            cs__class.cs__name
          end

          def enabled?
            # 1. it is not enabled because protect is not enabled
            return false unless ::Contrast::AGENT.enabled?
            return false unless ::Contrast::PROTECT.enabled?

            # 2. it is not enabled because it is in the list of disabled protect rules
            return false if ::Contrast::PROTECT.rule_config&.disabled_rules&.include?(rule_name)

            # 3. it is enabled so long as its mode is not NO_ACTION
            @mode != Contrast::Api::Settings::ProtectionRule::Mode::NO_ACTION
          end

          def excluded? exclusions
            Array(exclusions).any? do |ex|
              ex.protection_rule?(rule_name)
            end
          end

          def infilter? _context
            false
          end

          # return false for rules that modify or inspect the response body
          # during postfilter
          #
          # @return [Boolean] if the rule can safely be evaluated in streaming
          #   requests
          def stream_safe?
            true
          end

          # Actions required for the rules that have to happen before the
          # application has completed its processing of the request.
          #
          # For most rules, these actions are performed within the analysis
          # engine and communicated as an input analysis result. Those that
          # require specific action need to provide that action.
          #
          # @param _context [Contrast::Agent::RequestContext] the context for
          #   the current request
          def prefilter _context; end

          # This should only ever be called directly from patched code and will
          # have a different implementation based on the rule. As such, there
          # is not parent implementation.
          #
          # @param _context [Contrast::Agent::RequestContext] the context for
          #   the current request
          # @param _match_string [String] the input that violated the rule and
          #   matched the attack detection logic
          # @param _kwargs [Hash] key-value pairs used by the rule to build a
          #   report.
          def infilter _context, _match_string, **_kwargs; end

          # Actions required for the rules that have to happen after the
          # application has completed its processing of the request.
          #
          # Any implementation here needs to account for the fact that
          # responses may be streaming and, as such, transformations of the
          # response itself may not be permissible.
          #
          # @param _context [Contrast::Agent::RequestContext] the context for
          #    the current request
          def postfilter _context; end

          # A given input, candidate_string, was determined to violate a
          # protect rule and did exploit the application, or at least made it
          # to exploitable code in the case where we blocked the attack. As
          # such, we need to build a result to report this violation to the
          # Service.
          #
          # @param context [Contrast::Agent::RequestContext] the context of the
          #   request in which this input is evaluated.
          # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the
          #   analysis of the input that was determined to be an attack
          # @param result [Contrast::Api::Dtm::AttackResult, nil] previous
          #   attack result for this rule, if one exists, in the case of
          #   multiple inputs being found to violate the protection criteria
          # @param candidate_string [String] the value of the input which may
          #   be an attack
          # @param kwargs [Hash] key - value pairs of context individual rules
          #   need to build out details to send to the Service to tell the
          #   story of the attack
          # @return [Contrast::Api::Dtm::AttackResult] the attack result from
          #   this input
          def build_attack_with_match context, ia_result, result, candidate_string, **kwargs
            result ||= build_attack_result(context)
            update_successful_attack_response(context, ia_result, result, candidate_string)
            append_sample(context, ia_result, result, candidate_string, **kwargs)

            result
          end

          # A given input, candidate_string, was determined to violate a
          # protect rule but did not exploit the application. As such, we need
          # to build a result to report this violation to the Service.
          #
          # @param context [Contrast::Agent::RequestContext] the context of the
          #   request in which this input is evaluated.
          # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the
          #   analysis of the input that was determined to be an attack
          # @param result [Contrast::Api::Dtm::AttackResult, nil] previous
          #   attack result for this rule, if one exists, in the case of
          #   multiple inputs being found to violate the protection criteria
          # @param kwargs [Hash] key - value pairs of context individual rules
          #   need to build out details to send to the Service to tell the
          #   story of the attack
          # @return [Contrast::Api::Dtm::AttackResult] the attack result from
          #   this input
          def build_attack_without_match context, ia_result, result, **kwargs
            result ||= build_attack_result(context)
            update_perimeter_attack_response(context, ia_result, result)
            append_sample(context, ia_result, result, nil, **kwargs)

            result
          end

          # Attach the given result to the current request's context to report
          # it to the Service
          #
          # @param context [Contrast::Agent::RequestContext] the context of the
          #   request in which this input is evaluated.
          # @param result [Contrast::Api::Dtm::AttackResult]
          def append_to_activity context, result
            context.activity.results << result if result
          end

          # With this we log to CEF
          #
          # @param result [Contrast::Api::Dtm::AttackResult]
          # @param attack [Symbol] the type of message we want to send
          # @param value [String] the input value we want to log
          def cef_logging result, attack = :ineffective_attack, value = nil
            sample_to_json = Contrast::Api::Dtm::RaspRuleSample.to_controlled_hash(result.samples[0])
            outcome = if result.response.cs__is_a?(Hash)
                        Contrast::Agent::Reporting::ResponseType.cs__const_get(result.response[:name])
                      else
                        Contrast::Api::Dtm::AttackResult::ResponseType.get_name_by_tag(result.response)
                      end
            input_type = extract_input_type(sample_to_json[:user_input].input_type)
            input_value = value || sample_to_json[:user_input].value
            cef_logger.send(attack, result.rule_id, outcome, input_type, input_value)
          end

          protected

          def mode_from_settings
            ::Contrast::PROTECT.rule_mode(rule_name).tap do |mode|
              logger.trace('Retrieving rule mode', rule: rule_name, mode: mode)
            end
          end

          def blocked?
            enabled? && BLOCKING_MODES.include?(mode)
          end

          # Determine if there's an exclusion that matches an item in the call
          # stack
          #
          # @return [Boolean] if an exclusion was applicable to this request
          #   for this rule
          def protect_excluded_by_code?
            exclusions = ::Contrast::SETTINGS.code_exclusions
            return false unless exclusions

            for_rule = exclusions.select { |ex| ex.protection_rule?(rule_name) }
            return false if for_rule.empty?

            stack = caller_locations
            for_rule.any? { |ex| ex.match_code?(stack) }
          end

          # By default, rules do not have to find attackers as they do not have
          # Input Analysis. Any attack for the standard rule will be evaluated
          # at execution time. As such, those rules are expected to implement
          # this custom behavior
          #
          # @param _context [Contrast::Agent::RequestContext] the context for
          #   the current request
          # @param _potential_attack_string [String] the input that may violate
          #   the rule and matched the attack detection logic
          # @param _kwargs [Hash] key-value pairs used by the rule to build a
          #   report.
          # @raise[NoMethodError] raises if subclass did not implement this method on extend
          def find_attacker _context, _potential_attack_string, **_kwargs
            raise(NoMethodError, "Rule #{ rule_name } did not implement find_attack")
          end

          def update_successful_attack_response context, ia_result, result, attack_string = nil
            case mode
            when Contrast::Api::Settings::ProtectionRule::Mode::MONITOR
              result.response = Contrast::Api::Dtm::AttackResult::ResponseType::MONITORED
            when Contrast::Api::Settings::ProtectionRule::Mode::BLOCK
              result.response = Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED
            end

            ia_result.attack_count = ia_result.attack_count + 1 if ia_result
            log_rule_matched(context, ia_result, result.response, attack_string)

            result
          end

          # @param context [Contrast::Agent::RequestContext] the context of the
          #   request in which this input is evaluated.
          # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the
          #   analysis of the input that was determined to be an attack
          # @param result [Contrast::Api::Dtm::AttackResult] previous
          #   attack result for this rule, if one exists, in the case of
          #   multiple inputs being found to violate the protection criteria
          def update_perimeter_attack_response context, ia_result, result
            if mode == Contrast::Api::Settings::ProtectionRule::Mode::BLOCK_AT_PERIMETER
              result.response = if blocked_rule?(ia_result)
                                  Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED
                                else
                                  Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED_AT_PERIMETER
                                end
              log_rule_matched(context, ia_result, result.response)
            elsif ia_result.nil? || ia_result.attack_count.zero?
              result.response = assign_reporter_response_type(ia_result)
              log_rule_probed(context, ia_result)
            end

            result
          end

          # Set up an attack result for the current rule
          #
          # @param _context [Contrast::Agent::RequestContext] the context of
          #   the current request
          # @return [Contrast::Api::Dtm::AttackResult]
          def build_attack_result _context
            result = Contrast::Api::Dtm::AttackResult.new
            result.rule_id = rule_name
            result
          end

          def append_stack sample, result
            return unless sample
            return unless STACK_COLLECTION_RESULTS.include?(result&.response)

            stack = Contrast::Utils::StackTraceUtils.build_protect_stack_array
            return unless stack

            sample.stack_trace_elements += stack
          end

          def append_sample context, ia_result, result, candidate_string, **kwargs
            return unless result

            sample = build_sample(context, ia_result, candidate_string, **kwargs)
            return unless sample

            append_stack(sample, result)

            result.samples << sample
          end

          # Override if rule can make use of the candidate string or kwargs to
          # build rasp rule sample.
          def build_sample context, ia_result, _candidate_string, **_kwargs
            build_base_sample(context, ia_result)
          end

          def build_base_sample context, ia_result
            Contrast::Api::Dtm::RaspRuleSample.build(context, ia_result)
          end

          def log_rule_matched _context, ia_result, response, _matched_string = nil
            logger.debug('A successful attack was detected',
                         rule: rule_name,
                         type: ia_result&.input_type,
                         name: ia_result&.key,
                         input: ia_result&.value,
                         result: response)
          end

          # This method returns the symbol for the enum
          #
          # @param enum [Enumerable]
          # @return [Symbol]
          def extract_input_type enum
            Contrast::Api::Dtm::UserInput::InputType.get_name_by_tag(enum)
          end

          private

          # Block At Perimeter mode has been deprecated in sqli_worth_watching_v2
          # and should be treated equivalent to Blocked mode if set
          def blocked_rule? ia_result
            [
              Contrast::Agent::Protect::Rule::Sqli::NAME,
              Contrast::Agent::Protect::Rule::NoSqli::NAME
            ].include?(ia_result&.rule_id)
          end

          def log_rule_probed _context, ia_result
            logger.debug('An unsuccessful attack was detected', rule: rule_name, type: ia_result&.input_type,
                                                                name: ia_result&.key, input: ia_result&.value)
          end

          # Some rules are reported as suspicious, rather than exploited or probed, b/c they don't actually follow
          # input tracing or other detection types that provide enough confidnece for a determination.
          #
          # @param ia_result
          # @return [Boolean]
          def suspicious_rule? ia_result
            [
              Contrast::Agent::Protect::Rule::UnsafeFileUpload::NAME,
              Contrast::Agent::Protect::Rule::Xss::NAME
            ].include?(ia_result&.rule_id)
          end

          # Handles the Response type for different Protect rules. Some rules need to report SUSPICIOUS over PROBED in
          # MONITORED mode.
          #
          # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the analysis of the input that was
          #   determined to be an attack
          def assign_reporter_response_type ia_result
            if suspicious_rule?(ia_result) && Contrast::CONTRAST_SERVICE.use_agent_communication?
              Contrast::Api::Dtm::AttackResult::ResponseType::SUSPICIOUS
            else
              Contrast::Api::Dtm::AttackResult::ResponseType::PROBED
            end
          end
        end
      end
    end
  end
end