From 2557b2201197bdab65034e66a3e14af2b2edd262 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Tue, 18 Nov 2025 13:52:11 +0100 Subject: [PATCH 1/4] ROX-30257: implement permission change tracking This is done via the path_chmod LSM hook. WIP: the old mode is not quite working yet. --- fact-ebpf/src/bpf/events.h | 49 +++++++++++++++++------ fact-ebpf/src/bpf/main.c | 52 ++++++++++++++++++++++++- fact-ebpf/src/bpf/maps.h | 2 +- fact-ebpf/src/bpf/types.h | 8 ++++ fact-ebpf/src/lib.rs | 1 + fact/src/bpf/checks.rs | 8 ++-- fact/src/bpf/mod.rs | 39 ++++++++++--------- fact/src/event/mod.rs | 62 +++++++++++++++++++++++++++++- fact/src/metrics/kernel_metrics.rs | 9 +++++ 9 files changed, 193 insertions(+), 37 deletions(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index ab027bb4..4e9e51bf 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -11,17 +11,12 @@ #include // clang-format on -__always_inline static void submit_event(struct metrics_by_hook_t* m, - file_activity_type_t event_type, - const char filename[PATH_MAX], - inode_key_t* inode, - bool use_bpf_d_path) { - struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); - if (event == NULL) { - m->ringbuffer_full++; - return; - } - +__always_inline static void __submit_event(struct event_t* event, + struct metrics_by_hook_t* m, + file_activity_type_t event_type, + const char filename[PATH_MAX], + inode_key_t* inode, + bool use_bpf_d_path) { event->type = event_type; event->timestamp = bpf_ktime_get_boot_ns(); inode_copy_or_reset(&event->inode, inode); @@ -46,3 +41,35 @@ __always_inline static void submit_event(struct metrics_by_hook_t* m, m->error++; bpf_ringbuf_discard(event, 0); } + +__always_inline static void submit_event(struct metrics_by_hook_t* m, + file_activity_type_t event_type, + const char filename[PATH_MAX], + inode_key_t* inode, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + __submit_event(event, m, event_type, filename, inode, use_bpf_d_path); +} + +__always_inline static void submit_mode_event(struct metrics_by_hook_t* m, + const char filename[PATH_MAX], + inode_key_t* inode, + umode_t mode, + umode_t old_mode, + bool use_bpf_d_path) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + event->chmod.new = mode; + event->chmod.old = old_mode; + + __submit_event(event, m, FILE_ACTIVITY_CHMOD, filename, inode, use_bpf_d_path); +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 19b6ea49..2e4d64c0 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -75,7 +75,7 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { m->path_unlink.total++; struct bound_path_t* path = NULL; - if (path_unlink_supports_bpf_d_path) { + if (path_hooks_support_bpf_d_path) { path = path_read(dir); } else { path = path_read_no_d_path(dir); @@ -118,10 +118,58 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { FILE_ACTIVITY_UNLINK, path->path, &inode_key, - path_unlink_supports_bpf_d_path); + path_hooks_support_bpf_d_path); return 0; error: m->path_unlink.error++; return 0; } + +SEC("lsm/path_chmod") +int BPF_PROG(trace_path_chmod, struct path* path, umode_t mode) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_chmod.total++; + + struct bound_path_t* bound_path = NULL; + if (path_hooks_support_bpf_d_path) { + bound_path = path_read(path); + } else { + bound_path = path_read_no_d_path(path); + } + + if (bound_path == NULL) { + bpf_printk("Failed to read path"); + m->path_chmod.error++; + return 0; + } + + inode_key_t inode_key = inode_to_key(path->dentry->d_inode); + const inode_value_t* inode = inode_get(&inode_key); + + switch (inode_is_monitored(inode)) { + case NOT_MONITORED: + if (!is_monitored(bound_path)) { + m->path_chmod.ignored++; + return 0; + } + break; + + case MONITORED: + break; + } + + umode_t old_mode = BPF_CORE_READ(path, dentry, d_inode, i_mode); + submit_mode_event(&m->path_chmod, + bound_path->path, + &inode_key, + mode, + old_mode, + path_hooks_support_bpf_d_path); + + return 0; +} diff --git a/fact-ebpf/src/bpf/maps.h b/fact-ebpf/src/bpf/maps.h index 1b0402e2..37849f2d 100644 --- a/fact-ebpf/src/bpf/maps.h +++ b/fact-ebpf/src/bpf/maps.h @@ -112,6 +112,6 @@ __always_inline static struct metrics_t* get_metrics() { } uint64_t host_mount_ns; -volatile const bool path_unlink_supports_bpf_d_path; +volatile const bool path_hooks_support_bpf_d_path; // clang-format on diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 17c1f43b..f7100c98 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -47,6 +47,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_OPEN = 0, FILE_ACTIVITY_CREATION, FILE_ACTIVITY_UNLINK, + FILE_ACTIVITY_CHMOD, } file_activity_type_t; struct event_t { @@ -55,6 +56,12 @@ struct event_t { char filename[PATH_MAX]; inode_key_t inode; file_activity_type_t type; + union { + struct { + short unsigned int new; + short unsigned int old; + } chmod; + }; }; /** @@ -83,4 +90,5 @@ struct metrics_by_hook_t { struct metrics_t { struct metrics_by_hook_t file_open; struct metrics_by_hook_t path_unlink; + struct metrics_by_hook_t path_chmod; }; diff --git a/fact-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index 2251993d..5ba66dc5 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -111,6 +111,7 @@ impl metrics_t { let mut m = metrics_t { ..*self }; m.file_open = m.file_open.accumulate(&other.file_open); m.path_unlink = m.path_unlink.accumulate(&other.path_unlink); + m.path_chmod = m.path_chmod.accumulate(&other.path_chmod); m } } diff --git a/fact/src/bpf/checks.rs b/fact/src/bpf/checks.rs index 238cf306..10690fc4 100644 --- a/fact/src/bpf/checks.rs +++ b/fact/src/bpf/checks.rs @@ -3,7 +3,7 @@ use aya::{programs::Lsm, Btf}; use log::debug; pub(super) struct Checks { - pub(super) path_unlink_supports_bpf_d_path: bool, + pub(super) path_hooks_support_bpf_d_path: bool, } impl Checks { @@ -16,11 +16,11 @@ impl Checks { .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_unlink_supports_bpf_d_path = prog.load("path_unlink", btf).is_ok(); - debug!("path_unlink_supports_bpf_d_path: {path_unlink_supports_bpf_d_path}"); + 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}"); Ok(Checks { - path_unlink_supports_bpf_d_path, + path_hooks_support_bpf_d_path, }) } } diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index d8d6babf..e011d5f6 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -3,12 +3,12 @@ use std::{io, path::PathBuf}; use anyhow::{bail, Context}; use aya::{ maps::{Array, HashMap, LpmTrie, MapData, PerCpuArray, RingBuf}, - programs::Lsm, + programs::Program, Btf, Ebpf, }; use checks::Checks; use libc::c_char; -use log::{debug, error, info}; +use log::{error, info}; use tokio::{ io::unix::AsyncFd, sync::{mpsc, watch}, @@ -48,8 +48,8 @@ impl Bpf { let obj = aya::EbpfLoader::new() .set_global("host_mount_ns", &host_info::get_host_mount_ns(), true) .set_global( - "path_unlink_supports_bpf_d_path", - &(checks.path_unlink_supports_bpf_d_path as u8), + "path_hooks_support_bpf_d_path", + &(checks.path_hooks_support_bpf_d_path as u8), true, ) .set_max_entries(RINGBUFFER_NAME, ringbuf_size * 1024) @@ -143,24 +143,28 @@ impl Bpf { Ok(()) } - fn load_lsm_prog(&mut self, name: &str, hook: &str, btf: &Btf) -> anyhow::Result<()> { - let Some(prog) = self.obj.program_mut(name) else { - bail!("{name} program not found"); - }; - let prog: &mut Lsm = prog.try_into()?; - prog.load(hook, btf)?; - Ok(()) - } - fn load_progs(&mut self, btf: &Btf) -> anyhow::Result<()> { - self.load_lsm_prog("trace_file_open", "file_open", btf)?; - self.load_lsm_prog("trace_path_unlink", "path_unlink", btf) + for (name, prog) in self.obj.programs_mut() { + // The format used for our hook names is `trace_`, so + // we can just strip trace_ to get the hook name we need for + // loading. + let Some(hook) = name.strip_prefix("trace_") else { + bail!("Invalid hook name: {name}"); + }; + match prog { + Program::Lsm(prog) => prog.load(hook, btf)?, + u => unimplemented!("{u:?}"), + } + } + Ok(()) } fn attach_progs(&mut self) -> anyhow::Result<()> { for (_, prog) in self.obj.programs_mut() { - let prog: &mut Lsm = prog.try_into()?; - prog.attach()?; + match prog { + Program::Lsm(prog) => prog.attach()?, + u => unimplemented!("{u:?}"), + }; } Ok(()) } @@ -192,7 +196,6 @@ impl Bpf { Ok(event) => event, Err(e) => { error!("Failed to parse event: '{e}'"); - debug!("Event: {event:?}"); event_counter.dropped(); continue; } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 205b6339..1149878a 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -67,6 +67,7 @@ impl Event { FileData::Open(data) => &data.inode, FileData::Creation(data) => &data.inode, FileData::Unlink(data) => &data.inode, + FileData::Chmod(data) => &data.inner.inode, } } @@ -75,6 +76,7 @@ impl Event { FileData::Open(data) => data.host_file = host_path, FileData::Creation(data) => data.host_file = host_path, FileData::Unlink(data) => data.host_file = host_path, + FileData::Chmod(data) => data.inner.host_file = host_path, } } } @@ -85,7 +87,12 @@ impl TryFrom<&event_t> for Event { fn try_from(value: &event_t) -> Result { let process = Process::try_from(value.process)?; let timestamp = host_info::get_boot_time() + value.timestamp; - let file = FileData::new(value.type_, value.filename, value.inode)?; + let file = FileData::new( + value.type_, + value.filename, + value.inode, + value.__bindgen_anon_1, + )?; Ok(Event { timestamp, @@ -123,6 +130,7 @@ pub enum FileData { Open(BaseFileData), Creation(BaseFileData), Unlink(BaseFileData), + Chmod(ChmodFileData), } impl FileData { @@ -130,12 +138,21 @@ impl FileData { event_type: file_activity_type_t, filename: [c_char; PATH_MAX as usize], inode: inode_key_t, + extra_data: fact_ebpf::event_t__bindgen_ty_1, ) -> anyhow::Result { let inner = BaseFileData::new(filename, inode)?; let file = match event_type { file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), + file_activity_type_t::FILE_ACTIVITY_CHMOD => { + let data = ChmodFileData { + inner, + new_mode: unsafe { extra_data.chmod.new }, + old_mode: unsafe { extra_data.chmod.old }, + }; + FileData::Chmod(data) + } invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -161,6 +178,10 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileUnlink { activity }; fact_api::file_activity::File::Unlink(f_act) } + FileData::Chmod(event) => { + let f_act = fact_api::FilePermissionChange::from(event); + fact_api::file_activity::File::Permission(f_act) + } } } } @@ -211,3 +232,42 @@ impl From for fact_api::FileActivityBase { } } } + +#[derive(Debug, Clone, Serialize)] +pub struct ChmodFileData { + inner: BaseFileData, + new_mode: u16, + old_mode: u16, +} + +impl ChmodFileData { + pub fn new( + filename: [c_char; PATH_MAX as usize], + inode: inode_key_t, + new_mode: u16, + old_mode: u16, + ) -> anyhow::Result { + let inner = BaseFileData::new(filename, inode)?; + + Ok(ChmodFileData { + inner, + new_mode, + old_mode, + }) + } +} + +impl From for fact_api::FilePermissionChange { + fn from(value: ChmodFileData) -> Self { + let ChmodFileData { + inner: file, + new_mode, + old_mode: _, + } = value; + let activity = fact_api::FileActivityBase::from(file); + fact_api::FilePermissionChange { + activity: Some(activity), + mode: new_mode as u32, + } + } +} diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index d089a1c8..720943a9 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -10,6 +10,7 @@ use super::{EventCounter, LabelValues}; pub struct KernelMetrics { file_open: EventCounter, path_unlink: EventCounter, + path_chmod: EventCounter, map: PerCpuArray, } @@ -25,13 +26,20 @@ impl KernelMetrics { "Events processed by the path_unlink LSM hook", &[], // Labels are not needed since `collect` will add them all ); + let path_chmod = EventCounter::new( + "kernel_path_chmod_events", + "Events processed by the path_chmod LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); file_open.register(reg); path_unlink.register(reg); + path_chmod.register(reg); KernelMetrics { file_open, path_unlink, + path_chmod, map: kernel_metrics, } } @@ -78,6 +86,7 @@ impl KernelMetrics { KernelMetrics::refresh_labels(&self.file_open, &metrics.file_open); KernelMetrics::refresh_labels(&self.path_unlink, &metrics.path_unlink); + KernelMetrics::refresh_labels(&self.path_chmod, &metrics.path_chmod); Ok(()) } From 1746a5f6bd2dd20effb79dc3146e8594419f3be4 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Fri, 19 Dec 2025 14:42:01 +0100 Subject: [PATCH 2/4] test(bpf): add chmod operation to bpf unit test This allows us to exercise the trace_path_chmod bpf program on unit tests. --- fact/src/bpf/mod.rs | 33 ++++++++++++++++++++++++++------- fact/src/event/mod.rs | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index e011d5f6..ec9ca57f 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -228,15 +228,14 @@ impl Bpf { #[cfg(all(test, feature = "bpf-test"))] mod bpf_tests { - use std::{env, path::PathBuf, time::Duration}; + use std::{env, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; - use fact_ebpf::file_activity_type_t; use tempfile::NamedTempFile; use tokio::{sync::watch, time::timeout}; use crate::{ config::{reloader::Reloader, FactConfig}, - event::process::Process, + event::{process::Process, EventTestData}, host_info, metrics::exporter::Exporter, }; @@ -273,12 +272,31 @@ mod bpf_tests { let file = NamedTempFile::new_in(monitored_path).expect("Failed to create temporary file"); println!("Created {file:?}"); + // Trigger permission changes + let mut perms = file + .path() + .metadata() + .expect("Failed to read file permissions") + .permissions(); + let old_perm = perms.mode() as u16; + let new_perm: u16 = 0o666; + perms.set_mode(new_perm as u32); + std::fs::set_permissions(file.path(), perms).expect("Failed to set file permissions"); + let current = Process::current(); let file_path = file.path().to_path_buf(); let expected_events = [ Event::new( - file_activity_type_t::FILE_ACTIVITY_CREATION, + EventTestData::Creation, + host_info::get_hostname(), + file_path.clone(), + PathBuf::new(), // host path is resolved by HostScanner + current.clone(), + ) + .unwrap(), + Event::new( + EventTestData::Chmod(new_perm, old_perm), host_info::get_hostname(), file_path.clone(), PathBuf::new(), // host path is resolved by HostScanner @@ -286,7 +304,7 @@ mod bpf_tests { ) .unwrap(), Event::new( - file_activity_type_t::FILE_ACTIVITY_UNLINK, + EventTestData::Unlink, host_info::get_hostname(), file_path, PathBuf::new(), // host path is resolved by HostScanner @@ -298,12 +316,13 @@ mod bpf_tests { // Close the file, removing it file.close().expect("Failed to close temp file"); - println!("Expected: {expected_events:?}"); let wait = timeout(Duration::from_secs(1), async move { for expected in expected_events { + println!("expected: {expected:#?}"); while let Some(event) = rx.recv().await { - println!("{event:?}"); + println!("{event:#?}"); if event == expected { + println!("Found!"); break; } } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 1149878a..784458d7 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -21,6 +21,14 @@ fn timestamp_to_proto(ts: u64) -> prost_types::Timestamp { prost_types::Timestamp { seconds, nanos } } +#[cfg(test)] +#[derive(Debug)] +pub(crate) enum EventTestData { + Creation, + Unlink, + Chmod(u16, u16), +} + #[derive(Debug, Clone, Serialize)] pub struct Event { timestamp: u64, @@ -31,8 +39,8 @@ pub struct Event { impl Event { #[cfg(test)] - pub fn new( - event_type: file_activity_type_t, + pub(crate) fn new( + data: EventTestData, hostname: &'static str, filename: PathBuf, host_file: PathBuf, @@ -47,11 +55,17 @@ impl Event { host_file, inode: Default::default(), }; - let file = match event_type { - file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), - file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), - file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), - invalid => unreachable!("Invalid event type: {invalid:?}"), + let file = match data { + EventTestData::Creation => FileData::Creation(inner), + EventTestData::Unlink => FileData::Unlink(inner), + EventTestData::Chmod(new_mode, old_mode) => { + let data = ChmodFileData { + inner, + new_mode, + old_mode, + }; + FileData::Chmod(data) + } }; Ok(Event { @@ -193,6 +207,7 @@ impl PartialEq for FileData { (FileData::Open(this), FileData::Open(other)) => this == other, (FileData::Creation(this), FileData::Creation(other)) => this == other, (FileData::Unlink(this), FileData::Unlink(other)) => this == other, + (FileData::Chmod(this), FileData::Chmod(other)) => this == other, _ => false, } } @@ -271,3 +286,12 @@ impl From for fact_api::FilePermissionChange { } } } + +#[cfg(test)] +impl PartialEq for ChmodFileData { + fn eq(&self, other: &Self) -> bool { + self.new_mode == other.new_mode + && self.old_mode == other.old_mode + && self.inner == other.inner + } +} From c2486b042a567cb2c57ebdc2cf28efe0c87eb252 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Fri, 19 Dec 2025 16:35:13 +0100 Subject: [PATCH 3/4] test(integration): add integration tests for permission changes --- tests/event.py | 26 +++- tests/server.py | 1 + tests/test_path_chmod.py | 257 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 tests/test_path_chmod.py diff --git a/tests/event.py b/tests/event.py index bdd3a6ed..5422833b 100644 --- a/tests/event.py +++ b/tests/event.py @@ -30,6 +30,7 @@ class EventType(Enum): OPEN = 1 CREATION = 2 UNLINK = 3 + PERMISSION = 4 class Process: @@ -169,11 +170,13 @@ def __init__(self, process: Process, event_type: EventType, file: str, - host_path: str = ''): + host_path: str = '', + mode: int | None = None): self._type: EventType = event_type self._process: Process = process self._file: str = file self._host_path: str = host_path + self._mode: int | None = mode @property def event_type(self) -> EventType: @@ -191,6 +194,10 @@ def file(self) -> str: def host_path(self) -> str: return self._host_path + @property + def mode(self) -> int | None: + return self._mode + @override def __eq__(self, other: Any) -> bool: if isinstance(other, FileActivity): @@ -206,11 +213,22 @@ def __eq__(self, other: Any) -> bool: elif self.event_type == EventType.UNLINK: return self.file == other.unlink.activity.path and \ self.host_path == other.unlink.activity.host_path + elif self.event_type == EventType.PERMISSION: + return self.file == other.permission.activity.path and \ + self.host_path == other.permission.activity.host_path and \ + self.mode == other.permission.mode return False raise NotImplementedError @override def __str__(self) -> str: - return (f'Event(event_type={self.event_type.name}, ' - f'process={self.process}, file="{self.file}", ' - f'host_path="{self.host_path}")') + s = (f'Event(event_type={self.event_type.name}, ' + f'process={self.process}, file="{self.file}", ' + f'host_path="{self.host_path}"') + + if self.event_type == EventType.PERMISSION: + s += f', mode={self.mode}' + + s += ')' + + return s diff --git a/tests/server.py b/tests/server.py index 20a31460..2dd17b69 100644 --- a/tests/server.py +++ b/tests/server.py @@ -88,6 +88,7 @@ def _wait_events(self, events: list[Event], ignored: list[Event]): sleep(0.5) continue + print(f'Got event: {msg}') if msg in ignored: raise ValueError(f'Caught ignored event: {msg}') diff --git a/tests/test_path_chmod.py b/tests/test_path_chmod.py new file mode 100644 index 00000000..7e66fb6a --- /dev/null +++ b/tests/test_path_chmod.py @@ -0,0 +1,257 @@ +import multiprocessing as mp +import os + +from event import Event, EventType, Process + + +def test_chmod(fact, monitored_dir, server): + """ + Tests changing permissions on a file and verifies the corresponding + event is captured by the server + + Args: + fact: Fixture for file activity (only required to be runing). + monitored_dir: Temporary directory path for creating the test file. + server: The server instance to communicate with. + """ + # File Under Test + fut = os.path.join(monitored_dir, 'test.txt') + mode = 0o666 + os.chmod(fut, mode) + + e = Event(process=Process.from_proc(), event_type=EventType.PERMISSION, + file=fut, host_path=fut, mode=mode) + + print(f'Waiting for event: {e}') + + server.wait_events([e]) + + +def test_multiple(fact, monitored_dir, server): + """ + Tests modifying permissions on multiple files. + + Args: + fact: Fixture for file activity (only required to be runing). + monitored_dir: Temporary directory path for creating the test file. + server: The server instance to communicate with. + """ + events = [] + process = Process.from_proc() + mode = 0o646 + + for i in range(3): + fut = os.path.join(monitored_dir, f'{i}.txt') + with open(fut, 'w') as f: + f.write('This is a test') + os.chmod(fut, mode) + + events.extend([ + Event(process=process, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=process, event_type=EventType.PERMISSION, + file=fut, host_path='', mode=mode), + ]) + + server.wait_events(events) + + +def test_ignored(fact, test_file, ignored_dir, server): + """ + Tests that permission events on ignored files are not captured. + + Args: + fact: Fixture for file activity (only required to be running). + monitored_dir: Temporary directory path for creating the test file. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + mode = 0o666 + + # Ignored file, must not show up in the server + ignored_file = os.path.join(ignored_dir, 'test.txt') + with open(ignored_file, 'w') as f: + f.write('This is to be ignored') + os.chmod(ignored_file, mode) + + ignored_event = Event(process=process, event_type=EventType.PERMISSION, + file=ignored_file, host_path='', mode=mode) + print(f'Ignoring: {ignored_event}') + + # File Under Test + os.chmod(test_file, mode) + + e = Event(process=process, event_type=EventType.PERMISSION, + file=test_file, host_path=test_file, mode=mode) + print(f'Waiting foor event: {e}') + + server.wait_events([e], ignored=[ignored_event]) + + +def do_test(fut: str, mode: int, stop_event: mp.Event): + with open(fut, 'w') as f: + f.write('This is a test') + os.chmod(fut, mode) + + # Wait for test to be done + stop_event.wait() + + +def test_external_process(fact, monitored_dir, server): + """ + Tests permission change of a file by an external process and + verifies that the corresponding event is captured by the server. + + Args: + fact: Fixture for file activity (only required to be running). + monitored_dir: Temporary directory path for creating the test file. + server: The server instance to communicate with. + """ + # File Under Test + fut = os.path.join(monitored_dir, 'test2.txt') + mode = 0o666 + stop_event = mp.Event() + proc = mp.Process(target=do_test, args=(fut, mode, stop_event)) + proc.start() + process = Process.from_proc(proc.pid) + + event = Event(process=process, event_type=EventType.PERMISSION, + file=fut, host_path='', mode=mode) + print(f'Waiting for event: {event}') + + try: + server.wait_events([event]) + finally: + stop_event.set() + proc.join(1) + + +def test_overlay(fact, test_container, server): + """ + Test permission changes on an overlayfs file (inside a container) + + Args: + fact: Fixture for file activity (only required to be running). + test_container: A container for running commands in. + server: The server instance to communicate with. + """ + # File Under Test + fut = '/container-dir/test.txt' + mode = '666' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'chmod {mode} {fut}') + + loginuid = pow(2, 32)-1 + touch = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=loginuid) + chmod = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/chmod', + args=f'chmod {mode} {fut}', + name='chmod', + container_id=test_container.id[:12], + loginuid=loginuid) + events = [ + Event(process=touch, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=touch, event_type=EventType.OPEN, + file=fut, host_path=''), + Event(process=chmod, event_type=EventType.PERMISSION, + file=fut, host_path='', mode=int(mode, 8)), + ] + + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) + + +def test_mounted_dir(fact, test_container, ignored_dir, server): + """ + Test permission changes on a file bind mounted into a container + + Args: + fact: Fixture for file activity (only required to be running). + test_container: A container for running commands in. + ignored_dir: This directory is ignored on the host, and mounted to the container. + server: The server instance to communicate with. + """ + # File Under Test + fut = '/mounted/test.txt' + mode = '666' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'chmod {mode} {fut}') + + loginuid = pow(2, 32)-1 + touch = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=loginuid) + chmod = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/chmod', + args=f'chmod {mode} {fut}', + name='chmod', + container_id=test_container.id[:12], + loginuid=loginuid) + events = [ + Event(process=touch, event_type=EventType.CREATION, file=fut, + host_path=''), + Event(process=chmod, event_type=EventType.PERMISSION, file=fut, + host_path='', mode=int(mode, 8)), + ] + + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) + + +def test_unmonitored_mounted_dir(fact, test_container, test_file, server): + """ + Test permission changes on a file bind mounted to a container and + monitored on the host. + + Args: + fact: Fixture for file activity (only required to be running). + test_container: A container for running commands in. + test_file: File monitored on the host, mounted to the container. + server: The server instance to communicate with. + """ + # File Under Test + fut = '/unmonitored/test.txt' + mode = '666' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'chmod {mode} {fut}') + + process = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/chmod', + args=f'chmod {mode} {fut}', + name='chmod', + container_id=test_container.id[:12], + loginuid=pow(2, 32)-1) + event = Event(process=process, event_type=EventType.PERMISSION, + file=fut, host_path=test_file, mode=int(mode, 8)) + print(f'Waiting for event: {event}') + + server.wait_events([event]) From 55efa575108897b6258c64335ff006e45b2a9601 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Mon, 22 Dec 2025 11:30:34 +0100 Subject: [PATCH 4/4] Fix small typo --- tests/test_path_chmod.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_path_chmod.py b/tests/test_path_chmod.py index 7e66fb6a..3e23031f 100644 --- a/tests/test_path_chmod.py +++ b/tests/test_path_chmod.py @@ -84,7 +84,7 @@ def test_ignored(fact, test_file, ignored_dir, server): e = Event(process=process, event_type=EventType.PERMISSION, file=test_file, host_path=test_file, mode=mode) - print(f'Waiting foor event: {e}') + print(f'Waiting for event: {e}') server.wait_events([e], ignored=[ignored_event])