diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index d0657a7b..e8c5e874 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -162,6 +162,36 @@ impl VerifyOptions { } } +/// Phase 17 follow-up: predicate driving the +/// [`SandboxOptions::bind_mount_host_libs`] opt-in for the Linux +/// process backend under [`ProcessHardeningProfile::Strict`]. +/// +/// Returns `true` for languages whose harness runtime ships as an +/// external interpreter (`python3`, `node`, `java`, `ruby`, `php`). +/// Those interpreters dlopen shared libraries from the host filesystem +/// at cold-start, so the `chroot(2)` step in +/// [`crate::dynamic::sandbox::process_linux`] needs the host's +/// `/lib`, `/lib64`, `/usr/lib`, and `/usr/bin` reachable inside the +/// workdir. +/// +/// Returns `false` for natively-compiled languages (`rust`, `c`, +/// `cpp`, `go`). Their harnesses are linked statically under Strict +/// via [`crate::dynamic::build_sandbox::static_link_for_profile`], so +/// the chroot survives without bind-mounts and we skip the +/// `mount(2)` syscall sequence to avoid the host-mount side-channel +/// the bind-mounts open up. +/// +/// Standard-profile runs ignore this entirely — the engine only +/// consults the predicate inside the Strict branch in +/// [`verify_finding`]. +fn lang_needs_host_libs(lang: crate::symbol::Lang) -> bool { + use crate::symbol::Lang::*; + matches!( + lang, + Python | JavaScript | TypeScript | Java | Ruby | Php + ) +} + // ── Dynamic verdict cache helpers (§12 Q5) ─────────────────────────────────── /// Hash the content of `entry_file` with BLAKE3 and return a 16-char hex string. @@ -684,6 +714,14 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { crate::dynamic::sandbox::ProcessHardeningProfile::Strict, ) { sandbox_opts.seccomp_caps = spec.expected_cap.bits(); + // Phase 17 follow-up: interpreted-language harnesses cannot + // resolve their interpreter + shared libraries from inside the + // chroot unless the host's `/lib`, `/lib64`, `/usr/lib`, and + // `/usr/bin` are bind-mounted into the workdir. Native-compile + // langs (Rust / C / C++ / Go) are statically linked under + // Strict by `static_link_for_profile` so we keep the chroot + // tight by skipping the bind-mounts for them. + sandbox_opts.bind_mount_host_libs = lang_needs_host_libs(spec.lang); } // Phase 30: hand the runner an `Arc` clone so it can append // `build_*` / `sandbox_started` / `oracle_*` stages from inside @@ -1261,6 +1299,44 @@ mod tests { ); } + #[test] + fn lang_needs_host_libs_returns_true_for_interpreted_langs() { + use crate::symbol::Lang; + // Every lang that ships its harness as an external interpreter + // (python3 / node / java / ruby / php) must opt in so the + // Strict chroot still finds the runtime's shared libraries. + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Java, + Lang::Ruby, + Lang::Php, + ] { + assert!( + lang_needs_host_libs(lang), + "{lang:?} runs through an external interpreter that dlopens \ + host libs at cold-start, so the verifier must request \ + bind-mounts when Strict hardening engages" + ); + } + } + + #[test] + fn lang_needs_host_libs_returns_false_for_native_langs() { + use crate::symbol::Lang; + // Native-compile langs are statically linked under Strict via + // `static_link_for_profile`, so the chroot survives without + // exposing the host filesystem through bind-mounts. + for lang in [Lang::Rust, Lang::C, Lang::Cpp, Lang::Go] { + assert!( + !lang_needs_host_libs(lang), + "{lang:?} is statically linked under Strict; bind-mounting \ + host libs would widen the chroot surface for zero gain" + ); + } + } + #[test] fn from_config_unknown_harden_profile_falls_back_to_standard() { use crate::dynamic::sandbox::ProcessHardeningProfile; diff --git a/tests/sandbox_hardening_linux.rs b/tests/sandbox_hardening_linux.rs index 77deb986..0998cc47 100644 --- a/tests/sandbox_hardening_linux.rs +++ b/tests/sandbox_hardening_linux.rs @@ -648,6 +648,198 @@ mod hardening_tests { ); } + /// Phase 17 follow-up: interpreter-language harnesses survive the + /// Strict chroot because `VerifyOptions::from_config` flips + /// `bind_mount_host_libs = true` for any interpreted-lang spec + /// (Python / JS / TS / Java / Ruby / PHP). Drives the full + /// `verify_finding` pipeline against + /// `tests/dynamic_fixtures/python/cmdi_positive.py` under + /// `harden_profile = "strict"` + `verify_backend = "process"` and + /// asserts the python3 harness produced non-empty stdout — proof + /// that `ld.so` + `libpython` resolved from the bind-mounted host + /// directories inside the workdir-chroot. + /// + /// Skips when (a) `/usr/bin/python3` is missing on the host or + /// (b) the per-cap macOS `.sb` path is reached (this test is + /// `target_os = "linux"`-gated at the module level so case (b) is + /// a compile-time skip on macOS, but the python3 pre-flight still + /// covers Linux hosts without a system python). + /// + /// Mirrors the macOS counterpart at + /// `tests/determinism_audit.rs::confirmed_run_is_byte_identical_across_runs` + /// (same fixture, same Cap::CODE_EXEC payload, same flow_steps + /// shape) so the only behavioural delta between hosts is the + /// chroot + bind-mount layer this test gates. + #[test] + fn interpreter_strict_run_chroot_bind_mounts_work() { + use std::path::PathBuf; + + if std::process::Command::new("/usr/bin/python3") + .arg("--version") + .output() + .map(|o| !o.status.success()) + .unwrap_or(true) + { + eprintln!( + "SKIP: /usr/bin/python3 missing — cannot drive the python harness through \ + the Strict chroot. Install python3 (Debian/Ubuntu: `apt install python3`)." + ); + return; + } + + use nyx_scanner::commands::scan::Diag; + use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; + use nyx_scanner::evidence::{ + Confidence, Evidence, FlowStep, FlowStepKind, VerifyStatus, + }; + use nyx_scanner::labels::Cap; + use nyx_scanner::patterns::{FindingCategory, Severity}; + use nyx_scanner::utils::config::Config; + + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/python/cmdi_positive.py"); + + let tmp = tempfile::TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("cmdi_positive.py"); + std::fs::copy(&fixture_src, &dst).expect("stage fixture into tempdir"); + + unsafe { + std::env::set_var( + "NYX_REPRO_BASE", + tmp.path().join("repro").to_str().unwrap(), + ); + std::env::set_var( + "NYX_TELEMETRY_PATH", + tmp.path().join("events.jsonl").to_str().unwrap(), + ); + } + + let path_str = dst.to_string_lossy().into_owned(); + let evidence = Evidence { + flow_steps: vec![ + FlowStep { + step: 1, + kind: FlowStepKind::Source, + file: path_str.clone(), + line: 9, + col: 0, + snippet: None, + variable: Some("host".into()), + callee: None, + function: Some("run_ping".into()), + is_cross_file: false, + }, + FlowStep { + step: 2, + kind: FlowStepKind::Sink, + file: path_str.clone(), + line: 11, + col: 4, + snippet: None, + variable: None, + callee: Some("subprocess.run".into()), + function: None, + is_cross_file: false, + }, + ], + sink_caps: Cap::CODE_EXEC.bits(), + ..Default::default() + }; + let diag = Diag { + path: path_str, + line: 11, + col: 0, + severity: Severity::High, + id: "taint-unsanitised-flow".into(), + category: FindingCategory::Security, + path_validated: false, + guard_kind: None, + message: None, + labels: vec![], + confidence: Some(Confidence::High), + evidence: Some(evidence), + rank_score: None, + rank_reason: None, + suppressed: false, + suppression: None, + rollup: None, + finding_id: String::new(), + alternative_finding_ids: vec![], + stable_hash: 0, + }; + + let mut config = Config::default(); + config.scanner.harden_profile = "strict".to_owned(); + config.scanner.verify_backend = "process".to_owned(); + let opts = VerifyOptions::from_config(&config); + + // Sanity-check the wiring before driving the verifier: the + // `from_config` predicate must have flipped on the + // bind-mount opt-in for this Python diag because Strict + + // Python is the exact case `lang_needs_host_libs` was added + // for. Note: `from_config` itself does not see the diag, + // so the flag is actually set inside `verify_finding`'s + // per-finding clone — what we assert here is only that + // Strict survived the from_config round-trip. If this + // assertion ever flips, the verifier's per-finding wiring + // has regressed. + assert!( + matches!( + opts.sandbox.process_hardening, + ProcessHardeningProfile::Strict, + ), + "harden_profile=strict must engage ProcessHardeningProfile::Strict so \ + the per-finding clone in `verify_finding` can layer bind-mounts on top", + ); + + let result = verify_finding(&diag, &opts); + + unsafe { + std::env::remove_var("NYX_REPRO_BASE"); + std::env::remove_var("NYX_TELEMETRY_PATH"); + } + + // The Strict chroot only survives if `mount(2)` actually + // bind-mounted the host's libpython + ld.so inside the + // workdir. A failed bind-mount surfaces as a python3 cold- + // start crash before `subprocess.run` ever fires, which the + // oracle reports as `NotConfirmed`. + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive.py under --harden=strict must Confirm: \ + interpreter cold-start should succeed via bind-mounted /lib + /usr/lib + \ + /usr/bin (detail={:?})", + result.detail, + ); + let summary = result + .hardening_outcome + .as_ref() + .expect("Strict run must stamp hardening_outcome"); + assert_eq!( + summary.backend, "linux-process", + "Linux host should produce a linux-process backend stamp", + ); + assert_eq!( + summary.profile, "strict", + "Strict profile tag must round-trip through summarize_hardening", + ); + assert!( + !summary.primitives.is_empty(), + "Linux backend records one entry per primitive; got: {:?}", + summary.primitives, + ); + assert!( + summary + .primitives + .iter() + .any(|p| p.name == "chroot" && p.status == "applied"), + "chroot primitive must apply under Strict — bind-mounts only matter \ + when chroot is active. primitives: {:?}", + summary.primitives, + ); + } + /// Seccomp policy synthesised from `seccomp_policy.toml` includes /// the syscalls required for the probe to reach `__NYX_PROBE_DONE__` /// (read, write, openat, readlinkat, fcntl, exit_group, …). This