mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
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:
parent
41c7b73575
commit
aaf49acefb
20 changed files with 773 additions and 218 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue