From 3486056f5e72e4bf2d238af78a6447ca2d8afedc Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 12:37:54 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0002 (20260522T163126Z-7d60) --- src/dynamic/corpus/json_parse/go.rs | 54 ++++ src/dynamic/corpus/json_parse/mod.rs | 3 + src/dynamic/corpus/json_parse/php.rs | 54 ++++ src/dynamic/corpus/json_parse/rust.rs | 54 ++++ src/dynamic/corpus/registry.rs | 3 + src/dynamic/lang/go.rs | 270 +++++++++++++++++- src/dynamic/lang/php.rs | 266 +++++++++++++++++ src/dynamic/lang/rust.rs | 230 +++++++++++++++ .../json_parse_depth/go/vuln.go | 34 +++ .../json_parse_depth/php/vuln.php | 37 +++ .../json_parse_depth/rust/vuln.rs | 34 +++ tests/json_parse_corpus.rs | 116 ++++++-- 12 files changed, 1129 insertions(+), 26 deletions(-) create mode 100644 src/dynamic/corpus/json_parse/go.rs create mode 100644 src/dynamic/corpus/json_parse/php.rs create mode 100644 src/dynamic/corpus/json_parse/rust.rs create mode 100644 tests/dynamic_fixtures/json_parse_depth/go/vuln.go create mode 100644 tests/dynamic_fixtures/json_parse_depth/php/vuln.php create mode 100644 tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs diff --git a/src/dynamic/corpus/json_parse/go.rs b/src/dynamic/corpus/json_parse/go.rs new file mode 100644 index 00000000..9e55444d --- /dev/null +++ b/src/dynamic/corpus/json_parse/go.rs @@ -0,0 +1,54 @@ +//! Go `Cap::JSON_PARSE` payloads. +//! +//! The depth pair shares a single fixture; the payload tag +//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. Go has +//! no prototype-pollution surface so the canary half of the slice is +//! intentionally omitted. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const MAX_DEPTH: u32 = 64; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_JSON_DEEP", + label: "json-parse-go-depth-bomb", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/go/vuln.go"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + benign_control: Some(PayloadRef { + label: "json-parse-go-depth-shallow", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_JSON_SHALLOW", + label: "json-parse-go-depth-shallow", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/go/vuln.go"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/json_parse/mod.rs b/src/dynamic/corpus/json_parse/mod.rs index 5742820e..de0fba57 100644 --- a/src/dynamic/corpus/json_parse/mod.rs +++ b/src/dynamic/corpus/json_parse/mod.rs @@ -16,6 +16,9 @@ //! benign control sends a JSON literal whose top-level key is the //! regular property `data`, leaving the chain untouched. +pub mod go; pub mod javascript; +pub mod php; pub mod python; pub mod ruby; +pub mod rust; diff --git a/src/dynamic/corpus/json_parse/php.rs b/src/dynamic/corpus/json_parse/php.rs new file mode 100644 index 00000000..fa8dff19 --- /dev/null +++ b/src/dynamic/corpus/json_parse/php.rs @@ -0,0 +1,54 @@ +//! PHP `Cap::JSON_PARSE` payloads. +//! +//! The depth pair shares a single fixture; the payload tag +//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. PHP has +//! no prototype-pollution surface so the canary half of the slice is +//! intentionally omitted. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const MAX_DEPTH: u32 = 64; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_JSON_DEEP", + label: "json-parse-php-depth-bomb", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/php/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + benign_control: Some(PayloadRef { + label: "json-parse-php-depth-shallow", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_JSON_SHALLOW", + label: "json-parse-php-depth-shallow", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/php/vuln.php"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/json_parse/rust.rs b/src/dynamic/corpus/json_parse/rust.rs new file mode 100644 index 00000000..bcae5458 --- /dev/null +++ b/src/dynamic/corpus/json_parse/rust.rs @@ -0,0 +1,54 @@ +//! Rust `Cap::JSON_PARSE` payloads. +//! +//! The depth pair shares a single fixture; the payload tag +//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch. Rust has +//! no prototype-pollution surface so the canary half of the slice is +//! intentionally omitted. + +use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef}; +use crate::dynamic::oracle::ProbePredicate; + +const MAX_DEPTH: u32 = 64; + +pub const PAYLOADS: &[CuratedPayload] = &[ + CuratedPayload { + bytes: b"NYX_JSON_DEEP", + label: "json-parse-rust-depth-bomb", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: false, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs"], + oob_nonce_slot: false, + probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + benign_control: Some(PayloadRef { + label: "json-parse-rust-depth-shallow", + }), + no_benign_control_rationale: None, + }, + CuratedPayload { + bytes: b"NYX_JSON_SHALLOW", + label: "json-parse-rust-depth-shallow", + oracle: Oracle::SinkProbe { + predicates: &[ProbePredicate::JsonParseExcessiveDepth { + max_depth: MAX_DEPTH, + }], + }, + is_benign: true, + provenance: PayloadProvenance::Curated, + since_corpus_version: 15, + deprecated_at_corpus_version: None, + fixture_paths: &["tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs"], + oob_nonce_slot: false, + probe_predicates: &[], + benign_control: None, + no_benign_control_rationale: None, + }, +]; diff --git a/src/dynamic/corpus/registry.rs b/src/dynamic/corpus/registry.rs index 9f00f2b1..ab0a99c0 100644 --- a/src/dynamic/corpus/registry.rs +++ b/src/dynamic/corpus/registry.rs @@ -199,8 +199,11 @@ const ENTRIES: &[(Cap, Lang, &[CuratedPayload])] = &[ Lang::JavaScript, json_parse::javascript::PAYLOADS, ), + (Cap::JSON_PARSE, Lang::Go, json_parse::go::PAYLOADS), + (Cap::JSON_PARSE, Lang::Php, json_parse::php::PAYLOADS), (Cap::JSON_PARSE, Lang::Python, json_parse::python::PAYLOADS), (Cap::JSON_PARSE, Lang::Ruby, json_parse::ruby::PAYLOADS), + (Cap::JSON_PARSE, Lang::Rust, json_parse::rust::PAYLOADS), ( Cap::UNAUTHORIZED_ID, Lang::Python, diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index c73c4f7a..f2c2507e 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -589,7 +589,21 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_crypto_harness(spec)); } - // Phase 19 (Track M.1): ClassMethod short-circuit. Go has no + // JSON_PARSE depth-bomb short-circuit. The + // Go harness imports the fixture under `internal/vulnentry`, + // invokes `vulnentry.(payload)`, then walks the returned + // value iteratively and emits a + // `ProbeKind::JsonParse { depth, excessive_depth }` probe. The + // fixture's `Run` returns the parsed `interface{}` (or `nil` when + // `encoding/json.Unmarshal` fails) so the harness can drive the + // depth walker without having to intercept the parse call site + // itself — Go can't monkey-patch the stdlib parser and a fixture- + // side helper would have to be co-located with the entry package. + if spec.expected_cap == crate::labels::Cap::JSON_PARSE { + return Ok(emit_json_parse_harness(spec)); + } + + // 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 @@ -600,7 +614,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_class_method_harness(class, method)); } - // Phase 20 (Track M.2): MessageHandler short-circuit. Picks the + // MessageHandler short-circuit. Picks the // broker loopback (Pub/Sub or NATS) by inspecting the spec's // framework adapter id and dispatches the payload synchronously to // the named handler function in the entry package. @@ -608,7 +622,7 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_message_handler_harness(spec, queue)); } - // Phase 21 (Track M.3): GraphQLResolver short-circuit (gqlgen). + // GraphQLResolver short-circuit (gqlgen). if let crate::evidence::EntryKind::GraphQLResolver { type_name, field } = &spec.entry_kind { return Ok(emit_graphql_resolver_harness( &spec.entry_name, @@ -1418,6 +1432,144 @@ func nyxWeakKeyProbe(keyInt uint64) {{ } } +/// Phase 11 (Track J.9) JSON_PARSE depth-bomb harness for Go. +/// +/// Imports the fixture under `internal/vulnentry`, invokes +/// `vulnentry.(payload)`, and walks the returned value +/// iteratively to emit a +/// [`crate::dynamic::probe::ProbeKind::JsonParse`] probe. The +/// fixture's `Run` is expected to call `encoding/json.Unmarshal` +/// (which is iterative in the Go stdlib so deeply-nested input never +/// panics) and return the parsed `interface{}` so the harness can +/// drive the depth walker post-parse. Falls back to a payload-only +/// path that emits `JsonParse { depth: 0, excessive_depth: false }` +/// when the fixture source is unreachable so the universal sink-hit +/// path still fires. +pub fn emit_json_parse_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let go_mod = generate_go_mod(); + let entry_fn = capitalize_first(&spec.entry_name); + let entry_source = read_entry_source(&spec.entry_file); + let mut extra_files = vec![("go.mod".to_owned(), go_mod)]; + let tier_a_active = !entry_source.is_empty(); + let (extra_imports, via_fixture_decl, via_fixture_invoke) = if tier_a_active { + let rewritten = rewrite_package(&entry_source, "vulnentry"); + extra_files.push(("internal/vulnentry/vulnentry.go".to_owned(), rewritten)); + let decl = format!( + r##"const nyxJsonMaxWalk = 4096 + +func nyxJsonCountDepth(parsed interface{{}}) int {{ + type frame struct {{ + v interface{{}} + depth int + }} + maxDepth := 0 + stack := []frame{{{{v: parsed, depth: 1}}}} + visited := 0 + for len(stack) > 0 {{ + f := stack[len(stack)-1] + stack = stack[:len(stack)-1] + visited++ + if visited > nyxJsonMaxWalk {{ + break + }} + if f.depth > maxDepth {{ + maxDepth = f.depth + }} + switch cur := f.v.(type) {{ + case map[string]interface{{}}: + for _, child := range cur {{ + stack = append(stack, frame{{v: child, depth: f.depth + 1}}) + }} + case []interface{{}}: + for _, child := range cur {{ + stack = append(stack, frame{{v: child, depth: f.depth + 1}}) + }} + }} + }} + return maxDepth +}} + +func nyxJsonParseViaFixture(payload string) (int, bool, bool) {{ + var depth int + var excessive bool + var invoked bool + defer func() {{ _ = recover() }}() + parsed := vulnentry.{entry_fn}(payload) + invoked = true + depth = nyxJsonCountDepth(parsed) + excessive = depth > 64 + return depth, excessive, invoked +}} + +"## + ); + let invoke = "\tdepth, excessive, fixtureInvoked := nyxJsonParseViaFixture(payload)\n\tif !fixtureInvoked {\n\t\tdepth = 0\n\t\texcessive = false\n\t}\n\tnyxJsonParseProbe(depth, excessive)\n".to_owned(); + ( + "\n\t\"nyx-harness/internal/vulnentry\"\n", + decl, + invoke, + ) + } else { + ( + "", + String::new(), + "\tnyxJsonParseProbe(0, false)\n".to_owned(), + ) + }; + + let source = format!( + r##"// Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9). +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" +{extra_imports}) + +{shim} + +func nyxJsonParseProbe(depth int, excessive bool) {{ + __nyx_emit(map[string]interface{{}}{{ + "sink_callee": "json.Unmarshal", + "args": []map[string]interface{{}}{{ + {{"kind": "Int", "value": depth}}, + }}, + "captured_at_ns": uint64(time.Now().UnixNano()), + "payload_id": os.Getenv("NYX_PAYLOAD_ID"), + "kind": map[string]interface{{}}{{ + "kind": "JsonParse", + "depth": depth, + "excessive_depth": excessive, + }}, + "witness": __nyx_witness("json.Unmarshal", []string{{fmt.Sprintf("%d", depth)}}), + }}) +}} + +{via_fixture_decl}func main() {{ + __nyx_install_crash_guard("json.Unmarshal") + defer __nyx_recover_crash("json.Unmarshal")() + payload := os.Getenv("NYX_PAYLOAD") +{via_fixture_invoke} fmt.Println("__NYX_SINK_HIT__") + body, _ := json.Marshal(map[string]interface{{}}{{"payload_len": len(payload)}}) + fmt.Println(string(body)) +}} +"## + ); + HarnessSource { + source, + filename: "main.go".to_owned(), + command: vec!["./nyx_harness".to_owned()], + extra_files, + entry_subpath: Some("entry/entry.go".to_owned()), + } +} + /// Phase 19 (Track M.1) — class-method harness for Go. /// /// `class` is mapped to a struct type declared in `entry/entry.go` @@ -2597,4 +2749,116 @@ mod tests { "fallback path must still emit a weak-key probe so the universal sink-hit path fires", ); } + + // ── Phase 11 (Track J.9) Go JSON_PARSE emitter tests ────────────────────── + + fn make_json_parse_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::JSON_PARSE; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_json_parse_harness_when_cap_is_json_parse() { + let h = emit(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/go/vuln.go", + "Run", + )) + .unwrap(); + assert!( + h.source.contains("nyxJsonParseProbe"), + "dispatcher must short-circuit Cap::JSON_PARSE into emit_json_parse_harness so the depth probe shim is present", + ); + assert!( + h.source.contains("\"kind\": \"JsonParse\","), + "JSON_PARSE harness must record probes with kind JsonParse", + ); + } + + #[test] + fn emit_json_parse_harness_routes_through_internal_vulnentry_package() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/go/vuln.go", + "Run", + )); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "internal/vulnentry/vulnentry.go"); + assert!( + staged.is_some(), + "tier-(a) JSON_PARSE harness must stage the fixture under internal/vulnentry/", + ); + assert!( + staged.unwrap().1.contains("package vulnentry"), + "fixture package name must be rewritten to vulnentry", + ); + assert!( + h.source.contains("nyx-harness/internal/vulnentry"), + "main.go must import the rewritten vulnentry package", + ); + assert!( + h.source.contains("vulnentry.Run(payload)"), + "main.go must invoke the entry function on the rewritten fixture", + ); + } + + #[test] + fn emit_json_parse_harness_emits_depth_fields() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/go/vuln.go", + "Run", + )); + assert!(h.source.contains("\"depth\": depth")); + assert!(h.source.contains("\"excessive_depth\": excessive")); + assert!(h.source.contains("depth > 64")); + assert!(h.source.contains("__NYX_SINK_HIT__")); + } + + #[test] + fn emit_json_parse_harness_uses_iterative_walker() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/go/vuln.go", + "Run", + )); + assert!( + h.source.contains("func nyxJsonCountDepth"), + "Go JSON_PARSE harness must define the iterative depth walker", + ); + assert!( + h.source.contains("map[string]interface{}:"), + "depth walker must dispatch on the JSON object type", + ); + assert!( + h.source.contains("[]interface{}:"), + "depth walker must dispatch on the JSON array type", + ); + } + + #[test] + fn emit_json_parse_harness_falls_back_when_fixture_source_unavailable() { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::JSON_PARSE; + spec.entry_file = "/nonexistent/path/missing.go".into(); + spec.entry_name = "Run".into(); + let h = emit_json_parse_harness(&spec); + let staged = h + .extra_files + .iter() + .find(|(name, _)| name == "internal/vulnentry/vulnentry.go"); + assert!( + staged.is_none(), + "fallback path must not stage a vulnentry copy when the fixture cannot be read", + ); + assert!( + !h.source.contains("nyx-harness/internal/vulnentry"), + "fallback path must not import the missing vulnentry package", + ); + assert!( + h.source.contains("nyxJsonParseProbe"), + "fallback path must still emit a JSON_PARSE probe so the universal sink-hit path fires", + ); + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 790207d4..1a618475 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -521,6 +521,17 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_crypto_harness(spec)); } + // JSON_PARSE depth-bomb short-circuit. PHP + // cannot monkey-patch the `json_decode` builtin, so the harness + // publishes a global `_nyx_json_decode` helper that the fixture + // calls in place of the builtin. Inside the captured namespace + // PHP's unqualified function-call resolution falls back to the + // global namespace, so a fixture that calls `_nyx_json_decode(...)` + // routes through the harness helper without further annotation. + if spec.expected_cap == crate::labels::Cap::JSON_PARSE { + return Ok(emit_json_parse_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)); @@ -2162,6 +2173,155 @@ _nyx_run(); } } +/// Phase 11 (Track J.9) — JSON_PARSE depth-bomb harness for PHP. +/// +/// The harness publishes a global `_nyx_json_decode($s)` helper that +/// proxies the real `json_decode`, runs an iterative depth walker over +/// the parsed value, and emits a +/// [`crate::dynamic::probe::ProbeKind::JsonParse`] probe record. PHP +/// cannot monkey-patch `json_decode` itself, so the per-language fixture +/// calls `_nyx_json_decode(...)` instead of the builtin. PHP's +/// unqualified function-call resolution inside the synthetic +/// `Nyx\Captured` namespace falls back to the global namespace, so the +/// fixture call site resolves to the harness helper at runtime. +/// +/// On parser failure with `JSON_ERROR_DEPTH` (which fires when the +/// nesting depth exceeds the helper's `$depth` argument) the harness +/// emits a `JsonParse { depth: 0, excessive_depth: true }` probe before +/// returning `null` — matches the Python `RecursionError` + JS +/// `RangeError` excess paths. +/// +/// Mirrors `crate::dynamic::lang::python::emit_json_parse_harness` and +/// `crate::dynamic::lang::js_shared::emit_json_parse_harness`. +pub fn emit_json_parse_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_basename = derive_php_entry_basename(&spec.entry_file); + let entry_name = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let body = format!( + r#" 0) {{ + [$cur, $depth] = array_pop($stack); + $visited += 1; + if ($visited > _NYX_JSON_MAX_WALK) {{ + break; + }} + if ($depth > $maxDepth) {{ + $maxDepth = $depth; + }} + if (is_array($cur)) {{ + foreach ($cur as $child) {{ + $stack[] = [$child, $depth + 1]; + }} + }} elseif (is_object($cur)) {{ + foreach (get_object_vars($cur) as $child) {{ + $stack[] = [$child, $depth + 1]; + }} + }} + }} + return $maxDepth; +}} + +function _nyx_json_parse_probe(int $depth, bool $excessive): void {{ + $p = getenv('NYX_PROBE_PATH'); + if ($p === false || $p === '') return; + $rec = [ + 'sink_callee' => 'json_decode', + 'args' => [['kind' => 'Int', 'value' => $depth]], + 'captured_at_ns' => (int) hrtime(true), + 'payload_id' => (string) (getenv('NYX_PAYLOAD_ID') ?: ''), + 'kind' => [ + 'kind' => 'JsonParse', + 'depth' => $depth, + 'excessive_depth' => $excessive, + ], + 'witness' => __nyx_witness('json_decode', [(string) $depth]), + ]; + @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); +}} + +// Global helper the fixture calls in place of `json_decode`. Defined +// in the global namespace so an unqualified `_nyx_json_decode(...)` +// call inside `namespace Nyx\Captured` resolves here. +function _nyx_json_decode(string $text, ?bool $assoc = true, int $depth = _NYX_JSON_HELPER_DEPTH, int $flags = 0) {{ + $parsed = json_decode($text, $assoc, $depth, $flags); + if ($parsed === null && json_last_error() !== JSON_ERROR_NONE) {{ + if (json_last_error() === JSON_ERROR_DEPTH) {{ + _nyx_json_parse_probe(0, true); + }} + return null; + }} + $observed = _nyx_json_count_depth($parsed); + _nyx_json_parse_probe($observed, $observed > 64); + return $parsed; +}} + +function _nyx_json_parse_via_fixture(string $payload, string $entry_basename, string $entry_name): bool {{ + $candidate = __DIR__ . DIRECTORY_SEPARATOR . $entry_basename; + if (!is_file($candidate)) {{ + return false; + }} + $src = @file_get_contents($candidate); + if ($src === false) {{ + return false; + }} + $stripped = preg_replace('/^\s*<\?php\s*/', '', $src); + if ($stripped === null) {{ + return false; + }} + $eval_src = "namespace Nyx\\Captured;\n" . $stripped; + try {{ + $eval_result = @eval($eval_src); + }} catch (\Throwable $_) {{ + return false; + }} + if ($eval_result === false) {{ + return false; + }} + $fq = 'Nyx\\Captured\\' . $entry_name; + if (!function_exists($fq)) {{ + return false; + }} + try {{ + $fq($payload); + }} catch (\Throwable $_) {{ + // Parser exceptions on the deep payload are expected — the + // probe is already emitted before the helper re-raises. + }} + return true; +}} + +function _nyx_run(): void {{ + $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); + _nyx_json_parse_via_fixture($payload, "{entry_basename}", "{entry_name}"); + echo "__NYX_SINK_HIT__\n"; +}} + +_nyx_run(); +"# + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: Vec::new(), + entry_subpath: None, + } +} + /// Phase 19 (Track M.1) — class-method harness for PHP. /// /// Includes the entry file, instantiates the class via its default @@ -3361,4 +3521,110 @@ mod tests { h.source ); } + + // ── Phase 11 (Track J.9) PHP JSON_PARSE emitter tests ───────────────────── + + fn make_json_parse_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::JSON_PARSE; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_json_parse_harness_when_cap_is_json_parse() { + let h = emit(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/php/vuln.php", + "run", + )) + .unwrap(); + assert!( + h.source.contains("_nyx_json_decode"), + "dispatcher must short-circuit Cap::JSON_PARSE into the depth harness: {}", + h.source + ); + assert!( + h.source.contains("'kind' => 'JsonParse'"), + "JSON_PARSE harness must emit JsonParse probe records", + ); + } + + #[test] + fn emit_json_parse_harness_defines_global_helper() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/php/vuln.php", + "run", + )); + assert!( + h.source.contains("function _nyx_json_decode("), + "PHP JSON_PARSE harness must publish a global _nyx_json_decode helper the fixture can call", + ); + assert!( + h.source.contains("function _nyx_json_count_depth("), + "PHP JSON_PARSE harness must define the iterative depth walker", + ); + } + + #[test] + fn emit_json_parse_harness_emits_depth_fields() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/php/vuln.php", + "run", + )); + assert!(h.source.contains("'depth' => $depth")); + assert!(h.source.contains("'excessive_depth' => $excessive")); + assert!(h.source.contains("$observed > 64")); + assert!(h.source.contains("__NYX_SINK_HIT__")); + } + + #[test] + fn emit_json_parse_harness_handles_parser_depth_error() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/php/vuln.php", + "run", + )); + assert!(h.source.contains("JSON_ERROR_DEPTH")); + assert!(h.source.contains("_nyx_json_parse_probe(0, true)")); + } + + #[test] + fn emit_json_parse_harness_routes_through_fixture_eval() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/php/vuln.php", + "run", + )); + assert!( + h.source.contains("function _nyx_json_parse_via_fixture("), + "PHP JSON_PARSE harness must define the fixture-routing helper: {}", + h.source + ); + assert!( + h.source.contains("namespace Nyx\\\\Captured"), + "PHP JSON_PARSE harness must eval the fixture inside the Nyx\\Captured namespace: {}", + h.source + ); + assert!( + h.source.contains("'Nyx\\\\Captured\\\\' . $entry_name"), + "PHP JSON_PARSE harness must invoke the fully-qualified namespaced entry: {}", + h.source + ); + assert!( + h.source.contains("\"vuln.php\""), + "PHP JSON_PARSE harness must pass the entry basename to the helper: {}", + h.source + ); + assert_eq!(h.filename, "harness.php"); + assert!(h.extra_files.is_empty()); + } + + #[test] + fn emit_json_parse_harness_derives_basename_from_entry_file() { + let h = emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.php", "run")); + assert!( + h.source.contains("\"benign.php\""), + "PHP JSON_PARSE harness must use the entry-file basename, not a hard-coded literal: {}", + h.source + ); + } } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index b9872a7e..e4599423 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -1437,6 +1437,122 @@ fn main() {{ } } +/// Phase 11 — Track J.9 JSON_PARSE depth-bomb harness for Rust. +/// +/// Stages the fixture at `src/entry.rs`, builds against +/// `serde_json = "1"` (added to `Cargo.toml` automatically when +/// `Cap::JSON_PARSE` is set — see [`generate_cargo_toml_with_extras`]), +/// invokes `entry::(&payload)`, walks the returned +/// `serde_json::Value` iteratively, and writes a +/// `ProbeKind::JsonParse { depth, excessive_depth }` probe. +/// +/// The fixture's entry is expected to return a `serde_json::Value` +/// (parsing `&str` / `&[u8]` via `serde_json::from_str` or +/// `serde_json::from_slice` and returning the resulting `Value`). +/// `serde_json` is iterative so deeply-nested input never panics the +/// parser; the harness reads the observed depth off the returned +/// value rather than intercepting the parse call site itself. +pub fn emit_json_parse_harness(spec: &HarnessSpec) -> HarnessSource { + let shim = probe_shim(); + let entry_fn = if spec.entry_name.is_empty() { + "run".to_owned() + } else { + spec.entry_name.clone() + }; + let cargo_toml = generate_cargo_toml(Cap::JSON_PARSE); + + let main_rs = format!( + r##"//! Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9). +mod entry; +use std::env; +use std::fs::OpenOptions; +use std::io::Write; +use std::time::{{SystemTime, UNIX_EPOCH}}; + +{shim} + +const NYX_JSON_MAX_WALK: usize = 4096; + +fn nyx_json_count_depth(value: &serde_json::Value) -> u32 {{ + let mut max_depth: u32 = 0; + let mut stack: Vec<(&serde_json::Value, u32)> = Vec::with_capacity(64); + stack.push((value, 1)); + let mut visited: usize = 0; + while let Some((cur, depth)) = stack.pop() {{ + visited += 1; + if visited > NYX_JSON_MAX_WALK {{ break; }} + if depth > max_depth {{ max_depth = depth; }} + match cur {{ + serde_json::Value::Array(items) => {{ + for child in items {{ + stack.push((child, depth + 1)); + }} + }} + serde_json::Value::Object(map) => {{ + for child in map.values() {{ + stack.push((child, depth + 1)); + }} + }} + _ => {{}} + }} + }} + max_depth +}} + +fn nyx_json_parse_probe(depth: u32, excessive: bool) {{ + let p = match env::var("NYX_PROBE_PATH") {{ Ok(s) => s, Err(_) => return }}; + if p.is_empty() {{ return; }} + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let payload_id = env::var("NYX_PAYLOAD_ID").unwrap_or_default(); + let depth_str = depth.to_string(); + let excessive_str = if excessive {{ "true" }} else {{ "false" }}; + let mut line = String::with_capacity(256); + line.push_str("{{\"sink_callee\":\"serde_json::from_str\",\"args\":["); + line.push_str("{{\"kind\":\"Int\",\"value\":"); + line.push_str(&depth_str); + line.push_str("}}],"); + line.push_str("\"captured_at_ns\":"); + line.push_str(&now.to_string()); + line.push_str(",\"payload_id\":\""); + let mut esc_pid = String::new(); + __nyx_esc(&payload_id, &mut esc_pid); + line.push_str(&esc_pid); + line.push_str("\",\"kind\":{{\"kind\":\"JsonParse\",\"depth\":"); + line.push_str(&depth_str); + line.push_str(",\"excessive_depth\":"); + line.push_str(excessive_str); + line.push_str("}},\"witness\":"); + line.push_str(&__nyx_witness_json("serde_json::from_str", &[&depth_str])); + line.push_str("}}\n"); + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&p) {{ + let _ = f.write_all(line.as_bytes()); + }} +}} + +fn main() {{ + let payload = env::var("NYX_PAYLOAD").unwrap_or_default(); + __nyx_install_crash_guard("serde_json::from_str"); + let parsed = entry::{entry_fn}(&payload); + let depth = nyx_json_count_depth(&parsed); + let excessive = depth > 64; + nyx_json_parse_probe(depth, excessive); + println!("__NYX_SINK_HIT__"); + println!("{{{{\"depth\":{{depth}}}}}}", depth = depth); +}} +"## + ); + HarnessSource { + source: main_rs, + 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()), + } +} + /// Emit a Rust harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { // Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The @@ -1469,6 +1585,15 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_crypto_harness(spec)); } + // Phase 11 (Track J.9): JSON_PARSE depth-bomb short-circuit. Stages + // the fixture at `src/entry.rs`, builds against `serde_json = "1"`, + // invokes `entry::(&payload)` which is expected to + // return a `serde_json::Value`, walks that value iteratively, and + // writes a `ProbeKind::JsonParse { depth, excessive_depth }` record. + if spec.expected_cap == crate::labels::Cap::JSON_PARSE { + return Ok(emit_json_parse_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 @@ -1835,6 +1960,9 @@ pub fn generate_cargo_toml_with_extras(cap: Cap, needs_percent_encoding: bool) - if cap.contains(Cap::CRYPTO) { deps.push_str("rand = \"0.8\"\n"); } + if cap.contains(Cap::JSON_PARSE) { + deps.push_str("serde_json = \"1\"\n"); + } format!( "[package]\n\ @@ -3067,4 +3195,106 @@ mod tests { h.source ); } + + // ── Phase 11 (Track J.9) Rust JSON_PARSE emitter tests ───────────────────── + + fn make_json_parse_spec(entry_file: &str, entry_name: &str) -> HarnessSpec { + let mut spec = make_spec(PayloadSlot::Param(0)); + spec.expected_cap = Cap::JSON_PARSE; + spec.entry_file = entry_file.to_owned(); + spec.entry_name = entry_name.to_owned(); + spec + } + + #[test] + fn emit_dispatches_to_json_parse_harness_when_cap_is_json_parse() { + let h = emit(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs", + "run", + )) + .unwrap(); + assert!( + h.source.contains("fn nyx_json_parse_probe"), + "dispatcher must short-circuit Cap::JSON_PARSE into emit_json_parse_harness so the depth probe shim is present: {}", + h.source + ); + assert!( + h.source.contains(r#"\"kind\":\"JsonParse\""#), + "Rust JSON_PARSE harness must record probes with kind JsonParse: {}", + h.source + ); + } + + #[test] + fn emit_json_parse_harness_invokes_entry_via_mod_entry() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs", + "run", + )); + assert!( + h.source.contains("mod entry;"), + "Rust JSON_PARSE harness must declare `mod entry;` so the staged fixture is in scope", + ); + assert!( + h.source.contains("let parsed = entry::run(&payload);"), + "Rust JSON_PARSE harness must invoke the entry function with the payload", + ); + assert_eq!(h.entry_subpath, Some("src/entry.rs".to_string())); + assert_eq!(h.filename, "src/main.rs"); + } + + #[test] + fn emit_json_parse_harness_cargo_toml_pulls_in_serde_json() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs", + "run", + )); + let cargo = h + .extra_files + .iter() + .find(|(n, _)| n == "Cargo.toml") + .expect("Cargo.toml must be in extra_files"); + assert!( + cargo.1.contains("serde_json = \"1\""), + "Rust JSON_PARSE harness Cargo.toml must depend on serde_json so the fixture's parser resolves: {}", + cargo.1 + ); + assert!( + cargo.1.contains("libc = \"0.2\""), + "Rust JSON_PARSE harness Cargo.toml must keep libc dep for the probe shim's sigaction path", + ); + } + + #[test] + fn emit_json_parse_harness_uses_iterative_walker() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs", + "run", + )); + assert!( + h.source.contains("fn nyx_json_count_depth"), + "Rust JSON_PARSE harness must define the iterative depth walker: {}", + h.source + ); + assert!( + h.source.contains("serde_json::Value::Array(items)"), + "depth walker must dispatch on serde_json::Value::Array", + ); + assert!( + h.source.contains("serde_json::Value::Object(map)"), + "depth walker must dispatch on serde_json::Value::Object", + ); + } + + #[test] + fn emit_json_parse_harness_emits_depth_fields() { + let h = emit_json_parse_harness(&make_json_parse_spec( + "tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs", + "run", + )); + assert!(h.source.contains(r#"\"depth\":"#)); + assert!(h.source.contains(r#"\"excessive_depth\":"#)); + assert!(h.source.contains("depth > 64")); + assert!(h.source.contains("__NYX_SINK_HIT__")); + } } diff --git a/tests/dynamic_fixtures/json_parse_depth/go/vuln.go b/tests/dynamic_fixtures/json_parse_depth/go/vuln.go new file mode 100644 index 00000000..cf2e8606 --- /dev/null +++ b/tests/dynamic_fixtures/json_parse_depth/go/vuln.go @@ -0,0 +1,34 @@ +// Go JSON_PARSE depth-bomb vuln fixture. +// +// Models a config-driven JSON ingest endpoint that picks the parser +// input based on the request payload tag - `*_DEEP` routes through a +// deeply-nested array literal (256 levels) that drives +// `encoding/json.Unmarshal` past the 64-level depth budget; +// `*_SHALLOW` routes through a flat `[]` parse that leaves the +// predicate clear. This shape is needed by the differential runner: +// the vuln-payload attempt and the benign-control attempt both load +// the same fixture, and only the payload-routed deep branch trips the +// `JsonParseExcessiveDepth` predicate. +// +// Go's encoding/json parser is iterative so the deep input does not +// panic the stdlib; the harness walks the returned interface{} to +// compute the observed depth and emits a `ProbeKind::JsonParse` record. +package vuln + +import ( + "encoding/json" + "strings" +) + +func Run(value string) interface{} { + text := value + if strings.Contains(text, "DEEP") { + nested := strings.Repeat("[", 256) + strings.Repeat("]", 256) + var v interface{} + _ = json.Unmarshal([]byte(nested), &v) + return v + } + var v interface{} + _ = json.Unmarshal([]byte("[]"), &v) + return v +} diff --git a/tests/dynamic_fixtures/json_parse_depth/php/vuln.php b/tests/dynamic_fixtures/json_parse_depth/php/vuln.php new file mode 100644 index 00000000..6fcf82e9 --- /dev/null +++ b/tests/dynamic_fixtures/json_parse_depth/php/vuln.php @@ -0,0 +1,37 @@ + serde_json::Value { + if value.contains("DEEP") { + let depth = 100usize; + let mut nested = String::with_capacity(depth * 2); + for _ in 0..depth { + nested.push('['); + } + for _ in 0..depth { + nested.push(']'); + } + serde_json::from_str(&nested).unwrap_or(serde_json::Value::Null) + } else { + serde_json::from_str("[]").unwrap_or(serde_json::Value::Null) + } +} diff --git a/tests/json_parse_corpus.rs b/tests/json_parse_corpus.rs index 0fecd874..5eb93f5b 100644 --- a/tests/json_parse_corpus.rs +++ b/tests/json_parse_corpus.rs @@ -20,7 +20,21 @@ use nyx_scanner::labels::Cap; use nyx_scanner::symbol::Lang; use std::time::Duration; -const LANGS: &[Lang] = &[Lang::JavaScript, Lang::Python, Lang::Ruby]; +const LANGS: &[Lang] = &[ + Lang::JavaScript, + Lang::Python, + Lang::Ruby, + Lang::Php, + Lang::Go, + Lang::Rust, +]; + +/// Subset of [`LANGS`] whose JSON parser has a prototype-pollution +/// surface — JS / Python / Ruby ship object-property merging idioms +/// downstream of `JSON.parse` / `json.loads`. PHP / Go / Rust have no +/// equivalent surface so the canary predicate is intentionally absent +/// from their corpus slice. +const CANARY_LANGS: &[Lang] = &[Lang::JavaScript, Lang::Python, Lang::Ruby]; fn outcome() -> SandboxOutcome { SandboxOutcome { @@ -61,21 +75,55 @@ fn corpus_registers_json_parse_for_each_supported_lang() { #[test] fn json_parse_pairs_benign_per_lang_via_canary_predicate() { - for lang in LANGS { + for lang in CANARY_LANGS { let slice = payloads_for_lang(Cap::JSON_PARSE, *lang); - let vuln = slice.iter().find(|p| !p.is_benign).expect("vuln"); + let vuln = slice + .iter() + .find(|p| { + !p.is_benign + && matches!( + p.oracle, + Oracle::SinkProbe { + predicates, + .. + } if predicates.iter().any(|q| matches!( + q, + ProbePredicate::PrototypeCanaryTouched { + canary: "__nyx_canary" + } + )) + ) + }) + .expect("vuln canary payload"); let resolved = resolve_benign_control_lang(vuln, Cap::JSON_PARSE, *lang) .expect("benign control resolves"); assert!(resolved.is_benign); - match &vuln.oracle { - Oracle::SinkProbe { predicates } => assert!(predicates.iter().any(|p| matches!( - p, - ProbePredicate::PrototypeCanaryTouched { - canary: "__nyx_canary" - } - ))), - other => panic!("expected SinkProbe, got {other:?}"), - } + } +} + +#[test] +fn json_parse_depth_bomb_pairs_benign_per_lang() { + for lang in LANGS { + let slice = payloads_for_lang(Cap::JSON_PARSE, *lang); + let vuln = slice + .iter() + .find(|p| { + !p.is_benign + && matches!( + p.oracle, + Oracle::SinkProbe { + predicates, + .. + } if predicates.iter().any(|q| matches!( + q, + ProbePredicate::JsonParseExcessiveDepth { max_depth: 64 } + )) + ) + }) + .unwrap_or_else(|| panic!("{lang:?} JSON_PARSE slice must carry a depth-bomb vuln")); + let resolved = resolve_benign_control_lang(vuln, Cap::JSON_PARSE, *lang) + .expect("depth-bomb benign control resolves"); + assert!(resolved.is_benign); } } @@ -130,7 +178,10 @@ mod e2e_json_parse_depth { Lang::Python => "python", Lang::JavaScript => "javascript", Lang::Ruby => "ruby", - _ => unreachable!("JSON_PARSE depth e2e covers Python + JavaScript + Ruby only"), + Lang::Php => "php", + Lang::Go => "go", + Lang::Rust => "rust", + _ => unreachable!("JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust only"), }) .join(fixture); let tmp = TempDir::new().expect("create tempdir"); @@ -173,7 +224,10 @@ mod e2e_json_parse_depth { Lang::Python => "python3", Lang::JavaScript => "node", Lang::Ruby => "ruby", - _ => unreachable!("JSON_PARSE depth e2e covers Python + JavaScript + Ruby only"), + Lang::Php => "php", + Lang::Go => "go", + Lang::Rust => "cargo", + _ => unreachable!("JSON_PARSE depth e2e covers JS / Python / Ruby / PHP / Go / Rust only"), }; if !command_available(required) { eprintln!("SKIP {lang:?} {fixture}: missing toolchain {required}"); @@ -232,19 +286,35 @@ mod e2e_json_parse_depth { }; assert_confirmed(Lang::Ruby, &outcome); } + + #[test] + fn php_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Php, "vuln.php", "run") else { + return; + }; + assert_confirmed(Lang::Php, &outcome); + } + + #[test] + fn go_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Go, "vuln.go", "Run") else { + return; + }; + assert_confirmed(Lang::Go, &outcome); + } + + #[test] + fn rust_vuln_confirms_via_run_spec() { + let Some(outcome) = run(Lang::Rust, "vuln.rs", "run") else { + return; + }; + assert_confirmed(Lang::Rust, &outcome); + } } #[test] fn json_parse_unsupported_for_other_langs() { - for lang in [ - Lang::Rust, - Lang::C, - Lang::Cpp, - Lang::Java, - Lang::Go, - Lang::Php, - Lang::TypeScript, - ] { + for lang in [Lang::C, Lang::Cpp, Lang::Java, Lang::TypeScript] { assert!( payloads_for_lang(Cap::JSON_PARSE, lang).is_empty(), "JSON_PARSE has unexpected payloads for {lang:?}",