# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/reporting/settings/url_exclusion' require 'contrast/agent/reporting/input_analysis/input_type' require 'contrast/utils/object_share' require 'contrast/utils/assess/object_store' module Contrast module Agent # Given an array of exclusion matcher instances provides methods to # determine if the exclusions apply to particular urls. class Excluder # rubocop:disable Metrics/ClassLength include Contrast::Agent::Reporting::InputType # @return [Array] attr_reader :exclusions # @param exclusions [Array] def initialize exclusions = [] @exclusions = exclusions end def cached_paths @_cached_paths ||= Contrast::Utils::Assess::ObjectStore.new(10) end # Determine if an input is excluded for protect rule. # # @param results [Array] def protect_excluded_by_input? results return false unless results.any? exclusion_matched = 0 protect_input_exclusions.any? do |exclusion_match| # each exclusion against each input result results.each do |rule_result| # check and see the rule_id match first or if this applicable for all protect rules. next unless exclusion_match.protection_rule?(rule_result.rule_id) # Based on strategy: match = input_match_strategy(exclusion_match, input_match?(exclusion_match, rule_result.input_type, rule_result.key)) exclusion_matched += 1 if match end end return false if exclusion_matched.zero? true end # If an assess URL exclusion rule applies to the current url, *and* is defined as "All Rules" # then we can avoid any tracking for the request. # # @return [Boolean] def assess_excluded_by_url? assess_url_exclusions_for_all_rules.any? do |exclusion_matcher| path_match?(exclusion_matcher) end end # If an assess URL exclusion rule applies to the current url, *and* also covers the # provided rule_id, then we can avoid tracking this entry. # # @param rule_id [String] # return [Boolean] def assess_excluded_by_url_and_rule?rule_id assess_url_exclusions.any? do |exclusion_matcher| path_match?(exclusion_matcher) && (exclusion_matcher.assess_rules.empty? || exclusion_matcher.assess_rules.include?(rule_id)) end end # If an assess INPUT exclusion rule applies to the current url, *and* also covers all # rules, then we can avoid tracking this entry. # # @param source_type [String] # @param source_name [String] # return [Boolean] def assess_excluded_by_input?source_type, source_name assess_input_exclusions_for_all_rules.any? do |exclusion_matcher| input_match?(exclusion_matcher, source_type, source_name) && path_match?(exclusion_matcher) end end # If an assess INPUT exclusion rule covers the provided rule_id *for all finding event sources*, then we # can avoid tracking this entry. If any event source *isn't excluded* then we don't exclude the finding. # # @param finding [Contrast::Agent::Reporting::Finding] # @param rule [String] # return [Boolean] def assess_excluded_by_input_and_rule?finding, rule return false if finding.events.empty? # We need to check for url exclusions here for the input rules as the url exclusions # that have already been checked didn't include the INPUT exclusions. So we look for # any INPUT exclusions that apply to the current url and the supplied rule. rule_input_exclusions = assess_input_exclusions.select do |exclusion_matcher| (exclusion_matcher.protect_rules.empty? || exclusion_matcher.protect_rules.include?(rule)) && path_match?(exclusion_matcher) end return false if rule_input_exclusions.empty? event_sources = finding.events.flat_map(&:event_sources) event_sources.each do |event_source| return false unless rule_input_exclusions.any? do |exclusion| input_match?(exclusion, event_source.source_type, event_source.source_name) end end # If we reach here, and we have event sources then all of them matched so we should exclude # this finding. On the other hand, if there were no event sources we have nothing to exclude. event_sources.any? end # If a protect URL exclusion rule applies to the current url, *and* is defined as "All Rules" # then we can avoid using the rule for the request. # # @param rule_id [String] # return [Boolean] def protect_excluded_by_url? rule_id protect_url_exclusions.any? do |exclusion_matcher| next unless exclusion_matcher.protection_rule?(rule_id) return true if path_match?(exclusion_matcher) end end private # Here we check to see the matching strategy. If ALL is set we need to exclude any input matching # the exclusion. If ONLY is set, that means that we have a set if urls to match and apply the # input exclusion only to those matching urls. # # @param exclusion_match [Contrast::Agent::ExclusionMatcher] # @param input_match [Boolean] does the input match the exclusion # @return [Boolean] def input_match_strategy exclusion_match, input_match # for ALL urls return input_match if exclusion_match.match_all? # for ONLY match we need to check if there is an input and url match. input_match && path_match?(exclusion_match) end # @return [Array] def assess_url_exclusions_for_all_rules @_assess_url_exclusions_for_all_rules ||= assess_url_exclusions.select do |exclusion_matcher| exclusion_matcher.assess_rules.empty? end end # @return [Array] def assess_url_exclusions @_assess_url_exclusions ||= assess_exclusions.select do |exclusion_matcher| exclusion_matcher.exclusion_type == :URL end end # @return [Array] def assess_input_exclusions_for_all_rules @_assess_input_exclusions_for_all_rules ||= assess_input_exclusions.select do |exclusion_matcher| exclusion_matcher.assess_rules.empty? end end # @return [Array] def assess_input_exclusions @_assess_input_exclusions ||= assess_exclusions.select do |exclusion_matcher| exclusion_matcher.exclusion_type == :INPUT end end # @return [Array] def assess_exclusions @_assess_exclusions ||= @exclusions.select(&:assess) end # @return [Array] def protect_url_exclusions @_protect_url_exclusions ||= protect_exclusions.select do |exclusion_matcher| exclusion_matcher.exclusion_type == :URL end end # @return [Array] def protect_exclusions @_protect_exclusions ||= @exclusions.select(&:protect) end # @return [Array] def protect_input_exclusions @_protect_input_exclusions ||= protect_exclusions.select do |exclusion_matcher| exclusion_matcher.exclusion_type == :INPUT end end # Returns true if context.request.path matches any url exclusion. # # @return [Boolean] def path_match? exclusion_matcher return false unless Contrast::Agent::REQUEST_TRACKER.current&.request&.path return_cached_result(exclusion_matcher) matches = 0 matches += 1 if exclusion_matcher.wildcard_url exclusion_matcher.urls.any? do |url| if url.match?(Contrast::Agent::REQUEST_TRACKER.current.request.path) || regexp_match?(url, Contrast::Agent::REQUEST_TRACKER.current.request.path) matches += 1 end end add_cached_path(exclusion_matcher, matches) matches.positive? end # @param exclusion [Contrast::Agent::ExclusionMatcher] # @param source_type [String] # @param source_name [String] # @return [Boolean] def input_match? exclusion, source_type, source_name case exclusion.type when 'PARAMETER' input_match_parameter?(exclusion, source_type, source_name) when 'COOKIE' input_match_cookie?(exclusion, source_type, source_name) when 'HEADER' input_match_header?(exclusion, source_type, source_name) when 'BODY' BODY == source_type when 'QUERYSTRING' QUERYSTRING == source_type else false end end # Returns true if parameter exclusion is found. # # @param exclusion [Contrast::Agent::ExclusionMatcher] # @param source_type [Contrast::Agent::Reporting::InputType] # @param source_name [String] value to match # @return [Boolean] def input_match_parameter? exclusion, source_type, source_name return false unless params_types.include?(source_type) input_value_match?(exclusion, source_name) end # Returns true if cookie exclusion is found. # # @param exclusion [Contrast::Agent::ExclusionMatcher] # @param source_type [Contrast::Agent::Reporting::InputType] # @param source_name [String] value to match # @return [Boolean] def input_match_cookie? exclusion, source_type, source_name return false unless cookie_types.include?(source_type) input_value_match?(exclusion, source_name) end # Returns true if header exclusion is found. # # @param exclusion [Contrast::Agent::ExclusionMatcher] # @param source_type [Contrast::Agent::Reporting::InputType] # @param source_name [String] value to match # @return [Boolean] def input_match_header? exclusion, source_type, source_name return false unless source_type == HEADER input_value_match?(exclusion, source_name, header: true) end # regexp check for input name match # # @return [Boolean] def regexp_match? possible_pattern, source_name Regexp.new("^#{ possible_pattern }$").match?(source_name) || Regexp.new(possible_pattern).match?(source_name) rescue RegexpError false end # Returns true if ia input matches exclusion input name, or it's a all input type - wildcard [*, .*] # # @param exclusion [Contrast::Agent::ExclusionMatcher] # @param source_name [String] value to match # @param header [Boolean] # @return [Boolean] def input_value_match? exclusion, source_name, header: nil exclusion.wildcard_input || (header.nil? ? (exclusion.name == source_name) : exclusion.name.casecmp(source_name).zero?) || # rubocop:disable Security/Module/Name regexp_match?(exclusion.name, source_name) || exclusion.input_name == source_name # rubocop:disable Security/Module/Name end # Input types to match against exclusions parameter type. # # @return [Array] def params_types @_params_types ||= [PARAMETER_VALUE, PARAMETER_NAME].cs__freeze end # Input types to match against exclusions cookie type. # # @return [Array] def cookie_types @_cookie_types ||= [COOKIE_NAME, COOKIE_VALUE].cs__freeze end # Adds new cached result unless it already exists # # @param exclusion_matcher [Contrast::Agent::ExclusionMatcher] # @param matches [Boolean] the result of last iteration # @return [Hash, nil] def add_cached_path exclusion_matcher, matches return if cached_paths[Contrast::Agent::REQUEST_TRACKER.current.request.path.__id__] cached_paths[Contrast::Agent::REQUEST_TRACKER. current.request.path.__id__] = { matcher: exclusion_matcher.__id__, result: matches.positive? } rescue StandardError nil end # returns a cached result if current path and matcher are the same. # @param exclusion_matcher [Contrast::Agent::ExclusionMatcher] # @return [Boolean, nil] def return_cached_result exclusion_matcher return unless !cached_paths[Contrast::Agent::REQUEST_TRACKER.current.request.path.__id__].nil? && (cached_paths[Contrast::Agent::REQUEST_TRACKER.current. request.path.__id__][:matcher] == exclusion_matcher.__id__) cached_paths[Contrast::Agent::REQUEST_TRACKER.current.request.path.__id__][:result] rescue StandardError nil end end end end