# 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/utils/class_util' cs__scoped_require 'contrast/components/interface' module Contrast module Utils # Utility methods for finding routes within frameworks class PathUtil include Contrast::Components::Interface access_component :logging COVERAGE_LIMIT = 500 # CONTRAST-25730: Arbitrary coverage limit imposed by TeamServer class << self # find the routes in the application. since each framework maintains the # routes slightly differently, we'll only support those that we've explicitly # implemented (Rails & Sinatra currently) # # this method always returns an array, even if it's empty def find_routes if defined?(Rails) find_rails_routes elsif defined?(Sinatra) find_sinatra_routes else Contrast::Utils::ObjectShare::EMPTY_ARRAY end rescue StandardError Contrast::Utils::ObjectShare::EMPTY_ARRAY end # Given the Contrast Request object, determine the current Coverage route, # returning a RouteCoverage object def get_route request get_rails_route(request) if defined?(Rails) rescue StandardError => e logger.error(e, 'Unable to generate route from request') nil end def find_rails_routes routes = [] count = 0 Rails.application.routes.routes.each do |route| routes << rails_route_to_coverage(route) count += 1 return routes if count > COVERAGE_LIMIT end routes end def get_rails_route request return unless Rails.cs__respond_to?(:application) # returns array of arrays [[match_data, path_parameters, route]], sorted by # precedence # match_data: ActionDispatch::Journey::Path::Pattern::MatchData # path_parameters: hash of various things # route: ActionDispatch::Journey::Route full_routes = Rails.application.routes.router.send(:find_routes, request.rack_request) return if full_routes.empty? full_route = full_routes[0] # [match_data, path_parameters, route] return unless full_route route = full_route[2] # route w/ highest precedence return unless route rails_route_to_coverage(route) end # Convert ActionDispatch::Journey::Route to Contrast::Api::Dtm::RouteCoverage def rails_route_to_coverage route route_coverage = Contrast::Api::Dtm::RouteCoverage.new route_coverage.route = "#{ route.defaults[:controller] }##{ route.defaults[:action] }" verb = source_or_string(route.verb) route_coverage.verb = Contrast::Utils::StringUtils.force_utf8(verb) url = source_or_string(route.path.spec) route_coverage.url = Contrast::Utils::StringUtils.force_utf8(url) route_coverage end def source_or_string obj if obj.cs__is_a?(Regexp) obj.source elsif obj.cs__respond_to?(:safe_string) obj.safe_string else obj.to_s end end # Iterate over every class that extends Sinatra::Base, pull out its routes # (array of arrays with Mustermann::Sinatra as [][0]) and convert them into # Contrast::Api::Dtm::RouteCoverage def find_sinatra_routes routes = [] controllers = sinatra_controllers controllers.each do |clazz| class_routes = sinatra_class_routes(clazz) next unless class_routes class_routes.each_pair do |method, list| # item: [ Mustermann::Sinatra, [], Proc] list.each do |item| routes << sinatra_route_to_coverage(clazz, method, item[0]) return routes if routes.length > COVERAGE_LIMIT end end end routes end def sinatra_controllers return [] unless defined?(Sinatra) && defined?(Sinatra::Base) Contrast::Utils::ClassUtil.ancestors_of(Sinatra::Base) end def sinatra_class_routes controller controller.instance_variable_get(:@routes) rescue StandardError logger.debug(nil, "#{ clazz } has no routes instance") nil end # Invoked directly on Sinatra::Base#call! def get_sinatra_route clazz, method, pattern sinatra_route_to_coverage(clazz, method, pattern) end # given clazz, method, and Mustermann::Sinatra, build a # Contrast::Api::Dtm::RouteCoverage def sinatra_route_to_coverage clazz, method, pattern safe_pattern = source_or_string(pattern) route_coverage = Contrast::Api::Dtm::RouteCoverage.new route_coverage.route = "#{ clazz }##{ method } #{ safe_pattern }" route_coverage.verb = Contrast::Utils::StringUtils.force_utf8(method) route_coverage.url = Contrast::Utils::StringUtils.force_utf8(safe_pattern) route_coverage end end end end end