Module: Scryer::MechanicalFixer

Defined in:
lib/scryer/mechanical_fixer.rb

Overview

Deterministic, no-AI fixes for the narrow set of rules where the correct rewrite doesn't require judgment — there's exactly one sane answer every time, so there's nothing for an LLM to decide. Everything else (mass_assignment, idor, missing_authorization, csrf, ...) still needs an ai_client or a human, because the correct fix depends on things Scryer can't know statically (which params to permit, which policy to call).

Produces the exact same "explanation + AFTER: fenced block" shape AiFixSuggester's prompt asks a real model for (see FixVerifier's AFTER_BLOCK regex) — so a mechanical fix flows through the identical verify/apply pipeline as an AI one; nothing here is trusted more than an LLM's guess would be. If a specific line doesn't match the exact shape a fixer here knows how to rewrite, suggest returns nil and the finding falls through to the ai_client (if configured) or manual review, same as any other unsupported case — this never guesses.

Constant Summary collapse

SUPPORTED_RULES =
%w[
  frozen_string_literal
  sql_injection
  force_ssl_disabled
  insecure_cookie_serializer
  weak_session_cookie
  security_headers_disabled
].freeze
OPT_IN_RULES =

frozen_string_literal is mechanically fixable but, unlike the others, deliberately opt-in — a project-wide scryer fix sweep would otherwise touch nearly every file for a cosmetic, info-severity finding. See CLI#run_fix / ScryerTasks — this list is what those callers use to decide whether to ask before including a rule in an unscoped run, not something suggest itself gates on (explicitly requesting the rule, e.g. --rule frozen_string_literal, is already informed consent, so suggest always tries it).

%w[frozen_string_literal].freeze
MUTATING_METHODS =

Bang-methods and operators that mutate their receiver in place — used to detect whether freezing a file's string literals could actually break it (see fix_frozen_string_literal). Not exhaustive (this is a heuristic, not data-flow analysis), but covers the realistic cases.

%w[
  concat replace insert clear prepend
  upcase! downcase! capitalize! swapcase!
  strip! lstrip! rstrip! chomp! chop! squeeze!
  gsub! sub! slice! delete! tr! tr_s! succ! next!
  reverse! encode! force_encoding
].freeze

Class Method Summary collapse

Class Method Details

.fix_boolean_flip(finding, root, pattern, explanation) ⇒ Object



239
240
241
242
243
244
# File 'lib/scryer/mechanical_fixer.rb', line 239

def fix_boolean_flip(finding, root, pattern, explanation)
  line = raw_line(finding, root)
  return nil unless pattern.match?(line)

  wrap_after(explanation, line.sub(pattern, '\1true'))
end


246
247
248
249
250
251
252
253
# File 'lib/scryer/mechanical_fixer.rb', line 246

def fix_cookie_serializer(finding, root:)
  line = raw_line(finding, root)
  pattern = /(cookies_serializer\s*=\s*)(:marshal|["']marshal["'])/
  return nil unless pattern.match?(line)

  code = line.sub(pattern, '\1:json')
  wrap_after("Switches the cookie serializer from `:marshal` to Rails' safe default, `:json`.", code)
end

.fix_frozen_string_literal(finding, root:) ⇒ Object

A magic comment is recognized by Ruby only on the very first source line, or the second if the first is a shebang — so prepending it (or inserting it right after a shebang) is always the correct rewrite. But "correct" isn't the same as "safe": freezing every string literal in the file breaks anything that mutates one in place (str << x, str.gsub!(...), ...) at runtime with a FrozenError — something the frozen_string_literal rule itself has no way to see (it only checks for the magic comment's absence). Declines (nil) whenever the file can't be read/analyzed, or analysis finds a plausible in-place mutation — "analyse and fix only if no issue will arise from it".



107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/scryer/mechanical_fixer.rb', line 107

def fix_frozen_string_literal(finding, root:)
  source = read_source(finding, root)
  return nil if source.nil? || mutates_a_string_literal?(source)

  first_line = source.lines.first.to_s.chomp
  code = if first_line.start_with?("#!")
           "#{first_line}\n# frozen_string_literal: true\n"
         else
           "# frozen_string_literal: true\n\n#{first_line}"
         end
  wrap_after("Adds the `# frozen_string_literal: true` magic comment as the first line of the file — no in-place string mutation was found, so freezing literals here is safe.", code)
end

.fix_security_headers_disabled(finding, root:) ⇒ Object

Only the plain single-header = value assignment shape (see SecurityHeadersRule) — a .merge!(...) call can disable several headers in one statement, only one of which may be the actual finding, so removing the whole line there could silently take out an unrelated, legitimate header too. Comments the line out (rather than deleting it outright) so there's a visible trace of what changed — keeping the original leading indentation so the comment lines up with its surrounding code instead of jumping to column 0.



274
275
276
277
278
279
280
281
# File 'lib/scryer/mechanical_fixer.rb', line 274

def fix_security_headers_disabled(finding, root:)
  line = raw_line(finding, root)
  return nil if line.include?("merge!") || !line.include?("=")

  indent = line[/\A[ \t]*/]
  code = "#{indent}# #{line.strip} # removed by `scryer fix` — restores Rails' default security header"
  wrap_after("Comments out the line disabling this security header, restoring Rails' safe default.", code)
end

.fix_sql_injection(finding, root:) ⇒ Object

Only handles the unambiguous case: the interpolated string is the SOLE argument to the flagged call (immediately preceded by ( and immediately followed by ) on the same physical line) — anything else (an existing second argument, a multi-line call) is left alone rather than guessed at, since inserting a new bind parameter at the right spot in an arbitrary chained/multi-arg call isn't a one-answer problem. Quote characters directly hugging a #{...} (the common "id = '#{x}'" manual-SQL-quoting style) are consumed along with it — leaving them in place would produce '?', which double-quotes the bound value and silently breaks the query while still looking "verified" (Scryer's own check only looks for interpolation, not query correctness).



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/scryer/mechanical_fixer.rb', line 218

def fix_sql_injection(finding, root:)
  method = finding.message.to_s[/\A`(\w+)`/, 1]
  return nil unless method

  line = raw_line(finding, root)
  m = line.match(/\A(?<pre>.*\b#{Regexp.escape(method)}\s*\(\s*)"(?<body>(?:[^"\\]|\\.)*)"\s*\)(?<rest>.*)\z/)
  return nil unless m

  exprs = []
  new_body = m[:body].gsub(/(\\"|')?#\{([^{}]*)\}(\\"|')?/) do
    exprs << Regexp.last_match(2).strip
    "?"
  end
  return nil if exprs.empty? || new_body.include?("\#{")

  code = "#{m[:pre]}\"#{new_body}\", #{exprs.join(", ")})#{m[:rest]}"
  explanation = "Replaces the string interpolation inside the SQL string with #{exprs.size > 1 ? 'bind parameters' : 'a `?` bind parameter'}, " \
                "so the value#{exprs.size > 1 ? 's are' : ' is'} always sent as a query parameter rather than parsed as SQL text."
  wrap_after(explanation, code)
end

Appends secure: true (production-only) to the end of the session_store line — safe because this is a single command-call statement with no parens to worry about closing correctly.



258
259
260
261
262
263
264
# File 'lib/scryer/mechanical_fixer.rb', line 258

def fix_weak_session_cookie(finding, root:)
  line = raw_line(finding, root)
  return nil if line.strip.empty?

  code = "#{line.chomp}, secure: Rails.env.production?"
  wrap_after("Adds `secure: true` (production only) to the session cookie options, so it's never sent over plain HTTP.", code)
end

.opt_in?(rule_id) ⇒ Boolean

Returns:

  • (Boolean)


59
60
61
# File 'lib/scryer/mechanical_fixer.rb', line 59

def opt_in?(rule_id)
  OPT_IN_RULES.include?(rule_id)
end

.suggest(finding, root: nil) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/scryer/mechanical_fixer.rb', line 63

def suggest(finding, root: nil)
  return nil unless finding.is_a?(Scryer::Finding)

  case finding.rule_id
  when "frozen_string_literal" then fix_frozen_string_literal(finding, root: root)
  when "sql_injection" then fix_sql_injection(finding, root: root)
  when "force_ssl_disabled" then fix_boolean_flip(finding, root, /(\bforce_ssl\s*=\s*)false\b/, "Flips `force_ssl` to `true`, restoring Rails' HTTPS enforcement.")
  when "insecure_cookie_serializer" then fix_cookie_serializer(finding, root: root)
  when "weak_session_cookie" then fix_weak_session_cookie(finding, root: root)
  when "security_headers_disabled" then fix_security_headers_disabled(finding, root: root)
  end
end

.supported?(rule_id) ⇒ Boolean

Returns:

  • (Boolean)


55
56
57
# File 'lib/scryer/mechanical_fixer.rb', line 55

def supported?(rule_id)
  SUPPORTED_RULES.include?(rule_id)
end