# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/reporting/reporting_utilities/ng_response_extractor' require 'contrast/agent/reporting/reporting_utilities/response_extractor' require 'contrast/agent/reactions/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::NgResponseExtractor 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 }" def last_response_code @_last_response_code ||= '' end # String format of the server last modified Header:, :: GMT # # @return [String] def last_server_modified @_last_server_modified end # String format of the app last modified Header:, :: GMT # # @return [String] def last_application_modified @_last_application_modified end 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) # We still need to check the response code even if we are not analyzing it, since the 304 code does not # contain settings to be extracted but we still need to know for the diagnostics. Do not move this bellow # the ANALYZE_WHEN check. @_last_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_error_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, backtrace: e.backtrace) 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 # Extract Last-Modified header from ServerSettings response. # The new GET server settings endpoint have different payload. # Extract the last modify headers with last update form TS. # # @param response [Net::HTTP::Response, nil] # @param event [Contrast::Agent::Reporting::ServerSettings, # Contrast::Agent::Reporting::ApplicationSettings, nil] # @return last_modified[integer, nil] Time since last server update def extract_response_last_modified response, event return unless response.cs__is_a?(Net::HTTPResponse) return unless (header = response['last-modified']) case event # Server feature based events when Contrast::Agent::Reporting::ServerSettings @_last_server_modified = header # Application settings based events when Contrast::Agent::Reporting::ApplicationSettings @_last_application_modified = header end 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_error_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. Unfortunately, there are four types of responses # that TeamServer can send back to us. The FeatureSet for Servers, which come from Agent Startup and Server # Settings, and the SettingsState for Applications, which come from Application Startup and Application # Settings. # # The Startup messages come from NG and have the nested structure w/ success, message, and features/settings. # The Settings messages come from v1 and have the flat structure. # Neither have uniform keys, for instance assessment in startup vs assess in settings. # # This method works to extract away these differences. # # @param response [Net::HTTP::Response, nil] # @param event [Contrast::Agent::Reporting::ReportingEvent] The event sent to TeamServer. # @return response [Contrast::Agent::Reporting::Response] def convert_response response, event 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) extract_response_last_modified(response, event) populate_response(response_data, event) 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. # The response is being checked for it's type and settings received so the extractor methods # are invoked accordingly to the response type. # # @param response_data[Hash] # @param event [Contrast::Agent::Reporting::ReportingEvent] The event sent to TeamServer. # this is used to check the expected response type for this event. # @return response [Contrast::Agent::Reporting::Response, nil] def populate_response response_data, event # Responses fall into one of two types - those for Servers or those for Applications response = case event # Server feature based events when Contrast::Agent::Reporting::AgentStartup, Contrast::Agent::Reporting::ServerSettings Contrast::Agent::Reporting::Response.build_server_response # Application settings based events when Contrast::Agent::Reporting::ApplicationStartup, Contrast::Agent::Reporting::ApplicationSettings Contrast::Agent::Reporting::Response.build_application_response end return unless response return unless (success, messages = extract_success(response_data)) response.success = success response.messages = messages # Features & Settings have to be parsed from the response based on the event type sent case event when Contrast::Agent::Reporting::AgentStartup extract_agent_startup(response_data, response) when Contrast::Agent::Reporting::ApplicationStartup extract_application_startup(response_data, response) when Contrast::Agent::Reporting::ServerSettings extract_server_settings(response_data, response) when Contrast::Agent::Reporting::ApplicationSettings extract_application_settings(response_data, response) else return end logger.trace('Agent: Received updated features or settings', event: event.cs__class, raw: response_data, processed: response) response end # This method is used with the ng endpoint. # # @param response_data [Hash] JSON of the response body from a Contrast::Agent::Reporting::ApplicationStartup # event # @param response [Contrast::Agent::Reporting::Response] the object to populate with the body data def extract_application_startup response_data, response return unless response_data[:settings] ng_extract_assess(response_data, response) ng_extract_protect(response_data, response) ng_extract_exclusions(response_data, response) ng_extract_reactions(response_data, response) ng_extract_sensitive_data_policy(response_data, response) end # This method is used with the ng startup endpoint. # # @param response_data [Hash] JSON of the response body from a Contrast::Agent::Reporting::AgentStartup event # @param response [Contrast::Agent::Reporting::Response] the object to populate with the body data def extract_agent_startup response_data, response ng_extract_log_settings(response_data, response) response.server_features.telemetry = response_data[:telemetry] return unless response_data[:features] ng_extract_reactions(response_data, response) ng_extract_assess_features(response_data, response) ng_extract_protect_features(response_data, response) ng_extract_protect_lists(response_data, response) end # This method is used with the server settings endpoint. # # @param response_data [Hash] JSON of the response body from a Contrast::Agent::Reporting::ServerSettings event # @param response [Contrast::Agent::Reporting::Response] the object to populate with the body data def extract_server_settings response_data, response response.server_features.telemetry = response_data[:telemetry] if response_data[:telemetry] extract_loggers(response_data, response) extract_protect_server_settings(response_data, response) extract_assess_server_settings(response_data, response) response.server_features.telemetry = response_data[:telemetry][:enable] end # This method is used with the ng startup endpoint. # # @param response_data [Hash] JSON of the response body from a Contrast::Agent::Reporting::ApplicationSettings # event # @param response [Contrast::Agent::Reporting::Response] the object to populate with the body data def extract_application_settings response_data, response extract_assess_application_settings(response_data, response) extract_protect_application_settings(response_data, response) extract_exclusions(response_data, response) extract_sensitive_data_policy(response_data, response) extract_reactions(response_data, 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 # If we are here we have receive 200 or 204 response code. We'll try and # extract the success and messages received,but not all of the responses # we receive will have success field or messages. All of the new non-ng # endpoints won't have messages or success. The way we'll be sure that # a response is successful is by checking the response code. # # To extract response we need only 200 response code. success = @_last_response_code == '200' messages = response_data[:messages] || [] return [success, messages] if success nil end end end end end