diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab2d57..b326869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,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 - CI covers CPython 3.14: the unit matrix gains `3.14`, and a new `sub-interpreter cells / py3.14t` job runs the sub-interpreter backend on a @@ -48,6 +60,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. - A running sub-interpreter cell cannot be reclaimed: one that overruns its wall-time deadline is abandoned, and its thread stays pinned until the process exits. Cells enforce a wall-time deadline and no other quota; 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