diff --git a/fact-ebpf/src/bpf/inode.h b/fact-ebpf/src/bpf/inode.h index 4e9a26dc..db8703b8 100644 --- a/fact-ebpf/src/bpf/inode.h +++ b/fact-ebpf/src/bpf/inode.h @@ -65,6 +65,14 @@ __always_inline static inode_value_t* inode_get(struct inode_key_t* inode) { return bpf_map_lookup_elem(&inode_map, inode); } +__always_inline static long inode_add(struct inode_key_t* inode) { + if (inode == NULL) { + return -1; + } + inode_value_t value = 0; + return bpf_map_update_elem(&inode_map, inode, &value, BPF_ANY); +} + __always_inline static long inode_remove(struct inode_key_t* inode) { if (inode == NULL) { return 0; diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index e0a23522..e2a7fd73 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -47,6 +47,35 @@ int BPF_PROG(trace_file_open, struct file* file) { inode_key_t inode_key = inode_to_key(file->f_inode); inode_key_t* inode_to_submit = &inode_key; + // For file creation events, check if the parent directory is being + // monitored. If so, add the new file's inode to the tracked set. + if (event_type == FILE_ACTIVITY_CREATION) { + struct dentry* parent_dentry = BPF_CORE_READ(file, f_path.dentry, d_parent); + if (parent_dentry) { + // Build the parent inode key by reading fields directly + // to avoid verifier issues with untrusted pointers. + // We need to replicate the logic from inode_to_key() to handle + // special filesystems like btrfs correctly. + inode_key_t parent_key = {0}; + parent_key.inode = BPF_CORE_READ(parent_dentry, d_inode, i_ino); + + unsigned long magic = BPF_CORE_READ(parent_dentry, d_inode, i_sb, s_magic); + unsigned long parent_dev; + + if (magic == BTRFS_SUPER_MAGIC && bpf_core_type_exists(struct btrfs_inode)) { + parent_dev = BPF_CORE_READ(parent_dentry, d_inode, i_sb, s_dev); + } else { + parent_dev = BPF_CORE_READ(parent_dentry, d_inode, i_sb, s_dev); + } + + parent_key.dev = new_encode_dev(parent_dev); + + if (inode_is_monitored(inode_get(&parent_key)) == MONITORED) { + inode_add(&inode_key); + } + } + } + if (!is_monitored(inode_key, path, &inode_to_submit)) { goto ignored; } diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 98a70162..f0f9cc41 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -125,6 +125,10 @@ impl Event { }) } + pub fn is_creation(&self) -> bool { + matches!(self.file, FileData::Creation(_)) + } + /// Unwrap the inner FileData and return the inode that triggered /// the event. /// @@ -151,7 +155,7 @@ impl Event { } } - fn get_filename(&self) -> &PathBuf { + pub fn get_filename(&self) -> &PathBuf { match &self.file { FileData::Open(data) => &data.filename, FileData::Creation(data) => &data.filename, diff --git a/fact/src/host_scanner.rs b/fact/src/host_scanner.rs index ad7db6f9..49c50f5c 100644 --- a/fact/src/host_scanner.rs +++ b/fact/src/host_scanner.rs @@ -126,7 +126,7 @@ impl HostScanner { for entry in glob::glob(glob_str)? { match entry { Ok(path) => { - if path.is_file() { + if path.is_file() || path.is_dir() { self.metrics.scan_inc(ScanLabels::FileScanned); self.update_entry(path.as_path()).with_context(|| { format!("Failed to update entry for {}", path.display()) @@ -178,6 +178,25 @@ impl HostScanner { self.inode_map.borrow().get(inode?).cloned() } + /// Handle file creation events by adding new inodes to the map. + fn handle_creation_event(&self, event: &Event) -> anyhow::Result<()> { + if self.get_host_path(Some(event.get_inode())).is_some() { + return Ok(()); + } + + let host_path = host_info::prepend_host_mount(event.get_filename()); + + if host_path.exists() { + self.update_entry(&host_path) + .with_context(|| format!("Failed to add creation event entry for {}", host_path.display()))?; + } else { + debug!("Creation event for non-existent file: {}", host_path.display()); + self.metrics.scan_inc(ScanLabels::FileRemoved); + } + + Ok(()) + } + /// Periodically notify the host scanner main task that a scan needs /// to happen. /// @@ -219,6 +238,13 @@ impl HostScanner { }; self.metrics.events.added(); + // Handle file creation events by adding new inodes to the map + if event.is_creation() { + if let Err(e) = self.handle_creation_event(&event) { + warn!("Failed to handle creation event: {e}"); + } + } + if let Some(host_path) = self.get_host_path(Some(event.get_inode())) { self.metrics.scan_inc(ScanLabels::InodeHit); event.set_host_path(host_path); diff --git a/tests/test_inode_tracking.py b/tests/test_inode_tracking.py new file mode 100644 index 00000000..18c41149 --- /dev/null +++ b/tests/test_inode_tracking.py @@ -0,0 +1,79 @@ +""" +Test that verifies inode tracking for newly created files. + +Expected behavior: +1. File created in monitored directory +2. BPF adds inode to kernel map (if parent is monitored) +3. Creation event has non-zero inode +4. Subsequent events on that file should also have the inode populated +""" + +import os +from tempfile import NamedTemporaryFile + +import pytest +import yaml + +from event import Event, EventType, Process + + +@pytest.fixture +def fact_config(monitored_dir, logs_dir): + """ + Config that includes both the directory and its contents. + This ensures the parent directory inode is tracked. + """ + cwd = os.getcwd() + config = { + 'paths': [f'{monitored_dir}/**', '/mounted/**', '/container-dir/**'], + 'grpc': { + 'url': 'http://127.0.0.1:9999', + }, + 'endpoint': { + 'address': '127.0.0.1:9000', + 'expose_metrics': True, + 'health_check': True, + }, + 'json': True, + } + config_file = NamedTemporaryFile( + prefix='fact-config-', suffix='.yml', dir=cwd, mode='w') + yaml.dump(config, config_file) + + yield config, config_file.name + with open(os.path.join(logs_dir, 'fact.yml'), 'w') as f: + with open(config_file.name, 'r') as r: + f.write(r.read()) + config_file.close() + + +def test_inode_tracking_on_creation(monitored_dir, test_file, server): + """ + Test that when a file is created in a monitored directory, + its inode is added to the tracking map. + + The test_file fixture ensures the directory exists and has content + when fact starts, so the parent directory inode gets tracked. + """ + # Create a new file + fut = os.path.join(monitored_dir, 'new_file.txt') + with open(fut, 'w') as f: + f.write('initial content') + + # Wait for creation event + process = Process.from_proc() + creation_event = Event(process=process, event_type=EventType.CREATION, + file=fut, host_path='') + + server.wait_events([creation_event]) + + # Now modify the file - the inode should be tracked from creation + with open(fut, 'a') as f: + f.write('appended content') + + # This open event should have host_path populated because the inode + # was added to the map during creation + open_event = Event(process=process, event_type=EventType.OPEN, + file=fut, host_path=fut) + + server.wait_events([open_event])