From ad66754d4812e237cc8e0b9f1607157fa151e9d2 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Mon, 13 Jul 2026 16:08:36 +0200 Subject: [PATCH 1/2] feat(config): configurable loaded BPF programs This patch makes it possible to decide at runtime which BPF programs should be loaded. To make the code as flexible as possible the configuration side stores the programs in a map, then the bpf side validates the configuration to notify if an unknown BPF program is found. Default behavior is still loading and attaching all BPF programs. --- Cargo.lock | 1 + fact/Cargo.toml | 1 + fact/src/bpf/checks.rs | 6 +- fact/src/bpf/mod.rs | 173 ++++++++++++++++++++++----- fact/src/config/mod.rs | 131 +++++++++++++++----- fact/src/config/tests.rs | 252 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 505 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afdf9bf5..06d9a233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,7 @@ dependencies = [ "serde_json", "shlex 2.0.1", "tempfile", + "thiserror", "tokio", "tokio-native-tls", "tokio-stream", diff --git a/fact/Cargo.toml b/fact/Cargo.toml index 4e649315..4770feb6 100644 --- a/fact/Cargo.toml +++ b/fact/Cargo.toml @@ -35,6 +35,7 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shlex = { workspace = true } +thiserror = { version = "2.0.18" } uuid = { workspace = true } yaml-rust2 = { workspace = true } diff --git a/fact/src/bpf/checks.rs b/fact/src/bpf/checks.rs index d0c88428..f4485c7e 100644 --- a/fact/src/bpf/checks.rs +++ b/fact/src/bpf/checks.rs @@ -4,7 +4,7 @@ use log::debug; pub(super) struct Checks { pub(super) path_hooks_support_bpf_d_path: bool, - pub(super) supports_inode_set_acl: bool, + supports_inode_set_acl: bool, } impl Checks { @@ -40,4 +40,8 @@ impl Checks { }; prog.load(hook, btf).is_ok() } + + pub(super) fn is_unsupported_hook(&self, hook: &str) -> bool { + hook == "inode_set_acl" && !self.supports_inode_set_acl + } } diff --git a/fact/src/bpf/mod.rs b/fact/src/bpf/mod.rs index b07c86c8..d7cc5d4a 100644 --- a/fact/src/bpf/mod.rs +++ b/fact/src/bpf/mod.rs @@ -50,16 +50,9 @@ impl Bpf { // Include the BPF object as raw bytes at compile-time and load it // at runtime. - let obj = aya::EbpfLoader::new() - .override_global("host_mount_ns", &host_info::get_host_mount_ns(), true) - .override_global( - "path_hooks_support_bpf_d_path", - &(checks.path_hooks_support_bpf_d_path as u8), - true, - ) - .map_max_entries(RINGBUFFER_NAME, bpf_config.ringbuf_size() * 1024) - .map_max_entries("inode_map", bpf_config.inodes_max()) - .load(fact_ebpf::EBPF_OBJ)?; + let obj = Bpf::load_ebpf(&checks, bpf_config)?; + + Bpf::validate_config(&obj, bpf_config); let (tx, rx) = mpsc::channel(100); let paths = Vec::new(); @@ -73,7 +66,7 @@ impl Bpf { links: Vec::new(), }; - bpf.load_progs(&btf)?; + bpf.load_progs(&btf, bpf_config)?; bpf.load_paths()?; Ok((bpf, rx)) @@ -96,6 +89,22 @@ impl Bpf { Ok(()) } + fn load_ebpf(checks: &Checks, bpf_config: &BpfConfig) -> anyhow::Result { + // Include the BPF object as raw bytes at compile-time and load it + // at runtime. + aya::EbpfLoader::new() + .override_global("host_mount_ns", &host_info::get_host_mount_ns(), true) + .override_global( + "path_hooks_support_bpf_d_path", + &(checks.path_hooks_support_bpf_d_path as u8), + true, + ) + .map_max_entries(RINGBUFFER_NAME, bpf_config.ringbuf_size() * 1024) + .map_max_entries("inode_map", bpf_config.inodes_max()) + .load(fact_ebpf::EBPF_OBJ) + .context("failed to load eBPF object") + } + pub fn take_inode_map( &mut self, ) -> anyhow::Result> { @@ -172,7 +181,7 @@ impl Bpf { Ok(()) } - fn load_progs(&mut self, btf: &Btf) -> anyhow::Result<()> { + fn load_progs(&mut self, btf: &Btf, bpf_config: &BpfConfig) -> anyhow::Result<()> { for (name, prog) in self.obj.programs_mut() { // The format used for our hook names is `trace_`, so // we can just strip trace_ to get the hook name we need for @@ -181,8 +190,13 @@ impl Bpf { bail!("Invalid hook name: {name}"); }; + if !bpf_config.program_is_enabled(hook) { + info!("Skipping {hook} loading"); + continue; + } + // Skip hooks that the kernel doesn't support - if hook == "inode_set_acl" && !self.checks.supports_inode_set_acl { + if self.checks.is_unsupported_hook(hook) { info!("Skipping {hook}: not supported on this kernel"); continue; } @@ -195,25 +209,34 @@ impl Bpf { Ok(()) } + /// Attaches the supplied BPF program if it is loaded into the kernel. + fn attach_prog(prog: &mut Program) -> Result { + match prog { + Program::Lsm(prog) if prog.fd().is_ok() => { + let link_id = prog.attach()?; + Ok(prog.take_link(link_id)?) + } + Program::Lsm(_) => Err(BpfAttachError::NotLoaded), + u => unimplemented!("{u:?}"), + } + } + /// Attaches all loaded BPF programs. Programs that were not loaded /// (e.g. optional hooks on unsupported kernels) are skipped. + /// /// If any attach fails, programs that were already attached during - /// this call remain attached (they are not rolled back); callers - /// should treat an `Err` here as fatal. + /// this call are dropped. fn attach_progs(&mut self) -> anyhow::Result<()> { - self.links.clear(); - for (_, prog) in self.obj.programs_mut() { - match prog { - Program::Lsm(prog) => { - if prog.fd().is_err() { - continue; - } - let link_id = prog.attach()?; - self.links.push(prog.take_link(link_id)?); - } - u => unimplemented!("{u:?}"), - } - } + self.links = self + .obj + .programs_mut() + .filter_map(|(_, prog)| match Bpf::attach_prog(prog) { + Ok(link) => Some(Ok(link)), + Err(BpfAttachError::NotLoaded) => None, + Err(e) => Some(Err(e)), + }) + .collect::, BpfAttachError>>()?; + Ok(()) } @@ -222,6 +245,23 @@ impl Bpf { self.links.clear(); } + /// Verify the current configuration for errors. + /// + /// Checks for hooks that are not implemented. + fn validate_config(obj: &Ebpf, bpf_config: &BpfConfig) -> bool { + let mut is_valid = true; + + for name in bpf_config.programs.keys() { + let hook = "trace_".to_string() + name; + if obj.program(&hook).is_none() { + warn!("{name} is not a known program"); + is_valid = false; + } + } + + is_valid + } + // Gather events from the ring buffer and print them out. pub fn start( mut self, @@ -287,15 +327,23 @@ impl Bpf { } } +#[derive(thiserror::Error, Debug)] +enum BpfAttachError { + #[error("attempted to attach unloaded program")] + NotLoaded, + #[error("program error: {0:?}")] + ProgramError(#[from] aya::programs::ProgramError), +} + #[cfg(all(test, feature = "bpf-test"))] mod bpf_tests { - use std::{env, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; + use std::{collections, env, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; use tempfile::NamedTempFile; use tokio::{sync::watch, time::timeout}; use crate::{ - config::{FactConfig, reloader::Reloader}, + config::{BpfProgConfig, FactConfig, reloader::Reloader}, event::{EventTestData, process::Process}, host_info, metrics::exporter::Exporter, @@ -417,4 +465,69 @@ mod bpf_tests { run_tx.send(false).unwrap(); } + + #[test] + fn test_validate_config() { + let tests = [ + (collections::HashMap::new(), true), + ( + collections::HashMap::from([("file_open".into(), BpfProgConfig::default())]), + true, + ), + ( + collections::HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(true), + }, + )]), + true, + ), + ( + collections::HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + )]), + true, + ), + ( + collections::HashMap::from([ + ( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ( + "path_rename".into(), + BpfProgConfig { + enabled: Some(true), + }, + ), + ("path_unlink".into(), BpfProgConfig::default()), + ]), + true, + ), + ( + collections::HashMap::from([("gibberish".into(), BpfProgConfig::default())]), + false, + ), + ]; + + let btf = Btf::from_sys_fs().expect("Failed to read BTF symbols"); + let checks = Checks::new(&btf).expect("Failed to create `checks`"); + let obj = + Bpf::load_ebpf(&checks, &BpfConfig::default()).expect("Failed to load eBPF object"); + + for (programs, expected) in tests { + let mut bpf_config = BpfConfig::default(); + bpf_config.programs = programs.clone(); + + let res = Bpf::validate_config(&obj, &bpf_config); + + assert_eq!(res, expected, "input: {programs:#?}"); + } + } } diff --git a/fact/src/config/mod.rs b/fact/src/config/mod.rs index 88daa519..6363f9fe 100644 --- a/fact/src/config/mod.rs +++ b/fact/src/config/mod.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::read_to_string, net::SocketAddr, path::{Path, PathBuf}, @@ -544,6 +545,7 @@ impl TryFrom<&yaml::Hash> for OTelConfig { pub struct BpfConfig { ringbuf_size: Option, inodes_max: Option, + pub programs: HashMap, } impl BpfConfig { @@ -555,6 +557,10 @@ impl BpfConfig { if let Some(inodes_max) = from.inodes_max { self.inodes_max = Some(inodes_max); } + + for (k, v) in &from.programs { + self.programs.entry(k.clone()).or_default().update(v); + } } pub fn ringbuf_size(&self) -> u32 { @@ -564,44 +570,112 @@ impl BpfConfig { pub fn inodes_max(&self) -> u32 { self.inodes_max.unwrap_or(65536) } + + pub fn program_is_enabled(&self, name: &str) -> bool { + self.programs.get(name).map(|c| c.enabled()).unwrap_or(true) + } } impl TryFrom<&yaml::Hash> for BpfConfig { type Error = anyhow::Error; fn try_from(value: &yaml::Hash) -> Result { - value - .iter() - .try_fold(BpfConfig::default(), |mut bpf, (k, v)| { - let Some(k) = k.as_str() else { - bail!("key is not string: {k:?}"); - }; - - match k { - "ringbuf_size" => { - let Some(rb_size) = v.as_i64() else { - bail!("ringbuf_size field has incorrect type: {v:?}"); - }; - if rb_size < 64 || rb_size > (u32::MAX / 1024) as i64 { - bail!("ringbuf_size out of range: {rb_size}"); - } - let rb_size = rb_size as u32; - if rb_size.count_ones() != 1 { - bail!("ringbuf_size is not a power of 2: {rb_size}"); - } - bpf.ringbuf_size = Some(rb_size); + let mut bpf = BpfConfig::default(); + for (k, v) in value { + let Some(k) = k.as_str() else { + bail!("key is not string: {k:?}"); + }; + + match k { + "ringbuf_size" => { + let Some(rb_size) = v.as_i64() else { + bail!("ringbuf_size field has incorrect type: {v:?}"); + }; + if rb_size < 64 || rb_size > (u32::MAX / 1024) as i64 { + bail!("ringbuf_size out of range: {rb_size}"); } - "inodes_max" => { - let Some(inode_max) = v.as_i64() else { - bail!("inodes_max field has incorrect type: {v:?}"); - }; - bpf.inodes_max = Some(inode_max as u32); + let rb_size = rb_size as u32; + if rb_size.count_ones() != 1 { + bail!("ringbuf_size is not a power of 2: {rb_size}"); } - name => bail!("Invalid field 'bpf.{name}' with value: {v:?}"), + bpf.ringbuf_size = Some(rb_size); + } + "inodes_max" => { + let Some(inode_max) = v.as_i64() else { + bail!("inodes_max field has incorrect type: {v:?}"); + }; + bpf.inodes_max = Some(inode_max as u32); } + "programs" => { + let Some(programs) = v.as_hash() else { + bail!("bpf.programs field has incorrect type: {v:?}"); + }; + bpf.programs = programs + .iter() + .map(|(name, config)| { + let Some(name) = name.as_str() else { + bail!("bpf program name is not string: {name:?}"); + }; + let Some(config) = config.as_hash() else { + bail!("bpf.programs.{name} has wrong type: {config:?}"); + }; + let config = match BpfProgConfig::try_from(config) { + Ok(c) => c, + Err(e) => { + bail!("bpf.programs.{name} parsing failed: {e:?}"); + } + }; - Ok(bpf) - }) + Ok((name.into(), config)) + }) + .collect::, _>>()?; + } + name => bail!("Invalid field 'bpf.{name}' with value: {v:?}"), + } + } + Ok(bpf) + } +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct BpfProgConfig { + pub enabled: Option, +} + +impl BpfProgConfig { + fn update(&mut self, from: &BpfProgConfig) { + if let Some(enabled) = from.enabled { + self.enabled = Some(enabled); + } + } + + pub fn enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } +} + +impl TryFrom<&yaml::Hash> for BpfProgConfig { + type Error = anyhow::Error; + + fn try_from(value: &yaml::Hash) -> Result { + let mut bpf_prog_config = BpfProgConfig::default(); + for (k, v) in value.iter() { + let Some(k) = k.as_str() else { + bail!("key is not string: {k:?}"); + }; + + match k { + "enabled" => { + let Some(enabled) = v.as_bool() else { + bail!("enabled field has wrong type: {v:?}"); + }; + bpf_prog_config.enabled = Some(enabled); + } + name => bail!("Invalid field '{name}' with value: {v:?}"), + } + } + + Ok(bpf_prog_config) } } @@ -788,6 +862,7 @@ impl FactCli { bpf: BpfConfig { ringbuf_size: self.ringbuf_size, inodes_max: self.inodes_max, + programs: HashMap::new(), }, skip_pre_flight: resolve_bool_arg(self.skip_pre_flight, self.no_skip_pre_flight), json: resolve_bool_arg(self.json, self.no_json), diff --git a/fact/src/config/tests.rs b/fact/src/config/tests.rs index 18fc4475..88233886 100644 --- a/fact/src/config/tests.rs +++ b/fact/src/config/tests.rs @@ -351,6 +351,64 @@ fn parsing() { ..Default::default() }, ), + ( + r#" + bpf: + programs: + file_open: + enabled: false + "#, + FactConfig { + bpf: BpfConfig { + programs: HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + )]), + ..Default::default() + }, + ..Default::default() + }, + ), + ( + r#" + bpf: + programs: + file_open: + enabled: false + path_unlink: + enabled: true + giberish: + enabled: false + "#, + FactConfig { + bpf: BpfConfig { + programs: HashMap::from([ + ( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ( + "path_unlink".into(), + BpfProgConfig { + enabled: Some(true), + }, + ), + ( + "giberish".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ]), + ..Default::default() + }, + ..Default::default() + }, + ), ( "hotreload: true", FactConfig { @@ -417,6 +475,13 @@ fn parsing() { bpf: ringbuf_size: 8192 inodes_max: 64 + programs: + file_open: + enabled: false + path_unlink: + enabled: true + giberish: + enabled: false hotreload: false scan_interval: 60 "#, @@ -446,6 +511,26 @@ fn parsing() { bpf: BpfConfig { ringbuf_size: Some(8192), inodes_max: Some(64), + programs: HashMap::from([ + ( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ( + "path_unlink".into(), + BpfProgConfig { + enabled: Some(true), + }, + ), + ( + "giberish".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ]), }, hotreload: Some(false), scan_interval: Some(Duration::from_secs(60)), @@ -764,6 +849,48 @@ paths: "#, "inodes_max field has incorrect type: Boolean(true)", ), + ( + r#" + bpf: + programs: file_open + "#, + "bpf.programs field has incorrect type: String(\"file_open\")", + ), + ( + r#" + bpf: + programs: + true: + enabled: true + "#, + "bpf program name is not string: Boolean(true)", + ), + ( + r#" + bpf: + programs: + file_open: something + "#, + "bpf.programs.file_open has wrong type: String(\"something\")", + ), + ( + r#" + bpf: + programs: + file_open: + something: true + "#, + "bpf.programs.file_open parsing failed: Invalid field 'something' with value: Boolean(true)", + ), + ( + r#" + bpf: + programs: + file_open: + enabled: 5 + "#, + "bpf.programs.file_open parsing failed: enabled field has wrong type: Integer(5)", + ), ( "hotreload: 4", "hotreload field has incorrect type: Integer(4)", @@ -1492,6 +1619,59 @@ fn update() { ..Default::default() }, ), + ( + r#" + bpf: + programs: + file_open: + enabled: true + "#, + FactConfig::default(), + FactConfig { + bpf: BpfConfig { + programs: HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(true), + }, + )]), + ..Default::default() + }, + ..Default::default() + }, + ), + ( + r#" + bpf: + programs: + file_open: + enabled: true + "#, + FactConfig { + bpf: BpfConfig { + programs: HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + )]), + ..Default::default() + }, + ..Default::default() + }, + FactConfig { + bpf: BpfConfig { + programs: HashMap::from([( + "file_open".into(), + BpfProgConfig { + enabled: Some(true), + }, + )]), + ..Default::default() + }, + ..Default::default() + }, + ), ( "hotreload: false", FactConfig::default(), @@ -1595,6 +1775,9 @@ fn update() { bpf: ringbuf_size: 16384 inodes_max: 8192 + programs: + file_open: + enabled: false hotreload: false scan_interval: 60 "#, @@ -1624,6 +1807,12 @@ fn update() { bpf: BpfConfig { ringbuf_size: Some(64), inodes_max: Some(4096), + programs: HashMap::from([( + "path_unlink".into(), + BpfProgConfig { + enabled: Some(false), + }, + )]), }, hotreload: Some(true), scan_interval: Some(Duration::from_secs(30)), @@ -1655,6 +1844,20 @@ fn update() { bpf: BpfConfig { ringbuf_size: Some(16384), inodes_max: Some(8192), + programs: HashMap::from([ + ( + "path_unlink".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ( + "file_open".into(), + BpfProgConfig { + enabled: Some(false), + }, + ), + ]), }, hotreload: Some(false), scan_interval: Some(Duration::from_secs(60)), @@ -1698,6 +1901,55 @@ fn defaults() { assert_eq!(config.otel.endpoint(), None); } +#[test] +fn bpf_prog_defaults() { + let config = BpfProgConfig::default(); + assert!(config.enabled()); +} + +#[test] +fn bpf_prog_enabled() { + const PROGRAM: &str = "file_open"; + let tests = [ + ( + BpfConfig { + programs: HashMap::new(), + ..Default::default() + }, + true, + ), + ( + BpfConfig { + programs: HashMap::from([( + PROGRAM.into(), + BpfProgConfig { + enabled: Some(true), + }, + )]), + ..Default::default() + }, + true, + ), + ( + BpfConfig { + programs: HashMap::from([( + PROGRAM.into(), + BpfProgConfig { + enabled: Some(false), + }, + )]), + ..Default::default() + }, + false, + ), + ]; + + for (bpf_config, expected) in tests { + let is_enabled = bpf_config.program_is_enabled(PROGRAM); + assert_eq!(is_enabled, expected) + } +} + static ENV_MUTEX: Mutex<()> = Mutex::new(()); /// RAII guard that holds the `ENV_MUTEX` lock and removes the named environment From 45914e6fe2a819f930a52614bc25b5a819577741 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Tue, 14 Jul 2026 10:48:21 +0200 Subject: [PATCH 2/2] chore: add CHANGELOG line --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb1de72..947dddb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ possible include a PR number for easier tracking. ## Next +* feat(config): configurable loaded BPF programs (#1086) * feat(output): add basic opentelemetry output (#971) * feat(grpc): add exponential backoff for reconnection attempts (#789) * feat: run integration tests on more platforms (#760)