# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/inventory/dependencies' require 'contrast/components/interface' require 'contrast/utils/object_share' require 'set' module Contrast module Agent module Inventory # Used to analyze class usage for reporting class DependencyUsageAnalysis include Singleton include Contrast::Components::Interface include Contrast::Agent::Inventory::Dependencies access_component :analysis, :config, :logging def initialize return unless enabled? @lock = Mutex.new @lock.synchronize { @gemdigest_cache = Hash.new { |hash, key| hash[key] = Set.new } } end # This method is invoked once, along with the rest of our catchup code to report libraries and their associated # files that have already been loaded pre-contrast. def catchup return unless enabled? loaded_specs.each do |_name, spec| # Get a digest of the Gem file itself. next unless (digest = Contrast::Utils::Sha256Builder.instance.build_from_spec(spec)) loaded_files_from_gem = $LOADED_FEATURES.select { |f| f.start_with?(spec.full_gem_path) } new_files = loaded_files_from_gem.each_with_object(Set.new) do |file_path, set| set << adjust_path_for_reporting(file_path, spec) logger.trace('Recording loaded file for inventory analysis', line: file_path) end # Even if new_files is empty, still need to add digest key for library discovery. @lock.synchronize { @gemdigest_cache[digest].merge(new_files) } end end # This method is invoked once per TracePoint :end - to map a specific file being required to the gem to which # it belongs. # # @param path [String] the result of TracePoint#path from the :end event in which the Module was defined. def associate_file path return unless enabled? spec_lookup_path = adjust_path_for_spec_lookup(path) spec = Gem::Specification.find_by_path(spec_lookup_path) unless spec logger.debug('Unable to resolve gem spec for path', path: path) return end digest = Contrast::Utils::Sha256Builder.instance.build_from_spec(spec) unless digest logger.debug('Unable to resolve digest for gem spec', spec: spec.to_s) return end report_path = adjust_path_for_reporting(path, spec) @lock.synchronize { @gemdigest_cache[digest] << report_path } rescue StandardError => e logger.error('Unable to inventory file path', e, path: path) end # Populate the library_usages field of the Activity message using the data stored in the @gemdigest_cache. # # @param activity [Contrast::Api::Dtm::Activity] the message to which to append the usage data def generate_library_usage activity return unless enabled? return unless activity # Copy gemdigest_cache and clear it in sync. gem_spec_digest_to_files = @lock.synchronize do copy = @gemdigest_cache.dup @gemdigest_cache.clear copy end gem_spec_digest_to_files.each_pair do |digest, files| usage = Contrast::Api::Dtm::LibraryUsageUpdate.build(digest, files) activity.library_usages[usage.hash_code] = usage end rescue StandardError => e logger.error('Unable to generate library usage.', e) end private def adjust_path_for_spec_lookup path idx = path.index('/lib/') path = path[(idx + 4)..-1] if idx path end def adjust_path_for_reporting path, gem_spec path.delete_prefix(gem_spec.full_gem_path) end # We only use this if inventory and library analysis are enabled def enabled? @_enabled = INVENTORY.enabled? && INVENTORY.analyze_libraries? if @_enabled.nil? @_enabled end end end end end