diff --git a/.github/workflows/publish-marshalsea.yml b/.github/workflows/publish-marshalsea.yml new file mode 100644 index 00000000..75c2ca20 --- /dev/null +++ b/.github/workflows/publish-marshalsea.yml @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md index 07e39f6e..bf822adb 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index 14089e84..682803bf 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile index 60586ca9..23ac32a8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/Rakefile +++ b/PROJECTS/beginner/deserialization-gadget-lab/Rakefile @@ -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] diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 0986925c..1b6b7c42 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb new file mode 100644 index 00000000..b5ef2a47 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb similarity index 97% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb index eafe027e..5fd1c1d7 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains.rb @@ -2,7 +2,7 @@ # chains.rb # frozen_string_literal: true -module Rube +module Marshalsea module Chains class UnknownChainError < StandardError; end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb similarity index 98% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb index 133298f5..25a8a065 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/base.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/base.rb @@ -2,7 +2,7 @@ # base.rb # frozen_string_literal: true -module Rube +module Marshalsea module Chains class ChainError < StandardError; end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb similarity index 95% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb index 5193b060..c81d87f0 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/chains/erb_def_method.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/chains/erb_def_method.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb similarity index 98% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb index e36db2cc..121b0427 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/boundary_detector.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/boundary_detector.rb @@ -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. diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb similarity index 99% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb index de4bff54..55ac5503 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/constants.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/constants.rb @@ -2,7 +2,7 @@ # constants.rb # frozen_string_literal: true -module Rube +module Marshalsea module Marshal module Constants MAJOR_VERSION = 4 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/errors.rb similarity index 97% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/errors.rb index 3b202023..3a454f96 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/errors.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/errors.rb @@ -2,7 +2,7 @@ # errors.rb # frozen_string_literal: true -module Rube +module Marshalsea module Marshal class StreamError < StandardError; end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/float_body.rb similarity index 98% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/float_body.rb index 41814534..16198867 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/float_body.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/float_body.rb @@ -2,7 +2,7 @@ # float_body.rb # frozen_string_literal: true -module Rube +module Marshalsea module Marshal module FloatBody module_function diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb similarity index 99% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb index ff1f97e0..e76ec003 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/limits.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/limits.rb @@ -2,7 +2,7 @@ # limits.rb # frozen_string_literal: true -module Rube +module Marshalsea module Marshal class Limits DEFAULT_MAX_BYTES = 1_048_576 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb similarity index 99% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb index 8dfefa04..4b07e319 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/node.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/node.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb similarity index 99% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb index d18c957a..bf667420 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/marshal/parser.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/marshal/parser.rb @@ -2,7 +2,7 @@ # parser.rb # frozen_string_literal: true -module Rube +module Marshalsea module Marshal class Parser include Constants diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb similarity index 99% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb index 34484d1c..7b3f431e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/scanner.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/scanner.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/version.rb similarity index 83% rename from PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb rename to PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/version.rb index 9aa3f7d3..abf6184e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube/version.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/lib/marshalsea/version.rb @@ -2,6 +2,6 @@ # version.rb # frozen_string_literal: true -module Rube +module Marshalsea VERSION = "0.1.0" end diff --git a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb b/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb deleted file mode 100644 index 4bd18c08..00000000 --- a/PROJECTS/beginner/deserialization-gadget-lab/lib/rube.rb +++ /dev/null @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec b/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec similarity index 70% rename from PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec rename to PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec index cbd33765..b5583f4f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec +++ b/PROJECTS/beginner/deserialization-gadget-lab/marshalsea.gemspec @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh index 2579aeec..c74585b9 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/detector-gate.sh @@ -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")) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh index 7d0b6737..36b51f97 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb index 9aa2f58d..5147dc1f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb @@ -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) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh index cbb31ce1..8c5b396f 100755 --- a/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/target-gate.sh @@ -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 ')" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb index 3c19fa9a..b1277a97 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/app.rb @@ -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}", "", diff --git a/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru index 907193c3..48e90749 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru +++ b/PROJECTS/beginner/deserialization-gadget-lab/target/config.ru @@ -4,4 +4,4 @@ require_relative "target/app" -run Rube::Target::App +run Marshalsea::Target::App diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb index 9d5d22d5..e8419bf0 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/chains_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb index 5a756e4f..7794fbd8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/control_check.rb @@ -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) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb index 779f6f3b..e68a2a54 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/corpus_test.rb @@ -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) diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb index 9b099208..5a94efb5 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/boundary_detector_test.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb index 812e0252..e4f60c69 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/marshal/parser_test.rb @@ -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, diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb index a24247dc..c94d3bc5 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/scanner_test.rb @@ -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" diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb index 690b7fe9..60599625 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/adversarial_corpus.rb @@ -2,7 +2,7 @@ # adversarial_corpus.rb # frozen_string_literal: true -module Rube +module Marshalsea module AdversarialCorpus HEADER = "\x04\x08" INLINE_OFFSET = 5 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb index f7c62b13..98654fa2 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/exploit_probe.rb @@ -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, diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb index c0f17fc7..74ffc78f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/test_helper.rb @@ -5,4 +5,4 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "minitest/autorun" -require "rube" +require "marshalsea"