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
data/lib/scryer/cli.rb CHANGED
@@ -11,34 +11,49 @@ module Scryer
11
11
  class CLI
12
12
  EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
13
13
 
14
- def initialize(argv, stdout: $stdout, stderr: $stderr)
14
+ def initialize(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
15
15
  @argv = argv
16
16
  @stdout = stdout
17
17
  @stderr = stderr
18
+ @stdin = stdin
19
+ @color_override = nil
18
20
  end
19
21
 
20
22
  # Returns a process exit code: 0 if clean, 1 if security findings were
21
23
  # found (so `scryer -o report.json` can gate CI the way `brakeman -o
22
24
  # report.json` does), 2 on a usage error.
23
25
  def run
26
+ # `scryer verify` is a distinct subcommand, not a flag on the normal
27
+ # scan — it takes its own small option set (--rule/--file/--path) that
28
+ # would collide with the main parser's -p/-o meanings, so it's
29
+ # dispatched before the main OptionParser ever sees the rest of argv.
30
+ return run_verify(@argv[1..]) if @argv.first == "verify"
31
+ return run_fix(@argv[1..]) if @argv.first == "fix"
32
+
24
33
  options = parse(@argv)
25
34
  return 0 if options[:exit_early]
26
35
 
36
+ root = File.expand_path(options[:path] || Dir.pwd)
37
+
27
38
  # The standalone executable has no equivalent of a Rails app's
28
39
  # config/initializers/scryer.rb getting autoloaded at boot — this is
29
40
  # the only way to run Scryer.configure (set c.ai_client, c.skip_rules,
30
- # c.dirs, ...) before a scan starts outside Rails.
31
- Array(options[:require]).each { |path| require File.expand_path(path) }
41
+ # c.dirs, ...) before a scan starts outside Rails. With no explicit
42
+ # `-r`, fall back to auto-requiring config/initializers/scryer.rb
43
+ # under `root`, if it exists — see #auto_discover_initializer.
44
+ require_paths = Array(options[:require])
45
+ require_paths = auto_discover_initializer(root) if require_paths.empty?
46
+ require_paths.each { |path| require File.expand_path(path) }
32
47
 
33
48
  return check_gem(options[:check_gem]) if options[:check_gem]
34
49
 
35
- root = File.expand_path(options[:path] || Dir.pwd)
36
50
  return audit_deps(root) if options[:audit_deps]
37
51
 
38
52
  skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
39
53
  @stdout.puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
54
+ detect_duplicates = options[:no_duplicates] ? false : Scryer.configuration.detect_duplicates
40
55
 
41
- result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules).call
56
+ result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
42
57
 
43
58
  # Dependency auditing (OSV.dev) runs by default — a single `scryer`
44
59
  # invocation is meant to cover the same ground as RuboCop + Brakeman +
@@ -54,9 +69,16 @@ module Scryer
54
69
  DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
55
70
  end
56
71
 
72
+ return save_baseline(options[:save_baseline], result, dependency_findings) if options[:save_baseline]
73
+
74
+ fixed_count = 0
75
+ if options[:baseline]
76
+ dependency_findings, fixed_count = apply_baseline(options[:baseline], result, dependency_findings)
77
+ end
78
+
57
79
  if Scryer.configuration.ai_client
58
80
  @stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
59
- AiFixSuggester.enhance_result!(result)
81
+ AiFixSuggester.enhance_result!(result, root: root)
60
82
  AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
61
83
  end
62
84
 
@@ -72,7 +94,8 @@ module Scryer
72
94
  outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
73
95
  outputs.each { |path| write_report(renderer, path) }
74
96
 
75
- print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs)
97
+ print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs,
98
+ renderer: renderer, fixed_count: fixed_count, baseline_path: options[:baseline])
76
99
 
77
100
  result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
78
101
  rescue UsageError => e
@@ -84,6 +107,41 @@ module Scryer
84
107
 
85
108
  UsageError = Class.new(StandardError)
86
109
 
110
+ # Only consulted when `-r`/`--require` wasn't passed at all — an explicit
111
+ # `-r` (even to a file that doesn't exist, which `require` will raise on)
112
+ # always wins and this is never consulted. config/initializers/scryer.rb
113
+ # is exactly where a Rails app's own boot process would autoload
114
+ # Scryer.configure from (see the generator template at
115
+ # lib/generators/scryer/templates/scryer_initializer.rb); this just saves
116
+ # having to pass `-r config/initializers/scryer.rb` by hand every time
117
+ # when running the standalone executable against a Rails project that
118
+ # already has one. Prints a one-line notice so this is never a silent
119
+ # behavior switch — the whole point is to avoid the "AI client configured
120
+ # but scryer fix isn't using it" confusion an unnoticed missing `-r` used
121
+ # to cause.
122
+ def auto_discover_initializer(root)
123
+ candidate = File.join(root, "config", "initializers", "scryer.rb")
124
+ return [] unless File.file?(candidate)
125
+
126
+ @stdout.puts "Scryer: no -r/--require given — found and requiring #{candidate} (pass -r to override)."
127
+ [candidate]
128
+ end
129
+
130
+ # Thin wrappers around Scryer::Colorizer bound to this CLI's own stdout
131
+ # and --color/--no-color override — every color call in this file goes
132
+ # through these three instead of calling Colorizer directly.
133
+ def paint(text, *styles)
134
+ Colorizer.paint(text, *styles, stream: @stdout, override: @color_override)
135
+ end
136
+
137
+ def paint_severity(text, severity)
138
+ Colorizer.severity(text, severity, stream: @stdout, override: @color_override)
139
+ end
140
+
141
+ def paint_grade(text, letter)
142
+ Colorizer.grade(text, letter, stream: @stdout, override: @color_override)
143
+ end
144
+
87
145
  # Mirrors the `scryer:audit_dependencies` rake task's output/exit-code
88
146
  # behavior, so `scryer --audit-deps` works the same outside a Rails app.
89
147
  def audit_deps(root)
@@ -96,7 +154,7 @@ module Scryer
96
154
  credentials_exposure = DependencyAudit.credentials_exposure_check(root)
97
155
 
98
156
  (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
99
- @stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
157
+ @stdout.puts "[#{paint_severity(f.severity.upcase, f.severity)}] #{dependency_label(f)}"
100
158
  @stdout.puts " fix: #{f.suggested_fix}"
101
159
  end
102
160
 
@@ -118,7 +176,7 @@ module Scryer
118
176
  # category Scryer covers (security, performance, duplicate/smelly code,
119
177
  # dependencies), the same categories usually split across RuboCop +
120
178
  # Brakeman + bundler-audit + Reek, side by side in one box.
121
- def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
179
+ def print_summary(result:, dependency_findings:, ran_deps:, outputs:, renderer:, fixed_count: 0, baseline_path: nil)
122
180
  # "Code Quality" is the umbrella label for both duplicate-code groups
123
181
  # and rule-based style findings (e.g. frozen_string_literal) — two
124
182
  # different detectors, same broad concern, one row in the box.
@@ -133,15 +191,27 @@ module Scryer
133
191
  ["Dependencies", deps_count]
134
192
  ]
135
193
 
136
- divider = "─" * 32
194
+ divider = paint("─" * 32, :gray)
195
+ score = renderer.security_score
137
196
  @stdout.puts ""
138
- @stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
197
+ @stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
139
198
  @stdout.puts divider
140
199
  @stdout.puts ""
200
+ if baseline_path
201
+ @stdout.puts "Baseline: #{baseline_path} — showing new findings only " \
202
+ "(#{fixed_count} fixed since baseline)."
203
+ @stdout.puts ""
204
+ end
205
+ clean_rate = renderer.rules_clean_rate
206
+ @stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
207
+ @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
208
+ @stdout.puts ""
141
209
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
142
210
  @stdout.puts divider
143
- @stdout.puts summary_row("Total", total)
211
+ @stdout.puts paint(summary_row("Total", total), :bold)
144
212
  @stdout.puts ""
213
+ print_top_priorities(renderer.top_risks)
214
+ print_owasp_coverage(renderer.owasp_coverage)
145
215
  outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
146
216
  end
147
217
 
@@ -150,6 +220,536 @@ module Scryer
150
220
  "#{label.ljust(14)}#{value.rjust(20)}"
151
221
  end
152
222
 
223
+ # The categories above are counted separately, but nothing else ranks
224
+ # across them — this is what actually backs "tells you what to fix
225
+ # first" rather than just splitting findings into four buckets. Same
226
+ # data ReportRenderer#top_risks already sorts for the HTML report.
227
+ def print_top_priorities(risks)
228
+ return if risks.empty?
229
+
230
+ @stdout.puts paint("Top priorities:", :bold)
231
+ risks.each_with_index do |r, i|
232
+ @stdout.puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
233
+ end
234
+ @stdout.puts ""
235
+ end
236
+
237
+ # Byproduct of every security rule carrying an owasp_category — see
238
+ # ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
239
+ # OWASP-audited mapping (documented in full in the README).
240
+ def print_owasp_coverage(coverage)
241
+ return if coverage.empty?
242
+
243
+ @stdout.puts "OWASP Top 10 (2021) coverage:"
244
+ coverage.each { |category, count| @stdout.puts " #{category}: #{count} finding#{"s" unless count == 1}" }
245
+ @stdout.puts ""
246
+ end
247
+
248
+ # `scryer verify` — re-checks whether specific findings still fire,
249
+ # independent of writing a full report. `--rule`/`--file` narrow the
250
+ # scope; either or both can be omitted to broaden it:
251
+ # --rule ID --file PATH → re-parses just that one file and re-runs
252
+ # just that one rule (the original, narrowest
253
+ # case — meant to run right after applying a
254
+ # fix by hand or reviewing an AI suggestion,
255
+ # without waiting on/paying for a full scan)
256
+ # --file PATH only → re-parses that one file, runs every rule
257
+ # --rule ID only → full project scan, filtered to that rule
258
+ # neither → full project scan, every rule, every file
259
+ # Deliberately narrower than "did this change introduce a NEW finding
260
+ # elsewhere" in the --rule+--file case — that's what a normal `scryer`
261
+ # run (or `--baseline`) already answers; only the "neither" case here
262
+ # actually covers the whole project, and even then without a report.
263
+ def run_verify(argv)
264
+ options = {}
265
+
266
+ parser = OptionParser.new do |opts|
267
+ opts.banner = "Usage: scryer verify [--rule RULE_ID] [--file PATH] [--path ROOT]"
268
+ opts.on("--rule RULE_ID", "Only re-check this rule_id (omit to check every rule) — see `scryer verify --list-rules`.") { |v| options[:rule] = v }
269
+ opts.on("--file PATH", "Only re-check this file (omit to check the whole project) — relative to --path, or absolute.") { |v| options[:file] = v }
270
+ opts.on("--path ROOT", "Project root PATH is relative to, or that gets checked entirely if --file is omitted (default: current directory).") { |v| options[:root] = v }
271
+ opts.on("--list-rules", "List every known rule_id and exit.") { options[:list_rules] = true }
272
+ opts.on("--color", "Force colored output even when stdout isn't a terminal.") { options[:color] = true }
273
+ opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
274
+ opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
275
+ end
276
+
277
+ begin
278
+ parser.parse!(argv)
279
+ rescue OptionParser::ParseError => e
280
+ raise UsageError, "#{e.message}\n#{parser}"
281
+ end
282
+ @color_override = options[:color]
283
+ return 0 if options[:exit_early]
284
+
285
+ if options[:list_rules]
286
+ RuleSet.all.map(&:rule_id).sort.each { |id| @stdout.puts id }
287
+ return 0
288
+ end
289
+
290
+ rule_class = nil
291
+ if options[:rule]
292
+ rule_class = RuleSet.all.find { |r| r.rule_id == options[:rule] }
293
+ raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids." unless rule_class
294
+ end
295
+
296
+ root = File.expand_path(options[:root] || Dir.pwd)
297
+
298
+ if options[:file]
299
+ verify_file(options[:file], root: root, rule_class: rule_class, rule_id: options[:rule])
300
+ else
301
+ verify_project(root: root, rule_id: options[:rule])
302
+ end
303
+ end
304
+
305
+ def verify_file(file, root:, rule_class:, rule_id:)
306
+ abs_path = File.expand_path(file, root)
307
+ raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
308
+
309
+ rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
310
+ source = File.read(abs_path)
311
+
312
+ sexp = begin
313
+ Ripper.sexp(source)
314
+ rescue StandardError => e
315
+ raise UsageError, "#{rel_path} failed to parse: #{e.message}"
316
+ end
317
+ if sexp.nil?
318
+ raise UsageError, "#{rel_path} could not be parsed (a syntax error, or Ruby syntax newer " \
319
+ "than this gem's Ruby runtime supports)."
320
+ end
321
+
322
+ rule_classes = rule_class ? [rule_class] : RuleSet.all
323
+ findings = rule_classes.flat_map { |rc| rc.new(file: rel_path, source: source, sexp: sexp).scan }
324
+
325
+ if findings.empty?
326
+ subject = rule_id ? "#{rule_id} no longer fires on #{rel_path}" : "#{rel_path} is clean — no findings"
327
+ @stdout.puts "scryer verify: #{subject} — #{paint('fix verified', :green, :bold)}."
328
+ 0
329
+ else
330
+ subject = rule_id ? "#{rule_id} #{paint('still fires', :red, :bold)} on #{rel_path}" : "#{rel_path} #{paint('still has findings', :red, :bold)}"
331
+ @stdout.puts "scryer verify: #{subject} (#{findings.size} finding(s)):"
332
+ if rule_class
333
+ findings.each { |f| @stdout.puts " line #{f.line}: #{f.message}" }
334
+ else
335
+ @stdout.puts ""
336
+ print_findings_list(findings)
337
+ end
338
+ 1
339
+ end
340
+ end
341
+
342
+ def verify_project(root:, rule_id:)
343
+ # detect_duplicates: false — `scryer verify` only ever looks at
344
+ # security/performance/style findings (see below), never
345
+ # duplicate_groups, so there's nothing to gain from running that pass
346
+ # here regardless of Scryer.configuration.detect_duplicates.
347
+ result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: Scryer.configuration.skip_rules, detect_duplicates: false).call
348
+ candidates = result.security_findings + result.performance_findings + result.style_findings
349
+ candidates = candidates.select { |f| f.rule_id == rule_id } if rule_id
350
+
351
+ if candidates.empty?
352
+ subject = rule_id ? "#{rule_id} no longer fires anywhere under #{root}" : "#{root} is clean — no findings"
353
+ @stdout.puts "scryer verify: #{subject} — #{paint('fix verified', :green, :bold)}."
354
+ 0
355
+ else
356
+ subject = rule_id ? "#{rule_id} #{paint('still fires', :red, :bold)} under #{root}" : "#{paint('still has findings', :red, :bold)} under #{root}"
357
+ @stdout.puts "scryer verify: #{subject} (#{candidates.size} finding(s)):"
358
+ @stdout.puts ""
359
+ print_findings_list(candidates)
360
+ 1
361
+ end
362
+ end
363
+
364
+ SEVERITY_ORDER = { "critical" => 0, "warning" => 1, "info" => 2 }.freeze
365
+
366
+ # Grouped by severity (critical first, matching "Top priorities"), one
367
+ # short line per finding (severity tag + location) followed by the
368
+ # message truncated to a scannable length — full untruncated detail is
369
+ # what a real `scryer` report (JSON/HTML) is for; this is meant to be a
370
+ # quick "what's still failing" glance, not a wall of text.
371
+ def print_findings_list(findings)
372
+ findings.sort_by { |f| SEVERITY_ORDER.fetch(f.severity, 3) }.each do |f|
373
+ @stdout.puts " [#{paint_severity(f.severity, f.severity)}] #{f.rule_id} — #{f.file}:#{f.line}"
374
+ @stdout.puts " #{truncate_message(f.message)}"
375
+ end
376
+ @stdout.puts ""
377
+ end
378
+
379
+ def truncate_message(text, limit: 100)
380
+ text = text.to_s.strip
381
+ return text if text.length <= limit
382
+
383
+ cut = text[0...limit]
384
+ cut = cut[0...(cut.rindex(" ") || limit)]
385
+ "#{cut}…"
386
+ end
387
+
388
+ # `scryer fix` — the third leg of scan → fix → verify. Scans, asks the
389
+ # configured `ai_client` for a rewrite of every qualifying finding (same
390
+ # AiFixSuggester/FixVerifier machinery `scryer:report` already uses to
391
+ # populate `fix_verified`), and — this is the one command in the whole
392
+ # gem that does this — actually writes a fix to a real file, but ONLY
393
+ # when FixVerifier's in-memory check says that exact rewrite clears the
394
+ # finding. Anything not independently verified this way is left alone
395
+ # and reported as needing manual review, same as it would be in a normal
396
+ # report; this command never writes an unverified guess. Requires an
397
+ # `ai_client` (see -r/--require) — there's no non-AI fallback, since a
398
+ # rule's generic suggested_fix is prose, not a machine-applicable patch.
399
+ # `--deps` switches to a completely separate pipeline for dependency
400
+ # findings instead — see Scryer::DependencyFixer and #run_fix_deps below.
401
+ def run_fix(argv)
402
+ options = {}
403
+
404
+ parser = OptionParser.new do |opts|
405
+ opts.banner = "Usage: scryer fix [--rule RULE_ID] [--file PATH] [--number N] [--list] [--path ROOT] [--dry-run]\n" \
406
+ " scryer fix --deps [--path ROOT] [--dry-run]"
407
+ opts.on("--rule RULE_ID", "Only fix findings for this rule_id (repeatable).") { |v| (options[:rules] ||= []) << v }
408
+ opts.on("--file PATH", "Only fix findings in this file (repeatable) — relative to --path, or absolute.") { |v| (options[:files] ||= []) << v }
409
+ opts.on("--number N", "Only fix the finding(s) at this position in the numbered candidate " \
410
+ "list (see --list; repeatable, or comma-separated: --number 1,3). " \
411
+ "Numbering is stable across runs with the same --rule/--file filters.") do |v|
412
+ (options[:numbers] ||= []).concat(v.split(",").map(&:strip))
413
+ end
414
+ opts.on("--list", "Print the matching findings as a numbered list (after --rule/--file " \
415
+ "filters) and exit without fixing anything.") { options[:list] = true }
416
+ opts.on("--path ROOT", "Project root to scan (default: current directory).") { |v| options[:root] = v }
417
+ opts.on("-r PATH", "--require PATH",
418
+ "Require a Ruby file before scanning (repeatable) — use this to call " \
419
+ "Scryer.configure and set c.ai_client, same as the main scan command. If " \
420
+ "omitted, config/initializers/scryer.rb under --path is auto-required when it " \
421
+ "exists.") { |v| (options[:require] ||= []) << v }
422
+ opts.on("--skip RULE_ID", "Skip a rule by rule_id (repeatable), same as the main scan command.") { |v| (options[:skip] ||= []) << v }
423
+ opts.on("--dry-run", "Show what would be fixed without writing anything.") { options[:dry_run] = true }
424
+ opts.on("--yes", "Apply every independently-verified fix automatically, skipping the " \
425
+ "per-finding accept/skip prompt this command otherwise shows in an " \
426
+ "interactive terminal.") { options[:yes] = true }
427
+ opts.on("--deps", "Fix vulnerable dependencies instead of rule-based findings — runs " \
428
+ "`bundle update GEM --conservative` for each vulnerable gem " \
429
+ "(DependencyAudit.vulnerable_gems) that has a published patched " \
430
+ "version, then re-checks OSV.dev to confirm it cleared. Ignores " \
431
+ "--rule/--file/--number/--list, which only make sense for rule-based " \
432
+ "findings; --path/--dry-run/--yes/--color/--no-color still apply.") { options[:deps] = true }
433
+ opts.on("--color", "Force colored output even when stdout isn't a terminal.") { options[:color] = true }
434
+ opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
435
+ opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
436
+ end
437
+
438
+ begin
439
+ parser.parse!(argv)
440
+ rescue OptionParser::ParseError => e
441
+ raise UsageError, "#{e.message}\n#{parser}"
442
+ end
443
+ @color_override = options[:color]
444
+ return 0 if options[:exit_early]
445
+
446
+ root = File.expand_path(options[:root] || Dir.pwd)
447
+
448
+ # No explicit `-r`: fall back to auto-requiring
449
+ # config/initializers/scryer.rb under `root`, if it exists — see
450
+ # #auto_discover_initializer.
451
+ require_paths = Array(options[:require])
452
+ require_paths = auto_discover_initializer(root) if require_paths.empty?
453
+ require_paths.each { |path| require File.expand_path(path) }
454
+
455
+ return run_fix_deps(options, root) if options[:deps]
456
+
457
+ ai_client = Scryer.configuration.ai_client
458
+ skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
459
+ # detect_duplicates: false — `scryer fix` only ever works from
460
+ # security/performance/style findings (see `candidates` below); a
461
+ # duplicate-code group has no rule_id/single-line suggested_fix for
462
+ # FixRunner to act on, so there's nothing to gain from that pass here.
463
+ result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules, detect_duplicates: false).call
464
+
465
+ candidates = (result.security_findings + result.performance_findings + result.style_findings)
466
+ candidates = candidates.select { |f| options[:rules].include?(f.rule_id) } if options[:rules]
467
+ candidates = candidates.select { |f| fix_target_file?(f, options[:files], root) } if options[:files]
468
+ candidates = candidates.sort_by { |f| [f.file.to_s, f.line || 0] }
469
+
470
+ if candidates.empty?
471
+ @stdout.puts "scryer fix: no matching findings to fix."
472
+ return 0
473
+ end
474
+
475
+ if options[:list]
476
+ print_candidate_list(candidates)
477
+ return 0
478
+ end
479
+
480
+ candidates = gate_frozen_string_literal(candidates, explicit: !options[:rules].nil?)
481
+ if candidates.empty?
482
+ @stdout.puts "scryer fix: no matching findings to fix."
483
+ return 0
484
+ end
485
+
486
+ candidates = select_by_number(candidates, options[:numbers]) if options[:numbers]
487
+
488
+ # A handful of rules (see Scryer::MechanicalFixer) have exactly one
489
+ # correct, deterministic fix and don't need an ai_client at all — only
490
+ # refuse to run when there's genuinely nothing this invocation could
491
+ # possibly fix: no ai_client configured AND none of the matched
492
+ # findings are mechanically fixable. Anything mechanically fixable
493
+ # still gets fixed even with no ai_client set; anything that needs AI
494
+ # but has none configured is simply left for manual review, same as
495
+ # an AI reply the verifier rejects.
496
+ if ai_client.nil? && candidates.none? { |f| MechanicalFixer.supported?(f.rule_id) }
497
+ raise UsageError, "scryer fix needs an ai_client configured (see -r/--require and the " \
498
+ "README's \"AI-assisted fix suggestions\" section) — none of the " \
499
+ "matched finding(s) have a built-in mechanical fixer " \
500
+ "(#{MechanicalFixer::SUPPORTED_RULES.join(", ")}), and a rule's generic " \
501
+ "suggested_fix is prose, not something this command can apply " \
502
+ "automatically on its own. Nothing has been changed.\n#{parser}"
503
+ end
504
+
505
+ # In an interactive terminal (and not --dry-run/--yes), review each
506
+ # independently-verified fix one at a time before writing it — "yes /
507
+ # skip / yes to all remaining / cancel" — instead of silently applying
508
+ # everything that verified clean. Non-interactive runs (CI, piped
509
+ # stdin) and --dry-run/--yes keep the old apply-everything-verified
510
+ # behavior, since there's no one to ask.
511
+ confirm = build_fix_confirmer if interactive_terminal? && !options[:dry_run] && !options[:yes]
512
+
513
+ @stdout.puts "scryer fix: #{candidates.size} candidate finding(s) — applying built-in fixes " \
514
+ "where one exists, otherwise asking the configured AI client for a rewrite; " \
515
+ "either way, only writing what's independently verified to clear the " \
516
+ "finding#{options[:dry_run] ? " (--dry-run: nothing will actually be written)" : ""}..."
517
+
518
+ fixed, skipped = FixRunner.apply(candidates, client: ai_client, root: root, dry_run: options[:dry_run], confirm: confirm) do |finding, status, error|
519
+ print_fix_progress(finding, status, error)
520
+ end
521
+
522
+ print_fix_summary(fixed: fixed, skipped: skipped, dry_run: options[:dry_run])
523
+
524
+ return 0 if options[:dry_run]
525
+
526
+ verify_applied_fixes(fixed, root: root, skip_rules: skip_rules) if fixed.any?
527
+
528
+ skipped.empty? ? 0 : 1
529
+ end
530
+
531
+ # Printed once per finding, as FixRunner.apply resolves it — not just in
532
+ # the final summary — so a developer watching the run can see what's
533
+ # happening and why, not just a wall of silence until it's all done.
534
+ def print_fix_progress(finding, status, error = nil)
535
+ label = { fixed: "Fixed", would_fix: "Would fix", declined: "Skipped (declined)",
536
+ ai_error: "Skipped (AI client error)", cancelled: "Skipped (cancelled)",
537
+ skipped: "Skipped (needs manual review)" }.fetch(status)
538
+ colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
539
+ @stdout.puts "#{colored_label}: #{finding.rule_id} — #{finding.file}:#{finding.line}"
540
+
541
+ if status == :ai_error && error
542
+ @stdout.puts " #{paint("#{error.class}: #{error.message}", :red)}"
543
+ else
544
+ explanation = FixRunner.explain(finding.suggested_fix)
545
+ @stdout.puts " #{explanation}" unless explanation.empty?
546
+ end
547
+ end
548
+
549
+ def print_fix_summary(fixed:, skipped:, dry_run:)
550
+ verb = dry_run ? "Would fix" : "Fixed"
551
+ @stdout.puts ""
552
+ @stdout.puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
553
+ fixed.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
554
+ @stdout.puts ""
555
+
556
+ return if skipped.empty?
557
+
558
+ @stdout.puts paint("#{skipped.size} finding(s) not applied (declined, or fix not independently verified):", :yellow)
559
+ skipped.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
560
+ @stdout.puts ""
561
+ end
562
+
563
+ # `scryer fix --deps` — see Scryer::DependencyFixer for why this is a
564
+ # separate pipeline from the rule-based one above (a DependencyAudit
565
+ # finding has no .line/.file/.rule_id to rewrite). Needs network (OSV.dev)
566
+ # both up front and again for DependencyFixer's own post-update re-check.
567
+ def run_fix_deps(options, root)
568
+ @stdout.puts "scryer fix --deps: querying OSV.dev for known-vulnerable gems (needs network)..."
569
+ findings = DependencyAudit.vulnerable_gems(root)
570
+
571
+ if findings.empty?
572
+ @stdout.puts "scryer fix --deps: no vulnerable gems found."
573
+ return 0
574
+ end
575
+
576
+ actionable = findings.select { |f| Array(f.patched_versions).any? }
577
+ if actionable.empty?
578
+ @stdout.puts "scryer fix --deps: #{findings.size} vulnerable gem finding(s), but none have " \
579
+ "a published patched version yet to upgrade to — nothing this command can run. " \
580
+ "See `scryer --audit-deps` for details."
581
+ return 1
582
+ end
583
+
584
+ gem_count = actionable.map(&:gem_name).uniq.size
585
+ @stdout.puts "scryer fix --deps: #{actionable.size} finding(s) across #{gem_count} gem(s) — " \
586
+ "running `bundle update GEM --conservative` for each" \
587
+ "#{options[:dry_run] ? " (--dry-run: nothing will actually run)" : ""}..."
588
+
589
+ fixed, skipped = DependencyFixer.apply(findings, root: root, dry_run: options[:dry_run]) do |finding, status, error|
590
+ print_deps_fix_progress(finding, status, error)
591
+ end
592
+
593
+ print_deps_fix_summary(fixed: fixed, skipped: skipped, dry_run: options[:dry_run])
594
+ return 0 if options[:dry_run]
595
+
596
+ skipped.empty? ? 0 : 1
597
+ end
598
+
599
+ def print_deps_fix_progress(finding, status, error = nil)
600
+ label = { fixed: "Fixed", would_fix: "Would fix", error: "Error running bundle update",
601
+ skipped: "Skipped (needs manual review)" }.fetch(status)
602
+ colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
603
+ @stdout.puts "#{colored_label}: #{finding.gem_name}#{finding.advisory_id ? " (#{finding.advisory_id})" : ""}"
604
+
605
+ if error
606
+ @stdout.puts " #{paint(error.to_s.strip, :red)}"
607
+ elsif status == :skipped && finding.suggested_fix
608
+ @stdout.puts " #{finding.suggested_fix}"
609
+ end
610
+ end
611
+
612
+ def print_deps_fix_summary(fixed:, skipped:, dry_run:)
613
+ verb = dry_run ? "Would fix" : "Fixed"
614
+ @stdout.puts ""
615
+ @stdout.puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
616
+ fixed.each { |f| @stdout.puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
617
+ @stdout.puts ""
618
+
619
+ return if skipped.empty?
620
+
621
+ @stdout.puts paint("#{skipped.size} finding(s) not applied (no patched version yet, bundle update " \
622
+ "failed, or the re-check still shows it vulnerable):", :yellow)
623
+ skipped.each { |f| @stdout.puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
624
+ @stdout.puts ""
625
+ end
626
+
627
+ # The interactive half of FixRunner.apply's `confirm:` hook — shown once
628
+ # per independently-verified finding, right before it would be written.
629
+ # Numeric choices only (no y/n/a/s letters): "3" (yes to all remaining)
630
+ # latches acceptance for every later call without prompting again;
631
+ # "4" (cancel) returns :cancel, which FixRunner.apply treats as "stop
632
+ # entirely" — every remaining candidate is marked :cancelled without
633
+ # this lambda being called again. Anything other than "1"/"2"/"3"/"4"
634
+ # (including a blank answer) re-prompts rather than guessing.
635
+ def build_fix_confirmer
636
+ mode = :ask
637
+ lambda do |finding|
638
+ next true if mode == :all
639
+
640
+ print_finding_preview(finding)
641
+ loop do
642
+ @stdout.puts "Apply this fix?"
643
+ @stdout.puts " 1) Yes"
644
+ @stdout.puts " 2) Skip"
645
+ @stdout.puts " 3) Yes to all remaining"
646
+ @stdout.puts " 4) Cancel (stop reviewing — nothing further will be attempted)"
647
+ @stdout.print "Choice: "
648
+ @stdout.flush
649
+ case @stdin.gets.to_s.strip
650
+ when "1" then break true
651
+ when "2" then break false
652
+ when "3" then mode = :all; break true
653
+ when "4" then break :cancel
654
+ else @stdout.puts "Please enter 1, 2, 3, or 4."
655
+ end
656
+ end
657
+ end
658
+ end
659
+
660
+ def print_finding_preview(finding)
661
+ @stdout.puts ""
662
+ @stdout.puts "#{finding.rule_id} — #{finding.file}:#{finding.line}"
663
+ @stdout.puts " #{finding.message}" if finding.message
664
+ explanation = FixRunner.explain(finding.suggested_fix)
665
+ @stdout.puts " Fix: #{explanation}" unless explanation.empty?
666
+ after = FixVerifier.extract_after_snippet(finding.suggested_fix)
667
+ return unless after
668
+
669
+ @stdout.puts " AFTER:"
670
+ after.each_line { |line| @stdout.puts " #{line.chomp}" }
671
+ end
672
+
673
+ # frozen_string_literal (see MechanicalFixer::OPT_IN_RULES) is left out
674
+ # of an unscoped `scryer fix` sweep by default — it's a cosmetic,
675
+ # `info`-severity finding that would otherwise touch nearly every file
676
+ # in the project. Explicitly naming it via --rule is already informed
677
+ # consent, so this only gates an unscoped run: in an interactive
678
+ # terminal it asks; non-interactively (CI, piped stdin) it's excluded
679
+ # with a one-line notice, discoverable via `--rule frozen_string_literal`.
680
+ def gate_frozen_string_literal(candidates, explicit:)
681
+ return candidates if explicit
682
+
683
+ opted_in, rest = candidates.partition { |f| MechanicalFixer.opt_in?(f.rule_id) }
684
+ return candidates if opted_in.empty?
685
+
686
+ if interactive_terminal?
687
+ @stdout.print "#{opted_in.size} frozen_string_literal finding(s) matched — this only adds " \
688
+ "the magic comment to #{opted_in.size} file(s) (skipped automatically " \
689
+ "wherever an in-place string mutation makes it unsafe). Include them in " \
690
+ "this run? [y/N] "
691
+ @stdout.flush
692
+ answer = @stdin.gets.to_s.strip
693
+ return candidates if answer.downcase.start_with?("y")
694
+ end
695
+
696
+ @stdout.puts "scryer fix: excluding #{opted_in.size} frozen_string_literal finding(s) from " \
697
+ "this run — pass --rule frozen_string_literal to include them explicitly."
698
+ rest
699
+ end
700
+
701
+ # The "verify" leg: re-scan the whole project after every fix has been
702
+ # written and confirm each one is actually gone — see FixRunner.verify.
703
+ def verify_applied_fixes(fixed, root:, skip_rules:)
704
+ @stdout.puts "Re-scanning to verify every applied fix..."
705
+ regressed = FixRunner.verify(fixed, root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules)
706
+
707
+ if regressed.empty?
708
+ @stdout.puts paint("Verified: all #{fixed.size} applied fix(es) confirmed clean on a full re-scan.", :green, :bold)
709
+ else
710
+ @stdout.puts paint("Warning: #{regressed.size} of #{fixed.size} applied fix(es) still show up on a full " \
711
+ "re-scan (an edit may have shifted another finding onto the same rule, or a duplicate " \
712
+ "finding existed elsewhere) — review these by hand:", :yellow)
713
+ regressed.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
714
+ end
715
+ end
716
+
717
+ def print_candidate_list(candidates)
718
+ @stdout.puts ""
719
+ candidates.each_with_index { |f, i| @stdout.puts " #{i + 1}) #{f.rule_id} — #{f.file}:#{f.line} — #{f.message}" }
720
+ @stdout.puts ""
721
+ end
722
+
723
+ # Maps 1-based positions in `candidates` (as printed by print_candidate_list)
724
+ # back to the Finding at that position. Raises UsageError (caught by #run,
725
+ # same as every other bad-input case in this command) on a non-integer or
726
+ # out-of-range value — this never silently fixes something other than
727
+ # what was asked for.
728
+ def select_by_number(candidates, numbers)
729
+ indices = numbers.map do |n|
730
+ i = Integer(n, exception: false)
731
+ raise UsageError, "--number #{n.inspect} isn't a valid position — run with --list to see " \
732
+ "the numbered candidates first." unless i
733
+ raise UsageError, "--number #{i} is out of range — only #{candidates.size} matching " \
734
+ "finding(s) (see --list)." unless i.between?(1, candidates.size)
735
+
736
+ i
737
+ end
738
+ indices.uniq.sort.map { |i| candidates[i - 1] }
739
+ end
740
+
741
+ def interactive_terminal?
742
+ @stdin.respond_to?(:tty?) && @stdin.tty?
743
+ end
744
+
745
+ def fix_target_file?(finding, files, root)
746
+ files.any? do |f|
747
+ abs = File.expand_path(f, root)
748
+ rel = abs.sub(/\A#{Regexp.escape(root)}\/?/, "")
749
+ finding.file == rel || finding.file == f
750
+ end
751
+ end
752
+
153
753
  # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
154
754
  # no other network calls. `spec` is "name" or "name:version" (colon
155
755
  # rather than a second CLI arg, so this stays a single -o-style flag).
@@ -169,6 +769,62 @@ module Scryer
169
769
  findings.empty? ? 0 : 1
170
770
  end
171
771
 
772
+ # `--save-baseline PATH` is a distinct mode, same as --audit-deps/
773
+ # --check-gem: it captures every finding across every category (not
774
+ # just security — a legacy app's existing performance/style debt is
775
+ # just as much "not what I'm here to re-litigate today" as its security
776
+ # debt), writes the fingerprints, and exits without writing the normal
777
+ # -o reports. See Scryer::Baseline for why fingerprints, not file:line.
778
+ def save_baseline(path, result, dependency_findings)
779
+ all_findings = (result.security_findings + result.performance_findings + result.style_findings)
780
+ .map(&:to_h) + dependency_findings.map(&:to_h)
781
+ Baseline.save(path, all_findings)
782
+ @stdout.puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
783
+ 0
784
+ end
785
+
786
+ # Filters `result`'s finding arrays (mutated in place — Result is a
787
+ # plain Struct, this is the same object the caller already holds) and
788
+ # returns [new_dependency_findings, fixed_count] since dependency_findings
789
+ # is a local array in the caller, not a field this method can mutate by
790
+ # reference the way it can Struct fields.
791
+ # `fixed_count` has to be computed ONCE against the union of every
792
+ # category's current fingerprints, not once per category summed
793
+ # together — Baseline.diff's fixed_count is "baseline fingerprints not
794
+ # present in *this* call's findings," so calling it separately per
795
+ # category and summing would count every other category's
796
+ # still-present findings as "fixed" too (verified: this exact bug
797
+ # produced a nonsensical "762 fixed" on a rescan with zero changes,
798
+ # against a 255-finding baseline — fixed by computing fixed_count from
799
+ # the combined set once, while still filtering "new" per category since
800
+ # that part only checks baseline membership, which is fine to do
801
+ # separately).
802
+ def apply_baseline(path, result, dependency_findings)
803
+ baseline_fingerprints = Baseline.load(path)
804
+
805
+ security_hashes = result.security_findings.map(&:to_h)
806
+ performance_hashes = result.performance_findings.map(&:to_h)
807
+ style_hashes = result.style_findings.map(&:to_h)
808
+ dependency_hashes = dependency_findings.map(&:to_h)
809
+
810
+ all_current_fingerprints = Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
811
+ fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
812
+
813
+ result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
814
+ result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
815
+ result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
816
+ filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
817
+
818
+ [filtered_deps, fixed_count]
819
+ rescue Baseline::LoadError => e
820
+ raise UsageError, e.message
821
+ end
822
+
823
+ def filter_new(objects, hashes, baseline_fingerprints)
824
+ fingerprints = Baseline.fingerprints(hashes)
825
+ objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
826
+ end
827
+
172
828
  def parse(argv)
173
829
  options = { outputs: [] }
174
830
 
@@ -182,13 +838,24 @@ module Scryer
182
838
  "Require a Ruby file before scanning (repeatable) — the file can call " \
183
839
  "Scryer.configure to set c.ai_client, c.skip_rules, c.dirs, etc. This is the " \
184
840
  "standalone executable's equivalent of a Rails app's config/initializers/scryer.rb " \
185
- "getting autoloaded at boot; without it there's no way to configure anything " \
186
- "outside Rails.") { |v| (options[:require] ||= []) << v }
841
+ "getting autoloaded at boot. If omitted, config/initializers/scryer.rb under " \
842
+ "--path is auto-required when it exists.") { |v| (options[:require] ||= []) << v }
187
843
  opts.on("--audit-deps",
188
844
  "Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
189
845
  "insecure git/http sources (offline), instead of running the normal static scan. " \
190
846
  "Exits non-zero if anything is found, so this can gate CI the same way " \
191
847
  "`bundle-audit check` does.") { options[:audit_deps] = true }
848
+ opts.on("--save-baseline PATH",
849
+ "Run the normal scan, save every finding's fingerprint to PATH, then exit — no " \
850
+ "reports written. A later `scryer --baseline PATH` scan reports only findings " \
851
+ "new since this snapshot, so an app with existing security debt can gate CI on " \
852
+ "new issues without being forced to fix everything on day one.") { |v| options[:save_baseline] = v }
853
+ opts.on("--baseline PATH",
854
+ "Compare this scan against a baseline saved by --save-baseline: every report " \
855
+ "(-o files, console summary, exit code) reflects only findings new since PATH " \
856
+ "was saved. Fingerprints ignore line number (rule + file + offending code), so " \
857
+ "an unrelated edit elsewhere in the file won't make an existing finding look " \
858
+ "new.") { |v| options[:baseline] = v }
192
859
  opts.on("--no-deps",
193
860
  "Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
194
861
  "that otherwise runs as part of every normal scan. Use this for a fast, fully " \
@@ -198,6 +865,10 @@ module Scryer
198
865
  "Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
199
866
  "codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
200
867
  "invocations.") { |v| (options[:skip] ||= []) << v }
868
+ opts.on("--no-duplicates",
869
+ "Skip duplicate-code detection (method/query/cache-key similarity across models, " \
870
+ "controllers, helpers, and concerns) for this run only, regardless of " \
871
+ "c.detect_duplicates — doesn't affect other invocations.") { options[:no_duplicates] = true }
201
872
  opts.on("--check-gem NAME[:VERSION]",
202
873
  "Query OSV.dev for known vulnerabilities affecting a single gem, independent of " \
203
874
  "any Gemfile.lock or full scan/audit — instead of running the normal static " \
@@ -205,6 +876,8 @@ module Scryer
205
876
  "all versions.") { |v| options[:check_gem] = v }
206
877
  opts.on("--project-name NAME", "Project name shown in the report header.") { |v| options[:project_name] = v }
207
878
  opts.on("--branch BRANCH", "Git branch label recorded in the report (overrides the actual checked-out branch).") { |v| options[:branch] = v }
879
+ opts.on("--color", "Force colored output even when stdout isn't a terminal (e.g. piped to `less -R`).") { options[:color] = true }
880
+ opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
208
881
  opts.on("-v", "--version", "Show the Scryer version.") { options[:exit_early] = true; @stdout.puts Scryer::VERSION }
209
882
  opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
210
883
  end
@@ -215,6 +888,7 @@ module Scryer
215
888
  raise UsageError, "#{e.message}\n#{parser}"
216
889
  end
217
890
 
891
+ @color_override = options[:color]
218
892
  options
219
893
  end
220
894