datadog 2.35.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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
  4. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +71 -31
  5. data/ext/datadog_profiling_native_extension/collectors_discrete_dynamic_sampler.c +1 -1
  6. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +18 -4
  7. data/ext/datadog_profiling_native_extension/collectors_stack.c +37 -18
  8. data/ext/datadog_profiling_native_extension/collectors_stack.h +8 -2
  9. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +596 -341
  10. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +11 -7
  11. data/ext/datadog_profiling_native_extension/datadog_ruby_common.c +7 -8
  12. data/ext/datadog_profiling_native_extension/datadog_ruby_common.h +0 -12
  13. data/ext/datadog_profiling_native_extension/extconf.rb +13 -2
  14. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.c +4 -43
  15. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.h +15 -47
  16. data/ext/datadog_profiling_native_extension/heap_recorder.c +44 -26
  17. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +14 -35
  18. data/ext/datadog_profiling_native_extension/profiling.c +41 -4
  19. data/ext/datadog_profiling_native_extension/ruby_helpers.c +40 -34
  20. data/ext/datadog_profiling_native_extension/stack_recorder.c +24 -3
  21. data/ext/datadog_profiling_native_extension/stack_recorder.h +1 -0
  22. data/ext/datadog_profiling_native_extension/unsafe_api_calls_check.h +4 -2
  23. data/ext/libdatadog_api/datadog_ruby_common.c +7 -8
  24. data/ext/libdatadog_api/datadog_ruby_common.h +0 -12
  25. data/ext/libdatadog_api/di.c +76 -0
  26. data/ext/libdatadog_api/extconf.rb +6 -0
  27. data/ext/libdatadog_extconf_helpers.rb +1 -1
  28. data/lib/datadog/ai_guard/api_client.rb +6 -4
  29. data/lib/datadog/appsec/api_security/route_extractor.rb +13 -4
  30. data/lib/datadog/appsec/component.rb +1 -1
  31. data/lib/datadog/appsec/configuration.rb +13 -0
  32. data/lib/datadog/appsec/context.rb +4 -2
  33. data/lib/datadog/appsec/contrib/aws_lambda/waf_addresses.rb +37 -4
  34. data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
  35. data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
  36. data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +64 -19
  37. data/lib/datadog/appsec/contrib/graphql/integration.rb +1 -0
  38. data/lib/datadog/appsec/contrib/rack/buffered_input.rb +83 -0
  39. data/lib/datadog/appsec/contrib/rack/gateway/request.rb +41 -3
  40. data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +24 -7
  41. data/lib/datadog/appsec/contrib/rack/input_peeker.rb +79 -0
  42. data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
  43. data/lib/datadog/appsec/contrib/rails/gateway/request.rb +35 -1
  44. data/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +17 -1
  45. data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
  46. data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +24 -3
  47. data/lib/datadog/appsec/default_header_tags.rb +10 -6
  48. data/lib/datadog/appsec/ext.rb +1 -0
  49. data/lib/datadog/appsec/metrics/collector.rb +18 -1
  50. data/lib/datadog/appsec/metrics/exporter.rb +9 -3
  51. data/lib/datadog/appsec/remote.rb +9 -3
  52. data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
  53. data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
  54. data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
  55. data/lib/datadog/appsec/route_normalizer.rb +80 -0
  56. data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
  57. data/lib/datadog/core/configuration/components.rb +83 -2
  58. data/lib/datadog/core/configuration/components_state.rb +6 -1
  59. data/lib/datadog/core/configuration/settings.rb +1 -5
  60. data/lib/datadog/core/configuration/supported_configurations.rb +4 -0
  61. data/lib/datadog/core/evp.rb +11 -0
  62. data/lib/datadog/core/remote/client/capabilities.rb +34 -7
  63. data/lib/datadog/core/remote/component.rb +1 -1
  64. data/lib/datadog/core/telemetry/event/app_started.rb +8 -22
  65. data/lib/datadog/core/utils/at_fork_monkey_patch.rb +1 -1
  66. data/lib/datadog/core/utils/forking.rb +3 -1
  67. data/lib/datadog/core/utils/spawn_monkey_patch.rb +3 -1
  68. data/lib/datadog/core.rb +3 -0
  69. data/lib/datadog/di/base.rb +7 -2
  70. data/lib/datadog/di/code_tracker.rb +5 -2
  71. data/lib/datadog/di/component.rb +110 -36
  72. data/lib/datadog/di/error.rb +10 -0
  73. data/lib/datadog/di/fatal_exceptions.rb +26 -0
  74. data/lib/datadog/di/instrumenter.rb +381 -165
  75. data/lib/datadog/di/probe.rb +14 -0
  76. data/lib/datadog/di/probe_file_loader.rb +8 -3
  77. data/lib/datadog/di/probe_manager.rb +67 -9
  78. data/lib/datadog/di/probe_notification_builder.rb +4 -1
  79. data/lib/datadog/di/probe_notifier_worker.rb +52 -32
  80. data/lib/datadog/di/redactor.rb +16 -1
  81. data/lib/datadog/di/remote.rb +175 -14
  82. data/lib/datadog/di/serializer.rb +12 -5
  83. data/lib/datadog/di/transport/input.rb +8 -4
  84. data/lib/datadog/di.rb +63 -0
  85. data/lib/datadog/error_tracking/collector.rb +2 -1
  86. data/lib/datadog/error_tracking/component.rb +2 -2
  87. data/lib/datadog/kit/appsec/events/v2.rb +60 -2
  88. data/lib/datadog/kit/tracing/method_tracer.rb +4 -1
  89. data/lib/datadog/open_feature/component.rb +29 -6
  90. data/lib/datadog/open_feature/configuration.rb +8 -0
  91. data/lib/datadog/open_feature/ext.rb +2 -0
  92. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
  93. data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
  94. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
  95. data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
  96. data/lib/datadog/open_feature/native_evaluator.rb +22 -0
  97. data/lib/datadog/open_feature/provider.rb +59 -27
  98. data/lib/datadog/open_feature/transport.rb +55 -1
  99. data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
  100. data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
  101. data/lib/datadog/opentelemetry/sdk/propagator.rb +9 -3
  102. data/lib/datadog/opentelemetry/sdk/span_processor.rb +4 -1
  103. data/lib/datadog/opentelemetry/sdk.rb +19 -0
  104. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
  105. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
  106. data/lib/datadog/profiling/collectors/thread_context.rb +1 -0
  107. data/lib/datadog/profiling/component.rb +13 -15
  108. data/lib/datadog/profiling/ext/dir_monkey_patches.rb +3 -3
  109. data/lib/datadog/ruby_version.rb +25 -0
  110. data/lib/datadog/symbol_database/component.rb +453 -128
  111. data/lib/datadog/symbol_database/configuration.rb +12 -4
  112. data/lib/datadog/symbol_database/extensions.rb +19 -0
  113. data/lib/datadog/symbol_database/extractor.rb +326 -150
  114. data/lib/datadog/symbol_database/file_hash.rb +3 -1
  115. data/lib/datadog/symbol_database/remote.rb +8 -3
  116. data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
  117. data/lib/datadog/symbol_database/uploader.rb +9 -2
  118. data/lib/datadog/symbol_database.rb +22 -0
  119. data/lib/datadog/tracing/configuration/ext.rb +13 -0
  120. data/lib/datadog/tracing/configuration/settings.rb +17 -0
  121. data/lib/datadog/tracing/contrib/configuration/resolver.rb +7 -0
  122. data/lib/datadog/tracing/contrib/grpc/distributed/propagation.rb +2 -0
  123. data/lib/datadog/tracing/contrib/grpc.rb +1 -0
  124. data/lib/datadog/tracing/contrib/http/distributed/propagation.rb +2 -0
  125. data/lib/datadog/tracing/contrib/http.rb +1 -0
  126. data/lib/datadog/tracing/contrib/karafka/distributed/propagation.rb +2 -0
  127. data/lib/datadog/tracing/contrib/karafka.rb +1 -0
  128. data/lib/datadog/tracing/contrib/rack/middlewares.rb +3 -1
  129. data/lib/datadog/tracing/contrib/rack/route_inference.rb +3 -1
  130. data/lib/datadog/tracing/contrib/sidekiq/distributed/propagation.rb +2 -0
  131. data/lib/datadog/tracing/contrib/sidekiq.rb +1 -0
  132. data/lib/datadog/tracing/contrib/waterdrop/distributed/propagation.rb +2 -0
  133. data/lib/datadog/tracing/contrib/waterdrop.rb +1 -0
  134. data/lib/datadog/tracing/distributed/baggage.rb +2 -1
  135. data/lib/datadog/tracing/distributed/propagation.rb +33 -1
  136. data/lib/datadog/tracing/distributed/trace_context.rb +11 -2
  137. data/lib/datadog/tracing/ext.rb +9 -0
  138. data/lib/datadog/tracing/remote.rb +34 -3
  139. data/lib/datadog/tracing/trace_digest.rb +7 -0
  140. data/lib/datadog/tracing/trace_operation.rb +4 -1
  141. data/lib/datadog/tracing/tracer.rb +1 -0
  142. data/lib/datadog/version.rb +1 -1
  143. data/lib/datadog.rb +5 -1
  144. metadata +22 -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