The real difference between the two engineering approaches Rails security tools use, with concrete Ruby examples of where each gets it right and where each gets it wrong.
Taint/data-flow analysis tracks a value's origin and asks whether it can reach a dangerous
operation through the actual paths the program executes. Concretely, for Rails: it marks
params (and anything derived from cookies, headers, or the database) as
"tainted," then traces that taint through assignments, method calls, and even across files —
does this tainted value eventually arrive, unsanitized, at a SQL query, a shell command, an
eval, a redirect? If a sanitizing step (an escape, a whitelist check, a type
coercion) sits on that path, the taint is cleared and no finding fires.
This is what makes it precise: a data-flow tool can tell the difference between these two, even though they look almost identical at the call site:
# NOT flagged by a taint analyzer — the value is sanitized before it arrives
status = params[:status].to_s
raise ArgumentError unless %w[open closed].include?(status)
Order.where("status = ?", status) # already parameterized AND validated
# Flagged — tainted value reaches the sink with no sanitization on this path
Order.where("status = '#{params[:status]}'")
Building this requires understanding Rails' actual call graph (which methods call which,
across files, including framework internals like before_action filters) — real
engineering investment, which is exactly why Brakeman, built specifically for this, is more
mature at it than any general-purpose heuristic scanner will be.
A heuristic scanner skips the call-graph tracing entirely and asks a narrower, syntactic
question: does a dangerous-shaped call and a reference to untrusted input appear together
in the same expression? Scryer's sql_injection rule, concretely: walk the
parsed syntax tree for a call to where/find_by/order/etc.
whose first argument is a string literal containing #{...} interpolation. No
tracing of where the interpolated value came from — just "this shape, right here, is risky."
The tradeoff is exactly what you'd expect: faster (no call-graph construction, no framework boot), simpler to build and audit, but it can't tell sanitized-and-safe apart from tainted-and-dangerous when they don't differ syntactically:
# Flagged by both approaches — genuinely risky
User.where("name = '#{params[:name]}'")
# Flagged by heuristic pattern matching, but a taint analyzer would clear it —
# the value never actually reaches user input
ALLOWED_STATUSES = %w[open closed]
Order.where("status = '#{ALLOWED_STATUSES.sample}'")
That second example is exactly why every Scryer finding says "review this," never "this is
definitely a bug" — and why idor, the check with the least reliable signal in the
gem (an ID from params reaching Model.find, without being able to
trace whether a class-level before_action elsewhere already gates it), carries
that caveat most heavily. See the
full comparison footnotes for the specific precision gap on each harder check.
The precision gap matters most on checks that need to reason about a value's actual origin. It matters much less — heuristic and data-flow approaches converge — on checks that are really about a syntactic shape or a configuration state, independent of any specific tainted value:
config.force_ssl = false,
cookies_serializer = :marshal, a session store missing secure: true.
There's no data flow to trace; the line either sets the dangerous value or it doesn't.skip_before_action naming a known auth filter, a write action with no visible
authorization call anywhere in the class. Structural, not flow-dependent.Marshal.load on anything, JWT.decode
with signature verification disabled, http_basic_authenticate_with with a string
literal password. The call shape alone is the finding.
This is also exactly the set of rules Scryer's fix mode can
resolve automatically without an LLM — see Scryer::MechanicalFixer in the README.
Rules that genuinely need data-flow reasoning (mass_assignment's correct
.permit list, idor's correct authorization check) don't get a
mechanical fixer, because there isn't one correct answer a syntax-level tool can derive.
Not an either/or in practice — they answer different questions and cost different amounts to run:
| Taint/data-flow (Brakeman) | Heuristic (Scryer) | |
|---|---|---|
| Precision on hard checks (IDOR, auth) | Higher | Lower — "review this," not "confirmed" |
| Needs Rails/framework boot | Yes | No |
| Also covers performance/dependencies | No | Yes, same pass |
| Cross-category severity ranking | No | Yes |
| Maturity on Rails-specific security | Years, purpose-built | Newer, broader scope |
Running both costs nothing but CI minutes — different scanning strategies, no shared state, no conflict. Brakeman for the deepest security-only analysis; Scryer for the cross-category "what's actually most worth fixing this week" view across security, performance, dependencies, and code quality.