mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
389 lines
13 KiB
Rust
389 lines
13 KiB
Rust
//! Phase 10 (Track D.3) — boundary-stub providers, one positive +
|
|
//! one benign per stub kind.
|
|
//!
|
|
//! Each test wires a [`StubProvider`] to the corresponding fixture's
|
|
//! `vuln.txt` / `benign.txt` and asserts that the oracle confirms
|
|
//! only when the recorded event matches the kind-specific needle.
|
|
//! Synthesises harness behaviour with host-side `record_*` helpers
|
|
//! so the suite runs without spawning a language toolchain; the
|
|
//! shape mirrors what a real harness would do once the per-language
|
|
//! `__nyx_probe` shims gain stub-aware wrappers.
|
|
//!
|
|
//! Acceptance bullets from `plan.md` phase 10:
|
|
//!
|
|
//! > `cargo nextest run --features dynamic --test stubs_per_cap` green.
|
|
//! > SQL-cap fixture confirms with the captured query visible in the
|
|
//! > probe output.
|
|
//! > Harness with `stubs_required: []` boots in under 500ms.
|
|
|
|
#![cfg(feature = "dynamic")]
|
|
|
|
use nyx_scanner::dynamic::oracle::{Oracle, ProbePredicate, oracle_fired_with_stubs};
|
|
use nyx_scanner::dynamic::probe::{ProbeArg, ProbeChannel, SinkProbe};
|
|
use nyx_scanner::dynamic::sandbox::SandboxOutcome;
|
|
use nyx_scanner::dynamic::stubs::{
|
|
FilesystemStub, HttpStub, RedisStub, SqlStub, StubHarness, StubKind, StubProvider,
|
|
};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
fn fixture_path(stub_dir: &str, name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("dynamic_fixtures")
|
|
.join("stubs")
|
|
.join(stub_dir)
|
|
.join(name)
|
|
}
|
|
|
|
fn read_fixture(stub_dir: &str, name: &str) -> String {
|
|
std::fs::read_to_string(fixture_path(stub_dir, name))
|
|
.unwrap_or_else(|e| panic!("read fixture {stub_dir}/{name}: {e}"))
|
|
}
|
|
|
|
/// Extract the last non-comment, non-blank line. Fixture comments
|
|
/// begin with `//`; the payload is the surviving line.
|
|
fn extract_payload(s: &str) -> String {
|
|
s.lines()
|
|
.rfind(|l| !l.trim().is_empty() && !l.trim_start().starts_with("//"))
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_owned()
|
|
}
|
|
|
|
fn empty_outcome() -> SandboxOutcome {
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ── SQL stub ─────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn sql_stub_vuln_fixture_confirms_with_captured_query() {
|
|
let dir = TempDir::new().unwrap();
|
|
let stub = SqlStub::start(dir.path()).unwrap();
|
|
|
|
// Synthetic harness: read the vuln fixture, record the executed
|
|
// query against the stub, then evaluate the oracle.
|
|
let payload = extract_payload(&read_fixture("sql", "vuln.txt"));
|
|
assert!(
|
|
payload.contains("OR 1=1"),
|
|
"vuln fixture must carry a tautology"
|
|
);
|
|
stub.record_query(&payload).unwrap();
|
|
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Sql,
|
|
needle: "OR 1=1",
|
|
};
|
|
let events = stub.drain_events();
|
|
assert_eq!(
|
|
events.len(),
|
|
1,
|
|
"stub must have captured the executed query"
|
|
);
|
|
assert!(
|
|
events[0].summary.contains("OR 1=1"),
|
|
"captured query must be visible in probe output: {:?}",
|
|
events[0].summary,
|
|
);
|
|
assert!(
|
|
oracle_fired_with_stubs(&oracle, &empty_outcome(), &[], &events),
|
|
"SQL stub oracle must confirm the captured tautology",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sql_stub_benign_fixture_does_not_confirm() {
|
|
let dir = TempDir::new().unwrap();
|
|
let stub = SqlStub::start(dir.path()).unwrap();
|
|
|
|
let payload = extract_payload(&read_fixture("sql", "benign.txt"));
|
|
assert!(
|
|
!payload.contains("OR 1=1"),
|
|
"benign control must lack tautology"
|
|
);
|
|
stub.record_query(&payload).unwrap();
|
|
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Sql,
|
|
needle: "OR 1=1",
|
|
};
|
|
let events = stub.drain_events();
|
|
assert!(
|
|
!oracle_fired_with_stubs(&oracle, &empty_outcome(), &[], &events),
|
|
"benign control must not satisfy the oracle",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sql_stub_captured_query_threads_through_probe_predicate() {
|
|
// The plan calls for `ProbePredicate::StubEventMatches` as a
|
|
// cross-cutting predicate inside `Oracle::SinkProbe`. Confirm
|
|
// the predicate path fires with the same fixture.
|
|
let dir = TempDir::new().unwrap();
|
|
let stub = SqlStub::start(dir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("sql", "vuln.txt"));
|
|
stub.record_query(&payload).unwrap();
|
|
let events = stub.drain_events();
|
|
|
|
// Pair the stub-event check with a per-probe `CalleeEquals` so
|
|
// we exercise the predicate-partition path in
|
|
// `oracle_fired_with_stubs`.
|
|
let probe = SinkProbe {
|
|
sink_callee: "sqlite3.execute".into(),
|
|
args: vec![ProbeArg::String(payload.clone())],
|
|
captured_at_ns: 1,
|
|
payload_id: "sql-tautology".into(),
|
|
kind: Default::default(),
|
|
witness: Default::default(),
|
|
};
|
|
let oracle = Oracle::SinkProbe {
|
|
predicates: &[
|
|
ProbePredicate::CalleeEquals("sqlite3.execute"),
|
|
ProbePredicate::StubEventMatches {
|
|
kind: StubKind::Sql,
|
|
needle: "OR 1=1",
|
|
},
|
|
],
|
|
};
|
|
assert!(
|
|
oracle_fired_with_stubs(&oracle, &empty_outcome(), &[probe], &events),
|
|
"ProbePredicate::StubEventMatches must satisfy when stub log has needle",
|
|
);
|
|
}
|
|
|
|
// ── HTTP stub ────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn http_stub_vuln_fixture_confirms_recorded_request() {
|
|
let workdir = TempDir::new().unwrap();
|
|
let stub = HttpStub::start(workdir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("http", "vuln.txt"));
|
|
assert!(
|
|
payload.contains("169.254"),
|
|
"vuln fixture must carry metadata host"
|
|
);
|
|
|
|
stub.record(payload.clone());
|
|
let events = stub.drain_events();
|
|
assert_eq!(events.len(), 1);
|
|
assert!(events[0].summary.contains("169.254"));
|
|
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Http,
|
|
needle: "169.254",
|
|
};
|
|
assert!(oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn http_stub_benign_fixture_does_not_confirm() {
|
|
let workdir = TempDir::new().unwrap();
|
|
let stub = HttpStub::start(workdir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("http", "benign.txt"));
|
|
stub.record(payload);
|
|
let events = stub.drain_events();
|
|
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Http,
|
|
needle: "169.254",
|
|
};
|
|
assert!(!oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
// ── Redis stub ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn redis_stub_vuln_fixture_confirms_destructive_command() {
|
|
let stub = RedisStub::start().unwrap();
|
|
let payload = extract_payload(&read_fixture("redis", "vuln.txt"));
|
|
assert!(payload.contains("FLUSHALL"));
|
|
stub.record(payload, &[]);
|
|
|
|
let events = stub.drain_events();
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Redis,
|
|
needle: "FLUSHALL",
|
|
};
|
|
assert!(oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn redis_stub_benign_fixture_does_not_confirm() {
|
|
let stub = RedisStub::start().unwrap();
|
|
let payload = extract_payload(&read_fixture("redis", "benign.txt"));
|
|
let mut parts = payload.split_whitespace();
|
|
let cmd = parts.next().unwrap_or("");
|
|
let args: Vec<&str> = parts.collect();
|
|
stub.record(cmd, &args);
|
|
let events = stub.drain_events();
|
|
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Redis,
|
|
needle: "FLUSHALL",
|
|
};
|
|
assert!(!oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
// ── Filesystem stub ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn filesystem_stub_vuln_fixture_confirms_path_traversal() {
|
|
let dir = TempDir::new().unwrap();
|
|
let stub = FilesystemStub::start(dir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("filesystem", "vuln.txt"));
|
|
let (op, path) = payload.split_once(' ').unwrap_or(("read", &payload));
|
|
stub.record_access(op, path);
|
|
|
|
let events = stub.drain_events();
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Filesystem,
|
|
needle: "/etc/passwd",
|
|
};
|
|
assert!(oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn filesystem_stub_benign_fixture_does_not_confirm() {
|
|
let dir = TempDir::new().unwrap();
|
|
let stub = FilesystemStub::start(dir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("filesystem", "benign.txt"));
|
|
let (op, path) = payload.split_once(' ').unwrap_or(("read", &payload));
|
|
stub.record_access(op, path);
|
|
|
|
let events = stub.drain_events();
|
|
let oracle = Oracle::StubEvent {
|
|
kind: StubKind::Filesystem,
|
|
needle: "/etc/passwd",
|
|
};
|
|
assert!(!oracle_fired_with_stubs(
|
|
&oracle,
|
|
&empty_outcome(),
|
|
&[],
|
|
&events
|
|
));
|
|
}
|
|
|
|
// ── Performance invariant ────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn empty_stubs_required_boots_under_500ms() {
|
|
// Phase 10 acceptance bullet: "Harness with `stubs_required: []`
|
|
// boots in under 500ms (performance invariant from cross-cutting
|
|
// concerns)." Direct measurement on `StubHarness::start`.
|
|
let dir = TempDir::new().unwrap();
|
|
let start = std::time::Instant::now();
|
|
let h = StubHarness::start(&[], dir.path()).unwrap();
|
|
let elapsed = start.elapsed();
|
|
assert!(h.is_empty());
|
|
assert!(
|
|
elapsed < Duration::from_millis(500),
|
|
"stubs_required=[] must boot in <500ms, took {elapsed:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn harness_endpoints_carry_well_known_env_names() {
|
|
// Pull every stub kind so the test asserts the full mapping in
|
|
// `StubKind::env_var` survives at the aggregator level.
|
|
let dir = TempDir::new().unwrap();
|
|
let h = StubHarness::start(
|
|
&[
|
|
StubKind::Sql,
|
|
StubKind::Http,
|
|
StubKind::Redis,
|
|
StubKind::Filesystem,
|
|
],
|
|
dir.path(),
|
|
)
|
|
.unwrap();
|
|
let names: Vec<&str> = h.endpoints().iter().map(|(n, _)| *n).collect();
|
|
assert!(names.contains(&"NYX_SQL_ENDPOINT"));
|
|
assert!(names.contains(&"NYX_HTTP_ENDPOINT"));
|
|
assert!(names.contains(&"NYX_REDIS_ENDPOINT"));
|
|
assert!(names.contains(&"NYX_FS_ROOT"));
|
|
}
|
|
|
|
#[test]
|
|
fn drained_events_are_kind_tagged() {
|
|
// Cross-stub drain: when a harness aggregates multiple stubs,
|
|
// each drained event must carry its source kind so the oracle's
|
|
// `StubEventMatches { kind, .. }` filter works without external
|
|
// bookkeeping.
|
|
let dir = TempDir::new().unwrap();
|
|
let sql = SqlStub::start(dir.path()).unwrap();
|
|
let fs = FilesystemStub::start(dir.path()).unwrap();
|
|
sql.record_query("SELECT 1").unwrap();
|
|
fs.record_access("read", "/tmp/x");
|
|
|
|
let mut all = sql.drain_events();
|
|
all.extend(fs.drain_events());
|
|
let kinds: Vec<StubKind> = all.iter().map(|e| e.kind).collect();
|
|
assert!(kinds.contains(&StubKind::Sql));
|
|
assert!(kinds.contains(&StubKind::Filesystem));
|
|
}
|
|
|
|
#[test]
|
|
fn sql_stub_captured_query_visible_in_probe_output() {
|
|
// The plan's literal phrasing: "SQL-cap fixture confirms with the
|
|
// captured query visible in the probe output." Verify that the
|
|
// recorded query lands inside a serialisable probe-shaped record
|
|
// (`StubEvent` round-trips through serde) so downstream tooling
|
|
// can render the captured query alongside per-probe args.
|
|
let dir = TempDir::new().unwrap();
|
|
let workdir = TempDir::new().unwrap();
|
|
let stub = SqlStub::start(dir.path()).unwrap();
|
|
let payload = extract_payload(&read_fixture("sql", "vuln.txt"));
|
|
stub.record_query(&payload).unwrap();
|
|
|
|
let events = stub.drain_events();
|
|
let event = events.first().expect("captured event");
|
|
// Round-trip through serde so the assertion mirrors what the
|
|
// verifier writes into a repro bundle.
|
|
let serialised = serde_json::to_string(event).unwrap();
|
|
assert!(
|
|
serialised.contains("OR 1=1"),
|
|
"captured query must survive serialisation: {serialised}",
|
|
);
|
|
|
|
// Also confirm the probe channel adjacent to the stub is empty
|
|
// — the captured query lives on the stub event log, not on the
|
|
// probe channel. This locks the partition the oracle relies on.
|
|
let channel = ProbeChannel::for_workdir(workdir.path()).unwrap();
|
|
assert!(channel.drain().is_empty());
|
|
}
|