scryer 1.1.1 → 1.2.1

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
@@ -106,16 +106,21 @@ module Scryer
106
106
  end
107
107
 
108
108
  # A single 0-100 number (plus a letter grade) summarizing this scan's
109
- # security risk exposure — security findings and dependency findings
110
- # only (performance/code-quality findings aren't security risk, so
111
- # they're deliberately excluded; a slow app doesn't lower a *security*
112
- # score). Deliberately NOT normalized by files-scanned or lines of code:
113
- # it reflects this scan's absolute finding exposure, so it's meaningful
114
- # for tracking one project's trend over time (does the next scan score
115
- # higher or lower), not for comparing two differently-sized codebases
116
- # against each other — a bigger app with the same finding *density* will
117
- # naturally score lower here, and that's a documented limitation, not a
118
- # bug.
109
+ # security risk exposure — security findings and dependency findings at
110
+ # full weight, plus performance findings at a small fraction of that
111
+ # weight (a slow app is a real cost, just not a *security* one, so it
112
+ # nudges the score rather than driving it — see SCORE_CATEGORY_WEIGHT).
113
+ # Code-quality findings (duplicate-code groups, frozen_string_literal)
114
+ # are never part of this on purpose: a `DuplicateDetector::DuplicateGroup`
115
+ # isn't even a `Finding` (no severity/confidence to weigh in the first
116
+ # place — see duplicate_detector.rb), and style findings are cosmetic,
117
+ # not risk. Deliberately NOT normalized by files-scanned or lines of
118
+ # code: it reflects this scan's absolute finding exposure, so it's
119
+ # meaningful for tracking one project's trend over time (does the next
120
+ # scan score higher or lower), not for comparing two differently-sized
121
+ # codebases against each other — a bigger app with the same finding
122
+ # *density* will naturally score lower here, and that's a documented
123
+ # limitation, not a bug.
119
124
  #
120
125
  # Exponential decay rather than linear subtraction from 100: a single
121
126
  # critical/high finding should visibly move the score (100 -> ~86) without
@@ -123,11 +128,24 @@ module Scryer
123
128
  # which would make the score useless for comparing "bad" against "worse."
124
129
  # weighted_penalty combines severity (the dominant factor) with
125
130
  # confidence (a low-confidence rule's finding shouldn't hurt the score as
126
- # much as a high-confidence one saying the same severity) — same
127
- # philosophy as SARIF's `rank` (see sarif_rank above), computed once here
128
- # for a single scan-level number instead of per-result.
129
- SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 1 }.freeze
131
+ # much as a high-confidence one saying the same severity) and category —
132
+ # same philosophy as SARIF's `rank` (see sarif_rank above), computed once
133
+ # here for a single scan-level number instead of per-result.
134
+ # "info" is deliberately far below critical/warning, not just a step
135
+ # down from them — an info-severity finding is closer to a cosmetic
136
+ # note than a real risk (see e.g. csrf_protection_disabled's
137
+ # narrowly-scoped-skip case), so a codebase with only info findings
138
+ # should barely move off 100 even with a fair number of them, rather
139
+ # than accumulating like a smaller warning would.
140
+ SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 0.25 }.freeze
130
141
  SCORE_CONFIDENCE_WEIGHT = { "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
142
+ # `category` is "security" for every Scryer::Finding in security_findings
143
+ # and every DependencyAudit::Finding (which has no `category` field at
144
+ # all — `f["category"]` is nil for those, so they fall through to the
145
+ # 1.0 default below, same full weight as security). "performance" is the
146
+ # only other category ever passed into this method's `findings` — style
147
+ # findings and duplicate-code groups never reach here at all (see above).
148
+ SCORE_CATEGORY_WEIGHT = { "security" => 1.0, "performance" => 0.2 }.freeze
131
149
  SCORE_DECAY_CONSTANT = 100.0
132
150
 
133
151
  # Deliberately reads @result/@dependency_findings directly rather than
@@ -140,10 +158,14 @@ module Scryer
140
158
  # (has "confidence") and DependencyAudit::Finding (doesn't — a plain
141
159
  # Hash returns nil for a missing key rather than raising, unlike
142
160
  # calling #confidence directly on a struct that has no such member).
143
- findings = (@result.security_findings + @dependency_findings).map(&:to_h)
161
+ # style_findings/duplicate_groups are never included — see this
162
+ # method's doc comment above for why.
163
+ findings = (@result.security_findings + @result.performance_findings + @dependency_findings).map(&:to_h)
144
164
 
145
165
  weighted_penalty = findings.sum do |f|
146
- (SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) * (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7)
166
+ (SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) *
167
+ (SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7) *
168
+ (SCORE_CATEGORY_WEIGHT[f["category"]] || 1.0)
147
169
  end
148
170
 
149
171
  score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round
@@ -368,7 +390,8 @@ module Scryer
368
390
  </div>
369
391
  <div class="score-details">
370
392
  <p class="score-label">Security Score — #{score["finding_count"]} security + dependency
371
- finding(s), weighted by severity and this rule's confidence. Not normalized by app
393
+ + performance finding(s) (performance weighted far lighter than security), weighted
394
+ by severity and this rule's confidence. Not normalized by app
372
395
  size — see the README for what this number does and doesn't mean.
373
396
  <strong>#{clean_rate["clean"]}/#{clean_rate["total"]}</strong> rules clean
374
397
  (#{clean_rate["percent"]}%) — a rule-level pass rate, a different (and not always
@@ -399,7 +422,16 @@ module Scryer
399
422
  sec_counts = count_by_severity(security)
400
423
  perf_counts = count_by_severity(performance)
401
424
  style_counts = count_by_severity(style)
402
- total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] }
425
+ # Dependency findings carry a real severity (see DependencyAudit::
426
+ # Finding#severity) same as any other finding — previously left out of
427
+ # this row (rendered as a "—" placeholder) and out of the Total row's
428
+ # sum below, which made the Total row silently undercount relative to
429
+ # what a reader would expect from a row literally labeled "Total" at
430
+ # the bottom of this same table, and inconsistent with the executive
431
+ # summary's severity bars above (which do include dependency findings
432
+ # in their own count).
433
+ deps_counts = count_by_severity(dependency_findings)
434
+ total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] + deps_counts[s] }
403
435
 
404
436
  header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
405
437
  # Category rows link via the same search-filter mechanism as the OWASP
@@ -411,13 +443,20 @@ module Scryer
411
443
  perf_row = summary_row("Performance", perf_counts, filter_term: "performance")
412
444
  style_row = summary_row("Style", style_counts, filter_term: "style")
413
445
  # The Total row has no single category tag to filter by (it's the sum
414
- # across all three), so its per-severity cells link straight to the
446
+ # across all four), so its per-severity cells link straight to the
415
447
  # matching #sev-* heading instead — same anchors, same precision, as
416
- # the severity distribution chart at the top of the report.
448
+ # the severity distribution chart at the top of the report. Same
449
+ # caveat that chart's own comment already notes: #sev-* only groups
450
+ # security/performance/style findings, so a reader following this link
451
+ # for a severity that's only present via a dependency finding won't
452
+ # see it highlighted there — still the right destination for the bulk
453
+ # of what's counted, and dependency findings are one section away via
454
+ # the Dependency audit row's own link just below.
417
455
  total_row = summary_row("Total", total_counts, css_class: "total", severity_anchors: true)
418
456
  dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
419
457
  "<td><a class=\"jump-link\" href=\"#duplicates\">#{duplicate_groups.size} group(s)</a></td></tr>"
420
- deps_row = "<tr><th>Dependency audit</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
458
+ deps_cells = SEVERITY_ORDER.map { |s| "<td>#{deps_counts[s]}</td>" }.join
459
+ deps_row = "<tr><th>Dependency audit</th>#{deps_cells}" \
421
460
  "<td><a class=\"jump-link\" href=\"#dependency-audit\">#{dependency_findings.size} finding(s)</a></td></tr>"
422
461
 
423
462
  "<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
@@ -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.1"
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