# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/reporting/reporting_events/architecture_component' require 'contrast/components/logger' require 'contrast/utils/object_share' require 'contrast/utils/timer' module Contrast module Agent module Inventory # Methods used for parsing database connection configurations # for getting inventory information from the application module DatabaseConfig extend Contrast::Components::Logger::InstanceMethods # TeamServer only accepts certain values for FlowMap Services. # DO NOT CHANGE THIS ADAPTER = 'adapter' HOST = 'host' PORT = 'port' DATABASE = 'database' DEFAULT = 'default' LOCALHOST = 'localhost' class << self # Append the available database connection information to the message being sent to TeamServer. # # @param activity_or_update [Contrast::Agent::Reporting::ApplicationUpdate] # @param hash_or_str [Hash, String] the database connection information def append_db_config activity_or_update, hash_or_str = active_record_config arr = build_from_db_config(hash_or_str) return unless arr&.any? arr.each do |component| next unless component if activity_or_update.cs__is_a?(Contrast::Agent::Reporting::ApplicationUpdate) activity_or_update.components << component else activity_or_update.attach_inventory(component) end end rescue StandardError => e logger.warn('Unable to append db config', e) nil end private # We capture the active record configuration used by this application, as reported by # ActiveRecord::Base.connection_db_config, so that we can record it once and report it as needed. # # @return [Hash] def active_record_config return @_active_record_config if instance_variable_defined?(:@_active_record_config) @_active_record_config = if ActiveRecord::Base.cs__respond_to?(:connection_db_config) ActiveRecord::Base.connection_db_config else # TODO: RUBY-99999 - Remove when Rails 6.0 is not supported ActiveRecord::Base.connection_config end rescue StandardError => e logger.error('Unable to detect db config connection', e) nil end # The classes we instrument in order to determine which, if any, database(s) an application connects to take # either a Hash or a String as a parameter. We install this one patch into all of those methods, letting our # code here, rather than at the patch level, make the determination of which path to call. We'll make that # decision and then parse the given configuration to create a # Contrast::Agent::Reporting::ArchitectureComponent for reporting. # # @param hash_or_str [Hash, String] # @return [Array, nil] def build_from_db_config hash_or_str return unless hash_or_str # we need to handle types of HashConfig, which != Hash # for example ActiveRecord::DatabaseConfigurations::HashConfig is type of active_record config # but the method is not handling it properly # so we need to handle it here and extract the hash hash_or_str = hash_or_str.configuration_hash if hash_or_str.cs__respond_to?(:configuration_hash) if hash_or_str.is_a?(Hash) build_from_db_hash(hash_or_str) else build_from_db_string(hash_or_str.to_s) end end # Convert the Hash used to create a database connection into a # Contrast::Agent::Reporting::ArchitectureComponent understandable by TeamServer. # # @param hash [Hash] the information used to open a database connection # @return [Array] def build_from_db_hash hash ac = Contrast::Agent::Reporting::ArchitectureComponent.build_database ac.vendor = hash[:adapter] || hash[ADAPTER] || Contrast::Utils::ObjectShare::EMPTY_STRING ac.remote_host = host_from_hash(hash) ac.remote_port = port_from_hash(hash) ac.url = hash[:database] || hash[DATABASE] || DEFAULT [ac] end # Retrieve the host from the given connection Hash # # @param hash [Hash] # @return [String] def host_from_hash hash hash[:host] || hash[HOST] || Contrast::Utils::ObjectShare::EMPTY_STRING end # Retrieve the port from the given connection Hash # # @param hash [Hash] # @return [integer] def port_from_hash hash p = hash[:port] || hash[PORT] || Contrast::Utils::ObjectShare::EMPTY_STRING p.to_i end # Examples: # mongodb://[user:pass@]host1[:port1][,host2[:port2],[,hostN[:portN]]][/[database][?options]] # postgresql://scott:tiger@localhost/mydatabase # pragma: allowlist secret # mysql+mysqlconnector://scott:tiger@localhost/foo # pragma: allowlist secret # # @param str [String] the DB connection string # @return [Array, nil] def build_from_db_string str adapter, hosts, database = split_connection_str(str) return unless adapter && hosts && database acs = [] hosts.split(Contrast::Utils::ObjectShare::COMMA).map do |s| host, port = s.split(Contrast::Utils::ObjectShare::COLON) ac = Contrast::Agent::Reporting::ArchitectureComponent.build_database ac.vendor = Contrast::Utils::StringUtils.force_utf8(adapter) ac.remote_host = Contrast::Utils::StringUtils.force_utf8(host) ac.remote_port = port.to_i ac.url = Contrast::Utils::StringUtils.force_utf8(database) acs << ac end acs end # Parse the given string used to connect to a database into its composite components, allowing for the # generation of a Contrast::Agent::Reporting::ArchitectureComponent # # @param str [String] the DB connection string # @return [Array, nil] the adapter, hosts, and database def split_connection_str str adapter, str = str.split(Contrast::Utils::ObjectShare::COLON_SLASH_SLASH) return unless str _auth, str = str.split(Contrast::Utils::ObjectShare::AT) return unless str # Not currently used # user, pass = auth.split(Contrast::Utils::ObjectShare::COLON) hosts, db_and_options = str.split(Contrast::Utils::ObjectShare::SLASH) return unless db_and_options hosts << LOCALHOST if hosts.empty? database, _options = db_and_options.split(Contrast::Utils::ObjectShare::QUESTION_MARK) [adapter, hosts, database] end end end end end end