feat(rube): M7 - a version floor is a measurement, not a preference

required_ruby_version claimed ">= 3.3" while every gate stage ran on Ruby 4.0
images only. The claim was false. Marshal.load did not validate the bignum sign
byte until 3.4, so on 3.3 real Ruby accepts "!", "\x00", "\xFF" and "0" in the
sign position and reads them all as positive, where 3.4 and 4.0 raise
ArgumentError. The parser accepts "+" and "-" only, so it models 3.4+, and on
3.3 parser_test.rb goes red at its own liveness guard: the differential oracle
finds nothing rejected and says so instead of passing vacuously.

3.4.10 runs all five suites green at the same counts as 4.0 and prints ALL
CONTROLS PASSED. That makes 3.4 the oldest release actually proven, so the floor
is ">= 3.4". TargetRubyVersion moves with it, since those two must stay equal.

Teaching the parser two Marshal models to keep 3.3 was rejected. It buys a branch
in security maintenance only, and it pays with a second sign-validation path in
the one component whose whole job is modelling Marshal.load correctly.

The untracked rube-0.1.0.gem sitting in the repo root turned out to be built from
pre-B17 source: 12 lib files instead of 13, no float_body.rb, read_float still
using Float() with a bare rescue, no frozen_string_literal lines, declaring
">= 3.3". It installed and required without error, so nothing caught it. Two
artifacts with the same name and version and no way to tell them apart. Deleted.

package-gate.sh therefore asserts every shipped lib file is byte-identical to
the worktree rather than merely present, builds from the declared manifest alone
so an omitted file cannot produce a gem that builds anyway, installs the
artifact on the floor and current images and exercises it from the installed
copy, and re-proves the floor in both directions each run. Three negative
controls: a gem shipping the vulnerable target must be rejected, a gem with a
drifted lib file must be rejected, and RubyGems must refuse to install below the
declared floor. Aimed at the stale artifact it fails 6 of 23; on a fresh build it
passes 23 of 23. Both executed.

just build now writes to tmp/build as the invoking user instead of leaving a
root-owned gem in the tree, and just package audits an artifact you already have.

Full gate: 56 PASS, 0 FAIL across six stages. 194 tests. Lint 0 across 30 files.
This commit is contained in:
CarterPerez-dev 2026-07-29 14:58:40 -04:00
parent 8d7b114fb9
commit bfb0ab36e8
7 changed files with 396 additions and 5 deletions

View File

@ -8,10 +8,11 @@ plugins:
AllCops:
NewCops: enable
TargetRubyVersion: 3.3
TargetRubyVersion: 3.4
Exclude:
- "vendor/**/*"
- "docs/**/*"
- "tmp/**/*"
Style/Documentation:
Enabled: false

View File

@ -38,6 +38,15 @@ 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?`
- 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
shipped `lib` file is byte-identical to source, which catches an artifact built
from stale code even though such a gem installs and requires without error. It
can be pointed at a `.gem` you already have, and it carries three negative
controls: a gem that ships the vulnerable target must be rejected, a gem with a
drifted `lib` file must be rejected, and RubyGems must refuse to install below
the declared floor
- Four-state reachability analysis. A method is analysed (touches state or does
not), `unreadable_source?` (a path was given and could not be parsed, so it
fails open and stays reachable), or `unanalysable?` (no Ruby source exists at
@ -81,6 +90,16 @@ 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
- `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
until 3.4 and the parser is written against the version that does. 3.4 is the
oldest release on which the whole suite is green, so it is the floor.
`TargetRubyVersion` moves with it, as those two must stay equal
- `just build` writes to `tmp/build` as the invoking user instead of dropping a
root-owned `.gem` in the repository root, and stages only the files the gemspec
declares, so a manifest that omits a file can no longer produce a gem that
builds anyway
### Fixed

View File

@ -46,6 +46,38 @@ All six pieces are built and tested.
- **Boundary detector** — the defensive layer, with an explicit written statement of what
it cannot do.
## Requirements
Ruby **3.4 or newer**. That floor is measured, not picked for tidiness.
Ruby changed `Marshal.load` between 3.3 and 3.4. Through 3.3, any byte in a bignum's sign
position is accepted and anything that is not `-` is read as positive. From 3.4 onward the
same stream raises `ArgumentError: invalid Bignum sign`:
| sign byte | 3.2.11 | 3.3.12 | 3.4.10 | 4.0.6 |
|---|---|---|---|---|
| `+` 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
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:
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:
```
just build
gem install --local tmp/build/rube-0.1.0.gem
```
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.
## Usage
```ruby
@ -116,11 +148,19 @@ just matrix probe six pinned Ruby images and render the compatibility matrix
just exploit prove the chain fires on a vulnerable image and is blocked on a patched one
just target stand up the vulnerable app and attack it over HTTP
just detector prove the defensive layer rejects the payload the target executes
just package build the gem, audit what shipped, install it, prove the version floor
just gate everything above, in order
just build build the gem with --strict
just build build the gem with --strict into tmp/build
just manifest list exactly what would ship in the .gem
```
`just package` also audits an artifact you already have, which is how you check that a gem
on disk still matches the source it claims to be built from:
```
just package tmp/build/rube-0.1.0.gem
```
## A note on the object-link index
Ruby's Marshal format documentation states that object links are one-indexed. **They are

View File

@ -8,6 +8,8 @@ vuln_image := "ruby:4.0.2-slim"
run := "docker run --rm --network none -v $PWD:/app -w /app " + image
run_ro := "docker run --rm --network none -v $PWD:/app:ro -w /app " + image
lint_run := "docker run --rm -v $PWD:/app -w /app " + image
owner := `id -u` + ":" + `id -g`
build_run := "docker run --rm --network none --user " + owner + " -e HOME=/tmp"
default:
@just --list
@ -48,10 +50,15 @@ target:
detector:
@bash scripts/detector-gate.sh
gate: check matrix exploit detector target
package *gem:
@bash scripts/package-gate.sh {{gem}}
gate: check matrix exploit detector target package
build:
{{run}} sh -c "gem build --strict rube.gemspec"
@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"
@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"'
@ -65,3 +72,4 @@ pull:
clean:
rm -f *.gem
rm -rf tmp/build tmp/package

View File

@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
spec.homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
spec.license = "AGPL-3.0-or-later"
spec.required_ruby_version = ">= 3.3"
spec.required_ruby_version = ">= 3.4"
spec.metadata = {
"source_code_uri" => "#{spec.homepage}/tree/main/PROJECTS/beginner/deserialization-gadget-lab",

View File

@ -0,0 +1,79 @@
# ©AngelaMos | 2026
# audit_gem.rb
# frozen_string_literal: true
require "rubygems/package"
require "digest"
require "fileutils"
GEM_PATH = ARGV.fetch(0)
TREE = ARGV.fetch(1)
EXPECTED_FLOOR = ARGV.fetch(2)
EXTRACT_ROOT = ARGV.fetch(3)
DOC_FILES = %w[README.md CHANGELOG.md LICENSE].freeze
LIB_PREFIX = "lib/"
FORBIDDEN = {
"target_absent" => %r{\Atarget/},
"tests_absent" => %r{\Atest/},
"scripts_absent" => %r{\Ascripts/},
"dev_docs_absent" => %r{\Adocs/|\AAGENTS\.md\z},
"build_tooling_absent" => /\A(justfile|Gemfile|Gemfile\.lock|Rakefile|\.rubocop\.yml|\.gitignore)\z/,
"container_files_absent" => /Dockerfile|compose|config\.ru/i,
"lab_artifacts_absent" => %r{\.gem\z|\.marshal\z|\.bin\z|\Apayloads/|\Aloot/|canary}i
}.freeze
def tree_lib_files
Dir.chdir(TREE) { Dir.glob("#{LIB_PREFIX}**/*.rb").sort }
end
def emit(key, value)
puts "#{key}=#{value}"
end
def info(label, value)
puts "INFO #{label}: #{value}"
end
package = Gem::Package.new(GEM_PATH)
spec = package.spec
contents = package.contents.sort
expected = (DOC_FILES + tree_lib_files).sort
shipped_lib = contents.select { |path| path.start_with?(LIB_PREFIX) }
FileUtils.rm_rf(EXTRACT_ROOT)
FileUtils.mkdir_p(EXTRACT_ROOT)
package.extract_files(EXTRACT_ROOT)
missing = expected - contents
unexpected = contents - expected
drifted = shipped_lib.reject do |path|
source = File.join(TREE, path)
next false unless File.file?(source)
Digest::SHA256.file(File.join(EXTRACT_ROOT, path)).hexdigest ==
Digest::SHA256.file(source).hexdigest
end
orphaned = shipped_lib.reject { |path| File.file?(File.join(TREE, path)) }
info "gem", File.basename(GEM_PATH)
info "declares", "#{spec.name} #{spec.version} ruby #{spec.required_ruby_version}"
info "shipped", "#{contents.length} files, #{shipped_lib.length} under lib/"
info "worktree", "#{tree_lib_files.length} files under lib/"
info "missing", missing.empty? ? "none" : missing.join(", ")
info "unexpected", unexpected.empty? ? "none" : unexpected.join(", ")
info "drifted", drifted.empty? ? "none" : drifted.join(", ")
info "orphaned", orphaned.empty? ? "none" : orphaned.join(", ")
emit "every_declared_file_shipped", missing.empty?
emit "nothing_undeclared_shipped", unexpected.empty?
emit "lib_is_non_empty", shipped_lib.length.positive?
emit "lib_matches_worktree", drifted.empty? && orphaned.empty?
emit "floor_is_declared", spec.required_ruby_version.to_s == EXPECTED_FLOOR
FORBIDDEN.each do |key, pattern|
emit(key, contents.none? { |path| pattern.match?(path) })
end

View File

@ -0,0 +1,244 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# package-gate.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_IMAGE="ruby:4.0-slim"
FLOOR_IMAGE="ruby:3.4-slim"
BELOW_FLOOR_IMAGE="ruby:3.3-slim"
DECLARED_FLOOR=">= 3.4"
WORK="${HERE}/tmp/package"
OBSERVED="${WORK}/observed"
OWNER="$(id -u):$(id -g)"
GIVEN="${1:-}"
INVALID_SIGN_STREAM='"\x04\x08l!\x06\x01\x00".b'
run() {
docker run --rm --network none --user "${OWNER}" -e HOME=/tmp "$@"
}
record() {
tee -a "${OBSERVED}" | sed 's/^/ /'
}
echo "packaging gate"
echo
rm -rf "${WORK}"
mkdir -p "${WORK}/build" "${WORK}/ships-target" "${WORK}/drifted"
: >"${OBSERVED}"
echo "=== 1 build ==="
if [[ -n "${GIVEN}" ]]; then
GEM_DIR="$(cd "$(dirname "${GIVEN}")" && pwd)"
GEM_FILE="$(basename "${GIVEN}")"
echo " auditing a pre-built artifact, no build performed"
echo " ${GEM_DIR}/${GEM_FILE}"
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"
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 /out && tar -C /src -T declared.txt -cf - | tar -xf -
cp /src/rube.gemspec /out/
gem build --strict rube.gemspec
' 2>&1 | sed 's/^/ /'
if [[ -f "${GEM_DIR}/${GEM_FILE}" ]]; then
echo "gem_built=true" | record
else
echo "gem_built=false" | record
fi
fi
echo
echo "=== 2 manifest audit ==="
run -v "${HERE}:/src:ro" -v "${GEM_DIR}:/gem:ro" "${BUILD_IMAGE}" \
ruby /src/scripts/audit_gem.rb "/gem/${GEM_FILE}" /src "${DECLARED_FLOOR}" /tmp/extract 2>&1 | record
echo
echo "=== 3 install path ==="
install_and_use() {
local image="$1"
local label="$2"
docker run --rm --network none -v "${GEM_DIR}:/gem:ro" -w / "${image}" sh -c "
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\"]
blob = Marshal.dump(Gem::Requirement.new(\">= 0\"))
result = Rube::Marshal::Parser.new(blob).parse
sinks = result.sinks.map { |s| \"#{s.class_name}##{s.sink_method}\" }
decision = Rube::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?
puts \"installed_gem_works_on_${label}=#{ok}\"
'
" 2>&1 | tail -1
}
install_and_use "${FLOOR_IMAGE}" floor | record
install_and_use "${BUILD_IMAGE}" current | record
echo
echo "=== 4 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}" \
ruby -Ilib -Itest "test/${suite}.rb" >/dev/null 2>&1; then
echo " ${suite} is RED on the floor image"
suite_status=1
fi
done
if [[ ${suite_status} -eq 0 ]]; then
echo "suite_green_on_floor=true" | record
else
echo "suite_green_on_floor=false" | record
fi
differential() {
local image="$1"
local label="$2"
docker run --rm --network none -v "${HERE}:/app:ro" -w /app "${image}" ruby -Ilib -e "
require \"rube\"
bytes = ${INVALID_SIGN_STREAM}
ruby_accepts = begin
Marshal.load(bytes)
true
rescue StandardError
false
end
parser_accepts = begin
Rube::Marshal::Parser.new(bytes).parse
true
rescue Rube::Marshal::StreamError
false
end
puts \"${label}_ruby_accepts_invalid_sign=#{ruby_accepts}\"
puts \"${label}_parser_accepts_invalid_sign=#{parser_accepts}\"
" 2>&1 | tail -2
}
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"
Gem::Specification.new do |spec|
spec.name = "rube"
spec.version = Rube::VERSION
spec.authors = ["Carter Perez"]
spec.email = ["carterperez2222@gmail.com"]
spec.summary = "control fixture that deliberately ships the vulnerable target"
spec.description = "control fixture for package-gate.sh, never published"
spec.homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
spec.license = "AGPL-3.0-or-later"
spec.required_ruby_version = ">= 3.4"
spec.files = Dir["lib/**/*.rb", "target/**/*", "README.md", "CHANGELOG.md", "LICENSE"]
spec.require_paths = ["lib"]
end
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
' >/dev/null 2>&1
control_target="$(run -v "${HERE}:/src:ro" -v "${WORK}/ships-target:/gem:ro" "${BUILD_IMAGE}" \
ruby /src/scripts/audit_gem.rb "/gem/${GEM_FILE}" /src "${DECLARED_FLOOR}" /tmp/extract 2>/dev/null |
grep -c '^target_absent=false$')"
echo "control_auditor_rejects_a_gem_shipping_the_target=$([[ ${control_target} == 1 ]] && echo true || echo false)" | record
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 /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{
})"
gem build rube.gemspec
' >/dev/null 2>&1
control_drift="$(run -v "${HERE}:/src:ro" -v "${WORK}/drifted:/gem:ro" "${BUILD_IMAGE}" \
ruby /src/scripts/audit_gem.rb "/gem/${GEM_FILE}" /src "${DECLARED_FLOOR}" /tmp/extract 2>/dev/null |
grep -c '^lib_matches_worktree=false$')"
echo "control_auditor_rejects_a_drifted_lib_file=$([[ ${control_drift} == 1 ]] && echo true || echo false)" | record
if docker run --rm --network none -v "${GEM_DIR}:/gem:ro" -w / "${BELOW_FLOOR_IMAGE}" \
gem install --local --no-document "/gem/${GEM_FILE}" >/dev/null 2>&1; then
echo "control_floor_blocks_install_below_it=false" | record
else
echo "control_floor_blocks_install_below_it=true" | record
fi
echo
failures=0
expect() {
if grep -qx "$1=true" "${OBSERVED}"; then
echo " PASS $2"
else
echo " FAIL $2"
failures=$((failures + 1))
fi
}
reject() {
if grep -qx "$1=false" "${OBSERVED}"; then
echo " PASS $2"
else
echo " FAIL $2"
failures=$((failures + 1))
fi
}
echo "=== verdict ==="
expect gem_built "the gem builds with --strict from its declared manifest alone"
expect every_declared_file_shipped "every file the gemspec declares is in the artifact"
expect nothing_undeclared_shipped "the artifact carries nothing the gemspec did not declare"
expect lib_is_non_empty "the artifact ships a non-empty lib, so the audit is not vacuous"
expect lib_matches_worktree "every shipped lib file is byte-identical to the worktree"
expect floor_is_declared "the artifact declares the floor this gate proves"
expect target_absent "the vulnerable target is absent"
expect tests_absent "the test suite, corpus and fixtures are absent"
expect scripts_absent "the gate scripts are absent"
expect dev_docs_absent "research, plans and agent briefing are absent"
expect build_tooling_absent "justfile, Gemfile, Rakefile and lint config are absent"
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 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"
expect below_floor_ruby_accepts_invalid_sign "one version below the floor, real Marshal accepts it"
reject below_floor_parser_accepts_invalid_sign "the parser still rejects it, so below the floor they disagree"
expect control_auditor_rejects_a_gem_shipping_the_target "the auditor rejects a gem that ships the target"
expect control_auditor_rejects_a_drifted_lib_file "the auditor rejects a gem whose lib drifted from source"
expect control_floor_blocks_install_below_it "RubyGems refuses to install below the declared floor"
echo
if [[ ${failures} -eq 0 ]]; then
echo "GATE PASSED"
exit 0
fi
echo "GATE FAILED (${failures})"
exit 1