# Copyright (c) 2021 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/interface' 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::Interface access_component :scope def_delegator :root, :assign_value_to_path_array attr_reader :default_name, :root DEFAULT_YAML_PATH = 'contrast_security.yaml' MILLISECOND_MARKER = '_ms' CONVERSION = { 'agent.service.enable' => 'agent.start_bundled_service' }.cs__freeze CONFIG_BASE_PATHS = [ '', 'config/', '/etc/contrast/ruby/', '/etc/contrast/', '/etc/' ].cs__freeze def initialize cli_options = nil, default_name = DEFAULT_YAML_PATH @default_name = default_name # Load config_kv from file config_kv = deep_stringify_all_keys(load_config) # Overlay CLI options - they take precedence over config file cli_options = deep_stringify_all_keys(cli_options) config_kv = deep_merge(cli_options, config_kv) if cli_options # Some in-flight rewrites to maintain backwards compatibility config_kv = update_prop_keys(config_kv) @root = Contrast::Config::RootConfiguration.new(config_kv) end # Because we call this method to determine the need for scoping, it itself # must be executed inside a Contrast scope. Failure to do so could result # in an infinite loop on the to_sym method used later. def method_missing symbol, *args with_contrast_scope do root.public_send(symbol, *args) rescue NoMethodError => _e super end end def respond_to_missing? method_name, *args root&.cs__respond_to?(method_name) || super 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 protected # TODO: RUBY-546 move utility methods to auxiliary classes def load_config config = {} configuration_paths.find do |path| next unless File.exist?(path) unless File.readable?(path) log_file_read_error(path) next end config = yaml_to_hash(path) || {} break end config end def yaml_to_hash path if path && File.readable?(path) begin yaml = IO.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 # 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('.') 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('.') # 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 end_idx = new_keys.length - 1 new_keys.each_with_index do |new_key, index| if index == 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 end end config 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('.').first names = %w[yml yaml].map { |suffix| "#{ basename }.#{ suffix }" } paths = [] paths << ENV['CONTRAST_CONFIG_PATH'] if ENV['CONTRAST_CONFIG_PATH'] paths << ENV['CONTRAST_SECURITY_CONFIG'] if ENV['CONTRAST_SECURITY_CONFIG'] tmp = CONFIG_BASE_PATHS.product(names) paths += tmp.map!(&:join) paths end end def deep_merge cli_config, file_config cli_config.merge(file_config) do |_key, cli_value, file_value| cli_value.is_a?(Hash) && file_value.is_a?(Hash) ? deep_merge(cli_value, file_value) : cli_value end end def deep_stringify_all_keys hash return if hash.nil? new_hash = {} hash.each do |key, value| new_hash[key.to_s] = value.is_a?(Hash) ? deep_stringify_all_keys(value) : value end new_hash end private # 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, # or Contrast::Config::DefaultValue depending on desired behavior # # @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 = root, hash = {} case convert when Contrast::Config::BaseConfiguration convert.cs__class::KEYS.each_key do |key| hash[key] = convert_to_hash(convert.send(key), {}) end hash else convert end end end end