scryer 1.0.0 → 1.2.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
@@ -0,0 +1,288 @@
1
+ require "set"
2
+ require "ripper"
3
+
4
+ module Scryer
5
+ # Deterministic, no-AI fixes for the narrow set of rules where the
6
+ # correct rewrite doesn't require judgment — there's exactly one sane
7
+ # answer every time, so there's nothing for an LLM to decide. Everything
8
+ # else (mass_assignment, idor, missing_authorization, csrf, ...) still
9
+ # needs an `ai_client` or a human, because the correct fix depends on
10
+ # things Scryer can't know statically (which params to permit, which
11
+ # policy to call).
12
+ #
13
+ # Produces the exact same "explanation + AFTER: fenced block" shape
14
+ # AiFixSuggester's prompt asks a real model for (see FixVerifier's
15
+ # AFTER_BLOCK regex) — so a mechanical fix flows through the *identical*
16
+ # verify/apply pipeline as an AI one; nothing here is trusted more than an
17
+ # LLM's guess would be. If a specific line doesn't match the exact shape a
18
+ # fixer here knows how to rewrite, `suggest` returns nil and the finding
19
+ # falls through to the ai_client (if configured) or manual review, same as
20
+ # any other unsupported case — this never guesses.
21
+ module MechanicalFixer
22
+ module_function
23
+
24
+ SUPPORTED_RULES = %w[
25
+ frozen_string_literal
26
+ sql_injection
27
+ force_ssl_disabled
28
+ insecure_cookie_serializer
29
+ weak_session_cookie
30
+ security_headers_disabled
31
+ ].freeze
32
+
33
+ # frozen_string_literal is mechanically fixable but, unlike the others,
34
+ # deliberately opt-in — a project-wide `scryer fix` sweep would
35
+ # otherwise touch nearly every file for a cosmetic, `info`-severity
36
+ # finding. See CLI#run_fix / ScryerTasks — this list is what those
37
+ # callers use to decide whether to ask before including a rule in an
38
+ # unscoped run, not something `suggest` itself gates on (explicitly
39
+ # requesting the rule, e.g. `--rule frozen_string_literal`, is already
40
+ # informed consent, so `suggest` always tries it).
41
+ OPT_IN_RULES = %w[frozen_string_literal].freeze
42
+
43
+ # Bang-methods and operators that mutate their receiver in place — used
44
+ # to detect whether freezing a file's string literals could actually
45
+ # break it (see fix_frozen_string_literal). Not exhaustive (this is a
46
+ # heuristic, not data-flow analysis), but covers the realistic cases.
47
+ MUTATING_METHODS = %w[
48
+ concat replace insert clear prepend
49
+ upcase! downcase! capitalize! swapcase!
50
+ strip! lstrip! rstrip! chomp! chop! squeeze!
51
+ gsub! sub! slice! delete! tr! tr_s! succ! next!
52
+ reverse! encode! force_encoding
53
+ ].freeze
54
+
55
+ def supported?(rule_id)
56
+ SUPPORTED_RULES.include?(rule_id)
57
+ end
58
+
59
+ def opt_in?(rule_id)
60
+ OPT_IN_RULES.include?(rule_id)
61
+ end
62
+
63
+ def suggest(finding, root: nil)
64
+ return nil unless finding.is_a?(Scryer::Finding)
65
+
66
+ case finding.rule_id
67
+ when "frozen_string_literal" then fix_frozen_string_literal(finding, root: root)
68
+ when "sql_injection" then fix_sql_injection(finding, root: root)
69
+ when "force_ssl_disabled" then fix_boolean_flip(finding, root, /(\bforce_ssl\s*=\s*)false\b/, "Flips `force_ssl` to `true`, restoring Rails' HTTPS enforcement.")
70
+ when "insecure_cookie_serializer" then fix_cookie_serializer(finding, root: root)
71
+ when "weak_session_cookie" then fix_weak_session_cookie(finding, root: root)
72
+ when "security_headers_disabled" then fix_security_headers_disabled(finding, root: root)
73
+ end
74
+ end
75
+
76
+ # finding.code_snippet is deliberately `.strip`ped by Ast.source_line
77
+ # (it's meant for display in a report, not for rewriting) — every fixer
78
+ # below needs the actual on-disk line, indentation included, or the
79
+ # rewritten line silently loses its original indentation. Falls back to
80
+ # code_snippet only when the real file can't be read (e.g. a unit test
81
+ # constructing a bare Finding with no root/real file on disk).
82
+ def raw_line(finding, root)
83
+ return finding.code_snippet.to_s unless root && finding.file && finding.line
84
+
85
+ abs_path = File.join(root.to_s, finding.file.to_s)
86
+ return finding.code_snippet.to_s unless File.file?(abs_path)
87
+
88
+ lines = File.read(abs_path).lines
89
+ return finding.code_snippet.to_s unless finding.line.between?(1, lines.size)
90
+
91
+ lines[finding.line - 1].to_s.chomp
92
+ rescue StandardError
93
+ finding.code_snippet.to_s
94
+ end
95
+ private_class_method :raw_line
96
+
97
+ # A magic comment is recognized by Ruby only on the very first source
98
+ # line, or the second if the first is a shebang — so prepending it (or
99
+ # inserting it right after a shebang) is always the *correct* rewrite.
100
+ # But "correct" isn't the same as "safe": freezing every string literal
101
+ # in the file breaks anything that mutates one in place (`str << x`,
102
+ # `str.gsub!(...)`, ...) at runtime with a FrozenError — something the
103
+ # frozen_string_literal rule itself has no way to see (it only checks
104
+ # for the magic comment's absence). Declines (nil) whenever the file
105
+ # can't be read/analyzed, or analysis finds a plausible in-place
106
+ # mutation — "analyse and fix only if no issue will arise from it".
107
+ def fix_frozen_string_literal(finding, root:)
108
+ source = read_source(finding, root)
109
+ return nil if source.nil? || mutates_a_string_literal?(source)
110
+
111
+ first_line = source.lines.first.to_s.chomp
112
+ code = if first_line.start_with?("#!")
113
+ "#{first_line}\n# frozen_string_literal: true\n"
114
+ else
115
+ "# frozen_string_literal: true\n\n#{first_line}"
116
+ end
117
+ wrap_after("Adds the `# frozen_string_literal: true` magic comment as the first line of the file — no in-place string mutation was found, so freezing literals here is safe.", code)
118
+ end
119
+
120
+ def read_source(finding, root)
121
+ return nil unless root && finding.file
122
+
123
+ abs_path = File.join(root.to_s, finding.file.to_s)
124
+ File.file?(abs_path) ? File.read(abs_path) : nil
125
+ rescue StandardError
126
+ nil
127
+ end
128
+ private_class_method :read_source
129
+
130
+ # True if `source` plausibly mutates a string literal in place, either
131
+ # directly (`"foo" << x`, `"foo".gsub!(...)`) or via a local variable
132
+ # that was assigned a string literal earlier in the file (`s = "foo"`
133
+ # ... `s << x`). Heuristic, not scope-aware — a variable name reused for
134
+ # a different value in a different method can cause a false positive
135
+ # (declining a fix that would actually have been fine), which is the
136
+ # safe direction to err in; a real Ripper parse failure is treated the
137
+ # same way (unable to analyze -> decline).
138
+ def mutates_a_string_literal?(source)
139
+ sexp = begin
140
+ Ripper.sexp(source)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+ return true if sexp.nil?
145
+
146
+ literal_vars = string_literal_assigned_vars(sexp)
147
+
148
+ Ast.each_node(sexp).any? do |node|
149
+ shovel_onto_tracked_receiver?(node, literal_vars) ||
150
+ index_assign_onto_tracked_receiver?(node, literal_vars) ||
151
+ mutating_call_on_tracked_receiver?(node, literal_vars)
152
+ end
153
+ end
154
+ private_class_method :mutates_a_string_literal?
155
+
156
+ def string_literal_assigned_vars(sexp)
157
+ Ast.each_node(sexp).each_with_object(Set.new) do |node, vars|
158
+ next unless Ast.tagged?(node, :assign)
159
+
160
+ target = node[1]
161
+ value = node[2]
162
+ next unless Ast.tagged?(target, :var_field)
163
+ next unless Ast.tagged?(value, :string_literal)
164
+
165
+ name = Ast.ident_text(target[1])
166
+ vars << name if name
167
+ end
168
+ end
169
+ private_class_method :string_literal_assigned_vars
170
+
171
+ def shovel_onto_tracked_receiver?(node, literal_vars)
172
+ return false unless Ast.tagged?(node, :binary) && node[2] == :<<
173
+
174
+ tracked_receiver?(node[1], literal_vars)
175
+ end
176
+ private_class_method :shovel_onto_tracked_receiver?
177
+
178
+ def index_assign_onto_tracked_receiver?(node, literal_vars)
179
+ return false unless Ast.tagged?(node, :assign) && Ast.tagged?(node[1], :aref_field)
180
+
181
+ tracked_receiver?(node[1][1], literal_vars)
182
+ end
183
+ private_class_method :index_assign_onto_tracked_receiver?
184
+
185
+ def mutating_call_on_tracked_receiver?(node, literal_vars)
186
+ return false unless Ast.tagged?(node, :call, :command_call)
187
+
188
+ receiver_and_name = Ast.call_name(node)
189
+ return false unless receiver_and_name
190
+
191
+ receiver, method_name = receiver_and_name
192
+ return false unless MUTATING_METHODS.include?(method_name)
193
+
194
+ tracked_receiver?(receiver, literal_vars)
195
+ end
196
+ private_class_method :mutating_call_on_tracked_receiver?
197
+
198
+ def tracked_receiver?(receiver, literal_vars)
199
+ return true if Ast.tagged?(receiver, :string_literal)
200
+ return false unless Ast.tagged?(receiver, :var_ref, :vcall)
201
+
202
+ literal_vars.include?(Ast.ident_text(receiver[1]))
203
+ end
204
+ private_class_method :tracked_receiver?
205
+
206
+ # Only handles the unambiguous case: the interpolated string is the
207
+ # SOLE argument to the flagged call (immediately preceded by `(` and
208
+ # immediately followed by `)` on the same physical line) — anything
209
+ # else (an existing second argument, a multi-line call) is left alone
210
+ # rather than guessed at, since inserting a new bind parameter at the
211
+ # right spot in an arbitrary chained/multi-arg call isn't a one-answer
212
+ # problem. Quote characters directly hugging a `#{...}` (the common
213
+ # `"id = '#{x}'"` manual-SQL-quoting style) are consumed along with it
214
+ # — leaving them in place would produce `'?'`, which double-quotes the
215
+ # bound value and silently breaks the query while still looking
216
+ # "verified" (Scryer's own check only looks for interpolation, not
217
+ # query correctness).
218
+ def fix_sql_injection(finding, root:)
219
+ method = finding.message.to_s[/\A`(\w+)`/, 1]
220
+ return nil unless method
221
+
222
+ line = raw_line(finding, root)
223
+ m = line.match(/\A(?<pre>.*\b#{Regexp.escape(method)}\s*\(\s*)"(?<body>(?:[^"\\]|\\.)*)"\s*\)(?<rest>.*)\z/)
224
+ return nil unless m
225
+
226
+ exprs = []
227
+ new_body = m[:body].gsub(/(\\"|')?#\{([^{}]*)\}(\\"|')?/) do
228
+ exprs << Regexp.last_match(2).strip
229
+ "?"
230
+ end
231
+ return nil if exprs.empty? || new_body.include?("\#{")
232
+
233
+ code = "#{m[:pre]}\"#{new_body}\", #{exprs.join(", ")})#{m[:rest]}"
234
+ explanation = "Replaces the string interpolation inside the SQL string with #{exprs.size > 1 ? 'bind parameters' : 'a `?` bind parameter'}, " \
235
+ "so the value#{exprs.size > 1 ? 's are' : ' is'} always sent as a query parameter rather than parsed as SQL text."
236
+ wrap_after(explanation, code)
237
+ end
238
+
239
+ def fix_boolean_flip(finding, root, pattern, explanation)
240
+ line = raw_line(finding, root)
241
+ return nil unless pattern.match?(line)
242
+
243
+ wrap_after(explanation, line.sub(pattern, '\1true'))
244
+ end
245
+
246
+ def fix_cookie_serializer(finding, root:)
247
+ line = raw_line(finding, root)
248
+ pattern = /(cookies_serializer\s*=\s*)(:marshal|["']marshal["'])/
249
+ return nil unless pattern.match?(line)
250
+
251
+ code = line.sub(pattern, '\1:json')
252
+ wrap_after("Switches the cookie serializer from `:marshal` to Rails' safe default, `:json`.", code)
253
+ end
254
+
255
+ # Appends `secure: true` (production-only) to the end of the
256
+ # `session_store` line — safe because this is a single command-call
257
+ # statement with no parens to worry about closing correctly.
258
+ def fix_weak_session_cookie(finding, root:)
259
+ line = raw_line(finding, root)
260
+ return nil if line.strip.empty?
261
+
262
+ code = "#{line.chomp}, secure: Rails.env.production?"
263
+ wrap_after("Adds `secure: true` (production only) to the session cookie options, so it's never sent over plain HTTP.", code)
264
+ end
265
+
266
+ # Only the plain single-header `= value` assignment shape (see
267
+ # SecurityHeadersRule) — a `.merge!(...)` call can disable several
268
+ # headers in one statement, only one of which may be the actual
269
+ # finding, so removing the whole line there could silently take out an
270
+ # unrelated, legitimate header too. Comments the line out (rather than
271
+ # deleting it outright) so there's a visible trace of what changed —
272
+ # keeping the original leading indentation so the comment lines up with
273
+ # its surrounding code instead of jumping to column 0.
274
+ def fix_security_headers_disabled(finding, root:)
275
+ line = raw_line(finding, root)
276
+ return nil if line.include?("merge!") || !line.include?("=")
277
+
278
+ indent = line[/\A[ \t]*/]
279
+ code = "#{indent}# #{line.strip} # removed by `scryer fix` — restores Rails' default security header"
280
+ wrap_after("Comments out the line disabling this security header, restoring Rails' safe default.", code)
281
+ end
282
+
283
+ def wrap_after(explanation, code)
284
+ "#{explanation}\n\nAFTER:\n```ruby\n#{code}\n```\n"
285
+ end
286
+ private_class_method :wrap_after
287
+ end
288
+ end
@@ -0,0 +1,48 @@
1
+ # Opt-in Minitest integration — require this file yourself (e.g. `require
2
+ # "scryer/minitest"` in test_helper.rb) rather than it loading automatically
3
+ # with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a
4
+ # Scryer runtime dependency, and this file's assertions only make sense once
5
+ # the host app's own test framework is already loaded).
6
+ #
7
+ # Mix Scryer::MinitestAssertions into a Minitest::Test (or
8
+ # ActiveSupport::TestCase, which is one) to assert this app's own security
9
+ # scan stays clean as part of its normal test suite:
10
+ #
11
+ # class SecurityTest < ActiveSupport::TestCase
12
+ # include Scryer::MinitestAssertions
13
+ #
14
+ # test "no critical findings" do
15
+ # assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
16
+ # end
17
+ #
18
+ # test "the mass-assignment bug fixed in PR #123 doesn't come back" do
19
+ # assert_no_scryer_findings_for(Scryer.scan(root: Rails.root.to_s), "mass_assignment")
20
+ # end
21
+ # end
22
+ module Scryer
23
+ module MinitestAssertions
24
+ # Only security findings — style/performance findings aren't a security
25
+ # regression, same scoping as the RSpec have_no_critical_findings matcher.
26
+ def assert_no_critical_scryer_findings(result, msg = nil)
27
+ criticals = result.security_findings.select { |f| f.severity == "critical" }
28
+ default_msg = -> {
29
+ lines = criticals.map { |f| " - #{f.rule_id} at #{f.file}:#{f.line} — #{f.message}" }
30
+ "expected no critical security findings, but got #{criticals.size}:\n#{lines.join("\n")}"
31
+ }
32
+ assert criticals.empty?, msg || default_msg.call
33
+ end
34
+
35
+ # Checks all three categories (security/performance/style) — a rule
36
+ # regressing is worth catching regardless of which category it's filed
37
+ # under, unlike assert_no_critical_scryer_findings above.
38
+ def assert_no_scryer_findings_for(result, rule_id, msg = nil)
39
+ matches = (result.security_findings + result.performance_findings + result.style_findings)
40
+ .select { |f| f.rule_id == rule_id.to_s }
41
+ default_msg = -> {
42
+ lines = matches.map { |f| " - #{f.file}:#{f.line} — #{f.message}" }
43
+ "expected no findings for rule #{rule_id.inspect}, but got #{matches.size}:\n#{lines.join("\n")}"
44
+ }
45
+ assert matches.empty?, msg || default_msg.call
46
+ end
47
+ end
48
+ end
@@ -14,6 +14,7 @@ module Scryer
14
14
  self.category = "performance"
15
15
  self.default_severity = "warning"
16
16
  self.title = "Per-record save/update inside a loop"
17
+ self.confidence = "medium"
17
18
 
18
19
  LOOP_METHODS = %w[each each_with_index].freeze
19
20
  # Genuinely argless in normal use — a bare `:call` node, never wrapped
@@ -24,7 +25,25 @@ module Scryer
24
25
  # avoids double-counting the inner call node these wrap.
25
26
  ARG_METHODS = %w[update update! update_attribute update_attributes].freeze
26
27
 
28
+ # `db/seeds.rb` (and the multi-file `db/seeds/*.rb` convention Rails
29
+ # supports via `Rails.application.load_seed`) is a one-time, manually
30
+ # run setup script, not a request-handling hot path — the whole
31
+ # premise of this rule (per-record round-trips scale badly as load
32
+ # grows) doesn't apply to a script a developer runs once at setup
33
+ # time for a small, fixed reference dataset (roles, plans, countries,
34
+ # ...). Flagging `roles.each { |r| r.save! }` there is just noise, so
35
+ # this rule skips findings whose file is exactly this path or under
36
+ # this directory. Deliberately file-path-based rather than trying to
37
+ # infer "small dataset" from the AST (which isn't reliably knowable —
38
+ # a seed file can still iterate a CSV of arbitrary size), and narrow
39
+ # to this one well-known Rails convention rather than any file that
40
+ # merely "looks like a script" elsewhere in the app.
41
+ SEED_FILE = "db/seeds.rb"
42
+ SEED_DIR_PREFIX = "db/seeds/"
43
+
27
44
  def scan
45
+ return [] if seed_file?
46
+
28
47
  findings = []
29
48
 
30
49
  Ast.each_node(sexp) do |node|
@@ -58,6 +77,19 @@ module Scryer
58
77
 
59
78
  private
60
79
 
80
+ # `Scanner` builds `file` by globbing `File.join(root, dir, "**", "*.rb")`
81
+ # with `dir` defaulting to `"."`, so the relative path it hands rules is
82
+ # actually `"./db/seeds.rb"`, not `"db/seeds.rb"` — a plain `==`/
83
+ # `start_with?` against the un-prefixed string silently never matches
84
+ # against a real scan (verified: without stripping the prefix here,
85
+ # `db/seeds.rb` still fired in a real `Scryer::Scanner.new(root:,
86
+ # dirs: ["."]).call` run). Strip a leading `./` before comparing so
87
+ # this matches how the path actually arrives, not an assumed form.
88
+ def seed_file?
89
+ normalized = file.to_s.delete_prefix("./")
90
+ normalized == SEED_FILE || normalized.start_with?(SEED_DIR_PREFIX)
91
+ end
92
+
61
93
  def block_param_name(block_node)
62
94
  return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
63
95
 
@@ -12,6 +12,7 @@ module Scryer
12
12
  self.category = "performance"
13
13
  self.default_severity = "warning"
14
14
  self.title = "Possible unbounded result set on an index action"
15
+ self.confidence = "medium"
15
16
 
16
17
  QUERY_METHODS = %w[all where].freeze
17
18
  BOUND_METHODS = %w[limit page per paginate find_each find_in_batches first take].freeze
@@ -21,6 +21,7 @@ module Scryer
21
21
  self.category = "performance"
22
22
  self.default_severity = "warning"
23
23
  self.title = "Possible N+1 query inside a loop"
24
+ self.confidence = "medium"
24
25
 
25
26
  QUERY_METHODS = %w[where all find find_by find_by! order limit].freeze
26
27
  LOOP_METHODS = %w[each map collect each_with_index].freeze
@@ -14,6 +14,7 @@ module Scryer
14
14
  self.category = "performance"
15
15
  self.default_severity = "warning"
16
16
  self.title = "Full query result loaded into memory before iterating"
17
+ self.confidence = "medium"
17
18
 
18
19
  QUERY_METHODS = %w[all where order].freeze
19
20