contrast-agent 3.14.0 → 3.15.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/ext/cs__assess_marshal_module/cs__assess_marshal_module.c +18 -15
  3. data/ext/cs__assess_marshal_module/cs__assess_marshal_module.h +1 -0
  4. data/ext/cs__assess_string/cs__assess_string.c +24 -25
  5. data/ext/cs__assess_string/cs__assess_string.h +3 -1
  6. data/ext/cs__common/cs__common.c +4 -2
  7. data/ext/cs__common/cs__common.h +1 -1
  8. data/lib/contrast.rb +1 -1
  9. data/lib/contrast/agent/assess.rb +1 -0
  10. data/lib/contrast/agent/assess/contrast_event.rb +4 -12
  11. data/lib/contrast/agent/assess/finalizers/freeze.rb +3 -1
  12. data/lib/contrast/agent/assess/finalizers/hash.rb +45 -1
  13. data/lib/contrast/agent/assess/policy/patcher.rb +1 -1
  14. data/lib/contrast/agent/assess/policy/policy.rb +0 -2
  15. data/lib/contrast/agent/assess/policy/policy_scanner.rb +0 -1
  16. data/lib/contrast/agent/assess/policy/preshift.rb +7 -11
  17. data/lib/contrast/agent/assess/policy/propagation_method.rb +50 -33
  18. data/lib/contrast/agent/assess/policy/propagator/append.rb +8 -5
  19. data/lib/contrast/agent/assess/policy/propagator/base.rb +1 -1
  20. data/lib/contrast/agent/assess/policy/propagator/center.rb +9 -5
  21. data/lib/contrast/agent/assess/policy/propagator/database_write.rb +5 -3
  22. data/lib/contrast/agent/assess/policy/propagator/insert.rb +6 -3
  23. data/lib/contrast/agent/assess/policy/propagator/keep.rb +4 -1
  24. data/lib/contrast/agent/assess/policy/propagator/match_data.rb +6 -6
  25. data/lib/contrast/agent/assess/policy/propagator/next.rb +7 -5
  26. data/lib/contrast/agent/assess/policy/propagator/prepend.rb +8 -5
  27. data/lib/contrast/agent/assess/policy/propagator/remove.rb +8 -4
  28. data/lib/contrast/agent/assess/policy/propagator/replace.rb +5 -2
  29. data/lib/contrast/agent/assess/policy/propagator/reverse.rb +7 -5
  30. data/lib/contrast/agent/assess/policy/propagator/select.rb +15 -7
  31. data/lib/contrast/agent/assess/policy/propagator/splat.rb +14 -8
  32. data/lib/contrast/agent/assess/policy/propagator/split.rb +14 -8
  33. data/lib/contrast/agent/assess/policy/propagator/substitution.rb +30 -21
  34. data/lib/contrast/agent/assess/policy/propagator/trim.rb +11 -5
  35. data/lib/contrast/agent/assess/policy/source_method.rb +85 -58
  36. data/lib/contrast/agent/assess/policy/trigger/reflected_xss.rb +16 -11
  37. data/lib/contrast/agent/assess/policy/trigger/xpath.rb +1 -1
  38. data/lib/contrast/agent/assess/policy/trigger_method.rb +38 -15
  39. data/lib/contrast/agent/assess/policy/trigger_node.rb +14 -13
  40. data/lib/contrast/agent/assess/policy/trigger_validation/ssrf_validator.rb +2 -1
  41. data/lib/contrast/agent/assess/properties.rb +2 -0
  42. data/lib/contrast/agent/assess/property/updated.rb +136 -0
  43. data/lib/contrast/agent/assess/tracker.rb +66 -0
  44. data/lib/contrast/agent/class_reopener.rb +7 -5
  45. data/lib/contrast/agent/middleware.rb +0 -1
  46. data/lib/contrast/agent/patching/policy/patcher.rb +13 -22
  47. data/lib/contrast/agent/patching/policy/policy.rb +1 -4
  48. data/lib/contrast/agent/response.rb +17 -6
  49. data/lib/contrast/agent/rewriter.rb +1 -3
  50. data/lib/contrast/agent/version.rb +1 -1
  51. data/lib/contrast/api/communication/messaging_queue.rb +1 -4
  52. data/lib/contrast/api/decorators/application_update.rb +2 -4
  53. data/lib/contrast/api/decorators/trace_event.rb +5 -5
  54. data/lib/contrast/components/app_context.rb +11 -9
  55. data/lib/contrast/components/config.rb +3 -13
  56. data/lib/contrast/components/contrast_service.rb +2 -2
  57. data/lib/contrast/config/application_configuration.rb +5 -2
  58. data/lib/contrast/config/service_configuration.rb +8 -2
  59. data/lib/contrast/configuration.rb +88 -47
  60. data/lib/contrast/extension/assess.rb +0 -2
  61. data/lib/contrast/extension/assess/array.rb +8 -5
  62. data/lib/contrast/extension/assess/erb.rb +6 -3
  63. data/lib/contrast/extension/assess/fiber.rb +9 -9
  64. data/lib/contrast/extension/assess/hash.rb +2 -3
  65. data/lib/contrast/extension/assess/kernel.rb +12 -5
  66. data/lib/contrast/extension/assess/marshal.rb +3 -2
  67. data/lib/contrast/extension/assess/regexp.rb +5 -4
  68. data/lib/contrast/extension/assess/string.rb +8 -10
  69. data/lib/contrast/framework/rack/patch/session_cookie.rb +12 -18
  70. data/lib/contrast/framework/rails/patch/assess_configuration.rb +4 -10
  71. data/lib/contrast/framework/rails/support.rb +2 -0
  72. data/lib/contrast/logger/application.rb +11 -3
  73. data/lib/contrast/utils/assess/tracking_util.rb +48 -3
  74. data/lib/contrast/utils/duck_utils.rb +0 -10
  75. data/lib/contrast/utils/env_configuration_item.rb +2 -1
  76. data/lib/contrast/utils/invalid_configuration_util.rb +21 -19
  77. data/lib/contrast/utils/string_utils.rb +10 -5
  78. data/resources/assess/policy.json +0 -10
  79. data/ruby-agent.gemspec +16 -15
  80. data/service_executables/VERSION +1 -1
  81. data/service_executables/linux/contrast-service +0 -0
  82. data/service_executables/mac/contrast-service +0 -0
  83. metadata +42 -21
  84. data/lib/contrast/agent/assess/finalizers/finalize.rb +0 -21
  85. data/lib/contrast/extension/assess/assess_extension.rb +0 -145
  86. data/lib/contrast/utils/freeze_util.rb +0 -32
@@ -100,16 +100,17 @@ module Contrast
100
100
  # if the source isn't tracked, there can't be a violation
101
101
  # this condition may not hold true forever, but for now it's
102
102
  # a nice optimization
103
- return false unless source.cs__tracked?
103
+ return false unless Contrast::Agent::Assess::Tracker.tracked?(source)
104
104
 
105
+ properties = Contrast::Agent::Assess::Tracker.properties(source)
105
106
  # find the ranges that violate the rule (untrusted, etc)
106
- vulnerable_ranges = find_ranges_by_all_tags(Contrast::Utils::StringUtils.ret_length(source), source.cs__properties, required_tags)
107
+ vulnerable_ranges = find_ranges_by_all_tags(Contrast::Utils::StringUtils.ret_length(source), properties, required_tags)
107
108
  # if there aren't any vulnerable ranges, nope out
108
109
  return false if vulnerable_ranges.empty?
109
110
 
110
111
  # find the ranges that are exempt from the rule
111
112
  # (validated, sanitized, etc)
112
- secure_ranges = find_ranges_by_any_tag(source.cs__properties, disallowed_tags)
113
+ secure_ranges = find_ranges_by_any_tag(properties, disallowed_tags)
113
114
  # if there are vulnerable ranges and no secure, report
114
115
  return true if secure_ranges.empty?
115
116
 
@@ -178,27 +179,27 @@ module Contrast
178
179
  # @param length [Integer] the length of the object which may have the
179
180
  # given tags -- used as the maximum index to search for all of the
180
181
  # tags.
181
- # @param cs__properties [Contrast::Agent::Assess::Properties] the
182
+ # @param properties [Contrast::Agent::Assess::Properties] the
182
183
  # properties to check for the tags
183
184
  # @param tags [Set<String>] the list of tags on which to match
184
185
  # @return [Array<Contrast::Agent::Assess::Tag>] the ranges satisfied
185
186
  # by the given conditions
186
- def find_ranges_by_all_tags length, cs__properties, tags
187
+ def find_ranges_by_all_tags length, properties, tags
187
188
  # if there aren't any all_tags or tags, break early
188
- return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless cs__properties.tracked?
189
+ return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless properties.tracked?
189
190
  return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tags&.any?
190
191
 
191
192
  # :zap: faster to treat all as any if there's only one tag
192
- return find_ranges_by_any_tag(cs__properties, tags) if tags.length == 1
193
+ return find_ranges_by_any_tag(properties, tags) if tags.length == 1
193
194
 
194
195
  ranges = []
195
196
  # TODO: RUBY-946 clean this up, perhaps with
196
- # tags.each { |tag| applicable << cs__properties.fetch_tag(tag) }
197
+ # tags.each { |tag| applicable << properties.fetch_tag(tag) }
197
198
  # return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless applicable.length == tags.length
198
199
  # ...
199
200
  # find all the indicies on the source that have all the given tags
200
201
  (0..length).each do |idx|
201
- tags_at = cs__properties.tags_at(idx)
202
+ tags_at = properties.tags_at(idx)
202
203
  ranges << idx if tags.all? do |tag|
203
204
  found = false
204
205
  tags_at.each do |tag_at|
@@ -227,19 +228,19 @@ module Contrast
227
228
 
228
229
  # Find the ranges that satisfy any of the given tags.
229
230
  #
230
- # @param cs__properties [Contrast::Agent::Assess::Properties] the
231
+ # @param properties [Contrast::Agent::Assess::Properties] the
231
232
  # properties to check for the tags
232
233
  # @param tags [Set<String>] the list of tags on which to match
233
234
  # @return [Array<Contrast::Agent::Assess::Tag>] the ranges satisfied
234
235
  # by the given conditions
235
- def find_ranges_by_any_tag cs__properties, tags
236
+ def find_ranges_by_any_tag properties, tags
236
237
  # if there aren't any all_tags or tags, break early
237
- return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless cs__properties.tracked?
238
+ return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless properties.tracked?
238
239
  return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tags&.any?
239
240
 
240
241
  ranges = []
241
242
  tags.each do |desired|
242
- found = cs__properties.fetch_tag(desired)
243
+ found = properties.fetch_tag(desired)
243
244
  next unless found
244
245
 
245
246
  # we need to dup here so that we don't change the tags if target is
@@ -38,7 +38,8 @@ module Contrast
38
38
  finish = match.begin(:path)
39
39
  finish ||= url.length
40
40
 
41
- args[0].cs__properties.any_tags_between?(start, finish)
41
+ properties = Contrast::Agent::Assess::Tracker.properties(args[0])
42
+ properties.any_tags_between?(start, finish)
42
43
  end
43
44
  end
44
45
  end
@@ -5,6 +5,7 @@ require 'base64'
5
5
  require 'set'
6
6
  require 'contrast/agent/assess/property/evented'
7
7
  require 'contrast/agent/assess/property/tagged'
8
+ require 'contrast/agent/assess/property/updated'
8
9
  require 'contrast/utils/prevent_serialization'
9
10
 
10
11
  module Contrast
@@ -20,6 +21,7 @@ module Contrast
20
21
  include Contrast::Utils::PreventSerialization
21
22
  include Contrast::Agent::Assess::Property::Evented
22
23
  include Contrast::Agent::Assess::Property::Tagged
24
+ include Contrast::Agent::Assess::Property::Updated
23
25
 
24
26
  attr_accessor :dupped_from
25
27
 
@@ -0,0 +1,136 @@
1
+ # Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
2
+ # frozen_string_literal: true
3
+
4
+ require 'contrast/utils/duck_utils'
5
+
6
+ module Contrast
7
+ module Agent
8
+ module Assess
9
+ module Property
10
+ # This module serves to hold the functionality required for the
11
+ # update of properties as they go through dataflow.
12
+ module Updated
13
+ # copy tags and info from source's properties to self
14
+ # @param source [Object] the object from which existing properties
15
+ # should be copied.
16
+ # @param owner [Object] the object to which these properties apply.
17
+ # @param shift [Integer] (0) how far to shift the tags during copy,
18
+ # useful for insert and append operations.
19
+ # @param skip_tags [Set<String>] (nil) the tags to not copy over,
20
+ # useful for propagation events that have 'untags'.
21
+ def copy_from source, owner, shift = 0, skip_tags = nil
22
+ return if owner.equal?(source)
23
+ return unless Contrast::Agent::Assess::Tracker.tracked?(source)
24
+
25
+ original = Contrast::Agent::Assess::Tracker.properties(source)
26
+ return unless original
27
+
28
+ adjust_duplicate(original)
29
+
30
+ original.events.each do |event|
31
+ events << event
32
+ end
33
+
34
+ original.tag_keys.each do |key|
35
+ next if skip_tags&.include?(key)
36
+
37
+ existing = tags[key]
38
+ had_existing = existing.any?
39
+ value = original.fetch_tag(key)
40
+ value.each do |tag|
41
+ existing << tag.copy_modified(shift)
42
+ end
43
+ Contrast::Utils::TagUtil.size_aware_merge(owner, existing) if had_existing
44
+ end
45
+ end
46
+
47
+ # Some propagation occurred, but we're not sure what the
48
+ # exact transformation was. To be safe, we just explode
49
+ # all the tags from the source to the return.
50
+ #
51
+ # If the return already had that tag, the existing tag
52
+ # range is recycled to save us an object.
53
+ #
54
+ # @param source [Object] the object from which existing properties
55
+ # should be copied.
56
+ # @param owner [Object] the object to which these properties apply.
57
+ def splat_from source, owner
58
+ splat_length = Contrast::Utils::StringUtils.ret_length(owner)
59
+ return if splat_length.zero?
60
+
61
+ splat_from_ret(splat_length)
62
+ splat_from_source(source, splat_length)
63
+ cleanup_tags
64
+ end
65
+
66
+ private
67
+
68
+ # Because of how our tracking works now, sometimes the Source and
69
+ # Target are the same, but their IDs in our map will be different due
70
+ # to PreShift duplication. To account for this, we have to ensure that
71
+ # the Object we're copying from does not have the same Properties
72
+ # that the Object we're copying to does. If they are the same, wipe the
73
+ # Target so that the copy method can update events and ranges as
74
+ # necessary.
75
+ # DO NOT TAKE THIS OUT!
76
+ def adjust_duplicate original
77
+ reset_properties if original == self
78
+ reset_properties if original.__id__ == dupped_from
79
+ reset_properties if original.dupped_from == __id__
80
+ end
81
+
82
+ # Wipe out the instance variables on this Properties instance,
83
+ # allowing them to be rebuilt.
84
+ def reset_properties
85
+ @_tags = nil
86
+ @_events = nil
87
+ @_properties = nil
88
+ end
89
+
90
+ # Splat all the tags from the source to this set of Properties
91
+ #
92
+ # @param source [Object] the object from which tags will be copied
93
+ # and splatted.
94
+ # @param splat_length [Integer] the length to which to to set all
95
+ # tags.
96
+ def splat_from_source source, splat_length
97
+ properties = Contrast::Agent::Assess::Tracker.properties(source)
98
+ return unless properties
99
+
100
+ properties.tag_keys.each do |key|
101
+ existing = fetch_tag(key)
102
+ # if the tag already exists, drop all but the first range
103
+ # then change that range to cover the entire return
104
+ if existing
105
+ existing.drop(existing.length - 1)
106
+ range = existing[0]
107
+ range.repurpose(0, splat_length)
108
+ else
109
+ add_tag(key, 0...splat_length)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Splat all the tags existing on this set of Properties
115
+ #
116
+ # @param splat_length [Integer] the length to which to to set all
117
+ # tags.
118
+ def splat_from_ret splat_length
119
+ return unless tracked?
120
+
121
+ tag_keys.each do |key|
122
+ next unless key
123
+
124
+ existing = fetch_tag(key)
125
+ next unless existing
126
+
127
+ existing.each do |range|
128
+ range.repurpose(0, splat_length)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,66 @@
1
+ # Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
2
+ # frozen_string_literal: true
3
+
4
+ require 'contrast/agent/assess/finalizers/hash'
5
+
6
+ module Contrast
7
+ module Agent
8
+ module Assess
9
+ # How we track the Assess properties attached to objects
10
+ #
11
+ # Finalized objects should run through this class as the Finalizers
12
+ # have tightly coupled dependencies on each other.
13
+ class Tracker
14
+ PROPERTIES_HASH = Contrast::Agent::Assess::Finalizers::Hash.new
15
+
16
+ class << self
17
+ def properties source
18
+ return unless trackable?(source)
19
+
20
+ PROPERTIES_HASH[source] ||= Contrast::Agent::Assess::Properties.new
21
+ end
22
+
23
+ def trackable? source
24
+ PROPERTIES_HASH.trackable?(source)
25
+ end
26
+
27
+ def tracked? source
28
+ PROPERTIES_HASH.tracked?(source)
29
+ end
30
+
31
+ def pre_freeze source
32
+ PROPERTIES_HASH.pre_freeze(source)
33
+ end
34
+
35
+ # Copy the properties from one object to the next, assuming the
36
+ # target does not already have its own properties.
37
+ #
38
+ # @param source [Object] the instance from which to copy properties
39
+ # @param target [Object] the instance to which to copy properties
40
+ def copy source, target
41
+ PROPERTIES_HASH[target] ||= properties(source).dup
42
+ end
43
+
44
+ # Duplicate the given object, returning the duplicate after copying
45
+ # the properties of the original and storing them as the properties
46
+ # of the duplicate.
47
+ #
48
+ # @param source [Object] the thing to duplicate
49
+ # @return [Object] the duplicate of the original, or the original if
50
+ # it does not respond to duplication
51
+ def duplicate source
52
+ return source unless source
53
+
54
+ duplicate = source.dup
55
+ PROPERTIES_HASH[duplicate] ||= PROPERTIES_HASH[source].dup
56
+ duplicate
57
+ rescue StandardError
58
+ source
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ require 'contrast/agent/assess/finalizers/freeze'
@@ -37,7 +37,7 @@ module Contrast
37
37
  # being phased out with support for those language versions.
38
38
  class ClassReopener
39
39
  include Contrast::Components::Interface
40
- access_component :logging
40
+ access_component :logging, :scope
41
41
 
42
42
  END_NEW_LINE = "end\n"
43
43
  PROTECTED_WITH_NEW_LINE = "protected\n"
@@ -101,11 +101,13 @@ module Contrast
101
101
  # Evaluate the patches that have been staged for this class, replacing
102
102
  # the method definitions with those our rewrite.
103
103
  def commit_patches
104
- return unless staged_changes?
104
+ with_contrast_scope do
105
+ return unless staged_changes?
105
106
 
106
- content = build_content
107
- valid = Ripper.sexp(content)
108
- unbound_eval(class_name, content) if !!valid && !class_name.empty?
107
+ content = build_content
108
+ valid = Ripper.sexp(content)
109
+ unbound_eval(class_name, content) if !!valid && !class_name.empty?
110
+ end
109
111
  end
110
112
 
111
113
  # Find the sourcecode of the method at the given location and return it
@@ -13,7 +13,6 @@ require 'contrast/agent/request_handler'
13
13
  require 'contrast/agent/static_analysis'
14
14
 
15
15
  require 'contrast/utils/timer'
16
- require 'contrast/utils/freeze_util'
17
16
 
18
17
  module Contrast
19
18
  module Agent
@@ -77,19 +77,21 @@ module Contrast
77
77
  # This method is called by TracePointHook to instrument a specific class during a require
78
78
  # or eval of dynamic class definition
79
79
  def patch_specific_module mod
80
- mod_name = mod.cs__name
81
- return unless Contrast::Utils::ClassUtil.truly_defined?(mod_name)
82
- return if AGENT.skip_instrumentation?(mod_name)
80
+ with_contrast_scope do
81
+ mod_name = mod.cs__name
82
+ return unless Contrast::Utils::ClassUtil.truly_defined?(mod_name)
83
+ return if AGENT.skip_instrumentation?(mod_name)
83
84
 
84
- load_patches_for_module(mod_name)
85
+ load_patches_for_module(mod_name)
85
86
 
86
- return unless all_module_names.any? { |name| name == mod_name }
87
+ return unless all_module_names.any? { |name| name == mod_name }
87
88
 
88
- module_data = Contrast::Agent::ModuleData.new(mod, mod_name)
89
- patch_into_module(module_data)
90
- all_module_names.delete(mod_name) if status_type.get_status(mod).patched?
91
- rescue StandardError => e
92
- logger.error('Unable to patch module', e, module: mod_name)
89
+ module_data = Contrast::Agent::ModuleData.new(mod, mod_name)
90
+ patch_into_module(module_data)
91
+ all_module_names.delete(mod_name) if status_type.get_status(mod).patched?
92
+ rescue StandardError => e
93
+ logger.error('Unable to patch module', e, module: mod_name)
94
+ end
93
95
  end
94
96
 
95
97
  # We did it, team. We found a patcher(s) that applies to the given
@@ -186,7 +188,7 @@ module Contrast
186
188
  clazz = module_data.mod
187
189
 
188
190
  status.patching!
189
- patched = include_module(module_data)
191
+ patched = false
190
192
 
191
193
  counts = 0
192
194
  # Monkey patch any methods in this class that have matching nodes in the policy
@@ -219,17 +221,6 @@ module Contrast
219
221
  module_data.mod).patch_status)
220
222
  end
221
223
 
222
- # Includes the given module with the
223
- # Contrast::Extension::Assess::AssessExtension
224
- # @param module_data [Contrast::Agent::ModuleData] the module, and
225
- # its name, that's being patched into
226
- def include_module module_data
227
- return false unless Contrast::Agent::Assess::Policy::Policy.instance.tracked_classes.include?(module_data.name)
228
-
229
- module_data.mod.send(:include, Contrast::Extension::Assess::AssessExtension)
230
- true
231
- end
232
-
233
224
  # Get all of the instance methods on the given module, excluding
234
225
  # those from super classes. this list will always include the
235
226
  # initialize method
@@ -37,13 +37,12 @@ module Contrast
37
37
 
38
38
  access_component :analysis, :logging
39
39
 
40
- attr_reader :sources, :propagators, :triggers, :providers, :tracked_classes
40
+ attr_reader :sources, :propagators, :triggers, :providers
41
41
 
42
42
  SOURCES_KEY = 'sources'
43
43
  PROPAGATION_KEY = 'propagators'
44
44
  RULES_KEY = 'rules'
45
45
  TRIGGERS_KEY = 'triggers'
46
- TRACKED_CLASSES_KEY = 'tracked_classes'
47
46
 
48
47
  def self.policy_json
49
48
  File.join(policy_folder, 'policy.json').cs__freeze
@@ -54,7 +53,6 @@ module Contrast
54
53
  @propagators = []
55
54
  @triggers = []
56
55
  @providers = {}
57
- @tracked_classes = []
58
56
 
59
57
  json = Contrast::Utils::ResourceLoader.load(cs__class.policy_json)
60
58
  from_hash_string(json)
@@ -114,7 +112,6 @@ module Contrast
114
112
  def module_names
115
113
  @_module_names ||= begin
116
114
  m = Set.new
117
- tracked_classes.each { |tracked| m << tracked }
118
115
  sources.each { |source| m << source.class_name }
119
116
  propagators.each { |propagator| m << propagator.class_name }
120
117
  triggers.each { |trigger| m << trigger.class_name }
@@ -44,14 +44,9 @@ module Contrast
44
44
  context_response = Contrast::Api::Dtm::HttpResponse.new
45
45
  context_response.response_code = response_code.to_i
46
46
  headers&.each_pair do |key, value|
47
- k = Contrast::Utils::StringUtils.force_utf8(key)
48
- v = Contrast::Utils::StringUtils.force_utf8(value)
49
- context_response.response_headers[k] = v
47
+ append_pair(context_response.normalized_response_headers, key, value)
50
48
  end
51
- context_response.parsed_response_headers = true
52
-
53
49
  context_response.response_body_binary = Contrast::Utils::StringUtils.force_utf8(body)
54
- context_response.parsed_response_body = false
55
50
 
56
51
  doc_type = document_type
57
52
  context_response.document_type = doc_type if doc_type
@@ -97,6 +92,22 @@ module Contrast
97
92
 
98
93
  private
99
94
 
95
+ # From the dtm for normalized_response_headers:
96
+ # Key is UPPERCASE_UNDERSCORE
97
+ #
98
+ # Example: Content-Type: text/html; charset=utf-8
99
+ # "CONTENT_TYPE" => Content-Type,["text/html; charset=utf8"]
100
+ def append_pair map, key, value
101
+ return unless key && value
102
+ return if value.is_a?(Hash)
103
+
104
+ safe_key = Contrast::Utils::StringUtils.force_utf8(key)
105
+ hash_key = Contrast::Utils::StringUtils.normalized_key(safe_key)
106
+ map[hash_key] ||= Contrast::Api::Dtm::Pair.new
107
+ map[hash_key].key = safe_key
108
+ map[hash_key].values << Contrast::Utils::StringUtils.force_utf8(value)
109
+ end
110
+
100
111
  HTTP_PREFIX = /^[Hh][Tt][Tt][Pp][_-]/i.cs__freeze
101
112
 
102
113
  # Given some holder of the content of the response's body, extract that