From 6e662257dfa74cd764ce7e3ab27fb0112e76e362 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Wed, 19 Nov 2025 12:24:15 +0100 Subject: [PATCH 1/2] ROX-30437: resolve host path via inode tracking This patch makes it so, at start up, we walk through the configured paths we have to monitor and add the path to the inode of every file we find. This first implementation is inherently flawed and meant as a way to show how it would look once it is fully fleshed out. --- Cargo.lock | 21 ++++++++++++++-- Cargo.toml | 2 ++ fact-api/build.rs | 1 + fact-ebpf/src/bpf/events.h | 14 ++++------- fact-ebpf/src/bpf/file.h | 48 ------------------------------------- fact-ebpf/src/bpf/main.c | 11 +++++---- fact-ebpf/src/bpf/maps.h | 8 +++++++ fact-ebpf/src/bpf/process.h | 2 +- fact-ffi/Cargo.toml | 15 ++++++++++++ fact-ffi/build.rs | 9 +++++++ fact-ffi/src/c/inode.c | 36 ++++++++++++++++++++++++++++ fact-ffi/src/inode_store.rs | 33 +++++++++++++++++++++++++ fact-ffi/src/lib.rs | 1 + fact/Cargo.toml | 1 + fact/src/bpf/checks.rs | 1 + fact/src/bpf/mod.rs | 35 +++++++++++++++++++++------ fact/src/fs_walker.rs | 25 +++++++++++++++++++ fact/src/lib.rs | 10 ++++++++ k8s/manifest.yml | 10 ++++---- 19 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 fact-ffi/Cargo.toml create mode 100644 fact-ffi/build.rs create mode 100644 fact-ffi/src/c/inode.c create mode 100644 fact-ffi/src/inode_store.rs create mode 100644 fact-ffi/src/lib.rs create mode 100644 fact/src/fs_walker.rs diff --git a/Cargo.lock b/Cargo.lock index f2f51b2b..e7cc5799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,10 +263,11 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.30" +version = "1.2.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -427,6 +428,7 @@ dependencies = [ "env_logger", "fact-api", "fact-ebpf", + "fact-ffi", "http-body-util", "hyper", "hyper-util", @@ -468,12 +470,27 @@ dependencies = [ "libc", ] +[[package]] +name = "fact-ffi" +version = "0.1.0" +dependencies = [ + "anyhow", + "aya", + "cc", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fixedbitset" version = "0.5.7" diff --git a/Cargo.toml b/Cargo.toml index 68935d38..4793380d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "fact", "fact-api", "fact-ebpf", + "fact-ffi", ] default-members = ["fact"] @@ -14,6 +15,7 @@ license = "MIT OR Apache-2.0" aya = { version = "0.13.1", default-features = false } anyhow = { version = "1", default-features = false, features = ["std", "backtrace"] } +cc = "1.2.46" clap = { version = "4.5.41", features = ["derive", "env"] } env_logger = { version = "0.11.5", default-features = false, features = ["humantime"] } http-body-util = "0.1.3" diff --git a/fact-api/build.rs b/fact-api/build.rs index 4a84a8ed..47545f67 100644 --- a/fact-api/build.rs +++ b/fact-api/build.rs @@ -1,6 +1,7 @@ use anyhow::Context; fn main() -> anyhow::Result<()> { + println!("cargo::rerun-if-changed=../third_party/stackrox/"); tonic_prost_build::configure() .build_server(false) .compile_protos( diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index adadbd46..debf5ed4 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -7,7 +7,7 @@ #include "types.h" #include "vmlinux.h" -__always_inline static void submit_event(struct metrics_by_hook_t* m, file_activity_type_t event_type, const char filename[PATH_MAX], struct dentry* dentry, bool use_bpf_d_path) { +__always_inline static void submit_event(struct metrics_by_hook_t* m, file_activity_type_t event_type, const char filename[PATH_MAX], const char* host_path, bool use_bpf_d_path) { struct event_t* event = bpf_ringbuf_reserve(&rb, sizeof(struct event_t), 0); if (event == NULL) { m->ringbuffer_full++; @@ -18,14 +18,10 @@ __always_inline static void submit_event(struct metrics_by_hook_t* m, file_activ event->timestamp = bpf_ktime_get_boot_ns(); bpf_probe_read_str(event->filename, PATH_MAX, filename); - struct helper_t* helper = get_helper(); - if (helper == NULL) { - goto error; - } - - const char* p = get_host_path(helper->buf, dentry); - if (p != NULL) { - bpf_probe_read_str(event->host_file, PATH_MAX, p); + if (host_path != NULL) { + bpf_probe_read_str(event->host_file, PATH_MAX, host_path); + } else { + event->host_file[0] = '\0'; } int64_t err = process_fill(&event->process, use_bpf_d_path); diff --git a/fact-ebpf/src/bpf/file.h b/fact-ebpf/src/bpf/file.h index 5edbbc36..331407f6 100644 --- a/fact-ebpf/src/bpf/file.h +++ b/fact-ebpf/src/bpf/file.h @@ -3,9 +3,6 @@ // clang-format off #include "vmlinux.h" -#include "bound_path.h" -#include "builtins.h" -#include "d_path.h" #include "types.h" #include "maps.h" @@ -13,51 +10,6 @@ #include // clang-format on -__always_inline static char* get_host_path(char buf[PATH_MAX * 2], struct dentry* d) { - int offset = PATH_MAX - 1; - buf[PATH_MAX - 1] = '\0'; - - for (int i = 0; i < 16 && offset > 0; i++) { - struct qstr d_name; - BPF_CORE_READ_INTO(&d_name, d, d_name); - if (d_name.name == NULL) { - break; - } - - int len = d_name.len; - if (len <= 0 || len >= PATH_MAX) { - return NULL; - } - - offset -= len; - if (offset <= 0) { - return NULL; - } - - if (bpf_probe_read_kernel(&buf[offset], len, d_name.name) != 0) { - return NULL; - } - - if (len == 1 && buf[offset] == '/') { - // Reached the root - offset++; - break; - } - - offset--; - buf[offset] = '/'; - - struct dentry* parent = BPF_CORE_READ(d, d_parent); - // if we reached the root - if (parent == NULL || d == parent) { - break; - } - d = parent; - } - - return &buf[offset]; -} - __always_inline static bool is_monitored(struct bound_path_t* path) { if (!filter_by_prefix()) { // no path configured, allow all diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 659814bf..0c0e0105 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -37,6 +37,7 @@ int BPF_PROG(trace_file_open, struct file* file) { goto ignored; } + const char* host_path = bpf_inode_storage_get(&inode_store, file->f_inode, NULL, 0); struct bound_path_t* path = path_read(&file->f_path); if (path == NULL) { bpf_printk("Failed to read path"); @@ -44,12 +45,11 @@ int BPF_PROG(trace_file_open, struct file* file) { return 0; } - if (!is_monitored(path)) { + if (host_path == NULL && !is_monitored(path)) { goto ignored; } - struct dentry* d = BPF_CORE_READ(file, f_path.dentry); - submit_event(&m->file_open, event_type, path->path, d, true); + submit_event(&m->file_open, event_type, path->path, host_path, true); return 0; @@ -67,6 +67,7 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { m->path_unlink.total++; + const char* host_path = bpf_inode_storage_get(&inode_store, dentry->d_inode, NULL, 0); struct bound_path_t* path = NULL; if (path_unlink_supports_bpf_d_path) { path = path_read(dir); @@ -91,12 +92,12 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { goto error; } - if (!is_monitored(path)) { + if (host_path == NULL && !is_monitored(path)) { m->path_unlink.ignored++; return 0; } - submit_event(&m->path_unlink, FILE_ACTIVITY_UNLINK, path->path, dentry, path_unlink_supports_bpf_d_path); + submit_event(&m->path_unlink, FILE_ACTIVITY_UNLINK, path->path, host_path, path_unlink_supports_bpf_d_path); return 0; error: diff --git a/fact-ebpf/src/bpf/maps.h b/fact-ebpf/src/bpf/maps.h index 0e9ae4c5..40f4646b 100644 --- a/fact-ebpf/src/bpf/maps.h +++ b/fact-ebpf/src/bpf/maps.h @@ -107,4 +107,12 @@ __always_inline static struct metrics_t* get_metrics() { uint64_t host_mount_ns; volatile const bool path_unlink_supports_bpf_d_path; +struct { + __uint(type, BPF_MAP_TYPE_INODE_STORAGE); + __type(key, __u32); + __type(value, char[4096]); + __uint(max_entries, 0); + __uint(map_flags, BPF_F_NO_PREALLOC); +} inode_store SEC(".maps"); + // clang-format on diff --git a/fact-ebpf/src/bpf/process.h b/fact-ebpf/src/bpf/process.h index 910f4825..446b737c 100644 --- a/fact-ebpf/src/bpf/process.h +++ b/fact-ebpf/src/bpf/process.h @@ -1,6 +1,6 @@ #pragma once -#include "file.h" +#include "d_path.h" #include "maps.h" #include "types.h" diff --git a/fact-ffi/Cargo.toml b/fact-ffi/Cargo.toml new file mode 100644 index 00000000..596e2a74 --- /dev/null +++ b/fact-ffi/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fact-ffi" +version = "0.1.0" +edition = "2021" + +license.workspace = true + +[lib] + +[dependencies] +anyhow = { workspace = true } +aya = { workspace = true } + +[build-dependencies] +cc = { workspace = true } diff --git a/fact-ffi/build.rs b/fact-ffi/build.rs new file mode 100644 index 00000000..5b06158f --- /dev/null +++ b/fact-ffi/build.rs @@ -0,0 +1,9 @@ +fn main() { + println!("cargo::rerun-if-changed=src/c/"); + cc::Build::new() + .file("src/c/inode.c") + .opt_level(2) + .warnings_into_errors(true) + .warnings(true) + .compile("inode"); +} diff --git a/fact-ffi/src/c/inode.c b/fact-ffi/src/c/inode.c new file mode 100644 index 00000000..97e8bff5 --- /dev/null +++ b/fact-ffi/src/c/inode.c @@ -0,0 +1,36 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +int32_t add_path(int32_t map_fd, const char* path, const char* host_path) { + int fd = open(path, O_RDONLY); + if (fd <= 0) { + fprintf(stderr, "%s:%d - open error: %d\n", __FILE__, __LINE__, errno); + return errno; + } + + char buf[4096]; + snprintf(buf, 4096, "%s", host_path); + + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + attr.map_fd = map_fd; + attr.key = (unsigned long long)&fd; + attr.value = (unsigned long long)buf; + attr.flags = BPF_NOEXIST; + + long res = syscall(SYS_bpf, BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr)); + if (res == -EEXIST) { + res = 0; + } + + close(fd); + return res; +} diff --git a/fact-ffi/src/inode_store.rs b/fact-ffi/src/inode_store.rs new file mode 100644 index 00000000..1f88c747 --- /dev/null +++ b/fact-ffi/src/inode_store.rs @@ -0,0 +1,33 @@ +use std::{ + ffi::{c_char, CString}, + os::fd::{AsFd, AsRawFd}, + path::Path, +}; + +use aya::maps::MapData; + +#[link(name = "inode")] +unsafe extern "C" { + fn add_path(map_fd: i32, path: *const c_char, host_path: *const c_char) -> i32; +} + +fn path_to_cstring(path: &Path) -> anyhow::Result { + let path = path.as_os_str().to_string_lossy(); + Ok(CString::new(path.to_string())?) +} + +pub fn try_add_path( + inode_store: &mut MapData, + path: &Path, + host_path: &Path, +) -> anyhow::Result<()> { + let path = path_to_cstring(path)?; + let host_path = path_to_cstring(host_path)?; + let fd = inode_store.fd().as_fd().as_raw_fd(); + let res = unsafe { add_path(fd, path.as_ptr(), host_path.as_ptr()) }; + + if res != 0 { + anyhow::bail!("Failed to add inode: {res}"); + } + Ok(()) +} diff --git a/fact-ffi/src/lib.rs b/fact-ffi/src/lib.rs new file mode 100644 index 00000000..b1e8f959 --- /dev/null +++ b/fact-ffi/src/lib.rs @@ -0,0 +1 @@ +pub mod inode_store; diff --git a/fact/Cargo.toml b/fact/Cargo.toml index 808635a1..03053c18 100644 --- a/fact/Cargo.toml +++ b/fact/Cargo.toml @@ -28,6 +28,7 @@ yaml-rust2 = { workspace = true } fact-api = { path = "../fact-api" } fact-ebpf = { path = "../fact-ebpf" } +fact-ffi = { path = "../fact-ffi" } [dev-dependencies] tempfile = { workspace = true } diff --git a/fact/src/bpf/checks.rs b/fact/src/bpf/checks.rs index 238cf306..98057cd3 100644 --- a/fact/src/bpf/checks.rs +++ b/fact/src/bpf/checks.rs @@ -9,6 +9,7 @@ pub(super) struct Checks { impl Checks { pub(super) fn new(btf: &Btf) -> anyhow::Result { let mut obj = aya::EbpfLoader::new() + .allow_unsupported_maps() .load(fact_ebpf::CHECKS_OBJ) .context("Failed to load checks.o")?; diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index 3b6e27ec..b0746320 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -2,7 +2,7 @@ use std::{io, path::PathBuf, sync::Arc}; use anyhow::{bail, Context}; use aya::{ - maps::{Array, LpmTrie, MapData, PerCpuArray, RingBuf}, + maps::{Array, LpmTrie, Map, MapData, PerCpuArray, RingBuf}, programs::Lsm, Btf, Ebpf, }; @@ -52,6 +52,7 @@ impl Bpf { true, ) .set_max_entries(RINGBUFFER_NAME, ringbuf_size * 1024) + .allow_unsupported_maps() .load(fact_ebpf::EBPF_OBJ)?; let paths = Vec::new(); @@ -90,6 +91,16 @@ impl Bpf { self.tx.subscribe() } + pub fn get_inode_store(&mut self) -> anyhow::Result<&mut MapData> { + let Some(inode_store) = self.obj.map_mut("inode_store") else { + bail!("inode_store not found"); + }; + let Map::Unsupported(data) = inode_store else { + bail!("inode_store is of incorrect type: {inode_store:?}"); + }; + Ok(data) + } + pub fn take_metrics(&mut self) -> anyhow::Result> { let metrics = match self.obj.take_map("metrics") { Some(m) => m, @@ -220,7 +231,7 @@ impl Bpf { #[cfg(all(test, feature = "bpf-test"))] mod bpf_tests { - use std::{env, path::PathBuf, time::Duration}; + use std::{env, fs::OpenOptions, path::PathBuf, time::Duration}; use anyhow::Context; use fact_ebpf::file_activity_type_t; @@ -260,9 +271,18 @@ mod bpf_tests { let mut config = FactConfig::default(); config.set_paths(paths); let reloader = Reloader::from(config); + + // TODO: The inode tracking algorithm for host paths only works + // on files that exist at startup, this needs to be improved. + let file = NamedTempFile::new_in(&monitored_path).expect("Failed to create temporary file"); + println!("Created {file:?}"); + executor.block_on(async { let mut bpf = Bpf::new(reloader.paths(), reloader.config().ringbuf_size()) .expect("Failed to load BPF code"); + let inode_store = bpf.get_inode_store().expect("inode_store not found"); + fact_ffi::inode_store::try_add_path(inode_store, file.path(), file.path()) + .expect("Failed to add inode store object"); let mut rx = bpf.subscribe(); let (run_tx, run_rx) = watch::channel(true); // Create a metrics exporter, but don't start it @@ -272,13 +292,14 @@ mod bpf_tests { tokio::time::sleep(Duration::from_millis(500)).await; - // Create a file - let file = - NamedTempFile::new_in(monitored_path).expect("Failed to create temporary file"); - println!("Created {file:?}"); + // Open the monitored file + OpenOptions::new() + .write(true) + .open(file.path()) + .expect("Failed to open monitored file"); let expected = Event::new( - file_activity_type_t::FILE_ACTIVITY_CREATION, + file_activity_type_t::FILE_ACTIVITY_OPEN, host_info::get_hostname(), file.path().to_path_buf(), file.path().to_path_buf(), diff --git a/fact/src/fs_walker.rs b/fact/src/fs_walker.rs new file mode 100644 index 00000000..b7f07ae5 --- /dev/null +++ b/fact/src/fs_walker.rs @@ -0,0 +1,25 @@ +use std::path::Path; + +use aya::maps::MapData; +use log::debug; + +use crate::host_info; + +pub fn walk_path(inode_store: &mut MapData, path: &Path) -> anyhow::Result<()> { + if path.is_dir() { + for entry in (path.read_dir()?).flatten() { + walk_path(inode_store, &entry.path())?; + } + } + + if path.is_file() { + let host_path = path + .strip_prefix(host_info::get_host_mount()) + .unwrap_or(path); + let host_path = Path::new("/").join(host_path); + debug!("Adding inode: {path:?} - {host_path:?}"); + fact_ffi::inode_store::try_add_path(inode_store, path, &host_path)?; + } + + Ok(()) +} diff --git a/fact/src/lib.rs b/fact/src/lib.rs index d198f67a..8741ff80 100644 --- a/fact/src/lib.rs +++ b/fact/src/lib.rs @@ -2,6 +2,7 @@ use std::{borrow::BorrowMut, io::Write, str::FromStr}; use anyhow::Context; use bpf::Bpf; +use fs_walker::walk_path; use host_info::{get_distro, get_hostname, SystemInfo}; use log::{debug, info, warn, LevelFilter}; use metrics::exporter::Exporter; @@ -14,6 +15,7 @@ mod bpf; pub mod config; mod endpoints; mod event; +mod fs_walker; mod host_info; mod metrics; mod output; @@ -79,6 +81,14 @@ pub async fn run(config: FactConfig) -> anyhow::Result<()> { let mut bpf = Bpf::new(reloader.paths(), reloader.config().ringbuf_size())?; let exporter = Exporter::new(bpf.take_metrics()?); + // TODO: The inode tracking algorithm for host paths only works on + // files that exist at startup, this needs to be improved. + let inode_store = bpf.get_inode_store()?; + for p in reloader.paths().borrow().iter() { + let mounted_path = host_info::get_host_mount().join(p.strip_prefix("/")?); + walk_path(inode_store, &mounted_path)?; + } + output::start( bpf.subscribe(), running.subscribe(), diff --git a/k8s/manifest.yml b/k8s/manifest.yml index f470c708..dc8e1e94 100644 --- a/k8s/manifest.yml +++ b/k8s/manifest.yml @@ -26,6 +26,8 @@ spec: env: - name: FACT_LOGLEVEL value: 'debug' + - name: FACT_HOST_MOUNT + value: '/host' securityContext: capabilities: drop: @@ -33,11 +35,11 @@ spec: privileged: true readOnlyRootFilesystem: true volumeMounts: - - mountPath: /sys - name: sys-ro + - mountPath: /host + name: root-ro readOnly: true mountPropagation: HostToContainer volumes: - hostPath: - path: /sys/ - name: sys-ro + path: / + name: root-ro From 4b2cf3866673475874349f29561db68cabe8c229 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Wed, 12 Nov 2025 10:18:52 +0100 Subject: [PATCH 2/2] ROX-30437: add integration tests Added tests will validate events generated on an overlayfs file properly shows the event on the upper layer and the access to the underlying FS. They also validate a mounted path on a container resolves to the correct host path. While developing these tests, it became painfully obvious getting the information of the process running inside the container is not straightforward. Because containers tend to be fairly static, we should be able to manually create the information statically in the test and still have everything work correctly. In order to minimize the amount of changes on existing tests, the default Process constructor now takes fields directly and there is a from_proc class method that builds a new Process object from /proc. Additionally, getting the pid of a process in a container is virtually impossible, so we make the pid check optional. --- tests/conftest.py | 63 ++++++++++---- tests/event.py | 84 +++++++++++++----- tests/test_config_hotreload.py | 44 +++++----- tests/test_file_open.py | 123 ++++++++++++++++++++++----- tests/test_path_unlink.py | 150 +++++++++++++++++++++++++++------ 5 files changed, 361 insertions(+), 103 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b87f2914..3b74c36c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,20 @@ def monitored_dir(): rmtree(tmp) +@pytest.fixture +def test_file(monitored_dir): + """ + Create a temporary file for tests + + This file needs to exist when fact starts up for the inode tracking + algorithm to work. + """ + fut = os.path.join(monitored_dir, 'test.txt') + with open(fut, 'w') as f: + f.write('test') + yield fut + + @pytest.fixture def ignored_dir(): """ @@ -84,7 +98,7 @@ def dump_logs(container, file): def fact_config(request, monitored_dir, logs_dir): cwd = os.getcwd() config = { - 'paths': [monitored_dir], + 'paths': [monitored_dir, '/mounted', '/container-dir'], 'grpc': { 'url': 'http://127.0.0.1:9999', }, @@ -107,7 +121,36 @@ def fact_config(request, monitored_dir, logs_dir): @pytest.fixture -def fact(request, docker_client, fact_config, server, logs_dir): +def test_container(request, docker_client, monitored_dir, ignored_dir): + """ + Run a container for triggering events in. + """ + container = docker_client.containers.run( + 'quay.io/fedora/fedora:43', + detach=True, + tty=True, + volumes={ + ignored_dir: { + 'bind': '/mounted', + 'mode': 'z', + }, + monitored_dir: { + 'bind': '/unmonitored', + 'mode': 'z', + } + }, + name='fedora', + ) + container.exec_run('mkdir /container-dir') + + yield container + + container.stop(timeout=1) + container.remove() + + +@pytest.fixture +def fact(request, docker_client, fact_config, server, logs_dir, test_file): """ Run the fact docker container for integration tests. """ @@ -124,20 +167,8 @@ def fact(request, docker_client, fact_config, server, logs_dir): network_mode='host', privileged=True, volumes={ - '/sys/kernel/security': { - 'bind': '/host/sys/kernel/security', - 'mode': 'ro', - }, - '/etc': { - 'bind': '/host/etc', - 'mode': 'ro', - }, - '/proc/sys/kernel': { - 'bind': '/host/proc/sys/kernel', - 'mode': 'ro', - }, - '/usr/lib/os-release': { - 'bind': '/host/usr/lib/os-release', + '/': { + 'bind': '/host', 'mode': 'ro', }, config_file: { diff --git a/tests/event.py b/tests/event.py index 1b30cf7d..bdd3a6ed 100644 --- a/tests/event.py +++ b/tests/event.py @@ -37,10 +37,31 @@ class Process: Represents a process with its attributes. """ - def __init__(self, pid: int | None = None): - self._pid: int = pid if pid is not None else os.getpid() - proc_dir = os.path.join('/proc', str(self._pid)) - + def __init__(self, + pid: int | None, + uid: int, + gid: int, + exe_path: str, + args: str, + name: str, + container_id: str, + loginuid: int): + self._pid: int | None = pid + self._uid: int = uid + self._gid: int = gid + self._exe_path: str = exe_path + self._args: str = args + self._name: str = name + self._container_id: str = container_id + self._loginuid: int = loginuid + + @classmethod + def from_proc(cls, pid: int | None = None): + pid: int = pid if pid is not None else os.getpid() + proc_dir = os.path.join('/proc', str(pid)) + + uid = 0 + gid = 0 with open(os.path.join(proc_dir, 'status'), 'r') as f: def get_id(line: str, wanted_id: str) -> int | None: if line.startswith(f'{wanted_id}:'): @@ -50,27 +71,36 @@ def get_id(line: str, wanted_id: str) -> int | None: return None for line in f.readlines(): - if (uid := get_id(line, 'Uid')) is not None: - self._uid: int = uid - elif (gid := get_id(line, 'Gid')) is not None: - self._gid: int = gid + if (id := get_id(line, 'Uid')) is not None: + uid = id + elif (id := get_id(line, 'Gid')) is not None: + gid = id - self._exe_path: str = os.path.realpath(os.path.join(proc_dir, 'exe')) + exe_path = os.path.realpath(os.path.join(proc_dir, 'exe')) with open(os.path.join(proc_dir, 'cmdline'), 'rb') as f: content = f.read(4096) args = [arg.decode('utf-8') for arg in content.split(b'\x00') if arg] - self._args: str = ' '.join(args) + args = ' '.join(args) with open(os.path.join(proc_dir, 'comm'), 'r') as f: - self._name: str = f.read().strip() + name = f.read().strip() with open(os.path.join(proc_dir, 'cgroup'), 'r') as f: - self._container_id: str = extract_container_id(f.read()) + container_id = extract_container_id(f.read()) with open(os.path.join(proc_dir, 'loginuid'), 'r') as f: - self._loginuid: int = int(f.read()) + loginuid = int(f.read()) + + return Process(pid=pid, + uid=uid, + gid=gid, + exe_path=exe_path, + args=args, + name=name, + container_id=container_id, + loginuid=loginuid) @property def uid(self) -> int: @@ -81,7 +111,7 @@ def gid(self) -> int: return self._gid @property - def pid(self) -> int: + def pid(self) -> int | None: return self._pid @property @@ -107,10 +137,12 @@ def loginuid(self) -> int: @override def __eq__(self, other: Any) -> bool: if isinstance(other, ProcessSignal): + if self.pid is not None and self.pid != other.pid: + return False + return ( self.uid == other.uid and self.gid == other.gid and - self.pid == other.pid and self.exe_path == other.exec_file_path and self.args == other.args and self.name == other.name and @@ -124,7 +156,7 @@ def __str__(self) -> str: return (f'Process(uid={self.uid}, gid={self.gid}, pid={self.pid}, ' f'exe_path={self.exe_path}, args={self.args}, ' f'name={self.name}, container_id={self.container_id}, ' - f'loginuid={self.loginuid}') + f'loginuid={self.loginuid})') class Event: @@ -136,10 +168,12 @@ class Event: def __init__(self, process: Process, event_type: EventType, - file: str): + file: str, + host_path: str = ''): self._type: EventType = event_type self._process: Process = process self._file: str = file + self._host_path: str = host_path @property def event_type(self) -> EventType: @@ -153,6 +187,10 @@ def process(self) -> Process: def file(self) -> str: return self._file + @property + def host_path(self) -> str: + return self._host_path + @override def __eq__(self, other: Any) -> bool: if isinstance(other, FileActivity): @@ -160,15 +198,19 @@ def __eq__(self, other: Any) -> bool: return False if self.event_type == EventType.CREATION: - return self.file == other.creation.activity.path + return self.file == other.creation.activity.path and \ + self.host_path == other.creation.activity.host_path elif self.event_type == EventType.OPEN: - return self.file == other.open.activity.path + return self.file == other.open.activity.path and \ + self.host_path == other.open.activity.host_path elif self.event_type == EventType.UNLINK: - return self.file == other.unlink.activity.path + return self.file == other.unlink.activity.path and \ + self.host_path == other.unlink.activity.host_path return False raise NotImplementedError @override def __str__(self) -> str: return (f'Event(event_type={self.event_type.name}, ' - f'process={self.process}, file="{self.file}")') + f'process={self.process}, file="{self.file}", ' + f'host_path="{self.host_path}")') diff --git a/tests/test_config_hotreload.py b/tests/test_config_hotreload.py index d5f5b0bb..4b5d337d 100644 --- a/tests/test_config_hotreload.py +++ b/tests/test_config_hotreload.py @@ -93,12 +93,13 @@ def test_output_grpc_address_change(fact, fact_config, monitored_dir, server, al change. """ # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'test2.txt') with open(fut, 'w') as f: f.write('This is a test') - process = Process() - e = Event(process=process, event_type=EventType.CREATION, file=fut) + process = Process.from_proc() + e = Event(process=process, event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {e}') server.wait_events([e]) @@ -111,30 +112,32 @@ def test_output_grpc_address_change(fact, fact_config, monitored_dir, server, al with open(fut, 'w') as f: f.write('This is another test') - e = Event(process=process, event_type=EventType.OPEN, file=fut) + e = Event(process=process, event_type=EventType.OPEN, + file=fut, host_path='') print(f'Waiting for event on alternate server: {e}') alternate_server.wait_events([e]) def test_paths(fact, fact_config, monitored_dir, ignored_dir, server): - p = Process() + p = Process.from_proc() # Ignored file, must not show up in the server ignored_file = os.path.join(ignored_dir, 'test.txt') with open(ignored_file, 'w') as f: f.write('This is to be ignored') - ignored_event = Event( - process=p, event_type=EventType.CREATION, file=ignored_file) + ignored_event = Event(process=p, event_type=EventType.CREATION, + file=ignored_file, host_path='') print(f'Ignoring: {ignored_event}') # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'test2.txt') with open(fut, 'w') as f: f.write('This is a test') - e = Event(process=p, event_type=EventType.CREATION, file=fut) + e = Event(process=p, event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {e}') server.wait_events([e], ignored=[ignored_event]) @@ -148,38 +151,40 @@ def test_paths(fact, fact_config, monitored_dir, ignored_dir, server): with open(ignored_file, 'w') as f: f.write('This is another test') - e = Event( - process=p, event_type=EventType.OPEN, file=ignored_file) + e = Event(process=p, event_type=EventType.OPEN, + file=ignored_file, host_path='') print(f'Waiting for event: {e}') # File Under Test with open(fut, 'w') as f: f.write('This is another ignored event') - ignored_event = Event(process=p, event_type=EventType.OPEN, file=fut) + ignored_event = Event( + process=p, event_type=EventType.OPEN, file=fut, host_path='') print(f'Ignoring: {ignored_event}') server.wait_events([e], ignored=[ignored_event]) def test_paths_addition(fact, fact_config, monitored_dir, ignored_dir, server): - p = Process() + p = Process.from_proc() # Ignored file, must not show up in the server ignored_file = os.path.join(ignored_dir, 'test.txt') with open(ignored_file, 'w') as f: f.write('This is to be ignored') - ignored_event = Event( - process=p, event_type=EventType.CREATION, file=ignored_file) + ignored_event = Event(process=p, event_type=EventType.CREATION, + file=ignored_file, host_path='') print(f'Ignoring: {ignored_event}') # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'test2.txt') with open(fut, 'w') as f: f.write('This is a test') - e = Event(process=p, event_type=EventType.CREATION, file=fut) + e = Event(process=p, event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {e}') server.wait_events([e], ignored=[ignored_event]) @@ -196,8 +201,9 @@ def test_paths_addition(fact, fact_config, monitored_dir, ignored_dir, server): f.write('This is one final event') events = [ - Event(process=p, event_type=EventType.OPEN, file=ignored_file), - Event(process=p, event_type=EventType.OPEN, file=fut) + Event(process=p, event_type=EventType.OPEN, + file=ignored_file, host_path=''), + Event(process=p, event_type=EventType.OPEN, file=fut, host_path='') ] print(f'Waiting for events: {events}') diff --git a/tests/test_file_open.py b/tests/test_file_open.py index b499cdce..513adea8 100644 --- a/tests/test_file_open.py +++ b/tests/test_file_open.py @@ -1,6 +1,8 @@ import multiprocessing as mp import os +import docker + from event import Event, EventType, Process @@ -15,11 +17,12 @@ def test_open(fact, monitored_dir, server): server: The server instance to communicate with. """ # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'create.txt') with open(fut, 'w') as f: f.write('This is a test') - e = Event(process=Process(), event_type=EventType.CREATION, file=fut) + e = Event(process=Process.from_proc(), event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {e}') server.wait_events([e]) @@ -36,21 +39,22 @@ def test_multiple(fact, monitored_dir, server): server: The server instance to communicate with. """ events = [] - process = Process() + process = Process.from_proc() # File Under Test for i in range(3): fut = os.path.join(monitored_dir, f'{i}.txt') with open(fut, 'w') as f: f.write('This is a test') - e = Event(process=process, event_type=EventType.CREATION, file=fut) + e = Event(process=process, event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {e}') events.append(e) server.wait_events(events) -def test_multiple_access(fact, monitored_dir, server): +def test_multiple_access(fact, test_file, server): """ Tests multiple opening of a file and verifies that the corresponding events are captured by the server. @@ -61,49 +65,46 @@ def test_multiple_access(fact, monitored_dir, server): server: The server instance to communicate with. """ events = [] - # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') - for i in range(3): - with open(fut, 'a+') as f: + with open(test_file, 'a+') as f: f.write('This is a test') - e = Event(process=Process(), file=fut, - event_type=EventType.CREATION if i == 0 else EventType.OPEN) + e = Event(process=Process.from_proc(), file=test_file, + host_path=test_file, event_type=EventType.OPEN) print(f'Waiting for event: {e}') events.append(e) server.wait_events(events) -def test_ignored(fact, monitored_dir, ignored_dir, server): +def test_ignored(fact, test_file, ignored_dir, server): """ Tests that open events on ignored files are not captured by the server. Args: fact: Fixture for file activity (only required to be running). - monitored_dir: Temporary directory path for creating the test file. + test_file: Temporary file for testing. ignored_dir: Temporary directory path that is not monitored by fact. server: The server instance to communicate with. """ - p = Process() + p = Process.from_proc() # Ignored file, must not show up in the server ignored_file = os.path.join(ignored_dir, 'test.txt') with open(ignored_file, 'w') as f: f.write('This is to be ignored') - ignored_event = Event( - process=p, event_type=EventType.CREATION, file=ignored_file) + ignored_event = Event(process=p, event_type=EventType.CREATION, + file=ignored_file, host_path='') print(f'Ignoring: {ignored_event}') # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') - with open(fut, 'w') as f: + with open(test_file, 'w') as f: f.write('This is a test') - e = Event(process=p, event_type=EventType.CREATION, file=fut) + e = Event(process=p, event_type=EventType.OPEN, + file=test_file, host_path=test_file) print(f'Waiting for event: {e}') server.wait_events([e], ignored=[ignored_event]) @@ -131,15 +132,17 @@ def test_external_process(fact, monitored_dir, server): """ # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'test2.txt') stop_event = mp.Event() proc = mp.Process(target=do_test, args=(fut, stop_event)) proc.start() - p = Process(proc.pid) + p = Process.from_proc(proc.pid) - creation = Event(process=p, event_type=EventType.CREATION, file=fut) + creation = Event(process=p, event_type=EventType.CREATION, + file=fut, host_path='') print(f'Waiting for event: {creation}') - write_access = Event(process=p, event_type=EventType.OPEN, file=fut) + write_access = Event( + process=p, event_type=EventType.OPEN, file=fut, host_path='') print(f'Waiting for event: {write_access}') try: @@ -147,3 +150,77 @@ def test_external_process(fact, monitored_dir, server): finally: stop_event.set() proc.join(1) + + +def test_overlay(fact, test_container, server): + # File Under Test + fut = '/container-dir/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + inspect = docker.APIClient().inspect_container(test_container.id) + upper_dir = inspect['GraphDriver']['Data']['UpperDir'] + + process = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=pow(2, 32)-1) + events = [ + Event(process=process, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=process, event_type=EventType.OPEN, + file=fut, host_path='') + ] + + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) + + +def test_mounted_dir(fact, test_container, ignored_dir, server): + # File Under Test + fut = '/mounted/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + + process = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=pow(2, 32)-1) + event = Event(process=process, event_type=EventType.CREATION, + file=fut, host_path='') + print(f'Waiting for event: {event}') + + server.wait_events([event]) + + +def test_unmonitored_mounted_dir(fact, test_container, test_file, server): + # File Under Test + fut = '/unmonitored/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + + process = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=pow(2, 32)-1) + event = Event(process=process, event_type=EventType.OPEN, + file=fut, host_path=test_file) + print(f'Waiting for event: {event}') + + server.wait_events([event]) diff --git a/tests/test_path_unlink.py b/tests/test_path_unlink.py index 9486b9bd..106e5af7 100644 --- a/tests/test_path_unlink.py +++ b/tests/test_path_unlink.py @@ -1,28 +1,27 @@ import multiprocessing as mp import os +import docker + from event import Event, EventType, Process -def test_remove(fact, monitored_dir, server): +def test_remove(fact, test_file, server): """ Tests the removal of a file and verifies the corresponding event is captured by the server. Args: fact: Fixture for file activity (only required to be running). - monitored_dir: Temporary directory path for monitoring the test file. + test_file: Temporary file for testing. server: The server instance to communicate with. """ - fut = os.path.join(monitored_dir, 'test.txt') - with open(fut, 'w') as f: - f.write('This is a test') - os.remove(fut) + os.remove(test_file) - process = Process() + process = Process.from_proc() events = [ - Event(process=process, event_type=EventType.CREATION, file=fut), - Event(process=process, event_type=EventType.UNLINK, file=fut), + Event(process=process, event_type=EventType.UNLINK, + file=test_file, host_path=test_file), ] server.wait_events(events) @@ -39,7 +38,7 @@ def test_multiple(fact, monitored_dir, server): server: The server instance to communicate with. """ events = [] - process = Process() + process = Process.from_proc() # File Under Test for i in range(3): @@ -49,14 +48,16 @@ def test_multiple(fact, monitored_dir, server): os.remove(fut) events.extend([ - Event(process=process, event_type=EventType.CREATION, file=fut), - Event(process=process, event_type=EventType.UNLINK, file=fut), + Event(process=process, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=process, event_type=EventType.UNLINK, + file=fut, host_path=''), ]) server.wait_events(events) -def test_ignored(fact, monitored_dir, ignored_dir, server): +def test_ignored(fact, test_file, ignored_dir, server): """ Tests that unlink events on ignored files are not captured by the server. @@ -67,7 +68,7 @@ def test_ignored(fact, monitored_dir, ignored_dir, server): ignored_dir: Temporary directory path that is not monitored by fact. server: The server instance to communicate with. """ - process = Process() + process = Process.from_proc() # Ignored file, must not show up in the server ignored_file = os.path.join(ignored_dir, 'test.txt') @@ -75,17 +76,15 @@ def test_ignored(fact, monitored_dir, ignored_dir, server): f.write('This is to be ignored') os.remove(ignored_file) - ignored_event = Event( - process=process, event_type=EventType.UNLINK, file=ignored_file) + ignored_event = Event(process=process, event_type=EventType.UNLINK, + file=ignored_file, host_path='') print(f'Ignoring: {ignored_event}') # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') - with open(fut, 'w') as f: - f.write('This is a test') - os.remove(fut) + os.remove(test_file) - e = Event(process=process, event_type=EventType.UNLINK, file=fut) + e = Event(process=process, event_type=EventType.UNLINK, + file=test_file, host_path=test_file) print(f'Waiting for event: {e}') server.wait_events([e], ignored=[ignored_event]) @@ -111,13 +110,14 @@ def test_external_process(fact, monitored_dir, server): server: The server instance to communicate with. """ # File Under Test - fut = os.path.join(monitored_dir, 'test.txt') + fut = os.path.join(monitored_dir, 'test2.txt') stop_event = mp.Event() proc = mp.Process(target=do_test, args=(fut, stop_event)) proc.start() - process = Process(proc.pid) + process = Process.from_proc(proc.pid) - removal = Event(process=process, event_type=EventType.UNLINK, file=fut) + removal = Event(process=process, event_type=EventType.UNLINK, + file=fut, host_path='') print(f'Waiting for event: {removal}') try: @@ -125,3 +125,105 @@ def test_external_process(fact, monitored_dir, server): finally: stop_event.set() proc.join(1) + + +def test_overlay(fact, test_container, server): + # File Under Test + fut = '/container-dir/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'rm {fut}') + inspect = docker.APIClient().inspect_container(test_container.id) + upper_dir = inspect['GraphDriver']['Data']['UpperDir'] + + loginuid = pow(2, 32)-1 + touch = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=loginuid) + rm = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/rm', + args=f'rm {fut}', + name='rm', + container_id=test_container.id[:12], + loginuid=loginuid) + events = [ + Event(process=touch, event_type=EventType.CREATION, + file=fut, host_path=''), + Event(process=touch, event_type=EventType.OPEN, + file=fut, host_path=''), + Event(process=rm, event_type=EventType.UNLINK, + file=fut, host_path=''), + ] + + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) + + +def test_mounted_dir(fact, test_container, ignored_dir, server): + # File Under Test + fut = '/mounted/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'touch {fut}') + test_container.exec_run(f'rm {fut}') + + loginuid = pow(2, 32)-1 + touch = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/touch', + args=f'touch {fut}', + name='touch', + container_id=test_container.id[:12], + loginuid=loginuid) + rm = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/rm', + args=f'rm {fut}', + name='rm', + container_id=test_container.id[:12], + loginuid=loginuid) + events = [ + Event(process=touch, event_type=EventType.CREATION, file=fut, + host_path=''), + Event(process=rm, event_type=EventType.UNLINK, file=fut, + host_path=''), + ] + + for e in events: + print(f'Waiting for event: {e}') + + server.wait_events(events) + + +def test_unmonitored_mounted_dir(fact, test_container, test_file, server): + # File Under Test + fut = '/unmonitored/test.txt' + + # Create the exec and an equivalent event that it will trigger + test_container.exec_run(f'rm {fut}') + + process = Process(pid=None, + uid=0, + gid=0, + exe_path='/usr/bin/rm', + args=f'rm {fut}', + name='rm', + container_id=test_container.id[:12], + loginuid=pow(2, 32)-1) + event = Event(process=process, event_type=EventType.UNLINK, + file=fut, host_path=test_file) + print(f'Waiting for event: {event}') + + server.wait_events([event])