# 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/timer' cs__scoped_require 'contrast/agent/request' cs__scoped_require 'contrast/agent/response' cs__scoped_require 'contrast/utils/comment_range' cs__scoped_require 'contrast/utils/inventory_util' cs__scoped_require 'contrast/components/interface' module Contrast module Agent # This class acts to encapsulate information about the currently executed # request, making it available to the Agent for the duration of the request # in a standardized and normalized format which the Agent understands. class RequestContext include Contrast::Components::Interface access_component :logging, :analysis, :scope, :contrast_service EMPTY_INPUT_ANALYSIS_PB = Contrast::Api::Settings::InputAnalysis.new attr_reader :timer, :speedracer_input_analysis, :request, :response, :activity, :server_activity, :route, :observed_route def initialize rack_request, app_loaded = true with_contrast_scope do # all requests get a timer @timer = Contrast::Utils::Timer.new # instantiate helper for request and response @request = Contrast::Agent::Request.new(rack_request) @activity = Contrast::Api::Dtm::Activity.new @activity.http_request = request.dtm @server_activity = Contrast::Api::Dtm::ServerActivity.new @observed_route = Contrast::Api::Dtm::ObservedRoute.new # build analyzer @do_not_track = false @speedracer_input_analysis = EMPTY_INPUT_ANALYSIS_PB # flag to indicate whether the app is fully loaded @app_loaded = !!app_loaded # generic holder for properties that can be set throughout this request @_properties = {} @sample = true @sample_request, @sample_response = Contrast::Utils::Assess::SamplingUtil.instance.sample?(@request) if ASSESS.enabled? @sample_response &&= ASSESS.scan_response? append_route_coverage(Contrast::Utils::PathUtil.get_route(@request)) end end def app_loaded? @app_loaded end def analyze? @sample_request || @sample_response end def analyze_request? @sample_request end def analyze_response? @sample_response end # Convert the discovered route for this request to appropriate forms and # disseminate it to those locations where it is necessary for our route # coverage and finding vulnerability discovery features to function. def append_route_coverage route return unless route # For our findings @route = route # For SR findings @activity.routes << route # For TS routes @observed_route.signature = route.route @observed_route.verb = route.verb @observed_route.session_id = Contrast::Agent::FeatureState.instance.current_session_id # TODO: SPEED-273: deprecate when SR handles this. ContrastUI API currently does not allow empty url, so we have to provide a default @observed_route.url = route.url.empty? ? Contrast::Utils::ObjectShare::SLASH : route.url end # Collect the results for the given rule with the given action # # @param rule [String] the id of the rule to which the results apply # @param response_type [Symbol] the result of the response, matching a # value of Contrast::Api::Dtm::AttackResult::ResponseType # @return [Array] def results_for rule, response_type = nil if response_type.nil? activity.results.select { |r| r.rule_id == rule } else activity.results.select { |r| r.rule_id == rule && r.response == response_type } end end def service_extract_request return false if @do_not_track service_response = CONTRAST_SERVICE.send_message(@activity.http_request) return false unless service_response handle_protect_state(service_response) ia = service_response.input_analysis if ia logger.debug(nil, "Analysis from Contrast Service: evaluations=#{ ia.results.length }") logger.debug(nil, "IA=#{ ia.inspect }") @speedracer_input_analysis = ia else logger.debug(nil, 'Analysis from Contrast Service was empty.') false end rescue Contrast::SecurityException raise rescue StandardError => e logger.warn(e, 'Unable to extract Contrast Service information from request') false end # NOTE: this method is only used as a backstop if Speedracer sends Input Evaluations # when the protect state indicates a security exception should be thrown. This method # ensures that the attack reports are generated. Normally these should be generated on # Speedracer for any attacks detected during prefilter. def handle_protect_state agent_settings return unless agent_settings return unless agent_settings.protect_state @uuid = agent_settings.protect_state.uuid @do_not_track = true unless agent_settings.protect_state.track_request return unless agent_settings.protect_state.security_exception # If Contrast Service has NOT handled the input analysis, handle them here build_attack_results(agent_settings) msg = agent_settings.protect_state.security_message logger.warn(nil, 'Contrast Service said to block this request') raise Contrast::SecurityException.new(nil, (msg || 'Blocking suspicious behavior')) end # append anything we've learned to the request seen message # this is the sum-total of all inventory information that has # been accumulated since the last request def extract_after rack_response @response = Contrast::Agent::Response.new(rack_response) activity.http_response = @response.dtm if @sample_response rescue StandardError => e logger.error(e, 'Unable to extract information after request') end def add_property key, value @_properties[key] = value end def get_property key @_properties[key] end def reset_activity @activity = Contrast::Api::Dtm::Activity.new(http_request: request.dtm) @server_activity = Contrast::Api::Dtm::ServerActivity.new # it doesn't look like this is ever actually used? @observed_route = Contrast::Api::Dtm::ObservedRoute.new end private # Generate attack results directly from any evaluations on the # agent settings object. def build_attack_results agent_settings return unless agent_settings&.input_analysis&.results&.any? attack_results_by_rule = {} agent_settings.input_analysis.results.each do |ia_result| rule_id = ia_result.rule_id rule = PROTECT.rule(rule_id) next unless rule logger.debug(nil, "Building attack result from Contrast Service input analysis: result=#{ ia_result.inspect }") attack_result = if rule.mode == :BLOCK # special case for rules (like reflected xss) # that used to have an infilter / block # mode but now are just block at perimeter rule.build_attack_with_match( self, ia_result, attack_results_by_rule[rule_id], ia_result.value) else rule.build_attack_without_match( self, ia_result, attack_results_by_rule[rule_id]) end attack_results_by_rule[rule_id] = attack_result end attack_results_by_rule.each_pair do |_, attack_result| logger.debug(nil, "Blocking for #{ attack_result.rule_id }") activity.results << attack_result end end end end end