contrast-agent 3.14.0 → 3.15.0

Sign up to get free protection for your applications and to get access to all the features.
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