Skip to content

Commit e55b643

Browse files
authored
Merge pull request #4699 from DataDog/appsec-57738-add-api-security-sampler
[APPSEC-57738] Add API Security sampler for `rack`, `rails`, `sinatra`, `grape` contribs
2 parents 32e3810 + 648b7eb commit e55b643

File tree

23 files changed

+1033
-17
lines changed

23 files changed

+1033
-17
lines changed

.github/workflows/system-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ permissions: {}
2222
env:
2323
REGISTRY: ghcr.io
2424
REPO: ghcr.io/datadog/dd-trace-rb
25-
SYSTEM_TESTS_REF: 76f6755f072c2f50f1c956c4eb31cad9bb0e534c # Automated: Can be updated by .github/workflows/update-system-tests.yml
25+
SYSTEM_TESTS_REF: appsec-57872-enable-api-security # Automated: This reference is automatically updated.
2626

2727
jobs:
2828
changes:

.rubocop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ AllCops:
3535
- lib/datadog/core/**/*
3636
- lib/datadog/core.rb
3737
- spec/datadog/core/**/*
38+
- lib/datadog/appsec/**/*
39+
- lib/datadog/appsec.rb
40+
- spec/datadog/appsec/**/*
3841
NewCops: disable # Don't allow new cops to be enabled implicitly.
3942
SuggestExtensions: false # Stop pushing suggestions constantly.
4043

lib/datadog/appsec/api_security.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
# frozen_string_literal: true
22

3+
require_relative 'api_security/sampler'
4+
35
module Datadog
46
module AppSec
57
# A namespace for API Security features.
68
module APISecurity
9+
def self.enabled?
10+
Datadog.configuration.appsec.api_security.enabled?
11+
end
12+
13+
def self.sample?(request, response)
14+
Sampler.thread_local.sample?(request, response)
15+
end
16+
17+
def self.sample_trace?(trace)
18+
# NOTE: Reads as "if trace is priority sampled or if in standalone mode"
19+
trace&.priority_sampled? || !Datadog.configuration.apm.tracing.enabled
20+
end
721
end
822
end
923
end

lib/datadog/appsec/api_security/lru_cache.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ def [](key)
3030
end
3131
end
3232

33+
def store(key, value)
34+
return @store[key] = value if @store.delete(key)
35+
36+
# NOTE: evict the oldest entry if store reached the maximum allowed size
37+
@store.shift if @store.size >= @max_size
38+
@store[key] = value
39+
end
40+
3341
# NOTE: If the key exists, it's moved to the end of the list and
3442
# if does not, the given block will be executed and the result
3543
# will be stored (which will add it to the end of the list).
@@ -40,8 +48,7 @@ def fetch_or_store(key)
4048

4149
# NOTE: evict the oldest entry if store reached the maximum allowed size
4250
@store.shift if @store.size >= @max_size
43-
44-
@store[key] ||= yield
51+
@store[key] = yield
4552
end
4653
end
4754
end
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
module Datadog
4+
module AppSec
5+
module APISecurity
6+
# This is a helper module to extract the route pattern from the Rack::Request.
7+
module RouteExtractor
8+
SINATRA_ROUTE_KEY = 'sinatra.route'
9+
SINATRA_ROUTE_SEPARATOR = ' '
10+
GRAPE_ROUTE_KEY = 'grape.routing_args'
11+
RAILS_ROUTE_KEY = 'action_dispatch.route_uri_pattern'
12+
RAILS_ROUTES_KEY = 'action_dispatch.routes'
13+
RAILS_FORMAT_SUFFIX = '(.:format)'
14+
15+
# HACK: We rely on the fact that each contrib will modify `request.env`
16+
# and store information sufficient to compute the canonical
17+
# route (ex: `/users/:id`).
18+
#
19+
# When contribs like Sinatra or Grape are used, they could be mounted
20+
# into the Rails app, hence you can see the use of the `script_name`
21+
# that will contain the path prefix of the mounted app.
22+
#
23+
# Rack
24+
# does not support named arguments, so we have to use `path`
25+
# Sinatra
26+
# uses `sinatra.route` with a string like "GET /users/:id"
27+
# Grape
28+
# uses `grape.routing_args` with a hash with a `:route_info` key
29+
# that contains a `Grape::Router::Route` object that contains
30+
# `Grape::Router::Pattern` object with an `origin` method
31+
# Rails < 7.1 (slow path)
32+
# uses `action_dispatch.routes` to store `ActionDispatch::Routing::RouteSet`
33+
# which can recognize requests
34+
# Rails > 7.1 (fast path)
35+
# uses `action_dispatch.route_uri_pattern` with a string like
36+
# "/users/:id(.:format)"
37+
#
38+
# WARNING: This method works only *after* the request has been routed.
39+
def self.route_pattern(request)
40+
if request.env.key?(GRAPE_ROUTE_KEY)
41+
pattern = request.env[GRAPE_ROUTE_KEY][:route_info]&.pattern&.origin
42+
"#{request.script_name}#{pattern}"
43+
elsif request.env.key?(SINATRA_ROUTE_KEY)
44+
pattern = request.env[SINATRA_ROUTE_KEY].split(SINATRA_ROUTE_SEPARATOR, 2)[1]
45+
"#{request.script_name}#{pattern}"
46+
elsif request.env.key?(RAILS_ROUTE_KEY)
47+
request.env[RAILS_ROUTE_KEY].delete_suffix(RAILS_FORMAT_SUFFIX)
48+
elsif request.env.key?(RAILS_ROUTES_KEY)
49+
pattern = request.env[RAILS_ROUTES_KEY].router
50+
.recognize(request) { |route, _| break route.path.spec.to_s }
51+
52+
# NOTE: If rails is unable to recognize request it returns empty Array
53+
pattern = nil if pattern&.empty?
54+
55+
# NOTE: If rails can't recognize the request, we are going to fallback
56+
# to generic request path
57+
(pattern || request.path).delete_suffix(RAILS_FORMAT_SUFFIX)
58+
else
59+
request.path
60+
end
61+
end
62+
end
63+
end
64+
end
65+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
require 'zlib'
4+
require_relative 'lru_cache'
5+
require_relative 'route_extractor'
6+
require_relative '../../core/utils/time'
7+
8+
module Datadog
9+
module AppSec
10+
module APISecurity
11+
# A thread-local sampler for API security based on defined delay between
12+
# samples with caching capability.
13+
class Sampler
14+
THREAD_KEY = :datadog_appsec_api_security_sampler
15+
MAX_CACHE_SIZE = 4096
16+
17+
class << self
18+
def thread_local
19+
sampler = Thread.current.thread_variable_get(THREAD_KEY)
20+
return sampler unless sampler.nil?
21+
22+
Thread.current.thread_variable_set(THREAD_KEY, new(sample_delay))
23+
end
24+
25+
# @api private
26+
def reset!
27+
Thread.current.thread_variable_set(THREAD_KEY, nil)
28+
end
29+
30+
private
31+
32+
def sample_delay
33+
Datadog.configuration.appsec.api_security.sample_delay
34+
end
35+
end
36+
37+
def initialize(sample_delay)
38+
raise ArgumentError, 'sample_delay must be an Integer' unless sample_delay.is_a?(Integer)
39+
40+
@cache = LRUCache.new(MAX_CACHE_SIZE)
41+
@sample_delay_seconds = sample_delay
42+
end
43+
44+
def sample?(request, response)
45+
return true if @sample_delay_seconds.zero?
46+
47+
key = Zlib.crc32("#{request.request_method}#{RouteExtractor.route_pattern(request)}#{response.status}")
48+
current_timestamp = Core::Utils::Time.now.to_i
49+
cached_timestamp = @cache[key] || 0
50+
51+
return false if current_timestamp - cached_timestamp <= @sample_delay_seconds
52+
53+
@cache.store(key, current_timestamp)
54+
true
55+
end
56+
end
57+
end
58+
end
59+
end

lib/datadog/appsec/configuration/settings.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,12 +311,27 @@ def self.add_settings!(base)
311311
end
312312

313313
settings :api_security do
314+
define_method(:enabled?) { get_option(:enabled) }
315+
314316
option :enabled do |o|
315317
o.type :bool
316318
o.env 'DD_EXPERIMENTAL_API_SECURITY_ENABLED'
317319
o.default false
318320
end
319321

322+
# NOTE: Unfortunately, we have to go with Float due to other libs
323+
# setup, even tho we don't plan to support sub-second delays.
324+
#
325+
# WARNING: The value will be converted to Integer.
326+
option :sample_delay do |o|
327+
o.type :float
328+
o.env 'DD_API_SECURITY_SAMPLE_DELAY'
329+
o.default 30
330+
o.setter do |value|
331+
value.to_i
332+
end
333+
end
334+
320335
option :sample_rate do |o|
321336
o.type :float
322337
o.env 'DD_API_SECURITY_REQUEST_SAMPLE_RATE'

lib/datadog/appsec/contrib/rack/request_middleware.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative '../../event'
99
require_relative '../../response'
1010
require_relative '../../processor'
11+
require_relative '../../api_security'
1112
require_relative '../../security_event'
1213
require_relative '../../instrumentation/gateway'
1314

@@ -100,7 +101,23 @@ def call(env)
100101
http_response = AppSec::Response.from_interrupt_params(interrupt_params, env['HTTP_ACCEPT']).to_rack
101102
end
102103

103-
if AppSec.perform_api_security_check?
104+
# NOTE: This is not optimal, but in the current implementation
105+
# `gateway_response` is a container to dispatch response event
106+
# and in case of interruption it suppose to be `nil`.
107+
#
108+
# `http_response` is a real response object in both cases, but
109+
# to save us some computations, we will use already pre-computed
110+
# `gateway_response` instead of re-creating it.
111+
#
112+
# WARNING: This part will be refactored.
113+
tmp_response = if interrupt_params
114+
Gateway::Response.new(http_response[2], http_response[0], http_response[1], context: ctx)
115+
else
116+
gateway_response
117+
end
118+
119+
if AppSec::APISecurity.enabled? && AppSec::APISecurity.sample_trace?(ctx.trace) &&
120+
AppSec::APISecurity.sample?(gateway_request.request, tmp_response.response)
104121
ctx.events.push(
105122
AppSec::SecurityEvent.new(ctx.extract_schema, trace: ctx.trace, span: ctx.span)
106123
)

sig/datadog/appsec/api_security.rbs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
module Datadog
22
module AppSec
33
module APISecurity
4+
interface _Request
5+
def env: () -> Hash[String, untyped]
6+
def script_name: () -> String?
7+
def request_method: () -> String
8+
def path: () -> String
9+
end
10+
11+
interface _Response
12+
def status: () -> Integer
13+
end
14+
15+
def self.enabled?: () -> bool
16+
17+
def self.sample?: (_Request request, _Response response) -> bool
18+
19+
def self.sample_trace?: (Tracing::TraceOperation trace) -> bool
420
end
521
end
622
end

sig/datadog/appsec/api_security/lru_cache.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ module Datadog
1212

1313
def []: (untyped key) -> untyped?
1414

15+
def store: (untyped key, untyped value) -> untyped
16+
1517
def fetch_or_store: (untyped key) { () -> untyped } -> untyped
1618

1719
def clear: () -> void
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Datadog
2+
module AppSec
3+
module APISecurity
4+
module RouteExtractor
5+
SINATRA_ROUTE_KEY: String
6+
7+
SINATRA_ROUTE_SEPARATOR: String
8+
9+
GRAPE_ROUTE_KEY: String
10+
11+
RAILS_ROUTE_KEY: String
12+
13+
RAILS_ROUTES_KEY: String
14+
15+
RAILS_FORMAT_SUFFIX: String
16+
17+
def self.route_pattern: (APISecurity::_Request request) -> String
18+
end
19+
end
20+
end
21+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module Datadog
2+
module AppSec
3+
module APISecurity
4+
class Sampler
5+
THREAD_KEY: Symbol
6+
7+
MAX_CACHE_SIZE: Integer
8+
9+
@cache: LRUCache
10+
11+
@sample_delay_seconds: Integer
12+
13+
def self.thread_local: () -> Sampler
14+
15+
def self.reset!: () -> void
16+
17+
def initialize: (Integer sample_delay) -> void
18+
19+
def sample?: (_Request request, _Response response) -> bool
20+
21+
private
22+
23+
def self.sample_delay: () -> Integer
24+
end
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)