# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true return unless RUBY_VERSION < '2.6.0' # TODO: RUBY-714 remove guard w/ EOL of 2.5 require 'ripper' require 'contrast/extension/module' require 'contrast/components/interface' require 'contrast/logger/log' # This method is left purposefully at the top level namespace. Moving it # elsewhere will break functionality as it executes evaluations against the # namespace from which it is called -- ie putting it in Contrast would make all # changes it intends for Foo happen to Contrast::Foo instead # # @param _class_name [String] the name of the class in which the eval will # redefine functionality # @param content [String] the String content that will function as the code in # the given class def unbound_eval _class_name, content # Yuck, this is a top-level method that has to break encapsulation # in order to access scoping! Contrast::Components::Scope::COMPONENT_INTERFACE.scope_for_current_ec.enter_contrast_scope! eval(content) # rubocop:disable Security/Eval rescue Exception # rubocop:disable Lint/RescueException # We can't use components here, so we have to access the log directly. I hate # it, but we'll have to deal with it until we remove 2.5 support. Contrast::Logger::Log.instance.logger.error('Unable to perform unbound eval of new content', module: class_name) # And we need to return nil here, not the value from the logger. nil ensure Contrast::Components::Scope::COMPONENT_INTERFACE.scope_for_current_ec.exit_contrast_scope! end module Contrast module Agent # TODO: RUBY-714 remove w/ EOL of 2.5 # @deprecated Changes to this class are discouraged as this approach is # being phased out with support for those language versions. class ClassReopener include Contrast::Components::Interface access_component :logging, :scope END_NEW_LINE = "end\n" PROTECTED_WITH_NEW_LINE = "protected\n" PRIVATE_WITH_NEW_LINE = "private\n" CLASS_SELF_LINE = "class << self\n" attr_reader :public_singleton_methods, :class_module_path, :class_name, :files, :is_class, :name_space, :public_instance_methods, :protected_instance_methods, :private_instance_methods, :locations def initialize module_data @class_module_path = module_data.mod_name clazz = module_data.mod @is_class = clazz.is_a?(Class) @public_instance_methods = [] @protected_instance_methods = [] @private_instance_methods = [] @public_singleton_methods = [] @files = {} @name_space = [] @class_name = nil @locations = Hash.new { |h, k| h[k] = [] } gather_modules end # Indicates if any methods were rewritten for this class. # # @return [Boolean] def staged_changes? public_singleton_methods.any? || public_instance_methods.any? || protected_instance_methods.any? || private_instance_methods.any? end # Indicates if this class was written from the given location. # # @param source_location [Array] the result of a # Method#source_location call # @return [Boolean] def written_from_location? source_location return false unless source_location file = source_location[0] location = source_location[1] locations[file].include?(location) end # Marks that this class was written from the given location. # # @param source_location [Array] the result of a # Method#source_location call # @return [Boolean] def written_from_location! source_location return false unless source_location file = source_location[0] location = source_location[1] locations[file] << location end # Evaluate the patches that have been staged for this class, replacing # the method definitions with those our rewrite. def commit_patches with_contrast_scope do return unless staged_changes? content = build_content valid = Ripper.sexp(content) unbound_eval(class_name, content) if !!valid && !class_name.empty? end end # Find the sourcecode of the method at the given location and return it # if it is complete and compilable. # # @param location [Array] the result of a # Method#source_location call # @param method_name [Symbol] the name of the method defined at the given # location # @return [String, nil] the code defining the method or nil if no valid # code could be found. def source_code location, method_name file_name = location[0] line_number = location[1] return unless file_contents_available?(file_name, line_number) files[file_name] = File.readlines(file_name) unless files.key?(file_name) lines = files[file_name] code = +'' # location#line_number is 1 based, arrays are 0 based line_number -= 1 lines[line_number..-1].each do |line| code << line next unless compiles?(code) break if complete?(code) end unless complete?(code) && compiles?(code) logger.warn( 'Failed to determine sourcecode for rewriting.', file: file_name, method: method_name, line_number: line_number) return end code end private def file_contents_available? file_name, line_number return false unless file_name && line_number return false unless File.exist?(file_name) && File.readable?(file_name) return false if File.empty?(file_name) true end def gather_modules return if class_module_path.nil? segments = class_module_path.split(Contrast::Utils::ObjectShare::DOUBLE_COLON) @class_name = segments.last current = nil segments[0..-2].each do |chunk| defined = current ? current.cs__const_defined?(chunk) : Module.cs__const_defined?(chunk) next unless defined current = current ? current.cs__const_get(chunk) : Module.cs__const_get(chunk) case current when Class name_space << [chunk, Class] when Module name_space << [chunk, Module] end end end # code which ends with a , or \ is incomplete. # # @param code [String] the text to determine if complete # @return [Boolean] def complete? code code !~ /[,\\]\s*\z/ end # code which does not resolve to RubyVM::InstructionSequence does not # compile # # @param code [String] the text to determine if compiles # @return [Boolean] def compiles? code old_verbose = $VERBOSE $VERBOSE = nil RubyVM::InstructionSequence.compile(code) true rescue SyntaxError => _e false ensure $VERBOSE = old_verbose end def build_content content = +'' name_space.each do |arr| name = arr[0] type = arr[1] if type == Class content << "class #{ name }\n" elsif type == Module content << "module #{ name }\n" end end # Set the name to be a class or module content << if is_class "class #{ class_name }\n" else "module #{ class_name }\n" end # Recreate the updated class methods if public_singleton_methods.any? content << CLASS_SELF_LINE content << public_singleton_methods.join(Contrast::Utils::ObjectShare::NEW_LINE) content << Contrast::Utils::ObjectShare::NEW_LINE content << END_NEW_LINE end # Recreate the updated instance methods if public_instance_methods.any? content << public_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE) content << Contrast::Utils::ObjectShare::NEW_LINE end if protected_instance_methods.any? content << PROTECTED_WITH_NEW_LINE content << protected_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE) content << Contrast::Utils::ObjectShare::NEW_LINE end if private_instance_methods.any? content << PRIVATE_WITH_NEW_LINE content << private_instance_methods.join(Contrast::Utils::ObjectShare::NEW_LINE) content << Contrast::Utils::ObjectShare::NEW_LINE end content << END_NEW_LINE name_space.length.times do content << END_NEW_LINE end content end end end end