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
@@ -0,0 +1,56 @@
1
+ module Scryer
2
+ # Hand-rolled ANSI color/style helper for console output — deliberately
3
+ # not a gem dependency (`pastel`, `colorize`, ...), consistent with
4
+ # Scryer's zero-runtime-dependency design (see the gemspec).
5
+ #
6
+ # Off by default whenever it would be wrong to color: piped/redirected
7
+ # output, `NO_COLOR` set (https://no-color.org), or a dumb terminal
8
+ # (`TERM=dumb`). On by default at a real terminal otherwise. An explicit
9
+ # `--no-color`/`--color` flag (CLI) or `SCRYER_NO_COLOR`/`SCRYER_COLOR` env
10
+ # var (rake) always wins over all of that, in either direction — a flag
11
+ # typed for this one invocation is more specific than a session-wide env
12
+ # var, the same precedence ripgrep/eslint use for their own --color flags.
13
+ module Colorizer
14
+ module_function
15
+
16
+ CODES = { red: 31, green: 32, yellow: 33, cyan: 36, gray: 90, bold: 1 }.freeze
17
+
18
+ # `override`: true/false forces color on/off regardless of TTY/NO_COLOR
19
+ # (an explicit --color/--no-color flag or SCRYER_COLOR/SCRYER_NO_COLOR
20
+ # env var); nil (the default) means "decide automatically."
21
+ def enabled?(stream = $stdout, override: nil)
22
+ return override unless override.nil?
23
+ return false unless ENV["NO_COLOR"].to_s.empty?
24
+ return false if ENV["TERM"] == "dumb"
25
+
26
+ stream.respond_to?(:tty?) && stream.tty?
27
+ end
28
+
29
+ def paint(text, *styles, stream: $stdout, override: nil)
30
+ return text.to_s unless enabled?(stream, override: override)
31
+
32
+ codes = styles.map { |s| CODES.fetch(s) }.join(";")
33
+ "\e[#{codes}m#{text}\e[0m"
34
+ end
35
+
36
+ def severity(text, severity, **opts)
37
+ case severity.to_s
38
+ when "critical" then paint(text, :red, :bold, **opts)
39
+ when "warning" then paint(text, :yellow, **opts)
40
+ when "info" then paint(text, :cyan, **opts)
41
+ else text.to_s
42
+ end
43
+ end
44
+
45
+ # A/B/C/D/F — the security score's letter grade (see
46
+ # ReportRenderer#security_score).
47
+ def grade(text, letter, **opts)
48
+ case letter.to_s
49
+ when "A", "B" then paint(text, :green, :bold, **opts)
50
+ when "C" then paint(text, :yellow, :bold, **opts)
51
+ when "D", "F" then paint(text, :red, :bold, **opts)
52
+ else text.to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,96 @@
1
+ require "open3"
2
+
3
+ module Scryer
4
+ # `scryer fix --deps` — the dependency-audit analog of the rule-based
5
+ # `scryer fix`, and a separate small pipeline from it: a
6
+ # `DependencyAudit::Finding` has no `.line`/`.file`/`.rule_id` (see its
7
+ # Struct in dependency_audit.rb), so none of FixRunner/MechanicalFixer/
8
+ # FixVerifier — all built around rewriting a single source line and
9
+ # re-running one Ripper-based rule against it — apply here at all. This
10
+ # module exists for the one kind of dependency finding that has an
11
+ # unambiguous, mechanically-applicable fix: a gem with a published patched
12
+ # version to upgrade to.
13
+ #
14
+ # Only acts on `kind: "vulnerable_dependency"` findings that have at least
15
+ # one published patched version — DependencyAudit already leaves
16
+ # `patched_versions` empty when OSV has no fix yet, so there's nothing to
17
+ # bump to; those, along with `insecure_source` / `ruby_eol` /
18
+ # `credentials_exposure` findings (none of which "bundle update" can
19
+ # address — a source URL, a Ruby interpreter version, and a gitignore rule
20
+ # aren't gem versions), are always left for manual review.
21
+ module DependencyFixer
22
+ module_function
23
+
24
+ # Groups findings by gem_name (a gem can have more than one open
25
+ # advisory) and runs `bundle update GEM --conservative` once per gem,
26
+ # then re-queries OSV.dev for that one gem to confirm the bump actually
27
+ # cleared every advisory it had — `--conservative` tries to avoid moving
28
+ # anything beyond what's needed, but a version constraint elsewhere in
29
+ # the Gemfile can still leave a gem on an old, still-vulnerable version.
30
+ # This re-check is what turns "the command exited 0" into "verified,"
31
+ # the same verify-before-trusting discipline FixRunner/FixVerifier use
32
+ # for rule-based fixes — a real command actually ran and actually
33
+ # changed Gemfile.lock, so trusting its exit code alone isn't enough.
34
+ #
35
+ # Returns [fixed, skipped] (plain arrays of DependencyAudit::Finding).
36
+ # Yields (finding, status, error) to the given block as each gem is
37
+ # resolved — status is :fixed, :would_fix (dry_run), :error (the bundle
38
+ # command itself failed — `error` is its captured output), or :skipped
39
+ # (no patched version published, or the re-check still shows it
40
+ # vulnerable). `runner` and `recheck` are injectable for testing, so this
41
+ # is exercisable without a real Bundler process or network call.
42
+ def apply(findings, root:, dry_run: false, runner: method(:run_bundle_update),
43
+ recheck: method(:default_recheck), &on_result)
44
+ fixed = []
45
+ skipped = []
46
+
47
+ actionable, unfixable = findings.select { |f| f.kind == "vulnerable_dependency" }
48
+ .partition { |f| Array(f.patched_versions).any? }
49
+
50
+ unfixable.each do |finding|
51
+ skipped << finding
52
+ on_result&.call(finding, :skipped, nil)
53
+ end
54
+
55
+ actionable.group_by(&:gem_name).each_value do |gem_findings|
56
+ gem_name = gem_findings.first.gem_name
57
+
58
+ if dry_run
59
+ fixed.concat(gem_findings)
60
+ gem_findings.each { |f| on_result&.call(f, :would_fix, nil) }
61
+ next
62
+ end
63
+
64
+ success, output = runner.call(gem_name, root)
65
+
66
+ if !success
67
+ skipped.concat(gem_findings)
68
+ gem_findings.each { |f| on_result&.call(f, :error, output) }
69
+ next
70
+ end
71
+
72
+ if recheck.call(root, gem_name)
73
+ skipped.concat(gem_findings)
74
+ message = "bundle update #{gem_name} --conservative ran, but #{gem_name} is still flagged " \
75
+ "(no resolvable fixed version given the current Gemfile constraints, or a " \
76
+ "different advisory applies to the version it landed on) — check manually."
77
+ gem_findings.each { |f| on_result&.call(f, :skipped, message) }
78
+ else
79
+ fixed.concat(gem_findings)
80
+ gem_findings.each { |f| on_result&.call(f, :fixed, nil) }
81
+ end
82
+ end
83
+
84
+ [fixed, skipped]
85
+ end
86
+
87
+ def run_bundle_update(gem_name, root)
88
+ stdout, stderr, status = Open3.capture3("bundle", "update", gem_name, "--conservative", chdir: root)
89
+ [status.success?, status.success? ? stdout : "#{stdout}\n#{stderr}".strip]
90
+ end
91
+
92
+ def default_recheck(root, gem_name)
93
+ DependencyAudit.vulnerable_gems(root).any? { |f| f.gem_name == gem_name }
94
+ end
95
+ end
96
+ end
@@ -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,161 @@
1
+ require "set"
2
+
3
+ module Scryer
4
+ # The "apply verified AI fixes to real files" loop, shared behind both
5
+ # `scryer fix` (CLI) and `rails scryer:fix` (rake task) — the actual
6
+ # file-writing logic lives here exactly once; each caller formats its own
7
+ # console output around it (same house style as `ScryerTasks`/`CLI`
8
+ # already duplicating summary-printing methods rather than sharing them —
9
+ # see lib/tasks/scryer.rake).
10
+ module FixRunner
11
+ module_function
12
+
13
+ # Groups by file and processes each file's findings from the highest
14
+ # line number down — replacing a later line first means every not-yet-
15
+ # processed line number earlier in the same file stays valid even when a
16
+ # fix's replacement spans a different number of lines than the original
17
+ # one. (Two distinct findings landing on the exact same line in the same
18
+ # file is a narrow, unhandled-further edge case — FixVerifier.apply!
19
+ # always re-verifies against the file's current on-disk content right
20
+ # before writing, so it can't silently apply a fix that no longer
21
+ # actually matches what's there, but the second finding on that line may
22
+ # end up needing a second fix pass to resolve.)
23
+ #
24
+ # Returns [fixed, skipped] — both plain arrays of Finding (a
25
+ # user-declined finding, when `confirm` is given, lands in `skipped`
26
+ # too — it was verified but deliberately not written, same bucket as
27
+ # "not written" for any other reason). Yields (finding, status, error)
28
+ # to the given block as each candidate is resolved — status is :fixed,
29
+ # :would_fix (dry_run), :declined (confirm said no), :ai_error (the
30
+ # ai_client raised — `error` is that exception; nil for every other
31
+ # status), :cancelled (confirm said stop — see below), or :skipped — so
32
+ # a caller (see CLI#print_fix_progress / ScryerTasks#print_fix_progress)
33
+ # can print progress as it happens instead of only a summary once
34
+ # everything's done. Blocks in Ruby don't enforce arity, so an existing
35
+ # `{ |finding, status| ... }` callback still works fine and just never
36
+ # sees the third arg. The block is entirely optional; omitting it
37
+ # changes nothing else.
38
+ #
39
+ # `confirm`, when given, is called with each independently-verified
40
+ # finding right before it would be written (or counted as "would fix",
41
+ # under dry_run) — returning false skips it without writing, and
42
+ # returning the symbol `:cancel` stops entirely: every remaining
43
+ # candidate (including the current one) is marked :cancelled without
44
+ # `confirm` being asked about any of them again — and, critically,
45
+ # without spending an AiFixSuggester call or a MechanicalFixer/
46
+ # FixVerifier pass on any of them either, since "cancel" means stop
47
+ # working, not just "don't write what's already been verified." This is
48
+ # the model-layer half of `scryer fix`'s per-finding "yes / skip / yes
49
+ # to all remaining / cancel" review; the actual prompt (reading stdin,
50
+ # tracking a latched "yes to all" choice across calls) lives in the
51
+ # caller (CLI#build_fix_confirmer / ScryerTasks) so this stays testable
52
+ # without a real terminal. Omitting `confirm` (the default) applies
53
+ # every verified fix automatically, same as before this option existed.
54
+ def apply(candidates, client:, root:, dry_run: false, confirm: nil, &on_result)
55
+ fixed = []
56
+ skipped = []
57
+ cancelled = false
58
+
59
+ candidates.group_by(&:file).each_value do |findings_in_file|
60
+ findings_in_file.sort_by { |f| -(f.line || 0) }.each do |finding|
61
+ if cancelled
62
+ skipped << finding
63
+ on_result&.call(finding, :cancelled, nil)
64
+ next
65
+ end
66
+
67
+ # An ai_client, when configured, is tried first for every rule —
68
+ # including the ones a mechanical fixer could also handle — so a
69
+ # "real developer" fix (context-aware, not just the one
70
+ # mechanically-derivable rewrite) is what gets written whenever
71
+ # AI is actually available. The deterministic mechanical fixer is
72
+ # the fallback: it runs whenever AI isn't configured at all,
73
+ # declined to produce anything usable, raised, or its rewrite
74
+ # didn't independently verify — same safety gate either way, so
75
+ # neither path is ever trusted more than the other.
76
+ ai_error = nil
77
+ AiFixSuggester.enhance!(finding, client: client, root: root, on_error: ->(_f, e) { ai_error = e }) if client
78
+
79
+ if finding.fix_verified != true
80
+ mechanical_fix = MechanicalFixer.suggest(finding, root: root)
81
+ if mechanical_fix
82
+ finding.suggested_fix = mechanical_fix
83
+ finding.fix_verified = FixVerifier.verify(finding: finding, root: root)
84
+ end
85
+ end
86
+
87
+ if finding.fix_verified != true
88
+ skipped << finding
89
+ on_result&.call(finding, ai_error ? :ai_error : :skipped, ai_error)
90
+ next
91
+ end
92
+
93
+ if confirm
94
+ decision = confirm.call(finding)
95
+ if decision == :cancel
96
+ cancelled = true
97
+ skipped << finding
98
+ on_result&.call(finding, :cancelled, nil)
99
+ next
100
+ elsif !decision
101
+ skipped << finding
102
+ on_result&.call(finding, :declined)
103
+ next
104
+ end
105
+ end
106
+
107
+ if dry_run
108
+ fixed << finding
109
+ on_result&.call(finding, :would_fix)
110
+ elsif FixVerifier.apply!(finding: finding, root: root)
111
+ fixed << finding
112
+ on_result&.call(finding, :fixed)
113
+ else
114
+ skipped << finding
115
+ on_result&.call(finding, :skipped)
116
+ end
117
+ end
118
+ end
119
+
120
+ [fixed, skipped]
121
+ end
122
+
123
+ # Best-effort short human explanation of a fix — the first couple of
124
+ # sentences of the AI's own reply, before its code block(s)/AFTER:
125
+ # marker. AiFixSuggester's prompt asks the model to lead with 1-3
126
+ # sentences of plain-English explanation before any code, so this is
127
+ # just trimming that reply down to something that reads well as a single
128
+ # console line, not a new source of information. Returns "" (never nil)
129
+ # when there's nothing usable to show.
130
+ def explain(suggested_fix, max_sentences: 2)
131
+ text = suggested_fix.to_s.strip
132
+ return "" if text.empty?
133
+
134
+ cut_at = [text.index("```"), text.index(/^AFTER:/m)].compact.min
135
+ text = text[0...cut_at] if cut_at
136
+ text.strip.split(/(?<=[.!?])\s+/).first(max_sentences).join(" ").strip
137
+ end
138
+
139
+ # Re-scans `root` and returns the subset of `fixed` that still shows up
140
+ # (matched by fingerprint — rule + file + offending code, not line
141
+ # number, since every fixed line's line number just changed) — normally
142
+ # empty. FixVerifier.apply! only re-checks the single rule against the
143
+ # single file it just edited in isolation; this is the broader,
144
+ # whole-project confirmation that no interaction between edits (two
145
+ # fixes in the same file, one fix's line-count change shifting another
146
+ # finding's line number) left anything still firing.
147
+ def verify(fixed, root:, dirs:, skip_rules:)
148
+ # detect_duplicates: false — only security/performance/style findings
149
+ # are read below; duplicate_groups are never part of `fixed`
150
+ # (duplicate-code groups aren't Findings — see Scanner#initialize's
151
+ # comment on detect_duplicates), so there's nothing to gain from that
152
+ # pass here.
153
+ rescanned = Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: false).call
154
+ still_present = Baseline.fingerprints(
155
+ (rescanned.security_findings + rescanned.performance_findings + rescanned.style_findings).map(&:to_h)
156
+ ).to_set
157
+
158
+ fixed.select { |f| still_present.include?(Baseline.fingerprint(f.to_h)) }
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,169 @@
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
+ #
32
+ # `apply!` below is the one place in this whole gem that ever writes an
33
+ # AI-generated fix to a real file — and even there, only after this exact
34
+ # same in-memory check says the fix genuinely clears the finding. See
35
+ # `scryer fix` (lib/scryer/cli.rb) for the only caller; nothing else in
36
+ # this gem calls it, including the ai_client-enhancement path a normal
37
+ # scan/report run uses (AiFixSuggester#enhance!), which only ever rewrites
38
+ # `finding.suggested_fix` text, never touches disk.
39
+ module FixVerifier
40
+ module_function
41
+
42
+ AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
43
+
44
+ def verify(finding:, root:)
45
+ verify_with_source(finding: finding, root: root).first
46
+ end
47
+
48
+ # Writes the verified fix to the real file and returns true, or returns
49
+ # the same false/nil `verify` would return without writing anything.
50
+ # Re-derives the verification (rather than trusting a `fix_verified`
51
+ # value computed earlier) so there's no window between "we checked" and
52
+ # "we wrote" where the file could have changed out from under it.
53
+ def apply!(finding:, root:)
54
+ verified, modified_source, abs_path = verify_with_source(finding: finding, root: root)
55
+ return verified unless verified == true
56
+
57
+ File.write(abs_path, modified_source)
58
+ true
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ # [verified, modified_source, abs_path] — modified_source/abs_path are
64
+ # nil whenever verified isn't true (nothing for a caller to write in
65
+ # that case anyway).
66
+ def verify_with_source(finding:, root:)
67
+ return [nil, nil, nil] unless finding.is_a?(Scryer::Finding)
68
+ return [nil, nil, nil] unless finding.line && finding.rule_id
69
+
70
+ after_snippet = extract_after_snippet(finding.suggested_fix)
71
+ return [nil, nil, nil] unless after_snippet
72
+
73
+ abs_path = File.join(root, finding.file.to_s)
74
+ return [nil, nil, nil] unless File.file?(abs_path)
75
+
76
+ lines = File.read(abs_path).lines
77
+ return [nil, nil, nil] unless finding.line.between?(1, lines.size)
78
+
79
+ modified_source = apply_line_replacement(lines, finding.line, after_snippet)
80
+
81
+ sexp = begin
82
+ Ripper.sexp(modified_source)
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ return [false, nil, nil] if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
87
+
88
+ rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
89
+ return [nil, nil, nil] unless rule_class
90
+
91
+ remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
92
+
93
+ # Matched by rule_id + code_snippet (same identity Baseline fingerprints
94
+ # use), NOT just rule_id — a file with two separate sql_injection
95
+ # findings must let each be verified independently. Checking only
96
+ # rule_id here would mean neither ever verifies, since fixing one line
97
+ # in isolation always leaves the other (still-unfixed) occurrence
98
+ # showing up in `remaining`.
99
+ target_snippet = finding.code_snippet.to_s.strip
100
+ rule_cleared = remaining.none? { |f| f.rule_id == finding.rule_id && f.code_snippet.to_s.strip == target_snippet }
101
+ verified = rule_cleared && !introduces_undefined_params_helper?(finding.code_snippet, after_snippet, modified_source)
102
+ verified ? [true, modified_source, abs_path] : [false, nil, nil]
103
+ rescue StandardError
104
+ [nil, nil, nil]
105
+ end
106
+
107
+ PARAMS_HELPER_NAME = /\b([a-z_][a-zA-Z0-9_]*_params)\b/.freeze
108
+
109
+ # A common, real failure mode found in production: mass_assignment's own
110
+ # suggested_fix (and the AI prompt built from it) recommends extracting a
111
+ # strong-parameters helper — "wrap in `order_params`, with `def
112
+ # order_params; params.require(...).permit(...); end`" — but `AFTER:` can
113
+ # only ever replace the single flagged line, never add a method
114
+ # definition elsewhere in the file. An AI reply that takes this approach
115
+ # ends up calling a helper (e.g. `create_charge_params[:account_id]`)
116
+ # that was never actually defined anywhere — syntactically valid Ruby, so
117
+ # it parses fine, and the mass_assignment rule stops firing (the line no
118
+ # longer references `params` directly), so this "verifies" clean by
119
+ # every check above... and then raises NoMethodError the moment it
120
+ # actually runs. Only flags a name that's *new* in this rewrite (already
121
+ # present in the original flagged line means it's not this fix's doing,
122
+ # and may well be defined in a parent class/concern this per-file check
123
+ # can't see) and has no matching `def` anywhere in the file. Like every
124
+ # other heuristic here, this can false-positive (a legitimately
125
+ # inherited helper looks identical to a hallucinated one from a single
126
+ # file's contents) — declining a fix that would have been fine is the
127
+ # safe direction to err in; writing one that crashes at runtime is not.
128
+ def introduces_undefined_params_helper?(original_line, after_snippet, full_source)
129
+ original_names = original_line.to_s.scan(PARAMS_HELPER_NAME).flatten
130
+ new_names = after_snippet.to_s.scan(PARAMS_HELPER_NAME).flatten.uniq - original_names
131
+
132
+ new_names.any? { |name| !full_source.match?(/\bdef\s+#{Regexp.escape(name)}\b/) }
133
+ end
134
+
135
+ def extract_after_snippet(suggested_fix)
136
+ match = AFTER_BLOCK.match(suggested_fix.to_s)
137
+ return nil unless match
138
+
139
+ content = match[1].to_s
140
+ content.strip.empty? ? nil : content
141
+ end
142
+
143
+ # An AI reply's AFTER: block is asked for "a drop-in replacement for the
144
+ # single offending line" — in practice, models frequently reply with
145
+ # that replacement flush against the left margin, dropping the original
146
+ # line's indentation entirely (confirmed against a real AI-generated fix
147
+ # in production). Ruby doesn't care, so this was never a *correctness*
148
+ # bug, but a fix that silently de-indents a line looks nothing like what
149
+ # a developer would actually commit. Restores the original line's
150
+ # leading whitespace onto the replacement's first line specifically —
151
+ # only when the replacement doesn't already start with any indentation
152
+ # of its own, so a reply that already got it right (or a multi-line
153
+ # reply whose later lines carry their own deliberate relative indent)
154
+ # is left alone.
155
+ def apply_line_replacement(lines, line_number, replacement)
156
+ original_indent = lines[line_number - 1].to_s[/\A[ \t]*/]
157
+ replacement_lines = replacement.lines
158
+ if replacement_lines.first && replacement_lines.first !~ /\A[ \t]/
159
+ replacement_lines[0] = "#{original_indent}#{replacement_lines[0]}"
160
+ end
161
+
162
+ replacement_text = replacement_lines.join
163
+ replacement_text = "#{replacement_text}\n" unless replacement_text.end_with?("\n")
164
+ modified = lines.dup
165
+ modified[line_number - 1] = replacement_text
166
+ modified.join
167
+ end
168
+ end
169
+ end