mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
The kube-ax probe showed bwrap cannot map uids in gVisor as an unprivileged user. As root (gVisor is the outer boundary) the sandbox uses pid/ipc/uts/net/mount namespaces without a user namespace and drops the artefact to its own uid via setpriv; the mode is recorded. The probe reports each namespace kind separately. Co-Authored-By: Claude Opus 5.5 <[email protected]>
206 lines
7.4 KiB
Python
206 lines
7.4 KiB
Python
"""Local CLI lane using the existing sandbox job/result contract.
|
|
|
|
Bubblewrap is required, never emulated with chmod. Each process sees only the
|
|
immutable runtime, declared inputs, and its own scratch. No host /proc or home.
|
|
The seccomp filter denies sockets for artefacts and builds, even on hosts where
|
|
creating a network namespace is unavailable. SSRF apps join the oracle's private
|
|
loopback namespace. This is a Linux lane; other hosts fail closed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import resource
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
from ..sandbox import SandboxJob, SandboxResult
|
|
|
|
ARTEFACT_UID = 10001
|
|
ARTEFACT_GID = 10001
|
|
|
|
|
|
def isolation_mode() -> str:
|
|
"""Root containers need no user namespace; ordinary host callers retain it."""
|
|
return "root-no-userns" if os.geteuid() == 0 else "userns"
|
|
|
|
|
|
def drop_privileges() -> list[str]:
|
|
return [
|
|
"/usr/bin/setpriv",
|
|
f"--reuid={ARTEFACT_UID}",
|
|
f"--regid={ARTEFACT_GID}",
|
|
"--clear-groups",
|
|
"--no-new-privs",
|
|
"--bounding-set=-all",
|
|
"--inh-caps=-all",
|
|
"--ambient-caps=-all",
|
|
"--",
|
|
]
|
|
|
|
|
|
def capability_args() -> list[str]:
|
|
# Retain only what the trusted setpriv trampoline needs, then erase the
|
|
# bounding/inheritable/ambient sets before executing any untrusted code.
|
|
args = ["--cap-drop", "ALL"]
|
|
if isolation_mode() == "root-no-userns":
|
|
for capability in ("CAP_SETUID", "CAP_SETGID", "CAP_SETPCAP"):
|
|
args += ["--cap-add", capability]
|
|
return args
|
|
|
|
|
|
def writable_directory(path: Path) -> None:
|
|
if isolation_mode() == "root-no-userns":
|
|
os.chown(path, ARTEFACT_UID, ARTEFACT_GID, follow_symlinks=False)
|
|
|
|
|
|
class IsolationUnavailable(RuntimeError):
|
|
"""The isolation instrument failed, so no product outcome can be recorded."""
|
|
|
|
|
|
def isolation_check(*, timeout_seconds: int = 5) -> None:
|
|
"""Prove a trivial job can read its input through the real sandbox path."""
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="isolation-check-") as directory:
|
|
root = Path(directory)
|
|
if isolation_mode() == "root-no-userns":
|
|
root.chmod(0o755)
|
|
(root / "probe").write_text("isolation-ready\n")
|
|
scratch = root / "scratch"
|
|
scratch.mkdir()
|
|
job = SandboxJob("", ("/bin/cat", "/workspace/probe"), root, {}, timeout_seconds, 256, 128)
|
|
result = run(job, scratch=scratch)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise IsolationUnavailable(f"sandbox isolation preflight could not start: {exc}") from exc
|
|
if result.exit_code or result.timed_out or result.stdout != "isolation-ready\n":
|
|
raise IsolationUnavailable(
|
|
f"sandbox isolation preflight failed (exit={result.exit_code}, timeout={result.timed_out}): {result.stderr}"
|
|
)
|
|
|
|
|
|
def _filter(network: bool) -> bytes:
|
|
# Audit architecture check also excludes x32 syscall-number bypasses.
|
|
if platform.machine() == "x86_64":
|
|
arch, forbidden = 0xC000003E, [101, 165, 166, 272, 308, 321]
|
|
sockets = [41, 42, 43, 49, 50, 53, 288]
|
|
elif platform.machine() == "aarch64":
|
|
arch, forbidden = 0xC00000B7, [39, 40, 97, 117, 268, 280]
|
|
sockets = [198, 199, 200, 201, 202, 203, 242]
|
|
else:
|
|
raise OSError("unsupported seccomp architecture")
|
|
ins = [(0x20, 0, 0, 4), (0x15, 1, 0, arch), (6, 0, 0, 0x80000000), (0x20, 0, 0, 0), (0x35, 0, 1, 0x40000000), (6, 0, 0, 0x80000000)]
|
|
for number in forbidden + ([] if network else sockets):
|
|
ins.extend([(0x15, 0, 1, number), (6, 0, 0, 0x50001)])
|
|
ins.append((6, 0, 0, 0x7FFF0000))
|
|
return b"".join(struct.pack("HBBI", *i) for i in ins)
|
|
|
|
|
|
def run(
|
|
job: SandboxJob,
|
|
*,
|
|
scratch: Path,
|
|
mounts: dict[str, Path] | None = None,
|
|
env: dict[str, str] | None = None,
|
|
namespace_pid: int | None = None,
|
|
scratch_bytes: int | None = None,
|
|
) -> SandboxResult:
|
|
"""Run with bounded files, memory, processes, and time; no inherited env."""
|
|
|
|
def limits() -> None:
|
|
resource.setrlimit(resource.RLIMIT_FSIZE, (1024 * 1024, 1024 * 1024))
|
|
resource.setrlimit(resource.RLIMIT_AS, (job.memory_mb * 1024**2,) * 2)
|
|
resource.setrlimit(resource.RLIMIT_CPU, (job.timeout_seconds + 1,) * 2)
|
|
|
|
argv = ["bwrap", "--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts"]
|
|
mode = isolation_mode()
|
|
if namespace_pid is None:
|
|
if mode == "userns":
|
|
argv += ["--unshare-user"]
|
|
else:
|
|
argv = [
|
|
"/usr/bin/nsenter",
|
|
*(["--preserve-credentials", f"--user=/proc/{namespace_pid}/ns/user"] if mode == "userns" else []),
|
|
f"--net=/proc/{namespace_pid}/ns/net",
|
|
"--",
|
|
*argv,
|
|
]
|
|
argv += [*capability_args(), "--clearenv"]
|
|
for path in ("/usr", "/lib", "/lib64", "/bin"):
|
|
if Path(path).exists():
|
|
argv += ["--ro-bind", path, path]
|
|
argv += ["--ro-bind", str(job.repo_root), "/workspace"]
|
|
if scratch_bytes is None:
|
|
writable_directory(scratch)
|
|
argv += ["--bind", str(scratch), "/scratch"]
|
|
else:
|
|
if scratch_bytes < 1:
|
|
raise ValueError("positive scratch limit required")
|
|
argv += ["--size", str(scratch_bytes)]
|
|
if mode == "root-no-userns":
|
|
argv += ["--perms", "0777"]
|
|
argv += ["--tmpfs", "/scratch"]
|
|
for target, source in (mounts or {}).items():
|
|
argv += ["--ro-bind", str(source), target]
|
|
argv += [
|
|
"--remount-ro",
|
|
"/",
|
|
"--chdir",
|
|
"/scratch",
|
|
"--setenv",
|
|
"PATH",
|
|
"/usr/bin:/bin",
|
|
"--setenv",
|
|
"HOME",
|
|
"/scratch",
|
|
"--setenv",
|
|
"TMPDIR",
|
|
"/scratch",
|
|
]
|
|
for key, value in (env or {}).items():
|
|
argv += ["--setenv", key, value]
|
|
with tempfile.TemporaryFile() as policy, tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
policy.write(_filter(namespace_pid is not None))
|
|
policy.seek(0)
|
|
# Apply NPROC after namespace setup and any uid drop. In root mode the
|
|
# dedicated uid shares this process budget across concurrent sandboxes.
|
|
argv += [
|
|
"--seccomp",
|
|
str(policy.fileno()),
|
|
"--",
|
|
*(drop_privileges() if mode == "root-no-userns" else []),
|
|
"/usr/bin/prlimit",
|
|
f"--nproc={job.pids_limit}:{job.pids_limit}",
|
|
"--",
|
|
*job.command,
|
|
]
|
|
proc = subprocess.Popen(
|
|
argv,
|
|
stdout=out,
|
|
stderr=err,
|
|
pass_fds=(policy.fileno(),),
|
|
start_new_session=True,
|
|
preexec_fn=limits,
|
|
env={"PATH": "/usr/bin:/bin"},
|
|
)
|
|
timed_out = False
|
|
try:
|
|
proc.wait(timeout=job.timeout_seconds)
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
proc.wait()
|
|
finally:
|
|
# Also terminate descendants left behind after a successful shell exit.
|
|
with suppress(ProcessLookupError):
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
out.seek(0)
|
|
err.seek(0)
|
|
return SandboxResult(
|
|
proc.returncode, out.read(1024 * 1024).decode(errors="replace"), err.read(1024 * 1024).decode(errors="replace"), timed_out
|
|
)
|