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
@@ -7,6 +7,8 @@ require_relative '../../trace_keeper'
7
7
  require_relative '../../security_event'
8
8
  require_relative '../../utils/http/url_encoded'
9
9
  require_relative '../../utils/http/body'
10
+ require_relative '../../utils/http/body_reader'
11
+ require_relative '../../utils/http/media_type'
10
12
 
11
13
  module Datadog
12
14
  module AppSec
@@ -39,7 +41,7 @@ module Datadog
39
41
  end
40
42
 
41
43
  if !is_redirect && data[SAMPLE_BODY_KEY]
42
- body = parse_body(data[:body], content_type: headers['content-type'])
44
+ body = parse_request_body(data[:body], content_type: headers['content-type'])
43
45
  ephemeral_data['server.io.net.request.body'] = body if body
44
46
  end
45
47
 
@@ -67,7 +69,7 @@ module Datadog
67
69
  end
68
70
 
69
71
  if !is_redirect && data[SAMPLE_BODY_KEY]
70
- body = parse_body(data.dig(:response, :body), content_type: headers['content-type'])
72
+ body = parse_response_body(data.dig(:response, :body), headers: headers, context: context)
71
73
  ephemeral_data['server.io.net.response.body'] = body if body
72
74
  end
73
75
 
@@ -89,11 +91,64 @@ module Datadog
89
91
  data[SAMPLE_BODY_KEY] = true
90
92
  end
91
93
 
92
- def parse_body(body, content_type:)
94
+ def parse_request_body(body, content_type:)
93
95
  media_type = Utils::HTTP::MediaType.parse(content_type)
94
96
  return unless media_type
95
97
 
96
- Utils::HTTP::Body.parse(body, media_type: media_type)
98
+ limit = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
99
+ content = read_body(body, limit: limit)
100
+ return if content.nil? || content.bytesize > limit
101
+
102
+ Utils::HTTP::Body.parse(content, media_type: media_type)
103
+ end
104
+
105
+ def parse_response_body(body, headers:, context:)
106
+ return unless readable_body?(body)
107
+
108
+ media_type = Utils::HTTP::MediaType.parse(headers['content-type'])
109
+ if !media_type || media_type.type != 'application'
110
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
111
+ return
112
+ end
113
+
114
+ subtype = media_type.subtype
115
+ if subtype != 'json' && !subtype.end_with?('+json') && subtype != 'x-www-form-urlencoded'
116
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
117
+ return
118
+ end
119
+
120
+ content_length_value = headers['content-length']
121
+ if !content_length_value.is_a?(String) || !content_length_value.match?(/\A[1-9][0-9]*\z/)
122
+ context.metrics.record_ignored_downstream_response_body(:content_length_missing)
123
+ return
124
+ end
125
+
126
+ content_length = content_length_value.to_i
127
+ max = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
128
+ if content_length > max
129
+ context.metrics.record_ignored_downstream_response_body(:content_length_too_big)
130
+ return
131
+ end
132
+
133
+ content = read_body(body, limit: content_length)
134
+ return if content.nil?
135
+
136
+ if content.bytesize > content_length
137
+ context.metrics.record_ignored_downstream_response_body(:content_exceed_content_length)
138
+ return
139
+ end
140
+
141
+ Utils::HTTP::Body.parse(content, media_type: media_type)
142
+ end
143
+
144
+ def readable_body?(body)
145
+ body.is_a?(String) || (body.respond_to?(:read) && body.respond_to?(:rewind))
146
+ end
147
+
148
+ def read_body(body, limit:)
149
+ Utils::HTTP::BodyReader.read(body, limit: limit, rewind_before_read: true)
150
+ rescue
151
+ nil
97
152
  end
98
153
 
99
154
  def request_url(data)
@@ -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
@@ -37,7 +38,7 @@ module Datadog
37
38
  end
38
39
 
39
40
  if !is_redirect && env[SAMPLE_BODY_KEY]
40
- body = parse_body(env.body, content_type: headers['content-type'])
41
+ body = parse_request_body(env.body, content_type: headers['content-type'])
41
42
  ephemeral_data['server.io.net.request.body'] = body if body
42
43
  end
43
44
 
@@ -64,7 +65,7 @@ module Datadog
64
65
  end
65
66
 
66
67
  if !is_redirect && env[SAMPLE_BODY_KEY]
67
- body = parse_body(env.body, content_type: headers['content-type'])
68
+ body = parse_response_body(env.body, headers: headers, context: context)
68
69
  ephemeral_data['server.io.net.response.body'] = body if body
69
70
  end
70
71
 
@@ -82,11 +83,64 @@ module Datadog
82
83
  env[SAMPLE_BODY_KEY] = true
83
84
  end
84
85
 
85
- def parse_body(body, content_type:)
86
+ def parse_request_body(body, content_type:)
86
87
  media_type = Utils::HTTP::MediaType.parse(content_type)
87
88
  return unless media_type
88
89
 
89
- Utils::HTTP::Body.parse(body, media_type: media_type)
90
+ limit = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
91
+ content = read_body(body, limit: limit)
92
+ return if content.nil? || content.bytesize > limit
93
+
94
+ Utils::HTTP::Body.parse(content, media_type: media_type)
95
+ end
96
+
97
+ def parse_response_body(body, headers:, context:)
98
+ return unless readable_body?(body)
99
+
100
+ media_type = Utils::HTTP::MediaType.parse(headers['content-type'])
101
+ if !media_type || media_type.type != 'application'
102
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
103
+ return
104
+ end
105
+
106
+ subtype = media_type.subtype
107
+ if subtype != 'json' && !subtype.end_with?('+json') && subtype != 'x-www-form-urlencoded'
108
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
109
+ return
110
+ end
111
+
112
+ content_length_value = headers['content-length']
113
+ if !content_length_value.is_a?(String) || !content_length_value.match?(/\A[1-9][0-9]*\z/)
114
+ context.metrics.record_ignored_downstream_response_body(:content_length_missing)
115
+ return
116
+ end
117
+
118
+ content_length = content_length_value.to_i
119
+ max = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
120
+ if content_length > max
121
+ context.metrics.record_ignored_downstream_response_body(:content_length_too_big)
122
+ return
123
+ end
124
+
125
+ content = read_body(body, limit: content_length)
126
+ return if content.nil?
127
+
128
+ if content.bytesize > content_length
129
+ context.metrics.record_ignored_downstream_response_body(:content_exceed_content_length)
130
+ return
131
+ end
132
+
133
+ Utils::HTTP::Body.parse(content, media_type: media_type)
134
+ end
135
+
136
+ def readable_body?(body)
137
+ body.is_a?(String) || (body.respond_to?(:read) && body.respond_to?(:rewind))
138
+ end
139
+
140
+ def read_body(body, limit:)
141
+ Utils::HTTP::BodyReader.read(body, limit: limit, rewind_before_read: true)
142
+ rescue
143
+ nil
90
144
  end
91
145
 
92
146
  def normalize_headers(headers)
@@ -50,38 +50,83 @@ module Datadog
50
50
  selected_operation = query.selected_operation
51
51
  next unless selected_operation
52
52
 
53
- arguments_from_selections(selected_operation.selections, query.variables, args_hash)
53
+ arguments_from_selections(selected_operation.selections, query.variables, args_hash, query.fragments)
54
54
  end
55
55
  end
56
56
 
57
- def arguments_from_selections(selections, query_variables, args_hash)
57
+ def arguments_from_selections(selections, query_variables, args_hash, fragments, visited_fragments = {})
58
58
  selections.each do |selection|
59
- # rubocop:disable Style/ClassEqualityComparison
60
- next unless selection.class.name == Integration::AST_NODE_CLASS_NAMES[:field]
61
- # rubocop:enable Style/ClassEqualityComparison
62
-
63
- selection_name = selection.alias || selection.name
64
-
65
- if !selection.arguments.empty? || !selection.directives.empty?
66
- args_hash[selection_name] ||= []
67
- args_hash[selection_name] <<
68
- arguments_hash(selection.arguments, query_variables).merge!(
69
- arguments_from_directives(selection.directives, query_variables)
70
- )
59
+ case selection
60
+ when ::GraphQL::Language::Nodes::FragmentSpread
61
+ fragment_name = selection.name
62
+ append_arguments(
63
+ args_hash, fragment_name, nil, arguments_from_directives(selection.directives, query_variables)
64
+ )
65
+
66
+ next if visited_fragments[fragment_name]
67
+
68
+ fragment = fragments[fragment_name]
69
+ next unless fragment
70
+
71
+ append_arguments(
72
+ args_hash, fragment_name, nil, arguments_from_directives(fragment.directives, query_variables)
73
+ )
74
+
75
+ # re-used fragments are not expanded
76
+ visited_fragments[fragment_name] = true
77
+ arguments_from_selections(
78
+ fragment.selections, query_variables, args_hash, fragments, visited_fragments
79
+ )
80
+ when ::GraphQL::Language::Nodes::Field
81
+ selection_name = selection.alias || selection.name
82
+ field_arguments = arguments_hash(selection.arguments, query_variables) unless selection.arguments.empty?
83
+ append_arguments(
84
+ args_hash,
85
+ selection_name,
86
+ field_arguments,
87
+ arguments_from_directives(selection.directives, query_variables)
88
+ )
89
+
90
+ arguments_from_selections(
91
+ selection.selections, query_variables, args_hash, fragments, visited_fragments
92
+ )
93
+ when ::GraphQL::Language::Nodes::InlineFragment
94
+ append_arguments(
95
+ args_hash, selection.type.name, nil, arguments_from_directives(selection.directives, query_variables)
96
+ )
97
+
98
+ arguments_from_selections(
99
+ selection.selections, query_variables, args_hash, fragments, visited_fragments
100
+ )
71
101
  end
102
+ end
103
+ end
72
104
 
73
- arguments_from_selections(selection.selections, query_variables, args_hash)
105
+ def append_arguments(args_hash, selection_name, arguments, directive_arguments)
106
+ combined_arguments = if arguments
107
+ arguments.merge!(directive_arguments) if directive_arguments
108
+ arguments
109
+ else
110
+ directive_arguments
74
111
  end
112
+ return unless combined_arguments
113
+
114
+ args_hash[selection_name] ||= []
115
+ args_hash[selection_name] << combined_arguments
75
116
  end
76
117
 
77
118
  def arguments_from_directives(directives, query_variables)
78
- directives.each_with_object({}) do |directive, args_hash|
79
- # rubocop:disable Style/ClassEqualityComparison
80
- next unless directive.class.name == Integration::AST_NODE_CLASS_NAMES[:directive]
81
- # rubocop:enable Style/ClassEqualityComparison
119
+ return if directives.empty?
120
+
121
+ directive_arguments = directives.each_with_object({}) do |directive, args_hash|
122
+ next unless directive.is_a?(::GraphQL::Language::Nodes::Directive)
82
123
 
83
124
  args_hash[directive.name] = arguments_hash(directive.arguments, query_variables)
84
125
  end
126
+
127
+ return if directive_arguments.empty?
128
+
129
+ directive_arguments
85
130
  end
86
131
 
87
132
  def arguments_hash(arguments, query_variables)
@@ -16,6 +16,7 @@ module Datadog
16
16
  AST_NODE_CLASS_NAMES = {
17
17
  field: 'GraphQL::Language::Nodes::Field',
18
18
  directive: 'GraphQL::Language::Nodes::Directive',
19
+ fragment_spread: 'GraphQL::Language::Nodes::FragmentSpread',
19
20
  variable_identifier: 'GraphQL::Language::Nodes::VariableIdentifier',
20
21
  input_object: 'GraphQL::Language::Nodes::InputObject',
21
22
  }.freeze
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module AppSec
5
+ module Contrib
6
+ module Rack
7
+ # Wraps a `rack.input` stream with a buffer placed in front of it.
8
+ # Every read drains the buffer first, then continues from the stream
9
+ #
10
+ # NOTE: Forward-only: no rewind, no seek
11
+ #
12
+ # NOTE: Rack 3 dropped the rewind requirement from the input stream contract
13
+ # @see https://github.com/rack/rack/blob/v3.2.6/SPEC.rdoc
14
+ class BufferedInput
15
+ # NOTE: Rack's multipart parser reads in 1 MiB chunks, used to bound
16
+ # {#each} the same way
17
+ # @see https://github.com/rack/rack/blob/v3.2.6/lib/rack/multipart/parser.rb#L54
18
+ READ_BUFSIZE_BYTES = 1_048_576
19
+
20
+ def initialize(stream, buffer:)
21
+ @stream = stream
22
+ @buffer = buffer
23
+ end
24
+
25
+ def read(length = nil, outbuf = nil)
26
+ if length.nil?
27
+ data = @buffer.read(nil, outbuf) || +''
28
+ more = @stream.read
29
+
30
+ data << more if more
31
+
32
+ return data
33
+ end
34
+
35
+ data = @buffer.read(length, outbuf)
36
+
37
+ if data.nil?
38
+ more = @stream.read(length, outbuf)
39
+ return more if more && !more.empty?
40
+
41
+ # NOTE: Match `IO#read(length, outbuf)` at EOF. Return nil and clear
42
+ # the caller's buffer so stale bytes are not mistaken for data
43
+ outbuf&.clear
44
+ return
45
+ end
46
+
47
+ remaining = length - data.bytesize
48
+ return data if remaining <= 0
49
+
50
+ more = @stream.read(remaining)
51
+ data << more if more
52
+
53
+ data
54
+ end
55
+
56
+ def gets
57
+ line = @buffer.gets
58
+
59
+ return @stream.gets if line.nil?
60
+ return line if line.end_with?("\n")
61
+
62
+ more = @stream.gets
63
+ more ? (line << more) : line
64
+ end
65
+
66
+ def each
67
+ while (chunk = read(READ_BUFSIZE_BYTES))
68
+ yield chunk
69
+ end
70
+
71
+ self
72
+ end
73
+
74
+ def close
75
+ @buffer.close
76
+ ensure
77
+ @stream.close
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../input_peeker'
3
4
  require_relative '../../../instrumentation/gateway/argument'
4
5
  require_relative '../../../../core/header_collection'
5
6
  require_relative '../../../../tracing/client_ip'
@@ -75,14 +76,51 @@ module Datadog
75
76
  end
76
77
 
77
78
  def form_hash
78
- # force form data processing
79
+ # NOTE: Rack populates `rack.request.form_hash` only as a side effect
80
+ # of {::Rack::Request#POST}, which reads and parses the body.
79
81
  request.POST if request.form_data?
80
82
 
81
- # usually Hash<String,String> but can be a more complex
82
- # Hash<String,String||Array||Hash> when e.g coming from JSON
83
+ # usually Hash[String, String] but can be a more complex
84
+ # Hash[String, (String|Array|Hash)] when e.g coming from JSON
83
85
  env['rack.request.form_hash']
84
86
  end
85
87
 
88
+ # Returns the request body size in bytes using all available methods,
89
+ # or nil when the size cannot be measured within the limit
90
+ #
91
+ # NOTE: The priority of the measurement is the following:
92
+ # size if it's known, content-length if provided, and buffering
93
+ # to the limit if unknown-length
94
+ #
95
+ # WARNING: The buffering path adds overhead for streaming web-servers
96
+ # (Rack 3+) when the body length is unknown
97
+ def body_bytesize(limit)
98
+ io = request.body
99
+
100
+ return 0 unless io
101
+ return io.size if io.respond_to?(:size)
102
+
103
+ # NOTE: {::Rack::Request#content_length} is a plain `CONTENT_LENGTH`
104
+ # getter forces no body read
105
+ content_length = request.content_length
106
+ return content_length.to_i if content_length
107
+
108
+ # NOTE: A parsed-body cache without a raw byte count means the input
109
+ # stream was consumed before measurement, consider it unknown-length
110
+ return if env.key?('rack.request.form_hash')
111
+
112
+ InputPeeker.peek_bytesize(env, limit: limit)
113
+ end
114
+
115
+ # Whether a request body can be collected without forcing a parse:
116
+ # either form data parseable on demand, or a body already parsed upstream
117
+ #
118
+ # NOTE: Rack does not parse JSON itself, a body parser middleware such as
119
+ # {Rack::JSONBodyParser} populates the form hash read by {#form_hash}
120
+ def collectable_body?
121
+ request.form_data? || request.parseable_data? || env.key?('rack.request.form_hash')
122
+ end
123
+
86
124
  def client_ip
87
125
  remote_ip = remote_addr
88
126
  header_collection = Datadog::Core::HeaderCollection.from_hash(headers)
@@ -87,9 +87,30 @@ module Datadog
87
87
  gateway.watch('rack.request.body') do |stack, gateway_request|
88
88
  context = gateway_request.env[AppSec::Ext::CONTEXT_KEY]
89
89
 
90
- persistent_data = {
91
- 'server.request.body' => gateway_request.form_hash
92
- }
90
+ request = gateway_request.request
91
+ next stack.call(request) unless gateway_request.collectable_body?
92
+
93
+ # NOTE: A limit of 0 disables request body collection entirely.
94
+ limit = Datadog.configuration.appsec.body_parsing_size_limit
95
+ next stack.call(request) if limit.zero?
96
+
97
+ persistent_data = {}
98
+ byte_length = gateway_request.body_bytesize(limit)
99
+
100
+ if byte_length
101
+ persistent_data['server.request.body.byte_length'] = byte_length
102
+
103
+ if byte_length <= limit
104
+ body = gateway_request.form_hash
105
+ persistent_data['server.request.body'] = body if body
106
+ end
107
+ # NOTE: Body was parsed before measurement, keep byte_length unset
108
+ elsif gateway_request.env.key?('rack.request.form_hash')
109
+ body = gateway_request.env['rack.request.form_hash']
110
+ persistent_data['server.request.body'] = body if body
111
+ end
112
+
113
+ next stack.call(request) if persistent_data.empty?
93
114
 
94
115
  result = context.run_waf(persistent_data, {}, Datadog.configuration.appsec.waf_timeout)
95
116
 
@@ -119,10 +140,6 @@ module Datadog
119
140
  next stack.call(gateway_request.request) if context.span.nil?
120
141
 
121
142
  gateway_request.headers.each do |name, value|
122
- if Ext::COLLECTABLE_REQUEST_HEADERS.include?(name)
123
- context.span["http.request.headers.#{name}"] ||= value
124
- end
125
-
126
143
  if context.state[:has_identity_event] && Ext::IDENTITY_COLLECTABLE_REQUEST_HEADERS.include?(name)
127
144
  context.span["http.request.headers.#{name}"] ||= value
128
145
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative 'buffered_input'
5
+ require_relative '../../utils/http/body_reader'
6
+
7
+ module Datadog
8
+ module AppSec
9
+ module Contrib
10
+ module Rack
11
+ # Peeks at `rack.input` without changing what downstream Rack code can read
12
+ #
13
+ # @api private
14
+ module InputPeeker
15
+ module_function
16
+
17
+ # Peeks at `env['rack.input']` and returns the body bytesize when it
18
+ # can be measured within the given limit
19
+ #
20
+ # NOTE: Over-limit bodies cannot be fully measured, so returning `nil`
21
+ # is the tradeoff we accept
22
+ #
23
+ # WARNING: For forward-only input, replaces `env['rack.input']` with a
24
+ # replay stream over the peeked bytes, preserving the rest
25
+ # of the original stream
26
+ #
27
+ # @param env [Hash] Rack environment
28
+ # @param limit [Integer] Maximum body bytesize to measure
29
+ # @return [Integer, nil] `Integer` bytesize when measured within
30
+ # `limit`, including `0`; `nil` when input is missing, over the
31
+ # limit, or cannot be preserved for downstream reads
32
+ def peek_bytesize(env, limit:)
33
+ rack_input = env['rack.input']
34
+ return unless rack_input
35
+
36
+ rewindable = rewind? && rack_input.respond_to?(:rewind)
37
+
38
+ # NOTE: Rack 2 requires `rack.input` to be rewindable. Rewind before peeking
39
+ # in case an upstream framework already consumed part of the stream
40
+ return if rewindable && !rewind(rack_input)
41
+
42
+ # NOTE: Read one byte past the limit to distinguish an exact-limit body
43
+ # from an over-limit body without reading the whole stream.
44
+ buffer = Utils::HTTP::BodyReader.read_stream(rack_input, limit: limit)
45
+ over_limit = buffer.bytesize > limit
46
+
47
+ if rewindable
48
+ # NOTE: If we cannot rewind after peeking, downstream code would observe
49
+ # a partially consumed body. Treat it as not safely collectable
50
+ return unless rewind(rack_input)
51
+ else
52
+ env['rack.input'] = if over_limit
53
+ BufferedInput.new(rack_input, buffer: StringIO.new(buffer))
54
+ else
55
+ StringIO.new(buffer)
56
+ end
57
+ end
58
+
59
+ over_limit ? nil : buffer.bytesize
60
+ end
61
+
62
+ private_class_method def rewind?
63
+ return @rewind if defined?(@rewind)
64
+
65
+ @rewind = ::Gem::Version.new(::Rack.release) < ::Gem::Version.new('3')
66
+ end
67
+
68
+ private_class_method def rewind(io)
69
+ io.rewind
70
+ true
71
+ rescue => e
72
+ Datadog.logger.debug { "AppSec: Failed to rewind `rack.input`: #{e.class}: #{e.message}" }
73
+ false
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -10,6 +10,7 @@ require_relative '../../event'
10
10
  require_relative '../../response'
11
11
  require_relative '../../api_security'
12
12
  require_relative '../../default_header_tags'
13
+ require_relative '../../route_normalizer'
13
14
  require_relative '../../security_event'
14
15
  require_relative '../../instrumentation/gateway'
15
16
 
@@ -76,6 +77,8 @@ module Datadog
76
77
  nil
77
78
  end
78
79
 
80
+ add_normalized_route_tag(ctx, env)
81
+
79
82
  if interrupt_params
80
83
  ctx.mark_as_interrupted!
81
84
  http_response = AppSec::Response.from_interrupt_params(interrupt_params, env['HTTP_ACCEPT']).to_rack
@@ -198,6 +201,24 @@ module Datadog
198
201
  end
199
202
  end
200
203
 
204
+ def add_normalized_route_tag(context, env)
205
+ return unless AppSec::APISecurity.enabled?
206
+
207
+ span = context.span
208
+ return unless span
209
+
210
+ pattern = context.trace&.get_tag(Tracing::Metadata::Ext::HTTP::TAG_ROUTE)
211
+ return unless pattern
212
+
213
+ # NOTE: To build full path that covers mounted engines we need to add
214
+ # pre-computed by Tracer route path tag to the normalized route
215
+ prefix = context.trace&.get_tag(Tracing::Metadata::Ext::HTTP::TAG_ROUTE_PATH) || env['SCRIPT_NAME']
216
+ normalized_route = RouteNormalizer.extract_normalized_route(env, prefix: prefix, pattern: pattern)
217
+ return unless normalized_route
218
+
219
+ span.set_tag(AppSec::Ext::TAG_NORMALIZED_ROUTE, "#{prefix}#{normalized_route}")
220
+ end
221
+
201
222
  def oneshot_tags_sent?
202
223
  @oneshot_tags_sent
203
224
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../rack/input_peeker'
3
4
  require_relative '../../../instrumentation/gateway/argument'
4
5
 
5
6
  module Datadog
@@ -46,6 +47,7 @@ module Datadog
46
47
  body = request.env['action_dispatch.request.request_parameters']
47
48
 
48
49
  return if body.nil?
50
+ return body unless request.env['action_dispatch.request.path_parameters']
49
51
 
50
52
  body.reject do |k, _v|
51
53
  request.env['action_dispatch.request.path_parameters'].key?(k)
@@ -55,10 +57,42 @@ module Datadog
55
57
  def route_params
56
58
  excluded = [:controller, :action]
57
59
 
58
- request.env['action_dispatch.request.path_parameters'].reject do |k, _v|
60
+ request.env.fetch('action_dispatch.request.path_parameters', {}).reject do |k, _v|
59
61
  excluded.include?(k)
60
62
  end
61
63
  end
64
+
65
+ # Returns the request body size in bytes using all available methods,
66
+ # or nil when the size cannot be measured within the limit
67
+ #
68
+ # NOTE: The priority of the measurement is the following:
69
+ # raw posted data, raw form vars, size if known, raw
70
+ # Content-Length, then buffering to the limit if unknown-length
71
+ def body_bytesize(limit)
72
+ raw_body = env['RAW_POST_DATA']
73
+ return raw_body.bytesize if raw_body
74
+
75
+ form_vars = env['rack.request.form_vars']
76
+ return form_vars.bytesize if form_vars
77
+
78
+ io = request.body
79
+ return 0 unless io
80
+ return io.size if io.respond_to?(:size)
81
+
82
+ # NOTE: Read raw `CONTENT_LENGTH` as {ActionDispatch::Request#content_length}
83
+ # drains `rack.input` into `RAW_POST_DATA` on chunked Transfer-Encoding
84
+ content_length = env['CONTENT_LENGTH']
85
+ return content_length.to_i if content_length
86
+
87
+ # NOTE: An already-read body (e.g. late-parsed multipart on Rack 3+) peeks
88
+ # as 0, so we skip byte_length but still collect the parsed body
89
+ begin
90
+ Rack::InputPeeker.peek_bytesize(env, limit: limit)
91
+ rescue => e
92
+ Datadog.logger.debug { "AppSec: Failed to measure Rails request body: #{e.class}: #{e.message}" }
93
+ nil
94
+ end
95
+ end
62
96
  end
63
97
  end
64
98
  end