refactor(dynamic): enhance Ruby harness with framework-specific route replay logic (Sinatra, Rails, Hanami), extend Gemfile staging, and update tests/fixtures

This commit is contained in:
elipeter 2026-05-26 12:59:02 -05:00
parent 41c7b73575
commit aaf49acefb
20 changed files with 773 additions and 218 deletions

View file

@ -74,6 +74,10 @@ pub enum Prerequisite {
/// the resolution path skips with a structured reason instead of
/// failing the test.
NodeModuleAvailable(&'static str),
/// A Ruby feature must be loadable via `require`. Used by Ruby
/// framework-bound shape suites so hosts without preinstalled gems can
/// skip instead of depending on network access during tests.
RubyRequireAvailable(&'static str),
/// A binary must resolve on `PATH` and respond to `--version` with
/// exit code 0, but the binary name can be overridden via an env
/// var. Used by the C / C++ fixture suites where `cc` / `c++` can
@ -97,6 +101,7 @@ pub enum SkipReason {
DockerUnavailable,
MissingStaticLib(&'static str),
MissingNodeModule(&'static str),
MissingRubyRequire(&'static str),
}
impl std::fmt::Display for SkipReason {
@ -109,6 +114,7 @@ impl std::fmt::Display for SkipReason {
SkipReason::MissingNodeModule(m) => {
write!(f, "Node module not resolvable via require.resolve: {m}")
}
SkipReason::MissingRubyRequire(r) => write!(f, "Ruby feature not loadable: {r}"),
}
}
}
@ -178,6 +184,19 @@ pub fn check_prerequisites(reqs: &[Prerequisite]) -> Result<(), SkipReason> {
return Err(SkipReason::MissingNodeModule(name));
}
}
Prerequisite::RubyRequireAvailable(feature) => {
let script = "begin; require ARGV.fetch(0); rescue LoadError; exit 1; end";
let ok = std::process::Command::new("ruby")
.arg("-e")
.arg(script)
.arg(feature)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return Err(SkipReason::MissingRubyRequire(feature));
}
}
Prerequisite::StaticLib(lib) => {
// Treat the lib as linkable iff `cc -static -l<lib>` on
// an empty TU succeeds. Slow but reliable; only called

View file

@ -1,8 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Hanami Action shape. The adapter only inspects
# the class superclass / include list; the harness never actually
# boots `Hanami::Application`, so the gem is informational for
# cargo-side fixture pickup.
gem 'hanami'
# Hanami action fixture. The harness invokes the action with a Rack env.
gem 'hanami-controller'
gem 'rack'

View file

@ -1,13 +1,19 @@
# Phase 15 — Hanami Action.call, benign.
# Validates payload before running the fixed echo.
# Ruby Hanami Action.call, benign.
# Validates the real request parameter before running a fixed echo.
# nyx-shape: hanami
# nyx-route: GET /run
require 'hanami/action'
require 'rack/request'
class RunAction < Hanami::Action
def call(req)
payload = req && req.is_a?(Hash) ? (req['nyx.payload'] || '') : (ENV['NYX_PAYLOAD'] || '')
payload = if req.is_a?(Hash)
Rack::Request.new(req).params['payload'].to_s
elsif req.respond_to?(:params)
req.params['payload'].to_s
else
ENV['NYX_PAYLOAD'].to_s
end
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
return "invalid"

View file

@ -1,15 +1,21 @@
# Phase 15 — Hanami Action.call, vulnerable.
# Class includes Hanami::Action and exposes a `call` method that pipes
# the request body into /bin/sh.
# Ruby Hanami Action.call, vulnerable.
# The class imports Hanami::Action and reads the Rack request routed by
# the harness.
# nyx-shape: hanami
# nyx-route: GET /run
require 'hanami/action'
require 'rack/request'
class RunAction < Hanami::Action
def call(req)
STDOUT.print("__NYX_SINK_HIT__\n")
payload = req && req.is_a?(Hash) ? (req['nyx.payload'] || '') : (ENV['NYX_PAYLOAD'] || '')
payload = if req.is_a?(Hash)
Rack::Request.new(req).params['payload'].to_s
elsif req.respond_to?(:params)
req.params['payload'].to_s
else
ENV['NYX_PAYLOAD'].to_s
end
out = `echo hello #{payload}`
STDOUT.print(out)
out

View file

@ -1,6 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Rack middleware shape. The harness constructs
# a Rack-shaped env hash and dispatches; the rack gem is not required
# at runtime because the env-hash invocation pattern is standalone.
# Rack middleware fixture. The harness builds the env through
# Rack::MockRequest before dispatching the middleware.
gem 'rack'

View file

@ -1,7 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Rails action shape. The harness instantiates
# the controller via .new and calls the action through reflection;
# the rails gem is not actually required at runtime. The Gemfile is
# informational so cargo-side fixture pickup sees a non-empty manifest.
gem 'rails'
# ActionController fixture. The harness calls the controller's Rack
# endpoint with Rack::MockRequest.
gem 'actionpack'

View file

@ -1,24 +1,21 @@
# Phase 15 — Rails-style controller action, benign.
# Ruby ActionController action, benign.
class ApplicationController
def initialize; end
require 'action_controller'
class ApplicationController < ActionController::Base
self.view_paths = []
end
class UsersController < ApplicationController
def initialize
super
@__nyx_payload = nil
@__nyx_request = nil
end
def index
payload = @__nyx_payload || ENV['NYX_PAYLOAD'] || ''
payload = params[:payload].to_s
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
return "invalid"
render plain: "invalid"
return
end
out = `echo hello`
STDOUT.print(out)
out
render plain: out
end
end

View file

@ -1,23 +1,18 @@
# Phase 15 — Rails-style controller action, vulnerable.
# Controller inherits the conventional ApplicationController name so
# RubyShape::detect picks RailsAction.
# Ruby ActionController action, vulnerable.
# The harness drives UsersController.action(:index) through Rack.
class ApplicationController
def initialize; end
require 'action_controller'
class ApplicationController < ActionController::Base
self.view_paths = []
end
class UsersController < ApplicationController
def initialize
super
@__nyx_payload = nil
@__nyx_request = nil
end
def index
STDOUT.print("__NYX_SINK_HIT__\n")
payload = @__nyx_payload || ENV['NYX_PAYLOAD'] || ''
payload = params[:payload].to_s
out = `echo hello #{payload}`
STDOUT.print(out)
out
render plain: out
end
end

View file

@ -1,6 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Sinatra route shape. The harness emits its own
# route registry shim so the real sinatra gem is not required at
# runtime; the Gemfile is informational for cargo-side fixture pickup.
# Sinatra route fixture. The harness replays a Rack request through the
# real Sinatra app class.
gem 'sinatra'

View file

@ -1,13 +1,20 @@
# Phase 15 — Sinatra route, benign.
# Validates payload then runs a fixed echo.
# Ruby Sinatra route, benign.
# Validates the real path-capture parameter before running a fixed echo.
# nyx-shape: sinatra
get '/run' do |payload|
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
next "invalid"
require 'sinatra/base'
class NyxSinatraApp < Sinatra::Base
set :environment, :test
disable :run
get '/run/:payload' do |payload|
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
"invalid"
else
out = `echo hello`
STDOUT.print(out)
out
end
end
out = `echo hello`
STDOUT.print(out)
out
end

View file

@ -1,11 +1,16 @@
# Phase 15 — Sinatra route, vulnerable.
# Reads payload (passed by harness via block argument) and pipes through /bin/sh.
# Entry: route block Cap: CODE_EXEC
# Ruby Sinatra route, vulnerable.
# Reads a real path-capture parameter from Sinatra and pipes it through /bin/sh.
# nyx-shape: sinatra
get '/run' do |payload|
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
require 'sinatra/base'
class NyxSinatraApp < Sinatra::Base
set :environment, :test
disable :run
get '/run/:payload' do |payload|
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
end
end

View file

@ -58,8 +58,28 @@ mod phase15_shape_tests {
// Phase 29 (Track I): structured prerequisite gating replaces
// the bespoke `ruby_available()` + per-test
// `eprintln!("SKIP ..."); return;` pattern.
let mut requires = vec![Prerequisite::CommandAvailable("ruby")];
match shape {
"sinatra_route" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("sinatra/base"));
}
"rails_action" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("action_controller"));
}
"hanami_action" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("hanami/action"));
}
"rack_middleware" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("rack/mock"));
}
_ => {}
}
run_shape_fixture_lang_or_skip(
&[Prerequisite::CommandAvailable("ruby")],
&requires,
Lang::Ruby,
"ruby",
shape,
@ -81,7 +101,7 @@ mod phase15_shape_tests {
"vuln.rb",
"run",
Cap::CODE_EXEC,
7,
12,
EntryKind::HttpRoute,
PayloadSlot::Param(0),
) else {
@ -97,7 +117,7 @@ mod phase15_shape_tests {
"benign.rb",
"run",
Cap::CODE_EXEC,
10,
15,
EntryKind::HttpRoute,
PayloadSlot::Param(0),
) else {
@ -115,7 +135,7 @@ mod phase15_shape_tests {
"vuln.rb",
"index",
Cap::CODE_EXEC,
17,
14,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {
@ -131,7 +151,7 @@ mod phase15_shape_tests {
"benign.rb",
"index",
Cap::CODE_EXEC,
20,
17,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {
@ -140,6 +160,40 @@ mod phase15_shape_tests {
assert_not_confirmed("rails_action", &r);
}
// ── hanami_action ───────────────────────────────────────────────────────
#[test]
fn hanami_action_vuln_is_confirmed() {
let Some(r) = run(
"hanami_action",
"vuln.rb",
"call",
Cap::CODE_EXEC,
19,
EntryKind::HttpRoute,
PayloadSlot::QueryParam("payload".into()),
) else {
return;
};
assert_confirmed("hanami_action", &r);
}
#[test]
fn hanami_action_benign_not_confirmed() {
let Some(r) = run(
"hanami_action",
"benign.rb",
"call",
Cap::CODE_EXEC,
21,
EntryKind::HttpRoute,
PayloadSlot::QueryParam("payload".into()),
) else {
return;
};
assert_not_confirmed("hanami_action", &r);
}
// ── rack_middleware ──────────────────────────────────────────────────────
#[test]
@ -149,7 +203,7 @@ mod phase15_shape_tests {
"vuln.rb",
"call",
Cap::CODE_EXEC,
9,
10,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {

View file

@ -92,13 +92,16 @@ fn sinatra_vuln_fixture_binds_route() {
assert_eq!(binding.kind, EntryKind::HttpRoute);
let route = binding.route.as_ref().expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/run");
assert_eq!(route.path, "/run/:payload");
let payload_binding = binding
.request_params
.iter()
.find(|p| p.name == "payload")
.expect("payload block param");
assert!(matches!(payload_binding.source, ParamSource::QueryParam(_)));
.expect("payload path param");
assert!(matches!(
payload_binding.source,
ParamSource::PathSegment(_)
));
}
#[test]
@ -111,7 +114,7 @@ fn sinatra_benign_fixture_binds_same_route_shape() {
.expect("sinatra adapter must bind benign fixture");
assert_eq!(binding.adapter, "ruby-sinatra");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/run");
assert_eq!(route.path, "/run/:payload");
}
// ── Hanami ───────────────────────────────────────────────────────────────────

View file

@ -282,14 +282,14 @@ fn phase_15_ruby_route_findings_derive_specs_without_failure() {
(
"tests/dynamic_fixtures/ruby/rails_action/vuln.rb",
"index",
19,
14,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),
(
"tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb",
"run",
9,
12,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),
@ -310,7 +310,7 @@ fn phase_15_ruby_route_findings_derive_specs_without_failure() {
(
"tests/dynamic_fixtures/ruby/hanami_action/vuln.rb",
"call",
13,
19,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),