# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/utils/timer' require 'contrast/agent/request' require 'contrast/agent/response' require 'contrast/agent/inventory/database_config' require 'contrast/components/logger' require 'contrast/components/scope' 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. # # @attr_reader timer [Contrast::Utils::Timer] when the context was created # @attr_reader logging_hash [Hash] context used to log the request # @attr_reader speedracer_input_analysis [Contrast::Api::Settings::InputAnalysis] the protect input analysis of # sources on this request # @attr_reader request [Contrast::Agent::Request] our wrapper around the Rack::Request for this context # @attr_reader response [Contrast::Agent::Response] our wrapper aroudn the Rack::Response or Array for this context, # only available after the application has finished its processing # @attr_reader activity [Contrast::Api::Dtm::Activity] the application activity found in this request # @attr_reader server_activity [Contrast::Api::Dtm::ServerActivity] the server activity found in this request # @attr_reader route [Contrast::Api::Dtm::RouteCoverage] the route, used for findings, of this request # @attr_reader observed_route [Contrast::Api::Dtm::ObservedRoute] the route, used for coverage, of this request class RequestContext include Contrast::Components::Logger::InstanceMethods include Contrast::Components::Scope::InstanceMethods EMPTY_INPUT_ANALYSIS_PB = Contrast::Api::Settings::InputAnalysis.new attr_reader :activity, :logging_hash, :observed_route, :request, :response, :route, :speedracer_input_analysis, :server_activity, :timer def initialize rack_request, app_loaded = true with_contrast_scope do # all requests get a timer and hash @timer = Contrast::Utils::Timer.new @logging_hash = { request_id: __id__ } # 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 speedracer_input_analysis.request = request # 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 if ::Contrast::ASSESS.enabled? @sample_request, @sample_response = Contrast::Utils::Assess::SamplingUtil.instance.sample?(@request) end @sample_response &&= ::Contrast::ASSESS.scan_response? append_route_coverage(Contrast::Agent.framework_manager.get_route_dtm(@request)) end end def app_loaded? @app_loaded 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. # # @param route [Contrast::Api::Dtm::RouteCoverage, nil] the route of the current request, as determined from the # framework 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.url = route.url if route.url @request.route = route @request.observed_route = @observed_route 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 unless ::Contrast::AGENT.enabled? return false unless ::Contrast::PROTECT.enabled? return false if @do_not_track service_response = Contrast::Agent.messaging_queue.send_event_immediately(@activity.http_request) return false unless service_response handle_protect_state(service_response) ia = service_response.input_analysis if ia logger.trace("Analysis from Contrast Service: evaluations=#{ ia.results.length }") logger.trace('Results', input_analysis: ia.inspect) @speedracer_input_analysis = ia speedracer_input_analysis.request = request else logger.trace('Analysis from Contrast Service was empty.') false end rescue Contrast::SecurityException => e raise e rescue StandardError => e logger.warn('Unable to extract Contrast Service information from request', e) 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. # # @param agent_settings [Contrast::Api::Settings::AgentSettings] def handle_protect_state agent_settings return unless agent_settings&.protect_state state = agent_settings.protect_state @uuid = state.uuid @do_not_track = true unless state.track_request return unless state.security_exception # If Contrast Service has NOT handled the input analysis, handle them here build_attack_results(agent_settings) logger.debug('Contrast Service said to block this request') raise Contrast::SecurityException.new(nil, (state.security_message || '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('Unable to extract information after request', e) 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 @observed_route = Contrast::Api::Dtm::ObservedRoute.new end private # Generate attack results directly from any evaluations on the agent settings object. # # @param agent_settings [Contrast::Api::Settings::AgentSettings] 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 = ::Contrast::PROTECT.rule(rule_id) next unless rule logger.debug('Building attack result from Contrast Service input analysis result', 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.info('Blocking attack result', rule: attack_result.rule_id) activity.results << attack_result end end end end end