scryer 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/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- metadata +106 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module PerformanceRules
|
|
3
|
+
# Flags `.save`/`.save!`/`.update`/`.update!`/`.update_attribute(s)` called
|
|
4
|
+
# on the block variable inside a `.each`/`.each_with_index` loop — one
|
|
5
|
+
# database write per iteration where a single bulk statement
|
|
6
|
+
# (`update_all`/`insert_all`/`upsert_all`) could often do the same work in
|
|
7
|
+
# one round-trip. Best-effort: this rule can't tell whether the
|
|
8
|
+
# per-record operation is actually uniform enough to batch (e.g. distinct
|
|
9
|
+
# values per row still need `update_all` with a `CASE`, or don't fit the
|
|
10
|
+
# bulk-method shape at all) — it's a nudge to double check, not a
|
|
11
|
+
# guarantee the loop is replaceable as-is.
|
|
12
|
+
class InefficientSaveLoopRule < Rule
|
|
13
|
+
self.rule_id = "inefficient_save_loop"
|
|
14
|
+
self.category = "performance"
|
|
15
|
+
self.default_severity = "warning"
|
|
16
|
+
self.title = "Per-record save/update inside a loop"
|
|
17
|
+
|
|
18
|
+
LOOP_METHODS = %w[each each_with_index].freeze
|
|
19
|
+
# Genuinely argless in normal use — a bare `:call` node, never wrapped
|
|
20
|
+
# by a method_add_arg, so matching this tag alone can't double-count.
|
|
21
|
+
BARE_METHODS = %w[save save!].freeze
|
|
22
|
+
# Always take an argument, so they only ever show up wrapped in
|
|
23
|
+
# method_add_arg/command/command_call — matching only the wrapper
|
|
24
|
+
# avoids double-counting the inner call node these wrap.
|
|
25
|
+
ARG_METHODS = %w[update update! update_attribute update_attributes].freeze
|
|
26
|
+
|
|
27
|
+
def scan
|
|
28
|
+
findings = []
|
|
29
|
+
|
|
30
|
+
Ast.each_node(sexp) do |node|
|
|
31
|
+
next unless Ast.tagged?(node, :method_add_block)
|
|
32
|
+
|
|
33
|
+
call_node = node[1]
|
|
34
|
+
_receiver, loop_method = Ast.call_name(call_node) || [nil, nil]
|
|
35
|
+
next unless LOOP_METHODS.include?(loop_method)
|
|
36
|
+
|
|
37
|
+
block_node = node[2]
|
|
38
|
+
param_name = block_param_name(block_node)
|
|
39
|
+
next unless param_name
|
|
40
|
+
|
|
41
|
+
find_save_calls(block_node[2], param_name).each do |line, method_name|
|
|
42
|
+
findings << finding(
|
|
43
|
+
line: line,
|
|
44
|
+
message: "Inside a `.each` loop, `.#{method_name}` runs once per record — each call " \
|
|
45
|
+
"is a separate database round-trip, which scales linearly with the number " \
|
|
46
|
+
"of records instead of running as one bulk statement.",
|
|
47
|
+
suggested_fix: "If every record gets the same update, replace the loop with " \
|
|
48
|
+
"`Model.where(...).update_all(column: value)` (one UPDATE for the whole " \
|
|
49
|
+
"set). If each record's new values differ but come from data already " \
|
|
50
|
+
"in hand, `upsert_all`/`insert_all` with an array of attribute hashes " \
|
|
51
|
+
"can also replace the per-row round-trips."
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
findings
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def block_param_name(block_node)
|
|
62
|
+
return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
|
|
63
|
+
|
|
64
|
+
block_var = block_node[1]
|
|
65
|
+
return nil unless Ast.tagged?(block_var, :block_var)
|
|
66
|
+
|
|
67
|
+
params = block_var[1]
|
|
68
|
+
first_param = params.is_a?(Array) ? params[1]&.first : nil
|
|
69
|
+
return nil unless first_param.is_a?(Array) && first_param[0] == :@ident
|
|
70
|
+
|
|
71
|
+
first_param[1]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def block_var_receiver?(node, param_name)
|
|
75
|
+
return false unless Ast.tagged?(node, :var_ref, :vcall)
|
|
76
|
+
|
|
77
|
+
inner = node[1]
|
|
78
|
+
inner.is_a?(Array) && %i[@ident @ivar].include?(inner[0]) && inner[1] == param_name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_save_calls(body, param_name)
|
|
82
|
+
results = []
|
|
83
|
+
|
|
84
|
+
Ast.each_node(body) do |n|
|
|
85
|
+
if Ast.tagged?(n, :call)
|
|
86
|
+
name = Ast.ident_text(n[3])
|
|
87
|
+
next unless BARE_METHODS.include?(name)
|
|
88
|
+
next unless block_var_receiver?(n[1], param_name)
|
|
89
|
+
|
|
90
|
+
results << [Ast.line_of(n), name]
|
|
91
|
+
elsif Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
92
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
93
|
+
receiver_and_name = Ast.call_name(inner)
|
|
94
|
+
next unless receiver_and_name
|
|
95
|
+
|
|
96
|
+
receiver, name = receiver_and_name
|
|
97
|
+
next unless ARG_METHODS.include?(name)
|
|
98
|
+
next unless block_var_receiver?(receiver, param_name)
|
|
99
|
+
|
|
100
|
+
results << [Ast.line_of(n), name]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
results
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module PerformanceRules
|
|
3
|
+
# Flags a controller `index` action that loads `Model.all` or a bare
|
|
4
|
+
# `Model.where(...)` (no `.limit`/`.page`/`.per`/`.paginate`/`.find_each`
|
|
5
|
+
# bound anywhere in the chain) and hands the result straight to an
|
|
6
|
+
# instance variable or `render` — i.e. nothing bounds how many rows get
|
|
7
|
+
# loaded and rendered. Heuristic: only looks at the literal call chain
|
|
8
|
+
# text, not what happens to the variable afterwards (e.g. slicing it in
|
|
9
|
+
# the view would not be detected as "safe" by this rule).
|
|
10
|
+
class MissingPaginationRule < Rule
|
|
11
|
+
self.rule_id = "missing_pagination"
|
|
12
|
+
self.category = "performance"
|
|
13
|
+
self.default_severity = "warning"
|
|
14
|
+
self.title = "Possible unbounded result set on an index action"
|
|
15
|
+
|
|
16
|
+
QUERY_METHODS = %w[all where].freeze
|
|
17
|
+
BOUND_METHODS = %w[limit page per paginate find_each find_in_batches first take].freeze
|
|
18
|
+
|
|
19
|
+
def scan
|
|
20
|
+
findings = []
|
|
21
|
+
|
|
22
|
+
Ast.each_node(sexp) do |node|
|
|
23
|
+
next unless Ast.tagged?(node, :def)
|
|
24
|
+
next unless Ast.ident_text(node[1]) == "index"
|
|
25
|
+
|
|
26
|
+
body = node.last
|
|
27
|
+
findings.concat(check_body(body))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
findings
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def check_body(body)
|
|
36
|
+
results = []
|
|
37
|
+
seen_lines = []
|
|
38
|
+
|
|
39
|
+
sink_values(body).each do |value|
|
|
40
|
+
next unless unbounded_query?(value)
|
|
41
|
+
|
|
42
|
+
line = Ast.line_of(value)
|
|
43
|
+
next if seen_lines.include?(line)
|
|
44
|
+
|
|
45
|
+
seen_lines << line
|
|
46
|
+
results << finding(
|
|
47
|
+
line: line,
|
|
48
|
+
message: "The `index` action loads records with no `.limit`/`.page`/`.per` bound — " \
|
|
49
|
+
"as the table grows this action will load (and likely render) every row, " \
|
|
50
|
+
"getting slower and more memory-hungry over time.",
|
|
51
|
+
suggested_fix: "Bound the result set, e.g. with Kaminari/will_paginate " \
|
|
52
|
+
"(`Model.page(params[:page]).per(25)`) or a plain `.limit(...)`, " \
|
|
53
|
+
"so the action's cost stays roughly constant as the table grows."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
results
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Collects the value expressions that end up as an ivar/local assignment
|
|
61
|
+
# or a `render(...)` argument — the "sinks" an unbounded query could flow
|
|
62
|
+
# into directly.
|
|
63
|
+
def sink_values(body)
|
|
64
|
+
values = []
|
|
65
|
+
|
|
66
|
+
Ast.each_node(body) do |node|
|
|
67
|
+
if Ast.tagged?(node, :assign) && assignable_target?(node[1])
|
|
68
|
+
values << node[2]
|
|
69
|
+
elsif render_call?(node)
|
|
70
|
+
values.concat(flatten_arg_values(Ast.call_arguments(node)))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
values
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def assignable_target?(target)
|
|
78
|
+
return false unless Ast.tagged?(target, :var_field)
|
|
79
|
+
|
|
80
|
+
inner = target[1]
|
|
81
|
+
inner.is_a?(Array) && %i[@ident @ivar].include?(inner[0])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_call?(node)
|
|
85
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
86
|
+
return false unless Ast.tagged?(inner, :command, :fcall, :call, :vcall, :command_call)
|
|
87
|
+
|
|
88
|
+
Ast.call_name(inner)&.last == "render"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def flatten_arg_values(nodes)
|
|
92
|
+
nodes.flat_map do |n|
|
|
93
|
+
if Ast.tagged?(n, :bare_assoc_hash)
|
|
94
|
+
n[1].filter_map { |assoc| Ast.tagged?(assoc, :assoc_new) ? assoc[2] : nil }
|
|
95
|
+
else
|
|
96
|
+
[n]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unbounded_query?(value)
|
|
102
|
+
return false unless value.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
root = root_of(value)
|
|
105
|
+
return false unless Ast.tagged?(root, :var_ref, :vcall)
|
|
106
|
+
|
|
107
|
+
const = root[1]
|
|
108
|
+
return false unless const.is_a?(Array) && const[0] == :@const
|
|
109
|
+
|
|
110
|
+
method_names = Ast.each_node(value).filter_map do |n|
|
|
111
|
+
next unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
|
|
112
|
+
|
|
113
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
114
|
+
Ast.call_name(inner)&.last
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
(method_names & QUERY_METHODS).any? && (method_names & BOUND_METHODS).none?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def root_of(node)
|
|
121
|
+
return node unless node.is_a?(Array)
|
|
122
|
+
|
|
123
|
+
case node[0]
|
|
124
|
+
when :method_add_arg, :call, :command_call
|
|
125
|
+
root_of(node[1])
|
|
126
|
+
else
|
|
127
|
+
node
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module PerformanceRules
|
|
3
|
+
# Flags likely N+1 queries: inside a `.each`/`.map` block whose receiver
|
|
4
|
+
# looks like an Active Record collection (a local variable previously
|
|
5
|
+
# assigned from `Model.where(...)`/`Model.all`/`Model.find(...)`, or a bare
|
|
6
|
+
# instance variable — the common `@orders.each do |order| ... end` shape),
|
|
7
|
+
# a bare no-arg `.method_name` call directly on the block variable (likely
|
|
8
|
+
# an association access, e.g. `order.line_items`) is flagged unless an
|
|
9
|
+
# `.includes(:that_name)` appeared earlier in the same base query chain.
|
|
10
|
+
#
|
|
11
|
+
# This is a heuristic, not type inference: it can't tell an association
|
|
12
|
+
# read (`order.line_items`, which issues a query per row) from a plain
|
|
13
|
+
# attribute/column read (`order.status`) or a harmless Ruby method call —
|
|
14
|
+
# both parse identically as a bare no-arg call on the block variable. A
|
|
15
|
+
# small block-list of universally-common non-association methods
|
|
16
|
+
# (to_s, present?, class, ...) cuts down the noisiest false positives, but
|
|
17
|
+
# real false positives on attribute reads are still expected and normal
|
|
18
|
+
# for this class of tool (see README).
|
|
19
|
+
class NPlusOneQueryRule < Rule
|
|
20
|
+
self.rule_id = "n_plus_one_query"
|
|
21
|
+
self.category = "performance"
|
|
22
|
+
self.default_severity = "warning"
|
|
23
|
+
self.title = "Possible N+1 query inside a loop"
|
|
24
|
+
|
|
25
|
+
QUERY_METHODS = %w[where all find find_by find_by! order limit].freeze
|
|
26
|
+
LOOP_METHODS = %w[each map collect each_with_index].freeze
|
|
27
|
+
|
|
28
|
+
# Bare methods that are overwhelmingly plain Ruby/attribute reads rather
|
|
29
|
+
# than association traversal — flagging these would be mostly noise.
|
|
30
|
+
NON_ASSOCIATION_METHODS = %w[
|
|
31
|
+
to_s to_i to_a to_h inspect class dup clone freeze frozen? hash
|
|
32
|
+
present? blank? nil? empty? any? id id_value == equal? try tap then
|
|
33
|
+
is_a? kind_of? instance_of? respond_to? send public_send object_id
|
|
34
|
+
itself
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def scan
|
|
38
|
+
findings = []
|
|
39
|
+
|
|
40
|
+
Ast.each_node(sexp) do |node|
|
|
41
|
+
next unless Ast.tagged?(node, :def, :defs)
|
|
42
|
+
|
|
43
|
+
body = node.last
|
|
44
|
+
bindings = query_bindings(body)
|
|
45
|
+
|
|
46
|
+
Ast.each_node(body) do |block_call|
|
|
47
|
+
next unless Ast.tagged?(block_call, :method_add_block)
|
|
48
|
+
|
|
49
|
+
call_node = block_call[1]
|
|
50
|
+
receiver, loop_method = Ast.call_name(call_node) || [nil, nil]
|
|
51
|
+
next unless LOOP_METHODS.include?(loop_method)
|
|
52
|
+
next unless receiver
|
|
53
|
+
|
|
54
|
+
base_name = var_or_ivar_name(receiver)
|
|
55
|
+
next unless base_name
|
|
56
|
+
|
|
57
|
+
includes = if bindings.key?(base_name)
|
|
58
|
+
bindings[base_name]
|
|
59
|
+
elsif base_name.start_with?("@")
|
|
60
|
+
[] # ivar set elsewhere — treat as an AR collection with no known eager-loading
|
|
61
|
+
end
|
|
62
|
+
next unless includes # local var we never saw assigned from a query — don't guess
|
|
63
|
+
|
|
64
|
+
block_node = block_call[2]
|
|
65
|
+
param_name = block_param_name(block_node)
|
|
66
|
+
next unless param_name
|
|
67
|
+
|
|
68
|
+
findings.concat(association_calls(block_node[2], param_name, includes))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
findings
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Local-variable/ivar assignments in this method whose value is a query
|
|
78
|
+
# chain rooted at a bare Model constant, mapped to the set of association
|
|
79
|
+
# names already eager-loaded via `.includes(...)` in that same chain.
|
|
80
|
+
def query_bindings(body)
|
|
81
|
+
bindings = {}
|
|
82
|
+
|
|
83
|
+
Ast.each_node(body) do |node|
|
|
84
|
+
next unless Ast.tagged?(node, :assign)
|
|
85
|
+
|
|
86
|
+
name = assign_target_name(node[1])
|
|
87
|
+
next unless name
|
|
88
|
+
|
|
89
|
+
value = node[2]
|
|
90
|
+
next unless query_chain?(value)
|
|
91
|
+
|
|
92
|
+
bindings[name] = includes_symbols(value)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
bindings
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign_target_name(target)
|
|
99
|
+
return nil unless Ast.tagged?(target, :var_field)
|
|
100
|
+
|
|
101
|
+
inner = target[1]
|
|
102
|
+
return nil unless inner.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
inner[1] if %i[@ident @ivar].include?(inner[0])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def query_chain?(value)
|
|
108
|
+
root = root_of(value)
|
|
109
|
+
return false unless Ast.tagged?(root, :var_ref, :vcall)
|
|
110
|
+
|
|
111
|
+
const = root[1]
|
|
112
|
+
return false unless const.is_a?(Array) && const[0] == :@const
|
|
113
|
+
|
|
114
|
+
Ast.each_node(value).any? do |n|
|
|
115
|
+
next false unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
|
|
116
|
+
|
|
117
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
118
|
+
name = Ast.call_name(inner)&.last
|
|
119
|
+
QUERY_METHODS.include?(name)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def includes_symbols(value)
|
|
124
|
+
names = []
|
|
125
|
+
|
|
126
|
+
Ast.each_node(value) do |n|
|
|
127
|
+
next unless Ast.tagged?(n, :method_add_arg)
|
|
128
|
+
|
|
129
|
+
receiver_and_name = Ast.call_name(n[1])
|
|
130
|
+
next unless receiver_and_name && receiver_and_name.last == "includes"
|
|
131
|
+
|
|
132
|
+
Ast.call_arguments(n).each do |arg|
|
|
133
|
+
sym = symbol_name(arg)
|
|
134
|
+
names << sym if sym
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
names
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def symbol_name(node)
|
|
142
|
+
return nil unless Ast.tagged?(node, :symbol_literal)
|
|
143
|
+
|
|
144
|
+
# symbol_literal wraps [:symbol, [:@ident, name, pos]] — one more
|
|
145
|
+
# level of unwrapping than a bare @ident node.
|
|
146
|
+
wrapper = node[1]
|
|
147
|
+
return nil unless Ast.tagged?(wrapper, :symbol)
|
|
148
|
+
|
|
149
|
+
ident = wrapper[1]
|
|
150
|
+
return nil unless ident.is_a?(Array)
|
|
151
|
+
|
|
152
|
+
ident[1] if %i[@ident @const @kw].include?(ident[0])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Walks down the leftmost receiver chain of a call expression to find
|
|
156
|
+
# the root object the whole chain is called on.
|
|
157
|
+
def root_of(node)
|
|
158
|
+
return node unless node.is_a?(Array)
|
|
159
|
+
|
|
160
|
+
case node[0]
|
|
161
|
+
when :method_add_arg, :call, :command_call
|
|
162
|
+
root_of(node[1])
|
|
163
|
+
else
|
|
164
|
+
node
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def var_or_ivar_name(node)
|
|
169
|
+
return nil unless Ast.tagged?(node, :var_ref, :vcall)
|
|
170
|
+
|
|
171
|
+
inner = node[1]
|
|
172
|
+
return nil unless inner.is_a?(Array)
|
|
173
|
+
|
|
174
|
+
inner[1] if %i[@ident @ivar].include?(inner[0])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def block_param_name(block_node)
|
|
178
|
+
return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
|
|
179
|
+
|
|
180
|
+
block_var = block_node[1]
|
|
181
|
+
return nil unless Ast.tagged?(block_var, :block_var)
|
|
182
|
+
|
|
183
|
+
params = block_var[1]
|
|
184
|
+
first_param = params.is_a?(Array) ? params[1]&.first : nil
|
|
185
|
+
return nil unless first_param.is_a?(Array) && first_param[0] == :@ident
|
|
186
|
+
|
|
187
|
+
first_param[1]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def association_calls(body, param_name, includes)
|
|
191
|
+
results = []
|
|
192
|
+
|
|
193
|
+
Ast.each_node(body) do |n|
|
|
194
|
+
next unless Ast.tagged?(n, :call)
|
|
195
|
+
|
|
196
|
+
receiver = n[1]
|
|
197
|
+
next unless var_or_ivar_name(receiver) == param_name
|
|
198
|
+
|
|
199
|
+
method_name = Ast.ident_text(n[3])
|
|
200
|
+
next unless method_name
|
|
201
|
+
next if NON_ASSOCIATION_METHODS.include?(method_name)
|
|
202
|
+
next if includes.include?(method_name)
|
|
203
|
+
|
|
204
|
+
line = Ast.line_of(n)
|
|
205
|
+
results << finding(
|
|
206
|
+
line: line,
|
|
207
|
+
message: "`#{param_name}.#{method_name}` is called inside a loop — if `#{method_name}` " \
|
|
208
|
+
"is an association, this issues a separate query per iteration instead of one " \
|
|
209
|
+
"batched query (a classic N+1).",
|
|
210
|
+
suggested_fix: "Eager-load the association on the base query before the loop, e.g. " \
|
|
211
|
+
"`#{param_name.chomp('s')}s = Model.includes(:#{method_name}).where(...)` " \
|
|
212
|
+
"(or add `:#{method_name}` to an existing `.includes(...)` call), so Rails " \
|
|
213
|
+
"fetches it in one extra query instead of one per record."
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
results
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module PerformanceRules
|
|
3
|
+
# Flags `Model.all.each`/`Model.where(...).each` (or `.order(...).each`) —
|
|
4
|
+
# chaining `.each` directly onto a query loads every matching row into
|
|
5
|
+
# memory at once before iterating. `find_each`/`find_in_batches` load and
|
|
6
|
+
# yield records in bounded batches instead, keeping memory flat regardless
|
|
7
|
+
# of table size. This only looks at the literal chain (`Const.query.each`)
|
|
8
|
+
# so it won't catch the same problem one step removed — e.g. a variable
|
|
9
|
+
# assigned from the query and iterated later (that pattern is out of scope
|
|
10
|
+
# here; see `NPlusOneQueryRule`, which does track simple local
|
|
11
|
+
# assignments, for a related check on what happens *inside* such a loop).
|
|
12
|
+
class UnboundedTableScanRule < Rule
|
|
13
|
+
self.rule_id = "unbounded_table_scan"
|
|
14
|
+
self.category = "performance"
|
|
15
|
+
self.default_severity = "warning"
|
|
16
|
+
self.title = "Full query result loaded into memory before iterating"
|
|
17
|
+
|
|
18
|
+
QUERY_METHODS = %w[all where order].freeze
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
Ast.each_node(sexp) do |node|
|
|
24
|
+
next unless Ast.tagged?(node, :method_add_block)
|
|
25
|
+
|
|
26
|
+
call_node = node[1]
|
|
27
|
+
receiver, method_name = Ast.call_name(call_node) || [nil, nil]
|
|
28
|
+
next unless method_name == "each"
|
|
29
|
+
next unless receiver
|
|
30
|
+
next unless direct_query_chain?(receiver)
|
|
31
|
+
|
|
32
|
+
line = Ast.line_of(call_node)
|
|
33
|
+
findings << finding(
|
|
34
|
+
line: line,
|
|
35
|
+
message: "`.each` is chained directly onto a query — Active Record loads every " \
|
|
36
|
+
"matching row into memory before the block runs even once, which can exhaust " \
|
|
37
|
+
"memory (or just be very slow) once the table is large.",
|
|
38
|
+
suggested_fix: "Use `find_each` (row-by-row, fixed batch size) or `find_in_batches` " \
|
|
39
|
+
"(access a batch `Array` at a time) instead of `.each`, e.g. " \
|
|
40
|
+
"`Model.where(...).find_each { |record| ... }` — Active Record loads and " \
|
|
41
|
+
"discards records in bounded batches instead of all at once."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def direct_query_chain?(receiver)
|
|
51
|
+
return false unless receiver.is_a?(Array)
|
|
52
|
+
|
|
53
|
+
inner = Ast.tagged?(receiver, :method_add_arg) ? receiver[1] : receiver
|
|
54
|
+
return false unless Ast.tagged?(inner, :call)
|
|
55
|
+
|
|
56
|
+
_, name = Ast.call_name(inner)
|
|
57
|
+
return false unless QUERY_METHODS.include?(name)
|
|
58
|
+
|
|
59
|
+
root = root_of(receiver)
|
|
60
|
+
return false unless Ast.tagged?(root, :var_ref, :vcall)
|
|
61
|
+
|
|
62
|
+
const = root[1]
|
|
63
|
+
const.is_a?(Array) && const[0] == :@const
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def root_of(node)
|
|
67
|
+
return node unless node.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
case node[0]
|
|
70
|
+
when :method_add_arg, :call, :command_call
|
|
71
|
+
root_of(node[1])
|
|
72
|
+
else
|
|
73
|
+
node
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# Extracts each Active Record query chain rooted at a bare Model constant
|
|
5
|
+
# (`User.where(...).order(...)`, `Order.joins(:items).count`, ...) as a
|
|
6
|
+
# MethodInfo — same shape MethodExtractor produces for `def`s, so it can
|
|
7
|
+
# feed the same DuplicateDetector — to catch copy-pasted query logic that
|
|
8
|
+
# never got extracted into a scope/service, even when the surrounding
|
|
9
|
+
# methods themselves don't look alike.
|
|
10
|
+
#
|
|
11
|
+
# A "chain" is captured at its outermost call (the last method in the
|
|
12
|
+
# chain), and the walk does not recurse into a chain it just captured —
|
|
13
|
+
# so `User.where(...).order(...)` is one unit, not also a nested `where`
|
|
14
|
+
# unit. This means a subquery buried inside a captured chain's arguments
|
|
15
|
+
# (e.g. `Foo.where(id: Bar.select(:id))`) isn't separately extracted — an
|
|
16
|
+
# accepted simplification, not a correctness bug: it would still be found
|
|
17
|
+
# if the same subquery shape appears somewhere else outside a captured
|
|
18
|
+
# chain.
|
|
19
|
+
module QueryExtractor
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Mirrors the query-method lists in NPlusOneQueryRule/UnboundedTableScanRule
|
|
23
|
+
# but broader, since here we're looking for "this is recognizably a query"
|
|
24
|
+
# rather than one specific anti-pattern.
|
|
25
|
+
QUERY_METHODS = %w[
|
|
26
|
+
where find_by find_by! find_or_create_by find_or_initialize_by
|
|
27
|
+
order reorder limit offset joins left_joins includes preload eager_load
|
|
28
|
+
references group having select distinct pluck exists? count sum average
|
|
29
|
+
minimum maximum find first last not or none unscope ids find_each
|
|
30
|
+
find_in_batches in_batches lock readonly
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
MIN_TOKENS = 8 # shorter than MethodExtractor's threshold — queries are terser than whole methods
|
|
34
|
+
|
|
35
|
+
def extract(file:, source:, sexp:)
|
|
36
|
+
queries = []
|
|
37
|
+
walk(sexp) do |node|
|
|
38
|
+
info = build_info(node, file: file, source: source)
|
|
39
|
+
queries << info if info
|
|
40
|
+
end
|
|
41
|
+
queries
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def walk(node, &block)
|
|
45
|
+
return unless node.is_a?(Array)
|
|
46
|
+
|
|
47
|
+
if chain_root?(node)
|
|
48
|
+
block.call(node)
|
|
49
|
+
return # don't descend into a chain we just captured — see module doc
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
node.each { |child| walk(child, &block) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def chain_root?(node)
|
|
56
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
57
|
+
name = Ast.call_name(inner)&.last
|
|
58
|
+
return false unless name && QUERY_METHODS.include?(name)
|
|
59
|
+
|
|
60
|
+
root = root_of(node)
|
|
61
|
+
Ast.tagged?(root, :var_ref, :vcall) &&
|
|
62
|
+
root[1].is_a?(Array) && root[1][0] == :@const
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Walks down the leftmost receiver chain to the constant the whole chain
|
|
66
|
+
# is called on (same logic as NPlusOneQueryRule#root_of).
|
|
67
|
+
def root_of(node)
|
|
68
|
+
return node unless node.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
case node[0]
|
|
71
|
+
when :method_add_arg, :call, :command_call
|
|
72
|
+
root_of(node[1])
|
|
73
|
+
else
|
|
74
|
+
node
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_info(node, file:, source:)
|
|
79
|
+
start_line, end_line = Ast.line_range_of(node)
|
|
80
|
+
return nil unless start_line
|
|
81
|
+
|
|
82
|
+
tokens = Ast.normalized_tokens(node)
|
|
83
|
+
return nil if tokens.size < MIN_TOKENS
|
|
84
|
+
|
|
85
|
+
const_name = Ast.ident_text(root_of(node)[1]) || "?"
|
|
86
|
+
|
|
87
|
+
MethodInfo.new(
|
|
88
|
+
name: "#{const_name}.#{chain_label(node)}",
|
|
89
|
+
file: file,
|
|
90
|
+
start_line: start_line,
|
|
91
|
+
end_line: end_line,
|
|
92
|
+
token_stream: tokens,
|
|
93
|
+
source_snippet: Ast.source_text(source, node)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Best-effort ".method1.method2" label (outermost-first) for display —
|
|
98
|
+
# not used for comparison, just so a finding reads as
|
|
99
|
+
# "User.where.order" instead of a bare line number. Each iteration
|
|
100
|
+
# unwraps one call link (method_add_arg -> its :call, or a bare :call)
|
|
101
|
+
# and steps the cursor to *that* call's receiver, so a chain link is
|
|
102
|
+
# never counted twice.
|
|
103
|
+
def chain_label(node)
|
|
104
|
+
names = []
|
|
105
|
+
cursor = node
|
|
106
|
+
|
|
107
|
+
loop do
|
|
108
|
+
break unless cursor.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
inner = Ast.tagged?(cursor, :method_add_arg) ? cursor[1] : cursor
|
|
111
|
+
break unless Ast.tagged?(inner, :call, :vcall, :fcall, :command)
|
|
112
|
+
|
|
113
|
+
name = Ast.call_name(inner)&.last
|
|
114
|
+
names.unshift(name) if name
|
|
115
|
+
break unless Ast.tagged?(inner, :call) # vcall/fcall/command have no receiver to descend into
|
|
116
|
+
|
|
117
|
+
cursor = inner[1]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
names.join(".")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|