mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
[pitboss] phase 17: Track E.1 — Linux process backend hardening
This commit is contained in:
parent
a4f890797a
commit
dbad78fafa
10 changed files with 2414 additions and 68 deletions
124
tests/dynamic_fixtures/hardening/probe.c
Normal file
124
tests/dynamic_fixtures/hardening/probe.c
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Phase 17 (Track E.1) — process-backend hardening probe.
|
||||
*
|
||||
* Linked statically (no glibc dynamic loader needed) so it runs after
|
||||
* `chroot(workdir)` strips access to /usr/lib. Reads its own
|
||||
* `/proc/self` view to determine which Phase 17 primitives applied,
|
||||
* then prints a structured `key:value` line per primitive. The Rust
|
||||
* test reads stdout and asserts on each line.
|
||||
*
|
||||
* The probe is also reused by the path-traversal case: when
|
||||
* `argv[1] == "traverse"` it tries to open `/etc/passwd` and reports
|
||||
* either `chroot blocked` (open failed) or `chroot escaped` (open
|
||||
* succeeded, host file visible).
|
||||
*
|
||||
* Built at test runtime with `cc -static -O2 -o probe probe.c`. Test
|
||||
* skips with an eprintln! when the host has no `cc` or no static glibc.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static void grep_status(const char *needle, const char *fallback) {
|
||||
FILE *f = fopen("/proc/self/status", "r");
|
||||
if (!f) {
|
||||
printf("%s%s\n", needle, fallback);
|
||||
return;
|
||||
}
|
||||
char line[512];
|
||||
int found = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (strncmp(line, needle, strlen(needle)) == 0) {
|
||||
// Strip trailing newline.
|
||||
size_t n = strlen(line);
|
||||
if (n && line[n - 1] == '\n') line[n - 1] = '\0';
|
||||
printf("%s\n", line);
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) printf("%s%s\n", needle, fallback);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void print_rlimit(const char *tag, int resource) {
|
||||
struct rlimit rl;
|
||||
if (getrlimit(resource, &rl) == 0) {
|
||||
printf("%s:%llu/%llu\n", tag,
|
||||
(unsigned long long)rl.rlim_cur,
|
||||
(unsigned long long)rl.rlim_max);
|
||||
} else {
|
||||
printf("%s:err\n", tag);
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_namespaces(void) {
|
||||
// /proc/self/ns/user, /proc/self/ns/pid, /proc/self/ns/mnt are
|
||||
// symlinks like `user:[4026531837]`. We read the link target and
|
||||
// print the inode-id portion.
|
||||
const char *names[] = {"user", "pid", "mnt"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char path[64];
|
||||
char target[256];
|
||||
snprintf(path, sizeof(path), "/proc/self/ns/%s", names[i]);
|
||||
ssize_t n = readlink(path, target, sizeof(target) - 1);
|
||||
if (n > 0) {
|
||||
target[n] = '\0';
|
||||
printf("ns_%s:%s\n", names[i], target);
|
||||
} else {
|
||||
printf("ns_%s:err\n", names[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_chroot(void) {
|
||||
// After chroot(workdir), `/etc/passwd` should not exist (the harness
|
||||
// workdir does not contain /etc). Open + ENOENT means chroot held.
|
||||
int fd = open("/etc/passwd", O_RDONLY);
|
||||
if (fd < 0) {
|
||||
printf("chroot:blocked errno=%d\n", errno);
|
||||
} else {
|
||||
char buf[64];
|
||||
ssize_t n = read(fd, buf, sizeof(buf) - 1);
|
||||
close(fd);
|
||||
if (n > 0) {
|
||||
buf[n] = '\0';
|
||||
printf("chroot:escaped read=%zd\n", n);
|
||||
} else {
|
||||
printf("chroot:escaped read=0\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
grep_status("NoNewPrivs:", "\t?");
|
||||
grep_status("Seccomp:", "\t?");
|
||||
print_rlimit("rlimit_as", RLIMIT_AS);
|
||||
print_rlimit("rlimit_cpu", RLIMIT_CPU);
|
||||
print_rlimit("rlimit_nofile", RLIMIT_NOFILE);
|
||||
probe_namespaces();
|
||||
probe_chroot();
|
||||
|
||||
if (argc > 1 && strcmp(argv[1], "traverse") == 0) {
|
||||
// Path-traversal acceptance case: a payload that tries to read
|
||||
// /etc/passwd outside the workdir. Exit non-zero so the verifier
|
||||
// records NotConfirmed; the probe-level "chroot blocked" line
|
||||
// already printed above is what the test asserts on.
|
||||
if (open("/etc/passwd", O_RDONLY) >= 0) {
|
||||
// chroot did not hold — exit 0 to signal escape (test fails).
|
||||
printf("traverse:escaped\n");
|
||||
return 0;
|
||||
}
|
||||
printf("traverse:blocked\n");
|
||||
return 7;
|
||||
}
|
||||
|
||||
printf("__NYX_PROBE_DONE__\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -58,12 +58,8 @@ mod escape_tests {
|
|||
timeout: Duration::from_secs(10),
|
||||
memory_mib: 256,
|
||||
backend: SandboxBackend::Docker,
|
||||
env_passthrough: vec![],
|
||||
output_limit: 65536,
|
||||
network_policy: NetworkPolicy::None,
|
||||
probe_channel: None,
|
||||
extra_env: vec![],
|
||||
stub_harness: None,
|
||||
..SandboxOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
478
tests/sandbox_hardening_linux.rs
Normal file
478
tests/sandbox_hardening_linux.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
//! Phase 17 (Track E.1) — Linux process backend hardening acceptance tests.
|
||||
//!
|
||||
//! Each primitive in the Phase 17 sequence is exercised against a
|
||||
//! statically-linked C probe (`tests/dynamic_fixtures/hardening/probe.c`)
|
||||
//! that prints its own `/proc/self` view to stdout. The Rust test reads
|
||||
//! stdout back and asserts on the expected line per primitive.
|
||||
//!
|
||||
//! The probe is built once per test run via `cc -static -O2`. Hosts
|
||||
//! without `cc` or without a static-link-capable libc skip with an
|
||||
//! `eprintln!` rather than failing — the suite's authoritative gate is
|
||||
//! the Linux CI matrix row that has both.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo nextest run --features dynamic --test sandbox_hardening_linux`
|
||||
|
||||
#[cfg(all(feature = "dynamic", target_os = "linux"))]
|
||||
mod hardening_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use nyx_scanner::dynamic::harness::BuiltHarness;
|
||||
use nyx_scanner::dynamic::sandbox::process_linux::{
|
||||
last_hardening_outcome, reset_last_hardening_outcome, HardeningLevel, PrimitiveStatus,
|
||||
};
|
||||
use nyx_scanner::dynamic::sandbox::seccomp;
|
||||
use nyx_scanner::dynamic::sandbox::{
|
||||
self, ProcessHardeningProfile, SandboxBackend, SandboxOptions,
|
||||
};
|
||||
|
||||
// ── Probe build ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Path to the freshly-built probe binary, shared across every test.
|
||||
static PROBE_BINARY: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
fn probe_path() -> Option<&'static Path> {
|
||||
PROBE_BINARY
|
||||
.get_or_init(|| build_probe_once())
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
fn build_probe_once() -> Option<PathBuf> {
|
||||
let cc = std::env::var("CC").unwrap_or_else(|_| "cc".to_owned());
|
||||
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/dynamic_fixtures/hardening/probe.c");
|
||||
let out_dir = std::env::temp_dir().join("nyx-hardening-probe");
|
||||
let _ = std::fs::create_dir_all(&out_dir);
|
||||
let out_bin = out_dir.join("probe");
|
||||
|
||||
// Try a static link first (works under glibc-dev with libc.a, or
|
||||
// musl-cross). Fall back to dynamic if that fails — the probe
|
||||
// still functions before chroot but the chroot test will skip.
|
||||
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()) {
|
||||
return Some(out_bin);
|
||||
}
|
||||
|
||||
let dyn_status = Command::new(&cc)
|
||||
.args(["-O2", "-o"])
|
||||
.arg(&out_bin)
|
||||
.arg(&src)
|
||||
.status();
|
||||
if matches!(&dyn_status, Ok(s) if s.success()) {
|
||||
// Mark via env so the chroot test can branch.
|
||||
unsafe { std::env::set_var("NYX_PROBE_DYNAMIC", "1") };
|
||||
return Some(out_bin);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"SKIP: could not build hardening probe with {cc:?} (static={static_status:?}, \
|
||||
dyn={dyn_status:?})"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
fn probe_is_static() -> bool {
|
||||
std::env::var_os("NYX_PROBE_DYNAMIC").is_none()
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
// Keep seccomp_caps = 0 so only the BASE allowlist applies:
|
||||
// the probe needs `read`, `write`, `openat`, `readlink`, etc.,
|
||||
// all of which are in the base set.
|
||||
seccomp_caps: 0,
|
||||
..SandboxOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn standard_opts() -> SandboxOptions {
|
||||
SandboxOptions {
|
||||
timeout: Duration::from_secs(10),
|
||||
memory_mib: 256,
|
||||
backend: SandboxBackend::Process,
|
||||
output_limit: 65536,
|
||||
process_hardening: ProcessHardeningProfile::Standard,
|
||||
..SandboxOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_harness_with_probe(workdir: &Path, args: &[&str]) -> BuiltHarness {
|
||||
// Stage the probe inside the workdir so `chroot(workdir)` doesn't
|
||||
// leave the binary unreachable mid-exec.
|
||||
let probe_src = probe_path().expect("probe must be built").to_path_buf();
|
||||
let probe_dst = workdir.join("probe");
|
||||
std::fs::copy(&probe_src, &probe_dst).expect("copy probe into workdir");
|
||||
// Ensure it's executable (cc preserves +x but be explicit).
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&probe_dst).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&probe_dst, perms).unwrap();
|
||||
|
||||
let mut command: Vec<String> = vec![probe_dst.to_string_lossy().into_owned()];
|
||||
for a in args {
|
||||
command.push((*a).to_string());
|
||||
}
|
||||
|
||||
BuiltHarness {
|
||||
workdir: workdir.to_path_buf(),
|
||||
command,
|
||||
env: vec![],
|
||||
source: String::new(),
|
||||
entry_source: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn workdir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().expect("temp dir")
|
||||
}
|
||||
|
||||
fn stdout_string(out: &sandbox::SandboxOutcome) -> String {
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn assert_line(stdout: &str, prefix: &str) {
|
||||
assert!(
|
||||
stdout.lines().any(|l| l.starts_with(prefix)),
|
||||
"expected stdout to contain a line starting with {prefix:?}; full stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sanity gate: the probe must build and run on a Confirmed
|
||||
/// (exit-zero) baseline. All other tests presume this passes.
|
||||
#[test]
|
||||
fn probe_runs_under_strict_profile() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
eprintln!("probe stdout under strict:\n{stdout}");
|
||||
// Probe always prints a `__NYX_PROBE_DONE__` sentinel after the
|
||||
// primitive lines; absence means the binary died before reaching
|
||||
// the end (e.g. seccomp killed it). A clean Confirmed run prints
|
||||
// it.
|
||||
assert_line(&stdout, "__NYX_PROBE_DONE__");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_new_privs_set_under_strict() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
// /proc/self/status's `NoNewPrivs:` line is `1` after PR_SET_NO_NEW_PRIVS.
|
||||
assert!(
|
||||
stdout.contains("NoNewPrivs:\t1"),
|
||||
"expected NoNewPrivs:1 line; full stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rlimit_cpu_capped_under_strict() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
// RLIMIT_CPU is set to timeout * 2 = 20 seconds in strict_opts.
|
||||
// Under Standard the value would be RLIM_INFINITY.
|
||||
assert_line(&stdout, "rlimit_cpu:");
|
||||
for line in stdout.lines() {
|
||||
if let Some(rest) = line.strip_prefix("rlimit_cpu:") {
|
||||
let (cur, _) = rest.split_once('/').expect("rlimit_cpu format");
|
||||
let cur: u64 = cur.parse().expect("numeric rlimit");
|
||||
assert!(cur <= 30, "RLIMIT_CPU not capped: {cur}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("rlimit_cpu line missing from stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rlimit_nofile_capped_under_strict() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
for line in stdout.lines() {
|
||||
if let Some(rest) = line.strip_prefix("rlimit_nofile:") {
|
||||
let (cur, _) = rest.split_once('/').expect("rlimit_nofile format");
|
||||
let cur: u64 = cur.parse().expect("numeric rlimit");
|
||||
assert!(cur <= 256, "RLIMIT_NOFILE not capped: {cur}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("rlimit_nofile line missing from stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rlimit_as_capped_under_strict() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
for line in stdout.lines() {
|
||||
if let Some(rest) = line.strip_prefix("rlimit_as:") {
|
||||
let (cur, _) = rest.split_once('/').expect("rlimit_as format");
|
||||
let cur: u64 = cur.parse().expect("numeric rlimit");
|
||||
// memory_mib=256 → cap = max(256*8, 4096) MiB = 4 GiB
|
||||
let four_gib = 4_u64 * 1024 * 1024 * 1024;
|
||||
assert_eq!(cur, four_gib, "RLIMIT_AS not 4 GiB: {cur}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("rlimit_as line missing from stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
/// `unshare(CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNS)` is best-effort.
|
||||
/// On hosts that allow unprivileged user namespaces the probe's
|
||||
/// `/proc/self/ns/user` inode differs from the parent's; on locked-
|
||||
/// down hosts (sysctl `kernel.unprivileged_userns_clone=0`) the
|
||||
/// outcome decays to `Partial` instead of failing the run.
|
||||
#[test]
|
||||
fn unshare_namespaces_when_kernel_allows() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
let outcome = last_hardening_outcome().expect("hardening outcome recorded");
|
||||
|
||||
// Parent's user-ns inode for comparison.
|
||||
let parent_user_ns =
|
||||
std::fs::read_link("/proc/self/ns/user").map(|p| p.to_string_lossy().into_owned());
|
||||
|
||||
match outcome.unshare {
|
||||
PrimitiveStatus::Applied => {
|
||||
let probe_user_ns_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with("ns_user:"))
|
||||
.expect("ns_user: line in stdout");
|
||||
if let Ok(parent) = parent_user_ns {
|
||||
assert!(
|
||||
!probe_user_ns_line.contains(parent.as_str()),
|
||||
"child user ns identical to parent — unshare reported Applied but ns inode unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
PrimitiveStatus::Failed(errno) => {
|
||||
eprintln!(
|
||||
"unshare returned errno={errno} (likely unprivileged_userns_clone=0); \
|
||||
accepting Partial level"
|
||||
);
|
||||
assert!(matches!(
|
||||
outcome.level(),
|
||||
HardeningLevel::Partial | HardeningLevel::None
|
||||
));
|
||||
}
|
||||
PrimitiveStatus::Skipped => panic!("unshare must not be Skipped under Strict profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `chroot` should make the host's `/etc/passwd` unreachable from
|
||||
/// inside the harness. Under the Strict profile and a static probe
|
||||
/// the file open returns ENOENT and the probe prints
|
||||
/// `chroot:blocked`.
|
||||
#[test]
|
||||
fn chroot_blocks_etc_passwd() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
if !probe_is_static() {
|
||||
eprintln!("SKIP: probe is dynamically linked — chroot would block its loader before main()");
|
||||
return;
|
||||
}
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
let outcome = last_hardening_outcome().expect("hardening outcome recorded");
|
||||
|
||||
match outcome.chroot {
|
||||
PrimitiveStatus::Applied => {
|
||||
assert!(
|
||||
stdout.contains("chroot:blocked"),
|
||||
"chroot reported Applied but /etc/passwd was readable; full stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
PrimitiveStatus::Failed(errno) => {
|
||||
// Common failure: EPERM when the kernel blocks chroot
|
||||
// for unprivileged callers without CAP_SYS_CHROOT, or
|
||||
// EINVAL when the workdir doesn't satisfy the
|
||||
// canonicalisation precondition. Accept Partial.
|
||||
eprintln!("chroot returned errno={errno}; recorded as Partial");
|
||||
assert_ne!(outcome.level(), HardeningLevel::Full);
|
||||
}
|
||||
PrimitiveStatus::Skipped => panic!("chroot must not be Skipped under Strict profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Path-traversal acceptance case from the phase deliverables.
|
||||
/// Drives the probe with `traverse` so it tries to open
|
||||
/// `/etc/passwd`; the binary exits non-zero on chroot success
|
||||
/// (mapped to `NotConfirmed` by the runner's exit-code rule) and
|
||||
/// prints `chroot blocked` for the test to assert on.
|
||||
#[test]
|
||||
fn path_traversal_returns_not_confirmed_when_chroot_holds() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
if !probe_is_static() {
|
||||
eprintln!("SKIP: probe is dynamically linked — chroot test requires static link");
|
||||
return;
|
||||
}
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &["traverse"]);
|
||||
let opts = strict_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
let outcome = last_hardening_outcome().expect("hardening outcome recorded");
|
||||
|
||||
if matches!(outcome.chroot, PrimitiveStatus::Applied) {
|
||||
// NotConfirmed shape: the verifier maps a non-zero exit + no
|
||||
// sink-hit sentinel to NotConfirmed. We assert the two
|
||||
// structural pieces here directly.
|
||||
assert_eq!(
|
||||
result.exit_code,
|
||||
Some(7),
|
||||
"probe exit code mismatch — full stdout:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!result.sink_hit,
|
||||
"sink hit should be absent on a traversal-blocked run"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("chroot blocked") || stdout.contains("chroot:blocked")
|
||||
|| stdout.contains("traverse:blocked"),
|
||||
"expected `chroot blocked` marker in probe stdout; got:\n{stdout}"
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"SKIP: chroot did not apply (status={:?}); cannot assert traversal blocked",
|
||||
outcome.chroot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// seccomp filter installs cleanly under the Strict profile and the
|
||||
/// probe survives long enough to print its sentinel. /proc/self/
|
||||
/// status's `Seccomp:` line transitions from `0` (disabled) to `2`
|
||||
/// (filter mode) when the prctl call succeeds.
|
||||
#[test]
|
||||
fn seccomp_filter_installed_under_strict() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = strict_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
let outcome = last_hardening_outcome().expect("hardening outcome recorded");
|
||||
|
||||
match outcome.seccomp {
|
||||
PrimitiveStatus::Applied => {
|
||||
assert!(
|
||||
stdout.contains("Seccomp:\t2"),
|
||||
"Seccomp:2 missing — filter not active in /proc/self/status; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
PrimitiveStatus::Failed(errno) => {
|
||||
eprintln!(
|
||||
"SKIP: seccomp prctl returned errno={errno} (typical when running under \
|
||||
a sandbox that already locked the syscall down); accepting Partial level"
|
||||
);
|
||||
assert_ne!(outcome.level(), HardeningLevel::Full);
|
||||
}
|
||||
PrimitiveStatus::Skipped => panic!("seccomp must not be Skipped under Strict profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard profile keeps the historical baseline: PR_SET_NO_NEW_PRIVS
|
||||
/// and RLIMIT_AS only. /etc/passwd should still be readable
|
||||
/// (no chroot) and the seccomp counter stays at 0.
|
||||
#[test]
|
||||
fn standard_profile_skips_chroot_and_seccomp() {
|
||||
let Some(_) = probe_path() else { return };
|
||||
let tmp = workdir();
|
||||
let harness = build_harness_with_probe(tmp.path(), &[]);
|
||||
let opts = standard_opts();
|
||||
reset_last_hardening_outcome();
|
||||
let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run");
|
||||
let stdout = stdout_string(&result);
|
||||
let outcome = last_hardening_outcome().expect("hardening outcome recorded");
|
||||
|
||||
assert_eq!(outcome.level(), HardeningLevel::Baseline);
|
||||
assert!(matches!(outcome.no_new_privs, PrimitiveStatus::Applied));
|
||||
assert!(matches!(outcome.rlimit_as, PrimitiveStatus::Applied));
|
||||
// None of the strict-only primitives should have been attempted.
|
||||
assert!(matches!(outcome.chroot, PrimitiveStatus::Skipped));
|
||||
assert!(matches!(outcome.seccomp, PrimitiveStatus::Skipped));
|
||||
assert!(matches!(outcome.unshare, PrimitiveStatus::Skipped));
|
||||
|
||||
// Baseline: /etc/passwd should still be open-able from the host.
|
||||
// The probe prints either `chroot:blocked` (if outside the
|
||||
// sandbox restricted further) or `chroot:escaped`. We don't
|
||||
// require either: the assertion here is purely on the recorded
|
||||
// hardening outcome.
|
||||
let _ = stdout;
|
||||
let _ = result.exit_code;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// tests the codegen path without touching the kernel.
|
||||
#[test]
|
||||
fn seccomp_policy_includes_essential_syscalls() {
|
||||
let nrs = seccomp::allowed_syscall_numbers(0);
|
||||
for essential in &["read", "write", "close", "openat", "exit_group", "fstat"] {
|
||||
let nr = seccomp::syscalls::syscall_number(essential)
|
||||
.unwrap_or_else(|| panic!("syscall {essential} missing from per-arch table"));
|
||||
assert!(
|
||||
nrs.contains(&nr),
|
||||
"BASE seccomp allowlist missing essential syscall {essential} (nr={nr})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Linux placeholder so `cargo nextest run --test sandbox_hardening_linux`
|
||||
// doesn't fail with "no tests to run" on macOS / Windows CI rows. 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_hardening_linux.rs requires `--features dynamic` and \
|
||||
target_os = linux"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue