Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions fact-ebpf/src/bpf/inode.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions fact-ebpf/src/bpf/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to get the key for the parent using inode_to_key, but got a verifier error. That function is largely copied here. It is not completely copied, because doing so resulted in a verifier error. This will not work in all cases.

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;
}
Expand Down
6 changes: 5 additions & 1 deletion fact/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion fact/src/host_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial plan was to look up the parent, get its file path, and then add the file name. However, it seems possible to get the entire path from the event.


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.
///
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions tests/test_inode_tracking.py
Original file line number Diff line number Diff line change
@@ -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/**'],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what globbing to use here.

'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='')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should host_path be populated here.


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])
Loading