mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] sweep after phase 04: 3 deferred items resolved
This commit is contained in:
parent
3ffe480660
commit
84638e7d57
6 changed files with 184 additions and 28 deletions
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
|
|
@ -239,6 +239,34 @@ jobs:
|
|||
- name: Rust tests with docker (sandbox escape gate)
|
||||
run: cargo nextest run --all-features --test dynamic_sandbox_escape --test dynamic_parity
|
||||
|
||||
escape-positive-control:
|
||||
name: escape-positive-control
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
docker:
|
||||
image: docker:dind
|
||||
options: --privileged
|
||||
env:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Pull python image
|
||||
run: docker pull python:3-slim
|
||||
|
||||
- name: Escape positive control (gate wiring check)
|
||||
run: |
|
||||
cargo nextest run --all-features --test dynamic_sandbox_escape \
|
||||
-- --include-ignored positive_control_cap_sys_admin
|
||||
|
||||
cross-platform-smoke:
|
||||
name: cross-platform-smoke
|
||||
strategy:
|
||||
|
|
|
|||
|
|
@ -385,10 +385,17 @@ fn exec_in_container(
|
|||
use std::process::{Command, Stdio};
|
||||
|
||||
// Build the docker exec command.
|
||||
// exec_in_container is only called for interpreted harnesses (python3, node, …);
|
||||
// compiled binaries are routed to run_process by the dispatch in run().
|
||||
let payload_b64 = base64_encode(payload.bytes);
|
||||
let mut cmd_args: Vec<String> = vec![
|
||||
"exec".into(),
|
||||
"-i".into(),
|
||||
// Run the harness as an unprivileged user so that uid-based kernel
|
||||
// checks provide a second layer of defence on top of --cap-drop=ALL.
|
||||
// The container itself starts as root for setup (mkdir, docker cp),
|
||||
// but harness execution runs as nobody (uid/gid 65534).
|
||||
"--user".into(), "65534:65534".into(),
|
||||
"-e".into(), format!("NYX_PAYLOAD_B64={payload_b64}"),
|
||||
];
|
||||
// Forward harness-specific env vars.
|
||||
|
|
@ -398,33 +405,15 @@ fn exec_in_container(
|
|||
}
|
||||
cmd_args.push(container_name.into());
|
||||
|
||||
// Build the exec command inside the container.
|
||||
// For interpreters: `python3 /workdir/harness.py`
|
||||
// For compiled binaries: `/workdir/target/release/nyx_harness`
|
||||
// Build the exec command inside the container (always interpreted at this point).
|
||||
let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
|
||||
if harness_is_interpreted(&harness.command) {
|
||||
let harness_file = harness
|
||||
.command
|
||||
.get(1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("harness.py");
|
||||
cmd_args.push(exec_cmd.into());
|
||||
cmd_args.push(format!("/workdir/{harness_file}"));
|
||||
} else {
|
||||
// Compiled binary: the command is the relative path within workdir.
|
||||
// e.g. "target/release/nyx_harness" → run "/workdir/target/release/nyx_harness"
|
||||
let rel = std::path::Path::new(exec_cmd)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(exec_cmd);
|
||||
if exec_cmd.contains('/') || exec_cmd.contains('\\') {
|
||||
// Relative path within workdir (e.g. "target/release/nyx_harness").
|
||||
cmd_args.push(format!("/workdir/{exec_cmd}"));
|
||||
} else {
|
||||
// Just a filename — try /workdir directly.
|
||||
cmd_args.push(format!("/workdir/{rel}"));
|
||||
}
|
||||
}
|
||||
let harness_file = harness
|
||||
.command
|
||||
.get(1)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("harness.py");
|
||||
cmd_args.push(exec_cmd.into());
|
||||
cmd_args.push(format!("/workdir/{harness_file}"));
|
||||
|
||||
let mut cmd = Command::new(docker_bin());
|
||||
cmd.args(&cmd_args);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
"""Positive control: verify the escape-detection mechanism itself.
|
||||
|
||||
Writes to /proc/sysrq-trigger, which requires CAP_SYS_ADMIN. This fixture
|
||||
is expected to SUCCEED (print NYX_ESCAPE_SUCCESS) when run in a privileged
|
||||
container (--cap-add=SYS_ADMIN) and to FAIL when run with --cap-drop=ALL.
|
||||
|
||||
CI usage (escape-positive-control job):
|
||||
Run under a container started with --cap-add=SYS_ADMIN.
|
||||
Assert NYX_ESCAPE_SUCCESS appears in output.
|
||||
If it does NOT appear, the test mechanism is broken or the capability
|
||||
was not granted, and the CI job fails.
|
||||
|
||||
Normal escape suite: this fixture is NOT included. The positive control test
|
||||
(positive_control_cap_sys_admin in dynamic_sandbox_escape.rs) is #[ignore]d
|
||||
and only un-ignored in the dedicated escape-positive-control CI job.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open("/proc/sysrq-trigger", "w") as f:
|
||||
f.write("h") # 'h' = print help, harmless
|
||||
print("NYX_ESCAPE_SUCCESS: CAP_SYS_ADMIN active, wrote to /proc/sysrq-trigger")
|
||||
sys.exit(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED: CAP_SYS_ADMIN not present: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -1,9 +1,21 @@
|
|||
"""Escape attempt: open /dev/mem to read physical memory.
|
||||
|
||||
Requires CAP_SYS_RAWIO. Expected outcome: PermissionError.
|
||||
Requires CAP_SYS_RAWIO (and typically root). With --cap-drop=ALL and
|
||||
--user 65534:65534, both privilege escalation (setuid) and device access
|
||||
are blocked by distinct kernel checks, exercising two security layers.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Attempt privilege escalation first (tests CAP_SETUID independently).
|
||||
# With --cap-drop=ALL, setuid(0) requires CAP_SETUID — also dropped.
|
||||
if os.getuid() != 0:
|
||||
try:
|
||||
os.setuid(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED (setuid): {e}", file=sys.stderr)
|
||||
# Fall through: also probe /dev/mem directly to test CAP_SYS_RAWIO.
|
||||
|
||||
try:
|
||||
with open("/dev/mem", "rb") as f:
|
||||
data = f.read(64)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
"""Escape attempt: trigger kernel SysRq action via /proc/sysrq-trigger.
|
||||
|
||||
Expected outcome: PermissionError (read-only rootfs or missing CAP_SYS_ADMIN).
|
||||
Requires CAP_SYS_ADMIN (and typically root). With --cap-drop=ALL and
|
||||
--user 65534:65534, both privilege escalation (setuid) and the sysrq
|
||||
write are blocked by distinct kernel checks.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Attempt privilege escalation first (tests CAP_SETUID independently).
|
||||
# With --cap-drop=ALL, setuid(0) requires CAP_SETUID — also dropped.
|
||||
if os.getuid() != 0:
|
||||
try:
|
||||
os.setuid(0)
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"BLOCKED (setuid): {e}", file=sys.stderr)
|
||||
# Fall through: also probe /proc/sysrq-trigger to test CAP_SYS_ADMIN.
|
||||
|
||||
try:
|
||||
with open("/proc/sysrq-trigger", "w") as f:
|
||||
f.write("h") # 'h' = print help (harmless but requires access)
|
||||
|
|
|
|||
|
|
@ -226,6 +226,95 @@ mod escape_tests {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Positive control test ─────────────────────────────────────────────────
|
||||
|
||||
/// Positive control: verify the escape-detection mechanism itself.
|
||||
///
|
||||
/// Runs `cap_sys_admin_positive_control.py` inside a container started with
|
||||
/// `--cap-add=SYS_ADMIN` and asserts that `NYX_ESCAPE_SUCCESS` is detected
|
||||
/// in the output. If it is not detected, either the test mechanism is broken
|
||||
/// or the capability was not granted.
|
||||
///
|
||||
/// This test is `#[ignore]`d in the normal escape suite. It is un-ignored
|
||||
/// in the dedicated `escape-positive-control` CI job:
|
||||
///
|
||||
/// cargo nextest run --all-features --test dynamic_sandbox_escape \
|
||||
/// -- --include-ignored positive_control_cap_sys_admin
|
||||
#[test]
|
||||
#[ignore = "positive control: run only under --cap-add=SYS_ADMIN (escape-positive-control CI job)"]
|
||||
fn positive_control_cap_sys_admin() {
|
||||
if !docker_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (_tmpdir, _harness) = harness_for_fixture("cap_sys_admin_positive_control.py");
|
||||
let workdir_str = _tmpdir.path().to_string_lossy().to_string();
|
||||
|
||||
// Start a container with CAP_SYS_ADMIN to validate escape detection.
|
||||
// This is intentionally privileged — it IS the escape we're detecting.
|
||||
let container_name = format!("nyx-posctl-{}", std::process::id());
|
||||
let status = std::process::Command::new("docker")
|
||||
.args([
|
||||
"run", "-d", "--rm",
|
||||
"--name", &container_name,
|
||||
"--cap-add=SYS_ADMIN",
|
||||
"--network", "none",
|
||||
"python:3-slim",
|
||||
"sleep", "60",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.expect("docker run");
|
||||
|
||||
if !status.success() {
|
||||
// Container failed to start (image unavailable or docker error).
|
||||
// Accept — this is a best-effort gate, not a hard requirement here.
|
||||
return;
|
||||
}
|
||||
|
||||
// Create /workdir and copy the fixture in.
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["exec", &container_name, "mkdir", "-p", "/workdir"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
let cp_src = format!("{workdir_str}/.");
|
||||
let cp_dst = format!("{container_name}:/workdir");
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["cp", &cp_src, &cp_dst])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
// Run the fixture and capture output.
|
||||
let out = std::process::Command::new("docker")
|
||||
.args([
|
||||
"exec", &container_name,
|
||||
"python3", "/workdir/cap_sys_admin_positive_control.py",
|
||||
])
|
||||
.output()
|
||||
.expect("docker exec positive control");
|
||||
|
||||
// Cleanup the container immediately.
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["stop", "--time=0", &container_name])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
let stdout = std::str::from_utf8(&out.stdout).unwrap_or("");
|
||||
let stderr = std::str::from_utf8(&out.stderr).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
stdout.contains("NYX_ESCAPE_SUCCESS") || stderr.contains("NYX_ESCAPE_SUCCESS"),
|
||||
"positive control failed: NYX_ESCAPE_SUCCESS not detected with CAP_SYS_ADMIN\n\
|
||||
This means the test mechanism cannot detect actual escapes.\n\
|
||||
stdout: {stdout}\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Docker exec reuse test ────────────────────────────────────────────────
|
||||
|
||||
/// Verify that the second payload for the same spec_hash reuses the running
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue