# Copyright (c) 2023 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/utils/object_share' require 'contrast/logger/aliased_logging' require 'fileutils' module Contrast module Utils # Method utility used by Contrast::Logger::log module LogUtils DEFAULT_NAME = 'contrast.log' DEFAULT_LEVEL = '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' # StringIO is a valid path because it logs directly to a string buffer def write_permission? path return false if path.nil? return true if path.is_a?(StringIO) return File.writable?(path) if File.exist?(path) dir_name = File.dirname(File.absolute_path(path)) FileUtils.mkdir_p(dir_name) File.writable?(dir_name) end 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) 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.agent.logger config_path = config&.path&.length.to_i.positive? ? config.path : nil valid_path(config_path || log_file) end def valid_path path, default_name: DEFAULT_NAME 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.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 # CEF:|||| # ||| 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 context = Contrast::Agent::REQUEST_TRACKER.current 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 update_logger_formatter(logger, new_context: context) logger end def context @_context ||= Contrast::Agent::REQUEST_TRACKER.current end def context_update new_context @_context = new_context unless new_context.nil? end # @param logger [Logger] # @param new_context [Contrast::Agent::RequestContext] def update_logger_formatter logger, new_context: nil context_update(new_context) if new_context 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 def valid_level level level ||= DEFAULT_LEVEL level = level.upcase if VALID_LEVELS.include?(level) Object.cs__const_get("::Logger::Severity::#{ level }") else DEFAULT_LEVEL end rescue StandardError DEFAULT_LEVEL end # This method will extract the metadata information from context and other places # # initial structure of the data: # := " "" "" "" "" \ # "" " # 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 = [] request = context&.activity&.request sender_info = { ip: request&.ip || Contrast::Utils::ObjectShare::EMPTY_STRING, port: request&.port || 0 } rule_id ? message << "pri=#{ rule_id } " : 'asd' request_method = assign_request_method(context) app_name = ::Contrast::APP_CONTEXT.name # rubocop:disable Security/Module/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&.request&.headers&.transform_keys(&:to_s) return unless request_headers request_headers['X-Forwarded-For'] end def assign_request_method context request_method = context&.request&.rack_request&.env request_method = request_method['REQUEST_METHOD'] if request_method return DEFAULT_METADATA if request_method.nil? || !request_method.length.positive? request_method end end end end