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 +4 -4
- data/config/default.yml +60 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_check.rb +111 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +103 -0
- data/lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb +55 -0
- data/lib/rubocop/cop/dev_doc/auth/login_only_policy_justification.rb +165 -0
- data/lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb +104 -0
- data/lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb +194 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4840aa1ca5c8a4ee2f5dfcb5b2e6880dbe7966d451b8502e0983e8eb63e9b04c
|
|
4
|
+
data.tar.gz: 4cc9d0f5a9e352f4b361b831250e5683242a0b35578b8abb43eb3535a18c9a1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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.
|
|
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-
|
|
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
|