# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/assess/policy/trigger_validation/trigger_validation' require 'contrast/agent/excluder/excluder' require 'contrast/components/logger' require 'contrast/utils/object_share' require 'contrast/utils/duck_utils' require 'contrast/utils/sha256_builder' require 'contrast/utils/assess/trigger_method_utils' require 'contrast/agent/assess/events/event_data' require 'contrast/agent/reporting/reporting_events/preflight' require 'contrast/agent/reporting/reporting_events/application_activity' require 'contrast/agent/reporting/reporting_events/preflight_message' require 'contrast/agent/reporting/reporting_events/route_discovery' require 'contrast/agent/reporting/reporting_utilities/reporting_storage' require 'contrast/agent/reporting/reporting_utilities/build_preflight' require 'contrast/utils/assess/event_limit_utils' 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 TeamServer. module TriggerMethod extend Contrast::Components::Logger::InstanceMethods extend Contrast::Utils::Assess::TriggerMethodUtils extend Contrast::Utils::Assess::EventLimitUtils # Rules that always exists outside of Request Context # @return [Array] NON_REQUEST_RULES = [ 'hardcoded-password', # Contrast::Agent::Assess::Rule::Provider::HardcodedPassword.NAME, 'hardcoded-key' # Contrast::Agent::Assess::Rule::Provider::HardcodedKey.NAME ].cs__freeze class << self # 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] the Arguments with which the method was invoked def apply_trigger_rule trigger_node, object, ret, args return if trigger_node.nil? return if event_limit_for_rule?(trigger_node.rule_id) context = Contrast::Agent::REQUEST_TRACKER.current # return if there is no context and the flag is set to default => false # we need to have request if the flag is default # else proceed, if the flag is true we don't need to check for context we # go as currently. # When outside of a request, only track when the feature is enabled return unless context || Contrast::ASSESS.non_request_tracking? || NON_REQUEST_RULES.include?(trigger_node.rule_id) if trigger_node.sources&.any? trigger_node.sources.each do |marker| source = determine_source(marker, object, ret, args) apply_trigger(trigger_node, source, object, ret, *args) end else apply_trigger(trigger_node, nil, object, ret, *args) end end def apply_eval_trigger trigger_node, source, object, ret, *args apply_trigger(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 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, nil] the Return of the invoked method, or nil if void method # @param args [Array] the Arguments with which the method was invoked # @return [Contrast::Agent::Reporting::Finding, nil] the finding to send to TeamServer if found def build_finding trigger_node, source, object, ret, *args content_type = Contrast::Agent::REQUEST_TRACKER.current&.response&.content_type if content_type.nil? && trigger_node.collectable? Contrast::Agent::FINDINGS.collect_finding(trigger_node, source, object, ret, *args) return end return unless Contrast::Agent::Assess::Policy::TriggerValidation.valid?(trigger_node, object, ret, args) request = find_request(source) return unless reportable?(request&.env) return if excluded_by_url_and_rule?(trigger_node.rule_id) finding = Contrast::Agent::Reporting::Finding.new(trigger_node.rule_id) finding.attach_data(trigger_node, source, object, ret, request, *args) return if excluded_by_input_and_rule?(finding, trigger_node.rule_id) finding.hash_code = Contrast::Utils::HashDigest.generate_event_hash(finding, source, request) check_for_stored_xss(finding) finding rescue StandardError => e logger.error('Unable to build a finding', e, rule: trigger_node.rule_id, node_id: trigger_node.id) nil end # Given a finding, append it to an activity message and send it to the TeamServer for processing. If an # activity message does not exist, b/c we're invoked outside of a request context, build an activity and # immediately report it with the finding. # # @param finding [Contrast::Agent::Reporting::Finding] the Finding to report. def report_finding finding return unless finding preflight = Contrast::Agent::Reporting::BuildPreflight.generate(finding) return unless preflight preflight_data = preflight.messages[0].data return if Contrast::Agent::REQUEST_TRACKER.current&.reported_findings&.include?(preflight_data) Contrast::Agent::Reporting::ReportingStorage[preflight.messages[0].data] = finding if Contrast::Agent::REQUEST_TRACKER.current Contrast::Agent::REQUEST_TRACKER.current.reported_findings << preflight_data end Contrast::Agent.reporter&.send_event(preflight) end private def settings Contrast::Agent::FeatureState.instance 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 by index. # # @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] 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 # Check if the finding should be excluded due to the assess exclusion rules. # # @param rule_id [String] # return [Boolean] def excluded_by_url_and_rule? rule_id return false unless Contrast::SETTINGS.excluder.exclusions.any? return unless Contrast::Agent::REQUEST_TRACKER.current Contrast::SETTINGS.excluder.assess_excluded_by_url_and_rule?(rule_id) end def excluded_by_input_and_rule? finding, rule_id return false unless Contrast::SETTINGS.excluder.exclusions.any? return unless Contrast::Agent::REQUEST_TRACKER.current Contrast::SETTINGS.excluder.assess_excluded_by_input_and_rule?(finding, rule_id) end # Handles the Stored Xss rule. If a vector is stored in the database def check_for_stored_xss finding return unless finding && finding.rule_id == 'reflected-xss' # Check for database tainted event propagation: if finding.events.select { |event| event.reportable_tags.include?('DATABASE_WRITE') }. any? && !Contrast::ASSESS.disabled_rules.include?('stored-xss') # Override 'reflected-xss' => 'stored-xss' finding.instance_variable_set(:@rule_id, 'stored-xss') extract_dynamic_source_info(finding) finding end end def extract_dynamic_source_info finding return unless finding creation_events = finding.events.select { |e| e.action == :CREATION } source_event_policy = creation_events[0].policy_node if creation_events.any? properties = source_event_policy.properties if source_event_policy # Properties example: # => {"dynamic_source_id"=>"Assess:Source:Comment#message", # "dynamic_source_name"=>"Comment.message", # "readTable"=>"Comment", # "readColumn"=>:message, # "writeDateTimeUtc"=>1675087341948, # "writeRequestUrl"=>"/comments"} return if Contrast::Utils::DuckUtils.empty_duck?(properties) finding.instance_variable_set(:@properties, finding.properties.merge(properties)) end end end end end end end