rubocop-dev_doc 0.10.4 → 0.12.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 +4 -4
- data/config/default.yml +73 -0
- data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
- data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
- data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +81 -58
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb +176 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
- data/lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb +102 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
- data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
- data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
- data/lib/rubocop/dev_doc/plugin.rb +2 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +8 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Condition analysis for CurrentUserBranching, extracted so the cop
|
|
6
|
+
# class stays within size limits. Everything here answers one
|
|
7
|
+
# question: is this AST node auth-state/authorization branching?
|
|
8
|
+
#
|
|
9
|
+
# Three mechanisms cooperate (see the cop docstring for doctrine):
|
|
10
|
+
# - AUTH ROOTS: bare `current_user` / `user_signed_in?` calls, PLUS
|
|
11
|
+
# any local/instance variable assigned from `current_user` in the
|
|
12
|
+
# same file (taint tracking — `@user = current_user` must not be a
|
|
13
|
+
# way to launder the branch). Taint is per-file: an ivar assigned in
|
|
14
|
+
# a controller and read in a view is NOT tracked — the companion
|
|
15
|
+
# role-predicate cop covers that gap receiver-agnostically.
|
|
16
|
+
# - ALLOWED METHODS: the first method called on an auth root is
|
|
17
|
+
# classified. Data reads (`errors`, project-configured readers like
|
|
18
|
+
# `otp_required_for_login`) pass; everything else — role predicates,
|
|
19
|
+
# unknown methods — flags until someone classifies it in
|
|
20
|
+
# `AllowedMethods` (a deliberate, reviewed decision).
|
|
21
|
+
# - GUARD EXEMPTION: bare-return nil guards in every spelling stay
|
|
22
|
+
# allowed (the correct pre-auth pattern).
|
|
23
|
+
module CurrentUserBranchingHelpers
|
|
24
|
+
# Universal non-permission data reads, allowed WITHOUT configuration.
|
|
25
|
+
# Lives in code (not default.yml) so a project setting its own
|
|
26
|
+
# AllowedMethods cannot accidentally drop the baseline — RuboCop
|
|
27
|
+
# REPLACES array config wholesale, it does not merge. The yml key is
|
|
28
|
+
# therefore purely additive.
|
|
29
|
+
DEFAULT_ALLOWED_METHODS = %i[errors id email persisted? new_record?].freeze
|
|
30
|
+
|
|
31
|
+
# Nil-test spellings that keep the guard-return exemption; role/data
|
|
32
|
+
# chains never qualify.
|
|
33
|
+
NIL_TEST_METHODS = %i[present? blank? nil? !].freeze
|
|
34
|
+
|
|
35
|
+
# Identity comparisons are display logic, not access decisions.
|
|
36
|
+
IDENTITY_COMPARISON_METHODS = %i[== !=].freeze
|
|
37
|
+
|
|
38
|
+
def on_new_investigation
|
|
39
|
+
super
|
|
40
|
+
@tainted_names = collect_tainted_names(processed_source.ast)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Names that are PURE aliases of current_user: every assignment to
|
|
46
|
+
# the name, file-wide, is an unconditional `= current_user`. An
|
|
47
|
+
# assignment under an `if` is semantic narrowing (`@user =
|
|
48
|
+
# current_user` only when a token matches — the variable is
|
|
49
|
+
# genuinely nilable, and branching on it is resource-presence
|
|
50
|
+
# logic), and an assignment from any other source makes it a
|
|
51
|
+
# general holder; either disqualifies the name. `case` dispatch
|
|
52
|
+
# (glib_load_resource's per-action assignment) does NOT disqualify —
|
|
53
|
+
# per-action aliasing is still pure aliasing.
|
|
54
|
+
def collect_tainted_names(ast)
|
|
55
|
+
names = Set.new
|
|
56
|
+
disqualified = Set.new
|
|
57
|
+
ast&.each_node(:lvasgn, :ivasgn) do |assign|
|
|
58
|
+
target = pure_alias_assignment?(assign) ? names : disqualified
|
|
59
|
+
target << assign.children[0]
|
|
60
|
+
end
|
|
61
|
+
names - disqualified
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pure_alias_assignment?(assign)
|
|
65
|
+
value = assign.children[1]
|
|
66
|
+
return false unless value&.send_type? && value.method_name == :current_user && value.receiver.nil?
|
|
67
|
+
|
|
68
|
+
assign.each_ancestor(:if).none?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tainted_ref?(node)
|
|
72
|
+
node&.type?(:lvar, :ivar) && @tainted_names.include?(node.children.first)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def auth_condition?(condition)
|
|
76
|
+
return false unless condition
|
|
77
|
+
return tainted_ref?(condition) if condition.type?(:lvar, :ivar)
|
|
78
|
+
return auth_condition?(condition.children.last) if condition.begin_type?
|
|
79
|
+
return condition.children.any? { |operand| auth_condition?(operand) } if condition.type?(:and, :or)
|
|
80
|
+
|
|
81
|
+
condition.type?(:send, :csend) && auth_send_condition?(condition)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auth_send_condition?(node)
|
|
85
|
+
return true if bare_auth_call?(node)
|
|
86
|
+
# Identity comparisons are exempt in BOTH directions
|
|
87
|
+
# (`x.user == current_user` and `current_user.id == x`), so test
|
|
88
|
+
# before the chain-root rule.
|
|
89
|
+
return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
|
|
90
|
+
# `!current_user` / `!user_signed_in?` (or a negated chain)
|
|
91
|
+
return auth_send_condition?(node.receiver) if node.method_name == :! && node.receiver&.type?(:send, :csend)
|
|
92
|
+
return flagged_chain_off_auth_root?(node) if chain_first_hop(node)
|
|
93
|
+
|
|
94
|
+
current_user_as_access_argument?(node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The first method called on an auth root is the classified one:
|
|
98
|
+
# `current_user.oauth_tokens.any?` classifies `oauth_tokens` (the
|
|
99
|
+
# rest operates on the returned data). Allowed data reads pass;
|
|
100
|
+
# role predicates and unknown methods flag.
|
|
101
|
+
def flagged_chain_off_auth_root?(node)
|
|
102
|
+
!allowed_method?(chain_first_hop(node).method_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The send whose receiver IS an auth root (bare current_user or a
|
|
106
|
+
# tainted alias) — nil when the chain isn't rooted at auth state.
|
|
107
|
+
# `user_signed_in?` accepts no chain (it returns a bool).
|
|
108
|
+
def chain_first_hop(node)
|
|
109
|
+
current = node
|
|
110
|
+
while current.respond_to?(:receiver) && current.receiver
|
|
111
|
+
return current if auth_root?(current.receiver)
|
|
112
|
+
|
|
113
|
+
current = current.receiver
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def auth_root?(node)
|
|
119
|
+
(node.send_type? && node.method_name == :current_user && node.receiver.nil?) || tainted_ref?(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def bare_auth_call?(node)
|
|
123
|
+
node.send_type? && self.class::AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def allowed_method?(method_name)
|
|
127
|
+
DEFAULT_ALLOWED_METHODS.include?(method_name) ||
|
|
128
|
+
(cop_config['AllowedMethods'] || []).map(&:to_sym).include?(method_name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# `admins.include?(current_user)` / `accessible_by?(current_user)`
|
|
132
|
+
# in a condition — an access decision parameterised by the current
|
|
133
|
+
# user (or a tainted alias); belongs in the policy.
|
|
134
|
+
def current_user_as_access_argument?(node)
|
|
135
|
+
return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
|
|
136
|
+
|
|
137
|
+
node.arguments.any? do |arg|
|
|
138
|
+
(arg.send_type? && arg.method_name == :current_user && arg.receiver.nil?) || tainted_ref?(arg)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `return unless current_user` (and every nil-test SPELLING of it,
|
|
143
|
+
# on current_user or a tainted alias) is the correct guard pattern —
|
|
144
|
+
# the cop polices branching, not guard orthography. Role/data chains
|
|
145
|
+
# (`return unless current_user.admin?`) do NOT qualify.
|
|
146
|
+
def bare_return_guard?(node)
|
|
147
|
+
return false unless pure_auth_state_test?(node.condition)
|
|
148
|
+
|
|
149
|
+
bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def pure_auth_state_test?(condition)
|
|
153
|
+
return false if condition.nil?
|
|
154
|
+
return true if tainted_ref?(condition)
|
|
155
|
+
return false unless condition.type?(:send, :csend)
|
|
156
|
+
return true if bare_current_user?(condition)
|
|
157
|
+
|
|
158
|
+
nil_test_of_auth_state?(condition)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def nil_test_of_auth_state?(node)
|
|
162
|
+
NIL_TEST_METHODS.include?(node.method_name) && node.receiver && pure_auth_state_test?(node.receiver)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def bare_current_user?(node)
|
|
166
|
+
node.method_name == :current_user && node.receiver.nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def bare_return_branch?(branch)
|
|
170
|
+
branch&.return_type? && branch.children.empty?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -274,11 +274,11 @@ module RuboCop
|
|
|
274
274
|
# `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
|
|
275
275
|
# LAST statement does.
|
|
276
276
|
def branch_exits?(branch)
|
|
277
|
+
branch = branch.children.last if branch&.begin_type?
|
|
277
278
|
return false unless branch
|
|
278
279
|
|
|
279
|
-
branch
|
|
280
|
-
|
|
281
|
-
(branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
|
|
280
|
+
branch.return_type? ||
|
|
281
|
+
(branch.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
284
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Role predicates must not be called outside the policy layer.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# A role predicate (`admin?`, `super_admin?`,
|
|
9
|
+
# `effective_admin_of_organization?`, ...) answers "what may this
|
|
10
|
+
# person do?" — an authorization question. Scattering those calls
|
|
11
|
+
# through controllers and views fragments the permission model: the
|
|
12
|
+
# Pundit policy says one thing, page code quietly decides another.
|
|
13
|
+
#
|
|
14
|
+
# This cop is the receiver-agnostic companion to
|
|
15
|
+
# `DevDoc/Auth/CurrentUserBranching`: that cop tracks `current_user`
|
|
16
|
+
# and its same-file aliases, but taint cannot follow an ivar from a
|
|
17
|
+
# controller into a view, and any wrapper method breaks the chain.
|
|
18
|
+
# Keying on the PREDICATE NAME instead means `current_user.admin?`,
|
|
19
|
+
# `@user.admin?`, `user.admin?`, and `viewer.admin?` are all equally
|
|
20
|
+
# visible — no alias or wrapper hides the question being asked.
|
|
21
|
+
#
|
|
22
|
+
# ## Remedy
|
|
23
|
+
# Move the decision into the Pundit policy and query it where needed:
|
|
24
|
+
# `policy(record).manage?` — no role predicate at the call site.
|
|
25
|
+
# Name the policy query after the DECISION it guards (`auto_approve?`,
|
|
26
|
+
# `initiation_blocked_for_admin?`), never after the role: a generic
|
|
27
|
+
# getter (`user_is_admin?`) re-exports the scattering through the
|
|
28
|
+
# policy door — every call site still maps role to behavior itself.
|
|
29
|
+
# Genuine display logic keyed on a role (badges, admin-only hints)
|
|
30
|
+
# belongs in a helper (an excluded path), where the branching is
|
|
31
|
+
# openly presentation-side.
|
|
32
|
+
#
|
|
33
|
+
# ## The ratchet
|
|
34
|
+
# Once page code queries policies by purpose, add any role-shaped
|
|
35
|
+
# getters your policies DO export (`user_is_super_admin?`, ...) to
|
|
36
|
+
# RolePredicates. Policies are excluded paths, so policy-internal use
|
|
37
|
+
# stays free while new loose call sites in page code flag mechanically.
|
|
38
|
+
#
|
|
39
|
+
# ## Configuration
|
|
40
|
+
# `RolePredicates` is a closed, per-project list — add every role
|
|
41
|
+
# predicate your User/Member models define:
|
|
42
|
+
#
|
|
43
|
+
# DevDoc/Auth/RolePredicateOutsidePolicy:
|
|
44
|
+
# RolePredicates:
|
|
45
|
+
# - admin?
|
|
46
|
+
# - super_admin?
|
|
47
|
+
# - effective_admin_of_organization?
|
|
48
|
+
# - explicit_admin_of_organization?
|
|
49
|
+
#
|
|
50
|
+
# ## Allowed paths (Exclude:)
|
|
51
|
+
# By default the cop is silent in:
|
|
52
|
+
# app/policies/**/*.rb ← where the predicates belong
|
|
53
|
+
# app/models/**/*.rb ← where they are defined/composed
|
|
54
|
+
# app/helpers/**/*.rb ← sanctioned display branching
|
|
55
|
+
# app/views/layouts/**/* ← sanctioned display branching
|
|
56
|
+
# test/, spec/ ← tests assert role facts as fixture
|
|
57
|
+
# preconditions; they verify the permission
|
|
58
|
+
# model rather than fragment it
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# # bad — controller decides a permission by role
|
|
62
|
+
# if current_user.admin?
|
|
63
|
+
# auto_approve
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# # bad — the alias dodge is equally visible
|
|
67
|
+
# if @user.admin?
|
|
68
|
+
# auto_approve
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # good — the policy owns the decision; page code queries it
|
|
72
|
+
# if policy(@organization).auto_approve?
|
|
73
|
+
# auto_approve
|
|
74
|
+
# end
|
|
75
|
+
class RolePredicateOutsidePolicy < Base
|
|
76
|
+
MSG = '`%<method>s` is a role predicate — an authorization question that belongs in ' \
|
|
77
|
+
'the Pundit policy. Move the DECISION there and query it by purpose ' \
|
|
78
|
+
'(`policy(record).auto_approve?`) — name the query after the decision it ' \
|
|
79
|
+
'guards, never after the role (`user_is_admin?` just re-exports the ' \
|
|
80
|
+
'scattering through the policy door, and belongs in this cop\'s ' \
|
|
81
|
+
'RolePredicates list). For pure display branching (badges, admin hints), ' \
|
|
82
|
+
'use a helper. Renaming the receiver never exempts it: the flagged thing ' \
|
|
83
|
+
'is the question, not who is asked.'.freeze
|
|
84
|
+
|
|
85
|
+
def on_send(node)
|
|
86
|
+
method_name = node.method_name
|
|
87
|
+
return unless role_predicates.include?(method_name)
|
|
88
|
+
|
|
89
|
+
add_offense(node.loc.selector, message: format(MSG, method: method_name))
|
|
90
|
+
end
|
|
91
|
+
alias on_csend on_send
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def role_predicates
|
|
96
|
+
@role_predicates ||= (cop_config['RolePredicates'] || []).map(&:to_sym)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -40,9 +40,7 @@ module RuboCop
|
|
|
40
40
|
TRANSLATION_METHODS = %i[t translate].freeze
|
|
41
41
|
|
|
42
42
|
def on_send(node)
|
|
43
|
-
if watched_methods.include?(node.method_name.to_s)
|
|
44
|
-
node.arguments.each { |arg| each_localizable(arg) }
|
|
45
|
-
end
|
|
43
|
+
node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
|
|
46
44
|
|
|
47
45
|
check_jbuilder_positional(node)
|
|
48
46
|
end
|
|
@@ -56,19 +54,22 @@ module RuboCop
|
|
|
56
54
|
def each_localizable(node)
|
|
57
55
|
case node.type
|
|
58
56
|
when :hash
|
|
59
|
-
node.pairs.each
|
|
60
|
-
key = pair.key
|
|
61
|
-
if key.sym_type? && localizable_keys.include?(key.value.to_s)
|
|
62
|
-
inspect_localizable(key.value, pair.value)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
each_localizable(pair.value)
|
|
66
|
-
end
|
|
57
|
+
node.pairs.each { |pair| walk_pair(pair) }
|
|
67
58
|
when :array
|
|
68
59
|
node.children.each { |child| each_localizable(child) }
|
|
60
|
+
else
|
|
61
|
+
# Any other node type (literal, send, variable…) can't contain
|
|
62
|
+
# localizable key-value pairs.
|
|
69
63
|
end
|
|
70
64
|
end
|
|
71
65
|
|
|
66
|
+
def walk_pair(pair)
|
|
67
|
+
key = pair.key
|
|
68
|
+
inspect_localizable(key.value, pair.value) if key.sym_type? && localizable_keys.include?(key.value.to_s)
|
|
69
|
+
|
|
70
|
+
each_localizable(pair.value)
|
|
71
|
+
end
|
|
72
|
+
|
|
72
73
|
# jbuilder positional form: `json.title 'Forms'`. The method name is
|
|
73
74
|
# the key and the first argument is the value. Restricted to the
|
|
74
75
|
# jbuilder root `json` so ordinary `obj.text('...')` calls aren't
|
|
@@ -125,8 +125,9 @@ module RuboCop
|
|
|
125
125
|
private
|
|
126
126
|
|
|
127
127
|
def receiver_shape_matches?(node)
|
|
128
|
-
|
|
129
|
-
return
|
|
128
|
+
const_receiver = node.receiver&.const_type?
|
|
129
|
+
return const_receiver if CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
|
+
return !const_receiver if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
131
|
|
|
131
132
|
true
|
|
132
133
|
end
|
|
@@ -44,8 +44,8 @@ module RuboCop
|
|
|
44
44
|
# run — something `validate` can't express), disable this cop inline
|
|
45
45
|
# with a written justification:
|
|
46
46
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
47
|
+
# # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride -- <explanation>
|
|
48
|
+
# def run_validations!
|
|
49
49
|
# with_access { super }
|
|
50
50
|
# end
|
|
51
51
|
#
|
|
@@ -86,8 +86,8 @@ module RuboCop
|
|
|
86
86
|
# genuine exception, disable this cop inline with a written
|
|
87
87
|
# justification — expect pushback in review:
|
|
88
88
|
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
89
|
+
# # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks -- <explanation>
|
|
90
|
+
# after_create :some_callback
|
|
91
91
|
#
|
|
92
92
|
# Both the symbol form and the block form are flagged:
|
|
93
93
|
#
|
|
@@ -136,7 +136,7 @@ module RuboCop
|
|
|
136
136
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
-
def on_block(node)
|
|
139
|
+
def on_block(node)
|
|
140
140
|
send_node = node.send_node
|
|
141
141
|
return unless CALLBACKS.include?(send_node.method_name)
|
|
142
142
|
|
|
@@ -86,7 +86,9 @@ module RuboCop
|
|
|
86
86
|
|
|
87
87
|
def enum_column(klass, name)
|
|
88
88
|
table = schema.table_by(name: table_name(klass))
|
|
89
|
-
|
|
89
|
+
return nil unless table
|
|
90
|
+
|
|
91
|
+
table.columns.find { |c| c.name == name.to_s }
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def class_node(node)
|
|
@@ -121,7 +121,10 @@ module RuboCop
|
|
|
121
121
|
return explicit.method_name.to_s if explicit
|
|
122
122
|
|
|
123
123
|
# Otherwise the nearest block that isn't a transparent wrapper decides.
|
|
124
|
-
|
|
124
|
+
decider_context(sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decider_context(decider)
|
|
125
128
|
return unless decider
|
|
126
129
|
|
|
127
130
|
return 'collection' if RESOURCEFUL.include?(decider.method_name)
|
|
@@ -48,13 +48,14 @@ module RuboCop
|
|
|
48
48
|
expected = expected_name(node.method_name, name)
|
|
49
49
|
next if expected == name
|
|
50
50
|
|
|
51
|
-
add_offense(
|
|
52
|
-
arg,
|
|
53
|
-
message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
|
|
54
|
-
)
|
|
51
|
+
add_offense(arg, message: offense_message(node.method_name, expected))
|
|
55
52
|
end
|
|
56
53
|
end
|
|
57
54
|
|
|
55
|
+
def offense_message(method_name, expected)
|
|
56
|
+
format(MSG, method: method_name, number: number_word(method_name), expected: expected)
|
|
57
|
+
end
|
|
58
|
+
|
|
58
59
|
private
|
|
59
60
|
|
|
60
61
|
# The resource name as a string, for symbol or string arguments only
|
|
@@ -43,14 +43,13 @@ module RuboCop
|
|
|
43
43
|
RESTRICT_ON_SEND = %i[resources resource].freeze
|
|
44
44
|
|
|
45
45
|
def on_send(node)
|
|
46
|
-
|
|
47
|
-
has_except = key_present?(node, :except)
|
|
46
|
+
return if key_present?(node, :only)
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
has_except = key_present?(node, :except)
|
|
50
49
|
return if has_except && !require_only?
|
|
51
50
|
|
|
52
51
|
name = node.first_argument&.value || '?'
|
|
53
|
-
msg = has_except
|
|
52
|
+
msg = has_except ? MSG_REQUIRE_ONLY : MSG
|
|
54
53
|
add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
|
|
55
54
|
end
|
|
56
55
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `.to_sym` (including `&.to_sym`, and through laundering hops
|
|
6
|
+
# like `.presence`/`.to_s`/`.downcase`) chained directly onto an
|
|
7
|
+
# attacker-controlled boundary source: `params[...]` (also `.dig`,
|
|
8
|
+
# `.fetch`, `.require`) or `request.headers[...]`.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# A bare `.to_sym` lets ANY client-supplied value cross into the symbol
|
|
12
|
+
# domain, where downstream `case`/`==` dispatch treats it as known
|
|
13
|
+
# vocabulary. The allow-list is the missing DECISION: which values do
|
|
14
|
+
# we recognise, and what happens to the rest? Skipping it also mints a
|
|
15
|
+
# symbol per novel value (the classic symbol-DoS vector; GC reclaims
|
|
16
|
+
# dynamic symbols since Ruby 2.2, so today this is churn rather than a
|
|
17
|
+
# hard leak — the vocabulary laundering is the real bug).
|
|
18
|
+
#
|
|
19
|
+
# Resolve the value through the glib-web helper, which only ever
|
|
20
|
+
# returns a symbol already in the allow-list:
|
|
21
|
+
#
|
|
22
|
+
# ❌
|
|
23
|
+
# @mode = params[:mode]&.to_sym || :active
|
|
24
|
+
#
|
|
25
|
+
# ✔️ (glib-web >= 5.1.2)
|
|
26
|
+
# @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
|
|
27
|
+
#
|
|
28
|
+
# When recognised inputs must map to differently-spelled symbols
|
|
29
|
+
# (e.g. `'spring-promo'` → `:spring_promo`), use an explicit
|
|
30
|
+
# `case`-when at the boundary instead — same allow-list property.
|
|
31
|
+
#
|
|
32
|
+
# ## Sources detected
|
|
33
|
+
# - `params[…]`, `params.dig(…)`, `params.fetch(…)`, `params.require(…)`
|
|
34
|
+
# - `request.headers[…]`
|
|
35
|
+
#
|
|
36
|
+
# `ENV[…]` is deliberately NOT a source here (unlike the sibling
|
|
37
|
+
# `DevDoc/Style/StringSymbolComparison`): ENV is operator-controlled,
|
|
38
|
+
# not attacker-reachable, and `.to_sym` on boot-time config is
|
|
39
|
+
# legitimate where the controller helper doesn't even exist.
|
|
40
|
+
#
|
|
41
|
+
# ## Relationship with DevDoc/Style/StringSymbolComparison
|
|
42
|
+
# Complementary pair over the same boundary doctrine: that cop flags
|
|
43
|
+
# comparing an UNCONVERTED string source against a symbol (always
|
|
44
|
+
# false); this cop flags CONVERTING without validating. The sanctioned
|
|
45
|
+
# exit from both is the allow-list helper.
|
|
46
|
+
#
|
|
47
|
+
# ## Limitations
|
|
48
|
+
# Only the direct form is caught. Once the value flows through a
|
|
49
|
+
# helper method (`report_params[:x]`) or a local/instance variable,
|
|
50
|
+
# static analysis loses the boundary and review has to catch it.
|
|
51
|
+
# Laundering transforms (`.presence`, `.to_s`, `.strip`,
|
|
52
|
+
# `.downcase`, `.upcase`, `.squish`) do NOT reset the trail — they
|
|
53
|
+
# transform, they don't validate.
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# # bad
|
|
57
|
+
# @mode = params[:mode]&.to_sym || :active
|
|
58
|
+
# @theme = request.headers['X-Theme']&.to_sym
|
|
59
|
+
# @segment = params[:segment].presence&.to_sym
|
|
60
|
+
# @sort = params[:sort].to_s.downcase.to_sym
|
|
61
|
+
#
|
|
62
|
+
# # good
|
|
63
|
+
# @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
|
|
64
|
+
class AvoidSymbolizingBoundaryInput < Base
|
|
65
|
+
MSG = 'Unvalidated `.to_sym` on `%<source>s` lets any client value into the symbol ' \
|
|
66
|
+
'domain (and mints a symbol per novel value). Allow-list it instead: ' \
|
|
67
|
+
'`glib_allowlist_symbol_param(value, ALLOWED, default:)`. Laundering through ' \
|
|
68
|
+
'`.to_s`/`.presence`/`.downcase` is not validation.'.freeze
|
|
69
|
+
|
|
70
|
+
RESTRICT_ON_SEND = %i[to_sym].freeze
|
|
71
|
+
|
|
72
|
+
# String-preserving transforms that commonly sit between the boundary
|
|
73
|
+
# read and the `.to_sym` — they launder the value, not validate it,
|
|
74
|
+
# so the chain is still an offense.
|
|
75
|
+
TRANSFORM_HOPS = %i[presence to_s strip downcase upcase squish].freeze
|
|
76
|
+
|
|
77
|
+
def_node_matcher :params_access?, <<~PATTERN
|
|
78
|
+
(send (send nil? :params) {:[] :dig :fetch :require} ...)
|
|
79
|
+
PATTERN
|
|
80
|
+
|
|
81
|
+
def_node_matcher :request_headers_access?, <<~PATTERN
|
|
82
|
+
(send (send _ :headers) :[] _)
|
|
83
|
+
PATTERN
|
|
84
|
+
|
|
85
|
+
def on_send(node)
|
|
86
|
+
source = boundary_source_in(node.receiver)
|
|
87
|
+
return unless source
|
|
88
|
+
|
|
89
|
+
add_offense(node, message: format(MSG, source: source.source))
|
|
90
|
+
end
|
|
91
|
+
alias on_csend on_send
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The boundary source at the bottom of the receiver chain, walking
|
|
96
|
+
# through any number of laundering transforms. Returns nil for
|
|
97
|
+
# anything else (locals, `action_name`, helper-method results) so
|
|
98
|
+
# those don't false-positive.
|
|
99
|
+
def boundary_source_in(node)
|
|
100
|
+
while node
|
|
101
|
+
return node if boundary_source?(node)
|
|
102
|
+
return nil unless node.send_type? || node.csend_type?
|
|
103
|
+
return nil unless TRANSFORM_HOPS.include?(node.method_name)
|
|
104
|
+
|
|
105
|
+
node = node.receiver
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def boundary_source?(node)
|
|
110
|
+
params_access?(node) || request_headers_access?(node)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -15,7 +15,8 @@ module RuboCop
|
|
|
15
15
|
#
|
|
16
16
|
# 1. **Fall-through genuinely happens in normal operation** — add
|
|
17
17
|
# `else` with a comment stating WHY it is correct (requires
|
|
18
|
-
# `Style/EmptyElse`
|
|
18
|
+
# `Style/EmptyElse` with `AllowComments: true`, which keeps that
|
|
19
|
+
# cop's guard against comment-less empty elses).
|
|
19
20
|
# 2. **A non-match is a bug and continuing is unsafe** — `else raise`
|
|
20
21
|
# (fail fast; unexpected enum/mode values must not proceed).
|
|
21
22
|
# 3. **A non-match is a bug but users can safely continue** — report
|
|
@@ -83,21 +83,28 @@ module RuboCop
|
|
|
83
83
|
|
|
84
84
|
siblings = parent.children
|
|
85
85
|
nxt = siblings[siblings.index(asgn) + 1]
|
|
86
|
-
nxt if
|
|
86
|
+
nxt if plain_if?(nxt)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A statement-form `if` (not a ternary, modifier, or unless).
|
|
90
|
+
def plain_if?(node)
|
|
91
|
+
node&.if_type? && !node.ternary? && !node.modifier_form? && node.if?
|
|
87
92
|
end
|
|
88
93
|
|
|
89
94
|
# `if x` or `if x.present?` — a truthiness check on the assigned var.
|
|
90
95
|
def truthiness_check?(condition, name)
|
|
91
96
|
return false unless condition
|
|
97
|
+
return condition.children.first == name if condition.lvar_type?
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
truthy_predicate_on?(condition, name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# `x.present?`-style: a truthy predicate whose receiver is the var.
|
|
103
|
+
def truthy_predicate_on?(condition, name)
|
|
104
|
+
return false unless condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
|
|
105
|
+
|
|
106
|
+
recv = condition.receiver
|
|
107
|
+
recv&.lvar_type? && recv.children.first == name
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Every read of `name` in the enclosing scope sits inside the if's
|
|
@@ -109,12 +116,15 @@ module RuboCop
|
|
|
109
116
|
return false unless single_plain_assignment?(scope, name, asgn)
|
|
110
117
|
return false if rebinds_name?(scope, name)
|
|
111
118
|
|
|
119
|
+
reads_confined_to?(if_node, reads(scope, name))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read at least once in the true branch, and every read sits inside
|
|
123
|
+
# the if's condition or that branch.
|
|
124
|
+
def reads_confined_to?(if_node, refs)
|
|
112
125
|
true_branch = if_node.if_branch
|
|
113
126
|
return false unless true_branch
|
|
114
|
-
|
|
115
|
-
refs = reads(scope, name)
|
|
116
|
-
in_branch = refs.select { |r| within?(r, true_branch) }
|
|
117
|
-
return false if in_branch.empty?
|
|
127
|
+
return false if refs.none? { |r| within?(r, true_branch) }
|
|
118
128
|
|
|
119
129
|
refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
|
|
120
130
|
end
|