# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/reporting/reporting_utilities/response_extractor' require 'contrast/agent/disable_reaction' module Contrast module Agent module Reporting # This module holds utilities required by the reporting service response handler module ResponseHandlerUtils include Contrast::Agent::Reporting::ResponseExtractor ANALYZE_WHEN = %w[200 204].cs__freeze ERROR_CODES = { message_not_sent: '400', access_forbidden: '401', access_forbidden_no_action: '403', application_do_not_exist: '404', unprocessable_entity: '422', too_many_requests: '429' }.cs__freeze APP_NON_EXISTENT_MSG = 'Application does not exist! Either it has not been created or has '\ 'been deleted or archived. '\ 'Disabling permanently.' SUSPEND_MSG = 'Reporter is temporarily suspended.' UNSUCCESSFULLY_RECEIVED_MSG = 'The Reporter was unable to send message.' FORBIDDEN_MSG = 'Access was forbidden for current Report because the request authentication '\ 'information was not provided' FORBIDDEN_NO_ACTION_MSG = 'Report access was forbidden because the supplied credentials failed '\ 'to authenticate the Agent' UNPROCESSABLE_ENTITY_MSG = 'Reporter received Unprocessable Entity response. Disabling permanently.' RETRY_AFTER_MSG = "There are too many requests of this type being sent by this Agent. #{ SUSPEND_MSG }" private # check if response code is valid before analyze it # # @param response [Net::HTTP::Response, nil] # @return [Boolean] def analyze_response? response # Code descriptions: # 200: # Message successfully received and there are new settings # 204: # Message successfully received and it's up to Contrast Server to decide what is done with the data. # 304: # Message successfully received and there are no new settings. Use your current ones. # # ERRORS: # 400: # Message unsuccessfully received. The Contrast Server was unable to process the message properly. # 401: # Access was forbidden because the request authentication information was not provided. # headers: # www-Authenticate => Indicate that the API-Key and Authorization header are both required, # in the standard format per RFC 2616 # 403: # Access was forbidden because the supplied credentials failed to authenticate the Agent. # 404: # The application does not exist - either it has not been created or has been deleted or archived. # If possible, the Agent should no longer analyze this application. For those Agents with multiple # applications in a single process, at a minimum, cease reporting about this application. # 422: # Unprocessable Entity - The application startup is rejected because some piece of data is incorrect. # The session_id could reference a non-existent session or the metadata (not session_metadata) could # fail a constraint check. TeamServer should indicate this is an error message which the Agent should # log. The Agent should no longer analyze this application. # { # "error": "string" # } # 429: # There are too many requests of this type being sent by this Agent. Back off for the time listed in # the Retry-After header. In this case, it is on the Agent to determine if it is safe to hold onto the # data to attempt to send again or if it needs to be dropped. The Contrast Server can choose what to do # with message from improperly throttled Agents, including dropping them. # header: # Retry-After => how long, in seconds, to wait before attempting to send another request to this endpoint, # in the standard format per RFC 2616 # used for in observed routes message. return false unless response && (response_code = response&.code) return true if ANALYZE_WHEN.include?(response_code) handle_error(response) if ERROR_CODES.value?(response_code) # There was error, so analyze the Error and nothing more. false end # Analyze the headers of the response code. They have information about the # retry timeout and some response bodies contains error messages. # # @param response [String] the response code from Net::HTTPResponse, which is obnoxiousy a String, not an # Integer # @param message [String] Message to log. # @param mode [Symbol, nil] def handle_response_errors response, message, mode # Set the current mode status. @_mode.status = mode ready_after, error_message, auth_error = extract_response_info(response) # log, suspend, disable: if mode == @_mode.running log_debug_msg(message, response: response.__id__, request: Contrast::Agent::REQUEST_TRACKER.current&.request&.type, error_message: error_message || 'none', auth_error: auth_error || 'none') end suspend_reporting(message, ready_after, error_message) if mode == @_mode.resending return unless mode == @_mode.disabled stop_reporting(message, application: Contrast::APP_CONTEXT.name, error_message: error_message) # rubocop:disable Security/Module/Name rescue StandardError => e logger.debug('Could not handle Response error information', error: e) end # Extract what we've received. # # @param response [Net::HTTP::Response, nil] # @return [Array] all collected error info. def extract_response_info response # Extract what we got from the response: ready_after = response['Ready-After'] if response.to_hash.keys.map(&:downcase).include?('ready-after') if response.to_hash.keys.map(&:downcase).include?('www-authenticate') auth_error = response['www-Authenticate'] end error_message = response.message [ready_after.to_i, error_message, auth_error] end # Cease reporting about this application # # @param message [String] Message to log # @param info_hash [Hash] information about the context to log. def stop_reporting message, info_hash Contrast::Agent.reporter&.stop! log_debug_msg(message, info_hash) ::Contrast::AGENT.disable! end # Applies the settings from the TS response # # @param response [Contrast::Agent::Reporting::Response] def update_agent_settings response return unless response ::Contrast::SETTINGS.update_from_server_features(response) if response.server_features ::Contrast::SETTINGS.update_from_application_settings(response) if response.application_settings end # Process the given Reactions from the application settings based on what # TeamServer has indicated. Each Reaction will result in a log message # and, optionally, an action. # # @param response [Contrast::Agent::Reporting::Response] def update_reaction response return unless response&.reactions&.any? response.reactions.each do |reaction| # The enums are all uppercase, we need to downcase them before attempting to log. level = reaction.level.nil? ? :error : reaction.level.downcase logger.with_level(level, reaction.message) if reaction.message case reaction.operation when Contrast::Agent::Reporting::Settings::Reaction::OPERATIONS[1] # DISABLED Contrast::Agent::DisableReaction.run(reaction, level) when Contrast::Agent::Reporting::Settings::Reaction::OPERATIONS[0] # NOOP else logger.warn('ReactionProcessor received a reaction with an unknown operation', operation: reaction.operation) end end end # This can't go in the Settings component because protect and assess depend on settings # I don't think it should go into contrast_service because that only handles connection specific data. # # @param response [Contrast::Agent::Reporting::Response] def update_ruleset response logger.info('Updating features from TeamServer') return unless response&.server_features || response&.application_settings return unless ::Contrast::AGENT.enabled? logger.trace_with_time('Rebuilding rule modes from TeamServer') do ::Contrast::SETTINGS.build_protect_rules if ::Contrast::PROTECT.enabled? ::Contrast::AGENT.reset_ruleset logger.info('Current rule settings:') ::Contrast::PROTECT.defend_rules.each { |k, v| logger.info('Protect Rule mode set', rule: k, mode: v.mode) } logger.info('Disabled Assess Rules', rules: ::Contrast::ASSESS.disabled_rules) end end # Converts response from Net to Reporting Response object # # @param response [Net::HTTP::Response, nil] # @return response [Contrast::Agent::Reporting::Response] def convert_response response response_body = response&.body return unless response_body response_data = JSON.parse(response_body, symbolize_names: true) return unless response_data.cs__is_a?(Hash) populate_response(response_data) rescue StandardError => e logger.error('Unable to convert response', e) nil end # Extracts the data from the response and coverts it to # Contrast::Agent::Reporting::Response. # # @param response_data[Hash] # @return response [Contrast::Agent::Reporting::Response] def populate_response response_data return unless (success, messages = extract_success(response_data)) # check if response contains application settings or Feature settings if response_data[:settings] # the response contains ApplicationSettings response = Contrast::Agent::Reporting::Response.build_application_response response.success = success response.messages = messages app_settings = build_application_settings(response_data, response) logger.trace('Agent: Received updated application settings', raw: response_data, processed: app_settings) app_settings else # the response contains FeatureSettings response = Contrast::Agent::Reporting::Response.build_server_response response.success = success response.messages = messages server_features = build_feature_settings(response_data, response) logger.trace('Agent: Received updated application settings', raw: response_data, processed: server_features) server_features end response end # @param response_data [Hash] # @return res [Contrast::Agent::Reporting::Response] def build_application_settings response_data, response extract_assess(response_data, response) extract_protect(response_data, response) extract_exclusions(response_data, response) extract_reactions(response_data, response) extract_sensitive_data_policy(response_data, response) response end # @param response_data [Hash] # @return res [Contrast::Agent::Reporting::Response] def build_feature_settings response_data, response extract_reactions(response_data, response) extract_assess_server_features(response_data, response) extract_protect_server_features(response_data, response) extract_protect_lists(response_data, response) extract_log_settings(response_data, response) response.server_features.telemetry = response_data[:telemetry] response end # This method with check the success and messages field of the response. # If the success is false, then it will return nil and log the error. # # @param response_data [Hash] # @return [Array, nil] Returns the success status or nil if request # was not processed by TS. def extract_success response_data success = response_data[:success] messages = response_data[:messages] return [success, messages] if success logger.error('Unable to connect to Contrast UI') if messages.nil? logger.error('Failure on Contrast UI processing request', reasons: messages.join(', ')) if messages nil end end end end end