From b1f077cde09e7c0df5522bebe8a0f82d4dbed344 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 22:52:33 +0000 Subject: [PATCH] Build the eBPF programs in a shape the kernel can load The three programs under pyisolate/bpf/ could not be loaded on any kernel, and the tests covering them asserted that certain strings appeared in the .bpf.c sources, which passes equally well for a program the verifier rejects. Three defects, each independently fatal to a load: * The compile command omitted -g, so the objects carried no BTF. All three programs use BTF-defined maps, which are described entirely by their BTF type, so libbpf cannot parse the .maps section of an object built without it; LSM programs additionally need BTF to resolve their attach target. * Every lsm/* handler declared typed parameters -- `int h(void *file, int ret)` -- without libbpf's BPF_PROG wrapper to unpack them. An LSM program is called with one argument, a pointer to the hook's argument array, so `ret` compiled to a read of r2, a register the caller never sets. Disassembly of the old lsm/file_open starts `r0 = r2`; the verifier rejects that with "R2 !read_ok". * resource_guard.bpf.c declared two `struct { int dummy; }` placeholders in .maps. Those are not map definitions libbpf can parse, and one is enough to reject the whole object. Handlers now take the context array and unpack it by index, with the offset of the prior LSM decision named per hook so it can be checked against lsm_hook_defs.h. socket_connect copies sa_family with bpf_probe_read_kernel rather than dereferencing a kernel pointer that is not BTF-typed. The placeholder maps and their two dead no-op programs are gone; per-cgroup CPU accounting was already done for real by account_sched_switch. The tests now compile the sources with the manager's own command and inspect the resulting ELF: BTF sections are present, every LSM hook emits its own program, each one enters with a load through r1, and no program entry reads an uninitialised argument register. A root-gated test runs the compiled filter past the real verifier. Loading and attaching on a live kernel is still only covered by the PYISOLATE_LIVE_BPF_TESTS=1 tests, which need root, bpftool and BPF-LSM; CI does not run them. This makes the objects loadable-shaped and regression-tested, not verified end-to-end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse --- CHANGELOG.md | 15 ++ pyisolate/bpf/manager.py | 48 ++--- pyisolate/bpf/resource_guard.bpf.c | 71 +------ pyisolate/bpf/syscall_filter.bpf.c | 97 +++++++--- tests/test_bpf_kernel_enforcement.py | 264 ++++++++++++++++++++++++--- tests/test_bpf_manager.py | 33 +--- 6 files changed, 356 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d642587..efc023a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ guarantees; **no release should be treated as a hardened security boundary**. - `scripts/benchmark.py` for reproducible spawn/round-trip measurements. - `pyisolate[operator]` optional-dependency group for the Kubernetes operator. +### Fixed +- eBPF programs are now built in a shape the kernel can load. They were + compiled without `-g`, so the objects carried no BTF and libbpf could not + parse their BTF-defined `.maps` sections; every `lsm/*` handler declared + typed parameters without libbpf's `BPF_PROG` wrapper, so it read `r2` -- + a register an LSM program's caller never sets -- which the verifier rejects; + and `resource_guard.bpf.c` declared two `struct { int dummy; }` placeholders + in `.maps`, which are not map definitions libbpf can parse. Handlers now + unpack the LSM context array by index, `socket_connect` copies `sa_family` + with `bpf_probe_read_kernel` rather than dereferencing an untyped kernel + pointer, and the placeholder maps and their dead no-op programs are gone. + ### Changed - Threat model and `SECURITY.md` reconciled with the real, backend-conditional boundary (the sub-interpreter backend is an execution cell, not a boundary @@ -30,6 +42,9 @@ guarantees; **no release should be treated as a hardened security boundary**. ### Known gaps - The broker `request` op is surfaced but not yet executed end-to-end. +- The eBPF programs compile to loadable objects and are covered by ELF-level + tests, but load/attach against a live verifier is still only exercised by + the root-gated `PYISOLATE_LIVE_BPF_TESTS=1` tests, not by CI. - Process-backed sandboxes are not attached to cgroups or watched by the resource watchdog (they get `rlimit` only). - `backend="microvm"` fails closed: the guest agent and vsock cell transport are diff --git a/pyisolate/bpf/manager.py b/pyisolate/bpf/manager.py index 19a9cff..54b7873 100644 --- a/pyisolate/bpf/manager.py +++ b/pyisolate/bpf/manager.py @@ -124,6 +124,21 @@ def _run(self, cmd: list[str], *, raise_on_error: bool = False) -> bool: ) from exc return False + #: ``-g`` is not optional. All three programs use BTF-defined maps (the + #: ``struct { ... } name SEC(".maps")`` idiom), which are described entirely + #: by their BTF type -- libbpf cannot parse the ``.maps`` section of an + #: object built without it -- and LSM programs additionally need BTF to + #: resolve their attach target. An object compiled without ``-g`` fails to + #: load on every kernel, so the flag belongs in one place rather than in + #: three copies of the command. + COMPILE_FLAGS: tuple[str, ...] = ("-target", "bpf", "-g", "-O2") + + @classmethod + def _compile_command(cls, source: Path, obj: Path) -> list[str]: + """Return the ``clang`` invocation that builds *source* into *obj*.""" + + return ["clang", *cls.COMPILE_FLAGS, "-c", str(source), "-o", str(obj)] + def load( self, *, @@ -152,36 +167,9 @@ def load( strict_mode = mode == "hardened" - dummy_compile = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(self._src), - "-o", - str(self._obj), - ] - filter_compile = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(self._filter_src), - "-o", - str(self._filter_obj), - ] - guard_compile = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(self._guard_src), - "-o", - str(self._guard_obj), - ] + dummy_compile = self._compile_command(self._src, self._obj) + filter_compile = self._compile_command(self._filter_src, self._filter_obj) + guard_compile = self._compile_command(self._guard_src, self._guard_obj) ok = True compile_cmd = dummy_compile if self._src not in self._skel_cache or ( diff --git a/pyisolate/bpf/resource_guard.bpf.c b/pyisolate/bpf/resource_guard.bpf.c index e834347..f5db54f 100644 --- a/pyisolate/bpf/resource_guard.bpf.c +++ b/pyisolate/bpf/resource_guard.bpf.c @@ -60,6 +60,11 @@ struct __sk_buff { __u32 len; }; +/* Resource guard events are consumed by pyisolate.watchdog.ResourceWatchdog. + * The supervisor resolves cgroup_id/name to a sandbox and performs the + * userspace kill/quarantine path immediately; Python tracemalloc accounting is + * diagnostic only and is not used as the security decision point. + */ struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 1 << 22); @@ -71,46 +76,6 @@ struct { __type(key, __u64); __type(value, struct resource_account); } cgroup_accounting SEC(".maps"); -/* Resource guard event consumed by pyisolate.watchdog.ResourceWatchdog. - * The supervisor resolves cgroup_id/name to a SandboxThread and performs the - * userspace kill/quarantine path immediately; Python tracemalloc accounting is - * diagnostic only and is not used as the security decision point. - */ -enum breach_reason { - BREACH_CPU = 1, - BREACH_RSS = 2, -}; - -struct quota_t { - unsigned long cpu_quota_ns; - unsigned long rss_quota_bytes; -}; - -struct usage_t { - unsigned long cpu_time_ns; - unsigned long rss_bytes; -}; - -struct event_t { - unsigned long cgroup_id; - unsigned long cpu_time_ns; - unsigned long rss_bytes; - unsigned int reason; -}; - -/* Map placeholders. The production CO-RE object uses BPF_MAP_TYPE_HASH for - * quota/usage keyed by cgroup id and BPF_MAP_TYPE_RINGBUF for events. Keeping - * the declarations header-free preserves the lightweight test build while - * documenting the kernel/userspace contract. - */ -struct { - int dummy; -} quotas SEC(".maps"); - -struct { - int dummy; -} usage SEC(".maps"); - struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 16384); @@ -200,32 +165,6 @@ int account_sched_switch(struct sched_switch_args *ctx) return 0; } -static __inline int emit_breach(unsigned long cgroup_id, - unsigned long cpu_time_ns, - unsigned long rss_bytes, - unsigned int reason) -{ - /* Real implementation reserves event_t on the ring buffer and submits it. - * Tests inject equivalent dictionaries through BPFManager.open_ring_buffer. - */ - (void)cgroup_id; - (void)cpu_time_ns; - (void)rss_bytes; - (void)reason; - return 0; -} - -SEC("perf_event") -int on_cpu(void *ctx) -{ - /* Production path increments per-cgroup CPU usage, compares it to - * quota_t.cpu_quota_ns, and emits BREACH_CPU before userspace can rely on - * guest cooperation. - */ - (void)ctx; - return emit_breach(0, 0, 0, BREACH_CPU); -} - SEC("tracepoint/exceptions/page_fault_user") int account_user_page_fault(struct page_fault_args *ctx) { diff --git a/pyisolate/bpf/syscall_filter.bpf.c b/pyisolate/bpf/syscall_filter.bpf.c index c37fc50..58fe236 100644 --- a/pyisolate/bpf/syscall_filter.bpf.c +++ b/pyisolate/bpf/syscall_filter.bpf.c @@ -7,9 +7,27 @@ * Every decision is keyed by bpf_get_current_cgroup_id(), so enforcement follows * the sandbox cgroup even when guest code bypasses Python wrappers and performs * syscalls directly through libc or native extensions. + * + * Calling convention + * ------------------ + * A BPF LSM program is called with ONE argument: a pointer to an array of + * u64 holding the hook's arguments, with the previous LSM's return value + * appended at index . libbpf's BPF_PROG macro hides this + * by generating a wrapper that unpacks the array into typed parameters; this + * file is built without libbpf headers, so each handler takes the context + * array directly and unpacks it by index. Declaring the typed parameters + * without that wrapper does NOT work: the second parameter reads r2, which an + * LSM program never sets, and the verifier rejects the program with + * "R2 !read_ok" before it can be attached. + * + * Kernel pointers in the context array are opaque here for the same reason. + * Direct loads through a kernel pointer are only allowed for BTF-typed + * pointers (i.e. with vmlinux.h), so fields are read with + * bpf_probe_read_kernel instead of being dereferenced. */ typedef unsigned char __u8; +typedef unsigned short __u16; typedef unsigned int __u32; typedef unsigned long long __u64; @@ -39,12 +57,18 @@ typedef unsigned long long __u64; #define __uint(name, val) int (*name)[val] #define __type(name, val) val *name -union bpf_attr; - -struct sockaddr { - unsigned short sa_family; - char sa_data[14]; -}; +/* Offset of the previous LSM decision within the context array, which equals + * the arity of the hook. Naming them keeps each handler's indexing checkable + * against include/linux/lsm_hook_defs.h. */ +#define PYI_RET_file_open 1 /* (struct file *file) */ +#define PYI_RET_file_truncate 1 /* (struct file *file) */ +#define PYI_RET_socket_create 4 /* (family, type, protocol, kern) */ +#define PYI_RET_socket_connect 3 /* (struct socket *, struct sockaddr *, int) */ +#define PYI_RET_task_alloc 2 /* (struct task_struct *, unsigned long) */ +#define PYI_RET_bprm_check_security 1 /* (struct linux_binprm *bprm) */ +#define PYI_RET_ptrace_access_check 2 /* (struct task_struct *child, unsigned int mode) */ +#define PYI_RET_sb_mount 5 /* (dev_name, path, type, flags, data) */ +#define PYI_RET_bpf 3 /* (int cmd, union bpf_attr *attr, unsigned int size) */ struct pyisolate_policy { __u32 deny_mask; @@ -88,6 +112,7 @@ static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1; static long (*bpf_ringbuf_output)(void *ringbuf, void *data, __u64 size, __u64 flags) = (void *)130; static __u64 (*bpf_get_current_cgroup_id)(void) = (void *)80; static __u64 (*bpf_get_current_pid_tgid)(void) = (void *)14; +static long (*bpf_probe_read_kernel)(void *dst, __u32 size, const void *src) = (void *)113; static __u32 policy_mask_for_op(__u32 op) { @@ -133,83 +158,109 @@ static int pyisolate_check(__u32 op, __u32 aux) } SEC("lsm/file_open") -int BPF_PROG_filter_file_open(void *file, int ret) +int filter_file_open(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_file_open]; + if (ret) return ret; return pyisolate_check(PYI_OP_FILE_OPEN, 0); } SEC("lsm/file_truncate") -int BPF_PROG_filter_file_truncate(void *file, int ret) +int filter_file_truncate(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_file_truncate]; + if (ret) return ret; return pyisolate_check(PYI_OP_FILE_TRUNCATE, 0); } SEC("lsm/socket_create") -int BPF_PROG_filter_socket_create(int family, int type, int protocol, int kern, int ret) +int filter_socket_create(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_socket_create]; + __u32 family = (__u32)ctx[0]; + if (ret) return ret; if (family == AF_INET || family == AF_INET6) - return pyisolate_check(PYI_OP_SOCKET_CREATE, (__u32)family); + return pyisolate_check(PYI_OP_SOCKET_CREATE, family); return 0; } SEC("lsm/socket_connect") -int BPF_PROG_filter_socket_connect(void *sock, struct sockaddr *address, int addrlen, int ret) +int filter_socket_connect(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_socket_connect]; + const void *address = (const void *)ctx[1]; + __u16 family = 0; + if (ret) return ret; - if (address && (address->sa_family == AF_INET || address->sa_family == AF_INET6)) - return pyisolate_check(PYI_OP_SOCKET_CONNECT, (__u32)address->sa_family); + if (!address) + return 0; + /* sa_family is the first field of struct sockaddr. The pointer is not + * BTF-typed here, so it has to be copied rather than dereferenced. */ + if (bpf_probe_read_kernel(&family, sizeof(family), address) != 0) + return 0; + if (family == AF_INET || family == AF_INET6) + return pyisolate_check(PYI_OP_SOCKET_CONNECT, family); return 0; } SEC("lsm/task_alloc") -int BPF_PROG_filter_task_alloc(void *task, unsigned long clone_flags, int ret) +int filter_task_alloc(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_task_alloc]; + if (ret) return ret; return pyisolate_check(PYI_OP_TASK_ALLOC, 0); } SEC("lsm/bprm_check_security") -int BPF_PROG_filter_exec(void *bprm, int ret) +int filter_exec(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_bprm_check_security]; + if (ret) return ret; return pyisolate_check(PYI_OP_EXEC, 0); } SEC("lsm/ptrace_access_check") -int BPF_PROG_filter_ptrace(void *child, unsigned int mode, int ret) +int filter_ptrace(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_ptrace_access_check]; + __u32 mode = (__u32)ctx[1]; + if (ret) return ret; return pyisolate_check(PYI_OP_PTRACE, mode); } SEC("lsm/sb_mount") -/* BPF programs receive at most five register arguments, so the opaque ``data`` - * blob of the sb_mount hook is omitted here; the filter only needs the prior - * LSM decision (``ret``) and denies all mounts regardless of arguments. */ -int BPF_PROG_filter_mount(const char *dev_name, const void *path, const char *type, - unsigned long flags, int ret) +int filter_mount(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_sb_mount]; + if (ret) return ret; + /* Denies all mounts regardless of arguments, so none are unpacked. */ return pyisolate_check(PYI_OP_MOUNT, 0); } SEC("lsm/bpf") -int BPF_PROG_filter_bpf(int cmd, union bpf_attr *attr, unsigned int size, int ret) +int filter_bpf(__u64 *ctx) { + int ret = (int)ctx[PYI_RET_bpf]; + __u32 cmd = (__u32)ctx[0]; + if (ret) return ret; - return pyisolate_check(PYI_OP_BPF, (__u32)cmd); + return pyisolate_check(PYI_OP_BPF, cmd); } char _license[] SEC("license") = "GPL"; diff --git a/tests/test_bpf_kernel_enforcement.py b/tests/test_bpf_kernel_enforcement.py index ef4c04b..113281a 100644 --- a/tests/test_bpf_kernel_enforcement.py +++ b/tests/test_bpf_kernel_enforcement.py @@ -1,4 +1,24 @@ +"""Kernel-enforcement tests for the eBPF programs. + +These used to assert that certain strings appeared in the ``.bpf.c`` sources, +which passes for a program the verifier would reject. The checks here compile +the real sources with the real command the manager uses and inspect the +resulting ELF, so the two defects that made these programs unloadable on every +kernel stay fixed: + +* an object built without ``-g`` carries no BTF, and BTF-defined maps are + described entirely by their BTF type, so libbpf cannot parse the ``.maps`` + section at all; +* an LSM program is called with one argument -- a pointer to the array of hook + arguments -- so a handler declared with typed parameters reads ``r2``, which + the caller never sets, and the verifier rejects it with ``R2 !read_ok``. + +Loading and attaching still needs a kernel with BPF-LSM plus root and +``bpftool``; that remains the env-gated test at the bottom. +""" + import os +import re import shutil import socket import subprocess @@ -9,38 +29,199 @@ from pyisolate.bpf.manager import BPFManager ROOT = Path(__file__).resolve().parents[1] -SYSCALL_FILTER = ROOT / "pyisolate" / "bpf" / "syscall_filter.bpf.c" -RESOURCE_GUARD = ROOT / "pyisolate" / "bpf" / "resource_guard.bpf.c" +BPF_DIR = ROOT / "pyisolate" / "bpf" +SYSCALL_FILTER = BPF_DIR / "syscall_filter.bpf.c" +RESOURCE_GUARD = BPF_DIR / "resource_guard.bpf.c" + +requires_clang = pytest.mark.skipif( + shutil.which("clang") is None, reason="clang is required to compile BPF objects" +) +requires_objdump = pytest.mark.skipif( + shutil.which("llvm-objdump") is None, reason="llvm-objdump is required" +) + +#: Every LSM hook the filter installs, and the program section it lives in. +LSM_SECTIONS = ( + "lsm/file_open", + "lsm/file_truncate", + "lsm/socket_create", + "lsm/socket_connect", + "lsm/task_alloc", + "lsm/bprm_check_security", + "lsm/ptrace_access_check", + "lsm/sb_mount", + "lsm/bpf", +) + + +def _compile(source: Path, tmp_path: Path) -> Path: + """Build *source* with the manager's own command and return the object.""" + obj = tmp_path / (source.stem + ".o") + subprocess.run( + BPFManager._compile_command(source, obj), check=True, capture_output=True + ) + return obj + + +def _sections(obj: Path) -> list[str]: + out = subprocess.run( + ["llvm-objdump", "-h", str(obj)], check=True, capture_output=True, text=True + ).stdout + return re.findall(r"^\s*\d+\s+(\S+)", out, re.MULTILINE) + + +# --- the compile command itself ------------------------------------------- + + +def test_compile_command_requests_btf(): + """Without -g there is no BTF, and without BTF nothing loads.""" + assert "-g" in BPFManager.COMPILE_FLAGS + cmd = BPFManager._compile_command(Path("in.bpf.c"), Path("out.o")) + assert cmd[0] == "clang" + assert "-g" in cmd + assert cmd[-4:] == ["-c", "in.bpf.c", "-o", "out.o"] + + +@requires_clang +@requires_objdump +@pytest.mark.parametrize("source", [SYSCALL_FILTER, RESOURCE_GUARD]) +def test_compiled_objects_carry_btf(source, tmp_path): + sections = _sections(_compile(source, tmp_path)) + assert ".BTF" in sections, f"{source.name} has no BTF; libbpf cannot load it" + assert ".BTF.ext" in sections + + +@requires_clang +@requires_objdump +def test_filter_emits_every_lsm_hook_as_its_own_program(tmp_path): + sections = _sections(_compile(SYSCALL_FILTER, tmp_path)) + for section in LSM_SECTIONS: + assert section in sections, f"missing program section {section}" + + +# --- the calling convention ----------------------------------------------- + + +def _disassemble(obj: Path, section: str | None = None) -> list[str]: + """Return the instruction text of *obj*, optionally for one section.""" + cmd = ["llvm-objdump", "-d", str(obj)] + if section is not None: + cmd.insert(2, f"--section={section}") + out = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + instructions = [] + for line in out.splitlines(): + if not re.match(r"^\s+[0-9a-f]+:", line): + continue + # " 0:\t79 10 08 ...\tr0 = *(u64 *)(r1 + 0x8)" -- text is the last field. + fields = line.split("\t") + if len(fields) >= 3: + instructions.append(fields[-1].strip()) + return instructions + + +@requires_clang +@requires_objdump +@pytest.mark.parametrize("section", LSM_SECTIONS) +def test_lsm_programs_read_arguments_from_the_context_pointer(section, tmp_path): + """Hook arguments must come from the ctx array, not from r2. + + A BPF LSM program is invoked with a single argument in ``r1``: the pointer + to the hook's argument array. Reading ``r2`` reads an uninitialised + register and the verifier refuses the program, so the first thing each + handler does has to be a load through ``r1``. + """ + instructions = _disassemble(_compile(SYSCALL_FILTER, tmp_path), section) + assert instructions, f"no instructions disassembled for {section}" + first = instructions[0] + assert re.fullmatch(r"r\d+ = \*\(u64 \*\)\(r1 \+ 0x[0-9a-f]+\)", first), ( + f"{section} starts with {first!r}; an LSM program must begin by loading " + "its arguments out of the context array in r1" + ) -def test_syscall_filter_uses_lsm_hooks_and_cgroup_policy_maps(): - src = SYSCALL_FILTER.read_text() +@requires_clang +@requires_objdump +@pytest.mark.parametrize("source", [SYSCALL_FILTER, RESOURCE_GUARD]) +def test_no_program_entry_reads_an_uninitialised_argument_register(source, tmp_path): + """The signature of the typed-parameter form this replaced. - assert 'SEC("lsm/file_open")' in src - assert 'SEC("lsm/socket_connect")' in src - assert 'SEC("lsm/socket_create")' in src - assert 'SEC("lsm/task_alloc")' in src - assert 'SEC("lsm/bprm_check_security")' in src - assert 'SEC("lsm/ptrace_access_check")' in src - assert 'SEC("lsm/sb_mount")' in src - assert 'SEC("lsm/bpf")' in src + Declaring ``int handler(void *file, int ret)`` compiles to ``r0 = r2`` at + entry. A BPF program is entered with only ``r1`` set, so a move out of + ``r2``..``r5`` as the first instruction is reading nothing. + + Only entry points are checked. Inside ``.text`` the same move is an + ordinary argument register for a real call, which is why a whole-object + scan would flag correct code. + """ + obj = _compile(source, tmp_path) + programs = [s for s in _sections(obj) if "/" in s] + assert programs, f"{source.name} defines no program sections" + for section in programs: + instructions = _disassemble(obj, section) + assert instructions, f"no instructions in {section}" + assert not re.fullmatch(r"r\d+ = r[2-5]", instructions[0]), ( + f"{source.name}:{section} enters with {instructions[0]!r}, which " + "reads a register the caller never set" + ) + + +def test_lsm_handlers_take_the_context_array(tmp_path): + """Source-level guard against regressing to typed parameters.""" + src = SYSCALL_FILTER.read_text(encoding="utf-8") + handlers = re.findall( + r'SEC\("lsm/[a-z_]+"\)\s*\n\s*int\s+(\w+)\(([^)]*)\)', src, re.MULTILINE + ) + assert len(handlers) == len(LSM_SECTIONS) + for name, params in handlers: + assert params.strip() == "__u64 *ctx", ( + f"{name} declares {params!r}; an LSM program receives only the " + "context array, so typed parameters read registers the caller " + "never set" + ) + + +def test_socket_connect_copies_the_sockaddr_instead_of_dereferencing_it(): + """A kernel pointer that is not BTF-typed cannot be loaded through.""" + src = SYSCALL_FILTER.read_text(encoding="utf-8") + assert "bpf_probe_read_kernel" in src + assert "address->sa_family" not in src + + +# --- map definitions ------------------------------------------------------ + + +def test_resource_guard_defines_only_real_maps(): + """`struct { int dummy; }` is not a map definition libbpf can parse. + + A BTF-defined map is described entirely by its BTF type. A placeholder + struct in the ``.maps`` section makes libbpf reject the whole object, so + the guard must not carry any. + """ + src = RESOURCE_GUARD.read_text(encoding="utf-8") + maps = re.findall(r"struct \{(.*?)\}\s*(\w+) SEC\(\"\.maps\"\)", src, re.DOTALL) + assert maps, "resource guard defines no maps" + for body, name in maps: + assert "__uint(type," in body, f"map {name} has no BPF_MAP_TYPE_*" + assert "int dummy" not in body, f"map {name} is a placeholder, not a map" + names = {name for _, name in maps} + assert {"resource_events", "cgroup_accounting", "cgroup_quotas"} <= names + + +def test_resource_guard_has_no_dead_placeholder_programs(): + src = RESOURCE_GUARD.read_text(encoding="utf-8") + assert "emit_breach" not in src + assert "Real implementation" not in src + + +def test_syscall_filter_keys_every_decision_on_the_cgroup(): + src = SYSCALL_FILTER.read_text(encoding="utf-8") assert "bpf_get_current_cgroup_id" in src assert "sandbox_policy" in src assert "syscall_policy" in src assert "return -EPERM" in src -def test_resource_guard_uses_ringbuf_and_per_cgroup_accounting_maps(): - src = RESOURCE_GUARD.read_text() - - assert "BPF_MAP_TYPE_RINGBUF" in src - assert "resource_events" in src - assert "cgroup_accounting" in src - assert "cgroup_quotas" in src - assert 'SEC("tracepoint/sched/sched_switch")' in src - assert 'SEC("tracepoint/exceptions/page_fault_user")' in src - assert 'SEC("cgroup_skb/egress")' in src - assert "emit_if_breached" in src +# --- loader wiring -------------------------------------------------------- def test_manager_loads_and_attaches_kernel_programs(monkeypatch): @@ -61,6 +242,14 @@ def record(self, cmd, *, raise_on_error=False): ) assert any(cmd[:3] == ["bpftool", "cgroup", "attach"] for cmd in calls) assert mgr.loaded is True + # Every clang invocation must ask for BTF, or the loadall above fails. + compiles = [cmd for cmd in calls if cmd and cmd[0] == "clang"] + assert compiles, "no programs were compiled" + for cmd in compiles: + assert "-g" in cmd, f"compile without BTF: {cmd}" + + +# --- live kernel ---------------------------------------------------------- @pytest.mark.skipif( @@ -105,3 +294,32 @@ def test_live_kernel_policy_blocks_unwrapped_file_network_and_process_actions(tm with pytest.raises(PermissionError): subprocess.run(["/bin/true"], check=True) + + +@pytest.mark.skipif( + os.environ.get("PYISOLATE_LIVE_BPF_TESTS") != "1" + or os.geteuid() != 0 + or shutil.which("bpftool") is None, + reason="verifier test requires root, bpftool, and PYISOLATE_LIVE_BPF_TESTS=1", +) +def test_verifier_accepts_the_filter_programs(tmp_path): + """The narrow check: does the kernel verifier accept what clang produced? + + Separate from the enforcement test above so a verifier rejection is + reported as a compile/codegen defect rather than as a policy failure. + """ + obj = _compile(SYSCALL_FILTER, tmp_path) + result = subprocess.run( + [ + "bpftool", + "prog", + "loadall", + str(obj), + str(tmp_path / "pinned"), + "type", + "lsm", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"verifier rejected the filter:\n{result.stderr}" diff --git a/tests/test_bpf_manager.py b/tests/test_bpf_manager.py index e79d28c..057ed2c 100644 --- a/tests/test_bpf_manager.py +++ b/tests/test_bpf_manager.py @@ -75,16 +75,7 @@ def fake_run(self, cmd, *, raise_on_error=False): mgr = BPFManager() mgr.load() - clang_dummy = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(mgr._src), - "-o", - str(mgr._obj), - ] + clang_dummy = BPFManager._compile_command(mgr._src, mgr._obj) assert clang_dummy in calls skel_cmd = [ @@ -94,26 +85,8 @@ def fake_run(self, cmd, *, raise_on_error=False): ] assert skel_cmd in calls - clang_filter = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(mgr._filter_src), - "-o", - str(mgr._filter_obj), - ] - clang_guard = [ - "clang", - "-target", - "bpf", - "-O2", - "-c", - str(mgr._guard_src), - "-o", - str(mgr._guard_obj), - ] + clang_filter = BPFManager._compile_command(mgr._filter_src, mgr._filter_obj) + clang_guard = BPFManager._compile_command(mgr._guard_src, mgr._guard_obj) assert clang_dummy in calls assert clang_filter in calls assert clang_guard in calls