feat(rube): M3 gadget scanner - reflection-based sink discovery with reachability filter

Walks the live class graph and classifies auto-invoked methods along the
gated/ungated dispatch axis. Marshal calls respond_to? before invoking
marshal_load and _load, while hash, eql?, <=>, []= and to_s are dispatched
blind, so a class can be dead as a Marshal entry point and live as a #hash
entry point.

Rediscovers Gem::Requirement#marshal_load, Gem::Version#marshal_load and
Gem::Specification._load with source locations, from reflection alone, with no
class names hardcoded anywhere in the scanner.

A new control asserts the M1 parser and the M3 scanner agree. The parser reads
bytes off a payload, the scanner walks the class graph, neither consults the
other, and every sink the parser finds in a real Gem::Requirement payload is
one the scanner independently located. Two routes to the same fact.

Raw output was unusable at 163 ungated candidates dominated by to_s and hash,
which nearly every class defines. The reachability predicate from the research
narrows that to methods that are zero-arity AND touch instance state, cutting
163 to 12 and keeping 7.4 percent. Gated sinks are always reachable.

Two corrections found while building the predicate:

RubyVM::AbstractSyntaxTree.of does not work on Ruby 4.0. It raises
'cannot get AST for ISEQ compiled by prism' because prism is now the default
parser. Any tool reaching for that API on modern Ruby is broken. Replaced with
Prism, which ships as a default gem.

The literal reads-its-own-ivars predicate is too narrow. Gem::Requirement#hash
calls the requirements attr_reader rather than @requirements, so an instance
variable check reports false on a method that plainly operates on instance
state. Widened to instance variable reads OR implicit-self calls.

55 tests, 112 assertions, 0 failures across both suites.
This commit is contained in:
CarterPerez-dev 2026-07-26 09:57:03 -04:00
parent a4ca6760d1
commit b57e20d8fa
5 changed files with 449 additions and 0 deletions

View File

@ -13,6 +13,10 @@ default:
test:
{{run_ro}} ruby -Ilib -Itest test/marshal/parser_test.rb
{{run_ro}} ruby -Ilib -Itest test/scanner_test.rb
scan namespace="":
{{run_ro}} ruby -Ilib -e 'require "rube"; ns = "{{namespace}}"; r = Rube::Scanner.new(namespace: ns.empty? ? nil : ns).scan; puts "modules=#{r.scanned_modules} candidates=#{r.candidates.length} gated=#{r.gated.length} reachable=#{r.reachable.length}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }'
control:
{{run_ro}} ruby -Ilib -Itest test/control_check.rb

View File

@ -6,6 +6,7 @@ require_relative "rube/marshal/constants"
require_relative "rube/marshal/errors"
require_relative "rube/marshal/node"
require_relative "rube/marshal/parser"
require_relative "rube/scanner"
module Rube
end

View File

@ -0,0 +1,217 @@
# ©AngelaMos | 2026
# scanner.rb
module Rube
class Scanner
GATED_METHODS = %w[marshal_load _load_data].freeze
GATED_SINGLETON_METHODS = %w[_load].freeze
UNGATED_METHODS = %w[hash eql? == <=> []= to_s method_missing respond_to_missing? coerce].freeze
GATE_GATED = :gated
GATE_UNGATED = :ungated
PRISM_AVAILABLE = begin
require "prism"
true
rescue LoadError
false
end
LOCATION_SEPARATOR = ":"
UNKNOWN_LOCATION = nil
class Candidate
attr_reader :class_name, :method_name, :gate, :source_location, :arity
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:, touches_state:)
@class_name = class_name
@method_name = method_name
@gate = gate
@source_location = source_location
@arity = arity
@singleton = singleton
@touches_state = touches_state
end
def singleton?
@singleton
end
def gated?
gate == GATE_GATED
end
def zero_arity?
arity.zero?
end
def touches_state?
@touches_state
end
def reachable?
return true if gated?
zero_arity? && touches_state?
end
def to_s
"#{class_name}#{singleton? ? '.' : '#'}#{method_name}"
end
end
class Report
attr_reader :candidates, :scanned_modules
def initialize(candidates, scanned_modules)
@candidates = candidates
@scanned_modules = scanned_modules
end
def gated
candidates.select(&:gated?)
end
def ungated
candidates.reject(&:gated?)
end
def reachable
candidates.select(&:reachable?)
end
end
def initialize(namespace: nil)
@namespace = namespace
@candidates = []
@definition_cache = {}
@scanned_modules = 0
end
def scan
each_named_module do |mod, name|
@scanned_modules += 1
collect_instance_methods(mod, name)
collect_singleton_methods(mod, name)
end
Report.new(@candidates.sort_by(&:to_s), @scanned_modules)
end
private
attr_reader :namespace
def each_named_module
ObjectSpace.each_object(Module) do |mod|
name = safe_name(mod)
next unless name
next unless in_namespace?(name)
yield mod, name
end
end
def safe_name(mod)
name = mod.name
name if name.is_a?(String) && !name.empty?
rescue StandardError
nil
end
def in_namespace?(name)
namespace.nil? || name == namespace || name.start_with?("#{namespace}::")
end
def collect_instance_methods(mod, name)
own = own_instance_methods(mod)
(own & GATED_METHODS).each do |method_name|
record(mod, name, method_name, GATE_GATED, singleton: false)
end
(own & UNGATED_METHODS).each do |method_name|
record(mod, name, method_name, GATE_UNGATED, singleton: false)
end
end
def collect_singleton_methods(mod, name)
own = mod.singleton_methods(false).map(&:to_s)
(own & GATED_SINGLETON_METHODS).each do |method_name|
record(mod, name, method_name, GATE_GATED, singleton: true)
end
end
def own_instance_methods(mod)
(mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s)
rescue StandardError
[]
end
def record(mod, name, method_name, gate, singleton:)
handle = singleton ? mod.singleton_method(method_name) : mod.instance_method(method_name)
@candidates << Candidate.new(
class_name: name,
method_name: method_name,
gate: gate,
source_location: format_location(handle.source_location),
arity: handle.arity,
singleton: singleton,
touches_state: touches_state?(handle)
)
rescue StandardError, ScriptError
nil
end
def format_location(location)
return UNKNOWN_LOCATION unless location
location.join(LOCATION_SEPARATOR)
end
def touches_state?(handle)
return false unless PRISM_AVAILABLE
path, line = handle.source_location
return false unless path && line
node = definition_at(path, line)
return false unless node
node.compact_child_nodes.any? { |child| state_reference?(child) }
rescue StandardError, ScriptError
false
end
def definition_at(path, line)
definitions_for(path)[line]
end
def definitions_for(path)
@definition_cache[path] ||= begin
found = {}
collect_definitions(Prism.parse_file(path).value, found)
found
rescue StandardError, ScriptError
{}
end
end
def collect_definitions(node, found)
return unless node.is_a?(Prism::Node)
found[node.location.start_line] = node if node.is_a?(Prism::DefNode)
node.compact_child_nodes.each { |child| collect_definitions(child, found) }
end
def state_reference?(node)
return false unless node.is_a?(Prism::Node)
return true if node.is_a?(Prism::InstanceVariableReadNode)
return true if node.is_a?(Prism::CallNode) && node.receiver.nil?
node.compact_child_nodes.any? { |child| state_reference?(child) }
end
end
end

View File

@ -76,3 +76,32 @@ result = Rube::Marshal::Parser.new(payload).parse
puts " classes: #{result.class_names.inspect}"
puts " sinks: #{result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.inspect}"
puts " gated: #{result.gated_sinks.map(&:class_name).inspect}"
puts
puts "=== control 6: M1 stream parsing and M3 reflection must agree ==="
puts " Two independent routes to the same fact. The parser reads bytes off a"
puts " payload; the scanner walks the live class graph. Neither consults the"
puts " other. If they disagree, one of them is wrong."
from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort
scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s)
from_reflection = from_stream.select { |entry| scanned.include?(entry) }.sort
puts " parser (M1): #{from_stream.inspect}"
puts " scanner (M3): #{scanned.inspect}"
if from_stream == from_reflection && !from_stream.empty?
puts " AGREE - every sink the parser found in the payload is a sink the scanner"
puts " independently located in the class graph"
else
puts " DISAGREE - parser found #{(from_stream - from_reflection).inspect} that reflection did not"
end
puts
puts "=== control 7: scanner precision, it must not report everything ==="
full = Rube::Scanner.new.scan
ratio = (full.candidates.length.to_f / full.scanned_modules * 100).round(1)
puts " #{full.scanned_modules} modules scanned -> #{full.candidates.length} candidates (#{ratio}% hit rate)"
puts " gated: #{full.gated.length}"
puts(ratio < 100 ? " PASS - scanner discriminates" : " FAIL - scanner reports every module, it is not filtering")
puts
puts " NOTE: ObjectSpace only sees loaded code. #{full.scanned_modules} modules is a bare"
puts " Ruby with RubyGems. A booted Rails app eager-loaded is several times that."
puts " Coverage is bounded by what has been required, and that is a real limit."

View File

@ -0,0 +1,198 @@
# ©AngelaMos | 2026
# scanner_test.rb
require_relative "test_helper"
module Rube
class ScannerTest < Minitest::Test
def scan(**options)
Scanner.new(**options).scan
end
def local_scan
scan(namespace: "Rube::ScannerTest")
end
def candidates_for(class_name)
local_scan.candidates.select { |c| c.class_name == class_name }
end
def test_finds_gated_sink_defined_on_a_class
found = candidates_for("Rube::ScannerTest::GatedFixture")
assert_equal ["marshal_load"], found.map(&:method_name)
assert_equal :gated, found.first.gate
end
def test_finds_singleton_load_as_gated_sink
found = candidates_for("Rube::ScannerTest::UserDefFixture")
assert_includes found.map(&:method_name), "_load"
end
def test_finds_ungated_dispatch_method
found = candidates_for("Rube::ScannerTest::UngatedFixture")
assert_equal ["hash"], found.map(&:method_name)
assert_equal :ungated, found.first.gate
end
def test_finds_method_missing_as_ungated
found = candidates_for("Rube::ScannerTest::ProxyFixture")
assert_includes found.map(&:method_name), "method_missing"
end
def test_negative_control_class_with_no_auto_invoked_methods_is_not_reported
assert_empty candidates_for("Rube::ScannerTest::InertFixture")
end
def test_precision_control_inherited_methods_are_not_reported
assert_empty candidates_for("Rube::ScannerTest::InheritsOnlyFixture")
end
def test_candidates_carry_source_location
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
refute_nil candidate.source_location
assert_includes candidate.source_location, "scanner_test.rb"
end
def test_candidates_report_arity
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
assert_equal 1, candidate.arity
end
def test_ungated_methods_report_zero_arity
candidate = candidates_for("Rube::ScannerTest::UngatedFixture").first
assert_predicate candidate, :zero_arity?
end
def test_reachability_via_instance_variable_read
candidate = candidates_for("Rube::ScannerTest::StatefulFixture").first
assert_predicate candidate, :touches_state?
assert_predicate candidate, :reachable?
end
def test_reachability_via_implicit_self_call
candidate = candidates_for("Rube::ScannerTest::AccessorFixture").first
assert_predicate candidate, :touches_state?, "attr_reader access must count as touching state"
assert_predicate candidate, :reachable?
end
def test_negative_control_stateless_method_is_not_reachable
candidate = candidates_for("Rube::ScannerTest::StatelessFixture").first
refute_predicate candidate, :touches_state?
refute_predicate candidate, :reachable?
end
def test_gated_sinks_are_reachable_regardless_of_state
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
assert_predicate candidate, :reachable?
end
def test_reachable_is_a_strict_subset_of_candidates
report = scan(namespace: "Gem")
refute_empty report.reachable
assert_operator report.reachable.length, :<, report.candidates.length,
"reachability filter kept everything, so it is not filtering"
end
def test_rediscovers_accessor_backed_stdlib_sink
report = scan(namespace: "Gem")
names = report.reachable.map(&:to_s)
assert_includes names, "Gem::Requirement#hash"
end
def test_rediscovers_a_real_stdlib_sink_without_hardcoding
report = scan(namespace: "Gem")
names = report.gated.map { |c| "#{c.class_name}##{c.method_name}" }
assert_includes names, "Gem::Requirement#marshal_load"
refute_includes Scanner::GATED_METHODS + Scanner::UNGATED_METHODS, "Gem::Requirement"
end
def test_namespace_filter_excludes_everything_else
report = scan(namespace: "Rube::ScannerTest")
assert(report.candidates.all? { |c| c.class_name.start_with?("Rube::ScannerTest") })
end
def test_report_partitions_gated_and_ungated
report = local_scan
assert_equal report.candidates.length, report.gated.length + report.ungated.length
assert_empty(report.gated & report.ungated)
end
def test_scan_is_deterministic
first = local_scan.candidates.map(&:to_s).sort
second = local_scan.candidates.map(&:to_s).sort
assert_equal first, second
end
def test_anonymous_classes_are_skipped
Class.new { def marshal_load(data); end }
refute(local_scan.candidates.any? { |c| c.class_name.nil? || c.class_name.empty? })
end
def test_scanning_does_not_instantiate_anything
refute GatedFixture.instantiated
local_scan
refute GatedFixture.instantiated, "scanner constructed a candidate class"
end
class GatedFixture
@instantiated = false
class << self
attr_accessor :instantiated
end
def marshal_load(data); end
end
class UserDefFixture
def self._load(data)
allocate
end
end
class UngatedFixture
def hash
super
end
end
class ProxyFixture
def method_missing(name, *args)
super
end
def respond_to_missing?(name, include_private = false)
super
end
end
class StatefulFixture
def hash
@seed.to_i
end
end
class AccessorFixture
attr_reader :seed
def hash
seed.to_i
end
end
class StatelessFixture
def hash
42
end
end
class InertFixture
def ordinary_method; end
def another_one(argument); end
end
class InheritsOnlyFixture
end
end
end