diff --git a/fact-ebpf/src/bpf/bound_path.h b/fact-ebpf/src/bpf/bound_path.h index 7a2091f9..02ad07e1 100644 --- a/fact-ebpf/src/bpf/bound_path.h +++ b/fact-ebpf/src/bpf/bound_path.h @@ -38,6 +38,29 @@ __always_inline static struct bound_path_t* _path_read(struct path* path, bound_ return bound_path; } +/** + * Read a filesystem-relative path from a bare dentry. + * + * This is for hooks that only provide a struct dentry without a + * struct path (e.g. inode_set_acl). The resulting path can be used + * for LPM trie matching and inode monitoring checks. + */ +__always_inline static struct bound_path_t* dentry_read(struct dentry* dentry) { + struct bound_path_t* bound_path = get_bound_path(BOUND_PATH_MAIN); + if (bound_path == NULL) { + return NULL; + } + + bound_path->len = __d_path_from_dentry(dentry, bound_path->path, PATH_MAX); + if (bound_path->len <= 0) { + return NULL; + } + + bound_path->len = PATH_LEN_CLAMP(bound_path->len); + + return bound_path; +} + __always_inline static struct bound_path_t* path_read_unchecked(struct path* path) { return _path_read(path, BOUND_PATH_MAIN, true); } diff --git a/fact-ebpf/src/bpf/checks.c b/fact-ebpf/src/bpf/checks.c index 1b3623a2..3e8bb177 100644 --- a/fact-ebpf/src/bpf/checks.c +++ b/fact-ebpf/src/bpf/checks.c @@ -13,3 +13,9 @@ int BPF_PROG(check_path_unlink_supports_bpf_d_path, struct path* dir, struct den bpf_printk("dir: %s", p->path); return 0; } + +SEC("lsm/inode_set_acl") +int BPF_PROG(check_inode_set_acl, struct mnt_idmap* idmap, struct dentry* dentry, const char* acl_name, + struct posix_acl* kacl) { + return 0; +} diff --git a/fact-ebpf/src/bpf/d_path.h b/fact-ebpf/src/bpf/d_path.h index a922600e..fd1f0ef4 100644 --- a/fact-ebpf/src/bpf/d_path.h +++ b/fact-ebpf/src/bpf/d_path.h @@ -24,6 +24,14 @@ */ #define PATH_LEN_CLAMP(len) ((len) & PATH_MAX_MASK) +// Context for __d_path_inner. +// +// Supports two modes: +// Full path mode: mnt and root must both be set. Crosses mount +// boundaries and terminates at the process root. +// Dentry-only mode: mnt and root must both be NULL. Walks the +// dentry chain to the filesystem root, producing a +// filesystem-relative path. struct d_path_ctx { struct helper_t* helper; struct path* root; @@ -38,39 +46,49 @@ static long __d_path_inner(uint32_t index, void* _ctx) { struct d_path_ctx* ctx = (struct d_path_ctx*)_ctx; struct dentry* dentry = ctx->dentry; struct dentry* parent = BPF_CORE_READ(dentry, d_parent); - struct mount* mnt = ctx->mnt; - struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root); - if (dentry == ctx->root->dentry && &mnt->mnt == ctx->root->mnt) { - // Found the root of the process, we are done - ctx->success = true; - return 1; - } + if (ctx->mnt != NULL) { + // Full path mode: we have mount context and can cross mount + // boundaries and detect the process root. + struct mount* mnt = ctx->mnt; + struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root); - if (dentry == mnt_root) { - struct mount* m = BPF_CORE_READ(mnt, mnt_parent); - if (m != mnt) { - // Current dentry is a mount root different to the previous one we - // had (to prevent looping), switch over to that mount position - // and keep walking up the path. - ctx->dentry = BPF_CORE_READ(mnt, mnt_mountpoint); - ctx->mnt = m; - return 0; + if (dentry == ctx->root->dentry && &mnt->mnt == ctx->root->mnt) { + // Found the root of the process, we are done + ctx->success = true; + return 1; } - // Ended up in a global root, the path might need re-processing or - // the root is not attached yet, we are not getting a better path, - // so we assume we are correct and stop iterating. - ctx->success = true; - return 1; + if (dentry == mnt_root) { + struct mount* m = BPF_CORE_READ(mnt, mnt_parent); + if (m != mnt) { + // Current dentry is a mount root different to the previous one we + // had (to prevent looping), switch over to that mount position + // and keep walking up the path. + ctx->dentry = BPF_CORE_READ(mnt, mnt_mountpoint); + ctx->mnt = m; + return 0; + } + + // Ended up in a global root, the path might need re-processing or + // the root is not attached yet, we are not getting a better path, + // so we assume we are correct and stop iterating. + ctx->success = true; + return 1; + } } if (dentry == parent) { - // We escaped the mounts and ended up at (most likely) the root of - // the device, the path we formed will be wrong. + // Reached the root of the filesystem's dentry tree. + // + // In full path mode (mnt != NULL) this means we escaped the mounts + // and the path may be wrong due to a race condition. // - // This may happen in race conditions where some dentries go away - // while we are iterating. + // In dentry-only mode (mnt == NULL) this is the expected + // termination: we've reached the filesystem root and have a + // filesystem-relative path. This is correct for overlayfs + // (containers) and for files on the root filesystem. + ctx->success = (ctx->mnt == NULL); return 1; } @@ -140,6 +158,46 @@ __always_inline static long __d_path(const struct path* path, char* buf, int buf return buflen - ctx.offset; } +/** + * Resolve a filesystem-relative path from a bare dentry. + * + * This is used when no struct path is available (e.g. inode_set_acl). + * It walks the dentry chain up to the filesystem root, producing a + * path relative to the filesystem's root dentry. This is correct for + * overlayfs (containers) and for files on the root filesystem. It + * cannot cross mount boundaries, so paths on nested host mounts (e.g. + * a separate /var partition) will be relative to that mount's root. + */ +__always_inline static long __d_path_from_dentry(struct dentry* dentry, char* buf, int buflen) { + if (buflen <= 0) { + return -1; + } + + int offset = PATH_LEN_CLAMP(buflen - 1); + struct d_path_ctx ctx = { + .buflen = buflen, + .helper = get_helper(), + .offset = offset, + .mnt = NULL, + .root = NULL, + }; + + if (ctx.helper == NULL) { + return -1; + } + + ctx.helper->buf[offset] = '\0'; + ctx.dentry = dentry; + + long res = bpf_loop(PATH_MAX, __d_path_inner, &ctx, 0); + if (res <= 0 || !ctx.success) { + return -1; + } + + bpf_probe_read_str(buf, buflen, &ctx.helper->buf[PATH_LEN_CLAMP(ctx.offset)]); + return buflen - ctx.offset; +} + __always_inline static long d_path(struct path* path, char* buf, int buflen, bool use_bpf_helper) { if (use_bpf_helper) { return bpf_d_path(path, buf, buflen); diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index ac1d1bd8..bb3dd877 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -144,3 +144,45 @@ __always_inline static void submit_rmdir_event(struct submit_event_args_t* args) __submit_event(args, path_hooks_support_bpf_d_path); } + +__always_inline static void submit_acl_event(struct submit_event_args_t* args, + const char* acl_name, + struct posix_acl* kacl) { + if (!reserve_event(args)) { + return; + } + + args->event->type = FILE_ACTIVITY_ACL_SET; + + // Determine ACL type from the xattr name. + // "system.posix_acl_access" vs "system.posix_acl_default" + char name_buf[32] = {0}; + long name_len = bpf_probe_read_kernel_str(name_buf, sizeof(name_buf), acl_name); + if (name_len == 25 && __builtin_memcmp(name_buf, "system.posix_acl_default", 24) == 0) { + args->event->acl.acl_type = FACT_ACL_TYPE_DEFAULT; + } else { + args->event->acl.acl_type = FACT_ACL_TYPE_ACCESS; + } + + if (kacl == NULL) { + args->event->acl.count = 0; + } else { + unsigned int count = 0; + bpf_probe_read_kernel(&count, sizeof(count), &kacl->a_count); + if (count > FACT_MAX_ACL_ENTRIES) { + count = FACT_MAX_ACL_ENTRIES; + } + args->event->acl.count = count; + + for (unsigned int i = 0; i < FACT_MAX_ACL_ENTRIES && i < count; i++) { + struct posix_acl_entry entry = {0}; + bpf_probe_read_kernel(&entry, sizeof(entry), &kacl->a_entries[i]); + args->event->acl.entries[i].e_tag = entry.e_tag; + args->event->acl.entries[i].e_perm = entry.e_perm; + args->event->acl.entries[i].e_id = entry.e_uid.val; + } + } + + // inode_set_acl does not support bpf_d_path (no struct path available) + __submit_event(args, false); +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index eb2033e8..c6953e54 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -389,6 +389,38 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { return 0; } +SEC("lsm/inode_set_acl") +int BPF_PROG(trace_inode_set_acl, struct mnt_idmap* idmap, struct dentry* dentry, + const char* acl_name, struct posix_acl* kacl) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + struct submit_event_args_t args = {.metrics = &m->inode_set_acl}; + + args.metrics->total++; + + struct bound_path_t* bound_path = dentry_read(dentry); + if (bound_path == NULL) { + bpf_printk("Failed to read path from dentry"); + args.metrics->error++; + return 0; + } + args.filename = bound_path->path; + + struct inode* inode_ptr = BPF_CORE_READ(dentry, d_inode); + args.inode = inode_to_key(inode_ptr); + args.monitored = is_monitored(&args.inode, bound_path, NULL); + + if (args.monitored == NOT_MONITORED) { + args.metrics->ignored++; + return 0; + } + + submit_acl_event(&args, acl_name, kacl); + return 0; +} + SEC("lsm/path_rmdir") int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) { struct metrics_t* m = get_metrics(); diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 2f11c0db..2687bfc7 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -54,6 +54,18 @@ typedef enum monitored_t { // For the time being we just keep a char. typedef char inode_value_t; +#define FACT_MAX_ACL_ENTRIES 32 + +// ACL type constants matching the xattr names +#define FACT_ACL_TYPE_ACCESS 0 +#define FACT_ACL_TYPE_DEFAULT 1 + +struct acl_entry_t { + short e_tag; + unsigned short e_perm; + unsigned int e_id; +}; + typedef enum file_activity_type_t { FILE_ACTIVITY_INIT = -1, FILE_ACTIVITY_OPEN = 0, @@ -64,6 +76,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_RENAME, DIR_ACTIVITY_CREATION, DIR_ACTIVITY_UNLINK, + FILE_ACTIVITY_ACL_SET, } file_activity_type_t; struct event_t { @@ -90,6 +103,11 @@ struct event_t { inode_key_t inode; monitored_t monitored; } rename; + struct { + unsigned int count; + unsigned int acl_type; + struct acl_entry_t entries[FACT_MAX_ACL_ENTRIES]; + } acl; }; }; @@ -132,4 +150,5 @@ struct metrics_t { struct metrics_by_hook_t path_mkdir; struct metrics_by_hook_t d_instantiate; struct metrics_by_hook_t path_rmdir; + struct metrics_by_hook_t inode_set_acl; }; diff --git a/fact-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index 8b66d92b..7a66875e 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -155,6 +155,7 @@ impl_metrics_t!( path_mkdir, path_rmdir, d_instantiate, + inode_set_acl, ); unsafe impl Pod for metrics_t {} diff --git a/fact/src/bpf/checks.rs b/fact/src/bpf/checks.rs index 1686fdfc..d0c88428 100644 --- a/fact/src/bpf/checks.rs +++ b/fact/src/bpf/checks.rs @@ -4,6 +4,7 @@ use log::debug; pub(super) struct Checks { pub(super) path_hooks_support_bpf_d_path: bool, + pub(super) supports_inode_set_acl: bool, } impl Checks { @@ -12,15 +13,31 @@ impl Checks { .load(fact_ebpf::CHECKS_OBJ) .context("Failed to load checks.o")?; - let prog = obj - .program_mut("check_path_unlink_supports_bpf_d_path") - .context("Failed to find 'check_path_unlink_supports_bpf_d_path' program")?; - let prog: &mut Lsm = prog.try_into()?; - let path_hooks_support_bpf_d_path = prog.load("path_unlink", btf).is_ok(); - debug!("path_unlink_supports_bpf_d_path: {path_hooks_support_bpf_d_path}"); + let path_hooks_support_bpf_d_path = Self::probe_hook( + &mut obj, + "check_path_unlink_supports_bpf_d_path", + "path_unlink", + btf, + ); + debug!("path_hooks_support_bpf_d_path: {path_hooks_support_bpf_d_path}"); + + let supports_inode_set_acl = + Self::probe_hook(&mut obj, "check_inode_set_acl", "inode_set_acl", btf); + debug!("supports_inode_set_acl: {supports_inode_set_acl}"); Ok(Checks { path_hooks_support_bpf_d_path, + supports_inode_set_acl, }) } + + fn probe_hook(obj: &mut aya::Ebpf, prog_name: &str, hook: &str, btf: &Btf) -> bool { + let Some(prog) = obj.program_mut(prog_name) else { + return false; + }; + let Ok(prog): Result<&mut Lsm, _> = prog.try_into() else { + return false; + }; + prog.load(hook, btf).is_ok() + } } diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index 917eae3a..8f387284 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -26,6 +26,7 @@ const RINGBUFFER_NAME: &str = "rb"; pub struct Bpf { obj: Ebpf, + checks: Checks, tx: mpsc::Sender, @@ -64,6 +65,7 @@ impl Bpf { let paths = Vec::new(); let mut bpf = Bpf { obj, + checks, tx, paths, paths_config, @@ -178,28 +180,38 @@ impl Bpf { let Some(hook) = name.strip_prefix("trace_") else { bail!("Invalid hook name: {name}"); }; + + // Skip hooks that the kernel doesn't support + if hook == "inode_set_acl" && !self.checks.supports_inode_set_acl { + info!("Skipping {hook}: not supported on this kernel"); + continue; + } + match prog { Program::Lsm(prog) => prog.load(hook, btf)?, u => unimplemented!("{u:?}"), - } + }; } Ok(()) } - /// Attaches all BPF programs. If any attach fails, all previously - /// attached programs are automatically detached via drop. + /// Attaches all loaded BPF programs. Programs that were not loaded + /// (e.g. optional hooks on unsupported kernels) are skipped. + /// If any attach fails, all previously attached programs are + /// automatically detached via drop. fn attach_progs(&mut self) -> anyhow::Result<()> { - self.links = self - .obj - .programs_mut() - .map(|(_, prog)| match prog { + for (_, prog) in self.obj.programs_mut() { + match prog { Program::Lsm(prog) => { + if prog.fd().is_err() { + continue; + } let link_id = prog.attach()?; - prog.take_link(link_id) + self.links.push(prog.take_link(link_id)?); } u => unimplemented!("{u:?}"), - }) - .collect::>()?; + } + } Ok(()) } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 7944ada3..d3e267a4 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -162,6 +162,7 @@ impl Event { FileData::Chmod(data) => &data.inner.inode, FileData::Chown(data) => &data.inner.inode, FileData::Rename(data) => &data.new.inode, + FileData::AclSet(data) => &data.inner.inode, } } @@ -176,6 +177,7 @@ impl Event { FileData::Chmod(data) => &data.inner.parent_inode, FileData::Chown(data) => &data.inner.parent_inode, FileData::Rename(data) => &data.new.parent_inode, + FileData::AclSet(data) => &data.inner.parent_inode, } } @@ -199,6 +201,7 @@ impl Event { FileData::Chmod(data) => &data.inner.filename, FileData::Chown(data) => &data.inner.filename, FileData::Rename(data) => &data.new.filename, + FileData::AclSet(data) => &data.inner.filename, } } @@ -219,6 +222,7 @@ impl Event { FileData::Chmod(data) => &data.inner.host_file, FileData::Chown(data) => &data.inner.host_file, FileData::Rename(data) => &data.new.host_file, + FileData::AclSet(data) => &data.inner.host_file, } } @@ -243,6 +247,7 @@ impl Event { FileData::Chmod(data) => data.inner.host_file = host_path, FileData::Chown(data) => data.inner.host_file = host_path, FileData::Rename(data) => data.new.host_file = host_path, + FileData::AclSet(data) => data.inner.host_file = host_path, } } @@ -264,6 +269,7 @@ impl Event { FileData::Chmod(data) => data.inner.monitored, FileData::Chown(data) => data.inner.monitored, FileData::Rename(data) => data.new.monitored, + FileData::AclSet(data) => data.inner.monitored, } } @@ -356,6 +362,7 @@ pub enum FileData { Chmod(ChmodFileData), Chown(ChownFileData), Rename(RenameFileData), + AclSet(AclSetFileData), } impl FileData { @@ -407,6 +414,35 @@ impl FileData { }; FileData::Rename(data) } + file_activity_type_t::FILE_ACTIVITY_ACL_SET => { + let acl = unsafe { &extra_data.acl }; + let acl_type = if acl.acl_type == fact_ebpf::FACT_ACL_TYPE_DEFAULT { + AclType::Default + } else { + AclType::Access + }; + let count = acl.count.min(fact_ebpf::FACT_MAX_ACL_ENTRIES) as usize; + let mut entries = Vec::with_capacity(count); + for i in 0..count { + let entry = &acl.entries[i]; + let tag = AclTag::from_kernel(entry.e_tag); + let id = if tag.has_qualifier() { + Some(entry.e_id) + } else { + None + }; + entries.push(AclEntry { + tag, + perm: entry.e_perm, + id, + }); + } + FileData::AclSet(AclSetFileData { + inner, + acl_type, + entries, + }) + } invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -450,6 +486,10 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileRename::from(event); fact_api::file_activity::File::Rename(f_act) } + FileData::AclSet(event) => { + let f_act = fact_api::FileAclChange::from(event); + fact_api::file_activity::File::Acl(f_act) + } } } } @@ -465,6 +505,11 @@ impl PartialEq for FileData { (FileData::Unlink(this), FileData::Unlink(other)) => this == other, (FileData::Chmod(this), FileData::Chmod(other)) => this == other, (FileData::Rename(this), FileData::Rename(other)) => this == other, + (FileData::AclSet(this), FileData::AclSet(other)) => { + this.inner == other.inner + && this.acl_type == other.acl_type + && this.entries == other.entries + } _ => false, } } @@ -577,6 +622,56 @@ pub struct RenameFileData { old: BaseFileData, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub enum AclTag { + UserObj, + User, + GroupObj, + Group, + Mask, + Other, + Unknown(i16), +} + +impl AclTag { + fn from_kernel(tag: i16) -> Self { + match tag { + 0x01 => AclTag::UserObj, + 0x02 => AclTag::User, + 0x04 => AclTag::GroupObj, + 0x08 => AclTag::Group, + 0x10 => AclTag::Mask, + 0x20 => AclTag::Other, + other => AclTag::Unknown(other), + } + } + + /// Whether this tag type carries a meaningful uid/gid. + fn has_qualifier(&self) -> bool { + matches!(self, AclTag::User | AclTag::Group) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AclEntry { + pub tag: AclTag, + pub perm: u16, + pub id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub enum AclType { + Access, + Default, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AclSetFileData { + inner: BaseFileData, + pub acl_type: AclType, + pub entries: Vec, +} + impl From for fact_api::FileRename { fn from(RenameFileData { new, old }: RenameFileData) -> Self { let new = fact_api::FileActivityBase::from(new); @@ -588,6 +683,46 @@ impl From for fact_api::FileRename { } } +impl From for i32 { + fn from(tag: AclTag) -> Self { + match tag { + AclTag::UserObj => fact_api::AclTag::UserObj as i32, + AclTag::User => fact_api::AclTag::User as i32, + AclTag::GroupObj => fact_api::AclTag::GroupObj as i32, + AclTag::Group => fact_api::AclTag::Group as i32, + AclTag::Mask => fact_api::AclTag::Mask as i32, + AclTag::Other => fact_api::AclTag::Other as i32, + AclTag::Unknown(_) => fact_api::AclTag::Unspecified as i32, + } + } +} + +impl From for fact_api::FileAclChange { + fn from(value: AclSetFileData) -> Self { + let activity = fact_api::FileActivityBase::from(value.inner); + let acl_type = match value.acl_type { + AclType::Access => "access".to_string(), + AclType::Default => "default".to_string(), + }; + let entries = value + .entries + .into_iter() + .map(|e| fact_api::AclEntry { + tag: i32::from(e.tag), + perm: e.perm as u32, + // ACL_UNDEFINED_ID (0xFFFFFFFF) for entries that don't + // carry a uid/gid (USER_OBJ, GROUP_OBJ, MASK, OTHER). + id: e.id.unwrap_or(0xFFFFFFFF), + }) + .collect(); + fact_api::FileAclChange { + activity: Some(activity), + acl_type, + entries, + } + } +} + #[cfg(test)] impl PartialEq for RenameFileData { fn eq(&self, other: &Self) -> bool { diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index 9a01369f..db530da1 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -70,4 +70,5 @@ define_kernel_metrics!( path_mkdir, path_rmdir, d_instantiate, + inode_set_acl, ); diff --git a/tests/event.py b/tests/event.py index 811e19a4..555ba0c1 100644 --- a/tests/event.py +++ b/tests/event.py @@ -46,6 +46,16 @@ class EventType(Enum): PERMISSION = 4 OWNERSHIP = 5 RENAME = 6 + ACL = 7 + + +# POSIX ACL tag values matching the AclTag proto enum. +ACL_TAG_USER_OBJ = 1 +ACL_TAG_USER = 2 +ACL_TAG_GROUP_OBJ = 3 +ACL_TAG_GROUP = 4 +ACL_TAG_MASK = 5 +ACL_TAG_OTHER = 6 class Process: @@ -233,6 +243,8 @@ def __init__( owner_gid: int | None = None, old_file: str | Pattern[str] | None = None, old_host_path: str | Pattern[str] | None = None, + acl_type: str | None = None, + acl_entries: list[dict] | None = None, ): self._type: EventType = event_type self._process: Process = process @@ -243,6 +255,8 @@ def __init__( self._owner_gid: int | None = owner_gid self._old_file: str | Pattern[str] | None = old_file self._old_host_path: str | Pattern[str] | None = old_host_path + self._acl_type: str | None = acl_type + self._acl_entries: list[dict] | None = acl_entries @property def event_type(self) -> EventType: @@ -280,6 +294,14 @@ def old_file(self) -> str | Pattern[str] | None: def old_host_path(self) -> str | Pattern[str] | None: return self._old_host_path + @property + def acl_type(self) -> str | None: + return self._acl_type + + @property + def acl_entries(self) -> list[dict] | None: + return self._acl_entries + @classmethod def _diff_field(cls, diff: dict, name: str, expected: Any, actual: Any): if expected != actual: @@ -388,6 +410,28 @@ def diff(self, other: FileActivity) -> dict | None: self.owner_gid, event_field.gid, ) + elif self.event_type == EventType.ACL: + Event._diff_field( + diff, + 'acl_type', + self.acl_type, + event_field.acl_type, + ) + if self.acl_entries is not None: + actual_entries = [ + { + 'tag': e.tag, + 'perm': e.perm, + 'id': e.id, + } + for e in event_field.entries + ] + Event._diff_field( + diff, + 'acl_entries', + self.acl_entries, + actual_entries, + ) return diff if diff else None @@ -411,6 +455,10 @@ def __str__(self) -> str: f', old_host_path="{self.old_host_path}"' ) + if self.event_type == EventType.ACL: + s += f', acl_type={self.acl_type}' + s += f', acl_entries={self.acl_entries}' + s += ')' return s diff --git a/tests/test_acl.py b/tests/test_acl.py new file mode 100644 index 00000000..be2ea9e4 --- /dev/null +++ b/tests/test_acl.py @@ -0,0 +1,291 @@ +"""Tests for POSIX ACL change events. + +Uses os.setxattr to set ACLs directly via the POSIX ACL xattr wire +format, avoiding a dependency on the setfacl tool. +""" + +from __future__ import annotations + +import os +import struct + +import pytest + +from event import ( + ACL_TAG_GROUP_OBJ, + ACL_TAG_MASK, + ACL_TAG_OTHER, + ACL_TAG_USER, + ACL_TAG_USER_OBJ, + Event, + EventType, + Process, +) +from server import FileActivityService + +# POSIX ACL xattr wire format constants +_ACL_VERSION = 2 +_ACL_UNDEFINED_ID = 0xFFFFFFFF + +# Kernel ACL tag values (from include/uapi/linux/posix_acl.h) +_ACL_USER_OBJ = 0x01 +_ACL_USER = 0x02 +_ACL_GROUP_OBJ = 0x04 +_ACL_GROUP = 0x08 +_ACL_MASK = 0x10 +_ACL_OTHER = 0x20 + + +def _make_acl_xattr(entries: list[tuple[int, int, int]]) -> bytes: + """Build a POSIX ACL xattr value from a list of (tag, perm, id) tuples.""" + data = struct.pack(' bool: + """Check whether the kernel has the inode_set_acl LSM hook by + searching for its BTF type in /sys/kernel/btf/vmlinux.""" + needle = b'bpf_lsm_inode_set_acl' + chunk_size = 64 * 1024 + try: + with open('/sys/kernel/btf/vmlinux', 'rb') as f: + # Read in chunks, keeping an overlap to catch matches + # that span chunk boundaries. + prev = b'' + while chunk := f.read(chunk_size): + if needle in prev + chunk: + return True + prev = chunk[-len(needle) :] + return False + except OSError: + return False + + +pytestmark = pytest.mark.skipif( + not _kernel_supports_acl_hook(), + reason='kernel does not support inode_set_acl LSM hook', +) + + +def test_set_access_acl( + monitored_dir: str, + server: FileActivityService, +): + """Test setting an access ACL on a monitored file.""" + fut = os.path.join(monitored_dir, 'acl_test.txt') + with open(fut, 'w') as f: + f.write('test') + + acl = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 6, _ACL_UNDEFINED_ID), + (_ACL_USER, 6, 1000), + (_ACL_GROUP_OBJ, 4, _ACL_UNDEFINED_ID), + (_ACL_MASK, 6, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 4, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(fut, 'system.posix_acl_access', acl) + + process = Process.from_proc() + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=fut, + host_path=fut, + ), + Event( + process=process, + event_type=EventType.ACL, + file=fut, + host_path=fut, + acl_type='access', + acl_entries=[ + {'tag': ACL_TAG_USER_OBJ, 'perm': 6, 'id': _ACL_UNDEFINED_ID}, + {'tag': ACL_TAG_USER, 'perm': 6, 'id': 1000}, + {'tag': ACL_TAG_GROUP_OBJ, 'perm': 4, 'id': _ACL_UNDEFINED_ID}, + {'tag': ACL_TAG_MASK, 'perm': 6, 'id': _ACL_UNDEFINED_ID}, + {'tag': ACL_TAG_OTHER, 'perm': 4, 'id': _ACL_UNDEFINED_ID}, + ], + ), + ] + + server.wait_events(events) + + +def test_set_default_acl( + monitored_dir: str, + server: FileActivityService, +): + """Test setting a default ACL on a monitored directory.""" + fut = os.path.join(monitored_dir, 'acl_subdir') + os.makedirs(fut, exist_ok=True) + + acl = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 7, _ACL_UNDEFINED_ID), + (_ACL_GROUP_OBJ, 5, _ACL_UNDEFINED_ID), + (_ACL_GROUP, 5, 1000), + (_ACL_MASK, 5, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 5, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(fut, 'system.posix_acl_default', acl) + + process = Process.from_proc() + events = [ + Event( + process=process, + event_type=EventType.ACL, + file=fut, + host_path=fut, + acl_type='default', + ), + ] + + server.wait_events(events) + + +def test_remove_acl( + monitored_dir: str, + server: FileActivityService, +): + """Test removing ACLs from a monitored file.""" + fut = os.path.join(monitored_dir, 'acl_remove.txt') + with open(fut, 'w') as f: + f.write('test') + + # Set an ACL with an extra user entry + acl_with_user = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 6, _ACL_UNDEFINED_ID), + (_ACL_USER, 6, 1000), + (_ACL_GROUP_OBJ, 4, _ACL_UNDEFINED_ID), + (_ACL_MASK, 6, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 4, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(fut, 'system.posix_acl_access', acl_with_user) + + # Remove extended ACL entries by setting a minimal ACL + acl_minimal = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 6, _ACL_UNDEFINED_ID), + (_ACL_GROUP_OBJ, 4, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 4, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(fut, 'system.posix_acl_access', acl_minimal) + + process = Process.from_proc() + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=fut, + host_path=fut, + ), + Event( + process=process, + event_type=EventType.ACL, + file=fut, + host_path=fut, + acl_type='access', + ), + Event( + process=process, + event_type=EventType.ACL, + file=fut, + host_path=fut, + acl_type='access', + acl_entries=[ + {'tag': ACL_TAG_USER_OBJ, 'perm': 6, 'id': _ACL_UNDEFINED_ID}, + {'tag': ACL_TAG_GROUP_OBJ, 'perm': 4, 'id': _ACL_UNDEFINED_ID}, + {'tag': ACL_TAG_OTHER, 'perm': 4, 'id': _ACL_UNDEFINED_ID}, + ], + ), + ] + + server.wait_events(events) + + +def test_multiple_entries( + monitored_dir: str, + server: FileActivityService, +): + """Test setting multiple ACL entries on a single file.""" + fut = os.path.join(monitored_dir, 'acl_multi.txt') + with open(fut, 'w') as f: + f.write('test') + + acl = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 6, _ACL_UNDEFINED_ID), + (_ACL_USER, 7, 1000), + (_ACL_USER, 4, 1001), + (_ACL_GROUP_OBJ, 4, _ACL_UNDEFINED_ID), + (_ACL_GROUP, 6, 2000), + (_ACL_MASK, 7, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 4, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(fut, 'system.posix_acl_access', acl) + + process = Process.from_proc() + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=fut, + host_path=fut, + ), + Event( + process=process, + event_type=EventType.ACL, + file=fut, + host_path=fut, + acl_type='access', + ), + ] + + server.wait_events(events) + + +def test_ignored_path( + test_file: str, + ignored_dir: str, + server: FileActivityService, +): + """Test that ACL changes on ignored paths are not captured.""" + ignored_file = os.path.join(ignored_dir, 'ignored_acl.txt') + with open(ignored_file, 'w') as f: + f.write('ignored') + + acl = _make_acl_xattr( + [ + (_ACL_USER_OBJ, 6, _ACL_UNDEFINED_ID), + (_ACL_USER, 6, 1000), + (_ACL_GROUP_OBJ, 4, _ACL_UNDEFINED_ID), + (_ACL_MASK, 6, _ACL_UNDEFINED_ID), + (_ACL_OTHER, 4, _ACL_UNDEFINED_ID), + ] + ) + os.setxattr(ignored_file, 'system.posix_acl_access', acl) + + # Verify the server is working by doing a chmod on a monitored file + process = Process.from_proc() + mode = 0o644 + os.chmod(test_file, mode) + + event = Event( + process=process, + event_type=EventType.PERMISSION, + file=test_file, + host_path=test_file, + mode=mode, + ) + + server.wait_events([event]) diff --git a/third_party/stackrox b/third_party/stackrox index e6bcbecf..e31d9de0 160000 --- a/third_party/stackrox +++ b/third_party/stackrox @@ -1 +1 @@ -Subproject commit e6bcbecfe70a809fbcc809b8be496cd4c70a0691 +Subproject commit e31d9de035673f03d1b0327d822bc17d6ce98006