# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/components/logger' require 'contrast/components/scope' module Contrast module Agent module Protect module Rule # This is a basic rule for Protect. It's the abstract class which all other # protect rules extend in order to function. # # @abstract Subclass and override {#prefilter}, {#infilter}, {#find_attacker}, {#postfilter} to implement class Base include Contrast::Components::Logger::InstanceMethods include Contrast::Components::Scope::InstanceMethods UNKNOWN_USER_INPUT = Contrast::Api::Dtm::UserInput.new.tap do |user_input| user_input.input_type = :UNKNOWN end BLOCKING_MODES = Set.new([ Contrast::Api::Settings::ProtectionRule::Mode::BLOCK, Contrast::Api::Settings::ProtectionRule::Mode::BLOCK_AT_PERIMETER ]).cs__freeze POSTFILTER_MODES = Set.new([ Contrast::Api::Settings::ProtectionRule::Mode::BLOCK, Contrast::Api::Settings::ProtectionRule::Mode::PERMIT, Contrast::Api::Settings::ProtectionRule::Mode::MONITOR ]).cs__freeze STACK_COLLECTION_RESULTS = Set.new([ Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED, Contrast::Api::Dtm::AttackResult::ResponseType::MONITORED ]).cs__freeze attr_reader :mode def initialize ::Contrast::PROTECT.rules[rule_name] = self @mode = mode_from_settings end # Should return the name as it is known to Teamserver; defaults to class def rule_name cs__class.cs__name end def enabled? # 1. it is not enabled because protect is not enabled return false unless ::Contrast::AGENT.enabled? return false unless ::Contrast::PROTECT.enabled? # 2. it is not enabled because it is in the list of disabled protect rules return false if ::Contrast::PROTECT.rule_config&.disabled_rules&.include?(rule_name) # 3. it is enabled so long as its mode is not NO_ACTION @mode != Contrast::Api::Settings::ProtectionRule::Mode::NO_ACTION end def excluded? exclusions Array(exclusions).any? do |ex| ex.protection_rule?(rule_name) end end def infilter? _context false end # return false for rules that modify or inspect the response body # during postfilter # # @return [Boolean] if the rule can safely be evaluated in streaming # requests def stream_safe? true end # Actions required for the rules that have to happen before the # application has completed its processing of the request. # # For most rules, these actions are performed within the analysis # engine and communicated as an input analysis result. Those that # require specific action need to provide that action. # # @param _context [Contrast::Agent::RequestContext] the context for # the current request def prefilter _context; end # This should only ever be called directly from patched code and will # have a different implementation based on the rule. As such, there # is not parent implementation. # # @param _context [Contrast::Agent::RequestContext] the context for # the current request # @param _match_string [String] the input that violated the rule and # matched the attack detection logic # @param _kwargs [Hash] key-value pairs used by the rule to build a # report. def infilter _context, _match_string, **_kwargs; end # Actions required for the rules that have to happen after the # application has completed its processing of the request. # # Any implementation here needs to account for the fact that # responses may be streaming and, as such, transformations of the # response itself may not be permissible. # # @param _context [Contrast::Agent::RequestContext] the context for # the current request def postfilter _context; end # A given input, candidate_string, was determined to violate a # protect rule and did exploit the application, or at least made it # to exploitable code in the case where we blocked the attack. As # such, we need to build a result to report this violation to the # Service. # # @param context [Contrast::Agent::RequestContext] the context of the # request in which this input is evaluated. # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the # analysis of the input that was determined to be an attack # @param result [Contrast::Api::Dtm::AttackResult, nil] previous # attack result for this rule, if one exists, in the case of # multiple inputs being found to violate the protection criteria # @param candidate_string [String] the value of the input which may # be an attack # @param kwargs [Hash] key - value pairs of context individual rules # need to build out details to send to the Service to tell the # story of the attack # @return [Contrast::Api::Dtm::AttackResult] the attack result from # this input def build_attack_with_match context, ia_result, result, candidate_string, **kwargs result ||= build_attack_result(context) update_successful_attack_response(context, ia_result, result, candidate_string) append_sample(context, ia_result, result, candidate_string, **kwargs) result end # A given input, candidate_string, was determined to violate a # protect rule but did not exploit the application. As such, we need # to build a result to report this violation to the Service. # # @param context [Contrast::Agent::RequestContext] the context of the # request in which this input is evaluated. # @param ia_result [Contrast::Api::Settings::InputAnalysisResult] the # analysis of the input that was determined to be an attack # @param result [Contrast::Api::Dtm::AttackResult, nil] previous # attack result for this rule, if one exists, in the case of # multiple inputs being found to violate the protection criteria # @param kwargs [Hash] key - value pairs of context individual rules # need to build out details to send to the Service to tell the # story of the attack # @return [Contrast::Api::Dtm::AttackResult] the attack result from # this input def build_attack_without_match context, ia_result, result, **kwargs result ||= build_attack_result(context) update_perimeter_attack_response(context, ia_result, result) append_sample(context, ia_result, result, nil, **kwargs) result end # Attach the given result to the current request's context to report # it to the Service # # @param context [Contrast::Agent::RequestContext] the context of the # request in which this input is evaluated. # @param result [Contrast::Api::Dtm::AttackResult] def append_to_activity context, result context.activity.results << result if result end # With this we log to CEF # # @param result [Contrast::Api::Dtm::AttackResult] # @param attack [Symbol] the type of message we want to send # @param value [String] the input value we want to log def cef_logging result, attack = :ineffective_attack, value = nil sample_to_json = Contrast::Api::Dtm::RaspRuleSample.to_controlled_hash result.samples[0] outcome = Contrast::Api::Dtm::AttackResult::ResponseType.get_name_by_tag(result.response) input_type = extract_input_type sample_to_json[:user_input].input_type input_value = value || sample_to_json[:user_input].value cef_logger.send(attack, result.rule_id, outcome, input_type, input_value) end protected def mode_from_settings ::Contrast::PROTECT.rule_mode(rule_name).tap do |mode| logger.trace('Retrieving rule mode', rule: rule_name, mode: mode) end end def blocked? enabled? && BLOCKING_MODES.include?(mode) end # Determine if there's an exclusion that matches an item in the call # stack # # @return [Boolean] if an exclusion was applicable to this request # for this rule def protect_excluded_by_code? exclusions = ::Contrast::SETTINGS.code_exclusions return false unless exclusions for_rule = exclusions.select { |ex| ex.protection_rule?(rule_name) } return false if for_rule.empty? stack = caller_locations for_rule.any? { |ex| ex.match_code?(stack) } end # By default, rules do not have to find attackers as they do not have # Input Analysis. Any attack for the standard rule will be evaluated # at execution time. As such, those rules are expected to implement # this custom behavior # # @param _context [Contrast::Agent::RequestContext] the context for # the current request # @param _potential_attack_string [String] the input that may violate # the rule and matched the attack detection logic # @param _kwargs [Hash] key-value pairs used by the rule to build a # report. def find_attacker _context, _potential_attack_string, **_kwargs raise NoMethodError, "Rule #{ rule_name } did not implement find_attack" end def update_successful_attack_response context, ia_result, result, attack_string = nil case mode when Contrast::Api::Settings::ProtectionRule::Mode::MONITOR result.response = Contrast::Api::Dtm::AttackResult::ResponseType::MONITORED when Contrast::Api::Settings::ProtectionRule::Mode::BLOCK result.response = Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED end ia_result.attack_count = ia_result.attack_count + 1 if ia_result log_rule_matched(context, ia_result, result.response, attack_string) result end def update_perimeter_attack_response context, ia_result, result if mode == Contrast::Api::Settings::ProtectionRule::Mode::BLOCK_AT_PERIMETER result.response = if ia_result&.rule_id == Contrast::Agent::Protect::Rule::Sqli::NAME # Block At Perimeter mode has been deprecated in sqli_worth_watching_v2 # and should be treated equivalent to Blocked mode if set Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED else Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED_AT_PERIMETER end log_rule_matched(context, ia_result, result.response) elsif ia_result.nil? || ia_result.attack_count.zero? result.response = Contrast::Api::Dtm::AttackResult::ResponseType::PROBED log_rule_probed(context, ia_result) end result end # Set up an attack result for the current rule # # @param _context [Contrast::Agent::RequestContext] the context of # the current request # @return [Contrast::Api::Dtm::AttackResult] def build_attack_result _context result = Contrast::Api::Dtm::AttackResult.new result.rule_id = rule_name result end def append_stack sample, result return unless sample return unless STACK_COLLECTION_RESULTS.include?(result&.response) stack = Contrast::Utils::StackTraceUtils.build_protect_stack_array return unless stack sample.stack_trace_elements += stack end def append_sample context, ia_result, result, candidate_string, **kwargs return unless result sample = build_sample(context, ia_result, candidate_string, **kwargs) return unless sample append_stack(sample, result) result.samples << sample end # Override if rule can make use of the candidate string or kwargs to # build rasp rule sample. def build_sample context, ia_result, _candidate_string, **_kwargs build_base_sample(context, ia_result) end def build_base_sample context, ia_result Contrast::Api::Dtm::RaspRuleSample.build(context, ia_result) end def log_rule_matched _context, ia_result, response, _matched_string = nil logger.debug('A successful attack was detected', rule: rule_name, type: ia_result&.input_type, name: ia_result&.key, input: ia_result&.value, result: response) end # This method returns the symbol for the enum # # @param enum [Enumerable] # @return [Symbol] def extract_input_type enum Contrast::Api::Dtm::UserInput::InputType.get_name_by_tag enum end private def log_rule_probed _context, ia_result logger.debug('An unsuccessful attack was detected', rule: rule_name, type: ia_result&.input_type, name: ia_result&.key, input: ia_result&.value) end end end end end end