# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/excluder/excluder' require 'contrast/agent/reporting/settings/sensitive_data_masking' require 'contrast/components/config' require 'contrast/components/logger' require 'contrast/utils/duck_utils' module Contrast module Components # This component encapsulates the statefulness of settings. # When we say 'settings', we're referring specifically to external # directives (likely provided by TeamServer) about product operation. # 'Settings' is not a generic term for 'configurable stuff'. module Settings AGENT_STATE_BASE = Struct.new(:logger_path, :logger_level, :cef_logger_path, :cef_logger_level). new(nil, nil, nil, nil) APPLICATION_STATE_BASE = Struct.new(:modes_by_id).new({}) PROTECT_STATE_BASE = Struct.new(:enabled).new(false) ASSESS_STATE_BASE = Struct.new(:enabled, :sampling_settings, :disabled_assess_rules, :session_id). new(false, nil, [], nil) do def sampling_settings= new_val @sampling_settings = new_val Contrast::Utils::Assess::SamplingUtil.instance.update end end SENSITIVE_DATA_MASKING_BASE = Contrast::Agent::Reporting::Settings::SensitiveDataMasking.new # This is a class. class Interface # rubocop:disable Metrics/ClassLength include Contrast::Components::Logger::InstanceMethods extend Contrast::Components::Config # tainted_columns are database columns that receive unsanitized input. attr_reader :tainted_columns # This can probably go into assess_state? # Agent state. Used for extracting Agent level settings. # # logger_path[String] Path to the log file. # logger_level[String] Log level for the logger. # cef_logger_path[String] Path to the log file. # cef_logger_level[String] Log level for the logger. attr_reader :agent_state # Current state for Assess. # enabled [Boolean] Indicate if the assess feature set is enabled for this server or not. # # sampling [Hash] Hash of AssessSampling Used to control the sampling feature in the agent: { # baseline [Integer] The number of baseline requests to take before switching to sampling # for the window. # enabled [Boolean] If the sampling feature should be used or not. # frequency [Integer] The number of requests to skip before observing during the sampling # window after the baseline. # responseFrequency [Integer] # window [Integer] # } # # disabled_assess_rules [array] Assess rules to disable for this application. attr_reader :assess_state # Current State for Protect. # enabled [Boolean] Indicate if the protect feature set is enabled for this server or not. # # Protection rules are returned as: # rules [Hash MODE>, nil] Hash with rule_id as key and mode as value attr_reader :protect_state # Current Application State. # # modes_by_id [Hash Mode] Returns Hash with rules and their current mode. attr_reader :application_state # This the structure that will hold the masking rules send from TS. # # @return [Contrast::Agent::Reporting::Settings::SensitiveDataMasking]: # mask_http_body [Boolean] Policy flag to enable the use of masking on request body. # rules [Array] # Rules to follow when using the masking. Each rules contains Id [String] # and Keywords [Array]. attr_reader :sensitive_data_masking # @return [Integer] the time, in ms, that application settings last changed attr_reader :last_app_update_ms # @return [Integer] the time, in ms, that server settings last changed attr_reader :last_server_update_ms # @return [Contrast::Agent::Excluder] a wrapper around the exclusion rules for the application attr_reader :excluder # @return [String] The last update but in string format used to build request header. # This value should be sent be TS in the Last-Modified header to sync and save resources if the # two dates are the same. # format: , :: GMT attr_reader :server_settings_last_httpdate # @return [String] The last update but in string format used to build request header. # This value should be sent be TS in the Last-Modified header to sync and save resources if the # two dates are the same. # format: , :: GMT attr_reader :app_settings_last_httpdate def initialize reset_state end # @param features_response [Contrast::Agent::Reporting::Response] def update_from_server_features features_response return unless (server_features = features_response&.server_features) update_loggers(server_features) # TODO: RUBY-99999 Update Bot-Blocker from server settings - check enable value. # For now all protection rules are rebuild on Application update. Bot blocker uses the default # enable from the base rule, and update it's mode on app settings update. # Here we receive also bots for that rule. unless settings_empty?(server_features.protect.enabled?) @protect_state.enabled = server_features.protect.enabled? update_config_from_settings(%i[protect enable], server_features.protect.enabled?) end update_assess_server_features(server_features.assess) @last_server_update_ms = Contrast::Utils::Timer.now_ms # update via response header. We receive header from TS with last update info, setting the # next request's header with the same time will save needless update of settings if there # are no new server features updates after the said time. @server_settings_last_httpdate = header_server_last_update rescue StandardError => e logger.warn('The following error occurred from server update: ', e: e) end # Update Assess server features # # @param assess [Contrast::Agent::Reporting::Settings::AssessServerFeature] def update_assess_server_features assess return if settings_empty?(assess.enabled?) @assess_state.enabled = assess.enabled? update_config_from_settings(%i[assess enable], assess.enabled?) @assess_state.sampling_settings = assess.sampling samplings_path = Contrast::Components::Sampling::Interface::CANON_NAME.split('.').map(&:to_sym) Contrast::Components::Sampling::Interface::CONFIG_VALUES.each do |field| lookup_field = field == 'enable' ? :enabled : field.to_sym update_config_from_settings(samplings_path + [field.to_sym], assess.sampling.send(lookup_field)) end end # Updates logging settings # @param [Contrast::Agent::Reporting::Settings::ServerFeatures] def update_loggers server_features log_file = server_features.log_file log_level = server_features.log_level # Update logger: Contrast::Logger::Log.instance.update(log_file, log_level) if log_file || log_level unless settings_empty?(log_file) update_config_from_settings(%i[agent logger path], log_file) @agent_state.logger_path = log_file end unless settings_empty?(log_level) update_config_from_settings(%i[agent logger level], log_level) @agent_state.logger_level = log_level end # Update AgentLib Logger update_agent_lib_log(log_level.to_s) # Update CEFlogger: return if server_features.security_logger.settings_blank? cef_logger.build_logger(server_features.security_logger.log_level, server_features.security_logger.log_file) unless settings_empty?(log_file) update_config_from_settings(%i[agent security_logger path], log_file) @agent_state.cef_logger_level = log_file end return unless settings_empty?(log_level) update_config_from_settings(%i[agent security_logger level], log_level) @agent_state.cef_logger_level = log_level end # Update AgentLib log level def update_agent_lib_log new_log_level agent_lib_log_level = Contrast::AgentLib::InterfaceBase::LOG_LEVEL[0] if new_log_level.empty? agent_lib_log_level ||= Contrast::AgentLib::InterfaceBase::LOG_LEVEL.key(new_log_level.upcase) # detect if the provided level is invalid and log if it is # by default if we pass invalid log level - it will leave the last active unless Contrast::AgentLib::InterfaceBase::LOG_LEVEL.value?(new_log_level.upcase) cur_active = Contrast::AGENT_LIB.log_level logger.debug('The provided level was invalid, so the logger stays to the last active: ', active: cur_active, provided_level: new_log_level) end Contrast::AGENT_LIB.change_log_options(true, agent_lib_log_level) end # @param settings_response [Contrast::Agent::Reporting::Response] def update_from_application_settings settings_response return unless (app_settings = settings_response&.application_settings) extract_protect_app_settings(app_settings) update_matchers_and_sensitive_data(app_settings) @assess_state.disabled_assess_rules = app_settings.assess.disabled_rules update_config_from_settings(%i[assess rules disabled_rules], app_settings.assess.disabled_rules) new_session_id = app_settings.assess.session_id unless settings_empty?(new_session_id) @assess_state.session_id = new_session_id # TODO: RUBY-99999 Update the default values and effective config update from TS. # Using the session_id from the settings response to update the config. # The Effective Config sources values are fetched from the # Contrast::CONFIG.config.loaded_config. Some values are displayed from # their components, however not updated here. Using this may cause some # specs to fails check the update of all values from TS. # Contrast::CONFIG.application.session_id = new_session_id update_config_from_settings(%i[application session_id], new_session_id) end @last_app_update_ms = Contrast::Utils::Timer.now_ms @app_settings_last_httpdate = header_application_last_update end # @param app_settings [Contrast::Agent::Reporting::Settings::ApplicationSettings] def update_matchers_and_sensitive_data app_settings update_exclusion_matchers(app_settings.exclusions) app_settings.protect.virtual_patches = app_settings.protect.virtual_patches unless settings_empty?(app_settings.protect.virtual_patches) update_sensitive_data_policy(app_settings.sensitive_data_masking) end # Wipe state to zero. # # @param purge [Boolean] If true, also purge the persistent states. def reset_state purge: false @agent_state = AGENT_STATE_BASE.dup # Keep the protect state, since once set the rules depend ont it. # The state will be update on first settings response from TS. @protect_state = PROTECT_STATE_BASE.dup if purge || @protect_state.nil? update_assess_state @application_state = APPLICATION_STATE_BASE.dup @tainted_columns = {} @sensitive_data_masking = SENSITIVE_DATA_MASKING_BASE.dup @excluder = Contrast::Agent::Excluder.new end # We save the session_id, reset and set it again if available. # This done so that reporting between updates won't trigger argument error # for missing session_id given one is already set and used with the first application # create response received from TS. # # @return [Struct] def update_assess_state current_session_id = @assess_state&.session_id @assess_state = ASSESS_STATE_BASE.dup # There is application settings update for the session id if new is received. # Here we make sure not to delete the already set one. @assess_state&.session_id = current_session_id unless current_session_id&.empty? @assess_state end # @param exclusions [Contrast::Agent::Reporting::Settings::Exclusions] def update_exclusion_matchers exclusions matchers = [] exclusions.url_exclusions.each do |exclusion| matchers << Contrast::Agent::ExclusionMatcher.new(exclusion) end exclusions.input_exclusions.each do |exclusion| matchers << Contrast::Agent::ExclusionMatcher.new(exclusion) end # Do not populate the matchers unless we have any. There are certain checks in # SourceMethod that will safe-guard return if there are no exclusions received. # The matching operation is expensive, and the excluder calls are made for each # source, and we do not want to check for exclusions if they are empty. This is # probably redundant as all exclusions default to empty, but will save useless # new object creation at very least. return if Contrast::Utils::DuckUtils.empty_duck?(matchers) @excluder = Contrast::Agent::Excluder.new(matchers) end # Update the sensitive data masking policy from settings, # received from TS. In case the settings are empty, # keep current ones. # # @param sensitive_data_masking [Contrast::Agent::Reporting::Settings::SensitiveDataMasking] # Ts Response settings for sensitive data masking policy def update_sensitive_data_policy sensitive_data_masking @sensitive_data_masking.mask_http_body = sensitive_data_masking.mask_http_body? unless settings_empty?(sensitive_data_masking.mask_http_body?) @sensitive_data_masking.mask_attack_vector = sensitive_data_masking.mask_attack_vector? unless settings_empty?(sensitive_data_masking.mask_attack_vector?) return if settings_empty?(sensitive_data_masking.rules) @sensitive_data_masking.rules = sensitive_data_masking.rules # update with the newly received rules. Contrast::Agent::Reporting::Masker.send(:update_dictionary) end private # check if settings are empty and return true if so. # # @param settings [String, Boolean, Array, Hash] # @return true | false def settings_empty? settings return false if !!settings == settings return true if settings.nil? || settings.empty? false end # update server last updated via response header. # Used to build the next request header. # # @return [String] def header_server_last_update Contrast::Agent.reporter.client.response_handler.last_server_modified end # update application last updated via response header. # Used to build the next request header. # # @return [String] def header_application_last_update Contrast::Agent.reporter.client.response_handler.last_application_modified end # Extract the rules modes from protection_rules or rules_settings fields. # # @param app_settings [Contrast::Agent::Reporting::Settings::ApplicationSettings] def extract_protect_app_settings app_settings modes_by_id = app_settings.protect.protection_rules_to_settings_hash modes_by_id = app_settings.protect.rules_settings_to_settings_hash if settings_empty?(modes_by_id) # Preserve previous state if no new settings are extracted: return if settings_empty?(modes_by_id) @application_state.modes_by_id = modes_by_id end # Update the stored config values to ensure that we know about the correct values, # and that the sources are correct for entries updated from the UI. # # NOTE: For each passed component path here, there should br implemented a check for the value from # Config and Settings. For example if enable from CONFIG is nil, check the SETTINGS. # This will keep the value not empty and be reflected in effective config reporting. # # @param path [String] the canonical name for the config entry (such as api.proxy.enable) # @param value [String, Integer, Array, nil] the value for the configuration setting def update_config_from_settings path, value Contrast::Config::Diagnostics::Tools.update_config(path, value, Contrast::Components::Config::Sources::CONTRAST_UI) end end end end end