Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module Datadog
module AppSec
module APISecurity
module EndpointCollection
# This module serializes Rails Journey Router routes.
class RailsRoutesSerializer
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

def serialize_route(route)
method = route.verb.empty? ? "*" : route.verb
path = route.path.spec.to_s.delete_suffix(FORMAT_SUFFIX)

{
type: "REST",
resource_name: "#{method} #{path}",
operation_name: "http.request",
method: method,
path: path
}
end
end
end
end
end
end
9 changes: 9 additions & 0 deletions lib/datadog/appsec/configuration/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,15 @@ def self.add_settings!(base)
o.default true
end

settings :endpoint_collection do
# Enables reporting of application routes at application start via telemetry
option :enabled do |o|
o.type :bool, nilable: true
o.env 'DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED'
o.default false
end
end

# NOTE: Unfortunately, we have to go with Float due to other libs
# setup, even tho we don't plan to support sub-second delays.
#
Expand Down
28 changes: 28 additions & 0 deletions lib/datadog/appsec/contrib/rails/patcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +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 '../../../tracing/contrib/rack/middlewares'

Expand All @@ -20,6 +21,7 @@ module Rails
# Patcher for AppSec on Rails
module Patcher
GUARD_ACTION_CONTROLLER_ONCE_PER_APP = Hash.new { |h, key| h[key] = Datadog::Core::Utils::OnlyOnce.new }
GUARD_ROUTES_REPORTING_ONCE_PER_APP = Hash.new { |h, key| h[key] = Datadog::Core::Utils::OnlyOnce.new }
BEFORE_INITIALIZE_ONLY_ONCE_PER_APP = Hash.new { |h, key| h[key] = Datadog::Core::Utils::OnlyOnce.new }
AFTER_INITIALIZE_ONLY_ONCE_PER_APP = Hash.new { |h, key| h[key] = Datadog::Core::Utils::OnlyOnce.new }

Expand All @@ -38,6 +40,7 @@ def patch
patch_before_initialize
patch_after_initialize
patch_action_controller
subscribe_to_routes_loaded

Patcher.instance_variable_set(:@patched, true)
end
Expand Down Expand Up @@ -128,6 +131,31 @@ def patch_action_controller
GUARD_ACTION_CONTROLLER_ONCE_PER_APP[self].run do
::ActionController::Base.prepend(Patches::RenderToBodyPatch)
end

# Rails 7.1 adds `after_routes_loaded` hook
if Datadog::AppSec::Contrib::Rails::Patcher.target_version < Gem::Version.new('7.1')
Datadog::AppSec::Contrib::Rails::Patcher.report_routes_via_telemetry(::Rails.application.routes.routes)
end
end
end

def subscribe_to_routes_loaded
::ActiveSupport.on_load(:after_routes_loaded) do |app|
Datadog::AppSec::Contrib::Rails::Patcher.report_routes_via_telemetry(app.routes.routes)
end
end

def report_routes_via_telemetry(routes)
# We do not support Rails 4.x for Endpoint Collection,
# mainly because the Route#verb was a Regexp before Rails 5.0
return if target_version < Gem::Version.new('5.0')

return unless Datadog.configuration.appsec.api_security.endpoint_collection.enabled

GUARD_ROUTES_REPORTING_ONCE_PER_APP[self].run do
AppSec.telemetry.app_endpoints_loaded(
APISecurity::EndpointCollection::RailsRoutesSerializer.new(routes).to_enum
)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Configuration
{"DD_AGENT_HOST" => {version: ["A"]},
"DD_API_KEY" => {version: ["A"]},
"DD_API_SECURITY_ENABLED" => {version: ["A"]},
"DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED" => {version: ["A"]},
"DD_API_SECURITY_REQUEST_SAMPLE_RATE" => {version: ["A"]},
"DD_API_SECURITY_SAMPLE_DELAY" => {version: ["A"]},
"DD_APM_TRACING_ENABLED" => {version: ["A"]},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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
4 changes: 4 additions & 0 deletions sig/datadog/appsec/contrib/rails/patcher.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ module Datadog

def self?.after_initialize: (untyped app) -> untyped

def self.subscribe_to_routes_loaded: () -> void

def self.report_routes_via_telemetry: () -> void

def self?.setup_security: () -> untyped
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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
38 changes: 38 additions & 0 deletions spec/datadog/appsec/configuration/settings_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,44 @@ def patcher
end
end
end

describe 'endpoint_collection' do
describe '#enabled' do
context 'when DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED is undefined' do
around do |example|
ClimateControl.modify('DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED' => nil) { example.run }
end

it { expect(settings.appsec.api_security.endpoint_collection.enabled).to eq(false) }
end

context 'when DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED is set to true' do
around do |example|
ClimateControl.modify('DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED' => 'true') { example.run }
end

it { expect(settings.appsec.api_security.endpoint_collection.enabled).to eq(true) }
end

context 'when DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED is set to false' do
around do |example|
ClimateControl.modify('DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED' => 'false') { example.run }
end

it { expect(settings.appsec.api_security.endpoint_collection.enabled).to eq(false) }
end
end

describe '#enabled=' do
[true, false].each do |value|
context "when given #{value}" do
before { settings.appsec.api_security.endpoint_collection.enabled = value }

it { expect(settings.appsec.api_security.endpoint_collection.enabled).to eq(value) }
end
end
end
end
end

describe 'sca' do
Expand Down
Loading