diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 4e457deb..64bfaf49 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -15,6 +15,7 @@ test: {{run_ro}} ruby -Ilib -Itest test/marshal/parser_test.rb {{run_ro}} ruby -Ilib -Itest test/scanner_test.rb {{run_ro}} ruby -Ilib -Itest test/chains_test.rb + {{run_ro}} ruby -Ilib -Itest test/marshal/boundary_detector_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) }' @@ -39,7 +40,10 @@ exploit: target: @bash scripts/target-gate.sh -gate: check matrix exploit target +detector: + @bash scripts/detector-gate.sh + +gate: check matrix exploit detector target build: {{run}} sh -c "gem build --strict rube.gemspec" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb index 97ccc1c5..9b6061ab 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb @@ -5,7 +5,9 @@ require_relative "rube/version" require_relative "rube/marshal/constants" require_relative "rube/marshal/errors" require_relative "rube/marshal/node" +require_relative "rube/marshal/limits" require_relative "rube/marshal/parser" +require_relative "rube/marshal/boundary_detector" require_relative "rube/scanner" require_relative "rube/chains" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb new file mode 100644 index 00000000..f908bb8b --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb @@ -0,0 +1,131 @@ +# ©AngelaMos | 2026 +# boundary_detector.rb + +module Rube + module Marshal + class ReporterRequiredError < StandardError; end + + class BoundaryDetector + POLICY_STRICT_ALLOWLIST = :strict_allowlist + POLICY_DENY_SINKS_ONLY = :deny_sinks_only + POLICY_OBSERVE_AND_LOG = :observe_and_log + + POLICIES = [POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze + + PRIMITIVE_CLASS_NAMES = %w[].freeze + + REASON_INPUT_TYPE = "input is not a String" + REASON_MALFORMED = "stream is not canonical Marshal: %s" + REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run" + REASON_UNAPPROVED = "stream references unapproved class %s" + + LIMITATION_NOTICE = <<~NOTICE.freeze + SECURITY LIMITATION + + Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and + applies a caller-selected policy before deserialization. An ACCEPT decision means + only that this snapshot matched that policy. + + Acceptance does not make the payload safe, trusted, authenticated, or free of + gadget behavior. This detector does not sandbox Ruby, audit the current + implementations of allowlisted classes, freeze the runtime class graph, or prevent + callbacks and implicit method dispatch that its parser or policy fails to model. + Class allowlisting compares serialized names. It does not prove that the + corresponding Ruby code is harmless. + + A payload carrying no sink tag can still reach dangerous code. The published + CVE-2026-41316 chain produces zero sink tags because ERB defines no marshal_load. + It is caught by class allowlisting alone, and an application that allowlists ERB + will accept it. + NOTICE + + class Decision + attr_reader :reason, :snapshot, :result + + def initialize(accepted:, reason: nil, snapshot: nil, result: nil, observed: false, would_reject: false) + @accepted = accepted + @reason = reason + @snapshot = snapshot + @result = result + @observed = observed + @would_reject = would_reject + end + + def accepted? + @accepted + end + + def rejected? + !@accepted + end + + def observed? + @observed + end + + def would_reject? + @would_reject + end + end + + def initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil) + raise ArgumentError, "unknown policy #{policy}" unless POLICIES.include?(policy) + raise ReporterRequiredError, "#{POLICY_OBSERVE_AND_LOG} requires a reporter" if + policy == POLICY_OBSERVE_AND_LOG && reporter.nil? + + @policy = policy + @allowed_class_names = allowed_class_names.map(&:to_s).freeze + @limits = limits + @reporter = reporter + end + + def inspect_stream(input) + return reject(REASON_INPUT_TYPE) unless input.is_a?(String) + + snapshot = input.dup.force_encoding(Encoding::BINARY).freeze + result = Parser.new(snapshot, limits: limits).parse + evaluate(result, snapshot) + rescue StreamError => e + reject(format(REASON_MALFORMED, e.class.name.split("::").last)) + end + + private + + attr_reader :policy, :allowed_class_names, :limits, :reporter + + def evaluate(result, snapshot) + violation = violation_for(result) + return accept(snapshot, result) unless violation + + return observe(violation, snapshot, result) if policy == POLICY_OBSERVE_AND_LOG + + reject(violation) + end + + def violation_for(result) + sink = result.sinks.first + return format(REASON_SINK, sink.class_name, sink.sink_method) if sink + return nil if policy == POLICY_DENY_SINKS_ONLY + + unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) } + return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty? + + nil + end + + def observe(violation, snapshot, result) + reporter.call(violation) + Decision.new(accepted: true, reason: violation, snapshot: snapshot, + result: result, observed: true, would_reject: true) + end + + def accept(snapshot, result) + Decision.new(accepted: true, snapshot: snapshot, result: result) + end + + def reject(reason) + Decision.new(accepted: false, reason: reason) + end + end + end +end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb index 4b3cd95b..15d95320 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb @@ -18,5 +18,9 @@ module Rube class DepthLimitError < StreamError; end class MalformedCountError < StreamError; end + + class LimitExceededError < StreamError; end + + class InputTypeError < StreamError; end end end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb new file mode 100644 index 00000000..dbb898ec --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb @@ -0,0 +1,118 @@ +# ©AngelaMos | 2026 +# limits.rb + +module Rube + module Marshal + class Limits + DEFAULT_MAX_BYTES = 1_048_576 + DEFAULT_MAX_DEPTH = 64 + DEFAULT_MAX_NODES = 10_000 + DEFAULT_MAX_REGISTERED_OBJECTS = 4_096 + DEFAULT_MAX_SYMBOL_DEFINITIONS = 256 + DEFAULT_MAX_COLLECTION_ENTRIES = 1_024 + DEFAULT_MAX_SCALAR_BYTES = 262_144 + DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288 + DEFAULT_MAX_OBJECT_LINKS = 2_048 + + ROLE_BYTES = "stream bytes" + ROLE_NODES = "nodes" + ROLE_REGISTERED = "registered objects" + ROLE_SYMBOLS = "symbol definitions" + ROLE_ENTRIES = "collection entries" + ROLE_SCALAR = "scalar bytes" + ROLE_TOTAL_SCALAR = "total scalar bytes" + ROLE_LINKS = "object links" + + attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects, + :max_symbol_definitions, :max_collection_entries, + :max_scalar_bytes, :max_total_scalar_bytes, :max_object_links + + def initialize( + max_bytes: DEFAULT_MAX_BYTES, + max_depth: DEFAULT_MAX_DEPTH, + max_nodes: DEFAULT_MAX_NODES, + max_registered_objects: DEFAULT_MAX_REGISTERED_OBJECTS, + max_symbol_definitions: DEFAULT_MAX_SYMBOL_DEFINITIONS, + max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES, + max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES, + max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES, + max_object_links: DEFAULT_MAX_OBJECT_LINKS + ) + @max_bytes = max_bytes + @max_depth = max_depth + @max_nodes = max_nodes + @max_registered_objects = max_registered_objects + @max_symbol_definitions = max_symbol_definitions + @max_collection_entries = max_collection_entries + @max_scalar_bytes = max_scalar_bytes + @max_total_scalar_bytes = max_total_scalar_bytes + @max_object_links = max_object_links + end + + def permissive + self.class.new( + max_bytes: Float::INFINITY, + max_depth: Constants::DEFAULT_MAX_DEPTH, + max_nodes: Float::INFINITY, + max_registered_objects: Float::INFINITY, + max_symbol_definitions: Float::INFINITY, + max_collection_entries: Float::INFINITY, + max_scalar_bytes: Float::INFINITY, + max_total_scalar_bytes: Float::INFINITY, + max_object_links: Float::INFINITY + ) + end + end + + class Budget + def initialize(limits) + @limits = limits + @nodes = 0 + @registered = 0 + @symbols = 0 + @links = 0 + @scalar_total = 0 + end + + def node! + @nodes += 1 + check(@nodes, limits.max_nodes, Limits::ROLE_NODES) + end + + def registered! + @registered += 1 + check(@registered, limits.max_registered_objects, Limits::ROLE_REGISTERED) + end + + def symbol! + @symbols += 1 + check(@symbols, limits.max_symbol_definitions, Limits::ROLE_SYMBOLS) + end + + def link! + @links += 1 + check(@links, limits.max_object_links, Limits::ROLE_LINKS) + end + + def entries!(count) + check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES) + end + + def scalar!(size) + check(size, limits.max_scalar_bytes, Limits::ROLE_SCALAR) + @scalar_total += size + check(@scalar_total, limits.max_total_scalar_bytes, Limits::ROLE_TOTAL_SCALAR) + end + + private + + attr_reader :limits + + def check(value, ceiling, role) + return if value <= ceiling + + raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}" + end + end + end +end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb index c7a7afe1..45138fb6 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb @@ -6,9 +6,14 @@ module Rube class Parser include Constants - def initialize(source, max_depth: DEFAULT_MAX_DEPTH) - @source = source.to_s.dup.force_encoding(Encoding::BINARY) - @max_depth = max_depth + def initialize(source, max_depth: DEFAULT_MAX_DEPTH, limits: nil) + raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String) + + @limits = limits + @max_depth = limits ? limits.max_depth : max_depth + enforce_size(source) + @source = source.dup.force_encoding(Encoding::BINARY) + @budget = Budget.new(limits || Limits.new.permissive) @position = 0 @symbols = [] @objects = [] @@ -24,7 +29,14 @@ module Rube private - attr_reader :source, :max_depth, :symbols, :objects + attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits + + def enforce_size(candidate) + return unless limits + return if candidate.bytesize <= limits.max_bytes + + raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}" + end def remaining source.bytesize - @position @@ -81,10 +93,19 @@ module Rube end def read_counted_bytes - take(read_count(ROLE_LENGTH)) + size = read_count(ROLE_LENGTH) + budget.scalar!(size) + take(size) + end + + def read_entry_count(role) + count = read_count(role) + budget.entries!(count) + count end def register(node) + budget.registered! objects << node node end @@ -92,6 +113,7 @@ module Rube def read_value(depth) raise DepthLimitError, "exceeded depth #{max_depth}" if depth > max_depth + budget.node! tag = take(1) case tag @@ -123,6 +145,7 @@ module Rube end def read_symbol(tag) + budget.symbol! node = Node.new(type: :symbol, tag: tag, value: read_counted_bytes.to_sym) symbols << node.value node @@ -136,6 +159,7 @@ module Rube end def read_object_link(tag) + budget.link! index = read_fixnum raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0 @@ -166,13 +190,13 @@ module Rube def read_array(tag, depth) node = register(Node.new(type: :array, tag: tag)) - read_count(ROLE_ARRAY).times { node.children << read_value(depth + 1) } + read_entry_count(ROLE_ARRAY).times { node.children << read_value(depth + 1) } node end def read_hash(tag, depth) node = register(Node.new(type: :hash, tag: tag)) - read_count(ROLE_HASH).times { node.children << read_pair(depth) } + read_entry_count(ROLE_HASH).times { node.children << read_pair(depth) } node.children << read_value(depth + 1) if tag == TAG_HASH_DEFAULT node end @@ -186,7 +210,7 @@ module Rube def read_ivar(tag, depth) inner = read_value(depth) - read_count(ROLE_IVAR).times do + read_entry_count(ROLE_IVAR).times do name = read_value(depth + 1) inner.auxiliary << name inner.instance_variables_map[name.value] = read_value(depth + 1) @@ -199,7 +223,7 @@ module Rube class_node = read_value(depth + 1) node.class_name = class_node.value.to_s node.auxiliary << class_node - read_count(ROLE_IVAR).times do + read_entry_count(ROLE_IVAR).times do name = read_value(depth + 1) node.auxiliary << name node.instance_variables_map[name.value] = read_value(depth + 1) @@ -210,7 +234,7 @@ module Rube def read_struct(tag, depth) node = register(Node.new(type: :struct, tag: tag)) node.class_name = read_value(depth + 1).value.to_s - read_count(ROLE_STRUCT).times { node.children << read_pair(depth) } + read_entry_count(ROLE_STRUCT).times { node.children << read_pair(depth) } node end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh new file mode 100755 index 00000000..8542440f --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# detector-gate.sh + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VULNERABLE_IMAGE="ruby:4.0.2-slim" + +echo "boundary detector gate" +echo + +output="$(docker run --rm --network none --read-only --tmpfs /tmp:rw,noexec,nosuid,size=1m \ + --user nobody \ + -v "${HERE}/lib:/app/lib:ro" -w /app "${VULNERABLE_IMAGE}" ruby -Ilib -e ' +require "rube" +D = Rube::Marshal::BoundaryDetector +CANARY = "/tmp/rube-canary" + +hostile = Rube::Chains::ErbDefMethod.canary(CANARY, "fired").serialize +benign = Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3] }) +sinky = Marshal.dump(Gem::Requirement.new(">= 0")) + +strict_hostile = D.new.inspect_stream(hostile) +strict_benign = D.new.inspect_stream(benign) +strict_sinky = D.new.inspect_stream(sinky) +allowlisted = D.new(allowed_class_names: %w[Gem::Requirement Gem::Version]).inspect_stream(sinky) +loose_hostile = D.new(policy: D::POLICY_DENY_SINKS_ONLY).inspect_stream(hostile) + +puts "strict_rejects_cve=#{strict_hostile.rejected?}" +puts "strict_accepts_benign=#{strict_benign.accepted?}" +puts "strict_rejects_sink=#{strict_sinky.rejected?}" +puts "allowlist_does_not_exempt_sink=#{allowlisted.rejected?}" +puts "deny_sinks_only_accepts_cve=#{loose_hostile.accepted?}" + +fired = false +if loose_hostile.accepted? + begin + Marshal.load(loose_hostile.snapshot).def_method(Module.new, "x") + rescue StandardError + nil + end + fired = File.exist?(CANARY) +end +puts "documented_bypass_executes=#{fired}" +' 2>&1)" + +echo "${output}" | sed 's/^/ /' +echo + +failures=0 +expect() { + if echo "${output}" | grep -q "^$1=true$"; then + echo " PASS $2" + else + echo " FAIL $2" + failures=$((failures + 1)) + fi +} + +expect strict_rejects_cve "strict policy rejects the CVE-2026-41316 payload" +expect strict_accepts_benign "strict policy still accepts a benign primitive stream" +expect strict_rejects_sink "strict policy rejects a sink-bearing stream" +expect allowlist_does_not_exempt_sink "allowlisting a class does not exempt its sink" +expect deny_sinks_only_accepts_cve "deny-sinks-only accepts the CVE payload as documented" +expect documented_bypass_executes "the documented bypass actually executes, so the notice is honest" + +echo +if [[ ${failures} -eq 0 ]]; then + echo "GATE PASSED" + exit 0 +fi + +echo "GATE FAILED (${failures})" +exit 1 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb new file mode 100644 index 00000000..0386f627 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb @@ -0,0 +1,189 @@ +# ©AngelaMos | 2026 +# boundary_detector_test.rb + +require_relative "../test_helper" + +module Rube + module Marshal + class BoundaryDetectorTest < Minitest::Test + BENIGN = { "user" => "guest", "roles" => [1, 2, 3], "flag" => true }.freeze + CANARY_PATH = "/tmp/rube-canary" + CANARY_MARKER = "fired" + + def detector(**options) + BoundaryDetector.new(**options) + end + + def benign_blob + ::Marshal.dump(BENIGN) + end + + def sink_blob + ::Marshal.dump(Gem::Requirement.new(">= 0")) + end + + def cve_blob + Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize + end + + def test_default_policy_is_strict_allowlist + assert_equal BoundaryDetector::POLICY_STRICT_ALLOWLIST, + detector.send(:policy) + end + + def test_accepts_primitive_only_stream_with_an_empty_allowlist + assert_predicate detector.inspect_stream(benign_blob), :accepted? + end + + def test_rejects_non_string_input_without_converting_it + hostile = Object.new + def hostile.to_s + raise "to_s must never be called on untrusted input" + end + + decision = detector.inspect_stream(hostile) + assert_predicate decision, :rejected? + assert_equal BoundaryDetector::REASON_INPUT_TYPE, decision.reason + end + + def test_rejects_a_sink_bearing_stream + decision = detector.inspect_stream(sink_blob) + assert_predicate decision, :rejected? + assert_includes decision.reason, "Gem::Requirement#marshal_load" + end + + def test_allowlisting_a_class_does_not_exempt_its_sink + decision = detector(allowed_class_names: %w[Gem::Requirement Gem::Version]) + .inspect_stream(sink_blob) + assert_predicate decision, :rejected? + assert_includes decision.reason, "marshal_load" + end + + def test_rejects_unapproved_class_names + decision = detector.inspect_stream(cve_blob) + assert_predicate decision, :rejected? + assert_includes decision.reason, "ERB" + end + + def test_accepts_an_approved_class + decision = detector(allowed_class_names: %w[ERB]).inspect_stream(cve_blob) + assert_predicate decision, :accepted? + end + + def test_documented_bypass_is_real_deny_sinks_only_accepts_the_cve_payload + decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY) + .inspect_stream(cve_blob) + assert_predicate decision, :accepted?, + "the limitation notice claims this bypass exists, so it must be demonstrable" + end + + def test_deny_sinks_only_still_rejects_sinks + decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY) + .inspect_stream(sink_blob) + assert_predicate decision, :rejected? + end + + def test_observe_and_log_requires_a_reporter + assert_raises(ReporterRequiredError) do + detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG) + end + end + + def test_observe_and_log_accepts_but_records_the_violation + seen = [] + decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, + reporter: ->(reason) { seen << reason }) + .inspect_stream(cve_blob) + + assert_predicate decision, :accepted? + assert_predicate decision, :observed? + assert_predicate decision, :would_reject? + assert_equal 1, seen.length + end + + def test_observe_and_log_still_rejects_malformed_input + decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG, + reporter: ->(_) {}) + .inspect_stream("\x04\x08[\xFA") + assert_predicate decision, :rejected? + end + + def test_rejects_unknown_policy + assert_raises(ArgumentError) { detector(policy: :yolo) } + end + + def test_rejects_malformed_stream_with_a_named_reason + decision = detector.inspect_stream("\x04\x08[\xFA") + assert_predicate decision, :rejected? + assert_includes decision.reason, "MalformedCountError" + end + + def test_returns_a_frozen_snapshot_on_accept + decision = detector.inspect_stream(benign_blob) + assert_predicate decision.snapshot, :frozen? + end + + def test_snapshot_is_independent_of_a_mutated_original + original = +benign_blob + decision = detector.inspect_stream(original) + original << "tampered" + refute_includes decision.snapshot, "tampered" + end + + def test_enforces_a_byte_ceiling_before_parsing + limits = Limits.new(max_bytes: 8) + decision = detector(limits: limits).inspect_stream(benign_blob) + assert_predicate decision, :rejected? + assert_includes decision.reason, "LimitExceededError" + end + + def test_enforces_a_depth_ceiling + deep = "\x04\x08" + ("[\x06" * 80) + "0" + decision = detector.inspect_stream(deep) + assert_predicate decision, :rejected? + end + + def test_enforces_a_collection_entry_ceiling + limits = Limits.new(max_collection_entries: 2) + decision = detector(limits: limits).inspect_stream(::Marshal.dump([1, 2, 3, 4, 5])) + assert_predicate decision, :rejected? + end + + def test_enforces_a_symbol_ceiling + limits = Limits.new(max_symbol_definitions: 2) + blob = ::Marshal.dump(%i[a b c d e f]) + decision = detector(limits: limits).inspect_stream(blob) + assert_predicate decision, :rejected? + end + + def test_enforces_a_node_ceiling + limits = Limits.new(max_nodes: 5) + decision = detector(limits: limits).inspect_stream(::Marshal.dump((1..50).to_a)) + assert_predicate decision, :rejected? + end + + def test_never_exposes_a_safety_claiming_api + %i[safe? trusted? sanitized? safe_load].each do |forbidden| + refute_respond_to detector, forbidden, + "#{forbidden} implies a guarantee this detector cannot make" + end + end + + def test_ships_a_limitation_notice_that_names_the_bypass + notice = BoundaryDetector::LIMITATION_NOTICE + assert_includes notice, "does not make the payload safe" + assert_includes notice, "CVE-2026-41316" + assert_includes notice, "zero sink tags" + end + + def test_detector_never_calls_marshal_load + fired = false + tracer = TracePoint.new(:call, :c_call) do |tp| + fired = true if tp.method_id == :load && tp.self.equal?(::Marshal) + end + tracer.enable { detector.inspect_stream(cve_blob) } + refute fired + end + end + end +end