[pitboss/grind] deferred session-0002 (20260522T163126Z-7d60)

This commit is contained in:
pitboss 2026-05-22 12:37:54 -05:00
parent e4258d63ed
commit 3486056f5e
12 changed files with 1129 additions and 26 deletions

View file

@ -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,
},
];

View file

@ -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;

View file

@ -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,
},
];

View file

@ -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,
},
];

View file

@ -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,

View file

@ -589,7 +589,21 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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.<EntryFn>(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<HarnessSource, UnsupportedReason> {
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<HarnessSource, UnsupportedReason> {
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.<EntryFn>(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",
);
}
}

View file

@ -521,6 +521,17 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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#"<?php
// Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9).
{shim}
const _NYX_JSON_MAX_WALK = 4096;
const _NYX_JSON_HELPER_DEPTH = 4096;
function _nyx_json_count_depth($parsed): int {{
$maxDepth = 0;
$stack = [[$parsed, 1]];
$visited = 0;
while (count($stack) > 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
);
}
}

View file

@ -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::<entry_name>(&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<HarnessSource, UnsupportedReason> {
// Phase 08 (Track J.6): HEADER_INJECTION-sink short-circuit. The
@ -1469,6 +1585,15 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
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::<entry_name>(&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__"));
}
}

View file

@ -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
}

View file

@ -0,0 +1,37 @@
<?php
// PHP 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 `json_decode`
// 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.
//
// PHP cannot monkey-patch `json_decode` itself. The harness publishes
// a global `_nyx_json_decode($s)` helper that proxies the real
// `json_decode` and records the parse depth before returning. Inside
// the synthetic `Nyx\Captured` namespace the harness eval's this
// fixture into, PHP's unqualified function-call resolution falls back
// to the global namespace, so the call site below routes through the
// harness helper at runtime. When this fixture runs standalone (no
// harness) the fallback definition near the bottom of the file kicks
// in and the helper degrades to a direct `json_decode` call.
function run($value) {
$text = is_string($value) ? $value : (string) json_encode($value);
if (strpos($text, 'DEEP') !== false) {
$nested = str_repeat('[', 256) . str_repeat(']', 256);
return _nyx_json_decode($nested);
}
return _nyx_json_decode('[]');
}
if (!function_exists('_nyx_json_decode')) {
function _nyx_json_decode($s) {
return json_decode($s, true, 4096);
}
}

View file

@ -0,0 +1,34 @@
// Rust 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
// 100-level nested array literal that drives `serde_json::from_str`
// 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.
//
// `serde_json` defaults to a recursion limit of 128 stack frames
// during `from_str`, so the nesting is capped at 100 to stay under
// the parser's own guard while still overshooting the predicate's
// 64-level budget. The harness walks the returned `Value`
// iteratively to compute the observed depth and emits a
// `ProbeKind::JsonParse` record.
pub fn run(value: &str) -> 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)
}
}

View file

@ -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:?}",