rubocop-dev_doc 0.12.1 → 0.13.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,165 @@
1
+ require_relative 'adjacent_justification'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module Auth
7
+ # A policy gate whose entire body is "is anyone signed in?" must carry
8
+ # an adjacent justification naming where per-record scoping happens.
9
+ #
10
+ # ## Rationale
11
+ # `authorize :index do user_signed_in? end` authorizes EVERY
12
+ # authenticated user — including users belonging to other tenants. That
13
+ # is only safe when something *else* scopes what the action can reach:
14
+ # the controller resolves records through `current_user` associations,
15
+ # the id in the URL is a non-enumerable capability token, or the data is
16
+ # genuinely public. When none of those hold — when the action resolves
17
+ # any record from a client-controlled param — a login-only gate IS a
18
+ # cross-tenant IDOR: any signed-in user can enumerate ids and read
19
+ # another tenant's records. This cop exists because that exact shape
20
+ # shipped: routes resolving records straight from params, gated by
21
+ # policies whose only check was `user_signed_in?`.
22
+ #
23
+ # The cop cannot see the controller from the policy file, so it flags
24
+ # every login-only gate and requires the developer to state the scoping
25
+ # mechanism in an adjacent comment (containing the configurable marker,
26
+ # default "login-only:"). Writing that sentence honestly is the check:
27
+ # it forces the question "what stops a stranger with someone else's id?"
28
+ # at the moment the gate is written. A login-only catch-all
29
+ # (`authorize :glib_all`) gates every action of the resource and
30
+ # deserves the same scrutiny.
31
+ #
32
+ # ## What an offense means
33
+ # An offense does NOT assert the gate is wrong. Many login-only gates
34
+ # are correct — the record IS the current user, the controller scopes
35
+ # every lookup through `current_user` associations, the route is
36
+ # token-addressed, or the data is public. The cop asserts that scoping
37
+ # story is UNDOCUMENTED at the gate, the one place a security reviewer
38
+ # will look for it. A correct-but-unmarked gate is still a violation
39
+ # until the story is written down; the marker is the intended
40
+ # resolution, not a workaround. On adoption, expect a one-time
41
+ # justification sweep over existing gates — writing each sentence
42
+ # honestly IS the audit.
43
+ #
44
+ # ## Remedy
45
+ # Prefer binding the decision to the record or context over justifying:
46
+ #
47
+ # ❌ — every signed-in user passes; nothing binds them to the record
48
+ # authorize :index do
49
+ # user_signed_in?
50
+ # end
51
+ #
52
+ # ✔️ — the gate binds the user to the record
53
+ # authorize :index do
54
+ # record.is_a?(Project) && record.member?(current_user)
55
+ # end
56
+ #
57
+ # ✔️ — genuinely login-only, with the scoping stated where it can be
58
+ # # reviewed and re-checked
59
+ # # login-only: the controller resolves records exclusively through
60
+ # # current_user.projects; no params-derived id is honored.
61
+ # authorize :index do
62
+ # user_signed_in?
63
+ # end
64
+ #
65
+ # ✔️ — a policy that is login-only THROUGHOUT (current-user pages,
66
+ # # own-resource CRUD) states it once, above the class
67
+ # # login-only: every action operates on current_user itself; the
68
+ # # controller never resolves a record from params.
69
+ # class MfaDevicePolicy < ApplicationPolicy
70
+ # authorize :index do user_signed_in? end
71
+ # authorize :create do user_signed_in? end
72
+ # end
73
+ #
74
+ # A class-level marker covers every gate in the class — including ones
75
+ # added after the marker was written. Prefer per-gate markers unless
76
+ # the whole class genuinely shares one scoping story.
77
+ #
78
+ # ## Configuration
79
+ # `JustificationMarker` (default: `login-only:`) — the phrase the
80
+ # adjacent comment must contain (same line or the contiguous comment
81
+ # block directly above the gate, or above an enclosing class/module
82
+ # declaration to cover all gates within it).
83
+ #
84
+ # `LoginOnlyPredicates` (default: `user_signed_in?`, `everyone`) —
85
+ # bare predicates that make a gate login-only (or weaker) when they are
86
+ # its entire body. A literal `true` body is always flagged.
87
+ #
88
+ # NOTE: The cop is deliberately narrow: it flags only `authorize` blocks
89
+ # whose body is exactly one of the configured predicates (or `true`).
90
+ # Compound gates (`user_signed_in? && owner?`) and plain `def index?`
91
+ # policy methods are not tracked — the former carry more than login,
92
+ # the latter are building blocks whose decision point is elsewhere.
93
+ #
94
+ # @example
95
+ # # bad — login-only gate with no stated scoping
96
+ # authorize :index do
97
+ # user_signed_in?
98
+ # end
99
+ #
100
+ # # bad — `true` is weaker still
101
+ # authorize :show do
102
+ # true
103
+ # end
104
+ #
105
+ # # good — decision bound to the record
106
+ # authorize :show do
107
+ # record.participant?(current_user)
108
+ # end
109
+ #
110
+ # # good — justified login-only gate
111
+ # # login-only: records resolve through current_user associations only.
112
+ # authorize :index do
113
+ # user_signed_in?
114
+ # end
115
+ class LoginOnlyPolicyJustification < Base
116
+ include AdjacentJustification
117
+
118
+ MSG = 'This gate authorizes EVERY signed-in user, including other tenants\' — if the ' \
119
+ 'controller resolves any record from params, that is a cross-tenant IDOR. Bind the ' \
120
+ 'decision to the record/context, or state the scoping mechanism in a "%<marker>s" ' \
121
+ 'comment directly above (controller self-scope via current_user, non-enumerable ' \
122
+ 'capability id, or genuinely public data).'.freeze
123
+
124
+ def on_block(node)
125
+ send_node = node.send_node
126
+ return unless send_node.method?(:authorize) && send_node.receiver.nil?
127
+ return unless login_only_body?(node.body)
128
+ return if adjacent_justification?(node) || class_level_justification?(node)
129
+
130
+ add_offense(node.body, message: format(MSG, marker: justification_marker))
131
+ end
132
+
133
+ private
134
+
135
+ # A marker above an enclosing class/module declaration covers every
136
+ # gate within it — for policies that are login-only throughout.
137
+ # standalone_only: without it, a marker trailing the PREVIOUS class's
138
+ # `end` would silence this class too.
139
+ def class_level_justification?(node)
140
+ node.each_ancestor(:class, :module).any? do |ancestor|
141
+ adjacent_justification?(ancestor, standalone_only: true)
142
+ end
143
+ end
144
+
145
+ def login_only_body?(body)
146
+ return false unless body
147
+ return true if body.true_type?
148
+
149
+ body.send_type? && body.receiver.nil? && body.arguments.empty? &&
150
+ login_only_predicates.include?(body.method_name)
151
+ end
152
+
153
+ def login_only_predicates
154
+ @login_only_predicates ||=
155
+ Array(cop_config.fetch('LoginOnlyPredicates', %w[user_signed_in? everyone])).map(&:to_sym)
156
+ end
157
+
158
+ def justification_marker
159
+ cop_config.fetch('JustificationMarker', 'login-only:')
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,104 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # Policy code must not use `record.present?` / `record.blank?` as an
6
+ # authorization signal.
7
+ #
8
+ # ## Rationale
9
+ # A policy's `record` answers "what is being acted on". Whether it is
10
+ # *present* is a **loading** outcome — it only proves the controller's
11
+ # lookup resolved something — never an **authorization** fact. Gating on
12
+ # presence fails in two distinct ways:
13
+ #
14
+ # 1. **Fail-open via placeholder records.** Pundit-style stacks support
15
+ # headless policies, where the "record" is a Symbol naming the policy
16
+ # rather than a model instance — and resource-resolution layers can
17
+ # fall back to that Symbol when a lookup resolves nothing. A gate
18
+ # written as `record.present?` is then true for exactly the request
19
+ # it was written to refuse: the one whose record could not be
20
+ # resolved. This is an observed production incident shape — a
21
+ # cross-tenant guard reduced to `record.present?` passed for a
22
+ # foreign-tenant id because the missing record arrived as a truthy
23
+ # placeholder.
24
+ #
25
+ # 2. **Presence asserts nothing about the record.** Even when the record
26
+ # is genuinely a model instance, `present?` says nothing about its
27
+ # type, its tenant, or its relationship to the user. Authorization
28
+ # must bind the user to the record — a type check plus the
29
+ # ownership/attribute conditions that actually make the decision.
30
+ #
31
+ # ## Remedy
32
+ # Replace the presence check with a type check — it subsumes the nil
33
+ # guard and additionally asserts the record is the thing the policy
34
+ # believes it is — and bind the decision to the record's attributes:
35
+ #
36
+ # ❌
37
+ # authorize :create do
38
+ # admin_of_organization?(organization) && record.present?
39
+ # end
40
+ #
41
+ # ✔️
42
+ # authorize :create do
43
+ # admin_of_organization?(organization) && record.is_a?(Member)
44
+ # end
45
+ #
46
+ # ✔️ — bind to the user, not to mere existence
47
+ # def viewing_own_record?
48
+ # record.is_a?(Member) && record.user_id == user.id
49
+ # end
50
+ #
51
+ # NOTE: The cop is deliberately narrow: it flags `present?`/`blank?`
52
+ # only on the literal `record` receiver. Aliases (`def member = record`
53
+ # then `member.present?`) are not tracked — reviewers must catch those.
54
+ # Nil guards that precede attribute checks (`return false if
55
+ # record.nil?`) are not flagged; they fail closed and the attribute
56
+ # check that follows carries the authorization.
57
+ #
58
+ # @example
59
+ # # bad — presence as the authorization answer
60
+ # authorize :create do
61
+ # record.present?
62
+ # end
63
+ #
64
+ # # bad — negated form, same loading fact
65
+ # def member_missing?
66
+ # record.blank?
67
+ # end
68
+ #
69
+ # # good — type check binds the gate to a real record
70
+ # authorize :create do
71
+ # record.is_a?(Member)
72
+ # end
73
+ #
74
+ # # good — authorization binds the user to the record
75
+ # def viewing_own_record?
76
+ # record.is_a?(Member) && record.user_id == user.id
77
+ # end
78
+ class NoRecordPresenceInPolicy < Base
79
+ MSG = '`record.%<method>s` is a loading fact, not an authorization fact — resolution ' \
80
+ 'fallbacks can make it true via a placeholder even when nothing was resolved. ' \
81
+ 'Use a type check (`record.is_a?(SomeModel)`) plus the ownership/attribute ' \
82
+ 'conditions that actually authorize this user.'.freeze
83
+
84
+ RESTRICT_ON_SEND = %i[present? blank?].freeze
85
+
86
+ def on_send(node)
87
+ return unless bare_record?(node.receiver)
88
+
89
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
90
+ end
91
+ alias on_csend on_send
92
+
93
+ private
94
+
95
+ # True if `node` is a bare `record` send (the Pundit-style policy
96
+ # reader — no receiver, no arguments).
97
+ def bare_record?(node)
98
+ node&.send_type? && node.method_name == :record && node.receiver.nil? && node.arguments.empty?
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,194 @@
1
+ require_relative 'adjacent_justification'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module Auth
7
+ # A controller lookup that resolves a record from a client-controlled
8
+ # param on a bare model class (`Post.find(params[:id])`) must carry an
9
+ # adjacent justification naming where the record gets bound to the
10
+ # requester.
11
+ #
12
+ # ## Rationale
13
+ # `Post.find(params[:id])` reaches EVERY row of the table — the id is
14
+ # client-controlled and usually enumerable, so unless something else
15
+ # binds the resolved record to the requester (a policy that checks the
16
+ # record's owner/tenant, a tenant-scoped base controller, a
17
+ # non-enumerable capability id), any signed-in user can read or act on
18
+ # any tenant's record by walking ids. This cop exists because that
19
+ # exact shape shipped as cross-tenant IDORs: bare `find`s whose
20
+ # policies checked only "signed in" or bound the wrong level (the
21
+ # parent, not the specific record).
22
+ #
23
+ # The signal is inclusion-based: a CONSTANT receiver means unscoped. A
24
+ # lookup that travels through an association or `current_user`
25
+ # (`current_user.posts.find(...)`, `@project.members.find_by(...)`) has
26
+ # a non-constant receiver and passes silently — scoping through the
27
+ # association IS the fix this cop pushes toward.
28
+ #
29
+ # ## What an offense means
30
+ # An offense does NOT assert the site is vulnerable. The cop cannot see
31
+ # the policy, callback, or later guard that may already bind the
32
+ # record to the requester — it asserts the binding is UNDOCUMENTED at
33
+ # the lookup. A find that is safe through such controlling code is
34
+ # still a violation until the binding is either moved into the lookup
35
+ # (scoping) or stated in the marker comment; the marker is the intended
36
+ # resolution for legitimate root finds, not a workaround. On adoption,
37
+ # expect a one-time justification sweep over existing lookups — writing
38
+ # each binding sentence honestly IS the audit.
39
+ #
40
+ # ## Remedy
41
+ # Prefer scoping the lookup over justifying it:
42
+ #
43
+ # ❌ — any signed-in user can reach any row
44
+ # @post = Post.find(params[:id])
45
+ #
46
+ # ✔️ — the lookup itself binds the record to the requester
47
+ # @post = current_user.posts.find(params[:id])
48
+ #
49
+ # ✔️ — nested records resolve through the already-bound parent
50
+ # @comment = @post.comments.find_by(id: params[:comment_id])
51
+ #
52
+ # ✔️ — genuinely necessary root find, binding stated for review
53
+ # # unscoped-find: root record of the route; the policy receives it as
54
+ # # resource: and checks record-derived tenant admin.
55
+ # @post = Post.find(params[:id])
56
+ #
57
+ # ✔️ — the lookup key itself is the binding: a non-enumerable
58
+ # # capability id (see NonEnumerableKeys)
59
+ # @organization = Organization.find_by!(uuid: params[:organization_id])
60
+ #
61
+ # ## Configuration
62
+ # `JustificationMarker` (default: `unscoped-find:`) — the phrase the
63
+ # adjacent comment must contain (same line or the contiguous comment
64
+ # block directly above).
65
+ #
66
+ # `FinderMethods` (default: `find`, `find_by`, `find_by!`) — lookup
67
+ # methods the cop watches.
68
+ #
69
+ # `NonEnumerableKeys` (default: `uuid`, `token`, `secret_key`) — lookup
70
+ # keys treated as non-enumerable capability ids. A lookup is skipped
71
+ # only when its whole argument list is plain keyword pairs and every
72
+ # params-derived value is keyed by one of these
73
+ # (`find_by!(uuid: params[:id])`) — the unguessable key IS the binding,
74
+ # which the rationale above already endorses. The cop only trusts the
75
+ # key NAME; it cannot verify the column is actually unguessable, so
76
+ # only list keys your codebase reserves for high-entropy values.
77
+ # `slug` is deliberately not a default: slugs are public, guessable
78
+ # identifiers, not capabilities. Anything whose key can't be read off
79
+ # the call keeps the offense: positional lookups (`find(params[:id])` —
80
+ # even when `:id` holds a token), `**` splats, dynamic keys, and
81
+ # nested-hash values (association paths). Set to `[]` to disable the
82
+ # skip entirely.
83
+ #
84
+ # NOTE: The cop is deliberately narrow: it flags only a DIRECT constant
85
+ # receiver with an argument that references `params`. A chain rooted in
86
+ # a constant (`Post.includes(:x).find(params[:id])`) and an id copied
87
+ # into a local first (`id = params[:id]; Post.find(id)`) are not
88
+ # tracked — reviewers must catch those.
89
+ #
90
+ # @example
91
+ # # bad — unscoped, client-controlled id
92
+ # @post = Post.find(params[:id])
93
+ #
94
+ # # bad — find_by hides the same reach
95
+ # @post = Post.find_by(id: params[:post_id])
96
+ #
97
+ # # good — scoped through the requester
98
+ # @post = current_user.posts.find(params[:id])
99
+ #
100
+ # # good — justified root find
101
+ # # unscoped-find: policy binds via resource:; see PostPolicy#show?
102
+ # @post = Post.find(params[:id])
103
+ class UnscopedFindJustification < Base
104
+ include AdjacentJustification
105
+
106
+ MSG = '`%<receiver>s.%<method>s(params[...])` resolves a record from a client-controlled ' \
107
+ 'id with no scoping — unless something binds it to the requester, any signed-in ' \
108
+ 'user reaches any tenant\'s record by enumerating ids. Scope the lookup through an ' \
109
+ 'association/current_user, or state the binding in a "%<marker>s" comment ' \
110
+ 'directly above.'.freeze
111
+
112
+ # FinderMethods is configuration-driven, so the name filter lives in
113
+ # on_send rather than RESTRICT_ON_SEND.
114
+ def on_send(node)
115
+ return unless finder_methods.include?(node.method_name)
116
+ return unless node.receiver&.const_type?
117
+ return unless node.arguments.any? { |argument| references_params?(argument) }
118
+ return if exempted?(node)
119
+
120
+ add_offense(node.loc.selector, message: offense_message(node))
121
+ end
122
+ alias on_csend on_send
123
+
124
+ private
125
+
126
+ def exempted?(node)
127
+ non_enumerable_keys_only?(node) || adjacent_justification?(node)
128
+ end
129
+
130
+ # True when the WHOLE argument list is readable keyword pairs and
131
+ # every params-derived value is keyed by a configured non-enumerable
132
+ # key (`find_by!(uuid: params[:id])`). Anything whose key can't be
133
+ # read — a positional argument, a `**` splat, a dynamic key — keeps
134
+ # the offense even when it doesn't itself touch params: the skip must
135
+ # see the entire lookup to vouch for it.
136
+ def non_enumerable_keys_only?(node)
137
+ return false if non_enumerable_keys.empty?
138
+
139
+ node.arguments.all? do |argument|
140
+ argument.hash_type? && argument.children.all? { |child| non_enumerable_pair?(child) }
141
+ end
142
+ end
143
+
144
+ def non_enumerable_pair?(child)
145
+ return false unless child.pair_type? # kwsplat — the key is invisible
146
+ return false if references_params?(child.key) # dynamic key
147
+
148
+ # A hash value (`uuid: { id: params[:id] }`) is an association
149
+ # path, not a keyed lookup — `uuid` would be the table, `id` the
150
+ # enumerable column.
151
+ !references_params?(child.value) ||
152
+ (non_enumerable_key?(child.key) && !child.value.hash_type?)
153
+ end
154
+
155
+ def non_enumerable_key?(key)
156
+ key.sym_type? && non_enumerable_keys.include?(key.value)
157
+ end
158
+
159
+ def non_enumerable_keys
160
+ @non_enumerable_keys ||=
161
+ Array(cop_config.fetch('NonEnumerableKeys', %w[uuid token secret_key])).map(&:to_sym)
162
+ end
163
+
164
+ def offense_message(node)
165
+ format(
166
+ MSG,
167
+ receiver: node.receiver.const_name,
168
+ method: node.method_name,
169
+ marker: justification_marker
170
+ )
171
+ end
172
+
173
+ def references_params?(node)
174
+ return true if bare_params?(node)
175
+
176
+ node.each_descendant(:send).any? { |descendant| bare_params?(descendant) }
177
+ end
178
+
179
+ def bare_params?(node)
180
+ node.send_type? && node.method_name == :params && node.receiver.nil?
181
+ end
182
+
183
+ def finder_methods
184
+ @finder_methods ||= Array(cop_config.fetch('FinderMethods', %w[find find_by find_by!])).map(&:to_sym)
185
+ end
186
+
187
+ def justification_marker
188
+ cop_config.fetch('JustificationMarker', 'unscoped-find:')
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -2,18 +2,32 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Rails
5
- # Avoid `perform_later` calls inside model files.
5
+ # Avoid `perform_later`/`deliver_later` calls inside model files.
6
6
  #
7
7
  # ## Rationale
8
- # Avoid using ActiveJob inside Models, because:
8
+ # Enqueuing a job or mailer is execution-flow control — deciding *when*
9
+ # a side effect fires — and its home is the calling controller or job
10
+ # entry point (see the orchestration best-practice doc), because:
9
11
  #
10
12
  # - It is prone to conflicts between `transaction` and `perform_later`
11
13
  # (the job may run before the transaction commits and read stale data
12
14
  # — see `DevDoc/Rails/NoDeliverLaterInTransaction`).
13
- # - Execution flow control should be done in the Controller, not in the
14
- # Model.
15
- # - If it really must be done in the Model, name the method explicitly
16
- # so the side effect is obvious at the call site.
15
+ # - Flow control belongs to the Controller, not the Model. This governs
16
+ # the *trigger* only: the mail itself stays composed in the Mailer
17
+ # (parameters, templates see the controller best-practice doc); the
18
+ # controller merely calls `deliver_later`. Reuse across controllers
19
+ # goes in a controller concern — DRY alone never justifies pushing a
20
+ # trigger into a model.
21
+ # - The exception: an enqueue inseparable from a record state change
22
+ # (e.g. a throttle stamp and its send that must never be split across
23
+ # a boundary) stays in the model behind an explicitly named
24
+ # `notify_*`/`send_*` method, so the side effect is obvious at the
25
+ # call site.
26
+ #
27
+ # The boundary test: is the enqueue bound to a state change on this
28
+ # record, or is ordering already enforced by data flow (the caller can
29
+ # only enqueue with values that exist after the domain call returns)?
30
+ # The latter is flow control — trigger it from the controller.
17
31
  #
18
32
  # ❌ (in app/models/order.rb)
19
33
  # def finalize
@@ -21,18 +35,20 @@ module RuboCop
21
35
  # OrderJob.perform_later(self)
22
36
  # end
23
37
  #
24
- # ✔️ Explicit method name signals the side effect; reviewers
25
- # immediately see that this should not be called inside a
38
+ # ✔️ State-coupled enqueue behind an explicit `notify_*` name; the
39
+ # side effect is obvious at the call site, and reviewers
40
+ # immediately see that this must not be called inside a
26
41
  # `transaction` block.
27
- # def save_with_email_sending
28
- # save!
42
+ # def notify_finalized
43
+ # update!(finalize_notified_at: Time.current)
29
44
  # OrderJob.perform_later(self)
30
45
  # end
31
46
  #
32
47
  # NOTE: This cop flags *any* `perform_later` in `app/models/**/*.rb`,
33
48
  # including the legitimate "explicitly named method" case above. The
34
49
  # cop is intentionally conservative — disable per-line with a rubocop
35
- # comment when you have deliberately followed the naming convention.
50
+ # comment stating the justification when you have deliberately followed
51
+ # the naming convention.
36
52
  #
37
53
  # @example
38
54
  # # bad (in app/models/order.rb)
@@ -41,9 +57,9 @@ module RuboCop
41
57
  # OrderJob.perform_later(self)
42
58
  # end
43
59
  #
44
- # # good — explicit method communicates the side effect
45
- # def finalize_with_job
46
- # save!
60
+ # # good — state-coupled enqueue behind an explicitly named method
61
+ # def notify_finalized
62
+ # update!(finalize_notified_at: Time.current)
47
63
  # OrderJob.perform_later(self)
48
64
  # end
49
65
  class NoPerformLaterInModel < Base