fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity

Every item contracted to clear before M7 is closed. 151 tests from 119,
58 corpus cases from 48, all six gates green.

Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain
of any length parsed under any ceiling. Proven end to end against a rebuilt
target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError
that no rescue StreamError can catch, and a 724,287-byte response body
leaking absolute container paths for every file in lib/. Fixed, the same
cookie returns 400 DepthLimitError, and so does a 53,340-byte one.
read_userdef also hard-coded a depth of 1 for its class-name slot.

Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget
entirely and the sign byte accepted anything as positive where Marshal.load
raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes,
max_class_name_bytes, max_instance_variables and max_struct_members.
Parser.new now enforces Limits.new instead of resolving to an unbounded
config; Limits.permissive became a class method.

Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a
hash KEY, where #hash and #eql? run during load before any allowlist can
act. Measured against real Marshal.load: a key dispatches iff it carries a
class name and its underlying value is not a T_STRING. So TAG_REGEXP is not
a key-position risk and TAG_USERCLASS only conditionally - rejecting either
outright would have been a false positive. No opt-out allowlist was added,
because the dispatch happens before any check could run.

Fidelity (B15, B16, B9). The parser already matched Marshal.load on header
versions, so the 4.8 contradiction was resolved by giving the detector the
policy check and leaving the forensic parser permissive. Class-name slots
now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper
tags C and e no longer take an object-table slot, which Ruby does not give
them - link index 3 resolved to "bbb" for us and "ccc" for Ruby.

Gate soundness (B6, B7). Three discarded check() return values now register
as failures; section 6 no longer reports a vacuous 0/0; section 7 requires
reachable > 0, and prism absence is a named failure rather than a silent
zero. version-matrix.sh exits non-zero when any image produces no probe
result. control_check.rb no longer pulls in minitest, which was printing
"0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the
vacuous tests: the regexp options byte had zero minitest coverage and its
mutant survived the whole suite, and read_count's negative guard was
alibied by take's own guard.

The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist
policy and now runs one BoundaryDetector with real limits, branching on
rejected? rather than accepted?.

Everything here is mutation-proven. Notable misses that mutation caught:
B14 had no test at all until reverting it stayed green, and a struct-member
test was vacuous on the first attempt because struct member names are always
symbols.

lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be
split without interactive hunk staging, so this is one commit rather than
eight.
This commit is contained in:
CarterPerez-dev 2026-07-29 05:12:25 -04:00
parent 43ea87dffc
commit 582e032cc0
18 changed files with 827 additions and 92 deletions

View File

@ -9,4 +9,9 @@ Rake::TestTask.new(:test) do |t|
t.warning = true t.warning = true
end end
task default: :test desc "run the standalone control checks that the minitest suite cannot express"
task :control do
ruby "-Ilib -Itest test/control_check.rb"
end
task default: %i[test control]

View File

@ -18,6 +18,10 @@ module Rube
REASON_MALFORMED = "stream is not canonical Marshal: %s" REASON_MALFORMED = "stream is not canonical Marshal: %s"
REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run" REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
REASON_UNAPPROVED = "stream references unapproved class %s" REASON_UNAPPROVED = "stream references unapproved class %s"
REASON_KEY_DISPATCH = "stream puts %s in a hash key, so its #hash and #eql? run during " \
"load, before any allowlist can act"
REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
"this format emits %d.%d"
LIMITATION_NOTICE = <<~NOTICE.freeze LIMITATION_NOTICE = <<~NOTICE.freeze
SECURITY LIMITATION SECURITY LIMITATION
@ -105,8 +109,17 @@ module Rube
def violation_for(result) def violation_for(result)
sink = result.sinks.first sink = result.sinks.first
return format(REASON_SINK, sink.class_name, sink.sink_method) if sink return format(REASON_SINK, sink.class_name, sink.sink_method) if sink
key = result.dispatching_hash_keys.first
return format(REASON_KEY_DISPATCH, key.effective_class_name) if key
return nil if policy == POLICY_DENY_SINKS_ONLY return nil if policy == POLICY_DENY_SINKS_ONLY
unless result.canonical_version?
return format(REASON_NONCANONICAL_VERSION, result.major, result.minor,
Constants::MAJOR_VERSION, Constants::MINOR_VERSION)
end
unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) } unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty? return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty?

View File

@ -36,6 +36,7 @@ module Rube
BIGNUM_SIGN_POSITIVE = "+" BIGNUM_SIGN_POSITIVE = "+"
BIGNUM_SIGN_NEGATIVE = "-" BIGNUM_SIGN_NEGATIVE = "-"
BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze
BIGNUM_WORD_BYTES = 2 BIGNUM_WORD_BYTES = 2
FIXNUM_INLINE_OFFSET = 5 FIXNUM_INLINE_OFFSET = 5
@ -56,6 +57,10 @@ module Rube
ROLE_STRUCT = "struct member" ROLE_STRUCT = "struct member"
ROLE_BIGNUM = "bignum word" ROLE_BIGNUM = "bignum word"
CLASS_NAME_TYPES = %i[symbol symlink].freeze
REGISTERED_WRAPPER_TYPES = %i[data].freeze
SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
SINK_METHODS = { SINK_METHODS = {

View File

@ -19,6 +19,8 @@ module Rube
class MalformedCountError < StreamError; end class MalformedCountError < StreamError; end
class MalformedValueError < StreamError; end
class LimitExceededError < StreamError; end class LimitExceededError < StreamError; end
class InputTypeError < StreamError; end class InputTypeError < StreamError; end

View File

@ -13,6 +13,11 @@ module Rube
DEFAULT_MAX_SCALAR_BYTES = 262_144 DEFAULT_MAX_SCALAR_BYTES = 262_144
DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288 DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288
DEFAULT_MAX_OBJECT_LINKS = 2_048 DEFAULT_MAX_OBJECT_LINKS = 2_048
DEFAULT_MAX_SYMBOL_REFERENCES = 2_048
DEFAULT_MAX_SYMBOL_NAME_BYTES = 1_024
DEFAULT_MAX_CLASS_NAME_BYTES = 1_024
DEFAULT_MAX_INSTANCE_VARIABLES = 256
DEFAULT_MAX_STRUCT_MEMBERS = 256
ROLE_BYTES = "stream bytes" ROLE_BYTES = "stream bytes"
ROLE_NODES = "nodes" ROLE_NODES = "nodes"
@ -22,10 +27,19 @@ module Rube
ROLE_SCALAR = "scalar bytes" ROLE_SCALAR = "scalar bytes"
ROLE_TOTAL_SCALAR = "total scalar bytes" ROLE_TOTAL_SCALAR = "total scalar bytes"
ROLE_LINKS = "object links" ROLE_LINKS = "object links"
ROLE_SYMBOL_REFERENCES = "symbol references"
ROLE_SYMBOL_NAME = "symbol name bytes"
ROLE_CLASS_NAME = "class name bytes"
ROLE_INSTANCE_VARIABLES = "instance variables"
ROLE_STRUCT_MEMBERS = "struct members"
UNBOUNDED = Float::INFINITY
attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects, attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects,
:max_symbol_definitions, :max_collection_entries, :max_symbol_definitions, :max_collection_entries,
:max_scalar_bytes, :max_total_scalar_bytes, :max_object_links :max_scalar_bytes, :max_total_scalar_bytes, :max_object_links,
:max_symbol_references, :max_symbol_name_bytes, :max_class_name_bytes,
:max_instance_variables, :max_struct_members
def initialize( def initialize(
max_bytes: DEFAULT_MAX_BYTES, max_bytes: DEFAULT_MAX_BYTES,
@ -36,7 +50,12 @@ module Rube
max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES, max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES,
max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES, max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES,
max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES, max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES,
max_object_links: DEFAULT_MAX_OBJECT_LINKS max_object_links: DEFAULT_MAX_OBJECT_LINKS,
max_symbol_references: DEFAULT_MAX_SYMBOL_REFERENCES,
max_symbol_name_bytes: DEFAULT_MAX_SYMBOL_NAME_BYTES,
max_class_name_bytes: DEFAULT_MAX_CLASS_NAME_BYTES,
max_instance_variables: DEFAULT_MAX_INSTANCE_VARIABLES,
max_struct_members: DEFAULT_MAX_STRUCT_MEMBERS
) )
@max_bytes = max_bytes @max_bytes = max_bytes
@max_depth = max_depth @max_depth = max_depth
@ -47,19 +66,29 @@ module Rube
@max_scalar_bytes = max_scalar_bytes @max_scalar_bytes = max_scalar_bytes
@max_total_scalar_bytes = max_total_scalar_bytes @max_total_scalar_bytes = max_total_scalar_bytes
@max_object_links = max_object_links @max_object_links = max_object_links
@max_symbol_references = max_symbol_references
@max_symbol_name_bytes = max_symbol_name_bytes
@max_class_name_bytes = max_class_name_bytes
@max_instance_variables = max_instance_variables
@max_struct_members = max_struct_members
end end
def permissive def self.permissive
self.class.new( new(
max_bytes: Float::INFINITY, max_bytes: UNBOUNDED,
max_depth: Constants::DEFAULT_MAX_DEPTH, max_depth: Constants::DEFAULT_MAX_DEPTH,
max_nodes: Float::INFINITY, max_nodes: UNBOUNDED,
max_registered_objects: Float::INFINITY, max_registered_objects: UNBOUNDED,
max_symbol_definitions: Float::INFINITY, max_symbol_definitions: UNBOUNDED,
max_collection_entries: Float::INFINITY, max_collection_entries: UNBOUNDED,
max_scalar_bytes: Float::INFINITY, max_scalar_bytes: UNBOUNDED,
max_total_scalar_bytes: Float::INFINITY, max_total_scalar_bytes: UNBOUNDED,
max_object_links: Float::INFINITY max_object_links: UNBOUNDED,
max_symbol_references: UNBOUNDED,
max_symbol_name_bytes: UNBOUNDED,
max_class_name_bytes: UNBOUNDED,
max_instance_variables: UNBOUNDED,
max_struct_members: UNBOUNDED
) )
end end
end end
@ -71,6 +100,7 @@ module Rube
@registered = 0 @registered = 0
@symbols = 0 @symbols = 0
@links = 0 @links = 0
@symbol_references = 0
@scalar_total = 0 @scalar_total = 0
end end
@ -94,6 +124,27 @@ module Rube
check(@links, limits.max_object_links, Limits::ROLE_LINKS) check(@links, limits.max_object_links, Limits::ROLE_LINKS)
end end
def symbol_reference!
@symbol_references += 1
check(@symbol_references, limits.max_symbol_references, Limits::ROLE_SYMBOL_REFERENCES)
end
def symbol_name!(size)
check(size, limits.max_symbol_name_bytes, Limits::ROLE_SYMBOL_NAME)
end
def class_name!(size)
check(size, limits.max_class_name_bytes, Limits::ROLE_CLASS_NAME)
end
def instance_variables!(count)
check(count, limits.max_instance_variables, Limits::ROLE_INSTANCE_VARIABLES)
end
def struct_members!(count)
check(count, limits.max_struct_members, Limits::ROLE_STRUCT_MEMBERS)
end
def entries!(count) def entries!(count)
check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES) check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES)
end end

View File

@ -4,8 +4,11 @@
module Rube module Rube
module Marshal module Marshal
class Node class Node
STRING_BACKED_TYPES = %i[string regexp].freeze
WRAPPER_TYPES = %i[user_class extended].freeze
attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary
attr_accessor :value, :class_name attr_accessor :value, :class_name, :link_target
def initialize(type:, tag: nil, value: nil, class_name: nil) def initialize(type:, tag: nil, value: nil, class_name: nil)
@type = type @type = type
@ -29,6 +32,25 @@ module Rube
Constants::GATED_SINK_TAGS.include?(tag) Constants::GATED_SINK_TAGS.include?(tag)
end end
def dispatches_key_methods?
return link_target ? link_target.dispatches_key_methods? : false if type == :object_link
return false unless class_name
return !string_backed? if WRAPPER_TYPES.include?(type)
true
end
def effective_class_name
link_target ? link_target.class_name : class_name
end
def string_backed?
wrapped = children.first
return false unless wrapped
STRING_BACKED_TYPES.include?(wrapped.type)
end
def each(&block) def each(&block)
return enum_for(:each) unless block return enum_for(:each) unless block
@ -39,10 +61,16 @@ module Rube
end end
class Result class Result
attr_reader :root attr_reader :root, :major, :minor
def initialize(root) def initialize(root, major:, minor:)
@root = root @root = root
@major = major
@minor = minor
end
def canonical_version?
major == Constants::MAJOR_VERSION && minor == Constants::MINOR_VERSION
end end
def nodes def nodes
@ -60,6 +88,17 @@ module Rube
def gated_sinks def gated_sinks
sinks.select(&:gated?) sinks.select(&:gated?)
end end
def hash_keys
nodes.select { |node| node.type == :hash }
.flat_map(&:children)
.select { |child| child.type == :pair }
.filter_map { |pair| pair.children.first }
end
def dispatching_hash_keys
hash_keys.select(&:dispatches_key_methods?)
end
end end
end end
end end

View File

@ -6,14 +6,14 @@ module Rube
class Parser class Parser
include Constants include Constants
def initialize(source, max_depth: DEFAULT_MAX_DEPTH, limits: nil) def initialize(source, max_depth: nil, limits: Limits.new)
raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String) raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String)
@limits = limits @limits = limits
@max_depth = limits ? limits.max_depth : max_depth @max_depth = max_depth || limits.max_depth
enforce_size(source) enforce_size(source)
@source = source.dup.force_encoding(Encoding::BINARY) @source = source.dup.force_encoding(Encoding::BINARY)
@budget = Budget.new(limits || Limits.new.permissive) @budget = Budget.new(limits)
@position = 0 @position = 0
@symbols = [] @symbols = []
@objects = [] @objects = []
@ -24,7 +24,7 @@ module Rube
root = read_value(1) root = read_value(1)
raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero? raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero?
Result.new(root) Result.new(root, major: @major, minor: @minor)
end end
private private
@ -32,7 +32,6 @@ module Rube
attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits
def enforce_size(candidate) def enforce_size(candidate)
return unless limits
return if candidate.bytesize <= limits.max_bytes return if candidate.bytesize <= limits.max_bytes
raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}" raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}"
@ -68,11 +67,10 @@ module Rube
end end
def read_header def read_header
header = take(HEADER_LENGTH) @major, @minor = take(HEADER_LENGTH).unpack("CC")
major, minor = header.unpack("CC") return if @major == MAJOR_VERSION && @minor <= MINOR_VERSION
return if major == MAJOR_VERSION && minor <= MINOR_VERSION
raise UnsupportedVersionError, "stream declares #{major}.#{minor}" raise UnsupportedVersionError, "stream declares #{@major}.#{@minor}"
end end
def read_fixnum def read_fixnum
@ -133,7 +131,7 @@ module Rube
when TAG_IVAR then read_ivar(tag, depth) when TAG_IVAR then read_ivar(tag, depth)
when TAG_OBJECT then read_object(tag, depth) when TAG_OBJECT then read_object(tag, depth)
when TAG_STRUCT then read_struct(tag, depth) when TAG_STRUCT then read_struct(tag, depth)
when TAG_USERDEF then register(read_userdef(tag)) when TAG_USERDEF then register(read_userdef(tag, depth))
when TAG_USERMARSHAL then read_usermarshal(tag, depth) when TAG_USERMARSHAL then read_usermarshal(tag, depth)
when TAG_DATA then read_wrapped(tag, :data, depth) when TAG_DATA then read_wrapped(tag, :data, depth)
when TAG_USERCLASS then read_wrapped(tag, :user_class, depth) when TAG_USERCLASS then read_wrapped(tag, :user_class, depth)
@ -146,12 +144,16 @@ module Rube
def read_symbol(tag) def read_symbol(tag)
budget.symbol! budget.symbol!
node = Node.new(type: :symbol, tag: tag, value: read_counted_bytes.to_sym) size = read_count(ROLE_LENGTH)
budget.symbol_name!(size)
budget.scalar!(size)
node = Node.new(type: :symbol, tag: tag, value: take(size).to_sym)
symbols << node.value symbols << node.value
node node
end end
def read_symlink(tag) def read_symlink(tag)
budget.symbol_reference!
index = read_fixnum index = read_fixnum
raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0 raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0
@ -163,13 +165,20 @@ module Rube
index = read_fixnum index = read_fixnum
raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0 raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0
Node.new(type: :object_link, tag: tag, value: index) node = Node.new(type: :object_link, tag: tag, value: index)
node.link_target = objects[index]
node
end end
def read_bignum(tag) def read_bignum(tag)
negative = take(1) == BIGNUM_SIGN_NEGATIVE sign = take(1)
magnitude = little_endian(take(read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES)) raise MalformedValueError, "bignum sign #{sign.inspect}" unless BIGNUM_SIGNS.include?(sign)
Node.new(type: :bignum, tag: tag, value: negative ? -magnitude : magnitude)
size = read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES
budget.scalar!(size)
magnitude = little_endian(take(size))
Node.new(type: :bignum, tag: tag,
value: sign == BIGNUM_SIGN_NEGATIVE ? -magnitude : magnitude)
end end
def read_float(tag) def read_float(tag)
@ -210,13 +219,20 @@ module Rube
def read_class_name(node, depth) def read_class_name(node, depth)
class_node = read_value(depth) class_node = read_value(depth)
unless CLASS_NAME_TYPES.include?(class_node.type)
raise MalformedValueError,
"class name slot holds #{class_node.type}, not a symbol"
end
node.class_name = class_node.value.to_s node.class_name = class_node.value.to_s
node.auxiliary << class_node node.auxiliary << class_node
node node
end end
def read_instance_variables(node, depth) def read_instance_variables(node, depth)
read_entry_count(ROLE_IVAR).times do count = read_entry_count(ROLE_IVAR)
budget.instance_variables!(count)
count.times do
name = read_value(depth + 1) name = read_value(depth + 1)
value = read_value(depth + 1) value = read_value(depth + 1)
node.auxiliary << name node.auxiliary << name
@ -227,7 +243,7 @@ module Rube
end end
def read_ivar(tag, depth) def read_ivar(tag, depth)
read_instance_variables(read_value(depth), depth) read_instance_variables(read_value(depth + 1), depth)
end end
def read_object(tag, depth) def read_object(tag, depth)
@ -239,13 +255,15 @@ module Rube
def read_struct(tag, depth) def read_struct(tag, depth)
node = register(Node.new(type: :struct, tag: tag)) node = register(Node.new(type: :struct, tag: tag))
read_class_name(node, depth + 1) read_class_name(node, depth + 1)
read_entry_count(ROLE_STRUCT).times { node.children << read_pair(depth) } count = read_entry_count(ROLE_STRUCT)
budget.struct_members!(count)
count.times { node.children << read_pair(depth) }
node node
end end
def read_userdef(tag) def read_userdef(tag, depth)
node = Node.new(type: :userdef, tag: tag) node = Node.new(type: :userdef, tag: tag)
read_class_name(node, 1) read_class_name(node, depth + 1)
node.value = read_counted_bytes node.value = read_counted_bytes
node node
end end
@ -258,14 +276,18 @@ module Rube
end end
def read_wrapped(tag, type, depth) def read_wrapped(tag, type, depth)
node = register(Node.new(type: type, tag: tag)) node = Node.new(type: type, tag: tag)
register(node) if REGISTERED_WRAPPER_TYPES.include?(type)
read_class_name(node, depth + 1) read_class_name(node, depth + 1)
node.children << read_value(depth + 1) node.children << read_value(depth + 1)
node node
end end
def read_named(tag, type) def read_named(tag, type)
Node.new(type: type, tag: tag, class_name: read_counted_bytes) size = read_count(ROLE_LENGTH)
budget.class_name!(size)
budget.scalar!(size)
Node.new(type: type, tag: tag, class_name: take(size))
end end
end end
end end

View File

@ -79,6 +79,10 @@ module Rube
def reachable def reachable
candidates.select(&:reachable?) candidates.select(&:reachable?)
end end
def prism_available?
PRISM_AVAILABLE
end
end end
def initialize(namespace: nil) def initialize(namespace: nil)

View File

@ -2,6 +2,7 @@
# render_matrix.rb # render_matrix.rb
require "json" require "json"
require "rube"
TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze
MARK_YES = "yes" MARK_YES = "yes"
@ -9,7 +10,11 @@ MARK_NO = "no"
MARK_UNKNOWN = "?" MARK_UNKNOWN = "?"
RULE_WIDTH = 78 RULE_WIDTH = 78
CVE_PATCHED_ERB = ["4.0.3.1", "4.0.4.1", "6.0.1.1", "6.0.4"].freeze CHAIN = Rube::Chains::ErbDefMethod
def cve_patched?(version)
!CHAIN.affects?(version)
end
rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) }
abort "no probe results" if rows.empty? abort "no probe results" if rows.empty?
@ -54,7 +59,7 @@ section("ERB @_init GUARD (CVE-2026-41316), anchor = def_method") do
puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says") puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says")
rows.each do |r| rows.each do |r|
guard = r["erb_guard"] guard = r["erb_guard"]
expected = CVE_PATCHED_ERB.include?(r["erb"]) ? "patched" : "affected" expected = cve_patched?(r["erb"]) ? "patched" : "affected"
puts format(" %-14s %-9s %-9s %-24s %s", puts format(" %-14s %-9s %-9s %-24s %s",
short(r["image"]), r["erb"], mark(guard["guarded"]), short(r["image"]), r["erb"], mark(guard["guarded"]),
guard["delegating"].join(","), expected) guard["delegating"].join(","), expected)
@ -65,8 +70,7 @@ git_states = rows.map { |r| r["git_gadget"] }.uniq
guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq
agreements = rows.map do |r| agreements = rows.map do |r|
expected_patched = CVE_PATCHED_ERB.include?(r["erb"]) [short(r["image"]), r["erb_guard"]["guarded"] == cve_patched?(r["erb"])]
[short(r["image"]), r["erb_guard"]["guarded"] == expected_patched]
end end
disagreements = agreements.reject { |_, ok| ok }.map(&:first) disagreements = agreements.reject { |_, ok| ok }.map(&:first)

View File

@ -28,12 +28,15 @@ mkdir -p "${HERE}/tmp"
echo "probing ${#IMAGES[@]} images" echo "probing ${#IMAGES[@]} images"
incomplete=()
for image in "${IMAGES[@]}"; do for image in "${IMAGES[@]}"; do
printf ' %-18s ' "${image}" printf ' %-18s ' "${image}"
if ! docker image inspect "${image}" >/dev/null 2>&1; then if ! docker image inspect "${image}" >/dev/null 2>&1; then
if ! docker pull -q "${image}" >/dev/null 2>&1; then if ! docker pull -q "${image}" >/dev/null 2>&1; then
echo "UNAVAILABLE" echo "UNAVAILABLE"
incomplete+=("${image} unavailable")
continue continue
fi fi
fi fi
@ -46,11 +49,29 @@ for image in "${IMAGES[@]}"; do
echo "ok" echo "ok"
else else
echo "PROBE FAILED" echo "PROBE FAILED"
incomplete+=("${image} probe failed")
fi fi
done done
echo echo
render_status=0
docker run --rm --network none \ docker run --rm --network none \
-v "${OUT}:/matrix.jsonl:ro" \ -v "${OUT}:/matrix.jsonl:ro" \
-v "${RENDER}:/render.rb:ro" \ -v "${RENDER}:/render.rb:ro" \
"${RENDER_IMAGE}" ruby /render.rb /matrix.jsonl -v "${HERE}/lib:/app/lib:ro" \
-w /app \
"${RENDER_IMAGE}" ruby -Ilib /render.rb /matrix.jsonl || render_status=$?
echo
if [[ ${#incomplete[@]} -gt 0 ]]; then
echo "GATE FAILED - ${#incomplete[@]} of ${#IMAGES[@]} images produced no probe result:"
printf ' %s\n' "${incomplete[@]}"
echo " a matrix built from a subset cannot certify a version boundary"
exit 1
fi
if [[ ${render_status} -ne 0 ]]; then
echo "GATE FAILED - renderer rejected the matrix"
fi
exit ${render_status}

View File

@ -19,11 +19,15 @@ module Rube
ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze
BENIGN_TEMPLATE = "hello" BENIGN_TEMPLATE = "hello"
REJECTED_SINK = "rejected: payload reaches %s#%s before any allowlist can run" DETECTOR = Rube::Marshal::BoundaryDetector.new(
REJECTED_CLASS = "rejected: payload references %s" policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
allowed_class_names: ALLOWED_CLASSES,
limits: Rube::Marshal::Limits.new
)
REJECTED = "rejected: %s"
RENDERED = "rendered template for %s" RENDERED = "rendered template for %s"
NO_SESSION = "no session cookie" NO_SESSION = "no session cookie"
MALFORMED = "rejected: malformed stream (%s)"
class App < Sinatra::Base class App < Sinatra::Base
set :host_authorization, permitted_hosts: [] set :host_authorization, permitted_hosts: []
@ -63,10 +67,10 @@ module Rube
blob = decode(request.cookies[COOKIE_NAME]) blob = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless blob halt STATUS_BAD_REQUEST, NO_SESSION unless blob
verdict = inspect_stream(blob) decision = DETECTOR.inspect_stream(blob)
halt STATUS_BAD_REQUEST, verdict if verdict halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) if decision.rejected?
compile(::Marshal.load(blob)) compile(::Marshal.load(decision.snapshot))
end end
get "/canary" do get "/canary" do
@ -93,20 +97,6 @@ module Rube
template.def_method(Module.new, "render_it") if template.respond_to?(:def_method) template.def_method(Module.new, "render_it") if template.respond_to?(:def_method)
format(RENDERED, state[:user]) format(RENDERED, state[:user])
end end
def inspect_stream(blob)
result = Rube::Marshal::Parser.new(blob).parse
sink = result.sinks.first
return format(REJECTED_SINK, sink.class_name, sink.sink_method) if sink
unknown = result.class_names.reject { |name| ALLOWED_CLASSES.include?(name) }
return format(REJECTED_CLASS, unknown.join(", ")) unless unknown.empty?
nil
rescue Rube::Marshal::StreamError => e
format(MALFORMED, e.class.name.split("::").last)
end
end end
end end
end end

View File

@ -65,10 +65,11 @@ module Rube
assert_match(/\A#\nend\n/, chain.src) assert_match(/\A#\nend\n/, chain.src)
end end
def test_serialize_produces_a_loadable_marshal_stream def test_serialize_produces_a_parseable_marshal_stream
blob = chain.serialize blob = chain.serialize
assert_equal 4, blob.getbyte(0) assert_equal [Rube::Marshal::Constants::MAJOR_VERSION, Rube::Marshal::Constants::MINOR_VERSION],
assert_equal 8, blob.getbyte(1) [blob.getbyte(0), blob.getbyte(1)]
assert_equal :object, Rube::Marshal::Parser.new(blob).parse.root.type
end end
def test_payload_is_visible_to_the_parser_without_deserializing def test_payload_is_visible_to_the_parser_without_deserializing

View File

@ -1,7 +1,9 @@
# ©AngelaMos | 2026 # ©AngelaMos | 2026
# control_check.rb # control_check.rb
require_relative "test_helper" $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "rube"
Pair = Struct.new(:x, :y) Pair = Struct.new(:x, :y)
@ -77,15 +79,24 @@ puts
puts "=== 5 payload inspection ===" puts "=== 5 payload inspection ==="
result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort
check("classes extracted", !result.class_names.empty?, result.class_names.join(", ")) failures << "class names" unless check("classes extracted", !result.class_names.empty?,
check("gated sinks flagged", !from_stream.empty?, from_stream.join(", ")) result.class_names.join(", "))
failures << "gated sinks" unless check("gated sinks flagged", !from_stream.empty?,
from_stream.join(", "))
puts puts
puts "=== 6 parser and scanner agreement ===" puts "=== 6 parser and scanner agreement ==="
scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
missing = from_stream - scanned missing = from_stream - scanned
failures << "agreement" unless check("parser sinks located by reflection", missing.empty?, located = !from_stream.empty? && missing.empty?
missing.empty? ? "#{from_stream.length}/#{from_stream.length}" : "missing #{missing.join(', ')}") agreement_detail = if from_stream.empty?
"vacuous, section 5 produced no sinks to locate"
elsif missing.empty?
"#{from_stream.length}/#{from_stream.length}"
else
"missing #{missing.join(', ')}"
end
failures << "agreement" unless check("parser sinks located by reflection", located, agreement_detail)
puts puts
puts "=== 7 scanner precision ===" puts "=== 7 scanner precision ==="
@ -93,9 +104,13 @@ full = Rube::Scanner.new.scan
ungated = full.ungated.length ungated = full.ungated.length
reachable = full.reachable.reject(&:gated?).length reachable = full.reachable.reject(&:gated?).length
kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1) kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1)
failures << "precision" unless check("reachability filter discriminates", reachable < ungated, failures << "prism" unless check("prism backend live, so reachability is real", full.prism_available?,
full.prism_available? ? "Prism.parse_file in use" : "ABSENT, touches_state? is always false")
failures << "precision" unless check("reachability filter discriminates",
reachable.positive? && reachable < ungated,
"#{ungated} ungated -> #{reachable} reachable, #{kept}% kept") "#{ungated} ungated -> #{reachable} reachable, #{kept}% kept")
check("gated sinks located", !full.gated.empty?, full.gated.map(&:to_s).join(", ")) failures << "gated located" unless check("gated sinks located", !full.gated.empty?,
full.gated.map(&:to_s).join(", "))
puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded", puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded",
"#{full.scanned_modules} modules loaded") "#{full.scanned_modules} modules loaded")

View File

@ -37,7 +37,7 @@ module Rube
leaks = AdversarialCorpus::CASES.filter_map do |kase| leaks = AdversarialCorpus::CASES.filter_map do |kase|
detector(kase[:allowed]).inspect_stream(kase[:bytes]) detector(kase[:allowed]).inspect_stream(kase[:bytes])
nil nil
rescue StandardError => e rescue Exception => e
"#{kase[:name]}: #{e.class}" "#{kase[:name]}: #{e.class}"
end end

View File

@ -2,6 +2,7 @@
# boundary_detector_test.rb # boundary_detector_test.rb
require_relative "../test_helper" require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
module Rube module Rube
module Marshal module Marshal
@ -108,6 +109,28 @@ module Rube
assert_predicate decision, :rejected? assert_predicate decision, :rejected?
end end
def test_rejects_a_version_the_parser_accepts_but_no_ruby_emits
older = "\x04\x07\x30".b
assert_predicate Parser.new(older).parse, :root,
"control: the parser must still accept it, it is a forensic tool"
decision = detector.inspect_stream(older)
assert_predicate decision, :rejected?
assert_includes decision.reason, "4.7"
end
def test_canonical_version_is_accepted
assert_predicate detector.inspect_stream(benign_blob), :accepted?
end
def test_deny_sinks_only_does_not_police_the_version
decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY)
.inspect_stream("\x04\x07\x30".b)
assert_predicate decision, :accepted?,
"version canonicality runs no code during load, so it belongs with " \
"the allowlist, not with the sink checks"
end
def test_rejects_unknown_policy def test_rejects_unknown_policy
assert_raises(ArgumentError) { detector(policy: :yolo) } assert_raises(ArgumentError) { detector(policy: :yolo) }
end end
@ -130,36 +153,132 @@ module Rube
refute_includes decision.snapshot, "tampered" refute_includes decision.snapshot, "tampered"
end end
def test_enforces_a_byte_ceiling_before_parsing def assert_ceiling_rejects(blob, error_name, allowed: [], **narrow)
limits = Limits.new(max_bytes: 8) assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :accepted?,
decision = detector(limits: limits).inspect_stream(benign_blob) "control: this payload must be accepted under default limits, " \
"or the ceiling is not what rejected it"
decision = detector(allowed_class_names: allowed, limits: Limits.new(**narrow)).inspect_stream(blob)
assert_predicate decision, :rejected? assert_predicate decision, :rejected?
assert_includes decision.reason, "LimitExceededError" assert_includes decision.reason, error_name
decision
end
def test_enforces_a_byte_ceiling_before_parsing
assert_ceiling_rejects(benign_blob, "LimitExceededError", max_bytes: 8)
end end
def test_enforces_a_depth_ceiling def test_enforces_a_depth_ceiling
deep = "\x04\x08" + ("[\x06" * 80) + "0" deep = "\x04\x08" + ("[\x06" * 80) + "0"
shallow = "\x04\x08" + ("[\x06" * 8) + "0"
assert_predicate detector.inspect_stream(shallow), :accepted?,
"control: nesting inside the ceiling must be accepted"
decision = detector.inspect_stream(deep) decision = detector.inspect_stream(deep)
assert_predicate decision, :rejected? assert_predicate decision, :rejected?
assert_includes decision.reason, "DepthLimitError"
end end
def test_enforces_a_collection_entry_ceiling def test_enforces_a_collection_entry_ceiling
limits = Limits.new(max_collection_entries: 2) assert_ceiling_rejects(::Marshal.dump([1, 2, 3, 4, 5]), "LimitExceededError",
decision = detector(limits: limits).inspect_stream(::Marshal.dump([1, 2, 3, 4, 5])) max_collection_entries: 2)
assert_predicate decision, :rejected?
end end
def test_enforces_a_symbol_ceiling def test_enforces_a_symbol_ceiling
limits = Limits.new(max_symbol_definitions: 2) assert_ceiling_rejects(::Marshal.dump(%i[a b c d e f]), "LimitExceededError",
blob = ::Marshal.dump(%i[a b c d e f]) max_symbol_definitions: 2)
decision = detector(limits: limits).inspect_stream(blob)
assert_predicate decision, :rejected?
end end
def test_enforces_a_node_ceiling def test_enforces_a_node_ceiling
limits = Limits.new(max_nodes: 5) assert_ceiling_rejects(::Marshal.dump((1..50).to_a), "LimitExceededError",
decision = detector(limits: limits).inspect_stream(::Marshal.dump((1..50).to_a)) max_nodes: 5)
assert_predicate decision, :rejected? end
def test_enforces_a_bignum_magnitude_ceiling
words = AdversarialCorpus::BIGNUM_HUGE_WORDS
blob = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words))
decision = detector.inspect_stream(blob)
assert_predicate decision, :rejected?,
"#{words * AdversarialCorpus::BIGNUM_WORD_BYTES} magnitude bytes must be " \
"charged the same budget a string of that size is charged"
assert_includes decision.reason, "LimitExceededError"
end
def test_enforces_a_symbol_reference_ceiling
count = AdversarialCorpus::BULK_ENTRY_COUNT
blob = AdversarialCorpus.stream(
"[#{AdversarialCorpus.fixnum(count + 1)}#{AdversarialCorpus.symlink_run(count)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_references: 8)
end
def test_enforces_a_symbol_name_byte_ceiling
blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::MODEST_NAME_BYTES))
assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_name_bytes: 32)
end
def test_enforces_a_class_name_byte_ceiling
name = "A" * AdversarialCorpus::MODEST_NAME_BYTES
blob = AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(name.bytesize)}#{name}")
assert_ceiling_rejects(blob, "LimitExceededError", allowed: [name], max_class_name_bytes: 32)
end
def test_enforces_an_instance_variable_ceiling
blob = AdversarialCorpus.stream(
"o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_instance_variables: 8)
end
def test_enforces_a_struct_member_ceiling
blob = AdversarialCorpus.stream(
"S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_struct_members: 8)
end
def default_limit_probes
long = "A" * AdversarialCorpus::LONG_NAME_BYTES
bulk = AdversarialCorpus::BULK_ENTRY_COUNT
{
"bignum magnitude" =>
[AdversarialCorpus.stream(AdversarialCorpus.bignum("+", AdversarialCorpus::BIGNUM_HUGE_WORDS)), []],
"symbol references" =>
[AdversarialCorpus.stream(
AdversarialCorpus.symlink_groups(AdversarialCorpus::SYMLINK_BULK_GROUPS,
AdversarialCorpus::SYMLINK_BULK_PER_GROUP)
), []],
"symbol name bytes" =>
[AdversarialCorpus.stream(AdversarialCorpus.sym(long)), []],
"class name bytes" =>
[AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(long.bytesize)}#{long}"), [long]],
"instance variables" =>
[AdversarialCorpus.stream("o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]],
"struct members" =>
[AdversarialCorpus.stream("S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]]
}
end
def test_default_limits_bound_every_axis_without_being_asked
admitted = default_limit_probes.reject do |_axis, (blob, allowed)|
decision = detector(allowed_class_names: allowed).inspect_stream(blob)
decision.rejected? && decision.reason.include?("LimitExceededError")
end
assert_empty admitted.keys,
"Limits.new must bound these without a caller opting in: #{admitted.keys.join(', ')}"
end
def test_permissive_limits_admit_what_the_defaults_reject
admitted = default_limit_probes.count do |_axis, (blob, allowed)|
detector(allowed_class_names: allowed, limits: Limits.permissive).inspect_stream(blob).accepted?
end
assert_operator admitted, :>, 0,
"control: permissive must actually differ from the defaults, " \
"otherwise the previous test proves nothing about the ceilings"
end end
def test_never_exposes_a_safety_claiming_api def test_never_exposes_a_safety_claiming_api

View File

@ -35,6 +35,85 @@ module Rube
assert_equal :nil, parse("\x04\x07\x30").root.type assert_equal :nil, parse("\x04\x07\x30").root.type
end end
def ruby_accepts_stream?(bytes)
::Marshal.load(bytes)
true
rescue ArgumentError, TypeError
false
end
def parser_accepts_stream?(bytes)
parse(bytes)
true
rescue StreamError
false
end
def test_header_version_agreement_with_real_ruby
probes = (0..9).to_h { |minor| ["4.#{minor}", "\x04#{minor.chr}0".b] }
observed = probes.transform_values { |bytes| ruby_accepts_stream?(bytes) }
assert_includes observed.values, true, "no version accepted, so the oracle is dead"
assert_includes observed.values, false, "every version accepted, so the oracle proves nothing"
mismatches = probes.filter_map do |label, bytes|
mine = parser_accepts_stream?(bytes)
next if mine == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{mine}"
end
assert_empty mismatches,
"the parser is a forensic tool and must accept exactly what Marshal.load " \
"accepts:\n #{mismatches.join("\n ")}"
end
def test_result_reports_the_declared_version
result = parse("\x04\x07\x30")
assert_equal [4, 7], [result.major, result.minor]
refute_predicate result, :canonical_version?
assert_predicate parse(::Marshal.dump(nil)), :canonical_version?
end
class RoleFixture; end
def class_name_slot_streams
name = "Rube::Marshal::ParserTest::RoleFixture"
symbol = AdversarialCorpus.sym(name)
{
"symbol" => AdversarialCorpus.stream("o#{symbol}#{AdversarialCorpus.fixnum(0)}"),
"ivar wrapped symbol" => AdversarialCorpus.stream(
"oI#{symbol}#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus.sym('E')}T#{AdversarialCorpus.fixnum(0)}"
),
"symlink" => AdversarialCorpus.stream(
"[#{AdversarialCorpus.fixnum(2)}#{symbol}o#{AdversarialCorpus.symlink(0)}#{AdversarialCorpus.fixnum(0)}"
),
"fixnum" => AdversarialCorpus.stream("oi\x0a#{AdversarialCorpus.fixnum(0)}"),
"string" => AdversarialCorpus.stream("o#{AdversarialCorpus.str(name)}#{AdversarialCorpus.fixnum(0)}"),
"nil" => AdversarialCorpus.stream("o0#{AdversarialCorpus.fixnum(0)}")
}
end
def test_class_name_slot_role_agreement_with_real_ruby
observed = class_name_slot_streams.transform_values { |bytes| ruby_accepts_stream?(bytes) }
assert_includes observed.values, true, "no slot accepted, so the oracle is dead"
assert_includes observed.values, false, "every slot accepted, so the oracle proves nothing"
mismatches = class_name_slot_streams.filter_map do |label, bytes|
mine = parser_accepts_stream?(bytes)
next if mine == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{mine}"
end
assert_empty mismatches,
"a class-name slot accepts only a symbol, an ivar-wrapped symbol, or a " \
"symlink:\n #{mismatches.join("\n ")}"
end
def test_rejects_unknown_tag def test_rejects_unknown_tag
assert_raises(UnknownTagError) { parse("\x04\x08\x00") } assert_raises(UnknownTagError) { parse("\x04\x08\x00") }
end end
@ -65,6 +144,55 @@ module Rube
end end
end end
def ruby_rejects_bignum_sign?(bytes)
::Marshal.load(bytes)
false
rescue ArgumentError, TypeError
true
end
def parser_rejects_bignum_sign?(bytes)
parse(bytes)
false
rescue StreamError
true
end
def test_bignum_sign_byte_agreement_with_real_ruby
probes = AdversarialCorpus::BIGNUM_SIGNS.to_h do |sign|
[sign, AdversarialCorpus.stream(AdversarialCorpus.bignum(sign, 1))]
end
observed = probes.transform_values { |bytes| ruby_rejects_bignum_sign?(bytes) }
assert_includes observed.values, true, "no sign was rejected, so the oracle is dead"
assert_includes observed.values, false, "every sign was rejected, so the oracle proves nothing"
mismatches = probes.filter_map do |sign, bytes|
mine = parser_rejects_bignum_sign?(bytes)
next if mine == observed.fetch(sign)
"#{sign.inspect}: ruby_rejects=#{observed.fetch(sign)} parser_rejects=#{mine}"
end
assert_empty mismatches, "bignum sign handling diverges from Marshal.load:\n #{mismatches.join("\n ")}"
end
def test_bignum_magnitude_is_charged_the_same_budget_as_a_string
words = AdversarialCorpus::BIGNUM_HUGE_WORDS
magnitude = words * AdversarialCorpus::BIGNUM_WORD_BYTES
limits = Limits.new
big = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words))
str = AdversarialCorpus.stream(AdversarialCorpus.str("A" * magnitude))
assert_raises(LimitExceededError, "a #{magnitude}-byte string is rejected") do
Parser.new(str, limits: limits).parse
end
assert_raises(LimitExceededError, "so #{magnitude} bignum magnitude bytes must be too") do
Parser.new(big, limits: limits).parse
end
end
def test_parses_float def test_parses_float
assert_in_delta 3.5, roundtrip(3.5).value, 0.0 assert_in_delta 3.5, roundtrip(3.5).value, 0.0
end end
@ -105,6 +233,40 @@ module Rube
assert_equal 0, link.value assert_equal 0, link.value
end end
class LinkStringSubclass < String; end
module LinkExtension; end
def link_probe(first)
shared = +"ccc"
::Marshal.dump([first, +"bbb", shared, shared])
end
def link_probes
{
"plain object (o)" => link_probe(Object.new),
"user_class (C)" => link_probe(LinkStringSubclass.new("aaa")),
"extended (e)" => link_probe((+"aaa").extend(LinkExtension))
}
end
def test_object_link_indices_match_ruby_for_wrapper_tags
mismatches = link_probes.filter_map do |label, blob|
link = parse(blob).nodes.find { |node| node.type == :object_link }
flunk "#{label}: fixture carries no object link, so it proves nothing" unless link
ruby_target = ::Marshal.load(blob)[3]
next if link.link_target&.value == ruby_target
"#{label}: index #{link.value} resolves to " \
"#{link.link_target&.value.inspect}, Ruby resolves it to #{ruby_target.inspect}"
end
assert_empty mismatches,
"a wrapper tag must occupy the same object-table slot Ruby gives it:\n " \
"#{mismatches.join("\n ")}"
end
def test_rejects_out_of_bounds_object_link def test_rejects_out_of_bounds_object_link
assert_raises(InvalidLinkError) { parse("\x04\x08@\x0a") } assert_raises(InvalidLinkError) { parse("\x04\x08@\x0a") }
end end
@ -184,9 +346,35 @@ module Rube
assert_raises(MalformedCountError) { parse("\x04\x08S:\x06A\xFA") } assert_raises(MalformedCountError) { parse("\x04\x08S:\x06A\xFA") }
end end
def test_negative_length_is_caught_by_the_count_guard_not_the_take_guard
error = assert_raises(MalformedCountError) { parse("\x04\x08\"\xFA") }
assert_includes error.message, Constants::ROLE_LENGTH,
"take's own guard caught it, so read_count is not enforcing anything"
end
def test_negative_counts_never_rewind_the_cursor def test_negative_counts_never_rewind_the_cursor
parser = Parser.new("\x04\x08\"\xFA") parser = Parser.new("\x04\x08\"\xFA")
before = parser.instance_variable_get(:@position)
assert_raises(MalformedCountError) { parser.parse } assert_raises(MalformedCountError) { parser.parse }
assert_operator parser.instance_variable_get(:@position), :>=, before,
"the cursor moved backwards, which is a loop primitive"
end
def test_parses_regexp_and_consumes_its_options_byte
node = roundtrip(/pattern/i)
assert_equal :regexp, node.type
assert_equal "pattern", node.value
end
def test_parses_regexp_nested_in_a_collection
node = roundtrip([/first/, /second/mx])
assert_equal %w[first second], node.children.map(&:value)
end
def test_regexp_options_byte_is_not_mistaken_for_the_next_value
node = roundtrip([/pattern/ix, :sentinel])
assert_equal :symbol, node.children.last.type
assert_equal :sentinel, node.children.last.value
end end
def test_every_malformed_count_stays_inside_the_stream_error_hierarchy def test_every_malformed_count_stays_inside_the_stream_error_hierarchy
@ -261,11 +449,61 @@ module Rube
assert_raises(DepthLimitError) { parse(deep) } assert_raises(DepthLimitError) { parse(deep) }
end end
def test_parser_defaults_to_bounded_limits_not_permissive
blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::LONG_NAME_BYTES))
assert_raises(LimitExceededError, "a bare Parser.new must not be unbounded") do
Parser.new(blob).parse
end
assert_equal :symbol, Parser.new(blob, limits: Limits.permissive).parse.root.type,
"control: permissive must admit it, or a ceiling is not what rejected it"
end
def test_default_depth_ceiling_is_the_limits_value_not_the_permissive_one
deep = AdversarialCorpus.stream(AdversarialCorpus.array_nest(Limits::DEFAULT_MAX_DEPTH + 5) + "0")
assert_raises(DepthLimitError) { Parser.new(deep).parse }
assert_operator Limits::DEFAULT_MAX_DEPTH, :<, Constants::DEFAULT_MAX_DEPTH,
"control: the two depth constants must differ, or this test cannot discriminate"
end
def test_honours_custom_depth_limit def test_honours_custom_depth_limit
blob = ::Marshal.dump([[[1]]]) blob = ::Marshal.dump([[[1]]])
assert_raises(DepthLimitError) { Parser.new(blob, max_depth: 2).parse } assert_raises(DepthLimitError) { Parser.new(blob, max_depth: 2).parse }
end end
def test_instance_variable_wrapper_counts_toward_the_depth_limit
chain = AdversarialCorpus.stream(
AdversarialCorpus.ivar_chain(Constants::DEFAULT_MAX_DEPTH + 5)
)
assert_raises(DepthLimitError) { parse(chain) }
end
def test_deep_instance_variable_chain_rejects_before_the_ruby_stack_runs_out
chain = AdversarialCorpus.stream(
AdversarialCorpus.ivar_chain(AdversarialCorpus::IVAR_CHAIN_DEPTH)
)
assert_raises(DepthLimitError) { parse(chain) }
end
def parse_class_name_slot_at_ceiling(tail)
depth = AdversarialCorpus::USERDEF_NEST_DEPTH
blob = AdversarialCorpus.stream(AdversarialCorpus.array_nest(depth) + tail)
Parser.new(blob, max_depth: depth + 1).parse
end
def test_object_class_name_slot_is_charged_the_enclosing_depth
tail = "o#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(0)}"
assert_raises(DepthLimitError, "control failed, so the userdef test proves nothing") do
parse_class_name_slot_at_ceiling(tail)
end
end
def test_userdef_class_name_slot_does_not_reset_the_depth_budget
tail = "u#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(1)}x"
assert_raises(DepthLimitError) { parse_class_name_slot_at_ceiling(tail) }
end
def load_watcher def load_watcher
fired = false fired = false
tracer = TracePoint.new(:call, :c_call) do |tp| tracer = TracePoint.new(:call, :c_call) do |tp|
@ -290,6 +528,107 @@ module Rube
refute load_watcher { parse(blob) }, "parser invoked Marshal.load" refute load_watcher { parse(blob) }, "parser invoked Marshal.load"
end end
FIRED = []
module RecordsHash
def hash
FIRED << self.class.to_s
super
end
end
class PlainKey
include RecordsHash
end
class StringKey < String
include RecordsHash
end
class ArrayKey < Array
include RecordsHash
end
StructKey = Struct.new(:a) { include RecordsHash }
def dispatch_probes
{
"plain object (o)" => PlainKey.new,
"struct (S)" => StructKey.new(1),
"Array subclass (C)" => ArrayKey.new,
"String subclass (C)" => StringKey.new("x"),
"extended object (e)" => PlainKey.new.extend(RecordsHash),
"extended string (e)" => (+"x").extend(RecordsHash),
"plain string" => "x",
"symbol" => :x,
"integer" => 7,
"regexp" => /ab/
}
end
def ruby_dispatches?(key)
FIRED.clear
::Marshal.load(::Marshal.dump({ key => nil }))
!FIRED.empty?
end
def parser_predicts_dispatch?(key)
parse(::Marshal.dump({ key => nil })).dispatching_hash_keys.any?
end
def test_hash_key_dispatch_prediction_matches_real_ruby
observed = dispatch_probes.to_h { |label, key| [label, ruby_dispatches?(key)] }
assert_includes observed.values, true, "no probe dispatched, so the oracle is dead"
assert_includes observed.values, false, "every probe dispatched, so the oracle proves nothing"
mismatches = dispatch_probes.filter_map do |label, key|
predicted = parser_predicts_dispatch?(key)
next if predicted == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{predicted}"
end
assert_empty mismatches, "parser disagrees with Marshal.load:\n #{mismatches.join("\n ")}"
end
def test_the_same_class_is_only_flagged_in_key_position
in_key = parse(AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH[:object])
in_value = parse(AdversarialCorpus::OBJECT_IN_VALUE_POSITION)
assert_equal 1, in_key.dispatching_hash_keys.length
assert_empty in_value.dispatching_hash_keys,
"position is the whole signal, so a value-position object must not be flagged"
end
def test_every_dispatching_key_shape_is_detected
blind = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH.reject do |_shape, bytes|
parse(bytes).dispatching_hash_keys.any?
end
assert_empty blind.keys, "key-position dispatch missed in: #{blind.keys.join(', ')}"
end
def test_non_dispatching_key_shapes_are_not_flagged
noisy = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH.select do |_shape, bytes|
parse(bytes).dispatching_hash_keys.any?
end
assert_empty noisy.keys, "false positive on: #{noisy.keys.join(', ')}"
end
def test_struct_member_name_slot_is_not_scanned_as_a_hash_key
blob = AdversarialCorpus.stream(
"S#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}" \
"#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus::PLAIN_OBJECT}0"
)
assert_equal 1, parse(blob).nodes.count { |node| node.type == :pair },
"control: the fixture must actually contain a struct pair"
assert_empty parse(blob).dispatching_hash_keys,
"read_pair is shared with read_struct, so struct pairs must not be scanned"
end
class Fixture class Fixture
@instantiated = false @instantiated = false

View File

@ -113,6 +113,8 @@ module Rube
def test_report_partitions_gated_and_ungated def test_report_partitions_gated_and_ungated
report = local_scan report = local_scan
refute_empty report.gated, "a partition test proves nothing if one side is empty"
refute_empty report.ungated, "a partition test proves nothing if one side is empty"
assert_equal report.candidates.length, report.gated.length + report.ungated.length assert_equal report.candidates.length, report.gated.length + report.ungated.length
assert_empty(report.gated & report.ungated) assert_empty(report.gated & report.ungated)
end end
@ -123,9 +125,14 @@ module Rube
assert_equal first, second assert_equal first, second
end end
def test_anonymous_classes_are_skipped def test_anonymous_classes_contribute_no_candidate
Class.new { def marshal_load(data); end } anonymous = Class.new { def marshal_load(data); end }
refute(local_scan.candidates.any? { |c| c.class_name.nil? || c.class_name.empty? }) 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 end
def test_scanning_does_not_instantiate_anything def test_scanning_does_not_instantiate_anything

View File

@ -52,10 +52,50 @@ module Rube
allowed: allowed.freeze }.freeze allowed: allowed.freeze }.freeze
end end
def ivar_chain(depth)
(("I" * depth) + "0" + ("\x00" * depth)).b
end
def array_nest(depth)
("[#{fixnum(1)}" * depth).b
end
def regexp(source)
"/#{fixnum(source.bytesize)}#{source}\x00".b
end
def bignum(sign, word_count, fill = "\x01")
"l#{sign}#{fixnum(word_count)}#{fill * (word_count * BIGNUM_WORD_BYTES)}".b
end
def symlink_run(count)
(sym("s") + (symlink(0) * count)).b
end
def symlink_groups(groups, per_group)
inner = "[#{fixnum(per_group)}#{symlink(0) * per_group}"
"[#{fixnum(groups + 1)}#{sym('s')}#{inner * groups}".b
end
def ivar_run(count)
"#{fixnum(count)}#{sym('@a')}0#{"#{symlink(1)}0" * (count - 1)}".b
end
NESTED_DEPTH = 80 NESTED_DEPTH = 80
WIDE_COUNT = 4_096 WIDE_COUNT = 4_096
MANY_SYMBOLS = 400 MANY_SYMBOLS = 400
HUGE_SCALAR = 300_000 HUGE_SCALAR = 300_000
IVAR_CHAIN_DEPTH = 6_000
USERDEF_NEST_DEPTH = 3
BIGNUM_WORD_BYTES = 2
BIGNUM_HUGE_WORDS = 200_000
LONG_NAME_BYTES = 100_000
BULK_ENTRY_COUNT = 1_000
MODEST_NAME_BYTES = 64
MODEST_ENTRY_COUNT = 16
SYMLINK_BULK_GROUPS = 3
SYMLINK_BULK_PER_GROUP = 1_000
BIGNUM_SIGNS = ["-", "+", "!", "\x00", "\xFF", "0"].freeze
TRIPWIRE_CLASS = "Tripwire" TRIPWIRE_CLASS = "Tripwire"
HOST_CLASS = "Comparable" HOST_CLASS = "Comparable"
@ -95,6 +135,27 @@ module Rube
IVAR_DISTINCT_NAMES_CONTROL = IVAR_DISTINCT_NAMES_CONTROL =
stream("I#{str('hello')}#{fixnum(2)}#{sym(COLLIDING_IVAR)}#{TRIPWIRE}#{sym(HIDDEN_IVAR)}0") stream("I#{str('hello')}#{fixnum(2)}#{sym(COLLIDING_IVAR)}#{TRIPWIRE}#{sym(HIDDEN_IVAR)}0")
PLAIN_OBJECT = "o#{sym(OBJECT_CLASS)}#{fixnum(0)}".b
HASH_KEY_SHAPES_THAT_DISPATCH = {
object: stream("{#{fixnum(1)}#{PLAIN_OBJECT}0"),
object_link: stream("[#{fixnum(2)}#{PLAIN_OBJECT}{#{fixnum(1)}@#{fixnum(1)}0"),
struct: stream("{#{fixnum(1)}S#{sym(OBJECT_CLASS)}#{fixnum(0)}0"),
extended: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{PLAIN_OBJECT}0"),
user_class_over_array: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}[#{fixnum(0)}0")
}.freeze
HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH = {
user_class_over_string: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}#{str('x')}0"),
extended_over_string: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{str('x')}0"),
regexp: stream("{#{fixnum(1)}#{regexp('ab')}0"),
symbol: stream("{#{fixnum(1)}#{sym('k')}0"),
string: stream("{#{fixnum(1)}#{str('k')}0")
}.freeze
OBJECT_IN_VALUE_POSITION =
stream("{#{fixnum(1)}#{sym('k')}#{PLAIN_OBJECT}")
CASES = [ CASES = [
entry(:nil_literal, stream("0"), VERDICT_ACCEPT, entry(:nil_literal, stream("0"), VERDICT_ACCEPT,
"the smallest legal stream"), "the smallest legal stream"),
@ -161,6 +222,9 @@ module Rube
"declares a three-byte integer and supplies one byte"), "declares a three-byte integer and supplies one byte"),
entry(:deep_nesting, stream(("[\x06" * NESTED_DEPTH) + "0"), VERDICT_REJECT, entry(:deep_nesting, stream(("[\x06" * NESTED_DEPTH) + "0"), VERDICT_REJECT,
"nesting past the boundary depth ceiling"), "nesting past the boundary depth ceiling"),
entry(:deep_instance_variable_chain, stream(ivar_chain(IVAR_CHAIN_DEPTH)), VERDICT_REJECT,
"the I wrapper must charge depth, or a 12 KB cookie exhausts the Ruby stack " \
"with a SystemStackError that no rescue StreamError can catch"),
entry(:wide_collection, stream("[#{fixnum(WIDE_COUNT)}#{'0' * WIDE_COUNT}"), VERDICT_REJECT, entry(:wide_collection, stream("[#{fixnum(WIDE_COUNT)}#{'0' * WIDE_COUNT}"), VERDICT_REJECT,
"one collection consuming the whole node budget"), "one collection consuming the whole node budget"),
entry(:many_symbols, stream("[#{fixnum(MANY_SYMBOLS)}#{(0...MANY_SYMBOLS).map { |i| sym("s#{i}") }.join}"), entry(:many_symbols, stream("[#{fixnum(MANY_SYMBOLS)}#{(0...MANY_SYMBOLS).map { |i| sym("s#{i}") }.join}"),
@ -207,7 +271,41 @@ module Rube
allowed: [HOST_CLASS]), allowed: [HOST_CLASS]),
entry(:class_name_slot_extended, CLASS_NAME_SLOTS[:extended], VERDICT_REJECT, entry(:class_name_slot_extended, CLASS_NAME_SLOTS[:extended], VERDICT_REJECT,
"byte-identical gadget in an extend wrapper slot", "byte-identical gadget in an extend wrapper slot",
allowed: [HOST_CLASS]) allowed: [HOST_CLASS]),
entry(:object_in_value_position_control, OBJECT_IN_VALUE_POSITION, VERDICT_ACCEPT,
"control: an allowlisted class is fine as a hash VALUE, which is what makes the " \
"key-position cases below a statement about position rather than about the class",
allowed: [OBJECT_CLASS]),
entry(:hash_key_object, HASH_KEY_SHAPES_THAT_DISPATCH[:object], VERDICT_REJECT,
"an allowlisted class in KEY position runs its #hash during load",
allowed: [OBJECT_CLASS]),
entry(:hash_key_object_link, HASH_KEY_SHAPES_THAT_DISPATCH[:object_link], VERDICT_REJECT,
"the same dispatch reached through an object link instead of a fresh object",
allowed: [OBJECT_CLASS]),
entry(:hash_key_struct, HASH_KEY_SHAPES_THAT_DISPATCH[:struct], VERDICT_REJECT,
"a struct in key position dispatches too",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended, HASH_KEY_SHAPES_THAT_DISPATCH[:extended], VERDICT_REJECT,
"an extended object in key position dispatches the module's #hash",
allowed: [OBJECT_CLASS, HOST_CLASS]),
entry(:hash_key_user_class_over_array, HASH_KEY_SHAPES_THAT_DISPATCH[:user_class_over_array],
VERDICT_REJECT,
"an Array subclass in key position dispatches the subclass #hash",
allowed: [OBJECT_CLASS]),
entry(:hash_key_user_class_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:user_class_over_string],
VERDICT_ACCEPT,
"precision control: rb_any_hash keys on T_STRING, so a String subclass never " \
"reaches a user #hash and rejecting it would be a false positive",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:extended_over_string],
VERDICT_ACCEPT,
"same exemption when the string is reached through an extend wrapper",
allowed: [HOST_CLASS]),
entry(:hash_key_regexp, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:regexp], VERDICT_ACCEPT,
"a regexp key names no class, so the payload cannot choose whose #hash runs")
].freeze ].freeze
end end
end end