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,250 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# Runtime detector for two of the problems Bullet (github.com/flyerhzm/bullet)
|
|
5
|
+
# is best known for — a collection query followed by one repeat query per
|
|
6
|
+
# row ("N+1"), and an `.includes`/`.preload`/`.eager_load` association that
|
|
7
|
+
# gets fetched but never actually read ("unused eager loading") — built
|
|
8
|
+
# independently, on a different mechanism than Bullet's: SQL-shape
|
|
9
|
+
# correlation via ActiveSupport::Notifications, plus a `Module#prepend` on
|
|
10
|
+
# the two *public* entry points identified below (`QueryMethods#includes`
|
|
11
|
+
# et al., and `Association#reader`), rather than Bullet's own per-request
|
|
12
|
+
# association bookkeeping. No Bullet source was read or copied to build
|
|
13
|
+
# this — see the README's "Runtime query watcher" section for the
|
|
14
|
+
# conceptual write-up this was built from.
|
|
15
|
+
#
|
|
16
|
+
# Unlike the rest of Scryer (a one-shot static Ripper scan of source
|
|
17
|
+
# files with no Rails required), this module instruments a *running* app:
|
|
18
|
+
# it needs ActiveRecord loaded and real queries executing to find
|
|
19
|
+
# anything. It does nothing until `Scryer::QueryWatcher.enable!` is
|
|
20
|
+
# called (typically from an initializer, gated to non-production
|
|
21
|
+
# environments — see README) and a scope is opened with `.watch { }` (the
|
|
22
|
+
# Rack middleware below opens one per request automatically).
|
|
23
|
+
class QueryWatcher
|
|
24
|
+
Finding = Struct.new(:kind, :message, :call_site, :count, :suggested_fix, keyword_init: true) do
|
|
25
|
+
def to_h
|
|
26
|
+
super.transform_keys(&:to_s)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Everything this module needs to know about one request/job: which SQL
|
|
31
|
+
# shapes ran from which call sites and how many times, which
|
|
32
|
+
# associations got eager-loaded and from where, and which associations
|
|
33
|
+
# were actually read. Scoped per-thread (see .watch) so concurrent
|
|
34
|
+
# requests/jobs on different threads never share state.
|
|
35
|
+
class Scope
|
|
36
|
+
def initialize
|
|
37
|
+
@query_shapes = Hash.new { |h, k| h[k] = Hash.new(0) } # shape => {call_site => count}
|
|
38
|
+
@eager_loads = [] # [{owner:, association:, call_site:}]
|
|
39
|
+
@accessed = Set.new # "OwnerClass#association" strings
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record_query(shape, call_site)
|
|
43
|
+
@query_shapes[shape][call_site] += 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_eager_load(owner, association, call_site)
|
|
47
|
+
@eager_loads << { owner: owner, association: association, call_site: call_site }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_access(owner, association)
|
|
51
|
+
@accessed << "#{owner}##{association}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def findings(n_plus_one_threshold:)
|
|
55
|
+
n_plus_one_findings(n_plus_one_threshold) + unused_eager_load_findings
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def n_plus_one_findings(threshold)
|
|
61
|
+
out = []
|
|
62
|
+
@query_shapes.each_value do |by_site|
|
|
63
|
+
by_site.each do |call_site, count|
|
|
64
|
+
next if count < threshold
|
|
65
|
+
|
|
66
|
+
out << Finding.new(
|
|
67
|
+
kind: "n_plus_one_query_runtime",
|
|
68
|
+
message: "The same query shape ran #{count} times from one call site during this " \
|
|
69
|
+
"request/job — consistent with an N+1 (a collection loaded once, then this " \
|
|
70
|
+
"query re-ran once per row instead of being eager-loaded).",
|
|
71
|
+
call_site: call_site,
|
|
72
|
+
count: count,
|
|
73
|
+
suggested_fix: "If the code at #{call_site} loops over a collection and this query " \
|
|
74
|
+
"runs once per item, eager-load the association the loop reads instead " \
|
|
75
|
+
"(`.includes(:the_association)`, or `.preload`/`.eager_load`) on the " \
|
|
76
|
+
"query that produced the collection, so it's fetched once instead of " \
|
|
77
|
+
"once per row."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
out
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def unused_eager_load_findings
|
|
85
|
+
@eager_loads.reject { |e| @accessed.include?("#{e[:owner]}##{e[:association]}") }.map do |e|
|
|
86
|
+
Finding.new(
|
|
87
|
+
kind: "unused_eager_load",
|
|
88
|
+
message: "#{e[:owner]} eager-loaded `:#{e[:association]}` but it was never read during " \
|
|
89
|
+
"this request/job.",
|
|
90
|
+
call_site: e[:call_site],
|
|
91
|
+
count: 1,
|
|
92
|
+
suggested_fix: "Drop `:#{e[:association]}` from the `.includes`/`.preload`/`.eager_load` " \
|
|
93
|
+
"call at #{e[:call_site]} — it's fetched every time but unused here, so " \
|
|
94
|
+
"it's an extra query (or an extra JOIN) for nothing. If it's only used on " \
|
|
95
|
+
"some code paths, consider eager-loading it there instead."
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class << self
|
|
102
|
+
# Turns the watcher on for the life of the process. Idempotent — safe
|
|
103
|
+
# to call more than once (later calls are no-ops). `logger` receives a
|
|
104
|
+
# warning line per finding as it's detected; `n_plus_one_threshold` is
|
|
105
|
+
# how many repeats of the same query shape from the same call site
|
|
106
|
+
# count as N+1 (default 2 — the *second* occurrence is already one
|
|
107
|
+
# more than a single collection load needs).
|
|
108
|
+
def enable!(logger: nil, n_plus_one_threshold: 2)
|
|
109
|
+
return if @enabled
|
|
110
|
+
|
|
111
|
+
@logger = logger || default_logger
|
|
112
|
+
@n_plus_one_threshold = n_plus_one_threshold
|
|
113
|
+
@enabled = true
|
|
114
|
+
|
|
115
|
+
require "active_support/notifications"
|
|
116
|
+
subscribe_to_queries
|
|
117
|
+
patch_active_record
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def enabled?
|
|
121
|
+
!!@enabled
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Opens a fresh per-thread scope, runs the block, then reports (and
|
|
125
|
+
# returns) whatever was found. The bundled Rack middleware calls this
|
|
126
|
+
# once per request; call it directly to watch a Sidekiq job, a rake
|
|
127
|
+
# task, or anything else that isn't an HTTP request.
|
|
128
|
+
def watch
|
|
129
|
+
raise "Scryer::QueryWatcher.enable! was never called" unless enabled?
|
|
130
|
+
|
|
131
|
+
previous = Thread.current[:scryer_query_watcher_scope]
|
|
132
|
+
scope = Thread.current[:scryer_query_watcher_scope] = Scope.new
|
|
133
|
+
yield
|
|
134
|
+
report(scope)
|
|
135
|
+
ensure
|
|
136
|
+
Thread.current[:scryer_query_watcher_scope] = previous
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def current_scope
|
|
140
|
+
Thread.current[:scryer_query_watcher_scope]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call_site
|
|
144
|
+
loc = caller_locations.find { |l| !l.path.include?("/gems/") && !l.path.include?("lib/scryer/") }
|
|
145
|
+
loc ? "#{loc.path}:#{loc.lineno}" : "unknown"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def default_logger
|
|
151
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
152
|
+
Rails.logger
|
|
153
|
+
else
|
|
154
|
+
require "logger"
|
|
155
|
+
Logger.new($stdout)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def report(scope)
|
|
160
|
+
findings = scope.findings(n_plus_one_threshold: @n_plus_one_threshold)
|
|
161
|
+
findings.each { |f| @logger.warn("[Scryer::QueryWatcher] #{f.kind}: #{f.message} (#{f.call_site})") }
|
|
162
|
+
findings
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def subscribe_to_queries
|
|
166
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
167
|
+
scope = current_scope
|
|
168
|
+
next unless scope
|
|
169
|
+
|
|
170
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
171
|
+
next if event.payload[:name] == "SCHEMA" || event.payload[:cached]
|
|
172
|
+
|
|
173
|
+
shape = normalize_sql(event.payload[:sql])
|
|
174
|
+
scope.record_query(shape, call_site) if shape
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Scrubs literal values so e.g. `WHERE post_id = 1` and
|
|
179
|
+
# `WHERE post_id = 2` normalize to the same shape — a deliberately
|
|
180
|
+
# simple heuristic (strip quoted strings and standalone/$-prefixed
|
|
181
|
+
# numbers), not a real SQL parser.
|
|
182
|
+
def normalize_sql(sql)
|
|
183
|
+
return nil unless sql
|
|
184
|
+
|
|
185
|
+
sql.gsub(/'[^']*'/, "?").gsub(/\$?\b\d+\b/, "?").squeeze(" ").strip
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def patch_active_record
|
|
189
|
+
ActiveSupport.on_load(:active_record) { Scryer::QueryWatcher.send(:install_hooks) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def install_hooks
|
|
193
|
+
require "active_record"
|
|
194
|
+
|
|
195
|
+
ActiveRecord::QueryMethods.prepend(EagerLoadTracking)
|
|
196
|
+
ActiveRecord::Associations::CollectionAssociation.prepend(AccessTracking)
|
|
197
|
+
ActiveRecord::Associations::SingularAssociation.prepend(AccessTracking)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Prepended onto ActiveRecord::QueryMethods (mixed into every Relation).
|
|
202
|
+
# `includes`/`preload`/`eager_load` are the three public methods that
|
|
203
|
+
# request eager loading — recording *here*, not by inspecting the SQL,
|
|
204
|
+
# is what lets this tell "asked for but unused" apart from "never asked
|
|
205
|
+
# for at all".
|
|
206
|
+
module EagerLoadTracking
|
|
207
|
+
%i[includes preload eager_load].each do |method_name|
|
|
208
|
+
define_method(method_name) do |*args|
|
|
209
|
+
scope = Scryer::QueryWatcher.current_scope
|
|
210
|
+
if scope
|
|
211
|
+
site = Scryer::QueryWatcher.call_site
|
|
212
|
+
Array(args).flatten.each do |assoc|
|
|
213
|
+
scope.record_eager_load(klass.name, assoc.to_s, site) if assoc.is_a?(Symbol) || assoc.is_a?(String)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
super(*args)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Prepended onto both association classes. `reader` is the method the
|
|
222
|
+
# generated association method (`post.comments`) actually calls (see
|
|
223
|
+
# ActiveRecord::Associations::Builder::Association) — unlike
|
|
224
|
+
# `load_target`, it fires on *every* access, preloaded or not, which is
|
|
225
|
+
# exactly what "was this ever read" needs.
|
|
226
|
+
module AccessTracking
|
|
227
|
+
def reader
|
|
228
|
+
scope = Scryer::QueryWatcher.current_scope
|
|
229
|
+
scope&.record_access(owner.class.name, reflection.name.to_s)
|
|
230
|
+
super
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `use Scryer::QueryWatcher::Middleware` opens one scope per request so
|
|
235
|
+
# findings are correlated per-request rather than pooling across the
|
|
236
|
+
# whole process lifetime (which would make every N+1 look like it fired
|
|
237
|
+
# "once", the first time, and never again).
|
|
238
|
+
class Middleware
|
|
239
|
+
def initialize(app)
|
|
240
|
+
@app = app
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def call(env)
|
|
244
|
+
result = nil
|
|
245
|
+
Scryer::QueryWatcher.watch { result = @app.call(env) }
|
|
246
|
+
result
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
# This gem runs on-demand via a rake task, not as request-cycle
|
|
3
|
+
# instrumentation — so the Railtie doesn't need to hook into the
|
|
4
|
+
# middleware stack or boot process at all. Its only job is to make sure
|
|
5
|
+
# requiring this gem inside a Rails app never raises, and to load the
|
|
6
|
+
# rake tasks into the host app's Rails console/`rake -T` listing.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
rake_tasks do
|
|
9
|
+
load File.expand_path("../tasks/scryer.rake", __dir__)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|