contrast-agent 5.0.0 → 5.1.0

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