feat(dynamic): implement entry-driven verification with fallback to synthetic direct-sink, enhance per-language emitters, and improve test coverage

This commit is contained in:
elipeter 2026-06-01 12:34:38 -05:00
parent 130bf904eb
commit 738f1fedbc
9 changed files with 686 additions and 116 deletions

View file

@ -182,6 +182,79 @@ fn lang_emitter_dispatches_to_deserialize_harness() {
}
}
#[test]
fn deserialize_harness_drives_entry_when_derivable() {
// Java: reflectively load the fixture class and invoke the derived
// entry method so the fixture's own resolveClass allowlist runs before
// the gadget class resolves.
let java = lang::emit(&make_spec(
Lang::Java,
"tests/dynamic_fixtures/deserialize/java/Benign.java",
"run",
))
.expect("java deser emit");
assert!(
java.source.contains("Class.forName(\"Benign\")"),
"Java deser harness must reflectively load the fixture class",
);
assert!(
java.source.contains("getMethod(\"run\""),
"Java deser harness must invoke the derived entry method",
);
assert!(
java.source.contains("nyxCauseChainHas"),
"Java deser harness must detect gadget resolution via the cause chain",
);
// Ruby: require_relative the fixture and drive its entry so the
// const-name guard runs before Marshal.load.
let ruby = lang::emit(&make_spec(
Lang::Ruby,
"tests/dynamic_fixtures/deserialize/ruby/benign.rb",
"run",
))
.expect("ruby deser emit");
assert!(
ruby.source.contains("require_relative './benign'"),
"Ruby deser harness must require_relative the fixture",
);
assert!(
ruby.source.contains("__send__(:'run'"),
"Ruby deser harness must drive the derived entry function",
);
}
#[test]
fn deserialize_harness_falls_back_to_synthetic_without_entry() {
// No derivable enclosing entry → direct-sink synthetic path; the
// harness must not attempt to load a fixture it cannot name.
let java = lang::emit(&make_spec(
Lang::Java,
"tests/dynamic_fixtures/deserialize/java/Vuln.java",
"<unknown>",
))
.expect("java deser emit");
assert!(
!java.source.contains("Class.forName("),
"Java deser harness must not reflect into a fixture when no entry is derivable",
);
assert!(
java.source.contains("nyxSyntheticDeserialize"),
"Java synthetic fallback must drive the restricted-OIS path directly",
);
let ruby = lang::emit(&make_spec(
Lang::Ruby,
"tests/dynamic_fixtures/deserialize/ruby/vuln.rb",
"<unknown>",
))
.expect("ruby deser emit");
assert!(
!ruby.source.contains("require_relative"),
"Ruby deser harness must not require the fixture when no entry is derivable",
);
}
#[test]
fn framework_adapters_detect_deserialize_sink() {
// Java + Python + PHP + Ruby all register their J.1 sink adapter;

View file

@ -418,12 +418,13 @@ fn slug(lang: Lang) -> &'static str {
// into the prototype chain.
//
// Per-lang skips mirror the Phase 08 e2e block:
// - TypeScript: the synthetic harness short-circuits the entry
// source load entirely (`entry_subpath: None`), so no `tsx` /
// `ts-node` is needed at runtime — but on hosts without
// `tree_sitter_typescript` or the npm Node toolchain, the
// harness build will fall through `BuildFailed` and skip via the
// same branch.
// - TypeScript: the entry-driven harness now loads the fixture
// through an in-harness type-stripping + ESM→CJS shim
// (`nyxLoadTsEntry`), so no `tsx` / `ts-node` is needed at
// runtime — but on hosts without `tree_sitter_typescript`, a Node
// build lacking `module.stripTypeScriptTypes`, or the npm Node
// toolchain, the harness build/load falls through `BuildFailed`
// (or the runtime tier-(b) fallback) and skips via the same branch.
mod e2e_phase_10 {
use crate::common::fixture_harness::FIXTURE_LOCK;
@ -540,6 +541,25 @@ mod e2e_phase_10 {
assert_eq!(diff.verdict, DifferentialVerdict::Confirmed);
}
/// A benign control must NOT confirm: the entry-driven harness invokes
/// the fixture's own `run`, whose `Object.create(null)` merge target
/// keeps the `__proto__` payload off the shared prototype, so the
/// canary trap stays clear and the differential never confirms.
fn assert_not_confirmed(lang: Lang, outcome: &RunOutcome) {
assert!(
outcome.triggered_by.is_none(),
"{lang:?} PROTOTYPE_POLLUTION benign control must NOT confirm — the \
caller-side `Object.create(null)` guard must participate; got {outcome:?}",
);
if let Some(diff) = outcome.differential.as_ref() {
assert_ne!(
diff.verdict,
DifferentialVerdict::Confirmed,
"{lang:?} benign differential must not be Confirmed",
);
}
}
#[test]
fn js_vuln_confirms_via_run_spec() {
let Some(outcome) = run(Lang::JavaScript, "vuln.js", "run") else {
@ -555,4 +575,20 @@ mod e2e_phase_10 {
};
assert_confirmed(Lang::TypeScript, &outcome);
}
#[test]
fn js_benign_not_confirmed_via_run_spec() {
let Some(outcome) = run(Lang::JavaScript, "benign.js", "run") else {
return;
};
assert_not_confirmed(Lang::JavaScript, &outcome);
}
#[test]
fn ts_benign_not_confirmed_via_run_spec() {
let Some(outcome) = run(Lang::TypeScript, "benign.ts", "run") else {
return;
};
assert_not_confirmed(Lang::TypeScript, &outcome);
}
}