Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a79f460
X-Smart-Branch-Parent: main
JoukoVirtanen Jun 23, 2026
516b9ce
Added tests
JoukoVirtanen Jun 13, 2026
14b3c0e
Added tracking for xattr changes
JoukoVirtanen Jun 13, 2026
66b12e6
Combined calls to BPF_CORE_READ
JoukoVirtanen Jun 13, 2026
57d9c33
Clarified comment that the path is not available. Also clarified rele…
JoukoVirtanen Jun 13, 2026
8adfb4a
Added helper to reduce dry
JoukoVirtanen Jun 14, 2026
a5aef53
Added events.h which had been forgotten
JoukoVirtanen Jun 14, 2026
c2c57ad
Fix format
JoukoVirtanen Jun 14, 2026
8303156
Removed unreachable return
JoukoVirtanen Jun 14, 2026
02e93c0
Fixed format again
JoukoVirtanen Jun 14, 2026
57d2c15
Renamed test_inode_xattr.py to test_xattr.py
JoukoVirtanen Jun 14, 2026
40577b5
Tests now check gRPC messages instead of metrics
JoukoVirtanen Jun 17, 2026
c619cab
Fact now sends xattr events as gRPC messages
JoukoVirtanen Jun 17, 2026
c00d7a0
Added parameterized UTF-8 tests
JoukoVirtanen Jun 17, 2026
456765d
Test are more forgiving of extra node events
JoukoVirtanen Jun 17, 2026
fcfeb36
Validating the path in __submit_event instead of setting it in xattr …
JoukoVirtanen Jun 17, 2026
6dee60f
Added link to XATTR_NAME_MAX
JoukoVirtanen Jun 17, 2026
9b43401
Tests ignore events of unexpected type
JoukoVirtanen Jun 17, 2026
e35705d
Updated protobuf definitions
JoukoVirtanen Jun 18, 2026
cc396b5
Reverted changes to strict event comparison mode
JoukoVirtanen Jun 20, 2026
0234c34
Added xattr changes
JoukoVirtanen Jun 21, 2026
e96c4c6
Undid changes to tests/test_path_rename.py
JoukoVirtanen Jun 21, 2026
28573a3
Optionally skipping xattr events
JoukoVirtanen Jun 21, 2026
8b078be
make format
JoukoVirtanen Jun 21, 2026
f10ed18
Add os.removexattr to test_setxattr_multiple
JoukoVirtanen Jun 21, 2026
dfbb920
Extended test_setxattr_ignored with removexattr and renamed it
JoukoVirtanen Jun 21, 2026
61e6e06
Added os.removexattr to test_setxattr_new_file test_xattr_utf8_filena…
JoukoVirtanen Jun 21, 2026
5291250
Added a test that paramertizes the xattr name
JoukoVirtanen Jun 21, 2026
a6c0405
X-Smart-Branch-Parent: jv-ROX-33034-track-xattr-changes
JoukoVirtanen Jun 23, 2026
b9efab6
Checking xattr events for all tests
JoukoVirtanen Jun 22, 2026
26b9c59
All tests check for xattr events
JoukoVirtanen Jun 22, 2026
a24d4fe
Fixed how the process path is obtained
JoukoVirtanen Jun 23, 2026
3f224bf
Fixed format
JoukoVirtanen Jun 23, 2026
3a60638
Docker relabels both the file and its parent directory
JoukoVirtanen Jun 23, 2026
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
18 changes: 17 additions & 1 deletion fact-ebpf/src/bpf/events.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ __always_inline static void __submit_event(struct submit_event_args_t* args,
event->monitored = args->monitored;
inode_copy(&event->inode, &args->inode);
inode_copy(&event->parent_inode, &args->parent_inode);
bpf_probe_read_str(event->filename, PATH_MAX, args->filename);
if (args->filename != NULL) {
bpf_probe_read_str(event->filename, PATH_MAX, args->filename);
} else {
event->filename[0] = '\0';
}

struct helper_t* helper = get_helper();
if (helper == NULL) {
Expand Down Expand Up @@ -144,3 +148,15 @@ __always_inline static void submit_rmdir_event(struct submit_event_args_t* args)

__submit_event(args, path_hooks_support_bpf_d_path);
}

__always_inline static void submit_xattr_event(struct submit_event_args_t* args,
file_activity_type_t event_type,
const char* xattr_name) {
if (!reserve_event(args)) {
return;
}
args->event->type = event_type;
bpf_probe_read_str(args->event->xattr.name, XATTR_NAME_MAX_LEN, xattr_name);

__submit_event(args, false);
}
42 changes: 42 additions & 0 deletions fact-ebpf/src/bpf/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,48 @@ int BPF_PROG(trace_d_instantiate, struct dentry* dentry, struct inode* inode) {
return 0;
}

__always_inline static int handle_xattr(struct metrics_by_hook_t* hook_metrics,
struct dentry* dentry,
const char* xattr_name,
file_activity_type_t event_type) {
struct submit_event_args_t args = {.metrics = hook_metrics};

args.metrics->total++;

args.inode = inode_to_key(dentry->d_inode);
args.parent_inode = inode_to_key(BPF_CORE_READ(dentry, d_parent, d_inode));

args.monitored = inode_is_monitored(inode_get(&args.inode), inode_get(&args.parent_inode));

if (args.monitored == NOT_MONITORED) {
args.metrics->ignored++;
return 0;
}

submit_xattr_event(&args, event_type, xattr_name);
return 0;
}

SEC("lsm/inode_setxattr")
int BPF_PROG(trace_inode_setxattr, struct mnt_idmap* idmap, struct dentry* dentry,
const char* name, const void* value, size_t size, int flags) {
struct metrics_t* m = get_metrics();
if (m == NULL) {
return 0;
}
return handle_xattr(&m->inode_setxattr, dentry, name, FILE_ACTIVITY_SETXATTR);
}

SEC("lsm/inode_removexattr")
int BPF_PROG(trace_inode_removexattr, struct mnt_idmap* idmap, struct dentry* dentry,
const char* name) {
struct metrics_t* m = get_metrics();
if (m == NULL) {
return 0;
}
return handle_xattr(&m->inode_removexattr, dentry, name, FILE_ACTIVITY_REMOVEXATTR);
}

SEC("lsm/path_rmdir")
int BPF_PROG(trace_path_rmdir, struct path* dir, struct dentry* dentry) {
struct metrics_t* m = get_metrics();
Expand Down
11 changes: 11 additions & 0 deletions fact-ebpf/src/bpf/types.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@

#define LINEAGE_MAX 2

// Matches Linux kernel XATTR_NAME_MAX (255) + null terminator.
// https://github.com/torvalds/linux/blob/66affa37cfac0aec061cc4bcf4a065b0c52f7e19/include/uapi/linux/limits.h#L15
#define XATTR_NAME_MAX_LEN 256

#define LPM_SIZE_MAX 256

typedef struct lineage_t {
Expand Down Expand Up @@ -64,6 +68,8 @@ typedef enum file_activity_type_t {
FILE_ACTIVITY_RENAME,
DIR_ACTIVITY_CREATION,
DIR_ACTIVITY_UNLINK,
FILE_ACTIVITY_SETXATTR,
FILE_ACTIVITY_REMOVEXATTR,
} file_activity_type_t;

struct event_t {
Expand All @@ -90,6 +96,9 @@ struct event_t {
inode_key_t inode;
monitored_t monitored;
} rename;
struct {
char name[XATTR_NAME_MAX_LEN];
} xattr;
};
};

Expand Down Expand Up @@ -132,4 +141,6 @@ struct metrics_t {
struct metrics_by_hook_t path_mkdir;
struct metrics_by_hook_t d_instantiate;
struct metrics_by_hook_t path_rmdir;
struct metrics_by_hook_t inode_setxattr;
struct metrics_by_hook_t inode_removexattr;
};
67 changes: 66 additions & 1 deletion fact/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use std::{
use globset::GlobSet;
use serde::Serialize;

use fact_ebpf::{PATH_MAX, event_t, file_activity_type_t, inode_key_t, monitored_t};
use fact_ebpf::{
PATH_MAX, XATTR_NAME_MAX_LEN, event_t, file_activity_type_t, inode_key_t, monitored_t,
};

use crate::host_info;
use process::Process;
Expand Down Expand Up @@ -131,6 +133,10 @@ impl Event {
matches!(self.file, FileData::Creation(_) | FileData::MkDir(_))
}

pub fn is_xattr(&self) -> bool {
matches!(self.file, FileData::SetXattr(_) | FileData::RemoveXattr(_))
}

pub fn is_mkdir(&self) -> bool {
matches!(self.file, FileData::MkDir(_))
}
Expand Down Expand Up @@ -162,6 +168,8 @@ impl Event {
FileData::Chmod(data) => &data.inner.inode,
FileData::Chown(data) => &data.inner.inode,
FileData::Rename(data) => &data.new.inode,
FileData::SetXattr(data) => &data.inner.inode,
FileData::RemoveXattr(data) => &data.inner.inode,
}
}

Expand All @@ -176,6 +184,8 @@ impl Event {
FileData::Chmod(data) => &data.inner.parent_inode,
FileData::Chown(data) => &data.inner.parent_inode,
FileData::Rename(data) => &data.new.parent_inode,
FileData::SetXattr(data) => &data.inner.parent_inode,
FileData::RemoveXattr(data) => &data.inner.parent_inode,
}
}

Expand All @@ -199,6 +209,8 @@ impl Event {
FileData::Chmod(data) => &data.inner.filename,
FileData::Chown(data) => &data.inner.filename,
FileData::Rename(data) => &data.new.filename,
FileData::SetXattr(data) => &data.inner.filename,
FileData::RemoveXattr(data) => &data.inner.filename,
}
}

Expand All @@ -219,6 +231,8 @@ impl Event {
FileData::Chmod(data) => &data.inner.host_file,
FileData::Chown(data) => &data.inner.host_file,
FileData::Rename(data) => &data.new.host_file,
FileData::SetXattr(data) => &data.inner.host_file,
FileData::RemoveXattr(data) => &data.inner.host_file,
}
}

Expand All @@ -243,6 +257,8 @@ impl Event {
FileData::Chmod(data) => data.inner.host_file = host_path,
FileData::Chown(data) => data.inner.host_file = host_path,
FileData::Rename(data) => data.new.host_file = host_path,
FileData::SetXattr(data) => data.inner.host_file = host_path,
FileData::RemoveXattr(data) => data.inner.host_file = host_path,
}
}

Expand All @@ -264,6 +280,8 @@ impl Event {
FileData::Chmod(data) => data.inner.monitored,
FileData::Chown(data) => data.inner.monitored,
FileData::Rename(data) => data.new.monitored,
FileData::SetXattr(data) => data.inner.monitored,
FileData::RemoveXattr(data) => data.inner.monitored,
}
}

Expand Down Expand Up @@ -356,6 +374,8 @@ pub enum FileData {
Chmod(ChmodFileData),
Chown(ChownFileData),
Rename(RenameFileData),
SetXattr(XattrFileData),
RemoveXattr(XattrFileData),
}

impl FileData {
Expand Down Expand Up @@ -407,6 +427,18 @@ impl FileData {
};
FileData::Rename(data)
}
file_activity_type_t::FILE_ACTIVITY_SETXATTR => {
let xattr_name = slice_to_string(
&unsafe { extra_data.xattr }.name[..XATTR_NAME_MAX_LEN as usize],
)?;
FileData::SetXattr(XattrFileData { inner, xattr_name })
}
file_activity_type_t::FILE_ACTIVITY_REMOVEXATTR => {
let xattr_name = slice_to_string(
&unsafe { extra_data.xattr }.name[..XATTR_NAME_MAX_LEN as usize],
)?;
FileData::RemoveXattr(XattrFileData { inner, xattr_name })
}
invalid => unreachable!("Invalid event type: {invalid:?}"),
};

Expand All @@ -433,6 +465,14 @@ impl From<FileData> for fact_api::file_activity::File {
FileData::RmDir(_) => {
unreachable!("RmDir event reached protobuf conversion");
}
FileData::SetXattr(event) => {
let f_act = fact_api::FileXattrChange::from(event);
fact_api::file_activity::File::XattrSet(f_act)
}
FileData::RemoveXattr(event) => {
let f_act = fact_api::FileXattrChange::from(event);
fact_api::file_activity::File::XattrRemove(f_act)
}
FileData::Unlink(event) => {
let activity = Some(fact_api::FileActivityBase::from(event));
let f_act = fact_api::FileUnlink { activity };
Expand Down Expand Up @@ -465,6 +505,8 @@ impl PartialEq for FileData {
(FileData::Unlink(this), FileData::Unlink(other)) => this == other,
(FileData::Chmod(this), FileData::Chmod(other)) => this == other,
(FileData::Rename(this), FileData::Rename(other)) => this == other,
(FileData::SetXattr(this), FileData::SetXattr(other)) => this == other,
(FileData::RemoveXattr(this), FileData::RemoveXattr(other)) => this == other,
_ => false,
}
}
Expand Down Expand Up @@ -595,6 +637,29 @@ impl PartialEq for RenameFileData {
}
}

#[derive(Debug, Clone, Serialize)]
pub struct XattrFileData {
inner: BaseFileData,
xattr_name: String,
}

impl From<XattrFileData> for fact_api::FileXattrChange {
fn from(value: XattrFileData) -> Self {
let activity = fact_api::FileActivityBase::from(value.inner);
fact_api::FileXattrChange {
activity: Some(activity),
xattr_name: value.xattr_name,
}
}
}

#[cfg(test)]
impl PartialEq for XattrFileData {
fn eq(&self, other: &Self) -> bool {
self.xattr_name == other.xattr_name && self.inner == other.inner
}
}

#[cfg(test)]
mod test_utils {
use std::os::raw::c_char;
Expand Down
2 changes: 2 additions & 0 deletions fact/src/metrics/kernel_metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,6 @@ define_kernel_metrics!(
path_mkdir,
path_rmdir,
d_instantiate,
inode_setxattr,
inode_removexattr,
);
75 changes: 75 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
import subprocess
from shutil import rmtree
from tempfile import NamedTemporaryFile, mkdtemp
from time import sleep
Expand All @@ -12,8 +13,41 @@
import requests
import yaml

from event import Event, EventType, Process
from server import FileActivityService


def get_dockerd_process() -> Process | None:
result = subprocess.run(
['pgrep', 'dockerd'],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
pid = int(result.stdout.strip().split('\n')[0])
proc = Process.from_proc(pid)
Comment on lines +20 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Harden dockerd discovery failure paths.

Line 21 and Line 29 can raise (pgrep unavailable, PID race on /proc), which aborts tests instead of gracefully returning None like the rest of this helper intends.

Suggested fix
 def get_dockerd_process() -> Process | None:
-    result = subprocess.run(
-        ['pgrep', 'dockerd'],
-        capture_output=True,
-        text=True,
-    )
+    try:
+        result = subprocess.run(
+            ['pgrep', 'dockerd'],
+            capture_output=True,
+            text=True,
+            check=False,
+            timeout=2,
+        )
+    except (OSError, subprocess.TimeoutExpired):
+        return None
     if result.returncode != 0:
         return None
-    pid = int(result.stdout.strip().split('\n')[0])
-    proc = Process.from_proc(pid)
+    lines = [line for line in result.stdout.splitlines() if line]
+    if not lines:
+        return None
+    try:
+        pid = int(lines[0])
+        proc = Process.from_proc(pid)
+    except (ValueError, FileNotFoundError, PermissionError):
+        return None
     return Process(
         pid=None,
         uid=proc.uid,
🧰 Tools
🪛 ast-grep (0.44.0)

[error] 20-24: Command coming from incoming request
Context: subprocess.run(
['pgrep', 'dockerd'],
capture_output=True,
text=True,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` around lines 20 - 29, The get_dockerd_process() function
can raise exceptions from the subprocess.run call when pgrep is unavailable and
from the Process.from_proc() call when there is a race condition with the
process disappearing from /proc, which causes tests to abort instead of
gracefully returning None. Wrap the subprocess.run() call and the
Process.from_proc() call in try-except blocks to catch any exceptions (such as
FileNotFoundError or exceptions from Process.from_proc when the pid no longer
exists) and return None on any failure, ensuring the function consistently
returns None for all error cases.

# Process.from_proc uses os.path.realpath on /proc/<pid>/exe,
# which may not resolve across mount namespaces (e.g. CoreOS).
# Use the path from pgrep -a instead.
result = subprocess.run(
['pgrep', '-a', 'dockerd'],
capture_output=True,
text=True,
)
exe_path = result.stdout.strip().split('\n')[0].split()[1]
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid mixed process identity and brittle parsing in the second pgrep lookup.

Line 33-38 can pair exe_path from a different dockerd process than the pid chosen at Line 28, and split()[1] can raise if output is empty/unexpected. That makes the fixture flaky and can crash test setup.

Suggested fix
-    result = subprocess.run(
+    result = subprocess.run(
         ['pgrep', '-a', 'dockerd'],
         capture_output=True,
         text=True,
     )
-    exe_path = result.stdout.strip().split('\n')[0].split()[1]
+    if result.returncode != 0:
+        return None
+
+    exe_path = None
+    for line in result.stdout.splitlines():
+        parts = line.split(maxsplit=1)
+        if len(parts) != 2:
+            continue
+        if parts[0] != str(pid):
+            continue
+        cmdline = parts[1].strip()
+        if not cmdline:
+            continue
+        exe_path = cmdline.split()[0]
+        break
+    if exe_path is None:
+        return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
result = subprocess.run(
['pgrep', '-a', 'dockerd'],
capture_output=True,
text=True,
)
exe_path = result.stdout.strip().split('\n')[0].split()[1]
result = subprocess.run(
['pgrep', '-a', 'dockerd'],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
exe_path = None
for line in result.stdout.splitlines():
parts = line.split(maxsplit=1)
if len(parts) != 2:
continue
if parts[0] != str(pid):
continue
cmdline = parts[1].strip()
if not cmdline:
continue
exe_path = cmdline.split()[0]
break
if exe_path is None:
return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` around lines 33 - 38, The second pgrep lookup on lines
33-38 can return a different dockerd process than the one identified at line 28,
and the brittle string parsing with split()[1] can raise an IndexError if the
output is empty or unexpected. Instead of running a new pgrep command that could
match any dockerd process, use the specific pid that was already determined at
line 28 to get the exe_path from that same process (such as by reading from
/proc/{pid}/exe or by incorporating the pid into the command), and add proper
error handling to gracefully handle unexpected or empty output without crashing
the test setup.

return Process(
pid=None,
uid=proc.uid,
gid=proc.gid,
exe_path=exe_path,
args=proc.args,
name=proc.name,
container_id=proc.container_id,
loginuid=proc.loginuid,
)


# Declare files holding fixtures
pytest_plugins = ['test_editors.commons']

Expand Down Expand Up @@ -185,6 +219,47 @@ def test_container(
container.remove()


@pytest.fixture
def docker_selinux_xattr(
docker_client: docker.DockerClient,
monitored_dir: str,
test_file: str,
) -> list[Event]:
"""
Expected xattr events from Docker SELinux relabeling.

When Docker creates a container with ':z' volume mounts, it
relabels files with security.selinux. This fixture returns the
expected events if Docker has SELinux enabled, or an empty list
otherwise.

Docker relabels both the file and its parent directory.
"""
info = docker_client.info()
selinux = any('selinux' in opt for opt in info.get('SecurityOptions', []))
if not selinux:
return []
dockerd = get_dockerd_process()
if dockerd is None:
return []
return [
Event(
process=dockerd,
event_type=EventType.XATTR_SET,
file='',
host_path=test_file,
xattr_name='security.selinux',
),
Event(
process=dockerd,
event_type=EventType.XATTR_SET,
file='',
host_path=monitored_dir,
xattr_name='security.selinux',
),
]


@pytest.fixture(autouse=True)
def fact(
request: pytest.FixtureRequest,
Expand Down
Loading
Loading