scryer 1.1.1 → 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.
@@ -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
@@ -31,10 +31,19 @@ module Scryer
31
31
  # `skip_rules` silences specific checks by rule_id (e.g. a known false
32
32
  # positive on this codebase) without editing/removing the rule itself —
33
33
  # accepts strings or symbols, matched against Rule.rule_id.
34
- def initialize(root:, dirs: DEFAULT_GLOB_DIRS, skip_rules: [])
34
+ #
35
+ # `detect_duplicates: false` skips duplicate-code detection entirely
36
+ # (method/query/cache-key extraction and the DuplicateDetector passes
37
+ # below) — unlike the security/performance/style rules, duplicate
38
+ # detection isn't a `Scryer::Rule` with its own rule_id, so `skip_rules`
39
+ # has no way to address it; this is its equivalent off switch. See
40
+ # `Scryer::Configuration#detect_duplicates` for the config-driven default
41
+ # every CLI/rake entry point reads before constructing a Scanner.
42
+ def initialize(root:, dirs: DEFAULT_GLOB_DIRS, skip_rules: [], detect_duplicates: true)
35
43
  @root = File.expand_path(root)
36
44
  @dirs = dirs
37
45
  @skip_rules = Set.new(skip_rules.map(&:to_s))
46
+ @detect_duplicates = detect_duplicates
38
47
  end
39
48
 
40
49
  def call
@@ -77,24 +86,28 @@ module Scryer
77
86
  bucket.concat(rule_class.new(file: rel_path, source: source, sexp: sexp).scan)
78
87
  end
79
88
 
80
- if duplicate_detection_target?(rel_path)
89
+ if @detect_duplicates && duplicate_detection_target?(rel_path)
81
90
  all_methods.concat(MethodExtractor.extract(file: rel_path, source: source, sexp: sexp))
82
91
  all_queries.concat(QueryExtractor.extract(file: rel_path, source: source, sexp: sexp))
83
92
  all_cache_calls.concat(CacheExtractor.extract(file: rel_path, source: source, sexp: sexp))
84
93
  end
85
94
  end
86
95
 
87
- # Same computed value cached under the same key from multiple call
88
- # sites is normal (just reusing the cache). Only flag it when the
89
- # *keys* differ too — that's either a redundant cache entry or a key
90
- # that drifted out of sync with a copy-pasted sibling.
91
- cache_groups = DuplicateDetector.call(all_cache_calls, threshold: CACHE_SIMILARITY_THRESHOLD, kind: "cache_duplicate")
92
- .select { |g| g.members.map(&:cache_key).uniq.size > 1 }
93
-
94
96
  duplicate_groups =
95
- DuplicateDetector.call(all_methods, kind: "method_duplicate") +
96
- DuplicateDetector.call(all_queries, threshold: QUERY_SIMILARITY_THRESHOLD, kind: "query_duplicate") +
97
- cache_groups
97
+ if @detect_duplicates
98
+ # Same computed value cached under the same key from multiple call
99
+ # sites is normal (just reusing the cache). Only flag it when the
100
+ # *keys* differ too — that's either a redundant cache entry or a
101
+ # key that drifted out of sync with a copy-pasted sibling.
102
+ cache_groups = DuplicateDetector.call(all_cache_calls, threshold: CACHE_SIMILARITY_THRESHOLD, kind: "cache_duplicate")
103
+ .select { |g| g.members.map(&:cache_key).uniq.size > 1 }
104
+
105
+ DuplicateDetector.call(all_methods, kind: "method_duplicate") +
106
+ DuplicateDetector.call(all_queries, threshold: QUERY_SIMILARITY_THRESHOLD, kind: "query_duplicate") +
107
+ cache_groups
108
+ else
109
+ []
110
+ end
98
111
 
99
112
  Result.new(
100
113
  security_findings: security_findings,
@@ -1,3 +1,3 @@
1
1
  module Scryer
2
- VERSION = "1.1.1"
2
+ VERSION = "1.2.0"
3
3
  end
data/lib/scryer.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "scryer/version"
2
+ require "scryer/colorizer"
2
3
  require "scryer/ast"
3
4
  require "scryer/finding"
4
5
  require "scryer/rule_set"
@@ -13,7 +14,10 @@ require "scryer/dependency_audit"
13
14
  require "scryer/baseline"
14
15
  require "scryer/ai_client"
15
16
  require "scryer/fix_verifier"
17
+ require "scryer/mechanical_fixer"
16
18
  require "scryer/ai_fix_suggester"
19
+ require "scryer/fix_runner"
20
+ require "scryer/dependency_fixer"
17
21
 
18
22
  Dir[File.join(__dir__, "scryer", "rules", "*.rb")].sort.each { |f| require f }
19
23
  Dir[File.join(__dir__, "scryer", "performance_rules", "*.rb")].sort.each { |f| require f }
@@ -39,11 +43,21 @@ module Scryer
39
43
  # default: every registered rule runs. The `scryer` executable's
40
44
  # `--skip RULE_ID` flag adds to this list for a single run rather than
41
45
  # replacing it.
42
- attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules
46
+ #
47
+ # `detect_duplicates` toggles duplicate-code detection (method/query/
48
+ # cache-key similarity across models, controllers, helpers, and
49
+ # concerns — see Scryer::DuplicateDetector) on or off. `true` by
50
+ # default, matching this gem's existing behavior. Duplicate detection
51
+ # isn't a `Scryer::Rule`, so it has no `rule_id` and `skip_rules` can't
52
+ # address it — set this to `false` instead (or pass `--no-duplicates` /
53
+ # `SCRYER_NO_DUPLICATES=1` for a single run without changing the
54
+ # configured default) if it's too noisy or too slow for a given project.
55
+ attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules, :detect_duplicates
43
56
 
44
57
  def initialize
45
58
  @dirs = Scryer::Scanner::DEFAULT_GLOB_DIRS
46
59
  @skip_rules = []
60
+ @detect_duplicates = true
47
61
  end
48
62
  end
49
63
 
@@ -65,8 +79,8 @@ module Scryer
65
79
  # task aren't changed to use this (they also handle dependency auditing,
66
80
  # baselines, and report writing inline) — this is for callers that only
67
81
  # need the static-scan Result itself.
68
- def scan(root:, dirs: configuration.dirs, skip_rules: configuration.skip_rules)
69
- Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
82
+ def scan(root:, dirs: configuration.dirs, skip_rules: configuration.skip_rules, detect_duplicates: configuration.detect_duplicates)
83
+ Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
70
84
  end
71
85
  end
72
86
  end