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 fixsweep 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 somethingsuggestitself gates on (explicitly requesting the rule, e.g.--rule frozen_string_literal, is already informed consent, sosuggestalways 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
- .fix_boolean_flip(finding, root, pattern, explanation) ⇒ Object
- .fix_cookie_serializer(finding, root:) ⇒ Object
-
.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.
-
.fix_security_headers_disabled(finding, root:) ⇒ Object
Only the plain single-header
= valueassignment 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. -
.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. -
.fix_weak_session_cookie(finding, root:) ⇒ Object
Appends
secure: true(production-only) to the end of thesession_storeline — safe because this is a single command-call statement with no parens to worry about closing correctly. - .opt_in?(rule_id) ⇒ Boolean
- .suggest(finding, root: nil) ⇒ Object
- .supported?(rule_id) ⇒ Boolean
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 |
.fix_cookie_serializer(finding, root:) ⇒ Object
246 247 248 249 250 251 252 253 |
# File 'lib/scryer/mechanical_fixer.rb', line 246 def (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..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 |
.fix_weak_session_cookie(finding, root:) ⇒ Object
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 (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
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 (finding, root: root) when "weak_session_cookie" then (finding, root: root) when "security_headers_disabled" then fix_security_headers_disabled(finding, root: root) end end |
.supported?(rule_id) ⇒ Boolean
55 56 57 |
# File 'lib/scryer/mechanical_fixer.rb', line 55 def supported?(rule_id) SUPPORTED_RULES.include?(rule_id) end |