# 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