//! Ruby harness emitter. //! //! 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. //! //! 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. //! //! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` //! before the actual sink call (same pattern as Rust / JS / Go fixtures). //! //! 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::Argv(n)` — appended to `ARGV` for CLI-style entries. //! - `PayloadSlot::Stdin` — produces `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build: no compilation step. Command is `ruby harness.rb`. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKindTag, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for Ruby. 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 /// controller method shapes. const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, EntryKindTag::ScheduledJob, EntryKindTag::WebSocket, EntryKindTag::Middleware, EntryKindTag::Migration, ]; impl LangEmitter for RubyEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKindTag] { SUPPORTED } fn entry_kind_hint(&self, attempted: EntryKindTag) -> String { format!( "ruby emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 / 19 / 20 / 21 shape dispatch" ) } fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_ruby(env) } fn compose_chain_step( &self, prev_output: Option<&[u8]>, terminal: Option<&ChainStepTerminal>, ) -> ChainStepHarness { chain_step(prev_output, terminal) } } /// Phase 26 — Ruby chain-step harness. /// /// Splices the Ruby probe shim ([`probe_shim`]) in front of a minimal /// driver that reads `NYX_PREV_OUTPUT` from `ENV` and forwards it on /// stdout. When the step is the chain's terminal step the driver also /// calls `__nyx_probe(callee, prev)` and emits the /// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips /// `sink_hit` for the chain. fn chain_step( prev_output: Option<&[u8]>, terminal: Option<&ChainStepTerminal>, ) -> ChainStepHarness { let shim = probe_shim(); let mut driver = String::from("prev = ENV[\"NYX_PREV_OUTPUT\"] || \"\"\n$stdout.write(prev)\n"); if let Some(t) = terminal { let callee = ruby_string_literal(&t.sink_callee); let sentinel = ruby_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); driver.push_str(&format!( "__nyx_probe({callee}, prev)\nputs {sentinel}\n$stdout.flush\n", )); } let source = format!("{shim}\n{driver}"); ChainStepHarness { source, filename: "step.rb".to_owned(), command: vec!["ruby".to_owned(), "step.rb".to_owned()], extra_env: prev_output .map(|bytes| { vec![( ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), String::from_utf8_lossy(bytes).into_owned(), )] }) .unwrap_or_default(), extra_files: Vec::new(), } } /// Escape a string for safe Ruby double-quoted literal embedding. fn ruby_string_literal(s: &str) -> String { let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); format!("\"{escaped}\"") } // ── Phase 15: shape detector ───────────────────────────────────────────────── /// Concrete per-file shape resolved by reading the entry source. /// /// One harness template per variant. When the entry file is unreadable /// 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`. 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. RailsAction, /// Rack middleware: `def call(env) ... end` on a class. Harness /// builds a minimal Rack `env` hash and dispatches. RackMiddleware, /// Generic instance method on a controller class (no framework /// marker). Harness instantiates the class with `.new` and calls /// the named method with the payload. ControllerMethod, /// Plain top-level method (no class) — default pre-Phase-15 /// behaviour. Generic, } impl RubyShape { /// Detect the shape from `(spec, source)`. Framework markers in /// the source win over `spec.entry_kind`. pub fn detect(spec: &HarnessSpec, source: &str) -> Self { let entry = spec.entry_name.as_str(); let kind = spec.entry_kind.tag(); let has_sinatra = source.contains("require 'sinatra'") || source.contains("require \"sinatra\"") || source.contains("Sinatra::Base") || source.contains("# nyx-shape: sinatra") || (source.contains("get '/") && source.contains(" do")); let has_rails = source.contains("ApplicationController") || source.contains("ActionController::Base") || source.contains("ActionController::API") || source.contains("# nyx-shape: rails"); let has_rack = source.contains("def call(env)") || source.contains("Rack::") || source.contains("# nyx-shape: rack"); let has_class = source.contains("class "); let has_def = source.contains("def "); let entry_named_class = entry .chars() .next() .map(|c| c.is_ascii_uppercase()) .unwrap_or(false); if has_sinatra { return Self::SinatraRoute; } if has_rack && entry == "call" { return Self::RackMiddleware; } if has_rails { return Self::RailsAction; } if has_rack { return Self::RackMiddleware; } if kind == EntryKindTag::HttpRoute && has_class { return Self::ControllerMethod; } if has_class && has_def && !entry.is_empty() && !entry_named_class { return Self::ControllerMethod; } Self::Generic } } /// Public wrapper to detect the shape for a finalised `HarnessSpec`, /// reading the entry file from disk. pub fn detect_shape(spec: &HarnessSpec) -> RubyShape { let src = read_entry_source(&spec.entry_file); RubyShape::detect(spec, &src) } fn read_entry_source(entry_file: &str) -> String { let candidates = [ PathBuf::from(entry_file), PathBuf::from(".").join(entry_file), ]; for path in &candidates { if let Ok(s) = std::fs::read_to_string(path) { return s; } } String::new() } /// Phase 08 / 09 tier-(a) helper: strip the `.rb` extension off the /// entry file's basename so the harness can `require_relative` it. fn derive_entry_basename(entry_file: &str) -> String { PathBuf::from(entry_file) .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_owned()) .unwrap_or_else(|| "entry".to_owned()) } /// Source of the `__nyx_probe` shim for the Ruby harness (Phase 06 — /// Track C.1). pub fn probe_shim() -> &'static str { r#" # ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ────── NYX_DENY_SUBSTRINGS = %w[ TOKEN SECRET PASSWORD PASSWD API_KEY APIKEY PRIVATE_KEY CREDENTIAL SESSION COOKIE AUTH BEARER AWS_ACCESS AWS_SESSION GH_TOKEN GITHUB_TOKEN NPM_TOKEN PYPI_TOKEN DOCKER_PASS ].freeze NYX_PAYLOAD_LIMIT = 16 * 1024 NYX_REDACTED = '' def __nyx_is_denied_key(k) ku = k.to_s.upcase NYX_DENY_SUBSTRINGS.any? { |n| ku.include?(n) } end def __nyx_witness(sink_callee, args) env_snapshot = {} ENV.each do |k, v| env_snapshot[k] = __nyx_is_denied_key(k) ? NYX_REDACTED : v end payload = ENV['NYX_PAYLOAD'] || '' pb = payload.bytes pb = pb[0, NYX_PAYLOAD_LIMIT] if pb.length > NYX_PAYLOAD_LIMIT repr = args.map { |a| a.is_a?(String) ? a : a.to_s } cwd = (Dir.pwd rescue '') { env_snapshot: env_snapshot, cwd: cwd, payload_bytes: pb, callee: sink_callee.to_s, args_repr: repr, } end def __nyx_emit(rec) require 'json' p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? begin File.open(p, 'a') { |f| f.puts(rec.to_json) } rescue StandardError end end def __nyx_probe(sink_callee, *args) ser = args.map do |a| case a when Integer then { kind: 'Int', value: a } when String then { kind: 'String', value: a } else { kind: 'String', value: a.to_s } end end __nyx_emit({ sink_callee: sink_callee.to_s, args: ser, captured_at_ns: (Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)), payload_id: (ENV['NYX_PAYLOAD_ID'] || ''), kind: { kind: 'Normal' }, witness: __nyx_witness(sink_callee, args), }) end # Phase 08: install a sink-site signal trap. Ruby traps run in interrupt # context but can write to a file before re-raising via Process.kill. def __nyx_install_crash_guard(sink_callee) %w[SEGV ABRT BUS FPE ILL].each do |nm| begin Signal.trap(nm) do __nyx_emit({ sink_callee: sink_callee.to_s, args: [], captured_at_ns: (Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)), payload_id: (ENV['NYX_PAYLOAD_ID'] || ''), kind: { kind: 'Crash', signal: "SIG#{nm}" }, witness: __nyx_witness(sink_callee, []), }) Signal.trap(nm, 'DEFAULT') Process.kill(nm, Process.pid) end rescue ArgumentError, Errno::EINVAL # signal not supported on this platform end end end # Phase 10 (Track D.3) HTTP recording helper. When the verifier spawned an # HttpStub it publishes the side-channel log path through NYX_HTTP_LOG; a # sink call site whose outbound request never reaches the on-the-wire # listener (DNS-mocked, network-isolated sandbox, pre-flight check) can # call this helper to surface the attempted call. Format matches the # Python / Node / PHP / Go siblings so the host-side HttpStub log-line # merger parses all five streams identically. No-op when NYX_HTTP_LOG is # unset so the same harness still runs cleanly under modes that did not # spawn a stub. Single-quoted Ruby string literals keep this helper free # of the literal hash-after-double-quote sequence that would terminate # the surrounding Rust raw string. def __nyx_stub_http_record(method, url, body = nil, **detail) p = ENV['NYX_HTTP_LOG'] return if p.nil? || p.empty? begin File.open(p, 'a') do |f| f.puts('# method: ' + method.to_s) f.puts('# url: ' + url.to_s) f.puts('# body: ' + body.to_s) unless body.nil? detail.each { |k, v| f.puts('# ' + k.to_s + ': ' + v.to_s) } f.puts(method.to_s + ' ' + url.to_s) end rescue StandardError end end # Phase 10 (Track D.3) SQL recording helper. When the verifier spawned a # SqlStub it publishes the side-channel log path through NYX_SQL_LOG; a # sink call site whose query never reaches the on-the-wire SQLite engine # (no sqlite3 gem on the host, query pre-flighted before # SQLite3::Database.open) can call this helper to surface the attempted # query. Hash-prefixed detail lines followed by the query line so # SqlStub::drain_events parses every language stream identically. No-op # when NYX_SQL_LOG is unset. Single-quoted Ruby string literals keep this # helper free of the literal hash-after-double-quote sequence. def __nyx_stub_sql_record(query, **detail) p = ENV['NYX_SQL_LOG'] return if p.nil? || p.empty? begin File.open(p, 'a') do |f| detail.each { |k, v| f.puts('# ' + k.to_s + ': ' + v.to_s) } line = query.to_s line += "\n" unless line.end_with?("\n") f.write(line) end rescue StandardError end end "# } /// Phase 09 — Track D.2: synthesise a `Gemfile` listing every captured /// gem name. pub fn materialize_ruby(env: &Environment) -> RuntimeArtifacts { let mut artifacts = RuntimeArtifacts::new(); let mut deps: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for d in &env.direct_deps { if is_ruby_stdlib(d) { continue; } if seen.insert(d.clone()) { deps.push(d.clone()); } } deps.sort_unstable(); let mut body = String::with_capacity(64); body.push_str("source 'https://rubygems.org'\n"); for d in &deps { body.push_str(&format!("gem '{d}'\n")); } artifacts.push("Gemfile", body); artifacts } fn is_ruby_stdlib(name: &str) -> bool { matches!( name, "json" | "yaml" | "uri" | "net" | "time" | "date" | "csv" | "logger" | "fileutils" | "tempfile" | "open" | "stringio" | "set" | "open3" | "ostruct" | "digest" | "base64" | "securerandom" | "etc" ) } /// Emit a Ruby harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody | PayloadSlot::Argv(_) => {} PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported), } if spec.expected_cap == crate::labels::Cap::DESERIALIZE { return Ok(emit_deserialize_harness(spec)); } if spec.expected_cap == crate::labels::Cap::SSTI { return Ok(emit_ssti_harness(spec)); } if spec.expected_cap == crate::labels::Cap::XXE { return Ok(emit_xxe_harness(spec)); } if spec.expected_cap == crate::labels::Cap::HEADER_INJECTION { return Ok(emit_header_injection_harness(spec)); } if spec.expected_cap == crate::labels::Cap::OPEN_REDIRECT { return Ok(emit_open_redirect_harness(spec)); } // Phase 19 (Track M.1): ClassMethod short-circuit. if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { return Ok(emit_class_method_harness(class, method)); } // Phase 21 (Track M.3): ScheduledJob short-circuit (Sidekiq workers). if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind { return Ok(emit_scheduled_job_harness( &spec.entry_name, schedule.as_deref(), )); } // Phase 21 (Track M.3): WebSocket short-circuit (ActionCable channels). if let crate::evidence::EntryKind::WebSocket { path } = &spec.entry_kind { return Ok(emit_websocket_handler_harness(&spec.entry_name, path)); } // Phase 21 (Track M.3): Middleware short-circuit (Rack-shape). if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { return Ok(emit_middleware_harness(&spec.entry_name, name)); } // Phase 21 (Track M.3): Migration short-circuit (ActiveRecord up/down). if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { return Ok(emit_migration_harness(&spec.entry_name, version.as_deref())); } let entry_source = read_entry_source(&spec.entry_file); let shape = RubyShape::detect(spec, &entry_source); let source = generate_source(spec, shape); Ok(HarnessSource { source, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), }) } /// Phase 19 (Track M.1) — class-method harness for Ruby. /// /// Requires the entry file, looks up `class` as a top-level constant, /// instantiates via `.new` (falling back to a single mock-dependency /// `.new(...)` when the no-arg path raises `ArgumentError`), and /// invokes `instance.send(method, payload)`. fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { let shim = probe_shim(); let mock_http = crate::dynamic::stubs::mock_source( crate::dynamic::stubs::MockKind::HttpClient, crate::symbol::Lang::Ruby, ); let mock_db = crate::dynamic::stubs::mock_source( crate::dynamic::stubs::MockKind::DatabaseConnection, crate::symbol::Lang::Ruby, ); let mock_log = crate::dynamic::stubs::mock_source( crate::dynamic::stubs::MockKind::Logger, crate::symbol::Lang::Ruby, ); let body = format!( r#"# Nyx dynamic harness — class method (Phase 19 / Track M.1). {shim} {mock_http} {mock_db} {mock_log} def nyx_payload v = ENV['NYX_PAYLOAD'] return v if v && !v.empty? b64 = ENV['NYX_PAYLOAD_B64'] if b64 && !b64.empty? begin require 'base64' return Base64.decode64(b64) rescue StandardError return '' end end '' end $nyx_payload = nyx_payload begin require_relative './entry' rescue LoadError, ScriptError => e STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") exit 77 end cls_name = {class:?} unless Object.const_defined?(cls_name) STDERR.puts("NYX_CLASS_NOT_FOUND: #{{cls_name}}") exit 78 end cls = Object.const_get(cls_name) def _nyx_build_receiver(cls) begin return cls.new rescue ArgumentError end begin return cls.new(MockHttpClient.new, MockDatabaseConnection.new, MockLogger.new) rescue StandardError end [MockDatabaseConnection.new, MockHttpClient.new, MockLogger.new, nil].each do |dep| begin return cls.new(dep) rescue StandardError end end nil end instance = _nyx_build_receiver(cls) if instance.nil? STDERR.puts("NYX_CLASS_CTOR_FAILED: #{{cls_name}}") exit 78 end unless instance.respond_to?({method:?}) STDERR.puts("NYX_METHOD_NOT_FOUND: " + {method:?}) exit 78 end begin result = instance.send({method:?}, $nyx_payload) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end "#, class = class, method = method, ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), } } // ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── fn nyx_ruby_preamble() -> String { let shim = probe_shim(); format!( r#"# Nyx dynamic harness — Phase 21 / Track M.3 (auto-generated). {shim} def nyx_payload v = ENV['NYX_PAYLOAD'] return v if v && !v.empty? b64 = ENV['NYX_PAYLOAD_B64'] if b64 && !b64.empty? begin require 'base64' return Base64.decode64(b64) rescue StandardError return '' end end '' end $nyx_payload = nyx_payload begin require_relative './entry' rescue LoadError, ScriptError => e STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") exit 77 end puts "__NYX_SINK_HIT__" "#, shim = shim, ) } fn emit_scheduled_job_harness(handler: &str, schedule: Option<&str>) -> HarnessSource { let preamble = nyx_ruby_preamble(); let sched = schedule.unwrap_or(""); let body = format!( r#"{preamble} puts "__NYX_SCHEDULED_JOB__: " + {sched:?} # Sidekiq workers expose perform(*args) on a class. Try looking up the # named class first; fall back to a top-level function. target = nil if Object.const_defined?({handler:?}) begin target = Object.const_get({handler:?}).new if target.respond_to?(:perform) begin result = target.perform($nyx_payload) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end exit 0 end rescue StandardError end end if respond_to?({handler:?}.to_sym, true) begin result = send({handler:?}.to_sym, $nyx_payload) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end else STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) exit 78 end "#, preamble = preamble, handler = handler, sched = sched, ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), } } fn emit_websocket_handler_harness(handler: &str, path: &str) -> HarnessSource { let preamble = nyx_ruby_preamble(); let body = format!( r#"{preamble} puts "__NYX_WEBSOCKET__: " + {path:?} # ActionCable channels expose `receive(data)` on a subclass. Find the # enclosing class via const lookup; fall back to top-level send. if Object.const_defined?({handler:?}) cls = Object.const_get({handler:?}) begin inst = cls.new rescue (cls.allocate rescue nil) if inst && inst.respond_to?(:receive) begin result = inst.receive($nyx_payload) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end exit 0 end rescue StandardError end end if respond_to?({handler:?}.to_sym, true) begin result = send({handler:?}.to_sym, $nyx_payload) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end else STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) exit 78 end "#, preamble = preamble, handler = handler, path = path, ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), } } fn emit_middleware_harness(handler: &str, name: &str) -> HarnessSource { let preamble = nyx_ruby_preamble(); let body = format!( r#"{preamble} puts "__NYX_MIDDLEWARE__: " + {name:?} require 'stringio' # Rack-shape middleware: class with #call(env). env = {{ 'REQUEST_METHOD' => 'POST', 'PATH_INFO' => '/nyx', 'QUERY_STRING' => "q=#{{$nyx_payload}}", 'rack.input' => StringIO.new($nyx_payload), 'nyx.payload' => $nyx_payload, }} if Object.const_defined?({handler:?}) cls = Object.const_get({handler:?}) begin inst = cls.new(lambda {{ |e| [200, {{}}, ['ok']] }}) if inst.respond_to?(:call) result = inst.call(env) print(result.to_s) if result exit 0 end rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end end if respond_to?({handler:?}.to_sym, true) begin result = send({handler:?}.to_sym, env) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end else STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) exit 78 end "#, preamble = preamble, handler = handler, name = name, ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), } } fn emit_migration_harness(handler: &str, version: Option<&str>) -> HarnessSource { let preamble = nyx_ruby_preamble(); let ver = version.unwrap_or(""); let body = format!( r#"{preamble} puts "__NYX_MIGRATION__: " + {ver:?} # ActiveRecord migrations expose `up` / `down` / `change` on a subclass. if Object.const_defined?({handler:?}) cls = Object.const_get({handler:?}) begin inst = cls.new %i[up change down].each do |m| if inst.respond_to?(m) begin result = inst.send(m) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end exit 0 end end rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end end if respond_to?({handler:?}.to_sym, true) begin result = send({handler:?}.to_sym) print(result.to_s) if result rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end else STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) exit 78 end "#, preamble = preamble, handler = handler, ver = ver, ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: Some("entry.rb".to_owned()), } } /// Phase 03 — Track J.1 deserialize harness for Ruby. /// /// Wraps a call to `Marshal.load(input)` with a const-lookup /// instrumentation that asserts the requested constant is on the /// allowlist (`Integer`, `String`, `Array`). When the marker class /// is outside the allowlist the shim writes a /// [`crate::dynamic::probe::ProbeKind::Deserialize`] probe with /// `gadget_chain_invoked: true`. pub fn emit_deserialize_harness(_spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let body = format!( r#"# Nyx dynamic harness — deserialize (Phase 03 / Track J.1). require 'json' {shim} def _nyx_deserialize_probe(invoked) p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? rec = {{ 'sink_callee' => 'Marshal.load', 'args' => [], 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', 'kind' => {{ 'kind' => 'Deserialize', 'gadget_chain_invoked' => !!invoked }}, 'witness' => __nyx_witness('Marshal.load', []), }} File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end # Forge a Marshal v4.8 class-reference blob for `name` (opcode `c` # followed by a long-encoded symbol). Marshal.load resolves the class # via `Object.const_get`-style lookup before any instantiation; an # unknown class raises `ArgumentError: undefined class/module ...` — # the same boundary `Marshal.const_defined?`-style hardening checks. def _nyx_forge_marshal_class_ref(name) bytes = name.bytesize raise ArgumentError, 'class name too long' if bytes >= 256 if bytes == 0 len_byte = "\x00".b elsif bytes < 123 len_byte = [bytes + 5].pack('C') else len_byte = "\x01".b + [bytes].pack('C') end "\x04\x08c".b + len_byte + name.b end allowlist = ['Integer', 'String', 'Array'] payload = ENV['NYX_PAYLOAD'] || '' if payload.start_with?('NYX_GADGET_CLASS:') cls = payload[('NYX_GADGET_CLASS:'.length)..] begin Marshal.load(_nyx_forge_marshal_class_ref(cls)) rescue ArgumentError => e # `undefined class/module ` — the Marshal class-resolution # boundary refused the lookup. Real hardening would surface this # via a `Marshal.const_defined?` pre-check + reject; we record the # gadget-class invocation here. if e.message.start_with?('undefined class/module') _nyx_deserialize_probe(true) end rescue TypeError, NameError # Allow-listed class that exists at load time (e.g. `Integer`) # resolves cleanly via `Object.const_get` and Marshal returns the # class object — no rescue path. Other unexpected errors fall # through without writing a probe. end end # Sink-reachability sentinel — runner's `vuln_fired && sink_hit` # gate consumes this; without it differential confirmation cannot # fire even when the probe was written. STDOUT.puts '__NYX_SINK_HIT__' "# ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: None, } } /// Phase 04 — Track J.2 SSTI harness for Ruby (ERB). /// /// Reads `NYX_PAYLOAD`, simulates ERB's `<%= expr %>` evaluation by /// scanning for arithmetic inside the inline-output marker, prints /// `{"render": ""}` plus the sink-hit sentinel. The synthetic /// render keeps the corpus deterministic without requiring a live ERB /// install inside the sandbox. pub fn emit_ssti_harness(_spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let body = format!( r#"# Nyx dynamic harness — SSTI ERB (Phase 04 / Track J.2). # # Routes `NYX_PAYLOAD` through the real stdlib `ERB.new(payload).result` # call. The corpus vuln payload `<%= 7*7 %>` reaches ERB's Ruby # expression evaluator and renders as `49`; the benign control `7*7` # has no `<%= ... %>` markers so the engine echoes it verbatim. require 'erb' require 'json' {shim} def _nyx_erb_render(payload) begin ERB.new(payload).result(binding) rescue ScriptError, StandardError => e "" end end def _nyx_ssti_probe(rendered) p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? rec = {{ 'sink_callee' => 'ERB#result', 'args' => [{{ 'kind' => 'String', 'value' => rendered }}], 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', 'kind' => {{ 'kind' => 'Normal' }}, 'witness' => __nyx_witness('ERB#result', [rendered]), }} File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end payload = ENV['NYX_PAYLOAD'] || '' rendered = _nyx_erb_render(payload) _nyx_ssti_probe(rendered) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({{"render" => rendered}}) STDOUT.flush "# ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: None, } } /// Phase 05 — Track J.3 XXE harness for Ruby (REXML / Nokogiri). /// /// Reads `NYX_PAYLOAD`, parses it with stdlib `REXML::Document.new`, /// inspects the resulting `doctype.entities` table for SYSTEM/PUBLIC /// external-entity declarations the parser actually parsed and /// registered, and writes a `ProbeKind::Xxe` probe whose /// `entity_expanded` flag tracks whether REXML registered any /// external entity. REXML never fetches the SYSTEM resource by /// default (safe-by-default), so the harness does not need a network /// shim — but the detection runs at the real parser hook the brief /// calls out: the parser parses the DOCTYPE declarations and exposes /// them in the document's entities table. pub fn emit_xxe_harness(_spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let body = format!( r##"# Nyx dynamic harness — XXE REXML (Phase 05 / Track J.3). require 'json' require 'net/http' require 'rexml/document' require 'stringio' require 'uri' {shim} # Build the XML document fed into REXML. Two shapes (Phase 05 OOB # closure, 2026-05-21): # - URL-form NYX_PAYLOAD (`http://...` / `https://...`): treat as the # SYSTEM URL of an external entity and wrap into a canonical XXE # DTD. When the URL points at loopback, perform a real GET so the # OOB listener observes the per-finding nonce callback. # - Anything else: treat as the full XML document (existing shape). def _nyx_build_xxe_document(payload) if payload.start_with?('http://') || payload.start_with?('https://') if payload.start_with?('http://127.0.0.1') || payload.start_with?('http://host-gateway') || payload.start_with?('http://localhost') begin uri = URI.parse(payload) Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http| http.request_get(uri.request_uri) end rescue StandardError # best-effort OOB fetch end end escaped = payload.gsub('&', '&').gsub('"', '"').gsub('<', '<') "\n\n]>\n&xxe;" else payload end end def _nyx_libxml_parse(payload) # Real parser hook: REXML parses `` declarations # into Entity objects on the doctype. Inspect the entities table to # detect every external-entity reference the parser registered. expanded = false begin doc = REXML::Document.new(_nyx_build_xxe_document(payload)) if doc.doctype doc.doctype.entities.each_value do |ent| s = ent.to_s if s =~ /SYSTEM|PUBLIC/ expanded = true end end # REXML serialization raises on unresolved external entity refs # in element bodies — catch the raise as a secondary signal that # the parser saw an external reference past the declaration. begin doc.write(StringIO.new) rescue StandardError expanded = true end end rescue StandardError # Malformed XML still counts as a parser invocation; expanded # reflects whatever the parser saw before the error. end expanded end def _nyx_xxe_probe(payload, expanded) p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? rec = {{ 'sink_callee' => 'REXML::Document.new', 'args' => [{{ 'kind' => 'String', 'value' => payload }}], 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', 'kind' => {{ 'kind' => 'Xxe', 'entity_expanded' => !!expanded }}, 'witness' => __nyx_witness('REXML::Document.new', [payload]), }} File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end payload = ENV['NYX_PAYLOAD'] || '' expanded = _nyx_libxml_parse(payload) _nyx_xxe_probe(payload, expanded) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({{"entity_expanded" => expanded}}) STDOUT.flush "## ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: None, } } /// Phase 08 — Track J.6 header-injection harness for Ruby /// (`Rack::Response#set_header`). /// /// When the fixture imports `rack` the tier-(a) path imports the /// fixture, prepends a permissive captor module onto `Rack::Response` /// that records every `set_header(name, value)` call verbatim, and /// invokes the named entry function with the payload. Otherwise the /// synthetic fallback emits a single `HeaderEmit` probe with the /// payload bytes pre-bound to `Set-Cookie`, matching the prior /// behaviour. pub fn emit_header_injection_harness(spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let entry_source = read_entry_source(&spec.entry_file); let entry_basename = derive_entry_basename(&spec.entry_file); let entry_name = if spec.entry_name.is_empty() { "run".to_owned() } else { spec.entry_name.clone() }; let uses_rack = entry_source.contains("require 'rack'") || entry_source.contains("require \"rack\""); let via_fixture = if uses_rack { format!( r#"def _nyx_header_via_fixture(payload) # Phase 08 tier-(a): prepend a permissive captor onto # `Rack::Response` so every `set_header(name, value)` call records # the unmodified bytes (including raw `\r\n`) before Rack's # validator (if any) runs. Mirrors the Python werkzeug pattern at # `src/dynamic/lang/python.rs::emit_header_injection_harness`. # Returns `nil` when Rack is missing or the fixture cannot be # required so the caller can fall back to the synthetic probe. begin require 'rack' rescue LoadError, ScriptError return nil end captured = [] patcher = Module.new do define_method(:set_header) do |name, value| begin captured << [name.to_s, value.to_s] rescue StandardError # ignore non-string args end self end end Rack::Response.prepend(patcher) $LOAD_PATH.unshift('.') begin require_relative './{entry_basename}' rescue LoadError, ScriptError return nil end begin Object.new.__send__(:'{entry_name}', payload) rescue StandardError # Fixture raised — return whatever was captured before throw. end captured end "# ) } else { String::new() }; let invoke_via_fixture = if uses_rack { r#" captured = _nyx_header_via_fixture(payload) if captured && !captured.empty? captured.each do |(name, value)| _nyx_header_probe(name, value) end STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({ 'headers' => captured.map { |p| [p[0], p[1]] } }) STDOUT.flush return end "# } else { "" }; let body = format!( r#"# Nyx dynamic harness — HEADER_INJECTION Rack::Response#set_header (Phase 08 / Track J.6). require 'json' {shim} def _nyx_header_probe(name, value) p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? rec = {{ 'sink_callee' => 'Rack::Response#set_header', 'args' => [ {{ 'kind' => 'String', 'value' => name }}, {{ 'kind' => 'String', 'value' => value }}, ], 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', 'kind' => {{ 'kind' => 'HeaderEmit', 'name' => name, 'value' => value }}, 'witness' => __nyx_witness('Rack::Response#set_header', [name, value]), }} File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end {via_fixture}def _nyx_run payload = ENV['NYX_PAYLOAD'] || '' {invoke_via_fixture} # Synthetic fallback — mirrors `Rack::Response#set_header` # semantics: the value bytes flow through unmodified, so a tainted # payload that carries raw `\r\n` lands on the wire as a header split. name = 'Set-Cookie' value = payload _nyx_header_probe(name, value) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({{ 'name' => name, 'value' => value }}) STDOUT.flush end _nyx_run "# ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: None, } } /// Phase 09 — Track J.7 open-redirect harness for Ruby /// (`Rack::Response#redirect`). /// /// When the fixture imports `rack` the tier-(a) path imports the /// fixture, prepends a captor onto `Rack::Response` that records /// every `redirect(target)` / `set_header('Location', ...)` / /// `location(...)` call, and invokes the named entry function. The /// captor returns `self` so the fixture's downstream method chaining /// keeps working. Falls back to the synthetic probe on missing /// Rack / import failure. pub fn emit_open_redirect_harness(spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let entry_source = read_entry_source(&spec.entry_file); let entry_basename = derive_entry_basename(&spec.entry_file); let entry_name = if spec.entry_name.is_empty() { "run".to_owned() } else { spec.entry_name.clone() }; let uses_rack = entry_source.contains("require 'rack'") || entry_source.contains("require \"rack\""); let via_fixture = if uses_rack { format!( r#"def _nyx_redirect_via_fixture(payload) # Phase 09 tier-(a): prepend a captor onto `Rack::Response` so # every `redirect(target)` / `set_header('Location', value)` / # `location(value)` call records the bound location string. # Mirrors the Python flask pattern at # `src/dynamic/lang/python.rs::emit_open_redirect_harness`. # Returns `[location, 'example.com']` on success, `nil` when Rack # is missing or the fixture cannot be required so the caller can # fall back to the synthetic probe. begin require 'rack' rescue LoadError, ScriptError return nil end captured = [] patcher = Module.new do define_method(:redirect) do |target, *_args, **_opts| begin captured << target.to_s rescue StandardError # ignore end self end define_method(:set_header) do |name, value| begin if name.to_s.casecmp('Location').zero? captured << value.to_s end rescue StandardError # ignore end self end define_method(:location=) do |value| begin captured << value.to_s rescue StandardError # ignore end value end end Rack::Response.prepend(patcher) $LOAD_PATH.unshift('.') begin require_relative './{entry_basename}' rescue LoadError, ScriptError return nil end begin Object.new.__send__(:'{entry_name}', payload) rescue StandardError # Fixture raised — return whatever was captured before throw. end return nil if captured.empty? [captured.first, 'example.com'] end "# ) } else { String::new() }; let invoke_via_fixture = if uses_rack { r#" captured = _nyx_redirect_via_fixture(payload) if captured location, request_host = captured _nyx_redirect_probe(location, request_host) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({ 'location' => location, 'request_host' => request_host }) STDOUT.flush return end "# } else { "" }; let body = format!( r#"# Nyx dynamic harness — OPEN_REDIRECT Rack::Response#redirect (Phase 09 / Track J.7). require 'json' {shim} def _nyx_redirect_probe(location, request_host) p = ENV['NYX_PROBE_PATH'] return if p.nil? || p.empty? rec = {{ 'sink_callee' => 'Rack::Response#redirect', 'args' => [ {{ 'kind' => 'String', 'value' => location }}, ], 'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond), 'payload_id' => ENV['NYX_PAYLOAD_ID'] || '', 'kind' => {{ 'kind' => 'Redirect', 'location' => location, 'request_host' => request_host }}, 'witness' => __nyx_witness('Rack::Response#redirect', [location]), }} File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }} end {via_fixture}def _nyx_run payload = ENV['NYX_PAYLOAD'] || '' {invoke_via_fixture} request_host = 'example.com' location = payload _nyx_redirect_probe(location, request_host) STDOUT.puts '__NYX_SINK_HIT__' STDOUT.puts JSON.generate({{ 'location' => location, 'request_host' => request_host }}) STDOUT.flush end _nyx_run "# ); HarnessSource { source: body, filename: "harness.rb".to_owned(), command: vec!["ruby".to_owned(), "harness.rb".to_owned()], extra_files: vec![], entry_subpath: None, } } fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String { let entry_fn = &spec.entry_name; let pre_call = build_pre_call(spec); let invocation = invoke_for_shape(spec, shape, entry_fn); let shim = probe_shim(); let crash_callee = if entry_fn.is_empty() { "main" } else { entry_fn.as_str() }; format!( r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}). {shim} # ── Payload loading ────────────────────────────────────────────────────────── def nyx_payload v = ENV['NYX_PAYLOAD'] return v if v && !v.empty? b64 = ENV['NYX_PAYLOAD_B64'] if b64 && !b64.empty? begin require 'base64' return Base64.decode64(b64) rescue StandardError return '' end end '' end $nyx_payload = nyx_payload # 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 require_relative './entry' rescue LoadError, ScriptError => e STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") exit 77 end # ── Invocation ────────────────────────────────────────────────────────────── begin {invocation} rescue StandardError => e STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") end "#, shape = shape, pre_call = pre_call, invocation = invocation, ) } fn build_pre_call(spec: &HarnessSpec) -> String { let mut out = String::new(); match &spec.payload_slot { PayloadSlot::EnvVar(name) => { out.push_str(&format!("ENV[{name:?}] = $nyx_payload\n")); } PayloadSlot::Argv(n) => { for _ in 0..*n { out.push_str("ARGV << ''\n"); } out.push_str("ARGV << $nyx_payload\n"); } PayloadSlot::QueryParam(name) => { out.push_str(&format!( "$nyx_request = {{ method: 'GET', path: '/', params: {{ {name:?} => $nyx_payload }}, body: '' }}\n" )); } PayloadSlot::HttpBody => { out.push_str( "$nyx_request = { method: 'POST', path: '/', params: {}, body: $nyx_payload }\n", ); } _ => { out.push_str( "$nyx_request = { method: 'GET', path: '/', params: { 'payload' => $nyx_payload }, body: '' }\n", ); } } out } 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) elsif respond_to?({entry_fn:?}) print(send({entry_fn:?}, $nyx_payload).to_s) end"#, ), RubyShape::RailsAction => { 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 end"#, ) } RubyShape::RackMiddleware => { let cls = entry_class_from_spec(spec); format!( r#" require 'stringio' 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, }} status, headers, body = inner.call(env) Array(body).each {{ |chunk| print(chunk.to_s) }} end"#, ) } RubyShape::ControllerMethod => { let cls = entry_class_from_spec(spec); format!( r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil if cls instance = cls.new result = instance.send({entry_fn:?}, $nyx_payload) print(result.to_s) if result end"#, ) } } } fn generic_invocation(spec: &HarnessSpec, entry_fn: &str) -> String { match &spec.payload_slot { PayloadSlot::EnvVar(_) | PayloadSlot::Argv(_) => format!(" {entry_fn}()"), PayloadSlot::Param(idx) => { if *idx == 0 { format!(" {entry_fn}($nyx_payload)") } else { let pads = (0..*idx).map(|_| "nil").collect::>().join(", "); format!(" {entry_fn}({pads}, $nyx_payload)") } } _ => format!(" {entry_fn}($nyx_payload)"), } } /// Best-effort guess at the class name from the entry source. /// /// Walks every `class Foo` declaration and picks the one whose body /// contains `def {entry_name}` (the class that actually defines the /// entry method). When no class hosts the entry method — or the /// entry name is empty — falls back to the first class declaration, /// then to `"Entry"`. fn entry_class_from_spec(spec: &HarnessSpec) -> String { let src = read_entry_source(&spec.entry_file); parse_class_hosting_method(&src, &spec.entry_name) .or_else(|| parse_first_class_name(&src)) .unwrap_or_else(|| "Entry".to_owned()) } fn parse_class_hosting_method(source: &str, entry_name: &str) -> Option { if entry_name.is_empty() { return None; } let needle = format!("def {entry_name}"); // Walk every line, remembering the most-recently-seen class // declaration. When we encounter `def {entry_name}`, return the // last-seen class — that is the closest enclosing class scope. // Coarse but correct for the per-shape fixtures (no nested classes). let mut last_class: Option = None; for line in source.lines() { let l = line.trim_start(); if let Some(rest) = l.strip_prefix("class ") { let name: String = rest .chars() .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); if !name.is_empty() { last_class = Some(name); } continue; } if l.contains(&needle) { return last_class.clone(); } } None } fn parse_first_class_name(source: &str) -> Option { for line in source.lines() { let l = line.trim_start(); if let Some(rest) = l.strip_prefix("class ") { let name: String = rest .chars() .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); if !name.is_empty() { return Some(name); } } } None } #[cfg(test)] mod tests { use super::*; use crate::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; use crate::labels::Cap; use crate::symbol::Lang; fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec { HarnessSpec { finding_id: "rb000000000001".into(), entry_file: "src/login.rb".into(), entry_name: "login".into(), entry_kind: EntryKind::Function, lang: Lang::Ruby, toolchain_id: "ruby-3".into(), payload_slot, expected_cap: Cap::SQL_QUERY, constraint_hints: vec![], sink_file: "src/login.rb".into(), sink_line: 10, spec_hash: "rb000000000001".into(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } #[test] fn entry_kinds_supported_is_non_empty() { assert!(!RubyEmitter.entry_kinds_supported().is_empty()); assert!( RubyEmitter .entry_kinds_supported() .contains(&EntryKindTag::Function) ); assert!( RubyEmitter .entry_kinds_supported() .contains(&EntryKindTag::HttpRoute) ); assert!( RubyEmitter .entry_kinds_supported() .contains(&EntryKindTag::CliSubcommand) ); } #[test] fn entry_kind_hint_names_attempted_and_phase() { let hint = RubyEmitter.entry_kind_hint(EntryKindTag::LibraryApi); assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 15")); } #[test] fn emit_produces_source() { let spec = make_spec(PayloadSlot::Param(0)); let harness = emit(&spec).unwrap(); assert!(harness.source.contains("nyx_payload")); assert!(harness.source.contains("require_relative")); assert!(harness.source.contains("login($nyx_payload)")); assert_eq!(harness.filename, "harness.rb"); assert_eq!(harness.command, vec!["ruby", "harness.rb"]); } #[test] fn emit_entry_subpath_is_entry_rb() { let spec = make_spec(PayloadSlot::Param(0)); let harness = emit(&spec).unwrap(); assert_eq!(harness.entry_subpath, Some("entry.rb".to_owned())); } #[test] fn emit_env_var_slot() { let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into())); let harness = emit(&spec).unwrap(); assert!(harness.source.contains("ENV[\"DB_HOST\"]")); assert!(harness.source.contains("login()")); } #[test] fn emit_stdin_is_unsupported() { let spec = make_spec(PayloadSlot::Stdin); let err = emit(&spec).unwrap_err(); assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); } // ── Phase 15: shape detection ──────────────────────────────────────────── fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec { let mut s = make_spec(PayloadSlot::Param(0)); s.entry_kind = kind; s.entry_name = name.to_owned(); s.entry_file = entry_file.to_owned(); s } #[test] fn shape_detect_sinatra_route() { let src = "require 'sinatra'\nget '/run' do\n params['p']\nend\n"; let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb"); assert_eq!(RubyShape::detect(&spec, src), RubyShape::SinatraRoute); } #[test] fn shape_detect_rails_action() { let src = "class UsersController < ApplicationController\n def index\n @user = params[:p]\n end\nend\n"; let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb"); assert_eq!(RubyShape::detect(&spec, src), RubyShape::RailsAction); } #[test] fn shape_detect_rack_middleware() { let src = "class MyMiddleware\n def call(env)\n [200, {}, ['ok']]\n end\nend\n"; let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb"); assert_eq!(RubyShape::detect(&spec, src), RubyShape::RackMiddleware); } #[test] fn shape_detect_controller_method() { let src = "class Login\n def authenticate(payload)\n payload\n end\nend\n"; let spec = make_spec_with(EntryKind::Function, "authenticate", "entry.rb"); assert_eq!(RubyShape::detect(&spec, src), RubyShape::ControllerMethod); } #[test] fn shape_detect_generic_fallback() { let src = "def login(p)\n p\nend\n"; let spec = make_spec_with(EntryKind::Function, "login", "entry.rb"); assert_eq!(RubyShape::detect(&spec, src), RubyShape::Generic); } #[test] fn sinatra_shape_uses_route_registry() { let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb"); let src = generate_source(&spec, RubyShape::SinatraRoute); assert!(src.contains("$nyx_sinatra_routes")); } #[test] fn rack_shape_builds_env_hash() { 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")); } #[test] fn rails_shape_invokes_action_on_instance() { let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb"); let src = generate_source(&spec, RubyShape::RailsAction); assert!(src.contains("instance.send")); } #[test] fn controller_shape_calls_method() { let spec = make_spec_with(EntryKind::Function, "authenticate", "entry.rb"); let src = generate_source(&spec, RubyShape::ControllerMethod); assert!(src.contains("instance.send")); } #[test] fn parse_first_class_name_picks_up_class_decl() { assert_eq!( parse_first_class_name("class Foo\nend\n"), Some("Foo".to_owned()) ); assert_eq!( parse_first_class_name("class Bar < Base\nend\n"), Some("Bar".to_owned()) ); assert_eq!(parse_first_class_name("def foo\nend\n"), None); } #[test] fn emit_splices_probe_shim_and_installs_crash_guard() { let spec = make_spec(PayloadSlot::Param(0)); let h = emit(&spec).unwrap(); assert!( h.source.contains("__nyx_probe shim (Phase 06 — Track C.1"), "probe_shim banner missing from generated harness.rb — splicing regressed", ); assert!( h.source .contains("def __nyx_install_crash_guard(sink_callee)"), "install_crash_guard definition missing from generated harness.rb", ); assert!( h.source.contains("__nyx_install_crash_guard('login')"), "install_crash_guard call site missing or wrong callee in harness body", ); let install_pos = h.source.find("__nyx_install_crash_guard('login')").unwrap(); let payload_pos = h.source.find("$nyx_payload = nyx_payload").unwrap(); // The invocation is `login($nyx_payload)` for the default Generic shape. let invoke_pos = h.source.find("login($nyx_payload)").unwrap(); assert!( payload_pos < install_pos && install_pos < invoke_pos, "install_crash_guard ordering wrong: payload_pos={payload_pos} install_pos={install_pos} invoke_pos={invoke_pos}", ); } #[test] fn probe_shim_publishes_stub_http_recorder() { let shim = probe_shim(); assert!( shim.contains("def __nyx_stub_http_record"), "Ruby probe shim must define __nyx_stub_http_record" ); assert!( shim.contains("ENV['NYX_HTTP_LOG']"), "Ruby HTTP recorder must read NYX_HTTP_LOG to find the side-channel log" ); assert!( shim.contains("# method: "), "Ruby HTTP recorder must emit a hash-prefixed method detail line" ); assert!( shim.contains("# url: "), "Ruby HTTP recorder must emit a hash-prefixed url detail line" ); } #[test] fn probe_shim_publishes_stub_sql_recorder() { let shim = probe_shim(); assert!( shim.contains("def __nyx_stub_sql_record"), "Ruby probe shim must define __nyx_stub_sql_record" ); assert!( shim.contains("ENV['NYX_SQL_LOG']"), "Ruby SQL recorder must read NYX_SQL_LOG to find the side-channel log" ); assert!( shim.contains("line.end_with?"), "Ruby SQL recorder must guarantee a trailing newline on the query line so SqlStub::drain_events frames each record" ); } #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b""), None); assert!( step.source.contains("__nyx_probe"), "Ruby chain step must splice the probe shim" ); assert!( step.source.contains("ENV[\"NYX_PREV_OUTPUT\"]"), "Ruby chain step must keep its NYX_PREV_OUTPUT forwarder" ); let shim_pos = step.source.find("__nyx_probe").unwrap(); let driver_pos = step.source.find("ENV[\"NYX_PREV_OUTPUT\"]").unwrap(); assert!( shim_pos < driver_pos, "probe shim must come before the driver so a sink rewrite has the shim's helpers in scope" ); } // ── Phase 08 / 09 tier-(a) Ruby emitter tests ──────────────────────────── fn make_header_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { let mut spec = make_spec(PayloadSlot::Param(0)); spec.expected_cap = Cap::HEADER_INJECTION; spec.entry_file = entry_file.to_owned(); spec.entry_name = entry_name.to_owned(); spec } fn make_redirect_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { let mut spec = make_spec(PayloadSlot::Param(0)); spec.expected_cap = Cap::OPEN_REDIRECT; spec.entry_file = entry_file.to_owned(); spec.entry_name = entry_name.to_owned(); spec } #[test] fn emit_header_injection_harness_routes_through_fixture_when_rack_required() { let dir = std::env::temp_dir().join("nyx_phase08_rb_test_drive_fixture"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("vuln.rb"); std::fs::write( &entry, "require 'rack'\n\ def run(value)\n r = Rack::Response.new\n r.set_header('Set-Cookie', value)\n r\nend\n", ) .unwrap(); let h = emit_header_injection_harness(&make_header_spec( entry.to_str().unwrap(), "run", )); assert!( h.source.contains("def _nyx_header_via_fixture(payload)"), "tier-(a) harness must define the fixture-routing helper: {}", h.source ); assert!( h.source.contains("Rack::Response.prepend(patcher)"), "tier-(a) harness must prepend the captor onto Rack::Response: {}", h.source ); assert!( h.source.contains("require_relative './vuln'"), "tier-(a) harness must require the fixture by its file stem: {}", h.source ); assert!( h.source.contains("Object.new.__send__(:'run', payload)"), "tier-(a) harness must invoke the named entry function via __send__: {}", h.source ); assert!( h.source.contains("captured = _nyx_header_via_fixture(payload)"), "harness main must call the fixture-routing helper first: {}", h.source ); assert!( h.source.contains("value = payload\n _nyx_header_probe(name, value)"), "fallback path must keep the synthetic probe: {}", h.source ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn emit_header_injection_harness_falls_back_when_rack_not_required() { let dir = std::env::temp_dir().join("nyx_phase08_rb_test_no_rack"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("vuln.rb"); std::fs::write(&entry, "def run(value)\n value\nend\n").unwrap(); let h = emit_header_injection_harness(&make_header_spec( entry.to_str().unwrap(), "run", )); assert!( !h.source.contains("Rack::Response.prepend"), "fallback path must not patch Rack::Response: {}", h.source ); assert!( !h.source.contains("def _nyx_header_via_fixture"), "fallback path must not define the fixture-routing helper: {}", h.source ); assert!( h.source.contains("value = payload\n _nyx_header_probe(name, value)"), "fallback path must keep the synthetic probe: {}", h.source ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn emit_header_injection_harness_derives_basename_from_entry_file() { let dir = std::env::temp_dir().join("nyx_phase08_rb_test_basename_derive"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("benign.rb"); std::fs::write( &entry, "require 'rack'\n\ def run(v)\n Rack::Response.new\nend\n", ) .unwrap(); let h = emit_header_injection_harness(&make_header_spec( entry.to_str().unwrap(), "run", )); assert!( h.source.contains("require_relative './benign'"), "basename must come from the entry-file stem: {}", h.source ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn emit_open_redirect_harness_routes_through_fixture_when_rack_required() { let dir = std::env::temp_dir().join("nyx_phase09_rb_test_drive_fixture"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("vuln.rb"); std::fs::write( &entry, "require 'rack'\n\ def run(value)\n r = Rack::Response.new\n r.redirect(value)\n r\nend\n", ) .unwrap(); let h = emit_open_redirect_harness(&make_redirect_spec( entry.to_str().unwrap(), "run", )); assert!( h.source.contains("def _nyx_redirect_via_fixture(payload)"), "tier-(a) harness must define the fixture-routing helper: {}", h.source ); assert!( h.source.contains("Rack::Response.prepend(patcher)"), "tier-(a) harness must prepend the captor onto Rack::Response: {}", h.source ); assert!( h.source.contains("require_relative './vuln'"), "tier-(a) harness must require the fixture by its file stem: {}", h.source ); assert!( h.source.contains("define_method(:redirect)"), "tier-(a) captor must intercept the redirect method: {}", h.source ); assert!( h.source.contains("captured = _nyx_redirect_via_fixture(payload)"), "harness main must call the fixture-routing helper first: {}", h.source ); assert!( h.source.contains("location = payload\n _nyx_redirect_probe(location, request_host)"), "fallback path must keep the synthetic probe: {}", h.source ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn emit_open_redirect_harness_falls_back_when_rack_not_required() { let dir = std::env::temp_dir().join("nyx_phase09_rb_test_no_rack"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("vuln.rb"); std::fs::write(&entry, "def run(value)\n value\nend\n").unwrap(); let h = emit_open_redirect_harness(&make_redirect_spec( entry.to_str().unwrap(), "run", )); assert!( !h.source.contains("Rack::Response.prepend"), "fallback path must not patch Rack::Response: {}", h.source ); assert!( !h.source.contains("def _nyx_redirect_via_fixture"), "fallback path must not define the fixture-routing helper: {}", h.source ); assert!( h.source.contains("location = payload\n _nyx_redirect_probe(location, request_host)"), "fallback path must keep the synthetic probe: {}", h.source ); let _ = std::fs::remove_dir_all(&dir); } }