datadog 2.36.0 → 2.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
- data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +6 -3
- data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +17 -3
- data/ext/datadog_profiling_native_extension/collectors_thread_context.c +219 -98
- data/ext/datadog_profiling_native_extension/collectors_thread_context.h +2 -0
- data/ext/datadog_profiling_native_extension/extconf.rb +11 -0
- data/ext/datadog_profiling_native_extension/ruby_helpers.c +7 -0
- data/ext/libdatadog_api/di.c +76 -0
- data/ext/libdatadog_api/extconf.rb +6 -0
- data/ext/libdatadog_extconf_helpers.rb +1 -1
- data/lib/datadog/ai_guard/api_client.rb +6 -4
- data/lib/datadog/appsec/api_security/route_extractor.rb +7 -4
- data/lib/datadog/appsec/configuration.rb +6 -0
- data/lib/datadog/appsec/context.rb +4 -2
- data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
- data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
- data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +1 -1
- data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/contrib/rack/input_peeker.rb +4 -17
- data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
- data/lib/datadog/appsec/contrib/rails/gateway/request.rb +2 -1
- data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
- data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/ext.rb +1 -0
- data/lib/datadog/appsec/metrics/collector.rb +18 -1
- data/lib/datadog/appsec/metrics/exporter.rb +9 -3
- data/lib/datadog/appsec/remote.rb +9 -3
- data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
- data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
- data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
- data/lib/datadog/appsec/route_normalizer.rb +80 -0
- data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
- data/lib/datadog/core/configuration/components.rb +82 -2
- data/lib/datadog/core/configuration/components_state.rb +6 -1
- data/lib/datadog/core/configuration/settings.rb +0 -3
- data/lib/datadog/core/configuration/supported_configurations.rb +2 -0
- data/lib/datadog/core/evp.rb +11 -0
- data/lib/datadog/core/remote/client/capabilities.rb +34 -7
- data/lib/datadog/core/telemetry/event/app_started.rb +8 -1
- data/lib/datadog/di/base.rb +3 -1
- data/lib/datadog/di/code_tracker.rb +5 -2
- data/lib/datadog/di/component.rb +110 -36
- data/lib/datadog/di/error.rb +10 -0
- data/lib/datadog/di/fatal_exceptions.rb +26 -0
- data/lib/datadog/di/instrumenter.rb +381 -165
- data/lib/datadog/di/probe.rb +14 -0
- data/lib/datadog/di/probe_file_loader.rb +8 -3
- data/lib/datadog/di/probe_manager.rb +67 -9
- data/lib/datadog/di/probe_notification_builder.rb +4 -1
- data/lib/datadog/di/probe_notifier_worker.rb +52 -32
- data/lib/datadog/di/redactor.rb +16 -1
- data/lib/datadog/di/remote.rb +175 -14
- data/lib/datadog/di/serializer.rb +12 -5
- data/lib/datadog/di/transport/input.rb +8 -4
- data/lib/datadog/di.rb +63 -0
- data/lib/datadog/kit/appsec/events/v2.rb +60 -2
- data/lib/datadog/open_feature/component.rb +29 -6
- data/lib/datadog/open_feature/configuration.rb +8 -0
- data/lib/datadog/open_feature/ext.rb +2 -0
- data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
- data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
- data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
- data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
- data/lib/datadog/open_feature/native_evaluator.rb +22 -0
- data/lib/datadog/open_feature/provider.rb +59 -27
- data/lib/datadog/open_feature/transport.rb +55 -1
- data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
- data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
- data/lib/datadog/opentelemetry/sdk.rb +19 -0
- data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
- data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
- data/lib/datadog/symbol_database/component.rb +164 -47
- data/lib/datadog/symbol_database/configuration.rb +12 -4
- data/lib/datadog/symbol_database/extensions.rb +19 -0
- data/lib/datadog/symbol_database/extractor.rb +124 -87
- data/lib/datadog/symbol_database/file_hash.rb +3 -1
- data/lib/datadog/symbol_database/remote.rb +8 -3
- data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
- data/lib/datadog/symbol_database/uploader.rb +9 -2
- data/lib/datadog/symbol_database.rb +22 -0
- data/lib/datadog/tracing/distributed/baggage.rb +2 -1
- data/lib/datadog/tracing/ext.rb +9 -0
- data/lib/datadog/tracing/remote.rb +34 -3
- data/lib/datadog/version.rb +1 -1
- data/lib/datadog.rb +1 -0
- metadata +19 -6
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'route_text'
|
|
4
|
+
|
|
5
|
+
module Datadog
|
|
6
|
+
module AppSec
|
|
7
|
+
module RouteNormalizer
|
|
8
|
+
# Normalizes a route spec pattern into the route format, inspired by
|
|
9
|
+
# OpenAPI v3 path templating (best effort)
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
#
|
|
13
|
+
# /users/:id => /users/{id}
|
|
14
|
+
# /photos/:id.:format => /photos/{id+format}
|
|
15
|
+
# /posts/:id(.:format) => /posts/{id+format}
|
|
16
|
+
# /files/*path => /files/{path}
|
|
17
|
+
# /hello world => /hello%20world
|
|
18
|
+
#
|
|
19
|
+
# NOTE: When a request path is supplied, optional groups `(...)` are resolved
|
|
20
|
+
# against it: a group is kept only when the path actually matched it.
|
|
21
|
+
#
|
|
22
|
+
# NOTE: Without a request path (or for paths longer than {MAX_RESOLVE_LENGTH}),
|
|
23
|
+
# optional markers are flattened and kept as if present.
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
class RoutePattern
|
|
27
|
+
GROUP_OPEN_CHAR = '('
|
|
28
|
+
GROUP_CLOSE_CHAR = ')'
|
|
29
|
+
|
|
30
|
+
OPTIONAL_GROUP_SUFFIX_CHAR = '?'
|
|
31
|
+
OPTIONAL_GROUP_SIGILS = [
|
|
32
|
+
GROUP_OPEN_CHAR,
|
|
33
|
+
GROUP_CLOSE_CHAR,
|
|
34
|
+
OPTIONAL_GROUP_SUFFIX_CHAR
|
|
35
|
+
].join
|
|
36
|
+
|
|
37
|
+
NAMED_PARAM_PREFIX_CHAR = ':'
|
|
38
|
+
GLOB_PARAM_PREFIX_CHAR = '*'
|
|
39
|
+
|
|
40
|
+
PATTERN_STRUCTURE_CHARS = [
|
|
41
|
+
NAMED_PARAM_PREFIX_CHAR,
|
|
42
|
+
GLOB_PARAM_PREFIX_CHAR,
|
|
43
|
+
GROUP_OPEN_CHAR,
|
|
44
|
+
GROUP_CLOSE_CHAR
|
|
45
|
+
].join
|
|
46
|
+
|
|
47
|
+
PARAM_NAME_CHARS = [*'a'..'z', *'A'..'Z', *'0'..'9', '_'].join
|
|
48
|
+
|
|
49
|
+
EXCLUDED_PARAM_NAME_TERMINATOR_CHARS = [
|
|
50
|
+
PARAM_NAME_CHARS,
|
|
51
|
+
PATTERN_STRUCTURE_CHARS
|
|
52
|
+
].join
|
|
53
|
+
|
|
54
|
+
MAX_RESOLVE_LENGTH = 8192
|
|
55
|
+
|
|
56
|
+
Checkpoint = Struct.new(:resolved_length, :pattern_index, :path_index)
|
|
57
|
+
|
|
58
|
+
# Param sigils mark where dynamic route syntax may start
|
|
59
|
+
#
|
|
60
|
+
# Example:
|
|
61
|
+
#
|
|
62
|
+
# users -> no
|
|
63
|
+
# :id -> yes
|
|
64
|
+
# user-:id -> yes
|
|
65
|
+
# * -> yes
|
|
66
|
+
# foo: -> yes (validated later by {PARAM_TOKENS})
|
|
67
|
+
PARAM_START_SIGILS = /[:\*]/
|
|
68
|
+
PARAM_TOKENS = /:\w+|(?<!\w)\*\w*/
|
|
69
|
+
|
|
70
|
+
def initialize(pattern)
|
|
71
|
+
@pattern = pattern
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize(path: nil)
|
|
75
|
+
@path_index = 0
|
|
76
|
+
@pattern_index = 0
|
|
77
|
+
|
|
78
|
+
@path = path || ''
|
|
79
|
+
@path_length = @path.length
|
|
80
|
+
@pattern_length = @pattern.length
|
|
81
|
+
|
|
82
|
+
nameless_counter = 0
|
|
83
|
+
pattern = resolve_pattern_optionals(path)
|
|
84
|
+
|
|
85
|
+
result = pattern.split('/', -1).each_with_object(+'') do |segment, memo|
|
|
86
|
+
memo << '/' unless memo.empty? && segment.empty?
|
|
87
|
+
next if segment.empty?
|
|
88
|
+
|
|
89
|
+
unless segment.match?(PARAM_START_SIGILS)
|
|
90
|
+
memo << RouteText.escape(segment)
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tokens = segment.scan(PARAM_TOKENS)
|
|
95
|
+
|
|
96
|
+
if tokens.empty?
|
|
97
|
+
memo << RouteText.escape(segment)
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
names = tokens.map do |token|
|
|
102
|
+
if token.length > 1
|
|
103
|
+
token[1..-1]
|
|
104
|
+
else
|
|
105
|
+
"param#{nameless_counter += 1}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
memo << "{#{names.join('+')}}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result.start_with?('/') ? result : "/#{result}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Optional resolution walks the route pattern and request path together
|
|
118
|
+
#
|
|
119
|
+
# Example:
|
|
120
|
+
#
|
|
121
|
+
# pattern: /posts/:id(.:format)
|
|
122
|
+
# path: /posts/1.json
|
|
123
|
+
# ^
|
|
124
|
+
# `.` matches, so the optional group is kept
|
|
125
|
+
#
|
|
126
|
+
# pattern: /posts/:id(.:format)
|
|
127
|
+
# path: /posts/1
|
|
128
|
+
# ^
|
|
129
|
+
# path ended before `.`, so the optional group is skipped
|
|
130
|
+
#
|
|
131
|
+
# Checkpoints let optional groups fail after a partial match
|
|
132
|
+
#
|
|
133
|
+
# pattern: /posts(/:id)/edit
|
|
134
|
+
# path: /posts/edit
|
|
135
|
+
# ^
|
|
136
|
+
# `(/:id)` starts like `/edit`, so a checkpoint is saved
|
|
137
|
+
#
|
|
138
|
+
# pattern: /posts(/:id)/edit
|
|
139
|
+
# path: /posts/edit
|
|
140
|
+
# ^
|
|
141
|
+
# `:id` cannot consume `edit`, so we rewind after `(/:id)`
|
|
142
|
+
def resolve_pattern_optionals(path)
|
|
143
|
+
return @pattern if !@pattern.include?(GROUP_OPEN_CHAR) && !@pattern.include?(OPTIONAL_GROUP_SUFFIX_CHAR)
|
|
144
|
+
|
|
145
|
+
unless resolve_pattern_optionals?(path)
|
|
146
|
+
return remove_optional_group_sigils(@pattern) if @pattern.include?(GROUP_OPEN_CHAR)
|
|
147
|
+
|
|
148
|
+
return @pattern
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
resolved = +''
|
|
152
|
+
checkpoints = []
|
|
153
|
+
|
|
154
|
+
while @pattern_index < @pattern_length
|
|
155
|
+
char = @pattern[@pattern_index] # : String
|
|
156
|
+
|
|
157
|
+
case char
|
|
158
|
+
when GROUP_OPEN_CHAR
|
|
159
|
+
optional_group_end_index = find_next_optional_group_end_index
|
|
160
|
+
|
|
161
|
+
# NOTE: The request path may share the optional group's first char
|
|
162
|
+
# with the next required segment
|
|
163
|
+
#
|
|
164
|
+
# Example: `/posts/edit` request path starts like `(/:id)` in pattern
|
|
165
|
+
# `/posts(/:id)/edit`, but `:id` must be skipped
|
|
166
|
+
if @path_index < @path_length && @path[@path_index] == @pattern[@pattern_index + 1]
|
|
167
|
+
checkpoints << Checkpoint.new(resolved.length, optional_group_end_index + 1, @path_index)
|
|
168
|
+
@pattern_index += 1
|
|
169
|
+
else
|
|
170
|
+
@pattern_index = optional_group_end_index + 1
|
|
171
|
+
end
|
|
172
|
+
when GROUP_CLOSE_CHAR
|
|
173
|
+
@pattern_index += 1
|
|
174
|
+
@pattern_index += 1 if @pattern[@pattern_index] == OPTIONAL_GROUP_SUFFIX_CHAR
|
|
175
|
+
when OPTIONAL_GROUP_SUFFIX_CHAR
|
|
176
|
+
@pattern_index += 1
|
|
177
|
+
when NAMED_PARAM_PREFIX_CHAR
|
|
178
|
+
param_name_end_index = find_param_name_end_index(@pattern_index)
|
|
179
|
+
|
|
180
|
+
# NOTE: A ':' without a param name is literal text
|
|
181
|
+
# Example: `/foo:` must match a literal `:`, not a param
|
|
182
|
+
if param_name_end_index == @pattern_index + 1
|
|
183
|
+
unless expected_path_char?(NAMED_PARAM_PREFIX_CHAR)
|
|
184
|
+
checkpoint = restore_checkpoint!(resolved, checkpoints)
|
|
185
|
+
return remove_optional_group_sigils(@pattern) unless checkpoint
|
|
186
|
+
|
|
187
|
+
@pattern_index = checkpoint.pattern_index
|
|
188
|
+
@path_index = checkpoint.path_index
|
|
189
|
+
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@path_index += 1
|
|
194
|
+
@pattern_index += 1
|
|
195
|
+
resolved << NAMED_PARAM_PREFIX_CHAR
|
|
196
|
+
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# NOTE: A trailing `?` makes the param optional
|
|
201
|
+
# Example: `/posts/:id.?:format?` with `/posts/1` skips `:format?`
|
|
202
|
+
if @pattern[param_name_end_index] == OPTIONAL_GROUP_SUFFIX_CHAR && @path_index >= @path_length
|
|
203
|
+
@pattern_index = param_name_end_index + 1
|
|
204
|
+
next
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
param_token = @pattern[@pattern_index...param_name_end_index] # : String
|
|
208
|
+
resolved << param_token
|
|
209
|
+
|
|
210
|
+
terminator_char = find_param_value_terminator_char(param_name_end_index: param_name_end_index)
|
|
211
|
+
@path_index = find_param_value_end_index(terminator_char: terminator_char)
|
|
212
|
+
|
|
213
|
+
@pattern_index = param_name_end_index
|
|
214
|
+
when GLOB_PARAM_PREFIX_CHAR
|
|
215
|
+
param_name_end_index = find_param_name_end_index(@pattern_index)
|
|
216
|
+
terminator_char = find_param_value_terminator_char(param_name_end_index: param_name_end_index)
|
|
217
|
+
|
|
218
|
+
param_token = @pattern[@pattern_index...param_name_end_index] # : String
|
|
219
|
+
resolved << param_token
|
|
220
|
+
|
|
221
|
+
@path_index = find_glob_value_end_index(terminator_char: terminator_char)
|
|
222
|
+
@pattern_index = param_name_end_index
|
|
223
|
+
else
|
|
224
|
+
unless expected_path_char?(char)
|
|
225
|
+
if @pattern[@pattern_index + 1] == OPTIONAL_GROUP_SUFFIX_CHAR
|
|
226
|
+
@pattern_index += 2
|
|
227
|
+
next
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
next if skip_char_with_optional_param?
|
|
231
|
+
|
|
232
|
+
checkpoint = restore_checkpoint!(resolved, checkpoints)
|
|
233
|
+
return remove_optional_group_sigils(@pattern) unless checkpoint
|
|
234
|
+
|
|
235
|
+
@pattern_index = checkpoint.pattern_index
|
|
236
|
+
@path_index = checkpoint.path_index
|
|
237
|
+
|
|
238
|
+
next
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
resolved << char
|
|
242
|
+
|
|
243
|
+
@path_index += 1
|
|
244
|
+
@pattern_index += 1
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
resolved
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def resolve_pattern_optionals?(path)
|
|
252
|
+
return false unless path
|
|
253
|
+
|
|
254
|
+
@path_length <= MAX_RESOLVE_LENGTH
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def expected_path_char?(char)
|
|
258
|
+
@path_index < @path_length && @path[@path_index] == char
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def restore_checkpoint!(resolved, checkpoints)
|
|
262
|
+
checkpoint = checkpoints.pop
|
|
263
|
+
return unless checkpoint
|
|
264
|
+
|
|
265
|
+
resolved.slice!(checkpoint.resolved_length..-1)
|
|
266
|
+
checkpoint
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def remove_optional_group_sigils(pattern)
|
|
270
|
+
pattern.delete(OPTIONAL_GROUP_SIGILS)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# NOTE: When the path ends before a char that prefixes an optional param,
|
|
274
|
+
# skip both the char and that param
|
|
275
|
+
#
|
|
276
|
+
# Example:
|
|
277
|
+
# pattern: /posts/:id?
|
|
278
|
+
# path: /posts
|
|
279
|
+
# skip: ^^^^^
|
|
280
|
+
#
|
|
281
|
+
# pattern: /a.:b?
|
|
282
|
+
# path: /a
|
|
283
|
+
# skip: ^^^^
|
|
284
|
+
def skip_char_with_optional_param?
|
|
285
|
+
return false if @path_index < @path_length
|
|
286
|
+
|
|
287
|
+
param_start_index = @pattern_index + 1
|
|
288
|
+
return false unless param_start_char?(param_start_index)
|
|
289
|
+
|
|
290
|
+
param_name_end_index = find_param_name_end_index(param_start_index)
|
|
291
|
+
return false unless @pattern[param_name_end_index] == OPTIONAL_GROUP_SUFFIX_CHAR
|
|
292
|
+
|
|
293
|
+
@pattern_index = param_name_end_index + 1
|
|
294
|
+
true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def param_start_char?(index)
|
|
298
|
+
@pattern[index] == NAMED_PARAM_PREFIX_CHAR || @pattern[index] == GLOB_PARAM_PREFIX_CHAR
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def find_param_name_end_index(param_start_index)
|
|
302
|
+
current_index = param_start_index + 1
|
|
303
|
+
|
|
304
|
+
while current_index < @pattern_length
|
|
305
|
+
char = @pattern[current_index] # : String
|
|
306
|
+
break unless PARAM_NAME_CHARS.include?(char)
|
|
307
|
+
|
|
308
|
+
current_index += 1
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
current_index
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def find_param_value_terminator_char(param_name_end_index:)
|
|
315
|
+
char = @pattern[param_name_end_index]
|
|
316
|
+
|
|
317
|
+
if char == GROUP_OPEN_CHAR
|
|
318
|
+
@pattern[param_name_end_index + 1]
|
|
319
|
+
else
|
|
320
|
+
char
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def find_param_value_end_index(terminator_char:)
|
|
325
|
+
terminator_indexes = []
|
|
326
|
+
slash_index = @path.index('/', @path_index)
|
|
327
|
+
dot_index = @path.index('.', @path_index)
|
|
328
|
+
|
|
329
|
+
terminator_indexes << slash_index if slash_index
|
|
330
|
+
terminator_indexes << dot_index if dot_index
|
|
331
|
+
|
|
332
|
+
if terminator_char && !EXCLUDED_PARAM_NAME_TERMINATOR_CHARS.include?(terminator_char)
|
|
333
|
+
custom_index = @path.index(terminator_char, @path_index)
|
|
334
|
+
terminator_indexes << custom_index if custom_index
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
terminator_indexes.empty? ? @path.length : terminator_indexes.min
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def find_glob_value_end_index(terminator_char:)
|
|
341
|
+
return @path_length unless terminator_char
|
|
342
|
+
return @path_length if EXCLUDED_PARAM_NAME_TERMINATOR_CHARS.include?(terminator_char)
|
|
343
|
+
|
|
344
|
+
terminator_index = @path.rindex(terminator_char)
|
|
345
|
+
return @path_length unless terminator_index && terminator_index >= @path_index
|
|
346
|
+
|
|
347
|
+
terminator_index
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def find_next_optional_group_end_index
|
|
351
|
+
depth = 0
|
|
352
|
+
index = @pattern_index
|
|
353
|
+
|
|
354
|
+
while index < @pattern_length
|
|
355
|
+
char = @pattern[index]
|
|
356
|
+
depth += 1 if char == GROUP_OPEN_CHAR
|
|
357
|
+
|
|
358
|
+
if char == GROUP_CLOSE_CHAR
|
|
359
|
+
depth -= 1
|
|
360
|
+
|
|
361
|
+
if depth.zero?
|
|
362
|
+
next_index = index + 1
|
|
363
|
+
|
|
364
|
+
return next_index if @pattern[next_index] == OPTIONAL_GROUP_SUFFIX_CHAR
|
|
365
|
+
|
|
366
|
+
return index
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
index += 1
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
index
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Datadog
|
|
4
|
+
module AppSec
|
|
5
|
+
module RouteNormalizer
|
|
6
|
+
# Percent-encodes route text, leaving param templates untouched
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module RouteText
|
|
10
|
+
DISALLOWED_CHARS = %r{[^\w.~/-]}
|
|
11
|
+
|
|
12
|
+
# Per-byte percent-encoding lookup, indexed by byte value (0-255)
|
|
13
|
+
#
|
|
14
|
+
# Example:
|
|
15
|
+
#
|
|
16
|
+
# 32 => %20 (space)
|
|
17
|
+
# 33 => %21 (!)
|
|
18
|
+
# 47 => / (passthrough)
|
|
19
|
+
# 65 => A (passthrough)
|
|
20
|
+
# 195 => %C3 (UTF-8 lead byte)
|
|
21
|
+
# 169 => %A9 (UTF-8 continuation byte)
|
|
22
|
+
BYTE_ENCODING_TABLE = Array.new(256) do |byte|
|
|
23
|
+
char = byte.chr
|
|
24
|
+
char.match?(DISALLOWED_CHARS) ? -('%%%02X' % byte) : -char
|
|
25
|
+
end.freeze
|
|
26
|
+
|
|
27
|
+
# Max bytes one input byte expands to as percent-encoded `%XX`
|
|
28
|
+
MAX_ENCODED_BYTE_SIZE = 3
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Escapes literal route text into normalized-route form
|
|
33
|
+
#
|
|
34
|
+
# Example:
|
|
35
|
+
#
|
|
36
|
+
# a+b => a%2Bb
|
|
37
|
+
# café => caf%C3%A9
|
|
38
|
+
# /users/path => /users/path
|
|
39
|
+
#
|
|
40
|
+
# NOTE: {URI::Parser#escape} with {DISALLOWED_CHARS} gives the same output,
|
|
41
|
+
# but the generic gsub/sprintf path is slower and allocates more per request
|
|
42
|
+
def escape(text)
|
|
43
|
+
return text unless text.match?(DISALLOWED_CHARS)
|
|
44
|
+
|
|
45
|
+
buffer = String.new(capacity: text.bytesize * MAX_ENCODED_BYTE_SIZE, encoding: Encoding::UTF_8)
|
|
46
|
+
text.each_byte { |byte| buffer << BYTE_ENCODING_TABLE.fetch(byte) }
|
|
47
|
+
buffer
|
|
48
|
+
# NOTE: Defensive only — this can never happen. {String#each_byte} yields
|
|
49
|
+
# integers in 0-255 and {BYTE_ENCODING_TABLE} has an entry for every one
|
|
50
|
+
rescue IndexError => e
|
|
51
|
+
AppSec.telemetry&.report(e, description: 'AppSec: Route text byte outside 0-255 escape table')
|
|
52
|
+
'~invalid~'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'route_normalizer/route_pattern'
|
|
4
|
+
require_relative 'route_normalizer/rails_route_pattern'
|
|
5
|
+
|
|
6
|
+
module Datadog
|
|
7
|
+
module AppSec
|
|
8
|
+
# Extracts framework route data from Rack env and normalizes it into
|
|
9
|
+
# OpenAPI v3 compatible route format
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module RouteNormalizer
|
|
13
|
+
GRAPE_ROUTE_KEY = 'grape.routing_args'
|
|
14
|
+
SINATRA_ROUTE_KEY = 'sinatra.route'
|
|
15
|
+
DATADOG_ROUTE_KEY = 'datadog.action_dispatch.route'
|
|
16
|
+
RAILS_ROUTE_KEY = 'action_dispatch.route'
|
|
17
|
+
RAILS_ROUTE_URI_PATTERN_KEY = 'action_dispatch.route_uri_pattern'
|
|
18
|
+
RAILS_PATH_PARAMS_KEY = 'action_dispatch.request.path_parameters'
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Extracts a normalized route path for a Rack request.
|
|
23
|
+
#
|
|
24
|
+
# Examples:
|
|
25
|
+
#
|
|
26
|
+
# Grape: "/api/users/:id" => "/api/users/{id}"
|
|
27
|
+
# Sinatra: "GET /download/*.*" => "/download/{param1+param2}"
|
|
28
|
+
# Rails: "/posts/:id(.:format)" + path => "/posts/{id+format}"
|
|
29
|
+
# Pattern: "/users/:id" => "/users/{id}"
|
|
30
|
+
def extract_normalized_route(rack_env, prefix: nil, pattern: nil)
|
|
31
|
+
route_prefix = prefix || rack_env['SCRIPT_NAME']
|
|
32
|
+
|
|
33
|
+
if rack_env.key?(GRAPE_ROUTE_KEY)
|
|
34
|
+
route_info = rack_env[GRAPE_ROUTE_KEY][:route_info]
|
|
35
|
+
normalize_route_string(route_info&.pattern&.origin)
|
|
36
|
+
elsif rack_env.key?(SINATRA_ROUTE_KEY)
|
|
37
|
+
normalize_route_string(rack_env[SINATRA_ROUTE_KEY].split(' ', 2)[1])
|
|
38
|
+
elsif (rails_route = rack_env[DATADOG_ROUTE_KEY] || rack_env[RAILS_ROUTE_KEY])
|
|
39
|
+
normalize_rails_route(rails_route, rack_env, route_prefix)
|
|
40
|
+
elsif rack_env.key?(RAILS_ROUTE_URI_PATTERN_KEY)
|
|
41
|
+
normalize_rails_route(rack_env[RAILS_ROUTE_URI_PATTERN_KEY], rack_env, route_prefix)
|
|
42
|
+
else
|
|
43
|
+
normalize_route_string(pattern)
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
AppSec.telemetry&.report(e, description: 'AppSec: Could not compute normalized route')
|
|
47
|
+
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method def normalize_rails_route(route, rack_env, route_prefix)
|
|
52
|
+
path_params = rack_env.fetch(RAILS_PATH_PARAMS_KEY, {})
|
|
53
|
+
path = path_without_prefix(rack_env['PATH_INFO'].to_s, route_prefix)
|
|
54
|
+
|
|
55
|
+
RailsRoutePattern.new(route).normalize(path_params: path_params, path: path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method def normalize_route_string(route)
|
|
59
|
+
return unless route
|
|
60
|
+
|
|
61
|
+
RoutePattern.new(route).normalize
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private_class_method def path_without_prefix(path, prefix)
|
|
65
|
+
return path unless prefix
|
|
66
|
+
|
|
67
|
+
prefix = prefix.to_s
|
|
68
|
+
return path if prefix.empty? || prefix == '/'
|
|
69
|
+
return path unless path.start_with?(prefix)
|
|
70
|
+
|
|
71
|
+
next_char = path[prefix.length]
|
|
72
|
+
return path if next_char && next_char != '/'
|
|
73
|
+
|
|
74
|
+
# NOTE: `start_with?` guards impossible `nil` case
|
|
75
|
+
stripped = path[prefix.length..-1] # : String
|
|
76
|
+
stripped.empty? ? '/' : stripped
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Datadog
|
|
4
|
+
module AppSec
|
|
5
|
+
module Utils
|
|
6
|
+
module HTTP
|
|
7
|
+
module BodyReader
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def read(body, limit:, rewind_before_read: false)
|
|
11
|
+
return body.byteslice(0, limit + 1) if body.is_a?(String)
|
|
12
|
+
return if body.nil? || !body.respond_to?(:read)
|
|
13
|
+
return if rewind_before_read && !body.respond_to?(:rewind)
|
|
14
|
+
|
|
15
|
+
read_stream(body, limit: limit, rewind_before_read: rewind_before_read)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read_stream(body, limit:, rewind_before_read: false)
|
|
19
|
+
# @type var rewound: bool
|
|
20
|
+
rewound = false
|
|
21
|
+
|
|
22
|
+
if rewind_before_read
|
|
23
|
+
return unless rewind(body)
|
|
24
|
+
|
|
25
|
+
rewound = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
buffer = +''.b
|
|
29
|
+
max = limit + 1
|
|
30
|
+
|
|
31
|
+
while buffer.bytesize <= limit
|
|
32
|
+
chunk = body.read(max - buffer.bytesize)
|
|
33
|
+
break if chunk.nil? || chunk.empty?
|
|
34
|
+
|
|
35
|
+
buffer << chunk
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
buffer
|
|
39
|
+
rescue => e
|
|
40
|
+
Datadog.logger.debug { "AppSec: Failed to read body: #{e.class}: #{e.message}" }
|
|
41
|
+
|
|
42
|
+
raise
|
|
43
|
+
ensure
|
|
44
|
+
rewind(body) if rewound
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private_class_method def rewind(body)
|
|
48
|
+
return false unless body.respond_to?(:rewind)
|
|
49
|
+
|
|
50
|
+
# NOTE: Steep can't narrow interfaces via `respond_to?` guard-clause
|
|
51
|
+
body.rewind # steep:ignore
|
|
52
|
+
true
|
|
53
|
+
rescue => e
|
|
54
|
+
Datadog.logger.debug { "AppSec: Failed to rewind body: #{e.class}: #{e.message}" }
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -46,6 +46,38 @@ module Datadog
|
|
|
46
46
|
Core::Diagnostics::Health::Metrics.new(telemetry: telemetry, logger: logger, **options)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Decides whether to build the SymbolDatabase component, for the
|
|
50
|
+
# tri-state symbol_database.enabled setting: true/false are explicit
|
|
51
|
+
# overrides; nil (the default) mirrors Dynamic Instrumentation by
|
|
52
|
+
# building whenever DI's component was built. dynamic_instrumentation
|
|
53
|
+
# is DI's build result — nil only when DI is explicitly disabled or the
|
|
54
|
+
# runtime can't support it (Rails development mode, missing C
|
|
55
|
+
# extension, remote config off, non-MRI). Under the always-build model
|
|
56
|
+
# it is non-nil even when the DI setting defaults to off, so a built
|
|
57
|
+
# SymbolDatabase component is inert until an upload is actually
|
|
58
|
+
# permitted: the component's own gate (see Component#upload_allowed?)
|
|
59
|
+
# only extracts and uploads when DI is truly active or the customer
|
|
60
|
+
# opted in explicitly. This mirrors DI advertising/building a component
|
|
61
|
+
# by default while installing no probes until it is enabled.
|
|
62
|
+
# force_upload (internal/testing) always builds, since it uploads
|
|
63
|
+
# regardless of DI or the enabled setting.
|
|
64
|
+
# @param settings [Configuration::Settings]
|
|
65
|
+
# @param dynamic_instrumentation [DI::Component, nil]
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def enable_symbol_database?(settings, dynamic_instrumentation)
|
|
68
|
+
# The symbol_database settings group is only registered on the full
|
|
69
|
+
# library load path; a partial load (e.g. require 'datadog/di') leaves
|
|
70
|
+
# it absent, in which case the feature cannot be enabled.
|
|
71
|
+
return false unless settings.respond_to?(:symbol_database)
|
|
72
|
+
|
|
73
|
+
# force_upload uploads unconditionally, so the component must be built
|
|
74
|
+
# even when the setting is nil and DI's component was not built.
|
|
75
|
+
return true if settings.symbol_database.internal.force_upload
|
|
76
|
+
|
|
77
|
+
# nil (the default) follows whether DI's component was built.
|
|
78
|
+
Datadog::SymbolDatabase.resolve_enabled(settings.symbol_database.enabled, !dynamic_instrumentation.nil?)
|
|
79
|
+
end
|
|
80
|
+
|
|
49
81
|
def build_logger(settings)
|
|
50
82
|
logger = settings.logger.instance || Core::Logger.new($stderr)
|
|
51
83
|
logger.level = settings.diagnostics.debug ? ::Logger::DEBUG : settings.logger.level
|
|
@@ -174,10 +206,31 @@ module Datadog
|
|
|
174
206
|
@ai_guard = Datadog::AIGuard::Component.build(settings, logger: @logger, telemetry: telemetry)
|
|
175
207
|
@open_feature = OpenFeature::Component.build(settings, agent_settings, logger: @logger, telemetry: telemetry)
|
|
176
208
|
@dynamic_instrumentation = Datadog::DI::Component.build(settings, agent_settings, @logger, telemetry: telemetry)
|
|
177
|
-
|
|
209
|
+
# Only build symbol database when enabled, so a disabled component is
|
|
210
|
+
# never constructed.
|
|
211
|
+
@symbol_database =
|
|
212
|
+
if self.class.enable_symbol_database?(settings, @dynamic_instrumentation)
|
|
213
|
+
# di_active is a live predicate, not a snapshot: DI may start after
|
|
214
|
+
# this component is built (implicit enablement via remote config).
|
|
215
|
+
# Symbol Database holds only this opaque proc, never a DI reference,
|
|
216
|
+
# so the two modules stay independent — the orchestration layer
|
|
217
|
+
# owns the cross-feature knowledge.
|
|
218
|
+
Datadog::SymbolDatabase::Component.build(
|
|
219
|
+
settings, agent_settings, @logger,
|
|
220
|
+
telemetry: telemetry,
|
|
221
|
+
di_active: -> { dynamic_instrumentation&.started? || false },
|
|
222
|
+
)
|
|
223
|
+
end
|
|
178
224
|
@error_tracking = Datadog::ErrorTracking::Component.build(settings, @tracer, @logger)
|
|
179
225
|
@data_streams = self.class.build_data_streams(settings, agent_settings, @logger, @agent_info)
|
|
180
|
-
|
|
226
|
+
# Reflects "the customer configured DI to be on" — true iff the
|
|
227
|
+
# component was built AND the env-var-driven enabled flag is set.
|
|
228
|
+
# This is the post-initialize / pre-startup value visible to tests
|
|
229
|
+
# that don't call startup!. Production overwrites this in startup!
|
|
230
|
+
# below with `dynamic_instrumentation&.started?`, which also accounts
|
|
231
|
+
# for RC-driven enablement carried over via ComponentsState.
|
|
232
|
+
@environment_logger_extra[:dynamic_instrumentation_enabled] =
|
|
233
|
+
!!(@dynamic_instrumentation && settings.dynamic_instrumentation.enabled)
|
|
181
234
|
|
|
182
235
|
# Configure non-privileged components.
|
|
183
236
|
Datadog::Tracing::Contrib::Component.configure(settings)
|
|
@@ -228,11 +281,25 @@ module Datadog
|
|
|
228
281
|
remote.start
|
|
229
282
|
end
|
|
230
283
|
|
|
284
|
+
# Start DI component if enabled by env var or if it was implicitly enabled
|
|
285
|
+
# (via RC) in the previous Components instance. dynamic_instrumentation is
|
|
286
|
+
# non-nil only when Component.build's preconditions passed (Ruby 2.6+ MRI,
|
|
287
|
+
# etc.), which is exactly the condition under which di/base.rb is loaded
|
|
288
|
+
# and DI.activate_tracking is defined.
|
|
289
|
+
if dynamic_instrumentation
|
|
290
|
+
if settings.dynamic_instrumentation.enabled || old_state&.di_implicitly_enabled?
|
|
291
|
+
DI.activate_tracking
|
|
292
|
+
dynamic_instrumentation.start!
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
231
296
|
# This should stay here, not in initialize. During reconfiguration, the order of the calls is:
|
|
232
297
|
# initialize new components, shutdown old components, startup new components.
|
|
233
298
|
# Because this is a singleton, if we call it in initialize, it will be shutdown right away.
|
|
234
299
|
Core::ProcessDiscovery.publish(settings)
|
|
235
300
|
|
|
301
|
+
@environment_logger_extra[:dynamic_instrumentation_enabled] = dynamic_instrumentation&.started? || false
|
|
302
|
+
|
|
236
303
|
Core::Diagnostics::EnvironmentLogger.collect_and_log!(@environment_logger_extra)
|
|
237
304
|
end
|
|
238
305
|
|
|
@@ -309,9 +376,22 @@ module Datadog
|
|
|
309
376
|
|
|
310
377
|
# Returns the current state of various components.
|
|
311
378
|
def state
|
|
379
|
+
# di_implicitly_enabled distinguishes RC-driven start from explicit
|
|
380
|
+
# start (env var or programmatic) so that an explicit
|
|
381
|
+
# `enabled = false` on reconfiguration can take effect.
|
|
382
|
+
#
|
|
383
|
+
# using_default? — not `!enabled` — because Datadog.configure has
|
|
384
|
+
# already mutated the singleton settings by the time #state runs,
|
|
385
|
+
# so `!enabled` would treat a customer's explicit
|
|
386
|
+
# `enabled = false` as "implicitly enabled" and restart DI.
|
|
387
|
+
# using_default? asks "did the customer ever touch this setting",
|
|
388
|
+
# which is the right question for RC carry-over.
|
|
389
|
+
di_implicit = dynamic_instrumentation&.started? &&
|
|
390
|
+
@settings.dynamic_instrumentation.using_default?(:enabled)
|
|
312
391
|
ComponentsState.new(
|
|
313
392
|
telemetry_enabled: telemetry.enabled,
|
|
314
393
|
remote_started: remote&.started?,
|
|
394
|
+
di_implicitly_enabled: di_implicit || false,
|
|
315
395
|
)
|
|
316
396
|
end
|
|
317
397
|
end
|