fix(rube): clear the entire S2 backlog tier - a non-answer is never an answer

B17 through B30, fourteen items. Every one reproduced before it was touched
and mutation-proven after. 194 tests from 119, rubocop 903 offenses to 0,
all six gate stages green.

One rule runs through all of it: nothing may present a guess, a default, or
a swallowed error as a verdict.

Decision states (B19). Under POLICY_OBSERVE_AND_LOG a payload carrying a live
Gem::Requirement#marshal_load snapshot reported accepted? true AND rejected?
false, so both obvious caller shapes loaded it. The predicate pair could not
express the third outcome, so there was no safe branch to pick - the prior
note claiming target/app.rb sidestepped this by branching on rejected? was
wrong, and both forms were byte-for-byte equivalent in outcome. accepted?,
rejected? and would_reject? are removed rather than redefined, so copying
`if d.accepted?` now raises NoMethodError instead of silently changing
meaning. One state validated in the constructor, three exclusive predicates,
and proceed? is the only one that may gate a Marshal.load. Observe-and-log
stays non-blocking and still hands back its snapshot; the monitoring caller
writes `proceed? || observed?` and names the state out loud.

Scanner error accounting (B18). Five rescues returned nil, [] or false and
told nobody. They now record site, subject and error class, and Report
exposes suppressed_count, suppressions_by_site, complete? and
candidates_lost?. Wiring the counter immediately surfaced 3 suppressions on
a stock image that had always been invisible: <internal:symbol>,
<internal:pathname_builtin> and <internal:ractor> all fail Prism.parse_file
with ENOENT because Ruby hands out those paths but they are not files.

The fourth state (B29). Those 3 suppressions were also 7 wrong answers -
candidates scored "does not touch state", indistinguishable from analysed
and inert, and 4 zero-arity ungated ones silently dropped from reachable.
touches_state is now four-state. A source that was given and could not be
parsed fails OPEN and stays reachable, because a scanner that discards what
it failed to analyse is the exact failure mode B18 names. A C-defined method
with no Ruby source at all is reported as unanalysable instead: 132 of 173
candidates, and failing open there would take reachable from 25 to 74 of 165
ungated and stop the filter filtering. Report#unanalysable and
#fully_analysed? state the real coverage - 33 of 173 - rather than implying
the filter saw everything. The 5 recovered candidates were verified by
executing them, not by reading source we could not read; one of them,
ERB::Compiler::PercentLine#to_s, is an alias of an attr_reader, which is a
second and distinct analysis gap.

Reason escaping (B20). Reject reasons interpolated raw attacker bytes into a
caller-supplied reporter. A class name carrying CR, LF, ESC and NUL turned
one reporter call into three log lines, the middle one forging a successful
authentication. All three interpolation sites now truncate at 96 bytes and
inspect the binary form, so no byte below 0x20 survives and the value is
quote-delimited.

Target hardening (B21, B22). The defended endpoint returned HTTP 500 with a
source line for three roots the detector had just accepted, leaking paths the
same way the B3 mutant did. show_exceptions is off, the shape is checked, and
the gate now greps every error body for source paths. ALLOWED_CLASSES could
never match anything, measured: a benign session cookie carries zero class
names. It is PERMITTED_CLASS_NAMES = [] now, which is what the app actually
requires and is strictly tighter - the old list admitted a C-wrapped String,
and under a mutant restoring it only the new shape check stopped that payload.

Gated agreement (B23). GATED_SINK_TAGS omitted TAG_DATA while the scanner
listed _load_data. The scanner was right, and this is now execution evidence
rather than a reading of marshal.c: a hand-built d stream naming Thread::Mutex,
a real C-level T_DATA, raises TypeError naming the missing _load_data, while
the same stream naming String dies earlier at "dump format error" - which is
why the previous attempt could not see it. A test compares both definitions
directly so they cannot drift again.

Float fidelity (B17). read_float returned nil for seven body forms
Marshal.load accepts, two more than the finding listed. Ruby uses its own
ruby_strtod, so "INF" is 0.0 while "inf" is Infinity, and String#to_f turns
out to be that same function. Ruby's legacy binary mantissa is NOT decoded:
a model fitted to four oracle points passed a 25-case table and then failed
930 of 5000 randomised cases, and marshal.c is not available in these images.
Since 0 of 209 Marshal.dump outputs contain a NUL, no living Ruby emits that
form, so the parser records the strtod prefix and flags Node#undecoded_tail
instead. A plausible wrong number is worse than a labelled non-answer.
Final differential: 2919 agreed exactly, 2081 flagged, 0 claimed-and-wrong.

Hygiene (B24, B25, B27, B28). PRIMITIVE_CLASS_NAMES and NAMESPACE_SEPARATOR
had one reference each, their own definition. BIGNUM_SIGN_POSITIVE is live
and stays. The width > FIXNUM_MAX_WIDTH guard is unreachable for all 256
possible marker bytes, checked exhaustively, and raised the wrong error class;
it is replaced by a test that derives widths from real Marshal.dump output.
The symlink and object-link bounds checks no longer lean on negative-index
wraparound. The parse graph is sealed before it is returned - every node, its
collections and its scalars frozen - and the whole suite stayed green first
try, which proves nothing downstream was mutating it. exploit-gate.sh pins
both sides now, 4.0.2-slim erb 6.0.1 FIRED against 4.0.6-slim erb 6.0.1.1
BLOCKED; the finding's claim about detector-gate.sh was wrong, it never had
a patched side.

Lint (B30). just lint used a --network none runner, so gem install could
never reach RubyGems, the && short-circuited, and the recipe exited 2 while
printing absolutely nothing. That is the fourth instance of a dropped return
value hiding a failure in this project. It is loud now. The config had never
been validated against a real run: 903 offenses, dominated by a quote style
the codebase does not use. frozen_string_literal was verified safe by running
the whole suite under RUBYOPT=--enable-frozen-string-literal BEFORE the change,
so ~247 offenses were retired by fixing code rather than silencing a cop.
Every remaining disabled cop carries a reason.
This commit is contained in:
CarterPerez-dev 2026-07-29 14:35:48 -04:00
parent a866587f10
commit 8d7b114fb9
36 changed files with 1124 additions and 140 deletions

View File

@ -11,20 +11,40 @@ AllCops:
TargetRubyVersion: 3.3
Exclude:
- "vendor/**/*"
- "target/**/*"
- "docs/**/*"
Style/Documentation:
Enabled: false
Style/FrozenStringLiteralComment:
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: single_quotes
Style/FormatStringToken:
Enabled: false
Style/RedundantFormat:
Enabled: false
Style/StringConcatenation:
Enabled: false
Security/MarshalLoad:
Enabled: false
Minitest/EmptyLineBeforeAssertionMethods:
Enabled: false
Minitest/MultipleAssertions:
Max: 12
Metrics/MethodLength:
Max: 30
Metrics/AbcSize:
Max: 30
Max: 50
Metrics/CyclomaticComplexity:
Max: 30
@ -32,8 +52,47 @@ Metrics/CyclomaticComplexity:
Metrics/PerceivedComplexity:
Max: 30
Metrics/ParameterLists:
CountKeywordArgs: false
Metrics/ClassLength:
Max: 250
Exclude:
- "test/**/*"
Metrics/ModuleLength:
Exclude:
- "test/**/*"
Layout/LineLength:
Max: 120
Lint/RescueException:
Exclude:
- "test/**/*"
Lint/EmptyClass:
Exclude:
- "test/**/*"
Lint/UselessMethodDefinition:
Exclude:
- "test/**/*"
Style/MutableConstant:
Exclude:
- "test/**/*"
Minitest/AssertTruthy:
Enabled: false
Minitest/RefuteFalse:
Enabled: false
Style/EvalWithLocation:
Exclude:
- "test/**/*"
Style/OptionalBooleanParameter:
Exclude:
- "test/**/*"

View File

@ -26,8 +26,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
CVE-2026-41316 (ERB `@_init`) as the reference chain
- Deliberately vulnerable Sinatra target with one endpoint that loads a session
cookie directly and one that inspects the stream first
- `BoundaryDetector` with three policies, a frozen accepted snapshot, and a
written `LIMITATION_NOTICE` naming a bypass it cannot catch
- `BoundaryDetector` with three policies, a frozen snapshot on any decision that
is not blocked, and a written `LIMITATION_NOTICE` naming a bypass it cannot
catch
- `Decision` reports exactly one of three states — `proceed?`, `blocked?`,
`observed?` — validated in the constructor so they cannot overlap. `proceed?`
is the only predicate that should gate a `Marshal.load`; `observed?` is the
non-blocking observe-and-log outcome and a caller opts into it by name. There
is no `accepted?`, because one predicate cannot answer both "did the policy
permit this" and "is this stream free of violations"
- Scanner error accounting: every swallowed rescue is recorded with its site,
subject and error class, and `Report` exposes `suppressed_count`,
`suppressions_by_site`, `complete?` and `candidates_lost?`
- Four-state reachability analysis. A method is analysed (touches state or does
not), `unreadable_source?` (a path was given and could not be parsed, so it
fails open and stays reachable), or `unanalysable?` (no Ruby source exists at
all, so it is reported rather than guessed at). `Report#unanalysable` and
`#fully_analysed?` state the filter's real coverage instead of implying it saw
everything
- Reject reasons escape and bound every attacker-controlled class name before it
reaches a caller-supplied reporter, so a name carrying CR, LF, ESC or NUL can
no longer forge log lines
- Float bodies decode to the same value `Marshal.load` produces, including
`inf`, `-inf` and `nan`, hex literals, and the prefix-and-stop behaviour that
makes `"1_0"` parse as 1.0 and `"abc"` as 0.0. A body carrying Ruby's legacy
binary mantissa is reported through `Node#undecoded_tail` rather than guessed
at, so `fully_decoded?` is false instead of a plausible wrong number
- The parse graph is sealed before it is returned. Every node, its collections
and its scalar values are frozen, so a caller cannot rewrite a verdict field
or splice a node into a graph the parser already reported on
### Fixed
- `read_float` returned `nil` for seven classes of body that `Marshal.load`
accepts, including the `inf`/`-inf`/`nan` forms Ruby emits today
- Bounds checks on symlink and object-link indices relied on negative-index
wraparound being caught by a second clause; they now say what they mean
- `Constants::GATED_SINK_TAGS` omitted `TAG_DATA` while `Scanner::GATED_METHODS`
listed `_load_data`, so the two halves disagreed about which sinks are gated.
`Marshal.load` does check `respond_to?(:_load_data)` before dispatching, which
a hand-built `d` stream naming a real C-level `T_DATA` demonstrates directly
- The defended target endpoint returned HTTP 500 with a source snippet for any
root the detector accepted that was not a session hash
- Detection of objects placed in **hash key** position, where `#hash` and `#eql?`
are dispatched during load before any allowlist can act. Scoped to keys whose
reconstructed value is not a `T_STRING`, matching what `Marshal.load` actually

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# Gemfile
# frozen_string_literal: true
source "https://rubygems.org"

View File

@ -37,7 +37,9 @@ All six pieces are built and tested.
- **Version-compatibility matrix** — probes six pinned Ruby images and reports where the
published git gadget and the ERB `@_init` guard actually change.
- **Reflection-based gadget scanner** — walks `ObjectSpace` for auto-invoked methods and
classifies them by whether `Marshal.load` can reach them.
classifies them by whether `Marshal.load` can reach them. It counts every error it
swallows, names the site, and treats a method it could not analyse as reachable rather
than inert, so under-reporting is visible instead of silent.
- **Payload builder** — version-scoped chains carrying their own affected ranges.
- **Vulnerable containerized target** — a Sinatra app with one endpoint that loads a
session cookie and one that inspects it first.
@ -70,12 +72,33 @@ and hands back a frozen snapshot:
detector = Rube::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
decision = detector.inspect_stream(untrusted_bytes)
decision.rejected? # => true
decision.blocked? # => true
decision.reason # => "stream reaches Gem::Requirement#marshal_load during load, ..."
```
Read `Rube::Marshal::BoundaryDetector::LIMITATION_NOTICE` before relying on an accept.
An accepted stream is not a safe one, and the notice says so in detail.
A decision is in exactly one of three states, and `proceed?` is the only one that gates a
load:
```ruby
Marshal.load(decision.snapshot) if decision.proceed?
```
`proceed?` means the policy found no violation. `blocked?` means it found one and refused.
`observed?` is the third state, and it exists because `POLICY_OBSERVE_AND_LOG` is
non-blocking by design: a violation was found, reported, and deliberately not enforced. Such
a decision still carries its snapshot, so a caller running in monitoring mode opts in by
naming that state out loud:
```ruby
Marshal.load(decision.snapshot) if decision.proceed? || decision.observed?
```
There is no `accepted?`. The question "did the policy permit this" and the question "is
this stream free of violations" have different answers under observe-and-log, and one
predicate cannot answer both.
Read `Rube::Marshal::BoundaryDetector::LIMITATION_NOTICE` before relying on `proceed?`.
A stream that proceeds is not a safe one, and the notice says so in detail.
Nothing above instantiates a class, calls a constructor, or invokes `Marshal.load`.

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# Rakefile
# frozen_string_literal: true
require "rake/testtask"

View File

@ -7,6 +7,7 @@ image := "ruby:4.0-slim"
vuln_image := "ruby:4.0.2-slim"
run := "docker run --rm --network none -v $PWD:/app -w /app " + image
run_ro := "docker run --rm --network none -v $PWD:/app:ro -w /app " + image
lint_run := "docker run --rm -v $PWD:/app -w /app " + image
default:
@just --list
@ -19,16 +20,16 @@ test:
{{run_ro}} ruby -Ilib -Itest test/corpus_test.rb
corpus:
{{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; Rube::AdversarialCorpus::CASES.each { |k| d = Rube::Marshal::BoundaryDetector.new(allowed_class_names: k[:allowed]); dec = d.inspect_stream(k[:bytes]); puts format(" %-38s %-6s %-10s %s", k[:name], dec.accepted? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }'
{{run_ro}} ruby -Ilib -Itest -e 'require "rube"; require "support/adversarial_corpus"; Rube::AdversarialCorpus::CASES.each { |k| d = Rube::Marshal::BoundaryDetector.new(allowed_class_names: k[:allowed]); dec = d.inspect_stream(k[:bytes]); puts format(" %-38s %-6s %-10s %s", k[:name], dec.proceed? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }'
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) }'
{{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} suppressed=#{r.suppressed_count} candidates_lost=#{r.candidates_lost?}"; puts "analysed=#{r.candidates.count(&:state_known?)} unanalysable=#{r.unanalysable.length} unreadable=#{r.candidates.count(&:unreadable_source?)}"; puts; r.reachable.each { |c| puts format(" %-10s %-46s %s", c.gate, c.to_s, c.source_location) }; unless r.complete?; puts; puts "suppressed errors (this scan under-reports):"; r.suppressions_by_site.each { |site, n| puts format(" %-16s %d", site, n) }; end; unless r.fully_analysed?; puts; puts "#{r.unanalysable.length} candidates have no Ruby source and were never analysed; the reachability filter does not cover them"; end'
control:
{{run_ro}} ruby -Ilib -Itest test/control_check.rb
lint:
{{run}} sh -c "gem install --no-document rubocop rubocop-minitest rubocop-performance rubocop-rake >/dev/null 2>&1 && rubocop --force-exclusion"
{{lint_run}} sh -c "set -e; gem install --no-document rubocop rubocop-minitest rubocop-performance rubocop-rake >/dev/null; rubocop --force-exclusion"
check: test control

View File

@ -1,10 +1,12 @@
# ©AngelaMos | 2026
# rube.rb
# frozen_string_literal: true
require_relative "rube/version"
require_relative "rube/marshal/constants"
require_relative "rube/marshal/errors"
require_relative "rube/marshal/node"
require_relative "rube/marshal/float_body"
require_relative "rube/marshal/limits"
require_relative "rube/marshal/parser"
require_relative "rube/marshal/boundary_detector"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# chains.rb
# frozen_string_literal: true
module Rube
module Chains
@ -8,14 +9,16 @@ module Rube
@registry = []
class << self
attr_reader :registry
def registry
@registry.dup.freeze
end
def register(chain)
@registry << chain unless @registry.include?(chain)
end
def all
registry.reject { |chain| chain == Base }
@registry.reject { |chain| chain == Base }
end
def find(name)

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# base.rb
# frozen_string_literal: true
module Rube
module Chains
@ -8,7 +9,6 @@ module Rube
class NotImplementedByChainError < ChainError; end
class Base
NAMESPACE_SEPARATOR = "::"
SUBCLASS_MUST_DEFINE = "chain must define"
class << self

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# erb_def_method.rb
# frozen_string_literal: true
require "erb"
@ -27,7 +28,7 @@ module Rube
IVAR_FILENAME = :@filename
IVAR_LINENO = :@lineno
CANARY_TEMPLATE = 'File.write(%<path>p, %<marker>p)'
CANARY_TEMPLATE = "File.write(%<path>p, %<marker>p)"
def self.metadata
{

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# boundary_detector.rb
# frozen_string_literal: true
module Rube
module Marshal
@ -12,8 +13,6 @@ module Rube
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"
@ -23,7 +22,14 @@ module Rube
REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
"this format emits %d.%d"
LIMITATION_NOTICE = <<~NOTICE.freeze
REASON_MAX_NAME_BYTES = 96
REASON_MAX_NAMES = 8
REASON_TRUNCATED_MARKER = "[truncated"
REASON_TRUNCATED = "#{REASON_TRUNCATED_MARKER}, +%d bytes]".freeze
REASON_ELIDED_NAMES = ", and %d more"
REASON_NAME_SEPARATOR = ", "
LIMITATION_NOTICE = <<~NOTICE
SECURITY LIMITATION
Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
@ -44,31 +50,36 @@ module Rube
NOTICE
class Decision
attr_reader :reason, :snapshot, :result
STATE_PROCEED = :proceed
STATE_BLOCKED = :blocked
STATE_OBSERVED = :observed
def initialize(accepted:, reason: nil, snapshot: nil, result: nil, observed: false, would_reject: false)
@accepted = accepted
STATES = [STATE_PROCEED, STATE_BLOCKED, STATE_OBSERVED].freeze
STATE_PREDICATES = %i[proceed? blocked? observed?].freeze
UNKNOWN_STATE = "unknown decision state %p, expected one of %s"
attr_reader :state, :reason, :snapshot, :result
def initialize(state:, reason: nil, snapshot: nil, result: nil)
raise ArgumentError, format(UNKNOWN_STATE, state, STATES.join(", ")) unless STATES.include?(state)
@state = state
@reason = reason
@snapshot = snapshot
@result = result
@observed = observed
@would_reject = would_reject
end
def accepted?
@accepted
def proceed?
state == STATE_PROCEED
end
def rejected?
!@accepted
def blocked?
state == STATE_BLOCKED
end
def observed?
@observed
end
def would_reject?
@would_reject
state == STATE_OBSERVED
end
end
@ -106,12 +117,28 @@ module Rube
reject(violation)
end
def quoted(name)
raw = name.to_s.dup.force_encoding(Encoding::BINARY)
return raw.inspect if raw.bytesize <= REASON_MAX_NAME_BYTES
"#{raw.byteslice(0, REASON_MAX_NAME_BYTES).inspect}" \
"#{format(REASON_TRUNCATED, raw.bytesize - REASON_MAX_NAME_BYTES)}"
end
def quoted_list(names)
shown = names.first(REASON_MAX_NAMES).map { |name| quoted(name) }.join(REASON_NAME_SEPARATOR)
elided = names.length - REASON_MAX_NAMES
return shown unless elided.positive?
"#{shown}#{format(REASON_ELIDED_NAMES, elided)}"
end
def violation_for(result)
sink = result.sinks.first
return format(REASON_SINK, sink.class_name, sink.sink_method) if sink
return format(REASON_SINK, quoted(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 format(REASON_KEY_DISPATCH, quoted(key.effective_class_name)) if key
return nil if policy == POLICY_DENY_SINKS_ONLY
@ -121,23 +148,23 @@ module Rube
end
unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty?
return format(REASON_UNAPPROVED, quoted_list(unapproved)) 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)
Decision.new(state: Decision::STATE_OBSERVED, reason: violation,
snapshot: snapshot, result: result)
end
def accept(snapshot, result)
Decision.new(accepted: true, snapshot: snapshot, result: result)
Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: result)
end
def reject(reason)
Decision.new(accepted: false, reason: reason)
Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
end
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# constants.rb
# frozen_string_literal: true
module Rube
module Marshal
@ -34,6 +35,16 @@ module Rube
TAG_DATA = "d"
TAG_IVAR = "I"
NUL_BYTE = "\x00"
FLOAT_NAN = "nan"
FLOAT_INFINITY = "inf"
FLOAT_NEGATIVE_INFINITY = "-inf"
FLOAT_LEADING_SPACE = "[ \t\n\v\f\r]*"
FLOAT_HEX_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?0[xX](?:\h+(?:\.\h*)?|\.\h+)(?:[pP][+-]?\d+)?/
FLOAT_DECIMAL_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
BIGNUM_SIGN_POSITIVE = "+"
BIGNUM_SIGN_NEGATIVE = "-"
BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze
@ -69,7 +80,7 @@ module Rube
TAG_DATA => "_load_data"
}.freeze
GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL].freeze
GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
end
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# errors.rb
# frozen_string_literal: true
module Rube
module Marshal

View File

@ -0,0 +1,40 @@
# ©AngelaMos | 2026
# float_body.rb
# frozen_string_literal: true
module Rube
module Marshal
module FloatBody
module_function
def decode(body)
text = body.b
named = named_value(text)
return [named, nil] if named
token = prefix(text)
tail = text.byteslice(token.bytesize..)
[value_of(token), tail.empty? ? nil : tail]
end
def named_value(text)
case text.byteslice(0, text.index(Constants::NUL_BYTE) || text.bytesize)
when Constants::FLOAT_NAN then Float::NAN
when Constants::FLOAT_INFINITY then Float::INFINITY
when Constants::FLOAT_NEGATIVE_INFINITY then -Float::INFINITY
end
end
def prefix(text)
match = Constants::FLOAT_HEX_PREFIX.match(text) ||
Constants::FLOAT_DECIMAL_PREFIX.match(text)
match ? match[0] : ""
end
def value_of(token)
stripped = token.strip
stripped.empty? ? 0.0 : Float(stripped)
end
end
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# limits.rb
# frozen_string_literal: true
module Rube
module Marshal

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# node.rb
# frozen_string_literal: true
module Rube
module Marshal
@ -7,19 +8,24 @@ module Rube
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, :undecoded_tail
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, undecoded_tail: nil)
@type = type
@tag = tag
@value = value
@class_name = class_name
@undecoded_tail = undecoded_tail
@children = []
@instance_variables_map = {}
@auxiliary = []
end
def fully_decoded?
undecoded_tail.nil?
end
def sink?
Constants::SINK_TAGS.include?(tag)
end
@ -58,6 +64,16 @@ module Rube
children.each { |child| child.each(&block) }
auxiliary.each { |child| child.each(&block) }
end
def seal
value.freeze
class_name.freeze
undecoded_tail.freeze
children.freeze
auxiliary.freeze
instance_variables_map.freeze
freeze
end
end
class Result

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# parser.rb
# frozen_string_literal: true
module Rube
module Marshal
@ -24,7 +25,8 @@ module Rube
root = read_value(1)
raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero?
Result.new(root, major: @major, minor: @minor)
root.each(&:seal)
Result.new(root, major: @major, minor: @minor).freeze
end
private
@ -80,8 +82,6 @@ module Rube
return marker + FIXNUM_INLINE_OFFSET if marker < FIXNUM_MIN_INLINE
width = marker.abs
raise TruncatedStreamError, "fixnum width #{width}" if width > FIXNUM_MAX_WIDTH
value = little_endian(take(width))
marker.negative? ? value - (1 << (BITS_PER_BYTE * width)) : value
end
@ -155,7 +155,8 @@ module Rube
def read_symlink(tag)
budget.symbol_reference!
index = read_fixnum
raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0
raise InvalidLinkError, "symlink #{index} of #{symbols.length}" if
index.negative? || index >= symbols.length
Node.new(type: :symlink, tag: tag, value: symbols[index])
end
@ -163,7 +164,8 @@ module Rube
def read_object_link(tag)
budget.link!
index = read_fixnum
raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0
raise InvalidLinkError, "object link #{index} of #{objects.length}" if
index.negative? || index >= objects.length
node = Node.new(type: :object_link, tag: tag, value: index)
node.link_target = objects[index]
@ -182,9 +184,8 @@ module Rube
end
def read_float(tag)
Node.new(type: :float, tag: tag, value: Float(read_counted_bytes))
rescue ArgumentError
Node.new(type: :float, tag: tag)
value, tail = FloatBody.decode(read_counted_bytes)
Node.new(type: :float, tag: tag, value: value, undecoded_tail: tail)
end
def read_string(tag)
@ -242,7 +243,7 @@ module Rube
node
end
def read_ivar(tag, depth)
def read_ivar(_tag, depth)
read_instance_variables(read_value(depth + 1), depth)
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# scanner.rb
# frozen_string_literal: true
module Rube
class Scanner
@ -20,6 +21,23 @@ module Rube
LOCATION_SEPARATOR = ":"
UNKNOWN_LOCATION = nil
STATE_UNREADABLE = :unreadable
STATE_UNANALYSABLE = :unanalysable
STATE_VERDICTS = [true, false].freeze
SITE_MODULE_NAME = :module_name
SITE_OWN_METHODS = :own_methods
SITE_CANDIDATE = :candidate
SITE_SOURCE_PARSE = :source_parse
SITE_STATE_ANALYSIS = :state_analysis
SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE,
SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze
LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze
SUBJECT_UNNAMED = "(module that cannot report a name)"
class Candidate
attr_reader :class_name, :method_name, :gate, :source_location, :arity
@ -46,13 +64,26 @@ module Rube
end
def touches_state?
@touches_state
@touches_state == true
end
def state_known?
STATE_VERDICTS.include?(@touches_state)
end
def unanalysable?
@touches_state == STATE_UNANALYSABLE
end
def unreadable_source?
@touches_state == STATE_UNREADABLE
end
def reachable?
return true if gated?
return false unless zero_arity?
zero_arity? && touches_state?
touches_state? || unreadable_source?
end
def to_s
@ -60,12 +91,57 @@ module Rube
end
end
class Report
attr_reader :candidates, :scanned_modules
class Suppression
attr_reader :site, :subject, :error_class
def initialize(candidates, scanned_modules)
def initialize(site:, subject:, error_class:)
@site = site
@subject = subject
@error_class = error_class
end
def lossy?
LOSSY_SITES.include?(site)
end
def to_s
"#{site} #{subject} (#{error_class})"
end
end
class Report
attr_reader :candidates, :scanned_modules, :suppressions
def initialize(candidates, scanned_modules, suppressions)
@candidates = candidates
@scanned_modules = scanned_modules
@suppressions = suppressions
end
def suppressed_count
suppressions.length
end
def suppressions_by_site
suppressions.each_with_object({}) do |suppression, counts|
counts[suppression.site] = counts.fetch(suppression.site, 0) + 1
end
end
def complete?
suppressions.empty?
end
def candidates_lost?
suppressions.any?(&:lossy?)
end
def unanalysable
candidates.select(&:unanalysable?)
end
def fully_analysed?
candidates.all?(&:state_known?)
end
def gated
@ -90,6 +166,7 @@ module Rube
@candidates = []
@definition_cache = {}
@scanned_modules = 0
@suppressions = []
end
def scan
@ -99,13 +176,17 @@ module Rube
collect_singleton_methods(mod, name)
end
Report.new(@candidates.sort_by(&:to_s), @scanned_modules)
Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze)
end
private
attr_reader :namespace
def suppress(site, subject, error)
@suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name)
end
def each_named_module
ObjectSpace.each_object(Module) do |mod|
name = safe_name(mod)
@ -119,7 +200,8 @@ module Rube
def safe_name(mod)
name = mod.name
name if name.is_a?(String) && !name.empty?
rescue StandardError
rescue StandardError => e
suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e)
nil
end
@ -128,7 +210,7 @@ module Rube
end
def collect_instance_methods(mod, name)
own = own_instance_methods(mod)
own = own_instance_methods(mod, name)
(own & GATED_METHODS).each do |method_name|
record(mod, name, method_name, GATE_GATED, singleton: false)
@ -147,12 +229,17 @@ module Rube
end
end
def own_instance_methods(mod)
def own_instance_methods(mod, name)
(mod.instance_methods(false) + mod.private_instance_methods(false)).map(&:to_s)
rescue StandardError
rescue StandardError => e
suppress(SITE_OWN_METHODS, name, e)
[]
end
def qualified(name, method_name, singleton)
"#{name}#{singleton ? '.' : '#'}#{method_name}"
end
def record(mod, name, method_name, gate, singleton:)
handle = singleton ? mod.singleton_method(method_name) : mod.instance_method(method_name)
@ -163,9 +250,10 @@ module Rube
source_location: format_location(handle.source_location),
arity: handle.arity,
singleton: singleton,
touches_state: touches_state?(handle)
touches_state: state_reference_in(handle, qualified(name, method_name, singleton))
)
rescue StandardError, ScriptError
rescue StandardError, ScriptError => e
suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
nil
end
@ -175,32 +263,37 @@ module Rube
location.join(LOCATION_SEPARATOR)
end
def touches_state?(handle)
return false unless PRISM_AVAILABLE
def state_reference_in(handle, subject)
return STATE_UNANALYSABLE unless PRISM_AVAILABLE
path, line = handle.source_location
return false unless path && line
return STATE_UNANALYSABLE unless path && line
node = definition_at(path, line)
return false unless node
definitions = definitions_for(path)
return STATE_UNREADABLE unless definitions
node = definitions[line]
return STATE_UNREADABLE unless node
node.compact_child_nodes.any? { |child| state_reference?(child) }
rescue StandardError, ScriptError
false
end
def definition_at(path, line)
definitions_for(path)[line]
rescue StandardError, ScriptError => e
suppress(SITE_STATE_ANALYSIS, subject, e)
STATE_UNREADABLE
end
def definitions_for(path)
@definition_cache[path] ||= begin
found = {}
collect_definitions(Prism.parse_file(path).value, found)
found
rescue StandardError, ScriptError
{}
end
return @definition_cache[path] if @definition_cache.key?(path)
@definition_cache[path] = parse_definitions(path)
end
def parse_definitions(path)
found = {}
collect_definitions(Prism.parse_file(path).value, found)
found
rescue StandardError, ScriptError => e
suppress(SITE_SOURCE_PARSE, path, e)
nil
end
def collect_definitions(node, found)

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# version.rb
# frozen_string_literal: true
module Rube
VERSION = "0.1.0"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# rube.gemspec
# frozen_string_literal: true
require_relative "lib/rube/version"

View File

@ -27,14 +27,23 @@ 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?}"
observed_reporter = ->(_reason) {}
observing = D.new(policy: D::POLICY_OBSERVE_AND_LOG, reporter: observed_reporter)
observed_sinky = observing.inspect_stream(sinky)
observed_benign = observing.inspect_stream(benign)
puts "strict_rejects_cve=#{strict_hostile.blocked?}"
puts "strict_accepts_benign=#{strict_benign.proceed?}"
puts "strict_rejects_sink=#{strict_sinky.blocked?}"
puts "allowlist_does_not_exempt_sink=#{allowlisted.blocked?}"
puts "deny_sinks_only_accepts_cve=#{loose_hostile.proceed?}"
puts "observe_and_log_never_reports_proceed=#{!observed_sinky.proceed?}"
puts "observe_and_log_reports_observed=#{observed_sinky.observed?}"
puts "observe_and_log_proceeds_on_benign=#{observed_benign.proceed?}"
puts "blocked_is_not_also_observed=#{!strict_sinky.observed?}"
fired = false
if loose_hostile.accepted?
if loose_hostile.proceed?
begin
Marshal.load(loose_hostile.snapshot).def_method(Module.new, "x")
rescue StandardError
@ -63,6 +72,10 @@ expect strict_accepts_benign "strict policy still accepts a benign primitive str
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 observe_and_log_never_reports_proceed "observe-and-log never reports proceed for a flagged stream"
expect observe_and_log_reports_observed "observe-and-log reports the third state instead of hiding it"
expect observe_and_log_proceeds_on_benign "observe-and-log still proceeds on a clean stream"
expect blocked_is_not_also_observed "the three decision states stay mutually exclusive"
expect documented_bypass_executes "the documented bypass actually executes, so the notice is honest"
echo

View File

@ -7,7 +7,7 @@ set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VULNERABLE_IMAGE="ruby:4.0.2-slim"
PATCHED_IMAGE="ruby:4.0-slim"
PATCHED_IMAGE="ruby:4.0.6-slim"
run_probe() {
local image="$1"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# render_matrix.rb
# frozen_string_literal: true
require "json"
require "rube"
@ -39,7 +40,7 @@ def section(title)
end
section("RUNTIME") do
puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", "image", "ruby", "rubygems", "psych", "erb", "marshal")
puts format(" %-14s %-8s %-9s %-8s %-9s %s", "image", "ruby", "rubygems", "psych", "erb", "marshal")
rows.each do |r|
puts format(" %-14s %-8s %-9s %-8s %-9s %-7s",
short(r["image"]), r["ruby"], r["rubygems"], r["psych"], r["erb"], r["marshal_format"])
@ -47,8 +48,8 @@ section("RUNTIME") do
end
section("GADGET SURFACE") do
puts format(" %-14s %-12s %-14s %s",
"image", "git gadget", "safe_marshal", TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join)
heads = TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join
puts format(" %-14s %-12s %-14s %s", "image", "git gadget", "safe_marshal", heads)
rows.each do |r|
present = TRACKED_CLASSES.map { |c| format("%-13s", mark(r["classes_baseline"][c])) }.join
puts format(" %-14s %-12s %-14s %s", short(r["image"]), r["git_gadget"], r["safe_marshal"], present)
@ -56,7 +57,7 @@ section("GADGET SURFACE") do
end
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 %-8s", "image", "erb", "guarded", "delegating", "cve says")
rows.each do |r|
guard = r["erb_guard"]
expected = cve_patched?(r["erb"]) ? "patched" : "affected"

View File

@ -113,6 +113,58 @@ else
fi
fi
echo
encode() {
docker run --rm --network none ruby:4.0-slim \
ruby -e "require \"base64\"; print Base64.strict_encode64(Marshal.dump($1))"
}
for root in '"plain string"' 'nil' '[1, 2]' '{ user: "x" }'; do
body_file="$(mktemp)"
code="$(curl -s -o "${body_file}" -w '%{http_code}' \
-H "Cookie: session_state=$(encode "${root}")" "${BASE}/render/safe")"
body="$(head -c 80 "${body_file}")"
rm -f "${body_file}"
echo " defended on root ${root} : HTTP ${code} ${body}"
if [[ "${code}" == "500" ]]; then
echo " FAIL the defence accepted this root and then crashed compiling it"
failures=$((failures + 1))
fi
done
echo
leak_file="$(mktemp)"
curl -s -o "${leak_file}" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render/safe"
curl -s -o "${leak_file}.v" -H "Cookie: session_state=$(encode 'nil')" "${BASE}/render"
if grep -qE "app\.rb|/app/lib|rube/marshal" "${leak_file}" "${leak_file}.v"; then
echo " FAIL an error response leaked source paths or source lines"
failures=$((failures + 1))
else
echo " PASS error responses leak no source path or source line"
fi
rm -f "${leak_file}" "${leak_file}.v"
echo
class_named() {
docker run --rm --network none ruby:4.0-slim ruby -e "
require \"base64\"
def sym(n) = \":\" + (n.bytesize + 5).chr + n
def str(s) = %q(\") + (s.bytesize + 5).chr + s
print Base64.strict_encode64($1)
"
}
for probe in 'Marshal.dump(Object.new)' '("\x04\x08C" + sym("String") + str("hi")).b'; do
named_body="$(curl -s -H "Cookie: session_state=$(class_named "${probe}")" "${BASE}/render/safe")"
echo " class-named stream : ${named_body}"
if [[ "${named_body}" == *"unapproved class"* ]]; then
echo " PASS refused on the class name itself, not on a parse error"
else
echo " FAIL PERMITTED_CLASS_NAMES admitted a class name, or something else rejected it first"
failures=$((failures + 1))
fi
done
echo
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
ruby -Ilib -e '

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# app.rb
# frozen_string_literal: true
require "sinatra/base"
require "base64"
@ -16,21 +17,26 @@ module Rube
CONTENT_TYPE = "text/plain"
ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze
PERMITTED_CLASS_NAMES = [].freeze
BENIGN_TEMPLATE = "hello"
SESSION_KEYS = %i[user template].freeze
DETECTOR = Rube::Marshal::BoundaryDetector.new(
policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
allowed_class_names: ALLOWED_CLASSES,
allowed_class_names: PERMITTED_CLASS_NAMES,
limits: Rube::Marshal::Limits.new
)
REJECTED = "rejected: %s"
RENDERED = "rendered template for %s"
NO_SESSION = "no session cookie"
NOT_A_SESSION = "payload is not a session hash"
class App < Sinatra::Base
set :host_authorization, permitted_hosts: []
set :show_exceptions, false
set :dump_errors, false
get "/" do
content_type CONTENT_TYPE
@ -68,9 +74,12 @@ module Rube
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
decision = DETECTOR.inspect_stream(blob)
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) if decision.rejected?
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed?
compile(::Marshal.load(decision.snapshot))
state = ::Marshal.load(decision.snapshot)
halt STATUS_BAD_REQUEST, format(REJECTED, NOT_A_SESSION) unless session?(state)
compile(state)
end
get "/canary" do
@ -92,6 +101,10 @@ module Rube
nil
end
def session?(state)
state.is_a?(Hash) && SESSION_KEYS.all? { |key| state.key?(key) }
end
def compile(state)
template = state[:template]
template.def_method(Module.new, "render_it") if template.respond_to?(:def_method)

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# config.ru
# frozen_string_literal: true
require_relative "target/app"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# chains_test.rb
# frozen_string_literal: true
require_relative "test_helper"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# control_check.rb
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
@ -102,17 +103,46 @@ puts
puts "=== 7 scanner precision ==="
full = Rube::Scanner.new.scan
ungated = full.ungated.length
reachable = full.reachable.reject(&:gated?).length
reachable = full.reachable.count { |c| !c.gated? }
kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1)
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")
prism_detail = full.prism_available? ? "Prism.parse_file in use" : "ABSENT, every state verdict is a guess"
failures << "prism" unless check("prism backend live, so reachability is real",
full.prism_available?, prism_detail)
failures << "precision" unless check("reachability filter discriminates",
reachable.positive? && reachable < ungated,
"#{ungated} ungated -> #{reachable} reachable, #{kept}% kept")
failures << "gated located" unless check("gated sinks located", !full.gated.empty?,
full.gated.map(&:to_s).join(", "))
full.gated.join(", "))
lossy = full.candidates_lost? ? full.suppressions.join(", ") : "0 lossy suppressions"
failures << "candidates lost" unless check("no candidate was silently dropped",
!full.candidates_lost?, lossy)
unreadable = full.candidates.select(&:unreadable_source?)
fails_open = !unreadable.empty? &&
unreadable.all? { |c| c.gated? || !c.zero_arity? || c.reachable? }
failures << "unreadable fails open" unless check("an unreadable source fails open, never to inert",
fails_open,
"#{unreadable.length} candidates whose source could not be parsed")
unanalysable = full.unanalysable
owned = !unanalysable.empty? && unanalysable.none?(&:state_known?) && !full.fully_analysed?
failures << "unanalysable owned" unless check("C-defined methods are named unanalysable, not inert",
owned,
"#{unanalysable.length} of #{full.candidates.length} have no Ruby source")
flooded = unanalysable.reject(&:gated?).select(&:reachable?)
flood_detail = flooded.empty? ? "0 of #{unanalysable.length} promoted" : "#{flooded.length} promoted with no evidence"
failures << "unanalysable flood" unless check("unanalysable never buys its way into reachable",
!unanalysable.empty? && flooded.empty?, flood_detail)
coverage = "#{full.candidates.count(&:state_known?)} analysed, " \
"#{unanalysable.length} unanalysable, #{unreadable.length} unreadable"
suppressed = full.complete? ? "none" : full.suppressions_by_site.map { |site, n| "#{site}=#{n}" }.join(" ")
puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded",
"#{full.scanned_modules} modules loaded")
puts format(" %-6s %-46s %s", "INFO", "state analysis coverage", coverage)
puts format(" %-6s %-46s %s", "INFO", "suppressed errors this scan", suppressed)
puts
if failures.empty?

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# corpus_test.rb
# frozen_string_literal: true
require_relative "test_helper"
require_relative "support/adversarial_corpus"
@ -25,9 +26,10 @@ module Rube
disagreements = AdversarialCorpus::CASES.filter_map do |kase|
decision = detector(kase[:allowed]).inspect_stream(kase[:bytes])
expected = kase[:verdict] == AdversarialCorpus::VERDICT_ACCEPT
next if decision.accepted? == expected
next if decision.proceed? == expected
"#{kase[:name]}: expected #{kase[:verdict]}, got #{decision.accepted? ? 'accept' : 'reject'} (#{decision.reason})"
verdict = decision.proceed? ? "accept" : "reject"
"#{kase[:name]}: expected #{kase[:verdict]}, got #{verdict} (#{decision.reason})"
end
assert_empty disagreements, "corpus disagreements:\n #{disagreements.join("\n ")}"
@ -48,7 +50,7 @@ module Rube
allowlisted = AdversarialCorpus::CASES.reject { |kase| kase[:allowed].empty? }
refute_empty allowlisted
leaks = allowlisted.reject { |kase| detector.inspect_stream(kase[:bytes]).rejected? }
leaks = allowlisted.reject { |kase| detector.inspect_stream(kase[:bytes]).blocked? }
assert_empty leaks.map { |kase| kase[:name] },
"an allowlist must widen what is accepted, never what is rejected"
end
@ -79,7 +81,7 @@ module Rube
hosts.each do |slot|
refute_includes names, :"class_name_slot_#{slot}",
"#{slot} rejects on its own tag either way, so a corpus case cannot fail"
assert detector.inspect_stream(AdversarialCorpus::CLASS_NAME_SLOTS[slot]).rejected?
assert_predicate detector.inspect_stream(AdversarialCorpus::CLASS_NAME_SLOTS[slot]), :blocked?
end
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# boundary_detector_test.rb
# frozen_string_literal: true
require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
@ -11,8 +12,8 @@ module Rube
CANARY_PATH = "/tmp/rube-canary"
CANARY_MARKER = "fired"
def detector(**options)
BoundaryDetector.new(**options)
def detector(**)
BoundaryDetector.new(**)
end
def benign_blob
@ -33,7 +34,7 @@ module Rube
end
def test_accepts_primitive_only_stream_with_an_empty_allowlist
assert_predicate detector.inspect_stream(benign_blob), :accepted?
assert_predicate detector.inspect_stream(benign_blob), :proceed?
end
def test_rejects_non_string_input_without_converting_it
@ -43,45 +44,47 @@ module Rube
end
decision = detector.inspect_stream(hostile)
assert_predicate decision, :rejected?
assert_predicate decision, :blocked?
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"
assert_predicate decision, :blocked?
assert_includes decision.reason, '"Gem::Requirement"#marshal_load',
"the class name is attacker-controlled and stays quoted, so an operator " \
"can see where it starts and stops"
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_predicate decision, :blocked?
assert_includes decision.reason, "marshal_load"
end
def test_rejects_unapproved_class_names
decision = detector.inspect_stream(cve_blob)
assert_predicate decision, :rejected?
assert_predicate decision, :blocked?
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?
assert_predicate decision, :proceed?
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?,
assert_predicate decision, :proceed?,
"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?
assert_predicate decision, :blocked?
end
def test_observe_and_log_requires_a_reporter
@ -90,15 +93,15 @@ module Rube
end
end
def test_observe_and_log_accepts_but_records_the_violation
def test_observe_and_log_records_the_violation_without_reporting_proceed
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?
refute_predicate decision, :proceed?
refute_predicate decision, :blocked?
assert_equal 1, seen.length
end
@ -106,7 +109,7 @@ module Rube
decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG,
reporter: ->(_) {})
.inspect_stream("\x04\x08[\xFA")
assert_predicate decision, :rejected?
assert_predicate decision, :blocked?
end
def test_rejects_a_version_the_parser_accepts_but_no_ruby_emits
@ -115,18 +118,18 @@ module Rube
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_predicate decision, :blocked?
assert_includes decision.reason, "4.7"
end
def test_canonical_version_is_accepted
assert_predicate detector.inspect_stream(benign_blob), :accepted?
assert_predicate detector.inspect_stream(benign_blob), :proceed?
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?,
assert_predicate decision, :proceed?,
"version canonicality runs no code during load, so it belongs with " \
"the allowlist, not with the sink checks"
end
@ -137,7 +140,7 @@ module Rube
def test_rejects_malformed_stream_with_a_named_reason
decision = detector.inspect_stream("\x04\x08[\xFA")
assert_predicate decision, :rejected?
assert_predicate decision, :blocked?
assert_includes decision.reason, "MalformedCountError"
end
@ -154,12 +157,12 @@ module Rube
end
def assert_ceiling_rejects(blob, error_name, allowed: [], **narrow)
assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :accepted?,
assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :proceed?,
"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, :blocked?
assert_includes decision.reason, error_name
decision
end
@ -172,10 +175,10 @@ module Rube
deep = "\x04\x08" + ("[\x06" * 80) + "0"
shallow = "\x04\x08" + ("[\x06" * 8) + "0"
assert_predicate detector.inspect_stream(shallow), :accepted?,
assert_predicate detector.inspect_stream(shallow), :proceed?,
"control: nesting inside the ceiling must be accepted"
decision = detector.inspect_stream(deep)
assert_predicate decision, :rejected?
assert_predicate decision, :blocked?
assert_includes decision.reason, "DepthLimitError"
end
@ -199,7 +202,7 @@ module Rube
blob = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words))
decision = detector.inspect_stream(blob)
assert_predicate decision, :rejected?,
assert_predicate decision, :blocked?,
"#{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"
@ -264,7 +267,7 @@ module Rube
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")
decision.blocked? && decision.reason.include?("LimitExceededError")
end
assert_empty admitted.keys,
@ -273,7 +276,7 @@ module Rube
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?
detector(allowed_class_names: allowed, limits: Limits.permissive).inspect_stream(blob).proceed?
end
assert_operator admitted, :>, 0,
@ -303,6 +306,149 @@ module Rube
tracer.enable { detector.inspect_stream(cve_blob) }
refute fired
end
def policy_options(policy)
return { policy: policy, reporter: ->(_reason) {} } if
policy == BoundaryDetector::POLICY_OBSERVE_AND_LOG
{ policy: policy }
end
def decision_per_policy(blob, allowed: [])
BoundaryDetector::POLICIES.to_h do |policy|
[policy, detector(allowed_class_names: allowed, **policy_options(policy)).inspect_stream(blob)]
end
end
def state_matrix_blobs
{
"benign" => benign_blob,
"sink" => sink_blob,
"unapproved class" => cve_blob,
"malformed" => "\x04\x08[\xFA"
}
end
def test_no_policy_reports_proceed_for_a_payload_it_flagged
admitted = decision_per_policy(sink_blob).select { |_policy, decision| decision.proceed? }
assert_empty admitted.keys,
"every policy flags a marshal_load sink, so no policy may report proceed. " \
"Marshal.load(d.snapshot) if d.proceed? would load it under " \
"#{admitted.keys.join(', ')}"
end
def test_control_every_policy_reports_proceed_for_a_benign_payload
admitted = decision_per_policy(benign_blob).select { |_policy, decision| decision.proceed? }
assert_equal BoundaryDetector::POLICIES.length, admitted.length,
"control: a proceed? that is never true would pass the previous test vacuously"
end
def test_exactly_one_state_predicate_holds_for_every_policy_and_payload
seen = []
state_matrix_blobs.each do |name, blob|
decision_per_policy(blob).each do |policy, decision|
held = BoundaryDetector::Decision::STATE_PREDICATES.select { |predicate| decision.public_send(predicate) }
assert_equal 1, held.length,
"#{name} under #{policy} reported #{held.length} states: #{held.join(', ')}"
seen.concat(held)
end
end
assert_equal BoundaryDetector::Decision::STATE_PREDICATES.sort, seen.uniq.sort,
"control: the matrix must exercise every state, or exclusivity proves nothing"
end
def test_the_ambiguous_accept_predicates_no_longer_exist
decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG,
reporter: ->(_reason) {}).inspect_stream(sink_blob)
%i[accepted? rejected?].each do |ambiguous|
refute_respond_to decision, ambiguous,
"#{ambiguous} cannot answer whether this payload may be deserialized, " \
"and a reader who copies it loads a flagged stream"
end
end
def test_observe_and_log_still_hands_back_bytes_for_an_explicit_opt_in
decision = detector(policy: BoundaryDetector::POLICY_OBSERVE_AND_LOG,
reporter: ->(_reason) {}).inspect_stream(sink_blob)
assert_predicate decision, :observed?
refute_nil decision.snapshot,
"observe-and-log must stay non-blocking, so a caller who writes " \
"proceed? || observed? can still load"
assert_equal ::Marshal.load(sink_blob), ::Marshal.load(decision.snapshot)
end
def test_a_blocked_decision_carries_no_snapshot_to_load
decision = detector.inspect_stream(sink_blob)
assert_predicate decision, :blocked?
assert_nil decision.snapshot
end
def test_decision_rejects_an_unknown_state
assert_raises(ArgumentError) { BoundaryDetector::Decision.new(state: :yolo) }
end
def hostile_named_streams(name)
sym = AdversarialCorpus.sym(name)
{
"unapproved class" => AdversarialCorpus.stream("o#{sym}#{AdversarialCorpus.fixnum(0)}"),
"sink" => AdversarialCorpus.stream("U#{sym}#{AdversarialCorpus.fixnum(0)}"),
"hash key dispatch" =>
AdversarialCorpus.stream(
"{#{AdversarialCorpus.fixnum(1)}o#{sym}#{AdversarialCorpus.fixnum(0)}0"
)
}
end
def test_no_reject_reason_repeats_a_raw_control_byte_from_the_stream
forged = "Evil\r\n2026-07-29 INFO session validated user=admin\e[0m\x00"
raw = hostile_named_streams(forged).transform_values do |blob|
detector.inspect_stream(blob).reason.to_s
end
assert_equal 3, raw.values.count { |reason| !reason.empty? },
"control: every reason kind must actually fire, or this proves nothing"
raw.each do |kind, reason|
offending = reason.bytes.select { |byte| byte < 0x20 }
assert_empty offending,
"#{kind} reason carried raw control bytes #{offending.inspect} straight " \
"into a caller-supplied logger"
end
end
def test_a_reject_reason_still_identifies_the_class_it_refused
blob = hostile_named_streams("Evil\nInjected").fetch("unapproved class")
assert_includes detector.inspect_stream(blob).reason, 'Evil\nInjected',
"escaping must not cost the operator the name that was refused"
end
def test_a_reject_reason_bounds_how_much_attacker_text_it_repeats
ceiling = Limits::DEFAULT_MAX_CLASS_NAME_BYTES
long = "A" * ceiling
blob = hostile_named_streams(long).fetch("unapproved class")
decision = detector.inspect_stream(blob)
assert_predicate decision, :blocked?
assert_includes decision.reason, BoundaryDetector::REASON_TRUNCATED_MARKER
assert_operator decision.reason.bytesize, :<, ceiling,
"a 1 KiB class name is inside the parser ceiling, so the reason is the " \
"only thing bounding what reaches the log"
end
def test_control_a_short_class_name_is_not_truncated
blob = hostile_named_streams("Evil").fetch("unapproved class")
reason = detector.inspect_stream(blob).reason
assert_includes reason, '"Evil"'
refute_includes reason, BoundaryDetector::REASON_TRUNCATED_MARKER
end
end
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# parser_test.rb
# frozen_string_literal: true
require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
@ -139,7 +140,7 @@ module Rube
end
def test_parses_bignum_both_signs
[2**64, -(2**64), 2**128 + 7].each do |n|
[2**64, -(2**64), (2**128) + 7].each do |n|
assert_equal n, roundtrip(n).value, "bignum #{n} did not round-trip"
end
end
@ -276,7 +277,7 @@ module Rube
end
def test_resolves_symlink_to_prior_symbol
node = roundtrip([:same, :same])
node = roundtrip(%i[same same])
assert_equal :symlink, node.children.last.type
assert_equal :same, node.children.last.value
end
@ -399,7 +400,7 @@ module Rube
def test_sink_in_an_instance_variable_name_position_is_still_reported
result = parse("\x04\x08I\"\x06a\x06u:\x09Evil\x06x0")
assert_includes result.class_names, "Evil"
assert_equal ["Evil#_load"], result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }
assert_equal(["Evil#_load"], result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" })
end
def tripwires(blob)
@ -504,12 +505,12 @@ module Rube
assert_raises(DepthLimitError) { parse_class_name_slot_at_ceiling(tail) }
end
def load_watcher
def load_watcher(&)
fired = false
tracer = TracePoint.new(:call, :c_call) do |tp|
fired = true if tp.method_id == :load && tp.self.equal?(::Marshal)
end
tracer.enable { yield }
tracer.enable(&)
fired
end
@ -641,6 +642,164 @@ module Rube
end
end
def nested_result
parse(::Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3], "on" => true }))
end
def test_a_returned_parse_graph_cannot_be_mutated_by_its_consumer
result = nested_result
node = result.root
assert_raises(FrozenError) { node.children << Node.new(type: :nil) }
assert_raises(FrozenError) { node.auxiliary.clear }
assert_raises(FrozenError) { node.instance_variables_map[:injected] = Node.new(type: :nil) }
end
def test_a_returned_node_cannot_have_its_verdict_fields_rewritten
node = nested_result.root
assert_raises(FrozenError) { node.value = "rewritten" }
assert_raises(FrozenError) { node.class_name = "Innocuous" }
end
def test_every_node_in_the_graph_is_frozen_not_just_the_root
unfrozen = nested_result.nodes.reject(&:frozen?)
assert_empty unfrozen.map(&:type)
assert_operator nested_result.nodes.count, :>, 5,
"control: a one-node graph would not prove the walk reaches children"
end
def test_scalar_values_in_the_graph_are_frozen_too
strings = nested_result.nodes.filter_map { |n| n.value if n.value.is_a?(String) }
refute_empty strings, "control: the probe must contain string values"
assert(strings.all?(&:frozen?))
end
def test_the_chain_registry_cannot_be_appended_to_by_a_caller
assert_raises(FrozenError) { Rube::Chains.registry << Object }
end
def test_control_the_registry_still_reports_its_chains
refute_empty Rube::Chains.all
assert_includes Rube::Chains.all, Rube::Chains::ErbDefMethod
end
FIXNUM_WIDTH_PROBES = [
0, 1, -1, 122, -123, 123, -124, 255, -256, 256, -257, 65_535, -65_536,
65_536, 16_777_215, -16_777_216, 16_777_216, 1_073_741_823, -1_073_741_824
].freeze
def test_fixnum_round_trips_every_width_the_format_allows
mismatched = FIXNUM_WIDTH_PROBES.reject { |n| parse(::Marshal.dump(n)).root.value == n }
assert_empty mismatched.map(&:inspect)
end
def test_no_fixnum_marker_can_request_a_width_beyond_the_format_ceiling
payloads = FIXNUM_WIDTH_PROBES.map { |n| ::Marshal.dump(n).bytesize - Constants::HEADER_LENGTH - 2 }
assert_equal (0..Constants::FIXNUM_MAX_WIDTH).to_a, payloads.uniq.sort,
"control: the probe set must exercise the inline form and every width"
assert_operator payloads.max, :<=, Constants::FIXNUM_MAX_WIDTH,
"the format cannot express a wider fixnum, so a runtime width guard " \
"is unreachable and must not pretend otherwise"
end
def float_stream(body)
AdversarialCorpus.stream("f#{AdversarialCorpus.fixnum(body.bytesize)}#{body}")
end
def float_value(body)
parse(float_stream(body)).root.value
end
def marshal_float(body)
::Marshal.load(float_stream(body))
end
def same_float?(left, right)
return false unless left.is_a?(Float) && right.is_a?(Float)
(left.nan? && right.nan?) || left == right
end
EMITTED_FLOAT_BODIES = [
"inf", "-inf", "nan", "0", "-0", "1.5", "-2.5", "1e400", "-1e400",
"0.0001", "3.141592653589793", "1.7976931348623157e+308", "5.0e-324"
].freeze
HAND_BUILT_FLOAT_BODIES = [
"1_0", "abc", "", " 1.5", "0x10", "1.5abc", "+2.5", ".5",
"nan\x00j", "inf\x00x", "INF", "NaN"
].freeze
def test_float_decoding_agrees_with_marshal_load_on_every_body
bodies = EMITTED_FLOAT_BODIES + HAND_BUILT_FLOAT_BODIES
disagreements = bodies.reject { |b| same_float?(float_value(b), marshal_float(b)) }
assert_empty(disagreements.map { |b| "#{b.inspect}: #{float_value(b).inspect} vs #{marshal_float(b).inspect}" })
observed = bodies.map { |b| marshal_float(b) }
assert(observed.any?(&:nan?), "control: the table must exercise nan")
assert(observed.any?(&:infinite?), "control: the table must exercise infinity")
assert(observed.any?(&:finite?), "control: the table must exercise finite values")
end
def test_float_never_reports_nil_for_a_body_marshal_load_accepts
blind = (EMITTED_FLOAT_BODIES + HAND_BUILT_FLOAT_BODIES).select { |b| float_value(b).nil? }
assert_empty blind,
"a nil value is indistinguishable from a float of zero and hides what the " \
"stream actually carried"
end
def test_every_float_ruby_dumps_round_trips_through_the_parser
values = [0.0, -0.0, 1.5, -2.5, 1e308, 1e-308, 0.1, Float::INFINITY,
-Float::INFINITY, Float::NAN, Float::MAX, Float::MIN]
mismatched = values.reject do |v|
same_float?(parse(::Marshal.dump(v)).root.value, v)
end
assert_empty mismatched.map(&:inspect)
end
def test_a_legacy_mantissa_tail_is_preserved_rather_than_guessed
node = parse(float_stream("3.5\x00junk")).root
assert_in_delta 3.5, node.value, 0.0
assert_equal "\x00junk".b, node.undecoded_tail
refute_predicate node, :fully_decoded?
end
def test_control_a_float_ruby_actually_emits_is_fully_decoded
node = parse(::Marshal.dump(3.5)).root
assert_predicate node, :fully_decoded?
assert_nil node.undecoded_tail
end
def test_the_parser_and_the_scanner_agree_on_which_sinks_are_gated
from_tags = Constants::GATED_SINK_TAGS.map { |tag| Constants::SINK_METHODS.fetch(tag) }
from_scanner = Rube::Scanner::GATED_METHODS + Rube::Scanner::GATED_SINGLETON_METHODS
refute_empty from_tags, "control: an empty gated set would make this vacuous"
assert_equal from_scanner.sort, from_tags.sort,
"a sink method gated in one half and ungated in the other is a contradiction, " \
"and the two halves are the only two definitions of gated in this project"
end
def test_every_sink_tag_declares_the_method_marshal_load_dispatches
assert_equal Constants::SINK_TAGS.sort, Constants::SINK_METHODS.keys.sort
end
def test_data_tag_is_gated_because_marshal_load_checks_respond_to_first
assert_includes Constants::GATED_SINK_TAGS, Constants::TAG_DATA,
"Thread::Mutex is a real T_DATA with no _load_data and Marshal.load raises " \
"TypeError naming the missing method, which is the gate"
end
class UserMarshalFixture
def marshal_dump
["payload"]

View File

@ -1,12 +1,43 @@
# ©AngelaMos | 2026
# scanner_test.rb
# frozen_string_literal: true
require_relative "test_helper"
module Rube
class ScannerTest < Minitest::Test
def scan(**options)
Scanner.new(**options).scan
SUPPRESSION_NAMESPACE = "Rube::ScannerSuppressionFixture"
MISSING_SOURCE_PATH = "/nonexistent/rube-scanner-fixture.rb"
Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1)
module Rube
module ScannerSuppressionFixture
class VanishedSource
def hash
@seed.to_i
end
def to_s
"vanished"
end
end
end
end
SOURCE
def scan(**)
Scanner.new(**).scan
end
def with_exploding(*fixtures)
fixtures.each { |fixture| fixture.explode = true }
yield
ensure
fixtures.each { |fixture| fixture.explode = false }
end
def suppressions_at(report, site)
report.suppressions.select { |suppression| suppression.site == site }
end
def local_scan
@ -141,6 +172,212 @@ module Rube
refute GatedFixture.instantiated, "scanner constructed a candidate class"
end
def test_a_clean_scan_reports_no_suppressions
report = local_scan
assert_empty report.suppressions
assert_equal 0, report.suppressed_count
assert_predicate report, :complete?
refute_predicate report, :candidates_lost?
end
def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from
control = candidates_for("Rube::ScannerTest::ExplodingHandleFixture")
assert_equal ["marshal_load"], control.map(&:method_name),
"control: this fixture must be discoverable when it is not exploding"
with_exploding(ExplodingHandleFixture) do
report = local_scan
assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingHandleFixture") })
lost = suppressions_at(report, Scanner::SITE_CANDIDATE)
assert_equal 1, lost.length
assert_equal "Rube::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject
assert_predicate report, :candidates_lost?
refute_predicate report, :complete?
end
end
def test_a_suppressed_script_error_is_recorded_by_class
with_exploding(ExplodingHandleFixture) do
suppression = suppressions_at(local_scan, Scanner::SITE_CANDIDATE).first
assert_equal "ScriptError", suppression.error_class,
"record rescues ScriptError as well as StandardError, so it must report which"
end
end
def test_an_unreadable_method_list_is_counted_as_a_lost_candidate
with_exploding(ExplodingMethodListFixture) do
report = local_scan
assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingMethodListFixture") })
assert_equal 1, suppressions_at(report, Scanner::SITE_OWN_METHODS).length
assert_predicate report, :candidates_lost?
end
end
def test_a_module_that_cannot_report_its_name_is_counted
with_exploding(ExplodingNameFixture) do
named = suppressions_at(local_scan, Scanner::SITE_MODULE_NAME)
refute_empty named
assert_equal Scanner::SUBJECT_UNNAMED, named.first.subject
end
end
def test_unparseable_source_is_counted_without_losing_the_candidate
report = scan(namespace: SUPPRESSION_NAMESPACE)
assert_equal ["#{SUPPRESSION_NAMESPACE}::VanishedSource#hash",
"#{SUPPRESSION_NAMESPACE}::VanishedSource#to_s"],
report.candidates.map(&:to_s),
"control: the candidates must survive, only their state analysis failed"
assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length
refute_predicate report, :candidates_lost?
refute_predicate report, :complete?
end
def test_a_candidate_whose_source_cannot_be_read_stays_reachable
candidate = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first
refute_predicate candidate, :state_known?
refute_predicate candidate, :touches_state?
assert_predicate candidate, :reachable?,
"an unreadable source cannot prove a method inert, and a scanner that " \
"drops what it failed to analyse under-reports silently"
end
def test_a_c_defined_method_is_reported_as_unanalysable_not_as_inert
candidate = scan(namespace: "Gem").candidates.find { |c| c.source_location.nil? } ||
scan.candidates.find { |c| c.source_location.nil? }
refute_nil candidate, "control: the stdlib must supply at least one C-defined candidate"
assert_predicate candidate, :unanalysable?
refute_predicate candidate, :state_known?
refute_predicate candidate, :touches_state?,
"no Ruby source exists, so the answer is not false, it is unavailable"
end
def test_unanalysable_is_distinct_from_an_analysis_that_failed
vanished = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first
c_defined = scan.candidates.find { |c| c.source_location.nil? }
refute_predicate vanished, :unanalysable?,
"a source that exists but could not be read is a failure, not an absence"
assert_predicate c_defined, :unanalysable?
refute_predicate vanished, :state_known?
refute_predicate c_defined, :state_known?
end
def test_an_unanalysable_candidate_is_never_reachable_on_that_basis
report = scan
flooded = report.unanalysable.reject(&:gated?).select(&:reachable?)
refute_empty report.unanalysable, "control: a stock image must have C-defined candidates"
assert_empty flooded.first(5).map(&:to_s),
"#{flooded.length} C-defined candidates were called reachable purely because " \
"they could not be analysed; that is a pass-through, not a filter"
end
def test_control_an_unreadable_candidate_is_reachable_on_exactly_that_basis
unreadable = scan.candidates.select(&:unreadable_source?).reject(&:gated?).select(&:zero_arity?)
refute_empty unreadable, "control: without one of these the previous test is vacuous"
assert(unreadable.all?(&:reachable?),
"the two non-verdicts must behave differently, or splitting them bought nothing")
end
def test_the_report_counts_what_it_could_not_analyse
report = scan
assert_equal report.candidates.count(&:unanalysable?), report.unanalysable.length
assert_operator report.unanalysable.length, :>, 0
refute_predicate report, :fully_analysed?
end
def test_a_report_over_analysable_code_only_is_fully_analysed
report = local_scan
assert_empty report.unanalysable
assert_predicate report, :fully_analysed?
end
def test_an_analysed_candidate_reports_its_state_as_known
%w[StatefulFixture StatelessFixture].each do |fixture|
candidate = candidates_for("Rube::ScannerTest::#{fixture}").first
assert_predicate candidate, :state_known?,
"control: a readable source must produce a verdict, or unknown means nothing"
end
end
def test_one_unreadable_file_is_counted_once_not_once_per_candidate
report = scan(namespace: SUPPRESSION_NAMESPACE)
assert_operator report.candidates.length, :>, 1,
"control: one candidate cannot expose per-candidate inflation"
assert_equal 1, report.suppressed_count,
"the parse cache must remember a failure, or the count inflates per candidate"
end
def test_suppressions_by_site_accounts_for_every_suppression
with_exploding(ExplodingHandleFixture, ExplodingMethodListFixture, ExplodingNameFixture) do
report = local_scan
by_site = report.suppressions_by_site
assert_equal report.suppressed_count, by_site.values.sum
assert_equal 3, by_site.keys.length
assert(by_site.keys.all? { |site| Scanner::SITES.include?(site) })
end
end
class ExplodingNameFixture
@explode = false
class << self
attr_accessor :explode
def name
raise NameError, "name unavailable" if @explode
super
end
end
end
class ExplodingMethodListFixture
@explode = false
class << self
attr_accessor :explode
def instance_methods(include_super = true)
raise NoMethodError, "method list unavailable" if @explode
super
end
end
def marshal_load(data); end
end
class ExplodingHandleFixture
@explode = false
class << self
attr_accessor :explode
def instance_method(name)
raise ScriptError, "handle unavailable" if @explode
super
end
end
def marshal_load(data); end
end
class GatedFixture
@instantiated = false
@ -152,7 +389,7 @@ module Rube
end
class UserDefFixture
def self._load(data)
def self._load(_data)
allocate
end
end

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# adversarial_corpus.rb
# frozen_string_literal: true
module Rube
module AdversarialCorpus

View File

@ -1,6 +1,8 @@
# ©AngelaMos | 2026
# exploit_probe.rb
# frozen_string_literal: true
require "fileutils"
require "rube"
CANARY_PATH = "/tmp/rube-canary"
@ -15,7 +17,7 @@ blob = chain.serialize
inspection = Rube::Marshal::Parser.new(blob).parse
File.delete(CANARY_PATH) if File.exist?(CANARY_PATH)
FileUtils.rm_f(CANARY_PATH)
revived = Marshal.load(blob)
detail = begin

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# matrix_probe.rb
# frozen_string_literal: true
require "json"

View File

@ -1,5 +1,6 @@
# ©AngelaMos | 2026
# test_helper.rb
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))