# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/components/interface' 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} and {#build_details} to implement class Base include Contrast::Components::Interface access_component :agent, :analysis, :logging, :scope, :settings 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 default_mode = Contrast::Api::Settings::ProtectionRule::Mode::NO_ACTION PROTECT.rules[name] = self @mode = mode_from_settings || default_mode end # Should return the name as it is known to Teamserver; defaults to class def name cs__class.name end OFF = 'off' def enabled? # 1. it is not enabled because protect is not enabled return false unless AGENT.enabled? return false unless PROTECT.enabled? rule_configs = PROTECT.rule_config unless rule_configs.nil? # 2. it is not enabled because it is in the list of disabled protect rules disabled_rules = rule_configs.disabled_rules return false if disabled_rules&.include?(name) # 3. it is not enabled because it has been turned "off" explicitly rule_config = rule_configs.send(name) return rule_config.mode != OFF unless rule_config.mode.nil? end # 4. it is not enabled because it's mode is :NO_ACTION @mode != :NO_ACTION end def excluded? exclusions Array(exclusions).any? do |ex| ex.protection_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 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 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 def append_to_activity context, result context.activity.results << result if result end protected def build_details _input_string, _ia_result raise NoMethodError, "Rule #{ name } did not implement build_details" end def mode_from_settings PROTECT.rule_mode(name).tap do |mode| logger.trace('Retrieving rule mode', 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 = SETTINGS.code_exclusions return false unless exclusions for_rule = exclusions.select { |ex| ex.protection_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 #{ 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 = Contrast::Api::Dtm::AttackResult::ResponseType::BLOCKED_AT_PERIMETER 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 def build_attack_result _context result = Contrast::Api::Dtm::AttackResult.new result.rule_id = 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 nil unless result sample = build_sample(context, ia_result, candidate_string, **kwargs) return nil 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: name, type: ia_result&.input_type, name: ia_result&.key, input: ia_result&.value, result: response) end private def log_rule_probed _context, ia_result logger.debug('An unsuccessful attack was detected', rule: name, type: ia_result&.input_type, name: ia_result&.key, input: ia_result&.value) end end end end end end