diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index fca2c2e3..80589d35 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -361,6 +361,150 @@ fn build_cache_path( Ok(path) } +// ── Ruby build sandbox ─────────────────────────────────────────────────────── + +/// Prepare Ruby dependencies for `spec` in `workdir`. +/// +/// Runs `bundle check` first so hosts that already have the declared gems do +/// not need network access. When the check misses, runs `bundle install` into +/// `vendor/bundle` and caches both that tree and Bundler's local config. +pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result { + if !workdir.join("Gemfile").exists() { + return Ok(BuildResult { + venv_path: workdir.to_path_buf(), + cache_hit: false, + duration: Duration::ZERO, + }); + } + + let lockfile_hash = compute_ruby_lockfile_hash(workdir); + let cache_path = build_cache_path(&lockfile_hash, "ruby", &spec.toolchain_id).ok(); + + if let Some(cache_path) = &cache_path + && cache_path.join(".ruby_cache_done").exists() + { + restore_cached_ruby_bundle(cache_path, workdir); + return Ok(BuildResult { + venv_path: cache_path.clone(), + cache_hit: true, + duration: Duration::ZERO, + }); + } + + let start = Instant::now(); + const MAX_ATTEMPTS: u32 = 2; + const BACKOFF: [u64; 2] = [1, 4]; + let mut last_err = String::new(); + + for attempt in 0..MAX_ATTEMPTS { + if attempt > 0 { + std::thread::sleep(Duration::from_secs(BACKOFF[attempt as usize - 1])); + } + match try_bundle_install(workdir) { + Ok(()) => { + if let Some(cache_path) = &cache_path { + persist_ruby_bundle(workdir, cache_path); + let _ = std::fs::write(cache_path.join(".ruby_cache_done"), b"done"); + } + return Ok(BuildResult { + venv_path: cache_path.unwrap_or_else(|| workdir.to_path_buf()), + cache_hit: false, + duration: start.elapsed(), + }); + } + Err(e) => { + last_err = e; + } + } + } + + Err(BuildError::BuildFailed { + stderr: last_err, + attempts: MAX_ATTEMPTS, + }) +} + +fn try_bundle_install(workdir: &Path) -> Result<(), String> { + let bundle = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned()); + if bundle_check(&bundle, workdir)? { + return Ok(()); + } + + let config = Command::new(&bundle) + .args(["config", "set", "--local", "path", "vendor/bundle"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("bundle config: {e}"))?; + if !config.status.success() { + return Err(String::from_utf8_lossy(&config.stderr).into_owned()); + } + + let output = Command::new(&bundle) + .args(["install", "--jobs", "4", "--retry", "2"]) + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("bundle install: {e}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +fn bundle_check(bundle: &str, workdir: &Path) -> Result { + let output = Command::new(bundle) + .arg("check") + .current_dir(workdir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()) + .output() + .map_err(|e| format!("bundle check: {e}"))?; + Ok(output.status.success()) +} + +fn restore_cached_ruby_bundle(cache_path: &Path, workdir: &Path) { + let cached_vendor = cache_path.join("vendor").join("bundle"); + if cached_vendor.exists() && !workdir.join("vendor").join("bundle").exists() { + let _ = copy_dir_all(&cached_vendor, &workdir.join("vendor").join("bundle")); + } + let cached_bundle_config = cache_path.join(".bundle"); + if cached_bundle_config.exists() && !workdir.join(".bundle").exists() { + let _ = copy_dir_all(&cached_bundle_config, &workdir.join(".bundle")); + } +} + +fn persist_ruby_bundle(workdir: &Path, cache_path: &Path) { + let vendor = workdir.join("vendor").join("bundle"); + if vendor.exists() { + let _ = copy_dir_all(&vendor, &cache_path.join("vendor").join("bundle")); + } + let bundle_config = workdir.join(".bundle"); + if bundle_config.exists() { + let _ = copy_dir_all(&bundle_config, &cache_path.join(".bundle")); + } +} + +fn compute_ruby_lockfile_hash(workdir: &Path) -> String { + let mut h = Hasher::new(); + for fname in &["Gemfile", "Gemfile.lock"] { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!( + "{:016x}", + u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()) + ) +} + // ── Node.js build sandbox ───────────────────────────────────────────────────── /// Prepare a Node.js project for `spec` in `workdir`. @@ -646,35 +790,37 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result Result { + let build_root = cache_path.clone().unwrap_or_else(|| workdir.to_path_buf()); return Ok(BuildResult { - venv_path: cache_path, + venv_path: build_root, cache_hit: false, duration: start.elapsed(), }); @@ -700,7 +848,9 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result {}` no-op for Ruby -/// today, so the reverifier mirrors that contract. +/// other per-language preparers ignore it. pub fn dispatch_prepare( spec: &HarnessSpec, workdir: &Path, @@ -1350,9 +1499,9 @@ pub fn dispatch_prepare( Lang::Go => prepare_go(spec, workdir)?, Lang::Java => prepare_java(spec, workdir)?, Lang::Php => prepare_php(spec, workdir)?, + Lang::Ruby => prepare_ruby(spec, workdir)?, Lang::C => prepare_c(spec, workdir, profile)?, Lang::Cpp => prepare_cpp(spec, workdir)?, - Lang::Ruby => return Err(BuildError::Unsupported), }; Ok(ChainStepBuildResult { lang, @@ -1949,18 +2098,21 @@ mod tests { } #[test] - fn dispatch_prepare_ruby_returns_unsupported() { - // Ruby has no prepare_ruby — the runner falls through to a `_` - // no-op for it. The dispatcher mirrors that contract so the - // composite-chain reverifier can distinguish "build skipped" - // from "build failed" instead of silently producing a result. + fn dispatch_prepare_ruby_routes_to_bundler_no_gemfile_path() { + // Ruby now has the same dependency-prep leg as the other + // interpreted framework harnesses. With no Gemfile present, + // prepare_ruby takes the cheap path and records an empty cache + // entry without invoking Bundler. + let _lock = ENV_LOCK.lock().unwrap(); + let _cache = BuildCacheGuard::isolated(); let dir = tempfile::TempDir::new().unwrap(); - let spec = mk_spec(Lang::Ruby, "ruby-unsupported"); - let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard); - assert!( - matches!(result, Err(BuildError::Unsupported)), - "Ruby must route to BuildError::Unsupported; got {result:?}", - ); + let spec = mk_spec(Lang::Ruby, "ruby-no-gemfile"); + let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard) + .expect("Ruby dispatch must succeed on a workdir with no Gemfile"); + assert_eq!(result.lang, Lang::Ruby); + assert!(!result.cache_hit); + assert_eq!(result.duration, Duration::ZERO); + assert!(result.build_root.exists()); } #[test] diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index fd3d9662..00f5ad9a 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -4211,7 +4211,10 @@ mod tests { assert!(harness.source.contains("nyxPayload()")); assert!(harness.source.contains("Entry.processInput(payload)")); assert_eq!(harness.filename, "NyxHarness.java"); - assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]); + assert_eq!( + harness.command, + vec!["java", "-cp", ".:lib/*", "NyxHarness"] + ); } #[test] diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 9fc0599a..ebc30dac 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -3,13 +3,15 @@ //! Phase 15 (Track B Ruby vertical) replaces the previous `LangUnsupported` //! stub with dispatch over [`RubyShape`] — the cross product of //! [`EntryKind`] and a lightweight per-file shape detector that inspects -//! the entry file for Sinatra routes, Rails controller actions, Rack -//! middleware, and generic controller methods. +//! the entry file for Sinatra routes, Rails controller actions, Hanami +//! actions, Rack middleware, and generic controller methods. //! //! Each shape emits a single `harness.rb` that: //! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. //! 2. Requires the entry file from the workdir (`entry.rb`). -//! 3. Invokes the entry point via the per-shape adapter. +//! 3. Invokes the entry point via the per-shape adapter. Framework routes +//! are replayed through Rack / ActionController / Hanami request entry +//! points instead of an in-process route registry. //! //! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` //! before the actual sink call (same pattern as Rust / JS / Go fixtures). @@ -17,14 +19,13 @@ //! Payload slot support: //! - `PayloadSlot::Param(n)` — n-th positional argument. //! - `PayloadSlot::EnvVar(name)` — set `ENV[name]` before calling. -//! - `PayloadSlot::QueryParam(name)` — surfaced via the per-shape -//! request stub for Sinatra / Rails / Rack. -//! - `PayloadSlot::HttpBody` — surfaced via the per-shape request stub -//! for Sinatra / Rails / Rack. +//! - `PayloadSlot::QueryParam(name)` — surfaced via the Rack request. +//! - `PayloadSlot::HttpBody` — surfaced via the Rack request body. //! - `PayloadSlot::Argv(n)` — appended to `ARGV` for CLI-style entries. //! - `PayloadSlot::Stdin` — produces `UnsupportedReason::PayloadSlotUnsupported`. //! -//! Build: no compilation step. Command is `ruby harness.rb`. +//! Build: no compilation step. When the emitter stages a Gemfile, +//! `build_sandbox::prepare_ruby` runs Bundler before `ruby harness.rb`. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; @@ -37,8 +38,8 @@ pub struct RubyEmitter; /// Entry kinds the Ruby emitter understands after Phase 15. /// -/// `HttpRoute` covers Sinatra / Rails / Rack. `CliSubcommand` covers -/// `ARGV`-driven scripts. `Function` covers plain methods and +/// `HttpRoute` covers Sinatra / Rails / Hanami / Rack. `CliSubcommand` +/// covers `ARGV`-driven scripts. `Function` covers plain methods and /// controller method shapes. const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, @@ -131,17 +132,18 @@ fn ruby_string_literal(s: &str) -> String { /// or no marker fires the detector defaults to [`RubyShape::Generic`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RubyShape { - /// `get '/path' do ... end` Sinatra route. Harness publishes the - /// payload via `ENV` + `$nyx_request` and triggers the route's - /// block via `$nyx_sinatra_routes`. + /// `get '/path' do ... end` Sinatra route. Harness sends a Rack + /// request into the detected Sinatra app. SinatraRoute, /// Rails controller action (e.g. `def index ... end` on a class /// inheriting from `ApplicationController` / `ActionController::Base`). - /// Harness instantiates the controller and calls the action with a - /// stub `request` / `params` pair. + /// Harness calls the controller's Rack endpoint. RailsAction, + /// Hanami action (`class RunAction < Hanami::Action` with `call`). + /// Harness invokes the real action object with a Rack env. + HanamiAction, /// Rack middleware: `def call(env) ... end` on a class. Harness - /// builds a minimal Rack `env` hash and dispatches. + /// builds the env through Rack and dispatches the app. RackMiddleware, /// Generic instance method on a controller class (no framework /// marker). Harness instantiates the class with `.new` and calls @@ -168,6 +170,10 @@ impl RubyShape { || source.contains("ActionController::Base") || source.contains("ActionController::API") || source.contains("# nyx-shape: rails"); + let has_hanami = source.contains("require 'hanami/action'") + || source.contains("require \"hanami/action\"") + || source.contains("Hanami::Action") + || source.contains("# nyx-shape: hanami"); let has_rack = source.contains("def call(env)") || source.contains("Rack::") || source.contains("# nyx-shape: rack"); @@ -188,6 +194,9 @@ impl RubyShape { if has_rails { return Self::RailsAction; } + if has_hanami { + return Self::HanamiAction; + } if has_rack { return Self::RackMiddleware; } @@ -510,16 +519,35 @@ pub fn emit(spec: &HarnessSpec) -> Result { let entry_source = read_entry_source(&spec.entry_file); let shape = RubyShape::detect(spec, &entry_source); let source = generate_source(spec, shape); + let extra_files = extra_files_for_shape(shape); Ok(HarnessSource { source, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], - extra_files: vec![], + extra_files, entry_subpath: Some("entry.rb".to_owned()), }) } +fn extra_files_for_shape(shape: RubyShape) -> Vec<(String, String)> { + let deps: &[&str] = match shape { + RubyShape::SinatraRoute => &["rack", "sinatra"], + RubyShape::RailsAction => &["rack", "actionpack"], + RubyShape::HanamiAction => &["rack", "hanami-controller"], + RubyShape::RackMiddleware => &["rack"], + RubyShape::ControllerMethod | RubyShape::Generic => &[], + }; + if deps.is_empty() { + return Vec::new(); + } + let mut body = String::from("source 'https://rubygems.org'\n"); + for dep in deps { + body.push_str(&format!("gem '{dep}'\n")); + } + vec![("Gemfile".to_owned(), body)] +} + /// Phase 19 (Track M.1) — class-method harness for Ruby. /// /// Requires the entry file, looks up `class` as a top-level constant, @@ -2043,7 +2071,7 @@ STDOUT.flush fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String { let entry_fn = &spec.entry_name; - let pre_call = build_pre_call(spec); + let pre_call = build_pre_call(spec, shape); let invocation = invoke_for_shape(spec, shape, entry_fn); let shim = probe_shim(); let crash_callee = if entry_fn.is_empty() { @@ -2053,7 +2081,7 @@ fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String { }; format!( - r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}). + r#"# Nyx dynamic harness — auto-generated, do not edit (RubyShape::{shape:?}). {shim} # ── Payload loading ────────────────────────────────────────────────────────── def nyx_payload @@ -2073,26 +2101,60 @@ end $nyx_payload = nyx_payload +begin + require 'uri' + require 'bundler/setup' if File.exist?(File.join(__dir__, 'Gemfile')) +rescue LoadError, StandardError => e + STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") + exit 77 +end + +def _nyx_require_rack_mock + require 'rack/mock' +rescue LoadError => e + STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") + exit 77 +end + +def _nyx_materialize_path(template, payload) + encoded = URI.encode_www_form_component(payload.to_s) + path = template.to_s.empty? ? '/' : template.to_s + path = path.gsub(/\{{[^}}]+\}}/, encoded) + path.gsub(/:[A-Za-z_][A-Za-z0-9_]*/, encoded) +end + +def _nyx_request_uri + params = $nyx_request[:params] || {{}} + query = URI.encode_www_form(params) + path = $nyx_request[:path] || '/' + query.empty? ? path : path.to_s + '?' + query.to_s +end + +def _nyx_rack_env + _nyx_require_rack_mock + env = Rack::MockRequest.env_for( + _nyx_request_uri, + method: ($nyx_request[:method] || 'GET'), + input: ($nyx_request[:body] || '') + ) + env['nyx.payload'] = $nyx_payload + env +end + +def _nyx_print_rack_body(body) + if body.respond_to?(:each) + body.each {{ |chunk| print(chunk.to_s) }} + elsif body + print(body.to_s) + end +end + # Phase 08 sink-site signal trap: install AFTER payload decode so a crash # inside `nyx_payload` writes no Crash probe and routes the verifier to # `Inconclusive(UnrelatedCrash)`. A fatal signal inside the entry call # below DOES fire the handler and writes a Crash probe to `NYX_PROBE_PATH`. __nyx_install_crash_guard('{crash_callee}') {pre_call} -# ── Sinatra route registry ────────────────────────────────────────────────── -$nyx_sinatra_routes ||= [] -unless Object.method_defined?(:__nyx_register_route) - module Kernel - def get(path, &block) - $nyx_sinatra_routes ||= [] - $nyx_sinatra_routes << [path, :get, block] - end - def post(path, &block) - $nyx_sinatra_routes ||= [] - $nyx_sinatra_routes << [path, :post, block] - end - end -end # ── Entry require ─────────────────────────────────────────────────────────── begin @@ -2115,79 +2177,198 @@ end ) } -fn build_pre_call(spec: &HarnessSpec) -> String { +fn build_pre_call(spec: &HarnessSpec, shape: RubyShape) -> String { let mut out = String::new(); + let (method, path_template) = route_for_spec(spec, shape); + let default_param = default_payload_param(spec); + let default_request = format!( + "$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {default_param:?} => $nyx_payload }}, body: '' }}\n" + ); match &spec.payload_slot { PayloadSlot::EnvVar(name) => { out.push_str(&format!("ENV[{name:?}] = $nyx_payload\n")); + out.push_str(&default_request); } PayloadSlot::Argv(n) => { for _ in 0..*n { out.push_str("ARGV << ''\n"); } out.push_str("ARGV << $nyx_payload\n"); + out.push_str(&default_request); } PayloadSlot::QueryParam(name) => { out.push_str(&format!( - "$nyx_request = {{ method: 'GET', path: '/', params: {{ {name:?} => $nyx_payload }}, body: '' }}\n" + "$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {name:?} => $nyx_payload }}, body: '' }}\n" )); } PayloadSlot::HttpBody => { - out.push_str( - "$nyx_request = { method: 'POST', path: '/', params: {}, body: $nyx_payload }\n", - ); + out.push_str(&format!( + "$nyx_request = {{ method: 'POST', path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{}}, body: $nyx_payload }}\n" + )); } _ => { - out.push_str( - "$nyx_request = { method: 'GET', path: '/', params: { 'payload' => $nyx_payload }, body: '' }\n", - ); + out.push_str(&default_request); } } out } +fn default_payload_param(spec: &HarnessSpec) -> String { + spec.framework + .as_ref() + .and_then(|binding| { + binding + .request_params + .iter() + .find_map(|param| match ¶m.source { + crate::dynamic::framework::ParamSource::QueryParam(name) + | crate::dynamic::framework::ParamSource::FormField(name) + | crate::dynamic::framework::ParamSource::PathSegment(name) => { + Some(name.clone()) + } + _ => None, + }) + }) + .unwrap_or_else(|| "payload".to_owned()) +} + +fn route_for_spec(spec: &HarnessSpec, shape: RubyShape) -> (String, String) { + if let Some(route) = spec + .framework + .as_ref() + .and_then(|binding| binding.route.as_ref()) + { + return ( + http_method_name(route.method).to_owned(), + route.path.clone(), + ); + } + let source = read_entry_source(&spec.entry_file); + if let Some(found) = route_from_source(&source) { + return found; + } + match shape { + RubyShape::RailsAction => ("GET".to_owned(), format!("/{}", spec.entry_name)), + RubyShape::RackMiddleware => ("GET".to_owned(), "/".to_owned()), + RubyShape::SinatraRoute | RubyShape::HanamiAction => ("GET".to_owned(), "/run".to_owned()), + RubyShape::ControllerMethod | RubyShape::Generic => ("GET".to_owned(), "/".to_owned()), + } +} + +fn route_from_source(source: &str) -> Option<(String, String)> { + if let Some(found) = pinned_route_from_source(source) { + return Some(found); + } + for line in source.lines() { + let trimmed = line.trim_start(); + for verb in ["get", "post", "put", "patch", "delete", "options"] { + if let Some(rest) = trimmed.strip_prefix(verb) + && rest.starts_with(char::is_whitespace) + && let Some(path) = first_quoted_string(rest) + { + return Some((verb.to_ascii_uppercase(), path)); + } + } + } + None +} + +fn pinned_route_from_source(source: &str) -> Option<(String, String)> { + for line in source.lines() { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix("# nyx-route:") else { + continue; + }; + let mut parts = rest.split_ascii_whitespace(); + let method = parts.next()?.to_ascii_uppercase(); + let path = parts.next()?.to_owned(); + return Some((method, path)); + } + None +} + +fn first_quoted_string(input: &str) -> Option { + let trimmed = input.trim_start(); + let quote = match trimmed.as_bytes().first()? { + b'\'' => '\'', + b'"' => '"', + _ => return None, + }; + let rest = &trimmed[1..]; + let end = rest.find(quote)?; + Some(rest[..end].to_owned()) +} + +fn http_method_name(method: crate::dynamic::framework::HttpMethod) -> &'static str { + match method { + crate::dynamic::framework::HttpMethod::GET => "GET", + crate::dynamic::framework::HttpMethod::HEAD => "HEAD", + crate::dynamic::framework::HttpMethod::POST => "POST", + crate::dynamic::framework::HttpMethod::PUT => "PUT", + crate::dynamic::framework::HttpMethod::PATCH => "PATCH", + crate::dynamic::framework::HttpMethod::DELETE => "DELETE", + crate::dynamic::framework::HttpMethod::OPTIONS => "OPTIONS", + } +} + fn invoke_for_shape(spec: &HarnessSpec, shape: RubyShape, entry_fn: &str) -> String { match shape { RubyShape::Generic => generic_invocation(spec, entry_fn), RubyShape::SinatraRoute => format!( - r#" route = $nyx_sinatra_routes.find {{ |_, _, b| b }} - if route && route[2] - blk = route[2] - result = blk.call($nyx_payload) - print(result.to_s) + r#" _nyx_require_rack_mock + cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil + app = if cls && cls.respond_to?(:call) + cls + elsif defined?(Sinatra) && Sinatra.const_defined?(:Application) + Sinatra::Application + end + if app + response = Rack::MockRequest.new(app).request( + ($nyx_request[:method] || 'GET'), + _nyx_request_uri, + input: ($nyx_request[:body] || '') + ) + print(response.body.to_s) elsif respond_to?({entry_fn:?}) print(send({entry_fn:?}, $nyx_payload).to_s) end"#, + cls = entry_class_from_spec(spec), ), RubyShape::RailsAction => { + let cls = entry_class_from_spec(spec); + format!( + r#" _nyx_require_rack_mock + cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil + if cls && cls.respond_to?(:action) + _status, _headers, body = cls.action({entry_fn:?}).call(_nyx_rack_env) + _nyx_print_rack_body(body) + end"#, + ) + } + RubyShape::HanamiAction => { let cls = entry_class_from_spec(spec); format!( r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil if cls - instance = cls.new - instance.instance_variable_set(:@__nyx_payload, $nyx_payload) - instance.instance_variable_set(:@__nyx_request, $nyx_request) - result = instance.send({entry_fn:?}) - print(result.to_s) if result + action = cls.new + result = action.call(_nyx_rack_env) + if result.is_a?(Array) && result.length >= 3 + _nyx_print_rack_body(result[2]) + else + print(result.to_s) if result + end end"#, ) } RubyShape::RackMiddleware => { let cls = entry_class_from_spec(spec); format!( - r#" require 'stringio' - cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil + r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil if cls inner = cls.respond_to?(:new) ? (cls.method(:new).arity == 0 ? cls.new : cls.new(nil)) : nil - env = {{ - 'REQUEST_METHOD' => ($nyx_request[:method] rescue 'GET'), - 'PATH_INFO' => ($nyx_request[:path] rescue '/'), - 'QUERY_STRING' => "payload=#{{$nyx_payload}}", - 'rack.input' => StringIO.new(($nyx_request[:body] rescue '')), - 'nyx.payload' => $nyx_payload, - }} + env = _nyx_rack_env status, headers, body = inner.call(env) - Array(body).each {{ |chunk| print(chunk.to_s) }} + _nyx_print_rack_body(body) end"#, ) } @@ -2249,7 +2430,7 @@ fn parse_class_hosting_method(source: &str, entry_name: &str) -> Option if let Some(rest) = l.strip_prefix("class ") { let name: String = rest .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':') .collect(); if !name.is_empty() { last_class = Some(name); @@ -2269,7 +2450,7 @@ fn parse_first_class_name(source: &str) -> Option { if let Some(rest) = l.strip_prefix("class ") { let name: String = rest .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':') .collect(); if !name.is_empty() { return Some(name); @@ -2391,6 +2572,13 @@ mod tests { assert_eq!(RubyShape::detect(&spec, src), RubyShape::RailsAction); } + #[test] + fn shape_detect_hanami_action() { + let src = "require 'hanami/action'\nclass RunAction < Hanami::Action\n def call(req)\n 'ok'\n end\nend\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::HanamiAction); + } + #[test] fn shape_detect_rack_middleware() { let src = "class MyMiddleware\n def call(env)\n [200, {}, ['ok']]\n end\nend\n"; @@ -2413,26 +2601,56 @@ mod tests { } #[test] - fn sinatra_shape_uses_route_registry() { + fn sinatra_shape_uses_rack_request() { let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb"); let src = generate_source(&spec, RubyShape::SinatraRoute); - assert!(src.contains("$nyx_sinatra_routes")); + assert!(src.contains("Rack::MockRequest.new(app).request")); + assert!(!src.contains("$nyx_sinatra_routes")); } #[test] - fn rack_shape_builds_env_hash() { + fn rack_shape_builds_env_through_rack() { let mut spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb"); spec.payload_slot = PayloadSlot::QueryParam("payload".into()); let src = generate_source(&spec, RubyShape::RackMiddleware); - assert!(src.contains("REQUEST_METHOD")); - assert!(src.contains("rack.input")); + assert!(src.contains("Rack::MockRequest.env_for")); + assert!(src.contains("env['nyx.payload'] = $nyx_payload")); } #[test] - fn rails_shape_invokes_action_on_instance() { + fn rails_shape_invokes_action_rack_endpoint() { let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb"); let src = generate_source(&spec, RubyShape::RailsAction); - assert!(src.contains("instance.send")); + assert!(src.contains("cls.action(\"index\").call(_nyx_rack_env)")); + } + + #[test] + fn hanami_shape_invokes_action_with_rack_env() { + let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb"); + let src = generate_source(&spec, RubyShape::HanamiAction); + assert!(src.contains("action.call(_nyx_rack_env)")); + } + + #[test] + fn framework_shapes_stage_gemfile() { + let sinatra = extra_files_for_shape(RubyShape::SinatraRoute); + assert!( + sinatra + .iter() + .any(|(p, c)| p == "Gemfile" && c.contains("sinatra")) + ); + let rails = extra_files_for_shape(RubyShape::RailsAction); + assert!( + rails + .iter() + .any(|(p, c)| p == "Gemfile" && c.contains("actionpack")) + ); + let hanami = extra_files_for_shape(RubyShape::HanamiAction); + assert!( + hanami + .iter() + .any(|(p, c)| p == "Gemfile" && c.contains("hanami-controller")) + ); } #[test] @@ -2452,6 +2670,10 @@ mod tests { parse_first_class_name("class Bar < Base\nend\n"), Some("Bar".to_owned()) ); + assert_eq!( + parse_first_class_name("class Books::Show < Hanami::Action\nend\n"), + Some("Books::Show".to_owned()) + ); assert_eq!(parse_first_class_name("def foo\nend\n"), None); } diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 10bbaf1e..20a37988 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -354,6 +354,27 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result { + // bundle install if Gemfile is present. + match build_sandbox::prepare_ruby(spec, &harness.workdir) { + Ok(_) => {} + Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => { + return Err(RunError::BuildFailed { stderr, attempts }); + } + Err(build_sandbox::BuildError::Io(e)) => { + return Err(RunError::BuildFailed { + stderr: format!("prepare ruby build cache: {e}"), + attempts: 1, + }); + } + Err(build_sandbox::BuildError::Unsupported) => { + return Err(RunError::BuildFailed { + stderr: "ruby build preparation unsupported on this host".to_owned(), + attempts: 1, + }); + } + } + } Lang::C => { // Compile the harness binary with `cc -o nyx_harness main.c`. // Pass the sandbox profile so the build chooses `-static` when @@ -397,9 +418,6 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result {} } } - _ => { - // No build step for other languages. - } } trace_record( diff --git a/src/labels/c.rs b/src/labels/c.rs index db9f7dda..818ad506 100644 --- a/src/labels/c.rs +++ b/src/labels/c.rs @@ -132,6 +132,23 @@ pub static GATED_SINKS: &[SinkGate] = &[ object_destination_fields: &[], }, }, + // Output sinks: tainted values printed through a literal format string are + // not format-string vulnerabilities, but they still represent an + // attacker-controlled output flow in the real-world corpus. + SinkGate { + callee_matcher: "printf", + arg_index: 0, + dangerous_values: &[], + dangerous_prefixes: &[], + label: DataLabel::Sink(Cap::HTML_ESCAPE), + case_sensitive: false, + payload_args: crate::labels::ALL_ARGS_PAYLOAD, + keyword_name: None, + dangerous_kwargs: &[], + activation: GateActivation::Destination { + object_destination_fields: &[], + }, + }, SinkGate { callee_matcher: "fprintf", arg_index: 1, diff --git a/src/taint/tests.rs b/src/taint/tests.rs index 31126eb4..432cbf2d 100644 --- a/src/taint/tests.rs +++ b/src/taint/tests.rs @@ -1611,6 +1611,64 @@ int main(void) { ); } +#[test] +fn c_fgets_reaches_printf_data_arg() { + let src = br#"#include +int main(void) { + char buf[256]; + if (!fgets(buf, sizeof buf, stdin)) return 1; + printf("%s", buf); + return 0; +} +"#; + let lang = tree_sitter::Language::from(tree_sitter_c::LANGUAGE); + let file_cfg = parse_lang(src, "c", lang); + let findings = analyse_file( + &file_cfg, + &file_cfg.summaries, + None, + Lang::C, + "test.c", + &[], + None, + ); + assert!( + findings + .iter() + .any(|f| f.source_kind == crate::labels::SourceKind::UserInput), + "C: fgets buffer should reach printf data arg, got {findings:#?}" + ); +} + +#[test] +fn c_gets_reaches_printf_data_arg() { + let src = br#"#include +int main(void) { + char buf[256]; + gets(buf); + printf("%s\n", buf); + return 0; +} +"#; + let lang = tree_sitter::Language::from(tree_sitter_c::LANGUAGE); + let file_cfg = parse_lang(src, "c", lang); + let findings = analyse_file( + &file_cfg, + &file_cfg.summaries, + None, + Lang::C, + "test.c", + &[], + None, + ); + assert!( + findings + .iter() + .any(|f| f.source_kind == crate::labels::SourceKind::UserInput), + "C: gets buffer should reach printf data arg, got {findings:#?}" + ); +} + #[test] fn c_execvp_ignores_env_config_executable_path() { let src = br#"#include diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index 2863f15d..badfca22 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -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` on // an empty TU succeeds. Slow but reliable; only called diff --git a/tests/dynamic_fixtures/ruby/hanami_action/Gemfile b/tests/dynamic_fixtures/ruby/hanami_action/Gemfile index d4195fab..3daebc83 100644 --- a/tests/dynamic_fixtures/ruby/hanami_action/Gemfile +++ b/tests/dynamic_fixtures/ruby/hanami_action/Gemfile @@ -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' diff --git a/tests/dynamic_fixtures/ruby/hanami_action/benign.rb b/tests/dynamic_fixtures/ruby/hanami_action/benign.rb index d5e25696..449839f8 100644 --- a/tests/dynamic_fixtures/ruby/hanami_action/benign.rb +++ b/tests/dynamic_fixtures/ruby/hanami_action/benign.rb @@ -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" diff --git a/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb b/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb index 98d89c05..d4f50c5f 100644 --- a/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb +++ b/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb @@ -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 diff --git a/tests/dynamic_fixtures/ruby/rack_middleware/Gemfile b/tests/dynamic_fixtures/ruby/rack_middleware/Gemfile index f38c2e1d..a897e866 100644 --- a/tests/dynamic_fixtures/ruby/rack_middleware/Gemfile +++ b/tests/dynamic_fixtures/ruby/rack_middleware/Gemfile @@ -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' diff --git a/tests/dynamic_fixtures/ruby/rails_action/Gemfile b/tests/dynamic_fixtures/ruby/rails_action/Gemfile index b7710e9f..8d712a99 100644 --- a/tests/dynamic_fixtures/ruby/rails_action/Gemfile +++ b/tests/dynamic_fixtures/ruby/rails_action/Gemfile @@ -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' diff --git a/tests/dynamic_fixtures/ruby/rails_action/benign.rb b/tests/dynamic_fixtures/ruby/rails_action/benign.rb index e0402e84..05a902f2 100644 --- a/tests/dynamic_fixtures/ruby/rails_action/benign.rb +++ b/tests/dynamic_fixtures/ruby/rails_action/benign.rb @@ -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 diff --git a/tests/dynamic_fixtures/ruby/rails_action/vuln.rb b/tests/dynamic_fixtures/ruby/rails_action/vuln.rb index 4e1af559..80184ba4 100644 --- a/tests/dynamic_fixtures/ruby/rails_action/vuln.rb +++ b/tests/dynamic_fixtures/ruby/rails_action/vuln.rb @@ -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 diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile b/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile index 35146665..a8ab5c06 100644 --- a/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile +++ b/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile @@ -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' diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb b/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb index b461b96a..09640c9c 100644 --- a/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb +++ b/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb @@ -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 diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb b/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb index dc7afd03..b8c33a24 100644 --- a/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb +++ b/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb @@ -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 diff --git a/tests/ruby_fixtures.rs b/tests/ruby_fixtures.rs index 18a0dcdb..d2f44031 100644 --- a/tests/ruby_fixtures.rs +++ b/tests/ruby_fixtures.rs @@ -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 { diff --git a/tests/ruby_frameworks_corpus.rs b/tests/ruby_frameworks_corpus.rs index 4291d01a..05ca63b7 100644 --- a/tests/ruby_frameworks_corpus.rs +++ b/tests/ruby_frameworks_corpus.rs @@ -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 ─────────────────────────────────────────────────────────────────── diff --git a/tests/spec_framework_sample.rs b/tests/spec_framework_sample.rs index 4631b360..adbea41f 100644 --- a/tests/spec_framework_sample.rs +++ b/tests/spec_framework_sample.rs @@ -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", ),