Skip to content

Commit cbbffca

Browse files
authored
Merge pull request #5006 from DataDog/appsec-security-sampling-with-inferred-route
Add a fallback to inferred route for AppSec Api security sampler
2 parents 05283ad + f400f71 commit cbbffca

File tree

9 files changed

+94
-28
lines changed

9 files changed

+94
-28
lines changed

lib/datadog/appsec/api_security/route_extractor.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require_relative '../../tracing/contrib/rack/route_inference'
4+
35
module Datadog
46
module AppSec
57
module APISecurity
@@ -66,7 +68,7 @@ def self.route_pattern(request)
6668
# to generic request path
6769
(pattern || request.path).delete_suffix(RAILS_FORMAT_SUFFIX)
6870
else
69-
request.path
71+
Tracing::Contrib::Rack::RouteInference.read_or_infer(request.env)
7072
end
7173
end
7274
end

lib/datadog/appsec/api_security/sampler.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def initialize(sample_delay)
4343

4444
def sample?(request, response)
4545
return true if @sample_delay_seconds.zero?
46+
return false if response.status == 404
4647

4748
key = Zlib.crc32("#{request.request_method}#{RouteExtractor.route_pattern(request)}#{response.status}")
4849
current_timestamp = Core::Utils::Time.now.to_i

lib/datadog/tracing/contrib/rack/middlewares.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
require_relative 'header_collection'
1414
require_relative 'header_tagging'
1515
require_relative 'request_queue'
16-
require_relative 'route_from_path_inference'
16+
require_relative 'route_inference'
1717
require_relative 'trace_proxy_middleware'
1818

1919
module Datadog
@@ -235,7 +235,7 @@ def set_route_and_endpoint_tags(trace:, request_span:, status:, env:)
235235
# we infer the route from the full request path.
236236
if last_script_name == '' && env['SCRIPT_NAME'] != '' &&
237237
!Datadog.configuration.tracing.resource_renaming.always_simplified_endpoint &&
238-
(inferred_route = RouteFromPathInference.infer(env['PATH_INFO']))
238+
(inferred_route = RouteInference.infer(env['PATH_INFO']))
239239
set_endpoint_tag(request_span, last_route + inferred_route)
240240
end
241241

@@ -250,13 +250,8 @@ def set_route_and_endpoint_tags(trace:, request_span:, status:, env:)
250250

251251
if Datadog.configuration.tracing.resource_renaming.always_simplified_endpoint ||
252252
request_span.get_tag(Tracing::Metadata::Ext::HTTP::TAG_ROUTE).nil?
253-
# when http.route tag wasn't set or resource_renaming.always_simplified_endpoint is set to true,
254-
# we will try to infer the path for the full request path,
255-
# which is SCRIPT_NAME + PATH_INFO for mounted rack applications
256-
full_path = env['SCRIPT_NAME'].to_s + env['PATH_INFO'].to_s
257-
258-
if (inferred_route = RouteFromPathInference.infer(full_path))
259-
set_endpoint_tag(request_span, inferred_route)
253+
if (inferred_route = RouteInference.read_or_infer(env))
254+
set_endpoint_tag(request_span, inferred_route) if inferred_route
260255
end
261256
elsif !request_span.get_tag(Tracing::Metadata::Ext::HTTP::TAG_ENDPOINT)
262257
set_endpoint_tag(request_span, request_span.get_tag(Tracing::Metadata::Ext::HTTP::TAG_ROUTE))

lib/datadog/tracing/contrib/rack/route_from_path_inference.rb renamed to lib/datadog/tracing/contrib/rack/route_inference.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module Contrib
66
module Rack
77
# This module provides logic for inferring HTTP route pattern
88
# from an HTTP path.
9-
module RouteFromPathInference
9+
module RouteInference
1010
MAX_NUMBER_OF_SEGMENTS = 8
1111

1212
INT_PARAM_REGEX = /\A[0-9]+\z/.freeze
@@ -15,12 +15,19 @@ module RouteFromPathInference
1515
HEX_ID_PARAM_REGEX = /\A(?=.*\d)[A-Fa-f0-9._-]{6,}\z/.freeze
1616
STRING_PARAM_REGEX = /\A.{20,}|.*[%&'()*+,:=@].*\z/.freeze
1717

18+
DATADOG_INFERRED_ROUTE_ENV_KEY = 'datadog.inferred_route'
19+
1820
module_function
1921

22+
def read_or_infer(request_env)
23+
request_env[DATADOG_INFERRED_ROUTE_ENV_KEY] ||=
24+
infer(request_env['SCRIPT_NAME'].to_s + request_env['PATH_INFO'].to_s)
25+
end
26+
2027
def infer(path)
2128
segments = path.delete_prefix('/').split('/', MAX_NUMBER_OF_SEGMENTS + 1).first(MAX_NUMBER_OF_SEGMENTS)
2229

23-
segments.map! do |segment|
30+
segments.map! do |segment| #: Array[String?]
2431
next if segment.empty?
2532

2633
case segment
@@ -33,7 +40,7 @@ def infer(path)
3340
end
3441
end
3542

36-
segments.compact!
43+
segments.compact! #: Array[String]
3744

3845
"/#{segments.join("/")}"
3946
rescue

sig/datadog/appsec/api_security/route_extractor.rbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ module Datadog
1616

1717
RAILS_FORMAT_SUFFIX: String
1818

19-
def self.route_pattern: (APISecurity::_Request request) -> String
19+
def self.route_pattern: (APISecurity::_Request request) -> String?
2020
end
2121
end
2222
end

sig/datadog/tracing/contrib/rack/route_from_path_inference.rbs renamed to sig/datadog/tracing/contrib/rack/route_inference.rbs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module Datadog
22
module Tracing
33
module Contrib
44
module Rack
5-
module RouteFromPathInference
5+
module RouteInference
66
MAX_NUMBER_OF_SEGMENTS: ::Integer
77

88
INT_PARAM_REGEX: ::Regexp
@@ -11,7 +11,11 @@ module Datadog
1111
HEX_ID_PARAM_REGEX: ::Regexp
1212
STRING_PARAM_REGEX: ::Regexp
1313

14-
def self.infer: (::String path) -> ::String?
14+
DATADOG_INFERRED_ROUTE_ENV_KEY: ::String
15+
16+
def self?.read_or_infer: (Hash[::String, untyped] request_env) -> ::String?
17+
18+
def self?.infer: (::String path) -> ::String?
1519
end
1620
end
1721
end

spec/datadog/appsec/api_security/route_extractor_spec.rb

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,22 @@
9696
before do
9797
allow(request).to receive(:env).and_return({
9898
'action_dispatch.routes' => route_set,
99-
'action_dispatch.request.path_parameters' => {}
99+
'action_dispatch.request.path_parameters' => {},
100+
'PATH_INFO' => '/users/1'
100101
})
101102
end
102103

103104
let(:router) { double('ActionDispatch::Routing::RouteSet::Router') }
104105
let(:route_set) { double('ActionDispatch::Routing::RouteSet', router: router) }
105-
let(:request) { double('Rack::Request', env: {}, script_name: '', path: '/users/1') }
106+
let(:request) { double('Rack::Request', script_name: '', path: '/users/1') }
106107

107-
it { expect(described_class.route_pattern(request)).to eq('/users/1') }
108+
it { expect(described_class.route_pattern(request)).to eq('/users/{param:int}') }
109+
110+
it 'persists inferred route in the request env' do
111+
expect { described_class.route_pattern(request) }
112+
.to change { request.env[Datadog::Tracing::Contrib::Rack::RouteInference::DATADOG_INFERRED_ROUTE_ENV_KEY] }
113+
.from(nil).to('/users/{param:int}')
114+
end
108115
end
109116

110117
context 'when route_uri_pattern is not set and request path_parameters is present' do
@@ -130,8 +137,8 @@
130137
let(:action_dispatch_request) { double('ActionDispatch::Request', env: {}, script_name: '', path: '/users/1') }
131138

132139
it 'uses action dispatch request for route recognition' do
133-
expect(router).to receive(:recognize).with(action_dispatch_request).and_return('/users/1')
134-
expect(described_class.route_pattern(request)).to eq('/users/1')
140+
expect(router).to receive(:recognize).with(action_dispatch_request).and_return('/users/:id(.:format)')
141+
expect(described_class.route_pattern(request)).to eq('/users/:id')
135142
end
136143
end
137144

@@ -143,16 +150,18 @@
143150
let(:request) { double('Rack::Request', env: {}, script_name: '', path: '/users/1', head?: false) }
144151

145152
it 'uses action dispatch request for route recognition' do
146-
expect(router).to receive(:recognize).with(request).and_return('/users/1')
147-
expect(described_class.route_pattern(request)).to eq('/users/1')
153+
expect(router).to receive(:recognize).with(request).and_return('/users/:id(.:format)')
154+
expect(described_class.route_pattern(request)).to eq('/users/:id')
148155
end
149156
end
150157
end
151158

152159
context 'when Rails router cannot recognize request' do
153160
before do
154-
allow(request).to receive(:env).and_return({'action_dispatch.routes' => route_set})
155-
allow(request).to receive(:path).and_return('/unmatched/route')
161+
allow(request).to receive(:env).and_return({
162+
'action_dispatch.routes' => route_set,
163+
'PATH_INFO' => '/unmatched/route'
164+
})
156165
allow(router).to receive(:recognize).with(request).and_return([])
157166
end
158167

@@ -169,7 +178,7 @@
169178
end
170179

171180
context 'when route has nested path' do
172-
before { allow(request).to receive(:path).and_return('/some/other/path') }
181+
before { allow(request).to receive(:env).and_return({'PATH_INFO' => '/some/other/path'}) }
173182

174183
it { expect(described_class.route_pattern(request)).to eq('/some/other/path') }
175184
end

spec/datadog/appsec/api_security/sampler_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@
7474
end
7575
end
7676

77+
context 'when response status is 404' do
78+
let(:response) { double('Rack::Response', status: 404) }
79+
80+
it 'always returns false' do
81+
3.times do
82+
expect(sampler.sample?(request, response)).to be(false)
83+
end
84+
end
85+
end
86+
7787
context 'when sampling for the first time' do
7888
it { expect(sampler.sample?(request, response)).to be(true) }
7989
end

spec/datadog/tracing/contrib/rack/route_from_path_inference_spec.rb renamed to spec/datadog/tracing/contrib/rack/route_inference_spec.rb

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,46 @@
11
require 'datadog/tracing/contrib/support/spec_helper'
22

3-
require 'datadog/tracing/contrib/rack/route_from_path_inference'
3+
require 'datadog/tracing/contrib/rack/route_inference'
4+
5+
RSpec.describe Datadog::Tracing::Contrib::Rack::RouteInference do
6+
describe '.read_or_infer' do
7+
context 'when inferred route was not yet persisted in request env' do
8+
let(:env) do
9+
{
10+
'SCRIPT_NAME' => '/api',
11+
'PATH_INFO' => '/users/1'
12+
}
13+
end
14+
15+
it 'returns inferred route' do
16+
expect(described_class.read_or_infer(env)).to eq('/api/users/{param:int}')
17+
end
18+
19+
it 'persists inferred route in request env' do
20+
expect { described_class.read_or_infer(env) }.to change { env['datadog.inferred_route'] }
21+
.from(nil).to('/api/users/{param:int}')
22+
end
23+
end
24+
25+
context 'when inferred route already persisted in request env' do
26+
let(:env) do
27+
{
28+
'SCRIPT_NAME' => '/api',
29+
'PATH_INFO' => '/users/1',
30+
'datadog.inferred_route' => '/some_route'
31+
}
32+
end
33+
34+
it 'returns persisted inferred route' do
35+
expect(described_class.read_or_infer(env)).to eq('/some_route')
36+
end
37+
38+
it 'does not change inferred route value in request env' do
39+
expect { described_class.read_or_infer(env) }.not_to change { env['datadog.inferred_route'] }
40+
end
41+
end
42+
end
443

5-
RSpec.describe Datadog::Tracing::Contrib::Rack::RouteFromPathInference do
644
describe '.infer' do
745
it 'works with an empty path' do
846
expect(described_class.infer('/')).to eq('/')

0 commit comments

Comments
 (0)