contrast-agent 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4976ba07d49361d91d44561f93031bfbe6b0aaff32921c62c8a6841e1e9581ad
4
- data.tar.gz: 510081d49ac928d43cd2f97ab310b6f6dcd122c1227e52d35c40f6ff5f423723
3
+ metadata.gz: c756fb0e7fe0433c7507c2cdb7082e635adabea8847c545333d65800225b1e3d
4
+ data.tar.gz: c71e1a8d020e3dfe98ea58d844924b0b0a9e1576b5f6899d186abc28e4efb152
5
5
  SHA512:
6
- metadata.gz: e1dff81a71f5162d2f217ef061ae060b77d825a85fc0666e667b845e62f5e209b8d409bb2fe554af774f904d3586461a35c264fedb5c63c4f1d9a1130c7ed0c0
7
- data.tar.gz: '013393f427c6539d164f7f647d2b8635ef4c7093e8dbd517631c03ae2ad3a5b7e8529bce82e80cea5f5c3ad630c537abd7114f98042f01b4977059ebb5ad1aaf'
6
+ metadata.gz: 82a0a252fa7696b2590f8be43fba09e03c6b13371a58a678b31e088e01a244311e0ac9d5b077de5d7970c16bc9a2144504d01a0e037c5e0f0b06d06e8530c57a
7
+ data.tar.gz: d9fd4708ab8238a22a2e3187e880a898635739b00f0d2f4f224904325f3b88b28f3470cf37733d5875e97018a02a5bfe10bd7f041219fd842b596e9697bfdbb6
data/.simplecov CHANGED
@@ -1,9 +1,8 @@
1
1
  # Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
2
2
  # frozen_string_literal: true
3
3
 
4
- SimpleCov.minimum_coverage line: 94.75
4
+ SimpleCov.minimum_coverage line: 95
5
5
  SimpleCov.start do
6
6
  add_filter '/spec/'
7
- add_filter '/lib/contrast/extension/assess/erb.rb'
8
7
  enable_coverage :branch
9
8
  end
@@ -61,39 +61,39 @@ VALUE
61
61
  contrast_assess_module_prepend(const int argc, const VALUE *argv,
62
62
  const VALUE self) {
63
63
 
64
- rb_prepend_module(self, argv[0]);
64
+ // rb_prepend_module(self, argv[0]);
65
65
 
66
66
  VALUE module_at;
67
67
  VALUE rb_incl_in_mod_ary = rb_funcall(self, rb_intern("included_in"), 0);
68
68
 
69
69
  if (RB_TYPE_P(rb_incl_in_mod_ary, T_ARRAY)) {
70
- int i = 0;
71
- int size = rb_funcall(rb_incl_in_mod_ary, rb_intern("length"), 0);
72
- for (i = 0; i < size; ++i) {
73
- module_at = rb_ary_entry(rb_incl_in_mod_ary, i);
74
- if (RB_TYPE_P(module_at, T_MODULE)) {
75
- rb_include_module(module_at, argv[0]);
76
- }
77
- }
70
+ int i = 0;
71
+ int size = rb_funcall(rb_incl_in_mod_ary, rb_intern("length"), 0);
72
+ for (i = 0; i < size; ++i) {
73
+ module_at = rb_ary_entry(rb_incl_in_mod_ary, i);
74
+ if (RB_TYPE_P(module_at, T_MODULE)) {
75
+ rb_include_module(module_at, argv[0]);
76
+ }
77
+ }
78
78
  }
79
79
  return self;
80
80
  }
81
81
 
82
82
  VALUE
83
83
  contrast_assess_module_included(const int argc, const VALUE *argv,
84
- const VALUE self) {
85
- VALUE frozen;
86
- if (RB_TYPE_P(self, T_MODULE)) {
87
- // check if frozen
88
- frozen = rb_funcall(self, rb_intern("cs__frozen?"), 0);
89
- if (frozen == Qfalse) {
90
- VALUE ary = rb_funcall(self, rb_intern("included_in"), 0);
91
- if (RB_TYPE_P(ary, T_ARRAY)) {
92
- rb_ary_push(ary, argv[0]);
93
- }
94
- }
95
- }
96
- return self;
84
+ const VALUE self) {
85
+ VALUE frozen;
86
+ if (RB_TYPE_P(self, T_MODULE)) {
87
+ // check if frozen
88
+ frozen = rb_funcall(self, rb_intern("cs__frozen?"), 0);
89
+ if (frozen == Qfalse) {
90
+ VALUE ary = rb_funcall(self, rb_intern("included_in"), 0);
91
+ if (RB_TYPE_P(ary, T_ARRAY)) {
92
+ rb_ary_push(ary, argv[0]);
93
+ }
94
+ }
95
+ }
96
+ return self;
97
97
  }
98
98
 
99
99
  void Init_cs__assess_module(void) {
@@ -115,13 +115,23 @@ void Init_cs__assess_module(void) {
115
115
 
116
116
  contrast_register_patch("Module", "module_eval",
117
117
  contrast_assess_module_module_eval);
118
- /*
119
- * We patch these for better ancestors handling, and only for older ruby versions.
120
- */
121
- if (rb_ver_below_three()) {
122
- contrast_register_patch("Module", "included",
123
- contrast_assess_module_included);
124
- contrast_register_patch("Module", "prepend",
125
- contrast_assess_module_prepend);
126
- }
118
+ /*
119
+ * We patch these for better ancestors handling, and only for older ruby
120
+ * versions.
121
+ */
122
+ // if (rb_ver_below_three()) {
123
+ /*
124
+ * `included` is a private method. We should make it public, patch it,
125
+ * and make our new method public
126
+ */
127
+ // contrast_register_patch("Module", "included",
128
+ // contrast_assess_module_included);
129
+ /*
130
+ * The `prepend` patch may actually be the issue, if we're not properly
131
+ * passing along the call/context. It could be that my attempt to fix
132
+ * `included` left this section unreachable.
133
+ */
134
+ // contrast_register_patch("Module", "prepend",
135
+ // contrast_assess_module_prepend);
136
+ // }
127
137
  }
@@ -11,6 +11,10 @@ module Contrast
11
11
  # are unaffected beyond any merging of overlapping tags.
12
12
  class MatchData < Contrast::Agent::Assess::Policy::Propagator::Base
13
13
  class << self
14
+ # This patch method is used to track through the MatchData#[] and
15
+ # MatchData#match methods, since the last was introduced after
16
+ # Ruby 3.1.0, but shares similar functionality, except it does not
17
+ # support ranges.
14
18
  def square_bracket_tagger propagation_node, preshift, ret, _block
15
19
  case ret
16
20
  when Array
@@ -18,10 +18,6 @@ module Contrast
18
18
 
19
19
  protected
20
20
 
21
- HTML_PROP = 'html'.cs__freeze
22
- START_PROP = 'start'.cs__freeze
23
- END_PROP = 'end'.cs__freeze
24
-
25
21
  # Rules discern which responses they can/should analyze.
26
22
  #
27
23
  # @param response [Contrast::Agent::Response] the response of the application
@@ -44,42 +40,6 @@ module Contrast
44
40
  nil
45
41
  end
46
42
 
47
- # Find the forms in this body, if any, so as to determine if they violate this rule.
48
- #
49
- # @param body [String]
50
- # @return [Array<Hash>] the forms of this body, as well as their start and end indexes.
51
- def forms body
52
- forms = []
53
- body_start = 0
54
- # The instance of "<form" in the body may be a form. Turn them into chunks to check.
55
- potential_forms = body.split(form_start)
56
- potential_forms.each do |potential_form|
57
- # We can consider this a form if the next character is one of whitespace of form tag closing
58
- # characters.
59
- next unless potential_form
60
- next unless form_openings.any? { |opening| potential_form.start_with?(opening) }
61
-
62
- body_start = body.index(form_start, body_start)
63
- next unless body_start
64
-
65
- form_stop = potential_form.index('>').to_i
66
- next unless form_stop
67
-
68
- body_close = body_start + 6 + form_stop
69
- forms << capture(body, body_start, body_close, form_stop)
70
- body_start = body_close
71
- end
72
- forms
73
- end
74
-
75
- def form_start
76
- /<form/i
77
- end
78
-
79
- def form_openings
80
- [' ', "\n", "\r", "\t", '>']
81
- end
82
-
83
43
  def off_values
84
44
  [/"off"/, /'off'/, /off /, /off>/]
85
45
  end
@@ -100,28 +60,6 @@ module Contrast
100
60
  end
101
61
  true
102
62
  end
103
-
104
- # Capture the information needed to build the properties of this finding by parsing out from the body
105
- #
106
- # @param body [String] the entire HTTP Response body
107
- # @param body_start [Integer] the start of the range to take from the body
108
- # @param body_close [Integer] the end of the range to take from the body
109
- # @param form_stop [Integer] the index of the end of the form from its start
110
- # @return [Hash]
111
- def capture body, body_start, body_close, form_stop
112
- form = {}
113
- # Capture the 50 characters in front of the form, or up to the start if the form starts before 50.
114
- capture_start = body_start < 50 ? 0 : body_start - 50
115
- # Start is where the '<form' is in the body
116
- # 6 accounts for the characters in the form and the opening char
117
- # potential_form.index('>') accounts for finding the rest of the form
118
- # 50 accounts for the context to capture beyond
119
- capture_close = body_close + 50
120
- form[HTML_PROP] = body[capture_start...capture_close]
121
- form[START_PROP] = body_start < 50 ? body_start : 50
122
- form[END_PROP] = form[START_PROP] + 6 + form_stop
123
- form
124
- end
125
63
  end
126
64
  end
127
65
  end
@@ -40,6 +40,11 @@ module Contrast
40
40
 
41
41
  protected
42
42
 
43
+ DATA = 'data'.cs__freeze
44
+ HTML_PROP = 'html'.cs__freeze
45
+ START_PROP = 'start'.cs__freeze
46
+ END_PROP = 'end'.cs__freeze
47
+
43
48
  # Rules discern which responses they can/should analyze.
44
49
  #
45
50
  # @param response [Contrast::Agent::Response] the response of the application
@@ -68,15 +73,24 @@ module Contrast
68
73
  finding.rule_id = rule_id
69
74
  context = Contrast::Agent::REQUEST_TRACKER.current
70
75
  finding.routes << context.route if context&.route
71
- evidence.each_pair do |key, value|
72
- finding.properties[key] = Contrast::Utils::StringUtils.force_utf8(value)
73
- end
76
+ build_evidence evidence, finding
74
77
  hash = Contrast::Utils::HashDigest.generate_config_hash(finding)
75
78
  finding.hash_code = Contrast::Utils::StringUtils.force_utf8(hash)
76
79
  finding.preflight = Contrast::Utils::PreflightUtil.create_preflight(finding)
77
80
  finding
78
81
  end
79
82
 
83
+ # This method allows to change the evidence we attach and the way we attach it
84
+ # Change it accordingly the rule you work on
85
+ #
86
+ # @param evidence [Hash] the properties required to build this finding.
87
+ # @param finding [Contrast::Api::Dtm::Finding] finding to attach the evidence to
88
+ def build_evidence evidence, finding
89
+ evidence.each_pair do |key, value|
90
+ finding.properties[key] = Contrast::Utils::StringUtils.force_utf8(value)
91
+ end
92
+ end
93
+
80
94
  # A rule is disabled if assess is off or it is turned off by TeamServer or by configuration.
81
95
  #
82
96
  # @return [Boolean]
@@ -109,6 +123,72 @@ module Contrast
109
123
  def body? response
110
124
  Contrast::Utils::StringUtils.present?(response.body)
111
125
  end
126
+
127
+ # Capture the information needed to build the properties of this finding by parsing out from the body
128
+ #
129
+ # @param body [String] the entire HTTP Response body
130
+ # @param body_start [Integer] the start of the range to take from the body
131
+ # @param body_close [Integer] the end of the range to take from the body
132
+ # @param tag_stop [Integer] the index of the end of the html tag from its start
133
+ # @return [Hash]
134
+ def capture body, body_start, body_close, tag_stop
135
+ tag = {}
136
+ # Capture the 50 characters in front of the form, or up to the start if the form starts before 50.
137
+ capture_start = body_start < 50 ? 0 : body_start - 50
138
+ # Start is where the '<form' is in the body
139
+ # 6 accounts for the characters in the form and the opening char
140
+ # potential_form.index('>') accounts for finding the rest of the form
141
+ # 50 accounts for the context to capture beyond
142
+ capture_close = body_close + 50
143
+ tag[HTML_PROP] = body[capture_start...capture_close]
144
+ tag[START_PROP] = body_start < 50 ? body_start : 50
145
+ tag[END_PROP] = tag[START_PROP] + 6 + tag_stop
146
+ tag
147
+ end
148
+
149
+ # Find the forms in this body, if any, so as to determine if they violate this rule.
150
+ #
151
+ # @param body [String]
152
+ # @return [Array<Hash>] the forms of this body, as well as their start and end indexes.
153
+ def forms body
154
+ forms = []
155
+ body_start = 0
156
+ # The instance of "<form" in the body may be a form. Turn them into chunks to check.
157
+ potential_forms = body.split(form_start)
158
+ potential_forms.each do |potential_form|
159
+ # We can consider this a form if the next character is one of whitespace of form tag closing
160
+ # characters.
161
+ next unless potential_form
162
+ next unless form_openings.any? { |opening| potential_form.start_with?(opening) }
163
+
164
+ body_start = body.index(form_start, body_start)
165
+ next unless body_start
166
+
167
+ form_stop = potential_form.index('>').to_i
168
+ next unless form_stop
169
+
170
+ body_close = body_start + 6 + form_stop
171
+ forms << capture(body, body_start, body_close, form_stop)
172
+ body_start = body_close
173
+ end
174
+ forms
175
+ end
176
+
177
+ def form_start
178
+ /<form/i
179
+ end
180
+
181
+ def form_openings
182
+ [' ', "\n", "\r", "\t", '>']
183
+ end
184
+
185
+ # Determine if a response has headers.
186
+ #
187
+ # @param response [Contrast::Agent::Response] the response of the application
188
+ # @return [Boolean]
189
+ def headers? response
190
+ response.headers.cs__is_a?(Hash)
191
+ end
112
192
  end
113
193
  end
114
194
  end
@@ -0,0 +1,184 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+ require 'json'
7
+
8
+ module Contrast
9
+ module Agent
10
+ module Assess
11
+ module Rule
12
+ module Response
13
+ # These rules check the content of the HTTP Response to determine if the body or the headers include and/or
14
+ # set incorrectly the cache-control header
15
+ class Cachecontrol < BaseRule
16
+ def rule_id
17
+ 'cache-controls-missing'
18
+ end
19
+
20
+ protected
21
+
22
+ HEADER_KEY = 'Cache-Control'.cs__freeze
23
+ ACCEPTED_VALUES = %w[no-store no-cache].cs__freeze
24
+
25
+ # Rules discern which responses they can/should analyze.
26
+ #
27
+ # @param response [Contrast::Agent::Response] the response of the application
28
+ def analyze_response? response
29
+ super && body?(response)
30
+ end
31
+
32
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
33
+ #
34
+ # @param response [Contrast::Agent::Response] the response of the application
35
+ # @return [Hash, nil] the evidence required to prove the violation of the rule
36
+ def violated? response
37
+ # This rule is violated if the header is not there
38
+ # or if it's there, but the value is not 'no-store' or 'no-cache'
39
+ headers = response.headers
40
+ header_key_sym = HEADER_KEY.to_sym
41
+ cache_control = headers[HEADER_KEY] || headers[header_key_sym]
42
+ if cache_control && !valid_header?(cache_control)
43
+ return to_cachecontrol_rule('header', 'cache-control', cache_control)
44
+ end
45
+
46
+ body = response.body
47
+ # check if the meta tag is include it
48
+ tags = meta_tags(body)
49
+
50
+ tags.each do |tag|
51
+ return to_cachecontrol_rule('meta', 'pragma', tag[HTML_PROP]) if meta_cache_tag? tag[HTML_PROP]
52
+ end
53
+ # we should return if header not presented and no tags are detected
54
+ return {} if !(headers.key?(HEADER_KEY) || headers.key?(header_key_sym)) && tags.empty?
55
+
56
+ nil
57
+ end
58
+
59
+ def valid_header? header
60
+ ACCEPTED_VALUES.any? { |val| header.include?(val) || header == val }
61
+ end
62
+
63
+ # Find the tags in this body, if any, so as to determine if they violate this rule.
64
+ #
65
+ # @param body [String,nil]
66
+ # @return [Array<Hash>] the tags of this body, as well as their start and end indexes.
67
+ def meta_tags body
68
+ tags = []
69
+ body_start = 0
70
+
71
+ # meta tags are stored in the <head></head> section
72
+ head_section = body&.split(head_tag)
73
+ return [] unless head_section
74
+
75
+ potential_tags = head_section.map { |el| el.split(meta_start) }
76
+ potential_tags.flatten.each do |potential_tag|
77
+ next unless potential_tag
78
+ next unless tag_openings.any? { |opening| potential_tag.starts_with?(opening) }
79
+
80
+ body_start = body.index(meta_start, body_start)
81
+ next unless body_start
82
+
83
+ tag_stop = potential_tag.index('>').to_i
84
+ next unless tag_stop
85
+
86
+ body_close = body_start + 6 + tag_stop
87
+ tags << capture(body, body_start, body_close, tag_stop)
88
+ body_start = body_close
89
+ end
90
+ tags
91
+ end
92
+
93
+ def meta_start
94
+ /<meta/i
95
+ end
96
+
97
+ def head_tag
98
+ /<head>/i
99
+ end
100
+
101
+ def tag_openings
102
+ [' ', "\n", "\r", "\t"]
103
+ end
104
+
105
+ def accepted_http_values
106
+ [/'cache-control'/i, /"cache-control"/i]
107
+ end
108
+
109
+ def accepted_values
110
+ [/'no-cache'/i, /"no-cache"/i, /"no-store"/i, /'no-store'/i]
111
+ end
112
+
113
+ # Determine if the given metatag does not have a valid cache-control tag.
114
+ # Meta tags has the option to set http-equiv and content to set the http response header
115
+ # to define for the document
116
+ #
117
+ # @param tag [String] the meta tag
118
+ # @return [Boolean, nil]
119
+ def meta_cache_tag? tag
120
+ # Here we should determine the index of the needed keys
121
+ # http-equiv and content
122
+ http_equiv_idx = tag =~ /http-equiv=/i
123
+ return false unless http_equiv_idx
124
+
125
+ content_idx = tag =~ /content=/i
126
+ return false unless content_idx
127
+
128
+ # determine the value of the http-equiv if it's cache-control
129
+ http_equiv_idx += 11
130
+ is_valid = accepted_http_values.any? { |el| (tag =~ el) == http_equiv_idx }
131
+ return false unless is_valid
132
+
133
+ content_idx += 8
134
+ return false if accepted_values.any? { |value| (tag =~ value) == content_idx }
135
+
136
+ true
137
+ end
138
+
139
+ # This method allows to change the evidence we attach and the way we attach it
140
+ # Change it accordingly the rule you work on
141
+ #
142
+ # @param evidence [Hash] the properties required to build this finding.
143
+ # @param finding [Contrast::Api::Dtm::Finding] finding to attach the evidence to
144
+ def build_evidence evidence, finding
145
+ evidence.each_pair do |key, value|
146
+ finding.properties[key] = Contrast::Utils::StringUtils.protobuf_format(value)
147
+ end
148
+ end
149
+
150
+ # This method accepts the violation and transforms it to the proper hash
151
+ # before return in as violation
152
+ #
153
+ # @param type [String] String of Header or META of the type
154
+ # @param name [String] String of either cache-control or pragma
155
+ # @param value [String] String of the violated value
156
+ def to_cachecontrol_rule type, name, value
157
+ { data: { type: type, name: name, value: value }.to_s }
158
+ end
159
+
160
+ # Capture the information needed to build the properties of this finding by parsing out from the body
161
+ #
162
+ # @param body [String] the entire HTTP Response body
163
+ # @param body_start [Integer] the start of the range to take from the body
164
+ # @param body_close [Integer] the end of the range to take from the body
165
+ # @param tag_stop [Integer] the index of the end of the html tag from its start
166
+ # @return [Hash]
167
+ def capture body, body_start, body_close, tag_stop
168
+ # In this situation we don't need to capture before and after the meta tag, as this may produce an error
169
+ # So if we capture 30-50 chars before and after the tag, we may capture part of the tag, we want to
170
+ # inspect and eventually this wil return wrong string. Because of that - we split the <head> and take
171
+ # each meta tag and examine it
172
+ tag = {}
173
+ # we dont need to capture here before or after the meta tag
174
+ tag[HTML_PROP] = body[body_start...body_close]
175
+ tag[START_PROP] = body_start
176
+ tag[END_PROP] = tag[START_PROP] + 6 + tag_stop
177
+ tag
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,66 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+
7
+ module Contrast
8
+ module Agent
9
+ module Assess
10
+ module Rule
11
+ module Response
12
+ # These rules check the content of the HTTP Response to determine if the headers contains the required header
13
+ class Clickjacking < BaseRule
14
+ def rule_id
15
+ 'clickjacking-control-missing'
16
+ end
17
+
18
+ protected
19
+
20
+ HEADER_KEY = 'X-Frame-Options'.cs__freeze
21
+ HEADER_KEY_SYM = HEADER_KEY.to_sym
22
+ ACCEPTED_VALUES = [/^deny/i, /^sameorigin/i].cs__freeze
23
+
24
+ # Rules discern which responses they can/should analyze.
25
+ #
26
+ # @param response [Contrast::Agent::Response] the response of the application
27
+ def analyze_response? response
28
+ super && headers?(response)
29
+ end
30
+
31
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
32
+ #
33
+ # @param response [Contrast::Agent::Response] the response of the application
34
+ # @return [Hash, nil] the evidence required to prove the violation of the rule
35
+ def violated? response
36
+ headers = response.headers
37
+ cache_control = headers[HEADER_KEY] || headers[HEADER_KEY_SYM]
38
+ return unsafe_response unless cache_control
39
+ return unsafe_response(cache_control) unless valid_header?(cache_control)
40
+
41
+ nil
42
+ end
43
+
44
+ def valid_header? header
45
+ ACCEPTED_VALUES.any? { |val| header =~ val }
46
+ end
47
+
48
+ def unsafe_response value = ''
49
+ { data: value }
50
+ end
51
+
52
+ # Change it accordingly the rule you work on
53
+ #
54
+ # @param evidence [Hash] the properties required to build this finding.
55
+ # @param finding [Contrast::Api::Dtm::Finding] finding to attach the evidence to
56
+ def build_evidence evidence, finding
57
+ evidence.each_pair do |key, value|
58
+ finding.properties[key] = Contrast::Utils::StringUtils.protobuf_format(value)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,101 @@
1
+ # Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
2
+ # Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
3
+ # frozen_string_literal: true
4
+
5
+ require 'contrast/agent/assess/rule/response/base_rule'
6
+ require 'contrast/utils/string_utils'
7
+
8
+ module Contrast
9
+ module Agent
10
+ module Assess
11
+ module Rule
12
+ module Response
13
+ # These rules check that the HTTP Headers include CSP header types
14
+ class CspHeaderInsecure < BaseRule
15
+ def rule_id
16
+ 'csp-header-insecure'
17
+ end
18
+
19
+ protected
20
+
21
+ CSP_HEADERS = %w[CONTENT_SECURITY_POLICY X_CONTENT_SECURITY_POLICY X_WEBKIT_CSP].cs__freeze
22
+ SETTINGS = %w[
23
+ base-uri child-src default-src connect-src frame-src media-src object-src script-src
24
+ style-src form-action frame-ancestors plugin-types reflected-xss referer
25
+ ].cs__freeze
26
+ UNSAFE_VALUE_REGEXP = /^unsafe-(?:inline|eval)$/.cs__freeze
27
+ ASTERISK_REGEXP = /[*]/.cs__freeze
28
+
29
+ # Rules discern which responses they can/should analyze.
30
+ #
31
+ # @param response [Contrast::Agent::Response] the response of the application
32
+ def analyze_response? response
33
+ super && headers?(response)
34
+ end
35
+
36
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
37
+ #
38
+ # @param response [Contrast::Agent::Response] the response of the application
39
+ # @return [Contrast::Utils::ObjectShare::EMPTY_STRING, nil] if CSP Header is not found
40
+ def violated? response
41
+ settings = {}
42
+ csp_hash = get_csp_header_values(response.headers)
43
+ return if csp_hash.nil?
44
+
45
+ SETTINGS.each do |setting_attr|
46
+ value = csp_hash[setting_attr]
47
+ key = convert_key(setting_attr)
48
+ settings["#{ key }Secure"] = !value.nil? && value_secure?(value) && value_safe?(value)
49
+ settings["#{ key }Value"] = value.nil? ? Contrast::Utils::ObjectShare::EMPTY_STRING : value
50
+ end
51
+ { DATA => settings }
52
+ end
53
+
54
+ # Get the CSP values from and transforms them to key value hash
55
+ #
56
+ # ex default-src 'self' *.test.com; img-src * becomes:
57
+ # { default-src: "'self' *.test.com", img-src: "*" }
58
+ #
59
+ # @param headers [Hash] the response of the application
60
+ # @return [Array, nil] array of CSP header values
61
+ def get_csp_header_values headers
62
+ csp_hash = {}
63
+ CSP_HEADERS.each do |header_key|
64
+ next unless headers[header_key]&.length&.positive?
65
+
66
+ values = headers[header_key].split(Contrast::Utils::ObjectShare::SEMICOLON)
67
+ values.each do |value|
68
+ normalized = value.downcase.strip
69
+ kv = normalized.split(Contrast::Utils::ObjectShare::SPACE, 2)
70
+ csp_hash[kv[0]] = kv[1]
71
+ end
72
+ end
73
+ csp_hash
74
+ end
75
+
76
+ def value_secure? value
77
+ ASTERISK_REGEXP.match(value).nil?
78
+ end
79
+
80
+ def value_safe? value
81
+ UNSAFE_VALUE_REGEXP.match(value).nil?
82
+ end
83
+
84
+ # Converts the CSP key to camelcase to be used as key for evidence object
85
+ #
86
+ # base-uri -> baseUri
87
+ #
88
+ # @param key [String] key as found in header
89
+ # @return [String] camelcase key
90
+ def convert_key key
91
+ return key unless key.include?('-')
92
+
93
+ str = key.split('-')
94
+ "#{ str[0] }#{ str[1].capitalize }"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,46 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+
7
+ module Contrast
8
+ module Agent
9
+ module Assess
10
+ module Rule
11
+ module Response
12
+ # These rules check that the HTTP Headers include CSP header types
13
+ class CspHeaderMissing < BaseRule
14
+ def rule_id
15
+ 'csp-header-missing'
16
+ end
17
+
18
+ protected
19
+
20
+ CSP_HEADERS = %w[CONTENT_SECURITY_POLICY X_CONTENT_SECURITY_POLICY X_WEBKIT_CSP].cs__freeze
21
+
22
+ DATA = 'data'.cs__freeze
23
+
24
+ # Rules discern which responses they can/should analyze.
25
+ #
26
+ # @param response [Contrast::Agent::Response] the response of the application
27
+ def analyze_response? response
28
+ super && headers?(response)
29
+ end
30
+
31
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
32
+ #
33
+ # @param response [Contrast::Agent::Response] the response of the application
34
+ # @return [Contrast::Utils::ObjectShare::EMPTY_STRING, nil] if CSP Header is not found
35
+ def violated? response
36
+ response_headers = response.headers
37
+ return if CSP_HEADERS.any? { |header_key| response_headers[header_key]&.length&.positive? }
38
+
39
+ { DATA => Contrast::Utils::ObjectShare::EMPTY_STRING }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
2
+ # frozen_string_literal: true
3
+
4
+ module Contrast
5
+ module Agent
6
+ module Assess
7
+ module Rule
8
+ module Response
9
+ # This rule checks if the HTTP Headers include HSTS header and ensures that the max-age value
10
+ # is set to a value greater than 0.
11
+ class HSTSHeader < BaseRule
12
+ def rule_id
13
+ 'hsts-header-missing'
14
+ end
15
+
16
+ protected
17
+
18
+ HEADER_KEY = 'Strict-Transport-Security'
19
+ HEADER_KEY_SYM = HEADER_KEY.to_sym
20
+ MAX_AGE = 'max-age'
21
+ MAX_AGE_SYM = MAX_AGE.to_sym
22
+ # Rules discern which responses they can/should analyze.
23
+ #
24
+ # @param response [Contrast::Agent::Response] the response of the application
25
+ def analyze_response? response
26
+ super && response.headers.cs__is_a?(Hash)
27
+ end
28
+
29
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
30
+ #
31
+ # @param response [Contrast::Agent::Response] the response of the application
32
+ # @return [Hash<data: Contrast::Utils::ObjectShare::EMPTY_STRING, String>, nil] return string
33
+ # representation of the max_age
34
+ def violated? response
35
+ headers = response.headers
36
+ target = headers[HEADER_KEY] || headers[HEADER_KEY_SYM]
37
+ # this rule is safe by default if no target => no evidence
38
+ # if the property max_age is not positive or absent then the rule is violated
39
+ return unless target
40
+
41
+ max_age = target[MAX_AGE] || target[MAX_AGE_SYM]
42
+ return if max_age.to_i.positive?
43
+
44
+ evidence max_age
45
+ end
46
+
47
+ # returns evidence that the max_age is negative or absent
48
+ #
49
+ # @param max_age [String] String representation of the max-age value to which the header is set
50
+ # @return [Hash<data: Contrast::Utils::ObjectShare::EMPTY_STRING, String>] return string representation of
51
+ # the max_age
52
+ def evidence max_age
53
+ { data: max_age.to_s }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+
7
+ module Contrast
8
+ module Agent
9
+ module Assess
10
+ module Rule
11
+ module Response
12
+ # These rules check the content of the HTTP Response to determine if the body contains a form which
13
+ # incorrectly sets the action attribute.
14
+ class ParametersPollution < BaseRule
15
+ def rule_id
16
+ 'parameter-pollution'
17
+ end
18
+
19
+ protected
20
+
21
+ # Rules discern which responses they can/should analyze.
22
+ #
23
+ # @param response [Contrast::Agent::Response] the response of the application
24
+ def analyze_response? response
25
+ super && body?(response)
26
+ end
27
+
28
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
29
+ #
30
+ # @param response [Contrast::Agent::Response] the response of the application
31
+ # @return [Hash, nil] the evidence required to prove the violation of the rule
32
+ def violated? response
33
+ body = response.body
34
+ forms = forms(body)
35
+ forms.each do |form|
36
+ # Because TeamServer will reject any subsequent form on the same page due to deduplication, we can
37
+ # skip out on the first violation.
38
+ return form if action?(form[HTML_PROP])
39
+ end
40
+ nil
41
+ end
42
+
43
+ def accepted_values
44
+ [/action="[^"]/i, /action='[^']/i, /action=[^\\'"]/i, /action=[^\s<>'"]/i].cs__freeze
45
+ end
46
+
47
+ def action? form
48
+ return true unless /action=/i.match?(form)
49
+
50
+ accepted_values.each do |off_value|
51
+ return false if form.match?(off_value)
52
+ end
53
+ true
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+
7
+ module Contrast
8
+ module Agent
9
+ module Assess
10
+ module Rule
11
+ module Response
12
+ # These rules check the content of the HTTP Response to determine if the response contains the needed header
13
+ class XContentType < BaseRule
14
+ def rule_id
15
+ 'xcontenttype-header-missing'
16
+ end
17
+
18
+ protected
19
+
20
+ HEADER_KEY = 'X-Content-Type-Options'.cs__freeze
21
+ HEADER_KEY_SYM = HEADER_KEY.to_sym
22
+ ACCEPTED_VALUE = /^nosniff/i.cs__freeze
23
+
24
+ # Rules discern which responses they can/should analyze.
25
+ #
26
+ # @param response [Contrast::Agent::Response] the response of the application
27
+ def analyze_response? response
28
+ super && headers?(response)
29
+ end
30
+
31
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
32
+ #
33
+ # @param response [Contrast::Agent::Response] the response of the application
34
+ # @return [Hash, nil] the evidence required to prove the violation of the rule
35
+ def violated? response
36
+ headers = response.headers
37
+ x_content_type = headers[HEADER_KEY] || headers[HEADER_KEY_SYM]
38
+ return unsafe_response unless x_content_type
39
+ return unsafe_response x_content_type unless ACCEPTED_VALUE.match?(x_content_type)
40
+
41
+ nil
42
+ end
43
+
44
+ def unsafe_response value = ''
45
+ { data: value }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,53 @@
1
+ # Copyright (c) 2022 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/rule/response/base_rule'
5
+ require 'contrast/utils/string_utils'
6
+
7
+ module Contrast
8
+ module Agent
9
+ module Assess
10
+ module Rule
11
+ module Response
12
+ # These rules check the content of the HTTP Response to determine if the response contains the needed header
13
+ class XXssProtection < BaseRule
14
+ def rule_id
15
+ 'xxssprotection-header-disabled'
16
+ end
17
+
18
+ protected
19
+
20
+ HEADER_KEY = 'X-XSS-Protection'.cs__freeze
21
+ HEADER_KEY_SYM = HEADER_KEY.to_sym
22
+ ACCEPTED_VALUE = /^1/.cs__freeze
23
+
24
+ # Rules discern which responses they can/should analyze.
25
+ #
26
+ # @param response [Contrast::Agent::Response] the response of the application
27
+ def analyze_response? response
28
+ super && headers?(response)
29
+ end
30
+
31
+ # Determine if the Response violates the Rule or not. If it does, return the evidence that proves it so.
32
+ #
33
+ # @param response [Contrast::Agent::Response] the response of the application
34
+ # @return [Hash, nil] the evidence required to prove the violation of the rule
35
+ def violated? response
36
+ headers = response.headers
37
+ x_xss_protection = headers[HEADER_KEY] || headers[HEADER_KEY_SYM]
38
+ # header is safe by default so only need to return finding on failed value match
39
+ return unless x_xss_protection
40
+ return unsafe_response x_xss_protection unless ACCEPTED_VALUE.match?(x_xss_protection)
41
+
42
+ nil
43
+ end
44
+
45
+ def unsafe_response value = ''
46
+ { data: value }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -28,7 +28,17 @@ module Contrast
28
28
  attr_reader :rule_id, :routes, :events, :properties, :request
29
29
  attr_accessor :hash_code
30
30
 
31
- PROPERTIES_RULES = ['autocomplete-missing'].cs__freeze
31
+ PROPERTIES_RULES = %w[
32
+ autocomplete-missing
33
+ cache-controls-missing
34
+ clickjacking-control-missing
35
+ xcontenttype-header-missing
36
+ hsts-header-missing
37
+ xxssprotection-header-disabled
38
+ csp-header-missing
39
+ csp-header-insecure
40
+ parameter-pollution
41
+ ].cs__freeze
32
42
 
33
43
  class << self
34
44
  # @param finding_dtm [Contrast::Api::Dtm::Finding]
@@ -2,6 +2,11 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'contrast/agent/assess/rule/response/autocomplete_rule'
5
+ require 'contrast/agent/assess/rule/response/hsts_header_rule'
6
+ require 'contrast/agent/assess/rule/response/cachecontrol_rule'
7
+ require 'contrast/agent/assess/rule/response/clickjacking_rule'
8
+ require 'contrast/agent/assess/rule/response/x_content_type_rule'
9
+ require 'contrast/agent/assess/rule/response/parameters_pollution_rule'
5
10
 
6
11
  module Contrast
7
12
  module Agent
@@ -113,6 +118,14 @@ module Contrast
113
118
  return unless Contrast::Agent::Reporter.enabled?
114
119
 
115
120
  Contrast::Agent::Assess::Rule::Response::Autocomplete.new.analyze(@response)
121
+ Contrast::Agent::Assess::Rule::Response::HSTSHeader.new.analyze(@response)
122
+ Contrast::Agent::Assess::Rule::Response::Cachecontrol.new.analyze(@response)
123
+ Contrast::Agent::Assess::Rule::Response::XXssProtection.new.analyze(@response)
124
+ Contrast::Agent::Assess::Rule::Response::CspHeaderMissing.new.analyze(@response)
125
+ Contrast::Agent::Assess::Rule::Response::CspHeaderInsecure.new.analyze(@response)
126
+ Contrast::Agent::Assess::Rule::Response::Clickjacking.new.analyze(@response)
127
+ Contrast::Agent::Assess::Rule::Response::XContentType.new.analyze(@response)
128
+ Contrast::Agent::Assess::Rule::Response::ParametersPollution.new.analyze(@response)
116
129
  rescue StandardError => e
117
130
  logger.error('Unable to extract information after request', e)
118
131
  end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Contrast
5
5
  module Agent
6
- VERSION = '5.0.0'
6
+ VERSION = '5.1.0'
7
7
  end
8
8
  end
@@ -4,6 +4,13 @@
4
4
  # This module is used to track propagation through ERB template rendering
5
5
  module ERBPropagator
6
6
  class << self
7
+ # After ERB#result method is called we need to take the tags to the target and keep the
8
+ # propagation.
9
+ #
10
+ # @param patcher [Contrast::Agent::Assess::Policy::PolicyNode] the node that governs this event
11
+ # @param preshift [Contrast::Agent::Assess::Preshift] Saved state before the propagation
12
+ # @param ret [the Return of the invoked method]
13
+ # @param _block [&block, nil] block passed
7
14
  def result_tagger patcher, preshift, ret, _block
8
15
  return unless preshift.args.length >= 1
9
16
  return unless (properties = Contrast::Agent::Assess::Tracker.properties!(ret))
@@ -26,6 +33,16 @@ module ERBPropagator
26
33
 
27
34
  private
28
35
 
36
+ # Checks if binded variables set includes the object we track and proceed to update tags in the returned value
37
+ #
38
+ # @param binding_variable_set [Array<Symbol>] list of local variables used in the binding of params
39
+ # @param used_binding [Binding] the binding in of the current event, saved as preshift argument
40
+ # @param erb_pre_result [String] the source saved in the preshift
41
+ # @param properties [Contrast::Agent::Assess::Properties] properties of the target if none create new
42
+ # @param parent_events [Array<Contrast::Agent::Assess::ContrastEvent>] parents event extracted from the source
43
+ # properties
44
+ # @param ret [String] the Return of the invoked method
45
+ # @return [Array<Symbol>]
29
46
  def binding_variable_set binding_variable_set, used_binding, erb_pre_result, properties, parent_events, ret
30
47
  binding_variable_set.each do |bound_var_symbol|
31
48
  bound_variable_value = used_binding.local_variable_get(bound_var_symbol)
@@ -634,6 +634,16 @@
634
634
  "source":"O",
635
635
  "target":"R",
636
636
  "action":"REMOVE"
637
+ }, {
638
+ "class_name":"MatchData",
639
+ "instance_method": true,
640
+ "method_visibility": "public",
641
+ "method_name":"match",
642
+ "source":"O",
643
+ "target":"R",
644
+ "action":"CUSTOM",
645
+ "patch_class": "Contrast::Agent::Assess::Policy::Propagator::MatchData",
646
+ "patch_method": "square_bracket_tagger"
637
647
  }, {
638
648
  "class_name":"MatchData",
639
649
  "instance_method": true,
data/ruby-agent.gemspec CHANGED
@@ -59,7 +59,7 @@ def self.add_linters spec
59
59
  spec.add_development_dependency 'debride', '1.8.2'
60
60
  spec.add_development_dependency 'fasterer', '0.9.0'
61
61
  spec.add_development_dependency 'flay', '2.12.1'
62
- spec.add_development_dependency 'steep', '0.44.1'
62
+ spec.add_development_dependency 'steep', '0.47.0'
63
63
  add_rubocop(spec)
64
64
  end
65
65
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: contrast-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - galen.palmer@contrastsecurity.com
@@ -13,7 +13,7 @@ authors:
13
13
  autorequire:
14
14
  bindir: exe
15
15
  cert_chain: []
16
- date: 2022-01-06 00:00:00.000000000 Z
16
+ date: 2022-01-24 00:00:00.000000000 Z
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
19
19
  name: bundler
@@ -147,14 +147,14 @@ dependencies:
147
147
  requirements:
148
148
  - - '='
149
149
  - !ruby/object:Gem::Version
150
- version: 0.44.1
150
+ version: 0.47.0
151
151
  type: :development
152
152
  prerelease: false
153
153
  version_requirements: !ruby/object:Gem::Requirement
154
154
  requirements:
155
155
  - - '='
156
156
  - !ruby/object:Gem::Version
157
- version: 0.44.1
157
+ version: 0.47.0
158
158
  - !ruby/object:Gem::Dependency
159
159
  name: rubocop
160
160
  requirement: !ruby/object:Gem::Requirement
@@ -617,18 +617,18 @@ executables:
617
617
  - contrast_service
618
618
  extensions:
619
619
  - ext/cs__common/extconf.rb
620
+ - ext/cs__contrast_patch/extconf.rb
621
+ - ext/cs__assess_yield_track/extconf.rb
622
+ - ext/cs__assess_hash/extconf.rb
623
+ - ext/cs__assess_marshal_module/extconf.rb
620
624
  - ext/cs__assess_fiber_track/extconf.rb
625
+ - ext/cs__assess_string_interpolation26/extconf.rb
626
+ - ext/cs__assess_basic_object/extconf.rb
621
627
  - ext/cs__assess_array/extconf.rb
622
- - ext/cs__assess_string/extconf.rb
623
628
  - ext/cs__assess_regexp/extconf.rb
624
- - ext/cs__assess_yield_track/extconf.rb
625
- - ext/cs__contrast_patch/extconf.rb
626
629
  - ext/cs__assess_kernel/extconf.rb
627
- - ext/cs__assess_hash/extconf.rb
628
- - ext/cs__assess_string_interpolation26/extconf.rb
629
630
  - ext/cs__os_information/extconf.rb
630
- - ext/cs__assess_marshal_module/extconf.rb
631
- - ext/cs__assess_basic_object/extconf.rb
631
+ - ext/cs__assess_string/extconf.rb
632
632
  - ext/cs__assess_module/extconf.rb
633
633
  extra_rdoc_files: []
634
634
  files:
@@ -881,6 +881,14 @@ files:
881
881
  - lib/contrast/agent/assess/rule/provider/hardcoded_value_rule.rb
882
882
  - lib/contrast/agent/assess/rule/response/autocomplete_rule.rb
883
883
  - lib/contrast/agent/assess/rule/response/base_rule.rb
884
+ - lib/contrast/agent/assess/rule/response/cachecontrol_rule.rb
885
+ - lib/contrast/agent/assess/rule/response/clickjacking_rule.rb
886
+ - lib/contrast/agent/assess/rule/response/csp_header_insecure_rule.rb
887
+ - lib/contrast/agent/assess/rule/response/csp_header_missing_rule.rb
888
+ - lib/contrast/agent/assess/rule/response/hsts_header_rule.rb
889
+ - lib/contrast/agent/assess/rule/response/parameters_pollution_rule.rb
890
+ - lib/contrast/agent/assess/rule/response/x_content_type_rule.rb
891
+ - lib/contrast/agent/assess/rule/response/x_xss_protection_rule.rb
884
892
  - lib/contrast/agent/assess/tag.rb
885
893
  - lib/contrast/agent/assess/tracker.rb
886
894
  - lib/contrast/agent/at_exit_hook.rb