mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0002 (20260522T163126Z-7d60)
This commit is contained in:
parent
e4258d63ed
commit
3486056f5e
12 changed files with 1129 additions and 26 deletions
54
src/dynamic/corpus/json_parse/go.rs
Normal file
54
src/dynamic/corpus/json_parse/go.rs
Normal 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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
54
src/dynamic/corpus/json_parse/php.rs
Normal file
54
src/dynamic/corpus/json_parse/php.rs
Normal 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,
|
||||
},
|
||||
];
|
||||
54
src/dynamic/corpus/json_parse/rust.rs
Normal file
54
src/dynamic/corpus/json_parse/rust.rs
Normal 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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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__"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
tests/dynamic_fixtures/json_parse_depth/go/vuln.go
Normal file
34
tests/dynamic_fixtures/json_parse_depth/go/vuln.go
Normal 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
|
||||
}
|
||||
37
tests/dynamic_fixtures/json_parse_depth/php/vuln.php
Normal file
37
tests/dynamic_fixtures/json_parse_depth/php/vuln.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs
Normal file
34
tests/dynamic_fixtures/json_parse_depth/rust/vuln.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:?}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue