betterlint 1.0.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.
@@ -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