From 37aa52af5080641c679b4a1c408c362e1c8644e8 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Thu, 6 Nov 2025 10:58:53 +0100 Subject: [PATCH 1/3] feat(ebpf): add bprm_check_security probe to track process executions This change introduces the ability to monitor process executions by adding a new LSM probe attached to `bprm_check_security`. A new event type, `PROCESS_ACTIVITY_EXEC`, is used to signal these events from the kernel. To support activities beyond file operations, the main `Event` struct has been refactored. It now contains a generic `Activity` enum that can represent either a file or a process activity. This makes the event model more extensible for future additions, such as network events. Additionally, a defensive null check for the `dentry` pointer has been added in the `submit_event` function to improve robustness. This entire commit has been vibe coded, took over an hour to get here with multiple prompts. --- fact-ebpf/src/bpf/events.h | 10 +++--- fact-ebpf/src/bpf/main.c | 11 ++++++ fact-ebpf/src/bpf/types.h | 2 ++ fact/src/bpf.rs | 3 +- fact/src/event/mod.rs | 74 +++++++++++++++++++++++++++++++------- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/fact-ebpf/src/bpf/events.h b/fact-ebpf/src/bpf/events.h index 38395132..0d19afe0 100644 --- a/fact-ebpf/src/bpf/events.h +++ b/fact-ebpf/src/bpf/events.h @@ -16,10 +16,12 @@ __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(); - const char* p = get_host_path(helper->buf, dentry); - if (p != NULL) { - bpf_probe_read_str(event->host_file, PATH_MAX, p); + if (dentry != NULL) { + struct helper_t* helper = get_helper(); + const char* p = get_host_path(helper->buf, dentry); + if (p != NULL) { + bpf_probe_read_str(event->host_file, PATH_MAX, p); + } } int64_t err = process_fill(&event->process); diff --git a/fact-ebpf/src/bpf/main.c b/fact-ebpf/src/bpf/main.c index 8a05e389..ef4bed07 100644 --- a/fact-ebpf/src/bpf/main.c +++ b/fact-ebpf/src/bpf/main.c @@ -91,3 +91,14 @@ int BPF_PROG(trace_path_unlink, struct path* dir, struct dentry* dentry) { m->path_unlink.error++; return 0; } + +SEC("lsm/bprm_check_security") +int BPF_PROG(trace_bprm_check, struct linux_binprm* bprm) { + struct metrics_t* m = get_metrics(); + + m->bprm_check.total++; + + submit_event(&m->bprm_check, PROCESS_ACTIVITY_EXEC, "", NULL); + + return 0; +} diff --git a/fact-ebpf/src/bpf/types.h b/fact-ebpf/src/bpf/types.h index f32ade10..3357788e 100644 --- a/fact-ebpf/src/bpf/types.h +++ b/fact-ebpf/src/bpf/types.h @@ -37,6 +37,7 @@ typedef enum file_activity_type_t { FILE_ACTIVITY_OPEN = 0, FILE_ACTIVITY_CREATION, FILE_ACTIVITY_UNLINK, + PROCESS_ACTIVITY_EXEC, } file_activity_type_t; struct event_t { @@ -73,4 +74,5 @@ struct metrics_by_hook_t { struct metrics_t { struct metrics_by_hook_t file_open; struct metrics_by_hook_t path_unlink; + struct metrics_by_hook_t bprm_check; }; diff --git a/fact/src/bpf.rs b/fact/src/bpf.rs index b1b24e44..0a4abc17 100644 --- a/fact/src/bpf.rs +++ b/fact/src/bpf.rs @@ -139,7 +139,8 @@ impl Bpf { fn load_progs(&mut self) -> anyhow::Result<()> { let btf = Btf::from_sys_fs()?; self.load_lsm_prog("trace_file_open", "file_open", &btf)?; - self.load_lsm_prog("trace_path_unlink", "path_unlink", &btf) + self.load_lsm_prog("trace_path_unlink", "path_unlink", &btf)?; + self.load_lsm_prog("trace_bprm_check", "bprm_check_security", &btf) } fn attach_progs(&mut self) -> anyhow::Result<()> { diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 745b426d..277a3767 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -21,12 +21,54 @@ fn timestamp_to_proto(ts: u64) -> prost_types::Timestamp { prost_types::Timestamp { seconds, nanos } } +#[derive(Debug, Clone, Serialize, PartialEq)] +pub enum ProcessActivity { + Exec, +} + +#[derive(Debug, Clone, Serialize)] +pub enum Activity { + File(FileData), + Process(ProcessActivity), +} + +#[cfg(test)] +impl PartialEq for Activity { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::File(l), Self::File(r)) => l == r, + (Self::Process(l), Self::Process(r)) => l == r, + _ => false, + } + } +} + +impl Activity { + pub fn new( + event_type: file_activity_type_t, + filename: [c_char; PATH_MAX as usize], + host_file: [c_char; PATH_MAX as usize], + ) -> anyhow::Result { + let activity = match event_type { + file_activity_type_t::FILE_ACTIVITY_OPEN + | file_activity_type_t::FILE_ACTIVITY_CREATION + | file_activity_type_t::FILE_ACTIVITY_UNLINK => { + Activity::File(FileData::new(event_type, filename, host_file)?) + } + file_activity_type_t::PROCESS_ACTIVITY_EXEC => Activity::Process(ProcessActivity::Exec), + invalid => unreachable!("Invalid event type: {invalid:?}"), + }; + + Ok(activity) + } +} + #[derive(Debug, Clone, Serialize)] pub struct Event { timestamp: u64, hostname: &'static str, process: Process, - file: FileData, + activity: Activity, } impl Event { @@ -46,10 +88,13 @@ impl Event { filename, host_file, }; - let file = match event_type { - file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), - file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), - file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), + let activity = match event_type { + file_activity_type_t::FILE_ACTIVITY_OPEN => Activity::File(FileData::Open(inner)), + file_activity_type_t::FILE_ACTIVITY_CREATION => { + Activity::File(FileData::Creation(inner)) + } + file_activity_type_t::FILE_ACTIVITY_UNLINK => Activity::File(FileData::Unlink(inner)), + file_activity_type_t::PROCESS_ACTIVITY_EXEC => Activity::Process(ProcessActivity::Exec), invalid => unreachable!("Invalid event type: {invalid:?}"), }; @@ -57,7 +102,7 @@ impl Event { timestamp, hostname, process, - file, + activity, }) } } @@ -68,25 +113,28 @@ impl TryFrom<&event_t> for Event { fn try_from(value: &event_t) -> Result { let process = Process::try_from(value.process)?; let timestamp = host_info::get_boot_time() + value.timestamp; - let file = FileData::new(value.type_, value.filename, value.host_file)?; + let activity = Activity::new(value.type_, value.filename, value.host_file)?; Ok(Event { timestamp, hostname: host_info::get_hostname(), process, - file, + activity, }) } } impl From for fact_api::FileActivity { fn from(value: Event) -> Self { - let file = fact_api::file_activity::File::from(value.file); + let file = match value.activity { + Activity::File(file) => Some(fact_api::file_activity::File::from(file)), + Activity::Process(_) => None, + }; let timestamp = timestamp_to_proto(value.timestamp); let process = fact_api::ProcessSignal::from(value.process); Self { - file: Some(file), + file, timestamp: Some(timestamp), process: Some(process), } @@ -96,7 +144,9 @@ impl From for fact_api::FileActivity { #[cfg(test)] impl PartialEq for Event { fn eq(&self, other: &Self) -> bool { - self.hostname == other.hostname && self.process == other.process && self.file == other.file + self.hostname == other.hostname + && self.process == other.process + && self.activity == other.activity } } @@ -118,7 +168,7 @@ impl FileData { file_activity_type_t::FILE_ACTIVITY_OPEN => FileData::Open(inner), file_activity_type_t::FILE_ACTIVITY_CREATION => FileData::Creation(inner), file_activity_type_t::FILE_ACTIVITY_UNLINK => FileData::Unlink(inner), - invalid => unreachable!("Invalid event type: {invalid:?}"), + invalid => unreachable!("Invalid file event type: {invalid:?}"), }; Ok(file) From f2d59d4abb540abc029f9a26571fb0616b82a6f5 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Thu, 6 Nov 2025 11:54:27 +0100 Subject: [PATCH 2/3] feat(grpc): implement signal service stream The StackRox sensor API consumes events from different gRPC services depending on their type. This change introduces support for the `SignalService` to send process-related signals, separating them from the existing `FileActivityService`. This involves: - Compiling the `signal_iservice.proto` definitions. - Adding a `SignalServiceClient` in the gRPC output module. - Implementing the conversion from the internal `Event` struct to the `v1.Signal` protobuf message. - Updating the gRPC client to run two concurrent streams, filtering and routing file and process events to their respective services. - Refactoring protobuf conversions to align with upstream API models. This entire commit was vibe coded, took about 1 hour. --- fact-api/build.rs | 6 +++- fact-api/src/lib.rs | 2 +- fact/src/event/mod.rs | 64 +++++++++++++++++++++++++++++---------- fact/src/event/process.rs | 26 ++++++++-------- fact/src/output/grpc.rs | 58 ++++++++++++++++++++++++++++++----- 5 files changed, 116 insertions(+), 40 deletions(-) diff --git a/fact-api/build.rs b/fact-api/build.rs index 4a84a8ed..4bd67073 100644 --- a/fact-api/build.rs +++ b/fact-api/build.rs @@ -3,8 +3,12 @@ use anyhow::Context; fn main() -> anyhow::Result<()> { tonic_prost_build::configure() .build_server(false) + .include_file("mod.rs") .compile_protos( - &["../third_party/stackrox/proto/internalapi/sensor/sfa_iservice.proto"], + &[ + "../third_party/stackrox/proto/internalapi/sensor/sfa_iservice.proto", + "../third_party/stackrox/proto/internalapi/sensor/signal_iservice.proto", + ], &["../third_party/stackrox/proto"], ) .context("Failed to compile protos. Please makes sure you update your git submodules!")?; diff --git a/fact-api/src/lib.rs b/fact-api/src/lib.rs index 5d145007..4d7dd058 100644 --- a/fact-api/src/lib.rs +++ b/fact-api/src/lib.rs @@ -1 +1 @@ -tonic::include_proto!("sensor"); +tonic::include_proto!("mod"); diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 277a3767..09372346 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -68,7 +68,7 @@ pub struct Event { timestamp: u64, hostname: &'static str, process: Process, - activity: Activity, + pub activity: Activity, } impl Event { @@ -124,14 +124,46 @@ impl TryFrom<&event_t> for Event { } } -impl From for fact_api::FileActivity { +impl From for fact_api::v1::Signal { + fn from(value: Event) -> Self { + let process_signal = fact_api::storage::ProcessSignal::from(value.process); + fact_api::v1::Signal { + signal: Some(fact_api::v1::signal::Signal::ProcessSignal(process_signal)), + } + } +} + +impl From for fact_api::sensor::FileActivity { fn from(value: Event) -> Self { let file = match value.activity { - Activity::File(file) => Some(fact_api::file_activity::File::from(file)), + Activity::File(file) => Some(fact_api::sensor::file_activity::File::from(file)), Activity::Process(_) => None, }; let timestamp = timestamp_to_proto(value.timestamp); - let process = fact_api::ProcessSignal::from(value.process); + let process_signal = fact_api::storage::ProcessSignal::from(value.process.clone()); + let process = fact_api::sensor::ProcessSignal { + id: process_signal.id, + container_id: process_signal.container_id, + creation_time: process_signal.time, + name: process_signal.name, + args: process_signal.args, + exec_file_path: process_signal.exec_file_path, + pid: process_signal.pid, + gid: process_signal.gid, + uid: process_signal.uid, + username: value.process.username.to_string(), + login_uid: value.process.login_uid, + in_root_mount_ns: value.process.in_root_mount_ns, + scraped: process_signal.scraped, + lineage_info: process_signal + .lineage_info + .into_iter() + .map(|l| fact_api::sensor::process_signal::LineageInfo { + parent_uid: l.parent_uid, + parent_exec_file_path: l.parent_exec_file_path, + }) + .collect(), + }; Self { file, @@ -175,23 +207,23 @@ impl FileData { } } -impl From for fact_api::file_activity::File { +impl From for fact_api::sensor::file_activity::File { fn from(event: FileData) -> Self { match event { FileData::Open(event) => { - let activity = Some(fact_api::FileActivityBase::from(event)); - let f_act = fact_api::FileOpen { activity }; - fact_api::file_activity::File::Open(f_act) + let activity = Some(fact_api::sensor::FileActivityBase::from(event)); + let f_act = fact_api::sensor::FileOpen { activity }; + fact_api::sensor::file_activity::File::Open(f_act) } FileData::Creation(event) => { - let activity = Some(fact_api::FileActivityBase::from(event)); - let f_act = fact_api::FileCreation { activity }; - fact_api::file_activity::File::Creation(f_act) + let activity = Some(fact_api::sensor::FileActivityBase::from(event)); + let f_act = fact_api::sensor::FileCreation { activity }; + fact_api::sensor::file_activity::File::Creation(f_act) } FileData::Unlink(event) => { - let activity = Some(fact_api::FileActivityBase::from(event)); - let f_act = fact_api::FileUnlink { activity }; - fact_api::file_activity::File::Unlink(f_act) + let activity = Some(fact_api::sensor::FileActivityBase::from(event)); + let f_act = fact_api::sensor::FileUnlink { activity }; + fact_api::sensor::file_activity::File::Unlink(f_act) } } } @@ -237,9 +269,9 @@ impl PartialEq for BaseFileData { } } -impl From for fact_api::FileActivityBase { +impl From for fact_api::sensor::FileActivityBase { fn from(value: BaseFileData) -> Self { - fact_api::FileActivityBase { + fact_api::sensor::FileActivityBase { path: value.filename.to_string_lossy().to_string(), host_path: value.host_file.to_string_lossy().to_string(), } diff --git a/fact/src/event/process.rs b/fact/src/event/process.rs index 67e1056d..e869d58c 100644 --- a/fact/src/event/process.rs +++ b/fact/src/event/process.rs @@ -34,7 +34,7 @@ impl TryFrom<&lineage_t> for Lineage { } } -impl From for fact_api::process_signal::LineageInfo { +impl From for fact_api::storage::process_signal::LineageInfo { fn from(value: Lineage) -> Self { let Lineage { uid, exe_path } = value; Self { @@ -51,11 +51,11 @@ pub struct Process { exe_path: String, container_id: Option, uid: u32, - username: &'static str, + pub username: &'static str, gid: u32, - login_uid: u32, + pub login_uid: u32, pid: u32, - in_root_mount_ns: bool, + pub in_root_mount_ns: bool, lineage: Vec, } @@ -136,7 +136,6 @@ impl PartialEq for Process { && self.in_root_mount_ns == other.in_root_mount_ns } } - impl TryFrom for Process { type Error = anyhow::Error; @@ -184,7 +183,7 @@ impl TryFrom for Process { } } -impl From for fact_api::ProcessSignal { +impl From for fact_api::storage::ProcessSignal { fn from(value: Process) -> Self { let Process { comm, @@ -192,11 +191,11 @@ impl From for fact_api::ProcessSignal { exe_path, container_id, uid, - username, + username: _, gid, - login_uid, + login_uid: _, pid, - in_root_mount_ns, + in_root_mount_ns: _, lineage, } = value; @@ -207,10 +206,11 @@ impl From for fact_api::ProcessSignal { .reduce(|acc, i| acc + " " + &i) .unwrap_or("".to_owned()); + #[allow(deprecated)] Self { id: Uuid::new_v4().to_string(), container_id, - creation_time: None, + time: None, name: comm, args, exec_file_path: exe_path, @@ -218,13 +218,11 @@ impl From for fact_api::ProcessSignal { uid, gid, scraped: false, + lineage: vec![], lineage_info: lineage .into_iter() - .map(fact_api::process_signal::LineageInfo::from) + .map(fact_api::storage::process_signal::LineageInfo::from) .collect(), - login_uid, - username: username.to_owned(), - in_root_mount_ns, } } } diff --git a/fact/src/output/grpc.rs b/fact/src/output/grpc.rs index 0cebb735..3d68e0ce 100644 --- a/fact/src/output/grpc.rs +++ b/fact/src/output/grpc.rs @@ -1,7 +1,10 @@ use std::{fs::read_to_string, path::Path, sync::Arc, time::Duration}; use anyhow::bail; -use fact_api::file_activity_service_client::FileActivityServiceClient; +use fact_api::sensor::{ + file_activity_service_client::FileActivityServiceClient, + signal_service_client::SignalServiceClient, SignalStreamMessage, +}; use log::{debug, info, warn}; use tokio::{ sync::{broadcast, watch}, @@ -52,6 +55,15 @@ impl Interceptor for UserAgentInterceptor { } } +impl From for SignalStreamMessage { + fn from(value: Event) -> Self { + let signal = fact_api::v1::Signal::from(value); + SignalStreamMessage { + msg: Some(fact_api::sensor::signal_stream_message::Msg::Signal(signal)), + } + } +} + pub struct Client { rx: broadcast::Receiver>, running: watch::Receiver, @@ -127,29 +139,59 @@ impl Client { }; info!("Successfully connected to gRPC server"); - let mut client = - FileActivityServiceClient::with_interceptor(channel, UserAgentInterceptor {}); + let mut sfa_client = FileActivityServiceClient::with_interceptor( + channel.clone(), + UserAgentInterceptor {}, + ); + let mut signal_client = + SignalServiceClient::with_interceptor(channel, UserAgentInterceptor {}); let metrics = self.metrics.clone(); - let rx = + let sfa_rx = + BroadcastStream::new(self.rx.resubscribe()).filter_map(move |event| match event { + Ok(event) => { + if !matches!(event.activity, crate::event::Activity::File(_)) { + return None; + } + metrics.added(); + let event = Arc::unwrap_or_clone(event); + Some(event.into()) + } + Err(BroadcastStreamRecvError::Lagged(n)) => { + warn!("gRPC sfa stream lagged, dropped {n} events"); + metrics.dropped_n(n); + None + } + }); + let metrics = self.metrics.clone(); + let signal_rx = BroadcastStream::new(self.rx.resubscribe()).filter_map(move |event| match event { Ok(event) => { + if !matches!(event.activity, crate::event::Activity::Process(_)) { + return None; + } metrics.added(); let event = Arc::unwrap_or_clone(event); Some(event.into()) } Err(BroadcastStreamRecvError::Lagged(n)) => { - warn!("gRPC stream lagged, dropped {n} events"); + warn!("gRPC signal stream lagged, dropped {n} events"); metrics.dropped_n(n); None } }); tokio::select! { - res = client.communicate(rx) => { + res = sfa_client.communicate(sfa_rx) => { + match res { + Ok(_) => info!("gRPC sfa stream ended"), + Err(e) => warn!("gRPC sfa stream error: {e}"), + } + } + res = signal_client.push_signals(signal_rx) => { match res { - Ok(_) => info!("gRPC stream ended"), - Err(e) => warn!("gRPC stream error: {e}"), + Ok(_) => info!("gRPC signal stream ended"), + Err(e) => warn!("gRPC signal stream error: {e}"), } } _ = self.config.changed() => return Ok(true), From 87689523d57365fed45735029a8cc91b36faee33 Mon Sep 17 00:00:00 2001 From: Mauro Ezequiel Moltrasio Date: Thu, 6 Nov 2025 13:55:44 +0100 Subject: [PATCH 3/3] feat(bpf): drop process events with no container id Process events that do not have an associated container ID are considered noise. This change filters them out in the main BPF event loop to avoid sending them downstream, reducing the overall volume of events. This change was vibe coded. --- fact/src/bpf.rs | 16 ++++++++++++++-- fact/src/event/mod.rs | 2 +- fact/src/event/process.rs | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/fact/src/bpf.rs b/fact/src/bpf.rs index 0a4abc17..30342bd1 100644 --- a/fact/src/bpf.rs +++ b/fact/src/bpf.rs @@ -14,7 +14,11 @@ use tokio::{ task::JoinHandle, }; -use crate::{event::Event, host_info, metrics::EventCounter}; +use crate::{ + event::{Activity, Event}, + host_info, + metrics::EventCounter, +}; use fact_ebpf::{event_t, metrics_t, path_prefix_t, LPM_SIZE_MAX}; @@ -175,7 +179,7 @@ impl Bpf { while let Some(event) = ringbuf.next() { let event: &event_t = unsafe { &*(event.as_ptr() as *const _) }; let event = match Event::try_from(event) { - Ok(event) => Arc::new(event), + Ok(event) => event, Err(e) => { error!("Failed to parse event: '{e}'"); debug!("Event: {event:?}"); @@ -184,6 +188,14 @@ impl Bpf { } }; + if matches!(event.activity, Activity::Process(_)) + && event.process.container_id.is_none() + { + event_counter.dropped(); + continue; + } + + let event = Arc::new(event); event_counter.added(); if self.tx.send(event).is_err() { info!("No BPF consumers left, stopping..."); diff --git a/fact/src/event/mod.rs b/fact/src/event/mod.rs index 09372346..1ceb2a1d 100644 --- a/fact/src/event/mod.rs +++ b/fact/src/event/mod.rs @@ -67,7 +67,7 @@ impl Activity { pub struct Event { timestamp: u64, hostname: &'static str, - process: Process, + pub process: Process, pub activity: Activity, } diff --git a/fact/src/event/process.rs b/fact/src/event/process.rs index e869d58c..6c153d18 100644 --- a/fact/src/event/process.rs +++ b/fact/src/event/process.rs @@ -49,7 +49,7 @@ pub struct Process { comm: String, args: Vec, exe_path: String, - container_id: Option, + pub container_id: Option, uid: u32, pub username: &'static str, gid: u32,