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

require 'contrast/agent/protect/rule/base'
require 'contrast/agent/reporting/details/untrusted_deserialization_details'
require 'contrast/components/logger'

module Contrast
  module Agent
    module Protect
      module Rule
        # This class handles our implementation of the Untrusted
        # Deserialization Protect rule.
        class Deserialization < Contrast::Agent::Protect::Rule::Base
          # The TeamServer recognized name of this rule
          include Contrast::Components::Logger::InstanceMethods
          # Used to name this input since input analysis isn't done for this
          # rule
          INPUT_NAME = 'Serialized Gadget'

          NAME = 'untrusted-deserialization'

          # The rule specific reason for raising a security exception.
          BLOCK_MESSAGE = 'Untrusted Deserialization rule triggered. Deserialization blocked.'

          # Gadgets that map to ERB modules
          ERB_GADGETS = %W[object:ERB o:\bERB].cs__freeze

          # Gadgets that map to ActionDispatch modules
          ACTION_DISPATCH_GADGETS = %w[
            object:ActionDispatch::Routing::RouteSet::NamedRouteCollection
            o:\bActionDispatch::Routing::RouteSet::NamedRouteCollection
          ].cs__freeze

          # Gadgets that map to Arel Modules
          AREL_GADGETS = %w[string:Arel::Nodes::SqlLiteral object:Arel::Nodes o:\bArel::Nodes].cs__freeze

          # Used to indicate to TeamServer the gadget is an ERB module
          ERB = 'ERB'
          # Used to indicate to TeamServer the gadget is an ActionDispatch
          # module
          DISPATCH = 'ActionDispatch'
          # Used to indicate to TeamServer the gadget is an Arel module
          AREL = 'Arel'

          # Return the TeamServer understood id / name of this rule.
          # @return [String] the TeamServer understood id / name of this rule.
          def rule_name
            NAME
          end

          # Return the specific blocking message for this rule.
          # @return [String] the reason for the raised security exception.
          def block_message
            BLOCK_MESSAGE
          end

          # Per the spec, this rule applies regardless of input. Only the mode
          # of the rule and code exclusions apply at this point.
          # @return [Boolean] should the rule apply to this call.
          def infilter? context
            return false unless enabled?
            return false if protect_excluded_by_url?(rule_name, context.request.path)

            true
          end

          # Determine if the input being deserialized is an attack and take
          # appropriate actions.
          # @param context [Contrast::Agent::RequestContext] the request
          #   context in which this attack is occurring.
          # @param serialized_input [String] the string being deserialized.
          # @raise [Contrast::SecurityException] if attack detected while in
          #   block mode.
          def infilter context, serialized_input
            return unless infilter?(context)

            gadget = find_gadget(serialized_input)
            # If there isn't a gadget, this isn't an attack, so we have nothing
            # to do here. Let the application carry on business as usual.
            return unless gadget

            ia_result = build_evaluation(serialized_input)
            kwargs = { GADGET_TYPE: gadget }
            result = build_attack_with_match(context, ia_result, nil, serialized_input, **kwargs)
            append_to_activity(context, result)

            cef_logging(result, :successful_attack)

            raise(Contrast::SecurityException.new(self, block_message)) if blocked?
          end

          # Determine if the issued command was called while we're
          # deserializing. If we are, treat this as an attack.
          # @param gadget_command [String] the command being executed during
          #   the deserialization call.
          # @raise [Contrast::SecurityException] if attack detected while in
          #   block mode.
          def check_command_scope gadget_command
            # If we're within 'deserialization scope', then we've got a
            # deserialization method in our call stack.
            return unless in_deserialization_scope?

            context = Contrast::Agent::REQUEST_TRACKER.current
            kwargs = { COMMAND_SCOPE: true }
            ia_result = build_evaluation(gadget_command)
            result = build_attack_with_match(context, ia_result, nil, gadget_command, **kwargs)
            append_to_activity(context, result)
            cef_logging(result, :successful_attack, value: gadget_command)
            raise(Contrast::SecurityException.new(self, BLOCK_MESSAGE)) if blocked?
          end

          protected

          # Build the RaspRuleSample for the detected Deserialization attack.
          # @param context [Contrast::Agent::RequestContext] the request
          #   context in which this attack is occurring.
          # @param input_analysis_result [Contrast::Agent::Reporting::InputAnalysis]
          #   the result of the analysis done by this rule.
          # @param _candidate_string [nil] unused.
          # @param kwargs [Hash, nil] Hash of inputs used by this rule to flesh
          #   out the report to TeamServer in order to provide specific
          #   information for this rule.
          # @return [Contrast::Api::Dtm::RaspRuleSample] the information needed
          #   to render this attack event in TeamServer.
          def build_sample context, input_analysis_result, _candidate_string, **kwargs
            sample = build_base_sample(context, input_analysis_result)
            sample.details = Contrast::Agent::Reporting::Details::UntrustedDeserializationDetails.new

            deserializer = Contrast::Utils::StringUtils.protobuf_safe_string(kwargs[:GADGET_TYPE])
            sample.details.deserializer = deserializer

            command = !!kwargs[:COMMAND_SCOPE]
            sample.details.cmd = command

            sample
          end

          private

          # We know that this attack happened, so the result is always matched
          # and the level is always critical. Only variable is the Gadget
          # supplied by the attacker.
          # @param gadget_string [String] the input to be deserialized in which
          #   the gadget exists or the command that resulted from deserializing
          #   an input not detected in the initial infilter.
          # @return [Contrast::Agent::Reporting::InputAnalysisResult] the result
          #   of the analysis done by this rule.
          def build_evaluation gadget_string
            ia_result = Contrast::Agent::Reporting::InputAnalysisResult.new
            ia_result.rule_id = rule_name
            ia_result.input_type = :UNKNOWN
            ia_result.key = INPUT_NAME
            ia_result.value = Contrast::Utils::StringUtils.protobuf_safe_string(gadget_string)
            ia_result
          end

          # Find the gadget within the serialized input, if such a gadget
          # exists.
          # @param serialized_input [String] the string being deserialized in
          #   which the gadget may exist.
          # @return [String, nil] the gadget, if found.
          def find_gadget serialized_input
            if ERB_GADGETS.any? { |gadget| serialized_input.index(gadget) }
              ERB
            elsif ACTION_DISPATCH_GADGETS.any? { |gadget| serialized_input.index(gadget) }
              DISPATCH
            elsif AREL_GADGETS.any? { |gadget| serialized_input.index(gadget) }
              AREL
            end
          end
        end
      end
    end
  end
end