diff --git a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore index 2dbe4cb8..5b77316f 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/.gitignore +++ b/PROJECTS/beginner/deserialization-gadget-lab/.gitignore @@ -24,3 +24,4 @@ Gemfile.lock # Local env .env +tmp/ diff --git a/PROJECTS/beginner/deserialization-gadget-lab/justfile b/PROJECTS/beginner/deserialization-gadget-lab/justfile index cf260a72..cfdd464c 100644 --- a/PROJECTS/beginner/deserialization-gadget-lab/justfile +++ b/PROJECTS/beginner/deserialization-gadget-lab/justfile @@ -23,7 +23,7 @@ lint: check: test control probe: - {{run_ro}} ruby /app/test/support/probe.rb + {{run_ro}} ruby /app/test/support/matrix_probe.rb matrix: @bash scripts/version-matrix.sh diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb new file mode 100644 index 00000000..29efdd41 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/render_matrix.rb @@ -0,0 +1,97 @@ +# ©AngelaMos | 2026 +# render_matrix.rb + +require "json" + +TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze +MARK_YES = "yes" +MARK_NO = "no" +MARK_UNKNOWN = "?" +RULE_WIDTH = 78 + +CVE_PATCHED_ERB = ["4.0.3.1", "4.0.4.1", "6.0.1.1", "6.0.4"].freeze + +rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } +abort "no probe results" if rows.empty? + +def mark(value) + case value + when true then MARK_YES + when false then MARK_NO + else MARK_UNKNOWN + end +end + +def short(image) + image.sub("ruby:", "") +end + +def section(title) + puts title + puts " #{'-' * RULE_WIDTH}" + yield + puts +end + +section("RUNTIME") do + puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", "image", "ruby", "rubygems", "psych", "erb", "marshal") + rows.each do |r| + puts format(" %-14s %-8s %-9s %-8s %-9s %-7s", + short(r["image"]), r["ruby"], r["rubygems"], r["psych"], r["erb"], r["marshal_format"]) + end +end + +section("GADGET SURFACE") do + puts format(" %-14s %-12s %-14s %s", + "image", "git gadget", "safe_marshal", TRACKED_CLASSES.map { |c| format("%-13s", c.split("::").last) }.join) + rows.each do |r| + present = TRACKED_CLASSES.map { |c| format("%-13s", mark(r["classes_baseline"][c])) }.join + puts format(" %-14s %-12s %-14s %s", short(r["image"]), r["git_gadget"], r["safe_marshal"], present) + end +end + +section("ERB @_init GUARD (CVE-2026-41316)") do + puts " The guard sits only on def_method. def_module and def_class delegate to it," + puts " so one check covers all three. Measuring the delegates directly reports a" + puts " guard that was never supposed to be there." + puts + puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says") + rows.each do |r| + guard = r["erb_guard"] + expected = CVE_PATCHED_ERB.include?(r["erb"]) ? "patched" : "affected" + puts format(" %-14s %-9s %-9s %-24s %s", + short(r["image"]), r["erb"], mark(guard["guarded"]), + guard["delegating"].join(","), expected) + end +end + +git_states = rows.map { |r| r["git_gadget"] }.uniq +guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq + +agreements = rows.map do |r| + expected_patched = CVE_PATCHED_ERB.include?(r["erb"]) + [short(r["image"]), r["erb_guard"]["guarded"] == expected_patched] +end +disagreements = agreements.reject { |_, ok| ok }.map(&:first) + +section("CONTROLS") do + puts " A matrix reporting one value everywhere cannot be told apart from a probe" + puts " that always returns the same answer." + puts + puts format(" git gadget states observed : %s", git_states.join(", ")) + puts format(" erb guard states observed : %s", guard_states.map { |v| mark(v) }.join(", ")) + puts format(" guard vs published CVE : %d/%d agree", agreements.count { |_, ok| ok }, agreements.length) +end + +failures = [] +failures << "git gadget reported '#{git_states.first}' on every image" if git_states.length < 2 +failures << "erb guard reported the same value on every image" if guard_states.length < 2 +failures << "guard disagrees with the CVE ranges on: #{disagreements.join(', ')}" unless disagreements.empty? + +if failures.empty? + puts "PASS - the matrix discriminates on both axes and matches the published CVE ranges" + exit 0 +end + +failures.each { |f| puts "FAIL - #{f}" } +exit 1 diff --git a/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh new file mode 100755 index 00000000..d81a026e --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/scripts/version-matrix.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# version-matrix.sh + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROBE="${HERE}/test/support/matrix_probe.rb" +RENDER="${HERE}/scripts/render_matrix.rb" +OUT="${HERE}/tmp/matrix.jsonl" +RENDER_IMAGE="ruby:4.0-slim" + +IMAGES=( + "ruby:3.1-slim" + "ruby:3.2-slim" + "ruby:3.3-slim" + "ruby:3.4-slim" + "ruby:4.0.2-slim" + "ruby:4.0-slim" +) + +if [[ $# -gt 0 ]]; then + IMAGES=("$@") +fi + +mkdir -p "${HERE}/tmp" +: >"${OUT}" + +echo "probing ${#IMAGES[@]} images" + +for image in "${IMAGES[@]}"; do + printf ' %-18s ' "${image}" + + if ! docker image inspect "${image}" >/dev/null 2>&1; then + if ! docker pull -q "${image}" >/dev/null 2>&1; then + echo "UNAVAILABLE" + continue + fi + fi + + if result=$(docker run --rm --network none \ + -e "MATRIX_IMAGE=${image}" \ + -v "${PROBE}:/probe.rb:ro" \ + "${image}" ruby /probe.rb 2>/dev/null); then + echo "${result}" >>"${OUT}" + echo "ok" + else + echo "PROBE FAILED" + fi +done + +echo +docker run --rm --network none \ + -v "${OUT}:/matrix.jsonl:ro" \ + -v "${RENDER}:/render.rb:ro" \ + "${RENDER_IMAGE}" ruby /render.rb /matrix.jsonl diff --git a/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb new file mode 100644 index 00000000..f7a8a0f2 --- /dev/null +++ b/PROJECTS/beginner/deserialization-gadget-lab/test/support/matrix_probe.rb @@ -0,0 +1,117 @@ +# ©AngelaMos | 2026 +# matrix_probe.rb + +require "json" + +GADGET_CLASSES = %w[ + Gem::Requirement + Gem::Version + Gem::SpecFetcher + Gem::Source + Gem::Source::Git + Gem::RequestSet::Lockfile + Gem::URI + Gem::URI::HTTP + Gem::Package::TarReader + Net::BufferedIO + Net::WriteAdapter + UncaughtThrowError +].freeze + +ERB_GUARD_ANCHOR = :def_method +ERB_DELEGATING_METHODS = %i[def_module def_class].freeze + +GUARD_TOKEN = "@_init" +DELEGATION_TOKEN = "def_method" +PATCH_TOKEN = "git_command" +VULNERABLE_TOKEN = "popen(@git" +BINARIES = %w[make rake git].freeze + +def gem_version(name) + spec = Gem.loaded_specs[name] + return spec.version.to_s if spec + + Gem::Specification.find_all_by_name(name).map(&:version).max&.to_s || "absent" +rescue StandardError + "unknown" +end + +def constant_present?(name) + Object.const_get(name) + true +rescue StandardError, ScriptError + false +end + +def binary_present?(name) + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir| + File.executable?(File.join(dir, name)) + end +end + +def git_gadget_state + require "rubygems/source/git" + path = Gem::Source::Git.instance_method(:cache).source_location&.first + return "unknown" unless path && File.readable?(path) + + body = File.read(path) + return "patched" if body.include?(PATCH_TOKEN) + return "vulnerable" if body.include?(VULNERABLE_TOKEN) + + "unknown" +rescue StandardError, ScriptError + "unknown" +end + +def safe_marshal_state + require "rubygems/safe_marshal" + path = Gem::SafeMarshal.method(:safe_load).source_location&.first + return "unknown" unless path && File.readable?(path) + + File.read(path).include?("Date") ? "permits_date" : "no_date" +rescue StandardError, ScriptError + "absent" +end + +def erb_state + require "erb" + { + "guarded" => erb_method_body(ERB_GUARD_ANCHOR)&.include?(GUARD_TOKEN), + "delegating" => ERB_DELEGATING_METHODS.select do |name| + erb_method_body(name)&.include?(DELEGATION_TOKEN) + end.map(&:to_s) + } +rescue StandardError, ScriptError + { "guarded" => nil, "delegating" => [] } +end + +def erb_method_body(name) + path, line = ERB.instance_method(name).source_location + return nil unless path && File.readable?(path) + + File.readlines(path)[line - 1, 10].to_a.join +rescue StandardError, ScriptError + nil +end + +def marshal_format + blob = Marshal.dump(nil) + "#{blob.getbyte(0)}.#{blob.getbyte(1)}" +end + +report = { + "image" => ENV.fetch("MATRIX_IMAGE", "unknown"), + "ruby" => RUBY_VERSION, + "rubygems" => Gem::VERSION, + "psych" => gem_version("psych"), + "erb" => gem_version("erb"), + "json" => gem_version("json"), + "marshal_format" => marshal_format, + "binaries" => BINARIES.to_h { |name| [name, binary_present?(name)] }, + "classes_baseline" => GADGET_CLASSES.to_h { |name| [name, constant_present?(name)] }, + "git_gadget" => git_gadget_state, + "safe_marshal" => safe_marshal_state, + "erb_guard" => erb_state +} + +puts JSON.generate(report)