rubocop-dev_doc 0.12.1 → 0.12.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a70fbdce97064f296200b1a77f663eb5c7f928973ea7be693d0ad36f1ff015bb
4
- data.tar.gz: 3e285799f1deffcb5bd30c90d61b2f3bd5438cb7a6a30b358dd60eba61a31072
3
+ metadata.gz: 4840aa1ca5c8a4ee2f5dfcb5b2e6880dbe7966d451b8502e0983e8eb63e9b04c
4
+ data.tar.gz: 4cc9d0f5a9e352f4b361b831250e5683242a0b35578b8abb43eb3535a18c9a1c
5
5
  SHA512:
6
- metadata.gz: 7eb23bbe57954d976ce2975b1eb35a616a306b56bfacacbbc18c03c40bfce86564efe48990649b4137ebdbe7f3773f77f4c3649df27a3bc3d33cc763afc10717
7
- data.tar.gz: 2593c0b78fcc7d82755f1ae7ae7ee81e70a362f3f1e1eff2636ebd2b221c496304c3816cca489fa50d8f67711159ade8ecc7bd88dd2d705f3c820105579ae49c
6
+ metadata.gz: 8c66389a73be82ba697f12708c89023851dc246caa0b3439a389948c8490b0b903a8dd14b90e7f54fa33698bc5e441fe50db7465d5d808e46db72086da0a5305
7
+ data.tar.gz: 69fd78aefb3b759863f7a45e3de18bb84bdccf52e1485a4dac0ea9151e0790fcbfa13c1a45d6dffd79bd6fbfff478020231b8b5aafc1d3e9f7a21b2668437fbf
data/config/default.yml CHANGED
@@ -722,6 +722,66 @@ DevDoc/Auth/RolePredicateOutsidePolicy:
722
722
  - "test/**/*"
723
723
  - "spec/**/*"
724
724
 
725
+ # Presence of the policy record is a LOADING fact, not an authorization fact.
726
+ # Headless-policy stacks can hand `record` a truthy placeholder (the policy-name
727
+ # Symbol) when a lookup resolves nothing, turning `record.present?` fail-open for
728
+ # exactly the request it was written to refuse. Observed in production code as a
729
+ # cross-tenant guard reduced to `record.present?`. The remedy — a type check plus
730
+ # ownership/attribute conditions — is strictly stronger in every case, so there
731
+ # is no legitimate hit to tolerate.
732
+ DevDoc/Auth/NoRecordPresenceInPolicy:
733
+ Description: "`record.present?`/`record.blank?` is a loading fact, not an authorization fact; use a type check (`record.is_a?(SomeModel)`) plus ownership/attribute conditions."
734
+ Enabled: true
735
+ Include:
736
+ - "app/policies/**/*.rb"
737
+
738
+ # A login-only gate authorizes every signed-in user, including other tenants'.
739
+ # That is safe only when something else scopes what the action reaches — and the
740
+ # cop cannot see the controller from the policy file, so it makes the developer
741
+ # state that scoping in an adjacent "login-only:" comment. Expect a one-time
742
+ # justification sweep on adoption; writing each sentence honestly IS the audit
743
+ # ("what stops a stranger with someone else's id?"). Observed shipped IDORs had
744
+ # exactly this shape: params-resolved records behind user_signed_in?-only gates.
745
+ # A policy that is login-only throughout (current-user pages, own-resource CRUD)
746
+ # can carry one marker above its class instead of one per gate.
747
+ DevDoc/Auth/LoginOnlyPolicyJustification:
748
+ Description: "A gate that is only `user_signed_in?`/`everyone`/`true` needs an adjacent `login-only:` comment stating where per-record scoping happens — or bind the decision to the record."
749
+ Enabled: true
750
+ JustificationMarker: "login-only:"
751
+ LoginOnlyPredicates:
752
+ - user_signed_in?
753
+ - everyone
754
+ Include:
755
+ - "app/policies/**/*.rb"
756
+
757
+ # `Post.find(params[:id])` reaches every row from a client-controlled, usually
758
+ # enumerable id. Unless the policy binds the resolved record (or its tenant) to
759
+ # the requester, that is a cross-tenant IDOR — the observed shipped shape. The
760
+ # fix the cop pushes toward is scoping the lookup itself (current_user.posts,
761
+ # parent association); a genuinely necessary root find carries an adjacent
762
+ # "unscoped-find:" comment naming the binding. Pairs with
763
+ # LoginOnlyPolicyJustification: shipping a new IDOR past both requires writing
764
+ # two dishonest justifications in two files.
765
+ DevDoc/Auth/UnscopedFindJustification:
766
+ Description: "A `Model.find(params[...])` with a constant receiver is unscoped; scope it through an association/current_user or add an adjacent `unscoped-find:` comment naming the binding."
767
+ Enabled: true
768
+ JustificationMarker: "unscoped-find:"
769
+ FinderMethods:
770
+ - find
771
+ - find_by
772
+ - find_by!
773
+ # Lookup keys treated as non-enumerable capability ids: a keyword lookup
774
+ # whose params-derived values are all keyed by one of these
775
+ # (`find_by!(uuid: params[:id])`) is skipped — the unguessable key is the
776
+ # binding. Key NAMES only; the cop cannot verify entropy. `slug` is
777
+ # deliberately absent (public, guessable). Set to [] to disable.
778
+ NonEnumerableKeys:
779
+ - uuid
780
+ - token
781
+ - secret_key
782
+ Include:
783
+ - "app/controllers/**/*.rb"
784
+
725
785
  DevDoc/I18n/AvoidTitleizeHumanize:
726
786
  Description: "Avoid `.titleize`/`.humanize` for display text; it's English-only and bypasses I18n. Use `t(...)`."
727
787
  # A review aid, not a clean lint: it can't tell a display string from one
@@ -0,0 +1,111 @@
1
+ module DevDoc
2
+ module Test
3
+ module Lints
4
+ # Cross-tenant canary sweep: catches a tenant-isolation leak by
5
+ # *observing responses/side-effects*, not by inspecting source. The
6
+ # static auth cops (DevDoc/Auth/*) flag the leak-shaped code patterns
7
+ # we have seen before; this lint fails when another tenant's data
8
+ # actually APPEARS somewhere a persona must not see it — regardless of
9
+ # which novel controller shape caused it.
10
+ #
11
+ # ## The canary convention (consumer-side)
12
+ # Fixtures give every tenant globally-unique, greppable markers on
13
+ # fields that render into responses (e.g. `CANARY-<tenant>-<field>`),
14
+ # seeded for at least two tenants so "foreign" is well-defined. Markers
15
+ # must be STATIC fixture strings — never minted at runtime — so a leak
16
+ # is a pure substring test with zero false positives.
17
+ #
18
+ # ## What it covers, honestly
19
+ # - A passive scan of every crawled response catches leaks on routes a
20
+ # persona can reach. It reads CONTENT, not status: an endpoint that
21
+ # returns 200 by design but embeds foreign data (a metadata preview)
22
+ # is caught where a status-only probe passes it.
23
+ # - An adversarial replay (id-like params substituted with a foreign
24
+ # tenant's ids) reaches leaks no page ever links the persona to.
25
+ # Only 2xx bodies are asserted on — a substituted id that 404s has
26
+ # no body to leak, and status is not the signal.
27
+ # - An outbound-recipient scan catches side-effect leaks (mail sent to
28
+ # a foreign tenant's address) that leave no trace in any response.
29
+ #
30
+ # NOT covered: anything a persona can neither be linked to nor have
31
+ # its params mutated into, and any field carrying no marker. The lint
32
+ # reduces the cross-tenant surface; it does not eliminate it.
33
+ #
34
+ # ## Usage
35
+ # This file holds only the pure, Rails-free core (so the gem's own
36
+ # spec suite can load it). Projects include the runtime wrapper into
37
+ # their base integration test instead — see
38
+ # DevDoc::Test::Lints::CrossTenantCanarySweep.
39
+ module CrossTenantCanaryCheck
40
+ # The marker convention: `CANARY-<tenant>-<field>`. Consumers seed these
41
+ # STATIC strings onto tenant-owned fields. The tenant id is embedded, so
42
+ # collection needs no per-model knowledge — grouping is by the marker
43
+ # itself.
44
+ MARKER = /CANARY-\d+-[a-z0-9-]+/
45
+ TENANT_FROM_MARKER = /\ACANARY-(\d+)-/
46
+
47
+ module_function
48
+
49
+ # Build `{ tenant_key => [markers...] }` from arbitrary text sources —
50
+ # fixture-file contents, DB column values, rendered snapshots — grouping
51
+ # each marker by the tenant id embedded in it. Pure string logic: the
52
+ # consumer owns WHERE the text comes from (fixtures path, models), the
53
+ # gem owns the marker format and the grouping. Deduped per tenant.
54
+ def tenant_registry(texts)
55
+ registry = Hash.new { |hash, key| hash[key] = [] }
56
+ texts.each do |text|
57
+ text.to_s.scan(MARKER).each do |marker|
58
+ registry[marker[TENANT_FROM_MARKER, 1]] << marker
59
+ end
60
+ end
61
+ registry.transform_values(&:uniq)
62
+ end
63
+
64
+ # Markers belonging to every tenant OUTSIDE the persona's allowed
65
+ # set. Takes a SET of allowed tenant keys, not a single tenant — a
66
+ # global admin legitimately sees every tenant (pass all keys and the
67
+ # check is vacuous for it), and hierarchical tenants can grant a
68
+ # parent visibility into its children. Keys compare as strings so
69
+ # fixture-derived integers and configured symbols can't miss.
70
+ def foreign_markers(allowed_tenant_keys, registry)
71
+ allowed = allowed_tenant_keys.map(&:to_s)
72
+ registry.reject { |key, _| allowed.include?(key.to_s) }
73
+ .values.flatten.uniq
74
+ end
75
+
76
+ # The subset of markers present in the body. Boundary-aware, like
77
+ # CopDriftCheck.unmentioned_cops: a marker that PREFIXES a longer
78
+ # marker must not match on the longer one's occurrence, or the
79
+ # failure names the wrong leaked field.
80
+ def leaked_markers(body, markers)
81
+ return [] if body.nil? || body.empty?
82
+
83
+ markers.select { |marker| body.match?(/#{Regexp.escape(marker)}(?![A-Za-z0-9-])/) }
84
+ end
85
+
86
+ # Outbound recipients that belong to a foreign tenant. Emails are
87
+ # case-insensitive per RFC domain rules and common practice, so the
88
+ # intersection normalizes case on both sides.
89
+ def leaked_recipients(recipients, foreign_emails)
90
+ normalize(recipients) & normalize(foreign_emails)
91
+ end
92
+
93
+ # Param keys worth substituting with a foreign tenant's values in an
94
+ # adversarial replay: `id`, anything ending `_id`, plus an explicit
95
+ # project-configured allowlist (e.g. a display-mode toggle that
96
+ # switches a form into a data-bearing preview).
97
+ def id_like_param_keys(keys, extra: [])
98
+ extras = extra.map(&:to_s)
99
+ keys.map(&:to_s)
100
+ .select { |key| key == 'id' || key.end_with?('_id') || extras.include?(key) }
101
+ .uniq
102
+ end
103
+
104
+ def normalize(emails)
105
+ Array(emails).compact.map { |email| email.to_s.downcase }
106
+ end
107
+ private_class_method :normalize
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,103 @@
1
+ require 'dev_doc/test/lints/cross_tenant_canary_check'
2
+
3
+ module DevDoc
4
+ module Test
5
+ module Lints
6
+ # L1 of the cross-tenant canary sweep (see CrossTenantCanaryCheck for the
7
+ # doctrine and the honest coverage/miss statement). Passive: it asserts
8
+ # that no response a persona receives contains another tenant's canary
9
+ # marker. It adds no requests of its own — it rides whatever crawl/replay
10
+ # the host test already drives.
11
+ #
12
+ # Include into the project's base integration test:
13
+ #
14
+ # Glib::IntegrationTest.include DevDoc::Test::Lints::CrossTenantCanarySweep
15
+ #
16
+ # ## The choke point
17
+ # Every request helper (`get`/`post`/...) funnels through the verb methods
18
+ # this module overrides (the module sits ahead of the framework Runner in
19
+ # the MRO). A JSON crawler that drives requests via `send(:get, ...)` on
20
+ # the test instance is caught the same way as a direct call — no crawler
21
+ # changes needed. After `super` returns, `response.body` holds the body to
22
+ # scan.
23
+ #
24
+ # ## Where this has signal, and where it deliberately doesn't
25
+ # The scan only sees what the response actually rendered:
26
+ # - Full-render harnesses (a crawler that renders pages to follow their
27
+ # links; a permission replay run in FULL mode) → real signal: a foreign
28
+ # marker in a persona's own reachable page is a data-bleed leak.
29
+ # - A permission replay in its default SKIP mode short-circuits the
30
+ # controller action to a status-only body, so there is no rendered
31
+ # content to scan. That harness catches cross-tenant access at the
32
+ # STATUS level (its response-matrix snapshot); this lint contributes no
33
+ # content signal there, and that is fine — the two are complementary,
34
+ # not redundant. Passing this lint in skip mode is not evidence of
35
+ # isolation; the matrix is.
36
+ # This layer is passive, so it never reaches a leak a persona is not
37
+ # already linked to — that is the adversarial replay's job (L2/L3), which
38
+ # the host app owns because param-mutation and mail-capture are too
39
+ # app-shaped to generalize. Keep this core pure.
40
+ #
41
+ # ## Consumer contract
42
+ # The including test must provide two things, refreshed per persona:
43
+ # - `canary_registry` → `{ tenant_key => [markers...] }`, scanned from
44
+ # fixtures once (static strings only — never runtime-minted, per the
45
+ # determinism doctrine).
46
+ # - `canary_allowed_tenant_keys` → the tenant keys the CURRENT persona may
47
+ # legitimately see. A single-tenant member returns its one key; a global
48
+ # admin returns every key (making the scan vacuous for it — correct, it
49
+ # sees everything); a parent in a tenant hierarchy returns itself plus
50
+ # its descendants. Returning nil disables the scan for that request
51
+ # (e.g. before any persona has logged in).
52
+ module CrossTenantCanarySweep
53
+ %i[get post patch put delete head options].each do |verb|
54
+ define_method(verb) do |*args, **kwargs, &block|
55
+ result = super(*args, **kwargs, &block)
56
+ __dev_doc_scan_for_foreign_canary
57
+ result
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def __dev_doc_scan_for_foreign_canary
64
+ allowed = canary_allowed_tenant_keys
65
+ return if allowed.nil?
66
+
67
+ registry = canary_registry
68
+ return if registry.nil? || registry.empty?
69
+
70
+ foreign = CrossTenantCanaryCheck.foreign_markers(allowed, registry)
71
+ leaked = CrossTenantCanaryCheck.leaked_markers(__dev_doc_response_body, foreign)
72
+ return if leaked.empty?
73
+
74
+ raise Minitest::Assertion, __dev_doc_canary_message(allowed, leaked)
75
+ end
76
+
77
+ def __dev_doc_response_body
78
+ response&.body.to_s
79
+ rescue StandardError
80
+ ''
81
+ end
82
+
83
+ def __dev_doc_canary_message(allowed, leaked)
84
+ <<~MSG
85
+ Cross-tenant leak: this persona (allowed tenants: #{Array(allowed).join(', ')}) received a
86
+ response containing #{leaked.join(', ')}, which belongs to a tenant it must not see.
87
+ Route: #{__dev_doc_route_label}
88
+ Another tenant's data rendered into this persona's response — a cross-tenant read leak.
89
+ Bind the record to the requester: scope the lookup through the persona's tenant
90
+ (current_user / current organization), or gate the policy on the record's tenant.
91
+ See DevDoc/Auth/UnscopedFindJustification and DevDoc/Auth/LoginOnlyPolicyJustification
92
+ for the static companions.
93
+ MSG
94
+ end
95
+
96
+ def __dev_doc_route_label
97
+ req = request
98
+ req ? "#{req.request_method} #{req.fullpath}" : '(unknown)'
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,55 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # The adjacent-justification contract shared by the auth "flag and make
6
+ # the developer state the mechanism" cops: an offense is silenced by a
7
+ # comment containing the cop's marker phrase either on the flagged
8
+ # node's own line (trailing) or in the contiguous comment block ending
9
+ # on the line directly above it. Adjacency is the point — a marker
10
+ # elsewhere in the file justifies nothing. Host cops must provide
11
+ # `justification_marker`.
12
+ #
13
+ # With `standalone_only: true` the upward walk only crosses comment-ONLY
14
+ # lines. Use this when the checked node is a class/module declaration:
15
+ # there the "trailing comment on a preceding code line" rule would let a
16
+ # marker trailing the PREVIOUS class's `end` silence the next class.
17
+ module AdjacentJustification
18
+ private
19
+
20
+ def adjacent_justification?(node, standalone_only: false)
21
+ marker = justification_marker.downcase
22
+ comments_by_line = processed_source.comments.group_by { |comment| comment.location.line }
23
+
24
+ return true if contains_marker?(comments_by_line[node.first_line], marker)
25
+
26
+ marker_in_block_above?(node.first_line - 1, comments_by_line, marker, standalone_only)
27
+ end
28
+
29
+ # Walk the contiguous comment block upward from the line directly
30
+ # above. A trailing comment on a preceding code line also counts —
31
+ # still adjacent, still a deliberate statement — except in
32
+ # standalone_only mode.
33
+ def marker_in_block_above?(line, comments_by_line, marker, standalone_only)
34
+ while (line_comments = comments_by_line[line])
35
+ break if standalone_only && !comment_only_line?(line)
36
+ return true if contains_marker?(line_comments, marker)
37
+
38
+ line -= 1
39
+ end
40
+
41
+ false
42
+ end
43
+
44
+ def comment_only_line?(line)
45
+ processed_source.lines[line - 1].lstrip.start_with?('#')
46
+ end
47
+
48
+ def contains_marker?(comments, marker)
49
+ Array(comments).any? { |comment| comment.text.downcase.include?(marker) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.12.1".freeze
3
+ VERSION = "0.12.2".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-13 00:00:00.000000000 Z
11
+ date: 2026-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -93,15 +93,21 @@ files:
93
93
  - lib/dev_doc/test/lints/cop_drift_check.rb
94
94
  - lib/dev_doc/test/lints/cop_drift_test.rb
95
95
  - lib/dev_doc/test/lints/cron_schedule.rb
96
+ - lib/dev_doc/test/lints/cross_tenant_canary_check.rb
97
+ - lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb
96
98
  - lib/dev_doc/test/lints/duplicate_snapshot.rb
97
99
  - lib/dev_doc/test/lints/http_driven_controller_tests.rb
98
100
  - lib/dev_doc/test/lints/no_file_excludes.rb
99
101
  - lib/dev_doc/test/pseudo_i18n_crawler.rb
100
102
  - lib/rubocop-dev_doc.rb
103
+ - lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb
101
104
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
102
105
  - lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb
103
106
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
107
+ - lib/rubocop/cop/dev_doc/auth/login_only_policy_justification.rb
108
+ - lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb
104
109
  - lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb
110
+ - lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb
105
111
  - lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
106
112
  - lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
107
113
  - lib/rubocop/cop/dev_doc/i18n/report_text.rb