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

require 'contrast/agent/assess/events/event_factory'
require 'contrast/agent/assess/policy/trigger_validation/trigger_validation'
require 'contrast/components/interface'
require 'contrast/utils/object_share'
require 'contrast/utils/sha256_builder'

module Contrast
  module Agent
    module Assess
      module Policy
        # A trigger method is one which can perform a dangerous action, as
        # described by the Contrast::Agent::Assess::Policy::TriggerNode class.
        # Each such method will call to this module just after invocation in
        # order to determine if the call was done safely. In those cases where
        # it was not, a Finding report is issued to the Service
        module TriggerMethod
          include Contrast::Components::Interface
          access_component :analysis, :logging

          # The level of TeamServer compliance our traces meet when in the
          # abnormal condition of being dataflow rules without routes
          MINIMUM_FINDING_VERSION = 3
          # The level of TeamServer compliance our traces meet
          CURRENT_FINDING_VERSION = 4

          class << self
            # Append the given finding to the given context to be reported when
            # the Context's activity is sent to the Service or, in the absence
            # of that Context, generate an Activity and queue it manually
            # @param finding [Contrast::Api::Dtm::Finding]
            def report_finding finding
              context = Contrast::Agent::REQUEST_TRACKER.current
              if context
                context.activity.findings << finding
              else
                activity = Contrast::Api::Dtm::Activity.new
                activity.findings << finding

                Contrast::Agent.messaging_queue.send_event_eventually(activity)
              end
              logger.debug('Finding reported',
                           rule: finding.rule_id)
            end

            # This is called from within our woven proc. It will be called as if it
            # were inline in the Rack application.
            #
            # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode]
            #   the node that applies to the method being called
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            def apply_trigger_rule trigger_node, object, ret, args
              return if trigger_node.nil?

              current_context = Contrast::Agent::REQUEST_TRACKER.current
              return unless current_context&.analyze_request? && ASSESS.enabled?

              if trigger_node.sources&.any?
                trigger_node.sources.each do |marker|
                  source = determine_source(marker, object, ret, args)
                  apply_trigger(current_context,
                                trigger_node,
                                source,
                                object,
                                ret,
                                *args)
                end
              else
                apply_trigger(current_context,
                              trigger_node,
                              nil,
                              object,
                              ret,
                              *args)
              end
            end

            def apply_eval_trigger context, trigger_node, source, object, ret, *args
              apply_trigger(context, trigger_node, source, object, ret, *args)
            end

            # This converts the source of the finding, and the events leading
            # up to it into a Finding
            #
            # @param context [Contrast::Agent::RequestContext] the current
            #   request context
            # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode]
            #   the node to direct applying this trigger event
            # @param source [Object] the source of the Trigger Event
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            # @return [Contrast::Api::Dtm::Finding, nil] the
            #   Contrast::Api::Dtm::Finding to send to TeamServer or nil if
            #   conditions were not met
            def build_finding context, trigger_node, source, object, ret, *args
              return unless Contrast::Agent::Assess::Policy::TriggerValidation.valid?(trigger_node, object, ret, args)

              request = context.request
              env = request.env
              return if defined?(ActionController::Live) &&
                  env &&
                  env['action_controller.instance'].cs__class.included_modules.include?(ActionController::Live)

              finding = Contrast::Api::Dtm::Finding.new
              finding.rule_id = Contrast::Utils::StringUtils.protobuf_safe_string(trigger_node.rule_id)
              build_from_source(finding, source)
              trigger_event = Contrast::Agent::Assess::Events::EventFactory.build(trigger_node, source, object, ret, args).to_dtm_event
              finding.events << trigger_event
              build_hash(finding, source)
              finding.routes << context.route if context.route
              finding.version = determine_compliance_version(finding)
              logger.trace('Finding created',
                           node_id: trigger_node.id,
                           source_id: source.__id__,
                           rule: trigger_node.rule_id)
              report_finding(finding)
            rescue StandardError => e
              logger.error('Unable to build a finding', e, rule: trigger_node.rule_id, node_id: trigger_node.id)
            end

            private

            # This is our method that actually checks the taint on the object
            # our trigger_node targets.
            #
            # @param context [Contrast::Agent::RequestContext] the current
            #   request context
            # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode]
            #   the node to direct applying this trigger event
            # @param source [Object] the source of the Trigger Event
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            def apply_trigger context, trigger_node, source, object, ret, *args
              return unless context && trigger_node
              return if trigger_node.rule_disabled?
              return if trigger_node.dataflow? && source.nil?

              if trigger_node.regexp_rule?
                apply_regexp_rule(context, trigger_node, source, object, ret, *args)
              elsif trigger_node.custom_trigger?
                trigger_node.apply_custom_trigger(context, trigger_node, source, object, ret, *args)
              elsif trigger_node.dataflow?
                apply_dataflow_rule(context, trigger_node, source, object, ret, *args)
              else # trigger rule - just calling the method is dangerous
                build_finding(context, trigger_node, source, object, ret, *args)
              end
            rescue StandardError => e
              logger.warn('Unable to apply trigger', e, node_id: trigger_node.id)
            end

            # Given the marker from the trigger_node (the pointer indicating
            # the entity from which the taint originated), return the entity on
            # which this trigger needs to operate.
            #
            # In an effort to speed up this lookup, we've changed the marker
            # for parameters to be implicit - if it is not a return or an
            # object, it must be a parameter, which we can reference either by
            # index or by name.
            #
            # @param marker [String] the source marker that indicates which
            #   Object
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            # @return [Object] the literal object that this Trigger Event
            #   verifies
            def determine_source marker, object, ret, args
              case marker
              when Contrast::Utils::ObjectShare::RETURN_KEY
                ret
              when Contrast::Utils::ObjectShare::OBJECT_KEY
                object
              else # 'P'
                if marker.is_a?(Integer)
                  args[marker]
                else
                  arg = nil
                  args.each do |search|
                    next unless search.is_a?(Hash)

                    arg = search[marker]
                    break if arg
                  end
                  arg
                end
              end
            end

            # This is our method that actually checks the taint on the object
            # our trigger_node targets for our Regexp based rules.
            #
            # @param context [Contrast::Agent::RequestContext] the current
            #   request context
            # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode]
            #   the node to direct applying this trigger event
            # @param source [Object] the source of the Trigger Event
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            def apply_regexp_rule context, trigger_node, source, object, ret, *args
              return unless source.is_a?(String)
              return if trigger_node.good_value && source.match?(trigger_node.good_value)
              return if trigger_node.bad_value && source !~ trigger_node.bad_value

              build_finding(context, trigger_node, source, object, ret, *args)
            end

            # This is our method that actually checks the taint on the object
            # our trigger_node targets for our Dataflow based rules.
            #
            # @param context [Contrast::Agent::RequestContext] the current
            #   request context
            # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode]
            #   the node to direct applying this trigger event
            # @param source [Object] the source of the Trigger Event
            # @param object [Object] the Object on which the method was invoked
            # @param ret [Object] the Return of the invoked method
            # @param args [Array<Object>] the Arguments with which the method
            #   was invoked
            def apply_dataflow_rule context, trigger_node, source, object, ret, *args
              return unless source

              if Contrast::Agent::Assess::Tracker.trackable?(source)
                return unless Contrast::Agent::Assess::Tracker.tracked?(source)
                return unless trigger_node.violated?(source)

                build_finding(context, trigger_node, source, object, ret, *args)
              elsif Contrast::Utils::DuckUtils.iterable_hash?(source)
                source.each_pair do |key, value|
                  apply_dataflow_rule(context, trigger_node, key, object, ret, *args)
                  apply_dataflow_rule(context, trigger_node, value, object, ret, *args)
                end
              elsif Contrast::Utils::DuckUtils.iterable_enumerable?(source)
                source.each do |value|
                  apply_dataflow_rule(context, trigger_node, value, object, ret, *args)
                end
              else
                logger.debug('Trigger source is untrackable. Unable to inspect.',
                             node_id: trigger_node.id,
                             source_id: source.__id__,
                             source_type: source.cs__class.to_s,
                             frozen: source.cs__frozen?)
                logger.trace(source.to_s[0..99])
              end
            end

            def build_from_source finding, source
              return unless source
              return unless Contrast::Agent::Assess::Tracker.trackable?(source)

              properties = Contrast::Agent::Assess::Tracker.properties(source)
              return unless properties

              build_events finding, properties.event if properties.event

              # Google::Protobuf::Map doesn't support merge!, so we have to do this
              # long form
              source_props = properties.properties
              return unless source_props

              source_props.each_pair do |key, value|
                key = Contrast::Utils::StringUtils.force_utf8(key)
                finding.properties[key] = Contrast::Utils::StringUtils.force_utf8(value)
              end
            end

            def build_events finding, event
              return unless event

              event.parent_events&.each do |parent_event|
                build_events(finding, parent_event)
              end
              # events could technically be nil, but we would have failed
              # the rule check before getting here. not worth the nil check
              finding.events << event.to_dtm_event
            end

            def build_hash finding, source
              hash_code = Contrast::Utils::HashDigest.generate_event_hash(finding, source)
              finding.hash_code = Contrast::Utils::StringUtils.force_utf8(hash_code)
              finding.preflight = Contrast::Utils::PreflightUtil.create_preflight(finding)
            end

            # Because our APIs are not versioned, TeamServer relies on a field,
            # version, to tell it what, if any, validation it can preform on
            # the findings we send it. Examine the finding and determine the
            # level to which it conforms.
            #
            # @param finding [Contrast::Api::Dtm::Finding]
            # @return [int] the version of compliance
            def determine_compliance_version finding
              return MINIMUM_FINDING_VERSION unless finding
              # as routes are the only variable between findings, in the case
              # where we couldn't determine one, any finding with a route is at
              # maximum compliance
              return CURRENT_FINDING_VERSION if finding.routes.any?
              # any finding without events is not of a dataflow type and
              # therefore at maximum compliance
              return CURRENT_FINDING_VERSION unless finding.events.any?

              MINIMUM_FINDING_VERSION
            end
          end
        end
      end
    end
  end
end