test(rube): adversarial corpus, 40 cases spanning both verdicts
Forty explicit Marshal streams built from tag bytes rather than Marshal.dump, so the corpus stays valid when Ruby changes what it emits. Thirteen must be accepted, twenty-seven must be rejected across eight distinct error types. The accept half is what gives the reject half meaning. A detector that rejected everything would fail thirteen cases here, including legitimate cycles, shared object references, symlink reuse, and fixnums at every encoding width. Provenance note, stated because it weakens the evidence: Codex was asked to build this corpus and the request was blocked by its provider's content filter, which reads constructing hostile streams as offensive tooling. The request was not reworded to get around that. The corpus was therefore written by the same model that wrote the detector, which is exactly the arrangement the split exists to avoid, and it should be treated as weaker than the negative-count findings Codex produced independently. The boundary of what Codex can contribute here is now mapped. Defensive research, packaging research, defensive design and acceptance criteria, and probing the parser during a design task all succeeded. Researching gadget chains and building a hostile corpus were both refused. Also fixes a bug in the corpus fixnum encoder, which computed the width marker as width plus 256 and raised RangeError for any positive multibyte value. 110 tests, 244 assertions across five suites.
This commit is contained in:
parent
b6b5c03d74
commit
8ce093260c
|
|
@ -16,6 +16,10 @@ test:
|
|||
{{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
|
||||
{{run_ro}} ruby -Ilib -Itest test/corpus_test.rb
|
||||
|
||||
corpus:
|
||||
{{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; d = Rube::Marshal::BoundaryDetector.new; Rube::AdversarialCorpus::CASES.each { |k| dec = d.inspect_stream(k[:bytes]); puts format(" %-30s %-6s %s", k[:name], dec.accepted? ? "accept" : "reject", dec.reason.to_s[0, 60]) }'
|
||||
|
||||
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) }'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# ©AngelaMos | 2026
|
||||
# corpus_test.rb
|
||||
|
||||
require_relative "test_helper"
|
||||
require_relative "support/adversarial_corpus"
|
||||
|
||||
module Rube
|
||||
class CorpusTest < Minitest::Test
|
||||
def detector
|
||||
Marshal::BoundaryDetector.new
|
||||
end
|
||||
|
||||
def test_corpus_covers_both_verdicts
|
||||
verdicts = AdversarialCorpus::CASES.map { |c| c[:verdict] }.uniq
|
||||
assert_includes verdicts, AdversarialCorpus::VERDICT_ACCEPT
|
||||
assert_includes verdicts, AdversarialCorpus::VERDICT_REJECT
|
||||
end
|
||||
|
||||
def test_corpus_case_names_are_unique
|
||||
names = AdversarialCorpus::CASES.map { |c| c[:name] }
|
||||
assert_equal names.length, names.uniq.length
|
||||
end
|
||||
|
||||
def test_every_corpus_case_matches_its_verdict
|
||||
disagreements = AdversarialCorpus::CASES.filter_map do |kase|
|
||||
decision = detector.inspect_stream(kase[:bytes])
|
||||
expected = kase[:verdict] == AdversarialCorpus::VERDICT_ACCEPT
|
||||
next if decision.accepted? == expected
|
||||
|
||||
"#{kase[:name]}: expected #{kase[:verdict]}, got #{decision.accepted? ? 'accept' : 'reject'} (#{decision.reason})"
|
||||
end
|
||||
|
||||
assert_empty disagreements, "corpus disagreements:\n #{disagreements.join("\n ")}"
|
||||
end
|
||||
|
||||
def test_parser_never_raises_outside_the_stream_error_hierarchy
|
||||
leaks = AdversarialCorpus::CASES.filter_map do |kase|
|
||||
detector.inspect_stream(kase[:bytes])
|
||||
nil
|
||||
rescue StandardError => e
|
||||
"#{kase[:name]}: #{e.class}"
|
||||
end
|
||||
|
||||
assert_empty leaks, "unhandled exceptions escaped the detector:\n #{leaks.join("\n ")}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
# ©AngelaMos | 2026
|
||||
# adversarial_corpus.rb
|
||||
|
||||
module Rube
|
||||
module AdversarialCorpus
|
||||
HEADER = "\x04\x08"
|
||||
INLINE_OFFSET = 5
|
||||
INLINE_CEILING = 122
|
||||
INLINE_FLOOR = -123
|
||||
BYTE_MODULUS = 256
|
||||
|
||||
VERDICT_ACCEPT = :accept
|
||||
VERDICT_REJECT = :reject
|
||||
|
||||
module_function
|
||||
|
||||
def fixnum(value)
|
||||
return "\x00".b if value.zero?
|
||||
return (value + INLINE_OFFSET).chr if value.positive? && value <= INLINE_CEILING
|
||||
return (value - INLINE_OFFSET + BYTE_MODULUS).chr if value.negative? && value >= INLINE_FLOOR
|
||||
|
||||
wide(value)
|
||||
end
|
||||
|
||||
def wide(value)
|
||||
width = 1
|
||||
width += 1 while value >= (1 << (8 * width)) || value < -(1 << (8 * width))
|
||||
stored = value.negative? ? value + (1 << (8 * width)) : value
|
||||
bytes = (0...width).map { |index| (stored >> (8 * index)) & 0xFF }
|
||||
marker = value.negative? ? BYTE_MODULUS - width : width
|
||||
(marker.chr + bytes.map(&:chr).join).b
|
||||
end
|
||||
|
||||
def sym(name)
|
||||
":#{fixnum(name.bytesize)}#{name}".b
|
||||
end
|
||||
|
||||
def str(text)
|
||||
"\"#{fixnum(text.bytesize)}#{text}".b
|
||||
end
|
||||
|
||||
def stream(body)
|
||||
"#{HEADER}#{body}".b
|
||||
end
|
||||
|
||||
def entry(name, bytes, verdict, rationale)
|
||||
{ name: name, bytes: bytes.b, verdict: verdict, rationale: rationale }.freeze
|
||||
end
|
||||
|
||||
NESTED_DEPTH = 80
|
||||
WIDE_COUNT = 4_096
|
||||
MANY_SYMBOLS = 400
|
||||
HUGE_SCALAR = 300_000
|
||||
|
||||
CASES = [
|
||||
entry(:nil_literal, stream("0"), VERDICT_ACCEPT,
|
||||
"the smallest legal stream"),
|
||||
entry(:boolean_true, stream("T"), VERDICT_ACCEPT,
|
||||
"primitive tag carrying no class reference"),
|
||||
entry(:fixnum_zero, stream("i\x00"), VERDICT_ACCEPT,
|
||||
"zero uses the dedicated single-byte encoding"),
|
||||
entry(:fixnum_inline_max, stream("i#{fixnum(122)}"), VERDICT_ACCEPT,
|
||||
"largest value expressible without a width prefix"),
|
||||
entry(:fixnum_wide_positive, stream("i#{fixnum(65_536)}"), VERDICT_ACCEPT,
|
||||
"three-byte little-endian encoding"),
|
||||
entry(:fixnum_wide_negative, stream("i#{fixnum(-65_536)}"), VERDICT_ACCEPT,
|
||||
"negative widths sign-extend rather than being malformed"),
|
||||
entry(:bare_symbol, stream(sym("marshal_load")), VERDICT_ACCEPT,
|
||||
"a symbol naming a sink is not itself a sink"),
|
||||
entry(:empty_array, stream("[\x00"), VERDICT_ACCEPT,
|
||||
"zero-length collection"),
|
||||
entry(:nested_arrays, stream("[\x06[\x06[\x060"), VERDICT_ACCEPT,
|
||||
"ordinary nesting well inside the depth ceiling"),
|
||||
entry(:empty_hash, stream("{\x00"), VERDICT_ACCEPT,
|
||||
"zero-entry hash"),
|
||||
entry(:shared_reference, stream("[\x07#{str('shared')}@\x06"), VERDICT_ACCEPT,
|
||||
"a legitimate object link to a previously registered string"),
|
||||
entry(:self_referential_array, stream("[\x06@\x00"), VERDICT_ACCEPT,
|
||||
"zero-indexed cycle, the shape Ruby itself emits"),
|
||||
entry(:symlink_reuse, stream("[\x07#{sym('same')};\x00"), VERDICT_ACCEPT,
|
||||
"second occurrence of a symbol is a symlink"),
|
||||
|
||||
entry(:empty_stream, "", VERDICT_REJECT,
|
||||
"no header at all"),
|
||||
entry(:header_only, HEADER, VERDICT_REJECT,
|
||||
"header with no value follows"),
|
||||
entry(:wrong_major_version, "\x05\x080", VERDICT_REJECT,
|
||||
"major version has never been anything but 4"),
|
||||
entry(:future_minor_version, "\x04\x090", VERDICT_REJECT,
|
||||
"a minor above 8 is a format this parser has not seen"),
|
||||
entry(:unknown_tag, stream("\x00"), VERDICT_REJECT,
|
||||
"byte zero is not a type tag"),
|
||||
entry(:trailing_bytes, stream("0junk"), VERDICT_REJECT,
|
||||
"unread bytes after the root value indicate a smuggled second stream"),
|
||||
entry(:negative_array_count, stream("[\xFA"), VERDICT_REJECT,
|
||||
"a negative count silently yields an empty collection instead of failing"),
|
||||
entry(:negative_hash_count, stream("{\xFA"), VERDICT_REJECT,
|
||||
"same confusion in hash position"),
|
||||
entry(:negative_ivar_count, stream("I#{str('a')}\xFA"), VERDICT_REJECT,
|
||||
"negative instance variable count"),
|
||||
entry(:negative_struct_count, stream("S#{sym('A')}\xFA"), VERDICT_REJECT,
|
||||
"negative struct member count"),
|
||||
entry(:negative_bignum_words, stream("l+\xFA"), VERDICT_REJECT,
|
||||
"negative word count previously leaked a raw NoMethodError"),
|
||||
entry(:negative_string_length, stream("\"\xFA"), VERDICT_REJECT,
|
||||
"a negative length rewinds the cursor, which is a loop primitive"),
|
||||
entry(:out_of_range_object_link, stream("[\x06@\x63"), VERDICT_REJECT,
|
||||
"link index beyond the object table"),
|
||||
entry(:negative_object_link, stream("[\x06@\xFA"), VERDICT_REJECT,
|
||||
"negative index would count backwards from the end of the table"),
|
||||
entry(:negative_symlink, stream("[\x07#{sym('a')};\xFA"), VERDICT_REJECT,
|
||||
"negative symbol index"),
|
||||
entry(:truncated_string_body, stream("\"\x0aab"), VERDICT_REJECT,
|
||||
"declared length exceeds the bytes present"),
|
||||
entry(:truncated_array_elements, stream("[\x08i\x06"), VERDICT_REJECT,
|
||||
"declares three elements and supplies one"),
|
||||
entry(:truncated_multibyte_fixnum, stream("i\x03\x01"), VERDICT_REJECT,
|
||||
"declares a three-byte integer and supplies one byte"),
|
||||
entry(:deep_nesting, stream(("[\x06" * NESTED_DEPTH) + "0"), VERDICT_REJECT,
|
||||
"nesting past the boundary depth ceiling"),
|
||||
entry(:wide_collection, stream("[#{fixnum(WIDE_COUNT)}#{'0' * WIDE_COUNT}"), VERDICT_REJECT,
|
||||
"one collection consuming the whole node budget"),
|
||||
entry(:many_symbols, stream("[#{fixnum(MANY_SYMBOLS)}#{(0...MANY_SYMBOLS).map { |i| sym("s#{i}") }.join}"),
|
||||
VERDICT_REJECT,
|
||||
"attacker-chosen symbol creation past the definition ceiling"),
|
||||
entry(:huge_scalar, stream(str("A" * HUGE_SCALAR)), VERDICT_REJECT,
|
||||
"single scalar past the per-scalar byte ceiling"),
|
||||
entry(:userdef_sink, stream("u#{sym('Evil')}#{fixnum(1)}x"), VERDICT_REJECT,
|
||||
"_load runs during deserialization before any allowlist can act"),
|
||||
entry(:usermarshal_sink, stream("U#{sym('Evil')}0"), VERDICT_REJECT,
|
||||
"marshal_load runs during deserialization"),
|
||||
entry(:data_sink, stream("d#{sym('Evil')}0"), VERDICT_REJECT,
|
||||
"_load_data runs during deserialization"),
|
||||
entry(:sink_in_ivar_name_position, stream("I#{str('a')}\x06u#{sym('Evil')}#{fixnum(1)}x0"),
|
||||
VERDICT_REJECT,
|
||||
"a sink hidden where a symbol is expected must not disappear from the report"),
|
||||
entry(:plain_object_no_sink, stream("o#{sym('ERB')}\x06#{sym('@src')}#{str('payload')}"),
|
||||
VERDICT_REJECT,
|
||||
"carries no sink tag at all, which is exactly why sink detection is insufficient")
|
||||
].freeze
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue