# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/components/logger' require 'fileutils' require 'json' module Contrast module Agent module Reporting # This class will facilitate the Audit functionality and it will be # controlled from the configuration classes class Audit include Contrast::Components::Logger::InstanceMethods attr_reader :path_for_requests, :path_for_responses def initialize generate_paths if enabled? end # This method will be handling the auditing of the requests and responses we send to TeamServer. If the audit # feature is enabled, we'll log to file the request and/or response JSON objects. # # @param event [Contrast::Agent::Reporting::ReportingEvent] One of the DTMs valid for the # event field of Contrast::Agent::Reporting::ReportingEvent # @param response_data [Net::HTTP::Response] def audit_event event, response_data = nil return unless ::Contrast::API.request_audit_requests || ::Contrast::API.request_audit_responses file_name = event.cs__respond_to?(:file_name) ? event.file_name : event.cs__class.cs__name.to_s.downcase data = event.event_json log_data(:request, file_name, data) if data return unless ::Contrast::API.request_audit_responses data = response_data&.body || { response_code: response_data.code, response_body: 'There is no available response body' }.to_json log_data(:response, file_name, data) rescue StandardError => e logger.error('Audit failed with error: ', event: event, error: e) end private # This method will proceed with passing the data with to the writing method # @param type [Symbol] This is the type of the file /:request, :response/ # @param file_name[String] file_name to log # @param data[String] String representation if the logged data def log_data type, file_name, data = nil write_to_file(type, file_name, data) if enabled? end # This method will be actually writing to the file # @param type [Symbol] This is the type of the file /:request, :response/ # @param event_name [String] the type portion of the file to which to write # @param data [any] The data to be written to the file def write_to_file type, event_name, data = nil time = Time.now.to_i destination = type == :request ? path_for_requests : path_for_responses # If the feature is disabled or we have yet to create the directory structure, then we could have a nil # destination. In that case, take no action return unless destination filename = "#{ time }_#{ Thread.current.object_id }_#{ event_name.gsub('::', '-') }_teamserver_#{ type }.json" filepath = File.join(destination, filename) logger.debug('Writing to file', eventname: event_name, filename: filename, filepath: filepath) # Here is use append mode, because of a slightly possibility of overwriting an existing file File.open(filepath, 'a') { |f| f.write(Contrast::Utils::StringUtils.force_utf8(data)) } rescue StandardError => e logger.warn('Saving audit failed', e: e) end # Here we will generate the directories for the requests and responses def generate_paths message_directories = File.expand_path(path_to_audits) make_directory(message_directories) unless Dir.exist?(message_directories) requests_destination = File.expand_path(File.join(message_directories, '/requests')) responses_destination = File.expand_path(File.join(message_directories, '/responses')) make_directory(requests_destination) if enabled_for_requests? && !Dir.exist?(requests_destination) make_directory(responses_destination) if enabled_for_responses? && !Dir.exist?(responses_destination) @path_for_requests ||= requests_destination if enabled_for_requests? @path_for_responses ||= responses_destination if enabled_for_responses? rescue StandardError => e logger.warn('Generating the paths failed with: ', e: e) end # Make the directory provided, including any required intermediary directories. # We do this here in order to make allow us to easily override directory # creation while testing. def make_directory directory FileUtils.mkdir_p(directory) end # Retrieves the configuration value if the request audit is enabled # @return [Boolean] def enabled? ::Contrast::API.request_audit_enable end # The boolean values for the requests and the responses should be taken under # consideration only if it's in combination with enabled # So in order for us to actually audit the requests, we need: # - enabled? -> ture and enabled_for_requests? -> true # The same is for the responses # # # Retrieve the configuration value if the audit for requests is enabled # @return [Boolean] def enabled_for_requests? ::Contrast::API.request_audit_requests end # Retrieve the configuration value if the audit for responses is enabled # @return [Boolean] def enabled_for_responses? ::Contrast::API.request_audit_requests end # Retrieve the configuration value for the path of the audits # The value will be read from the configuration yml # but if it isn't defined any - here will be returned the default path from # Contrast::Config::RequestAuditConfiguration::DEFAULT_PATH # @return [String] def path_to_audits ::Contrast::API.request_audit_path end end end end end