scryer 0.3.0 → 1.1.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +322 -0
  3. data/README.md +546 -43
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +95 -6
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +209 -11
  11. data/lib/scryer/dependency_audit.rb +108 -5
  12. data/lib/scryer/finding.rb +6 -0
  13. data/lib/scryer/fix_verifier.rb +82 -0
  14. data/lib/scryer/minitest.rb +48 -0
  15. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  16. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  18. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  19. data/lib/scryer/report_renderer.rb +637 -44
  20. data/lib/scryer/rspec.rb +55 -0
  21. data/lib/scryer/rule.rb +22 -2
  22. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
  23. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
  24. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
  25. data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  27. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  28. data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
  29. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  30. data/lib/scryer/rules/force_ssl_rule.rb +48 -0
  31. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
  32. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
  33. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
  34. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  35. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  36. data/lib/scryer/rules/idor_rule.rb +165 -0
  37. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
  38. data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
  39. data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
  40. data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
  41. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  42. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  43. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  44. data/lib/scryer/rules/path_traversal_rule.rb +110 -0
  45. data/lib/scryer/rules/security_headers_rule.rb +133 -0
  46. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  47. data/lib/scryer/rules/ssrf_rule.rb +139 -0
  48. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  49. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  50. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  51. data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
  52. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  53. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  54. data/lib/scryer/version.rb +1 -1
  55. data/lib/scryer.rb +15 -0
  56. data/lib/tasks/scryer.rake +138 -13
  57. metadata +41 -12
data/lib/scryer/cli.rb CHANGED
@@ -9,7 +9,7 @@ module Scryer
9
9
  # only needed for this CLI entry point, not when the gem is required
10
10
  # inside a host app.
11
11
  class CLI
12
- EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv" }.freeze
12
+ EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
13
13
 
14
14
  def initialize(argv, stdout: $stdout, stderr: $stderr)
15
15
  @argv = argv
@@ -21,6 +21,12 @@ module Scryer
21
21
  # found (so `scryer -o report.json` can gate CI the way `brakeman -o
22
22
  # report.json` does), 2 on a usage error.
23
23
  def run
24
+ # `scryer verify` is a distinct subcommand, not a flag on the normal
25
+ # scan — it takes its own small option set (--rule/--file/--path) that
26
+ # would collide with the main parser's -p/-o meanings, so it's
27
+ # dispatched before the main OptionParser ever sees the rest of argv.
28
+ return run_verify(@argv[1..]) if @argv.first == "verify"
29
+
24
30
  options = parse(@argv)
25
31
  return 0 if options[:exit_early]
26
32
 
@@ -50,12 +56,20 @@ module Scryer
50
56
  dependency_findings = []
51
57
  if ran_deps
52
58
  @stdout.puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
53
- dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root)
59
+ dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root) +
60
+ DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
61
+ end
62
+
63
+ return save_baseline(options[:save_baseline], result, dependency_findings) if options[:save_baseline]
64
+
65
+ fixed_count = 0
66
+ if options[:baseline]
67
+ dependency_findings, fixed_count = apply_baseline(options[:baseline], result, dependency_findings)
54
68
  end
55
69
 
56
70
  if Scryer.configuration.ai_client
57
71
  @stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
58
- AiFixSuggester.enhance_result!(result)
72
+ AiFixSuggester.enhance_result!(result, root: root)
59
73
  AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
60
74
  end
61
75
 
@@ -71,7 +85,8 @@ module Scryer
71
85
  outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
72
86
  outputs.each { |path| write_report(renderer, path) }
73
87
 
74
- print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs)
88
+ print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs,
89
+ renderer: renderer, fixed_count: fixed_count, baseline_path: options[:baseline])
75
90
 
76
91
  result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
77
92
  rescue UsageError => e
@@ -91,23 +106,33 @@ module Scryer
91
106
 
92
107
  @stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
93
108
  vulnerable = DependencyAudit.vulnerable_gems(root)
109
+ ruby_eol = DependencyAudit.ruby_eol_check(root)
110
+ credentials_exposure = DependencyAudit.credentials_exposure_check(root)
94
111
 
95
- (insecure + vulnerable).each do |f|
96
- label = f.kind == "insecure_source" ? "[#{f.severity.upcase}] #{f.message}" : "[#{f.severity.upcase}] #{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}"
97
- @stdout.puts label
112
+ (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
113
+ @stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
98
114
  @stdout.puts " fix: #{f.suggested_fix}"
99
115
  end
100
116
 
101
- total = insecure.size + vulnerable.size
102
- @stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, #{vulnerable.size} vulnerable gem)."
117
+ total = insecure.size + vulnerable.size + ruby_eol.size + credentials_exposure.size
118
+ @stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, " \
119
+ "#{vulnerable.size} vulnerable gem, #{ruby_eol.size} Ruby EOL, " \
120
+ "#{credentials_exposure.size} credentials exposure)."
103
121
  total.positive? ? 1 : 0
104
122
  end
105
123
 
124
+ def dependency_label(f)
125
+ return f.message if f.kind == "insecure_source"
126
+ return "#{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}" if f.advisory_id
127
+
128
+ "#{f.gem_name} #{f.installed_version}: #{f.title}"
129
+ end
130
+
106
131
  # The "one audit command" summary — a single scan's worth of every
107
132
  # category Scryer covers (security, performance, duplicate/smelly code,
108
133
  # dependencies), the same categories usually split across RuboCop +
109
134
  # Brakeman + bundler-audit + Reek, side by side in one box.
110
- def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
135
+ def print_summary(result:, dependency_findings:, ran_deps:, outputs:, renderer:, fixed_count: 0, baseline_path: nil)
111
136
  # "Code Quality" is the umbrella label for both duplicate-code groups
112
137
  # and rule-based style findings (e.g. frozen_string_literal) — two
113
138
  # different detectors, same broad concern, one row in the box.
@@ -123,14 +148,26 @@ module Scryer
123
148
  ]
124
149
 
125
150
  divider = "─" * 32
151
+ score = renderer.security_score
126
152
  @stdout.puts ""
127
153
  @stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
128
154
  @stdout.puts divider
129
155
  @stdout.puts ""
156
+ if baseline_path
157
+ @stdout.puts "Baseline: #{baseline_path} — showing new findings only " \
158
+ "(#{fixed_count} fixed since baseline)."
159
+ @stdout.puts ""
160
+ end
161
+ clean_rate = renderer.rules_clean_rate
162
+ @stdout.puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
163
+ @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
164
+ @stdout.puts ""
130
165
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
131
166
  @stdout.puts divider
132
167
  @stdout.puts summary_row("Total", total)
133
168
  @stdout.puts ""
169
+ print_top_priorities(renderer.top_risks)
170
+ print_owasp_coverage(renderer.owasp_coverage)
134
171
  outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
135
172
  end
136
173
 
@@ -139,6 +176,99 @@ module Scryer
139
176
  "#{label.ljust(14)}#{value.rjust(20)}"
140
177
  end
141
178
 
179
+ # The categories above are counted separately, but nothing else ranks
180
+ # across them — this is what actually backs "tells you what to fix
181
+ # first" rather than just splitting findings into four buckets. Same
182
+ # data ReportRenderer#top_risks already sorts for the HTML report.
183
+ def print_top_priorities(risks)
184
+ return if risks.empty?
185
+
186
+ @stdout.puts "Top priorities:"
187
+ risks.each_with_index do |r, i|
188
+ @stdout.puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
189
+ end
190
+ @stdout.puts ""
191
+ end
192
+
193
+ # Byproduct of every security rule carrying an owasp_category — see
194
+ # ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
195
+ # OWASP-audited mapping (documented in full in the README).
196
+ def print_owasp_coverage(coverage)
197
+ return if coverage.empty?
198
+
199
+ @stdout.puts "OWASP Top 10 (2021) coverage:"
200
+ coverage.each { |category, count| @stdout.puts " #{category}: #{count} finding#{"s" unless count == 1}" }
201
+ @stdout.puts ""
202
+ end
203
+
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."
212
+ def run_verify(argv)
213
+ options = {}
214
+
215
+ 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 }
220
+ opts.on("--list-rules", "List every known rule_id and exit.") { options[:list_rules] = true }
221
+ opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
222
+ end
223
+
224
+ begin
225
+ parser.parse!(argv)
226
+ rescue OptionParser::ParseError => e
227
+ raise UsageError, "#{e.message}\n#{parser}"
228
+ end
229
+ return 0 if options[:exit_early]
230
+
231
+ if options[:list_rules]
232
+ RuleSet.all.map(&:rule_id).sort.each { |id| @stdout.puts id }
233
+ return 0
234
+ end
235
+
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."
241
+ end
242
+
243
+ root = File.expand_path(options[:root] || Dir.pwd)
244
+ abs_path = File.expand_path(options[:file], root)
245
+ raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
246
+
247
+ rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
248
+ source = File.read(abs_path)
249
+
250
+ sexp = begin
251
+ Ripper.sexp(source)
252
+ rescue StandardError => e
253
+ raise UsageError, "#{rel_path} failed to parse: #{e.message}"
254
+ end
255
+ if sexp.nil?
256
+ raise UsageError, "#{rel_path} could not be parsed (a syntax error, or Ruby syntax newer " \
257
+ "than this gem's Ruby runtime supports)."
258
+ end
259
+
260
+ findings = rule_class.new(file: rel_path, source: source, sexp: sexp).scan
261
+
262
+ if findings.empty?
263
+ @stdout.puts "scryer verify: #{options[:rule]} no longer fires on #{rel_path} — fix verified."
264
+ 0
265
+ 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}" }
268
+ 1
269
+ end
270
+ end
271
+
142
272
  # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
143
273
  # no other network calls. `spec` is "name" or "name:version" (colon
144
274
  # rather than a second CLI arg, so this stays a single -o-style flag).
@@ -158,6 +288,62 @@ module Scryer
158
288
  findings.empty? ? 0 : 1
159
289
  end
160
290
 
291
+ # `--save-baseline PATH` is a distinct mode, same as --audit-deps/
292
+ # --check-gem: it captures every finding across every category (not
293
+ # just security — a legacy app's existing performance/style debt is
294
+ # just as much "not what I'm here to re-litigate today" as its security
295
+ # debt), writes the fingerprints, and exits without writing the normal
296
+ # -o reports. See Scryer::Baseline for why fingerprints, not file:line.
297
+ def save_baseline(path, result, dependency_findings)
298
+ all_findings = (result.security_findings + result.performance_findings + result.style_findings)
299
+ .map(&:to_h) + dependency_findings.map(&:to_h)
300
+ Baseline.save(path, all_findings)
301
+ @stdout.puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
302
+ 0
303
+ end
304
+
305
+ # Filters `result`'s finding arrays (mutated in place — Result is a
306
+ # plain Struct, this is the same object the caller already holds) and
307
+ # returns [new_dependency_findings, fixed_count] since dependency_findings
308
+ # is a local array in the caller, not a field this method can mutate by
309
+ # reference the way it can Struct fields.
310
+ # `fixed_count` has to be computed ONCE against the union of every
311
+ # category's current fingerprints, not once per category summed
312
+ # together — Baseline.diff's fixed_count is "baseline fingerprints not
313
+ # present in *this* call's findings," so calling it separately per
314
+ # category and summing would count every other category's
315
+ # still-present findings as "fixed" too (verified: this exact bug
316
+ # produced a nonsensical "762 fixed" on a rescan with zero changes,
317
+ # against a 255-finding baseline — fixed by computing fixed_count from
318
+ # the combined set once, while still filtering "new" per category since
319
+ # that part only checks baseline membership, which is fine to do
320
+ # separately).
321
+ def apply_baseline(path, result, dependency_findings)
322
+ baseline_fingerprints = Baseline.load(path)
323
+
324
+ security_hashes = result.security_findings.map(&:to_h)
325
+ performance_hashes = result.performance_findings.map(&:to_h)
326
+ style_hashes = result.style_findings.map(&:to_h)
327
+ dependency_hashes = dependency_findings.map(&:to_h)
328
+
329
+ all_current_fingerprints = Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
330
+ fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
331
+
332
+ result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
333
+ result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
334
+ result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
335
+ filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
336
+
337
+ [filtered_deps, fixed_count]
338
+ rescue Baseline::LoadError => e
339
+ raise UsageError, e.message
340
+ end
341
+
342
+ def filter_new(objects, hashes, baseline_fingerprints)
343
+ fingerprints = Baseline.fingerprints(hashes)
344
+ objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
345
+ end
346
+
161
347
  def parse(argv)
162
348
  options = { outputs: [] }
163
349
 
@@ -165,7 +351,7 @@ module Scryer
165
351
  opts.banner = "Usage: scryer [options]"
166
352
  opts.on("-o PATH", "--output PATH",
167
353
  "Write a report to PATH (repeatable). Format is inferred from the " \
168
- "extension: .json, .html, or .csv.") { |v| options[:outputs] << v }
354
+ "extension: .json, .html, .csv, or .sarif.") { |v| options[:outputs] << v }
169
355
  opts.on("-p PATH", "--path PATH", "Root directory to scan (default: current directory).") { |v| options[:path] = v }
170
356
  opts.on("-r PATH", "--require PATH",
171
357
  "Require a Ruby file before scanning (repeatable) — the file can call " \
@@ -178,6 +364,17 @@ module Scryer
178
364
  "insecure git/http sources (offline), instead of running the normal static scan. " \
179
365
  "Exits non-zero if anything is found, so this can gate CI the same way " \
180
366
  "`bundle-audit check` does.") { options[:audit_deps] = true }
367
+ opts.on("--save-baseline PATH",
368
+ "Run the normal scan, save every finding's fingerprint to PATH, then exit — no " \
369
+ "reports written. A later `scryer --baseline PATH` scan reports only findings " \
370
+ "new since this snapshot, so an app with existing security debt can gate CI on " \
371
+ "new issues without being forced to fix everything on day one.") { |v| options[:save_baseline] = v }
372
+ opts.on("--baseline PATH",
373
+ "Compare this scan against a baseline saved by --save-baseline: every report " \
374
+ "(-o files, console summary, exit code) reflects only findings new since PATH " \
375
+ "was saved. Fingerprints ignore line number (rule + file + offending code), so " \
376
+ "an unrelated edit elsewhere in the file won't make an existing finding look " \
377
+ "new.") { |v| options[:baseline] = v }
181
378
  opts.on("--no-deps",
182
379
  "Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
183
380
  "that otherwise runs as part of every normal scan. Use this for a fast, fully " \
@@ -212,6 +409,7 @@ module Scryer
212
409
  content = case format
213
410
  when "json" then renderer.as_json
214
411
  when "csv" then renderer.as_csv
412
+ when "sarif" then renderer.as_sarif
215
413
  else renderer.as_html
216
414
  end
217
415
 
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "date"
2
3
 
3
4
  module Scryer
4
5
  # Dependency vulnerability + supply-chain-hygiene checks for Gemfile.lock —
@@ -55,15 +56,35 @@ module Scryer
55
56
  "LOW" => "info"
56
57
  }.freeze
57
58
 
59
+ # Ruby's own published maintenance-branch EOL dates
60
+ # (ruby-lang.org/en/downloads/branches/) — these are announced years in
61
+ # advance and essentially never move, unlike a CVE feed, so this is a
62
+ # one-time/occasional-update cost rather than an ongoing sync burden.
63
+ # Update when a new branch's EOL is announced, or an older branch not
64
+ # listed here needs adding.
65
+ RUBY_EOL_DATES = {
66
+ "2.5" => Date.new(2021, 3, 31),
67
+ "2.6" => Date.new(2022, 3, 31),
68
+ "2.7" => Date.new(2023, 3, 31),
69
+ "3.0" => Date.new(2024, 3, 31),
70
+ "3.1" => Date.new(2025, 3, 31),
71
+ "3.2" => Date.new(2026, 3, 31),
72
+ "3.3" => Date.new(2027, 3, 31),
73
+ "3.4" => Date.new(2028, 3, 31)
74
+ }.freeze
75
+
58
76
  class << self
59
- # Parses a Gemfile.lock into `{ gems: { name => {version:, source:} }, git_or_path_sources: [...] }`.
60
- # `source` is "gem", "git", or "path" taken from which top-level
61
- # block (GEM/GIT/PATH) the spec's `specs:` list appeared under.
77
+ # Parses a Gemfile.lock into `{ gems: { name => {version:, source:} },
78
+ # git_or_path_sources: [...], ruby_version: "3.3.3" | nil }`. `source`
79
+ # is "gem", "git", or "path" taken from which top-level block
80
+ # (GEM/GIT/PATH) the spec's `specs:` list appeared under.
62
81
  def parse_lockfile(path)
63
82
  gems = {}
64
83
  git_or_path_sources = []
84
+ ruby_version = nil
65
85
 
66
- current_block = nil # "gem" | "git" | "path" | other section name
86
+ current_block = nil # "gem" | "git" | "path" | nil (other section)
87
+ current_section = nil # PLATFORMS | DEPENDENCIES | BUNDLED WITH | RUBY VERSION | nil
67
88
  current_remote = nil
68
89
  in_specs = false
69
90
 
@@ -71,10 +92,12 @@ module Scryer
71
92
  case line
72
93
  when /\A(GEM|GIT|PATH)\s*\z/
73
94
  current_block = Regexp.last_match(1).downcase
95
+ current_section = nil
74
96
  current_remote = nil
75
97
  in_specs = false
76
98
  when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
77
99
  current_block = nil
100
+ current_section = Regexp.last_match(1)
78
101
  in_specs = false
79
102
  when /\A {2}remote:\s*(\S+)\s*\z/
80
103
  current_remote = Regexp.last_match(1)
@@ -90,10 +113,46 @@ module Scryer
90
113
  # in pathological Gemfiles; last one wins, consistent with how
91
114
  # Bundler itself resolves a single spec per gem name.
92
115
  gems[name] = { version: version, source: current_block }
116
+ when /\A\s*ruby\s+(\S+)\s*\z/
117
+ ruby_version = Regexp.last_match(1) if current_section == "RUBY VERSION"
93
118
  end
94
119
  end
95
120
 
96
- { gems: gems, git_or_path_sources: git_or_path_sources }
121
+ { gems: gems, git_or_path_sources: git_or_path_sources, ruby_version: ruby_version }
122
+ end
123
+
124
+ # Offline. Flags the Ruby version pinned in Gemfile.lock's RUBY
125
+ # VERSION section if its minor series is past end-of-life — after that
126
+ # date Ruby publishes no more security patches for it, for any issue,
127
+ # so this is a real gap even with every gem otherwise up to date.
128
+ # Returns [] if no version is pinned, or its series isn't one
129
+ # RUBY_EOL_DATES knows about (never guessed as "fine" by omission).
130
+ def ruby_eol_check(root)
131
+ lockfile = File.join(root, "Gemfile.lock")
132
+ return [] unless File.exist?(lockfile)
133
+
134
+ ruby_version = parse_lockfile(lockfile)[:ruby_version]
135
+ return [] unless ruby_version
136
+
137
+ series = ruby_version[/\A\d+\.\d+/]
138
+ eol_date = series && RUBY_EOL_DATES[series]
139
+ return [] unless eol_date && Date.today > eol_date
140
+
141
+ [
142
+ Finding.new(
143
+ kind: "ruby_eol",
144
+ gem_name: "Ruby",
145
+ installed_version: ruby_version,
146
+ severity: "critical",
147
+ title: "Ruby #{series} is end-of-life",
148
+ url: "https://www.ruby-lang.org/en/downloads/branches/",
149
+ patched_versions: [],
150
+ message: "Ruby #{ruby_version} (#{series} series) reached end-of-life on " \
151
+ "#{eol_date} — no security patches are published for it anymore, for any issue.",
152
+ suggested_fix: "Upgrade to a Ruby version still receiving security maintenance — see " \
153
+ "https://www.ruby-lang.org/en/downloads/branches/ for current status."
154
+ )
155
+ ]
97
156
  end
98
157
 
99
158
  # Offline. Flags GIT/PATH sources recorded with an unencrypted remote
@@ -160,6 +219,41 @@ module Scryer
160
219
  findings
161
220
  end
162
221
 
222
+ # Offline. Flags `config/master.key` (the key that decrypts Rails
223
+ # encrypted credentials, config/credentials.yml.enc) if it exists on
224
+ # disk and .gitignore doesn't exclude it — Rails generates it
225
+ # gitignored by default (`/config/master.key`), but that line is easy
226
+ # to lose (a merge, a from-scratch .gitignore, copying the file into a
227
+ # repo that never had the Rails default). If this file is ever
228
+ # committed, anyone with repo access — including git history, even
229
+ # after a later removal — can decrypt every credential in
230
+ # config/credentials.yml.enc.
231
+ def credentials_exposure_check(root)
232
+ master_key_path = File.join(root, "config", "master.key")
233
+ return [] unless File.exist?(master_key_path)
234
+ return [] if master_key_gitignored?(root)
235
+
236
+ [
237
+ Finding.new(
238
+ kind: "credentials_exposure",
239
+ gem_name: "Rails",
240
+ severity: "critical",
241
+ title: "config/master.key is present and not gitignored",
242
+ url: "https://guides.rubyonrails.org/security.html",
243
+ patched_versions: [],
244
+ message: "config/master.key exists in this app, but .gitignore doesn't exclude it " \
245
+ "(checked for both `/config/master.key` and `config/master.key`) — if this " \
246
+ "file is ever committed, anyone with repository access, including git " \
247
+ "history even after a later removal, can decrypt every credential in " \
248
+ "config/credentials.yml.enc.",
249
+ suggested_fix: "Add `/config/master.key` to .gitignore immediately. If this key was " \
250
+ "ever actually committed to git history, treat every credential in " \
251
+ "config/credentials.yml.enc as compromised: rotate them and regenerate " \
252
+ "the master key (delete both files, then `bin/rails credentials:edit`)."
253
+ )
254
+ ]
255
+ end
256
+
163
257
  # Needs network. One-off OSV.dev lookup for a single gem, independent
164
258
  # of any Gemfile.lock — backs `scryer --check-gem NAME[:VERSION]`.
165
259
  # With a version, only vulnerabilities affecting that exact version
@@ -171,6 +265,15 @@ module Scryer
171
265
 
172
266
  private
173
267
 
268
+ def master_key_gitignored?(root)
269
+ gitignore_path = File.join(root, ".gitignore")
270
+ return false unless File.exist?(gitignore_path)
271
+
272
+ File.foreach(gitignore_path).any? do |line|
273
+ line.strip.match?(%r{\A/?config/master\.key\z})
274
+ end
275
+ end
276
+
174
277
  def query_osv(name, version = nil)
175
278
  require "net/http"
176
279
  require "uri"
@@ -7,11 +7,17 @@ module Scryer
7
7
  :rule_id, # e.g. "sql_injection"
8
8
  :category, # "security" | "performance" | "duplication"
9
9
  :severity, # "critical" | "warning" | "info"
10
+ :confidence, # "high" | "medium" | "low" — see Rule.confidence
11
+ :cwe, # e.g. "CWE-89", or nil for non-security rules
12
+ :owasp_category, # e.g. "A03:2021-Injection", or nil for non-security rules
10
13
  :file, # relative path
11
14
  :line, # integer line number (1-indexed) or nil
12
15
  :code_snippet, # the offending source line, stripped
13
16
  :message, # human-readable description of the issue
14
17
  :suggested_fix, # human-readable explanation + example patch
18
+ :fix_verified, # true/false/nil — see Scryer::FixVerifier; nil unless
19
+ # an ai_client is configured AND the AI's reply had a
20
+ # verifiable AFTER: block, not "no fix exists"
15
21
  keyword_init: true
16
22
  ) do
17
23
  def to_h
@@ -0,0 +1,82 @@
1
+ require "ripper"
2
+
3
+ module Scryer
4
+ # Best-effort verification that an AI-rewritten suggested_fix actually
5
+ # clears the finding it was generated for — "AI-verified remediation" in
6
+ # the sense of "Scryer independently re-checked this," not in the sense of
7
+ # a human review being unnecessary (suggested_fix is still never
8
+ # auto-applied to a real file; see AiFixSuggester's own header comment).
9
+ #
10
+ # How: AiFixSuggester's prompt (see static_prompt_for) asks the model to
11
+ # end its reply with a fenced "AFTER:" code block containing a drop-in
12
+ # replacement for the single offending source line. This class extracts
13
+ # that block, substitutes it for that one line in an in-memory copy of the
14
+ # file (nothing is ever written to disk), re-parses the result, and
15
+ # re-runs *only the one rule that flagged this finding* against it. If
16
+ # that rule no longer fires anywhere in the modified file, the fix is
17
+ # marked verified.
18
+ #
19
+ # Deliberately narrow, same spirit as `scryer verify` (the CLI command
20
+ # this shares its core logic with): this confirms the ONE finding it
21
+ # targeted is gone, not that the fix is otherwise correct, idiomatic, or
22
+ # free of introducing a different problem — a full rescan (or `scryer
23
+ # verify` again with a different --rule) answers that.
24
+ #
25
+ # Returns true (rule no longer fires), false (attempted but the rule still
26
+ # fires, or the rewritten line doesn't even parse), or nil (verification
27
+ # wasn't attempted at all — no AFTER: block in the AI's reply, the
28
+ # original file isn't readable, or this isn't a rule-backed Finding to
29
+ # begin with). nil is deliberately distinct from false: it means "we don't
30
+ # know," not "we checked and it's still broken."
31
+ module FixVerifier
32
+ module_function
33
+
34
+ AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
35
+
36
+ def verify(finding:, root:)
37
+ return nil unless finding.is_a?(Scryer::Finding)
38
+ return nil unless finding.line && finding.rule_id
39
+
40
+ after_snippet = extract_after_snippet(finding.suggested_fix)
41
+ return nil unless after_snippet
42
+
43
+ abs_path = File.join(root, finding.file.to_s)
44
+ return nil unless File.file?(abs_path)
45
+
46
+ lines = File.read(abs_path).lines
47
+ return nil unless finding.line.between?(1, lines.size)
48
+
49
+ modified_source = apply_line_replacement(lines, finding.line, after_snippet)
50
+
51
+ sexp = begin
52
+ Ripper.sexp(modified_source)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ return false if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
57
+
58
+ rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
59
+ return nil unless rule_class
60
+
61
+ remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
62
+ remaining.none? { |f| f.rule_id == finding.rule_id }
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ def extract_after_snippet(suggested_fix)
68
+ match = AFTER_BLOCK.match(suggested_fix.to_s)
69
+ return nil unless match
70
+
71
+ content = match[1].to_s
72
+ content.strip.empty? ? nil : content
73
+ end
74
+
75
+ def apply_line_replacement(lines, line_number, replacement)
76
+ replacement_text = replacement.end_with?("\n") ? replacement : "#{replacement}\n"
77
+ modified = lines.dup
78
+ modified[line_number - 1] = replacement_text
79
+ modified.join
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,48 @@
1
+ # Opt-in Minitest integration — require this file yourself (e.g. `require
2
+ # "scryer/minitest"` in test_helper.rb) rather than it loading automatically
3
+ # with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a
4
+ # Scryer runtime dependency, and this file's assertions only make sense once
5
+ # the host app's own test framework is already loaded).
6
+ #
7
+ # Mix Scryer::MinitestAssertions into a Minitest::Test (or
8
+ # ActiveSupport::TestCase, which is one) to assert this app's own security
9
+ # scan stays clean as part of its normal test suite:
10
+ #
11
+ # class SecurityTest < ActiveSupport::TestCase
12
+ # include Scryer::MinitestAssertions
13
+ #
14
+ # test "no critical findings" do
15
+ # assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
16
+ # end
17
+ #
18
+ # test "the mass-assignment bug fixed in PR #123 doesn't come back" do
19
+ # assert_no_scryer_findings_for(Scryer.scan(root: Rails.root.to_s), "mass_assignment")
20
+ # end
21
+ # end
22
+ module Scryer
23
+ module MinitestAssertions
24
+ # Only security findings — style/performance findings aren't a security
25
+ # regression, same scoping as the RSpec have_no_critical_findings matcher.
26
+ def assert_no_critical_scryer_findings(result, msg = nil)
27
+ criticals = result.security_findings.select { |f| f.severity == "critical" }
28
+ default_msg = -> {
29
+ lines = criticals.map { |f| " - #{f.rule_id} at #{f.file}:#{f.line} — #{f.message}" }
30
+ "expected no critical security findings, but got #{criticals.size}:\n#{lines.join("\n")}"
31
+ }
32
+ assert criticals.empty?, msg || default_msg.call
33
+ end
34
+
35
+ # Checks all three categories (security/performance/style) — a rule
36
+ # regressing is worth catching regardless of which category it's filed
37
+ # under, unlike assert_no_critical_scryer_findings above.
38
+ def assert_no_scryer_findings_for(result, rule_id, msg = nil)
39
+ matches = (result.security_findings + result.performance_findings + result.style_findings)
40
+ .select { |f| f.rule_id == rule_id.to_s }
41
+ default_msg = -> {
42
+ lines = matches.map { |f| " - #{f.file}:#{f.line} — #{f.message}" }
43
+ "expected no findings for rule #{rule_id.inspect}, but got #{matches.size}:\n#{lines.join("\n")}"
44
+ }
45
+ assert matches.empty?, msg || default_msg.call
46
+ end
47
+ end
48
+ end