betterlint 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,15 @@
1
+ require 'rubocop'
2
+ require 'rubocop/cop/betterment/utils/parser'
3
+ require 'rubocop/cop/betterment/utils/method_return_table'
4
+ require 'rubocop/cop/betterment/authorization_in_controller'
5
+ require 'rubocop/cop/betterment/dynamic_params'
6
+ require 'rubocop/cop/betterment/unscoped_find'
7
+ require 'rubocop/cop/betterment/unsafe_job'
8
+ require 'rubocop/cop/betterment/timeout'
9
+ require 'rubocop/cop/betterment/memoization_with_arguments'
10
+ require 'rubocop/cop/betterment/site_prism_loaded'
11
+ require 'rubocop/cop/betterment/spec_helper_required_outside_spec_dir'
12
+ require 'rubocop/cop/betterment/implicit_redirect_type'
13
+ require 'rubocop/cop/betterment/active_job_performable'
14
+ require 'rubocop/cop/betterment/allowlist_blocklist'
15
+ require 'rubocop/cop/betterment/server_error_assertion'
@@ -0,0 +1,40 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Betterment
4
+ class ActiveJobPerformable < Cop
5
+ MSG = <<-DOC.freeze
6
+ Classes that are "performable" should be ActiveJobs
7
+
8
+ class MyJob < ApplicationJob
9
+ def perform
10
+ end
11
+ end
12
+
13
+ You can learn more about ActiveJob here:
14
+ https://guides.rubyonrails.org/active_job_basics.html
15
+ DOC
16
+
17
+ def_node_matcher :subclasses_application_job?, <<-PATTERN
18
+ (class (const ...) (const _ :ApplicationJob) ...)
19
+ PATTERN
20
+
21
+ def_node_matcher :is_perform_method?, <<-PATTERN
22
+ (def :perform ...)
23
+ PATTERN
24
+
25
+ def on_class(node)
26
+ return unless has_perform_method?(node)
27
+ return if subclasses_application_job?(node)
28
+
29
+ add_offense(node.children.first)
30
+ end
31
+
32
+ private
33
+
34
+ def has_perform_method?(node)
35
+ node.descendants.find(&method(:is_perform_method?))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # rubocop:disable Betterment/AllowlistBlocklist
2
+ module RuboCop
3
+ module Cop
4
+ module Betterment
5
+ class AllowlistBlocklist < Cop
6
+ MSG = <<-DOC.freeze
7
+ Avoid usages of whitelist & blacklist, in favor of more inclusive and descriptive language.
8
+ For consistency, favor 'allowlist' and 'blocklist' where possible, but other terms (such as
9
+ denylist, ignorelist, warnlist, safelist, etc) may be appropriate, depending on the use case.
10
+ DOC
11
+
12
+ def on_class(node)
13
+ evaluate_node(node)
14
+ end
15
+
16
+ private
17
+
18
+ def evaluate_node(node)
19
+ return unless should_use_allowlist?(node) || should_use_blocklist?(node)
20
+
21
+ add_offense(node)
22
+ end
23
+
24
+ def should_use_allowlist?(node)
25
+ node.to_s.downcase.include?('whitelist')
26
+ end
27
+
28
+ def should_use_blocklist?(node)
29
+ node.to_s.downcase.include?('blacklist')
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ # rubocop:enable Betterment/AllowlistBlocklist
@@ -0,0 +1,148 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Betterment
4
+ class AuthorizationInController < Cop
5
+ attr_accessor :unsafe_parameters, :unsafe_regex
6
+
7
+ # MSG_UNSAFE_CREATE = 'Model created/updated using unsafe parameters'.freeze
8
+ MSG_UNSAFE_CREATE = <<~MSG.freeze
9
+ Model created/updated using unsafe parameters.
10
+ Please query for the associated record in a way that enforces authorization (e.g. "trust-root chaining"),
11
+ and then pass the resulting object into your model instead of the unsafe parameter.
12
+
13
+ INSTEAD OF THIS:
14
+ post_parameters = params.permit(:album_id, :caption)
15
+ Post.new(post_parameters)
16
+
17
+ DO THIS:
18
+ album = current_user.albums.find(params[:album_id])
19
+ post_parameters = params.permit(:caption).merge(album: album)
20
+ Post.new(post_parameters)
21
+
22
+ See here for more information on this error:
23
+ https://github.com/Betterment/betterlint/blob/main/README.md#bettermentauthorizationincontroller
24
+ MSG
25
+
26
+ def_node_matcher :model_new?, <<-PATTERN
27
+ (send (const ... _) {:new :build :create :create! :find_or_create_by :find_or_create_by! :find_or_initialize_by :find_or_initialize_by!} ...)
28
+ PATTERN
29
+
30
+ def_node_matcher :model_update?, <<-PATTERN
31
+ (send (...) {:assign_attributes :update :update! :find_or_create_by :find_or_create_by! :find_or_initialize_by :find_or_initialize_by! :update_attribute :update_attributes :update_attributes! :update_all :update_column :update_columns} ...)
32
+ PATTERN
33
+
34
+ def initialize(config = nil, options = nil)
35
+ super(config, options)
36
+ config = @config.for_cop(self)
37
+ @unsafe_parameters = config.fetch("unsafe_parameters", []).map(&:to_sym)
38
+ @unsafe_regex = Regexp.new config.fetch("unsafe_regex", ".*_id$")
39
+ @param_wrappers = []
40
+ end
41
+
42
+ def on_class(node)
43
+ Utils::MethodReturnTable.populate_index node
44
+ Utils::MethodReturnTable.indexed_methods.each do |method_name, method_returns|
45
+ method_returns.each do |x|
46
+ name = Utils::Parser.get_root_token(x)
47
+ @param_wrappers << method_name if name == :params || @param_wrappers.include?(name)
48
+ end
49
+ end
50
+ end
51
+
52
+ def on_send(node) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
53
+ return if !model_new?(node) && !model_update?(node)
54
+
55
+ node.arguments.each do |argument|
56
+ if argument.send_type? || argument.variable?
57
+ flag_literal_param_use(argument)
58
+ flag_indirect_param_use(argument)
59
+ elsif argument.hash_type?
60
+ argument.children.select(&:pair_type?).each do |pair|
61
+ _key, value = *pair.children
62
+ flag_literal_param_use(value)
63
+ flag_indirect_param_use(value)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # Flags objects being created/updated with unsafe
72
+ # params directly from params or through params.permit
73
+ #
74
+ # class MyController < ApplicationController
75
+ # def create
76
+ # Object.create params.permit(:user_id)
77
+ # Object.create(user_id: params[:user_id])
78
+ # end
79
+ # end
80
+ #
81
+ def flag_literal_param_use(node)
82
+ name = Utils::Parser.get_root_token(node)
83
+ extracted_params = Utils::Parser.get_extracted_parameters(node)
84
+ add_offense(node, message: MSG_UNSAFE_CREATE) if name == :params && contains_id_parameter?(extracted_params)
85
+ end
86
+
87
+ # Flags objects being created/updated with unsafe
88
+ # params indirectly from params or through params.permit
89
+ def flag_indirect_param_use(node) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
90
+ name = Utils::Parser.get_root_token(node)
91
+ # extracted_params contains parameters used like:
92
+ # def create
93
+ # Object.new(user_id: indirect_params[:user_id])
94
+ # end
95
+ # def indirect_params
96
+ # params.permit(:user_id)
97
+ # end
98
+ extracted_params = Utils::Parser.get_extracted_parameters(node, param_aliases: @param_wrappers)
99
+
100
+ returns = Utils::MethodReturnTable.get_method(name) || []
101
+ returns.each do |ret|
102
+ # # propagated_params contains parameters used like:
103
+ # def create
104
+ # Object.new indirect_params
105
+ # end
106
+ # def indirect_params
107
+ # params.permit(:user_id)
108
+ # end
109
+ propagated_params = Utils::Parser.get_extracted_parameters(ret, param_aliases: @param_wrappers)
110
+
111
+ # # internal_params contains parameters used like:
112
+ # def create
113
+ # Object.new(user_id: indirect_params)
114
+ # end
115
+ # def indirect_params
116
+ # params[:user_id]
117
+ # end
118
+ if ret.send_type? && ret.method?(:[])
119
+ internal_params = ret.arguments.select { |x| x.sym_type? || x.str_type? }.map(&:value)
120
+ else
121
+ internal_returns = Utils::MethodReturnTable.get_method(Utils::Parser.get_root_token(ret)) || []
122
+ internal_params = internal_returns.flat_map { |x| Utils::Parser.get_extracted_parameters(x, param_aliases: @param_wrappers) }
123
+ end
124
+
125
+ add_offense(node, message: MSG_UNSAFE_CREATE) if flag_indirect_param_use?(extracted_params, internal_params, propagated_params)
126
+ end
127
+ end
128
+
129
+ def flag_indirect_param_use?(extracted_params, internal_params, propagated_params)
130
+ return contains_id_parameter?(extracted_params) if extracted_params.any?
131
+
132
+ contains_id_parameter?(extracted_params) || contains_id_parameter?(internal_params) || contains_id_parameter?(propagated_params)
133
+ end
134
+
135
+ def contains_id_parameter?(params)
136
+ params.any? do |arg|
137
+ suspicious_id?(arg)
138
+ end
139
+ end
140
+
141
+ # check a symbol name against the cop's config parameters
142
+ def suspicious_id?(symbol_name)
143
+ @unsafe_parameters.include?(symbol_name.to_sym) || @unsafe_regex.match(symbol_name) # symbol_name.to_s.end_with?("_id")
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,36 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Betterment
4
+ class DynamicParams < Cop
5
+ MSG_DYNAMIC_PARAMS = <<~MSG.freeze
6
+ Parameter names accessed dynamically, cannot determine safeness. Please inline the keys explicitly when calling `permit` or when accessing `params` like a hash.
7
+
8
+ See here for more information on this error:
9
+ https://github.com/Betterment/betterlint/blob/main/README.md#bettermentdynamicparams
10
+ MSG
11
+
12
+ def_node_matcher :permit_or_hash?, <<-PATTERN
13
+ (send (...) {:[] :permit} ...)
14
+ PATTERN
15
+
16
+ def on_send(node)
17
+ _, _, *arg_nodes = *node # rubocop:disable InternalAffairs/NodeDestructuring
18
+ return unless permit_or_hash?(node) && Utils::Parser.get_root_token(node) == :params
19
+
20
+ dynamic_param = find_dynamic_param(arg_nodes)
21
+ add_offense(dynamic_param, message: MSG_DYNAMIC_PARAMS) if dynamic_param
22
+ end
23
+
24
+ private
25
+
26
+ def find_dynamic_param(arg_nodes)
27
+ return unless arg_nodes
28
+
29
+ arg_nodes.find do |arg|
30
+ arg.array_type? && find_dynamic_param(arg.values) || !arg.literal? && !arg.const_type?
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,70 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Betterment
4
+ # Require explicit redirect statuses in routes.rb. Permanent redirects (301) are cached by clients,
5
+ # which makes it difficult to change later, and/or reuse the route for something else.
6
+ #
7
+ # @example
8
+ # # bad
9
+ # get '/', redirect('/dashboard')
10
+ # get { |params, request| '/dashboard' }
11
+ #
12
+ # # good
13
+ # get '/', redirect('/dashboard', status: 301)
14
+ # get(status: 302) { |params, request| '/dashboard' }
15
+ class ImplicitRedirectType < Cop
16
+ ROUTES_FILE_NAME = 'routes.rb'.freeze
17
+ MSG =
18
+ 'Rails will create a permanent (301) redirect, which is dangerous. ' \
19
+ 'Please specify your desired status, e.g. redirect(..., status: 302)'.freeze
20
+
21
+ # redirect('/')
22
+ def_node_matcher :arg_form_without_options?, <<-PATTERN
23
+ (send nil? :redirect (str _))
24
+ PATTERN
25
+
26
+ # redirect { |_params, _request| '/' }
27
+ def_node_matcher :block_form_without_options?, <<-PATTERN
28
+ (block (send nil? :redirect) ...)
29
+ PATTERN
30
+
31
+ # redirect('/', foo: 'bar')
32
+ def_node_matcher :arg_form_with_options, <<-PATTERN
33
+ (send nil? :redirect (str _) (hash $...))
34
+ PATTERN
35
+
36
+ # redirect(foo: 'bar') { |_params, _request| '/' }
37
+ def_node_matcher :block_form_with_options, <<-PATTERN
38
+ (block (send nil? :redirect (hash $...)) ...)
39
+ PATTERN
40
+
41
+ # status: anything
42
+ def_node_matcher :valid_status_option?, <<-PATTERN
43
+ (pair (sym :status) _)
44
+ PATTERN
45
+
46
+ def on_block(node)
47
+ return unless routes_file?
48
+
49
+ if block_form_with_options(node) { |options| options.none?(&method(:valid_status_option?)) } || block_form_without_options?(node)
50
+ add_offense(node)
51
+ end
52
+ end
53
+
54
+ def on_send(node)
55
+ return unless routes_file?
56
+
57
+ if arg_form_with_options(node) { |options| options.none?(&method(:valid_status_option?)) } || arg_form_without_options?(node)
58
+ add_offense(node)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def routes_file?
65
+ Pathname.new(processed_source.buffer.name).basename.to_s == ROUTES_FILE_NAME
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,33 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Betterment
4
+ class MemoizationWithArguments < Cop
5
+ MSG = 'Memoized method `%<method>s` accepts arguments, ' \
6
+ 'which may cause it to return a stale result. ' \
7
+ 'Remove memoization or refactor to remove arguments.'.freeze
8
+
9
+ def self.node_pattern
10
+ memo_assign = '(or_asgn $(ivasgn _) _)'
11
+ memoized_at_end_of_method = "(begin ... #{memo_assign})"
12
+ instance_method =
13
+ "(def $_ _ {#{memo_assign} #{memoized_at_end_of_method}})"
14
+ class_method =
15
+ "(defs self $_ _ {#{memo_assign} #{memoized_at_end_of_method}})"
16
+ "{#{instance_method} #{class_method}}"
17
+ end
18
+
19
+ private_class_method :node_pattern
20
+ def_node_matcher :memoized?, node_pattern
21
+
22
+ def on_def(node)
23
+ (method_name, ivar_assign) = memoized?(node)
24
+ return if ivar_assign.nil? || node.arguments.length.zero?
25
+
26
+ msg = format(MSG, method: method_name)
27
+ add_offense(node, location: ivar_assign.source_range, message: msg)
28
+ end
29
+ alias on_defs on_def
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Betterment
6
+ # Checks the status passed to have_http_status
7
+ #
8
+ # If a number, enforces that it doesn't start with 5. If a symbol or a string, enforces that it's not one of:
9
+ #
10
+ # * internal_server_error
11
+ # * not_implemented
12
+ # * bad_gateway
13
+ # * service_unavailable
14
+ # * gateway_timeout
15
+ # * http_version_not_supported
16
+ # * insufficient_storage
17
+ # * not_extended
18
+ #
19
+ # @example
20
+ #
21
+ # # bad
22
+ # expect(response).to have_http_status :internal_server_error
23
+ #
24
+ # # bad
25
+ # expect(response).to have_http_status 500
26
+ #
27
+ # # good
28
+ # expect(response).to have_http_status :forbidden
29
+ #
30
+ # # good
31
+ # expect(response).to have_http_status 422
32
+ class ServerErrorAssertion < Cop
33
+ MSG = 'Do not assert on 5XX statuses. Use a semantic status (e.g., 403, 422, etc.) or treat them as bugs (omit tests).'
34
+ BAD_STATUSES = %i(
35
+ internal_server_error
36
+ not_implemented
37
+ bad_gateway
38
+ service_unavailable
39
+ gateway_timeout
40
+ http_version_not_supported
41
+ insufficient_storage
42
+ not_extended
43
+ ).freeze
44
+
45
+ def_node_matcher :offensive_node?, <<-PATTERN
46
+ (send nil? :have_http_status
47
+ {
48
+ (int {#{(500..599).map(&:to_s).join(' ')}})
49
+ (str {#{BAD_STATUSES.map(&:to_s).map(&:inspect).join(' ')}})
50
+ (sym {#{BAD_STATUSES.map(&:inspect).join(' ')}})
51
+ }
52
+ )
53
+ PATTERN
54
+
55
+ def on_send(node)
56
+ return unless offensive_node?(node)
57
+
58
+ add_offense(node)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end