Module: Scryer::Ast

Defined in:
lib/scryer/ast.rb

Overview

Small set of helpers for walking the S-expression tree that Ripper.sexp produces. We deliberately don't depend on the parser/RuboCop::AST gems so this gem has zero runtime dependencies beyond Ruby's own stdlib — Ripper has shipped with Ruby since 1.9.

A Ripper sexp node is either a plain Ruby object (String/Integer/nil/false) or an Array whose first element is a Symbol tag (:def, :call, :string_literal, etc.) followed by child nodes. Terminal "token" nodes look like [:@ident, "foo", [line, col]] — the trailing [line, col] pair is what lets us report accurate line numbers for findings.

Class Method Summary collapse

Class Method Details

.call_arguments(node) ⇒ Object

Given a [:method_add_arg, call_node, args_node] or [:command, ident, args_node] node, return the flattened list of top-level argument sexp nodes (best effort — walks through the [:arg_paren, [:args_add_block, [args...], block]] wrapping).



123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/scryer/ast.rb', line 123

def call_arguments(node)
  return [] unless node.is_a?(Array)

  args_node =
    case node[0]
    when :method_add_arg then node[2]
    when :command then node[2]
    when :command_call then node[4]
    end

  unwrap_args(args_node)
end

.call_name(node) ⇒ Object

Matches a .method_name(...) or bare method_name(...) call node. Returns the receiver node (nil for a bare/vcall) and the method name string if node is a call to one of method_names, else nil.

Handles the shapes Ripper produces for a called method:

[:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]]        (has a receiver, parens or no args)
[:vcall, [:@ident, "name", pos]]                                       (bare, no args)
[:fcall, [:@ident, "name", pos]]                                       (bare + parens, no receiver)
[:command, [:@ident, "name", pos], args]                               (bare, with args, no parens)
[:command_call, receiver, [:@period,...], [:@ident, "name", pos], args] (receiver + args, no parens —
                                                                       e.g. `config.session_store :x, y: z`)
[:method_add_arg, call_or_fcall_node, args_node]                       (receiver/bare + parens, wraps one of the above)


75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/scryer/ast.rb', line 75

def call_name(node)
  case node
  when ->(n) { tagged?(n, :call, :command_call) }
    [node[1], ident_text(node[3])]
  when ->(n) { tagged?(n, :vcall) }
    [nil, ident_text(node[1])]
  when ->(n) { tagged?(n, :fcall) }
    [nil, ident_text(node[1])]
  when ->(n) { tagged?(n, :command) }
    [nil, ident_text(node[1])]
  end
end

.class_name(node) ⇒ Object

The full dotted name from a class/module's own name node — the second element of a [:class, name_node, superclass, body] sexp. A plain class Foo parses name_node as [:const_ref, [:@const, "Foo", pos]]; a namespaced class Admin::PostsController parses it as a :const_path_ref chain instead (verified via Ripper.sexpApi::V1::UsersController nests two levels deep: [:const_path_ref, [:const_path_ref, [:var_ref, [:@const,"Api"]], [:@const,"V1"]], [:@const,"UsersController"]]). Several rules used to check only the :const_ref shape (node[1][1] via ident_text), so a namespaced controller's class name silently came back nil and the whole class was never examined — a real false-negative gap, not a minor edge case, given how common namespacing (admin areas, API versions) is in real Rails apps. Returns e.g. "Admin::PostsController" so a plain .end_with?("Controller") check still works the same either way.



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

def class_name(node)
  if tagged?(node, :const_ref)
    ident_text(node[1])
  elsif tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
    node[1][1]
  elsif tagged?(node, :const_path_ref)
    left = class_name(node[1])
    right = ident_text(node[2])
    [left, right].compact.join("::")
  end
end

.closing_interpolation_braces(line, from_col, node) ⇒ Object



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/scryer/ast.rb', line 261

def closing_interpolation_braces(line, from_col, node)
  return "" unless line

  path = path_to_last_token(node)
  count = path ? path.count { |n| tagged?(n, :string_embexpr, :string_dvar) } : 0
  return "" if count.zero?

  cursor = from_col
  result = +""
  count.times do
    break unless line[cursor] == "}"

    result << "}"
    cursor += 1
  end
  result
end

.each_node(node, &block) ⇒ Object

Depth-first walk of every node in the tree. Yields each node (both tagged Array nodes and plain values) to the block. This is intentionally simple/generic rather than type-specific, so new rules can filter for whatever node shape they care about.



21
22
23
24
25
26
27
28
29
30
# File 'lib/scryer/ast.rb', line 21

def each_node(node, &block)
  return enum_for(:each_node, node) unless block

  block.call(node)
  return unless node.is_a?(Array)

  node.each do |child|
    each_node(child, &block) if child.is_a?(Array)
  end
end

.exact_source_text(source, node) ⇒ Object

Column-precise source text for exactly what node spans — unlike source_text, doesn't pull in the rest of the line. Needed for e.g. a cache key expression that shares a line with the surrounding assignment/call (x = Rails.cache.fetch("key_#{id}") { ... } unless x) — source_text would return that whole statement, not just the key. Built from the node's first/last terminal tokens (assumed to appear in source order, which holds for the expressions this is used on); nil if the node has no terminal tokens or its positions don't fit the source.

Ripper.sexp doesn't emit a terminal token for a string interpolation's closing } — if the interpolation is the last thing in the string ("foo_#{id}"), the raw span above ends right after id, one } short of the true end. closing_interpolation_braces counts how many string_embexpr/string_dvar wrappers actually contain that last token (usually 0 or 1, more if nested) and appends exactly that many } — only when the source really has one there, never guessed blindly.



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

def exact_source_text(source, node)
  tokens = each_node(node).select do |n|
    n.is_a?(Array) && n[0].is_a?(Symbol) && n[0].to_s.start_with?("@") &&
      n[2].is_a?(Array) && n[2].size == 2
  end
  return nil if tokens.empty?

  lines = source.lines
  start_line, start_col = tokens.first[2]
  end_line, end_col = tokens.last[2]
  end_col += tokens.last[1].to_s.length
  return nil if start_line.nil? || end_line.nil? || end_line > lines.size

  text =
    if start_line == end_line
      lines[start_line - 1][start_col...end_col]
    else
      ([lines[start_line - 1][start_col..]] +
        lines[start_line...(end_line - 1)] +
        [lines[end_line - 1][0...end_col]]).join
    end
  return nil unless text

  text + closing_interpolation_braces(lines[end_line - 1], end_col, node)
end

.false_literal?(node) ⇒ Boolean

Returns:

  • (Boolean)


315
316
317
# File 'lib/scryer/ast.rb', line 315

def false_literal?(node)
  kw_literal?(node) == "false"
end

.ident_text(node) ⇒ Object



88
89
90
91
92
# File 'lib/scryer/ast.rb', line 88

def ident_text(node)
  return nil unless node.is_a?(Array)

  node[1] if node[0].is_a?(Symbol) && %i[@ident @const @kw @op].include?(node[0])
end

.keyword_arg(args, label) ⇒ Object

Given a list of top-level argument nodes (from call_arguments), finds the bare_assoc_hash entry whose label matches label: and returns its value node, or nil if that keyword argument isn't present. Covers Rails DSL calls like session_store :cookie_store, secure: true or http_basic_authenticate_with password: "x", where keyword args arrive as a bare_assoc_hash rather than a real Hash literal.



285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/scryer/ast.rb', line 285

def keyword_arg(args, label)
  target = "#{label}:"

  args.each do |arg|
    next unless tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)

    pair = arg[1].find { |p| tagged?(p, :assoc_new) && p[1].is_a?(Array) && p[1][0] == :@label && p[1][1] == target }
    return pair[2] if pair
  end

  nil
end

.kw_literal?(node) ⇒ Boolean

Returns:

  • (Boolean)


319
320
321
322
323
# File 'lib/scryer/ast.rb', line 319

def kw_literal?(node)
  return nil unless tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@kw

  node[1][1]
end

.line_of(node) ⇒ Object



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

def line_of(node)
  position_of(node)&.first
end

.line_range_of(node) ⇒ Object

The [start_line, end_line] a node spans, found by scanning its descendants for position-bearing terminal tokens (nodes themselves don't carry an explicit end line — see MethodExtractor's comment). Returns nil if the node has no position-bearing descendants at all.



177
178
179
180
181
182
183
# File 'lib/scryer/ast.rb', line 177

def line_range_of(node)
  positions = each_node(node).filter_map { |n| position_of(n) }
  return nil if positions.empty?

  lines = positions.map(&:first)
  [lines.min, lines.max]
end

.literal_text(node) ⇒ Object

The literal text of a symbol_literal (:inline) or plain string_literal ("inline") node — the two shapes a Rails DSL keyword argument's value commonly takes. nil for anything else (interpolated string, array, boolean, ...).



302
303
304
305
306
307
# File 'lib/scryer/ast.rb', line 302

def literal_text(node)
  return plain_string_value(node) if tagged?(node, :string_literal)
  return nil unless tagged?(node, :symbol_literal) && node[1].is_a?(Array)

  ident_text(node[1][1]) if tagged?(node[1], :symbol)
end

.normalized_tokens(node) ⇒ Object

Walks every terminal token-bearing node inside node and maps it to a normalized symbol: identifiers/literals become placeholders (so renamed variables/changed literal values still count as "the same" shape), keywords/operators/punctuation stay literal (so the actual control-flow shape of the code still has to match for two nodes to look similar). Shared by MethodExtractor, QueryExtractor, and CacheExtractor — anything that needs to compare two code fragments for near-duplication.



346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/scryer/ast.rb', line 346

def normalized_tokens(node)
  each_node(node).filter_map do |n|
    next unless n.is_a?(Array) && n[0].is_a?(Symbol) && n[0].to_s.start_with?("@")

    case n[0]
    when :@ident, :@const, :@ivar, :@gvar, :@cvar, :@label
      :ID
    when :@int, :@float, :@CHAR
      :LIT_NUM
    when :@tstring_content
      :LIT_STR
    when :@kw
      n[1].to_sym # if/else/end/def/do/while/... — structurally meaningful
    when :@op, :@period, :@comma, :@lbracket, :@rbracket, :@lparen, :@rparen,
         :@lbrace, :@rbrace, :@semicolon
      n[1].to_sym
    end
  end
end

.path_to_last_token(node) ⇒ Object

The ancestor chain (node itself first) from node down to whichever descendant holds the last terminal token found by depth-first order — i.e. mirrors each_node's traversal, but keeps the path instead of just the leaf, so callers can inspect what wraps that last token.



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/scryer/ast.rb', line 243

def path_to_last_token(node)
  return nil unless node.is_a?(Array)

  if node[0].is_a?(Symbol) && node[0].to_s.start_with?("@") && node[2].is_a?(Array) && node[2].size == 2
    return [node]
  end

  last_path = nil
  node.each do |child|
    next unless child.is_a?(Array)

    sub = path_to_last_token(child)
    last_path = sub if sub
  end

  last_path && [node] + last_path
end

.plain_string_value(node) ⇒ Object

Extracts the literal text of a plain (non-interpolated) string_literal, or nil if it has interpolation or isn't a string literal at all.



156
157
158
159
160
161
162
163
164
# File 'lib/scryer/ast.rb', line 156

def plain_string_value(node)
  return nil if string_literal_has_interpolation?(node)
  return nil unless tagged?(node, :string_literal)

  content = node[1]
  return nil unless tagged?(content, :string_content)

  content[1..].map { |part| part.is_a?(Array) && part[0] == :@tstring_content ? part[1] : nil }.compact.join
end

.position_of(node) ⇒ Object

Extract the [line, col] position from a node, searching its descendants for the first terminal token if the node itself isn't one. Returns nil if no position info can be found (shouldn't normally happen).



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/scryer/ast.rb', line 41

def position_of(node)
  return nil unless node.is_a?(Array)

  # Terminal tokens look like [:@ident, "text", [line, col]]
  if node[0].is_a?(Symbol) && node[0].to_s.start_with?("@") && node[2].is_a?(Array) && node[2].size == 2
    return node[2]
  end

  node.each do |child|
    next unless child.is_a?(Array)

    pos = position_of(child)
    return pos if pos
  end

  nil
end

.references_params?(node) ⇒ Boolean

True if node is params, params[:x], or contains such a reference anywhere in its subtree — the shared "does this touch raw request data" check behind mass assignment, IDOR, SSRF, and path traversal detection. Says nothing about whether that reference is guarded (.permit, sanitization, an allowlist, ...) — callers that care about a specific guard (like MassAssignmentRule's .permit) check for it separately.

Returns:

  • (Boolean)


331
332
333
334
335
336
337
# File 'lib/scryer/ast.rb', line 331

def references_params?(node)
  return false unless node.is_a?(Array)

  each_node(node).any? do |n|
    tagged?(n, :vcall, :var_ref, :fcall) && ident_text(n[1]) == "params"
  end
end

.source_line(source, line_number) ⇒ Object

Best-effort source-line snippet (1-indexed line number) for display in a finding.



167
168
169
170
171
# File 'lib/scryer/ast.rb', line 167

def source_line(source, line_number)
  return nil unless line_number

  source.lines[line_number - 1]&.strip
end

.source_text(source, node) ⇒ Object

Best-effort source text spanning every line node touches — used for duplicate-detection snippets (query chains, cache keys/values) where we want the literal source rather than a reconstructed one. Whole-line granularity: fine for a display snippet, but too coarse when the exact boundary matters (see exact_source_text below).



190
191
192
193
194
195
# File 'lib/scryer/ast.rb', line 190

def source_text(source, node)
  start_line, end_line = line_range_of(node)
  return nil unless start_line

  source.lines[(start_line - 1)...end_line]&.join
end

.string_literal_has_interpolation?(node) ⇒ Boolean

True if a [:string_literal, [:string_content, ...]] node contains any interpolation (:string_embexpr / :string_dvar children).

Returns:

  • (Boolean)


148
149
150
151
152
# File 'lib/scryer/ast.rb', line 148

def string_literal_has_interpolation?(node)
  return false unless tagged?(node, :string_literal, :xstring_literal, :dyna_symbol)

  each_node(node).any? { |n| tagged?(n, :string_embexpr, :string_dvar) }
end

.tagged?(node, *tags) ⇒ Boolean

True if node is a tagged sexp node (e.g. [:def, ...]) whose tag is one of tags (symbols).

Returns:

  • (Boolean)


34
35
36
# File 'lib/scryer/ast.rb', line 34

def tagged?(node, *tags)
  node.is_a?(Array) && node[0].is_a?(Symbol) && tags.include?(node[0])
end

.true_literal?(node) ⇒ Boolean

True if node is the literal true/false keyword ([:var_ref, [:@kw, "true"|"false", pos]]).

Returns:

  • (Boolean)


311
312
313
# File 'lib/scryer/ast.rb', line 311

def true_literal?(node)
  kw_literal?(node) == "true"
end

.unwrap_args(node) ⇒ Object



136
137
138
139
140
141
142
143
144
# File 'lib/scryer/ast.rb', line 136

def unwrap_args(node)
  return [] unless node.is_a?(Array)

  inner = tagged?(node, :arg_paren) ? node[1] : node
  return [] unless tagged?(inner, :args_add_block)

  list = inner[1]
  list.is_a?(Array) ? list : []
end