# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'socket'
require 'contrast/agent/version'
require 'contrast/logger/aliased_logging'

module Contrast
  module Utils
    # Method utility used by Contrast::Logger::log
    module LogUtils
      DEFAULT_NAME     = 'contrast.log'
      DEFAULT_LEVEL    = ::Ougai::Logging::Severity::INFO
      VALID_LEVELS     = ::Ougai::Logging::Severity::SEV_LABEL
      STDOUT_STR       = 'STDOUT'
      STDERR_STR       = 'STDERR'
      PROGNAME         = 'Contrast Agent'
      DATE_TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%L%z'

      private

      def build path: STDOUT_STR, level_const: DEFAULT_LEVEL, progname: PROGNAME
        logger = case path
                 when STDOUT_STR, STDERR_STR
                   ::Ougai::Logger.new(Object.cs__const_get(path))
                 else
                   ::Ougai::Logger.new(path)
                 end
        add_contrast_loggers(logger)
        logger.progname = progname
        logger.level = level_const
        logger.formatter = Contrast::Logger::Format.new
        logger.formatter.datetime_format = DATE_TIME_FORMAT
        logger
      end

      def add_contrast_loggers logger
        logger.extend(Contrast::Logger::Application)
        logger.extend(Contrast::Logger::Request)
        logger.extend(Contrast::Logger::Time)
        logger.extend(Contrast::Logger::AliasedLogging) if Contrast::Utils::Telemetry.exceptions_enabled?
      end

      # Determine the valid path to which to log, given the precedence of config > settings > default.
      #
      # @param log_file [String, nil] the file to which to log as provided by the settings retrieved from the
      #   TeamServer.
      # @return [String] the path to which to log or STDOUT / STDERR if one of those values provided.
      def find_valid_path log_file
        config = ::Contrast::CONFIG.root.agent.logger
        config_path = config&.path&.length.to_i.positive? ? config.path : nil
        valid_path(config_path || log_file)
      end

      def valid_path path
        path = path.nil? ? Contrast::Utils::ObjectShare::EMPTY_STRING : path
        return path if path == STDOUT_STR
        return path if path == STDERR_STR

        path = DEFAULT_NAME if path.empty?
        if write_permission?(path)
          path
        elsif write_permission?(DEFAULT_NAME)
          # Log once when the path is invalid. We'll change to this path, so no
          # need to log again.
          if previous_path != DEFAULT_NAME
            $stdout.puts("[!] Unable to write to '#{ path }'. Writing to default log '#{ DEFAULT_NAME }' instead.")
          end
          DEFAULT_NAME
        else
          # Log once when the path is invalid. We'll change to this path, so no
          # need to log again.
          $stdout.puts("[!] Unable to write to '#{ path }'. Writing to standard out instead.")
          STDOUT_STR
        end
      end

      # Determine the valid level to which to log, given the precedence of config > settings > default.
      #
      # @param log_level [String, nil] the level at which to log as provided by the settings retrieved from the
      #   TeamServer.
      # @return [::Ougai::Logging::Severity] the level at which to log
      def find_valid_level log_level
        config = ::Contrast::CONFIG.root.agent.logger
        config_level = config&.level&.length&.positive? ? config.level : nil

        valid_level(config_level || log_level)
      end

      def valid_level level
        level ||= DEFAULT_LEVEL
        level = level.upcase
        if VALID_LEVELS.include?(level)
          Object.cs__const_get("::Ougai::Logging::Severity::#{ level }")
        else
          DEFAULT_LEVEL
        end
      rescue StandardError
        DEFAULT_LEVEL
      end

      # Log that the Agent log has changed and include some default information at the start of the log.
      def log_update
        logger.debug('Initialized new contrast agent logger')
        logger.debug_with_time('middleware: log environment') do
          logger.application_environment
          logger.application_configuration
          logger.application_libraries
        end
      end
    end
  end
end

module Contrast
  module Utils
    # These are the utilities for the CEF Logger, which will use the default ruby logger as we are not
    # interested in special logging. We have the format we need and that's all we need. It would be useless
    # to use Ougai
    module CEFLogUtils
      include Contrast::Utils::LogUtils
      # <date> <host> CEF:<version>|<company>|<product>|<agent version>|<event type>
      # |<event message>|<severity>|<other name-value pairs>
      DEFAULT_CEF_NAME = 'security.log'
      DEFAULT_LEVEL    = ::Logger::Severity::INFO
      VALID_LEVELS     = ::Logger::SEV_LABEL
      PROGNAME         = 'Contrast Agent Ruby'
      DATE_TIME_FORMAT = '%b %d %Y %H:%M:%S.%L%z'
      AGENT_VERSION    = Contrast::Agent::VERSION
      EVENT_TYPE       = 'SECURITY'
      DEFAULT_METADATA = '-'

      private

      def build path: STDOUT_STR, level_const: DEFAULT_LEVEL
        logger = case path
                 when STDOUT_STR, STDERR_STR
                   ::Logger.new(Object.cs__const_get(path))
                 else
                   ::Logger.new(path)
                 end
        logger.progname = PROGNAME
        logger.level = level_const
        change_logger_formatter(logger)
        logger
      end

      def context
        Contrast::Agent::REQUEST_TRACKER.current
      end

      def change_logger_formatter logger
        ip_address = extract_ip_address
        logger.formatter = proc do |severity, datetime, progname, msg|
          date_format = datetime.strftime(DATE_TIME_FORMAT)
          message = []
          message << "#{ date_format } #{ ip_address }"
          message << 'CEF:0|Contrast Security'
          message << progname
          message << AGENT_VERSION
          message << EVENT_TYPE
          message << msg[0]
          message << severity
          message << extract_metadata(msg[1], msg[2])
          "#{ message.join('|') }\n"
        end
      end

      # This method will extract the metadata information from context and other places
      #
      # initial structure of the data:
      # <metadata> := <message-source>" "<source-ip>" "<source-port>" "<request-url>" "<request-method>" \
      # "<application>" "<outcome>
      # it could come from: blockEntry, lei, bbi(bot blocker), vp(virtual patch) or pri(rule)
      # initially here we will use case to add it
      def extract_metadata rule_id = nil, outcome = nil
        message = []
        sender_info = context&.activity&.http_request&.sender
        rule_id ? message << "pri=#{ rule_id } " : 'asd'
        request_method = if context.request.rack_request.env['REQUEST_METHOD'].length.positive?
                           context.request.rack_request.env['REQUEST_METHOD']
                         else
                           DEFAULT_METADATA
                         end
        app_name = ::Contrast::APP_CONTEXT.app_name
        attach_request_and_sender_info(message, sender_info)
        message << "request=#{ context.request.url } "
        message << "requestMethod=#{ request_method } "
        message << "app=#{ app_name } "
        message << "outcome=#{ outcome } "
      end

      def attach_request_and_sender_info message, sender_info
        # here, instead of the ip, we need to report the first non-private 'X-Forwarded-For' Header if available.
        # if not we return '-'
        needed_header = extract_sender_ip
        # I'm not sure if we should report the sender ip from the ActivityDtm
        src = if needed_header
                needed_header
              else
                sender_info.ip.length > 1 ? sender_info.ip : DEFAULT_METADATA
              end
        message << "src=#{ src }"
        message << "port=#{ sender_info.port }"
      end

      def extract_ip_address
        res = Socket.getifaddrs.reject do |ifaddr|
          !ifaddr.addr.ipv4? ||
              (ifaddr.flags & Socket::IFF_MULTICAST).zero? ||
              ifaddr.name != 'en0' # rubocop:disable Security/Module/Name
        end
        return unless res.length.positive?

        res[0].addr.ip_address
      end

      def extract_sender_ip
        request_headers = context.activity.http_request.request_headers&.transform_keys(&:to_s)
        request_headers['X-Forwarded-For']
      end
    end
  end
end