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

cs__scoped_require 'contrast/utils/prevent_serialization'

module Contrast
  module Agent
    module Assess
      # This class holds the data about an event in the application
      # We'll use it to build an event that TeamServer can consume if
      # the object to which this event belongs ends in a trigger.
      class ContrastEvent
        include Contrast::Utils::PreventSerialization

        class << self
          def safe_args_representation args
            return nil unless args
            return Contrast::Utils::ObjectShare::EMPTY_ARRAY if args.empty?

            rep = []
            args.each do |arg|
              # We have to handle named args
              rep <<  if arg.is_a?(Hash)
                        safe_arg_hash_representation(arg)
                      else
                        arg.cs__inspect
                      end
            end
            rep
          end

          def safe_arg_hash_representation hash
            # since this is the named hash for arguments, only the value is
            # suspect here
            hash.transform_values(&:cs__inspect)
          end

          # if given an object that can be duped, duplicate it. otherwise just
          # return the original object. swallow all exceptions from
          # non-duplicable things.
          #
          # we can't just check respond_to? though b/c dup exists on the
          # base Object class
          def safe_dup original
            return nil unless original

            begin
              original.dup
            rescue StandardError
              original
            end
          end
        end

        attr_accessor :source_name
        attr_reader :event_id, :source_type, :parent_ids

        # We need this to track the parent id's of events to build up a flow
        # chart of the finding
        @atomic_id = 0
        @atomic_mutex = Mutex.new
        def self.next_atomic_id
          @atomic_mutex.synchronize do
            begin
              @atomic_id += 1
              # Rollover things
            rescue StandardError
              @atomic_id = 1
            end
          end
        end

        def initialize policy_node, tagged, object, ret, args, invoked = 0, source_type = nil, source_name = nil
          @caller = caller_locations(get_call_start(policy_node, invoked), 10)
          @policy_node = policy_node
          @time = Contrast::Utils::Timer.now_ms
          @thread = Thread.current.object_id
          @source_type = source_type
          @source_name = source_name

          # These methods rely on the above being set. Don't move them!
          @event_id = Contrast::Agent::Assess::ContrastEvent.next_atomic_id
          @parent_ids = find_parent_ids(policy_node, object, ret, args)
          snapshot(tagged, object, ret, args)
        end

        # Parent IDs are the event ids of all the sources of this event which
        # were tracked prior to this event occurring
        def find_parent_ids policy_node, object, ret, args
          return if policy_node.is_a?(Contrast::Agent::Assess::Policy::SourceNode)

          mapped = policy_node.sources.map do |source|
            value_of_source(source, object, ret, args)
          end
          selected = mapped.select do |source|
            source &&
                Contrast::Utils::DuckUtils.quacks_to?(source, :cs__properties) &&
                source.cs__properties.events &&
                source.cs__properties.events.last
          end
          selected.map do |source|
            source.cs__properties.events.last.event_id
          end
        end

        def snapshot tagged, object, ret, args
          target = @policy_node.target
          case target
            # If the target is nil, this rule was violated simply by a method
            # being called. We'll save all the information, but nothing will be
            # marked up, as nothing need be tracked
          when nil
            @object = object.cs__inspect
            @args = cs__class.safe_args_representation(args)
            @ret = ret.cs__inspect
            # If the target is O, then we dup the O and safely represent the rest
          when Contrast::Utils::ObjectShare::OBJECT_KEY
            @object = cs__class.safe_dup(tagged)
            @args = cs__class.safe_args_representation(args)
            @ret = ret.cs__inspect
            # If the target is R, then we dup the R and safely represent the rest
          when Contrast::Utils::ObjectShare::RETURN_KEY
            @object = object.cs__inspect
            @args = cs__class.safe_args_representation(args)
            @ret = cs__class.safe_dup(tagged)
            # If the target is P*, then we need to dup things a differently. We
            # need to find the true target inside so that we can mark it up
            # later, but the other args should be represented as their safe form.
          else
            @object = object.cs__inspect
            @args = cs__class.safe_args_representation(args)
            @ret = ret.cs__inspect
            save_target_arg(target, tagged)
          end
        end

        # I know we're creating an extra string here since we replace the safe
        # one w/ a dup, but good enough for now. Trying not to make this too
        # complicated. - HM 8/8/19
        def save_target_arg target, tagged
          if target.is_a?(Integer)
            @args[target] = cs__class.safe_dup(tagged)
            return
          end

          @args.each_with_index do |search, index|
            next unless search.is_a?(Hash)
            next unless search[target]

            search[target] = cs__class.safe_dup(tagged)
            @highlight = index
            break
          end
        end

        def value_of_source source, object, ret, args
          case source
          when Contrast::Utils::ObjectShare::OBJECT_KEY
            object
          when Contrast::Utils::ObjectShare::RETURN_KEY
            ret
          else
            if source.is_a?(Integer)
              args[source]
            else
              args.each do |search|
                next unless search.is_a?(Hash)

                s = search[source]
                return s if s
              end
            end
          end
        end

        # each policy_node has a certain number of levels down it calls
        # before getting here. since we know them, we can skip
        # right to the part of the stack we care about.
        #
        # Note: if our callstack changes, this number has to change
        def get_call_start policy_node, invoked
          # TODO: RUBY-440 audit these numbers to get stacktraces to render
          #   properly
          base = case policy_node
                 when Contrast::Agent::Assess::Policy::SourceNode
                   6
                 when Contrast::Agent::Assess::Policy::PropagationNode
                   7
                 when Contrast::Agent::Assess::Policy::TriggerNode
                   7
                 else
                   2
                 end
          base + invoked
        end

        # Convert this event into a DTM that TeamServer can consume
        def to_dtm_event
          event = Contrast::Api::Dtm::TraceEvent.new

          # Figure out what the target of this event was. It's a little
          # annoying for us since P can be named (thanks, Ruby) where
          # as for everyone else it is idx based.
          taint_target = determine_taint_target(event)

          event.type = @policy_node.node_type
          event.action = @policy_node.build_action
          event.timestamp_ms = @time.to_i
          event.thread = Contrast::Utils::StringUtils.force_utf8(@thread)
          truncate_obj = Contrast::Utils::ObjectShare::OBJECT_KEY != taint_target
          event.object = build_event_object(@object, truncate_obj)
          truncate_ret = Contrast::Utils::ObjectShare::RETURN_KEY != taint_target
          event.ret = build_event_object(@ret, truncate_ret)
          built_args = build_event_args(taint_target)
          built_args.each do |arg|
            event.args << arg
          end
          taint_ranges = find_taint_ranges(@object, @args, @ret, taint_target)
          taint_ranges.each do |range|
            event.taint_ranges << range
          end

          # We delayed doing this as long as possible b/c it's expensive
          stack = Contrast::Utils::StackTraceUtils.to_dtm_stack(
              stack_locations: @caller,
              rasp_element: false)
          stack.each do |frame|
            event.stack << frame
          end

          event.field_name = Contrast::Utils::StringUtils.force_utf8(source_name)

          event_source_dtm = build_event_source_dtm
          event.event_sources << event_source_dtm if event_source_dtm

          event.object_id = event_id.to_i
          @parent_ids&.each do |id|
            parent = Contrast::Api::Dtm::ParentObjectId.new
            parent.id = id.to_i
            event.parent_object_ids << parent
          end

          # not to be confused w/ the partial signature
          build_complete_signature(event)

          event
        end

        def forced_source_type
          @_forced_source_type ||= Contrast::Utils::StringUtils.force_utf8(source_type)
        end

        def forced_source_name
          @_forced_source_name ||= Contrast::Utils::StringUtils.force_utf8(source_name)
        end

        # Probably only for source events, but we'll go
        # with source_type instead. java & .net support source_type
        # in propagation events, so we'll future proof this
        def build_event_source_dtm
          # You can have a source w/o a name, but not w/o a type
          return unless source_type

          dtm = Contrast::Api::Dtm::TraceEventSource.new
          dtm.type = forced_source_type
          dtm.name = forced_source_name
          dtm
        end

        # We're not going to build the signature string here, b/c we have all
        # the composite pieces of it. Instead, we're going to let TeamServer
        # render this for us.
        def build_complete_signature event
          signature = Contrast::Api::Dtm::TraceEventSignature.new
          event.signature = signature
          return_type = @ret ? @ret.cs__class.name : Contrast::Utils::ObjectShare::NIL_STRING
          signature.return_type = Contrast::Utils::StringUtils.force_utf8(return_type)
          signature.class_name = Contrast::Utils::StringUtils.force_utf8(@policy_node.class_name)
          signature.method_name = Contrast::Utils::StringUtils.force_utf8(@policy_node.method_name)
          if @args
            @args&.each do |arg|
              arg_type = arg ? arg.cs__class.name : Contrast::Utils::ObjectShare::NIL_STRING
              signature.arg_types << Contrast::Utils::StringUtils.force_utf8(arg_type)
            end
          end
          signature.constructor = @policy_node.method_name == :new
          # if there's a ret, then this method isn't nil. not 100% full proof since you can
          # return nil, but this is the best we've got currently.
          signature.void_method = @ret.nil?
          # 8 is STATIC in Java... we have to placate them for now
          # it has been requested that flags be removed since it isn't used
          signature.flags = 8 unless @policy_node.instance_method?
        end

        # Wrapper around build_event_object for the args array. Handles
        # tainting the correct argument.
        def build_event_args taint_target
          event_args = []
          @args.each_index do |idx|
            truncate_arg = taint_target != idx
            event_arg = build_event_object(@args[idx], truncate_arg)
            event_args << event_arg
          end
          event_args
        end

        # Build the event object. We were originally going to include taint on
        # each one, but TS doesn't accept / use that, so it is a waste of time.
        #
        # We'll truncate any object that isn't important to the taint ranges of
        # this event, so that we don't murder TeamServer by, for instance,
        # hypothetically sending the entire rendered HTML page >_>  <_<  >_>
        ELLIPSIS = '...'
        UNTRUNCATED_PORTION_LENGTH = 25
        TRUNCATION_LENGTH = (UNTRUNCATED_PORTION_LENGTH * 2) + 3 # ELLIPSIS length
        def build_event_object object, truncate
          event_object = Contrast::Api::Dtm::TraceEventObject.new
          obj_string = Contrast::Utils::StringUtils.force_utf8(object)
          if truncate && Contrast::Utils::StringUtils.ret_length(obj_string) > TRUNCATION_LENGTH
            tmp = []
            tmp << obj_string[0, UNTRUNCATED_PORTION_LENGTH]
            tmp << ELLIPSIS
            tmp << obj_string[
                obj_string.length - UNTRUNCATED_PORTION_LENGTH,
                UNTRUNCATED_PORTION_LENGTH]
            obj_string = tmp.join
          end
          event_object.value = Base64.encode64(obj_string)
          event_object.tracked = Contrast::Utils::Assess::TrackingUtil.tracked?(object)
          event_object
        end

        # We have to do a little work to figure out what our TS appropriate
        # target is. To break this down, the logic is as follows:
        # 1) If my policy_node has a target, work on targets. Else, work on sources.
        #    Per TS law, each policy_node must have at least a source or a target.
        #    The only type of policy_node w/o targets is a Trigger, but that may
        #    change.
        # 2) If I have a highlight, it means that I have a P target that is
        #    not in integer form (it was a named / keyword type for which I had
        #    to find the index). I need to address this so that TS can process
        #    it.
        # 3) I'll set the event's source and target to TS values.
        # 4) Return the highlight or the first source/target as the taint
        #    target.
        def determine_taint_target event
          if @policy_node&.targets&.any?
            event.source = @policy_node.source_string if @policy_node.source_string
            event.target = if @highlight
                             "P#{ @highlight }"
                           else
                             @policy_node.target_string
                           end
            @highlight || @policy_node.targets[0]
          elsif @policy_node&.sources&.any?
            event.source = if @highlight
                             "P#{ @highlight }"
                           else
                             @policy_node.source_string
                           end
            event.target = @policy_node.target_string if @policy_node.target_string
            @highlight || @policy_node.sources[0]
          end
        end

        # TeamServer only supports one object's taint ranges at a time.
        # We'll find the taint ranges for the target and return those
        def find_taint_ranges object, args, ret, taint_target
          # If there's no taint_target, this isn't a dataflow trace, but a
          # trigger one
          return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless taint_target

          properties = case taint_target
                       when Contrast::Utils::ObjectShare::OBJECT_KEY
                         object.cs__properties
                       when Contrast::Utils::ObjectShare::RETURN_KEY
                         ret.cs__properties
                       else
                         target = args[taint_target]
                         if target.is_a?(Hash)
                           if @policy_node&.targets&.any?
                             target[@policy_node.targets[0]].cs__properties
                           else
                             target[@policy_node.sources[0]].cs__properties
                           end
                         else
                           target.cs__properties
                         end
                       end

          return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless properties.tracked?

          properties.tags_to_dtm
        end
      end
    end
  end
end