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

require 'contrast/utils/timer'
require 'contrast/agent/request'
require 'contrast/agent/response'
require 'contrast/agent/inventory/database_config'
require 'contrast/components/logger'
require 'contrast/components/scope'

module Contrast
  module Agent
    # This class acts to encapsulate information about the currently executed request, making it available to the Agent
    # for the duration of the request in a standardized and normalized format which the Agent understands.
    #
    # @attr_reader timer [Contrast::Utils::Timer] when the context was created
    # @attr_reader logging_hash [Hash] context used to log the request
    # @attr_reader speedracer_input_analysis [Contrast::Api::Settings::InputAnalysis] the protect input analysis of
    #   sources on this request
    # @attr_reader request [Contrast::Agent::Request] our wrapper around the Rack::Request for this context
    # @attr_reader response [Contrast::Agent::Response] our wrapper aroudn the Rack::Response or Array for this context,
    #   only available after the application has finished its processing
    # @attr_reader activity [Contrast::Api::Dtm::Activity] the application activity found in this request
    # @attr_reader server_activity [Contrast::Api::Dtm::ServerActivity] the server activity found in this request
    # @attr_reader route [Contrast::Api::Dtm::RouteCoverage] the route, used for findings, of this request
    # @attr_reader observed_route [Contrast::Api::Dtm::ObservedRoute] the route, used for coverage, of this request
    class RequestContext
      include Contrast::Components::Logger::InstanceMethods
      include Contrast::Components::Scope::InstanceMethods

      EMPTY_INPUT_ANALYSIS_PB = Contrast::Api::Settings::InputAnalysis.new

      attr_reader :activity, :logging_hash, :observed_route, :request, :response, :route, :speedracer_input_analysis,
                  :server_activity, :timer

      def initialize rack_request, app_loaded = true
        with_contrast_scope do
          # all requests get a timer and hash
          @timer = Contrast::Utils::Timer.new
          @logging_hash = { request_id: __id__ }

          # instantiate helper for request and response
          @request = Contrast::Agent::Request.new(rack_request)

          @activity = Contrast::Api::Dtm::Activity.new
          @activity.http_request = request.dtm

          @server_activity = Contrast::Api::Dtm::ServerActivity.new

          @observed_route = Contrast::Api::Dtm::ObservedRoute.new

          # build analyzer
          @do_not_track = false
          @speedracer_input_analysis = EMPTY_INPUT_ANALYSIS_PB
          speedracer_input_analysis.request = request

          # flag to indicate whether the app is fully loaded
          @app_loaded = !!app_loaded

          # generic holder for properties that can be set throughout this request
          @_properties = {}

          if ::Contrast::ASSESS.enabled?
            @sample_req, @sample_res = Contrast::Utils::Assess::SamplingUtil.instance.sample?(@request)
          end

          append_route_coverage(Contrast::Agent.framework_manager.get_route_dtm(@request))
        end
      end

      def app_loaded?
        @app_loaded
      end

      def analyze_request?
        analyze_request_assess? || analyze_req_res_protect?
      end

      def analyze_response?
        analyze_response_assess? || analyze_req_res_protect?
      end

      def analyze_req_res_protect?
        ::Contrast::PROTECT.enabled?
      end

      def analyze_request_assess?
        return false unless analyze_req_res_assess?

        @sample_req
      end

      def analyze_response_assess?
        return false unless analyze_req_res_assess?

        @sample_res &&= ::Contrast::ASSESS.scan_response?
      end

      def analyze_req_res_assess?
        ::Contrast::ASSESS.enabled?
      end

      # Convert the discovered route for this request to appropriate forms and disseminate it to those locations
      # where it is necessary for our route coverage and finding vulnerability discovery features to function.
      #
      # @param route [Contrast::Api::Dtm::RouteCoverage, nil] the route of the current request, as determined from the
      #   framework
      def append_route_coverage route
        return unless route

        # For our findings
        @route = route

        # For SR findings
        @activity.routes << route

        # For TS routes
        @observed_route.signature  = route.route
        @observed_route.verb       = route.verb
        @observed_route.url        = route.url if route.url
        @request.route = route
        @request.observed_route = @observed_route
      end

      # Collect the results for the given rule with the given action
      #
      # @param rule [String] the id of the rule to which the results apply
      # @param response_type [Symbol] the result of the response, matching a value of
      #   Contrast::Api::Dtm::AttackResult::ResponseType
      # @return [Array<Contrast::Api::Dtm::AttackResult>]
      def results_for rule, response_type = nil
        if response_type.nil?
          activity.results.select { |r| r.rule_id == rule }
        else
          activity.results.select { |r| r.rule_id == rule && r.response == response_type }
        end
      end

      def service_extract_request
        return false unless ::Contrast::AGENT.enabled?
        return false unless ::Contrast::PROTECT.enabled?
        return false if @do_not_track

        service_response = Contrast::Agent.messaging_queue.send_event_immediately(@activity.http_request)
        return false unless service_response

        handle_protect_state(service_response)
        ia = service_response.input_analysis
        if ia
          if logger.trace?
            logger.trace('Analysis from Contrast Service', evaluations: ia.results.length)
            logger.trace('Results', input_analysis: ia.inspect)
          end
          @speedracer_input_analysis = ia
          speedracer_input_analysis.request = request
        else
          logger.trace('Analysis from Contrast Service was empty.')
          false
        end
      rescue Contrast::SecurityException => e
        raise e
      rescue StandardError => e
        logger.warn('Unable to extract Contrast Service information from request', e)
        false
      end

      # NOTE: this method is only used as a backstop if Speedracer sends Input Evaluations when the protect state
      # indicates a security exception should be thrown. This method ensures that the attack reports are generated.
      # Normally these should be generated on Speedracer for any attacks detected during prefilter.
      #
      # @param agent_settings [Contrast::Api::Settings::AgentSettings]
      def handle_protect_state agent_settings
        return unless agent_settings&.protect_state

        state = agent_settings.protect_state
        @uuid = state.uuid
        @do_not_track = true unless state.track_request
        return unless state.security_exception

        # If Contrast Service has NOT handled the input analysis, handle them here
        build_attack_results(agent_settings)
        logger.debug('Contrast Service said to block this request')
        raise Contrast::SecurityException.new(nil, (state.security_message || 'Blocking suspicious behavior'))
      end

      # append anything we've learned to the request seen message this is the sum-total of all inventory information
      # that has been accumulated since the last request
      def extract_after rack_response
        @response = Contrast::Agent::Response.new(rack_response)
        activity.http_response = @response.dtm if @sample_res
      rescue StandardError => e
        logger.error('Unable to extract information after request', e)
      end

      def add_property key, value
        @_properties[key] = value
      end

      def get_property key
        @_properties[key]
      end

      def reset_activity
        @activity = Contrast::Api::Dtm::Activity.new(http_request: request.dtm)
        @server_activity = Contrast::Api::Dtm::ServerActivity.new
        @observed_route = Contrast::Api::Dtm::ObservedRoute.new
      end

      private

      # Generate attack results directly from any evaluations on the agent settings object.
      #
      # @param agent_settings [Contrast::Api::Settings::AgentSettings]
      def build_attack_results agent_settings
        return unless agent_settings&.input_analysis&.results&.any?

        attack_results_by_rule = {}
        agent_settings.input_analysis.results.each do |ia_result|
          rule_id = ia_result.rule_id
          rule = ::Contrast::PROTECT.rule(rule_id)
          next unless rule

          if logger.debug?
            logger.debug('Building attack result from Contrast Service input analysis result',
                         result: ia_result.inspect)
          end

          attack_result = if rule.mode == :BLOCK
                            # special case for rules (like reflected xss) that used to have an infilter / block mode
                            # but now are just block at perimeter
                            rule.build_attack_with_match(self, ia_result, attack_results_by_rule[rule_id],
                                                         ia_result.value)
                          else
                            rule.build_attack_without_match(self, ia_result, attack_results_by_rule[rule_id])
                          end
          attack_results_by_rule[rule_id] = attack_result
        end

        attack_results_by_rule.each_pair do |_, attack_result|
          logger.info('Blocking attack result', rule: attack_result.rule_id)
          activity.results << attack_result
        end
      end
    end
  end
end