diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 12d5e86b..4279272c 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(); @@ -112,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; @@ -238,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; @@ -250,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; @@ -266,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; @@ -274,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. 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)