# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'logger' require 'ougai' require 'singleton' require 'contrast/extension/module' require 'contrast/logger/application' require 'contrast/logger/format' require 'contrast/logger/request' require 'contrast/logger/time' require 'contrast/components/config' module Contrast # This module allows us to dynamically weave timing into our code, so that only when the time is actually needed do # we pay the penalty for that timing block module TraceTiming def methods_to_time @_methods_to_time ||= [] end # Store info about methods for later patching. METHOD_INFO = Struct.new(:clazz, :method_name, :custom_msg, :aliased) # Add a method to the list of methods to be trace timed if logger set to TRACE. Enables trace timing after if # logger set to TRACE. # # @params: clazz [Class] the class of the method to time. # @params: method [Symbol] the method to time. # @params: method [String] optional custom logging message. def add_method_to_trace_timing clazz, method, msg = nil methods_to_time.append(METHOD_INFO.new(clazz, method, msg, false)) enable_trace_timing if logger.level == ::Ougai::Logging::TRACE end # Add a method to the list of methods to be trace timed if logger set to TRACE. Enables trace timing after if # logger set to TRACE. # # @params: method_spec [METHOD_INFO] specs about the method to be timed. # @params: class_method [Boolean] whether this is or isn't a class/module method. def trace_time_class_method meth_spec, class_method untimed_func_symbol = "untimed_#{ meth_spec.method_name }".to_sym send_to = class_method ? meth_spec.clazz.cs__singleton_class : meth_spec.clazz meth_spec.clazz.class_eval do include Contrast::Components::Logger::InstanceMethods extend Contrast::Components::Logger::InstanceMethods send_to.send(:alias_method, untimed_func_symbol, meth_spec.method_name) meth_spec.aliased = true log_message = "Elapsed time for #{ meth_spec.method_name }." log_message = meth_spec.custom_message if meth_spec.custom_msg send_to.send(:define_method, meth_spec.method_name) do |*args, **kwargs, &block| # rubocop:disable Performance/Kernel/DefineMethod start = Process.clock_gettime(Process::CLOCK_MONOTONIC) rv = if kwargs.empty? send(untimed_func_symbol, *args, &block) else send(untimed_func_symbol, *args, **kwargs, &block) end delta = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start logger.trace(log_message, elapsed: delta * 1000) rv end end end # Enable trace timing of methods specified in @_methods_to_time via aliasing. def enable_trace_timing methods_to_time.each do |meth_spec| next if meth_spec.aliased is_class_method = meth_spec.clazz.singleton_methods(false).include?(meth_spec.method_name) trace_time_class_method meth_spec, is_class_method end end end end module Contrast # Used as a wrapper around our logging. The module option specifically adds in a new method for error that raises the # logged exception, used in testing so that we can see if anything unexpected happens without it being swallowed # while still providing safe options for customers. module Logger # For development set following env var to raise logged exceptions instead of just logging. if ENV['CONTRAST__AGENT__RUBY_MORE_COWBELL'] Ougai::Logger.class_eval do alias_method :cs__error, :error alias_method :cs__warn, :warn def error *args, **kwargs if kwargs.empty? cs__error(*args) else cs__error(*args, **kwargs) end args.each { |arg| raise arg if arg && arg.cs__class < Exception } end end end # This class functions to serve as a wrapper around our logging, as we need to be able to dynamically update # level based on updates to TeamServer. class Log include Singleton include ::Contrast::TraceTiming DEFAULT_NAME = 'contrast.log' DEFAULT_LEVEL = ::Ougai::Logging::Severity::INFO VALID_LEVELS = ::Ougai::Logging::Severity::SEV_LABEL STDOUT_STR = 'STDOUT' STDERR_STR = 'STDERR' attr_reader :previous_path, :previous_level def initialize update end # Given new settings from TeamServer, update our logging to use the new file and level, assuming they weren't # set by local configuration. # # @param log_file [String] the file to which to log, as provided by TeamServer settings # @param log_level [String] the level at which to log, as provided by TeamServer settings def update log_file = nil, log_level = nil current_path = find_valid_path(log_file) current_level_const = find_valid_level(log_level) path_change = current_path != previous_path level_change = current_level_const != previous_level # don't needlessly recreate logger return if @_logger && !(path_change || level_change) @previous_path = current_path @previous_level = current_level_const enable_trace_timing if current_level_const == ::Ougai::Logging::TRACE @_logger = build(path: current_path, level_const: current_level_const) # If we're logging to a new path, then let's start it w/ our helpful # data gathering messages log_update if path_change rescue StandardError => e if logger logger.error('Unable to process update to LoggerManager.', e) else puts 'Unable to process update to LoggerManager.' raise e if ENV['CONTRAST__AGENT__RUBY_MORE_COWBELL'] puts e.message puts e.backtrace.join("\n") end end def logger @_logger end # 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)) File.writable?(dir_name) end private def build path: STDOUT_STR, level_const: DEFAULT_LEVEL 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 = 'Contrast Agent' logger.level = level_const logger.formatter = Contrast::Logger::Format.new logger.formatter.datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z' logger end def add_contrast_loggers logger logger.extend(Contrast::Logger::Application) logger.extend(Contrast::Logger::Request) logger.extend(Contrast::Logger::Time) 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 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. puts "[!] Unable to write to '#{ path }'. Writing to standard out instead." if previous_path != STDOUT_STR 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