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.
- checksums.yaml +7 -0
- data/README.md +158 -0
- data/STYLEGUIDE.md +111 -0
- data/config/default.yml +290 -0
- data/lib/rubocop/cop/betterment.rb +15 -0
- data/lib/rubocop/cop/betterment/active_job_performable.rb +40 -0
- data/lib/rubocop/cop/betterment/allowlist_blocklist.rb +35 -0
- data/lib/rubocop/cop/betterment/authorization_in_controller.rb +148 -0
- data/lib/rubocop/cop/betterment/dynamic_params.rb +36 -0
- data/lib/rubocop/cop/betterment/implicit_redirect_type.rb +70 -0
- data/lib/rubocop/cop/betterment/memoization_with_arguments.rb +33 -0
- data/lib/rubocop/cop/betterment/server_error_assertion.rb +63 -0
- data/lib/rubocop/cop/betterment/site_prism_loaded.rb +25 -0
- data/lib/rubocop/cop/betterment/spec_helper_required_outside_spec_dir.rb +38 -0
- data/lib/rubocop/cop/betterment/timeout.rb +19 -0
- data/lib/rubocop/cop/betterment/unsafe_job.rb +33 -0
- data/lib/rubocop/cop/betterment/unscoped_find.rb +87 -0
- data/lib/rubocop/cop/betterment/utils/method_return_table.rb +48 -0
- data/lib/rubocop/cop/betterment/utils/parser.rb +115 -0
- metadata +173 -0
@@ -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
|