From 256a0fa7bb219c308eff799693d36b32b442ad6e Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Thu, 13 Aug 2026 23:43:58 +0200 Subject: [PATCH 01/13] path_link ebpf program --- fact-ebpf/src/bpf/main.c | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 94471cdd..7732b590 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -86,6 +86,50 @@ int BPF_PROG(trace_file_open, struct file* file) { return 0; } +SEC("lsm/path_link") +int BPF_PROG(trace_path_link, struct dentry* old_dentry, const struct path* new_dir, struct dentry* new_dentry) { + struct metrics_t* m = get_metrics(); + if (m == NULL) { + return 0; + } + struct submit_event_args_t args = {.metrics = &m->file_open}; + + args.metrics->total++; + + struct bound_path_t* new_path = path_read_append_d_entry((struct path*)new_dir, new_dentry); + if (new_path == NULL) { + bpf_printk("Failed to read new path"); + m->file_open.error++; + return 0; + } + args.filename = new_path->path; + + // The inode is from the old file (being linked to) + args.inode = inode_to_key(old_dentry->d_inode); + + struct dentry* parent_dentry = BPF_CORE_READ(new_dir, dentry); + struct inode* parent_inode_ptr = parent_dentry ? BPF_CORE_READ(parent_dentry, d_inode) : NULL; + args.parent_inode = inode_to_key(parent_inode_ptr); + + args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); + if (args.monitored == NOT_MONITORED) { + goto ignored; + } + + // Add the inode to tracking if monitored by parent + if (args.monitored == MONITORED_BY_PARENT) { + inode_add(&args.inode); + } + + submit_open_event(&args, FILE_ACTIVITY_CREATION); + + return 0; + +ignored: + m->file_open.ignored++; + return 0; +} + SEC("lsm/path_unlink") int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { struct metrics_t* m = get_metrics(); From f467a810d55dddd3aad0432b745c3171ec21e50a Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Thu, 13 Aug 2026 23:48:45 +0200 Subject: [PATCH 02/13] Test the new path_link program --- tests/test_path_link.py | 385 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 tests/test_path_link.py diff --git a/tests/test_path_link.py b/tests/test_path_link.py new file mode 100644 index 00000000..6434418e --- /dev/null +++ b/tests/test_path_link.py @@ -0,0 +1,385 @@ +import os + +import pytest + +from event import Event, EventType, Process + + +def test_link(monitored_dir, server): + """ + Tests the creation of a hardlink and verifies that the corresponding + event is captured by the server. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink + hardlink = os.path.join(monitored_dir, 'hardlink.txt') + os.link(original, hardlink) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + ] + + server.wait_events(events) + + +def test_multiple_hardlinks(monitored_dir, server): + """ + Tests creating multiple hardlinks to the same file. + All paths should be tracked independently. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create multiple hardlinks + link1 = os.path.join(monitored_dir, 'link1.txt') + link2 = os.path.join(monitored_dir, 'link2.txt') + link3 = os.path.join(monitored_dir, 'link3.txt') + + os.link(original, link1) + os.link(original, link2) + os.link(original, link3) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link1, + host_path=link1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link2, + host_path=link2, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=link3, + host_path=link3, + ), + ] + + server.wait_events(events) + + +def test_link_in_subdirectory(monitored_dir, server): + """ + Tests hardlinks in different subdirectories of the monitored path. + + Args: + monitored_dir: Temporary directory path for creating test files. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create subdirectories + dir1 = os.path.join(monitored_dir, 'dir1') + dir2 = os.path.join(monitored_dir, 'dir2') + os.makedirs(dir1) + os.makedirs(dir2) + + # Create original file in dir1 + original = os.path.join(dir1, 'file.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in dir2 + hardlink = os.path.join(dir2, 'file.txt') + os.link(original, hardlink) + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=hardlink, + host_path=hardlink, + ), + ] + + server.wait_events(events) + + +def test_ignored(monitored_dir, ignored_dir, server): + """ + Tests that link events creating hardlinks in ignored directories + are not captured by the server. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file in monitored directory + original = os.path.join(monitored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(original, ignored_link) + + # Create hardlink in monitored directory + monitored_link = os.path.join(monitored_dir, 'link.txt') + os.link(original, monitored_link) + + # Only the original creation and monitored hardlink should be reported + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=original, + host_path=original, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored_link, + host_path=monitored_link, + ), + ] + + server.wait_events(events) + + +def test_link_from_ignored_to_monitored(monitored_dir, ignored_dir, server): + """ + Tests creating a hardlink in a monitored path when the original file + is in an ignored path. The inode should start being tracked. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create original file in IGNORED directory + original = os.path.join(ignored_dir, 'original.txt') + with open(original, 'w') as f: + f.write('test content') + + # Create hardlink in MONITORED directory + monitored_link = os.path.join(monitored_dir, 'link.txt') + os.link(original, monitored_link) + + # Only the monitored hardlink creation should be reported + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored_link, + host_path=monitored_link, + ), + ] + + server.wait_events(events) + + +def test_access_via_unmonitored_hardlink(monitored_dir, ignored_dir, server): + """ + Tests accessing a file via an unmonitored hardlink when a monitored + hardlink exists. The inode is tracked, so access should generate an + event, but what path should be reported? + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create file in monitored directory + monitored = os.path.join(monitored_dir, 'file.txt') + with open(monitored, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(monitored, ignored_link) + + # Access via the IGNORED hardlink + with open(ignored_link, 'r') as f: + f.read() + + # Should we get an event? If so, what should host_path be? + # The inode is tracked because monitored path exists. + # Access via ignored path should either: + # 1. Report the actual ignored path (probably empty host_path) + # 2. Report the monitored path that is tracked + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + # What event do we expect here? This exposes the implementation question. + Event( + process=process, + event_type=EventType.OPEN, + file=ignored_link, + host_path=monitored, + ), # Or host_path=''? + ] + + server.wait_events(events) + + +def test_unlink_monitored_hardlink_with_ignored_remaining( + monitored_dir, ignored_dir, server +): + """ + Tests unlinking the monitored hardlink when an unmonitored hardlink + still exists. Should inode tracking be removed? + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create file in monitored directory + monitored = os.path.join(monitored_dir, 'file.txt') + with open(monitored, 'w') as f: + f.write('test content') + + # Create hardlink in ignored directory + ignored_link = os.path.join(ignored_dir, 'link.txt') + os.link(monitored, ignored_link) + + # Unlink the MONITORED path + os.unlink(monitored) + + # The inode should be removed from tracking even though + # the ignored hardlink still exists (file not deleted from filesystem) + # Verify this by trying to access via ignored link - should not generate event + with open(ignored_link, 'r') as f: + f.read() + + # Only creation and unlink events expected, no open event + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored, + host_path=monitored, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored, + host_path=monitored, + ), + ] + + server.wait_events(events) + + +def test_multiple_monitored_and_ignored_hardlinks( + monitored_dir, ignored_dir, server +): + """ + Tests complex scenario with multiple hardlinks in both monitored + and ignored paths. + + Args: + monitored_dir: Temporary directory path for creating test files. + ignored_dir: Temporary directory path that is not monitored by fact. + server: The server instance to communicate with. + """ + process = Process.from_proc() + + # Create file in monitored directory + monitored1 = os.path.join(monitored_dir, 'file1.txt') + with open(monitored1, 'w') as f: + f.write('test content') + + # Create multiple hardlinks + monitored2 = os.path.join(monitored_dir, 'file2.txt') + ignored1 = os.path.join(ignored_dir, 'file1.txt') + ignored2 = os.path.join(ignored_dir, 'file2.txt') + + os.link(monitored1, monitored2) + os.link(monitored1, ignored1) + os.link(monitored1, ignored2) + + # Unlink one monitored path + os.unlink(monitored1) + + # Access via remaining monitored path should still work + with open(monitored2, 'r') as f: + f.read() + + events = [ + Event( + process=process, + event_type=EventType.CREATION, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.CREATION, + file=monitored2, + host_path=monitored2, + ), + Event( + process=process, + event_type=EventType.UNLINK, + file=monitored1, + host_path=monitored1, + ), + Event( + process=process, + event_type=EventType.OPEN, + file=monitored2, + host_path=monitored2, + ), + ] + + server.wait_events(events) From 4a04e3cef792ef5afdbe38925d0aa5f2edce193e Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Thu, 27 Aug 2026 15:57:49 +0200 Subject: [PATCH 03/13] Remove inode only when sure it is orphaned. --- fact-ebpf/src/bpf/main.c | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 7732b590..bad2c358 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -156,8 +156,10 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { return 0; } - // We only support files with one link for now - inode_remove(&args.inode); + // Only remove from kernel map if this is the last link + if (BPF_CORE_READ(dentry, d_inode, i_nlink) == 1) { + inode_remove(&args.inode); + } submit_unlink_event(&args); return 0; @@ -282,7 +284,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // Old inode is monitored, new path is not. // If the old path is a directory userspace will remove any // subdirectories and files too. - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -294,7 +298,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // which should never happen. When the inode crosses into a new // mount, a new inode is created altogether. Still, we can cover // our bases. - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -310,7 +316,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // Old inode is monitored and will land on a path that has a // monitored parent but the path itself is not monitored, we // stop tracking the inode - inode_remove(&old_inode); + if (BPF_CORE_READ(old_dentry, d_inode, i_nlink) == 1) { + inode_remove(&old_inode); + } } break; @@ -318,7 +326,9 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, // If we landed here, the new path already has an inode that is // being tracked and is about to be overwritten, we need to remove // it from the map - inode_remove(&args.inode); + if (BPF_CORE_READ(new_dentry, d_inode, i_nlink) == 1) { + inode_remove(&args.inode); + } if (old_monitored != MONITORED_BY_INODE) { // Old inode is not monitored, but is landing in a monitored // path that uses inode tracking. From c6e0f40a414a7abda0a3ce0960af4c3412a07200 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Mon, 31 Aug 2026 16:26:10 +0200 Subject: [PATCH 04/13] address lint issues --- tests/test_path_link.py | 161 ++++++++-------------------------------- 1 file changed, 30 insertions(+), 131 deletions(-) diff --git a/tests/test_path_link.py b/tests/test_path_link.py index 6434418e..4e1c1529 100644 --- a/tests/test_path_link.py +++ b/tests/test_path_link.py @@ -1,11 +1,10 @@ import os -import pytest - from event import Event, EventType, Process +from server import EventServer -def test_link(monitored_dir, server): +def test_link(monitored_dir: str, server: EventServer): """ Tests the creation of a hardlink and verifies that the corresponding event is captured by the server. @@ -36,14 +35,14 @@ def test_link(monitored_dir, server): process=process, event_type=EventType.CREATION, file=hardlink, - host_path=hardlink, + host_path=original, ), ] server.wait_events(events) -def test_multiple_hardlinks(monitored_dir, server): +def test_multiple_hardlinks(monitored_dir: str, server: EventServer): """ Tests creating multiple hardlinks to the same file. All paths should be tracked independently. @@ -79,69 +78,26 @@ def test_multiple_hardlinks(monitored_dir, server): process=process, event_type=EventType.CREATION, file=link1, - host_path=link1, + host_path=original, ), Event( process=process, event_type=EventType.CREATION, file=link2, - host_path=link2, + host_path=original, ), Event( process=process, event_type=EventType.CREATION, file=link3, - host_path=link3, - ), - ] - - server.wait_events(events) - - -def test_link_in_subdirectory(monitored_dir, server): - """ - Tests hardlinks in different subdirectories of the monitored path. - - Args: - monitored_dir: Temporary directory path for creating test files. - server: The server instance to communicate with. - """ - process = Process.from_proc() - - # Create subdirectories - dir1 = os.path.join(monitored_dir, 'dir1') - dir2 = os.path.join(monitored_dir, 'dir2') - os.makedirs(dir1) - os.makedirs(dir2) - - # Create original file in dir1 - original = os.path.join(dir1, 'file.txt') - with open(original, 'w') as f: - f.write('test content') - - # Create hardlink in dir2 - hardlink = os.path.join(dir2, 'file.txt') - os.link(original, hardlink) - - events = [ - Event( - process=process, - event_type=EventType.CREATION, - file=original, host_path=original, ), - Event( - process=process, - event_type=EventType.CREATION, - file=hardlink, - host_path=hardlink, - ), ] server.wait_events(events) -def test_ignored(monitored_dir, ignored_dir, server): +def test_ignored(monitored_dir: str, ignored_dir: str, server: EventServer): """ Tests that link events creating hardlinks in ignored directories are not captured by the server. @@ -178,14 +134,16 @@ def test_ignored(monitored_dir, ignored_dir, server): process=process, event_type=EventType.CREATION, file=monitored_link, - host_path=monitored_link, + host_path=original, ), ] server.wait_events(events) -def test_link_from_ignored_to_monitored(monitored_dir, ignored_dir, server): +def test_link_from_ignored_to_monitored( + monitored_dir: str, ignored_dir: str, server: EventServer +): """ Tests creating a hardlink in a monitored path when the original file is in an ignored path. The inode should start being tracked. @@ -219,11 +177,13 @@ def test_link_from_ignored_to_monitored(monitored_dir, ignored_dir, server): server.wait_events(events) -def test_access_via_unmonitored_hardlink(monitored_dir, ignored_dir, server): +def test_access_via_unmonitored_hardlink( + monitored_dir: str, ignored_dir: str, server: EventServer +): """ Tests accessing a file via an unmonitored hardlink when a monitored hardlink exists. The inode is tracked, so access should generate an - event, but what path should be reported? + event Args: monitored_dir: Temporary directory path for creating test files. @@ -242,14 +202,9 @@ def test_access_via_unmonitored_hardlink(monitored_dir, ignored_dir, server): os.link(monitored, ignored_link) # Access via the IGNORED hardlink - with open(ignored_link, 'r') as f: + with open(ignored_link) as f: f.read() - # Should we get an event? If so, what should host_path be? - # The inode is tracked because monitored path exists. - # Access via ignored path should either: - # 1. Report the actual ignored path (probably empty host_path) - # 2. Report the monitored path that is tracked events = [ Event( process=process, @@ -257,7 +212,12 @@ def test_access_via_unmonitored_hardlink(monitored_dir, ignored_dir, server): file=monitored, host_path=monitored, ), - # What event do we expect here? This exposes the implementation question. + Event( + process=process, + event_type=EventType.CREATION, + file=ignored_link, + host_path=monitored, + ), Event( process=process, event_type=EventType.OPEN, @@ -270,11 +230,11 @@ def test_access_via_unmonitored_hardlink(monitored_dir, ignored_dir, server): def test_unlink_monitored_hardlink_with_ignored_remaining( - monitored_dir, ignored_dir, server + monitored_dir: str, ignored_dir: str, server: EventServer ): """ Tests unlinking the monitored hardlink when an unmonitored hardlink - still exists. Should inode tracking be removed? + still exists. Args: monitored_dir: Temporary directory path for creating test files. @@ -295,10 +255,7 @@ def test_unlink_monitored_hardlink_with_ignored_remaining( # Unlink the MONITORED path os.unlink(monitored) - # The inode should be removed from tracking even though - # the ignored hardlink still exists (file not deleted from filesystem) - # Verify this by trying to access via ignored link - should not generate event - with open(ignored_link, 'r') as f: + with open(ignored_link) as f: f.read() # Only creation and unlink events expected, no open event @@ -309,77 +266,19 @@ def test_unlink_monitored_hardlink_with_ignored_remaining( file=monitored, host_path=monitored, ), - Event( - process=process, - event_type=EventType.UNLINK, - file=monitored, - host_path=monitored, - ), - ] - - server.wait_events(events) - - -def test_multiple_monitored_and_ignored_hardlinks( - monitored_dir, ignored_dir, server -): - """ - Tests complex scenario with multiple hardlinks in both monitored - and ignored paths. - - Args: - monitored_dir: Temporary directory path for creating test files. - ignored_dir: Temporary directory path that is not monitored by fact. - server: The server instance to communicate with. - """ - process = Process.from_proc() - - # Create file in monitored directory - monitored1 = os.path.join(monitored_dir, 'file1.txt') - with open(monitored1, 'w') as f: - f.write('test content') - - # Create multiple hardlinks - monitored2 = os.path.join(monitored_dir, 'file2.txt') - ignored1 = os.path.join(ignored_dir, 'file1.txt') - ignored2 = os.path.join(ignored_dir, 'file2.txt') - - os.link(monitored1, monitored2) - os.link(monitored1, ignored1) - os.link(monitored1, ignored2) - - # Unlink one monitored path - os.unlink(monitored1) - - # Access via remaining monitored path should still work - with open(monitored2, 'r') as f: - f.read() - - events = [ - Event( - process=process, - event_type=EventType.CREATION, - file=monitored1, - host_path=monitored1, - ), Event( process=process, event_type=EventType.CREATION, - file=monitored2, - host_path=monitored2, + file=ignored_link, + host_path=monitored, ), Event( process=process, event_type=EventType.UNLINK, - file=monitored1, - host_path=monitored1, - ), - Event( - process=process, - event_type=EventType.OPEN, - file=monitored2, - host_path=monitored2, + file=monitored, + host_path=monitored, ), ] server.wait_events(events) + From 67763160ef92a09c802221972d751da1f28a97b1 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 1 Sep 2026 15:05:50 +0200 Subject: [PATCH 05/13] Inode refcount for scan+unlink --- fact/src/host_scanner.rs | 52 ++++++++++++++++++++++++++++------------ tests/test_path_link.py | 20 +++++++++++----- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index ccebbc33..e2b28da3 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -111,6 +111,7 @@ pub type IntrospectionRequest = ( pub struct HostScanner { kernel_inode_map: RefCell>, inode_map: RefCell, + usage_count: RefCell>, paths: watch::Receiver>, scan_interval: watch::Receiver, @@ -141,6 +142,7 @@ impl HostScanner { let mut host_scanner = HostScanner { kernel_inode_map, inode_map, + usage_count: RefCell::new(HashMap::new()), paths, scan_interval, rx, @@ -185,6 +187,10 @@ impl HostScanner { let start = Instant::now(); self.metrics.scan_inc(ScanLabels::Scans); + // Clear usage counts before scanning; will be + // repopulated as we encounter monitored inodes. + self.usage_count.borrow_mut().clear(); + // Cleanup any items that are either: // * Not configured to be monitored anymore. // * Are configured to be monitored but no longer are found in @@ -200,7 +206,7 @@ impl HostScanner { }); for path in &self.paths_patterns { - self.scan_inner(path)?; + self.scan_inner(path, true)?; } let duration = start.elapsed(); self.metrics.scan_duration.observe(duration.as_secs_f64()); @@ -212,7 +218,7 @@ impl HostScanner { Ok(()) } - fn scan_inner(&self, path: &Path) -> anyhow::Result<()> { + fn scan_inner(&self, path: &Path, update_usage_count: bool) -> anyhow::Result<()> { self.metrics.scan_inc(ScanLabels::ElementsScanned); let Some(glob_str) = path.to_str() else { @@ -241,7 +247,7 @@ impl HostScanner { self.metrics.scan_inc(ScanLabels::FileScanned); } else if metadata.is_symlink() { self.metrics.scan_inc(ScanLabels::SymlinkScanned); - self.scan_symlink(&path); + self.scan_symlink(&path, update_usage_count); } else if metadata.is_dir() { self.metrics.scan_inc(ScanLabels::DirectoryScanned); } else { @@ -249,13 +255,13 @@ impl HostScanner { continue; } - self.update_entry(&path, &metadata) + self.update_entry(&path, &metadata, update_usage_count) .with_context(|| format!("Failed to update entry for {}", path.display()))?; } Ok(()) } - fn scan_symlink(&self, path: &Path) { + fn scan_symlink(&self, path: &Path, update_usage_count: bool) { let target = match path.read_link() { Ok(p) => { if p.has_root() { @@ -272,7 +278,7 @@ impl HostScanner { match target.metadata() { Ok(metadata) => { - if let Err(e) = self.update_entry(path, &metadata) { + if let Err(e) = self.update_entry(path, &metadata, update_usage_count) { warn!("Failed to update symlink entry for {}: {e}", path.display()); } } @@ -312,7 +318,7 @@ impl HostScanner { .collect::>(); for pattern in scan_set.iter().map(|index| &self.paths_patterns[*index]) { - self.scan_inner(pattern)?; + self.scan_inner(pattern, false)?; } self.metrics @@ -321,21 +327,25 @@ impl HostScanner { Ok(()) } - fn update_entry(&self, path: &Path, metadata: &Metadata) -> anyhow::Result<()> { + fn update_entry(&self, path: &Path, metadata: &Metadata, update_usage_count: bool) -> anyhow::Result<()> { let inode = inode_key_t { inode: metadata.st_ino(), dev: metadata.st_dev(), }; let host_path = host_info::remove_host_mount(path); - self.update_entry_with_inode(inode, host_path.to_path_buf())?; + self.update_entry_with_inode(inode, host_path.to_path_buf(), update_usage_count)?; debug!("Added entry for {}: {inode:?}", path.display()); Ok(()) } /// Similar to update_entry except we are are directly using the inode instead of the path. - fn update_entry_with_inode(&self, inode: inode_key_t, path: PathBuf) -> anyhow::Result<()> { + fn update_entry_with_inode(&self, inode: inode_key_t, path: PathBuf, update_usage_count: bool) -> anyhow::Result<()> { + if update_usage_count { + self.usage_count.borrow_mut().entry(inode).and_modify(|c| *c += 1).or_insert(1); + } + let mut inode_map = self.inode_map.borrow_mut(); match inode_map.get_mut(&inode) { Some(p) => { @@ -401,7 +411,7 @@ You can increase this limit with: match self.build_host_path(event) { Some(host_path) => self - .update_entry_with_inode(*inode, host_path) + .update_entry_with_inode(*inode, host_path, true) .with_context(|| { format!( "Failed to add creation event entry for {}", @@ -413,17 +423,29 @@ You can increase this limit with: } } - /// Handle unlink events by removing the inode from the inode->path map. - /// - /// The probe already cleared the kernel inode map. + /// Handle unlink events by removing the inode from the inode->path map, + /// when its usage count falls to 0. fn handle_unlink_event(&self, event: &Event) { + self.metrics.scan_inc(ScanLabels::FileRemoved); + let inode = event.get_inode(); + let mut usage_count = self.usage_count.borrow_mut(); + if let Some(count) = usage_count.get_mut(inode) { + *count -= 1; + if *count > 0 { + return; + } + usage_count.remove(inode); + } + if self.inode_map.borrow_mut().remove(inode).is_some() { self.metrics.scan_inc(ScanLabels::InodeRemoved); } - self.metrics.scan_inc(ScanLabels::FileRemoved); + if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { + warn!("Failed to remove inode kernel entry: {e:?}"); + } } fn handle_rename_event(&self, event: &mut Event) { diff --git a/tests/test_path_link.py b/tests/test_path_link.py index 4e1c1529..3109a0b3 100644 --- a/tests/test_path_link.py +++ b/tests/test_path_link.py @@ -100,7 +100,7 @@ def test_multiple_hardlinks(monitored_dir: str, server: EventServer): def test_ignored(monitored_dir: str, ignored_dir: str, server: EventServer): """ Tests that link events creating hardlinks in ignored directories - are not captured by the server. + is captured via inode tracking. Args: monitored_dir: Temporary directory path for creating test files. @@ -122,7 +122,9 @@ def test_ignored(monitored_dir: str, ignored_dir: str, server: EventServer): monitored_link = os.path.join(monitored_dir, 'link.txt') os.link(original, monitored_link) - # Only the original creation and monitored hardlink should be reported + # The hardlink in the ignored directory must be reported with the + # original host_path, since this is the basis of how inode tracking + # works. events = [ Event( process=process, @@ -130,6 +132,12 @@ def test_ignored(monitored_dir: str, ignored_dir: str, server: EventServer): file=original, host_path=original, ), + Event( + process=process, + event_type=EventType.CREATION, + file=ignored_link, + host_path=original, + ), Event( process=process, event_type=EventType.CREATION, @@ -183,7 +191,7 @@ def test_access_via_unmonitored_hardlink( """ Tests accessing a file via an unmonitored hardlink when a monitored hardlink exists. The inode is tracked, so access should generate an - event + event. Args: monitored_dir: Temporary directory path for creating test files. @@ -202,8 +210,8 @@ def test_access_via_unmonitored_hardlink( os.link(monitored, ignored_link) # Access via the IGNORED hardlink - with open(ignored_link) as f: - f.read() + with open(ignored_link, 'w') as f: + f.write('This is a test') events = [ Event( @@ -223,7 +231,7 @@ def test_access_via_unmonitored_hardlink( event_type=EventType.OPEN, file=ignored_link, host_path=monitored, - ), # Or host_path=''? + ), ] server.wait_events(events) From d84f644398f5877782100a59d3c81c38ed07c7af Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 1 Sep 2026 16:09:34 +0200 Subject: [PATCH 06/13] Inode refcount for rename --- fact/src/host_scanner.rs | 70 +++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index e2b28da3..7e4807b0 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -429,23 +429,30 @@ You can increase this limit with: self.metrics.scan_inc(ScanLabels::FileRemoved); let inode = event.get_inode(); + if self.unref_inode(inode) { + self.metrics.scan_inc(ScanLabels::InodeRemoved); + } + } + /// Decrement the usage count for an inode and remove it from the + /// inode and kernel maps if the count falls to zero. + /// + /// Returns true if the inode was removed + fn unref_inode(&self, inode: &inode_key_t) -> bool { let mut usage_count = self.usage_count.borrow_mut(); if let Some(count) = usage_count.get_mut(inode) { *count -= 1; if *count > 0 { - return; + return false; } usage_count.remove(inode); } - if self.inode_map.borrow_mut().remove(inode).is_some() { - self.metrics.scan_inc(ScanLabels::InodeRemoved); - } - if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { warn!("Failed to remove inode kernel entry: {e:?}"); } + + self.inode_map.borrow_mut().remove(inode).is_some() } fn handle_rename_event(&self, event: &mut Event) { @@ -455,15 +462,17 @@ You can increase this limit with: // place of an existing, tracked file. We need to remove the // inode we are landing on and put the associated host path in // the old inode. - let mut inode_map = self.inode_map.borrow_mut(); - let Some(path) = inode_map.remove(event.get_inode()) else { + let inode = event.get_inode(); + let Some(path) = self.inode_map.borrow().get(inode).cloned() else { warn!("Old path was not found for inode tracked event"); return; }; + self.unref_inode(inode); + let Some(old_inode) = event.get_old_inode() else { unreachable!("old inode not found for rename event"); }; - inode_map.insert(*old_inode, path); + self.inode_map.borrow_mut().insert(*old_inode, path); } monitored_t::NOT_MONITORED if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => @@ -474,14 +483,16 @@ You can increase this limit with: warn!("Rename event did not have old host path for inode tracked item"); return; }; - self.inode_map.borrow_mut().retain(|inode, path| { - if !path.starts_with(old_host_path) { - return true; - } - - let _ = self.kernel_inode_map.borrow_mut().remove(inode); - false - }); + let inodes_to_remove: Vec<_> = self + .inode_map + .borrow() + .iter() + .filter(|(_, path)| path.starts_with(old_host_path)) + .map(|(inode, _)| *inode) + .collect(); + for inode in inodes_to_remove { + self.unref_inode(&inode); + } } monitored_t::NOT_MONITORED => { // The new path is not monitored and the old path is most likely @@ -490,11 +501,10 @@ You can increase this limit with: monitored_t::MONITORED_BY_PARENT if !event.get_inode().empty() => { // The parent for the target is monitored, but the file itself // is not. Remove the entry for the old file from the map. - self.inode_map.borrow_mut().remove( - event - .get_old_inode() - .expect("rename event did not have old inode"), - ); + let old_inode = event + .get_old_inode() + .expect("rename event did not have old inode"); + self.unref_inode(old_inode); } monitored_t::MONITORED_BY_PARENT if event.get_old_monitored() == Some(monitored_t::MONITORED_BY_INODE) => @@ -533,15 +543,15 @@ You can increase this limit with: event.set_host_path(new_host_path); } else { // New path is not tracked, remove old entries - inode_map.retain(|inode, path| { - if !path.starts_with(old_host_path) { - return true; - } - if let Err(e) = self.kernel_inode_map.borrow_mut().remove(inode) { - warn!("Failed to remove inode kernel entry: {e:?}"); - } - false - }); + let inodes_to_remove: Vec<_> = inode_map + .iter() + .filter(|(_, path)| path.starts_with(old_host_path)) + .map(|(inode, _)| *inode) + .collect(); + drop(inode_map); + for inode in inodes_to_remove { + self.unref_inode(&inode); + } } } monitored_t::MONITORED_BY_PARENT => { From 006a2f71c1b2e0b42214513be73391db1cc4c54e Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 1 Sep 2026 16:35:41 +0200 Subject: [PATCH 07/13] Update 'link' to send is_monitored info --- fact-ebpf/src/bpf/events.h | 11 +++--- fact-ebpf/src/bpf/main.c | 72 ++++++++++++++++++++++++++++---------- fact-ebpf/src/bpf/types.h | 2 ++ 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index a48eb953..77b2266c 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -114,15 +114,16 @@ __always_inline static void submit_ownership_event(struct submit_event_args_t* a __submit_event(args, path_hooks_support_bpf_d_path); } -__always_inline static void submit_rename_event(struct submit_event_args_t* args, - const char old_filename[PATH_MAX], - inode_key_t* old_inode, - monitored_t old_monitored) { +__always_inline static void submit_move_event(struct submit_event_args_t* args, + file_activity_type_t event_type, + const char old_filename[PATH_MAX], + inode_key_t* old_inode, + monitored_t old_monitored) { if (!reserve_event(args)) { return; } - args->event->type = FILE_ACTIVITY_RENAME; + args->event->type = event_type; bpf_probe_read_str(args->event->from.filename, PATH_MAX, old_filename); inode_copy(&args->event->from.inode, old_inode); args->event->from.monitored = old_monitored; diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index bad2c358..4e1f2900 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -92,41 +92,75 @@ int BPF_PROG(trace_path_link, struct dentry* old_dentry, const struct path* new_ if (m == NULL) { return 0; } - struct submit_event_args_t args = {.metrics = &m->file_open}; + struct submit_event_args_t args = {.metrics = &m->path_link}; args.metrics->total++; struct bound_path_t* new_path = path_read_append_d_entry((struct path*)new_dir, new_dentry); if (new_path == NULL) { bpf_printk("Failed to read new path"); - m->file_open.error++; - return 0; + goto error; } args.filename = new_path->path; - // The inode is from the old file (being linked to) + // Construct a path for the old dentry's parent directory. + // Hard links cannot cross filesystem boundaries, so we reuse the mount + // from new_dir. + struct path old_dir = { + .mnt = BPF_CORE_READ(new_dir, mnt), + .dentry = BPF_CORE_READ(old_dentry, d_parent), + }; + struct bound_path_t* old_path = path_read_alt_append_d_entry(&old_dir, old_dentry); + if (old_path == NULL) { + bpf_printk("Failed to read old path"); + goto error; + } + + // The inode is from the old file (being linked to), which is the same + // inode the new link will point to. args.inode = inode_to_key(old_dentry->d_inode); + args.parent_inode = inode_to_key(new_dir->dentry->d_inode); + args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); - struct dentry* parent_dentry = BPF_CORE_READ(new_dir, dentry); - struct inode* parent_inode_ptr = parent_dentry ? BPF_CORE_READ(parent_dentry, d_inode) : NULL; - args.parent_inode = inode_to_key(parent_inode_ptr); + inode_key_t old_inode = inode_to_key(old_dentry->d_inode); + monitored_t old_monitored = is_monitored(&old_inode, old_path, NULL); - args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); - if (args.monitored == NOT_MONITORED) { - goto ignored; - } + // Handle inode tracking based on monitoring status of both old and new + // paths. Unlike rename, the old file still exists after a link, so we + // never remove the old inode from tracking. + switch (args.monitored) { + case NOT_MONITORED: + if (old_monitored == NOT_MONITORED) { + m->path_link.ignored++; + return 0; + } + break; - // Add the inode to tracking if monitored by parent - if (args.monitored == MONITORED_BY_PARENT) { - inode_add(&args.inode); - } + case MONITORED_BY_PATH: + break; + + case MONITORED_BY_PARENT: + if (old_monitored != MONITORED_BY_INODE) { + // Old inode is not tracked, new parent is monitored. + // Track the inode so userspace can verify. + inode_add(&old_inode); + } + break; - submit_open_event(&args, FILE_ACTIVITY_CREATION); + case MONITORED_BY_INODE: + if (old_monitored != MONITORED_BY_INODE) { + // Old inode is not tracked but the new path lands on a tracked + // inode location, start tracking. + inode_add(&old_inode); + } + break; + } + submit_move_event(&args, FILE_ACTIVITY_LINK, old_path->path, &old_inode, old_monitored); return 0; -ignored: - m->file_open.ignored++; +error: + args.metrics->error++; return 0; } @@ -337,7 +371,7 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, break; } - submit_rename_event(&args, old_path->path, &old_inode, old_monitored); + submit_move_event(&args, FILE_ACTIVITY_RENAME, old_path->path, &old_inode, old_monitored); return 0; error: diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index 4037861e..d54c5ee5 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -106,6 +106,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_INIT = -1, FILE_ACTIVITY_OPEN = 0, FILE_ACTIVITY_CREATION, + FILE_ACTIVITY_LINK, FILE_ACTIVITY_UNLINK, FILE_ACTIVITY_CHMOD, FILE_ACTIVITY_CHOWN, @@ -187,6 +188,7 @@ struct metrics_d_instantiate_t { struct metrics_t { struct metrics_by_hook_t file_open; + struct metrics_by_hook_t path_link; struct metrics_by_hook_t path_unlink; struct metrics_by_hook_t path_chmod; struct metrics_by_hook_t path_chown; From 1126a28719d3c95ea8c6b6440196bbcb780d5c6c Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Tue, 1 Sep 2026 17:39:17 +0200 Subject: [PATCH 08/13] Handle 'link' events, sends CREATE messages --- fact/src/event/mod.rs | 29 ++++++++++++++++-- fact/src/host_scanner.rs | 64 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 3bed7a04..c2ca7229 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -154,6 +154,10 @@ impl Event { matches!(self.file, FileData::Unlink(_) | FileData::RmDir(_)) } + pub fn is_link(&self) -> bool { + matches!(self.file, FileData::Link { .. }) + } + pub fn is_rename(&self) -> bool { matches!(self.file, FileData::Rename { .. }) } @@ -183,6 +187,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -204,6 +209,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -236,6 +242,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -265,6 +272,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -298,6 +306,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -313,9 +322,9 @@ impl Event { /// operations that have one, like rename. pub fn set_old_host_path(&mut self, host_path: PathBuf) { match &mut self.file { - FileData::Rename { old: from, .. } | FileData::MoveMount { from, .. } => { - from.host_file = host_path - } + FileData::Rename { old: from, .. } + | FileData::MoveMount { from, .. } + | FileData::Link { old: from, .. } => from.host_file = host_path, _ => unreachable!("Called set_old_host_path on invalid type"), } } @@ -329,6 +338,7 @@ impl Event { | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) + | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -444,6 +454,10 @@ pub enum FileData { Creation(BaseFileData), MkDir(BaseFileData), RmDir(BaseFileData), + Link { + new: BaseFileData, + old: BaseFileData, + }, Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), @@ -488,6 +502,10 @@ impl FileData { 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_LINK => { + let old = read_from_data(extra_data); + FileData::Link { new: inner, old } + } file_activity_type_t::DIR_ACTIVITY_UNLINK => FileData::RmDir(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), file_activity_type_t::FILE_ACTIVITY_CHMOD => { @@ -618,6 +636,11 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileOwnershipChange::from(event); fact_api::file_activity::File::Ownership(f_act) } + FileData::Link { new, old: _ } => { + let activity = Some(fact_api::FileActivityBase::from(new)); + let f_act = fact_api::FileCreation { activity }; + fact_api::file_activity::File::Creation(f_act) + } FileData::Rename { new, old } => { let f_act = fact_api::FileRename { new: Some(new.into()), diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 7e4807b0..6f4cfb71 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -327,7 +327,12 @@ impl HostScanner { Ok(()) } - fn update_entry(&self, path: &Path, metadata: &Metadata, update_usage_count: bool) -> anyhow::Result<()> { + fn update_entry( + &self, + path: &Path, + metadata: &Metadata, + update_usage_count: bool, + ) -> anyhow::Result<()> { let inode = inode_key_t { inode: metadata.st_ino(), dev: metadata.st_dev(), @@ -341,9 +346,18 @@ impl HostScanner { } /// Similar to update_entry except we are are directly using the inode instead of the path. - fn update_entry_with_inode(&self, inode: inode_key_t, path: PathBuf, update_usage_count: bool) -> anyhow::Result<()> { + fn update_entry_with_inode( + &self, + inode: inode_key_t, + path: PathBuf, + update_usage_count: bool, + ) -> anyhow::Result<()> { if update_usage_count { - self.usage_count.borrow_mut().entry(inode).and_modify(|c| *c += 1).or_insert(1); + self.usage_count + .borrow_mut() + .entry(inode) + .and_modify(|c| *c += 1) + .or_insert(1); } let mut inode_map = self.inode_map.borrow_mut(); @@ -455,6 +469,48 @@ You can increase this limit with: self.inode_map.borrow_mut().remove(inode).is_some() } + /// Handle link events by adding the new link to the inode map. + /// + /// This is similar to `handle_rename_event`, except that the old + /// entry is not dereferenced in the process (the original file + /// still exists after a link). + fn handle_link_event(&self, event: &mut Event) { + match event.get_monitored() { + monitored_t::MONITORED_BY_INODE => { + // The inode is already tracked, the new link just adds + // another reference to it. + let inode = event.get_inode(); + self.usage_count + .borrow_mut() + .entry(*inode) + .and_modify(|c| *c += 1) + .or_insert(1); + } + monitored_t::NOT_MONITORED => { + // The new path is not monitored, nothing to do. + } + monitored_t::MONITORED_BY_PARENT => { + // The parent for the target is monitored. We need to + // figure out the host path and check if we should track + // the new link. + if let Some(host_path) = self.build_host_path(event) { + if self.paths_globset.is_match(&host_path) { + let inode = *event.get_inode(); + if let Err(e) = self.update_entry_with_inode(inode, host_path.clone(), true) + { + warn!("Failed to add link event entry: {e}"); + } + event.set_host_path(host_path); + } + } + } + monitored_t::MONITORED_BY_PATH => { + // Nothing to do here, no inode tracking is involved. + } + _ => unreachable!("Invalid monitored value"), + } + } + fn handle_rename_event(&self, event: &mut Event) { match event.get_monitored() { monitored_t::MONITORED_BY_INODE => { @@ -695,6 +751,8 @@ You can increase this limit with: warn!("Failed to handle symlink event: {e:?}"); } + if event.is_link() { self.handle_link_event(&mut event); } + if event.is_rename() { self.handle_rename_event(&mut event); } // Before sending the event forward, we need to check From 48dbd88788eaa6f124d1c2a66cb0687a10ddf622 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 2 Sep 2026 17:34:03 +0200 Subject: [PATCH 09/13] newline format fix --- tests/test_path_link.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_path_link.py b/tests/test_path_link.py index 3109a0b3..f71deb71 100644 --- a/tests/test_path_link.py +++ b/tests/test_path_link.py @@ -289,4 +289,3 @@ def test_unlink_monitored_hardlink_with_ignored_remaining( ] server.wait_events(events) - From 2d951a4efd3d05bc7f1d37663e0fff701dad4dc1 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 2 Sep 2026 17:35:31 +0200 Subject: [PATCH 10/13] Revert to only include new_dentry for path_link --- fact-ebpf/src/bpf/events.h | 20 +++++++++---- fact-ebpf/src/bpf/main.c | 61 +++++++------------------------------- fact/src/event/mod.rs | 35 +++++++++------------- fact/src/host_scanner.rs | 6 +--- 4 files changed, 39 insertions(+), 83 deletions(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 77b2266c..1a1f96a7 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -73,6 +73,15 @@ __always_inline static void submit_open_event(struct submit_event_args_t* args, __submit_event(args, true); } +__always_inline static void submit_link_event(struct submit_event_args_t* args) { + if (!reserve_event(args)) { + return; + } + args->event->type = FILE_ACTIVITY_LINK; + + __submit_event(args, path_hooks_support_bpf_d_path); +} + __always_inline static void submit_unlink_event(struct submit_event_args_t* args) { if (!reserve_event(args)) { return; @@ -114,16 +123,15 @@ __always_inline static void submit_ownership_event(struct submit_event_args_t* a __submit_event(args, path_hooks_support_bpf_d_path); } -__always_inline static void submit_move_event(struct submit_event_args_t* args, - file_activity_type_t event_type, - const char old_filename[PATH_MAX], - inode_key_t* old_inode, - monitored_t old_monitored) { +__always_inline static void submit_rename_event(struct submit_event_args_t* args, + const char old_filename[PATH_MAX], + inode_key_t* old_inode, + monitored_t old_monitored) { if (!reserve_event(args)) { return; } - args->event->type = event_type; + args->event->type = FILE_ACTIVITY_RENAME; bpf_probe_read_str(args->event->from.filename, PATH_MAX, old_filename); inode_copy(&args->event->from.inode, old_inode); args->event->from.monitored = old_monitored; diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 4e1f2900..8d5aae7d 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -99,68 +99,27 @@ int BPF_PROG(trace_path_link, struct dentry* old_dentry, const struct path* new_ struct bound_path_t* new_path = path_read_append_d_entry((struct path*)new_dir, new_dentry); if (new_path == NULL) { bpf_printk("Failed to read new path"); - goto error; + args.metrics->error++; + return 0; } args.filename = new_path->path; - // Construct a path for the old dentry's parent directory. - // Hard links cannot cross filesystem boundaries, so we reuse the mount - // from new_dir. - struct path old_dir = { - .mnt = BPF_CORE_READ(new_dir, mnt), - .dentry = BPF_CORE_READ(old_dentry, d_parent), - }; - struct bound_path_t* old_path = path_read_alt_append_d_entry(&old_dir, old_dentry); - if (old_path == NULL) { - bpf_printk("Failed to read old path"); - goto error; - } - // The inode is from the old file (being linked to), which is the same // inode the new link will point to. args.inode = inode_to_key(old_dentry->d_inode); args.parent_inode = inode_to_key(new_dir->dentry->d_inode); args.monitored = is_monitored(&args.inode, new_path, &args.parent_inode); - inode_key_t old_inode = inode_to_key(old_dentry->d_inode); - monitored_t old_monitored = is_monitored(&old_inode, old_path, NULL); - - // Handle inode tracking based on monitoring status of both old and new - // paths. Unlike rename, the old file still exists after a link, so we - // never remove the old inode from tracking. - switch (args.monitored) { - case NOT_MONITORED: - if (old_monitored == NOT_MONITORED) { - m->path_link.ignored++; - return 0; - } - break; - - case MONITORED_BY_PATH: - break; - - case MONITORED_BY_PARENT: - if (old_monitored != MONITORED_BY_INODE) { - // Old inode is not tracked, new parent is monitored. - // Track the inode so userspace can verify. - inode_add(&old_inode); - } - break; - - case MONITORED_BY_INODE: - if (old_monitored != MONITORED_BY_INODE) { - // Old inode is not tracked but the new path lands on a tracked - // inode location, start tracking. - inode_add(&old_inode); - } - break; + if (args.monitored == NOT_MONITORED) { + args.metrics->ignored++; + return 0; } - submit_move_event(&args, FILE_ACTIVITY_LINK, old_path->path, &old_inode, old_monitored); - return 0; + if (args.monitored == MONITORED_BY_PARENT) { + inode_add(&args.inode); + } -error: - args.metrics->error++; + submit_link_event(&args); return 0; } @@ -371,7 +330,7 @@ int BPF_PROG(trace_path_rename, struct path* old_dir, break; } - submit_move_event(&args, FILE_ACTIVITY_RENAME, old_path->path, &old_inode, old_monitored); + submit_rename_event(&args, old_path->path, &old_inode, old_monitored); return 0; error: diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index c2ca7229..4278474d 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -184,10 +184,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -206,10 +206,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -239,10 +239,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -269,10 +269,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -303,10 +303,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -323,8 +323,7 @@ impl Event { pub fn set_old_host_path(&mut self, host_path: PathBuf) { match &mut self.file { FileData::Rename { old: from, .. } - | FileData::MoveMount { from, .. } - | FileData::Link { old: from, .. } => from.host_file = host_path, + | FileData::MoveMount { from, .. } => from.host_file = host_path, _ => unreachable!("Called set_old_host_path on invalid type"), } } @@ -335,10 +334,10 @@ impl Event { | FileData::Creation(inner) | FileData::MkDir(inner) | FileData::RmDir(inner) + | FileData::Link(inner) | FileData::Unlink(inner) | FileData::Chmod(ChmodFileData { inner, .. }) | FileData::Chown(ChownFileData { inner, .. }) - | FileData::Link { new: inner, .. } | FileData::Rename { new: inner, .. } | FileData::MoveMount { to: inner, .. } | FileData::Mount(inner) @@ -454,10 +453,7 @@ pub enum FileData { Creation(BaseFileData), MkDir(BaseFileData), RmDir(BaseFileData), - Link { - new: BaseFileData, - old: BaseFileData, - }, + Link(BaseFileData), Unlink(BaseFileData), Chmod(ChmodFileData), Chown(ChownFileData), @@ -502,11 +498,8 @@ impl FileData { 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_LINK => { - let old = read_from_data(extra_data); - FileData::Link { new: inner, old } - } file_activity_type_t::DIR_ACTIVITY_UNLINK => FileData::RmDir(inner), + file_activity_type_t::FILE_ACTIVITY_LINK => FileData::Link(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), file_activity_type_t::FILE_ACTIVITY_CHMOD => { let data = ChmodFileData { @@ -623,6 +616,11 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileXattrChange::from(event); fact_api::file_activity::File::XattrRemove(f_act) } + FileData::Link(event) => { + let activity = Some(fact_api::FileActivityBase::from(event)); + let f_act = fact_api::FileCreation { activity }; + fact_api::file_activity::File::Creation(f_act) + } FileData::Unlink(event) => { let activity = Some(fact_api::FileActivityBase::from(event)); let f_act = fact_api::FileUnlink { activity }; @@ -636,11 +634,6 @@ impl From for fact_api::file_activity::File { let f_act = fact_api::FileOwnershipChange::from(event); fact_api::file_activity::File::Ownership(f_act) } - FileData::Link { new, old: _ } => { - let activity = Some(fact_api::FileActivityBase::from(new)); - let f_act = fact_api::FileCreation { activity }; - fact_api::file_activity::File::Creation(f_act) - } FileData::Rename { new, old } => { let f_act = fact_api::FileRename { new: Some(new.into()), diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index 6f4cfb71..a3a8b82d 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -469,11 +469,7 @@ You can increase this limit with: self.inode_map.borrow_mut().remove(inode).is_some() } - /// Handle link events by adding the new link to the inode map. - /// - /// This is similar to `handle_rename_event`, except that the old - /// entry is not dereferenced in the process (the original file - /// still exists after a link). + /// Handle link events by potentially adding the new link to the inode map. fn handle_link_event(&self, event: &mut Event) { match event.get_monitored() { monitored_t::MONITORED_BY_INODE => { From a523c822ef873e3b3475611f6b7ee73b182c06db Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 2 Sep 2026 17:46:17 +0200 Subject: [PATCH 11/13] Refactor if statement --- fact/src/host_scanner.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index a3a8b82d..ee011905 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -489,15 +489,14 @@ You can increase this limit with: // The parent for the target is monitored. We need to // figure out the host path and check if we should track // the new link. - if let Some(host_path) = self.build_host_path(event) { - if self.paths_globset.is_match(&host_path) { - let inode = *event.get_inode(); - if let Err(e) = self.update_entry_with_inode(inode, host_path.clone(), true) - { - warn!("Failed to add link event entry: {e}"); - } - event.set_host_path(host_path); + if let Some(host_path) = self.build_host_path(event) + && self.paths_globset.is_match(&host_path) + { + let inode = *event.get_inode(); + if let Err(e) = self.update_entry_with_inode(inode, host_path.clone(), true) { + warn!("Failed to add link event entry: {e}"); } + event.set_host_path(host_path); } } monitored_t::MONITORED_BY_PATH => { From 3ed2fa8886a52e8d0e5f177332a8ed53cab77d07 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 2 Sep 2026 17:46:38 +0200 Subject: [PATCH 12/13] Formatting --- fact/src/event/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 4278474d..27191f3d 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -322,8 +322,9 @@ impl Event { /// operations that have one, like rename. pub fn set_old_host_path(&mut self, host_path: PathBuf) { match &mut self.file { - FileData::Rename { old: from, .. } - | FileData::MoveMount { from, .. } => from.host_file = host_path, + FileData::Rename { old: from, .. } | FileData::MoveMount { from, .. } => { + from.host_file = host_path + } _ => unreachable!("Called set_old_host_path on invalid type"), } } @@ -574,6 +575,7 @@ impl FileData { FileData::Creation(_) => "creation", FileData::MkDir(_) => "mkdir", FileData::RmDir(_) => "rmdir", + FileData::Link(_) => "link", FileData::Unlink(_) => "unlink", FileData::Chmod(_) => "permission", FileData::Chown(_) => "ownership", @@ -669,6 +671,7 @@ impl From for opentelemetry::logs::AnyValue { | FileData::RmDir(data) | FileData::Mount(data) | FileData::Umount(data) + | FileData::Link(data) | FileData::Unlink(data) => AnyValue::from(data), FileData::Chmod(data) => AnyValue::from(data), FileData::Chown(data) => AnyValue::from(data), From 67297f9f3aad22173e9fe8f9dbec44e458644444 Mon Sep 17 00:00:00 2001 From: Olivier Valentin Date: Wed, 2 Sep 2026 17:46:53 +0200 Subject: [PATCH 13/13] Register path_link metrics --- fact/src/metrics/kernel_metrics.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/fact/src/metrics/kernel_metrics.rs b/fact/src/metrics/kernel_metrics.rs index 59ad1d38..b65f44e7 100644 --- a/fact/src/metrics/kernel_metrics.rs +++ b/fact/src/metrics/kernel_metrics.rs @@ -60,6 +60,7 @@ macro_rules! define_kernel_metrics { define_kernel_metrics!( file_open, + path_link, path_unlink, path_chmod, path_chown,