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:
parent
a4ca6760d1
commit
b57e20d8fa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue