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
@@ -11,25 +11,30 @@ 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]'. " \
22
+ "Set SCRYER_BASELINE=PATH (an env var, not a bracket arg — see scryer:save_baseline) to " \
23
+ "report only findings new since that baseline was saved."
20
24
  task :report, [:format] do |_, args|
21
25
  root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
22
26
  dirs = Scryer.configuration.dirs
23
27
 
24
28
  # Ignore the declared :format name and read every positional value Rake
25
29
  # was given (args.to_a) — the whole point is accepting a variable number
26
- # of format/nodeps tokens plus one path, which a single named param can't do.
27
- 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)
28
33
 
29
34
  skip_rules = Scryer.configuration.skip_rules
30
35
  puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
31
36
 
32
- 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
33
38
 
34
39
  # Dependency auditing (OSV.dev) runs by default — a single scryer:report
35
40
  # run is meant to cover the same ground as RuboCop + Brakeman +
@@ -42,9 +47,23 @@ namespace :scryer do
42
47
  Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
43
48
  end
44
49
 
50
+ # Rake bracket args are already stretched thin (format tokens + nodeps +
51
+ # one output path) — an ENV var is the more conventional way Rails rake
52
+ # tasks take an extra config value without overloading positional args
53
+ # further. `rails 'scryer:save_baseline[tmp/scryer_baseline.json]'` writes
54
+ # one; `SCRYER_BASELINE=tmp/scryer_baseline.json rails scryer:report`
55
+ # reads it back. See Scryer::Baseline / the CLI's --baseline flag for
56
+ # what this actually does (fingerprint-based new-vs-fixed diffing, not
57
+ # tied to line number).
58
+ baseline_path = blank_to_nil(ENV["SCRYER_BASELINE"])
59
+ fixed_count = 0
60
+ if baseline_path
61
+ dependency_findings, fixed_count = ScryerTasks.apply_baseline(baseline_path, result, dependency_findings)
62
+ end
63
+
45
64
  if Scryer.configuration.ai_client
46
65
  puts "Scryer: rewriting suggested fixes via the configured AI client..."
47
- Scryer::AiFixSuggester.enhance_result!(result)
66
+ Scryer::AiFixSuggester.enhance_result!(result, root: root)
48
67
  Scryer::AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
49
68
  end
50
69
 
@@ -70,7 +89,8 @@ namespace :scryer do
70
89
  File.write(path, content)
71
90
  end
72
91
 
73
- ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths)
92
+ ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths,
93
+ renderer: renderer, fixed_count: fixed_count, baseline_path: baseline_path)
74
94
 
75
95
  # Same gate as the `scryer` executable and scryer:audit_dependencies
76
96
  # below: fail the task (and so the CI job running it) on any security or
@@ -83,6 +103,158 @@ namespace :scryer do
83
103
  end
84
104
  end
85
105
 
106
+ desc "Convenience wrapper for CI: runs scryer:report with CI-sensible defaults — JSON + " \
107
+ "SARIF written under tmp/ (SARIF for GitHub Code Scanning / similar dashboards, JSON as " \
108
+ "the machine-readable form other CI steps can parse), dependency audit on, same " \
109
+ "SCRYER_BASELINE support and same fail-the-build-on-findings behavior as scryer:report. " \
110
+ "Equivalent to `rails 'scryer:report[json,sarif]'` under a name that doesn't require " \
111
+ "remembering the bracket-arg syntax. e.g. rails scryer:ci"
112
+ task :ci do
113
+ Rake::Task["scryer:report"].invoke("json", "sarif")
114
+ end
115
+
116
+ desc "Run Scryer and save every finding's fingerprint to PATH, for later comparison via " \
117
+ "SCRYER_BASELINE=PATH rails scryer:report (see that task's SCRYER_BASELINE note). " \
118
+ "Runs the same scan as scryer:report (including the dependency audit) but writes no " \
119
+ "report files. e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'"
120
+ task :save_baseline, [:path] do |_, args|
121
+ path = args[:path] || abort("Scryer: scryer:save_baseline needs a path, " \
122
+ "e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'")
123
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
124
+ dirs = Scryer.configuration.dirs
125
+
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
130
+ puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
131
+ dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
132
+ Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
133
+
134
+ all_findings = (result.security_findings + result.performance_findings + result.style_findings).map(&:to_h) +
135
+ dependency_findings.map(&:to_h)
136
+ Scryer::Baseline.save(path, all_findings)
137
+ puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
138
+ end
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
+
86
258
  desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
87
259
  "and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
88
260
  "this can gate CI the same way `bundle-audit check` does."
@@ -98,7 +270,7 @@ namespace :scryer do
98
270
  credentials_exposure = Scryer::DependencyAudit.credentials_exposure_check(root)
99
271
 
100
272
  (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
101
- puts "[#{f.severity.upcase}] #{ScryerTasks.dependency_label(f)}"
273
+ puts "[#{ScryerTasks.paint_severity(f.severity.upcase, f.severity)}] #{ScryerTasks.dependency_label(f)}"
102
274
  puts " fix: #{f.suggested_fix}"
103
275
  end
104
276
 
@@ -112,16 +284,22 @@ namespace :scryer do
112
284
  VALID_FORMATS = %w[json html csv sarif].freeze
113
285
  EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv", "sarif" => "sarif" }.freeze
114
286
  NO_DEPS_TOKEN = "nodeps".freeze
287
+ NO_DUPLICATES_TOKEN = "noduplicates".freeze
115
288
 
116
289
  # tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
117
290
  # %w[json html] or %w[html nodeps] or []. Returns [formats, path_arg,
118
- # run_deps] — any token matching a known format is a format, the literal
119
- # "nodeps" token opts *out* of the dependency audit that otherwise runs by
120
- # 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.
121
298
  def parse_report_args(tokens)
122
299
  tokens = tokens.map { |t| blank_to_nil(t) }.compact
123
300
  run_deps = tokens.none? { |t| t.downcase == NO_DEPS_TOKEN }
124
- 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) }
125
303
 
126
304
  format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
127
305
 
@@ -132,7 +310,7 @@ namespace :scryer do
132
310
  formats = format_tokens.map(&:downcase).uniq
133
311
  formats = %w[json html] if formats.empty?
134
312
 
135
- [formats, other_tokens.first, run_deps]
313
+ [formats, other_tokens.first, run_deps, detect_duplicates]
136
314
  end
137
315
 
138
316
  # No path given: default filenames under tmp/. A path given with a single
@@ -164,6 +342,29 @@ end
164
342
  module ScryerTasks
165
343
  module_function
166
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
+
167
368
  def git_commit_sha
168
369
  shell_out("git rev-parse HEAD")
169
370
  end
@@ -190,11 +391,40 @@ module ScryerTasks
190
391
  "#{f.gem_name} #{f.installed_version}: #{f.title}"
191
392
  end
192
393
 
394
+ # Same logic as Scryer::CLI#apply_baseline (see that method's comment for
395
+ # why fixed_count has to be computed once against the combined set of
396
+ # every category's current fingerprints, not once per category summed).
397
+ def apply_baseline(path, result, dependency_findings)
398
+ baseline_fingerprints = Scryer::Baseline.load(path)
399
+
400
+ security_hashes = result.security_findings.map(&:to_h)
401
+ performance_hashes = result.performance_findings.map(&:to_h)
402
+ style_hashes = result.style_findings.map(&:to_h)
403
+ dependency_hashes = dependency_findings.map(&:to_h)
404
+
405
+ all_current_fingerprints = Scryer::Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
406
+ fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
407
+
408
+ result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
409
+ result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
410
+ result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
411
+ filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
412
+
413
+ [filtered_deps, fixed_count]
414
+ rescue Scryer::Baseline::LoadError => e
415
+ abort("Scryer: #{e.message}")
416
+ end
417
+
418
+ def filter_new(objects, hashes, baseline_fingerprints)
419
+ fingerprints = Scryer::Baseline.fingerprints(hashes)
420
+ objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
421
+ end
422
+
193
423
  # The "one audit command" summary — a single scan's worth of every
194
424
  # category Scryer covers (security, performance, duplicate/smelly code,
195
425
  # dependencies), the same categories usually split across RuboCop +
196
426
  # Brakeman + bundler-audit + Reek, side by side in one box.
197
- def print_summary(result:, dependency_findings:, ran_deps:, paths:)
427
+ def print_summary(result:, dependency_findings:, ran_deps:, paths:, renderer:, fixed_count: 0, baseline_path: nil)
198
428
  # "Code Quality" is the umbrella label for both duplicate-code groups
199
429
  # and rule-based style findings (e.g. frozen_string_literal) — two
200
430
  # different detectors, same broad concern, one row in the box.
@@ -209,15 +439,26 @@ module ScryerTasks
209
439
  ["Dependencies", deps_count]
210
440
  ]
211
441
 
212
- divider = "─" * 32
442
+ divider = paint("─" * 32, :gray)
443
+ score = renderer.security_score
213
444
  puts ""
214
- puts "Scryer Audit — #{result.files_scanned} files scanned"
445
+ puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
215
446
  puts divider
216
447
  puts ""
448
+ if baseline_path
449
+ puts "Baseline: #{baseline_path} — showing new findings only (#{fixed_count} fixed since baseline)."
450
+ puts ""
451
+ end
452
+ clean_rate = renderer.rules_clean_rate
453
+ puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
454
+ puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
455
+ puts ""
217
456
  rows.each { |label, count| puts summary_row(label, count) }
218
457
  puts divider
219
- puts summary_row("Total", total)
458
+ puts paint(summary_row("Total", total), :bold)
220
459
  puts ""
460
+ print_top_priorities(renderer.top_risks)
461
+ print_owasp_coverage(renderer.owasp_coverage)
221
462
  paths.each { |format, path| puts "#{format.upcase} report: #{path}" }
222
463
  end
223
464
 
@@ -225,4 +466,190 @@ module ScryerTasks
225
466
  value = count.nil? ? "skipped (nodeps)" : "#{count} finding#{"s" unless count == 1}"
226
467
  "#{label.ljust(14)}#{value.rjust(20)}"
227
468
  end
469
+
470
+ # The categories above are counted separately, but nothing else ranks
471
+ # across them — this is what actually backs "tells you what to fix
472
+ # first" rather than just splitting findings into four buckets. Same
473
+ # data ReportRenderer#top_risks already sorts for the HTML report.
474
+ def print_top_priorities(risks)
475
+ return if risks.empty?
476
+
477
+ puts paint("Top priorities:", :bold)
478
+ risks.each_with_index do |r, i|
479
+ puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
480
+ end
481
+ puts ""
482
+ end
483
+
484
+ # Byproduct of every security rule carrying an owasp_category — see
485
+ # ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
486
+ # OWASP-audited mapping (documented in full in the README).
487
+ def print_owasp_coverage(coverage)
488
+ return if coverage.empty?
489
+
490
+ puts "OWASP Top 10 (2021) coverage:"
491
+ coverage.each { |category, count| puts " #{category}: #{count} finding#{"s" unless count == 1}" }
492
+ puts ""
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
228
655
  end