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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -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 +71 -31
- data/ext/datadog_profiling_native_extension/collectors_discrete_dynamic_sampler.c +1 -1
- data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +18 -4
- data/ext/datadog_profiling_native_extension/collectors_stack.c +37 -18
- data/ext/datadog_profiling_native_extension/collectors_stack.h +8 -2
- data/ext/datadog_profiling_native_extension/collectors_thread_context.c +596 -341
- data/ext/datadog_profiling_native_extension/collectors_thread_context.h +11 -7
- data/ext/datadog_profiling_native_extension/datadog_ruby_common.c +7 -8
- data/ext/datadog_profiling_native_extension/datadog_ruby_common.h +0 -12
- data/ext/datadog_profiling_native_extension/extconf.rb +13 -2
- data/ext/datadog_profiling_native_extension/gvl_profiling_helper.c +4 -43
- data/ext/datadog_profiling_native_extension/gvl_profiling_helper.h +15 -47
- data/ext/datadog_profiling_native_extension/heap_recorder.c +44 -26
- data/ext/datadog_profiling_native_extension/private_vm_api_access.c +14 -35
- data/ext/datadog_profiling_native_extension/profiling.c +41 -4
- data/ext/datadog_profiling_native_extension/ruby_helpers.c +40 -34
- data/ext/datadog_profiling_native_extension/stack_recorder.c +24 -3
- data/ext/datadog_profiling_native_extension/stack_recorder.h +1 -0
- data/ext/datadog_profiling_native_extension/unsafe_api_calls_check.h +4 -2
- data/ext/libdatadog_api/datadog_ruby_common.c +7 -8
- data/ext/libdatadog_api/datadog_ruby_common.h +0 -12
- 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 +13 -4
- data/lib/datadog/appsec/component.rb +1 -1
- data/lib/datadog/appsec/configuration.rb +13 -0
- data/lib/datadog/appsec/context.rb +4 -2
- data/lib/datadog/appsec/contrib/aws_lambda/waf_addresses.rb +37 -4
- 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 +64 -19
- data/lib/datadog/appsec/contrib/graphql/integration.rb +1 -0
- data/lib/datadog/appsec/contrib/rack/buffered_input.rb +83 -0
- data/lib/datadog/appsec/contrib/rack/gateway/request.rb +41 -3
- data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +24 -7
- data/lib/datadog/appsec/contrib/rack/input_peeker.rb +79 -0
- data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
- data/lib/datadog/appsec/contrib/rails/gateway/request.rb +35 -1
- data/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +17 -1
- data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
- data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +24 -3
- data/lib/datadog/appsec/default_header_tags.rb +10 -6
- 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 +83 -2
- data/lib/datadog/core/configuration/components_state.rb +6 -1
- data/lib/datadog/core/configuration/settings.rb +1 -5
- data/lib/datadog/core/configuration/supported_configurations.rb +4 -0
- data/lib/datadog/core/evp.rb +11 -0
- data/lib/datadog/core/remote/client/capabilities.rb +34 -7
- data/lib/datadog/core/remote/component.rb +1 -1
- data/lib/datadog/core/telemetry/event/app_started.rb +8 -22
- data/lib/datadog/core/utils/at_fork_monkey_patch.rb +1 -1
- data/lib/datadog/core/utils/forking.rb +3 -1
- data/lib/datadog/core/utils/spawn_monkey_patch.rb +3 -1
- data/lib/datadog/core.rb +3 -0
- data/lib/datadog/di/base.rb +7 -2
- 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/error_tracking/collector.rb +2 -1
- data/lib/datadog/error_tracking/component.rb +2 -2
- data/lib/datadog/kit/appsec/events/v2.rb +60 -2
- data/lib/datadog/kit/tracing/method_tracer.rb +4 -1
- 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/propagator.rb +9 -3
- data/lib/datadog/opentelemetry/sdk/span_processor.rb +4 -1
- 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/profiling/collectors/thread_context.rb +1 -0
- data/lib/datadog/profiling/component.rb +13 -15
- data/lib/datadog/profiling/ext/dir_monkey_patches.rb +3 -3
- data/lib/datadog/ruby_version.rb +25 -0
- data/lib/datadog/symbol_database/component.rb +453 -128
- 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 +326 -150
- 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/configuration/ext.rb +13 -0
- data/lib/datadog/tracing/configuration/settings.rb +17 -0
- data/lib/datadog/tracing/contrib/configuration/resolver.rb +7 -0
- data/lib/datadog/tracing/contrib/grpc/distributed/propagation.rb +2 -0
- data/lib/datadog/tracing/contrib/grpc.rb +1 -0
- data/lib/datadog/tracing/contrib/http/distributed/propagation.rb +2 -0
- data/lib/datadog/tracing/contrib/http.rb +1 -0
- data/lib/datadog/tracing/contrib/karafka/distributed/propagation.rb +2 -0
- data/lib/datadog/tracing/contrib/karafka.rb +1 -0
- data/lib/datadog/tracing/contrib/rack/middlewares.rb +3 -1
- data/lib/datadog/tracing/contrib/rack/route_inference.rb +3 -1
- data/lib/datadog/tracing/contrib/sidekiq/distributed/propagation.rb +2 -0
- data/lib/datadog/tracing/contrib/sidekiq.rb +1 -0
- data/lib/datadog/tracing/contrib/waterdrop/distributed/propagation.rb +2 -0
- data/lib/datadog/tracing/contrib/waterdrop.rb +1 -0
- data/lib/datadog/tracing/distributed/baggage.rb +2 -1
- data/lib/datadog/tracing/distributed/propagation.rb +33 -1
- data/lib/datadog/tracing/distributed/trace_context.rb +11 -2
- data/lib/datadog/tracing/ext.rb +9 -0
- data/lib/datadog/tracing/remote.rb +34 -3
- data/lib/datadog/tracing/trace_digest.rb +7 -0
- data/lib/datadog/tracing/trace_operation.rb +4 -1
- data/lib/datadog/tracing/tracer.rb +1 -0
- data/lib/datadog/version.rb +1 -1
- data/lib/datadog.rb +5 -1
- 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
|