# Copyright (c) 2023 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/no_sqli' require 'contrast/agent/protect/input_analyzer/input_analyzer' require 'contrast/agent/protect/rule/input_classification/base' 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 Contrast::Agent::Protect::Rule::InputClassification::Base 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 DEFAULT_RULE_DEFINITIONS = [ { keywords: [], name: 'nosql-injection', patterns: [ { caseSensitive: false, id: 'NO-SQLI-1', score: 1, value: '(?:\\{\\s*\".*\"\\s*:.*\\})' }, { id: 'NO-SQLI-2', caseSensitive: true, score: 3, value: "(?:\"|')?\\$(?:gte|gt|lt|lte|eq|ne|in|nin|where|mod|all|size|exists|type|slice|or)(?:\"|')?\\s*:.*" # rubocop:disable Layout/LineLength } ] } ].cs__freeze 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<String>] 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<String>] the value of the input. # # @return res [Contrast::Agent::Reporting::InputAnalysisResult] def create_new_input_result request, rule_id, input_type, value return unless Contrast::AGENT_LIB # Cache retrieve cached = Contrast::Agent::Protect::InputAnalyzer.lru_cache.lookout(rule_id, value, input_type, request) return cached.result if cached.cs__is_a?(Contrast::Agent::Protect::InputClassification::CachedResult) eval_value = base64_decode_input(value, input_type) score = evaluate_patterns(eval_value) score = evaluate_rules(eval_value, score) score_level = if definite_attack?(score) DEFINITEATTACK elsif worth_watching?(score) WORTHWATCHING else IGNORE end ia_result = new_ia_result(rule_id, input_type, score_level, request.path, value) add_needed_key(request, ia_result, input_type, value) if KEYS_NEEDED.include?(input_type) # Cache save. Cache must be saved after the input evaluation is completed. Contrast::Agent::Protect::InputAnalyzer.lru_cache.save(rule_id, ia_result, request) ia_result 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 total_score [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 total_score [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 = DEFAULT_RULE_DEFINITIONS if rule_definitions.empty? 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