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
-
.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).
-
.call_name(node) ⇒ Object
Matches a
.method_name(...)or baremethod_name(...)call node. -
.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. - .closing_interpolation_braces(line, from_col, node) ⇒ Object
-
.each_node(node, &block) ⇒ Object
Depth-first walk of every node in the tree.
-
.exact_source_text(source, node) ⇒ Object
Column-precise source text for exactly what
nodespans — unlike source_text, doesn't pull in the rest of the line. - .false_literal?(node) ⇒ Boolean
- .ident_text(node) ⇒ Object
-
.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. - .kw_literal?(node) ⇒ Boolean
- .line_of(node) ⇒ Object
-
.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).
-
.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. -
.normalized_tokens(node) ⇒ Object
Walks every terminal token-bearing node inside
nodeand 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). -
.path_to_last_token(node) ⇒ Object
The ancestor chain (node itself first) from
nodedown to whichever descendant holds the last terminal token found by depth-first order — i.e. -
.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.
-
.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.
-
.references_params?(node) ⇒ Boolean
True if
nodeisparams,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. -
.source_line(source, line_number) ⇒ Object
Best-effort source-line snippet (1-indexed line number) for display in a finding.
-
.source_text(source, node) ⇒ Object
Best-effort source text spanning every line
nodetouches — used for duplicate-detection snippets (query chains, cache keys/values) where we want the literal source rather than a reconstructed one. -
.string_literal_has_interpolation?(node) ⇒ Boolean
True if a [:string_literal, [:string_content, ...]] node contains any interpolation (:string_embexpr / :string_dvar children).
-
.tagged?(node, *tags) ⇒ Boolean
True if
nodeis a tagged sexp node (e.g. [:def, ...]) whose tag is one oftags(symbols). -
.true_literal?(node) ⇒ Boolean
True if
nodeis the literaltrue/falsekeyword ([:var_ref, [:@kw, "true"|"false", pos]]). - .unwrap_args(node) ⇒ Object
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.sexp —
Api::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
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
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.
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).
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).
34 35 36 |
# File 'lib/scryer/ast.rb', line 34 def tagged?(node, *) node.is_a?(Array) && node[0].is_a?(Symbol) && .include?(node[0]) end |
.true_literal?(node) ⇒ Boolean
True if node is the literal true/false keyword
([:var_ref, [:@kw, "true"|"false", pos]]).
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 |