feat(marshalsea): rename off a name taken since 2009, then build the release path

rube has been on rubygems.org since 2009-08-05: Richard LeBer, 12,305 downloads,
and it is an ERB front-end, which is funny given the flagship CVE here is an ERB
gadget. The name was never publishable, so publishing required a rename first.

marshalsea. The Marshalsea was a London debtors' prison, 1373 to 1842, and the
name is the job description: hold untrusted objects at the gate and decide what
gets through before Marshal.load turns bytes into behaviour. It also carries
"Marshal", so the gem reads as on-topic without a subtitle.

module Rube is module Marshalsea, lib/rube/ is lib/marshalsea/, require "rube" is
require "marshalsea", RUBE_TARGET_PORT is MARSHALSEA_TARGET_PORT, and the canary
moved to /tmp/marshalsea-canary. 31 files, roughly 163 occurrences, every one a
hand edit. The single deliberate survivor is the README's "a Rube Goldberg
machine", which describes the gadget chain and not the gem.

Publishing is trusted publishing over OIDC, so no long-lived API key exists in
this repository to leak. A marshalsea-v* tag runs the five suites and the
standalone controls on Ruby 3.4 and 4.0, refuses to continue if the tag disagrees
with Marshalsea::VERSION or if the gemspec floor stops matching the tested
matrix, and then publishes with a Sigstore attestation. The attestation is
recorded as an auditable record and explicitly NOT as an install-time protection,
because neither gem install nor bundle install verifies one today.

Two things the primary source settled that the docs did not. rubygems/release-gem
does accept working-directory, which neither its README nor the RubyGems guide
mentions, so a monorepo subdirectory works. And it runs bundle exec rake release,
which this Rakefile had no task for at all.

Adding bundler/gem_tasks exposed a monorepo trap: Bundler::GemHelper tags a bare
v0.1.0, which says nothing about which of sixty projects it belongs to. Fixed
with tag_prefix. The catch is that rake -T still PRINTS "Create tag v0.1.0",
because that description is built when gem_tasks is required and the prefix is
assigned after. The tag actually created is marshalsea-v0.1.0. The label is
wrong and the behaviour is right, so the gate asserts the runtime value and
carries a control proving a Rakefile without the prefix line really does produce
the bare tag.

Full gate: 58 PASS, 0 FAIL across six stages, package now 25 of 25. 194 tests.
Lint 0 across 30 files.
This commit is contained in:
CarterPerez-dev 2026-07-29 15:29:25 -04:00
parent bfb0ab36e8
commit 9e616ad1a4
35 changed files with 333 additions and 160 deletions

View File

@ -0,0 +1,98 @@
# =============================================================================
# AngelaMos | 2026
# publish-marshalsea.yml
# =============================================================================
name: Publish marshalsea to RubyGems
on:
push:
tags:
- 'marshalsea-v*'
workflow_dispatch:
permissions:
contents: read
env:
PROJECT_DIR: PROJECTS/beginner/deserialization-gadget-lab
jobs:
test:
name: Test on Ruby ${{ matrix.ruby }}
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
ruby: ['3.4', '4.0']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
working-directory: PROJECTS/beginner/deserialization-gadget-lab
- name: Run the suites
working-directory: PROJECTS/beginner/deserialization-gadget-lab
run: |
for suite in test/marshal/parser_test.rb test/scanner_test.rb test/chains_test.rb \
test/marshal/boundary_detector_test.rb test/corpus_test.rb; do
echo "::group::${suite}"
ruby -Ilib -Itest "${suite}"
echo "::endgroup::"
done
- name: Run the standalone controls
working-directory: PROJECTS/beginner/deserialization-gadget-lab
run: ruby -Ilib -Itest test/control_check.rb
- name: Confirm the declared floor matches this matrix
working-directory: PROJECTS/beginner/deserialization-gadget-lab
run: |
floor=$(ruby -e 'print Gem::Specification.load("marshalsea.gemspec").required_ruby_version.to_s')
echo "gemspec declares ${floor}, tested on ${{ matrix.ruby }}"
test "${floor}" = ">= 3.4" || {
echo "::error::gemspec floor ${floor} no longer matches the tested matrix"
exit 1
}
release:
name: Push marshalsea to RubyGems
needs: test
runs-on: ubuntu-latest
environment:
name: rubygems
url: https://rubygems.org/gems/marshalsea
permissions:
contents: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0'
bundler-cache: true
working-directory: PROJECTS/beginner/deserialization-gadget-lab
- name: Confirm the tag matches the gem version
working-directory: PROJECTS/beginner/deserialization-gadget-lab
run: |
version=$(ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION')
expected="marshalsea-v${version}"
echo "tag=${GITHUB_REF_NAME} gemspec=${expected}"
if [ "${GITHUB_REF_NAME}" != "${expected}" ] && [ "${GITHUB_EVENT_NAME}" = "push" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match ${expected}"
exit 1
fi
- name: Release to RubyGems with trusted publishing and attestation
uses: rubygems/release-gem@v1
with:
working-directory: PROJECTS/beginner/deserialization-gadget-lab

View File

@ -1,6 +1,6 @@
# Changelog
All notable changes to rube are documented here.
All notable changes to marshalsea are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@ -38,6 +38,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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?`
- Release workflow publishing to RubyGems by trusted publishing, so no long-lived API
key exists in repository secrets to leak. It fires on a `marshalsea-v*` tag, runs the
suites and the standalone controls on both Ruby 3.4 and 4.0 first, refuses to publish
if the tag disagrees with `Marshalsea::VERSION` or if the gemspec floor no longer
matches the tested matrix, and emits a Sigstore attestation. The attestation is a
publicly auditable record of which workflow built the artifact; it is **not** an
install-time protection, because neither `gem install` nor `bundle install` verifies
one today
- Packaging gate that builds the gem from its declared manifest alone, audits
what shipped, installs the artifact on the floor and current images, and
exercises it from the installed copy rather than the worktree. It asserts every
@ -90,6 +98,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
of a stream you already trust
- `Limits.permissive` is a class method; it was an instance method that ignored
its receiver and allocated twice
- **Renamed from `rube` to `marshalsea`.** `rube` has been taken on rubygems.org since
2009-08-05, so the original name could never have been published. The module is
`Marshalsea`, the library path is `lib/marshalsea/`, and `require "rube"` becomes
`require "marshalsea"`. The Marshalsea was a London debtors' prison; the name is the
job description, since the tool holds untrusted objects at the gate and decides what
gets through
- `Rakefile` gained `bundler/gem_tasks` and, with it, the `release` task the publishing
action invokes. It sets `Bundler::GemHelper.tag_prefix = "marshalsea-"`, because the
default produces a bare `v0.1.0` tag and this gem lives in a repository shared by
sixty projects. Note that `rake -T` still *prints* `Create tag v0.1.0`: that
description string is built when `bundler/gem_tasks` is required, before the prefix is
assigned. The tag actually created is `marshalsea-v0.1.0`, and `just package` asserts
the real value rather than the printed one
- `required_ruby_version` raised from `>= 3.3` to `>= 3.4`. The old floor was
never tested: every gate stage ran on Ruby 4.0 images only. Ruby 3.3 turns out
to fail the suite, because `Marshal.load` did not validate the bignum sign byte

View File

@ -1,4 +1,4 @@
# rube
# marshalsea
A Ruby object-deserialization security lab.
@ -6,6 +6,10 @@ A gadget chain is a Rube Goldberg machine. One untrusted blob goes in, a dozen
unrelated standard-library methods knock each other over, and code execution falls out
the far end. This project builds the machine, then builds the thing that stops it.
The Marshalsea was a London debtors' prison, in operation from 1373 to 1842. The name is
the job: hold untrusted objects at the gate and decide what gets through, before
`Marshal.load` turns bytes into behaviour.
## Why this exists
`Marshal.load` on untrusted input is arbitrary code execution. So is `YAML.unsafe_load`,
@ -59,7 +63,7 @@ same stream raises `ArgumentError: invalid Bignum sign`:
| `+` and `-` | accept | accept | accept | accept |
| `!`, `\x00`, `\xFF`, `0` | accept | accept | **reject** | **reject** |
rube's parser accepts `+` and `-` only, so it models 3.4 and newer. Run it on 3.3 and it
marshalsea's parser accepts `+` and `-` only, so it models 3.4 and newer. Run it on 3.3 and it
disagrees with the interpreter it exists to model on four of those six bytes. A stream
inspector that disagrees with the loader it guards is not worth shipping, so the floor sits
where the agreement starts. `just package` re-proves this in both directions on every run:
@ -67,13 +71,17 @@ on the floor image Ruby and the parser agree, one version below it they diverge.
## Installation
rube is not published to rubygems.org. Build it from this checkout and install the artifact:
The first release has not been cut yet, so there is nothing on rubygems.org to install from.
Build it from this checkout:
```
just build
gem install --local tmp/build/rube-0.1.0.gem
gem install --local tmp/build/marshalsea-0.1.0.gem
```
Releases are published from CI by trusted publishing, so no long-lived API key exists to
leak. `gem install marshalsea` starts working once the first tag ships.
The gem carries `lib/`, the README, the changelog, and the license. Nothing else. The
vulnerable target, the adversarial corpus, the gate scripts, and the research notes stay in
the repository, and `just package` fails if any of them turn up inside a built artifact.
@ -81,10 +89,10 @@ the repository, and `just package` fails if any of them turn up inside a built a
## Usage
```ruby
require "rube"
require "marshalsea"
payload = Marshal.dump(Gem::Requirement.new(">= 0"))
result = Rube::Marshal::Parser.new(payload).parse
result = Marshalsea::Marshal::Parser.new(payload).parse
result.class_names
# => ["Gem::Requirement", "Gem::Version"]
@ -93,15 +101,15 @@ result.sinks.map { |s| "#{s.class_name}##{s.sink_method}" }
# => ["Gem::Requirement#marshal_load", "Gem::Version#marshal_load"]
```
`Parser.new` enforces `Rube::Marshal::Limits.new` unless you say otherwise. Every ceiling
is opt-out, never opt-in — pass `limits: Rube::Marshal::Limits.permissive` if you are doing
`Parser.new` enforces `Marshalsea::Marshal::Limits.new` unless you say otherwise. Every ceiling
is opt-out, never opt-in — pass `limits: Marshalsea::Marshal::Limits.permissive` if you are doing
forensics on a stream you already trust and want it parsed whole.
To make a decision rather than inspect a stream, use the detector, which applies a policy
and hands back a frozen snapshot:
```ruby
detector = Rube::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
detector = Marshalsea::Marshal::BoundaryDetector.new(allowed_class_names: %w[Hash String])
decision = detector.inspect_stream(untrusted_bytes)
decision.blocked? # => true
@ -129,7 +137,7 @@ There is no `accepted?`. The question "did the policy permit this" and the quest
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?`.
Read `Marshalsea::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`.
@ -158,9 +166,34 @@ just manifest list exactly what would ship in the .gem
on disk still matches the source it claims to be built from:
```
just package tmp/build/rube-0.1.0.gem
just package tmp/build/marshalsea-0.1.0.gem
```
## Releasing
Bump `Marshalsea::VERSION`, then push a tag:
```
git tag marshalsea-v0.1.0
git push origin marshalsea-v0.1.0
```
That is the whole release. CI runs the suites and the standalone controls on Ruby 3.4 and
4.0, refuses to continue if the tag disagrees with `Marshalsea::VERSION` or if the gemspec
floor no longer matches the tested matrix, and then publishes through RubyGems trusted
publishing. There is no API key anywhere in this repository, and none to rotate or leak:
the job proves its identity to rubygems.org with a short-lived OIDC token issued by GitHub
for that specific workflow.
The tag is prefixed because sixty projects share this repository and a bare `v0.1.0` would
not say which one it belongs to. `rake -T` still prints `Create tag v0.1.0` because that
description is built before the prefix is applied; the tag actually created is
`marshalsea-v0.1.0`, and `just package` asserts the real value rather than the printed one.
Each release also publishes a Sigstore attestation recording which workflow built the
artifact and from which commit. Treat it as an auditable record, not as protection: neither
`gem install` nor `bundle install` verifies attestations today.
## A note on the object-link index
Ruby's Marshal format documentation states that object links are one-indexed. **They are

View File

@ -3,6 +3,9 @@
# frozen_string_literal: true
require "rake/testtask"
require "bundler/gem_tasks"
Bundler::GemHelper.tag_prefix = "marshalsea-"
Rake::TestTask.new(:test) do |t|
t.libs = %w[lib test]

View File

@ -22,10 +22,10 @@ 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.proceed? ? "accept" : "reject", k[:allowed].join(","), dec.reason.to_s[0, 52]) }'
{{run_ro}} ruby -Ilib -Itest -e 'require "marshalsea"; require "support/adversarial_corpus"; Marshalsea::AdversarialCorpus::CASES.each { |k| d = Marshalsea::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} 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'
{{run_ro}} ruby -Ilib -e 'require "marshalsea"; ns = "{{namespace}}"; r = Marshalsea::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
@ -57,11 +57,11 @@ gate: check matrix exploit detector target package
build:
@mkdir -p tmp/build
{{build_run}} -v $PWD:/src:ro -v $PWD/tmp/build:/out -w /out {{image}} sh -c "set -e; cd /src && ruby -e 'puts Gem::Specification.load(%q{rube.gemspec}).files' >/out/declared.txt; cd /out && tar -C /src -T declared.txt -cf - | tar -xf -; cp /src/rube.gemspec /out/; gem build --strict rube.gemspec"
{{build_run}} -v $PWD:/src:ro -v $PWD/tmp/build:/out -w /out {{image}} sh -c "set -e; cd /src && ruby -e 'puts Gem::Specification.load(%q{marshalsea.gemspec}).files' >/out/declared.txt; cd /out && tar -C /src -T declared.txt -cf - | tar -xf -; cp /src/marshalsea.gemspec /out/; gem build --strict marshalsea.gemspec"
@ls -l tmp/build/*.gem
manifest:
{{run_ro}} ruby -e 'spec = Gem::Specification.load("rube.gemspec"); puts spec.files.sort; puts; puts "#{spec.files.length} files"'
{{run_ro}} ruby -e 'spec = Gem::Specification.load("marshalsea.gemspec"); puts spec.files.sort; puts; puts "#{spec.files.length} files"'
shell:
docker run --rm -it --network none -v $PWD:/app -w /app {{image}} bash

View File

@ -0,0 +1,17 @@
# ©AngelaMos | 2026
# marshalsea.rb
# frozen_string_literal: true
require_relative "marshalsea/version"
require_relative "marshalsea/marshal/constants"
require_relative "marshalsea/marshal/errors"
require_relative "marshalsea/marshal/node"
require_relative "marshalsea/marshal/float_body"
require_relative "marshalsea/marshal/limits"
require_relative "marshalsea/marshal/parser"
require_relative "marshalsea/marshal/boundary_detector"
require_relative "marshalsea/scanner"
require_relative "marshalsea/chains"
module Marshalsea
end

View File

@ -2,7 +2,7 @@
# chains.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Chains
class UnknownChainError < StandardError; end

View File

@ -2,7 +2,7 @@
# base.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Chains
class ChainError < StandardError; end

View File

@ -4,7 +4,7 @@
require "erb"
module Rube
module Marshalsea
module Chains
class ErbDefMethod < Base
CHAIN_NAME = "erb-def-method"
@ -20,7 +20,7 @@ module Rube
].freeze
SRC_PREFIX = "#\nend\n"
SRC_SUFFIX = "\ndef _rube_unused\n"
SRC_SUFFIX = "\ndef _marshalsea_unused\n"
DEFAULT_FILENAME = "(erb)"
DEFAULT_LINENO = 0

View File

@ -2,7 +2,7 @@
# boundary_detector.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
class ReporterRequiredError < StandardError; end
@ -32,7 +32,7 @@ module Rube
LIMITATION_NOTICE = <<~NOTICE
SECURITY LIMITATION
Rube::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
Marshalsea::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
applies a caller-selected policy before deserialization. An ACCEPT decision means
only that this snapshot matched that policy.

View File

@ -2,7 +2,7 @@
# constants.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
module Constants
MAJOR_VERSION = 4

View File

@ -2,7 +2,7 @@
# errors.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
class StreamError < StandardError; end

View File

@ -2,7 +2,7 @@
# float_body.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
module FloatBody
module_function

View File

@ -2,7 +2,7 @@
# limits.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
class Limits
DEFAULT_MAX_BYTES = 1_048_576

View File

@ -2,7 +2,7 @@
# node.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
class Node
STRING_BACKED_TYPES = %i[string regexp].freeze

View File

@ -2,7 +2,7 @@
# parser.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module Marshal
class Parser
include Constants

View File

@ -2,7 +2,7 @@
# scanner.rb
# frozen_string_literal: true
module Rube
module Marshalsea
class Scanner
GATED_METHODS = %w[marshal_load _load_data].freeze
GATED_SINGLETON_METHODS = %w[_load].freeze

View File

@ -2,6 +2,6 @@
# version.rb
# frozen_string_literal: true
module Rube
module Marshalsea
VERSION = "0.1.0"
end

View File

@ -1,17 +0,0 @@
# ©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"
require_relative "rube/scanner"
require_relative "rube/chains"
module Rube
end

View File

@ -1,19 +1,19 @@
# ©AngelaMos | 2026
# rube.gemspec
# marshalsea.gemspec
# frozen_string_literal: true
require_relative "lib/rube/version"
require_relative "lib/marshalsea/version"
Gem::Specification.new do |spec|
spec.name = "rube"
spec.version = Rube::VERSION
spec.name = "marshalsea"
spec.version = Marshalsea::VERSION
spec.authors = ["Carter Perez"]
spec.email = ["carterperez2222@gmail.com"]
spec.summary = "Ruby object-deserialization security lab: inspect, understand, and defend against gadget chains"
spec.description = "rube parses Ruby Marshal streams without deserializing them, discovers gadget " \
"sinks by runtime reflection, and ships the defenses that stop them. Built as a " \
"teaching artifact for the deserialization vulnerability class."
spec.description = "marshalsea parses Ruby Marshal streams without deserializing them, discovers " \
"gadget sinks by runtime reflection, and ships the defenses that stop them. Built " \
"as a teaching artifact for the deserialization vulnerability class."
spec.homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
spec.license = "AGPL-3.0-or-later"

View File

@ -13,11 +13,11 @@ echo
output="$(docker run --rm --network none --read-only --tmpfs /tmp:rw,noexec,nosuid,size=1m \
--user nobody \
-v "${HERE}/lib:/app/lib:ro" -w /app "${VULNERABLE_IMAGE}" ruby -Ilib -e '
require "rube"
D = Rube::Marshal::BoundaryDetector
CANARY = "/tmp/rube-canary"
require "marshalsea"
D = Marshalsea::Marshal::BoundaryDetector
CANARY = "/tmp/marshalsea-canary"
hostile = Rube::Chains::ErbDefMethod.canary(CANARY, "fired").serialize
hostile = Marshalsea::Chains::ErbDefMethod.canary(CANARY, "fired").serialize
benign = Marshal.dump({ "user" => "guest", "roles" => [1, 2, 3] })
sinky = Marshal.dump(Gem::Requirement.new(">= 0"))

View File

@ -42,15 +42,15 @@ if [[ -n "${GIVEN}" ]]; then
echo "gem_built=true" | record
else
GEM_DIR="${WORK}/build"
GEM_FILE="rube-$(run -v "${HERE}:/src:ro" -w /src "${BUILD_IMAGE}" \
ruby -e 'require "./lib/rube/version"; print Rube::VERSION').gem"
GEM_FILE="marshalsea-$(run -v "${HERE}:/src:ro" -w /src "${BUILD_IMAGE}" \
ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION').gem"
run -v "${HERE}:/src:ro" -v "${WORK}/build:/out" -w /out "${BUILD_IMAGE}" sh -c '
set -e
cd /src && ruby -e "puts Gem::Specification.load(%q{rube.gemspec}).files" >/out/declared.txt
cd /src && ruby -e "puts Gem::Specification.load(%q{marshalsea.gemspec}).files" >/out/declared.txt
cd /out && tar -C /src -T declared.txt -cf - | tar -xf -
cp /src/rube.gemspec /out/
gem build --strict rube.gemspec
cp /src/marshalsea.gemspec /out/
gem build --strict marshalsea.gemspec
' 2>&1 | sed 's/^/ /'
if [[ -f "${GEM_DIR}/${GEM_FILE}" ]]; then
@ -75,16 +75,16 @@ install_and_use() {
set -e
gem install --local --no-document /gem/${GEM_FILE} >/dev/null
ruby -e '
require \"rube\"
raise \"loaded from the worktree\" unless Gem.loaded_specs[\"rube\"]
require \"marshalsea\"
raise \"loaded from the worktree\" unless Gem.loaded_specs[\"marshalsea\"]
blob = Marshal.dump(Gem::Requirement.new(\">= 0\"))
result = Rube::Marshal::Parser.new(blob).parse
result = Marshalsea::Marshal::Parser.new(blob).parse
sinks = result.sinks.map { |s| \"#{s.class_name}##{s.sink_method}\" }
decision = Rube::Marshal::BoundaryDetector.new.inspect_stream(blob)
decision = Marshalsea::Marshal::BoundaryDetector.new.inspect_stream(blob)
ok = result.class_names.include?(\"Gem::Requirement\") &&
sinks.include?(\"Gem::Requirement#marshal_load\") &&
decision.blocked? &&
!defined?(Rube::Marshal::FloatBody).nil?
!defined?(Marshalsea::Marshal::FloatBody).nil?
puts \"installed_gem_works_on_${label}=#{ok}\"
'
" 2>&1 | tail -1
@ -94,7 +94,23 @@ install_and_use "${FLOOR_IMAGE}" floor | record
install_and_use "${BUILD_IMAGE}" current | record
echo
echo "=== 4 the floor is measured, not asserted ==="
echo "=== 4 release identity ==="
release_tag="$(run -v "${HERE}:/app:ro" -w /app "${BUILD_IMAGE}" \
ruby -e 'require "rake"; load "Rakefile"; print Bundler::GemHelper.instance.send(:version_tag)' 2>/dev/null)"
gem_version="$(run -v "${HERE}:/app:ro" -w /app "${BUILD_IMAGE}" \
ruby -e 'require "./lib/marshalsea/version"; print Marshalsea::VERSION' 2>/dev/null)"
echo " rake release would tag: ${release_tag}"
echo "release_tag_is_namespaced=$([[ ${release_tag} == "marshalsea-v${gem_version}" ]] && echo true || echo false)" | record
bare_tag="$(run -v "${HERE}:/app:ro" -w /tmp "${BUILD_IMAGE}" sh -c '
cp -r /app/lib /app/marshalsea.gemspec /app/README.md /app/CHANGELOG.md /app/LICENSE /tmp/ 2>/dev/null
printf "require \"rake\"\nrequire \"bundler/gem_tasks\"\n" >/tmp/Rakefile
ruby -e "require \"rake\"; load \"Rakefile\"; print Bundler::GemHelper.instance.send(:version_tag)"
' 2>/dev/null)"
echo "control_bare_rakefile_tags_the_whole_monorepo=$([[ ${bare_tag} == "v${gem_version}" ]] && echo true || echo false)" | record
echo
echo "=== 5 the floor is measured, not asserted ==="
suite_status=0
for suite in marshal/parser_test scanner_test chains_test marshal/boundary_detector_test corpus_test; do
if ! docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${FLOOR_IMAGE}" \
@ -114,7 +130,7 @@ differential() {
local label="$2"
docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${image}" ruby -Ilib -e "
require \"rube\"
require \"marshalsea\"
bytes = ${INVALID_SIGN_STREAM}
ruby_accepts = begin
Marshal.load(bytes)
@ -123,9 +139,9 @@ differential() {
false
end
parser_accepts = begin
Rube::Marshal::Parser.new(bytes).parse
Marshalsea::Marshal::Parser.new(bytes).parse
true
rescue Rube::Marshal::StreamError
rescue Marshalsea::Marshal::StreamError
false
end
puts \"${label}_ruby_accepts_invalid_sign=#{ruby_accepts}\"
@ -137,13 +153,13 @@ differential "${FLOOR_IMAGE}" floor | record
differential "${BELOW_FLOOR_IMAGE}" below_floor | record
echo
echo "=== 5 negative controls ==="
cat >"${WORK}/ships-target/rube.gemspec" <<'SPEC'
require_relative "lib/rube/version"
echo "=== 6 negative controls ==="
cat >"${WORK}/ships-target/marshalsea.gemspec" <<'SPEC'
require_relative "lib/marshalsea/version"
Gem::Specification.new do |spec|
spec.name = "rube"
spec.version = Rube::VERSION
spec.name = "marshalsea"
spec.version = Marshalsea::VERSION
spec.authors = ["Carter Perez"]
spec.email = ["carterperez2222@gmail.com"]
spec.summary = "control fixture that deliberately ships the vulnerable target"
@ -159,7 +175,7 @@ SPEC
run -v "${HERE}:/src:ro" -v "${WORK}/ships-target:/out" -w /out "${BUILD_IMAGE}" sh -c '
set -e
tar -C /src -cf - lib target README.md CHANGELOG.md LICENSE | tar -xf -
gem build rube.gemspec
gem build marshalsea.gemspec
' >/dev/null 2>&1
control_target="$(run -v "${HERE}:/src:ro" -v "${WORK}/ships-target:/gem:ro" "${BUILD_IMAGE}" \
@ -169,12 +185,12 @@ echo "control_auditor_rejects_a_gem_shipping_the_target=$([[ ${control_target} =
run -v "${HERE}:/src:ro" -v "${WORK}/drifted:/out" -w /out "${BUILD_IMAGE}" sh -c '
set -e
cd /src && ruby -e "puts Gem::Specification.load(%q{rube.gemspec}).files" >/out/declared.txt
cd /src && ruby -e "puts Gem::Specification.load(%q{marshalsea.gemspec}).files" >/out/declared.txt
cd /out && tar -C /src -T declared.txt -cf - | tar -xf -
cp /src/rube.gemspec /out/
ruby -e "File.write(%q{lib/rube/version.rb}, File.read(%q{lib/rube/version.rb}) + %q{
cp /src/marshalsea.gemspec /out/
ruby -e "File.write(%q{lib/marshalsea/version.rb}, File.read(%q{lib/marshalsea/version.rb}) + %q{
})"
gem build rube.gemspec
gem build marshalsea.gemspec
' >/dev/null 2>&1
control_drift="$(run -v "${HERE}:/src:ro" -v "${WORK}/drifted:/gem:ro" "${BUILD_IMAGE}" \
@ -225,6 +241,8 @@ expect container_files_absent "Dockerfile and rack config are absent"
expect lab_artifacts_absent "no canary, payload or nested gem artifact shipped"
expect installed_gem_works_on_floor "the installed gem parses, classifies and blocks on the floor"
expect installed_gem_works_on_current "the installed gem parses, classifies and blocks on current"
expect release_tag_is_namespaced "rake release tags marshalsea-vX, not a bare vX the monorepo shares"
expect control_bare_rakefile_tags_the_whole_monorepo "without the tag_prefix line the tag really is bare, so that check is live"
expect suite_green_on_floor "every suite is green on the floor image"
reject floor_ruby_accepts_invalid_sign "on the floor, real Marshal rejects an invalid bignum sign"
reject floor_parser_accepts_invalid_sign "on the floor, the parser rejects it too, so they agree"

View File

@ -3,7 +3,7 @@
# frozen_string_literal: true
require "json"
require "rube"
require "marshalsea"
TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze
MARK_YES = "yes"
@ -11,7 +11,7 @@ MARK_NO = "no"
MARK_UNKNOWN = "?"
RULE_WIDTH = 78
CHAIN = Rube::Chains::ErbDefMethod
CHAIN = Marshalsea::Chains::ErbDefMethod
def cve_patched?(version)
!CHAIN.affects?(version)

View File

@ -6,9 +6,9 @@ set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="rube-target:local"
CONTAINER="rube-target-gate"
PORT="${RUBE_TARGET_PORT:-47823}"
IMAGE="marshalsea-target:local"
CONTAINER="marshalsea-target-gate"
PORT="${MARSHALSEA_TARGET_PORT:-47823}"
BASE="http://127.0.0.1:${PORT}"
CANARY_MARKER="fired"
@ -48,9 +48,9 @@ echo
payload="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
ruby -Ilib -e '
require "rube"
require "marshalsea"
require "base64"
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
state = { user: "attacker", template: chain.generate }
print Base64.strict_encode64(Marshal.dump(state))
')"
@ -76,7 +76,7 @@ else
failures=$((failures + 1))
fi
docker exec "${CONTAINER}" rm -f /tmp/rube-canary >/dev/null 2>&1 || true
docker exec "${CONTAINER}" rm -f /tmp/marshalsea-canary >/dev/null 2>&1 || true
reset="$(curl -s "${BASE}/canary")"
safe_body="$(curl -s --cookie "session_state=${payload}" "${BASE}/render/safe")"
@ -136,7 +136,7 @@ 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
if grep -qE "app\.rb|/app/lib|marshalsea/marshal" "${leak_file}" "${leak_file}.v"; then
echo " FAIL an error response leaked source paths or source lines"
failures=$((failures + 1))
else
@ -168,11 +168,11 @@ done
echo
sinks="$(docker run --rm --network none -v "${HERE}/lib:/app/lib:ro" -w /app ruby:4.0-slim \
ruby -Ilib -e '
require "rube"
require "marshalsea"
require "base64"
chain = Rube::Chains::ErbDefMethod.canary("/tmp/rube-canary", "fired")
chain = Marshalsea::Chains::ErbDefMethod.canary("/tmp/marshalsea-canary", "fired")
blob = Marshal.dump({ user: "attacker", template: chain.generate })
result = Rube::Marshal::Parser.new(blob).parse
result = Marshalsea::Marshal::Parser.new(blob).parse
print result.sinks.length
')"

View File

@ -5,12 +5,12 @@
require "sinatra/base"
require "base64"
require "erb"
require "rube"
require "marshalsea"
module Rube
module Marshalsea
module Target
COOKIE_NAME = "session_state"
CANARY_PATH = "/tmp/rube-canary"
CANARY_PATH = "/tmp/marshalsea-canary"
STATUS_OK = 200
STATUS_BAD_REQUEST = 400
@ -22,10 +22,10 @@ module Rube
SESSION_KEYS = %i[user template].freeze
DETECTOR = Rube::Marshal::BoundaryDetector.new(
policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
DETECTOR = Marshalsea::Marshal::BoundaryDetector.new(
policy: Marshalsea::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
allowed_class_names: PERMITTED_CLASS_NAMES,
limits: Rube::Marshal::Limits.new
limits: Marshalsea::Marshal::Limits.new
)
REJECTED = "rejected: %s"
@ -41,7 +41,7 @@ module Rube
get "/" do
content_type CONTENT_TYPE
[
"rube target",
"marshalsea target",
"erb #{Gem::Specification.find_all_by_name('erb').map(&:version).max}",
"ruby #{RUBY_VERSION}",
"",

View File

@ -4,4 +4,4 @@
require_relative "target/app"
run Rube::Target::App
run Marshalsea::Target::App

View File

@ -4,10 +4,10 @@
require_relative "test_helper"
module Rube
module Marshalsea
module Chains
class ChainsTest < Minitest::Test
CANARY_PATH = "/tmp/rube-canary"
CANARY_PATH = "/tmp/marshalsea-canary"
CANARY_MARKER = "fired"
def chain
@ -68,13 +68,13 @@ module Rube
def test_serialize_produces_a_parseable_marshal_stream
blob = chain.serialize
assert_equal [Rube::Marshal::Constants::MAJOR_VERSION, Rube::Marshal::Constants::MINOR_VERSION],
assert_equal [Marshalsea::Marshal::Constants::MAJOR_VERSION, Marshalsea::Marshal::Constants::MINOR_VERSION],
[blob.getbyte(0), blob.getbyte(1)]
assert_equal :object, Rube::Marshal::Parser.new(blob).parse.root.type
assert_equal :object, Marshalsea::Marshal::Parser.new(blob).parse.root.type
end
def test_payload_is_visible_to_the_parser_without_deserializing
result = Rube::Marshal::Parser.new(chain.serialize).parse
result = Marshalsea::Marshal::Parser.new(chain.serialize).parse
assert_includes result.class_names, "ERB"
end

View File

@ -4,7 +4,7 @@
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "rube"
require "marshalsea"
Pair = Struct.new(:x, :y)
@ -28,7 +28,7 @@ puts "=== 2 object link indexing ==="
cyclic = []
cyclic << cyclic
blob = Marshal.dump(cyclic)
link = Rube::Marshal::Parser.new(blob).parse.root.children.first
link = Marshalsea::Marshal::Parser.new(blob).parse.root.children.first
bytes = blob.bytes.map { |b| format("%02x", b) }.join(" ")
failures << "link index" unless check("self-referential array links to index 0", link.value.zero?, bytes)
@ -50,7 +50,7 @@ corpus = [
:sym, :"with spaces", :marshal_load,
[], {}, [1, [2, [3, [4]]]], { a: { b: { c: 1 } } },
(1..5).to_a, Pair.new(1, 2), Pair.new(nil, [1, 2]),
Object.new, Time.now, /regex/i, //mx, String, Comparable, Rube,
Object.new, Time.now, /regex/i, //mx, String, Comparable, Marshalsea,
aliased, cyclic_hash, deep,
{ "mixed" => [1, :two, 3.0, nil, true] },
Hash.new(0).tap { |h| h[:k] = 1 },
@ -58,7 +58,7 @@ corpus = [
]
parsed = corpus.count do |item|
Rube::Marshal::Parser.new(Marshal.dump(item)).parse.root
Marshalsea::Marshal::Parser.new(Marshal.dump(item)).parse.root
true
rescue StandardError => e
puts " #{item.class}: #{e.class}: #{e.message}"
@ -69,16 +69,16 @@ failures << "corpus" unless check("every corpus entry parsed", parsed == corpus.
puts
puts "=== 4 stream rejection ==="
rejected = begin
Rube::Marshal::Parser.new("\x04\x08[\x06@\x63").parse
Marshalsea::Marshal::Parser.new("\x04\x08[\x06@\x63").parse
false
rescue Rube::Marshal::InvalidLinkError
rescue Marshalsea::Marshal::InvalidLinkError
true
end
failures << "bounds" unless check("out-of-range object link rejected", rejected, "InvalidLinkError")
puts
puts "=== 5 payload inspection ==="
result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
result = Marshalsea::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort
failures << "class names" unless check("classes extracted", !result.class_names.empty?,
result.class_names.join(", "))
@ -87,7 +87,7 @@ failures << "gated sinks" unless check("gated sinks flagged", !from_stream.empty
puts
puts "=== 6 parser and scanner agreement ==="
scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
scanned = Marshalsea::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
missing = from_stream - scanned
located = !from_stream.empty? && missing.empty?
agreement_detail = if from_stream.empty?
@ -101,7 +101,7 @@ failures << "agreement" unless check("parser sinks located by reflection", locat
puts
puts "=== 7 scanner precision ==="
full = Rube::Scanner.new.scan
full = Marshalsea::Scanner.new.scan
ungated = full.ungated.length
reachable = full.reachable.count { |c| !c.gated? }
kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1)

View File

@ -5,7 +5,7 @@
require_relative "test_helper"
require_relative "support/adversarial_corpus"
module Rube
module Marshalsea
class CorpusTest < Minitest::Test
def detector(allowed = [])
Marshal::BoundaryDetector.new(allowed_class_names: allowed)

View File

@ -5,11 +5,11 @@
require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
module Rube
module Marshalsea
module Marshal
class BoundaryDetectorTest < Minitest::Test
BENIGN = { "user" => "guest", "roles" => [1, 2, 3], "flag" => true }.freeze
CANARY_PATH = "/tmp/rube-canary"
CANARY_PATH = "/tmp/marshalsea-canary"
CANARY_MARKER = "fired"
def detector(**)
@ -25,7 +25,7 @@ module Rube
end
def cve_blob
Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize
Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER).serialize
end
def test_default_policy_is_strict_allowlist

View File

@ -5,7 +5,7 @@
require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
module Rube
module Marshalsea
module Marshal
class ParserTest < Minitest::Test
def parse(blob)
@ -80,7 +80,7 @@ module Rube
class RoleFixture; end
def class_name_slot_streams
name = "Rube::Marshal::ParserTest::RoleFixture"
name = "Marshalsea::Marshal::ParserTest::RoleFixture"
symbol = AdversarialCorpus.sym(name)
{
@ -285,7 +285,7 @@ module Rube
def test_extracts_class_name_from_plain_object
blob = ::Marshal.dump(Fixture.new)
result = parse(blob)
assert_includes result.class_names, "Rube::Marshal::ParserTest::Fixture"
assert_includes result.class_names, "Marshalsea::Marshal::ParserTest::Fixture"
end
def test_extracts_class_name_without_instantiating
@ -299,7 +299,7 @@ module Rube
sink = result.sinks.first
refute_nil sink
assert_equal "marshal_load", sink.sink_method
assert_equal "Rube::Marshal::ParserTest::UserMarshalFixture", sink.class_name
assert_equal "Marshalsea::Marshal::ParserTest::UserMarshalFixture", sink.class_name
end
def test_flags_userdef_as_sink
@ -410,7 +410,7 @@ module Rube
def test_class_name_node_is_traversable
result = parse(::Marshal.dump(Fixture.new))
names = result.nodes.select { |node| node.type == :symbol }.map(&:value)
assert_includes names, :"Rube::Marshal::ParserTest::Fixture"
assert_includes names, :"Marshalsea::Marshal::ParserTest::Fixture"
end
def test_class_name_slot_gadget_is_reachable_in_every_slot
@ -678,12 +678,12 @@ module Rube
end
def test_the_chain_registry_cannot_be_appended_to_by_a_caller
assert_raises(FrozenError) { Rube::Chains.registry << Object }
assert_raises(FrozenError) { Marshalsea::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
refute_empty Marshalsea::Chains.all
assert_includes Marshalsea::Chains.all, Marshalsea::Chains::ErbDefMethod
end
FIXNUM_WIDTH_PROBES = [
@ -782,7 +782,7 @@ module Rube
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
from_scanner = Marshalsea::Scanner::GATED_METHODS + Marshalsea::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,

View File

@ -4,13 +4,13 @@
require_relative "test_helper"
module Rube
module Marshalsea
class ScannerTest < Minitest::Test
SUPPRESSION_NAMESPACE = "Rube::ScannerSuppressionFixture"
MISSING_SOURCE_PATH = "/nonexistent/rube-scanner-fixture.rb"
SUPPRESSION_NAMESPACE = "Marshalsea::ScannerSuppressionFixture"
MISSING_SOURCE_PATH = "/nonexistent/marshalsea-scanner-fixture.rb"
Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1)
module Rube
module Marshalsea
module ScannerSuppressionFixture
class VanishedSource
def hash
@ -41,7 +41,7 @@ module Rube
end
def local_scan
scan(namespace: "Rube::ScannerTest")
scan(namespace: "Marshalsea::ScannerTest")
end
def candidates_for(class_name)
@ -49,71 +49,71 @@ module Rube
end
def test_finds_gated_sink_defined_on_a_class
found = candidates_for("Rube::ScannerTest::GatedFixture")
found = candidates_for("Marshalsea::ScannerTest::GatedFixture")
assert_equal ["marshal_load"], found.map(&:method_name)
assert_equal :gated, found.first.gate
end
def test_finds_singleton_load_as_gated_sink
found = candidates_for("Rube::ScannerTest::UserDefFixture")
found = candidates_for("Marshalsea::ScannerTest::UserDefFixture")
assert_includes found.map(&:method_name), "_load"
end
def test_finds_ungated_dispatch_method
found = candidates_for("Rube::ScannerTest::UngatedFixture")
found = candidates_for("Marshalsea::ScannerTest::UngatedFixture")
assert_equal ["hash"], found.map(&:method_name)
assert_equal :ungated, found.first.gate
end
def test_finds_method_missing_as_ungated
found = candidates_for("Rube::ScannerTest::ProxyFixture")
found = candidates_for("Marshalsea::ScannerTest::ProxyFixture")
assert_includes found.map(&:method_name), "method_missing"
end
def test_negative_control_class_with_no_auto_invoked_methods_is_not_reported
assert_empty candidates_for("Rube::ScannerTest::InertFixture")
assert_empty candidates_for("Marshalsea::ScannerTest::InertFixture")
end
def test_precision_control_inherited_methods_are_not_reported
assert_empty candidates_for("Rube::ScannerTest::InheritsOnlyFixture")
assert_empty candidates_for("Marshalsea::ScannerTest::InheritsOnlyFixture")
end
def test_candidates_carry_source_location
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
refute_nil candidate.source_location
assert_includes candidate.source_location, "scanner_test.rb"
end
def test_candidates_report_arity
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
assert_equal 1, candidate.arity
end
def test_ungated_methods_report_zero_arity
candidate = candidates_for("Rube::ScannerTest::UngatedFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::UngatedFixture").first
assert_predicate candidate, :zero_arity?
end
def test_reachability_via_instance_variable_read
candidate = candidates_for("Rube::ScannerTest::StatefulFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::StatefulFixture").first
assert_predicate candidate, :touches_state?
assert_predicate candidate, :reachable?
end
def test_reachability_via_implicit_self_call
candidate = candidates_for("Rube::ScannerTest::AccessorFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::AccessorFixture").first
assert_predicate candidate, :touches_state?, "attr_reader access must count as touching state"
assert_predicate candidate, :reachable?
end
def test_negative_control_stateless_method_is_not_reachable
candidate = candidates_for("Rube::ScannerTest::StatelessFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::StatelessFixture").first
refute_predicate candidate, :touches_state?
refute_predicate candidate, :reachable?
end
def test_gated_sinks_are_reachable_regardless_of_state
candidate = candidates_for("Rube::ScannerTest::GatedFixture").first
candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first
assert_predicate candidate, :reachable?
end
@ -138,8 +138,8 @@ module Rube
end
def test_namespace_filter_excludes_everything_else
report = scan(namespace: "Rube::ScannerTest")
assert(report.candidates.all? { |c| c.class_name.start_with?("Rube::ScannerTest") })
report = scan(namespace: "Marshalsea::ScannerTest")
assert(report.candidates.all? { |c| c.class_name.start_with?("Marshalsea::ScannerTest") })
end
def test_report_partitions_gated_and_ungated
@ -182,7 +182,7 @@ module Rube
end
def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from
control = candidates_for("Rube::ScannerTest::ExplodingHandleFixture")
control = candidates_for("Marshalsea::ScannerTest::ExplodingHandleFixture")
assert_equal ["marshal_load"], control.map(&:method_name),
"control: this fixture must be discoverable when it is not exploding"
@ -192,7 +192,7 @@ module Rube
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_equal "Marshalsea::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject
assert_predicate report, :candidates_lost?
refute_predicate report, :complete?
end
@ -305,7 +305,7 @@ module Rube
def test_an_analysed_candidate_reports_its_state_as_known
%w[StatefulFixture StatelessFixture].each do |fixture|
candidate = candidates_for("Rube::ScannerTest::#{fixture}").first
candidate = candidates_for("Marshalsea::ScannerTest::#{fixture}").first
assert_predicate candidate, :state_known?,
"control: a readable source must produce a verdict, or unknown means nothing"

View File

@ -2,7 +2,7 @@
# adversarial_corpus.rb
# frozen_string_literal: true
module Rube
module Marshalsea
module AdversarialCorpus
HEADER = "\x04\x08"
INLINE_OFFSET = 5

View File

@ -3,25 +3,25 @@
# frozen_string_literal: true
require "fileutils"
require "rube"
require "marshalsea"
CANARY_PATH = "/tmp/rube-canary"
CANARY_PATH = "/tmp/marshalsea-canary"
CANARY_MARKER = "fired"
RESULT_FIRED = "FIRED"
RESULT_BLOCKED = "BLOCKED"
RESULT_INERT = "INERT"
erb_version = Gem::Specification.find_all_by_name("erb").map(&:version).max.to_s
chain = Rube::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
chain = Marshalsea::Chains::ErbDefMethod.canary(CANARY_PATH, CANARY_MARKER)
blob = chain.serialize
inspection = Rube::Marshal::Parser.new(blob).parse
inspection = Marshalsea::Marshal::Parser.new(blob).parse
FileUtils.rm_f(CANARY_PATH)
revived = Marshal.load(blob)
detail = begin
revived.def_method(Module.new, "rube_probe")
revived.def_method(Module.new, "marshalsea_probe")
"def_method returned"
rescue StandardError => e
"#{e.class}: #{e.message}"
@ -36,7 +36,7 @@ outcome = if fired
RESULT_INERT
end
predicted = Rube::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
predicted = Marshalsea::Chains::ErbDefMethod.affects?(erb_version) ? RESULT_FIRED : RESULT_BLOCKED
puts format("%-9s erb=%-9s outcome=%-8s predicted=%-8s classes=%-6s %s",
ENV.fetch("MATRIX_IMAGE", "?"), erb_version, outcome, predicted,

View File

@ -5,4 +5,4 @@
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "minitest/autorun"
require "rube"
require "marshalsea"