# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/reporting/reporting_events/finding_event_object' require 'contrast/agent/reporting/reporting_events/finding_event_parent_object' require 'contrast/agent/reporting/reporting_events/finding_event_property' require 'contrast/agent/reporting/reporting_events/finding_event_signature' require 'contrast/agent/reporting/reporting_events/finding_event_stack' require 'contrast/agent/reporting/reporting_events/finding_event_taint_range' require 'contrast/agent/reporting/reporting_events/finding_event_source' require 'contrast/agent/assess/contrast_object' require 'contrast/utils/assess/tracking_util' require 'contrast/utils/class_util' require 'contrast/utils/duck_utils' require 'contrast/utils/object_share' require 'contrast/utils/stack_trace_utils' require 'contrast/utils/string_utils' require 'contrast/utils/timer' require 'contrast/agent/reporting/reporting_events/reportable_hash' module Contrast module Agent module Reporting # This is the new FindingEvent class which will include all the needed information for the new reporting system # to relay this information in the Finding/Trace messages. These FindingEvents are used by TeamServer to # construct the vulnerability information for the assess feature. They represent the operation the application # underwent that transformed data during the dataflow. class FindingEvent < Contrast::Agent::Reporting::ReportableHash # @return [Symbol] what the event did; CREATION, A2O, A2P, A2A, A2R, O2A, O2O, O2P, O2R, P2A, P2O, P2P, P2R, # TAG, TRIGGER. attr_reader :action # @return [Array] the arguments passed to the method. attr_reader :reportable_args # @return [Array] the safe representation of the Arguments with # which the method was invoked attr_reader :args # @return [nil] unused. attr_reader :code # @return [Integer] the id of this event. attr_reader :event_id # @return [Array] the source of taint attr_reader :event_sources # @return [String, nil] attr_reader :source_type # @return [String, nil] attr_reader :source_name # @return [nil] unused. attr_reader :field_name # @return [Contrast::Agent::Reporting::FindingEventObject] the object this method was invoked on. attr_reader :reportable_object # @return [Contrast::Agent::Request, nil] our wrapper around the Rack::Request for this context attr_reader :request # @return [Contrast::Agent::Assess::ContrastObject] the safe representation of the Object on which the method # was invoked attr_reader :object # @return [Array] the ids of all the events directly # preceding this attr_reader :parent_object_ids # @return [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event. attr_reader :policy_node # @return [Array] attr_reader :properties # @return [Contrast::Agent::Reporting::FindingEventObject] the return of the method. attr_reader :reportable_ret # @return [Contrast::Agent::Assess::ContrastObject] the safe representation of the Return of the invoked method attr_reader :ret # @return [Contrast::Agent::Reporting::FindingEventSignature] the signature of the method. attr_reader :signature # @return [String] the source of the taint from the method; ^(O|R|P\d+)$ attr_reader :source # @return [Array] attr_reader :stack # @return [String] comma separated list of descriptions of what's happened to the data attr_reader :reportable_tags # @return [Hash] attr_reader :tags # @return [Array] the tags and spans of the source that are # tracked attr_reader :taint_ranges # @return [String] the target of the taint from the method; ^(O|R|P\d+)$ attr_reader :target # @return [String] the id of the thread on which the method was invoked attr_reader :thread # @return [Integer] the time, in ms, when the event was generated attr_reader :time # @return [String] the type of event; METHOD, PROPAGATION, TAG attr_reader :type # @return [Array] the execution stack at the time the method for this event was invoked attr_reader :stack_trace # Creates new FindingEvent. # # @param event_data [Contrast::Agent::Assess::Events::EventData] # @param source_type [String, nil] the type of this source, from the # source_node, or a KEY_TYPE if invoked for a map, # @param source_name [String, nil] the name of this source, i.e. # the key used to accessed if from a map or nil if a type like, # @return [Contrast::Agent::Reporting::FindingEvent] def initialize event_data = nil, source_type = nil, source_name = nil @event_sources = [] @stack = [] @time = Contrast::Utils::Timer.now_ms @thread = Thread.current.object_id.to_s @event_id = Contrast::Agent::Reporting::FindingEvent.next_atomic_id initialize_routine(event_data, source_type, source_name) super() end # Init routine to find parents events, capture stack trace and retrieve object, args, ret and properties. # # @param event_data [Contrast::Agent::Assess::Events::EventData] # @param source_type [String, nil] the type of this source, from the # source_node, or a KEY_TYPE if invoked for a map, # @param source_name [String, nil] the name of this source, i.e. # the key used to accessed if from a map or nil if a type like, def initialize_routine event_data, source_type = nil, source_name = nil return unless event_data&.cs__is_a?(Contrast::Agent::Assess::Events::EventData) # Initialize source event: if event_data.policy_node.cs__class == Contrast::Agent::Assess::Policy::SourceNode build_source_event(source_type, source_name) end @policy_node = event_data.policy_node @tags = Contrast::Agent::Assess::Tracker.properties(event_data.tagged)&.get_tags find_parent_events!(event_data.policy_node, event_data.object, event_data.ret, event_data.args) snapshot!(event_data.object, event_data.ret, event_data.args) display_params! capture_stacktrace! stack! properties! # following methods must be called after snapshot! dataflow! @signature = Contrast::Agent::Reporting::FindingEventSignature.new(policy_node, args, ret) end # 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 @atomic_id += 1 # Rollover things rescue StandardError @atomic_id = 1 end end # @return [Array] def parent_events @_parent_events ||= [] end class << self # Find all the events leading up to the given source and return an array of FindingEvents # # @param source [Object] something that may have been tracked # @return [Array] def from_source source return unless source && (props = Contrast::Agent::Assess::Tracker.properties(source)) build_events([], props.event) if props.event end private # Pull the parent events from the given event, generating an array of FindingEvents, passing back the given # array of events after populating it. # # @param events [Array] # @param event [Contrast::Agent::Reporting::FindingEvent] # @return [Array] def build_events events, event return unless event event.parent_events&.each do |parent_event| build_events(events, parent_event) end events << event events end end # Convert the instance variables on the class, and other information, into the identifiers required for # TeamServer to process the JSON form of this message. # # @return [Hash] # @raise [ArgumentError] def to_controlled_hash # rubocop:disable Metrics/AbcSize validate { action: action, args: reportable_args.map(&:to_controlled_hash), # code: code, # Unused by our agent objectId: event_id, eventSources: event_sources.map(&:to_controlled_hash), # fieldName: field_name, # Unused by our agent object: reportable_object.to_controlled_hash, parentObjectIds: parent_object_ids.map(&:to_controlled_hash), properties: properties.map(&:to_controlled_hash), ret: reportable_ret&.to_controlled_hash, signature: signature.to_controlled_hash, source: source || '', stack: stack.map(&:to_controlled_hash), tags: reportable_tags.join(','), taintRanges: taint_ranges.map(&:to_controlled_hash), target: target || '', thread: thread, time: time, type: type } end # @raise [ArgumentError] def validate validate_base validate_dataflow end private # @param source_type [String, nil] the type of this source, from the # source_node, or a KEY_TYPE if invoked for a map, # @param source_name [String, nil] the name of this source, i.e. # the key used to accessed if from a map or nil if a type like, def build_source_event source_type = nil, source_name = nil @source_type = Contrast::Utils::StringUtils.force_utf8(source_type) @source_name = Contrast::Utils::StringUtils.force_utf8(source_name) @event_sources << Contrast::Agent::Reporting::FindingEventSource.new(@source_type, @source_name) @request = Contrast::Agent::REQUEST_TRACKER.current&.request end # Find the action and type of this event, used by TeamServer to display type of the event and the # transformation if made. def display_params! @action = policy_node.build_action @type = case policy_node.node_type when :TYPE_TAG 'TAG' when :TYPE_PROPAGATION 'PROPAGATION' else # :TYPE_METHOD 'METHOD' end end # Build the dataflow components of this FindingEvent. def dataflow! taint_target = taint_target! truncate_obj = Contrast::Utils::ObjectShare::OBJECT_KEY != taint_target @reportable_object = Contrast::Agent::Reporting::FindingEventObject.convert(@object, truncate_obj) truncate_ret = Contrast::Utils::ObjectShare::RETURN_KEY != taint_target @reportable_ret = Contrast::Agent::Reporting::FindingEventObject.convert(@ret, truncate_ret) event_args!(taint_target) taint_ranges! end # Convert the arguments of the given ContrastEvent to the reportable form for this FindingEvent. We'll # truncate any argument that isn't the taint target of this event. # # @param taint_target [String,nil] the target of the taint in this event. def event_args!taint_target @reportable_args = [] return unless args idx = 0 while idx < args.length @reportable_args << Contrast::Agent::Reporting::FindingEventObject.convert(args[idx], taint_target != idx) idx += 1 end end # Convert the parent id's of the given ContrastEvent to the reportable form for this FindingEvent. def parent_ids! @parent_object_ids = [] parent_events&.each do |parent_event| parent_object_ids << Contrast::Agent::Reporting::FindingEventParentObject.new(parent_event.event_id.to_i) end end # A set of properties that pertain to this event. This is not required for all rules. # Dataflow rules can safely omit this property from their report. The properties are set # by properties based rules on build_evidence method ( evidence - legacy, now new rules use properties). # It returns a key-value pair of properties on each violated rule. The build finding does not create event, # since it checks for violation and if there is a violation in the response it is reported, and properties # are populated only for Contrast::Agent::Reporting::Finding. # TODO: RUBY-99999 How do properties get to events? Do they? I thought Stored-XSS needed it. # # @return [Array] def properties! @properties = [] end # Capture stack traces only as configured. We'll use this to grab the start of the call stack as if the # instrumented method were the caller. This means we'll start at the entry just after the first block of # Contrast code. # def stack! @stack = if stack_trace stack_trace.compact.map! do |stack_event| Contrast::Agent::Reporting::FindingEventStack.new(stack_event) end else Contrast::Utils::ObjectShare::EMPTY_ARRAY end end # Convert the taint ranges of the given ContrastEvent to the reportable form for this FindingEvent. def taint_ranges! @reportable_tags = [] @taint_ranges = [] return unless tags tags.each_pair do |tag_key, tag_ranges| reportable_tags << tag_key tag_ranges.each do |range| taint_ranges << Contrast::Agent::Reporting::FindingEventTaintRange.convert(range) end end end # Find the source and target for this FindingEvent based on the provided event. # # @return [String,nil] the target of the taint in this event. def taint_target! return unless policy_node @source = policy_node.source_string if policy_node.source_string @target = policy_node.target_string if policy_node.target_string return policy_node.targets[0] if policy_node.targets&.any? policy_node.sources[0] if policy_node.sources&.any? end # Parent events are the events of all the sources of this event which were tracked prior to this event # occurring. Depending on which, if any of the sources were tracked, there may be more than one parent. # # All events except for [Contrast::Agent::Assess::Events::SourceEvent] will have at least one parent. # # We set those events to this event's instance variables. # # @param policy_node [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event. # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked # @return event [Contrast::Agent::Reporting::FindingEvent] def find_parent_events! policy_node, object, ret, args policy_node.sources.each do |source_marker| source = value_of_source(source_marker, object, ret, args) next unless source event = Contrast::Agent::Assess::Tracker.properties(source)&.event parent_events << event if event end parent_ids! end # @param source [String] the marker for the source type # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked # @return [Object,nil] the literal value of the source indicated by the given marker 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 args[source] end end # Everything* is mutable in Ruby. As such, to ensure we can accurately report the application state at the time # of this method's invocation, we have to snapshot the given values, making safe representations of them for # our later use. We set those safe values to this event's instance variables. # # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked def snapshot! object, ret, args @object = Contrast::Agent::Assess::ContrastObject.new(object) if object @ret = Contrast::Agent::Assess::ContrastObject.new(ret) if ret @args = safe_args_representation(args) self end # Given an array of arguments, copy them into a safe, meaning String, format that we can use to send to SR and # TS for rendering. # # @param args [Array] the arguments to translate # @return [Array] the String forms of those Objects, as determined by # Contrast::Utils::ClassUtil.to_contrast_string def safe_args_representation args return unless args return Contrast::Utils::ObjectShare::EMPTY_ARRAY if args.empty? args.map { |arg| arg ? Contrast::Agent::Assess::ContrastObject.new(arg) : nil } end # Capture stack traces only as configured. We'll use this to grab the start of the call stack as if the # instrumented method were the caller. This means we'll start at the entry just after the first block of # Contrast code. def capture_stacktrace! # If we're configured to not capture the stacktrace, usually for performance reasons, then don't and return an # empty array instead unless ::Contrast::ASSESS.capture_stacktrace?(policy_node) @stack_trace = Contrast::Utils::ObjectShare::EMPTY_ARRAY return end # Otherwise, find where in the stack the application / Ruby code starts start = caller(0, 20)&.find_index { |stack| !stack.include?('/lib/contrast') } # And then use that to build out the reported stacktrace, or a fallback if we couldn't find it. @stack_trace = start ? caller(start + 1, 20) : caller(20, 20) end # @raise [ArgumentError] def validate_base raise(ArgumentError, "#{ self } did not have a proper action. Unable to continue.") unless action raise(ArgumentError, "#{ self } did not have a proper eventId. Unable to continue.") unless event_id raise(ArgumentError, "#{ self } did not have a proper thread. Unable to continue.") unless thread raise(ArgumentError, "#{ self } did not have a proper time. Unable to continue.") unless time raise(ArgumentError, "#{ self } did not have a proper type. Unable to continue.") unless type end # @raise [ArgumentError] def validate_dataflow unless parent_object_ids raise(ArgumentError, "#{ self } did not have a proper parentObjectIds. Unable to continue.") end raise(ArgumentError, "#{ self } did not have a proper taintRanges. Unable to continue.") unless taint_ranges raise(ArgumentError, "#{ self } did not have a proper args. Unable to continue.") unless reportable_args raise(ArgumentError, "#{ self } did not have a proper object. Unable to continue.") unless reportable_object raise(ArgumentError, "#{ self } did not have a proper signature. Unable to continue.") unless signature raise(ArgumentError, "#{ self } did not have a proper stack. Unable to continue.") unless stack raise(ArgumentError, "#{ self } did not have a proper tags. Unable to continue.") unless reportable_tags end end end end end