mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
225 lines
7.3 KiB
Rust
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));
|
|
}
|