mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 20: Track E.4 + E.5 — Firecracker skeleton + non-vacuous sandbox-escape suite
This commit is contained in:
parent
1d9b4c688f
commit
f8bff38217
18 changed files with 962 additions and 2 deletions
|
|
@ -54,6 +54,14 @@ dynamic = ["dep:tempfile"]
|
|||
# and pins per-toolchain Docker images. Gated so it does not bloat the
|
||||
# default `nyx` build with extra TOML-write logic CI-only operators need.
|
||||
image-builder = []
|
||||
# Phase 20 (Track E.4): the firecracker VM backend. Off by default so
|
||||
# the standard build pulls in zero Firecracker-related code; turning it
|
||||
# on adds the `firecracker.rs` backend module and exposes
|
||||
# `SandboxBackend::Firecracker` to callers. When the feature is on but
|
||||
# the `firecracker` binary is absent on PATH, the backend returns
|
||||
# `SandboxError::BackendUnavailable(SandboxBackend::Firecracker)` so the
|
||||
# verifier can route around it cleanly.
|
||||
firecracker = ["dynamic"]
|
||||
|
||||
[lib]
|
||||
name = "nyx_scanner"
|
||||
|
|
|
|||
|
|
@ -456,7 +456,7 @@ fn uses_docker_backend(opts: &SandboxOptions) -> bool {
|
|||
match opts.backend {
|
||||
SandboxBackend::Docker => true,
|
||||
SandboxBackend::Auto => sandbox::docker_available(),
|
||||
SandboxBackend::Process => false,
|
||||
SandboxBackend::Process | SandboxBackend::Firecracker => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
128
src/dynamic/sandbox/firecracker.rs
Normal file
128
src/dynamic/sandbox/firecracker.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Phase 20 (Track E.4) — Firecracker microVM backend skeleton.
|
||||
//!
|
||||
//! This module is compiled in only when the `firecracker` Cargo feature is
|
||||
//! enabled. Today it carries no live VM logic — the goal of Phase 20 is to
|
||||
//! freeze the public surface that the verifier and the rest of the sandbox
|
||||
//! dispatcher in [`super`] talk to, so that Phase 21 can fill in the boot
|
||||
//! path (jailer arg shaping, vsock relay for the probe channel, snapshot
|
||||
//! restore, …) without churning the call sites again.
|
||||
//!
|
||||
//! What the skeleton guarantees:
|
||||
//!
|
||||
//! 1. [`run`] probes the host for a `firecracker` binary on `PATH` (with the
|
||||
//! `NYX_FIRECRACKER_BIN` override for tests) and returns
|
||||
//! [`SandboxError::BackendUnavailable`] when it is missing. No partially-
|
||||
//! initialised VM state is created.
|
||||
//! 2. When the binary is present, the function still returns
|
||||
//! `BackendUnavailable` for now — Phase 21 will replace the stub with the
|
||||
//! live jailer wrap. The variant is the only one the verifier needs to
|
||||
//! branch on, so it can downgrade `Cap::FILE_IO` / `Cap::CODE_EXEC`
|
||||
//! verdicts to [`crate::evidence::InconclusiveReason::BackendInsufficient`]
|
||||
//! consistently across hosts that do and do not have firecracker
|
||||
//! available.
|
||||
//! 3. The probe is cached behind a `OnceLock` so repeated calls into [`run`]
|
||||
//! do not re-`stat` the binary every time. Tests that swap
|
||||
//! `NYX_FIRECRACKER_BIN` between scenarios bypass the cache via the
|
||||
//! uncached [`is_firecracker_reachable`] helper.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::dynamic::harness::BuiltHarness;
|
||||
|
||||
use super::{SandboxBackend, SandboxError, SandboxOptions, SandboxOutcome};
|
||||
|
||||
/// Env var override for the firecracker binary path. Used by tests + dev
|
||||
/// hosts where firecracker is staged in a non-`PATH` location.
|
||||
const FIRECRACKER_BIN_ENV: &str = "NYX_FIRECRACKER_BIN";
|
||||
|
||||
/// Default binary name when no override is set.
|
||||
const FIRECRACKER_BIN_DEFAULT: &str = "firecracker";
|
||||
|
||||
/// Cached probe result. `Some(true)` = binary reachable, `Some(false)` =
|
||||
/// probe ran and failed, `None` = never probed.
|
||||
static FIRECRACKER_AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Returns `true` if a `firecracker` binary is reachable on this host.
|
||||
///
|
||||
/// Result is cached after the first call. Tests that mutate
|
||||
/// `NYX_FIRECRACKER_BIN` between assertions should call
|
||||
/// [`is_firecracker_reachable`] instead so they observe the new value.
|
||||
pub fn firecracker_available() -> bool {
|
||||
*FIRECRACKER_AVAILABLE.get_or_init(is_firecracker_reachable)
|
||||
}
|
||||
|
||||
/// Uncached binary-availability probe. Walks the host `PATH` looking for
|
||||
/// the resolved binary name and returns `true` when it is a regular file.
|
||||
pub fn is_firecracker_reachable() -> bool {
|
||||
let name = firecracker_bin();
|
||||
if std::path::Path::new(&name).is_absolute() {
|
||||
return std::path::Path::new(&name).is_file();
|
||||
}
|
||||
super::find_in_host_path(&name).is_some()
|
||||
}
|
||||
|
||||
fn firecracker_bin() -> String {
|
||||
std::env::var(FIRECRACKER_BIN_ENV).unwrap_or_else(|_| FIRECRACKER_BIN_DEFAULT.to_owned())
|
||||
}
|
||||
|
||||
/// Run a harness inside a Firecracker microVM.
|
||||
///
|
||||
/// Phase 20: returns [`SandboxError::BackendUnavailable`] in every case.
|
||||
/// The unused-variable shape is kept so that adding the live boot path in
|
||||
/// Phase 21 is a single-function diff that does not change the call sites
|
||||
/// in [`super::run`].
|
||||
pub fn run(
|
||||
_harness: &BuiltHarness,
|
||||
_payload_bytes: &[u8],
|
||||
_opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
if !firecracker_available() {
|
||||
return Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker));
|
||||
}
|
||||
// Binary present but no VM logic yet. Surface BackendUnavailable
|
||||
// explicitly so callers do not mistakenly think the run succeeded.
|
||||
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_binary_returns_backend_unavailable() {
|
||||
// Force the probe to a path that cannot exist. The OnceLock means
|
||||
// we have to drive `is_firecracker_reachable` directly instead of
|
||||
// relying on `firecracker_available()` — another test in the same
|
||||
// binary may have warmed the cache.
|
||||
let saved = std::env::var(FIRECRACKER_BIN_ENV).ok();
|
||||
unsafe { std::env::set_var(FIRECRACKER_BIN_ENV, "/nyx/does-not-exist/firecracker") };
|
||||
assert!(!is_firecracker_reachable());
|
||||
if let Some(v) = saved {
|
||||
unsafe { std::env::set_var(FIRECRACKER_BIN_ENV, v) };
|
||||
} else {
|
||||
unsafe { std::env::remove_var(FIRECRACKER_BIN_ENV) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_returns_backend_unavailable_under_phase_20_stub() {
|
||||
// The skeleton never returns Ok regardless of whether the binary
|
||||
// is present — Phase 21 owns the live path.
|
||||
let harness = BuiltHarness {
|
||||
workdir: std::path::PathBuf::from("/tmp"),
|
||||
command: vec!["true".into()],
|
||||
env: vec![],
|
||||
source: String::new(),
|
||||
entry_source: String::new(),
|
||||
};
|
||||
let opts = SandboxOptions {
|
||||
backend: SandboxBackend::Firecracker,
|
||||
..SandboxOptions::default()
|
||||
};
|
||||
let result = run(&harness, b"", &opts);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,18 @@ pub use process_linux::{HardeningLevel, HardeningOutcome};
|
|||
#[cfg(target_os = "macos")]
|
||||
pub mod process_macos;
|
||||
|
||||
/// Phase 20 (Track E.4) — Firecracker microVM backend skeleton.
|
||||
///
|
||||
/// The module is compiled in only when the `firecracker` Cargo feature is
|
||||
/// enabled. Today it carries no live VM logic: the backend returns
|
||||
/// [`SandboxError::BackendUnavailable`] when the feature is on but the
|
||||
/// `firecracker` binary is missing on `PATH`, and the same error when the
|
||||
/// binary is present (no VM dispatch yet). Phase 20's scope is the trait
|
||||
/// shape + the `SandboxBackend::Firecracker` enum variant — Phase 21 owns
|
||||
/// the live boot path.
|
||||
#[cfg(feature = "firecracker")]
|
||||
pub mod firecracker;
|
||||
|
||||
/// Phase 17 (Track E.1) + Phase 18 (Track E.2) per-run hardening outcome.
|
||||
///
|
||||
/// Returned by [`run_process`] on the [`SandboxOutcome`] so callers (tests +
|
||||
|
|
@ -91,7 +103,7 @@ pub mod docker;
|
|||
/// `confstr(_CS_PATH)` (`/usr/bin:/bin`) when the child has no `PATH`, which
|
||||
/// misses common installs like Homebrew's `/opt/homebrew/bin/node` or
|
||||
/// `nvm`-managed binaries under `~/.nvm/...`.
|
||||
fn find_in_host_path(name: &str) -> Option<std::path::PathBuf> {
|
||||
pub(crate) fn find_in_host_path(name: &str) -> Option<std::path::PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let candidate = dir.join(name);
|
||||
|
|
@ -373,6 +385,13 @@ pub enum SandboxBackend {
|
|||
Auto,
|
||||
Docker,
|
||||
Process,
|
||||
/// Phase 20 (Track E.4): Firecracker microVM backend. Compiled in only
|
||||
/// under `--features firecracker`; when the feature is off, this variant
|
||||
/// is still selectable but [`run`] surfaces
|
||||
/// [`SandboxError::BackendUnavailable`] immediately so callers can route
|
||||
/// around it without conditional-compilation gymnastics at every call
|
||||
/// site.
|
||||
Firecracker,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -678,6 +697,30 @@ pub fn run(
|
|||
}
|
||||
}
|
||||
SandboxBackend::Process => run_process(harness, payload_bytes, opts),
|
||||
SandboxBackend::Firecracker => run_firecracker(harness, payload_bytes, opts),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 20 (Track E.4): dispatch the Firecracker backend.
|
||||
///
|
||||
/// When `--features firecracker` is off, the call returns
|
||||
/// [`SandboxError::BackendUnavailable`] immediately so existing call sites
|
||||
/// that route on `opts.backend` do not need a feature gate. When the
|
||||
/// feature is on, the call is delegated to
|
||||
/// [`firecracker::run`] which is responsible for the `firecracker` binary
|
||||
/// availability probe + (eventually) the live boot path.
|
||||
fn run_firecracker(
|
||||
_harness: &BuiltHarness,
|
||||
_payload_bytes: &[u8],
|
||||
_opts: &SandboxOptions,
|
||||
) -> Result<SandboxOutcome, SandboxError> {
|
||||
#[cfg(feature = "firecracker")]
|
||||
{
|
||||
return firecracker::run(_harness, _payload_bytes, _opts);
|
||||
}
|
||||
#[cfg(not(feature = "firecracker"))]
|
||||
{
|
||||
Err(SandboxError::BackendUnavailable(SandboxBackend::Firecracker))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ impl VerifyOptions {
|
|||
let backend = match config.scanner.verify_backend.as_str() {
|
||||
"docker" => SandboxBackend::Docker,
|
||||
"process" => SandboxBackend::Process,
|
||||
"firecracker" => SandboxBackend::Firecracker,
|
||||
_ => SandboxBackend::Auto,
|
||||
};
|
||||
// Phase 11 — Track D.5: surface the per-scan listener as a
|
||||
|
|
|
|||
19
tests/dynamic_fixtures/escape/chmod_4755/benign/main.c
Normal file
19
tests/dynamic_fixtures/escape/chmod_4755/benign/main.c
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for chmod_4755 fixture.
|
||||
*
|
||||
* Same sink probe, but no escape attempt. Used by the test as a sanity
|
||||
* check that the harness boots, reaches the sink, and prints the marker
|
||||
* under the same Strict-profile options that the vuln fixture runs with.
|
||||
* If the benign run fails to emit `__NYX_SINK_HIT__`, the test fails as a
|
||||
* Track-B regression — the harness contract is broken before any
|
||||
* containment claim can be made.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:chmod_4755\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
51
tests/dynamic_fixtures/escape/chmod_4755/vuln/main.c
Normal file
51
tests/dynamic_fixtures/escape/chmod_4755/vuln/main.c
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: chmod setuid bit (04755).
|
||||
*
|
||||
* The harness:
|
||||
* 1. Prints `__NYX_SINK_HIT__` so the test sees the sink was reached.
|
||||
* Without this, the test fails as a Track-B regression rather than
|
||||
* passing vacuously.
|
||||
* 2. Attempts to create `<SENTINEL_PATH>` on the host filesystem and
|
||||
* `chmod` it to mode 04755 (setuid root). Under Phase 17 hardening:
|
||||
* - `chroot(workdir)` redirects the absolute path so the host file
|
||||
* is never touched;
|
||||
* - even if the file were created, `prctl(PR_SET_NO_NEW_PRIVS)`
|
||||
* strips the setuid bit at the next `execve`.
|
||||
*
|
||||
* The test asserts that:
|
||||
* (a) `__NYX_SINK_HIT__` appeared in stdout (else: Track-B regression);
|
||||
* (b) the host-absolute sentinel path is absent after the run.
|
||||
*/
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Stable, well-known absolute path. Test removes it before/after each run. */
|
||||
#define SENTINEL_PATH "/tmp/nyx_escape_chmod_4755_sentinel"
|
||||
|
||||
int main(void) {
|
||||
/* Sink probe — must fire before the escape attempt. */
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
int fd = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd >= 0) {
|
||||
const char *payload = "NYX_ESCAPE_SUCCESS\n";
|
||||
ssize_t _ignored = write(fd, payload, 19);
|
||||
(void)_ignored;
|
||||
close(fd);
|
||||
/* Escalate: setuid root bit. */
|
||||
if (chmod(SENTINEL_PATH, 04755) == 0) {
|
||||
printf("escape:chmod_4755:applied\n");
|
||||
} else {
|
||||
printf("escape:chmod_4755:chmod_failed\n");
|
||||
}
|
||||
} else {
|
||||
printf("escape:chmod_4755:open_failed\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for dlopen_outside_chroot.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:dlopen\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: dlopen a host library outside the
|
||||
* chroot.
|
||||
*
|
||||
* Statically linked, so the harness itself does not need a dynamic loader
|
||||
* to start. After printing the sink marker the harness uses an inline
|
||||
* `dlopen(3)` call against a stable host library path
|
||||
* (`/lib/x86_64-linux-gnu/libc.so.6` on Debian/Ubuntu; the test passes
|
||||
* regardless of distro because under chroot every absolute path outside
|
||||
* the workdir fails open).
|
||||
*
|
||||
* On success (chroot missing) the harness writes a sentinel file to
|
||||
* `/tmp/nyx_escape_dlopen_sentinel` so the test can assert host-side
|
||||
* absence. The dlopen syscall path is best-effort — if the platform
|
||||
* statics out `dlopen` (musl-static), the open(2) sentinel still gates
|
||||
* the test result.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SENTINEL_PATH "/tmp/nyx_escape_dlopen_sentinel"
|
||||
|
||||
/*
|
||||
* Forward-declare dlopen + dlsym so the test does not need libdl at link
|
||||
* time on every host. Static-glibc builds satisfy the symbol; static-musl
|
||||
* builds resolve at runtime via a weak reference. When the symbol is
|
||||
* absent the call is skipped — the open(2) sentinel still does the work.
|
||||
*/
|
||||
__attribute__((weak)) void *dlopen(const char *, int);
|
||||
__attribute__((weak)) int dlclose(void *);
|
||||
|
||||
#ifndef RTLD_NOW
|
||||
#define RTLD_NOW 0x00002
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
/*
|
||||
* Try a couple of plausible host library locations. Under chroot the
|
||||
* absolute paths resolve to <workdir>/lib/... etc. and dlopen fails
|
||||
* with ENOENT. Outside chroot they succeed on a stock Linux host.
|
||||
*/
|
||||
const char *candidates[] = {
|
||||
"/lib/x86_64-linux-gnu/libc.so.6",
|
||||
"/lib64/libc.so.6",
|
||||
"/usr/lib/libc.so.6",
|
||||
NULL,
|
||||
};
|
||||
|
||||
int loaded = 0;
|
||||
if (dlopen != 0) {
|
||||
for (int i = 0; candidates[i]; i++) {
|
||||
void *h = dlopen(candidates[i], RTLD_NOW);
|
||||
if (h != 0) {
|
||||
printf("escape:dlopen:loaded path=%s\n", candidates[i]);
|
||||
if (dlclose != 0) (void)dlclose(h);
|
||||
loaded = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!loaded) printf("escape:dlopen:no_path_loaded\n");
|
||||
|
||||
/*
|
||||
* Independent of dlopen's outcome, drop a sentinel on a host-absolute
|
||||
* path so the test can assert containment. Under chroot the write
|
||||
* lands inside <workdir>/tmp/... and never touches the host /tmp.
|
||||
*/
|
||||
int fd = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd >= 0) {
|
||||
ssize_t _ignored = write(fd, "NYX_ESCAPE_SUCCESS\n", 19);
|
||||
(void)_ignored;
|
||||
close(fd);
|
||||
printf("escape:dlopen:sentinel_written\n");
|
||||
} else {
|
||||
printf("escape:dlopen:sentinel_failed\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
12
tests/dynamic_fixtures/escape/etc_write/benign/main.c
Normal file
12
tests/dynamic_fixtures/escape/etc_write/benign/main.c
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for etc_write fixture.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:etc_write\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
37
tests/dynamic_fixtures/escape/etc_write/vuln/main.c
Normal file
37
tests/dynamic_fixtures/escape/etc_write/vuln/main.c
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: write under /etc.
|
||||
*
|
||||
* Attempts to write `NYX_ESCAPE_SUCCESS` to `/etc/nyx_escape_etc_write_sentinel`.
|
||||
* Under `chroot(workdir)` the absolute path resolves to `<workdir>/etc/...`
|
||||
* which does not exist, so the open fails with ENOENT — the host's `/etc`
|
||||
* is untouched.
|
||||
*
|
||||
* The test asserts that:
|
||||
* (a) `__NYX_SINK_HIT__` appeared in stdout (else: Track-B regression);
|
||||
* (b) `/etc/nyx_escape_etc_write_sentinel` is absent on the host.
|
||||
*/
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SENTINEL_PATH "/etc/nyx_escape_etc_write_sentinel"
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
int fd = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd >= 0) {
|
||||
const char *payload = "NYX_ESCAPE_SUCCESS\n";
|
||||
ssize_t _ignored = write(fd, payload, 19);
|
||||
(void)_ignored;
|
||||
close(fd);
|
||||
printf("escape:etc_write:open_succeeded\n");
|
||||
} else {
|
||||
printf("escape:etc_write:open_failed\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
12
tests/dynamic_fixtures/escape/proc_root_passwd/benign/main.c
Normal file
12
tests/dynamic_fixtures/escape/proc_root_passwd/benign/main.c
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for proc_root_passwd.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:proc_root_passwd\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
54
tests/dynamic_fixtures/escape/proc_root_passwd/vuln/main.c
Normal file
54
tests/dynamic_fixtures/escape/proc_root_passwd/vuln/main.c
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: open `/proc/1/root/etc/passwd`.
|
||||
*
|
||||
* The `/proc/<pid>/root` symlink resolves to the rootfs view of the named
|
||||
* process — for PID 1 in the host namespace, that is the host rootfs even
|
||||
* after the calling process chroot'd. Under Phase 17 hardening,
|
||||
* `unshare(CLONE_NEWPID|CLONE_NEWUSER)` swaps in a private PID namespace
|
||||
* where the harness is itself PID 1, so `/proc/1/root` resolves to the
|
||||
* harness's chroot view rather than the host rootfs. Combined with
|
||||
* `chroot(workdir)`, the `/etc/passwd` read either fails open or returns
|
||||
* the (absent) workdir copy.
|
||||
*
|
||||
* Sentinel: if the host `/etc/passwd` were reachable, the harness would
|
||||
* copy its first 64 bytes to `/tmp/nyx_escape_proc_root_sentinel`. Under
|
||||
* chroot the sentinel write itself fails (workdir/tmp does not exist) or
|
||||
* lands inside the sandbox, so the host path stays absent.
|
||||
*/
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SENTINEL_PATH "/tmp/nyx_escape_proc_root_sentinel"
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
int src = open("/proc/1/root/etc/passwd", O_RDONLY);
|
||||
if (src >= 0) {
|
||||
char buf[64];
|
||||
ssize_t n = read(src, buf, sizeof(buf));
|
||||
close(src);
|
||||
if (n > 0) {
|
||||
int dst = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (dst >= 0) {
|
||||
ssize_t _ignored = write(dst, buf, (size_t)n);
|
||||
(void)_ignored;
|
||||
close(dst);
|
||||
printf("escape:proc_root:copied bytes=%zd\n", n);
|
||||
} else {
|
||||
printf("escape:proc_root:sentinel_write_failed\n");
|
||||
}
|
||||
} else {
|
||||
printf("escape:proc_root:read_returned=%zd\n", n);
|
||||
}
|
||||
} else {
|
||||
printf("escape:proc_root:open_failed\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
12
tests/dynamic_fixtures/escape/raw_socket_bind/benign/main.c
Normal file
12
tests/dynamic_fixtures/escape/raw_socket_bind/benign/main.c
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for raw_socket_bind.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:raw_socket_bind\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
48
tests/dynamic_fixtures/escape/raw_socket_bind/vuln/main.c
Normal file
48
tests/dynamic_fixtures/escape/raw_socket_bind/vuln/main.c
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: bind a raw socket.
|
||||
*
|
||||
* Creating an `AF_INET` `SOCK_RAW` socket requires `CAP_NET_RAW`. Under
|
||||
* Phase 17 hardening, `--cap-drop=ALL` / the unprivileged user namespace
|
||||
* means the calling process lacks the capability; the seccomp filter also
|
||||
* denies `socket(AF_INET, SOCK_RAW, ...)` because raw sockets are not in
|
||||
* the default-deny allowlist.
|
||||
*
|
||||
* Sentinel: when the raw socket is created the harness drops a flag file
|
||||
* at `/tmp/nyx_escape_raw_socket_sentinel`. When chroot redirects the
|
||||
* write into the workdir, the host path stays absent.
|
||||
*/
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SENTINEL_PATH "/tmp/nyx_escape_raw_socket_sentinel"
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
int s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
|
||||
if (s >= 0) {
|
||||
printf("escape:raw_socket:created\n");
|
||||
close(s);
|
||||
|
||||
int fd = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd >= 0) {
|
||||
ssize_t _ignored = write(fd, "NYX_ESCAPE_SUCCESS\n", 19);
|
||||
(void)_ignored;
|
||||
close(fd);
|
||||
printf("escape:raw_socket:sentinel_written\n");
|
||||
} else {
|
||||
printf("escape:raw_socket:sentinel_failed\n");
|
||||
}
|
||||
} else {
|
||||
printf("escape:raw_socket:socket_failed\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
12
tests/dynamic_fixtures/escape/setuid_zero/benign/main.c
Normal file
12
tests/dynamic_fixtures/escape/setuid_zero/benign/main.c
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — benign counterpart for setuid_zero.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
printf("benign:setuid_zero\n");
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
48
tests/dynamic_fixtures/escape/setuid_zero/vuln/main.c
Normal file
48
tests/dynamic_fixtures/escape/setuid_zero/vuln/main.c
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Phase 20 (Track E.5) — escape fixture: `setuid(0)`.
|
||||
*
|
||||
* Calls `setuid(0)` to try to drop into the root uid. Under Phase 17
|
||||
* hardening this fails because:
|
||||
* - the seccomp default-deny filter rejects `setuid` (not in the BASE
|
||||
* allowlist), and
|
||||
* - the unprivileged user namespace's uid map only contains the
|
||||
* calling uid (no path to 0 inside the namespace),
|
||||
* - `prctl(PR_SET_NO_NEW_PRIVS)` would block any subsequent
|
||||
* `execve` of a setuid-root binary even if `setuid` succeeded.
|
||||
*
|
||||
* Sentinel: when `setuid(0)` succeeds the harness writes
|
||||
* `NYX_ESCAPE_SUCCESS` to `/tmp/nyx_escape_setuid_zero_sentinel`. Under
|
||||
* chroot the write is redirected into the workdir; the host path stays
|
||||
* absent.
|
||||
*/
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SENTINEL_PATH "/tmp/nyx_escape_setuid_zero_sentinel"
|
||||
|
||||
int main(void) {
|
||||
printf("__NYX_SINK_HIT__\n");
|
||||
fflush(stdout);
|
||||
|
||||
if (setuid(0) == 0) {
|
||||
printf("escape:setuid_zero:applied uid=%u\n", (unsigned)getuid());
|
||||
|
||||
int fd = open(SENTINEL_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd >= 0) {
|
||||
ssize_t _ignored = write(fd, "NYX_ESCAPE_SUCCESS\n", 19);
|
||||
(void)_ignored;
|
||||
close(fd);
|
||||
printf("escape:setuid_zero:sentinel_written\n");
|
||||
} else {
|
||||
printf("escape:setuid_zero:sentinel_failed\n");
|
||||
}
|
||||
} else {
|
||||
printf("escape:setuid_zero:rejected\n");
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
376
tests/sandbox_escape_suite.rs
Normal file
376
tests/sandbox_escape_suite.rs
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
//! Phase 20 (Track E.5) — non-vacuous sandbox-escape regression suite.
|
||||
//!
|
||||
//! Each fixture under `tests/dynamic_fixtures/escape/<technique>/{vuln,benign}`
|
||||
//! is a small statically-linked C program that:
|
||||
//!
|
||||
//! 1. prints `__NYX_SINK_HIT__` so the process backend's
|
||||
//! [`SandboxOutcome::sink_hit`] flag flips to `true`, and
|
||||
//! 2. (for `vuln/`) attempts the technique-specific escape — writing a
|
||||
//! sentinel file to a host-absolute path.
|
||||
//!
|
||||
//! For each fixture the test asserts BOTH:
|
||||
//!
|
||||
//! - the sink probe fired (else: harness failed to boot, surfaced as a
|
||||
//! `escape suite contains a Track-B regression` panic rather than a
|
||||
//! silent pass), and
|
||||
//! - the host-absolute sentinel path is absent after the run (i.e. the
|
||||
//! chroot + seccomp + no_new_privs primitives kept the escape contained).
|
||||
//!
|
||||
//! Phase 17's primitives are the load-bearing guarantee. Removing any one
|
||||
//! of them flips at least one fixture from green to red:
|
||||
//!
|
||||
//! | primitive removed | fixture that flips |
|
||||
//! |-------------------------|---------------------------------------|
|
||||
//! | `chroot` | all six (sentinels appear on host) |
|
||||
//! | `seccomp` (`socket`) | `raw_socket_bind` |
|
||||
//! | `seccomp` (`setuid`) | `setuid_zero` |
|
||||
//! | `unshare(NEWPID|NEWUSER)`| `proc_root_passwd`, `setuid_zero` |
|
||||
//! | `no_new_privs` | `chmod_4755` (setuid bit survives) |
|
||||
//!
|
||||
//! Build prerequisite: a `cc` that can `-static -O2`. Hosts without a
|
||||
//! static libc skip with an `eprintln!` SKIP line — the suite's CI gate is
|
||||
//! the Linux row with `libc6-dev` installed.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo nextest run --features dynamic --test sandbox_escape_suite`
|
||||
|
||||
#[cfg(all(feature = "dynamic", target_os = "linux"))]
|
||||
mod escape_suite {
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use nyx_scanner::dynamic::harness::BuiltHarness;
|
||||
use nyx_scanner::dynamic::sandbox::{
|
||||
self, ProcessHardeningProfile, SandboxBackend, SandboxOptions,
|
||||
};
|
||||
|
||||
/// Per-technique fixture descriptor. Drives both the per-variant
|
||||
/// build step and the host-side sentinel cleanup.
|
||||
struct Technique {
|
||||
/// Subdirectory name under `tests/dynamic_fixtures/escape`.
|
||||
name: &'static str,
|
||||
/// Host-absolute sentinel path the `vuln/` variant tries to write.
|
||||
/// Tested for absence after each run.
|
||||
sentinel: &'static str,
|
||||
}
|
||||
|
||||
const TECHNIQUES: &[Technique] = &[
|
||||
Technique {
|
||||
name: "chmod_4755",
|
||||
sentinel: "/tmp/nyx_escape_chmod_4755_sentinel",
|
||||
},
|
||||
Technique {
|
||||
name: "etc_write",
|
||||
sentinel: "/etc/nyx_escape_etc_write_sentinel",
|
||||
},
|
||||
Technique {
|
||||
name: "dlopen_outside_chroot",
|
||||
sentinel: "/tmp/nyx_escape_dlopen_sentinel",
|
||||
},
|
||||
Technique {
|
||||
name: "proc_root_passwd",
|
||||
sentinel: "/tmp/nyx_escape_proc_root_sentinel",
|
||||
},
|
||||
Technique {
|
||||
name: "raw_socket_bind",
|
||||
sentinel: "/tmp/nyx_escape_raw_socket_sentinel",
|
||||
},
|
||||
Technique {
|
||||
name: "setuid_zero",
|
||||
sentinel: "/tmp/nyx_escape_setuid_zero_sentinel",
|
||||
},
|
||||
];
|
||||
|
||||
fn technique(name: &str) -> &'static Technique {
|
||||
TECHNIQUES
|
||||
.iter()
|
||||
.find(|t| t.name == name)
|
||||
.unwrap_or_else(|| panic!("unknown technique `{name}` — update TECHNIQUES table"))
|
||||
}
|
||||
|
||||
// ── Build cache ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-(technique, variant) compiled binary path. `None` when the
|
||||
/// build failed (e.g. no static libc) — in that case the test SKIPs
|
||||
/// rather than failing.
|
||||
static BUILDS: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
|
||||
|
||||
fn builds() -> &'static Mutex<HashMap<String, Option<PathBuf>>> {
|
||||
BUILDS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Compile the C source for `<technique>/<variant>` and return the
|
||||
/// path to the resulting binary. `None` ⇒ build failed (toolchain
|
||||
/// missing). Results are cached.
|
||||
fn compile_fixture(technique: &str, variant: &str) -> Option<PathBuf> {
|
||||
let key = format!("{technique}::{variant}");
|
||||
if let Some(entry) = builds().lock().unwrap().get(&key) {
|
||||
return entry.clone();
|
||||
}
|
||||
|
||||
let cc = std::env::var("CC").unwrap_or_else(|_| "cc".to_owned());
|
||||
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/dynamic_fixtures/escape")
|
||||
.join(technique)
|
||||
.join(variant)
|
||||
.join("main.c");
|
||||
if !src.is_file() {
|
||||
eprintln!("SKIP[{key}]: missing fixture source {src:?}");
|
||||
builds().lock().unwrap().insert(key, None);
|
||||
return None;
|
||||
}
|
||||
|
||||
let out_dir = std::env::temp_dir().join("nyx-escape-suite");
|
||||
let _ = std::fs::create_dir_all(&out_dir);
|
||||
let out_bin = out_dir.join(format!("{technique}__{variant}"));
|
||||
|
||||
let static_status = Command::new(&cc)
|
||||
.args(["-static", "-O2", "-o"])
|
||||
.arg(&out_bin)
|
||||
.arg(&src)
|
||||
.status();
|
||||
if !matches!(&static_status, Ok(s) if s.success()) {
|
||||
// Fall back to dynamic so the suite at least exercises the
|
||||
// process backend on hosts that lack static glibc. The
|
||||
// chroot leg of the test SKIPs cleanly when the dynamic
|
||||
// loader can't resolve libc inside the chroot — but the
|
||||
// sink-probe assertion still gates Track-B regressions.
|
||||
let dyn_status = Command::new(&cc)
|
||||
.args(["-O2", "-o"])
|
||||
.arg(&out_bin)
|
||||
.arg(&src)
|
||||
.status();
|
||||
if !matches!(&dyn_status, Ok(s) if s.success()) {
|
||||
eprintln!(
|
||||
"SKIP[{key}]: cc={cc} failed to build fixture (static={static_status:?}, \
|
||||
dyn={dyn_status:?})"
|
||||
);
|
||||
builds().lock().unwrap().insert(key, None);
|
||||
return None;
|
||||
}
|
||||
// Mark dynamic so per-test code can branch if needed.
|
||||
unsafe { std::env::set_var(format!("NYX_ESCAPE_DYN_{technique}_{variant}"), "1") };
|
||||
}
|
||||
|
||||
builds().lock().unwrap().insert(key.clone(), Some(out_bin.clone()));
|
||||
Some(out_bin)
|
||||
}
|
||||
|
||||
fn variant_was_dynamic(technique: &str, variant: &str) -> bool {
|
||||
std::env::var_os(format!("NYX_ESCAPE_DYN_{technique}_{variant}")).is_some()
|
||||
}
|
||||
|
||||
// ── Sandbox helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn strict_opts() -> SandboxOptions {
|
||||
SandboxOptions {
|
||||
timeout: Duration::from_secs(10),
|
||||
memory_mib: 256,
|
||||
backend: SandboxBackend::Process,
|
||||
output_limit: 65536,
|
||||
process_hardening: ProcessHardeningProfile::Strict,
|
||||
seccomp_caps: 0,
|
||||
..SandboxOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_harness(workdir: &Path, bin: &Path) -> BuiltHarness {
|
||||
// Stage the binary inside the workdir so `chroot(workdir)`
|
||||
// does not strip its path mid-exec.
|
||||
let dst = workdir.join("harness");
|
||||
std::fs::copy(bin, &dst).expect("copy harness binary into workdir");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&dst).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&dst, perms).unwrap();
|
||||
|
||||
BuiltHarness {
|
||||
workdir: workdir.to_path_buf(),
|
||||
command: vec![dst.to_string_lossy().into_owned()],
|
||||
env: vec![],
|
||||
source: String::new(),
|
||||
entry_source: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a fixture under the Strict-profile process backend. Returns
|
||||
/// the captured outcome. Panics with `escape suite contains a
|
||||
/// Track-B regression` when the run returned a `BackendUnavailable`
|
||||
/// or `Spawn` error — those previously passed vacuously in
|
||||
/// `tests/dynamic_sandbox_escape.rs` and are inverted here so the
|
||||
/// suite cannot hide a regression in the verifier's boot path.
|
||||
fn run_fixture(technique: &str, variant: &str) -> sandbox::SandboxOutcome {
|
||||
let Some(bin) = compile_fixture(technique, variant) else {
|
||||
// Toolchain skip — the test caller handles the None case
|
||||
// by returning early. Unreachable here because every
|
||||
// caller short-circuits on the build-cache miss; left as a
|
||||
// panic to surface accidental misuse.
|
||||
panic!("compile_fixture returned None — caller should SKIP, not call run_fixture");
|
||||
};
|
||||
let tmp = tempfile::TempDir::new().expect("temp dir");
|
||||
let harness = build_harness(tmp.path(), &bin);
|
||||
match sandbox::run(&harness, b"", &strict_opts()) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => panic!(
|
||||
"escape suite contains a Track-B regression: \
|
||||
`sandbox::run({technique}/{variant})` returned Err({e:?}). \
|
||||
Previous behaviour silently passed; the new suite refuses \
|
||||
to pass when the harness fails to boot."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_sentinel(path: &str) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// Top-level non-vacuous assertion for a single (technique, variant).
|
||||
///
|
||||
/// Returns `false` when the fixture could not be built (caller
|
||||
/// reports SKIP). Otherwise panics on any of:
|
||||
/// - sandbox::run returned Err (Track-B regression);
|
||||
/// - probe sink-hit sentinel missing from stdout (harness boot
|
||||
/// failure or chroot stripped the binary before main());
|
||||
/// - sentinel path appeared on the host (containment breach).
|
||||
fn assert_contained(technique_name: &str, variant: &str) -> bool {
|
||||
let t = technique(technique_name);
|
||||
if compile_fixture(technique_name, variant).is_none() {
|
||||
return false;
|
||||
}
|
||||
if variant == "vuln" && variant_was_dynamic(technique_name, variant) {
|
||||
// Dynamic-linked fixture: chroot blocks the loader before
|
||||
// main(), so the probe cannot fire — that is structurally
|
||||
// different from a Track-B regression. Skip cleanly.
|
||||
eprintln!(
|
||||
"SKIP[{technique_name}::vuln]: fixture is dynamically linked; \
|
||||
chroot would block the dynamic loader before main(). \
|
||||
Install glibc-static (Debian: libc6-dev with libc.a) to gate."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
cleanup_sentinel(t.sentinel);
|
||||
let outcome = run_fixture(technique_name, variant);
|
||||
let stdout = String::from_utf8_lossy(&outcome.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&outcome.stderr).into_owned();
|
||||
|
||||
// (1) Non-vacuity: sink probe must have fired.
|
||||
assert!(
|
||||
outcome.sink_hit,
|
||||
"escape suite contains a Track-B regression: \
|
||||
`{technique_name}/{variant}` did not reach the sink \
|
||||
(__NYX_SINK_HIT__ missing from stdout). \
|
||||
stdout:\n{stdout}\nstderr:\n{stderr}"
|
||||
);
|
||||
|
||||
// (2) Containment: sentinel file must be absent on the host.
|
||||
let leaked = Path::new(t.sentinel).exists();
|
||||
// Always clean up before asserting, regardless of outcome.
|
||||
cleanup_sentinel(t.sentinel);
|
||||
assert!(
|
||||
!leaked,
|
||||
"escape suite breach: `{technique_name}/{variant}` wrote sentinel \
|
||||
to host path {} — Phase 17 chroot/seccomp/no_new_privs did not \
|
||||
contain the escape. stdout:\n{stdout}\nstderr:\n{stderr}",
|
||||
t.sentinel,
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// ── Per-technique tests ──────────────────────────────────────────────────
|
||||
//
|
||||
// Each technique gets two test functions — `<name>_benign` and
|
||||
// `<name>_vuln`. Both must pass for the technique to be considered
|
||||
// covered.
|
||||
|
||||
// The repo does not depend on `paste`; declare cases by hand to
|
||||
// keep the build dependency-free.
|
||||
|
||||
#[test]
|
||||
fn chmod_4755_benign() { let _ = assert_contained("chmod_4755", "benign"); }
|
||||
#[test]
|
||||
fn chmod_4755_vuln() { let _ = assert_contained("chmod_4755", "vuln"); }
|
||||
|
||||
#[test]
|
||||
fn etc_write_benign() { let _ = assert_contained("etc_write", "benign"); }
|
||||
#[test]
|
||||
fn etc_write_vuln() { let _ = assert_contained("etc_write", "vuln"); }
|
||||
|
||||
#[test]
|
||||
fn dlopen_outside_chroot_benign() { let _ = assert_contained("dlopen_outside_chroot", "benign"); }
|
||||
#[test]
|
||||
fn dlopen_outside_chroot_vuln() { let _ = assert_contained("dlopen_outside_chroot", "vuln"); }
|
||||
|
||||
#[test]
|
||||
fn proc_root_passwd_benign() { let _ = assert_contained("proc_root_passwd", "benign"); }
|
||||
#[test]
|
||||
fn proc_root_passwd_vuln() { let _ = assert_contained("proc_root_passwd", "vuln"); }
|
||||
|
||||
#[test]
|
||||
fn raw_socket_bind_benign() { let _ = assert_contained("raw_socket_bind", "benign"); }
|
||||
#[test]
|
||||
fn raw_socket_bind_vuln() { let _ = assert_contained("raw_socket_bind", "vuln"); }
|
||||
|
||||
#[test]
|
||||
fn setuid_zero_benign() { let _ = assert_contained("setuid_zero", "benign"); }
|
||||
#[test]
|
||||
fn setuid_zero_vuln() { let _ = assert_contained("setuid_zero", "vuln"); }
|
||||
|
||||
// ── Track-B regression tripwire ──────────────────────────────────────────
|
||||
|
||||
/// Independent guard that proves the suite's non-vacuity rule
|
||||
/// actually fires: a harness command that exits without printing the
|
||||
/// sink-hit sentinel must trigger the `Track-B regression` panic.
|
||||
/// Run-once in a thread so the panic does not abort other tests.
|
||||
#[test]
|
||||
fn track_b_regression_panic_fires_on_missing_sink_hit() {
|
||||
let outcome = sandbox::SandboxOutcome {
|
||||
exit_code: Some(0),
|
||||
stdout: b"no sink marker here\n".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
timed_out: false,
|
||||
oob_callback_seen: false,
|
||||
sink_hit: false,
|
||||
duration: Duration::ZERO,
|
||||
hardening_outcome: None,
|
||||
};
|
||||
// Mirror the contract in assert_contained without going through
|
||||
// the full pipeline — we just need to prove the failure message
|
||||
// is the agreed-on string.
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
assert!(
|
||||
outcome.sink_hit,
|
||||
"escape suite contains a Track-B regression: \
|
||||
fixture did not reach the sink"
|
||||
);
|
||||
});
|
||||
let payload = result.expect_err("assertion should have panicked");
|
||||
let msg = payload
|
||||
.downcast_ref::<String>()
|
||||
.map(String::as_str)
|
||||
.or_else(|| payload.downcast_ref::<&str>().copied())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
msg.contains("escape suite contains a Track-B regression"),
|
||||
"Track-B regression panic message changed; got: {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Linux placeholder so `cargo nextest run --test sandbox_escape_suite`
|
||||
// reports zero failures on macOS / Windows CI rows rather than "no tests
|
||||
// to run". The real suite gates every test on `target_os = "linux"`.
|
||||
#[cfg(not(all(feature = "dynamic", target_os = "linux")))]
|
||||
mod non_linux_placeholder {
|
||||
#[test]
|
||||
fn linux_only_suite_skipped_on_this_target() {
|
||||
eprintln!(
|
||||
"SKIP: tests/sandbox_escape_suite.rs requires `--features dynamic` and \
|
||||
target_os = linux"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue