From acec041676dcbdbd36501eaa29e0116e7d8dc284 Mon Sep 17 00:00:00 2001 From: elipeter Date: Sun, 24 May 2026 21:45:54 -0500 Subject: [PATCH] refactor(dynamic): add recursive dependency resolution for Java, Go, and Ruby receivers, expand corresponding tests --- src/dynamic/lang/go.rs | 69 ++++++++++++++++++- src/dynamic/lang/java.rs | 36 +++++++--- src/dynamic/lang/ruby.rs | 38 +++++++++- tests/class_method_corpus.rs | 45 ++++++++++++ .../class_method/go_recursive_deps/benign.go | 32 +++++++++ .../class_method/go_recursive_deps/vuln.go | 33 +++++++++ .../java_recursive_deps/Benign.java | 32 +++++++++ .../java_recursive_deps/Vuln.java | 39 +++++++++++ .../ruby_recursive_deps/benign.rb | 26 +++++++ .../class_method/ruby_recursive_deps/vuln.rb | 26 +++++++ 10 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go create mode 100644 tests/dynamic_fixtures/class_method/go_recursive_deps/vuln.go create mode 100644 tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java create mode 100644 tests/dynamic_fixtures/class_method/java_recursive_deps/Vuln.java create mode 100644 tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb create mode 100644 tests/dynamic_fixtures/class_method/ruby_recursive_deps/vuln.rb diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index a8c29bc8..f1effc2d 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -1876,11 +1876,78 @@ func nyxBuildReceiver(structName string) (reflect.Value, error) {{ // the auto-generated file references the target type by name and // the Go compiler enforces the contract. if r, ok := entry.NyxAutoReceivers[structName]; ok {{ - return reflect.ValueOf(r), nil + return nyxPopulateReceiver(reflect.ValueOf(r), 3), nil }} return reflect.Value{{}}, fmt.Errorf("class not found: %s", structName) }} +func nyxPopulateReceiver(v reflect.Value, depth int) reflect.Value {{ + seen := map[reflect.Type]bool{{}} + return nyxPopulateValue(v, depth, seen) +}} + +func nyxPopulateValue(v reflect.Value, depth int, seen map[reflect.Type]bool) reflect.Value {{ + if !v.IsValid() || depth < 0 {{ + return v + }} + if v.Kind() == reflect.Pointer {{ + if v.IsNil() {{ + if v.Type().Elem().Kind() != reflect.Struct {{ + return v + }} + v = reflect.New(v.Type().Elem()) + }} + nyxPopulateStruct(v.Elem(), depth, seen) + return v + }} + if v.Kind() == reflect.Struct {{ + out := reflect.New(v.Type()).Elem() + out.Set(v) + nyxPopulateStruct(out, depth, seen) + return out + }} + return v +}} + +func nyxPopulateStruct(v reflect.Value, depth int, seen map[reflect.Type]bool) {{ + if !v.IsValid() || v.Kind() != reflect.Struct || depth < 0 {{ + return + }} + t := v.Type() + if seen[t] {{ + return + }} + seen[t] = true + defer delete(seen, t) + for i := 0; i < v.NumField(); i++ {{ + field := v.Field(i) + if !field.CanSet() {{ + continue + }} + dep := nyxBuildValueForType(field.Type(), depth-1, seen) + if dep.IsValid() && dep.Type().AssignableTo(field.Type()) {{ + field.Set(dep) + }} + }} +}} + +func nyxBuildValueForType(t reflect.Type, depth int, seen map[reflect.Type]bool) reflect.Value {{ + if depth < 0 {{ + return reflect.Value{{}} + }} + if t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct {{ + ptr := reflect.New(t.Elem()) + nyxPopulateStruct(ptr.Elem(), depth, seen) + return ptr + }} + if t.Kind() == reflect.Struct {{ + value := reflect.New(t).Elem() + nyxPopulateStruct(value, depth, seen) + return value + }} + return reflect.Value{{}} +}} + func nyxPayload() string {{ if v := os.Getenv("NYX_PAYLOAD"); v != "" {{ return v diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index e0ee4a12..9f248c44 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -3313,6 +3313,9 @@ fn emit_class_method_harness( import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; public class NyxHarness {{ {probe} @@ -3322,6 +3325,14 @@ public class NyxHarness {{ {mock_log} static Object nyxBuildReceiver(Class cls) throws Exception {{ + return nyxBuildReceiver(cls, 3, new HashSet>()); + }} + + static Object nyxBuildReceiver(Class cls, int depth, Set> seen) throws Exception {{ + if (cls == null || seen.contains(cls)) {{ + return null; + }} + seen.add(cls); // Preferred path: zero-arg ctor. try {{ Constructor c = cls.getDeclaredConstructor(); @@ -3335,22 +3346,28 @@ public class NyxHarness {{ Class[] params = c.getParameterTypes(); Object[] args = new Object[params.length]; for (int i = 0; i < params.length; i++) {{ - args[i] = nyxStubForType(params[i]); + args[i] = nyxValueForType(params[i], depth - 1, new HashSet>(seen)); }} 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(); + static Object nyxValueForType(Class t, int depth, Set> seen) {{ 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; + if (depth >= 0 && !t.isPrimitive() && !t.isInterface() && !Modifier.isAbstract(t.getModifiers())) {{ + try {{ + Object receiver = nyxBuildReceiver(t, depth, seen); + if (receiver != null) return receiver; + }} catch (Throwable ignore) {{}} + }} + 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(); return null; }} @@ -3380,10 +3397,13 @@ public class NyxHarness {{ 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]); + mArgs[i] = params[i].equals(String.class) ? payload : nyxValueForType(params[i], 2, new HashSet>()); }} - match.invoke(instance, mArgs); + Object result = match.invoke(instance, mArgs); System.out.println("__NYX_SINK_HIT__"); + if (result != null) {{ + System.out.println(result.toString()); + }} }} catch (InvocationTargetException ite) {{ Throwable cause = ite.getCause() == null ? ite : ite.getCause(); System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage()); diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 5f8ca7da..089f572e 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -578,11 +578,47 @@ unless Object.const_defined?(cls_name) end cls = Object.const_get(cls_name) -def _nyx_build_receiver(cls) +def _nyx_known_mock_for(name) + n = name.to_s.downcase + return MockHttpClient.new if n.include?('http') || n.include?('client') + return MockDatabaseConnection.new if n.include?('db') || n.include?('conn') || n.include?('repo') || n.include?('session') + return MockLogger.new if n.include?('log') + nil +end + +def _nyx_const_for_param(name) + raw = name.to_s + camel = raw.split('_').reject(&:empty?).map {{ |part| part[0].upcase + part[1..].to_s }}.join + [camel, raw].each do |candidate| + next if candidate.empty? + if Object.const_defined?(candidate, false) + value = Object.const_get(candidate) + return value if value.is_a?(Class) + end + end + nil +end + +def _nyx_build_receiver(cls, depth = 3, seen = {{}}) + return nil if seen[cls] + seen = seen.merge(cls => true) begin return cls.new rescue ArgumentError end + begin + init = cls.instance_method(:initialize) + deps = init.parameters.map do |_kind, name| + dep = nil + if depth > 0 && name + dep_cls = _nyx_const_for_param(name) + dep = _nyx_build_receiver(dep_cls, depth - 1, seen) if dep_cls && dep_cls != cls + end + dep || _nyx_known_mock_for(name) + end + return cls.new(*deps) + rescue StandardError + end begin return cls.new(MockHttpClient.new, MockDatabaseConnection.new, MockLogger.new) rescue StandardError diff --git a/tests/class_method_corpus.rs b/tests/class_method_corpus.rs index 8ddadec9..828156e8 100644 --- a/tests/class_method_corpus.rs +++ b/tests/class_method_corpus.rs @@ -182,14 +182,26 @@ fn class_method_java_emits_reflective_dispatch() { let h = lang::emit(&spec).expect("emit ok"); assert!(h.source.contains("Class.forName")); assert!(h.source.contains("nyxBuildReceiver")); + assert!(h.source.contains("nyxValueForType(params[i], depth - 1")); + assert!(h.source.contains("Object result = match.invoke")); assert!(h.source.contains("UserRepository")); } +#[test] +fn class_method_ruby_dispatch_builds_recursive_receiver() { + let spec = make_spec(Lang::Ruby); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("_nyx_build_receiver(cls, depth = 3")); + assert!(h.source.contains("_nyx_const_for_param")); + assert!(h.source.contains("depth - 1")); +} + #[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.NyxAutoReceivers")); + assert!(h.source.contains("nyxPopulateReceiver")); assert!(h.source.contains("MethodByName")); let registry = h .extra_files @@ -284,6 +296,17 @@ mod e2e_phase_19 { cap: Cap::CODE_EXEC, bins: &["ruby"], }, + Case { + lang: Lang::Ruby, + fixture_dir: "ruby_recursive_deps", + vuln_file: "vuln.rb", + benign_file: "benign.rb", + vuln_class: "UserService", + benign_class: "UserService", + method: "run", + cap: Cap::CODE_EXEC, + bins: &["ruby"], + }, Case { lang: Lang::JavaScript, fixture_dir: "javascript", @@ -361,6 +384,17 @@ mod e2e_phase_19 { cap: Cap::CODE_EXEC, bins: &["java", "javac"], }, + Case { + lang: Lang::Java, + fixture_dir: "java_recursive_deps", + vuln_file: "Vuln.java", + benign_file: "Benign.java", + vuln_class: "Vuln$UserService", + benign_class: "Benign$UserService", + method: "run", + cap: Cap::CODE_EXEC, + bins: &["java", "javac"], + }, Case { lang: Lang::Go, fixture_dir: "go", @@ -372,6 +406,17 @@ mod e2e_phase_19 { cap: Cap::CODE_EXEC, bins: &["go"], }, + Case { + lang: Lang::Go, + fixture_dir: "go_recursive_deps", + vuln_file: "vuln.go", + benign_file: "benign.go", + vuln_class: "UserService", + benign_class: "UserService", + method: "Run", + cap: Cap::CODE_EXEC, + bins: &["go"], + }, Case { lang: Lang::Rust, fixture_dir: "rust", diff --git a/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go b/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go new file mode 100644 index 00000000..610fbf73 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/go_recursive_deps/benign.go @@ -0,0 +1,32 @@ +// Benign control for recursively populated Go struct dependencies. +package entry + +import "strings" + +type ShellRunner struct{} + +func (ShellRunner) Run(command string) string { + return strings.ReplaceAll(command, "NYX_PWN_CMDI", "") +} + +type UserRepository struct { + Runner *ShellRunner +} + +func (r UserRepository) Find(input string) string { + if r.Runner == nil { + return "" + } + return r.Runner.Run(input) +} + +type UserService struct { + Repository *UserRepository +} + +func (s UserService) Run(input string) string { + if s.Repository == nil { + return "" + } + return s.Repository.Find(input) +} diff --git a/tests/dynamic_fixtures/class_method/go_recursive_deps/vuln.go b/tests/dynamic_fixtures/class_method/go_recursive_deps/vuln.go new file mode 100644 index 00000000..0b8cf95b --- /dev/null +++ b/tests/dynamic_fixtures/class_method/go_recursive_deps/vuln.go @@ -0,0 +1,33 @@ +// Class-method fixture with recursively populated Go struct dependencies. +package entry + +import "os/exec" + +type ShellRunner struct{} + +func (ShellRunner) Run(command string) string { + out, _ := exec.Command("sh", "-c", "true "+command).Output() + return string(out) +} + +type UserRepository struct { + Runner *ShellRunner +} + +func (r UserRepository) Find(input string) string { + if r.Runner == nil { + return "" + } + return r.Runner.Run(input) +} + +type UserService struct { + Repository *UserRepository +} + +func (s UserService) Run(input string) string { + if s.Repository == nil { + return "" + } + return s.Repository.Find(input) +} diff --git a/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java b/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java new file mode 100644 index 00000000..bcc301db --- /dev/null +++ b/tests/dynamic_fixtures/class_method/java_recursive_deps/Benign.java @@ -0,0 +1,32 @@ +// Benign control for recursively constructed Java dependencies. +public class Benign { + public static class ShellRunner { + public String run(String command) { + return command.replace("NYX_PWN_CMDI", ""); + } + } + + public static class UserRepository { + private final ShellRunner shellRunner; + + public UserRepository(ShellRunner shellRunner) { + this.shellRunner = shellRunner; + } + + public String find(String input) { + return shellRunner.run(input); + } + } + + public static class UserService { + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public String run(String input) { + return userRepository.find(input); + } + } +} diff --git a/tests/dynamic_fixtures/class_method/java_recursive_deps/Vuln.java b/tests/dynamic_fixtures/class_method/java_recursive_deps/Vuln.java new file mode 100644 index 00000000..61a4c2a0 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/java_recursive_deps/Vuln.java @@ -0,0 +1,39 @@ +// Class-method fixture with recursively constructed Java dependencies. +import java.io.InputStream; + +public class Vuln { + public static class ShellRunner { + public String run(String command) throws Exception { + Process p = new ProcessBuilder("sh", "-c", "true " + command) + .redirectErrorStream(true) + .start(); + try (InputStream in = p.getInputStream()) { + return new String(in.readAllBytes()); + } + } + } + + public static class UserRepository { + private final ShellRunner shellRunner; + + public UserRepository(ShellRunner shellRunner) { + this.shellRunner = shellRunner; + } + + public String find(String input) throws Exception { + return shellRunner.run(input); + } + } + + public static class UserService { + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public String run(String input) throws Exception { + return userRepository.find(input); + } + } +} diff --git a/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb new file mode 100644 index 00000000..3d9c8e47 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/benign.rb @@ -0,0 +1,26 @@ +# Benign control for recursively constructed Ruby dependencies. +class ShellRunner + def run(command) + command.gsub('NYX_PWN_CMDI', '') + end +end + +class UserRepository + def initialize(shell_runner) + @shell_runner = shell_runner + end + + def find(input) + @shell_runner.run(input) + end +end + +class UserService + def initialize(user_repository) + @user_repository = user_repository + end + + def run(input) + @user_repository.find(input) + end +end diff --git a/tests/dynamic_fixtures/class_method/ruby_recursive_deps/vuln.rb b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/vuln.rb new file mode 100644 index 00000000..19a3530b --- /dev/null +++ b/tests/dynamic_fixtures/class_method/ruby_recursive_deps/vuln.rb @@ -0,0 +1,26 @@ +# Class-method fixture with recursively constructed Ruby dependencies. +class ShellRunner + def run(command) + `true #{command}` + end +end + +class UserRepository + def initialize(shell_runner) + @shell_runner = shell_runner + end + + def find(input) + @shell_runner.run(input) + end +end + +class UserService + def initialize(user_repository) + @user_repository = user_repository + end + + def run(input) + @user_repository.find(input) + end +end