nyx/tests/oracle_sink_probe.rs
2026-06-05 10:16:30 -05:00

225 lines
7.3 KiB
Rust

//! Integration test for Phase 06 — Track C.1.
//!
//! Synthetic harness emits a structured [`SinkProbe`] record to the
//! per-run [`ProbeChannel`]; the oracle's [`Oracle::SinkProbe`] path
//! drains the channel and applies [`ProbePredicate`]s. A matching
//! synthetic control harness *omits* the probe write — the same oracle
//! must then return `NotConfirmed`.
//!
//! Acceptance bullet from `plan.md` phase 06:
//!
//! > Removing the probe write from one fixture flips its verdict from
//! > `Confirmed` to `NotConfirmed` in CI.
//!
//! Mechanism: the two fixtures share the identical oracle + payload
//! configuration; the only difference is whether the synthetic harness
//! body writes a [`SinkProbe`] record to the probe channel.
#![cfg(feature = "dynamic")]
use nyx_scanner::dynamic::oracle::{Oracle, ProbePredicate, oracle_fired};
use nyx_scanner::dynamic::probe::{
PROBE_PATH_ENV, ProbeArg, ProbeChannel, ProbeKind, ProbeWitness, SinkProbe,
};
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
use tempfile::TempDir;
static PROBE_ENV_LOCK: Mutex<()> = Mutex::new(());
struct ProbeEnvGuard {
_lock: MutexGuard<'static, ()>,
prior: Option<String>,
}
impl ProbeEnvGuard {
fn set(channel: &ProbeChannel) -> Self {
let lock = PROBE_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let prior = std::env::var(PROBE_PATH_ENV).ok();
unsafe { std::env::set_var(PROBE_PATH_ENV, channel.path()) };
Self { _lock: lock, prior }
}
}
impl Drop for ProbeEnvGuard {
fn drop(&mut self) {
match self.prior.take() {
Some(value) => unsafe { std::env::set_var(PROBE_PATH_ENV, value) },
None => unsafe { std::env::remove_var(PROBE_PATH_ENV) },
}
}
}
/// Minimal [`SandboxOutcome`] suitable for oracle evaluation when the
/// runner-side execution path is not exercised. All flags are off so any
/// `true` verdict must come from the probe channel, not from
/// `output_contains` / `oob_callback_seen` etc.
fn dummy_outcome() -> nyx_scanner::dynamic::sandbox::SandboxOutcome {
nyx_scanner::dynamic::sandbox::SandboxOutcome {
exit_code: Some(0),
stdout: vec![],
stderr: vec![],
timed_out: false,
oob_callback_seen: false,
sink_hit: true,
duration: Duration::from_millis(1),
hardening_outcome: None,
}
}
/// Synthetic harness body. Mirrors what a real per-language `__nyx_probe`
/// shim would do: read `NYX_PROBE_PATH` from its env, append one JSON
/// record per fired sink. The runner-side test serialises the harness
/// invocation with this Rust function instead of spawning a subprocess.
fn synthetic_harness_fires_probe(
channel: &ProbeChannel,
sink_callee: &str,
captured_arg: &str,
payload_id: &str,
) {
let probe = SinkProbe {
sink_callee: sink_callee.into(),
args: vec![ProbeArg::String(captured_arg.into())],
captured_at_ns: 1,
payload_id: payload_id.into(),
kind: ProbeKind::Normal,
witness: ProbeWitness::empty(),
};
channel
.write(&probe)
.expect("synthetic harness probe write");
}
/// "Control" harness — runs the same way but does NOT write a probe.
fn synthetic_harness_omits_probe(_channel: &ProbeChannel) {
// Intentionally empty: the oracle path must observe zero probe records
// and decide NotConfirmed.
}
#[test]
fn sink_probe_oracle_confirms_when_harness_writes_probe() {
let dir = TempDir::new().unwrap();
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
// Exercise the harness env-var path so the test also locks the
// NYX_PROBE_PATH contract the real sandbox forwards to the harness.
let _env = ProbeEnvGuard::set(&channel);
assert_eq!(
std::env::var(PROBE_PATH_ENV).unwrap().as_str(),
channel.path().to_str().unwrap(),
);
synthetic_harness_fires_probe(
&channel,
"os.system",
"; echo NYX_PWN_CMDI",
"cmdi-echo-marker",
);
let oracle = Oracle::SinkProbe {
predicates: &[
ProbePredicate::CalleeEquals("os.system"),
ProbePredicate::ArgContains {
index: 0,
needle: "NYX_PWN_CMDI",
},
],
};
let probes = channel.drain();
assert_eq!(probes.len(), 1, "harness must have written one probe");
assert!(
oracle_fired(&oracle, &dummy_outcome(), &probes),
"oracle with SinkProbe predicates must confirm when probe matches",
);
}
#[test]
fn sink_probe_oracle_not_confirmed_when_harness_omits_probe() {
let dir = TempDir::new().unwrap();
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
let _env = ProbeEnvGuard::set(&channel);
// Control fixture: identical configuration but the harness skips its
// probe write. Same oracle predicate set as the Confirmed test —
// the only difference is the (absent) write.
synthetic_harness_omits_probe(&channel);
let oracle = Oracle::SinkProbe {
predicates: &[
ProbePredicate::CalleeEquals("os.system"),
ProbePredicate::ArgContains {
index: 0,
needle: "NYX_PWN_CMDI",
},
],
};
let probes = channel.drain();
assert!(
probes.is_empty(),
"control harness must not have written any probe",
);
assert!(
!oracle_fired(&oracle, &dummy_outcome(), &probes),
"oracle must NOT confirm when no probe is present",
);
}
#[test]
fn sink_probe_oracle_not_confirmed_when_predicate_mismatch() {
// Probe is present, but its captured arg does not satisfy the
// predicates. Verifies the oracle does not blanket-confirm on
// "any probe at all" — payload predicates have teeth.
let dir = TempDir::new().unwrap();
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
synthetic_harness_fires_probe(
&channel,
"os.system",
"benign argument that does not match",
"cmdi-echo-marker",
);
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::ArgContains {
index: 0,
needle: "NYX_PWN_CMDI",
}],
};
let probes = channel.drain();
assert_eq!(probes.len(), 1);
assert!(
!oracle_fired(&oracle, &dummy_outcome(), &probes),
"oracle must NOT confirm when probe args fail the predicate set",
);
}
#[test]
fn probe_channel_clear_between_runs_isolates_verdicts() {
// Mirrors the runner's clear-before-each-payload behaviour: a probe
// left over from a previous payload run must not bleed into the
// verdict for a later payload.
let dir = TempDir::new().unwrap();
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
synthetic_harness_fires_probe(&channel, "os.system", "stale probe", "earlier-payload");
assert_eq!(channel.drain().len(), 1);
channel.clear().unwrap();
assert!(
channel.drain().is_empty(),
"clear() must remove the leftover probe from the previous run",
);
let oracle = Oracle::SinkProbe {
predicates: &[ProbePredicate::CalleeEquals("os.system")],
};
// Second payload omits the probe write entirely.
let probes = channel.drain();
assert!(!oracle_fired(&oracle, &dummy_outcome(), &probes));
}