# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/api/dtm.pb' require 'contrast/framework/base_support' require 'contrast/framework/rails/patch/support' require 'contrast/utils/string_utils' module Contrast module Framework module Rails # Used when Rails is present to define framework specific behavior class Support # rubocop:disable Metrics/ClassLength extend Contrast::Framework::BaseSupport extend Contrast::Framework::Rails::Patch::Support include Contrast::Components::Logger::InstanceMethods extend Contrast::Components::Logger::InstanceMethods class << self RAILS_MODULE_NAME_VERSION = Gem::Version.new('6.0.0') def detection_class 'Rails' end def version ::Rails.version end def application_name app_class = ::Rails.application.cs__class # Rails version 6.0.0 deprecated Rails::Application#parent_name, in Rails 6.1.0 that method will be removed # entirely and instead we need to use parent_module_name return app_class.parent_module_name if Gem::Version.new(::Rails.version) >= RAILS_MODULE_NAME_VERSION app_class.parent_name end def application_root ::Rails.root end def server_type 'rails' end # @return [Array] def collect_routes find_all_routes(::Rails.application, []) end # Find the current route, based on the provided Request wrapper # # @param request[Contrast::Agent::Request] # @return [Contrast::Agent::Reporting::RouteCoverage, nil] a Dtm describing the route # matched to the request if a match was found. def current_route_coverage request return unless ::Rails.cs__respond_to?(:application) # ActionDispatch::Journey::Path::Pattern::MatchData, Hash, ActionDispatch::Journey::Route, Array match, _params, route, path = get_full_route(request.rack_request) unless route logger.warn("Unable to determine the current route of this request: #{ request.rack_request }") return end original_url = request.rack_request.path_info mounted_app = route&.app&.app # Route is either the final rails route, or a router that points to a Sinatra controller. if mounted_app && Contrast::Framework::Sinatra::Support.sinatra_controller?(mounted_app) return mounted_new_sinatra_route(request, match, path, route, original_url) end if mounted_app && Contrast::Framework::Grape::Support.grape_controller?(mounted_app) return mounted_new_grape_route(request, match, path, route, original_url) end new_route_coverage = Contrast::Agent::Reporting::RouteCoverage.new new_route_coverage&.attach_rails_data(route, original_url) new_route_coverage rescue StandardError => e logger.warn('Unable to determine the current route of this request due to exception: ', e) nil end # Copy a request for modification. # # @param env [::ActionDispatch::Request] original env. # @return [::ActionDispatch::Request] a copy of original env with rails env merged. def retrieve_request env rails_env = ::Rails.application.env_config.merge(env) ::ActionDispatch::Request.new(rails_env || env) end AC_INSTANCE = 'action_controller.instance' def streaming? env return false unless defined?(::ActionController::Live) env[AC_INSTANCE].cs__class.included_modules.include?(::ActionController::Live) end private # Determine if route is a Rails engine route. # # @param route [Object] app or route that points to a ::Rails::Engine # @return [Boolean, nil] whether the router is an engine or not. def engine_route? route return false unless route&.app&.app return false unless route.app.is_a?(::ActionDispatch::Routing::Mapper::Constraints) || route.app.is_a?(::ActionDispatch::Routing::RouteSet::Dispatcher) clazz = route.app.app.is_a?(Class) ? route.app.app : route.app.app.cs__class clazz < ::Rails::Engine end # Recursively get final route traversing engines as required. Because this can only be called once, we store # this match for the duration of our request context. # # @param request [::Rack::Request] the rack request as will be handed to rails controller. # @param top_router [::ActionDispatch::Journey::Router] the current router relative to the previous. # @param path [Array] the chunks of path that have been seen. # @return [Array] the final set of rails route classes. # ActionDispatch::Journey::Path::Pattern::MatchData, Hash, ActionDispatch::Journey::Route, Array def get_full_route request, top_router = ::Rails.application.routes.router, path = [] return if (route_matches = top_router.send(:find_routes, request)).empty? match, params, route = route_matches.first # If the current routing node points to a sub-app (::Rails::Engine), dive deeper. # Have sub-app route the remainder of the url. if engine_route?(route) new_req = retrieve_request(request.env) new_req.path_info = new_req.path_info.gsub(match.to_s, '') get_full_route(new_req, route.app.app.routes.router, path << match.to_s) else [match, params, route, path] end end # Rails engine routes need to be detected by inspecting Engine class route set # # @param app [Rails::Application] # @param route_list [Array] the list of discovered routes to # which to append and return # @return [Array] def find_all_routes app, route_list return route_list unless app.cs__respond_to?(:routes) && app.routes.cs__respond_to?(:routes) app.routes.routes.each do |route| if route.cs__respond_to?(:app) && route.app.cs__class == ActionDispatch::Routing::RouteSet::Dispatcher route_list << Contrast::Agent::Reporting::DiscoveredRoute.from_action_dispatch_journey(route) elsif route.app.app.cs__respond_to?(:routes) route_list += find_all_routes(route.app.app, []) end end logger.debug("Routes Found: #{ route_list }") route_list end # @return [Contrast::Agent::Reporting::RouteCoverage, nil] def mounted_new_sinatra_route request, match, path, route, original_url new_req = unmounted_route(request, match, path) Contrast::Framework::Sinatra::Support.current_route_coverage(new_req, route.app.app, original_url) end # @return [Contrast::Agent::Reporting::RouteCoverage, nil] def mounted_new_grape_route request, match, path, route, original_url new_req = unmounted_route(request, match, path) Contrast::Framework::Grape::Support.current_route_coverage(new_req, route.app.app, original_url) end # Create a request copied from current request, but with the base path removed from path_info, as that's # the mount. # # @param request[Contrast::Agent::Request] # @param match [] # @param path [String] the path of this request # @return [::ActionDispatch::Request] def unmounted_route request, match, path new_req = ::ActionDispatch::Request.new(request.env) new_req.path_info = new_req.path_info.gsub((path << match).join, '') new_req end end end end end end