# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'yaml' require 'fileutils' require 'contrast/config' require 'contrast/utils/object_share' require 'contrast/components/agent' require 'contrast/components/api' require 'contrast/components/app_context' require 'contrast/components/scope' require 'contrast/components/inventory' require 'contrast/components/protect' require 'contrast/components/assess' require 'contrast/components/config/sources' require 'contrast/config/server_configuration' require 'contrast/config/configuration_files' require 'contrast/utils/hash_utils' module Contrast # This is how we read in the local settings for the Agent, both ENV/ CMD line # and from properties files, in order to determine which settings, if any, # the user has overridden. class Configuration extend Forwardable include Contrast::Components::Scope::InstanceMethods extend Contrast::Components::Scope::InstanceMethods include Contrast::Config::BaseConfiguration attr_reader :default_name # @return [Contrast::Components::Api::Interface] attr_writer :api # @return [Contrast::Components::Agent::Interface] attr_writer :agent # @return [Contrast::Components::AppContext::Interface] attr_writer :application # @return [Contrast::Config::ServerConfiguration] attr_writer :server # @return [Contrast::Components::Assess::Interface] attr_writer :assess # @return [Contrast::Components::Inventory::Interface] attr_writer :inventory # @return [Contrast::Components::Protect::Interface] attr_writer :protect # @return [Boolean, nil] attr_accessor :enable # @return [Hash] attr_accessor :loaded_config # @return [Contrast::Config::Sources] attr_accessor :sources # @return [String,nil] attr_reader :config_file CONTRAST_ENV_MARKER = 'CONTRAST__' DEFAULT_YAML_PATH = 'contrast_security.yaml' MILLISECOND_MARKER = '_ms' CONVERSION = {}.cs__freeze # Precedence of paths, shift if config file values needs to go up the chain. CONFIG_BASE_PATHS = %w[./ config/ /etc/contrast/ruby/ /etc/contrast/ /etc/].cs__freeze KEYS_TO_REDACT = %i[api_key url service_key user_name].cs__freeze REDACTED = '**REDACTED**' EFFECTIVE_REDACTED = '****' DEPRECATED_PROPERTIES = %w[ CONTRAST__AGENT__SERVICE__ENABLE CONTRAST__AGENT__SERVICE__LOGGER__LEVEL CONTRAST__AGENT__SERVICE__LOGGER__PATH CONTRAST__AGENT__SERVICE__LOGGER__STDOUT ].cs__freeze def initialize cli_options = nil, default_name = DEFAULT_YAML_PATH @default_name = default_name # Load config_kv from file config_kv = Contrast::Utils::HashUtils.deep_symbolize_all_keys(load_config) # Load cli options from env cli_options ||= cli_to_hash config_kv = Contrast::Utils::HashUtils.precedence_merge(config_kv, cli_options) update_sources_from_cli(cli_options) # Some in-flight rewrites to maintain backwards compatibility config_kv = update_prop_keys(config_kv) @sources = Contrast::Components::Config::Sources.new(source_file_extensions) @loaded_config = config_kv # requires loaded_config: create_config_components end # Get a loggable YAML format of this configuration # @return [String] the current active configuration of the Agent, # represented as a YAML string def loggable convert_to_hash.to_yaml end # @return [Hash] map of all extensions for each config key. def source_file_extensions @_source_file_extensions ||= {} end # @return [Contrast::Components::Api::Interface] def api @api ||= Contrast::Components::Api::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Components::Agent::Interface] def agent @agent ||= Contrast::Components::Agent::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Components::AppContext::Interface] def application @application ||= Contrast::Components::AppContext::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Config::ServerConfiguration] def server @server ||= Contrast::Config::ServerConfiguration.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Components::Assess::Interface] def assess @assess ||= Contrast::Components::Assess::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Components::Inventory::Interface] def inventory @inventory ||= Contrast::Components::Inventory::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # @return [Contrast::Components::Protect::Interface] def protect @protect ||= Contrast::Components::Protect::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName end # Base paths to check for the contrast configuration file, sorted by # reverse order of precedence (first is most important). def configuration_paths @_configuration_paths ||= begin basename = default_name.split('.')[0] # Order of extensions comes from here: extensions = Contrast::Components::Config::Sources::APP_CONFIGURATION_EXTENSIONS paths = [] # Environment paths takes precedence here. Look first through them. config_path = ENV.fetch('CONTRAST_CONFIG_PATH', nil) security_path = ENV.fetch('CONTRAST_SECURITY_CONFIG', nil) paths << config_path if config_path paths << security_path if security_path extensions.each do |ext| places = CONFIG_BASE_PATHS.product(["#{ basename }.#{ ext }"]) paths += places.map!(&:join) end paths end end # List of all read configuration files. # # @return [Contrast::Config::ConfigurationFiles] of paths def origin @_origin ||= Contrast::Config::ConfigurationFiles.new end protected def yaml_to_hash path if path && File.readable?(path) begin yaml = File.read(path) yaml = ERB.new(yaml).result if defined?(ERB) return YAML.safe_load(yaml) rescue Psych::Exception => e log_yaml_parse_error(path, e) rescue RuntimeError => e puts("WARN: Unable to load configuration. #{ e }; path: #{ path }, pwd: #{ Dir.pwd }") end end {} end # TODO: RUBY-546 move utility methods to auxiliary classes # Read through all the paths we know config may live. Merge all values from all files found. # Priority is given to yaml over yml. If same keys are found on two configs with different extensions, # the Agent will read first from the yaml file and ignore the values for same key on the yml file. # # @return config [Hash] All source configurations from files. def load_config config = {} @_source_file_extensions = {} configuration_paths.each do |path| next unless File.exist?(path) unless File.readable?(path) log_file_read_error(path) next end origin.add_source_file(path, (yaml_to_hash(path) || {})) end # Legacy usage: Assign main configuration file for reference. @config_file = origin.main_file # merge all settings keeping the top yaml files values as priority. # If in top file a key exists it's value won't be changed if same key has different value in one # of the other config files. Only unique values will be taken in consideration.w # precedence of paths: see Contrast::Configuration::CONFIG_BASE_PATHS extensions_maps = [] origin.source_files.each do |file| config = Contrast::Utils::HashUtils.precedence_merge(config, file.values) # assign source values extentions: extensions_maps << assign_source_to(Contrast::Utils::HashUtils.deep_symbolize_all_keys(file.values), file.path) end # merge all origin paths to be used as extension classification to preserve the precedence of config files: extensions_maps.each do |path| @_source_file_extensions = Contrast::Utils::HashUtils.precedence_merge!(@_source_file_extensions, path) end config end # We're updating properties loaded from the configuration files to match the new agreed upon standard configuration # names, so that one file works for all agents def update_prop_keys config CONVERSION.each_pair do |old_method, new_method| # See if the old value was set and needs to be translated deprecated_keys = old_method.split('.').map(&:to_sym) old_value = config deprecated_keys.each do |key| old_value = old_value[key] break if old_value.nil? end next if old_value.nil? # have to account for literal false log_deprecated_property(old_method, new_method) new_keys = new_method.split('.').map(&:to_sym) # We changed the seconds values into ms values. Multiply them accordingly old_value = old_value.to_i * 1000 if new_method.end_with?(MILLISECOND_MARKER) new_value = config replace_props(new_keys, new_value, old_value) end config end private # Creates and updates the config components with the loaded config values. def create_config_components @api = Contrast::Components::Api::Interface.new(loaded_config[:api]) @enable = loaded_config[:enable] @agent = Contrast::Components::Agent::Interface.new(loaded_config[:agent]) @application = Contrast::Components::AppContext::Interface.new(loaded_config[:application]) @server = Contrast::Config::ServerConfiguration.new(loaded_config[:server]) @assess = Contrast::Components::Assess::Interface.new(loaded_config[:assess]) @inventory = Contrast::Components::Inventory::Interface.new(loaded_config[:inventory]) @protect = Contrast::Components::Protect::Interface.new(loaded_config[:protect]) end # We cannot use all access components at this point, unfortunately, as they # may not have been initialized. Instead, we need to access the logger # directly. def logger @_logger ||= (Contrast::Logger::Log.instance.logger if defined?(Contrast::Logger::Log)) end # When we fail to parse a configuration because it is misformatted, log an # appropriate message based on the Agent Onboarding specification def log_yaml_parse_error path, exception hash = { path: path, pwd: Dir.pwd } if exception.is_a?(Psych::SyntaxError) hash[:context] = exception.context hash[:column] = exception.column hash[:line] = exception.line hash[:offset] = exception.offset hash[:problem] = exception.problem end if logger logger.warn('YAML validator found an error', hash) else puts("CONTRAST - WARN: YAML validator found an error. #{ hash.inspect }") end end def log_file_read_error path if logger logger.warn('Configuration file is not readable by current user', path: path) else puts("CONTRAST - WARN: Configuration file is not readable by current user; path: #{ path }") end end def log_deprecated_property old_method, new_method if logger logger.warn('Deprecated property in use', old_method: old_method, new_method: new_method) else puts("CONTRAST - WARN: Deprecated property in use; old_method: #{ old_method }, new_method: #{ new_method }") end end # Convert this entire configuration into a hash, walking down the entries # in the thing to convert and setting them in the given hash. For now, this # logs every possible key, whether set or not. If we want to change that # behavior, we can skip adding keys to the hash if the value is nil, blank, # # @param hash [Hash] the hash to populate # @param convert [Contrast::Config::BaseConfiguration, Object] the level of # configuration from which to convert. Note that at least one top level # Contrast::Config::BaseConfiguration is required for anything to be set # in the hash # @return [Hash, Object] the leaf of each # Contrast::Config::BaseConfiguration will be returned in the N > 0 steps # the Hash will be returned at the end of the 0 level def convert_to_hash convert = self, hash = {} case convert when Contrast::Config::BaseConfiguration # to_hash returns @configuration_map convert.to_contrast_hash.each_key do |key| # change '-' to '_' for ProtectRulesConfiguration hash[key] = convert_to_hash(convert.send(key.tr('-', '_').to_sym), {}) hash[key] = REDACTED if redactable?(key) end hash else convert end end def replace_props new_keys, new_value, old_value idx = 0 end_idx = new_keys.length - 1 while idx < new_keys.length new_key = new_keys[idx].to_sym if idx == end_idx new_value[new_key] = old_value if new_value[new_key].nil? else new_value = {} if new_value.nil? new_value[new_key] = {} if new_value[new_key].nil? new_value = new_value[new_key] end idx += 1 end end # Check if keys with sensitive data needs to be # redacted. # # @param key [Symbol] key to check # @return[Boolean] true | false def redactable? key KEYS_TO_REDACT.include?(key.to_sym) end def assign_source_to hash, source = Contrast::Components::Config::Sources::APP_CONFIGURATION_FILE hash.transform_values do |value| if value.is_a?(Hash) assign_source_to(value, source) else source end end end # Update the source mapping to reflect the cli values passed. Using raw string rather than path values. # # @param cli_options[Hash] def update_sources_from_cli cli_options @_source_file_extensions = Contrast::Utils::HashUtils. precedence_merge(assign_source_to(cli_options, Contrast::Components::Config::Sources::COMMAND_LINE), @_source_file_extensions) end # Find all the set Contrast environment variables and cast them to their hash form. Keys will be split on __ and # converted to symbols to match parsing of the YAML file # # @return [Hash] def cli_to_hash cli_options ||= ENV.select do |name, _value| name.to_s.start_with?(CONTRAST_ENV_MARKER) && !DEPRECATED_PROPERTIES.include?(name.to_s) end converted = {} cli_options&.each do |key, value| # Split the env key into path components path = key.to_s.split(Contrast::Utils::ObjectShare::DOUBLE_UNDERSCORE) # Remove the `CONTRAST` start path&.shift # Convert it to hash form, with lowercase symbol keys as_hash = path&.reverse&.reduce(value) do |assigned_value, path_segment| { path_segment.downcase.to_sym => assigned_value } end # And join it w/ the parsed keys Contrast::Utils::HashUtils.precedence_merge!(converted, as_hash) end converted end end end