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.
data/lib/scryer/cli.rb CHANGED
@@ -11,10 +11,12 @@ 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
@@ -26,25 +28,32 @@ module Scryer
26
28
  # would collide with the main parser's -p/-o meanings, so it's
27
29
  # dispatched before the main OptionParser ever sees the rest of argv.
28
30
  return run_verify(@argv[1..]) if @argv.first == "verify"
31
+ return run_fix(@argv[1..]) if @argv.first == "fix"
29
32
 
30
33
  options = parse(@argv)
31
34
  return 0 if options[:exit_early]
32
35
 
36
+ root = File.expand_path(options[:path] || Dir.pwd)
37
+
33
38
  # The standalone executable has no equivalent of a Rails app's
34
39
  # config/initializers/scryer.rb getting autoloaded at boot — this is
35
40
  # the only way to run Scryer.configure (set c.ai_client, c.skip_rules,
36
- # c.dirs, ...) before a scan starts outside Rails.
37
- 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) }
38
47
 
39
48
  return check_gem(options[:check_gem]) if options[:check_gem]
40
49
 
41
- root = File.expand_path(options[:path] || Dir.pwd)
42
50
  return audit_deps(root) if options[:audit_deps]
43
51
 
44
52
  skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
45
53
  @stdout.puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
54
+ detect_duplicates = options[:no_duplicates] ? false : Scryer.configuration.detect_duplicates
46
55
 
47
- 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
48
57
 
49
58
  # Dependency auditing (OSV.dev) runs by default — a single `scryer`
50
59
  # invocation is meant to cover the same ground as RuboCop + Brakeman +
@@ -98,6 +107,41 @@ module Scryer
98
107
 
99
108
  UsageError = Class.new(StandardError)
100
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
+
101
145
  # Mirrors the `scryer:audit_dependencies` rake task's output/exit-code
102
146
  # behavior, so `scryer --audit-deps` works the same outside a Rails app.
103
147
  def audit_deps(root)
@@ -110,7 +154,7 @@ module Scryer
110
154
  credentials_exposure = DependencyAudit.credentials_exposure_check(root)
111
155
 
112
156
  (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
113
- @stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
157
+ @stdout.puts "[#{paint_severity(f.severity.upcase, f.severity)}] #{dependency_label(f)}"
114
158
  @stdout.puts " fix: #{f.suggested_fix}"
115
159
  end
116
160
 
@@ -147,10 +191,10 @@ module Scryer
147
191
  ["Dependencies", deps_count]
148
192
  ]
149
193
 
150
- divider = "─" * 32
194
+ divider = paint("─" * 32, :gray)
151
195
  score = renderer.security_score
152
196
  @stdout.puts ""
153
- @stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
197
+ @stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
154
198
  @stdout.puts divider
155
199
  @stdout.puts ""
156
200
  if baseline_path
@@ -159,12 +203,12 @@ module Scryer
159
203
  @stdout.puts ""
160
204
  end
161
205
  clean_rate = renderer.rules_clean_rate
162
- @stdout.puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
206
+ @stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
163
207
  @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
164
208
  @stdout.puts ""
165
209
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
166
210
  @stdout.puts divider
167
- @stdout.puts summary_row("Total", total)
211
+ @stdout.puts paint(summary_row("Total", total), :bold)
168
212
  @stdout.puts ""
169
213
  print_top_priorities(renderer.top_risks)
170
214
  print_owasp_coverage(renderer.owasp_coverage)
@@ -183,9 +227,9 @@ module Scryer
183
227
  def print_top_priorities(risks)
184
228
  return if risks.empty?
185
229
 
186
- @stdout.puts "Top priorities:"
230
+ @stdout.puts paint("Top priorities:", :bold)
187
231
  risks.each_with_index do |r, i|
188
- @stdout.puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
232
+ @stdout.puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
189
233
  end
190
234
  @stdout.puts ""
191
235
  end
@@ -201,23 +245,32 @@ module Scryer
201
245
  @stdout.puts ""
202
246
  end
203
247
 
204
- # `scryer verify --rule RULE_ID --file PATH` — re-parses just that one
205
- # file and re-runs just that one rule against it, independent of a full
206
- # scan. Meant to run right after applying a fix (by hand or via an LLM)
207
- # to confirm the specific finding it targeted is actually gone, without
208
- # waiting on/paying for a full project scan. Deliberately narrower than
209
- # "did this fix introduce a NEW finding elsewhere" — that's what a normal
210
- # `scryer` run (or `--baseline`) already answers; this only answers "does
211
- # the one thing I just tried to fix still fire."
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.
212
263
  def run_verify(argv)
213
264
  options = {}
214
265
 
215
266
  parser = OptionParser.new do |opts|
216
- opts.banner = "Usage: scryer verify --rule RULE_ID --file PATH [--path ROOT]"
217
- opts.on("--rule RULE_ID", "The rule_id to re-check (required) — see `scryer verify --list-rules`.") { |v| options[:rule] = v }
218
- opts.on("--file PATH", "File to re-scan (required) — relative to --path, or absolute.") { |v| options[:file] = v }
219
- opts.on("--path ROOT", "Project root PATH is relative to (default: current directory).") { |v| options[:root] = v }
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 }
220
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 }
221
274
  opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
222
275
  end
223
276
 
@@ -226,6 +279,7 @@ module Scryer
226
279
  rescue OptionParser::ParseError => e
227
280
  raise UsageError, "#{e.message}\n#{parser}"
228
281
  end
282
+ @color_override = options[:color]
229
283
  return 0 if options[:exit_early]
230
284
 
231
285
  if options[:list_rules]
@@ -233,15 +287,23 @@ module Scryer
233
287
  return 0
234
288
  end
235
289
 
236
- raise UsageError, "scryer verify needs --rule RULE_ID and --file PATH\n#{parser}" unless options[:rule] && options[:file]
237
-
238
- rule_class = RuleSet.all.find { |r| r.rule_id == options[:rule] }
239
- unless rule_class
240
- raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids."
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
241
294
  end
242
295
 
243
296
  root = File.expand_path(options[:root] || Dir.pwd)
244
- abs_path = File.expand_path(options[:file], root)
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)
245
307
  raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
246
308
 
247
309
  rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
@@ -257,18 +319,437 @@ module Scryer
257
319
  "than this gem's Ruby runtime supports)."
258
320
  end
259
321
 
260
- findings = rule_class.new(file: rel_path, source: source, sexp: sexp).scan
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 }
261
324
 
262
325
  if findings.empty?
263
- @stdout.puts "scryer verify: #{options[:rule]} no longer fires on #{rel_path} — fix verified."
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)}."
264
328
  0
265
329
  else
266
- @stdout.puts "scryer verify: #{options[:rule]} still fires on #{rel_path} (#{findings.size} finding(s)):"
267
- findings.each { |f| @stdout.puts " line #{f.line}: #{f.message}" }
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
268
338
  1
269
339
  end
270
340
  end
271
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
+
272
753
  # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
273
754
  # no other network calls. `spec` is "name" or "name:version" (colon
274
755
  # rather than a second CLI arg, so this stays a single -o-style flag).
@@ -357,8 +838,8 @@ module Scryer
357
838
  "Require a Ruby file before scanning (repeatable) — the file can call " \
358
839
  "Scryer.configure to set c.ai_client, c.skip_rules, c.dirs, etc. This is the " \
359
840
  "standalone executable's equivalent of a Rails app's config/initializers/scryer.rb " \
360
- "getting autoloaded at boot; without it there's no way to configure anything " \
361
- "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 }
362
843
  opts.on("--audit-deps",
363
844
  "Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
364
845
  "insecure git/http sources (offline), instead of running the normal static scan. " \
@@ -384,6 +865,10 @@ module Scryer
384
865
  "Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
385
866
  "codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
386
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 }
387
872
  opts.on("--check-gem NAME[:VERSION]",
388
873
  "Query OSV.dev for known vulnerabilities affecting a single gem, independent of " \
389
874
  "any Gemfile.lock or full scan/audit — instead of running the normal static " \
@@ -391,6 +876,8 @@ module Scryer
391
876
  "all versions.") { |v| options[:check_gem] = v }
392
877
  opts.on("--project-name NAME", "Project name shown in the report header.") { |v| options[:project_name] = v }
393
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 }
394
881
  opts.on("-v", "--version", "Show the Scryer version.") { options[:exit_early] = true; @stdout.puts Scryer::VERSION }
395
882
  opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
396
883
  end
@@ -401,6 +888,7 @@ module Scryer
401
888
  raise UsageError, "#{e.message}\n#{parser}"
402
889
  end
403
890
 
891
+ @color_override = options[:color]
404
892
  options
405
893
  end
406
894