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