mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
281 lines
9.7 KiB
Rust
281 lines
9.7 KiB
Rust
//! Phase 08 — Track C.4 + C.5 acceptance tests.
|
|
//!
|
|
//! The runner-side path is exercised in isolation by the
|
|
//! `oracle_differential` tests; here we lock down the synthetic side of
|
|
//! Phase 08 — that a sink-site crash probe confirms via
|
|
//! [`Oracle::SinkCrash`], that an outside-sink process abort *does not*
|
|
//! confirm, and that witness construction stays bounded.
|
|
//!
|
|
//! Acceptance bullets (`plan.md` phase 08):
|
|
//!
|
|
//! - (a) sink-site crash → `Confirmed`
|
|
//! - (b) crash outside sink → `Inconclusive(UnrelatedCrash)`
|
|
//! - (c) bounded witness capture for known payloads
|
|
|
|
#![cfg(feature = "dynamic")]
|
|
|
|
use nyx_scanner::dynamic::oracle::{
|
|
oracle_fired, probe_crash_signal, Oracle, Signal, SignalSet,
|
|
};
|
|
use nyx_scanner::dynamic::policy;
|
|
use nyx_scanner::dynamic::probe::{
|
|
ProbeArg, ProbeChannel, ProbeKind, ProbeWitness, SinkProbe,
|
|
};
|
|
use nyx_scanner::dynamic::sandbox::SandboxOutcome;
|
|
use nyx_scanner::evidence::InconclusiveReason;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
fn crashed_outcome() -> SandboxOutcome {
|
|
// Process-level abort: no exit code, no timeout.
|
|
SandboxOutcome {
|
|
exit_code: None,
|
|
stdout: vec![],
|
|
stderr: vec![],
|
|
timed_out: false,
|
|
oob_callback_seen: false,
|
|
sink_hit: false,
|
|
duration: Duration::from_millis(1),
|
|
hardening_outcome: None,
|
|
}
|
|
}
|
|
|
|
fn clean_outcome() -> SandboxOutcome {
|
|
SandboxOutcome {
|
|
exit_code: Some(0),
|
|
stdout: vec![],
|
|
stderr: vec![],
|
|
timed_out: false,
|
|
oob_callback_seen: false,
|
|
sink_hit: false,
|
|
duration: Duration::from_millis(1),
|
|
hardening_outcome: None,
|
|
}
|
|
}
|
|
|
|
fn crash_probe(callee: &str, signal: Signal, witness: ProbeWitness) -> SinkProbe {
|
|
SinkProbe {
|
|
sink_callee: callee.into(),
|
|
args: vec![],
|
|
captured_at_ns: 1,
|
|
payload_id: "crash-test".into(),
|
|
kind: ProbeKind::Crash { signal },
|
|
witness,
|
|
}
|
|
}
|
|
|
|
// ── (a) Sink-site crash → Confirmed ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn case_a_sink_site_crash_confirms() {
|
|
// Simulates the per-language signal handler: harness aborted, but
|
|
// before re-raising it wrote a Crash probe to the channel.
|
|
let dir = TempDir::new().unwrap();
|
|
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
|
|
let witness = ProbeWitness::from_inputs(
|
|
vec![("PATH".to_owned(), "/bin".to_owned())],
|
|
"/tmp/run",
|
|
b"<? system($_GET[x]); ?>",
|
|
"system",
|
|
vec!["<? system($_GET[x]); ?>".to_owned()],
|
|
);
|
|
channel
|
|
.write(&crash_probe("system", Signal::Sigsegv, witness))
|
|
.unwrap();
|
|
|
|
let probes = channel.drain();
|
|
assert_eq!(probes.len(), 1);
|
|
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::from_slice(&[Signal::Sigsegv]),
|
|
};
|
|
assert!(
|
|
oracle_fired(&oracle, &crashed_outcome(), &probes),
|
|
"sink-site Crash probe with matching signal must fire SinkCrash oracle"
|
|
);
|
|
|
|
// Helper accessor exposes the signal so the runner can distinguish
|
|
// "matching probe present" from "process crashed only".
|
|
assert_eq!(probe_crash_signal(&probes[0]), Some(Signal::Sigsegv));
|
|
}
|
|
|
|
// ── (b) Crash outside sink → Inconclusive(UnrelatedCrash) ────────────────────
|
|
|
|
#[test]
|
|
fn case_b_outside_sink_crash_does_not_fire_and_is_unrelated() {
|
|
// The harness was instrumented with Oracle::SinkCrash but the
|
|
// process aborted in setup code (e.g. abort() in module init)
|
|
// before the sink ran — no Crash probe was written.
|
|
let dir = TempDir::new().unwrap();
|
|
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
|
|
let probes = channel.drain();
|
|
assert!(probes.is_empty(), "no probe written from outside-sink abort");
|
|
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::all(),
|
|
};
|
|
assert!(
|
|
!oracle_fired(&oracle, &crashed_outcome(), &probes),
|
|
"process crash without a sink-site probe must NOT fire SinkCrash"
|
|
);
|
|
|
|
// The verifier's runner-side condition that promotes this case to
|
|
// `Inconclusive(UnrelatedCrash)` is: SinkCrash oracle + crashed
|
|
// outcome + no probe with a crash signal. Lock the predicate
|
|
// here so the runner's wiring in src/dynamic/runner.rs stays in
|
|
// sync with what the test labels expect.
|
|
let process_crashed =
|
|
crashed_outcome().exit_code.is_none() && !crashed_outcome().timed_out;
|
|
let has_sink_crash_probe = probes.iter().any(|p| probe_crash_signal(p).is_some());
|
|
let is_sink_crash_oracle = matches!(oracle, Oracle::SinkCrash { .. });
|
|
assert!(is_sink_crash_oracle && process_crashed && !has_sink_crash_probe);
|
|
|
|
// The verdict mapping itself is constructed by the verifier; reference
|
|
// the variant so a rename keeps this test honest.
|
|
let _reason = InconclusiveReason::UnrelatedCrash;
|
|
}
|
|
|
|
#[test]
|
|
fn case_b_clean_exit_does_not_fire_sink_crash() {
|
|
// Sanity: a clean run with no probe is also not Confirmed (and not
|
|
// UnrelatedCrash either, since the process did not crash).
|
|
let oracle = Oracle::SinkCrash {
|
|
signals: SignalSet::all(),
|
|
};
|
|
assert!(!oracle_fired(&oracle, &clean_outcome(), &[]));
|
|
}
|
|
|
|
// ── (c) Bounded witness capture ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn case_c_witness_capture_is_bounded_and_scrubbed() {
|
|
// Construct a witness from intentionally oversized + credential-tainted
|
|
// inputs to lock the policy contract: payload truncated at 16 KiB and
|
|
// denied env keys redacted.
|
|
let huge_payload = vec![0x41u8; policy::PAYLOAD_CAPTURE_LIMIT_BYTES * 4];
|
|
let env = vec![
|
|
("PATH".to_owned(), "/usr/bin".to_owned()),
|
|
("AWS_SECRET_ACCESS_KEY".to_owned(), "AKIAEXAMPLE".to_owned()),
|
|
("GITHUB_TOKEN".to_owned(), "ghs_fake".to_owned()),
|
|
("HOME".to_owned(), "/home/x".to_owned()),
|
|
];
|
|
let witness = ProbeWitness::from_inputs(
|
|
env,
|
|
"/tmp/nyx-run-1",
|
|
&huge_payload,
|
|
"exec",
|
|
vec!["arg0".to_owned(), "arg1".to_owned()],
|
|
);
|
|
|
|
assert_eq!(
|
|
witness.payload_bytes.len(),
|
|
policy::PAYLOAD_CAPTURE_LIMIT_BYTES,
|
|
"payload must be truncated to the 16 KiB cap"
|
|
);
|
|
assert!(
|
|
witness.payload_bytes.iter().all(|b| *b == 0x41),
|
|
"head-truncation keeps prefix bytes"
|
|
);
|
|
|
|
// PATH / HOME unchanged.
|
|
assert_eq!(
|
|
witness.env_snapshot.get("PATH").map(String::as_str),
|
|
Some("/usr/bin"),
|
|
);
|
|
assert_eq!(
|
|
witness.env_snapshot.get("HOME").map(String::as_str),
|
|
Some("/home/x"),
|
|
);
|
|
|
|
// Credential-shaped keys redacted.
|
|
assert_eq!(
|
|
witness
|
|
.env_snapshot
|
|
.get("AWS_SECRET_ACCESS_KEY")
|
|
.map(String::as_str),
|
|
Some(policy::REDACTED_VALUE),
|
|
);
|
|
assert_eq!(
|
|
witness.env_snapshot.get("GITHUB_TOKEN").map(String::as_str),
|
|
Some(policy::REDACTED_VALUE),
|
|
);
|
|
|
|
assert_eq!(witness.cwd, "/tmp/nyx-run-1");
|
|
assert_eq!(witness.callee, "exec");
|
|
assert_eq!(witness.args_repr, vec!["arg0".to_owned(), "arg1".to_owned()]);
|
|
}
|
|
|
|
#[test]
|
|
fn case_c_witness_round_trips_through_probe_channel() {
|
|
// The witness must survive serde round-trip so downstream repro
|
|
// tools see what the harness captured.
|
|
let dir = TempDir::new().unwrap();
|
|
let channel = ProbeChannel::for_workdir(dir.path()).unwrap();
|
|
let witness = ProbeWitness::from_inputs(
|
|
vec![
|
|
("PATH".to_owned(), "/usr/bin".to_owned()),
|
|
("API_KEY".to_owned(), "live".to_owned()),
|
|
],
|
|
"/tmp/run",
|
|
b"; rm -rf /",
|
|
"system",
|
|
vec!["; rm -rf /".to_owned()],
|
|
);
|
|
let probe = SinkProbe {
|
|
sink_callee: "system".into(),
|
|
args: vec![ProbeArg::String("; rm -rf /".into())],
|
|
captured_at_ns: 42,
|
|
payload_id: "phase08-c".into(),
|
|
kind: ProbeKind::Crash {
|
|
signal: Signal::Sigabrt,
|
|
},
|
|
witness,
|
|
};
|
|
channel.write(&probe).unwrap();
|
|
|
|
let drained = channel.drain();
|
|
assert_eq!(drained.len(), 1);
|
|
let p = &drained[0];
|
|
assert!(matches!(
|
|
p.kind,
|
|
ProbeKind::Crash {
|
|
signal: Signal::Sigabrt
|
|
}
|
|
));
|
|
assert_eq!(p.witness.cwd, "/tmp/run");
|
|
assert_eq!(
|
|
p.witness.env_snapshot.get("API_KEY").map(String::as_str),
|
|
Some(policy::REDACTED_VALUE),
|
|
);
|
|
assert_eq!(
|
|
p.witness.env_snapshot.get("PATH").map(String::as_str),
|
|
Some("/usr/bin"),
|
|
);
|
|
assert_eq!(p.witness.payload_bytes, b"; rm -rf /".to_vec());
|
|
}
|
|
|
|
#[test]
|
|
fn signal_wire_format_accepts_canonical_and_short_aliases() {
|
|
// The per-language shims write SIGSEGV / SIGABRT / etc. as the
|
|
// signal value; downstream JSON consumers and the host-side oracle
|
|
// both need to deserialise the same wire format.
|
|
let canonical =
|
|
serde_json::from_str::<Signal>("\"SIGSEGV\"").expect("canonical SIG name");
|
|
assert_eq!(canonical, Signal::Sigsegv);
|
|
let short = serde_json::from_str::<Signal>("\"SEGV\"").expect("short alias");
|
|
assert_eq!(short, Signal::Sigsegv);
|
|
let title =
|
|
serde_json::from_str::<Signal>("\"Sigsegv\"").expect("derive-default alias");
|
|
assert_eq!(title, Signal::Sigsegv);
|
|
}
|
|
|
|
#[test]
|
|
fn signal_set_const_construction_is_order_independent() {
|
|
const A: SignalSet = SignalSet::from_slice(&[Signal::Sigsegv, Signal::Sigabrt]);
|
|
const B: SignalSet = SignalSet::from_slice(&[Signal::Sigabrt, Signal::Sigsegv]);
|
|
assert!(A.contains(Signal::Sigsegv));
|
|
assert!(A.contains(Signal::Sigabrt));
|
|
assert!(B.contains(Signal::Sigsegv));
|
|
assert!(B.contains(Signal::Sigabrt));
|
|
assert!(!A.contains(Signal::Sigfpe));
|
|
}
|