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.
@@ -11,12 +11,14 @@ namespace :scryer do
11
11
  desc "Run Scryer and write a report. Args are any mix of json/html/csv/sarif (which formats " \
12
12
  "to write — default json,html), the token 'nodeps' (skip the dependency audit — OSV.dev " \
13
13
  "vulnerable gems + insecure git/http sources — that otherwise runs by default; use for a " \
14
- "fast, fully offline run), plus at most one path (a directory when writing more than one " \
15
- "format, or an exact file for a single format). Rake splits bracket args on every comma, " \
16
- "so pass each token as its own item rather than one comma-joined string. " \
17
- "e.g. rails scryer:report, rails 'scryer:report[html]', " \
14
+ "fast, fully offline run), the token 'noduplicates' (skip duplicate-code detection for " \
15
+ "this run only, regardless of c.detect_duplicates), plus at most one path (a directory " \
16
+ "when writing more than one format, or an exact file for a single format). Rake splits " \
17
+ "bracket args on every comma, so pass each token as its own item rather than one " \
18
+ "comma-joined string. e.g. rails scryer:report, rails 'scryer:report[html]', " \
18
19
  "rails 'scryer:report[json,doc/security.json]', rails 'scryer:report[json,html]', " \
19
- "rails 'scryer:report[html,nodeps]', rails 'scryer:report[csv]', rails 'scryer:report[sarif]'. " \
20
+ "rails 'scryer:report[html,nodeps]', rails 'scryer:report[html,noduplicates]', " \
21
+ "rails 'scryer:report[csv]', rails 'scryer:report[sarif]'. " \
20
22
  "Set SCRYER_BASELINE=PATH (an env var, not a bracket arg — see scryer:save_baseline) to " \
21
23
  "report only findings new since that baseline was saved."
22
24
  task :report, [:format] do |_, args|
@@ -25,13 +27,14 @@ namespace :scryer do
25
27
 
26
28
  # Ignore the declared :format name and read every positional value Rake
27
29
  # was given (args.to_a) — the whole point is accepting a variable number
28
- # of format/nodeps tokens plus one path, which a single named param can't do.
29
- formats, path_arg, run_deps = parse_report_args(args.to_a)
30
+ # of format/nodeps/noduplicates tokens plus one path, which a single
31
+ # named param can't do.
32
+ formats, path_arg, run_deps, detect_duplicates = parse_report_args(args.to_a)
30
33
 
31
34
  skip_rules = Scryer.configuration.skip_rules
32
35
  puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
33
36
 
34
- result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
37
+ result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
35
38
 
36
39
  # Dependency auditing (OSV.dev) runs by default — a single scryer:report
37
40
  # run is meant to cover the same ground as RuboCop + Brakeman +
@@ -120,7 +123,10 @@ namespace :scryer do
120
123
  root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
121
124
  dirs = Scryer.configuration.dirs
122
125
 
123
- result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: Scryer.configuration.skip_rules).call
126
+ # detect_duplicates: false — a baseline never includes duplicate_groups
127
+ # (see all_findings below and Scryer::CLI#apply_baseline's own comment
128
+ # on why), so there's nothing to gain from that pass here.
129
+ result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: Scryer.configuration.skip_rules, detect_duplicates: false).call
124
130
  puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
125
131
  dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
126
132
  Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
@@ -131,6 +137,124 @@ namespace :scryer do
131
137
  puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
132
138
  end
133
139
 
140
+ desc "Scan, then ask the configured c.ai_client for a rewrite of every qualifying finding and " \
141
+ "write the ones independently verified to actually clear it (see Scryer::FixVerifier) — " \
142
+ "requires c.ai_client to be set (config/initializers/scryer.rb); anything not verified is " \
143
+ "left alone and reported for manual review, same as a normal report would show it. Takes " \
144
+ "an optional rule_id bracket arg to only fix that rule; set SCRYER_FIX_DRY_RUN=1 to preview " \
145
+ "without writing anything, SCRYER_FIX_LIST=1 to print the numbered candidates and exit, " \
146
+ "SCRYER_FIX_NUMBERS=1,3 to only fix those numbered positions (see SCRYER_FIX_LIST), or " \
147
+ "SCRYER_FIX_YES=1 to skip the per-finding accept/skip prompt this task otherwise shows in " \
148
+ "an interactive terminal, or SCRYER_FIX_DEPS=1 to fix vulnerable dependencies instead " \
149
+ "(runs `bundle update GEM --conservative` per vulnerable gem with a published patched " \
150
+ "version, then re-checks OSV.dev — see Scryer::DependencyFixer; ignores the rule_id arg " \
151
+ "and SCRYER_FIX_LIST/SCRYER_FIX_NUMBERS, which only make sense for rule-based findings). " \
152
+ "e.g. rails scryer:fix, rails 'scryer:fix[sql_injection]', " \
153
+ "SCRYER_FIX_DRY_RUN=1 rails scryer:fix, SCRYER_FIX_DEPS=1 rails scryer:fix"
154
+ task :fix, [:rule_id] do |_, args|
155
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
156
+ dry_run = !blank_to_nil(ENV["SCRYER_FIX_DRY_RUN"]).nil?
157
+
158
+ if !blank_to_nil(ENV["SCRYER_FIX_DEPS"]).nil?
159
+ puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
160
+ findings = Scryer::DependencyAudit.vulnerable_gems(root)
161
+
162
+ if findings.empty?
163
+ puts "Scryer: no vulnerable gems found."
164
+ next
165
+ end
166
+
167
+ actionable = findings.select { |f| Array(f.patched_versions).any? }
168
+ if actionable.empty?
169
+ puts "Scryer: #{findings.size} vulnerable gem finding(s), but none have a published " \
170
+ "patched version yet to upgrade to — nothing this task can run. See " \
171
+ "`rails scryer:audit_dependencies` for details."
172
+ next
173
+ end
174
+
175
+ gem_count = actionable.map(&:gem_name).uniq.size
176
+ puts "Scryer: #{actionable.size} finding(s) across #{gem_count} gem(s) — running " \
177
+ "`bundle update GEM --conservative` for each" \
178
+ "#{dry_run ? " (SCRYER_FIX_DRY_RUN: nothing will actually run)" : ""}..."
179
+
180
+ fixed, skipped = Scryer::DependencyFixer.apply(findings, root: root, dry_run: dry_run) do |finding, status, error|
181
+ ScryerTasks.print_deps_fix_progress(finding, status, error)
182
+ end
183
+ ScryerTasks.print_deps_fix_summary(fixed: fixed, skipped: skipped, dry_run: dry_run)
184
+
185
+ abort("Scryer: #{skipped.size} finding(s) not applied.") if !dry_run && skipped.any?
186
+ next
187
+ end
188
+
189
+ ai_client = Scryer.configuration.ai_client
190
+ dirs = Scryer.configuration.dirs
191
+ skip_rules = Scryer.configuration.skip_rules
192
+ list_only = !blank_to_nil(ENV["SCRYER_FIX_LIST"]).nil?
193
+ yes = !blank_to_nil(ENV["SCRYER_FIX_YES"]).nil?
194
+ numbers = blank_to_nil(ENV["SCRYER_FIX_NUMBERS"])&.split(",")&.map(&:strip)
195
+
196
+ # detect_duplicates: false — candidates below are only ever
197
+ # security/performance/style findings; a duplicate-code group has no
198
+ # rule_id/single-line suggested_fix for FixRunner to act on.
199
+ result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: false).call
200
+ candidates = result.security_findings + result.performance_findings + result.style_findings
201
+ candidates = candidates.select { |f| f.rule_id == args[:rule_id] } if args[:rule_id]
202
+ candidates = candidates.sort_by { |f| [f.file.to_s, f.line || 0] }
203
+
204
+ if candidates.empty?
205
+ puts "Scryer: no matching findings to fix."
206
+ next
207
+ end
208
+
209
+ if list_only
210
+ ScryerTasks.print_candidate_list(candidates)
211
+ next
212
+ end
213
+
214
+ candidates = ScryerTasks.gate_frozen_string_literal(candidates, explicit: !args[:rule_id].nil?)
215
+ if candidates.empty?
216
+ puts "Scryer: no matching findings to fix."
217
+ next
218
+ end
219
+
220
+ candidates = ScryerTasks.select_by_number(candidates, numbers) if numbers
221
+
222
+ # See Scryer::MechanicalFixer — a handful of rules have exactly one
223
+ # correct fix and don't need c.ai_client at all. Only abort when
224
+ # there's genuinely nothing this run could fix: no ai_client AND
225
+ # nothing matched has a built-in fixer.
226
+ if ai_client.nil? && candidates.none? { |f| Scryer::MechanicalFixer.supported?(f.rule_id) }
227
+ abort("Scryer: scryer:fix needs c.ai_client configured in config/initializers/scryer.rb — " \
228
+ "none of the matched finding(s) have a built-in mechanical fixer " \
229
+ "(#{Scryer::MechanicalFixer::SUPPORTED_RULES.join(", ")}), and a rule's generic " \
230
+ "suggested_fix is prose, not something this task can apply automatically on its own. " \
231
+ "Nothing has been changed.")
232
+ end
233
+
234
+ # See ScryerTasks#build_fix_confirmer / Scryer::CLI#build_fix_confirmer:
235
+ # in an interactive terminal (and not dry-run/SCRYER_FIX_YES), review
236
+ # each verified fix one at a time before writing it.
237
+ confirm = ScryerTasks.build_fix_confirmer if $stdin.respond_to?(:tty?) && $stdin.tty? && !dry_run && !yes
238
+
239
+ puts "Scryer: #{candidates.size} candidate finding(s) — applying built-in fixes where one " \
240
+ "exists, otherwise asking the configured AI client for a rewrite; either way, only " \
241
+ "writing what's independently verified to clear the finding" \
242
+ "#{dry_run ? " (SCRYER_FIX_DRY_RUN: nothing will actually be written)" : ""}..."
243
+
244
+ fixed, skipped = Scryer::FixRunner.apply(candidates, client: ai_client, root: root, dry_run: dry_run, confirm: confirm) do |finding, status, error|
245
+ ScryerTasks.print_fix_progress(finding, status, error)
246
+ end
247
+ ScryerTasks.print_fix_summary(fixed: fixed, skipped: skipped, dry_run: dry_run)
248
+
249
+ if !dry_run && fixed.any?
250
+ puts "Scryer: re-scanning to verify every applied fix..."
251
+ regressed = Scryer::FixRunner.verify(fixed, root: root, dirs: dirs, skip_rules: skip_rules)
252
+ ScryerTasks.print_fix_verification(regressed, total: fixed.size)
253
+ end
254
+
255
+ abort("Scryer: #{skipped.size} finding(s) still need manual review.") if !dry_run && skipped.any?
256
+ end
257
+
134
258
  desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
135
259
  "and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
136
260
  "this can gate CI the same way `bundle-audit check` does."
@@ -146,7 +270,7 @@ namespace :scryer do
146
270
  credentials_exposure = Scryer::DependencyAudit.credentials_exposure_check(root)
147
271
 
148
272
  (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
149
- puts "[#{f.severity.upcase}] #{ScryerTasks.dependency_label(f)}"
273
+ puts "[#{ScryerTasks.paint_severity(f.severity.upcase, f.severity)}] #{ScryerTasks.dependency_label(f)}"
150
274
  puts " fix: #{f.suggested_fix}"
151
275
  end
152
276
 
@@ -160,16 +284,22 @@ namespace :scryer do
160
284
  VALID_FORMATS = %w[json html csv sarif].freeze
161
285
  EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv", "sarif" => "sarif" }.freeze
162
286
  NO_DEPS_TOKEN = "nodeps".freeze
287
+ NO_DUPLICATES_TOKEN = "noduplicates".freeze
163
288
 
164
289
  # tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
165
290
  # %w[json html] or %w[html nodeps] or []. Returns [formats, path_arg,
166
- # run_deps] — any token matching a known format is a format, the literal
167
- # "nodeps" token opts *out* of the dependency audit that otherwise runs by
168
- # default, and at most one other token is allowed, which is the path.
291
+ # run_deps, detect_duplicates] — any token matching a known format is a
292
+ # format, the literal "nodeps" token opts *out* of the dependency audit
293
+ # that otherwise runs by default, the literal "noduplicates" token opts
294
+ # out of duplicate-code detection for this run only (c.detect_duplicates
295
+ # already off is still off either way — this token can only turn it off,
296
+ # never back on), and at most one other token is allowed, which is the
297
+ # path.
169
298
  def parse_report_args(tokens)
170
299
  tokens = tokens.map { |t| blank_to_nil(t) }.compact
171
300
  run_deps = tokens.none? { |t| t.downcase == NO_DEPS_TOKEN }
172
- tokens = tokens.reject { |t| t.downcase == NO_DEPS_TOKEN }
301
+ detect_duplicates = Scryer.configuration.detect_duplicates && tokens.none? { |t| t.downcase == NO_DUPLICATES_TOKEN }
302
+ tokens = tokens.reject { |t| [NO_DEPS_TOKEN, NO_DUPLICATES_TOKEN].include?(t.downcase) }
173
303
 
174
304
  format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
175
305
 
@@ -180,7 +310,7 @@ namespace :scryer do
180
310
  formats = format_tokens.map(&:downcase).uniq
181
311
  formats = %w[json html] if formats.empty?
182
312
 
183
- [formats, other_tokens.first, run_deps]
313
+ [formats, other_tokens.first, run_deps, detect_duplicates]
184
314
  end
185
315
 
186
316
  # No path given: default filenames under tmp/. A path given with a single
@@ -212,6 +342,29 @@ end
212
342
  module ScryerTasks
213
343
  module_function
214
344
 
345
+ # SCRYER_COLOR/SCRYER_NO_COLOR are this task's equivalent of the CLI's
346
+ # --color/--no-color — an explicit env var wins over Scryer::Colorizer's
347
+ # own NO_COLOR/TTY auto-detection either way, same precedence the CLI
348
+ # flags use (see Scryer::Colorizer's own comment for why).
349
+ def color_override
350
+ return true unless blank_to_nil(ENV["SCRYER_COLOR"]).nil?
351
+ return false unless blank_to_nil(ENV["SCRYER_NO_COLOR"]).nil?
352
+
353
+ nil
354
+ end
355
+
356
+ def paint(text, *styles)
357
+ Scryer::Colorizer.paint(text, *styles, stream: $stdout, override: color_override)
358
+ end
359
+
360
+ def paint_severity(text, severity)
361
+ Scryer::Colorizer.severity(text, severity, stream: $stdout, override: color_override)
362
+ end
363
+
364
+ def paint_grade(text, letter)
365
+ Scryer::Colorizer.grade(text, letter, stream: $stdout, override: color_override)
366
+ end
367
+
215
368
  def git_commit_sha
216
369
  shell_out("git rev-parse HEAD")
217
370
  end
@@ -286,10 +439,10 @@ module ScryerTasks
286
439
  ["Dependencies", deps_count]
287
440
  ]
288
441
 
289
- divider = "─" * 32
442
+ divider = paint("─" * 32, :gray)
290
443
  score = renderer.security_score
291
444
  puts ""
292
- puts "Scryer Audit — #{result.files_scanned} files scanned"
445
+ puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
293
446
  puts divider
294
447
  puts ""
295
448
  if baseline_path
@@ -297,12 +450,12 @@ module ScryerTasks
297
450
  puts ""
298
451
  end
299
452
  clean_rate = renderer.rules_clean_rate
300
- puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
453
+ puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
301
454
  puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
302
455
  puts ""
303
456
  rows.each { |label, count| puts summary_row(label, count) }
304
457
  puts divider
305
- puts summary_row("Total", total)
458
+ puts paint(summary_row("Total", total), :bold)
306
459
  puts ""
307
460
  print_top_priorities(renderer.top_risks)
308
461
  print_owasp_coverage(renderer.owasp_coverage)
@@ -321,9 +474,9 @@ module ScryerTasks
321
474
  def print_top_priorities(risks)
322
475
  return if risks.empty?
323
476
 
324
- puts "Top priorities:"
477
+ puts paint("Top priorities:", :bold)
325
478
  risks.each_with_index do |r, i|
326
- puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
479
+ puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
327
480
  end
328
481
  puts ""
329
482
  end
@@ -338,4 +491,165 @@ module ScryerTasks
338
491
  coverage.each { |category, count| puts " #{category}: #{count} finding#{"s" unless count == 1}" }
339
492
  puts ""
340
493
  end
494
+
495
+ # Same real-time progress line as Scryer::CLI#print_fix_progress.
496
+ def print_fix_progress(finding, status, error = nil)
497
+ label = { fixed: "Fixed", would_fix: "Would fix", declined: "Skipped (declined)",
498
+ ai_error: "Skipped (AI client error)", cancelled: "Skipped (cancelled)",
499
+ skipped: "Skipped (needs manual review)" }.fetch(status)
500
+ colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
501
+ puts "#{colored_label}: #{finding.rule_id} — #{finding.file}:#{finding.line}"
502
+
503
+ if status == :ai_error && error
504
+ puts " #{paint("#{error.class}: #{error.message}", :red)}"
505
+ else
506
+ explanation = Scryer::FixRunner.explain(finding.suggested_fix)
507
+ puts " #{explanation}" unless explanation.empty?
508
+ end
509
+ end
510
+
511
+ def print_fix_summary(fixed:, skipped:, dry_run:)
512
+ verb = dry_run ? "Would fix" : "Fixed"
513
+ puts ""
514
+ puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
515
+ fixed.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
516
+ puts ""
517
+
518
+ return if skipped.empty?
519
+
520
+ puts paint("#{skipped.size} finding(s) not applied (declined, or fix not independently verified):", :yellow)
521
+ skipped.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
522
+ puts ""
523
+ end
524
+
525
+ # Same real-time progress line as Scryer::CLI#print_deps_fix_progress —
526
+ # SCRYER_FIX_DEPS's dependency-finding analog of print_fix_progress above.
527
+ def print_deps_fix_progress(finding, status, error = nil)
528
+ label = { fixed: "Fixed", would_fix: "Would fix", error: "Error running bundle update",
529
+ skipped: "Skipped (needs manual review)" }.fetch(status)
530
+ colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
531
+ puts "#{colored_label}: #{finding.gem_name}#{finding.advisory_id ? " (#{finding.advisory_id})" : ""}"
532
+
533
+ if error
534
+ puts " #{paint(error.to_s.strip, :red)}"
535
+ elsif status == :skipped && finding.suggested_fix
536
+ puts " #{finding.suggested_fix}"
537
+ end
538
+ end
539
+
540
+ def print_deps_fix_summary(fixed:, skipped:, dry_run:)
541
+ verb = dry_run ? "Would fix" : "Fixed"
542
+ puts ""
543
+ puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
544
+ fixed.each { |f| puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
545
+ puts ""
546
+
547
+ return if skipped.empty?
548
+
549
+ puts paint("#{skipped.size} finding(s) not applied (no patched version yet, bundle update " \
550
+ "failed, or the re-check still shows it vulnerable):", :yellow)
551
+ skipped.each { |f| puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
552
+ puts ""
553
+ end
554
+
555
+ # Same real-time per-finding review as Scryer::CLI#build_fix_confirmer —
556
+ # SCRYER_FIX_YES is this task's equivalent of the CLI's --yes. Numeric
557
+ # choices only; "4" (cancel) returns :cancel, which FixRunner.apply treats
558
+ # as "stop entirely" — see Scryer::FixRunner.apply's own doc comment.
559
+ def build_fix_confirmer
560
+ mode = :ask
561
+ lambda do |finding|
562
+ next true if mode == :all
563
+
564
+ print_finding_preview(finding)
565
+ loop do
566
+ puts "Apply this fix?"
567
+ puts " 1) Yes"
568
+ puts " 2) Skip"
569
+ puts " 3) Yes to all remaining"
570
+ puts " 4) Cancel (stop reviewing — nothing further will be attempted)"
571
+ print "Choice: "
572
+ $stdout.flush
573
+ case $stdin.gets.to_s.strip
574
+ when "1" then break true
575
+ when "2" then break false
576
+ when "3" then mode = :all; break true
577
+ when "4" then break :cancel
578
+ else puts "Please enter 1, 2, 3, or 4."
579
+ end
580
+ end
581
+ end
582
+ end
583
+
584
+ def print_finding_preview(finding)
585
+ puts ""
586
+ puts "#{finding.rule_id} — #{finding.file}:#{finding.line}"
587
+ puts " #{finding.message}" if finding.message
588
+ explanation = Scryer::FixRunner.explain(finding.suggested_fix)
589
+ puts " Fix: #{explanation}" unless explanation.empty?
590
+ after = Scryer::FixVerifier.extract_after_snippet(finding.suggested_fix)
591
+ return unless after
592
+
593
+ puts " AFTER:"
594
+ after.each_line { |line| puts " #{line.chomp}" }
595
+ end
596
+
597
+ def print_fix_verification(regressed, total:)
598
+ if regressed.empty?
599
+ puts paint("Verified: all #{total} applied fix(es) confirmed clean on a full re-scan.", :green, :bold)
600
+ else
601
+ puts paint("Warning: #{regressed.size} of #{total} applied fix(es) still show up on a full " \
602
+ "re-scan (an edit may have shifted another finding onto the same rule, or a " \
603
+ "duplicate finding existed elsewhere) — review these by hand:", :yellow)
604
+ regressed.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
605
+ end
606
+ end
607
+
608
+ def print_candidate_list(candidates)
609
+ puts ""
610
+ candidates.each_with_index { |f, i| puts " #{i + 1}) #{f.rule_id} — #{f.file}:#{f.line} — #{f.message}" }
611
+ puts ""
612
+ end
613
+
614
+ # Same mapping as Scryer::CLI#select_by_number (see that method's comment)
615
+ # — SCRYER_FIX_NUMBERS is this task's equivalent of the CLI's --number.
616
+ # Aborts (this file's usual bad-input response, matching e.g. apply_baseline
617
+ # above) rather than fixing anything other than what was asked for.
618
+ def select_by_number(candidates, numbers)
619
+ indices = numbers.map do |n|
620
+ i = Integer(n, exception: false)
621
+ abort("Scryer: SCRYER_FIX_NUMBERS value #{n.inspect} isn't a valid position — set " \
622
+ "SCRYER_FIX_LIST=1 to see the numbered candidates first.") unless i
623
+ abort("Scryer: SCRYER_FIX_NUMBERS position #{i} is out of range — only #{candidates.size} " \
624
+ "matching finding(s) (set SCRYER_FIX_LIST=1 to list them).") unless i.between?(1, candidates.size)
625
+
626
+ i
627
+ end
628
+ indices.uniq.sort.map { |i| candidates[i - 1] }
629
+ end
630
+
631
+ # Same gate as Scryer::CLI#gate_frozen_string_literal — see that method's
632
+ # comment. `explicit` is true when the rake task's rule_id bracket arg
633
+ # was given (e.g. `scryer:fix[frozen_string_literal]`), which is already
634
+ # informed consent for that rule.
635
+ def gate_frozen_string_literal(candidates, explicit:)
636
+ return candidates if explicit
637
+
638
+ opted_in, rest = candidates.partition { |f| Scryer::MechanicalFixer.opt_in?(f.rule_id) }
639
+ return candidates if opted_in.empty?
640
+
641
+ if $stdin.respond_to?(:tty?) && $stdin.tty?
642
+ print "#{opted_in.size} frozen_string_literal finding(s) matched — this only adds the " \
643
+ "magic comment to #{opted_in.size} file(s) (skipped automatically wherever an " \
644
+ "in-place string mutation makes it unsafe). Include them in this run? [y/N] "
645
+ $stdout.flush
646
+ answer = $stdin.gets.to_s.strip
647
+ return candidates if answer.downcase.start_with?("y")
648
+ end
649
+
650
+ puts "Scryer: excluding #{opted_in.size} frozen_string_literal finding(s) from this run — " \
651
+ "pass it as the rule_id (e.g. rails 'scryer:fix[frozen_string_literal]') to include them " \
652
+ "explicitly."
653
+ rest
654
+ end
341
655
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scryer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ram Laxman Yadav
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rake
@@ -24,11 +23,27 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
27
40
  description: |
28
- Scryer scans a Ruby/Rails codebase for security vulnerabilities, performance problems,
29
- dependency risk, and code-quality issues, then ranks everything it finds by severity across
30
- all of those categories — so a scan ends with one answer to "what's most worth fixing," not
31
- four separate reports to reconcile by hand. (Style/lint is RuboCop's job — Scryer doesn't
41
+ Scryer is an all-in-one security auditing and static analysis tool for Ruby on Rails
42
+ applications — tells you what to fix first, across security, performance, dependencies, and
43
+ code quality. It scans a Ruby/Rails codebase for security vulnerabilities, performance
44
+ problems, dependency risk, and code-quality issues, then ranks everything it finds by severity
45
+ across all of those categories — so a scan ends with one answer to "what's most worth fixing,"
46
+ not four separate reports to reconcile by hand. (Style/lint is RuboCop's job — Scryer doesn't
32
47
  touch that, except one narrow check.)
33
48
 
34
49
  Detects SQL injection, mass assignment, SSRF, path traversal, IDOR, insecure JWT/CORS/session
@@ -40,7 +55,6 @@ description: |
40
55
  rewritten against your actual code by any LLM you configure. Reports in JSON, self-contained
41
56
  HTML, CSV, or SARIF (for GitHub Code Scanning). Zero runtime dependencies beyond Ruby's own
42
57
  stdlib.
43
- email:
44
58
  executables:
45
59
  - scryer
46
60
  extensions: []
@@ -49,6 +63,12 @@ files:
49
63
  - CHANGELOG.md
50
64
  - LICENSE.txt
51
65
  - README.md
66
+ - docs/architecture.md
67
+ - docs/contributing.md
68
+ - docs/fix-mode.md
69
+ - docs/rails-integration.md
70
+ - docs/rules.md
71
+ - docs/usage.md
52
72
  - exe/scryer
53
73
  - lib/generators/scryer/USAGE
54
74
  - lib/generators/scryer/install_generator.rb
@@ -61,10 +81,14 @@ files:
61
81
  - lib/scryer/baseline.rb
62
82
  - lib/scryer/cache_extractor.rb
63
83
  - lib/scryer/cli.rb
84
+ - lib/scryer/colorizer.rb
64
85
  - lib/scryer/dependency_audit.rb
86
+ - lib/scryer/dependency_fixer.rb
65
87
  - lib/scryer/duplicate_detector.rb
66
88
  - lib/scryer/finding.rb
89
+ - lib/scryer/fix_runner.rb
67
90
  - lib/scryer/fix_verifier.rb
91
+ - lib/scryer/mechanical_fixer.rb
68
92
  - lib/scryer/method_extractor.rb
69
93
  - lib/scryer/minitest.rb
70
94
  - lib/scryer/performance_rules/inefficient_save_loop_rule.rb
@@ -122,7 +146,6 @@ metadata:
122
146
  changelog_uri: https://github.com/ramlaxmanyadav/scryer/blob/main/CHANGELOG.md
123
147
  bug_tracker_uri: https://github.com/ramlaxmanyadav/scryer/issues
124
148
  rubygems_mfa_required: 'true'
125
- post_install_message:
126
149
  rdoc_options: []
127
150
  require_paths:
128
151
  - lib
@@ -137,8 +160,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
137
160
  - !ruby/object:Gem::Version
138
161
  version: '0'
139
162
  requirements: []
140
- rubygems_version: 3.5.11
141
- signing_key:
163
+ rubygems_version: 3.6.9
142
164
  specification_version: 4
143
- summary: Ruby/Rails application security & risk auditor — tells you what to fix first.
165
+ summary: An all-in-one security auditing and static analysis tool for Ruby on Rails
166
+ applications — tells you what to fix first, across security, performance, dependencies,
167
+ and code quality.
144
168
  test_files: []