diff --git a/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml b/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml index ca78cb86..207a40f8 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml +++ b/PROJECTS/beginner/deserialization-gadget-lab/.rubocop.yml @@ -8,10 +8,11 @@ plugins: AllCops: NewCops: enable - TargetRubyVersion: 3.3 + TargetRubyVersion: 3.4 Exclude: - "vendor/**/*" - "docs/**/*" + - "tmp/**/*" Style/Documentation: Enabled: false diff --git a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md index f10b70f8..07e39f6e 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/CHANGELOG.md @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/README.md b/PROJECTS/beginner/deserialization-gadget-lab/README.md index e44310fd..14089e84 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/README.md +++ b/PROJECTS/beginner/deserialization-gadget-lab/README.md @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index 6abf887a..0986925c 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec b/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec index 47ada9b3..cbd33765 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec +++ b/PROJECTS/beginner/deserialization-gadget-lab/rube.gemspec @@ -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", diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb new file mode 100644 index 00000000..ad3b1153 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/audit_gem.rb @@ -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 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh new file mode 100755 index 00000000..7d0b6737 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/package-gate.sh @@ -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