# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'set' require 'contrast/utils/sha256_builder' require 'contrast/utils/string_utils' require 'contrast/components/interface' require 'contrast/api' module Contrast module Utils # GemfileReader has methods for extracting information from gem specs # it also has a cache of library file digests to the lines of code found. class GemfileReader include Singleton include Contrast::Components::Interface access_component :config, :logging CONTRAST_AGENT = 'contrast-agent' def initialize # Map of a Gem's Spec Digest to all loaded files from that Gem @spec_to_files = {} end # the #clone is necessary here, as a require in another thread could # potentially result in adding a key to the loaded_specs hash during # iteration. (as in RUBY-330) def loaded_specs Gem.loaded_specs.clone end # indicates if there's been an update to library information, allowing us # to only serialize this information on change. def updated? @updated end def updated! @updated = true end # Once we're Contrasted, we intercept require calls to do inventory. # In order to catch up, we do a one-time manual catchup, & inventory # all the already-loaded gems. def map_loaded_classes loaded_specs.each do |name, spec| # Don't count Contrast gems next if contrast_gems.include? name # Get a digest of the Gem file itself next unless (digest = Contrast::Utils::Sha256Builder.build_from_spec(spec)) paths = get_by_digest(digest) path = spec.full_gem_path $LOADED_FEATURES.each do |line| next unless line.cs__is_a?(String) next unless line.start_with?(path) logger.trace('Recording loaded gem for inventory analysis', line: line) updated! paths << adjust_lib(line) end end end def map_class path path = adjust_lib(path) return unless (spec = Gem::Specification.find_by_path(path)) return unless (digest = Contrast::Utils::Sha256Builder.build_from_spec(spec)) updated! get_by_digest(digest) << path end def library_pb_list loaded_specs.each_with_object([]) do |(name, spec), arr| next if contrast_gems.include? name next unless spec next unless (digest = Contrast::Utils::Sha256Builder.build_from_spec(spec)) arr << build_library_pb(digest, spec) end end def generate_library_usage activity = nil return unless updated? @spec_to_files.each_pair do |digest, files| usage = Contrast::Api::Dtm::LibraryUsageUpdate.new usage.hash_code = Contrast::Utils::StringUtils.force_utf8(digest) activity.library_usages[usage.hash_code] = usage if activity # TODO: RUBY-882 once TS switches to take filenames, remove the count setter and # send the class names in usage.class_names usage.count = files.size end # TODO: RUBY-882 once TS switches to take filenames, clear this and remove the # @updated variable # @spec_to_files.clear @updated = false end private # marker for the lib dir in an absolute file path. purposefully includes # the trailing '/' LIB = '/lib/' # Kernel#load uses the absolute path, but Gems / Specs use the path after # `/lib`. This method accounts for that and trims out the `/lib/` section # and starts with the first `/` after and the trailing file extension, if # present. # # @param path [String] the path to parse # @return [String] the relative path of the file, after the lib directory def adjust_lib path idx = path.index(LIB) path = path[(idx + 4)..-1] if idx idx = path.rindex(Contrast::Utils::ObjectShare::PERIOD) path = path[0..idx] if idx path end def get_by_digest digest @spec_to_files[digest] = Set.new unless @spec_to_files.key?(digest) @spec_to_files[digest] end def build_library_pb digest, spec lib = Contrast::Api::Dtm::Library.new lib.file_path = Contrast::Utils::StringUtils.force_utf8(spec.name) lib.hash_code = Contrast::Utils::StringUtils.force_utf8(digest) lib.version = Contrast::Utils::StringUtils.force_utf8(spec.version) lib.manifest = Contrast::Utils::StringUtils.force_utf8(build_manifest(spec)) lib.external_ms = date_to_ms(spec.date) lib.internal_ms = lib.external_ms lib.url = Contrast::Utils::StringUtils.force_utf8(spec.homepage) # Library tags are appended in the ApplicationUpdate delegator update_class_counts(lib, digest, spec) lib end def date_to_ms date (date.to_f * 1000.0).to_i end def update_class_counts lib, digest, spec # Updating the class counts path = spec.full_gem_path.to_s lib.class_count = all_files(path).length lib.used_class_count = @spec_to_files.key?(digest) ? get_by_digest(digest).size : 0 lib end def build_manifest spec Contrast::Utils::StringUtils.force_utf8(spec.to_yaml.to_s) if defined?(YAML) rescue StandardError nil end # These are all the code files that are located in the Gem directory loaded # by the current environment; this includes more than Ruby files def all_files path Contrast::Utils::Sha256Builder.instance.files(path) end # Go through all dependents, given as a pair from the DependencyList: `dependency` # is the dependency itself, filled with all its specs. `dependents` is the array of reverse # dependencies for the aforementioned dependency. If the dependency is also in contrast_dep_set, # then contrast depends on it. If its array of dependents is 1, then contrast is the # only dependency in that list. Since only contrast depends on it, we should ignore it. def contrast_gems @_contrast_gems ||= find_contrast_gems end def find_contrast_gems ignore = Set.new([CONTRAST_AGENT]) contrast_specs = Gem::DependencyList.from_specs.specs.find do |dependency| dependency.name == CONTRAST_AGENT end contrast_dep_set = contrast_specs.dependencies.map(&:name).to_set Gem::DependencyList.from_specs.spec_predecessors.each_pair do |dependency, dependents| ignore.add(dependency.name) if contrast_dep_set.include?(dependency.name) && dependents.length == 1 end ignore end end end end