scryer 1.1.1 → 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.
@@ -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
@@ -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
@@ -28,23 +28,53 @@ module Scryer
28
28
  # original file isn't readable, or this isn't a rule-backed Finding to
29
29
  # begin with). nil is deliberately distinct from false: it means "we don't
30
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.
31
39
  module FixVerifier
32
40
  module_function
33
41
 
34
42
  AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
35
43
 
36
44
  def verify(finding:, root:)
37
- return nil unless finding.is_a?(Scryer::Finding)
38
- return nil unless finding.line && finding.rule_id
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
39
69
 
40
70
  after_snippet = extract_after_snippet(finding.suggested_fix)
41
- return nil unless after_snippet
71
+ return [nil, nil, nil] unless after_snippet
42
72
 
43
73
  abs_path = File.join(root, finding.file.to_s)
44
- return nil unless File.file?(abs_path)
74
+ return [nil, nil, nil] unless File.file?(abs_path)
45
75
 
46
76
  lines = File.read(abs_path).lines
47
- return nil unless finding.line.between?(1, lines.size)
77
+ return [nil, nil, nil] unless finding.line.between?(1, lines.size)
48
78
 
49
79
  modified_source = apply_line_replacement(lines, finding.line, after_snippet)
50
80
 
@@ -53,15 +83,53 @@ module Scryer
53
83
  rescue StandardError
54
84
  nil
55
85
  end
56
- return false if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
86
+ return [false, nil, nil] if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
57
87
 
58
88
  rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
59
- return nil unless rule_class
89
+ return [nil, nil, nil] unless rule_class
60
90
 
61
91
  remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
62
- remaining.none? { |f| f.rule_id == finding.rule_id }
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]
63
103
  rescue StandardError
64
- nil
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/) }
65
133
  end
66
134
 
67
135
  def extract_after_snippet(suggested_fix)
@@ -72,8 +140,27 @@ module Scryer
72
140
  content.strip.empty? ? nil : content
73
141
  end
74
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.
75
155
  def apply_line_replacement(lines, line_number, replacement)
76
- replacement_text = replacement.end_with?("\n") ? replacement : "#{replacement}\n"
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")
77
164
  modified = lines.dup
78
165
  modified[line_number - 1] = replacement_text
79
166
  modified.join