diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 26254778..fe521450 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, DIR_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 5ac35159..d262f2d8 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -231,3 +231,98 @@ 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 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) { + 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 = bpf_map_lookup_elem(&mkdir_context, &pid_tgid); + if (mkdir_ctx == NULL) { + static const struct mkdir_context_t empty_ctx = {0}; + if (bpf_map_update_elem(&mkdir_context, &pid_tgid, &empty_ctx, BPF_NOEXIST) != 0) { + bpf_printk("Failed to create mkdir context entry"); + m->path_mkdir.error++; + return 0; + } + mkdir_ctx = bpf_map_lookup_elem(&mkdir_context, &pid_tgid); + if (mkdir_ctx == NULL) { + bpf_printk("Failed to lookup mkdir context after creation"); + 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++; + bpf_map_delete_elem(&mkdir_context, &pid_tgid); + return 0; + } + mkdir_ctx->parent_inode = parent_inode; + + 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++; + + __u64 pid_tgid = bpf_get_current_pid_tgid(); + + if (inode == NULL) { + m->d_instantiate.ignored++; + goto cleanup; + } + + struct mkdir_context_t* mkdir_ctx = bpf_map_lookup_elem(&mkdir_context, &pid_tgid); + + if (mkdir_ctx == NULL) { + m->d_instantiate.ignored++; + return 0; + } + + inode_key_t inode_key = inode_to_key(inode); + + if (inode_add(&inode_key) == 0) { + m->d_instantiate.added++; + } else { + m->d_instantiate.error++; + } + + submit_mkdir_event(&m->d_instantiate, + mkdir_ctx->path, + &inode_key, + &mkdir_ctx->parent_inode); + +cleanup: + 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..c8595829 100644 --- a/fact-ebpf/src/bpf/maps.h +++ b/fact-ebpf/src/bpf/maps.h @@ -83,6 +83,13 @@ struct { __uint(map_flags, BPF_F_NO_PREALLOC); } inode_map SEC(".maps"); +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __type(key, __u64); + __type(value, struct mkdir_context_t); + __uint(max_entries, 16384); +} 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..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 { @@ -96,6 +97,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 +118,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-ebpf/src/lib.rs b/fact-ebpf/src/lib.rs index bd84ee08..c4c95ec6 100644 --- a/fact-ebpf/src/lib.rs +++ b/fact-ebpf/src/lib.rs @@ -125,6 +125,8 @@ impl metrics_t { m.path_chmod = m.path_chmod.accumulate(&other.path_chmod); m.path_chown = m.path_chown.accumulate(&other.path_chown); m.path_rename = m.path_rename.accumulate(&other.path_rename); + m.path_mkdir = m.path_mkdir.accumulate(&other.path_mkdir); + m.d_instantiate = m.d_instantiate.accumulate(&other.d_instantiate); m } } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index bc9d7897..1c87a859 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -127,7 +127,11 @@ impl Event { } pub fn is_creation(&self) -> bool { - matches!(self.file, FileData::Creation(_)) + matches!(self.file, FileData::Creation(_) | FileData::MkDir(_)) + } + + pub fn is_mkdir(&self) -> bool { + matches!(self.file, FileData::MkDir(_)) } pub fn is_unlink(&self) -> bool { @@ -143,6 +147,7 @@ impl Event { match &self.file { FileData::Open(data) => &data.inode, FileData::Creation(data) => &data.inode, + FileData::MkDir(data) => &data.inode, FileData::Unlink(data) => &data.inode, FileData::Chmod(data) => &data.inner.inode, FileData::Chown(data) => &data.inner.inode, @@ -155,6 +160,7 @@ impl Event { match &self.file { FileData::Open(data) => &data.parent_inode, FileData::Creation(data) => &data.parent_inode, + FileData::MkDir(data) => &data.parent_inode, FileData::Unlink(data) => &data.parent_inode, FileData::Chmod(data) => &data.inner.parent_inode, FileData::Chown(data) => &data.inner.parent_inode, @@ -176,6 +182,7 @@ impl Event { match &self.file { FileData::Open(data) => &data.filename, FileData::Creation(data) => &data.filename, + FileData::MkDir(data) => &data.filename, FileData::Unlink(data) => &data.filename, FileData::Chmod(data) => &data.inner.filename, FileData::Chown(data) => &data.inner.filename, @@ -194,6 +201,7 @@ impl Event { match &self.file { FileData::Open(data) => &data.host_file, FileData::Creation(data) => &data.host_file, + FileData::MkDir(data) => &data.host_file, FileData::Unlink(data) => &data.host_file, FileData::Chmod(data) => &data.inner.host_file, FileData::Chown(data) => &data.inner.host_file, @@ -209,6 +217,7 @@ impl Event { match &mut self.file { FileData::Open(data) => data.host_file = host_path, FileData::Creation(data) => data.host_file = host_path, + FileData::MkDir(data) => data.host_file = host_path, FileData::Unlink(data) => data.host_file = host_path, FileData::Chmod(data) => data.inner.host_file = host_path, FileData::Chown(data) => data.inner.host_file = host_path, @@ -293,6 +302,7 @@ impl PartialEq for Event { pub enum FileData { Open(BaseFileData), Creation(BaseFileData), + MkDir(BaseFileData), Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), @@ -311,6 +321,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::MkDir(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), file_activity_type_t::FILE_ACTIVITY_CHMOD => { let data = ChmodFileData { @@ -359,6 +370,9 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileCreation { activity }; fact_api::file_activity::File::Creation(f_act) } + FileData::MkDir(_) => { + unreachable!("MkDir event reached protobuf conversion"); + } FileData::Unlink(event) => { let activity = Some(fact_api::FileActivityBase::from(event)); let f_act = fact_api::FileUnlink { activity }; @@ -386,6 +400,7 @@ impl PartialEq for FileData { match (self, other) { (FileData::Open(this), FileData::Open(other)) => this == other, (FileData::Creation(this), FileData::Creation(other)) => this == other, + (FileData::MkDir(this), FileData::MkDir(other)) => this == other, (FileData::Unlink(this), FileData::Unlink(other)) => this == other, (FileData::Chmod(this), FileData::Chmod(other)) => this == other, (FileData::Rename(this), FileData::Rename(other)) => this == other, diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 19d5b719..9c5bf5e9 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -280,7 +280,7 @@ impl HostScanner { }; self.metrics.events.added(); - // Handle file creation events by adding new inodes to the map + // Handle file and directory creation events by adding new inodes to the map if event.is_creation() && let Err(e) = self.handle_creation_event(&event) { warn!("Failed to handle creation event: {e}"); @@ -301,6 +301,11 @@ impl HostScanner { self.handle_unlink_event(&event); } + // Skip directory creation events - we track them internally but don't send to sensor + if event.is_mkdir() { + continue; + } + let event = Arc::new(event); if let Err(e) = self.tx.send(event) { self.metrics.events.dropped(); 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(()) } diff --git a/tests/test_path_mkdir.py b/tests/test_path_mkdir.py new file mode 100644 index 00000000..88685f8f --- /dev/null +++ b/tests/test_path_mkdir.py @@ -0,0 +1,73 @@ +import os + +import pytest + +from event import Event, EventType, Process + + +@pytest.mark.parametrize("dirname", [ + pytest.param('level3', id='ASCII'), + pytest.param('café', id='French'), + pytest.param('файл', id='Cyrillic'), + pytest.param('日本語', id='Japanese'), +]) +def test_mkdir_nested(monitored_dir, server, dirname): + """ + 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. + dirname: Final directory name to test (including UTF-8 variants). + """ + process = Process.from_proc() + + # Create nested directories + test_dir = os.path.join(monitored_dir, 'level1', 'level2', dirname) + os.makedirs(test_dir, exist_ok=True) + + # Create a file in the deepest directory + test_file = os.path.join(test_dir, 'deep_file.txt') + with open(test_file, 'w') as f: + f.write('nested content') + + # Directory creation events are tracked internally but not sent to sensor + # Only the file creation event should be sent + events = [ + 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) + ignored_file = os.path.join(ignored_subdir, 'ignored.txt') + with open(ignored_file, 'w') as f: + f.write('ignored') + + # Create directory in monitored path - should be tracked + monitored_subdir = os.path.join(monitored_dir, 'monitored_subdir') + os.mkdir(monitored_subdir) + monitored_file = os.path.join(monitored_subdir, 'monitored.txt') + with open(monitored_file, 'w') as f: + f.write('monitored') + + # Only the monitored file should generate an event (directories are tracked internally) + e = Event(process=process, event_type=EventType.CREATION, + file=monitored_file, host_path=monitored_file) + + server.wait_events([e])