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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- 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
|