feat(rube): M6 boundary detector, implemented against criteria written before the code
Implements the detector Codex specified while it had no implementation to look at, so neither model defined and graded the same thing. Parser-level budgets rather than post-parse checks. Codex's architectural point was that inspecting after parsing is too late because the allocation already happened, so Limits and Budget enforce byte size, depth, node count, registered objects, symbol definitions, collection entries, scalar bytes and object links DURING recursive descent. The size ceiling is checked before the parser is constructed and non-String input is rejected without ever calling to_s. Three policies, STRICT_ALLOWLIST as the default, on the reasoning that people keep defaults far longer than they intend. No enforcing mode accepts an allowed_sinks option, because permitting a class-and-sink pair still authorizes a callback during load. OBSERVE_AND_LOG refuses to construct without a reporter. Allowlisting a class does NOT exempt its sinks, and that is a test. No method is named safe?, trusted?, sanitized? or safe_load, and a test asserts their absence. Those names claim a guarantee this cannot make. The gate demonstrates the documented bypass rather than asserting it. Under DENY_SINKS_ONLY the detector ACCEPTS the CVE-2026-41316 payload, and the gate then loads that accepted snapshot on vulnerable erb and confirms the canary fires. Our own detector, in a shipped mode, admits a payload that achieves code execution. That is the limitation notice being true rather than decorative, and if it ever stops being demonstrable the gate fails. The notice ships verbatim and names the bypass concretely: a payload carrying no sink tag can still reach dangerous code, the published chain produces zero sink tags because ERB defines no marshal_load, and an application that allowlists ERB will accept it. 106 tests, 235 assertions across four suites. Five gates: check, matrix, exploit, detector, target.
This commit is contained in:
parent
66473a6007
commit
b6b5c03d74
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -18,5 +18,9 @@ module Rube
|
|||
class DepthLimitError < StreamError; end
|
||||
|
||||
class MalformedCountError < StreamError; end
|
||||
|
||||
class LimitExceededError < StreamError; end
|
||||
|
||||
class InputTypeError < StreamError; end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue