From 6e63d30b8b1633fce93441fd1902c00c5e711dfd Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Tue, 31 Mar 2026 15:32:42 -0700 Subject: [PATCH 01/10] X-Smart-Branch-Parent: main From 0682fb295d25eab4163ede8ff7e8e59b8b448207 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Tue, 31 Mar 2026 16:41:02 -0700 Subject: [PATCH 02/10] Added integration tests --- tests/test_path_mkdir.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_path_mkdir.py diff --git a/tests/test_path_mkdir.py b/tests/test_path_mkdir.py new file mode 100644 index 00000000..5242894d --- /dev/null +++ b/tests/test_path_mkdir.py @@ -0,0 +1,69 @@ +import os + +import pytest + +from event import Event, EventType, Process + + +def test_mkdir_nested(monitored_dir, server): + """ + Tests that creating nested directories tracks all inodes correctly. + + Args: + monitored_dir: Temporary directory path for creating the test directory. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create nested directories + level1 = os.path.join(monitored_dir, 'level1') + level2 = os.path.join(level1, 'level2') + level3 = os.path.join(level2, 'level3') + + os.mkdir(level1) + os.mkdir(level2) + os.mkdir(level3) + + # Create a file in the deepest directory + test_file = os.path.join(level3, 'deep_file.txt') + with open(test_file, 'w') as f: + f.write('nested content') + + events = [ + Event(process=process, event_type=EventType.CREATION, + file=level1, host_path=level1), + Event(process=process, event_type=EventType.CREATION, + file=level2, host_path=level2), + Event(process=process, event_type=EventType.CREATION, + file=level3, host_path=level3), + Event(process=process, event_type=EventType.CREATION, + file=test_file, host_path=test_file), + ] + + server.wait_events(events) + + +def test_mkdir_ignored(monitored_dir, ignored_dir, server): + """ + Tests that directories created outside monitored paths are ignored. + + Args: + monitored_dir: Temporary directory path that is monitored. + ignored_dir: Temporary directory path that is not monitored. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create directory in ignored path - should not be tracked + ignored_subdir = os.path.join(ignored_dir, 'ignored_subdir') + os.mkdir(ignored_subdir) + + # Create directory in monitored path - should be tracked + monitored_subdir = os.path.join(monitored_dir, 'monitored_subdir') + os.mkdir(monitored_subdir) + + # Only the monitored directory should generate an event + e = Event(process=process, event_type=EventType.CREATION, + file=monitored_subdir, host_path=monitored_subdir) + + server.wait_events([e]) From 7aecaa4ff2f19f88881a4171650f0a1669cf1ce1 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Wed, 1 Apr 2026 10:50:56 -0700 Subject: [PATCH 03/10] Instrument inode tracking on directory being created --- fact-ebpf/src/bpf/events.h | 14 ++++ fact-ebpf/src/bpf/file.h | 16 +++++ fact-ebpf/src/bpf/main.c | 107 +++++++++++++++++++++++++++++ fact-ebpf/src/bpf/maps.h | 20 ++++++ fact-ebpf/src/bpf/types.h | 8 +++ fact/src/metrics/kernel_metrics.rs | 18 +++++ 6 files changed, 183 insertions(+) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 26254778..5fc3d3a6 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -129,3 +129,17 @@ __always_inline static void submit_rename_event(struct metrics_by_hook_t* m, __submit_event(event, m, FILE_ACTIVITY_RENAME, new_filename, new_inode, new_parent_inode, path_hooks_support_bpf_d_path); } + +__always_inline static void submit_mkdir_event(struct metrics_by_hook_t* m, + const char filename[PATH_MAX], + inode_key_t* inode, + inode_key_t* parent_inode) { + struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); + if (event == NULL) { + m->ringbuffer_full++; + return; + } + + // d_instantiate doesn't support bpf_d_path, so we use false and rely on the stashed path from path_mkdir + __submit_event(event, m, FILE_ACTIVITY_CREATION, filename, inode, parent_inode, false); +} diff --git a/fact-ebpf/src/bpf/file.h b/fact-ebpf/src/bpf/file.h index d0fdc8b1..eac1b429 100644 --- a/fact-ebpf/src/bpf/file.h +++ b/fact-ebpf/src/bpf/file.h @@ -43,3 +43,19 @@ __always_inline static inode_monitored_t is_monitored(inode_key_t inode, struct return NOT_MONITORED; } + +// Check if a new directory should be tracked based on its parent and path. +// This is used during mkdir operations where the child inode doesn't exist yet. +__always_inline static inode_monitored_t should_track_mkdir(inode_key_t parent_inode, struct bound_path_t* child_path) { + const inode_value_t* volatile parent_value = inode_get(&parent_inode); + + if (parent_value != NULL) { + return PARENT_MONITORED; + } + + if (path_is_monitored(child_path)) { + return MONITORED; + } + + return NOT_MONITORED; +} diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index b7c044f1..f8b30a92 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -19,6 +19,11 @@ char _license[] SEC("license") = "Dual MIT/GPL"; #define FMODE_PWRITE ((fmode_t)(1 << 4)) #define FMODE_CREATED ((fmode_t)(1 << 20)) +// File type constants from linux/stat.h +#define S_IFMT 00170000 +#define S_IFDIR 0040000 +#define S_ISDIR(m) (((m) & S_IFMT) == S_IFDIR) + SEC("lsm/file_open") int BPF_PROG(trace_file_open, struct file* file) { struct metrics_t* m = get_metrics(); @@ -228,3 +233,105 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, m->path_rename.error++; return 0; } + +SEC("lsm/path_mkdir") +int BPF_PROG(trace_path_mkdir, struct path* dir, struct dentry* dentry, umode_t mode) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->path_mkdir.total++; + + struct bound_path_t* path = path_read_append_d_entry(dir, dentry); + if (path == NULL) { + bpf_printk("Failed to read path"); + m->path_mkdir.error++; + return 0; + } + + struct dentry* parent_dentry = BPF_CORE_READ(dir, dentry); + struct inode* parent_inode_ptr = BPF_CORE_READ(parent_dentry, d_inode); + inode_key_t parent_inode = inode_to_key(parent_inode_ptr); + + if (should_track_mkdir(parent_inode, path) == NOT_MONITORED) { + m->path_mkdir.ignored++; + return 0; + } + + // Stash mkdir context for security_d_instantiate + __u64 pid_tgid = bpf_get_current_pid_tgid(); + struct mkdir_context_t* mkdir_ctx = get_mkdir_context(); + if (mkdir_ctx == NULL) { + bpf_printk("Failed to get mkdir context buffer"); + m->path_mkdir.error++; + return 0; + } + + long path_copy_len = bpf_probe_read_str(mkdir_ctx->path, PATH_MAX, path->path); + if (path_copy_len < 0) { + bpf_printk("Failed to copy path string"); + m->path_mkdir.error++; + return 0; + } + mkdir_ctx->parent_inode = parent_inode; + + if (bpf_map_update_elem(&mkdir_context, &pid_tgid, mkdir_ctx, BPF_ANY) != 0) { + bpf_printk("Failed to stash mkdir context"); + m->path_mkdir.error++; + return 0; + } + + return 0; +} + +SEC("lsm/d_instantiate") +int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + + m->d_instantiate.total++; + + if (inode == NULL) { + m->d_instantiate.ignored++; + return 0; + } + + __u64 pid_tgid = bpf_get_current_pid_tgid(); + struct mkdir_context_t* mkdir_ctx = bpf_map_lookup_elem(&mkdir_context, &pid_tgid); + if (mkdir_ctx == NULL) { + m->d_instantiate.ignored++; + return 0; + } + + // Check if this is a directory + umode_t mode = BPF_CORE_READ(inode, i_mode); + if (!S_ISDIR(mode)) { + bpf_map_delete_elem(&mkdir_context, &pid_tgid); + m->d_instantiate.ignored++; + return 0; + } + + // Get the inode key for the new directory + inode_key_t inode_key = inode_to_key(inode); + + // Add the new directory inode to tracking + if (inode_add(&inode_key) == 0) { + m->d_instantiate.added++; + } else { + m->d_instantiate.error++; + } + + // Submit creation event using the stashed path + submit_mkdir_event(&m->d_instantiate, + mkdir_ctx->path, + &inode_key, + &mkdir_ctx->parent_inode); + + // Clean up context + bpf_map_delete_elem(&mkdir_context, &pid_tgid); + + return 0; +} diff --git a/fact-ebpf/src/bpf/maps.h b/fact-ebpf/src/bpf/maps.h index eca822f0..3758ea80 100644 --- a/fact-ebpf/src/bpf/maps.h +++ b/fact-ebpf/src/bpf/maps.h @@ -83,6 +83,26 @@ struct { __uint(map_flags, BPF_F_NO_PREALLOC); } inode_map SEC(".maps"); +struct { + __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); + __type(key, __u32); + __type(value, struct mkdir_context_t); + __uint(max_entries, 1); +} mkdir_context_heap SEC(".maps"); + +__always_inline static struct mkdir_context_t* get_mkdir_context() { + unsigned int zero = 0; + return bpf_map_lookup_elem(&mkdir_context_heap, &zero); +} + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __type(key, __u64); + __type(value, struct mkdir_context_t); + __uint(max_entries, 16384); + __uint(map_flags, BPF_F_NO_PREALLOC); +} mkdir_context SEC(".maps"); + struct { __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); __type(key, __u32); diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 55005c00..9059c7aa 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -96,6 +96,12 @@ struct path_prefix_t { const char path[LPM_SIZE_MAX]; }; +// Context for correlating mkdir operations +struct mkdir_context_t { + char path[PATH_MAX]; + inode_key_t parent_inode; +}; + // Metrics types struct metrics_by_hook_t { unsigned long long total; @@ -111,4 +117,6 @@ struct metrics_t { struct metrics_by_hook_t path_chmod; struct metrics_by_hook_t path_chown; struct metrics_by_hook_t path_rename; + struct metrics_by_hook_t path_mkdir; + struct metrics_by_hook_t d_instantiate; }; diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index d1a3a242..9caa1ff3 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -13,6 +13,8 @@ pub struct KernelMetrics { path_chmod: EventCounter, path_chown: EventCounter, path_rename: EventCounter, + path_mkdir: EventCounter, + d_instantiate: EventCounter, map: PerCpuArray, } @@ -43,12 +45,24 @@ impl KernelMetrics { "Events processed by the path_rename LSM hook", &[], // Labels are not needed since `collect` will add them all ); + let path_mkdir = EventCounter::new( + "kernel_path_mkdir_events", + "Events processed by the path_mkdir LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); + let d_instantiate = EventCounter::new( + "kernel_d_instantiate_events", + "Events processed by the d_instantiate LSM hook", + &[], // Labels are not needed since `collect` will add them all + ); file_open.register(reg); path_unlink.register(reg); path_chmod.register(reg); path_chown.register(reg); path_rename.register(reg); + path_mkdir.register(reg); + d_instantiate.register(reg); KernelMetrics { file_open, @@ -56,6 +70,8 @@ impl KernelMetrics { path_chmod, path_chown, path_rename, + path_mkdir, + d_instantiate, map: kernel_metrics, } } @@ -105,6 +121,8 @@ impl KernelMetrics { KernelMetrics::refresh_labels(&self.path_chmod, &metrics.path_chmod); KernelMetrics::refresh_labels(&self.path_chown, &metrics.path_chown); KernelMetrics::refresh_labels(&self.path_rename, &metrics.path_rename); + KernelMetrics::refresh_labels(&self.path_mkdir, &metrics.path_mkdir); + KernelMetrics::refresh_labels(&self.d_instantiate, &metrics.d_instantiate); Ok(()) } From e01ccb1c2d84f7ddf98640d305ce7525579ecc44 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Wed, 1 Apr 2026 15:29:42 -0700 Subject: [PATCH 04/10] Only tracking directories if the parent is monitored --- fact-ebpf/src/bpf/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index f8b30a92..744f2a18 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -254,7 +254,7 @@ int BPF_PROG(trace_path_mkdir, struct path* dir, struct dentry* dentry, umode_t struct inode* parent_inode_ptr = BPF_CORE_READ(parent_dentry, d_inode); inode_key_t parent_inode = inode_to_key(parent_inode_ptr); - if (should_track_mkdir(parent_inode, path) == NOT_MONITORED) { + if (should_track_mkdir(parent_inode, path) != PARENT_MONITORED) { m->path_mkdir.ignored++; return 0; } From 9a7a03ea22eef339aea02f61a972ffadbaae66ce Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Wed, 1 Apr 2026 16:41:52 -0700 Subject: [PATCH 05/10] Removed some comments --- fact-ebpf/src/bpf/main.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 744f2a18..9b3c9c54 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -306,7 +306,6 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { return 0; } - // Check if this is a directory umode_t mode = BPF_CORE_READ(inode, i_mode); if (!S_ISDIR(mode)) { bpf_map_delete_elem(&mkdir_context, &pid_tgid); @@ -314,23 +313,19 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { return 0; } - // Get the inode key for the new directory inode_key_t inode_key = inode_to_key(inode); - // Add the new directory inode to tracking if (inode_add(&inode_key) == 0) { m->d_instantiate.added++; } else { m->d_instantiate.error++; } - // Submit creation event using the stashed path submit_mkdir_event(&m->d_instantiate, mkdir_ctx->path, &inode_key, &mkdir_ctx->parent_inode); - // Clean up context bpf_map_delete_elem(&mkdir_context, &pid_tgid); return 0; From ab17d2654471a91375b8ae71f59b484c181bee83 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 2 Apr 2026 10:43:48 -0700 Subject: [PATCH 06/10] Combined two uses of BPF_CORE_READ --- fact-ebpf/src/bpf/main.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 9b3c9c54..ced11746 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -250,8 +250,7 @@ int BPF_PROG(trace_path_mkdir, struct path* dir, struct dentry* dentry, umode_t return 0; } - struct dentry* parent_dentry = BPF_CORE_READ(dir, dentry); - struct inode* parent_inode_ptr = BPF_CORE_READ(parent_dentry, d_inode); + struct inode* parent_inode_ptr = BPF_CORE_READ(dir, dentry, d_inode); inode_key_t parent_inode = inode_to_key(parent_inode_ptr); if (should_track_mkdir(parent_inode, path) != PARENT_MONITORED) { From 3aa499889d355bfe656bef57e82160e546aae375 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 2 Apr 2026 11:25:58 -0700 Subject: [PATCH 07/10] Added DIR_ACTIVITY_CREATION --- fact-ebpf/src/bpf/events.h | 2 +- fact-ebpf/src/bpf/types.h | 1 + fact/src/event/mod.rs | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 5fc3d3a6..fe521450 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -141,5 +141,5 @@ __always_inline static void submit_mkdir_event(struct metrics_by_hook_t* m, } // d_instantiate doesn't support bpf_d_path, so we use false and rely on the stashed path from path_mkdir - __submit_event(event, m, FILE_ACTIVITY_CREATION, filename, inode, parent_inode, false); + __submit_event(event, m, DIR_ACTIVITY_CREATION, filename, inode, parent_inode, false); } diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 9059c7aa..95c67e86 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -55,6 +55,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_CHMOD, FILE_ACTIVITY_CHOWN, FILE_ACTIVITY_RENAME, + DIR_ACTIVITY_CREATION, } file_activity_type_t; struct event_t { diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 40bd317a..6ddf1199 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -307,6 +307,7 @@ impl FileData { 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::DIR_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 { From 52888663006369af703b8ec9b74dd657e07df995 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 2 Apr 2026 11:29:22 -0700 Subject: [PATCH 08/10] Added permalink to linux/stat.h --- fact-ebpf/src/bpf/main.c | 1 + 1 file changed, 1 insertion(+) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index ced11746..c4ede974 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -20,6 +20,7 @@ char _license[] SEC("license") = "Dual MIT/GPL"; #define FMODE_CREATED ((fmode_t)(1 << 20)) // File type constants from linux/stat.h +// https://github.com/torvalds/linux/blob/5619b098e2fbf3a23bf13d91897056a1fe238c6d/include/uapi/linux/stat.h #define S_IFMT 00170000 #define S_IFDIR 0040000 #define S_ISDIR(m) (((m) & S_IFMT) == S_IFDIR) From 99ee8cbb3c9a643231be5703e8a8d98cbf38e699 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 2 Apr 2026 11:43:15 -0700 Subject: [PATCH 09/10] Removing map entry in case of early return in lsm/d_instantiate --- fact-ebpf/src/bpf/main.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index c4ede974..1343e7b1 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -306,11 +306,11 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { return 0; } + // From this point on, we must clean up mkdir_context before returning umode_t mode = BPF_CORE_READ(inode, i_mode); if (!S_ISDIR(mode)) { - bpf_map_delete_elem(&mkdir_context, &pid_tgid); m->d_instantiate.ignored++; - return 0; + goto cleanup; } inode_key_t inode_key = inode_to_key(inode); @@ -326,7 +326,7 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) { &inode_key, &mkdir_ctx->parent_inode); +cleanup: bpf_map_delete_elem(&mkdir_context, &pid_tgid); - return 0; } From be580e185b1658386b615f9b6149ee87ce1e6065 Mon Sep 17 00:00:00 2001 From: JoukoVirtanen Date: Thu, 2 Apr 2026 15:25:20 -0700 Subject: [PATCH 10/10] Using os.makedirs instead of os.mkdir three times --- tests/test_path_mkdir.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_path_mkdir.py b/tests/test_path_mkdir.py index 5242894d..759e0022 100644 --- a/tests/test_path_mkdir.py +++ b/tests/test_path_mkdir.py @@ -20,9 +20,7 @@ def test_mkdir_nested(monitored_dir, server): level2 = os.path.join(level1, 'level2') level3 = os.path.join(level2, 'level3') - os.mkdir(level1) - os.mkdir(level2) - os.mkdir(level3) + os.makedirs(level3, exist_ok=True) # Create a file in the deepest directory test_file = os.path.join(level3, 'deep_file.txt')