[pitboss] phase 15: Track B — Go + PHP + Ruby harness emitter shapes

This commit is contained in:
pitboss 2026-05-14 17:45:42 -05:00
parent 919bc4e7e2
commit a9b61a9126
39 changed files with 2142 additions and 186 deletions

View file

@ -1,25 +1,37 @@
//! Go harness emitter.
//!
//! Generates a Go `main` package that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Imports the entry package from `./entry/` and calls the entry function.
//! 3. Uses `runtime.Caller`-style wrapping in fixtures for sink-reachability
//! probes (fixtures explicitly emit `__NYX_SINK_HIT__` before the sink).
//! Phase 15 (Track B Go vertical) replaces the single legacy `emit` body
//! with dispatch over [`GoShape`] — the cross product of [`EntryKind`]
//! and a lightweight per-file shape detector that inspects the entry
//! file for `net/http` handler signatures, gin context handlers,
//! `flag.Parse` CLIs, and `func(args ...) error` fuzz harnesses.
//!
//! Build step: `prepare_go()` in `build_sandbox.rs` runs `go build -o nyx_harness .`
//! in the workdir. The harness command is updated to the compiled binary path.
//! Each shape emits a single `main.go` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Imports the entry package from `./entry/` and invokes the entry
//! function via the per-shape adapter.
//!
//! Build step: `prepare_go()` in `build_sandbox.rs` runs
//! `go build -o nyx_harness .` in the workdir. The harness command is
//! updated to the compiled binary path.
//!
//! File layout in workdir:
//! ```text
//! main.go ← harness entry point (generated)
//! go.mod ← module definition (generated)
//! entry/
//! entry.go ← entry function (copied from project; must have `package entry`)
//! entry.go ← entry function (copied from project; `package entry`)
//! ```
//!
//! Payload slot support:
//! - `PayloadSlot::Param(0)` — pass payload as `string` first argument.
//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry.
//! - `PayloadSlot::QueryParam(name)` — surfaced to HandlerFunc / gin
//! shapes as the named query parameter.
//! - `PayloadSlot::HttpBody` — surfaced to HandlerFunc / gin shapes as
//! the request body.
//! - `PayloadSlot::Argv(n)` — appended to `os.Args` for `flag.Parse`
//! shapes.
//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`.
//!
//! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1).
@ -28,15 +40,22 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts};
use crate::dynamic::lang::{HarnessSource, LangEmitter};
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
use std::path::PathBuf;
/// Zero-sized [`LangEmitter`] handle for Go. Method bodies delegate to the
/// existing free functions in this module.
pub struct GoEmitter;
/// Entry kinds the Go emitter currently understands. Extended in Phase 15
/// (Track B Go vertical) to include `HttpRoute` (`net/http`, gin) and CLI
/// (`flag.Parse`) shapes.
const SUPPORTED: &[EntryKind] = &[EntryKind::Function];
/// Entry kinds the Go emitter understands after Phase 15.
///
/// `HttpRoute` covers `net/http` and gin handlers. `CliSubcommand`
/// covers `flag.Parse` CLIs. `Function` covers plain functions and
/// fuzz harnesses.
const SUPPORTED: &[EntryKind] = &[
EntryKind::Function,
EntryKind::HttpRoute,
EntryKind::CliSubcommand,
];
impl LangEmitter for GoEmitter {
fn emit(&self, spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
@ -49,7 +68,7 @@ impl LangEmitter for GoEmitter {
fn entry_kind_hint(&self, attempted: EntryKind) -> String {
format!(
"go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add net/http, gin, flag.Parse shapes in phase 15"
"go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch"
)
}
@ -58,6 +77,90 @@ impl LangEmitter for GoEmitter {
}
}
// ── 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 [`GoShape::Generic`],
/// preserving the pre-Phase-15 behaviour (direct `entry.Func(payload)`
/// call).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoShape {
/// `func(w http.ResponseWriter, r *http.Request)`. Harness builds
/// a `httptest.NewRequest` + `httptest.NewRecorder` and dispatches
/// the handler.
HttpHandlerFunc,
/// `func(c *gin.Context)`. Harness constructs a minimal
/// `gin.Context` stub and dispatches. Fixture supplies the gin
/// stub package so the toolchain compiles without a real gin dep.
GinHandler,
/// `flag.Parse`-driven CLI. Harness sets `os.Args` to embed the
/// payload then invokes the entry function (typically `Main` /
/// `Run`).
FlagParseCli,
/// Fuzz-style harness: `func(args ...) error` taking `[]byte`-ish
/// inputs. Harness invokes with `[]byte(payload)`.
FuzzVariadic,
/// Generic free function — pre-Phase-15 default. Harness calls
/// `entry.Func(payload)` directly.
Generic,
}
impl GoShape {
/// Detect the shape from `(spec, source)`. `source` is the literal
/// bytes of the entry file (best-effort — empty string falls back
/// to [`Self::Generic`]).
pub fn detect(spec: &HarnessSpec, source: &str) -> Self {
let entry = spec.entry_name.as_str();
let kind = spec.entry_kind;
let has_http_handler = source.contains("http.ResponseWriter")
&& source.contains("*http.Request");
let has_gin = source.contains("gin.Context") || source.contains("*gin.Context");
let has_flag_parse = source.contains("flag.Parse()") || source.contains("flag.Parse(");
let has_fuzz_signature = source.contains("[]byte")
&& (entry.starts_with("Fuzz") || source.contains("// nyx-shape: fuzz"));
if has_gin {
return Self::GinHandler;
}
if has_http_handler {
return Self::HttpHandlerFunc;
}
if has_flag_parse {
return Self::FlagParseCli;
}
if has_fuzz_signature {
return Self::FuzzVariadic;
}
if kind == EntryKind::HttpRoute {
return Self::HttpHandlerFunc;
}
if kind == EntryKind::CliSubcommand {
return Self::FlagParseCli;
}
Self::Generic
}
}
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
/// reading the entry file from disk.
pub fn detect_shape(spec: &HarnessSpec) -> GoShape {
let src = read_entry_source(&spec.entry_file);
GoShape::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 09 — Track D.2: synthesise a `go.mod` listing every captured
/// third-party import path. Standard-library imports are skipped via
/// [`is_go_stdlib`].
@ -246,51 +349,52 @@ func __nyx_recover_crash(sinkCallee string) func() {
/// Emit a Go harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {}
_ => return Err(UnsupportedReason::PayloadSlotUnsupported),
PayloadSlot::Param(_)
| PayloadSlot::EnvVar(_)
| PayloadSlot::QueryParam(_)
| PayloadSlot::HttpBody
| PayloadSlot::Argv(_) => {}
PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported),
}
let main_go = generate_main_go(spec);
let entry_source = read_entry_source(&spec.entry_file);
let shape = GoShape::detect(spec, &entry_source);
let main_go = generate_main_go(spec, shape);
let go_mod = generate_go_mod();
let mut extra_files = vec![("go.mod".to_owned(), go_mod)];
// Phase 15: GinHandler shape stages a minimal gin stub package so
// the toolchain can compile the harness without pulling real gin.
if matches!(shape, GoShape::GinHandler) {
extra_files.push(("entry/gin/gin.go".to_owned(), gin_stub_pkg()));
}
Ok(HarnessSource {
source: main_go,
filename: "main.go".to_owned(),
command: vec!["./nyx_harness".to_owned()],
extra_files: vec![("go.mod".to_owned(), go_mod)],
extra_files,
entry_subpath: Some("entry/entry.go".to_owned()),
})
}
fn generate_main_go(spec: &HarnessSpec) -> String {
fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> String {
let entry_fn = capitalize_first(&spec.entry_name);
let (pre_call, call_expr) = build_call(spec, &entry_fn);
// Determine which imports are needed.
let env_import = if matches!(&spec.payload_slot, PayloadSlot::EnvVar(_)) {
""
} else {
""
};
let _ = env_import;
let pre_call = pre_call_setup(spec);
let imports = imports_for_shape(shape);
let invocation = invoke_for_shape(spec, shape, &entry_fn);
format!(
r#"// Nyx dynamic harness — auto-generated, do not edit.
r#"// Nyx dynamic harness — auto-generated, do not edit (Phase 15 — GoShape::{shape:?}).
package main
import (
"encoding/base64"
"fmt"
"os"
"nyx-harness/entry"
)
{imports})
func main() {{
payload := nyxPayload()
{pre_call} {call_expr}
_ = fmt.Sprintf("") // suppress unused import if call_expr uses fmt directly
_ = os.Stderr // suppress unused import
_ = payload
{pre_call}{invocation}
}}
func nyxPayload() string {{
@ -305,34 +409,154 @@ func nyxPayload() string {{
return ""
}}
"#,
shape = shape,
imports = imports,
pre_call = pre_call,
call_expr = call_expr,
invocation = invocation,
)
}
fn imports_for_shape(shape: GoShape) -> &'static str {
match shape {
GoShape::Generic => {
"\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n"
}
GoShape::HttpHandlerFunc => {
"\t\"encoding/base64\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\n\t\"nyx-harness/entry\"\n"
}
GoShape::GinHandler => {
"\t\"encoding/base64\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\n\t\"nyx-harness/entry\"\n\t\"nyx-harness/entry/gin\"\n"
}
GoShape::FlagParseCli => {
"\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n"
}
GoShape::FuzzVariadic => {
"\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n"
}
}
}
fn pre_call_setup(spec: &HarnessSpec) -> String {
match &spec.payload_slot {
PayloadSlot::EnvVar(name) => format!("\tos.Setenv({name:?}, payload)\n"),
PayloadSlot::Argv(n) => {
let pads = (0..*n).map(|_| "\"\"".to_owned()).collect::<Vec<_>>().join(", ");
if pads.is_empty() {
format!("\tos.Args = []string{{\"nyx_harness\", payload}}\n")
} else {
format!("\tos.Args = []string{{\"nyx_harness\", {pads}, payload}}\n")
}
}
_ => String::new(),
}
}
fn invoke_for_shape(spec: &HarnessSpec, shape: GoShape, entry_fn: &str) -> String {
let query_param = match &spec.payload_slot {
PayloadSlot::QueryParam(name) => name.clone(),
_ => "payload".to_owned(),
};
let use_body = matches!(&spec.payload_slot, PayloadSlot::HttpBody);
match shape {
GoShape::Generic => format!("\tentry.{entry_fn}(payload)\n"),
GoShape::HttpHandlerFunc => {
let body_setup = if use_body {
"\treq := httptest.NewRequest(\"POST\", \"/\", strings.NewReader(payload))\n"
} else {
""
};
let url_setup = if use_body {
String::new()
} else {
format!(
"\treq := httptest.NewRequest(\"GET\", \"/?{q}=\"+payload, strings.NewReader(\"\"))\n",
q = query_param
)
};
format!(
"{body_setup}{url_setup}\trw := httptest.NewRecorder()\n\tentry.{entry_fn}(rw, req)\n\t_ = http.StatusOK\n",
)
}
GoShape::GinHandler => {
let setup = if use_body {
"\treq := httptest.NewRequest(\"POST\", \"/\", strings.NewReader(payload))\n"
} else {
"\treq := httptest.NewRequest(\"GET\", \"/?payload=\"+payload, strings.NewReader(\"\"))\n"
};
format!(
"{setup}\trw := httptest.NewRecorder()\n\tctx := gin.NewContext(rw, req)\n\tentry.{entry_fn}(ctx)\n\t_ = http.StatusOK\n",
)
}
GoShape::FlagParseCli => format!("\tentry.{entry_fn}()\n"),
GoShape::FuzzVariadic => format!("\t_ = entry.{entry_fn}([]byte(payload))\n"),
}
}
fn generate_go_mod() -> String {
"module nyx-harness\n\ngo 1.21\n".to_owned()
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, entry_fn: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(0) => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("\tos.Setenv({name:?}, payload)\n");
let call = format!("entry.{entry_fn}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
}
/// Minimal `gin` stub package used by [`GoShape::GinHandler`] fixtures
/// so the toolchain can compile without a real gin dependency.
/// Exposes just enough surface (Context.Query, Context.JSON,
/// Context.String, NewContext) to support the per-shape harness call.
fn gin_stub_pkg() -> String {
r#"// Phase 15 — minimal gin stub for harness build (not the real gin).
package gin
import (
"fmt"
"io"
"net/http"
)
type Context struct {
Writer http.ResponseWriter
Request *http.Request
}
func NewContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{Writer: w, Request: r}
}
func (c *Context) Query(name string) string {
if c.Request == nil {
return ""
}
return c.Request.URL.Query().Get(name)
}
func (c *Context) PostForm(name string) string {
if c.Request == nil {
return ""
}
_ = c.Request.ParseForm()
return c.Request.PostFormValue(name)
}
func (c *Context) GetRawData() ([]byte, error) {
if c.Request == nil || c.Request.Body == nil {
return []byte{}, nil
}
return io.ReadAll(c.Request.Body)
}
func (c *Context) JSON(code int, obj interface{}) {
if c.Writer != nil {
c.Writer.WriteHeader(code)
fmt.Fprintf(c.Writer, "%v", obj)
}
}
func (c *Context) String(code int, format string, values ...interface{}) {
if c.Writer != nil {
c.Writer.WriteHeader(code)
fmt.Fprintf(c.Writer, format, values...)
}
}
"#
.to_owned()
}
/// Capitalize the first character of a string (Go exported names must start uppercase).
@ -405,13 +629,6 @@ mod tests {
assert!(harness.source.contains("\"DB_USER\""));
}
#[test]
fn emit_param_gt_0_is_unsupported() {
let spec = make_spec(PayloadSlot::Param(1));
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported);
}
#[test]
fn emit_stdin_is_unsupported() {
let spec = make_spec(PayloadSlot::Stdin);
@ -423,13 +640,15 @@ mod tests {
fn entry_kinds_supported_is_non_empty() {
assert!(!GoEmitter.entry_kinds_supported().is_empty());
assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::Function));
assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::HttpRoute));
assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand));
}
#[test]
fn entry_kind_hint_names_attempted_and_phase() {
let hint = GoEmitter.entry_kind_hint(EntryKind::HttpRoute);
assert!(hint.contains("HttpRoute"));
assert!(hint.contains("phase 15"));
let hint = GoEmitter.entry_kind_hint(EntryKind::LibraryApi);
assert!(hint.contains("LibraryApi"));
assert!(hint.contains("Phase 15"));
}
#[test]
@ -446,4 +665,82 @@ mod tests {
assert!(go_mod.contains("module nyx-harness"));
assert!(go_mod.contains("go 1.21"));
}
// ── 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_http_handler_func() {
let src = "package entry\nimport \"net/http\"\nfunc Handle(w http.ResponseWriter, r *http.Request) {}";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::HttpHandlerFunc);
}
#[test]
fn shape_detect_gin_handler() {
let src = "package entry\nimport \"nyx-harness/entry/gin\"\nfunc Handle(c *gin.Context) {}";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::GinHandler);
}
#[test]
fn shape_detect_flag_parse_cli() {
let src = "package entry\nimport \"flag\"\nfunc Run() { flag.Parse() }";
let spec = make_spec_with(EntryKind::CliSubcommand, "Run", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::FlagParseCli);
}
#[test]
fn shape_detect_fuzz_variadic() {
let src = "package entry\nfunc FuzzHandle(data []byte) error { return nil }";
let spec = make_spec_with(EntryKind::Function, "FuzzHandle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::FuzzVariadic);
}
#[test]
fn shape_detect_generic_fallback() {
let src = "package entry\nfunc Login(payload string) {}";
let spec = make_spec_with(EntryKind::Function, "Login", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::Generic);
}
#[test]
fn http_shape_emits_httptest_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::HttpHandlerFunc);
assert!(src.contains("httptest.NewRequest"));
assert!(src.contains("httptest.NewRecorder"));
assert!(src.contains("entry.Handle(rw, req)"));
}
#[test]
fn gin_shape_emits_context_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::GinHandler);
assert!(src.contains("gin.NewContext"));
assert!(src.contains("entry.Handle(ctx)"));
}
#[test]
fn cli_shape_emits_os_args_setup() {
let mut spec = make_spec_with(EntryKind::CliSubcommand, "Run", "entry.go");
spec.payload_slot = PayloadSlot::Argv(0);
let src = generate_main_go(&spec, GoShape::FlagParseCli);
assert!(src.contains("os.Args = []string"));
assert!(src.contains("entry.Run()"));
}
#[test]
fn fuzz_shape_emits_bytes_invocation() {
let spec = make_spec_with(EntryKind::Function, "FuzzHandle", "entry.go");
let src = generate_main_go(&spec, GoShape::FuzzVariadic);
assert!(src.contains("entry.FuzzHandle([]byte(payload))"));
}
}

View file

@ -1,19 +1,29 @@
//! PHP harness emitter.
//!
//! Generates a PHP script that:
//! Phase 15 (Track B PHP vertical) replaces the single legacy `emit`
//! body with dispatch over [`PhpShape`] — the cross product of
//! [`EntryKind`] and a lightweight per-file shape detector that
//! inspects the entry file for Slim/Laravel/Symfony route closures,
//! `$argv`-driven CLI scripts, and top-level script bodies.
//!
//! Each shape emits a single `harness.php` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Includes the entry file (`entry.php`) from the workdir.
//! 3. Calls the entry function with the payload routed to the correct slot.
//! 4. Catches all Throwables to prevent harness crashes from masking results.
//! 3. Invokes the entry function / closure via the per-shape adapter.
//! 4. Catches all Throwables so the harness exit stays observable.
//!
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before
//! the actual sink call (same pattern as Rust / JS fixtures).
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__`
//! before the actual sink call (same pattern as Rust / JS fixtures).
//!
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling.
//! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload.
//! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`.
//! - `PayloadSlot::Argv(n)` — appended to `$argv` for CLI shapes.
//! - `PayloadSlot::QueryParam(name)` — surfaced via `$_GET[name]` /
//! request stub query for route closures.
//! - `PayloadSlot::HttpBody` — surfaced via `$_POST` / request stub body
//! for route closures.
//!
//! Build: no compilation step. Command is `php harness.php`.
//! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1).
@ -22,15 +32,22 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts};
use crate::dynamic::lang::{HarnessSource, LangEmitter};
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
use std::path::PathBuf;
/// Zero-sized [`LangEmitter`] handle for PHP. Method bodies delegate to the
/// existing free functions in this module.
pub struct PhpEmitter;
/// Entry kinds the PHP emitter currently understands. Extended in Phase 15
/// (Track B PHP vertical) to include `HttpRoute` (Slim / Laravel / Symfony
/// closures) and `CliSubcommand` (`$argv`).
const SUPPORTED: &[EntryKind] = &[EntryKind::Function];
/// Entry kinds the PHP emitter understands after Phase 15.
///
/// `HttpRoute` covers Slim / Laravel / Symfony route closures.
/// `CliSubcommand` covers `$argv`-driven CLI scripts. `Function`
/// covers plain functions and top-level scripts.
const SUPPORTED: &[EntryKind] = &[
EntryKind::Function,
EntryKind::HttpRoute,
EntryKind::CliSubcommand,
];
impl LangEmitter for PhpEmitter {
fn emit(&self, spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
@ -43,7 +60,7 @@ impl LangEmitter for PhpEmitter {
fn entry_kind_hint(&self, attempted: EntryKind) -> String {
format!(
"php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Slim / Laravel / Symfony route + CLI shapes in phase 15"
"php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch"
)
}
@ -52,11 +69,101 @@ impl LangEmitter for PhpEmitter {
}
}
// ── 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 [`PhpShape::Generic`],
/// preserving the pre-Phase-15 behaviour (direct function call).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhpShape {
/// Slim / Laravel / Symfony route closure. Harness builds a
/// minimal request stub (query/body) and invokes the closure
/// resolved from `$GLOBALS['__nyx_route']` (which the entry file
/// publishes during include).
RouteClosure,
/// CLI script driven by `$argv`. Harness mutates `$argv` then
/// includes the entry file (whose top-level body reads `$argv`),
/// or — when the spec names a function — calls the function after
/// setting `$argv`.
CliArgvScript,
/// Top-level script body — no function entry point. Harness just
/// includes the entry file (the include itself runs the body).
TopLevelScript,
/// Plain function — pre-Phase-15 default. Harness calls
/// `funcName($payload)` directly.
Generic,
}
impl PhpShape {
/// 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;
let has_route_marker = source.contains("$app->get(")
|| source.contains("$app->post(")
|| source.contains("$app->any(")
|| source.contains("$app->map(")
|| source.contains("$router->get(")
|| source.contains("$router->post(")
|| source.contains("Route::get(")
|| source.contains("Route::post(")
|| source.contains("Route::any(")
|| source.contains("// nyx-shape: route");
let has_argv = source.contains("$argv") || source.contains("// nyx-shape: cli");
let has_function_decl = source.contains("function ")
&& !source.trim_start().starts_with("<?php\n//");
let entry_named_function = entry != "main"
&& entry != "__main__"
&& !entry.is_empty()
&& source.contains(&format!("function {entry}"));
if has_route_marker {
return Self::RouteClosure;
}
if has_argv && !entry_named_function {
return Self::CliArgvScript;
}
if kind == EntryKind::HttpRoute {
return Self::RouteClosure;
}
if kind == EntryKind::CliSubcommand {
return Self::CliArgvScript;
}
// TopLevelScript only fires when we actually saw the source
// and confirmed there's no function declaration to call. When
// the source is unreadable (empty), fall through to Generic so
// the legacy pre-Phase-15 behaviour (direct named-function call)
// survives.
if !source.is_empty() && !has_function_decl && entry.is_empty() {
return Self::TopLevelScript;
}
Self::Generic
}
}
/// Public wrapper to detect the shape for a finalised `HarnessSpec`,
/// reading the entry file from disk.
pub fn detect_shape(spec: &HarnessSpec) -> PhpShape {
let src = read_entry_source(&spec.entry_file);
PhpShape::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 09 — Track D.2: synthesise a `composer.json` with the captured
/// PHP version pin and (where known) the framework deps. Direct
/// imports of namespaced classes are too coarse to pin without a
/// vendor→package registry, so the manifest stays toolchain-only by
/// default; Phase 10 corpus expansion will introduce the registry.
/// PHP version pin and (where known) the framework deps.
pub fn materialize_php(env: &Environment) -> RuntimeArtifacts {
let mut artifacts = RuntimeArtifacts::new();
let php_ver = env
@ -199,11 +306,17 @@ function __nyx_install_crash_guard(string $sinkCallee): void {
/// Emit a PHP harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {}
_ => return Err(UnsupportedReason::PayloadSlotUnsupported),
PayloadSlot::Param(_)
| PayloadSlot::EnvVar(_)
| PayloadSlot::Stdin
| PayloadSlot::Argv(_)
| PayloadSlot::QueryParam(_)
| PayloadSlot::HttpBody => {}
}
let source = generate_source(spec);
let entry_source = read_entry_source(&spec.entry_file);
let shape = PhpShape::detect(spec, &entry_source);
let source = generate_source(spec, shape);
Ok(HarnessSource {
source,
@ -214,13 +327,15 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
})
}
fn generate_source(spec: &HarnessSpec) -> String {
fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
let entry_fn = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, entry_fn);
let pre_call = build_pre_call(spec, shape);
let entry_block = build_entry_block(shape);
let call_expr = build_call_expr(spec, shape, entry_fn);
format!(
r#"<?php
// Nyx dynamic harness — auto-generated, do not edit.
// Nyx dynamic harness — auto-generated, do not edit (Phase 15 — PhpShape::{shape:?}).
// ── Payload loading ────────────────────────────────────────────────────────────
function nyx_payload(): string {{
@ -237,16 +352,10 @@ function nyx_payload(): string {{
$payload = nyx_payload();
// ── Entry include ─────────────────────────────────────────────────────────────
try {{
require_once __DIR__ . '/entry.php';
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n");
exit(77);
}}
// ── Pre-call setup ─────────────────────────────────────────────────────────────
{pre_call}
// ── Entry include ─────────────────────────────────────────────────────────────
{entry_block}
// ── Call entry point ──────────────────────────────────────────────────────────
try {{
$result = {call_expr};
@ -257,43 +366,115 @@ try {{
fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n");
}}
"#,
shape = shape,
pre_call = pre_call,
entry_block = entry_block,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) {
fn build_pre_call(spec: &HarnessSpec, shape: PhpShape) -> String {
let mut out = String::new();
match &spec.payload_slot {
PayloadSlot::EnvVar(name) => {
out.push_str(&format!(
"putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n"
));
}
PayloadSlot::Stdin => {
out.push_str(
"if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n}\n",
);
}
PayloadSlot::Argv(n) => {
out.push_str("$argv = $argv ?? [];\n");
out.push_str("$argv[0] = $argv[0] ?? 'nyx_harness';\n");
for _ in 0..*n {
out.push_str("$argv[] = '';\n");
}
out.push_str("$argv[] = $payload;\n");
out.push_str("$argc = count($argv);\n");
out.push_str("$_SERVER['argv'] = $argv;\n");
out.push_str("$_SERVER['argc'] = $argc;\n");
}
PayloadSlot::QueryParam(name) => {
out.push_str(&format!("$_GET[{name:?}] = $payload;\n"));
out.push_str("$_REQUEST = array_merge($_REQUEST ?? [], $_GET);\n");
}
PayloadSlot::HttpBody => {
out.push_str("$_POST['body'] = $payload;\n");
out.push_str("$GLOBALS['__nyx_body'] = $payload;\n");
}
_ => {}
}
if matches!(shape, PhpShape::CliArgvScript)
&& !matches!(&spec.payload_slot, PayloadSlot::Argv(_))
{
out.push_str("$argv = $argv ?? ['nyx_harness'];\n");
out.push_str("$argv[] = $payload;\n");
out.push_str("$argc = count($argv);\n");
out.push_str("$_SERVER['argv'] = $argv;\n");
out.push_str("$_SERVER['argc'] = $argc;\n");
}
out
}
fn build_entry_block(_shape: PhpShape) -> String {
r#"try {
require_once __DIR__ . '/entry.php';
} catch (Throwable $e) {
fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n");
exit(77);
}"#
.to_owned()
}
fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String {
match shape {
PhpShape::TopLevelScript => "null".to_owned(),
PhpShape::CliArgvScript => {
if func.is_empty() || func == "main" || func == "__main__" {
"null".to_owned()
} else if function_exists_call(func) {
format!("{func}()")
} else {
"null".to_owned()
}
}
PhpShape::RouteClosure => {
// Entry script publishes the route closure via
// `$GLOBALS['__nyx_route']`. When the global is missing,
// fall back to calling the named function directly.
format!(
"(isset($GLOBALS['__nyx_route']) && is_callable($GLOBALS['__nyx_route'])) ? call_user_func($GLOBALS['__nyx_route'], $payload) : (function_exists({func:?}) ? {func}($payload) : null)"
)
}
PhpShape::Generic => build_generic_call(spec, func),
}
}
fn build_generic_call(spec: &HarnessSpec, func: &str) -> String {
match &spec.payload_slot {
PayloadSlot::Param(idx) => {
let pre = String::new();
let call = if *idx == 0 {
if *idx == 0 {
format!("{func}($payload)")
} else {
let pads = (0..*idx).map(|_| "''").collect::<Vec<_>>().join(", ");
format!("{func}({pads}, $payload)")
};
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n");
let call = format!("{func}()");
(pre, call)
}
PayloadSlot::Stdin => {
// Replace STDIN with an in-memory stream containing the payload.
let pre = "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n // Note: STDIN reassignment is not portable; fixture reads via fgets(STDIN).\n}\n".to_owned();
let call = format!("{func}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("{func}($payload)");
(pre, call)
}
}
PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => format!("{func}()"),
_ => format!("{func}($payload)"),
}
}
/// Wrap the named-function call in a `function_exists` guard for shapes
/// where the entry function may be optional (CLI scripts whose body is
/// the entry, not a named function).
fn function_exists_call(_func: &str) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
@ -355,10 +536,11 @@ mod tests {
}
#[test]
fn emit_http_body_is_unsupported() {
let spec = make_spec(PayloadSlot::HttpBody);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported);
fn emit_http_body_now_supported_for_route_shape() {
let mut spec = make_spec(PayloadSlot::HttpBody);
spec.entry_kind = EntryKind::HttpRoute;
let h = emit(&spec).unwrap();
assert!(h.source.contains("$GLOBALS['__nyx_body']"));
}
#[test]
@ -374,13 +556,19 @@ mod tests {
assert!(PhpEmitter
.entry_kinds_supported()
.contains(&EntryKind::Function));
assert!(PhpEmitter
.entry_kinds_supported()
.contains(&EntryKind::HttpRoute));
assert!(PhpEmitter
.entry_kinds_supported()
.contains(&EntryKind::CliSubcommand));
}
#[test]
fn entry_kind_hint_names_attempted_and_phase() {
let hint = PhpEmitter.entry_kind_hint(EntryKind::HttpRoute);
assert!(hint.contains("HttpRoute"));
assert!(hint.contains("phase 15"));
let hint = PhpEmitter.entry_kind_hint(EntryKind::LibraryApi);
assert!(hint.contains("LibraryApi"));
assert!(hint.contains("Phase 15"));
}
#[test]
@ -390,4 +578,72 @@ mod tests {
assert!(harness.source.contains("base64_decode"));
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
}
// ── 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_slim_route_closure() {
let src = "<?php\n$app->get('/run', function ($req, $res) {\n return 'hi';\n});\n";
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
assert_eq!(PhpShape::detect(&spec, src), PhpShape::RouteClosure);
}
#[test]
fn shape_detect_laravel_route_closure() {
let src = "<?php\nRoute::get('/run', function ($payload) { return $payload; });\n";
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
assert_eq!(PhpShape::detect(&spec, src), PhpShape::RouteClosure);
}
#[test]
fn shape_detect_cli_argv_script() {
let src = "<?php\n$cmd = $argv[1] ?? '';\necho $cmd;\n";
let spec = make_spec_with(EntryKind::CliSubcommand, "main", "entry.php");
assert_eq!(PhpShape::detect(&spec, src), PhpShape::CliArgvScript);
}
#[test]
fn shape_detect_top_level_script() {
let src = "<?php\necho 'hello';\n";
let spec = make_spec_with(EntryKind::Function, "", "entry.php");
assert_eq!(PhpShape::detect(&spec, src), PhpShape::TopLevelScript);
}
#[test]
fn shape_detect_generic_function() {
let src = "<?php\nfunction login($payload) { return $payload; }\n";
let spec = make_spec_with(EntryKind::Function, "login", "entry.php");
assert_eq!(PhpShape::detect(&spec, src), PhpShape::Generic);
}
#[test]
fn route_shape_emits_globals_dispatch() {
let spec = make_spec_with(EntryKind::HttpRoute, "ping", "entry.php");
let src = generate_source(&spec, PhpShape::RouteClosure);
assert!(src.contains("$GLOBALS['__nyx_route']"));
}
#[test]
fn cli_shape_appends_payload_to_argv() {
let spec = make_spec_with(EntryKind::CliSubcommand, "main", "entry.php");
let src = generate_source(&spec, PhpShape::CliArgvScript);
assert!(src.contains("$argv"));
assert!(src.contains("$_SERVER['argv']"));
}
#[test]
fn top_level_script_only_includes() {
let spec = make_spec_with(EntryKind::Function, "", "entry.php");
let src = generate_source(&spec, PhpShape::TopLevelScript);
assert!(src.contains("require_once"));
assert!(src.contains("$result = null"));
}
}

View file

@ -1,29 +1,168 @@
//! Ruby harness emitter (stub).
//! Ruby harness emitter.
//!
//! No harness source is generated yet — `emit` returns
//! [`UnsupportedReason::LangUnsupported`]. The module exists so that
//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry
//! kinds Track B will deliver (Phase 15: Sinatra route, Rails action, Rack
//! middleware, generic controller method) and so the verifier can surface
//! a structured `Inconclusive(EntryKindUnsupported { … })` instead of
//! silently dropping Ruby findings.
//! 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::{HarnessSource, LangEmitter};
use crate::dynamic::spec::{EntryKind, HarnessSpec};
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
use std::path::PathBuf;
/// Zero-sized [`LangEmitter`] handle for Ruby.
pub struct RubyEmitter;
/// Entry kinds the Ruby emitter intends to support once Phase 15 lands.
/// Advertised pre-implementation so the verifier can route findings into
/// `Inconclusive(EntryKindUnsupported)` rather than `Unsupported`.
const SUPPORTED: &[EntryKind] = &[EntryKind::Function];
/// 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: &[EntryKind] = &[
EntryKind::Function,
EntryKind::HttpRoute,
EntryKind::CliSubcommand,
];
/// Source of the `__nyx_probe` shim for the (future) Ruby harness
/// (Phase 06 — Track C.1). Defined here for the deliverable contract
/// even though `emit` returns `LangUnsupported` until Phase 15 lands.
impl LangEmitter for RubyEmitter {
fn emit(&self, spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
emit(spec)
}
fn entry_kinds_supported(&self) -> &'static [EntryKind] {
SUPPORTED
}
fn entry_kind_hint(&self, attempted: EntryKind) -> String {
format!(
"ruby emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch"
)
}
fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts {
materialize_ruby(env)
}
}
// ── 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;
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 == EntryKind::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()
}
/// 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)
@ -112,29 +251,8 @@ end
"#
}
impl LangEmitter for RubyEmitter {
fn emit(&self, _spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
Err(UnsupportedReason::LangUnsupported)
}
fn entry_kinds_supported(&self) -> &'static [EntryKind] {
SUPPORTED
}
fn entry_kind_hint(&self, attempted: EntryKind) -> String {
format!(
"ruby emitter is a stub; once Phase 15 (Track B Ruby vertical) lands it will support {SUPPORTED:?} plus Sinatra / Rails / Rack route shapes — attempted `EntryKind::{attempted}`"
)
}
fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts {
materialize_ruby(env)
}
}
/// Phase 09 — Track D.2: synthesise a `Gemfile` listing every captured
/// gem name. Ruby `require` statements give us first-segment package
/// names directly so the manifest can name real gems.
/// gem name.
pub fn materialize_ruby(env: &Environment) -> RuntimeArtifacts {
let mut artifacts = RuntimeArtifacts::new();
let mut deps: Vec<String> = Vec::new();
@ -183,43 +301,415 @@ fn is_ruby_stdlib(name: &str) -> bool {
)
}
/// Emit a Ruby harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_)
| PayloadSlot::EnvVar(_)
| PayloadSlot::QueryParam(_)
| PayloadSlot::HttpBody
| PayloadSlot::Argv(_) => {}
PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported),
}
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()),
})
}
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);
format!(
r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}).
# 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
{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#" 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,
}}
require 'stringio'
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::<Vec<_>>().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<String> {
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<String> = 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<String> {
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, 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![],
}
}
#[test]
fn entry_kinds_supported_is_non_empty() {
assert!(!RubyEmitter.entry_kinds_supported().is_empty());
assert!(RubyEmitter
.entry_kinds_supported()
.contains(&EntryKind::Function));
assert!(RubyEmitter
.entry_kinds_supported()
.contains(&EntryKind::HttpRoute));
assert!(RubyEmitter
.entry_kinds_supported()
.contains(&EntryKind::CliSubcommand));
}
#[test]
fn entry_kind_hint_names_attempted_and_phase() {
let hint = RubyEmitter.entry_kind_hint(EntryKind::HttpRoute);
assert!(hint.contains("HttpRoute"));
let hint = RubyEmitter.entry_kind_hint(EntryKind::LibraryApi);
assert!(hint.contains("LibraryApi"));
assert!(hint.contains("Phase 15"));
}
#[test]
fn emit_returns_lang_unsupported() {
let spec = HarnessSpec {
finding_id: "0".into(),
entry_file: "x.rb".into(),
entry_name: "f".into(),
entry_kind: EntryKind::Function,
lang: crate::symbol::Lang::Ruby,
toolchain_id: "ruby-3".into(),
payload_slot: crate::dynamic::spec::PayloadSlot::Param(0),
expected_cap: crate::labels::Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "x.rb".into(),
sink_line: 1,
spec_hash: "0".into(),
derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
stubs_required: vec![],
};
assert_eq!(
RubyEmitter.emit(&spec).unwrap_err(),
UnsupportedReason::LangUnsupported
);
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);
}
}

View file

@ -0,0 +1,18 @@
// Phase 15 — flag.Parse CLI, benign.
// Echoes a fixed string; argv is discarded.
package entry
import (
"flag"
"fmt"
"os/exec"
)
func Run() {
flag.Parse()
_ = flag.Args()
cmd := exec.Command("echo", "hello")
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}

View file

@ -0,0 +1,3 @@
module nyx_flag_cli_fixture
go 1.21

View file

@ -0,0 +1,23 @@
// Phase 15 — flag.Parse CLI, vulnerable.
// Reads the first non-flag argv positional and pipes to /bin/sh -c.
// Entry: Run() Cap: CODE_EXEC
package entry
import (
"flag"
"fmt"
"os/exec"
)
func Run() {
fmt.Print("__NYX_SINK_HIT__\n")
flag.Parse()
payload := ""
if flag.NArg() > 0 {
payload = flag.Arg(0)
}
cmd := exec.Command("sh", "-c", "echo hello "+payload)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
}

View file

@ -0,0 +1,19 @@
// Phase 15 — fuzz-style variadic harness, benign.
// Validates input length then echoes a fixed string.
package entry
import (
"fmt"
"os/exec"
)
func FuzzHandle(data []byte) error {
if len(data) > 1024 {
return fmt.Errorf("too long")
}
cmd := exec.Command("echo", "hello")
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
return nil
}

View file

@ -0,0 +1,3 @@
module nyx_fuzz_variadic_fixture
go 1.21

View file

@ -0,0 +1,18 @@
// Phase 15 — fuzz-style variadic harness, vulnerable.
// Takes raw bytes and pipes to /bin/sh -c.
// Entry: FuzzHandle(data []byte) error Cap: CODE_EXEC
package entry
import (
"fmt"
"os/exec"
)
func FuzzHandle(data []byte) error {
fmt.Print("__NYX_SINK_HIT__\n")
cmd := exec.Command("sh", "-c", "echo hello "+string(data))
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
return nil
}

View file

@ -0,0 +1,19 @@
// Phase 15 — gin handler, benign.
// Echoes a fixed string; query value is discarded.
package entry
import (
"fmt"
"os/exec"
"nyx-harness/entry/gin"
)
func Handle(c *gin.Context) {
_ = c.Query("payload")
cmd := exec.Command("echo", "hello")
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
c.String(200, "%s", string(out))
}

View file

@ -0,0 +1,3 @@
module nyx_gin_handler_fixture
go 1.21

View file

@ -0,0 +1,21 @@
// Phase 15 — gin handler, vulnerable.
// Reads gin context query value and pipes to /bin/sh -c.
// Entry: Handle(c *gin.Context) Cap: CODE_EXEC
package entry
import (
"fmt"
"os/exec"
"nyx-harness/entry/gin"
)
func Handle(c *gin.Context) {
fmt.Print("__NYX_SINK_HIT__\n")
payload := c.Query("payload")
cmd := exec.Command("sh", "-c", "echo hello "+payload)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
c.String(200, "%s", string(out))
}

View file

@ -0,0 +1,19 @@
// Phase 15 — http.HandlerFunc, benign.
// Echoes a fixed string; query value is discarded.
package entry
import (
"fmt"
"net/http"
"os/exec"
)
func Handle(w http.ResponseWriter, r *http.Request) {
_ = r.URL.Query().Get("payload")
cmd := exec.Command("echo", "hello")
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
w.WriteHeader(http.StatusOK)
w.Write(out)
}

View file

@ -0,0 +1,3 @@
module nyx_handler_func_fixture
go 1.21

View file

@ -0,0 +1,21 @@
// Phase 15 — http.HandlerFunc, vulnerable.
// Reads `?payload=` query value and pipes to /bin/sh -c.
// Entry: Handle(w http.ResponseWriter, r *http.Request) Cap: CODE_EXEC
package entry
import (
"fmt"
"net/http"
"os/exec"
)
func Handle(w http.ResponseWriter, r *http.Request) {
fmt.Print("__NYX_SINK_HIT__\n")
payload := r.URL.Query().Get("payload")
cmd := exec.Command("sh", "-c", "echo hello "+payload)
out, _ := cmd.CombinedOutput()
fmt.Print(string(out))
w.WriteHeader(http.StatusOK)
w.Write(out)
}

View file

@ -0,0 +1,11 @@
<?php
// Phase 15 — CLI script with $argv, benign.
// Validates $argv[1] then runs a fixed echo.
$payload = $argv[1] ?? '';
if (!preg_match('/^[A-Za-z0-9]{1,32}$/', $payload)) {
echo "invalid\n";
exit(0);
}
$out = shell_exec("echo hello");
echo $out;

View file

@ -0,0 +1,6 @@
{
"name": "nyx/cli-script-fixture",
"require": {
"php": ">=8.0"
}
}

View file

@ -0,0 +1,9 @@
<?php
// Phase 15 — CLI script with $argv, vulnerable.
// Top-level body reads $argv[1] and pipes to /bin/sh -c.
echo "__NYX_SINK_HIT__\n";
$payload = $argv[1] ?? '';
$out = shell_exec("echo hello " . $payload);
echo $out;

View file

@ -0,0 +1,17 @@
<?php
// Phase 15 — Slim/Laravel-style route closure, benign.
// Validates payload before invoking sink.
$GLOBALS['__nyx_route'] = function ($payload) {
if (!preg_match('/^[A-Za-z0-9]{1,32}$/', (string)$payload)) {
echo "invalid\n";
return "invalid";
}
$out = shell_exec("echo hello");
echo $out;
return $out;
};
if (false) {
$app->get('/run', $GLOBALS['__nyx_route']);
}

View file

@ -0,0 +1,6 @@
{
"name": "nyx/route-closure-fixture",
"require": {
"php": ">=8.0"
}
}

View file

@ -0,0 +1,17 @@
<?php
// Phase 15 — Slim/Laravel-style route closure, vulnerable.
// Reads payload and pipes to /bin/sh -c.
// Entry: route closure Cap: CODE_EXEC
echo "__NYX_SINK_HIT__\n";
$GLOBALS['__nyx_route'] = function ($payload) {
$out = shell_exec("echo hello " . $payload);
echo $out;
return $out;
};
// Slim-shape marker so PhpShape::detect picks RouteClosure.
if (false) {
$app->get('/run', $GLOBALS['__nyx_route']);
}

View file

@ -0,0 +1,11 @@
<?php
// Phase 15 — top-level script (no function entry), benign.
// Validates payload before invoking sink.
$payload = getenv('NYX_PAYLOAD') ?: '';
if (!preg_match('/^[A-Za-z0-9]{1,32}$/', $payload)) {
echo "invalid\n";
exit(0);
}
$out = shell_exec("echo hello");
echo $out;

View file

@ -0,0 +1,6 @@
{
"name": "nyx/top-level-script-fixture",
"require": {
"php": ">=8.0"
}
}

View file

@ -0,0 +1,9 @@
<?php
// Phase 15 — top-level script (no function entry), vulnerable.
// Body reads NYX_PAYLOAD env var directly and pipes to /bin/sh -c.
echo "__NYX_SINK_HIT__\n";
$payload = getenv('NYX_PAYLOAD') ?: '';
$out = shell_exec("echo hello " . $payload);
echo $out;

View file

@ -0,0 +1,4 @@
source 'https://rubygems.org'
# Phase 15 fixture — generic controller-method shape. No framework
# dep is required at runtime; the Gemfile is informational.

View file

@ -0,0 +1,13 @@
# Phase 15 — generic instance method on a controller, benign.
class LoginController
def authenticate(payload)
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
return "invalid"
end
out = `echo hello`
STDOUT.print(out)
out
end
end

View file

@ -0,0 +1,12 @@
# Phase 15 — generic instance method on a controller, vulnerable.
# No framework markers — RubyShape::detect picks ControllerMethod
# from the class+def pair.
class LoginController
def authenticate(payload)
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
end
end

View file

@ -0,0 +1,6 @@
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.
gem 'rack'

View file

@ -0,0 +1,16 @@
# Phase 15 — Rack middleware, benign.
class NyxRackApp
def initialize(app = nil); @app = app; end
def call(env)
payload = env['nyx.payload'] || ENV['NYX_PAYLOAD'] || ''
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
[400, { 'Content-Type' => 'text/plain' }, ['invalid']]
else
out = `echo hello`
STDOUT.print(out)
[200, { 'Content-Type' => 'text/plain' }, [out]]
end
end
end

View file

@ -0,0 +1,14 @@
# Phase 15 — Rack middleware, vulnerable.
# `call(env)` reads env['nyx.payload'] and pipes to /bin/sh -c.
class NyxRackApp
def initialize(app = nil); @app = app; end
def call(env)
STDOUT.print("__NYX_SINK_HIT__\n")
payload = env['nyx.payload'] || ENV['NYX_PAYLOAD'] || ''
out = `echo hello #{payload}`
STDOUT.print(out)
[200, { 'Content-Type' => 'text/plain' }, [out]]
end
end

View file

@ -0,0 +1,7 @@
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'

View file

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

View file

@ -0,0 +1,23 @@
# Phase 15 — Rails-style controller action, vulnerable.
# Controller inherits the conventional ApplicationController name so
# RubyShape::detect picks RailsAction.
class ApplicationController
def initialize; end
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'] || ''
out = `echo hello #{payload}`
STDOUT.print(out)
out
end
end

View file

@ -0,0 +1,6 @@
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.
gem 'sinatra'

View file

@ -0,0 +1,13 @@
# Phase 15 — Sinatra route, benign.
# Validates payload then runs 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"
end
out = `echo hello`
STDOUT.print(out)
out
end

View file

@ -0,0 +1,11 @@
# Phase 15 — Sinatra route, vulnerable.
# Reads payload (passed by harness via block argument) and pipes through /bin/sh.
# Entry: route block Cap: CODE_EXEC
# nyx-shape: sinatra
get '/run' do |payload|
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
end

View file

@ -9,6 +9,8 @@
//!
//! Run with: `cargo nextest run --features dynamic --test go_fixtures`
mod common;
#[cfg(feature = "dynamic")]
mod go_fixture_tests {
use nyx_scanner::commands::scan::Diag;
@ -446,3 +448,175 @@ mod go_fixture_tests {
}
}
}
// ── Phase 15: per-shape acceptance ───────────────────────────────────────────
#[cfg(feature = "dynamic")]
mod phase15_shape_tests {
use crate::common::fixture_harness::run_shape_fixture_lang;
use nyx_scanner::dynamic::spec::PayloadSlot;
use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus};
use nyx_scanner::labels::Cap;
use nyx_scanner::symbol::Lang;
fn go_available() -> bool {
std::process::Command::new("go")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn assert_confirmed(shape: &str, result: &VerifyResult) {
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"{shape}/vuln: expected Confirmed, got {:?} ({:?})",
result.status,
result.detail,
);
}
fn assert_not_confirmed(shape: &str, result: &VerifyResult) {
assert!(
matches!(
result.status,
VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive
),
"{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})",
result.status,
result.detail,
);
assert_ne!(
result.status,
VerifyStatus::Confirmed,
"{shape}/benign: must not confirm",
);
}
fn run(
shape: &str,
file: &str,
func: &str,
cap: Cap,
sink_line: u32,
kind: EntryKind,
slot: PayloadSlot,
) -> VerifyResult {
run_shape_fixture_lang(
Lang::Go, "go", shape, file, func, cap, sink_line, kind, slot,
)
}
// ── handler_func ─────────────────────────────────────────────────────────
#[test]
fn handler_func_vuln_is_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"handler_func", "vuln.go", "Handle", Cap::CODE_EXEC, 17,
EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()),
);
assert_confirmed("handler_func", &r);
}
#[test]
fn handler_func_benign_not_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"handler_func", "benign.go", "Handle", Cap::CODE_EXEC, 14,
EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()),
);
assert_not_confirmed("handler_func", &r);
}
// ── gin_handler ──────────────────────────────────────────────────────────
#[test]
fn gin_handler_vuln_is_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"gin_handler", "vuln.go", "Handle", Cap::CODE_EXEC, 16,
EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()),
);
assert_confirmed("gin_handler", &r);
}
#[test]
fn gin_handler_benign_not_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"gin_handler", "benign.go", "Handle", Cap::CODE_EXEC, 14,
EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()),
);
assert_not_confirmed("gin_handler", &r);
}
// ── flag_cli ─────────────────────────────────────────────────────────────
#[test]
fn flag_cli_vuln_is_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"flag_cli", "vuln.go", "Run", Cap::CODE_EXEC, 19,
EntryKind::CliSubcommand, PayloadSlot::Argv(0),
);
assert_confirmed("flag_cli", &r);
}
#[test]
fn flag_cli_benign_not_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"flag_cli", "benign.go", "Run", Cap::CODE_EXEC, 15,
EntryKind::CliSubcommand, PayloadSlot::Argv(0),
);
assert_not_confirmed("flag_cli", &r);
}
// ── fuzz_variadic ────────────────────────────────────────────────────────
#[test]
fn fuzz_variadic_vuln_is_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"fuzz_variadic", "vuln.go", "FuzzHandle", Cap::CODE_EXEC, 14,
EntryKind::Function, PayloadSlot::Param(0),
);
assert_confirmed("fuzz_variadic", &r);
}
#[test]
fn fuzz_variadic_benign_not_confirmed() {
if !go_available() {
eprintln!("SKIP: go not available");
return;
}
let r = run(
"fuzz_variadic", "benign.go", "FuzzHandle", Cap::CODE_EXEC, 14,
EntryKind::Function, PayloadSlot::Param(0),
);
assert_not_confirmed("fuzz_variadic", &r);
}
}

View file

@ -9,6 +9,8 @@
//!
//! Run with: `cargo nextest run --features dynamic --test php_fixtures`
mod common;
#[cfg(feature = "dynamic")]
mod php_fixture_tests {
use nyx_scanner::commands::scan::Diag;
@ -446,3 +448,147 @@ mod php_fixture_tests {
}
}
}
// ── Phase 15: per-shape acceptance ───────────────────────────────────────────
#[cfg(feature = "dynamic")]
mod phase15_shape_tests {
use crate::common::fixture_harness::run_shape_fixture_lang;
use nyx_scanner::dynamic::spec::PayloadSlot;
use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus};
use nyx_scanner::labels::Cap;
use nyx_scanner::symbol::Lang;
fn php_available() -> bool {
std::process::Command::new("php")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn assert_confirmed(shape: &str, result: &VerifyResult) {
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"{shape}/vuln: expected Confirmed, got {:?} ({:?})",
result.status,
result.detail,
);
}
fn assert_not_confirmed(shape: &str, result: &VerifyResult) {
assert!(
matches!(
result.status,
VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive
),
"{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})",
result.status,
result.detail,
);
assert_ne!(
result.status,
VerifyStatus::Confirmed,
"{shape}/benign: must not confirm",
);
}
fn run(
shape: &str,
file: &str,
func: &str,
cap: Cap,
sink_line: u32,
kind: EntryKind,
slot: PayloadSlot,
) -> VerifyResult {
run_shape_fixture_lang(
Lang::Php, "php", shape, file, func, cap, sink_line, kind, slot,
)
}
// ── route_closure ────────────────────────────────────────────────────────
#[test]
fn route_closure_vuln_is_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"route_closure", "vuln.php", "run", Cap::CODE_EXEC, 10,
EntryKind::HttpRoute, PayloadSlot::Param(0),
);
assert_confirmed("route_closure", &r);
}
#[test]
fn route_closure_benign_not_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"route_closure", "benign.php", "run", Cap::CODE_EXEC, 11,
EntryKind::HttpRoute, PayloadSlot::Param(0),
);
assert_not_confirmed("route_closure", &r);
}
// ── cli_script ───────────────────────────────────────────────────────────
#[test]
fn cli_script_vuln_is_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"cli_script", "vuln.php", "main", Cap::CODE_EXEC, 8,
EntryKind::CliSubcommand, PayloadSlot::Argv(0),
);
assert_confirmed("cli_script", &r);
}
#[test]
fn cli_script_benign_not_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"cli_script", "benign.php", "main", Cap::CODE_EXEC, 11,
EntryKind::CliSubcommand, PayloadSlot::Argv(0),
);
assert_not_confirmed("cli_script", &r);
}
// ── top_level_script ─────────────────────────────────────────────────────
#[test]
fn top_level_script_vuln_is_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"top_level_script", "vuln.php", "", Cap::CODE_EXEC, 8,
EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_confirmed("top_level_script", &r);
}
#[test]
fn top_level_script_benign_not_confirmed() {
if !php_available() {
eprintln!("SKIP: php not available");
return;
}
let r = run(
"top_level_script", "benign.php", "", Cap::CODE_EXEC, 10,
EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_not_confirmed("top_level_script", &r);
}
}

182
tests/ruby_fixtures.rs Normal file
View file

@ -0,0 +1,182 @@
//! Ruby fixture integration tests (Phase 15 acceptance gate).
//!
//! Per-shape acceptance for the Ruby emitter shapes shipped in Phase 15
//! (Track B Ruby vertical): Sinatra route, Rails action, Rack middleware,
//! and generic controller method. Each shape ships a `vuln.rb` + `benign.rb`
//! pair under `tests/dynamic_fixtures/ruby/<shape>/`.
//!
//! Prerequisites: skips cleanly when `ruby` is unavailable on the host.
//!
//! Run with: `cargo nextest run --features dynamic --test ruby_fixtures`
mod common;
#[cfg(feature = "dynamic")]
mod phase15_shape_tests {
use crate::common::fixture_harness::run_shape_fixture_lang;
use nyx_scanner::dynamic::spec::PayloadSlot;
use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus};
use nyx_scanner::labels::Cap;
use nyx_scanner::symbol::Lang;
fn ruby_available() -> bool {
std::process::Command::new("ruby")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn assert_confirmed(shape: &str, result: &VerifyResult) {
assert_eq!(
result.status,
VerifyStatus::Confirmed,
"{shape}/vuln: expected Confirmed, got {:?} ({:?})",
result.status,
result.detail,
);
}
fn assert_not_confirmed(shape: &str, result: &VerifyResult) {
assert!(
matches!(
result.status,
VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive
),
"{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})",
result.status,
result.detail,
);
assert_ne!(
result.status,
VerifyStatus::Confirmed,
"{shape}/benign: must not confirm",
);
}
fn run(
shape: &str,
file: &str,
func: &str,
cap: Cap,
sink_line: u32,
kind: EntryKind,
slot: PayloadSlot,
) -> VerifyResult {
run_shape_fixture_lang(
Lang::Ruby, "ruby", shape, file, func, cap, sink_line, kind, slot,
)
}
// ── sinatra_route ────────────────────────────────────────────────────────
#[test]
fn sinatra_route_vuln_is_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"sinatra_route", "vuln.rb", "run", Cap::CODE_EXEC, 7,
EntryKind::HttpRoute, PayloadSlot::Param(0),
);
assert_confirmed("sinatra_route", &r);
}
#[test]
fn sinatra_route_benign_not_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"sinatra_route", "benign.rb", "run", Cap::CODE_EXEC, 10,
EntryKind::HttpRoute, PayloadSlot::Param(0),
);
assert_not_confirmed("sinatra_route", &r);
}
// ── rails_action ─────────────────────────────────────────────────────────
#[test]
fn rails_action_vuln_is_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"rails_action", "vuln.rb", "index", Cap::CODE_EXEC, 17,
EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_confirmed("rails_action", &r);
}
#[test]
fn rails_action_benign_not_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"rails_action", "benign.rb", "index", Cap::CODE_EXEC, 20,
EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_not_confirmed("rails_action", &r);
}
// ── rack_middleware ──────────────────────────────────────────────────────
#[test]
fn rack_middleware_vuln_is_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"rack_middleware", "vuln.rb", "call", Cap::CODE_EXEC, 9,
EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_confirmed("rack_middleware", &r);
}
#[test]
fn rack_middleware_benign_not_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"rack_middleware", "benign.rb", "call", Cap::CODE_EXEC, 11,
EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
);
assert_not_confirmed("rack_middleware", &r);
}
// ── controller_method ────────────────────────────────────────────────────
#[test]
fn controller_method_vuln_is_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"controller_method", "vuln.rb", "authenticate", Cap::CODE_EXEC, 7,
EntryKind::Function, PayloadSlot::Param(0),
);
assert_confirmed("controller_method", &r);
}
#[test]
fn controller_method_benign_not_confirmed() {
if !ruby_available() {
eprintln!("SKIP: ruby not available");
return;
}
let r = run(
"controller_method", "benign.rb", "authenticate", Cap::CODE_EXEC, 10,
EntryKind::Function, PayloadSlot::Param(0),
);
assert_not_confirmed("controller_method", &r);
}
}