Cybersecurity-Projects/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb

561 lines
19 KiB
Ruby

# ©AngelaMos | 2026
# scanner_test.rb
# frozen_string_literal: true
require_relative "test_helper"
require "tmpdir"
module Marshalsea
class ScannerTest < Minitest::Test
SUPPRESSION_NAMESPACE = "Marshalsea::ScannerSuppressionFixture"
MISSING_SOURCE_PATH = "/nonexistent/marshalsea-scanner-fixture.rb"
Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1)
module Marshalsea
module ScannerSuppressionFixture
class VanishedSource
def hash
@seed.to_i
end
def to_s
"vanished"
end
end
end
end
SOURCE
def scan(**)
Scanner.new(**).scan
end
def with_exploding(*fixtures)
fixtures.each { |fixture| fixture.explode = true }
yield
ensure
fixtures.each { |fixture| fixture.explode = false }
end
def suppressions_at(report, site)
report.suppressions.select { |suppression| suppression.site == site }
end
def local_scan
scan(namespace: "Marshalsea::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("Marshalsea::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("Marshalsea::ScannerTest::UserDefFixture")
assert_includes found.map(&:method_name), "_load"
end
def test_finds_ungated_dispatch_method
found = candidates_for("Marshalsea::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("Marshalsea::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("Marshalsea::ScannerTest::InertFixture")
end
def test_precision_control_inherited_methods_are_not_reported
assert_empty candidates_for("Marshalsea::ScannerTest::InheritsOnlyFixture")
end
def test_candidates_carry_source_location
candidate = candidates_for("Marshalsea::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("Marshalsea::ScannerTest::GatedFixture").first
assert_equal 1, candidate.arity
end
def test_ungated_methods_report_zero_arity
candidate = candidates_for("Marshalsea::ScannerTest::UngatedFixture").first
assert_predicate candidate, :zero_arity?
end
def test_reachability_via_instance_variable_read
candidate = candidates_for("Marshalsea::ScannerTest::StatefulFixture").first
assert_predicate candidate, :touches_state?
assert_predicate candidate, :reachable?
end
def test_reachability_via_implicit_self_call
candidate = candidates_for("Marshalsea::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("Marshalsea::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("Marshalsea::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: "Marshalsea::ScannerTest")
assert(report.candidates.all? { |c| c.class_name.start_with?("Marshalsea::ScannerTest") })
end
def test_report_partitions_every_candidate_by_gate
report = local_scan
soft = report.candidates.select(&:soft_gated?)
buckets = { gated: report.gated, ungated: report.ungated, links: report.links, soft: soft }
buckets.except(:soft).each do |name, bucket|
refute_empty bucket, "a partition test proves nothing if #{name} is empty"
end
assert_equal report.candidates.length, buckets.values.sum(&:length),
"every gate value must land in exactly one bucket, or a candidate is invisible"
buckets.values.combination(2) { |left, right| assert_empty(left & right) }
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_contribute_no_candidate
anonymous = Class.new { def marshal_load(data); end }
location = anonymous.instance_method(:marshal_load).source_location.join(":")
report = scan
refute_empty report.gated, "the global scan found nothing, so absence proves nothing"
refute_includes report.candidates.map(&:source_location), location,
"a class with no name reached the report"
end
def test_scanning_does_not_instantiate_anything
refute GatedFixture.instantiated
local_scan
refute GatedFixture.instantiated, "scanner constructed a candidate class"
end
def test_a_clean_scan_reports_no_suppressions
report = local_scan
assert_empty report.suppressions
assert_equal 0, report.suppressed_count
assert_predicate report, :complete?
refute_predicate report, :candidates_lost?
end
def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from
control = candidates_for("Marshalsea::ScannerTest::ExplodingHandleFixture")
assert_equal ["marshal_load"], control.map(&:method_name),
"control: this fixture must be discoverable when it is not exploding"
with_exploding(ExplodingHandleFixture) do
report = local_scan
assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingHandleFixture") })
lost = suppressions_at(report, Scanner::SITE_CANDIDATE)
assert_equal 1, lost.length
assert_equal "Marshalsea::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject
assert_predicate report, :candidates_lost?
refute_predicate report, :complete?
end
end
def test_a_suppressed_script_error_is_recorded_by_class
with_exploding(ExplodingHandleFixture) do
suppression = suppressions_at(local_scan, Scanner::SITE_CANDIDATE).first
assert_equal "ScriptError", suppression.error_class,
"record rescues ScriptError as well as StandardError, so it must report which"
end
end
def test_an_unreadable_method_list_is_counted_as_a_lost_candidate
with_exploding(ExplodingMethodListFixture) do
report = local_scan
assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingMethodListFixture") })
assert_equal 1, suppressions_at(report, Scanner::SITE_OWN_METHODS).length
assert_predicate report, :candidates_lost?
end
end
def test_a_module_that_cannot_report_its_name_is_counted
with_exploding(ExplodingNameFixture) do
named = suppressions_at(local_scan, Scanner::SITE_MODULE_NAME)
refute_empty named
assert_equal Scanner::SUBJECT_UNNAMED, named.first.subject
end
end
def test_unparseable_source_is_counted_without_losing_the_candidate
report = scan(namespace: SUPPRESSION_NAMESPACE)
assert_equal ["#{SUPPRESSION_NAMESPACE}::VanishedSource#hash",
"#{SUPPRESSION_NAMESPACE}::VanishedSource#to_s"],
report.candidates.map(&:to_s),
"control: the candidates must survive, only their state analysis failed"
assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length
refute_predicate report, :candidates_lost?
refute_predicate report, :complete?
end
def test_a_candidate_whose_source_cannot_be_read_stays_reachable
candidate = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first
refute_predicate candidate, :state_known?
refute_predicate candidate, :touches_state?
assert_predicate candidate, :reachable?,
"an unreadable source cannot prove a method inert, and a scanner that " \
"drops what it failed to analyse under-reports silently"
end
def test_a_c_defined_method_is_reported_as_unanalysable_not_as_inert
candidate = scan(namespace: "Gem").candidates.find { |c| c.source_location.nil? } ||
scan.candidates.find { |c| c.source_location.nil? }
refute_nil candidate, "control: the stdlib must supply at least one C-defined candidate"
assert_predicate candidate, :unanalysable?
refute_predicate candidate, :state_known?
refute_predicate candidate, :touches_state?,
"no Ruby source exists, so the answer is not false, it is unavailable"
end
def test_unanalysable_is_distinct_from_an_analysis_that_failed
vanished = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first
c_defined = scan.candidates.find { |c| c.source_location.nil? }
refute_predicate vanished, :unanalysable?,
"a source that exists but could not be read is a failure, not an absence"
assert_predicate c_defined, :unanalysable?
refute_predicate vanished, :state_known?
refute_predicate c_defined, :state_known?
end
def test_an_unanalysable_candidate_is_never_reachable_on_that_basis
report = scan
flooded = report.unanalysable.reject(&:gated?).select(&:reachable?)
refute_empty report.unanalysable, "control: a stock image must have C-defined candidates"
assert_empty flooded.first(5).map(&:to_s),
"#{flooded.length} C-defined candidates were called reachable purely because " \
"they could not be analysed; that is a pass-through, not a filter"
end
def test_control_an_unreadable_candidate_is_reachable_on_exactly_that_basis
unreadable = scan.candidates.select(&:unreadable_source?)
.select(&:entry_point?).reject(&:gated?).reject(&:soft_gated?)
.select(&:accepts_dispatch?)
refute_empty unreadable, "control: without one of these the previous test is vacuous"
assert(unreadable.all?(&:reachable?),
"the two non-verdicts must behave differently, or splitting them bought nothing")
end
def test_the_report_counts_what_it_could_not_analyse
report = scan
assert_equal report.candidates.count(&:unanalysable?), report.unanalysable.length
assert_operator report.unanalysable.length, :>, 0
refute_predicate report, :fully_analysed?
end
def test_a_report_over_analysable_code_only_is_fully_analysed
report = local_scan
assert_empty report.unanalysable
assert_predicate report, :fully_analysed?
end
def test_an_analysed_candidate_reports_its_state_as_known
%w[StatefulFixture StatelessFixture].each do |fixture|
candidate = candidates_for("Marshalsea::ScannerTest::#{fixture}").first
assert_predicate candidate, :state_known?,
"control: a readable source must produce a verdict, or unknown means nothing"
end
end
def test_one_unreadable_file_is_counted_once_not_once_per_candidate
report = scan(namespace: SUPPRESSION_NAMESPACE)
assert_operator report.candidates.length, :>, 1,
"control: one candidate cannot expose per-candidate inflation"
assert_equal 1, report.suppressed_count,
"the parse cache must remember a failure, or the count inflates per candidate"
end
def test_suppressions_by_site_accounts_for_every_suppression
with_exploding(ExplodingHandleFixture, ExplodingMethodListFixture, ExplodingNameFixture) do
report = local_scan
by_site = report.suppressions_by_site
assert_equal report.suppressed_count, by_site.values.sum
assert_equal 3, by_site.keys.length
assert(by_site.keys.all? { |site| Scanner::SITES.include?(site) })
end
end
def test_a_gated_hook_that_cannot_accept_the_call_is_not_reachable
wrong = candidates_for("Marshalsea::ScannerTest::WrongArityFixture").first
right = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
assert_equal 0, wrong.arity
refute_predicate wrong, :accepts_dispatch?,
"Marshal.load calls marshal_load with one argument, so an arity-0 hook " \
"raises ArgumentError and the chain is dead"
refute_predicate wrong, :reachable?
assert_predicate right, :accepts_dispatch?, "control: the arity-1 hook must still qualify"
assert_predicate right, :reachable?
end
def test_an_ungated_entry_point_that_takes_an_argument_is_still_reachable
candidate = candidates_for("Marshalsea::ScannerTest::ComparableFixture").first
assert_equal "<=>", candidate.method_name
assert_equal 1, candidate.arity
refute_predicate candidate, :zero_arity?
assert_predicate candidate, :reachable?,
"Range#marshal_load supplies the argument, so arity 1 is what <=> must " \
"have, not a reason to drop it"
end
def test_a_link_method_is_recorded_and_never_called_an_entry_point
candidate = candidates_for("Marshalsea::ScannerTest::LinkFixture").first
assert_equal "to_s", candidate.method_name
assert_predicate candidate, :link?
refute_predicate candidate, :entry_point?
refute_predicate candidate, :reachable?,
"research 02 4.2 verified Marshal.load never invokes to_s directly, so " \
"reporting it as reachable is a false positive"
assert_includes local_scan.links.map(&:to_s), "Marshalsea::ScannerTest::LinkFixture#to_s",
"a link is how a chain continues and must not be discarded either"
end
def test_a_private_singleton_load_is_discovered
found = candidates_for("Marshalsea::ScannerTest::PrivateLoadFixture")
assert_equal ["_load"], found.map(&:method_name),
"Marshal.load reaches _load through rb_funcallv, which ignores visibility"
assert_predicate found.first, :gated?
assert_predicate found.first, :reachable?
end
def test_control_a_public_singleton_load_is_still_discovered
assert_includes candidates_for("Marshalsea::ScannerTest::UserDefFixture").map(&:method_name),
"_load"
end
BROKEN_SOURCE_PATH = File.join(Dir.tmpdir, "marshalsea-broken-fixture.rb")
File.write(BROKEN_SOURCE_PATH, "class Unterminated\n def hash\n \"open\n")
Object.class_eval(<<~SOURCE, BROKEN_SOURCE_PATH, 2)
module Marshalsea
module ScannerRecoveredFixture
class Recovered
def hash
@seed.to_i
end
end
end
end
SOURCE
def test_a_recovered_prism_parse_is_a_suppression_not_a_verdict
report = scan(namespace: "Marshalsea::ScannerRecoveredFixture")
candidate = report.candidates.first
assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length,
"Prism is error tolerant, so a file it could not parse must be counted"
refute_predicate candidate, :state_known?,
"a tree recovered from syntax errors cannot support a true or false verdict"
assert_predicate candidate, :unreadable_source?
assert_predicate candidate, :reachable?, "an unreadable source must fail open"
end
def test_control_prism_really_does_recover_a_definition_from_that_file
parsed = Prism.parse_file(BROKEN_SOURCE_PATH)
assert_predicate parsed, :failure?
refute_empty parsed.errors
refute_nil parsed.value,
"control: if Prism returned nothing there would be no wrong verdict to prevent"
end
class WrongArityFixture
def marshal_load; end
end
class ComparableFixture
def <=>(other)
@seed <=> other
end
end
class LinkFixture
def to_s
@seed.to_s
end
end
class PrivateLoadFixture
def self._load(_data)
allocate
end
private_class_method :_load
end
class ExplodingNameFixture
@explode = false
class << self
attr_accessor :explode
def name
raise NameError, "name unavailable" if @explode
super
end
end
end
class ExplodingMethodListFixture
@explode = false
class << self
attr_accessor :explode
def instance_methods(include_super = true)
raise NoMethodError, "method list unavailable" if @explode
super
end
end
def marshal_load(data); end
end
class ExplodingHandleFixture
@explode = false
class << self
attr_accessor :explode
def instance_method(name)
raise ScriptError, "handle unavailable" if @explode
super
end
end
def marshal_load(data); end
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