mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] sweep after phase 03: 7 deferred items resolved
This commit is contained in:
parent
a8b9dcd72b
commit
e875aa1208
6 changed files with 122 additions and 38 deletions
|
|
@ -132,12 +132,44 @@ static CONTAINER_REGISTRY: OnceLock<dashmap::DashMap<String, String>> = OnceLock
|
|||
|
||||
fn container_registry() -> &'static dashmap::DashMap<String, String> {
|
||||
CONTAINER_REGISTRY.get_or_init(|| {
|
||||
// Best-effort cleanup at process exit.
|
||||
// Containers are started with --rm, so they self-remove on stop.
|
||||
// Register an atexit handler to stop containers on normal process exit.
|
||||
// Containers are also started with --rm and `sleep 300` so they self-remove
|
||||
// within 5 minutes if the handler doesn't run (e.g. SIGKILL).
|
||||
#[cfg(unix)]
|
||||
register_exit_cleanup();
|
||||
dashmap::DashMap::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// extern "C" fn registered via atexit(3).
|
||||
///
|
||||
/// Stops all containers in the registry with --time=0 (immediate SIGKILL).
|
||||
/// Runs on normal process exit and on `std::process::exit()`. Does not run
|
||||
/// on SIGKILL; the `sleep 300` in started containers bounds the leak window.
|
||||
#[cfg(unix)]
|
||||
extern "C" fn stop_all_containers() {
|
||||
let Some(reg) = CONTAINER_REGISTRY.get() else { return };
|
||||
let bin = std::env::var("NYX_DOCKER_BIN").unwrap_or_else(|_| "docker".to_owned());
|
||||
for entry in reg.iter() {
|
||||
let _ = std::process::Command::new(&bin)
|
||||
.args(["stop", "--time=0", entry.key()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn register_exit_cleanup() {
|
||||
unsafe extern "C" {
|
||||
fn atexit(f: extern "C" fn()) -> i32;
|
||||
}
|
||||
// Safety: atexit(3) is async-signal-safe for registration; the handler
|
||||
// itself runs on the main thread during normal shutdown, after all Rust
|
||||
// destructors, so std::process::Command is safe to call from it.
|
||||
unsafe { atexit(stop_all_containers) };
|
||||
}
|
||||
|
||||
fn workdir_to_container_name(workdir: &Path) -> String {
|
||||
// The workdir is /tmp/nyx-harness/{spec_hash}; the spec_hash is the last
|
||||
// path component (16-char hex). Use it directly for a readable name.
|
||||
|
|
@ -263,8 +295,9 @@ fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), Sandbo
|
|||
"--cap-drop=ALL",
|
||||
"--security-opt", "no-new-privileges:true",
|
||||
"--network", "none",
|
||||
"--tmpfs", "/tmp:size=128m,exec",
|
||||
image,
|
||||
"sleep", "3600",
|
||||
"sleep", "300",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
|
|
@ -355,14 +388,24 @@ fn exec_in_container(
|
|||
let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let timed_out_clone = timed_out.clone();
|
||||
let child_id = child.id();
|
||||
let container_name_for_kill = container_name.to_owned();
|
||||
|
||||
let _timer = std::thread::spawn(move || {
|
||||
std::thread::sleep(timeout);
|
||||
timed_out_clone.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
// Kill the local docker-exec client.
|
||||
#[cfg(unix)]
|
||||
libc_kill(child_id as i32, 9);
|
||||
#[cfg(not(unix))]
|
||||
let _ = child_id;
|
||||
// Also kill all non-PID-1 processes inside the container so runaway
|
||||
// payloads (fork bombs, infinite loops) don't keep consuming host
|
||||
// resources after the harness reports timed_out.
|
||||
let _ = std::process::Command::new(docker_bin())
|
||||
.args(["exec", &container_name_for_kill, "kill", "-9", "-1"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
});
|
||||
|
||||
let limit = opts.output_limit;
|
||||
|
|
|
|||
|
|
@ -264,9 +264,12 @@ pub struct ScannerConfig {
|
|||
/// `"auto"` (default): docker when available, else process.
|
||||
/// `"docker"`: require docker; fail if unavailable.
|
||||
/// `"process"`: in-process runner (same as `--unsafe-sandbox`).
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_verify_backend")]
|
||||
pub verify_backend: String,
|
||||
}
|
||||
fn default_verify_backend() -> String {
|
||||
"auto".to_owned()
|
||||
}
|
||||
impl Default for ScannerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
|
|||
52
tests/cli_unsafe_sandbox.rs
Normal file
52
tests/cli_unsafe_sandbox.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! CLI validation tests for --unsafe-sandbox and --backend flag interactions.
|
||||
//!
|
||||
//! Guards against regressions in the mutual-exclusion check between
|
||||
//! `--unsafe-sandbox` and `--backend docker`. The validation only fires when
|
||||
//! the binary is built with `--features dynamic`; without it both flags are
|
||||
//! silently accepted (no-op).
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
mod dynamic_sandbox_cli {
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
fn scan_cmd_with_fresh_env() -> Command {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let mut cmd = Command::cargo_bin("nyx").expect("nyx binary");
|
||||
cmd.env("HOME", home.path())
|
||||
.env("XDG_CONFIG_HOME", home.path().join(".config"))
|
||||
.env("XDG_DATA_HOME", home.path().join(".local/share"))
|
||||
.env("NO_COLOR", "1");
|
||||
// Scan a non-existent path; the backend validation runs before any
|
||||
// filesystem work so the path doesn't need to exist for these tests.
|
||||
cmd.args(["scan", "/dev/null/nonexistent"]);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// `--unsafe-sandbox --backend docker` must be rejected with a clear error.
|
||||
#[test]
|
||||
fn unsafe_sandbox_with_docker_backend_is_rejected() {
|
||||
let mut cmd = scan_cmd_with_fresh_env();
|
||||
cmd.args(["--unsafe-sandbox", "--backend", "docker"]);
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(
|
||||
"--unsafe-sandbox and --backend docker are mutually exclusive",
|
||||
));
|
||||
}
|
||||
|
||||
/// `--unsafe-sandbox` alone (no explicit --backend) must NOT trigger the
|
||||
/// mutual-exclusion error. It may fail for other reasons (path not found,
|
||||
/// no findings, etc.) but not with the mutex message.
|
||||
#[test]
|
||||
fn unsafe_sandbox_alone_does_not_trigger_mutex_error() {
|
||||
let mut cmd = scan_cmd_with_fresh_env();
|
||||
cmd.arg("--unsafe-sandbox");
|
||||
cmd.assert().stderr(
|
||||
predicate::str::contains(
|
||||
"--unsafe-sandbox and --backend docker are mutually exclusive",
|
||||
)
|
||||
.not(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
"""Escape attempt: create a hardlink to a sensitive file outside the workdir.
|
||||
|
||||
Expected outcome: PermissionError (EPERM — cross-device or protected hardlink).
|
||||
Linux protects hardlinks to files owned by other users since 3.6+.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
os.link("/etc/shadow", "/tmp/shadow_hardlink")
|
||||
with open("/tmp/shadow_hardlink", "r") as f:
|
||||
data = f.read(64)
|
||||
print(f"NYX_ESCAPE_SUCCESS: hardlinked /etc/shadow: {data[:32]!r}")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -1,28 +1,32 @@
|
|||
"""Escape attempt: create a System V shared memory segment accessible host-wide.
|
||||
|
||||
Expected outcome: success creates IPC segment inside the container IPC namespace
|
||||
(isolated by --ipc private default); OR EPERM if IPC syscalls are blocked.
|
||||
Expected outcome: ENOSYS or EPERM when IPC syscalls are blocked; or the segment
|
||||
is created inside the container IPC namespace (isolated by default, not an escape).
|
||||
Rewritten to use ctypes so python:3-slim does not need sysv_ipc installed.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sysv_ipc
|
||||
key = sysv_ipc.ftok("/tmp", ord('N'))
|
||||
shm = sysv_ipc.SharedMemory(key, sysv_ipc.IPC_CREAT, size=4096)
|
||||
shm.write(b"NYX_IPC_ESCAPE_TEST" + b"\x00" * (4096 - 20))
|
||||
# If we can create IPC, check if it's in an isolated namespace.
|
||||
# A properly isolated container won't share this with the host.
|
||||
# We can only verify this from the host side, so just report success.
|
||||
shm.detach()
|
||||
shm.remove()
|
||||
# IPC created successfully but inside the container namespace — not an escape.
|
||||
libc_path = ctypes.util.find_library("c") or "libc.so.6"
|
||||
libc = ctypes.CDLL(libc_path, use_errno=True)
|
||||
|
||||
IPC_PRIVATE = 0
|
||||
IPC_CREAT = 0o1000
|
||||
IPC_RMID = 0
|
||||
|
||||
shmid = libc.shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0o600)
|
||||
if shmid == -1:
|
||||
errno = ctypes.get_errno()
|
||||
print(f"BLOCKED: shmget failed errno={errno}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Segment created in the container IPC namespace — not accessible from host.
|
||||
libc.shmctl(shmid, IPC_RMID, None)
|
||||
print("BLOCKED: IPC shm created but confined to container IPC namespace",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ImportError:
|
||||
# sysv_ipc not available — not an escape.
|
||||
print("BLOCKED: sysv_ipc module not available", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"BLOCKED: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ mod escape_tests {
|
|||
escape_test!(escape_proc_sysrq, "proc_sysrq.py");
|
||||
escape_test!(escape_device_file_access, "device_file_access.py");
|
||||
escape_test!(escape_symlink_escape, "symlink_escape.py");
|
||||
escape_test!(escape_hardlink_escape, "hardlink_escape.py");
|
||||
escape_test!(escape_env_injection, "env_injection.py");
|
||||
escape_test!(escape_dns_leak, "dns_leak.py");
|
||||
escape_test!(escape_egress_non_allowlisted, "egress_non_allowlisted.py");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue