diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index cfdd464c..38e0a41e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb index 337b48c6..2c889a9a 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb new file mode 100644 index 00000000..616e20cc --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb index 409e3f41..2eddbd71 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb @@ -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." diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb new file mode 100644 index 00000000..701250f8 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb @@ -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