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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. 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