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 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,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ require 'active_record_safer_lookup_query'
6
+ rescue LoadError
7
+ require_relative '../lib/active_record_safer_lookup_query'
8
+ end
9
+
10
+ exit ActiveRecordSaferLookupQuery::Cli.run(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordSaferLookupQuery
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'active_record_safer_lookup_query/checker'
4
+ require_relative 'active_record_safer_lookup_query/version'
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: []