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

165 lines
6.2 KiB
Rust

//! Phase 07 — differential confirmation rule (`differential::evaluate`).
//!
//! These tests pin the pure-function behaviour of the differential rule
//! (§4.1): given the (vulnerable, benign-control) oracle firing booleans
//! produce the right verdict. Each case has a matching paragraph in the
//! plan's acceptance criteria.
//!
//! The harness here does *not* spawn a sandbox — it exercises the rule
//! independently of payload corpus, sandbox availability, or per-language
//! toolchains. Integration coverage that runs both payloads end-to-end
//! lives in `tests/{python,rust}_fixtures.rs` and the golden harness from
//! Phase 05.
#![cfg(feature = "dynamic")]
use nyx_scanner::dynamic::differential::{build_outcome, evaluate};
use nyx_scanner::dynamic::probe::{ProbeArg, ProbeKind, ProbeWitness, SinkProbe};
use nyx_scanner::evidence::DifferentialVerdict;
// ── Rule table ──────────────────────────────────────────────────────────────
//
// | vuln fires | benign fires | verdict |
// |------------|--------------|-------------------------------|
// | true | true | OracleCollisionSuspected (a) |
// | true | false | Confirmed (b) |
// | false | false | NotConfirmed (c) |
// | false | true | ReversedDifferential (d) |
#[test]
fn case_a_both_fire_is_oracle_collision() {
assert_eq!(
evaluate(true, true),
DifferentialVerdict::OracleCollisionSuspected,
"both vulnerable and benign firing must downgrade to OracleCollisionSuspected"
);
}
#[test]
fn case_b_only_vuln_fires_is_confirmed() {
assert_eq!(
evaluate(true, false),
DifferentialVerdict::Confirmed,
"vuln fires + benign silent is the canonical Confirmed shape"
);
}
#[test]
fn case_c_neither_fires_is_not_confirmed() {
assert_eq!(
evaluate(false, false),
DifferentialVerdict::NotConfirmed,
"zero firings is plain NotConfirmed (nothing to triage)"
);
}
#[test]
fn case_d_only_benign_fires_is_reversed_differential() {
assert_eq!(
evaluate(false, true),
DifferentialVerdict::ReversedDifferential,
"only-benign-fires surfaces a misconfigured corpus, never a real Confirmed"
);
}
// ── build_outcome plumbing ───────────────────────────────────────────────────
//
// `build_outcome` is what the runner actually calls — it stamps the
// verdict and converts native [`SinkProbe`] records into the serde-stable
// shape stored on `VerifyResult`. These tests pin the conversion.
fn sample_probe(callee: &str, arg: &str, label: &str) -> SinkProbe {
SinkProbe {
sink_callee: callee.into(),
args: vec![ProbeArg::String(arg.into())],
captured_at_ns: 1,
payload_id: label.into(),
kind: ProbeKind::Normal,
witness: ProbeWitness::empty(),
}
}
#[test]
fn build_outcome_confirmed_carries_both_traces() {
let vuln = vec![sample_probe(
"os.system",
"; echo NYX_PWN_CMDI",
"cmdi-echo-marker",
)];
let benign = vec![sample_probe("os.system", "benign_safe_cmdi", "cmdi-benign")];
let outcome = build_outcome(
"cmdi-echo-marker",
true,
&vuln,
"cmdi-benign",
false,
&benign,
);
assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed);
assert_eq!(outcome.vuln_label, "cmdi-echo-marker");
assert_eq!(outcome.benign_label, "cmdi-benign");
assert_eq!(outcome.vuln_probes.len(), 1);
assert_eq!(outcome.benign_probes.len(), 1);
assert_eq!(outcome.vuln_probes[0].sink_callee, "os.system");
assert_eq!(outcome.vuln_probes[0].payload_id, "cmdi-echo-marker");
assert_eq!(outcome.benign_probes[0].payload_id, "cmdi-benign");
}
#[test]
fn build_outcome_oracle_collision_keeps_both_traces() {
let vuln = vec![sample_probe("os.system", "a", "v")];
let benign = vec![sample_probe("os.system", "b", "b")];
let outcome = build_outcome("v", true, &vuln, "b", true, &benign);
assert_eq!(
outcome.verdict,
DifferentialVerdict::OracleCollisionSuspected
);
assert_eq!(outcome.vuln_probes.len(), 1);
assert_eq!(outcome.benign_probes.len(), 1);
}
#[test]
fn build_outcome_not_confirmed_records_empty_traces() {
let outcome = build_outcome("v", false, &[], "b", false, &[]);
assert_eq!(outcome.verdict, DifferentialVerdict::NotConfirmed);
assert!(outcome.vuln_probes.is_empty());
assert!(outcome.benign_probes.is_empty());
}
#[test]
fn build_outcome_reversed_records_benign_only_trace() {
let benign = vec![sample_probe("os.system", "x", "b")];
let outcome = build_outcome("v", false, &[], "b", true, &benign);
assert_eq!(outcome.verdict, DifferentialVerdict::ReversedDifferential);
assert!(outcome.vuln_probes.is_empty());
assert_eq!(outcome.benign_probes.len(), 1);
}
// ── Serde stability ──────────────────────────────────────────────────────────
//
// `VerifyResult.differential` is part of the public verdict JSON shape
// (consumed by SARIF emitters, the React frontend, and the verdict cache).
// Pin the wire format.
#[test]
fn differential_outcome_serialises_as_pascal_case_verdict() {
let outcome = build_outcome("v", true, &[], "b", false, &[]);
let json = serde_json::to_value(&outcome).expect("serialise");
assert_eq!(json["verdict"], "Confirmed");
assert_eq!(json["vuln_label"], "v");
assert_eq!(json["benign_label"], "b");
}
#[test]
fn differential_verdict_round_trips_through_json() {
for v in [
DifferentialVerdict::Confirmed,
DifferentialVerdict::OracleCollisionSuspected,
DifferentialVerdict::NotConfirmed,
DifferentialVerdict::ReversedDifferential,
] {
let json = serde_json::to_string(&v).unwrap();
let back: DifferentialVerdict = serde_json::from_str(&json).unwrap();
assert_eq!(v, back);
}
}