# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/utils/object_share' require 'contrast/agent/protect/rule/no_sqli' require 'contrast/agent/protect/input_analyzer/input_analyzer' require 'contrast/utils/input_classification' module Contrast module Agent module Protect module Rule # This module will do the Input Classification stage of NoSQLI rule # as a result input would be marked as WORTHWATCHING or IGNORE, # to be analyzed at the sink level. module NoSqliInputClassification class << self include InputClassificationBase NOSQL_COMMENT_REGEXP = %r{"\s*(?:<--|//)}.cs__freeze NOSQL_OR_REGEXP = /(?=(\s+\|\|\s+))/.cs__freeze NOSQL_COMMENTS_AFTER_REGEXP = %r{(?:<--|//)}.cs__freeze ZERO_OR_MORE_SPACES_REGEXP = /\s*/.cs__freeze QUOTE_REGEXP = /['"’‘]/.cs__freeze NUMBERS_AND_LETTERS_REGEXP = /[[:alnum:]]+/.cs__freeze COMPARISON_REGEXP = /(?:==|>=|<=|>|<|)/.cs__freeze NOSQL_QUOTED_REGEXP = / #{ ZERO_OR_MORE_SPACES_REGEXP } #{ QUOTE_REGEXP } #{ NUMBERS_AND_LETTERS_REGEXP } #{ QUOTE_REGEXP } #{ ZERO_OR_MORE_SPACES_REGEXP } #{ COMPARISON_REGEXP } #{ ZERO_OR_MORE_SPACES_REGEXP } #{ QUOTE_REGEXP } #{ NUMBERS_AND_LETTERS_REGEXP } #{ QUOTE_REGEXP } /x.cs__freeze NOSQL_NUMERIC_REGEXP = / #{ ZERO_OR_MORE_SPACES_REGEXP } #{ NUMBERS_AND_LETTERS_REGEXP } #{ ZERO_OR_MORE_SPACES_REGEXP } #{ COMPARISON_REGEXP } #{ ZERO_OR_MORE_SPACES_REGEXP } #{ NUMBERS_AND_LETTERS_REGEXP } /x.cs__freeze NOSQL_DEFINITE_THRESHOLD = 3 NOSQL_WORTH_WATCHING_THRESHOLD = 1 NOSQL_CONFIDENCE_THRESHOLD = 3 MAX_DISTANCE = 10 # Input Classification stage is done to determine if an user input is # WORTHWATCHING or to be ignored. # # @param input_type [Contrast::Agent::Reporting::InputType] The type of the user input. # @param value [String, Array] the value of the input. # @param input_analysis [Contrast::Agent::Reporting::InputAnalysis] Holds all the results from the # agent analysis from the current # Request. # @return ia [Contrast::Agent::Reporting::InputAnalysis] with updated results. def classify input_type, value, input_analysis return unless input_analysis.request rule_id = Contrast::Agent::Protect::Rule::NoSqli::NAME # double check the input to avoid calling match? on array Array(value).each do |val| Array(val).each do |v| input_analysis.results << nosqli_create_new_input_result(input_analysis.request, rule_id, input_type, v) end end input_analysis end private # Creates a new instance of InputAnalysisResult with basic info. # # @param rule_id [String] The name of the Protect Rule. # @param input_type [Contrast::Agent::Reporting::InputType] The type of the user input. # @param score_level [Contrast::Agent::Reporting::ScoreLevel] the score tag after analysis. # @param value [String, Array] the value of the input. # @param path [String] the path of the current request context. # # @return res [Contrast::Agent::Reporting::InputAnalysisResult] def new_ia_result rule_id, input_type, score_level, path, value super(rule_id, input_type, path, value).tap { |res| res.score_level = score_level } end # This methods checks if input should be tagged DEFINITEATTACK, WORTHWATCHING, or IGNORE, # and creates a new instance of InputAnalysisResult. # # @param request [Contrast::Agent::Request] the current request context. # @param rule_id [String] The name of the Protect Rule. # @param input_type [Contrast::Agent::Reporting::InputType] The type of the user input. # @param value [String, Array] the value of the input. # # @return res [Contrast::Agent::Reporting::InputAnalysisResult] def nosqli_create_new_input_result request, rule_id, input_type, value score = evaluate_patterns(value) score = evaluate_rules(value, score) score_level = if definite_attack? score DEFINITEATTACK elsif worth_watching? score WORTHWATCHING else IGNORE end new_ia_result(rule_id, input_type, score_level, request.path, value) end # This method evaluates the patterns relevant to NoSQL Injection to check whether # the input is matched by any of them. The total score of all patterns matched is # returned, unless the individual matching pattern score matches or exceeds # NOSQL_CONFIDENCE_THRESHOLD *and* the total score for all evaluations matches or # exceeds NOSQL_DEFINITE_THRESHOLD, in which case the total score achieved to that # point is returned. # # @param value [String] the value of the input. # @param value [Integer] the total score thus far. # # @return res [Integer] def evaluate_patterns value, total_score = 0 applicable_patterns = nosqli_patterns return total_score if applicable_patterns.nil? total_patterns_score = 0 applicable_patterns.each do |pattern| next unless value.match?(pattern[:value]) total_patterns_score += pattern[:score].to_i total_score += pattern[:score].to_i if pattern[:score].to_i >= NOSQL_CONFIDENCE_THRESHOLD && definite_attack?(total_score) return total_score end end total_score end def evaluate_comment_rule value (value =~ NOSQL_COMMENT_REGEXP).nil? ? 0 : 2 # None/Medium end # This method evaluates the searcher rules relevant to NoSQL Injection to check whether # the input is matched by any of them. The total score of all rules matched is # returned, unless the individual matching rukle score matches or exceeds # NOSQL_CONFIDENCE_THRESHOLD *and* the total score for all evaluations matches or # exceeds NOSQL_DEFINITE_THRESHOLD, in which case the total score achieved to that # point is returned. # # @param value [String] the value of the input. # @param value [Integer] the total score thus far. # # @return res [Integer] def evaluate_rules value, total_score = 0 total_rules_score = 0 %i[evaluate_comment_rule evaluate_or_rule].each do |method| rule_score = send(method, value) total_rules_score += rule_score total_score += rule_score return total_score if rule_score >= NOSQL_CONFIDENCE_THRESHOLD && definite_attack?(total_score) end total_score end # This method evaluates the input value for NoSQL Or searches. Each possible match is # checked. If a match is found then a score of either 3 (High) or 4 (Critical) will # be returned, depending on whether the regexp for finding comments is also matched. # # @param value [String] the value of the input. # # @return res [Integer] def evaluate_or_rule value score = 0 locs = matches_by_position value, NOSQL_OR_REGEXP return score if locs.empty? locs.each do |loc| [NOSQL_QUOTED_REGEXP, NOSQL_NUMERIC_REGEXP].each do |pattern| pattern_locs = matches_by_position(value[loc[1]..], pattern) next unless !pattern_locs.empty? && pattern_locs[0][0] < MAX_DISTANCE return 4 if value.match?(NOSQL_COMMENTS_AFTER_REGEXP, loc[1] + pattern_locs[0][1]) # Critical score = 3 # High break end end score end # This method returns the patterns to be checked against the input value. # # @return res [Array, nil] def nosqli_patterns server_features = Contrast::Agent::Reporting::Settings::ServerFeatures.new rule_definitions = server_features&.protect&.rule_definition_list rule_definitions&.find { |r| r[:name] == Contrast::Agent::Protect::Rule::NoSqli::NAME }&.dig(:patterns) end def matches_by_position value, pattern value.to_enum(:scan, pattern).map { Regexp.last_match.offset(Regexp.last_match.size - 1) } end def definite_attack? score score >= NOSQL_DEFINITE_THRESHOLD end def worth_watching? score score >= NOSQL_WORTH_WATCHING_THRESHOLD end end end end end end end