# Copyright (c) 2022 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/scope' 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 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 KEYS_TO_REDACT = %i[api_key url service_key user_name].cs__freeze REDACTED = '**REDACTED**' def initialize cli_options = nil, default_name = DEFAULT_YAML_PATH @default_name = default_name # Load config_kv from file config_kv = deep_symbolize_all_keys(load_config) # Overlay CLI options - they take precedence over config file cli_options = deep_symbolize_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) if root.cs__respond_to?(symbol) 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 = 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 # 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 # 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_symbolize_all_keys hash return if hash.nil? new_hash = {} hash.each do |key, value| new_hash[key.to_sym] = value.is_a?(Hash) ? deep_symbolize_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, # # @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 # to_hash returns @configuration_map convert.to_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 end end