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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +6 -3
  5. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +17 -3
  6. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +219 -98
  7. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +2 -0
  8. data/ext/datadog_profiling_native_extension/extconf.rb +11 -0
  9. data/ext/datadog_profiling_native_extension/ruby_helpers.c +7 -0
  10. data/ext/libdatadog_api/di.c +76 -0
  11. data/ext/libdatadog_api/extconf.rb +6 -0
  12. data/ext/libdatadog_extconf_helpers.rb +1 -1
  13. data/lib/datadog/ai_guard/api_client.rb +6 -4
  14. data/lib/datadog/appsec/api_security/route_extractor.rb +7 -4
  15. data/lib/datadog/appsec/configuration.rb +6 -0
  16. data/lib/datadog/appsec/context.rb +4 -2
  17. data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
  18. data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
  19. data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +1 -1
  20. data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +4 -0
  21. data/lib/datadog/appsec/contrib/rack/input_peeker.rb +4 -17
  22. data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
  23. data/lib/datadog/appsec/contrib/rails/gateway/request.rb +2 -1
  24. data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
  25. data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +4 -0
  26. data/lib/datadog/appsec/ext.rb +1 -0
  27. data/lib/datadog/appsec/metrics/collector.rb +18 -1
  28. data/lib/datadog/appsec/metrics/exporter.rb +9 -3
  29. data/lib/datadog/appsec/remote.rb +9 -3
  30. data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
  31. data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
  32. data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
  33. data/lib/datadog/appsec/route_normalizer.rb +80 -0
  34. data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
  35. data/lib/datadog/core/configuration/components.rb +82 -2
  36. data/lib/datadog/core/configuration/components_state.rb +6 -1
  37. data/lib/datadog/core/configuration/settings.rb +0 -3
  38. data/lib/datadog/core/configuration/supported_configurations.rb +2 -0
  39. data/lib/datadog/core/evp.rb +11 -0
  40. data/lib/datadog/core/remote/client/capabilities.rb +34 -7
  41. data/lib/datadog/core/telemetry/event/app_started.rb +8 -1
  42. data/lib/datadog/di/base.rb +3 -1
  43. data/lib/datadog/di/code_tracker.rb +5 -2
  44. data/lib/datadog/di/component.rb +110 -36
  45. data/lib/datadog/di/error.rb +10 -0
  46. data/lib/datadog/di/fatal_exceptions.rb +26 -0
  47. data/lib/datadog/di/instrumenter.rb +381 -165
  48. data/lib/datadog/di/probe.rb +14 -0
  49. data/lib/datadog/di/probe_file_loader.rb +8 -3
  50. data/lib/datadog/di/probe_manager.rb +67 -9
  51. data/lib/datadog/di/probe_notification_builder.rb +4 -1
  52. data/lib/datadog/di/probe_notifier_worker.rb +52 -32
  53. data/lib/datadog/di/redactor.rb +16 -1
  54. data/lib/datadog/di/remote.rb +175 -14
  55. data/lib/datadog/di/serializer.rb +12 -5
  56. data/lib/datadog/di/transport/input.rb +8 -4
  57. data/lib/datadog/di.rb +63 -0
  58. data/lib/datadog/kit/appsec/events/v2.rb +60 -2
  59. data/lib/datadog/open_feature/component.rb +29 -6
  60. data/lib/datadog/open_feature/configuration.rb +8 -0
  61. data/lib/datadog/open_feature/ext.rb +2 -0
  62. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
  63. data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
  64. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
  65. data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
  66. data/lib/datadog/open_feature/native_evaluator.rb +22 -0
  67. data/lib/datadog/open_feature/provider.rb +59 -27
  68. data/lib/datadog/open_feature/transport.rb +55 -1
  69. data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
  70. data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
  71. data/lib/datadog/opentelemetry/sdk.rb +19 -0
  72. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
  73. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
  74. data/lib/datadog/symbol_database/component.rb +164 -47
  75. data/lib/datadog/symbol_database/configuration.rb +12 -4
  76. data/lib/datadog/symbol_database/extensions.rb +19 -0
  77. data/lib/datadog/symbol_database/extractor.rb +124 -87
  78. data/lib/datadog/symbol_database/file_hash.rb +3 -1
  79. data/lib/datadog/symbol_database/remote.rb +8 -3
  80. data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
  81. data/lib/datadog/symbol_database/uploader.rb +9 -2
  82. data/lib/datadog/symbol_database.rb +22 -0
  83. data/lib/datadog/tracing/distributed/baggage.rb +2 -1
  84. data/lib/datadog/tracing/ext.rb +9 -0
  85. data/lib/datadog/tracing/remote.rb +34 -3
  86. data/lib/datadog/version.rb +1 -1
  87. data/lib/datadog.rb +1 -0
  88. metadata +19 -6
@@ -3,8 +3,9 @@
3
3
  require_relative '../../event'
4
4
  require_relative '../../trace_keeper'
5
5
  require_relative '../../security_event'
6
- require_relative '../../utils/http/media_type'
7
6
  require_relative '../../utils/http/body'
7
+ require_relative '../../utils/http/body_reader'
8
+ require_relative '../../utils/http/media_type'
8
9
 
9
10
  module Datadog
10
11
  module AppSec
@@ -35,7 +36,7 @@ module Datadog
35
36
  end
36
37
 
37
38
  if !is_redirect && sample_body
38
- body = parse_body(payload.to_s, content_type: headers['content-type'])
39
+ body = parse_request_body(payload, content_type: headers['content-type'])
39
40
  ephemeral_data['server.io.net.request.body'] = body if body
40
41
  end
41
42
 
@@ -74,7 +75,7 @@ module Datadog
74
75
  context.state[:downstream_redirect_url] = URI.join(url, headers['location']).to_s if is_redirect && sample_body
75
76
 
76
77
  if sample_body && !is_redirect
77
- body = parse_body(response.body, content_type: headers['content-type'])
78
+ body = parse_response_body(response.body, headers: headers, context: context)
78
79
  ephemeral_data['server.io.net.response.body'] = body if body
79
80
  end
80
81
 
@@ -94,13 +95,65 @@ module Datadog
94
95
  true
95
96
  end
96
97
 
97
- def parse_body(body, content_type:)
98
- return if body.empty?
99
-
98
+ def parse_request_body(body, content_type:)
100
99
  media_type = Utils::HTTP::MediaType.parse(content_type)
101
100
  return unless media_type
102
101
 
103
- Utils::HTTP::Body.parse(body, media_type: media_type)
102
+ # NOTE: Request body analysis is best-effort, non-rewindable payloads are skipped
103
+ limit = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
104
+ content = read_body(body, limit: limit)
105
+ return if content.nil? || content.bytesize > limit
106
+
107
+ Utils::HTTP::Body.parse(content, media_type: media_type)
108
+ end
109
+
110
+ def parse_response_body(body, headers:, context:)
111
+ return unless readable_body?(body)
112
+
113
+ media_type = Utils::HTTP::MediaType.parse(headers['content-type'])
114
+ if !media_type || media_type.type != 'application'
115
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
116
+ return
117
+ end
118
+
119
+ subtype = media_type.subtype
120
+ if subtype != 'json' && !subtype.end_with?('+json') && subtype != 'x-www-form-urlencoded'
121
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
122
+ return
123
+ end
124
+
125
+ content_length_value = headers['content-length']
126
+ if !content_length_value.is_a?(String) || !content_length_value.match?(/\A[1-9][0-9]*\z/)
127
+ context.metrics.record_ignored_downstream_response_body(:content_length_missing)
128
+ return
129
+ end
130
+
131
+ content_length = content_length_value.to_i
132
+ max = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
133
+ if content_length > max
134
+ context.metrics.record_ignored_downstream_response_body(:content_length_too_big)
135
+ return
136
+ end
137
+
138
+ content = read_body(body, limit: content_length)
139
+ return if content.nil?
140
+
141
+ if content.bytesize > content_length
142
+ context.metrics.record_ignored_downstream_response_body(:content_exceed_content_length)
143
+ return
144
+ end
145
+
146
+ Utils::HTTP::Body.parse(content, media_type: media_type)
147
+ end
148
+
149
+ def readable_body?(body)
150
+ body.is_a?(String) || (body.respond_to?(:read) && body.respond_to?(:rewind))
151
+ end
152
+
153
+ def read_body(body, limit:)
154
+ Utils::HTTP::BodyReader.read(body, limit: limit, rewind_before_read: true)
155
+ rescue
156
+ nil
104
157
  end
105
158
 
106
159
  # NOTE: Starting version 2.1.0 headers are already normalized via internal
@@ -44,6 +44,10 @@ module Datadog
44
44
  body = gateway_request.form_hash
45
45
  persistent_data['server.request.body'] = body if body
46
46
  end
47
+ # NOTE: Body was parsed before measurement, keep byte_length unset
48
+ elsif gateway_request.env.key?('rack.request.form_hash')
49
+ body = gateway_request.env['rack.request.form_hash']
50
+ persistent_data['server.request.body'] = body if body
47
51
  end
48
52
 
49
53
  next stack.call(request) if persistent_data.empty?
@@ -17,6 +17,7 @@ module Datadog
17
17
  EXPLOIT_PREVENTION_EVENT_CATEGORY = 'exploit'
18
18
 
19
19
  TAG_APPSEC_ENABLED = '_dd.appsec.enabled'
20
+ TAG_NORMALIZED_ROUTE = '_dd.appsec.normalized_route'
20
21
  TAG_METASTRUCT_STACK_TRACE = '_dd.stack'
21
22
 
22
23
  TELEMETRY_METRICS_NAMESPACE = 'appsec'
@@ -17,7 +17,15 @@ module Datadog
17
17
  keyword_init: true
18
18
  )
19
19
 
20
- attr_reader :waf, :rasp
20
+ DownstreamResponseStore = Struct.new(
21
+ :content_type_invalid,
22
+ :content_length_missing,
23
+ :content_length_too_big,
24
+ :content_exceed_content_length,
25
+ keyword_init: true
26
+ )
27
+
28
+ attr_reader :waf, :rasp, :downstream_responses
21
29
 
22
30
  def initialize
23
31
  @mutex = Mutex.new
@@ -31,6 +39,11 @@ module Datadog
31
39
  evals: 0, matches: 0, errors: 0, timeouts: 0, duration_ns: 0,
32
40
  duration_ext_ns: 0, inputs_truncated: 0, downstream_requests: 0
33
41
  )
42
+
43
+ @downstream_responses = DownstreamResponseStore.new(
44
+ content_type_invalid: 0, content_length_missing: 0,
45
+ content_length_too_big: 0, content_exceed_content_length: 0
46
+ )
34
47
  end
35
48
 
36
49
  def record_waf(result)
@@ -57,6 +70,10 @@ module Datadog
57
70
  @rasp.downstream_requests += 1 if type == Ext::RASP_SSRF && phase == Ext::RASP_REQUEST_PHASE
58
71
  end
59
72
  end
73
+
74
+ def record_ignored_downstream_response_body(reason)
75
+ @mutex.synchronize { @downstream_responses[reason] += 1 }
76
+ end
60
77
  end
61
78
  end
62
79
  end
@@ -3,7 +3,7 @@
3
3
  module Datadog
4
4
  module AppSec
5
5
  module Metrics
6
- # A class responsible for exporting WAF and RASP call metrics.
6
+ # A class responsible for exporting WAF and RASP call metrics
7
7
  module Exporter
8
8
  module_function
9
9
 
@@ -31,9 +31,15 @@ module Datadog
31
31
  end
32
32
  end
33
33
 
34
- # private
34
+ def export_downstream_response_metrics(metrics, span)
35
+ metrics.each_pair do |reason, count|
36
+ next if count.zero?
35
37
 
36
- def convert_ns_to_us(value)
38
+ span.set_tag("_dd.appsec.downstream_request.response_body_ignored.#{reason}", count)
39
+ end
40
+ end
41
+
42
+ private_class_method def convert_ns_to_us(value)
37
43
  value / 1000.0
38
44
  end
39
45
  end
@@ -78,17 +78,23 @@ module Datadog
78
78
  engine = AppSec.security_engine
79
79
  next unless engine
80
80
 
81
+ # We must process delete operations before insert and update operations
82
+ # to prevent duplicate-rule errors when the config name changes
83
+ changes.each do |change|
84
+ next unless change.type == :delete
85
+
86
+ engine.remove_config_at_path(change.path.to_s)
87
+ end
88
+
81
89
  changes.each do |change|
82
90
  content = repository[change.path]
83
- next unless content || change.type == :delete
91
+ next unless content
84
92
 
85
93
  case change.type
86
94
  when :insert, :update
87
95
  # @type var content: Core::Remote::Configuration::Content
88
96
  engine.add_or_update_config(parse_content(content), path: change.path.to_s)
89
97
  content.applied
90
- when :delete
91
- engine.remove_config_at_path(change.path.to_s)
92
98
  end
93
99
  end
94
100
 
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_text'
4
+ require_relative 'route_pattern'
5
+
6
+ module Datadog
7
+ module AppSec
8
+ module RouteNormalizer
9
+ # Normalizes Rails route patterns into route format, inspired by
10
+ # OpenAPI v3 path templating
11
+ #
12
+ # NOTE: Uses the parsed Journey AST when available
13
+ #
14
+ # @api private
15
+ class RailsRoutePattern
16
+ DOT_CHAR = '.'
17
+ GROUP_OPEN_CHAR = '('
18
+ OPTIONAL_GROUP_PATTERN = /\(([^()]*)\)/
19
+ NAMED_PARAM_PREFIX_CHAR = ':'
20
+ GLOB_PARAM_PREFIX_CHAR = '*'
21
+
22
+ def initialize(pattern)
23
+ @pattern = pattern
24
+ end
25
+
26
+ def normalize(path_params:, path:)
27
+ @path_params = path_params
28
+ @path = path
29
+
30
+ if @pattern.is_a?(String)
31
+ # NOTE: Journey groups without route params are never kept
32
+ # Example: `/foo(/bar)` with request `/foo/bar` normalizes to `/foo`
33
+ pattern = remove_paramless_optional_groups(@pattern)
34
+ return RoutePattern.new(pattern).normalize(path: path)
35
+ end
36
+
37
+ route_path = @pattern.path
38
+ route_spec = route_path.spec
39
+
40
+ unless route_spec.respond_to?(:type)
41
+ return RoutePattern.new(route_spec.to_s).normalize(path: path)
42
+ end
43
+
44
+ if route_path.names.empty?
45
+ route_string = route_spec.to_s
46
+ return RouteText.escape(route_string) unless route_string.include?(GROUP_OPEN_CHAR)
47
+ end
48
+
49
+ buffer = Buffer.new
50
+ visit_route_node(route_spec, buffer)
51
+ buffer.to_path
52
+ end
53
+
54
+ private
55
+
56
+ def remove_paramless_optional_groups(pattern)
57
+ result = pattern
58
+
59
+ loop do
60
+ substituted = result.gsub(OPTIONAL_GROUP_PATTERN) do
61
+ # NOTE: OPTIONAL_GROUP_PATTERN always captures a string for each gsub match
62
+ group = ::Regexp.last_match(1) # : String
63
+ optional_group_has_route_params?(group) ? "(#{group})" : ''
64
+ end
65
+
66
+ return result if substituted == result
67
+
68
+ result = substituted
69
+ end
70
+ end
71
+
72
+ def visit_route_node(node, buffer)
73
+ case node.type
74
+ when :CAT
75
+ visit_route_node(node.left, buffer)
76
+ visit_route_node(node.right, buffer)
77
+ when :SLASH
78
+ buffer.flush
79
+ when :LITERAL
80
+ buffer.add_literal(node.left)
81
+ when :DOT
82
+ buffer.add_literal(DOT_CHAR)
83
+ when :SYMBOL, :STAR
84
+ buffer.add_param(node.name)
85
+ when :GROUP
86
+ visit_route_node(node.left, buffer) if group_present?(node.left)
87
+ end
88
+ end
89
+
90
+ def group_present?(node)
91
+ param_names = collect_group_param_names(node, [])
92
+ return false if param_names.empty?
93
+
94
+ param_names.all? { |name| param_matched_path?(name.to_sym) }
95
+ end
96
+
97
+ def param_matched_path?(name)
98
+ return @path_params[name].is_a?(String) unless name == :format
99
+
100
+ format = @path_params[:format]
101
+ return false if !format.is_a?(String) || format.empty?
102
+
103
+ dot_index = @path.length - format.length - 1
104
+ return false if dot_index < 0 || @path[dot_index] != DOT_CHAR
105
+
106
+ @path.end_with?(format)
107
+ end
108
+
109
+ def collect_group_param_names(node, memo)
110
+ case node.type
111
+ when :SYMBOL, :STAR
112
+ memo << node.name
113
+ when :CAT
114
+ collect_group_param_names(node.left, memo)
115
+ collect_group_param_names(node.right, memo)
116
+ end
117
+
118
+ memo
119
+ end
120
+
121
+ def optional_group_has_route_params?(group)
122
+ group.include?(NAMED_PARAM_PREFIX_CHAR) || group.include?(GLOB_PARAM_PREFIX_CHAR)
123
+ end
124
+
125
+ class Buffer
126
+ def initialize
127
+ @segments = []
128
+ @text = +''
129
+ @params = []
130
+ @nameless_param_count = 0
131
+ end
132
+
133
+ def add_literal(text)
134
+ @text << text
135
+ end
136
+
137
+ def add_param(name)
138
+ @params << name
139
+ end
140
+
141
+ def flush
142
+ return if @text.empty? && @params.empty?
143
+
144
+ @segments << if @params.empty?
145
+ RouteText.escape(@text)
146
+ else
147
+ render_params(@params)
148
+ end
149
+
150
+ @text = +''
151
+ @params.clear
152
+ end
153
+
154
+ def to_path
155
+ flush
156
+
157
+ "/#{@segments.join('/')}"
158
+ end
159
+
160
+ private
161
+
162
+ def render_params(params)
163
+ names = params.map do |name| # $ String
164
+ next name unless name.empty?
165
+
166
+ @nameless_param_count += 1
167
+ "param#{@nameless_param_count}"
168
+ end
169
+
170
+ "{#{names.join('+')}}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end