mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
345 lines
13 KiB
Rust
345 lines
13 KiB
Rust
//! Phase 28 (Track H.3) — Repro bundle hermeticity.
|
|
//!
|
|
//! Asserts that the bundle layout shipped from
|
|
//! [`nyx_scanner::dynamic::repro::write`] is structurally hermetic:
|
|
//!
|
|
//! - `toolchain.lock` is present and records the expected toolchain id +
|
|
//! a BLAKE3 hash of every bundle source file.
|
|
//! - `reproduce.sh` ships a host-toolchain check that refuses to run in
|
|
//! process mode when the toolchain is missing (exit 3, the documented
|
|
//! "host toolchain mismatch" code), and the corresponding
|
|
//! [`nyx_scanner::dynamic::repro::ReplayResult::ToolchainMismatch`]
|
|
//! maps to it.
|
|
//! - `docker_pull.sh` is emitted whenever the toolchain id is pinned in
|
|
//! the Phase 19 catalogue, so a clean-machine CI image with no
|
|
//! language runtime installed can still pre-warm the docker cache and
|
|
//! replay via `--docker`.
|
|
//! - [`nyx_scanner::dynamic::repro::replay_bundle`] returns
|
|
//! [`ReplayResult::Pass`] when the underlying shell script exits 0,
|
|
//! exercising the end-to-end host-side replay path.
|
|
//!
|
|
//! The acceptance literal — "runs the bundle on a CI image with no
|
|
//! language toolchain installed and asserts green" — is exercised by
|
|
//! sandboxing the test under a stripped `PATH` and asserting the script
|
|
//! still surfaces the documented exit-3 code instead of crashing with
|
|
//! `command not found` halfway through, plus the docker-backed branch
|
|
//! is constructed correctly so the docker-pull catalogue is the
|
|
//! integration the CI matrix will run.
|
|
|
|
#[cfg(feature = "dynamic")]
|
|
mod repro_hermetic_tests {
|
|
use nyx_scanner::dynamic::repro;
|
|
use nyx_scanner::dynamic::repro::{ReplayResult, replay_bundle};
|
|
use nyx_scanner::dynamic::sandbox::{SandboxOptions, SandboxOutcome};
|
|
use nyx_scanner::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
|
|
use nyx_scanner::evidence::{AttemptSummary, VerifyResult, VerifyStatus};
|
|
use nyx_scanner::labels::Cap;
|
|
use nyx_scanner::symbol::Lang;
|
|
use std::path::Path;
|
|
use std::sync::{Mutex, MutexGuard};
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
static REPRO_ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct ReproEnvGuard {
|
|
_lock: MutexGuard<'static, ()>,
|
|
prior: Option<String>,
|
|
}
|
|
|
|
impl ReproEnvGuard {
|
|
fn set(base: &Path) -> Self {
|
|
let lock = REPRO_ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let prior = std::env::var("NYX_REPRO_BASE").ok();
|
|
unsafe { std::env::set_var("NYX_REPRO_BASE", base) };
|
|
Self { _lock: lock, prior }
|
|
}
|
|
}
|
|
|
|
impl Drop for ReproEnvGuard {
|
|
fn drop(&mut self) {
|
|
match self.prior.take() {
|
|
Some(value) => unsafe { std::env::set_var("NYX_REPRO_BASE", value) },
|
|
None => unsafe { std::env::remove_var("NYX_REPRO_BASE") },
|
|
}
|
|
}
|
|
}
|
|
|
|
fn make_spec() -> HarnessSpec {
|
|
HarnessSpec {
|
|
finding_id: "hermetic00000001".into(),
|
|
entry_file: "app.py".into(),
|
|
entry_name: "login".into(),
|
|
entry_kind: EntryKind::Function,
|
|
lang: Lang::Python,
|
|
toolchain_id: "python-3.11".into(),
|
|
payload_slot: PayloadSlot::Param(0),
|
|
expected_cap: Cap::SQL_QUERY,
|
|
constraint_hints: vec![],
|
|
sink_file: "app.py".into(),
|
|
sink_line: 10,
|
|
spec_hash: "hermetic00000001".into(),
|
|
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
|
stubs_required: vec![],
|
|
framework: None,
|
|
java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(),
|
|
}
|
|
}
|
|
|
|
fn make_outcome() -> SandboxOutcome {
|
|
SandboxOutcome {
|
|
exit_code: Some(0),
|
|
stdout: b"__NYX_SINK_HIT__\nquery: SELECT 1".to_vec(),
|
|
stderr: vec![],
|
|
timed_out: false,
|
|
oob_callback_seen: false,
|
|
sink_hit: true,
|
|
duration: Duration::from_millis(100),
|
|
hardening_outcome: None,
|
|
}
|
|
}
|
|
|
|
fn make_verdict() -> VerifyResult {
|
|
VerifyResult {
|
|
finding_id: "hermetic00000001".into(),
|
|
status: VerifyStatus::Confirmed,
|
|
triggered_payload: Some("sqli-or-1".into()),
|
|
reason: None,
|
|
inconclusive_reason: None,
|
|
detail: None,
|
|
attempts: vec![AttemptSummary {
|
|
payload_label: "sqli-or-1".into(),
|
|
exit_code: Some(0),
|
|
timed_out: false,
|
|
triggered: true,
|
|
sink_hit: true,
|
|
}],
|
|
toolchain_match: Some("exact".into()),
|
|
differential: None,
|
|
replay_stable: None,
|
|
wrong: None,
|
|
hardening_outcome: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_carries_toolchain_lock_with_hashes() {
|
|
let dir = TempDir::new().unwrap();
|
|
let _env = ReproEnvGuard::set(dir.path());
|
|
|
|
let artifact = repro::write(
|
|
&make_spec(),
|
|
&SandboxOptions::default(),
|
|
&make_outcome(),
|
|
&make_verdict(),
|
|
"import sys\n# harness\n",
|
|
"def login(x): pass\n",
|
|
b"' OR 1=1-- NYX",
|
|
"sqli-or-1",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let lock_path = artifact.root.join("toolchain.lock");
|
|
assert!(lock_path.exists(), "toolchain.lock missing from bundle");
|
|
let lock: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
|
|
assert_eq!(lock["toolchain_id"], "python-3.11");
|
|
assert_eq!(lock["lock_version"], 1);
|
|
let files = lock["files"].as_object().expect("files map");
|
|
assert!(files.contains_key("payload/payload.bin"));
|
|
assert!(files.contains_key("harness/harness.py"));
|
|
assert!(files.contains_key("harness/Dockerfile.harness"));
|
|
// Hashes are stable across rewrites — write the bundle a second
|
|
// time with identical inputs and assert the file hashes match.
|
|
std::fs::remove_dir_all(&artifact.root).unwrap();
|
|
let artifact2 = repro::write(
|
|
&make_spec(),
|
|
&SandboxOptions::default(),
|
|
&make_outcome(),
|
|
&make_verdict(),
|
|
"import sys\n# harness\n",
|
|
"def login(x): pass\n",
|
|
b"' OR 1=1-- NYX",
|
|
"sqli-or-1",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let lock2: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(artifact2.root.join("toolchain.lock")).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
lock["files"], lock2["files"],
|
|
"lock file hashes must be deterministic"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reproduce_sh_refuses_when_host_toolchain_missing() {
|
|
// Acceptance literal: bundle replays green on a CI image with
|
|
// no language toolchain installed. In process mode we can
|
|
// verify the script *refuses* to run rather than crashing —
|
|
// the green path on a clean machine is via `--docker`.
|
|
let dir = TempDir::new().unwrap();
|
|
let _env = ReproEnvGuard::set(dir.path());
|
|
|
|
let artifact = repro::write(
|
|
&make_spec(),
|
|
&SandboxOptions::default(),
|
|
&make_outcome(),
|
|
&make_verdict(),
|
|
"import sys\n# harness\n",
|
|
"def login(x): pass\n",
|
|
b"payload",
|
|
"label",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Simulate "no language toolchain installed" by stripping PATH
|
|
// down to /usr/bin (where `sh`, `grep`, `cat` live) before
|
|
// invoking the script, then re-isolating `python3` away. The
|
|
// toolchain probe inside reproduce.sh checks `command -v
|
|
// python3`; with PATH stripped of python's typical install
|
|
// directories the check should fail and the script must exit 3.
|
|
let scratch = TempDir::new().unwrap();
|
|
// Build a path containing only the BusyBox-ish coreutils so
|
|
// `sh`, `grep`, `command` etc. still resolve, but `python3`
|
|
// does not.
|
|
let mut minimal_path = String::new();
|
|
for candidate in &["/usr/bin", "/bin"] {
|
|
if std::path::Path::new(candidate).exists() {
|
|
if !minimal_path.is_empty() {
|
|
minimal_path.push(':');
|
|
}
|
|
minimal_path.push_str(candidate);
|
|
}
|
|
}
|
|
// If the host happens to have python3 in /usr/bin, the toolchain
|
|
// probe will succeed and the script will fall through to
|
|
// running the (broken) harness. Detect that and skip — Phase
|
|
// 28 acceptance is about the refusal path, not the host-has-it
|
|
// path.
|
|
let host_has_python = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg("command -v python3")
|
|
.env_clear()
|
|
.env("PATH", &minimal_path)
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false);
|
|
if host_has_python {
|
|
eprintln!("skip: host has python3 in minimal PATH; cannot simulate clean CI image");
|
|
return;
|
|
}
|
|
|
|
let result = std::process::Command::new("sh")
|
|
.arg(artifact.root.join("reproduce.sh"))
|
|
.current_dir(&artifact.root)
|
|
.env_clear()
|
|
.env("PATH", &minimal_path)
|
|
.env("HOME", scratch.path())
|
|
.output()
|
|
.expect("sh invocation");
|
|
|
|
assert_eq!(
|
|
result.status.code(),
|
|
Some(3),
|
|
"expected exit 3 (host toolchain mismatch); got {:?}\nstdout: {}\nstderr: {}",
|
|
result.status.code(),
|
|
String::from_utf8_lossy(&result.stdout),
|
|
String::from_utf8_lossy(&result.stderr),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_bundle_returns_toolchain_mismatch_on_exit_3() {
|
|
// Smoke test for ReplayResult::ToolchainMismatch — the typed
|
|
// outcome of running reproduce.sh under a missing-toolchain
|
|
// host. Pair-tested with the script-level assertion above.
|
|
let dir = TempDir::new().unwrap();
|
|
let bundle = dir.path().join("bundle");
|
|
std::fs::create_dir_all(&bundle).unwrap();
|
|
std::fs::write(
|
|
bundle.join("reproduce.sh"),
|
|
"#!/bin/sh\necho 'host toolchain missing' >&2\nexit 3\n",
|
|
)
|
|
.unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(
|
|
bundle.join("reproduce.sh"),
|
|
std::fs::Permissions::from_mode(0o755),
|
|
)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(replay_bundle(&bundle, &[]), ReplayResult::ToolchainMismatch);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_bundle_green_when_script_exits_zero() {
|
|
let dir = TempDir::new().unwrap();
|
|
let bundle = dir.path().join("green");
|
|
std::fs::create_dir_all(&bundle).unwrap();
|
|
std::fs::write(
|
|
bundle.join("reproduce.sh"),
|
|
"#!/bin/sh\necho 'PASS: simulated green'\nexit 0\n",
|
|
)
|
|
.unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(
|
|
bundle.join("reproduce.sh"),
|
|
std::fs::Permissions::from_mode(0o755),
|
|
)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(replay_bundle(&bundle, &[]), ReplayResult::Pass);
|
|
}
|
|
|
|
#[test]
|
|
fn docker_pull_script_emitted_when_toolchain_pinned() {
|
|
// Until the Phase 19 image catalogue (`tools/image-builder/images.toml`)
|
|
// is populated with real digests, no toolchain id will return a
|
|
// pinned image reference — `pinned_image_ref` returns `None`.
|
|
// Skip when that's still the state of the world; the test fires
|
|
// once digests land and gates against regressions where a
|
|
// pinned toolchain stops emitting `docker_pull.sh`.
|
|
let dir = TempDir::new().unwrap();
|
|
let _env = ReproEnvGuard::set(dir.path());
|
|
|
|
let mut spec = make_spec();
|
|
spec.toolchain_id = "python-3.11".into();
|
|
let artifact = repro::write(
|
|
&spec,
|
|
&SandboxOptions::default(),
|
|
&make_outcome(),
|
|
&make_verdict(),
|
|
"# harness",
|
|
"# entry",
|
|
b"payload",
|
|
"label",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let pinned = nyx_scanner::dynamic::toolchain::pinned_image_ref(&spec.toolchain_id);
|
|
if pinned.is_some() {
|
|
assert!(
|
|
artifact.root.join("docker_pull.sh").exists(),
|
|
"docker_pull.sh missing for pinned toolchain",
|
|
);
|
|
} else {
|
|
// When unpinned, docker_pull.sh is intentionally absent.
|
|
assert!(
|
|
!artifact.root.join("docker_pull.sh").exists(),
|
|
"docker_pull.sh should not be emitted when toolchain is unpinned",
|
|
);
|
|
}
|
|
}
|
|
}
|