mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-27 20:29:39 +02:00
[pitboss/grind] deferred session-0001 (20260522T163126Z-7d60)
This commit is contained in:
parent
fd50549582
commit
e4258d63ed
7 changed files with 625 additions and 13 deletions
|
|
@ -1,17 +1,19 @@
|
||||||
//! JavaScript `Cap::JSON_PARSE` payloads — `JSON.parse` then deep
|
//! JavaScript `Cap::JSON_PARSE` payloads.
|
||||||
//! assign / `Object.assign` chain.
|
|
||||||
//!
|
//!
|
||||||
//! Same canary oracle as the Phase 10 PROTOTYPE_POLLUTION corpus
|
//! Covers two oracle shapes: the prototype-canary pair reuses the
|
||||||
//! ([`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`]).
|
//! Phase 10 PROTOTYPE_POLLUTION canary
|
||||||
//! The harness routes both payloads through `JSON.parse` first to
|
//! ([`crate::dynamic::oracle::ProbePredicate::PrototypeCanaryTouched`])
|
||||||
//! exercise the parse-then-assign flow specifically (whereas the
|
//! against a `JSON.parse`-then-deep-merge fixture, and the depth-bomb
|
||||||
//! Phase 10 corpus passes the JSON literal directly to the deep-merge
|
//! pair drives `JSON.parse` past the 64-level depth budget for the
|
||||||
//! sink without an intervening parse).
|
//! [`crate::dynamic::oracle::ProbePredicate::JsonParseExcessiveDepth`]
|
||||||
|
//! oracle. The depth pair shares a single fixture; the payload tag
|
||||||
|
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch.
|
||||||
|
|
||||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
use crate::dynamic::oracle::ProbePredicate;
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
const CANARY: &str = "__nyx_canary";
|
const CANARY: &str = "__nyx_canary";
|
||||||
|
const MAX_DEPTH: u32 = 64;
|
||||||
|
|
||||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
CuratedPayload {
|
CuratedPayload {
|
||||||
|
|
@ -48,4 +50,44 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
benign_control: None,
|
benign_control: None,
|
||||||
no_benign_control_rationale: None,
|
no_benign_control_rationale: None,
|
||||||
},
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"NYX_JSON_DEEP",
|
||||||
|
label: "json-parse-js-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/javascript/vuln.js"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||||
|
max_depth: MAX_DEPTH,
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "json-parse-js-depth-shallow",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"NYX_JSON_SHALLOW",
|
||||||
|
label: "json-parse-js-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/javascript/vuln.js"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
//! Ruby `Cap::JSON_PARSE` payloads — `JSON.parse` then recursive
|
//! Ruby `Cap::JSON_PARSE` payloads.
|
||||||
//! `Hash#deep_merge!` on a shared sentinel object.
|
//!
|
||||||
|
//! Covers two oracle shapes: the prototype-canary pair reuses the
|
||||||
|
//! Phase 10 PROTOTYPE_POLLUTION canary against a `JSON.parse` then
|
||||||
|
//! recursive `Hash#deep_merge!` fixture, and the depth-bomb pair
|
||||||
|
//! drives `JSON.parse` past the 64-level depth budget for the
|
||||||
|
//! [`crate::dynamic::oracle::ProbePredicate::JsonParseExcessiveDepth`]
|
||||||
|
//! oracle. The depth pair shares a single fixture; the payload tag
|
||||||
|
//! (`NYX_JSON_DEEP` vs `NYX_JSON_SHALLOW`) picks the branch.
|
||||||
|
|
||||||
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
use super::super::{CuratedPayload, Oracle, PayloadProvenance, PayloadRef};
|
||||||
use crate::dynamic::oracle::ProbePredicate;
|
use crate::dynamic::oracle::ProbePredicate;
|
||||||
|
|
||||||
const CANARY: &str = "__nyx_canary";
|
const CANARY: &str = "__nyx_canary";
|
||||||
|
const MAX_DEPTH: u32 = 64;
|
||||||
|
|
||||||
pub const PAYLOADS: &[CuratedPayload] = &[
|
pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
CuratedPayload {
|
CuratedPayload {
|
||||||
|
|
@ -41,4 +49,44 @@ pub const PAYLOADS: &[CuratedPayload] = &[
|
||||||
benign_control: None,
|
benign_control: None,
|
||||||
no_benign_control_rationale: None,
|
no_benign_control_rationale: None,
|
||||||
},
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"NYX_JSON_DEEP",
|
||||||
|
label: "json-parse-ruby-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/ruby/vuln.rb"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[ProbePredicate::JsonParseExcessiveDepth {
|
||||||
|
max_depth: MAX_DEPTH,
|
||||||
|
}],
|
||||||
|
benign_control: Some(PayloadRef {
|
||||||
|
label: "json-parse-ruby-depth-shallow",
|
||||||
|
}),
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
|
CuratedPayload {
|
||||||
|
bytes: b"NYX_JSON_SHALLOW",
|
||||||
|
label: "json-parse-ruby-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/ruby/vuln.rb"],
|
||||||
|
oob_nonce_slot: false,
|
||||||
|
probe_predicates: &[],
|
||||||
|
benign_control: None,
|
||||||
|
no_benign_control_rationale: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,18 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
||||||
return Ok(emit_prototype_pollution_harness(spec));
|
return Ok(emit_prototype_pollution_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11 (Track J.9): JSON_PARSE depth-bomb short-circuit. The
|
||||||
|
// synthetic harness monkey-patches `JSON.parse`, walks the parsed
|
||||||
|
// value iteratively to record maximum nesting depth, emits a
|
||||||
|
// `ProbeKind::JsonParse { depth, excessive_depth }` record, then
|
||||||
|
// routes the payload through the fixture entry. RangeError-style
|
||||||
|
// V8 stack-exhaustion paths emit `JsonParse { depth: 0,
|
||||||
|
// excessive_depth: true }` so the predicate still fires when the
|
||||||
|
// engine rejects the input outright.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::JSON_PARSE {
|
||||||
|
return Ok(emit_json_parse_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 19 (Track M.1): ClassMethod short-circuit. Same shape gap
|
// Phase 19 (Track M.1): ClassMethod short-circuit. Same shape gap
|
||||||
// closer as the Python emitter — instantiate the class via its
|
// closer as the Python emitter — instantiate the class via its
|
||||||
// zero-arg constructor (falling back to a stubbed-dependency ctor
|
// zero-arg constructor (falling back to a stubbed-dependency ctor
|
||||||
|
|
@ -1957,6 +1969,135 @@ console.log(JSON.stringify({{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 11 (Track J.9) — JSON_PARSE depth-bomb harness for Node.
|
||||||
|
///
|
||||||
|
/// Monkey-patches `JSON.parse` with a wrapper that calls the original
|
||||||
|
/// parser, walks the resulting value iteratively (no recursion stack)
|
||||||
|
/// to compute maximum nesting depth, emits a
|
||||||
|
/// `ProbeKind::JsonParse { depth, excessive_depth }` record, then
|
||||||
|
/// returns the parsed value verbatim. A `RangeError` raised by V8 on
|
||||||
|
/// excessively-deep input is caught and converted into a
|
||||||
|
/// `JsonParse { depth: 0, excessive_depth: true }` probe before the
|
||||||
|
/// error is re-thrown — matching the Python harness's
|
||||||
|
/// `RecursionError` handling.
|
||||||
|
///
|
||||||
|
/// Mirrors `crate::dynamic::lang::python::emit_json_parse_harness`.
|
||||||
|
pub fn emit_json_parse_harness(spec: &HarnessSpec) -> HarnessSource {
|
||||||
|
let shim = probe_shim();
|
||||||
|
let entry_stem = derive_js_entry_stem(&spec.entry_file);
|
||||||
|
let entry_name = if spec.entry_name.is_empty() {
|
||||||
|
"run".to_owned()
|
||||||
|
} else {
|
||||||
|
spec.entry_name.clone()
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
r#"// Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9).
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
const _NYX_MAX_WALK = 4096;
|
||||||
|
|
||||||
|
function _nyx_count_depth(parsed) {{
|
||||||
|
let maxDepth = 0;
|
||||||
|
const stack = [[parsed, 1]];
|
||||||
|
let visited = 0;
|
||||||
|
while (stack.length > 0) {{
|
||||||
|
const [cur, depth] = stack.pop();
|
||||||
|
visited += 1;
|
||||||
|
if (visited > _NYX_MAX_WALK) break;
|
||||||
|
if (depth > maxDepth) maxDepth = depth;
|
||||||
|
if (cur !== null && typeof cur === 'object') {{
|
||||||
|
if (Array.isArray(cur)) {{
|
||||||
|
for (let i = 0; i < cur.length; i += 1) {{
|
||||||
|
stack.push([cur[i], depth + 1]);
|
||||||
|
}}
|
||||||
|
}} else {{
|
||||||
|
for (const k of Object.keys(cur)) {{
|
||||||
|
stack.push([cur[k], depth + 1]);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return maxDepth;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function _nyx_json_parse_probe(depth, excessive) {{
|
||||||
|
const p = process.env.NYX_PROBE_PATH;
|
||||||
|
if (!p) return;
|
||||||
|
const rec = {{
|
||||||
|
sink_callee: 'JSON.parse',
|
||||||
|
args: [{{ kind: 'Int', value: depth | 0 }}],
|
||||||
|
captured_at_ns: Number(process.hrtime.bigint()),
|
||||||
|
payload_id: process.env.NYX_PAYLOAD_ID || '',
|
||||||
|
kind: {{
|
||||||
|
kind: 'JsonParse',
|
||||||
|
depth: depth | 0,
|
||||||
|
excessive_depth: !!excessive,
|
||||||
|
}},
|
||||||
|
witness: __nyx_witness('JSON.parse', [depth | 0]),
|
||||||
|
}};
|
||||||
|
try {{
|
||||||
|
require('fs').appendFileSync(p, JSON.stringify(rec) + '\n');
|
||||||
|
}} catch (e) {{
|
||||||
|
// best-effort
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
const _nyx_orig_json_parse = JSON.parse;
|
||||||
|
|
||||||
|
JSON.parse = function _nyx_json_parse_with_depth(text, reviver) {{
|
||||||
|
let parsed;
|
||||||
|
try {{
|
||||||
|
parsed = _nyx_orig_json_parse(text, reviver);
|
||||||
|
}} catch (e) {{
|
||||||
|
// V8 raises `RangeError: Maximum call stack size exceeded` on
|
||||||
|
// deeply-nested input. Emit the excessive-depth probe before
|
||||||
|
// re-raising so the oracle still fires.
|
||||||
|
if (e instanceof RangeError) {{
|
||||||
|
_nyx_json_parse_probe(0, true);
|
||||||
|
}}
|
||||||
|
throw e;
|
||||||
|
}}
|
||||||
|
const depth = _nyx_count_depth(parsed);
|
||||||
|
_nyx_json_parse_probe(depth, depth > 64);
|
||||||
|
return parsed;
|
||||||
|
}};
|
||||||
|
|
||||||
|
function _nyx_json_parse_via_fixture(payload) {{
|
||||||
|
let _entry;
|
||||||
|
try {{
|
||||||
|
_entry = require('./{entry_stem}');
|
||||||
|
}} catch (e) {{
|
||||||
|
process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n');
|
||||||
|
process.exit(77);
|
||||||
|
}}
|
||||||
|
const fn =
|
||||||
|
_entry && (typeof _entry === 'function' ? _entry : _entry['{entry_name}']);
|
||||||
|
if (typeof fn !== 'function') {{
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
try {{
|
||||||
|
fn(payload);
|
||||||
|
}} catch (e) {{
|
||||||
|
// Parser errors / depth-induced throws are expected on the vuln
|
||||||
|
// payload; the probe is already emitted.
|
||||||
|
}}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
|
||||||
|
const payload = process.env.NYX_PAYLOAD || '';
|
||||||
|
_nyx_json_parse_via_fixture(payload);
|
||||||
|
console.log('__NYX_SINK_HIT__');
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
HarnessSource {
|
||||||
|
source: body,
|
||||||
|
filename: "harness.js".to_owned(),
|
||||||
|
command: vec!["node".to_owned(), "harness.js".to_owned()],
|
||||||
|
extra_files: Vec::new(),
|
||||||
|
entry_subpath: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||||
///
|
///
|
||||||
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
/// Splices the Node probe shim ([`probe_shim`]) in front of a minimal
|
||||||
|
|
@ -3436,4 +3577,92 @@ mod tests {
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_json_parse_spec(entry_file: &str, entry_name: &str) -> HarnessSpec {
|
||||||
|
let mut spec = make_spec(EntryKind::Function, entry_name, PayloadSlot::Param(0));
|
||||||
|
spec.expected_cap = Cap::JSON_PARSE;
|
||||||
|
spec.entry_file = entry_file.into();
|
||||||
|
spec.entry_name = entry_name.into();
|
||||||
|
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/javascript/vuln.js",
|
||||||
|
"run",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
h.source.contains("_nyx_json_parse_with_depth"),
|
||||||
|
"dispatcher must select the JSON_PARSE depth harness: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("kind: 'JsonParse'"),
|
||||||
|
"JSON_PARSE harness must emit JsonParse probes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_monkey_patches_json_parse() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("const _nyx_orig_json_parse = JSON.parse"));
|
||||||
|
assert!(
|
||||||
|
h.source
|
||||||
|
.contains("JSON.parse = function _nyx_json_parse_with_depth")
|
||||||
|
);
|
||||||
|
assert!(h.source.contains("function _nyx_count_depth(parsed)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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/javascript/vuln.js",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("depth: depth | 0"));
|
||||||
|
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_handles_range_error() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("e instanceof RangeError"));
|
||||||
|
assert!(h.source.contains("_nyx_json_parse_probe(0, true)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_routes_through_fixture_require() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
h.source
|
||||||
|
.contains("function _nyx_json_parse_via_fixture(payload)")
|
||||||
|
);
|
||||||
|
assert!(h.source.contains("require('./vuln')"));
|
||||||
|
assert!(h.source.contains("_entry['run']"));
|
||||||
|
assert_eq!(h.filename, "harness.js");
|
||||||
|
assert!(h.extra_files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_derives_entry_stem_from_entry_file() {
|
||||||
|
let h =
|
||||||
|
emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.js", "run"));
|
||||||
|
assert!(h.source.contains("require('./benign')"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,18 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
|
||||||
return Ok(emit_open_redirect_harness(spec));
|
return Ok(emit_open_redirect_harness(spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11 (Track J.9): JSON_PARSE depth-bomb short-circuit. The
|
||||||
|
// synthetic harness rebinds `JSON.parse` to a depth-counting
|
||||||
|
// wrapper, walks the parsed value iteratively, and emits a
|
||||||
|
// `ProbeKind::JsonParse { depth, excessive_depth }` record before
|
||||||
|
// returning the parsed value. `JSON::NestingError` (raised by the
|
||||||
|
// Ruby json gem when the input exceeds `max_nesting`) is caught
|
||||||
|
// and converted into a `JsonParse { depth: 0, excessive_depth:
|
||||||
|
// true }` probe before the error is re-raised.
|
||||||
|
if spec.expected_cap == crate::labels::Cap::JSON_PARSE {
|
||||||
|
return Ok(emit_json_parse_harness(spec));
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 19 (Track M.1): ClassMethod short-circuit.
|
// Phase 19 (Track M.1): ClassMethod short-circuit.
|
||||||
if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind {
|
if let crate::evidence::EntryKind::ClassMethod { class, method } = &spec.entry_kind {
|
||||||
return Ok(emit_class_method_harness(class, method));
|
return Ok(emit_class_method_harness(class, method));
|
||||||
|
|
@ -1596,6 +1608,131 @@ _nyx_run
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 11 (Track J.9) — JSON_PARSE depth-bomb harness for Ruby.
|
||||||
|
///
|
||||||
|
/// Rebinds `JSON.parse` to a depth-counting wrapper that calls the
|
||||||
|
/// original parser, walks the resulting value iteratively (no
|
||||||
|
/// recursion stack) to compute maximum nesting depth, emits a
|
||||||
|
/// `ProbeKind::JsonParse { depth, excessive_depth }` record, then
|
||||||
|
/// returns the parsed value verbatim. `JSON::NestingError` raised by
|
||||||
|
/// the Ruby json gem (default `max_nesting` is 100) is caught and
|
||||||
|
/// converted into a `JsonParse { depth: 0, excessive_depth: true }`
|
||||||
|
/// probe before the error is re-raised — matching the Python harness's
|
||||||
|
/// `RecursionError` handling and the JS harness's `RangeError`
|
||||||
|
/// handling.
|
||||||
|
///
|
||||||
|
/// 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_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#"# Nyx dynamic harness — JSON_PARSE depth checks (Phase 11 / Track J.9).
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
{shim}
|
||||||
|
|
||||||
|
NYX_MAX_WALK = 4096
|
||||||
|
|
||||||
|
def _nyx_count_depth(parsed)
|
||||||
|
max_depth = 0
|
||||||
|
stack = [[parsed, 1]]
|
||||||
|
visited = 0
|
||||||
|
until stack.empty?
|
||||||
|
cur, depth = stack.pop
|
||||||
|
visited += 1
|
||||||
|
break if visited > NYX_MAX_WALK
|
||||||
|
max_depth = depth if depth > max_depth
|
||||||
|
case cur
|
||||||
|
when Hash
|
||||||
|
cur.each_value {{ |v| stack.push([v, depth + 1]) }}
|
||||||
|
when Array
|
||||||
|
cur.each {{ |v| stack.push([v, depth + 1]) }}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
max_depth
|
||||||
|
end
|
||||||
|
|
||||||
|
def _nyx_json_parse_probe(depth, excessive)
|
||||||
|
p = ENV['NYX_PROBE_PATH']
|
||||||
|
return if p.nil? || p.empty?
|
||||||
|
rec = {{
|
||||||
|
'sink_callee' => 'JSON.parse',
|
||||||
|
'args' => [{{ 'kind' => 'Int', 'value' => depth.to_i }}],
|
||||||
|
'captured_at_ns' => Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond),
|
||||||
|
'payload_id' => ENV['NYX_PAYLOAD_ID'] || '',
|
||||||
|
'kind' => {{
|
||||||
|
'kind' => 'JsonParse',
|
||||||
|
'depth' => depth.to_i,
|
||||||
|
'excessive_depth' => !!excessive,
|
||||||
|
}},
|
||||||
|
'witness' => __nyx_witness('JSON.parse', [depth.to_i]),
|
||||||
|
}}
|
||||||
|
begin
|
||||||
|
File.open(p, 'a') {{ |f| f.write(rec.to_json + "\n") }}
|
||||||
|
rescue StandardError
|
||||||
|
# best-effort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
_nyx_orig_json_parse = JSON.method(:parse)
|
||||||
|
|
||||||
|
JSON.define_singleton_method(:parse) do |source, *args, **opts|
|
||||||
|
begin
|
||||||
|
parsed = _nyx_orig_json_parse.call(source, *args, **opts)
|
||||||
|
rescue JSON::NestingError => e
|
||||||
|
# The json gem raises NestingError once `max_nesting` (default 100)
|
||||||
|
# is exceeded. Emit the excessive-depth probe before re-raising so
|
||||||
|
# the oracle still fires when the parser rejects the input.
|
||||||
|
_nyx_json_parse_probe(0, true)
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
depth = _nyx_count_depth(parsed)
|
||||||
|
_nyx_json_parse_probe(depth, depth > 64)
|
||||||
|
parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
def _nyx_json_parse_via_fixture(payload)
|
||||||
|
$LOAD_PATH.unshift('.')
|
||||||
|
begin
|
||||||
|
require_relative './{entry_basename}'
|
||||||
|
rescue LoadError, ScriptError => e
|
||||||
|
STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}")
|
||||||
|
exit 77
|
||||||
|
end
|
||||||
|
fn_sym = :'{entry_name}'
|
||||||
|
unless Object.respond_to?(fn_sym, true) || self.respond_to?(fn_sym, true)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
send(fn_sym, payload)
|
||||||
|
rescue StandardError
|
||||||
|
# Parser errors / depth-induced raises are expected on the vuln
|
||||||
|
# payload; the probe is already emitted.
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
payload = ENV['NYX_PAYLOAD'] || ''
|
||||||
|
_nyx_json_parse_via_fixture(payload)
|
||||||
|
STDOUT.puts '__NYX_SINK_HIT__'
|
||||||
|
STDOUT.flush
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
HarnessSource {
|
||||||
|
source: body,
|
||||||
|
filename: "harness.rb".to_owned(),
|
||||||
|
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
|
||||||
|
extra_files: vec![],
|
||||||
|
entry_subpath: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
|
||||||
let entry_fn = &spec.entry_name;
|
let entry_fn = &spec.entry_name;
|
||||||
let pre_call = build_pre_call(spec);
|
let pre_call = build_pre_call(spec);
|
||||||
|
|
@ -2434,4 +2571,90 @@ mod tests {
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
h.source.contains("JSON.define_singleton_method(:parse)"),
|
||||||
|
"dispatcher must select the JSON_PARSE depth harness: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.source.contains("=> 'JsonParse'"),
|
||||||
|
"JSON_PARSE harness must emit JsonParse probes: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_monkey_patches_json_parse() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("_nyx_orig_json_parse = JSON.method(:parse)"));
|
||||||
|
assert!(
|
||||||
|
h.source.contains("JSON.define_singleton_method(:parse)"),
|
||||||
|
"must rebind JSON.parse: {}",
|
||||||
|
h.source
|
||||||
|
);
|
||||||
|
assert!(h.source.contains("def _nyx_count_depth(parsed)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("'depth' => depth.to_i"));
|
||||||
|
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_handles_nesting_error() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(h.source.contains("rescue JSON::NestingError => e"));
|
||||||
|
assert!(h.source.contains("_nyx_json_parse_probe(0, true)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_routes_through_fixture_require() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec(
|
||||||
|
"tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb",
|
||||||
|
"run",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
h.source
|
||||||
|
.contains("def _nyx_json_parse_via_fixture(payload)")
|
||||||
|
);
|
||||||
|
assert!(h.source.contains("require_relative './vuln'"));
|
||||||
|
assert!(h.source.contains("fn_sym = :'run'"));
|
||||||
|
assert_eq!(h.filename, "harness.rb");
|
||||||
|
assert!(h.extra_files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_json_parse_harness_derives_entry_basename_from_entry_file() {
|
||||||
|
let h = emit_json_parse_harness(&make_json_parse_spec("/abs/path/benign.rb", "run"));
|
||||||
|
assert!(h.source.contains("require_relative './benign'"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js
Normal file
23
tests/dynamic_fixtures/json_parse_depth/javascript/vuln.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// JavaScript 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.parse`
|
||||||
|
// 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.
|
||||||
|
function run(value) {
|
||||||
|
const text = Buffer.isBuffer(value)
|
||||||
|
? value.toString('utf8')
|
||||||
|
: String(value);
|
||||||
|
if (text.indexOf('DEEP') !== -1) {
|
||||||
|
const nested = '['.repeat(256) + ']'.repeat(256);
|
||||||
|
return JSON.parse(nested);
|
||||||
|
}
|
||||||
|
return JSON.parse('[]');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
23
tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb
Normal file
23
tests/dynamic_fixtures/json_parse_depth/ruby/vuln.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Ruby 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.parse`
|
||||||
|
# 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. `max_nesting: false` disables the json gem's depth
|
||||||
|
# guard so the harness's depth walker sees the full 256-level shape
|
||||||
|
# rather than triggering `JSON::NestingError` at depth 100.
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
def run(value)
|
||||||
|
text = value.to_s
|
||||||
|
if text.include?('DEEP')
|
||||||
|
nested = '[' * 256 + ']' * 256
|
||||||
|
return JSON.parse(nested, max_nesting: false)
|
||||||
|
end
|
||||||
|
JSON.parse('[]')
|
||||||
|
end
|
||||||
|
|
@ -128,7 +128,9 @@ mod e2e_json_parse_depth {
|
||||||
.join("tests/dynamic_fixtures/json_parse_depth")
|
.join("tests/dynamic_fixtures/json_parse_depth")
|
||||||
.join(match lang {
|
.join(match lang {
|
||||||
Lang::Python => "python",
|
Lang::Python => "python",
|
||||||
_ => unreachable!("JSON_PARSE depth e2e covers Python only"),
|
Lang::JavaScript => "javascript",
|
||||||
|
Lang::Ruby => "ruby",
|
||||||
|
_ => unreachable!("JSON_PARSE depth e2e covers Python + JavaScript + Ruby only"),
|
||||||
})
|
})
|
||||||
.join(fixture);
|
.join(fixture);
|
||||||
let tmp = TempDir::new().expect("create tempdir");
|
let tmp = TempDir::new().expect("create tempdir");
|
||||||
|
|
@ -167,8 +169,14 @@ mod e2e_json_parse_depth {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(lang: Lang, fixture: &str, entry_name: &str) -> Option<RunOutcome> {
|
fn run(lang: Lang, fixture: &str, entry_name: &str) -> Option<RunOutcome> {
|
||||||
if !command_available("python3") {
|
let required = match lang {
|
||||||
eprintln!("SKIP {lang:?} {fixture}: missing toolchain python3");
|
Lang::Python => "python3",
|
||||||
|
Lang::JavaScript => "node",
|
||||||
|
Lang::Ruby => "ruby",
|
||||||
|
_ => unreachable!("JSON_PARSE depth e2e covers Python + JavaScript + Ruby only"),
|
||||||
|
};
|
||||||
|
if !command_available(required) {
|
||||||
|
eprintln!("SKIP {lang:?} {fixture}: missing toolchain {required}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = FIXTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
@ -208,6 +216,22 @@ mod e2e_json_parse_depth {
|
||||||
};
|
};
|
||||||
assert_confirmed(Lang::Python, &outcome);
|
assert_confirmed(Lang::Python, &outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn javascript_vuln_confirms_via_run_spec() {
|
||||||
|
let Some(outcome) = run(Lang::JavaScript, "vuln.js", "run") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_confirmed(Lang::JavaScript, &outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ruby_vuln_confirms_via_run_spec() {
|
||||||
|
let Some(outcome) = run(Lang::Ruby, "vuln.rb", "run") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_confirmed(Lang::Ruby, &outcome);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue