activerecord-safer-lookup-query 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +76 -0
- data/exe/activerecord-safer-lookup-query +10 -0
- data/lib/active_record_safer_lookup_query/checker.rb +492 -0
- data/lib/active_record_safer_lookup_query/version.rb +5 -0
- data/lib/active_record_safer_lookup_query.rb +4 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9a87fedc5a45149512589bb8be278f6d11415ed04b1c6af58df82360891491f9
|
|
4
|
+
data.tar.gz: 0f3e30a871a7b709571dd986fc2e85d14017d787ee8e85f3152b142bbd649e07
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b263e41022b7bfcd9c659311057ae4d8b6a977535ffb11adfd1d1ebc1c55bb25f45381c06a48fbc9352ab1ae94b7c2c205c55d91b3c902d942003d44eb408b6c
|
|
7
|
+
data.tar.gz: 44da164571e78076467b80b50c729edbffc0d6a3920c3af6f95fb3375d37bed8e13a2c22e659bcf8262e2c306226dfb4e4c17b27f3a29e4a36e397f3ebddbb2c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# activerecord-safer-lookup-query
|
|
2
|
+
|
|
3
|
+
`activerecord-safer-lookup-query` is a small static checker for Rails applications. It
|
|
4
|
+
looks for class-level ActiveRecord lookups that may bypass tenant, organization,
|
|
5
|
+
or user scopes.
|
|
6
|
+
|
|
7
|
+
This is an audit tool, not a proof engine. Findings should be reviewed by a
|
|
8
|
+
human before treating them as vulnerabilities.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Run it from the target Rails repository:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
exe/activerecord-safer-lookup-query
|
|
16
|
+
exe/activerecord-safer-lookup-query app/graphql app/controllers
|
|
17
|
+
exe/activerecord-safer-lookup-query --root /path/to/rails-app app/graphql
|
|
18
|
+
exe/activerecord-safer-lookup-query --fail-level HIGH app/graphql
|
|
19
|
+
exe/activerecord-safer-lookup-query --format json app/controllers/organizations
|
|
20
|
+
exe/activerecord-safer-lookup-query --whitelist config/safer-query-whitelist.yml app/graphql
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
When installed as a gem, run:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
gem install activerecord-safer-lookup-query
|
|
27
|
+
activerecord-safer-lookup-query app/graphql app/controllers
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
- `GLOBAL_FIND_EXTERNAL_INPUT`: class-level `find` / `find_by` with `params`,
|
|
33
|
+
GraphQL `input`, `args`, `session`, cookies, headers, or request data.
|
|
34
|
+
- `GLOBAL_FIND_ID_VARIABLE`: class-level `find` with a local `id`, `*_id`,
|
|
35
|
+
`*_ids`, `*_uuid`, `*_slug`, or `*_code` variable.
|
|
36
|
+
- `GLOBAL_WHERE_EXTERNAL_IDS`: class-level `where(id: ...)` with external input.
|
|
37
|
+
- `GLOBAL_NATURAL_KEY_LOOKUP`: class-level lookup by `email`, `uid`, `issuer`,
|
|
38
|
+
`code`, `subdomain`, `slug`, `token`, or similar natural keys.
|
|
39
|
+
- `UNSCOPED_DESTRUCTIVE_IDS`: destructive operations driven by external/global
|
|
40
|
+
IDs.
|
|
41
|
+
- `WITHOUT_TENANT_BOUNDARY`: boundary code that calls
|
|
42
|
+
`ActsAsTenant.without_tenant`.
|
|
43
|
+
- `DRAFT_COURSE_EXPOSURE`: draft/closed course scopes in public-ish Rails
|
|
44
|
+
boundaries.
|
|
45
|
+
|
|
46
|
+
Suppress a known-safe finding with:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Course.find(params[:id]) # active_record_safer_lookup_query: ignore
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Suppress resolved findings in `.activerecord-safer-lookup-query.yml`:
|
|
53
|
+
|
|
54
|
+
```yml
|
|
55
|
+
whitelist:
|
|
56
|
+
- path: app/graphql/mutations/active_user/select_curriculum.rb
|
|
57
|
+
rule: GLOBAL_FIND_EXTERNAL_INPUT
|
|
58
|
+
source: Curriculum.find
|
|
59
|
+
reason: The caller already scopes available curriculum IDs.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Whitelist entries match all fields that are present. `path`, `rule`, and
|
|
63
|
+
`severity` support glob patterns, `line` can be a number or list of numbers,
|
|
64
|
+
and `source` matches a source-code fragment. `reason` is documentation-only and
|
|
65
|
+
does not affect matching.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
bundle install
|
|
71
|
+
bundle exec rake
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
module ActiveRecordSaferLookupQuery
|
|
9
|
+
class Checker
|
|
10
|
+
DEFAULT_PATHS = %w[
|
|
11
|
+
app/controllers
|
|
12
|
+
app/graphql
|
|
13
|
+
app/api
|
|
14
|
+
app/forms
|
|
15
|
+
app/services
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
DEFAULT_EXCLUDE_PATTERNS = [
|
|
19
|
+
%r{\Aapp/controllers/debug/}
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_WHITELIST_FILES = %w[
|
|
23
|
+
.activerecord-safer-lookup-query.yml
|
|
24
|
+
.activerecord-safer-lookup-query.yaml
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
IGNORE_MARKER = /(?:active_record_safer_lookup_query|activerecord_safer_query|global_find_audit|tenant_scope_audit):\s*ignore/
|
|
28
|
+
SEVERITY_RANK = {
|
|
29
|
+
'LOW' => 1,
|
|
30
|
+
'MEDIUM' => 2,
|
|
31
|
+
'HIGH' => 3
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
CONST_RECEIVER = /(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
|
|
35
|
+
PLAIN_SCOPE_CHAIN = /(?:\.[a-z_][A-Za-z0-9_!?]*)*/
|
|
36
|
+
MODEL_CHAIN = /#{CONST_RECEIVER}#{PLAIN_SCOPE_CHAIN}/
|
|
37
|
+
EXTERNAL_SOURCE = /(?:params\s*(?:\[|\.|\.dig)|input\s*(?:\[|\.|\.dig)|args\s*(?:\[|\.|\.dig)|context\s*\[|session\s*\[|cookies\s*\[|request\.|headers\s*\[)/
|
|
38
|
+
RISKY_ID_VARIABLE = /(?:\bid\b|[a-z][a-z0-9_]*(?:_id|_ids|_uuid|_slug|_code)\b)/
|
|
39
|
+
NATURAL_KEY = /(?:email|uid|issuer|code|subdomain|slug|token|account|client_id|external_id)/
|
|
40
|
+
NATURAL_KEY_VALUE = /(?:#{EXTERNAL_SOURCE}|row\b|metadata\b|attributes_hash\b|saml_setting\b|response\b|payload\b|csv\b|id_token\b|token\b|issuer\b|code\b|email\b|uid\b)/
|
|
41
|
+
FIND_METHOD = /(?:find|find_by!?|find_or_initialize_by!?|find_or_create_by!?|create_or_find_by!?)/
|
|
42
|
+
DESTRUCTIVE_METHOD = /(?:destroy_all|delete_all|update_all|delete|destroy)\b/
|
|
43
|
+
|
|
44
|
+
DIRECT_FIND_START = /\b#{MODEL_CHAIN}\.(?:friendly\.)?#{FIND_METHOD}\b/
|
|
45
|
+
DIRECT_FIND_EXTERNAL_INPUT = /\b#{MODEL_CHAIN}\.(?:friendly\.)?#{FIND_METHOD}\s*\([^)]*#{EXTERNAL_SOURCE}/
|
|
46
|
+
DIRECT_FIND_ID_START = /\b#{MODEL_CHAIN}\.(?:friendly\.)?find\b/
|
|
47
|
+
DIRECT_FIND_ID_VARIABLE = /\b#{MODEL_CHAIN}\.(?:friendly\.)?find\s*\(\s*#{RISKY_ID_VARIABLE}\s*\)/
|
|
48
|
+
WHERE_START = /\b#{MODEL_CHAIN}\.where\b/
|
|
49
|
+
WHERE_CHAIN_START = /\A\.where\b/
|
|
50
|
+
WHERE_EXTERNAL_IDS = /\b#{MODEL_CHAIN}\.where\s*\([^)]*(?:\bid\b|[a-z_]+_id):\s*[^)]*#{EXTERNAL_SOURCE}/
|
|
51
|
+
NATURAL_KEY_LOOKUP_START = /\b#{MODEL_CHAIN}\.(?:find_by!?|find_or_initialize_by!?|find_or_create_by!?|create_or_find_by!?)\b/
|
|
52
|
+
NATURAL_KEY_LOOKUP = /\b#{MODEL_CHAIN}\.(?:find_by!?|find_or_initialize_by!?|find_or_create_by!?|create_or_find_by!?)\s*\([^)]*#{NATURAL_KEY}:\s*[^)]*#{NATURAL_KEY_VALUE}/
|
|
53
|
+
DESTRUCTIVE_EXTERNAL_ID_LOOKUP = /\b#{MODEL_CHAIN}\.where\s*\([^)]*(?:\bid\b|[a-z_]+_id):[^)]*(?:#{EXTERNAL_SOURCE}|#{RISKY_ID_VARIABLE})[^)]*\).*\.#{DESTRUCTIVE_METHOD}/
|
|
54
|
+
DESTRUCTIVE_EXTERNAL_LOOKUP = /\b#{MODEL_CHAIN}\.where\s*\([^)]*(?:#{EXTERNAL_SOURCE}|#{RISKY_ID_VARIABLE})[^)]*\).*\.#{DESTRUCTIVE_METHOD}/
|
|
55
|
+
DRAFT_COURSE_SCOPE = /\bCourse\.(?:draft|closed|where\s*\([^)]*state:\s*[^)]*(?:draft|closed)|glopla_lms)\b/
|
|
56
|
+
|
|
57
|
+
Finding = Struct.new(:path, :line, :severity, :rule, :message, :source, keyword_init: true) do
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
path: path,
|
|
61
|
+
line: line,
|
|
62
|
+
severity: severity,
|
|
63
|
+
rule: rule,
|
|
64
|
+
message: message,
|
|
65
|
+
source: source
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fail_at?(threshold)
|
|
70
|
+
Checker::SEVERITY_RANK.fetch(severity) >= Checker::SEVERITY_RANK.fetch(threshold)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Whitelist
|
|
75
|
+
def self.load(root:, paths:)
|
|
76
|
+
config_paths = default_paths(root) + explicit_paths(root, paths)
|
|
77
|
+
entries = config_paths.flat_map { |path| entries_from(path) }
|
|
78
|
+
|
|
79
|
+
new(entries)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.default_paths(root)
|
|
83
|
+
DEFAULT_WHITELIST_FILES.map { |path| root.join(path) }.select(&:file?)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.explicit_paths(root, paths)
|
|
87
|
+
paths.map do |path|
|
|
88
|
+
candidate = Pathname.new(path)
|
|
89
|
+
absolute_path = candidate.absolute? ? candidate : root.join(candidate)
|
|
90
|
+
raise ArgumentError, "whitelist file does not exist: #{path}" unless absolute_path.file?
|
|
91
|
+
|
|
92
|
+
absolute_path
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.entries_from(path)
|
|
97
|
+
config = YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
|
|
98
|
+
entries = if config.is_a?(Array)
|
|
99
|
+
config
|
|
100
|
+
elsif config.is_a?(Hash)
|
|
101
|
+
config.fetch('whitelist', config.fetch('allowlist', []))
|
|
102
|
+
else
|
|
103
|
+
raise ArgumentError, "whitelist file must contain a hash or array: #{path}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
unless entries.is_a?(Array)
|
|
107
|
+
raise ArgumentError, "whitelist entries must be an array: #{path}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
entries.map { |entry| Entry.new(entry, path) }
|
|
111
|
+
rescue Psych::SyntaxError => e
|
|
112
|
+
raise ArgumentError, "invalid whitelist YAML: #{path}: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def initialize(entries)
|
|
116
|
+
@entries = entries
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def match?(finding)
|
|
120
|
+
@entries.any? { |entry| entry.match?(finding) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class Entry
|
|
124
|
+
def initialize(config, path)
|
|
125
|
+
unless config.is_a?(Hash)
|
|
126
|
+
raise ArgumentError, "whitelist entry must be a hash: #{path}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@path = value(config, 'path')
|
|
130
|
+
@rule = value(config, 'rule')
|
|
131
|
+
@severity = value(config, 'severity')
|
|
132
|
+
@line = value(config, 'line')
|
|
133
|
+
@source = value(config, 'source')
|
|
134
|
+
@reason = value(config, 'reason')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def match?(finding)
|
|
138
|
+
match_pattern?(@path, finding.path) &&
|
|
139
|
+
match_pattern?(@rule, finding.rule) &&
|
|
140
|
+
match_pattern?(@severity, finding.severity) &&
|
|
141
|
+
match_line?(finding.line) &&
|
|
142
|
+
match_source?(finding.source)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def value(config, key)
|
|
148
|
+
config[key] || config[key.to_sym]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def match_pattern?(expected, actual)
|
|
152
|
+
return true if expected.nil?
|
|
153
|
+
|
|
154
|
+
Array(expected).any? do |pattern|
|
|
155
|
+
pattern = pattern.to_s
|
|
156
|
+
actual == pattern || File.fnmatch?(pattern, actual)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def match_line?(actual)
|
|
161
|
+
return true if @line.nil?
|
|
162
|
+
|
|
163
|
+
Array(@line).map(&:to_i).include?(actual)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def match_source?(actual)
|
|
167
|
+
return true if @source.nil?
|
|
168
|
+
|
|
169
|
+
Array(@source).any? { |source| actual.include?(source.to_s) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def initialize(paths: DEFAULT_PATHS, root: Dir.pwd, whitelist_paths: [])
|
|
175
|
+
@root = Pathname.new(root).expand_path
|
|
176
|
+
@paths = paths.empty? ? DEFAULT_PATHS : paths
|
|
177
|
+
@whitelist = Whitelist.load(root: @root, paths: whitelist_paths)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def findings
|
|
181
|
+
ruby_files.flat_map { |path| findings_for(path) }
|
|
182
|
+
.uniq { |finding| [finding.path, finding.line, finding.rule] }
|
|
183
|
+
.reject { |finding| whitelist.match?(finding) }
|
|
184
|
+
.sort_by { |finding| [finding.path, finding.line, finding.rule] }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
attr_reader :root, :paths, :whitelist
|
|
190
|
+
|
|
191
|
+
def ruby_files
|
|
192
|
+
files = paths.flat_map do |path|
|
|
193
|
+
absolute_path = absolute(path)
|
|
194
|
+
if absolute_path.file?
|
|
195
|
+
[absolute_path]
|
|
196
|
+
elsif absolute_path.directory?
|
|
197
|
+
absolute_path.glob('**/*.rb')
|
|
198
|
+
else
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
files.uniq.sort.reject { |path| excluded?(relative(path)) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def findings_for(path)
|
|
207
|
+
lines = path.readlines(chomp: false)
|
|
208
|
+
lines.each_with_index.flat_map do |line, index|
|
|
209
|
+
next [] unless interesting_line?(line)
|
|
210
|
+
|
|
211
|
+
context = context_around(lines, index)
|
|
212
|
+
forward_context = context_from(lines, index)
|
|
213
|
+
next [] if ignored?(context)
|
|
214
|
+
|
|
215
|
+
rules_for(relative(path), index + 1, line, context, forward_context)
|
|
216
|
+
end
|
|
217
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
|
|
218
|
+
[
|
|
219
|
+
Finding.new(
|
|
220
|
+
path: relative(path),
|
|
221
|
+
line: 1,
|
|
222
|
+
severity: 'LOW',
|
|
223
|
+
rule: 'UNREADABLE_FILE',
|
|
224
|
+
message: "Could not read as UTF-8: #{e.class}",
|
|
225
|
+
source: ''
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def rules_for(path, line_number, line, context, forward_context)
|
|
231
|
+
current_line = normalize(line)
|
|
232
|
+
normalized = normalize(context)
|
|
233
|
+
normalized_forward = normalize(forward_context)
|
|
234
|
+
findings = []
|
|
235
|
+
|
|
236
|
+
if direct_find_from_external_input?(current_line, normalized_forward)
|
|
237
|
+
findings << build_finding(
|
|
238
|
+
path: path,
|
|
239
|
+
line: line_number,
|
|
240
|
+
severity: 'HIGH',
|
|
241
|
+
rule: 'GLOBAL_FIND_EXTERNAL_INPUT',
|
|
242
|
+
message: 'Class-level find/find_by uses params/input/args. Resolve from a tenant/user-scoped relation first.',
|
|
243
|
+
source: source_for(line, normalized)
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
if direct_find_from_id_variable?(current_line, normalized_forward)
|
|
248
|
+
findings << build_finding(
|
|
249
|
+
path: path,
|
|
250
|
+
line: line_number,
|
|
251
|
+
severity: 'MEDIUM',
|
|
252
|
+
rule: 'GLOBAL_FIND_ID_VARIABLE',
|
|
253
|
+
message: 'Class-level find uses a local *_id/id variable. Check that the variable was scoped before lookup.',
|
|
254
|
+
source: source_for(line, normalized)
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if where_from_external_ids?(current_line, normalized_forward)
|
|
259
|
+
findings << build_finding(
|
|
260
|
+
path: path,
|
|
261
|
+
line: line_number,
|
|
262
|
+
severity: 'HIGH',
|
|
263
|
+
rule: 'GLOBAL_WHERE_EXTERNAL_IDS',
|
|
264
|
+
message: 'Class-level where(id: ...) uses external input. Intersect with the current tenant/user relation first.',
|
|
265
|
+
source: source_for(line, normalized)
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if natural_key_lookup?(current_line, normalized_forward)
|
|
270
|
+
findings << build_finding(
|
|
271
|
+
path: path,
|
|
272
|
+
line: line_number,
|
|
273
|
+
severity: 'MEDIUM',
|
|
274
|
+
rule: 'GLOBAL_NATURAL_KEY_LOOKUP',
|
|
275
|
+
message: 'Class-level lookup by email/uid/issuer/code/etc. can cross tenant boundaries unless scoped.',
|
|
276
|
+
source: source_for(line, normalized)
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if destructive_external_ids?(current_line, normalized)
|
|
281
|
+
findings << build_finding(
|
|
282
|
+
path: path,
|
|
283
|
+
line: line_number,
|
|
284
|
+
severity: 'HIGH',
|
|
285
|
+
rule: 'UNSCOPED_DESTRUCTIVE_IDS',
|
|
286
|
+
message: 'Destructive operation is driven by external/global IDs. Scope the target set before deleting/updating.',
|
|
287
|
+
source: source_for(line, normalized)
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if without_tenant_boundary?(path, current_line)
|
|
292
|
+
findings << build_finding(
|
|
293
|
+
path: path,
|
|
294
|
+
line: line_number,
|
|
295
|
+
severity: 'LOW',
|
|
296
|
+
rule: 'WITHOUT_TENANT_BOUNDARY',
|
|
297
|
+
message: 'Boundary code disables tenant scoping. Verify that no cross-tenant data is returned to the caller.',
|
|
298
|
+
source: source_for(line, normalized)
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
if draft_course_exposure?(path, current_line)
|
|
303
|
+
findings << build_finding(
|
|
304
|
+
path: path,
|
|
305
|
+
line: line_number,
|
|
306
|
+
severity: 'MEDIUM',
|
|
307
|
+
rule: 'DRAFT_COURSE_EXPOSURE',
|
|
308
|
+
message: 'Draft/closed course scope appears in an external boundary. Verify user entitlement before returning it.',
|
|
309
|
+
source: source_for(line, normalized)
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
findings
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def direct_find_from_external_input?(current_line, normalized)
|
|
317
|
+
return false unless current_line.match?(DIRECT_FIND_START)
|
|
318
|
+
|
|
319
|
+
normalized.match?(DIRECT_FIND_EXTERNAL_INPUT)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def direct_find_from_id_variable?(current_line, normalized)
|
|
323
|
+
return false unless current_line.match?(DIRECT_FIND_ID_START)
|
|
324
|
+
|
|
325
|
+
normalized.match?(DIRECT_FIND_ID_VARIABLE)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def where_from_external_ids?(current_line, normalized)
|
|
329
|
+
return false unless current_line.match?(WHERE_START) || current_line.match?(WHERE_CHAIN_START)
|
|
330
|
+
|
|
331
|
+
normalized.match?(WHERE_EXTERNAL_IDS)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def natural_key_lookup?(current_line, normalized)
|
|
335
|
+
return false unless current_line.match?(NATURAL_KEY_LOOKUP_START)
|
|
336
|
+
|
|
337
|
+
normalized.match?(NATURAL_KEY_LOOKUP)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def destructive_external_ids?(current_line, normalized)
|
|
341
|
+
return false unless current_line.match?(DESTRUCTIVE_METHOD)
|
|
342
|
+
|
|
343
|
+
normalized.match?(DESTRUCTIVE_EXTERNAL_ID_LOOKUP) ||
|
|
344
|
+
normalized.match?(DESTRUCTIVE_EXTERNAL_LOOKUP)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def without_tenant_boundary?(path, current_line)
|
|
348
|
+
boundary_path?(path) && current_line.include?('ActsAsTenant.without_tenant')
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def draft_course_exposure?(path, current_line)
|
|
352
|
+
return false unless boundary_path?(path)
|
|
353
|
+
return false unless path.include?('/active_user/') || path.include?('/partner/') || path.include?('/api/') || path.include?('/graphql/')
|
|
354
|
+
|
|
355
|
+
current_line.match?(DRAFT_COURSE_SCOPE) ||
|
|
356
|
+
(current_line.include?('draft_or_published') && !current_line.include?('viewable'))
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def interesting_line?(line)
|
|
360
|
+
stripped = line.strip
|
|
361
|
+
return false if stripped.empty? || stripped.start_with?('#')
|
|
362
|
+
|
|
363
|
+
stripped.match?(CONST_RECEIVER) ||
|
|
364
|
+
stripped.include?('ActsAsTenant.without_tenant') ||
|
|
365
|
+
stripped.match?(DESTRUCTIVE_METHOD) ||
|
|
366
|
+
stripped.include?('draft_or_published')
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def ignored?(context)
|
|
370
|
+
context.match?(IGNORE_MARKER)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def boundary_path?(path)
|
|
374
|
+
path.start_with?('app/controllers/', 'app/graphql/', 'app/api/', 'app/forms/', 'app/services/')
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def context_around(lines, index)
|
|
378
|
+
from = [index - 4, 0].max
|
|
379
|
+
to = [index + 4, lines.length - 1].min
|
|
380
|
+
lines[from..to].join
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def context_from(lines, index)
|
|
384
|
+
to = [index + 4, lines.length - 1].min
|
|
385
|
+
lines[index..to].join
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def normalize(source)
|
|
389
|
+
source.gsub(/#.*$/, '').gsub(/\s+/, ' ').gsub(/\s+\./, '.').strip
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def source_for(line, normalized)
|
|
393
|
+
source = line.strip.empty? ? normalized : line.strip
|
|
394
|
+
source.gsub(/\s+/, ' ')[0, 220]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def build_finding(path:, line:, severity:, rule:, message:, source:)
|
|
398
|
+
Finding.new(
|
|
399
|
+
path: path,
|
|
400
|
+
line: line,
|
|
401
|
+
severity: severity,
|
|
402
|
+
rule: rule,
|
|
403
|
+
message: message,
|
|
404
|
+
source: source
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def absolute(path)
|
|
409
|
+
candidate = Pathname.new(path)
|
|
410
|
+
candidate.absolute? ? candidate : root.join(candidate)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def relative(path)
|
|
414
|
+
path.expand_path.relative_path_from(root).to_s
|
|
415
|
+
rescue ArgumentError
|
|
416
|
+
path.to_s
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def excluded?(relative_path)
|
|
420
|
+
DEFAULT_EXCLUDE_PATTERNS.any? { |pattern| relative_path.match?(pattern) }
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
class Cli
|
|
425
|
+
DEFAULT_FORMAT = 'text'
|
|
426
|
+
DEFAULT_FAIL_LEVEL = 'LOW'
|
|
427
|
+
|
|
428
|
+
def self.run(argv = ARGV, out: $stdout, err: $stderr)
|
|
429
|
+
options = {
|
|
430
|
+
format: DEFAULT_FORMAT,
|
|
431
|
+
fail_level: DEFAULT_FAIL_LEVEL,
|
|
432
|
+
root: Dir.pwd,
|
|
433
|
+
whitelist_paths: []
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
parser = OptionParser.new do |opts|
|
|
437
|
+
opts.banner = 'Usage: activerecord-safer-lookup-query [options] [paths...]'
|
|
438
|
+
opts.separator ''
|
|
439
|
+
opts.separator 'Detect class-level ActiveRecord lookups that may bypass tenant/user scopes.'
|
|
440
|
+
opts.separator ''
|
|
441
|
+
opts.on('--root PATH', 'Target repository root. Default: current directory') { |value| options[:root] = value }
|
|
442
|
+
opts.on('--format FORMAT', 'text or json') { |value| options[:format] = value }
|
|
443
|
+
opts.on('--fail-level LEVEL', 'LOW, MEDIUM, or HIGH. Default: LOW') { |value| options[:fail_level] = value.upcase }
|
|
444
|
+
opts.on('--whitelist PATH', 'YAML whitelist file. Can be used multiple times') { |value| options[:whitelist_paths] << value }
|
|
445
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
446
|
+
out.puts opts
|
|
447
|
+
return 0
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
paths = parser.parse(argv)
|
|
452
|
+
validate_options!(options)
|
|
453
|
+
|
|
454
|
+
findings = Checker.new(paths: paths, root: options[:root], whitelist_paths: options[:whitelist_paths]).findings
|
|
455
|
+
emit(findings, options, out)
|
|
456
|
+
|
|
457
|
+
findings.any? { |finding| finding.fail_at?(options[:fail_level]) } ? 1 : 0
|
|
458
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
459
|
+
err.puts "[activerecord-safer-lookup-query] #{e.message}"
|
|
460
|
+
err.puts parser
|
|
461
|
+
2
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def self.validate_options!(options)
|
|
465
|
+
unless %w[text json].include?(options[:format])
|
|
466
|
+
raise ArgumentError, "--format must be text or json: #{options[:format]}"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
unless Checker::SEVERITY_RANK.key?(options[:fail_level])
|
|
470
|
+
raise ArgumentError, "--fail-level must be LOW, MEDIUM, or HIGH: #{options[:fail_level]}"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def self.emit(findings, options, out)
|
|
475
|
+
if options[:format] == 'json'
|
|
476
|
+
out.puts JSON.pretty_generate(findings.map(&:to_h))
|
|
477
|
+
return
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
if findings.empty?
|
|
481
|
+
out.puts '[activerecord-safer-lookup-query] no findings'
|
|
482
|
+
return
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
out.puts "[activerecord-safer-lookup-query] #{findings.size} findings"
|
|
486
|
+
findings.each do |finding|
|
|
487
|
+
out.puts "#{finding.path}:#{finding.line}: #{finding.severity} #{finding.rule}: #{finding.message}"
|
|
488
|
+
out.puts " #{finding.source}" unless finding.source.empty?
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-safer-lookup-query
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- developer
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-15 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rspec
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.13'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.13'
|
|
40
|
+
description: Audits Rails code for class-level ActiveRecord lookups that may bypass
|
|
41
|
+
tenant, organization, or user scopes.
|
|
42
|
+
email: []
|
|
43
|
+
executables:
|
|
44
|
+
- activerecord-safer-lookup-query
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- exe/activerecord-safer-lookup-query
|
|
51
|
+
- lib/active_record_safer_lookup_query.rb
|
|
52
|
+
- lib/active_record_safer_lookup_query/checker.rb
|
|
53
|
+
- lib/active_record_safer_lookup_query/version.rb
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata: {}
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.1'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.6.2
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Static checker for risky class-level ActiveRecord lookups
|
|
74
|
+
test_files: []
|