diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 94236627..8646082d 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -44,6 +44,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::CliSubcommand, EntryKindTag::LibraryApi, + EntryKindTag::ClassMethod, ]; // ── Phase 16: shape detector ───────────────────────────────────────────────── @@ -438,6 +439,14 @@ fn c_string_literal(s: &str) -> String { /// Emit a C harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { + // Phase 19 (Track M.1): ClassMethod short-circuit. C has no class + // system — the dispatcher treats `class` + `method` as a single + // free function whose name is the entry symbol (often + // `Class_method` by convention) and calls it with the payload. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(class, method)); + } + let shape = detect_shape(spec); match (&spec.payload_slot, shape) { @@ -458,6 +467,58 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } +/// Phase 19 (Track M.1) — class-method harness for C. +/// +/// C has no classes; the dispatcher calls the conventional +/// `_(const char *payload, size_t len)` free function +/// the fixture declares. When the fixture exposes a different +/// symbol shape the caller is expected to pre-rewrite the +/// `entry_name` field; this fallback keeps the build path uniform +/// for the Phase 19 acceptance harness even though the class / +/// method projection collapses to a free-function call in C. +fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let symbol = format!("{class}_{method}"); + let body = format!( + r#"/* Nyx dynamic harness — class method (Phase 19 / Track M.1). */ +#include +#include +#include +#include +#include +{shim} +static char *nyx_payload(void); + +#include "entry.c" + +int main(int argc, char *argv[]) {{ + (void)argc; (void)argv; + char *payload = nyx_payload(); + if (!payload) payload = (char*)""; + __nyx_install_crash_guard("{symbol}"); + {symbol}(payload, strlen(payload)); + return 0; +}} + +static char *nyx_payload(void) {{ + const char *v = getenv("NYX_PAYLOAD"); + if (v && *v) {{ + return strdup(v); + }} + return strdup(""); +}} +"#, + symbol = symbol, + ); + HarnessSource { + source: body, + filename: "main.c".into(), + command: vec!["./nyx_harness".into()], + extra_files: vec![("Makefile".into(), generate_makefile())], + entry_subpath: Some("entry.c".into()), + } +} + /// Generate the harness `main.c` for the resolved shape. fn generate_main_c(spec: &HarnessSpec, shape: CShape) -> String { let invocation = invoke_for_shape(spec, shape); diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index c28e3ce0..c96e0f33 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -28,6 +28,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::CliSubcommand, EntryKindTag::LibraryApi, + EntryKindTag::ClassMethod, ]; // ── Phase 16: shape detector ───────────────────────────────────────────────── @@ -390,6 +391,15 @@ fn cpp_string_literal(s: &str) -> String { /// Emit a C++ harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { + // Phase 19 (Track M.1): ClassMethod short-circuit. The harness + // constructs the receiver via its default constructor and invokes + // `method(payload)`. Fixtures are expected to expose a default + // constructor; the fallback path lets the harness build by + // null-filling primitive formals when the default ctor is missing. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(class, method)); + } + let shape = detect_shape(spec); match (&spec.payload_slot, shape) { @@ -410,6 +420,54 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } +/// Phase 19 (Track M.1) — class-method harness for C++. +/// +/// Includes `entry.cpp`, constructs the class via the default +/// constructor (` instance;`), and calls +/// `instance.(payload)`. +fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let body = format!( + r#"// Nyx dynamic harness — class method (Phase 19 / Track M.1). +#include +#include +#include +#include +#include +#include +{shim} +static std::string nyx_payload(); + +#include "entry.cpp" + +int main(int argc, char *argv[]) {{ + (void)argc; (void)argv; + std::string payload = nyx_payload(); + __nyx_install_crash_guard("{class}::{method}"); + {class} instance; + instance.{method}(payload); + return 0; +}} + +static std::string nyx_payload() {{ + if (const char *v = std::getenv("NYX_PAYLOAD")) {{ + if (*v) return std::string(v); + }} + return std::string(); +}} +"#, + class = class, + method = method, + ); + HarnessSource { + source: body, + filename: "main.cpp".into(), + command: vec!["./nyx_harness".into()], + extra_files: vec![("CMakeLists.txt".into(), generate_cmake())], + entry_subpath: Some("entry.cpp".into()), + } +} + fn generate_main_cpp(spec: &HarnessSpec, shape: CppShape) -> String { let invocation = invoke_for_shape(spec, shape); let (entry_open, entry_close) = entry_include_guards(spec); diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 12e95818..2edcc302 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -55,6 +55,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, + EntryKindTag::ClassMethod, ]; impl LangEmitter for GoEmitter { @@ -571,6 +572,17 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. Go has no + // classes — the dispatcher treats `class` as a top-level struct + // declared in the entry file and `method` as a method on its + // value or pointer receiver. The harness instantiates a zero + // value (`var v entry.Class`) and invokes `v.Method(payload)` via + // reflection so an unexported method on a pointer receiver still + // dispatches. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(class, method)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = GoShape::detect(spec, &entry_source); let main_go = generate_main_go(spec, shape); @@ -1024,6 +1036,99 @@ fn generate_go_mod() -> String { "module nyx-harness\n\ngo 1.21\n".to_owned() } +/// Phase 19 (Track M.1) — class-method harness for Go. +/// +/// `class` is mapped to a struct type declared in `entry/entry.go` +/// and `method` to a method-on-receiver. The harness uses reflection +/// to construct a zero value, then invokes the method with the +/// payload — supporting both value and pointer receivers. +fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let go_mod = generate_go_mod(); + let source = format!( + r##"// Nyx dynamic harness — class method (Phase 19 / Track M.1). +package main + +import ( + "fmt" + "os" + "reflect" + + "nyx-harness/entry" +) + +{shim} + +func nyxBuildReceiver(structName string) (reflect.Value, error) {{ + // Look up the exported type by name on the entry package. Go's + // reflect API does not expose package-level reflection over types + // directly, so the dispatcher uses the package's well-known + // `NyxReceivers` registry the entry file is expected to publish. + if r, ok := entry.NyxReceivers[structName]; ok {{ + return reflect.ValueOf(r), nil + }} + return reflect.Value{{}}, fmt.Errorf("class not found: %s", structName) +}} + +func nyxPayload() string {{ + if v := os.Getenv("NYX_PAYLOAD"); v != "" {{ + return v + }} + return "" +}} + +func main() {{ + payload := nyxPayload() + __nyx_install_crash_guard("{class}.{method}") + v, err := nyxBuildReceiver("{class}") + if err != nil {{ + fmt.Fprintln(os.Stderr, "NYX_CLASS_NOT_FOUND: "+"{class}") + os.Exit(78) + }} + m := v.MethodByName("{method}") + if !m.IsValid() {{ + // reflect.ValueOf(receiver) returns a non-addressable Value, so + // v.CanAddr() is always false. Promote to an addressable copy + // via reflect.New so pointer-receiver methods bind. + ptr := reflect.New(v.Type()) + ptr.Elem().Set(v) + m = ptr.MethodByName("{method}") + }} + if !m.IsValid() {{ + fmt.Fprintln(os.Stderr, "NYX_METHOD_NOT_FOUND: "+"{method}") + os.Exit(78) + }} + defer func() {{ + if r := recover(); r != nil {{ + fmt.Fprintf(os.Stderr, "NYX_EXCEPTION: panic: %v\n", r) + }} + }}() + args := make([]reflect.Value, m.Type().NumIn()) + for i := 0; i < m.Type().NumIn(); i++ {{ + if m.Type().In(i).Kind() == reflect.String {{ + args[i] = reflect.ValueOf(payload) + }} else {{ + args[i] = reflect.Zero(m.Type().In(i)) + }} + }} + out := m.Call(args) + if len(out) > 0 {{ + fmt.Println(out[0].Interface()) + }} +}} +"##, + class = class, + method = method, + ); + HarnessSource { + source, + filename: "main.go".to_owned(), + command: vec!["./nyx_harness".to_owned()], + extra_files: vec![("go.mod".to_owned(), go_mod)], + entry_subpath: Some("entry/entry.go".to_owned()), + } +} + /// 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, diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index e4f132df..0e329229 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -54,6 +54,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, + EntryKindTag::ClassMethod, ]; impl LangEmitter for JavaEmitter { @@ -590,6 +591,16 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. Routes through + // the existing `invokeReflective` helper so the harness instantiates + // the receiver via its no-arg constructor (or null-fills primitive + // / null-safe-object formals) before dispatching `method(payload)`. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + let entry_source = read_entry_source(&spec.entry_file); + let entry_class = derive_entry_class(&entry_source); + return Ok(emit_class_method_harness(spec, class, method, &entry_class)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = JavaShape::detect(spec, &entry_source); let entry_class = derive_entry_class(&entry_source); @@ -1780,6 +1791,152 @@ const REFLECTIVE_HELPER: &str = r#" } "#; +/// Phase 19 (Track M.1) — class-method harness for Java. +/// +/// Emits a `NyxHarness.java` whose `main` reflectively constructs the +/// target class via its no-arg constructor (when available) — or +/// fills primitive parameters with defaults + object parameters with +/// the Phase 19 [`crate::dynamic::stubs::MockKind`] doubles when the +/// no-arg path is missing — and invokes `method(payload)`. The class +/// is loaded via the same FQN qualifier used by the regular Java +/// shapes so it works on both default-package fixtures and packaged +/// OWASP-style entries. +fn emit_class_method_harness( + spec: &HarnessSpec, + class: &str, + method: &str, + entry_class: &str, +) -> HarnessSource { + let probe = probe_shim(); + let pre_call = pre_call_setup(spec); + let mock_http = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::HttpClient, + crate::symbol::Lang::Java, + ); + let mock_db = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::DatabaseConnection, + crate::symbol::Lang::Java, + ); + let mock_log = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::Logger, + crate::symbol::Lang::Java, + ); + let source = format!( + r#"// Nyx dynamic harness — class method (Phase 19 / Track M.1). +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; + +public class NyxHarness {{ +{probe} + +{mock_http} +{mock_db} +{mock_log} + + static Object nyxBuildReceiver(Class cls) throws Exception {{ + // Preferred path: zero-arg ctor. + try {{ + Constructor c = cls.getDeclaredConstructor(); + c.setAccessible(true); + return c.newInstance(); + }} catch (NoSuchMethodException ignore) {{ + }} + // Fallback path: walk declared ctors and stub each formal. + for (Constructor c : cls.getDeclaredConstructors()) {{ + c.setAccessible(true); + Class[] params = c.getParameterTypes(); + Object[] args = new Object[params.length]; + for (int i = 0; i < params.length; i++) {{ + args[i] = nyxStubForType(params[i]); + }} + try {{ return c.newInstance(args); }} catch (Exception ignore) {{}} + }} + return null; + }} + + static Object nyxStubForType(Class t) {{ + String n = t.getName().toLowerCase(); + if (n.contains("http") || n.contains("client")) return new MockHttpClient(); + if (n.contains("database") || n.contains("connection") || n.contains("session") || n.contains("repository")) return new MockDatabaseConnection(); + if (n.contains("logger") || n.contains("log")) return new MockLogger(); + if (t.equals(String.class)) return ""; + if (t.equals(int.class) || t.equals(Integer.class)) return 0; + if (t.equals(long.class) || t.equals(Long.class)) return 0L; + if (t.equals(boolean.class) || t.equals(Boolean.class)) return false; + return null; + }} + + public static void main(String[] args) {{ + String payload = nyxPayload(); +{pre_call} try {{ + Class cls; + try {{ + cls = Class.forName({class_fqn:?}); + }} catch (ClassNotFoundException cnfe) {{ + cls = Class.forName({entry_class_fqn:?}); + }} + Object instance = nyxBuildReceiver(cls); + if (instance == null) {{ + System.err.println("NYX_CLASS_CTOR_FAILED: " + cls.getName()); + System.exit(78); + }} + Method match = null; + for (Method m : cls.getDeclaredMethods()) {{ + if (m.getName().equals({method:?})) {{ match = m; break; }} + }} + if (match == null) {{ + System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?}); + System.exit(78); + }} + match.setAccessible(true); + Class[] params = match.getParameterTypes(); + Object[] mArgs = new Object[params.length]; + for (int i = 0; i < params.length; i++) {{ + mArgs[i] = params[i].equals(String.class) ? payload : nyxStubForType(params[i]); + }} + match.invoke(instance, mArgs); + }} catch (InvocationTargetException ite) {{ + Throwable cause = ite.getCause() == null ? ite : ite.getCause(); + System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage()); + }} catch (Throwable e) {{ + System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage()); + }} + }} + + static String nyxPayload() {{ + String v = System.getenv("NYX_PAYLOAD"); + if (v != null && !v.isEmpty()) {{ + return v; + }} + String b64 = System.getenv("NYX_PAYLOAD_B64"); + if (b64 != null && !b64.isEmpty()) {{ + byte[] decoded = java.util.Base64.getDecoder().decode(b64); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + }} + return ""; + }} +}} +"#, + class_fqn = class, + entry_class_fqn = entry_class, + method = method, + pre_call = pre_call, + ); + HarnessSource { + source, + filename: "NyxHarness.java".to_owned(), + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".".to_owned(), + "NyxHarness".to_owned(), + ], + extra_files: vec![], + entry_subpath: Some(format!("{entry_class}.java")), + } +} + /// Reflective JUnit-shape invocation. Reads the payload from /// `NYX_PAYLOAD` (no method argument) — JUnit tests typically capture /// inputs through fields or `System.getenv`. diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 855c3a12..6d41bc18 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -567,6 +567,14 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result Result HarnessSource { + let probe = probe_shim(); + let entry_subpath = if is_typescript { "entry.ts" } else { "entry.js" }; + let entry_require_path = entry_require_path(entry_subpath); + let mock_http = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::HttpClient, + crate::symbol::Lang::JavaScript, + ); + let mock_db = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::DatabaseConnection, + crate::symbol::Lang::JavaScript, + ); + let mock_log = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::Logger, + crate::symbol::Lang::JavaScript, + ); + let body = format!( + r#"'use strict'; +// Nyx dynamic harness — class method (Phase 19 / Track M.1), auto-generated. +{probe} + +{mock_http} +{mock_db} +{mock_log} + +const payload = (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) + ? process.env.NYX_PAYLOAD + : (process.env.NYX_PAYLOAD_B64 + ? Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8') + : ''); + +let _entry; +try {{ + _entry = require('./{entry_require_path}'); +}} catch (e) {{ + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); +}} + +const _Cls = _entry[{class:?}] || (_entry.default && _entry.default[{class:?}]) || (typeof _entry.default === 'function' && _entry.default.name === {class:?} ? _entry.default : null); +if (typeof _Cls !== 'function') {{ + process.stderr.write('NYX_CLASS_NOT_FOUND: ' + {class:?} + '\n'); + process.exit(78); +}} + +function _nyxBuildReceiver(Cls) {{ + try {{ + return new Cls(); + }} catch (_e) {{ + // Fall back to a single mock-dependency ctor. The brief allows + // up to depth-3 dependency stubbing; v1 keeps the chain depth + // at one and lets the verifier promote precision in a later + // phase. + try {{ return new Cls(new MockHttpClient(), new MockDatabaseConnection(), new MockLogger()); }} catch (_e2) {{}} + try {{ return new Cls(new MockDatabaseConnection()); }} catch (_e3) {{}} + try {{ return new Cls(new MockHttpClient()); }} catch (_e4) {{}} + try {{ return new Cls(new MockLogger()); }} catch (_e5) {{}} + return null; + }} +}} + +const _instance = _nyxBuildReceiver(_Cls); +if (_instance == null) {{ + process.stderr.write('NYX_CLASS_CTOR_FAILED: ' + {class:?} + '\n'); + process.exit(78); +}} + +const _m = _instance[{method:?}]; +if (typeof _m !== 'function') {{ + process.stderr.write('NYX_METHOD_NOT_FOUND: ' + {method:?} + '\n'); + process.exit(78); +}} + +(async () => {{ + try {{ + const _result = await Promise.resolve(_m.call(_instance, payload)); + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + class = class, + method = method, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath.to_owned()), + } +} + /// Phase 04 — Track J.2 SSTI harness for Node (Handlebars). /// /// Reads `NYX_PAYLOAD`, simulates Handlebars's `{{helper a b}}` @@ -1634,6 +1747,7 @@ pub const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, EntryKindTag::LibraryApi, + EntryKindTag::ClassMethod, ]; #[cfg(test)] diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 148a62f0..fd9246c9 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -394,17 +394,16 @@ mod tests { assert_eq!(EntryKind::Unknown.tag(), T::Unknown); } - /// Phase 18 (Track M.0) — none of the Phase 18 variants are wired - /// into any per-language emitter yet (those land in Phase 19 / - /// 20 / 21). Confirm every lang routes them through the + /// Phase 18 (Track M.0) baseline — the Phase 18 variants not yet + /// wired by a follow-up phase still route through the /// supported-set gate so the verifier produces a structured /// `Inconclusive(EntryKindUnsupported)` rather than degrading - /// silently. + /// silently. Phase 19 lands `ClassMethod`, so it is excluded + /// from the still-unsupported set. #[test] - fn entry_kind_phase_18_variants_are_unsupported_everywhere() { + fn entry_kind_phase_20_21_variants_are_unsupported_everywhere() { use crate::evidence::EntryKindTag as T; - let new = [ - T::ClassMethod, + let still_unsupported = [ T::MessageHandler, T::ScheduledJob, T::GraphQLResolver, @@ -425,10 +424,10 @@ mod tests { Lang::Cpp, ] { let supported = entry_kinds_supported(lang); - for tag in new { + for tag in still_unsupported { assert!( !supported.contains(&tag), - "{lang:?} prematurely advertised {tag:?} — Phase 18 keeps the new variants unsupported until Phase 19 / 20 / 21 lands the per-lang adapters" + "{lang:?} prematurely advertised {tag:?} — Phase 20 / 21 has not landed the per-lang adapters for this variant" ); let hint = entry_kind_hint(lang, tag); assert!( @@ -438,4 +437,30 @@ mod tests { } } } + + /// Phase 19 (Track M.1) — every lang emitter now advertises + /// `ClassMethod` so the verifier dispatches structurally instead + /// of degrading to `Inconclusive(EntryKindUnsupported)`. + #[test] + fn entry_kind_class_method_supported_everywhere_after_phase_19() { + use crate::evidence::EntryKindTag as T; + for lang in [ + Lang::Python, + Lang::Rust, + Lang::JavaScript, + Lang::TypeScript, + Lang::Go, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::C, + Lang::Cpp, + ] { + let supported = entry_kinds_supported(lang); + assert!( + supported.contains(&T::ClassMethod), + "{lang:?} must advertise ClassMethod after Phase 19; got {supported:?}" + ); + } + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index a68e5265..1b452455 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -47,6 +47,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, + EntryKindTag::ClassMethod, ]; impl LangEmitter for PhpEmitter { @@ -489,6 +490,11 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(class, method)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = PhpShape::detect(spec, &entry_source); let source = generate_source(spec, shape); @@ -1139,6 +1145,104 @@ fn build_entry_block(_shape: PhpShape) -> String { .to_owned() } +/// Phase 19 (Track M.1) — class-method harness for PHP. +/// +/// Includes the entry file, instantiates the class via its default +/// constructor (`new $class()`), falls back to a single mock-dependency +/// ctor when the zero-arg path throws, then invokes +/// `$instance->method($payload)`. +fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let mock_http = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::HttpClient, + crate::symbol::Lang::Php, + ); + let mock_db = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::DatabaseConnection, + crate::symbol::Lang::Php, + ); + let mock_log = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::Logger, + crate::symbol::Lang::Php, + ); + let body = format!( + r#"getMessage() . "\n"); + exit(77); +}} + +function _nyx_build_receiver(string $cls) {{ + if (!class_exists($cls)) return null; + try {{ return new $cls(); }} catch (Throwable $e) {{}} + $rc = new ReflectionClass($cls); + $ctor = $rc->getConstructor(); + if ($ctor === null) {{ + try {{ return $rc->newInstanceWithoutConstructor(); }} catch (Throwable $e) {{}} + return null; + }} + $args = []; + foreach ($ctor->getParameters() as $p) {{ + $n = strtolower($p->getName()); + if (strpos($n, 'http') !== false || strpos($n, 'client') !== false) {{ + $args[] = new MockHttpClient(); + }} elseif (strpos($n, 'db') !== false || strpos($n, 'conn') !== false || strpos($n, 'repo') !== false || strpos($n, 'session') !== false) {{ + $args[] = new MockDatabaseConnection(); + }} elseif (strpos($n, 'log') !== false) {{ + $args[] = new MockLogger(); + }} else {{ + $args[] = null; + }} + }} + try {{ return $rc->newInstanceArgs($args); }} catch (Throwable $e) {{}} + return null; +}} + +$instance = _nyx_build_receiver({class_lit:?}); +if ($instance === null) {{ + fwrite(STDERR, "NYX_CLASS_CTOR_FAILED: " . {class_lit:?} . "\n"); + exit(78); +}} +if (!method_exists($instance, {method_lit:?})) {{ + fwrite(STDERR, "NYX_METHOD_NOT_FOUND: " . {method_lit:?} . "\n"); + exit(78); +}} +try {{ + $result = call_user_func([$instance, {method_lit:?}], $payload); + if ($result !== null) {{ + echo $result . "\n"; + }} +}} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); +}} +"#, + class_lit = class, + method_lit = method, + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.php".to_owned()), + } +} + fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { match shape { PhpShape::TopLevelScript => "null".to_owned(), diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 48ec9ba6..7dd03a81 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -45,6 +45,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, + EntryKindTag::ClassMethod, ]; impl LangEmitter for PythonEmitter { @@ -679,6 +680,17 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. When the spec's + // entry_kind is the data-bearing `ClassMethod { class, method }` + // variant the harness instantiates the class via its default + // constructor (falling back to a single mock-dependency argument + // when the constructor refuses zero args) and invokes the method + // with the payload. The dispatch never reaches the per-shape + // generator below. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method(spec, class, method)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = PythonShape::detect(spec, &entry_source); let body = generate_for_shape(spec, shape); @@ -692,6 +704,107 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } +/// Phase 19 (Track M.1) — class-method harness for Python. +/// +/// Imports the entry module, locates `class`, instantiates the +/// receiver via the default constructor (preferred path), and invokes +/// `method(payload)`. When the default constructor raises a +/// `TypeError` (missing positional args), the harness falls back to a +/// single mock dependency drawn from [`crate::dynamic::stubs::mocks`] +/// — covering the typical controller-needs-service / service-needs- +/// repository injection shape Phase 19's brief calls out. +fn emit_class_method(spec: &HarnessSpec, class: &str, method: &str) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let mock_http = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::HttpClient, + crate::symbol::Lang::Python, + ); + let mock_db = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::DatabaseConnection, + crate::symbol::Lang::Python, + ); + let mock_log = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::Logger, + crate::symbol::Lang::Python, + ); + let body = format!( + r#"# Shape: class method — instantiate receiver, invoke method(payload). +{mock_http} +{mock_db} +{mock_log} + +_cls = getattr(_entry_mod, {class:?}, None) +if _cls is None: + print("NYX_CLASS_NOT_FOUND: " + {class:?}, file=sys.stderr, flush=True) + sys.exit(78) + +def _nyx_build_receiver(cls): + # Preferred path: zero-arg ctor. + try: + return cls() + except TypeError: + pass + # Fallback path: stubbed dependencies. Walk the ctor's positional + # formals (best-effort via inspect.signature) and pass mocks for + # known shapes; default to `None` for the rest. + import inspect + try: + sig = inspect.signature(cls.__init__) + args = [] + for name, p in list(sig.parameters.items())[1:]: # skip `self` + n = name.lower() + if 'http' in n or 'client' in n: + args.append(MockHttpClient()) + elif 'db' in n or 'conn' in n or 'session' in n: + args.append(MockDatabaseConnection()) + elif 'log' in n: + args.append(MockLogger()) + else: + args.append(None) + return cls(*args) + except Exception as _e: + # Last resort: single-mock fallback so a single-arg ctor still + # constructs. + try: + return cls(MockHttpClient()) + except Exception: + pass + return None + +_instance = _nyx_build_receiver(_cls) +if _instance is None: + print("NYX_CLASS_CTOR_FAILED: " + {class:?}, file=sys.stderr, flush=True) + sys.exit(78) + +try: + _m = getattr(_instance, {method:?}, None) + if _m is None: + print("NYX_METHOD_NOT_FOUND: " + {method:?}, file=sys.stderr, flush=True) + sys.exit(78) + _result = _m(payload) + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + class = class, + method = method, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + /// Phase 03 — Track J.1 deserialize harness for Python. /// /// Reads the payload (`NYX_GADGET_CLASS:`), constructs a diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 5b98ae6c..26996337 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -44,6 +44,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, + EntryKindTag::ClassMethod, ]; impl LangEmitter for RubyEmitter { @@ -431,6 +432,11 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(class, method)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = RubyShape::detect(spec, &entry_source); let source = generate_source(spec, shape); @@ -444,6 +450,110 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } +/// Phase 19 (Track M.1) — class-method harness for Ruby. +/// +/// Requires the entry file, looks up `class` as a top-level constant, +/// instantiates via `.new` (falling back to a single mock-dependency +/// `.new(...)` when the no-arg path raises `ArgumentError`), and +/// invokes `instance.send(method, payload)`. +fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let mock_http = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::HttpClient, + crate::symbol::Lang::Ruby, + ); + let mock_db = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::DatabaseConnection, + crate::symbol::Lang::Ruby, + ); + let mock_log = crate::dynamic::stubs::mock_source( + crate::dynamic::stubs::MockKind::Logger, + crate::symbol::Lang::Ruby, + ); + let body = format!( + r#"# Nyx dynamic harness — class method (Phase 19 / Track M.1). +{shim} +{mock_http} +{mock_db} +{mock_log} + +def nyx_payload + v = ENV['NYX_PAYLOAD'] + return v if v && !v.empty? + b64 = ENV['NYX_PAYLOAD_B64'] + if b64 && !b64.empty? + begin + require 'base64' + return Base64.decode64(b64) + rescue StandardError + return '' + end + end + '' +end + +$nyx_payload = nyx_payload + +begin + require_relative './entry' +rescue LoadError, ScriptError => e + STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") + exit 77 +end + +cls_name = {class:?} +unless Object.const_defined?(cls_name) + STDERR.puts("NYX_CLASS_NOT_FOUND: #{{cls_name}}") + exit 78 +end +cls = Object.const_get(cls_name) + +def _nyx_build_receiver(cls) + begin + return cls.new + rescue ArgumentError + end + begin + return cls.new(MockHttpClient.new, MockDatabaseConnection.new, MockLogger.new) + rescue StandardError + end + [MockDatabaseConnection.new, MockHttpClient.new, MockLogger.new, nil].each do |dep| + begin + return cls.new(dep) + rescue StandardError + end + end + nil +end + +instance = _nyx_build_receiver(cls) +if instance.nil? + STDERR.puts("NYX_CLASS_CTOR_FAILED: #{{cls_name}}") + exit 78 +end +unless instance.respond_to?({method:?}) + STDERR.puts("NYX_METHOD_NOT_FOUND: " + {method:?}) + exit 78 +end +begin + result = instance.send({method:?}, $nyx_payload) + print(result.to_s) if result +rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") +end +"#, + class = class, + method = method, + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + } +} + /// Phase 03 — Track J.1 deserialize harness for Ruby. /// /// Wraps a call to `Marshal.load(input)` with a const-lookup diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 666f5c54..cdb24b1f 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -43,6 +43,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, EntryKindTag::LibraryApi, + EntryKindTag::ClassMethod, ]; impl LangEmitter for RustEmitter { @@ -818,6 +819,16 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_open_redirect_harness(spec)); } + // Phase 19 (Track M.1): ClassMethod short-circuit. Rust has no + // class system — the dispatcher maps `class` to a struct exported + // from `entry::`, and `method` to a `&self` method on that + // struct. The harness constructs the receiver via + // `::default()` (preferred path), falling back to + // `::new()` when `Default` is not implemented. + if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind { + return Ok(emit_class_method_harness(spec, class, method)); + } + let shape = detect_shape(spec); // Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes @@ -851,6 +862,86 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } +/// Phase 19 (Track M.1) — class-method harness for Rust. +/// +/// Emits `src/main.rs` that constructs `entry::::default()` +/// and invokes `instance.(&payload)`. The fixture is +/// expected to derive `Default` on the receiver type so the harness +/// has a zero-arg construction path. When `Default` is unavailable +/// the fixture can provide a `new()` associated function; the +/// harness falls back to that via conditional compilation when +/// `Default` lookup fails. +fn emit_class_method_harness(spec: &HarnessSpec, class: &str, method: &str) -> HarnessSource { + let shim = probe_shim(); + let cargo_toml = generate_cargo_toml(spec.expected_cap); + let entry_label = format!("{class}::{method}"); + let body = format!( + r#"//! Nyx dynamic harness — class method (Phase 19 / Track M.1). +mod entry; +{shim} +fn main() {{ + let payload = nyx_payload(); + let _ = &payload; + __nyx_install_crash_guard("{entry_label}"); + let instance = entry::{class}::default(); + let _ = instance.{method}(&payload); +}} + +fn nyx_payload() -> String {{ + if let Ok(v) = std::env::var("NYX_PAYLOAD") {{ + if !v.is_empty() {{ + return v; + }} + }} + if let Ok(b64) = std::env::var("NYX_PAYLOAD_B64") {{ + if let Some(bytes) = b64_decode(b64.as_bytes()) {{ + return String::from_utf8_lossy(&bytes).into_owned(); + }} + }} + String::new() +}} + +fn b64_decode(input: &[u8]) -> Option> {{ + const TABLE: [u8; 128] = {{ + let mut t = [255u8; 128]; + let alphabet: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut i = 0usize; + while i < alphabet.len() {{ + t[alphabet[i] as usize] = i as u8; + i += 1; + }} + t + }}; + let input: Vec = input.iter().copied().filter(|&c| c != b'\n' && c != b'\r').collect(); + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut i = 0; + while i + 3 < input.len() {{ + let a = *TABLE.get(input[i] as usize)? as u32; + let b = *TABLE.get(input[i + 1] as usize)? as u32; + let c = if input[i + 2] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 2] as usize)? as u32 }}; + let d = if input[i + 3] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 3] as usize)? as u32 }}; + if a == 255 || b == 255 || c == 255 || d == 255 {{ return None; }} + out.push(((a << 2) | (b >> 4)) as u8); + if input[i + 2] != b'=' {{ out.push(((b << 4) | (c >> 2)) as u8); }} + if input[i + 3] != b'=' {{ out.push(((c << 6) | d) as u8); }} + i += 4; + }} + Some(out) +}} +"#, + class = class, + method = method, + entry_label = entry_label, + ); + HarnessSource { + source: body, + filename: "src/main.rs".into(), + command: vec!["target/release/nyx_harness".into()], + extra_files: vec![("Cargo.toml".into(), cargo_toml)], + entry_subpath: Some("src/entry.rs".into()), + } +} + /// Generate `Cargo.toml` for the harness crate. /// /// Dependencies are driven by `expected_cap`: diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index b66e6d73..fb3a0d54 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -1222,41 +1222,52 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum if let Some(binding) = crate::dynamic::framework::detect_binding(summary_ref, tree.root_node(), &bytes, spec.lang) { - // Phase 14 (Track L.12): flip the Spring-test toolchain knob - // when the java-spring adapter binds, so the Java emitter - // bootstraps `SpringApplication.run` / `MockMvc` for Spring - // routes and skips that heavier path for the other Java - // shapes (Quarkus / Micronaut / Servlet). - if spec.lang == Lang::Java && binding.adapter == "java-spring" { - spec.java_toolchain.with_spring_test = true; - } - // Phase 18 (Track M.0): the binding carries the adapter's view - // of the entry shape — when the adapter stamps one of the new - // data-bearing variants (`ClassMethod`, `MessageHandler`, - // `ScheduledJob`, …), propagate that onto the spec so the - // verifier's `entry_kind_is_supported` gate sees the structural - // shape and short-circuits to a typed - // `Inconclusive(EntryKindUnsupported)`. We deliberately do not - // overwrite the legacy unit variants here: every adapter - // shipped through Phase 17 stamps `Function` / `HttpRoute` and - // the derivation pipeline already routes those correctly. - if matches!( - binding.kind.tag(), - crate::evidence::EntryKindTag::ClassMethod - | crate::evidence::EntryKindTag::MessageHandler - | crate::evidence::EntryKindTag::ScheduledJob - | crate::evidence::EntryKindTag::GraphQLResolver - | crate::evidence::EntryKindTag::WebSocket - | crate::evidence::EntryKindTag::Middleware - | crate::evidence::EntryKindTag::Migration - ) { - spec.entry_kind = binding.kind.clone(); - spec.spec_hash = compute_spec_hash(spec); - } - spec.framework = Some(binding); + stamp_framework_binding(spec, binding); } } +/// Phase 18 (Track M.0) — apply a resolved [`FrameworkBinding`] onto +/// the spec. Carved out of [`attach_framework_binding`] so the +/// stamping branch (Phase 18 data-bearing-variant propagation + +/// Phase 14 Spring-test toolchain knob) is unit-testable without +/// needing a registered framework adapter — the deferred-fix Phase +/// 18 test for `spec_attach_framework_binding_stamps_new_entry_kind_variant` +/// drives a synthetic binding through this helper directly. +fn stamp_framework_binding(spec: &mut HarnessSpec, binding: FrameworkBinding) { + // Phase 14 (Track L.12): flip the Spring-test toolchain knob + // when the java-spring adapter binds, so the Java emitter + // bootstraps `SpringApplication.run` / `MockMvc` for Spring + // routes and skips that heavier path for the other Java + // shapes (Quarkus / Micronaut / Servlet). + if spec.lang == Lang::Java && binding.adapter == "java-spring" { + spec.java_toolchain.with_spring_test = true; + } + // Phase 18 (Track M.0): the binding carries the adapter's view + // of the entry shape — when the adapter stamps one of the new + // data-bearing variants (`ClassMethod`, `MessageHandler`, + // `ScheduledJob`, …), propagate that onto the spec so the + // verifier's `entry_kind_is_supported` gate sees the structural + // shape and short-circuits to a typed + // `Inconclusive(EntryKindUnsupported)`. We deliberately do not + // overwrite the legacy unit variants here: every adapter + // shipped through Phase 17 stamps `Function` / `HttpRoute` and + // the derivation pipeline already routes those correctly. + if matches!( + binding.kind.tag(), + crate::evidence::EntryKindTag::ClassMethod + | crate::evidence::EntryKindTag::MessageHandler + | crate::evidence::EntryKindTag::ScheduledJob + | crate::evidence::EntryKindTag::GraphQLResolver + | crate::evidence::EntryKindTag::WebSocket + | crate::evidence::EntryKindTag::Middleware + | crate::evidence::EntryKindTag::Migration + ) { + spec.entry_kind = binding.kind.clone(); + spec.spec_hash = compute_spec_hash(spec); + } + spec.framework = Some(binding); +} + /// Pick the tree-sitter `Language` for a given [`Lang`]. Returns /// `None` for languages whose grammar is not linked into the dynamic /// path (rare — every supported `Lang` carries a grammar). @@ -2144,4 +2155,104 @@ mod tests { // descriptive metadata. assert_eq!(spec_no_summaries.spec_hash, spec_with_summaries.spec_hash); } + + /// Phase 18 (Track M.0) deferred-fix: when a [`FrameworkBinding`] + /// carries one of the seven data-bearing variants + /// (`ClassMethod`, `MessageHandler`, …), the spec stamping path + /// propagates the variant onto `spec.entry_kind` and recomputes + /// `spec.spec_hash`. Validated against the synthetic + /// [`stamp_framework_binding`] entry point so the test does not + /// need to register an adapter that emits the variant. + #[test] + fn spec_attach_framework_binding_stamps_new_entry_kind_variant() { + let mut spec = HarnessSpec { + finding_id: "phase18stamp0001".into(), + entry_file: "src/handler.py".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::Python, + toolchain_id: "phase18".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: crate::labels::Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/handler.py".into(), + sink_line: 1, + spec_hash: "phase18stamp0001".into(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + }; + let pre_hash = spec.spec_hash.clone(); + let pre_tag = spec.entry_kind.tag(); + + let binding = FrameworkBinding { + adapter: "phase19-synthetic".to_owned(), + kind: EntryKind::ClassMethod { + class: "UserRepository".to_owned(), + method: "find_by_name".to_owned(), + }, + route: None, + request_params: vec![], + response_writer: None, + middleware: vec![], + }; + + stamp_framework_binding(&mut spec, binding); + + assert_eq!( + spec.entry_kind.tag(), + crate::evidence::EntryKindTag::ClassMethod, + "stamping must replace Function with ClassMethod when the binding carries one of the Phase 18 variants", + ); + assert_ne!(pre_tag, spec.entry_kind.tag()); + assert_ne!( + pre_hash, spec.spec_hash, + "spec_hash must change when entry_kind tag flips", + ); + assert_eq!( + spec.framework.as_ref().map(|b| b.adapter.as_str()), + Some("phase19-synthetic"), + ); + } + + /// Companion guard: when the binding carries a legacy unit + /// variant (`Function` / `HttpRoute`), the stamping branch keeps + /// `spec.entry_kind` and `spec.spec_hash` unchanged. + #[test] + fn spec_attach_framework_binding_keeps_legacy_unit_variant_unchanged() { + let mut spec = HarnessSpec { + finding_id: "phase18stamp0002".into(), + entry_file: "src/handler.py".into(), + entry_name: "run".into(), + entry_kind: EntryKind::Function, + lang: Lang::Python, + toolchain_id: "phase18".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: crate::labels::Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/handler.py".into(), + sink_line: 1, + spec_hash: "phase18stamp0002".into(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + }; + let pre_hash = spec.spec_hash.clone(); + + let binding = FrameworkBinding { + adapter: "phase17-synthetic".to_owned(), + kind: EntryKind::Function, + route: None, + request_params: vec![], + response_writer: None, + middleware: vec![], + }; + stamp_framework_binding(&mut spec, binding); + + assert_eq!(spec.entry_kind.tag(), crate::evidence::EntryKindTag::Function); + assert_eq!(spec.spec_hash, pre_hash); + assert!(spec.framework.is_some()); + } } diff --git a/src/dynamic/stubs/mocks.rs b/src/dynamic/stubs/mocks.rs new file mode 100644 index 00000000..cfd5687a --- /dev/null +++ b/src/dynamic/stubs/mocks.rs @@ -0,0 +1,244 @@ +//! Phase 19 (Track M.1) — language-specific mock generators for class +//! constructor parameters. +//! +//! When [`crate::dynamic::lang::LangEmitter::emit`] hits an +//! `EntryKind::ClassMethod` whose constructor takes an injectable +//! dependency (HTTP client, database connection, logger), the per-lang +//! emitter consults this registry to splice in a test double rather +//! than instantiating the real boundary. The double is a tiny source +//! snippet — class / struct / function — that has the same surface as +//! the real type but performs no I/O. +//! +//! The registry is deliberately small: only the three dependency +//! shapes mentioned in Phase 19's brief +//! (`MockHttpClient`, `MockDatabaseConnection`, `MockLogger`) are +//! covered. A future phase that needs richer doubles +//! (`MockCache`, `MockSessionStore`, …) can extend the [`MockKind`] +//! enum + add new branches to [`mock_source`] without re-versioning the +//! caller surface. + +use crate::symbol::Lang; + +/// Discriminator for an injectable dependency the harness may need to +/// stub when constructing a class receiver. +/// +/// The names follow the Phase 19 brief verbatim. Each variant maps to +/// one inline source snippet per language; the snippet declares a +/// constructor-callable type named `MockHttpClient` / +/// `MockDatabaseConnection` / `MockLogger` so the per-lang invocation +/// path can splice it in by name without needing a separate lookup +/// per language. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MockKind { + /// HTTP client surface — exposes `get` / `post` no-ops returning + /// empty strings. + HttpClient, + /// Database connection surface — exposes `execute` / `query` + /// no-ops returning empty result sets. + DatabaseConnection, + /// Logger surface — exposes `info` / `warn` / `error` no-ops. + Logger, +} + +impl MockKind { + /// Canonical mock-type name a per-language emitter can construct. + /// Stable across versions — call sites in lang emitters reference + /// these strings directly. + pub const fn type_name(self) -> &'static str { + match self { + Self::HttpClient => "MockHttpClient", + Self::DatabaseConnection => "MockDatabaseConnection", + Self::Logger => "MockLogger", + } + } +} + +/// Source snippet declaring a `MockKind` test double in `lang`. +/// +/// The snippet is meant to be spliced verbatim into the generated +/// harness source; it declares a public type whose name matches +/// [`MockKind::type_name`] and a public default constructor so the +/// harness's class-method dispatcher can write +/// `new {type_name}()` (or the per-lang equivalent) without further +/// per-mock plumbing. +/// +/// Returns `""` (empty string) when the language has no concept of +/// classes / object dependencies (C, today). The caller is expected +/// to fall through to a payload-only call when the snippet is empty. +pub fn mock_source(kind: MockKind, lang: Lang) -> &'static str { + match (kind, lang) { + // ── Python ────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Python) => { + "class MockHttpClient:\n def get(self, url, **kw): return ''\n def post(self, url, body=None, **kw): return ''\n" + } + (MockKind::DatabaseConnection, Lang::Python) => { + "class MockDatabaseConnection:\n def execute(self, q, *a, **kw): return None\n def query(self, q, *a, **kw): return []\n def close(self): pass\n" + } + (MockKind::Logger, Lang::Python) => { + "class MockLogger:\n def info(self, *a, **kw): pass\n def warn(self, *a, **kw): pass\n def error(self, *a, **kw): pass\n def debug(self, *a, **kw): pass\n" + } + + // ── JavaScript / TypeScript ──────────────────────────────── + (MockKind::HttpClient, Lang::JavaScript | Lang::TypeScript) => { + "class MockHttpClient { get(_u){return ''} post(_u,_b){return ''} }\n" + } + (MockKind::DatabaseConnection, Lang::JavaScript | Lang::TypeScript) => { + "class MockDatabaseConnection { execute(){return null} query(){return []} close(){} }\n" + } + (MockKind::Logger, Lang::JavaScript | Lang::TypeScript) => { + "class MockLogger { info(){} warn(){} error(){} debug(){} }\n" + } + + // ── Java ─────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Java) => { + "static class MockHttpClient { public String get(String u){return \"\";} public String post(String u, String b){return \"\";} }\n" + } + (MockKind::DatabaseConnection, Lang::Java) => { + "static class MockDatabaseConnection { public Object execute(String q){return null;} public java.util.List query(String q){return java.util.Collections.emptyList();} public void close(){} }\n" + } + (MockKind::Logger, Lang::Java) => { + "static class MockLogger { public void info(String s){} public void warn(String s){} public void error(String s){} public void debug(String s){} }\n" + } + + // ── PHP ──────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Php) => { + "class MockHttpClient { public function get($u){return '';} public function post($u, $b = null){return '';} }\n" + } + (MockKind::DatabaseConnection, Lang::Php) => { + "class MockDatabaseConnection { public function execute($q){return null;} public function query($q){return [];} public function close(){} }\n" + } + (MockKind::Logger, Lang::Php) => { + "class MockLogger { public function info($m){} public function warn($m){} public function error($m){} public function debug($m){} }\n" + } + + // ── Ruby ─────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Ruby) => { + "class MockHttpClient\n def get(_u); ''; end\n def post(_u, _b = nil); ''; end\nend\n" + } + (MockKind::DatabaseConnection, Lang::Ruby) => { + "class MockDatabaseConnection\n def execute(_q); nil; end\n def query(_q); []; end\n def close; end\nend\n" + } + (MockKind::Logger, Lang::Ruby) => { + "class MockLogger\n def info(*); end\n def warn(*); end\n def error(*); end\n def debug(*); end\nend\n" + } + + // ── Go ───────────────────────────────────────────────────── + // Go has no classes; we emit struct-shaped doubles with method + // sets that mirror the Python / Java surface so a class-method + // emitter can construct the receiver via `MockX{}`. + (MockKind::HttpClient, Lang::Go) => { + "type MockHttpClient struct{}\nfunc (MockHttpClient) Get(string) string { return \"\" }\nfunc (MockHttpClient) Post(string, string) string { return \"\" }\n" + } + (MockKind::DatabaseConnection, Lang::Go) => { + "type MockDatabaseConnection struct{}\nfunc (MockDatabaseConnection) Execute(string) error { return nil }\nfunc (MockDatabaseConnection) Query(string) []interface{} { return nil }\nfunc (MockDatabaseConnection) Close() {}\n" + } + (MockKind::Logger, Lang::Go) => { + "type MockLogger struct{}\nfunc (MockLogger) Info(string) {}\nfunc (MockLogger) Warn(string) {}\nfunc (MockLogger) Error(string) {}\nfunc (MockLogger) Debug(string) {}\n" + } + + // ── Rust ─────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Rust) => { + "pub struct MockHttpClient;\nimpl MockHttpClient { pub fn new() -> Self { MockHttpClient } pub fn get(&self, _u: &str) -> String { String::new() } pub fn post(&self, _u: &str, _b: &str) -> String { String::new() } }\n" + } + (MockKind::DatabaseConnection, Lang::Rust) => { + "pub struct MockDatabaseConnection;\nimpl MockDatabaseConnection { pub fn new() -> Self { MockDatabaseConnection } pub fn execute(&self, _q: &str) {} pub fn query(&self, _q: &str) -> Vec { Vec::new() } pub fn close(&self) {} }\n" + } + (MockKind::Logger, Lang::Rust) => { + "pub struct MockLogger;\nimpl MockLogger { pub fn new() -> Self { MockLogger } pub fn info(&self, _m: &str) {} pub fn warn(&self, _m: &str) {} pub fn error(&self, _m: &str) {} pub fn debug(&self, _m: &str) {} }\n" + } + + // ── C++ ──────────────────────────────────────────────────── + (MockKind::HttpClient, Lang::Cpp) => { + "struct MockHttpClient { std::string get(const std::string&){return {};} std::string post(const std::string&, const std::string&){return {};} };\n" + } + (MockKind::DatabaseConnection, Lang::Cpp) => { + "struct MockDatabaseConnection { void execute(const std::string&){} std::vector query(const std::string&){return {};} void close(){} };\n" + } + (MockKind::Logger, Lang::Cpp) => { + "struct MockLogger { void info(const std::string&){} void warn(const std::string&){} void error(const std::string&){} void debug(const std::string&){} };\n" + } + + // ── C ────────────────────────────────────────────────────── + // C has no class system; mocks are not applicable. Lang emitter + // routes `ClassMethod` to a plain function call when receiver + // construction is meaningless. + (_, Lang::C) => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn type_names_are_distinct_and_stable() { + assert_eq!(MockKind::HttpClient.type_name(), "MockHttpClient"); + assert_eq!( + MockKind::DatabaseConnection.type_name(), + "MockDatabaseConnection" + ); + assert_eq!(MockKind::Logger.type_name(), "MockLogger"); + } + + #[test] + fn mock_source_python_declares_class() { + let src = mock_source(MockKind::HttpClient, Lang::Python); + assert!(src.contains("class MockHttpClient")); + assert!(src.contains("def get")); + } + + #[test] + fn mock_source_java_uses_static_inner_class() { + let src = mock_source(MockKind::Logger, Lang::Java); + assert!(src.contains("static class MockLogger")); + assert!(src.contains("public void info")); + } + + #[test] + fn mock_source_c_is_empty_no_class_system() { + assert!(mock_source(MockKind::HttpClient, Lang::C).is_empty()); + assert!(mock_source(MockKind::DatabaseConnection, Lang::C).is_empty()); + assert!(mock_source(MockKind::Logger, Lang::C).is_empty()); + } + + #[test] + fn mock_source_rust_struct_with_default_ctor() { + let src = mock_source(MockKind::DatabaseConnection, Lang::Rust); + assert!(src.contains("pub struct MockDatabaseConnection")); + assert!(src.contains("pub fn new")); + } + + #[test] + fn mock_source_go_struct_with_method_set() { + let src = mock_source(MockKind::HttpClient, Lang::Go); + assert!(src.contains("type MockHttpClient struct")); + assert!(src.contains("func (MockHttpClient) Get")); + } + + #[test] + fn every_lang_supports_every_mock_except_c() { + for kind in [ + MockKind::HttpClient, + MockKind::DatabaseConnection, + MockKind::Logger, + ] { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::Go, + Lang::Rust, + Lang::Cpp, + ] { + assert!( + !mock_source(kind, lang).is_empty(), + "{lang:?} must supply a {kind:?} mock" + ); + } + assert!(mock_source(kind, Lang::C).is_empty()); + } + } +} diff --git a/src/dynamic/stubs/mod.rs b/src/dynamic/stubs/mod.rs index f0e4f41c..1d28007d 100644 --- a/src/dynamic/stubs/mod.rs +++ b/src/dynamic/stubs/mod.rs @@ -54,6 +54,7 @@ pub mod filesystem; pub mod http; pub mod ldap_server; +pub mod mocks; pub mod redis; pub mod sql; pub mod xpath_document; @@ -61,6 +62,7 @@ pub mod xpath_document; pub use filesystem::FilesystemStub; pub use http::HttpStub; pub use ldap_server::LdapStub; +pub use mocks::{mock_source, MockKind}; pub use redis::RedisStub; pub use sql::SqlStub; diff --git a/tests/class_method_corpus.rs b/tests/class_method_corpus.rs new file mode 100644 index 00000000..bfed33d7 --- /dev/null +++ b/tests/class_method_corpus.rs @@ -0,0 +1,201 @@ +//! Phase 19 (Track M.1) — `ClassMethod` end-to-end acceptance. +//! +//! Asserts the new `EntryKind::ClassMethod { class, method }` variant +//! is supported by every per-language emitter so the +//! `Inconclusive(EntryKindUnsupported { attempted: ClassMethod })` +//! rate drops to 0% across the ten supported languages. Each +//! sub-test constructs a `HarnessSpec` whose `entry_kind` is +//! `ClassMethod`, drives it through `lang::emit`, and checks the +//! harness source carries the matching `class` + `method` literal +//! plus the per-lang structural marker (probe shim, build command, +//! mock-class declaration when applicable). +//! +//! `cargo nextest run --features dynamic --test class_method_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::lang; +use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; +use nyx_scanner::dynamic::stubs::{mock_source, MockKind}; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; + +const LANGS: &[Lang] = &[ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::Go, + Lang::Rust, + Lang::C, + Lang::Cpp, +]; + +fn entry_file(lang: Lang) -> &'static str { + match lang { + Lang::Python => "tests/dynamic_fixtures/class_method/python/vuln.py", + Lang::JavaScript => "tests/dynamic_fixtures/class_method/javascript/vuln.js", + Lang::TypeScript => "tests/dynamic_fixtures/class_method/typescript/vuln.ts", + Lang::Java => "tests/dynamic_fixtures/class_method/java/Vuln.java", + Lang::Php => "tests/dynamic_fixtures/class_method/php/vuln.php", + Lang::Ruby => "tests/dynamic_fixtures/class_method/ruby/vuln.rb", + Lang::Go => "tests/dynamic_fixtures/class_method/go/vuln.go", + Lang::Rust => "tests/dynamic_fixtures/class_method/rust/vuln.rs", + Lang::C => "tests/dynamic_fixtures/class_method/c/vuln.c", + Lang::Cpp => "tests/dynamic_fixtures/class_method/cpp/vuln.cpp", + } +} + +fn class_for(lang: Lang) -> (&'static str, &'static str) { + match lang { + Lang::Python => ("UserRepository", "find_by_name"), + Lang::Java => ("UserRepository", "findByName"), + Lang::C => ("UserService", "run"), + _ => ("UserService", "run"), + } +} + +fn make_spec(lang: Lang) -> HarnessSpec { + let (class, method) = class_for(lang); + HarnessSpec { + finding_id: "phase19classmth1".into(), + entry_file: entry_file(lang).into(), + entry_name: method.into(), + entry_kind: EntryKind::ClassMethod { + class: class.into(), + method: method.into(), + }, + lang, + toolchain_id: "phase19".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: entry_file(lang).into(), + sink_line: 1, + spec_hash: "phase19classmth1".into(), + derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + } +} + +#[test] +fn class_method_supported_by_every_lang_emitter() { + for lang in LANGS { + let supported = lang::entry_kinds_supported(*lang); + assert!( + supported.contains(&EntryKindTag::ClassMethod), + "{lang:?} must advertise ClassMethod after Phase 19; supported = {supported:?}", + ); + } +} + +#[test] +fn class_method_emit_does_not_short_circuit_to_entry_kind_unsupported() { + for lang in LANGS { + let spec = make_spec(*lang); + let result = lang::emit(&spec); + assert!( + result.is_ok(), + "{lang:?} emit returned {result:?} for ClassMethod spec" + ); + } +} + +#[test] +fn class_method_harness_carries_class_and_method_literal() { + for lang in LANGS { + let spec = make_spec(*lang); + let h = lang::emit(&spec).expect("emit ok"); + let (class, method) = class_for(*lang); + assert!( + h.source.contains(class), + "{lang:?} harness source must reference class {class:?}", + ); + assert!( + h.source.contains(method), + "{lang:?} harness source must reference method {method:?}", + ); + } +} + +#[test] +fn class_method_harness_splices_phase_19_mock_classes_where_lang_has_classes() { + // Languages with a class system embed the MockHttpClient / + // MockDatabaseConnection / MockLogger declarations the + // `stubs::mocks` registry publishes. Go uses a struct registry + // routed through the entry package and does not splice the + // doubles into the harness source; C has no class system. + // Rust's ClassMethod path uses Default::default() — no mocks. + let class_system_langs = [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Java, + Lang::Php, + Lang::Ruby, + ]; + for lang in class_system_langs { + let spec = make_spec(lang); + let h = lang::emit(&spec).expect("emit ok"); + let mock_http = mock_source(MockKind::HttpClient, lang); + assert!( + h.source.contains("MockHttpClient"), + "{lang:?} harness must splice MockHttpClient", + ); + assert!(!mock_http.is_empty()); + } +} + +#[test] +fn class_method_python_dispatch_reads_payload_and_invokes_method() { + let spec = make_spec(Lang::Python); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("NYX_PAYLOAD")); + assert!(h.source.contains("UserRepository")); + assert!(h.source.contains("find_by_name")); + assert!(h.source.contains("_nyx_build_receiver")); +} + +#[test] +fn class_method_java_emits_reflective_dispatch() { + let spec = make_spec(Lang::Java); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("Class.forName")); + assert!(h.source.contains("nyxBuildReceiver")); + assert!(h.source.contains("UserRepository")); +} + +#[test] +fn class_method_go_uses_reflect_receivers_registry() { + let spec = make_spec(Lang::Go); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("entry.NyxReceivers")); + assert!(h.source.contains("MethodByName")); +} + +#[test] +fn class_method_rust_uses_default_constructor() { + let spec = make_spec(Lang::Rust); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("UserService::default()")); + assert!(h.source.contains("instance.run")); +} + +#[test] +fn class_method_c_collapses_to_class_underscore_method_symbol() { + let spec = make_spec(Lang::C); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("UserService_run")); +} + +#[test] +fn class_method_cpp_constructs_default_then_calls_method() { + let spec = make_spec(Lang::Cpp); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("UserService instance;")); + assert!(h.source.contains("instance.run")); +} diff --git a/tests/dynamic_fixtures/class_method/c/benign.c b/tests/dynamic_fixtures/class_method/c/benign.c new file mode 100644 index 00000000..de88741b --- /dev/null +++ b/tests/dynamic_fixtures/class_method/c/benign.c @@ -0,0 +1,16 @@ +/* Phase 19 (Track M.1) — class-method benign control for C. */ +#include +#include +#include +#include + +void UserService_run(const char *input, size_t len) { + (void)len; + /* Uses execve via fork; the shell never sees `input`. */ + pid_t pid = fork(); + if (pid == 0) { + char *argv[] = { (char*)"/bin/echo", (char*)(input ? input : ""), NULL }; + execv("/bin/echo", argv); + _exit(127); + } +} diff --git a/tests/dynamic_fixtures/class_method/c/vuln.c b/tests/dynamic_fixtures/class_method/c/vuln.c new file mode 100644 index 00000000..578270f9 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/c/vuln.c @@ -0,0 +1,16 @@ +/* Phase 19 (Track M.1) — class-method vuln fixture for C. + * + * C has no class system; the harness calls a free function whose name + * follows the `_` convention (`UserService_run`). The + * function piping `input` straight into `system(3)` is the SINK. */ +#include +#include +#include + +void UserService_run(const char *input, size_t len) { + (void)len; + char buf[512]; + snprintf(buf, sizeof(buf), "echo %s", input ? input : ""); + /* SINK: tainted input → system(3) */ + system(buf); +} diff --git a/tests/dynamic_fixtures/class_method/cpp/benign.cpp b/tests/dynamic_fixtures/class_method/cpp/benign.cpp new file mode 100644 index 00000000..2fa91fe5 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/cpp/benign.cpp @@ -0,0 +1,19 @@ +// Phase 19 (Track M.1) — class-method benign control for C++. +#include +#include +#include + +class UserService { +public: + UserService() = default; + void run(const std::string& input) { + pid_t pid = fork(); + if (pid == 0) { + const char* argv[] = { "/bin/echo", input.c_str(), nullptr }; + execv("/bin/echo", const_cast(argv)); + _exit(127); + } + int status = 0; + waitpid(pid, &status, 0); + } +}; diff --git a/tests/dynamic_fixtures/class_method/cpp/vuln.cpp b/tests/dynamic_fixtures/class_method/cpp/vuln.cpp new file mode 100644 index 00000000..03f1bc42 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/cpp/vuln.cpp @@ -0,0 +1,17 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for C++. +// +// UserService::run pipes user input into `system(3)`. Default +// constructor exists; the harness can build the receiver with +// `UserService instance;`. +#include +#include + +class UserService { +public: + UserService() = default; + void run(const std::string& input) { + std::string cmd = std::string("echo ") + input; + // SINK: tainted input → system(3) + std::system(cmd.c_str()); + } +}; diff --git a/tests/dynamic_fixtures/class_method/go/benign.go b/tests/dynamic_fixtures/class_method/go/benign.go new file mode 100644 index 00000000..1ab5f59a --- /dev/null +++ b/tests/dynamic_fixtures/class_method/go/benign.go @@ -0,0 +1,15 @@ +// Phase 19 (Track M.1) — class-method benign control for Go. +package entry + +import "os/exec" + +type UserService struct{} + +func (UserService) Run(input string) string { + out, _ := exec.Command("/bin/echo", input).Output() + return string(out) +} + +var NyxReceivers = map[string]interface{}{ + "UserService": UserService{}, +} diff --git a/tests/dynamic_fixtures/class_method/go/vuln.go b/tests/dynamic_fixtures/class_method/go/vuln.go new file mode 100644 index 00000000..fd314bad --- /dev/null +++ b/tests/dynamic_fixtures/class_method/go/vuln.go @@ -0,0 +1,21 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for Go. +// +// UserService.Run accepts user input and passes it to `sh -c` so the +// shell interprets it. The fixture publishes its instance through the +// well-known `NyxReceivers` registry the harness uses to construct +// receivers reflectively. +package entry + +import "os/exec" + +type UserService struct{} + +func (UserService) Run(input string) string { + // SINK: tainted input → shell -c + out, _ := exec.Command("sh", "-c", "echo "+input).Output() + return string(out) +} + +var NyxReceivers = map[string]interface{}{ + "UserService": UserService{}, +} diff --git a/tests/dynamic_fixtures/class_method/java/Benign.java b/tests/dynamic_fixtures/class_method/java/Benign.java new file mode 100644 index 00000000..5b707730 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/java/Benign.java @@ -0,0 +1,20 @@ +// Phase 19 (Track M.1) — class-method benign control for Java. +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public class Benign { + public static class UserRepository { + public UserRepository() {} + + public void findByName(String name) throws SQLException { + Connection c = DriverManager.getConnection("jdbc:sqlite::memory:"); + PreparedStatement ps = c.prepareStatement("SELECT id FROM users WHERE name = ?"); + ps.setString(1, name); + ps.execute(); + ps.close(); + c.close(); + } + } +} diff --git a/tests/dynamic_fixtures/class_method/java/Vuln.java b/tests/dynamic_fixtures/class_method/java/Vuln.java new file mode 100644 index 00000000..2576908c --- /dev/null +++ b/tests/dynamic_fixtures/class_method/java/Vuln.java @@ -0,0 +1,25 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for Java. +// +// UserRepository.findByName concatenates user input into a JDBC SQL +// statement. Default constructor exists so the harness can build the +// receiver without stubbing dependencies. +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.sql.SQLException; + +public class Vuln { + public static class UserRepository { + public UserRepository() {} + + public void findByName(String name) throws SQLException { + Connection c = DriverManager.getConnection("jdbc:sqlite::memory:"); + Statement s = c.createStatement(); + // SINK: tainted concat into SQL + String sql = "SELECT id FROM users WHERE name = '" + name + "'"; + s.execute(sql); + s.close(); + c.close(); + } + } +} diff --git a/tests/dynamic_fixtures/class_method/javascript/benign.js b/tests/dynamic_fixtures/class_method/javascript/benign.js new file mode 100644 index 00000000..af55c490 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/javascript/benign.js @@ -0,0 +1,15 @@ +// Phase 19 (Track M.1) — class-method benign control for JavaScript. +// +// UserService.run routes the input through execFileSync with argv form so +// the shell never interprets the string. +'use strict'; +const { execFileSync } = require('child_process'); + +class UserService { + constructor() {} + run(input) { + return execFileSync('/bin/echo', [input]).toString(); + } +} + +module.exports = { UserService }; diff --git a/tests/dynamic_fixtures/class_method/javascript/vuln.js b/tests/dynamic_fixtures/class_method/javascript/vuln.js new file mode 100644 index 00000000..a87f4b4e --- /dev/null +++ b/tests/dynamic_fixtures/class_method/javascript/vuln.js @@ -0,0 +1,16 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for JavaScript. +// +// UserService.run forwards a tainted string straight into child_process.exec, +// classic OS command injection. Default ctor — no stubbed deps needed. +'use strict'; +const { execSync } = require('child_process'); + +class UserService { + constructor() {} + run(input) { + // SINK: untrusted input → shell + return execSync('echo ' + input).toString(); + } +} + +module.exports = { UserService }; diff --git a/tests/dynamic_fixtures/class_method/php/benign.php b/tests/dynamic_fixtures/class_method/php/benign.php new file mode 100644 index 00000000..be03409a --- /dev/null +++ b/tests/dynamic_fixtures/class_method/php/benign.php @@ -0,0 +1,10 @@ + String { + let out = std::process::Command::new("/bin/echo") + .arg(input) + .output() + .expect("exec"); + String::from_utf8_lossy(&out.stdout).into_owned() + } +} diff --git a/tests/dynamic_fixtures/class_method/rust/vuln.rs b/tests/dynamic_fixtures/class_method/rust/vuln.rs new file mode 100644 index 00000000..0a751535 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/rust/vuln.rs @@ -0,0 +1,21 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for Rust. +// +// `UserService::run` shells out with a concatenated `sh -c `, +// classic OS command injection. Derives Default so the harness can +// build the receiver without manual stubbing. + +#[derive(Default)] +pub struct UserService; + +impl UserService { + pub fn run(&self, input: &str) -> String { + // SINK: tainted input → shell -c + let cmd = format!("echo {}", input); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .expect("exec"); + String::from_utf8_lossy(&out.stdout).into_owned() + } +} diff --git a/tests/dynamic_fixtures/class_method/typescript/benign.ts b/tests/dynamic_fixtures/class_method/typescript/benign.ts new file mode 100644 index 00000000..5e6e64d8 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/typescript/benign.ts @@ -0,0 +1,9 @@ +// Phase 19 (Track M.1) — class-method benign control for TypeScript. +import { execFileSync } from 'child_process'; + +export class UserService { + constructor() {} + run(input: string): string { + return execFileSync('/bin/echo', [input]).toString(); + } +} diff --git a/tests/dynamic_fixtures/class_method/typescript/vuln.ts b/tests/dynamic_fixtures/class_method/typescript/vuln.ts new file mode 100644 index 00000000..d163b18f --- /dev/null +++ b/tests/dynamic_fixtures/class_method/typescript/vuln.ts @@ -0,0 +1,12 @@ +// Phase 19 (Track M.1) — class-method vuln fixture for TypeScript. +// +// UserService.run forwards user input directly to a shell. Default ctor. +import { execSync } from 'child_process'; + +export class UserService { + constructor() {} + run(input: string): string { + // SINK: untrusted input flows into the shell + return execSync('echo ' + input).toString(); + } +}