# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true cs__scoped_require 'contrast/utils/object_share' cs__scoped_require 'contrast/api' module Contrast module Utils # Utilities for converting ruby stack trace into DTMs class StackTraceUtils CONTRAST_MARKER = 'contrast/core_extensions' MONKEYPATCH_MARKER = 'cs__' # TODO: RUBY-532 CLASSNAME_CACHE = Contrast::Utils::Cache.new # need lib here to separate from specs AGENT_CLASS_MARKERS = %w[ /lib/contrast/ ].cs__freeze def self.custom_code_context? stack = Kernel.caller(0, 15) i = 0 while i < stack.length stack_element = stack[i] if stack_element.include?('contrast') # Noop elsif stack_element.include?('gems') return false else return true end i += 1 end end def self.build( skip: 0, depth: 10, ignore: false, ignore_strings: AGENT_CLASS_MARKERS, class_lookup: false, rasp_element: true) stack = caller(skip.to_i, depth.to_i) return [] unless stack stack = reject_caller_entries(stack, ignore_strings) if ignore stack.map! do |entry| element = rasp_or_assess(rasp_element) element = fill_element(element, entry, class_lookup) element end stack.compact! stack end def self.reject_caller_entries stack, ignore_strings return stack unless ignore_strings&.any? stack.reject do |entry| ignore_strings.any? { |marker| entry.include?(marker) } || entry.include?(MONKEYPATCH_MARKER) end end def self.rasp_or_assess rasp_element if rasp_element Contrast::Api::Dtm::StackTraceElement.new else Contrast::Api::Dtm::TraceStack.new end end def self.to_dtm_stack stack_locations: nil, rasp_element: true return [] unless stack_locations stack_locations = reject_locations(stack_locations) stack_locations.map! do |caller_location| element = rasp_or_assess(rasp_element) element = fill_loc_element(element, caller_location) element end stack_locations.compact! stack_locations end def self.reject_locations stack_locations stack_locations.reject do |entry| class_path = entry.path method = entry.label AGENT_CLASS_MARKERS.any? { |marker| class_path.include?(marker) } || method.include?(MONKEYPATCH_MARKER) end end # "/.rvm/rubies/ruby-1.9.3-p551/lib/ruby/1.9.1/irb/workspace.rb:80:in `eval'" def self.fill_element element, str, class_lookup return element if str.nil? || str.strip.empty? path, line, method = str.split(':') element.line_number = line.to_i file_name = filename_from_path(path) element.file_name = Contrast::Utils::StringUtils.force_utf8(file_name) declaring_class = best_classname(path, element.file_name) if class_lookup element.declaring_class = Contrast::Utils::StringUtils.force_utf8(declaring_class) method_name = find_method_name(method) element.method_name = Contrast::Utils::StringUtils.force_utf8(method_name) element rescue StandardError # NOOP nil end def self.best_classname path, file name = first_class(path) name || look_like_classname(file) end # "/.rvm/rubies/ruby-1.9.3-p551/lib/ruby/1.9.1/irb/workspace.rb:80:in `eval'" def self.fill_loc_element element, caller_location return element unless caller_location lineno = caller_location.lineno element.line_number = lineno.to_i path = caller_location.path path = Contrast::Utils::StringUtils.force_utf8(path) file_name = filename_from_path(path) element.file_name = Contrast::Utils::StringUtils.force_utf8(file_name) element.declaring_class = path label = caller_location.label element.method_name = Contrast::Utils::StringUtils.force_utf8(label) element rescue StandardError # NOOP nil end def self.filename_from_path path file_idx = path.rindex(Contrast::Utils::ObjectShare::SLASH) if file_idx txt_idx = file_idx + 1 path[txt_idx, path.length - txt_idx] else path end end # Bit of a HACK: Given a path to a file, assume that the first line in the # form `class Name` is the class that was loaded. True most of the time. # # Using regexp here is expensive b/c it creates a ton of new Strings. We're # trying to avoid that, so our guess is a little more clumsy, but should # still work. # # Note: Might need to tune end marker to include /n # based on: /\s*class\s+([_[:alpha:]][_[:alnum:]]*)/m CLASS_MARKER = ' class ' CLASS_END_MARKER = Contrast::Utils::ObjectShare::NEW_LINE def self.first_class path return nil if path.nil? || path.empty? name = CLASSNAME_CACHE[path] return name if name text = File.read(path) name = parse_string(text, CLASS_MARKER, CLASS_END_MARKER) CLASSNAME_CACHE[path] = name if name name end RB_MARKER = '.rb' # Convert a file to look like a classname # If the file ends w/ '.rb', trim that off # If the file doesn't start with a capital letter, # capitalize it def self.look_like_classname file file = file.to_s file[0].capitalize + (file.end_with?(RB_MARKER) ? file.slice(1..-4) : file.slice(1..-1)) end # Using regexp here is expensive b/c it creates a ton of new Strings. We're # trying to avoid that, so our guess is a little more clumsy, but should # still work. # # Note: Might need to tune end marker to include /n # based on: /[`]([^']*)[']/ METHOD_MARKER = Contrast::Utils::ObjectShare::TICK METHOD_END_MARKER = Contrast::Utils::ObjectShare::COMMA def self.find_method_name method parse_string(method, METHOD_MARKER, METHOD_END_MARKER) end def self.parse_string string, start_marker, end_marker return nil unless string start_idx = string.index(start_marker) return nil unless start_idx start_idx += start_marker.length # account for marker length end_idx = string.index(end_marker, start_idx) end_idx ||= string.length len = end_idx - start_idx string[start_idx, len] end end end end