mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 15: Track B — Go + PHP + Ruby harness emitter shapes
This commit is contained in:
parent
919bc4e7e2
commit
a9b61a9126
39 changed files with 2142 additions and 186 deletions
|
|
@ -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))"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
tests/dynamic_fixtures/go/flag_cli/benign.go
Normal file
18
tests/dynamic_fixtures/go/flag_cli/benign.go
Normal 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))
|
||||
}
|
||||
3
tests/dynamic_fixtures/go/flag_cli/go.mod
Normal file
3
tests/dynamic_fixtures/go/flag_cli/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module nyx_flag_cli_fixture
|
||||
|
||||
go 1.21
|
||||
23
tests/dynamic_fixtures/go/flag_cli/vuln.go
Normal file
23
tests/dynamic_fixtures/go/flag_cli/vuln.go
Normal 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))
|
||||
}
|
||||
19
tests/dynamic_fixtures/go/fuzz_variadic/benign.go
Normal file
19
tests/dynamic_fixtures/go/fuzz_variadic/benign.go
Normal 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
|
||||
}
|
||||
3
tests/dynamic_fixtures/go/fuzz_variadic/go.mod
Normal file
3
tests/dynamic_fixtures/go/fuzz_variadic/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module nyx_fuzz_variadic_fixture
|
||||
|
||||
go 1.21
|
||||
18
tests/dynamic_fixtures/go/fuzz_variadic/vuln.go
Normal file
18
tests/dynamic_fixtures/go/fuzz_variadic/vuln.go
Normal 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
|
||||
}
|
||||
19
tests/dynamic_fixtures/go/gin_handler/benign.go
Normal file
19
tests/dynamic_fixtures/go/gin_handler/benign.go
Normal 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))
|
||||
}
|
||||
3
tests/dynamic_fixtures/go/gin_handler/go.mod
Normal file
3
tests/dynamic_fixtures/go/gin_handler/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module nyx_gin_handler_fixture
|
||||
|
||||
go 1.21
|
||||
21
tests/dynamic_fixtures/go/gin_handler/vuln.go
Normal file
21
tests/dynamic_fixtures/go/gin_handler/vuln.go
Normal 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))
|
||||
}
|
||||
19
tests/dynamic_fixtures/go/handler_func/benign.go
Normal file
19
tests/dynamic_fixtures/go/handler_func/benign.go
Normal 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)
|
||||
}
|
||||
3
tests/dynamic_fixtures/go/handler_func/go.mod
Normal file
3
tests/dynamic_fixtures/go/handler_func/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module nyx_handler_func_fixture
|
||||
|
||||
go 1.21
|
||||
21
tests/dynamic_fixtures/go/handler_func/vuln.go
Normal file
21
tests/dynamic_fixtures/go/handler_func/vuln.go
Normal 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)
|
||||
}
|
||||
11
tests/dynamic_fixtures/php/cli_script/benign.php
Normal file
11
tests/dynamic_fixtures/php/cli_script/benign.php
Normal 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;
|
||||
6
tests/dynamic_fixtures/php/cli_script/composer.json
Normal file
6
tests/dynamic_fixtures/php/cli_script/composer.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "nyx/cli-script-fixture",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
}
|
||||
}
|
||||
9
tests/dynamic_fixtures/php/cli_script/vuln.php
Normal file
9
tests/dynamic_fixtures/php/cli_script/vuln.php
Normal 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;
|
||||
17
tests/dynamic_fixtures/php/route_closure/benign.php
Normal file
17
tests/dynamic_fixtures/php/route_closure/benign.php
Normal 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']);
|
||||
}
|
||||
6
tests/dynamic_fixtures/php/route_closure/composer.json
Normal file
6
tests/dynamic_fixtures/php/route_closure/composer.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "nyx/route-closure-fixture",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
}
|
||||
}
|
||||
17
tests/dynamic_fixtures/php/route_closure/vuln.php
Normal file
17
tests/dynamic_fixtures/php/route_closure/vuln.php
Normal 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']);
|
||||
}
|
||||
11
tests/dynamic_fixtures/php/top_level_script/benign.php
Normal file
11
tests/dynamic_fixtures/php/top_level_script/benign.php
Normal 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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "nyx/top-level-script-fixture",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
}
|
||||
}
|
||||
9
tests/dynamic_fixtures/php/top_level_script/vuln.php
Normal file
9
tests/dynamic_fixtures/php/top_level_script/vuln.php
Normal 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;
|
||||
4
tests/dynamic_fixtures/ruby/controller_method/Gemfile
Normal file
4
tests/dynamic_fixtures/ruby/controller_method/Gemfile
Normal 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.
|
||||
13
tests/dynamic_fixtures/ruby/controller_method/benign.rb
Normal file
13
tests/dynamic_fixtures/ruby/controller_method/benign.rb
Normal 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
|
||||
12
tests/dynamic_fixtures/ruby/controller_method/vuln.rb
Normal file
12
tests/dynamic_fixtures/ruby/controller_method/vuln.rb
Normal 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
|
||||
6
tests/dynamic_fixtures/ruby/rack_middleware/Gemfile
Normal file
6
tests/dynamic_fixtures/ruby/rack_middleware/Gemfile
Normal 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'
|
||||
16
tests/dynamic_fixtures/ruby/rack_middleware/benign.rb
Normal file
16
tests/dynamic_fixtures/ruby/rack_middleware/benign.rb
Normal 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
|
||||
14
tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb
Normal file
14
tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb
Normal 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
|
||||
7
tests/dynamic_fixtures/ruby/rails_action/Gemfile
Normal file
7
tests/dynamic_fixtures/ruby/rails_action/Gemfile
Normal 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'
|
||||
24
tests/dynamic_fixtures/ruby/rails_action/benign.rb
Normal file
24
tests/dynamic_fixtures/ruby/rails_action/benign.rb
Normal 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
|
||||
23
tests/dynamic_fixtures/ruby/rails_action/vuln.rb
Normal file
23
tests/dynamic_fixtures/ruby/rails_action/vuln.rb
Normal 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
|
||||
6
tests/dynamic_fixtures/ruby/sinatra_route/Gemfile
Normal file
6
tests/dynamic_fixtures/ruby/sinatra_route/Gemfile
Normal 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'
|
||||
13
tests/dynamic_fixtures/ruby/sinatra_route/benign.rb
Normal file
13
tests/dynamic_fixtures/ruby/sinatra_route/benign.rb
Normal 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
|
||||
11
tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb
Normal file
11
tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
182
tests/ruby_fixtures.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue