# 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' 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 APPLICATION_STATE_BASE = Struct.new(:modes_by_id).new(Hash.new(:NO_ACTION)) PROTECT_STATE_BASE = Struct.new(:enabled, :rules).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? # 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 # rubocop:disable Metrics/AbcSize return unless (server_features = features_response&.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 # Update AgentLib Logger update_agent_lib_log(log_level.to_s) # Update CEFlogger: unless server_features.security_logger.settings_blank? cef_logger.build_logger(server_features.security_logger.log_level, server_features.security_logger.log_file) end # 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? store_in_config(%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 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 # 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? store_in_config(%i[assess enable], assess.enabled?) @assess_state.sampling_settings = assess.sampling Contrast::Components::Sampling::Interface::CONFIG_VALUES.each do |field| lookup_field = field == 'enable' ? :enabled : field.to_sym store_in_config(Contrast::Components::Sampling::Interface::CANON_NAME.split('.') + [field.to_sym], assess.sampling.send(lookup_field)) end end # @param settings_response [Contrast::Agent::Reporting::Response] def update_from_application_settings settings_response return unless (app_settings = settings_response&.application_settings) @application_state.modes_by_id = app_settings.protect.protection_rules_to_settings_hash 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) @assess_state.disabled_assess_rules = app_settings.assess.disabled_rules new_session_id = app_settings.assess.session_id @assess_state.session_id = new_session_id if new_session_id && !new_session_id.blank? @last_app_update_ms = Contrast::Utils::Timer.now_ms @app_settings_last_httpdate = header_application_last_update end # Wipe state to zero. def reset_state @protect_state = PROTECT_STATE_BASE.dup 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 def build_protect_rules @protect_state.rules = {} # Rules. They add themselves on initialize. Contrast::Agent::Protect::Rule::BotBlocker.new cmdi = Contrast::Agent::Protect::Rule::CmdInjection.new cmdi.sub_rules Contrast::Agent::Protect::Rule::Deserialization.new Contrast::Agent::Protect::Rule::NoSqli.new path = Contrast::Agent::Protect::Rule::PathTraversal.new path.sub_rules sqli = Contrast::Agent::Protect::Rule::Sqli.new sqli.sub_rules Contrast::Agent::Protect::Rule::UnsafeFileUpload.new Contrast::Agent::Protect::Rule::Xss.new Contrast::Agent::Protect::Rule::Xxe.new 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 @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 # 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. # # @param parts [Array] the path to the setting in config # @param value [String, Integer, Array, nil] the value for the configuration setting def store_in_config parts, value level = Contrast::CONFIG.config.loaded_config parts[0...-1].each do |segment| level[segment] ||= {} level = level[segment] end return unless level.cs__is_a?(Hash) level[parts[-1]] = value Contrast::CONFIG.sources.set(parts.join('.'), Contrast::Components::Config::Sources::CONTRAST_UI) end end end end end