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