diff --git a/appraisal/ruby-3.3.rb b/appraisal/ruby-3.3.rb index be399c51c5f..cdeabe2e644 100644 --- a/appraisal/ruby-3.3.rb +++ b/appraisal/ruby-3.3.rb @@ -239,11 +239,13 @@ appraise 'rails-app' do gem 'devise', '~> 4.9' gem 'faraday', '~> 2.0' + gem 'grape' # for endpoint collection tests gem 'excon', '~> 1.2' gem 'rest-client' gem 'rack', '~> 2' gem 'rack-contrib', '~> 2' gem 'rack-test' # Dev dependencies for testing rack-based code gem 'rails', '~> 7.0' + gem 'sinatra' # for endpoint collection tests gem 'sqlite3', '>= 1.4.2', platform: :ruby end diff --git a/gemfiles/ruby_3.3_rails_app.gemfile b/gemfiles/ruby_3.3_rails_app.gemfile index b67353ea29c..3cd1e85e0fe 100644 --- a/gemfiles/ruby_3.3_rails_app.gemfile +++ b/gemfiles/ruby_3.3_rails_app.gemfile @@ -27,12 +27,14 @@ gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.7.0" gem "devise", "~> 4.9" gem "faraday", "~> 2.0" +gem "grape" gem "excon", "~> 1.2" gem "rest-client" gem "rack", "~> 2" gem "rack-contrib", "~> 2" gem "rack-test" gem "rails", "~> 7.0" +gem "sinatra" gem "sqlite3", ">= 1.4.2", platform: :ruby group :check do diff --git a/gemfiles/ruby_3.3_rails_app.gemfile.lock b/gemfiles/ruby_3.3_rails_app.gemfile.lock index 27c868e87af..f6b257a646d 100644 --- a/gemfiles/ruby_3.3_rails_app.gemfile.lock +++ b/gemfiles/ruby_3.3_rails_app.gemfile.lock @@ -118,6 +118,23 @@ GEM domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) drb (2.2.1) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-types (1.8.3) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) erubi (1.13.1) excon (1.2.3) extlz4 (0.3.4) @@ -134,6 +151,12 @@ GEM activesupport (>= 6.1) google-protobuf (3.25.6-aarch64-linux) google-protobuf (3.25.6-x86_64-linux) + grape (2.4.0) + activesupport (>= 6.1) + dry-types (>= 1.1) + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk hashdiff (1.1.2) http-accept (1.7.0) http-cookie (1.0.5) @@ -172,6 +195,10 @@ GEM mini_mime (1.1.5) minitest (5.25.4) msgpack (1.8.0) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + mustermann-grape (1.1.0) + mustermann (>= 1.0.0) net-http (0.6.0) uri net-imap (0.5.6) @@ -207,6 +234,9 @@ GEM rack (2.2.10) rack-contrib (2.5.0) rack (< 4) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-session (1.0.2) rack (< 3) rack-test (2.2.0) @@ -278,6 +308,7 @@ GEM rspec (>= 3.0) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) + ruby2_keywords (0.0.5) securerandom (0.4.1) simplecov (0.22.0) docile (~> 1.1) @@ -285,11 +316,17 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) sqlite3 (2.5.0-aarch64-linux-gnu) sqlite3 (2.5.0-aarch64-linux-musl) sqlite3 (2.5.0-x86_64-linux-gnu) stringio (3.1.2) thor (1.3.2) + tilt (2.6.1) timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -332,6 +369,7 @@ DEPENDENCIES extlz4 (~> 0.3, >= 0.3.3) faraday (~> 2.0) google-protobuf (~> 3.0, != 3.7.1, != 3.7.0) + grape json-schema (< 3) memory_profiler (~> 0.9) os (~> 1.1) @@ -348,6 +386,7 @@ DEPENDENCIES rspec-wait (~> 0) rspec_junit_formatter (>= 0.5.1) simplecov (~> 0.22.0) + sinatra sqlite3 (>= 1.4.2) warning (~> 1) webmock (>= 3.10.0) diff --git a/lib/datadog/appsec/api_security/endpoint_collection.rb b/lib/datadog/appsec/api_security/endpoint_collection.rb new file mode 100644 index 00000000000..b34969226ac --- /dev/null +++ b/lib/datadog/appsec/api_security/endpoint_collection.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module APISecurity + module EndpointCollection + end + end + end +end diff --git a/lib/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rb b/lib/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rb new file mode 100644 index 00000000000..c20eac001a1 --- /dev/null +++ b/lib/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module APISecurity + module EndpointCollection + # This module serializes Grape routes. + module GrapeRouteSerializer + module_function + + def serialize(route, path_prefix: '') + path = path_prefix + route.pattern.origin + + { + type: "REST", + resource_name: "#{route.request_method} #{path}", + operation_name: "http.request", + method: route.request_method, + path: path + } + end + end + end + end + end +end diff --git a/lib/datadog/appsec/api_security/endpoint_collection/rails_collector.rb b/lib/datadog/appsec/api_security/endpoint_collection/rails_collector.rb new file mode 100644 index 00000000000..de21126a17d --- /dev/null +++ b/lib/datadog/appsec/api_security/endpoint_collection/rails_collector.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'rails_route_serializer' +require_relative 'grape_route_serializer' +require_relative 'sinatra_route_serializer' + +module Datadog + module AppSec + module APISecurity + module EndpointCollection + # This class works with a collection of rails routes + # and produces an Enumerator that yields serialized endpoints. + class RailsCollector + def initialize(routes) + @routes = routes + end + + def to_enum + Enumerator.new do |yielder| + @routes.each do |route| + if route.dispatcher? + yielder.yield RailsRouteSerializer.serialize(route) + elsif mounted_grape_app?(route.app.rack_app) + route.app.rack_app.routes.each do |grape_route| + yielder.yield GrapeRouteSerializer.serialize(grape_route, path_prefix: route.path.spec.to_s) + end + elsif mounted_sinatra_app?(route.app.rack_app) + route.app.rack_app.routes.each do |method, sinatra_routes| + next if method == 'HEAD' + + sinatra_routes.each do |sinatra_route, _, _| + yielder.yield SinatraRouteSerializer.serialize( + sinatra_route, method: method, path_prefix: route.path.spec.to_s + ) + end + end + end + end + end + end + + private + + def mounted_grape_app?(rack_app) + return false unless defined?(::Grape::API) + + rack_app.is_a?(Class) && rack_app < ::Grape::API + end + + def mounted_sinatra_app?(rack_app) + return false unless defined?(::Sinatra::Base) + + rack_app.is_a?(Class) && rack_app < ::Sinatra::Base + end + end + end + end + end +end diff --git a/lib/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rb b/lib/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rb similarity index 59% rename from lib/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rb rename to lib/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rb index 67e5fb8f8f0..a9fd7318800 100644 --- a/lib/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rb +++ b/lib/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rb @@ -5,26 +5,12 @@ module AppSec module APISecurity module EndpointCollection # This module serializes Rails Journey Router routes. - class RailsRoutesSerializer + module RailsRouteSerializer FORMAT_SUFFIX = "(.:format)" - def initialize(routes) - @routes = routes - end - - def to_enum - Enumerator.new do |yielder| - @routes.each do |route| - next unless route.dispatcher? - - yielder.yield serialize_route(route) - end - end - end - - private + module_function - def serialize_route(route) + def serialize(route) method = route.verb.empty? ? "*" : route.verb path = route.path.spec.to_s.delete_suffix(FORMAT_SUFFIX) diff --git a/lib/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rb b/lib/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rb new file mode 100644 index 00000000000..197ccbe2c78 --- /dev/null +++ b/lib/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module APISecurity + module EndpointCollection + # This module serializes Sinatra routes. + module SinatraRouteSerializer + module_function + + def serialize(route, method:, path_prefix: '') + path = path_prefix + route.safe_string + + { + type: "REST", + resource_name: "#{method} #{path}", + operation_name: "http.request", + method: method, + path: path + } + end + end + end + end + end +end diff --git a/lib/datadog/appsec/contrib/rails/patcher.rb b/lib/datadog/appsec/contrib/rails/patcher.rb index fb6e00df6c7..d4dbd2a4951 100644 --- a/lib/datadog/appsec/contrib/rails/patcher.rb +++ b/lib/datadog/appsec/contrib/rails/patcher.rb @@ -10,7 +10,7 @@ require_relative 'gateway/request' require_relative 'patches/render_to_body_patch' require_relative 'patches/process_action_patch' -require_relative '../../api_security/endpoint_collection/rails_routes_serializer' +require_relative '../../api_security/endpoint_collection/rails_collector' require_relative '../../../tracing/contrib/rack/middlewares' @@ -154,7 +154,7 @@ def report_routes_via_telemetry(routes) GUARD_ROUTES_REPORTING_ONCE_PER_APP[::Rails.application].run do AppSec.telemetry.app_endpoints_loaded( - APISecurity::EndpointCollection::RailsRoutesSerializer.new(routes).to_enum + APISecurity::EndpointCollection::RailsCollector.new(routes).to_enum ) end rescue => e diff --git a/sig/datadog/appsec/api_security/endpoint_collection.rbs b/sig/datadog/appsec/api_security/endpoint_collection.rbs new file mode 100644 index 00000000000..27f31a96d02 --- /dev/null +++ b/sig/datadog/appsec/api_security/endpoint_collection.rbs @@ -0,0 +1,45 @@ +module Datadog + module AppSec + module APISecurity + module EndpointCollection + interface _RailsRoute + def dispatcher?: () -> bool + def verb: () -> ::String + def path: () -> _RailsRoutePath + def app: () -> _RailsRouteApp + end + + interface _RailsRoutePath + def spec: () -> _RailsRouteSpec + end + + interface _RailsRouteSpec + def to_s: () -> ::String + end + + interface _RailsRouteApp + def rack_app: () -> _RackApp + end + + interface _RackApp + def is_a?: (Class) -> bool + + def routes: () -> untyped + end + + interface _GrapeRoute + def request_method: () -> String + def pattern: () -> _GrapeRoutePattern + end + + interface _GrapeRoutePattern + def origin: () -> String + end + + interface _SinatraRoute + def safe_string: () -> String + end + end + end + end +end diff --git a/sig/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rbs b/sig/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rbs new file mode 100644 index 00000000000..71ab4f18a77 --- /dev/null +++ b/sig/datadog/appsec/api_security/endpoint_collection/grape_route_serializer.rbs @@ -0,0 +1,11 @@ +module Datadog + module AppSec + module APISecurity + module EndpointCollection + module GrapeRouteSerializer + def self?.serialize: (_GrapeRoute route, ?path_prefix: ::String) -> Core::Telemetry::Event::AppEndpointsLoaded::endpoint + end + end + end + end +end diff --git a/sig/datadog/appsec/api_security/endpoint_collection/rails_collector.rbs b/sig/datadog/appsec/api_security/endpoint_collection/rails_collector.rbs new file mode 100644 index 00000000000..24d58ebcff4 --- /dev/null +++ b/sig/datadog/appsec/api_security/endpoint_collection/rails_collector.rbs @@ -0,0 +1,21 @@ +module Datadog + module AppSec + module APISecurity + module EndpointCollection + class RailsCollector + @routes: Array[_RailsRoute] + + def initialize: (Array[_RailsRoute] routes) -> void + + def to_enum: () -> Enumerator[Core::Telemetry::Event::AppEndpointsLoaded::endpoint] + + private + + def mounted_grape_app?: (_RackApp rack_app) -> bool + + def mounted_sinatra_app?: (_RackApp rack_app) -> bool + end + end + end + end +end diff --git a/sig/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rbs b/sig/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rbs new file mode 100644 index 00000000000..65a0164be85 --- /dev/null +++ b/sig/datadog/appsec/api_security/endpoint_collection/rails_route_serializer.rbs @@ -0,0 +1,13 @@ +module Datadog + module AppSec + module APISecurity + module EndpointCollection + module RailsRouteSerializer + FORMAT_SUFFIX: ::String + + def self?.serialize: (_RailsRoute route) -> Core::Telemetry::Event::AppEndpointsLoaded::endpoint + end + end + end + end +end diff --git a/sig/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rbs b/sig/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rbs deleted file mode 100644 index 5fffbf903e5..00000000000 --- a/sig/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer.rbs +++ /dev/null @@ -1,33 +0,0 @@ -module Datadog - module AppSec - module APISecurity - module EndpointCollection - interface _Route - def dispatcher?: () -> bool - def verb: () -> String - def path: () -> _RoutePath - end - - interface _RoutePath - def spec: () -> _RouteSpec - end - - interface _RouteSpec - def to_s: () -> String - end - - class RailsRoutesSerializer - FORMAT_SUFFIX: String - - @routes: Array[_Route] - - def initialize: (Array[_Route] routes) -> void - - def to_enum: () -> Enumerator[Core::Telemetry::Event::AppEndpointsLoaded::endpoint] - - def serialize_route: (_Route route) -> Core::Telemetry::Event::AppEndpointsLoaded::endpoint - end - end - end - end -end diff --git a/sig/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rbs b/sig/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rbs new file mode 100644 index 00000000000..8e1f7d2668b --- /dev/null +++ b/sig/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer.rbs @@ -0,0 +1,11 @@ +module Datadog + module AppSec + module APISecurity + module EndpointCollection + module SinatraRouteSerializer + def self?.serialize: (_SinatraRoute route, method: ::String, ?path_prefix: ::String) -> Core::Telemetry::Event::AppEndpointsLoaded::endpoint + end + end + end + end +end diff --git a/spec/datadog/appsec/api_security/endpoint_collection/grape_route_serializer_spec.rb b/spec/datadog/appsec/api_security/endpoint_collection/grape_route_serializer_spec.rb new file mode 100644 index 00000000000..94cde86c718 --- /dev/null +++ b/spec/datadog/appsec/api_security/endpoint_collection/grape_route_serializer_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/appsec/api_security/endpoint_collection/grape_route_serializer' + +RSpec.describe Datadog::AppSec::APISecurity::EndpointCollection::GrapeRouteSerializer do + describe '.serialize' do + it 'correctly serializes the route' do + result = described_class.serialize(build_route_double(path: '/events', method: 'GET')) + + aggregate_failures 'serialized attributes' do + expect(result.fetch(:type)).to eq('REST') + expect(result.fetch(:resource_name)).to eq('GET /events') + expect(result.fetch(:operation_name)).to eq('http.request') + expect(result.fetch(:method)).to eq('GET') + expect(result.fetch(:path)).to eq('/events') + end + end + + it 'adds path prefix to the route path' do + result = described_class.serialize(build_route_double(path: '/events', method: 'GET'), path_prefix: '/grape') + + aggregate_failures 'path attributes' do + expect(result.fetch(:resource_name)).to eq('GET /grape/events') + expect(result.fetch(:path)).to eq('/grape/events') + end + end + end + + def build_route_double(path:, method:) + instance_double( + 'Grape::Router::Route', + request_method: method, + pattern: instance_double('Grape::Router::Pattern', origin: path) + ) + end +end diff --git a/spec/datadog/appsec/api_security/endpoint_collection/rails_collector_spec.rb b/spec/datadog/appsec/api_security/endpoint_collection/rails_collector_spec.rb new file mode 100644 index 00000000000..56df4503fc0 --- /dev/null +++ b/spec/datadog/appsec/api_security/endpoint_collection/rails_collector_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/appsec/api_security/endpoint_collection/rails_collector' + +RSpec.describe Datadog::AppSec::APISecurity::EndpointCollection::RailsCollector do + describe '#to_enum' do + it 'returns an Enumerator' do + expect(described_class.new([]).to_enum).to be_a(Enumerator) + end + + it 'serializes Rails dispatcher routes' do + route = instance_double( + 'ActionDispatch::Journey::Route', dispatcher?: true, verb: 'GET', + path: instance_double('ActionDispatch::Journey::Path::Pattern', spec: '/events') + ) + + expect(Datadog::AppSec::APISecurity::EndpointCollection::RailsRouteSerializer) + .to receive(:serialize).and_call_original + + described_class.new([route]).to_enum.first + end + + # Grape and Sinatra routes are tested in endpoint collection integration test, + # to avoid adding grape and sinatra dependencies for unit tests + end +end diff --git a/spec/datadog/appsec/api_security/endpoint_collection/rails_route_serializer_spec.rb b/spec/datadog/appsec/api_security/endpoint_collection/rails_route_serializer_spec.rb new file mode 100644 index 00000000000..14e92c2b194 --- /dev/null +++ b/spec/datadog/appsec/api_security/endpoint_collection/rails_route_serializer_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/appsec/api_security/endpoint_collection/rails_route_serializer' + +RSpec.describe Datadog::AppSec::APISecurity::EndpointCollection::RailsRouteSerializer do + describe '.serialize' do + it 'correctly serializes the route' do + result = described_class.serialize(build_route_double(method: 'GET', path: '/events')) + + aggregate_failures 'serialized attributes' do + expect(result.fetch(:type)).to eq('REST') + expect(result.fetch(:resource_name)).to eq('GET /events') + expect(result.fetch(:operation_name)).to eq('http.request') + expect(result.fetch(:method)).to eq('GET') + expect(result.fetch(:path)).to eq('/events') + end + end + + it 'removes rails format suffix from the path' do + result = described_class.serialize(build_route_double(method: 'GET', path: '/events(.:format)')) + + aggregate_failures 'path attributes' do + expect(result.fetch(:resource_name)).to eq('GET /events') + expect(result.fetch(:path)).to eq('/events') + end + end + + it 'sets method to * for wildcard routes' do + result = described_class.serialize(build_route_double(method: '*', path: '/')) + + aggregate_failures 'path attributes' do + expect(result.fetch(:resource_name)).to eq('* /') + expect(result.fetch(:method)).to eq('*') + end + end + end + + def build_route_double(path:, method:) + instance_double( + 'ActionDispatch::Journey::Route', + verb: method, + path: instance_double('ActionDispatch::Journey::Path::Pattern', spec: path) + ) + end +end diff --git a/spec/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer_spec.rb b/spec/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer_spec.rb deleted file mode 100644 index a9cbdd7169c..00000000000 --- a/spec/datadog/appsec/api_security/endpoint_collection/rails_routes_serializer_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'datadog/appsec/api_security/endpoint_collection/rails_routes_serializer' - -RSpec.describe Datadog::AppSec::APISecurity::EndpointCollection::RailsRoutesSerializer do - describe '#to_enum' do - it 'returns an Enumerator' do - expect(described_class.new([]).to_enum).to be_a(Enumerator) - end - - it 'correctly serializes routes' do - routes = described_class.new([ - build_route_double(method: 'GET', path: '/events') - ]).to_enum - - expect(routes.count).to eq(1) - - aggregate_failures 'serialized attributes' do - expect(routes.first.fetch(:type)).to eq('REST') - expect(routes.first.fetch(:resource_name)).to eq('GET /events') - expect(routes.first.fetch(:operation_name)).to eq('http.request') - expect(routes.first.fetch(:method)).to eq('GET') - expect(routes.first.fetch(:path)).to eq('/events') - end - end - - it 'removes rails format suffix from the path' do - routes = described_class.new([ - build_route_double(method: 'GET', path: '/events(.:format)') - ]).to_enum - - aggregate_failures 'path attributes' do - expect(routes.first.fetch(:resource_name)).to eq('GET /events') - expect(routes.first.fetch(:path)).to eq('/events') - end - end - - it 'sets method to * for wildcard routes' do - routes = described_class.new([ - build_route_double(method: '*', path: '/') - ]).to_enum - - aggregate_failures 'path attributes' do - expect(routes.first.fetch(:resource_name)).to eq('* /') - expect(routes.first.fetch(:method)).to eq('*') - end - end - - it 'skips non-dispatcher routes for now' do - routes = described_class.new([ - build_route_double(method: nil, path: 'admin', is_dispatcher: false) - ]).to_enum - - expect(routes.to_a).to be_empty - end - end - - def build_route_double(method:, path:, is_dispatcher: true) - instance_double( - 'ActionDispatch::Journey::Route', - dispatcher?: is_dispatcher, - verb: method, - path: instance_double( - 'ActionDispatch::Journey::Path::Pattern', - spec: path - ) - ) - end -end diff --git a/spec/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer_spec.rb b/spec/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer_spec.rb new file mode 100644 index 00000000000..3a1c0fbb48f --- /dev/null +++ b/spec/datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/appsec/api_security/endpoint_collection/sinatra_route_serializer' + +RSpec.describe Datadog::AppSec::APISecurity::EndpointCollection::SinatraRouteSerializer do + describe '.serialize' do + it 'correctly serializes the route' do + result = described_class.serialize(build_route_double(path: '/events'), method: 'GET') + + aggregate_failures 'serialized attributes' do + expect(result.fetch(:type)).to eq('REST') + expect(result.fetch(:resource_name)).to eq('GET /events') + expect(result.fetch(:operation_name)).to eq('http.request') + expect(result.fetch(:method)).to eq('GET') + expect(result.fetch(:path)).to eq('/events') + end + end + + it 'adds path prefix to the route path' do + result = described_class.serialize(build_route_double(path: '/events'), method: 'GET', path_prefix: '/sinatra') + + aggregate_failures 'path attributes' do + expect(result.fetch(:resource_name)).to eq('GET /sinatra/events') + expect(result.fetch(:path)).to eq('/sinatra/events') + end + end + end + + def build_route_double(path:) + instance_double('Mustermann::Sinatra', safe_string: path) + end +end diff --git a/spec/datadog/appsec/integration/contrib/rails/endpoint_collection_spec.rb b/spec/datadog/appsec/integration/contrib/rails/endpoint_collection_spec.rb index 3d5109ad47f..c758fc2a04f 100644 --- a/spec/datadog/appsec/integration/contrib/rails/endpoint_collection_spec.rb +++ b/spec/datadog/appsec/integration/contrib/rails/endpoint_collection_spec.rb @@ -6,6 +6,8 @@ require 'action_controller/railtie' require 'active_record' +require 'grape' +require 'sinatra/base' require 'sqlite3' require 'devise' @@ -105,6 +107,44 @@ def initialize(files, dirs = {}, &block) allow(Datadog::AppSec.telemetry).to receive(:app_endpoints_loaded) + grape_app = Class.new(Grape::API) do + format :json + + get '/' do + {message: 'Grape Home'} + end + + get '/param/:name' do + route = request.env["datadog.http.route"] + { + message: 'Grape Params Endpoint (GET)', + route: route, + name: params[:name] + } + end + + namespace 'namespaced' do + get '/param/:name' do + route = request.env["datadog.http.route"] + { + message: 'Grape Params Endpoint (GET)', + route: route, + name: params[:name] + } + end + end + end + + sinatra_app = Class.new(Sinatra::Base) do + get '/' do + '' + end + + get '/param/:name' do + '' + end + end + # app.initialize! app.routes.draw do resources :products @@ -127,6 +167,9 @@ def initialize(files, dirs = {}, &block) end match '/search', to: 'search#index', via: :all + + mount grape_app => '/grape' + mount sinatra_app => '/sinatra' end allow(Rails).to receive(:application).and_return(app) @@ -301,6 +344,41 @@ def initialize(files, dirs = {}, &block) operation_name: 'http.request', method: '*', path: '/search' + }, + { + type: 'REST', + resource_name: 'GET /grape/', + operation_name: 'http.request', + method: 'GET', + path: '/grape/' + }, + { + type: 'REST', + resource_name: 'GET /grape/param/:name', + operation_name: 'http.request', + method: 'GET', + path: '/grape/param/:name' + }, + { + type: 'REST', + resource_name: 'GET /grape/namespaced/param/:name', + operation_name: 'http.request', + method: 'GET', + path: '/grape/namespaced/param/:name' + }, + { + type: 'REST', + resource_name: 'GET /sinatra/', + operation_name: 'http.request', + method: 'GET', + path: '/sinatra/' + }, + { + type: 'REST', + resource_name: 'GET /sinatra/param/{name}', + operation_name: 'http.request', + method: 'GET', + path: '/sinatra/param/{name}' } ) end diff --git a/vendor/rbs/grape/0/grape.rbs b/vendor/rbs/grape/0/grape.rbs new file mode 100644 index 00000000000..7d60ba137eb --- /dev/null +++ b/vendor/rbs/grape/0/grape.rbs @@ -0,0 +1,4 @@ +module Grape + class API + end +end diff --git a/vendor/rbs/sinatra/0/sinatra.rbs b/vendor/rbs/sinatra/0/sinatra.rbs index 886cb9b6941..14e3b414fd6 100644 --- a/vendor/rbs/sinatra/0/sinatra.rbs +++ b/vendor/rbs/sinatra/0/sinatra.rbs @@ -2,4 +2,7 @@ module Sinatra class Response def initialize: (::Array[::String] body, ::Integer status, ::Hash[::String, ::String] headers) -> void end + + class Base + end end