# 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' 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 DEFAULT_YAML_PATH = 'contrast_security.yaml' MILLISECOND_MARKER = '_ms' CONVERSION = {}.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 # rubocop:disable Metrics/AbcSize @default_name = default_name # Load config_kv from file config_kv = deep_symbolize_all_keys(load_config) config_sources = assign_source_to(config_kv, Contrast::Components::Config::Sources::YAML) # Overlay CLI options - they take precedence over config file cli_options = deep_symbolize_all_keys(cli_options) if cli_options config_kv = deep_merge(cli_options, config_kv) config_sources = deep_merge(assign_source_to(cli_options, Contrast::Components::Config::Sources::CLI), config_sources) end # Some in-flight rewrites to maintain backwards compatibility config_kv = update_prop_keys(config_kv) @loaded_config = config_kv @sources = Contrast::Components::Config::Sources.new(config_sources) @api = Contrast::Components::Api::Interface.new(config_kv[:api]) @enable = config_kv[:enable] @agent = Contrast::Components::Agent::Interface.new(config_kv[:agent]) @application = Contrast::Components::AppContext::Interface.new(config_kv[:application]) @server = Contrast::Config::ServerConfiguration.new(config_kv[:server]) @assess = Contrast::Components::Assess::Interface.new(config_kv[:assess]) @inventory = Contrast::Components::Inventory::Interface.new(config_kv[:inventory]) @protect = Contrast::Components::Protect::Interface.new(config_kv[:protect]) 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 [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 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) || {} @config_file = 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 = 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::YAML hash.transform_values do |value| if value.is_a?(Hash) assign_source_to(value, source) else source end end end end end