Repository navigation
feat(sandboxpolicy): enforce the SandboxPolicy file section with a BPF LSM gadget - #1075
Open
AlonLiwsky wants to merge 147 commits into
Open
AlonLiwsky wants to merge 147 commits into
AlonLiwsky wants to merge 147 commits into
Conversation
…ent library pkg/ebpf/gadgets/common is a small library for BPF LSM enforcement gadgets: the bindings map (cgroup -> policy slot), policy rank resolution, verdict base fields and counters, the arming rule, and the Go binding controller of one node-wide instance. It is taken from the enforce_exec simple engine of the pinned Inspektor Gadget fork. pkg/ebpf/gadgets/enforce_privilege enforces the SandboxPolicy privilege section with BPF LSM, no seccomp: namespaces, mounts, pivot_root/chroot, capabilities, ptrace, modules, bpf, perf, keys, io_uring, userfaultfd, mknod, setuid/setgid and capset. Its compiler merges several policies into one slot per combination. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…cy controller - CompiledPolicy carries the privilege section; the compiler accepts it - SectionBackend splits each plan per section; plans without a privilege section go to the exec backend unchanged - PrivilegeBackend owns one enforce_privilege instance per node: live bind/unbind; a new policy combination replaces the instance with overlap - slots are created when a matching pod is scheduled to the node - sandboxPolicyPrivilegeImage enables the section; empty rejects it Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
- each namespace flag of a call resolves on its own (bpf_loop), so an allow for one flag cannot carry another flag past a default deny - compat ptrace read-mode access is not judged - failed arming marks are counted; a deep tree arms instead of matching a wrong namespace - transient arming failures are retried and reported - a binding whose rollback failed stays tracked for Unbind - reject NUL in mountFsType and duplicate slot generation entries - the gadget build loop stops on the first failure Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
- each namespace flag of a call resolves on its own (bpf_loop), so an allow for one flag cannot carry another flag past a default deny - compat ptrace read-mode access is not judged - failed arming marks are counted; a deep tree arms instead of matching a wrong namespace - transient arming failures are retried and reported - a binding whose rollback failed stays tracked for Unbind - reject NUL in mountFsType and duplicate slot generation entries - the gadget build loop stops on the first failure Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
The binding cgroup path comes from the container collection, which reads /proc/<pid>/cgroup in the cgroup namespace of PID 1. node-agent runs in its own cgroup namespace, where the same file shows a /../.. path, so every lease check failed with "process cgroup changed" and no container bound. Check now reads the file in PID 1's cgroup namespace when it differs. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…efix Gadget eBPF parameters are named operator.oci.ebpf.<key>. The engine set operator.ebpf.host-mntns-id, which IG ignored, so host_mntns_id stayed 0 and the program used the global init task's mount namespace. In kind the runtime runs in the node container's mount namespace, so runc init for kubectl exec was armed and its setns(CLONE_NEWNS) was denied: every exec into a bound pod failed. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…group Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…group Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
- keep an unready candidate that failed to stop for a stop retry - prepare slots for ephemeral containers too - list the node's pods for slot preparation only after a Pending-pod event or every 30 s, not on every refresh Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
- the gadget has its own policy document with Parse and an example file; the SandboxPolicy translation moves to the controller connection (kubescape#1068) - neutral names in the gadget, BPF, gadget.yaml and README - a guard test fails on imports outside Inspektor Gadget and the gadget folders, and on host-product names or outside includes - an opt-in test runs the gadget with Inspektor Gadget alone Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
- the gadget has its own policy document with Parse and an example file; the SandboxPolicy translation moves to the controller connection (kubescape#1068) - neutral names in the gadget, BPF, gadget.yaml and README - a guard test fails on imports outside Inspektor Gadget and the gadget folders, and on host-product names or outside includes - an opt-in test runs the gadget with Inspektor Gadget alone Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…n the controller layer PrivilegeDocument (pkg/sandboxpolicy/privilege.go) replaces the gadget former FromAPI, so the gadget no longer imports the CRD types. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…active Without "bpf" in the host's active LSM list, BPF LSM programs attach but never run (for example default AKS Ubuntu and Azure Linux images), so a policy could report Ready with no enforcement. The service now reads <host>/sys/kernel/security/lsm at startup; if bpf is missing it creates no privilege backend, privilege bindings are refused, and every policy with a privilege section reports Ready=False with reason BPFLSMInactive. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…path Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…path Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…sted upward includes Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…sted upward includes Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…an enforcing slot When an armed task forks and the child's task storage mark cannot be allocated, the child escaped every rule while the instance stayed ready. Now an enforcing slot (any enforce entry) refuses the fork with -ENOMEM (a clone into another cgroup too), and counts a tracking fault per slot in arming_faults. A lost mark at exec, which cannot be refused, is counted the same way. Audit-only slots and unbound cgroups allow the fork. A new thread of an armed group leader is covered through the leader and is never refused. Moving a container to another slot arms its running tasks again. The binding controller reads the count with SlotFaults. A test-only parameter injects the failure for the new kernel test. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…an enforcing slot When an armed task forks and the child's task storage mark cannot be allocated, the child escaped every rule while the instance stayed ready. Now an enforcing slot (any enforce entry) refuses the fork with -ENOMEM (a clone into another cgroup too), and counts a tracking fault per slot in arming_faults. A lost mark at exec, which cannot be refused, is counted the same way. Audit-only slots and unbound cgroups allow the fork. A new thread of an armed group leader is covered through the leader and is never refused. Moving a container to another slot arms its running tasks again. The binding controller reads the count with SlotFaults. A test-only parameter injects the failure for the new kernel test. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…/privilege-gadget Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…roller Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
kubescape#1054 now keeps class errors per class name, and a policy fails only on the errors of the shared classes its exec rules use. Pod plans follow the same rule, so a class error in an unrelated policy no longer blocks a pod binding, and the controller builds again with the new base. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com> # Conflicts: # pkg/sandboxpolicy/controller.go
The per-class errors remove a broken or duplicate shared path class; the file section then treats it as unresolved and degrades the rule (test). Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…ntainer A parent cgroup bound before its containers start (a pod cgroup) also covered the OCI createContainer and startContainer hooks: runc init forks them inside the container mount namespace, so they were armed and judged as workload. A strict policy then stopped the container from starting (for example kind's own createContainer hook, a bind mount). A task is no longer armed at exec when its parent is unarmed, outside the runtime mount namespace, and in the same mount namespace and cgroup as the task: it was forked by a runtime helper inside the container. The workload and exec commands are execed by runc init itself, whose parent is in the runtime mount namespace, so they stay armed, and their children inherit the mark as before. An unknown runtime namespace trusts no parent. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…roller Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com> # Conflicts: # pkg/ebpf/gadgets/enforce_privilege/README.md
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
… of Linux 6.15 Linux 6.15 added `bool kernel` to the bpf hook and dropped the attr argument of perf_event_open, so the previous-verdict check refused every 6.15+ kernel (and the old perf_event_open program would not load there). Both programs now read `ret` at the position of the running kernel: a CO-RE type match against security_list_options picks the branch at load. The BTF check returns a result per hook (LSMHookArgMismatches) and accepts both signatures for these two hooks. The other 21 hooks have the same argument count from 6.1 to 6.18 (checked in lsm_hook_defs.h). Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…roller Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
clang folded the CLONE_INTO_CGROUP check into (flags << 30 s>> 63) & -ENOMEM. The Linux 6.17 verifier (Ubuntu 6.17.0-40) loses the range of that AND and rejects ig_file_task_alloc: R0 not in [-4095, 0]. Use the same volatile result as the match path. Verified: loads on 6.8, 6.12 and 6.17; the kernel test passes on 6.17 at cgroup levels 2 and 4. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
setns into a namespace checks CAP_SYS_ADMIN twice (the namespace user namespace and the caller's). Audit lets the first check pass, so one audited setns gives two identical verdicts; enforce stops at the first. A per-syscall dedupe is not reliable in an LSM hook (there is no syscall instance id, and a time window would merge real calls), so the README documents it and a kernel test pins it down. The test fixture now records the fields of each verdict. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…roller Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…/privilege-gadget Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
…roller Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
The kubescape#1054 base moved to storage v0.0.388: PrivilegeTarget has no path, fromImage or fsType fields any more, and Rule.Origin is a pointer. The translator follows: it rejects actor and pathClass as before, now also rejects except (the gadget has no exceptions, and they were ignored before), and maps a missing origin and an empty one to no origin. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
… storage types kubescape/storage now has the network fields of the SandboxPolicy design (sandbox-policy-crd.md section 6.1): NetworkSection.DefaultMode, NetworkSection.DefaultOrigin and NetworkRule.Except. Use them and remove the local NetworkExtras mirror and the pre-decode split of the raw spec. - TranslateNetwork and validateNetwork take the spec only. - An except entry is validated with NetworkException.Validate, so an entry with an empty actor and no target is rejected, as in storage. An except list on an allow rule is rejected also when it is empty. - defaultOrigin is still not carried: the gadget default has no origin. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
The new storage types carry the file defaults by access, their modes and origins, and the except list of deny rules, so the file local mirror (FileExtras, FileAccessValues, FileExcept, splitFileExtras) is removed and every field is read from api.FileSection. Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
Signed-off-by: Alon Liwsky <40373481+AlonLiwsky@users.noreply.github.com>
This was referenced Nov 5, 2026
Draft
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This enforces the
filesection of SandboxPolicy with a new portable BPF LSM gadget, built the same way as the privilege and network gadgets: one node-wide instance, a cgroup → policy-slot bindings map, and the policy as frozen map data. Binding changes apply live (no pod restart); a policy change replaces the instance with overlap.It is stacked on #1073 (network gadget, which contains #1067 and #1068). GitHub can't use a fork branch as the base, so please review only the commits that are not in #1073 (
git log AlonLiwsky/feat/sandbox-policy-network-gadget..; the merge commits only bring #1073 up to date), and merge after #1073.Production code is about 3,800 lines: 2,800 in the gadget (BPF program and header, compiler, runtime), 1,000 in node-agent. Tests are about 3,600 lines.
1. The gadget —
pkg/ebpf/gadgets/enforce_file/(portable, no node-agent or Kubernetes concepts)Hooks per access kind:
lsm/file_open(byf_mode; O_TRUNC is write),path_truncatepath_mknod(coversopen(O_CREAT), openat2, io_uring),path_mkdir,path_symlink,path_linkpath_unlink,path_rmdirpath_rename(both names)path_chmod,path_chown, andinode_setxattr/inode_set_aclfor file capabilities and POSIX ACLs (setcap,setfacl). These two hooks have no mount, so they are judged only on the task's root filesystem; elsewhere they fail closed.path_mknod, not atfile_open. A deny atfile_openwould leave an empty file.Paths:
bpf_d_pathwhere the kernel allows it.path_mknoduses a bounded dentry walk, becausebpf_d_pathis refused there andbpf_path_d_pathdoes not exist on 6.8. No kfunc is needed, so 6.8 and 6.12 run the same program.Matching: a component-level shift-and NFA. There is one hash lookup per path component, against frozen per-slot bit masks. Globs: literal,
*(one component),**(any depth), and one*inside a component (cron*,*.sock,rc*.d). That covers every default path class in the CRD design. The hash is FNV-1a with a random seed per instance. A Go simulator runs the same step function, and a C harness checks that the real header agrees with it.Moves: a link or rename source (and both names of
RENAME_EXCHANGE) is judged for rename, read and write with the full decision. A new name is judged as create and write (or write and delete when it replaces a file). Renaming a parent directory of a protected path is denied at both ends. Soln /root/.aws/credentials /tmp/x,mv /root/.aws /tmpandmv /tmp/dir /root/.awsdon't get around a rule.fromImage: an overlay file with a lower-layer origin (ovl_entryhas a lower stack). A copy-up by write, chmod, touch or setxattr does not change it. The runtime checks the privateovl_inodelayout in the kernel BTF; if it differs,fromImagerules are reported as not enforced instead of reading a wrong field.Overlay: each overlay open calls
file_opentwice. The second call is the backing file on an internal mount. The gadget skips it, because a deny there breaks copy-up.Runtime work: the shared arming rule (
common/arming.h) keeps runc init,kubectl execsetup and probes out until the workload's exec. Without it, a write default deny breaksdocker execand healthchecks (oom_score_adj).Fail closed: a path the gadget can't resolve (too long, walk too deep, a task whose root is not its mount root after a
chroot) gets the most limiting verdict of its access kind (unresolved-path).Merge of several policies follows
common/merge/CONTRACT.mdper access kind and passes its shared vectors.Cost for unbound containers:
file_openis a hot path on every container of the node. The gadget checks the internal mount first and reads the cgroup level with plain loads (BINDINGS_DIRECT_LEVEL, opt-in incommon/bindings.h; network and privilege don't change). Program time per open (bpf_stats), measured in the lab on arm64:On 6.8 the first program on
file_openalso adds about 250 ns of trampoline cost to every open on the node, when no other LSM program uses that hook.2. The node-agent layer —
pkg/sandboxpolicy/fileto a new backend on the generic node owner.compiler.gono longer rejects thefilesection;identitystays rejected (separate ticket).file.go): per-access defaults (defaultByAccess, or the old object form ofdefault), their modes and origins, rules withaccess,path,pathClass,fromImage, andexcepton deny rules./var/run/...pattern also gets/run/...; a pattern that ends in a file name also matches the kubelet atomic-writer formdir/..*/file(Secret, ConfigMap and projected volumes); and deny rules on/bin,/sbin,/lib,/lib64also cover/usr/...(merged-usr images). Without this, the service-account token in credentialStore never matches./dev/null,/dev/zero,/dev/tty,/dev/pts/**, ...) and/dev/shm/**. Identical allow rules from several policies merge, so they don't count twice against the slot limit.FileNotEnforced):execMap,fsType,actoron file rules, patterns the gadget can't express (?,[...], two*in one component).host-mntns-id), like privilege. Without it, runc init is judged on kind andkubectl execfails.sandboxPolicyFileImage(default empty: the section reportsFileNotConfigured).exceptsplitting of the local mirror moved to a shared helper, used by network and file.How to Test
go test ./pkg/ebpf/gadgets/... ./pkg/sandboxpolicy/...go test -tags file_kernel ./pkg/ebpf/gadgets/enforce_file/runtime/(see the README for the env vars). I ran it on 6.8 (Ubuntu 24.04) and 6.12 (Debian 13), arm64, at cgroup depth 2 and 4. It covers every access kind, the move guard at both ends,setfacl/setcap,fromImageafter copy-up, chroot, audit,docker execand healthchecks under a write default deny, and binding changes on a running task (bind after it opened files, moves to child cgroups and out, unbind, rebind).fromImage, audit wouldDeny, unbind restoring access in about 3 s,kubectl execand exec probes, ClassUnresolved and FileNotEnforced, two policies merged, an except entry, a Secret-volume file matched by**/.docker/config.json,setfacl/setcapunder a chmod default deny, and one object with bothnetworkandfilesections.Known limits
chrootinto a mount root (for example a volume) is not detected either..claude/) is not guarded.open(O_CREAT)of an existing file that is not in the dentry cache can log a false createwouldDeny. The kernel drops that error, so the open works and the enforce verdict is correct.setcap,setfaclandcp -awith ACLs on a volume file (not the root filesystem) fail when the policy has any chmod deny. Removing an ACL is not judged. io_uring with registered credentials on a volume file is not judged for these two hooks.cap_inode_setxattrwhenever BPF LSM is active, so an unprivileged task can setsecurity.*xattrs. I reproduced it on Ubuntu 6.8 with no BPF program loaded. Ubuntu 6.8 also refuses an LSM program that returns 1, so a program can't restore the check there. Where a load probe shows that the old convention works, the gadget returns the hook default when it allows./liband/usr/libare separate directories (for example Alpine), a deny rule on/lib/**also covers/usr/lib/**, because of the merged-usr alias.items[].path: a/b/file) is not covered by the atomic-writer alias.metacopy=on(not the containerd default) a data-only lower file is not tested.Open questions
defaultByAccess,defaultModeByAccess,defaultOriginByAccessandexcept, and the translator reads them from the storage types (the local mirror is gone). Tested in kind with the unpatched CRDs.execMap: not built (mmap_file/file_mprotect). The design marks it default-off because it breaks JITs. Is it needed in this ticket?TestFileDuplicateSharedClassDegrades).Tracking: SUB-9583.