# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/telemetry/exception' module Contrast module Logger # Our decorator for the Ougai logger allowing for the catching, creating and saving Telemetry exceptions module AliasedLogging ALIASED_WARN = 'warn'.cs__freeze ALIASED_ERROR = 'error'.cs__freeze ALIASED_FATAL = 'fatal'.cs__freeze # @param message [String] The message to log. Use default_message if not specified. # @param exception [Exception] The exception or the error # @param data [Object] Any structured data def warn message = nil, exception = nil, data = nil, &block # build Telemetry Exclusion build_exception(ALIASED_WARN, message, exception, data) super(message, exception, data, &block) end # @param message [String] The message to log. Use default_message if not specified. # @param exception [Exception] The exception or the error # @param data [Object] Any structured data def error message = nil, exception = nil, data = nil, &block # build Telemetry Exclusion build_exception(ALIASED_ERROR, message, exception, data) super(message, exception, data, &block) end # @param message [String] The message to log. Use default_message if not specified. # @param exception [Exception] The exception or the error # @param data [Object] Any structured data def fatal message = nil, exception = nil, data = nil, &block # build Telemetry Exclusion build_exception(ALIASED_FATAL, message, exception, data) super(message, exception, data, &block) end private # There's a weird circular import in our code that we don't have time to untangle. This is 100% bad code and # I'm sorry to the future team, but this is all we got. # # If the configuration we need is not available, we'll skip out for now - it means the agent isn't ready. We # won't get telemetry exceptions at this point, but that means the agent hasn't initialized and there's a # chance we're not supposed to. Essentially, we fail closed. # # Once we have the agent initialized, we'll use the value to check. Since it cannot change once set, we'll use # the saved value. # # - HM # # @return [Boolean] def buildable? if @_buildable.nil? return false unless defined?(Contrast) && defined?(Contrast::Agent) && defined?(Contrast::Agent::Telemetry) @_buildable = Contrast::Agent::Telemetry.exceptions_enabled? end @_buildable end # @param type [ALIASED_FATAL, ALIASED_ERROR, ALIASED_WARN] the type of error, used to indicate the function used # for logging # @param message [String] the exception message # @param exception [Exception] The exception or error # @param data [Object] Any structured data def build_exception type, message = nil, exception = nil, data = nil return unless buildable? caller_idx = wrapped_caller_locations&.find_index { |stack| stack.to_s.include?(type) } || 0 caller_idx += 1 caller = wrapped_caller_locations[caller_idx] stack_frame_type = obfuscate_type(caller) stack_frame_function = caller.label key = "#{ stack_frame_type }|#{ stack_frame_function }|#{ message }" if Contrast::TELEMETRY_EXCEPTIONS[key] Contrast::TELEMETRY_EXCEPTIONS.increment(key) return end return if Contrast::TELEMETRY_EXCEPTIONS.exception_limit? message_exception_type = exception ? exception.cs__class.to_s : stack_frame_type.split('/').last event_message = create_message(stack_frame_function, stack_frame_type, message_exception_type, data, exception, message) build_stack(event_message, wrapped_caller_locations, caller_idx) TELEMETRY_EXCEPTIONS[key] = event_message rescue StandardError => e debug('[Telemetry] Unable to report exception', e) end # @return [Contrast::Agent::Telemetry::Exception::Event] def create_message stack_frame_function, stack_frame_type, message_exception_type, data, exception, message message_for_exception = if exception exception.cs__respond_to?(:message) ? exception.message : exception else message end module_name = message_exception_type ? message_exception_type.split('::')[0] : nil stack_frame = Contrast::Agent::Telemetry::Exception::StackFrame.build(stack_frame_function, stack_frame_type, module_name) message_exception = Contrast::Agent::Telemetry::Exception::MessageException.build( message_exception_type, message_for_exception, module_name, stack_frame) tags = if data data else exception.cs__is_a?(Hash) ? exception : {} end message = Contrast::Agent::Telemetry::Exception::Message.build(tags, [message_exception]) Contrast::Agent::Telemetry::Exception::Event.new(message) rescue ArgumentError => e debug('[Telemetry] TelemetryException failed from aliased logging with: ', e) end # Convert the given caller_stack from the event into the exception StackFrame format for reporting, appending it # to the Exception wrapped in the Event. # # @param event_message [Contrast::Agent::Telemetry::Exception::Event] # @param caller_stack [(Array, nil] # @param caller_idx [Integer] the starting location for the exception, which allows filtering out the logger code # from the stack def build_stack event_message, caller_stack, caller_idx = 0 return unless caller_stack event_exception = event_message.exceptions[0] event_exception_message = event_exception.exceptions[0] caller_stack.each_with_index do |caller, idx| next unless idx > caller_idx obfuscated_label = Contrast::Agent::Telemetry::Exception::Obfuscate.obfuscate_path(caller.label) obfuscated_path = Contrast::Agent::Telemetry::Exception::Obfuscate. obfuscate_path(caller.path.delete_prefix(Dir.pwd)) stack_frame = Contrast::Agent::Telemetry::Exception::StackFrame.build(obfuscated_label, obfuscated_path) event_exception_message.push(stack_frame) end end # This is purely a wrapper around caller_locations used for testing # # @return [Array, nil] def wrapped_caller_locations caller_locations end # @param caller [Thread::Backtrace::Location, nil] # @return [String] def obfuscate_type caller return '' unless caller.cs__respond_to?(:path) Contrast::Agent::Telemetry::Exception::Obfuscate.obfuscate_path(caller.path.delete_prefix(Dir.pwd).to_s) end end end end