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:
parent
a866587f10
commit
8d7b114fb9
|
|
@ -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/**/*"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# Gemfile
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# Rakefile
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rake/testtask"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# errors.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshal
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# limits.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module Marshal
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# version.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
VERSION = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# rube.gemspec
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "lib/rube/version"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# config.ru
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "target/app"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# chains_test.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "test_helper"
|
||||
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# adversarial_corpus.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rube
|
||||
module AdversarialCorpus
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# matrix_probe.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "json"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# ©AngelaMos | 2026
|
||||
# test_helper.rb
|
||||
# frozen_string_literal: true
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue