# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/assess/rule/response/auto_complete_rule' require 'contrast/agent/assess/rule/response/cache_control_header_rule' require 'contrast/agent/assess/rule/response/click_jacking_header_rule' require 'contrast/agent/assess/rule/response/csp_header_insecure_rule' require 'contrast/agent/assess/rule/response/csp_header_missing_rule' require 'contrast/agent/assess/rule/response/hsts_header_rule' require 'contrast/agent/assess/rule/response/parameters_pollution_rule' require 'contrast/agent/assess/rule/response/x_content_type_header_rule' require 'contrast/agent/assess/rule/response/x_xss_protection_header_rule' require 'contrast/agent/protect/input_analyzer/input_analyzer' require 'contrast/components/logger' require 'contrast/utils/log_utils' module Contrast module Agent # This class extends RequestContexts: 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. module RequestContextExtend # rubocop:disable Metrics/ModuleLength include Contrast::Utils::CEFLogUtils include Contrast::Components::Logger::InstanceMethods BUILD_ATTACK_LOGGER_MESSAGE = 'Building attack result from Contrast Service input analysis result' # 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 # 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_to_new_observed_route route return unless route @new_observed_route.signature = route.route @new_observed_route.verb = route.verb @new_observed_route.url = route.url if route.url @request.new_observed_route = @new_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 service_extract_logging ia # using Agent analysis initialize_agent_input_analysis request @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 # We must ALWAYS save the response, even if we don't need it here for response sampling. It is used for other # vulnerability detection, most notably XSS, and not capturing it may suppress valid findings. @response = Contrast::Agent::Response.new(rack_response) return unless @sample_res # # TODO: RUBY-1376 once all rules translated, move this to if/else w/ the enabled if Contrast::Agent::Reporter.enabled? Contrast::Agent::Assess::Rule::Response::AutoComplete.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::CacheControl.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::ClickJacking.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::CspHeaderMissing.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::CspHeaderInsecure.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::HSTSHeader.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::ParametersPollution.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::XContentType.new.analyze(@response) Contrast::Agent::Assess::Rule::Response::XXssProtection.new.analyze(@response) else activity.http_response = @response.dtm end rescue StandardError => e logger.error('Unable to extract information after request', e) end # This here is for things we don't have implemented def log_to_cef activity.results.each { |attack_result| logging_logic attack_result, attack_result.rule_id.downcase } end # @param input_analysis [Contrast::Api::Settings::InputAnalysis] def service_extract_logging input_analysis log_to_cef logger.trace('Analysis from Contrast Service', evaluations: input_analysis.results.length) if logger.trace? logger.trace('Results', input_analysis: input_analysis.inspect) if logger.trace? 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? 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(BUILD_ATTACK_LOGGER_MESSAGE, result: ia_result.inspect) if logger.debug? results_by_rule[rule_id] = attack_result rule, rule_id, ia_result, results_by_rule end results_by_rule.each_pair do |_, attack_result| logger.info('Blocking attack result', rule: attack_result.rule_id) activity.results << attack_result end end # Generates the attack result # # @param rule [Contrast::Agent::Protect::Rule, Contrast::Agent::Assess::Rule] # @param rule_id [String] String name of the rule # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the # analysis of the input that was determined to be an attack # @param results_by_rule [Hash] attack results from any evaluations on the agent settings object. # @return [Contrast::Api::Dtm::AttackResult] the attack result from this input def attack_result rule, rule_id, ia_result, results_by_rule @_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, results_by_rule[rule_id], ia_result.value) else rule.build_attack_without_match(self, ia_result, results_by_rule[rule_id]) end end # Sets request to be used with agent and service input analysis. # # @param request [Contrast::Agent::Request] our wrapper around the Rack::Request # for this context def initialize_agent_input_analysis request # using Agent analysis ia = Contrast::Agent::Protect::InputAnalyzer.analyse request if ia @agent_input_analysis = ia else logger.trace('Analysis from Agent was empty.') end end def logging_logic result, rule_id rules = %w[bot_blocker virtual_patch ip_denylist] return unless rules.include?(rule_id) rule_details = Contrast::Api::Dtm::RaspRuleSample.to_controlled_hash(result.samples[0]).fetch(rule_id.to_sym) outcome = Contrast::Api::Dtm::AttackResult::ResponseType.get_name_by_tag(result.response) case rule_id when /bot_blocker/i blocker_to_json = Contrast::Api::Dtm::BotBlockerDetails.to_controlled_hash rule_details cef_logger.bot_blocking_message(blocker_to_json, outcome) when /virtual_patch/i virtual_patch_to_json = Contrast::Api::Dtm::VirtualPatchDetails.to_controlled_hash rule_details cef_logger.virtual_patch_message(virtual_patch_to_json, outcome) when /ip_denylist/i sender_ip = extract_sender_ip ip_denylist_to_json = Contrast::Api::Dtm::IpDenylistDetails.to_controlled_hash rule_details return unless sender_ip return unless sender_ip.include?(ip_denylist_to_json[:ip]) cef_logger.ip_denylisted_message(sender_ip, ip_denylist_to_json, outcome) end end end end end