diff --git a/vectorguard/config.toml b/vectorguard/config.toml index 0010cb3..e8f764f 100644 --- a/vectorguard/config.toml +++ b/vectorguard/config.toml @@ -39,9 +39,16 @@ time_window_secs = 60 similarity_threshold = 0.85 [slow_path.embedder] -backend = "local" # local | openai | claude +backend = "local" # local | openai | voyage | gemini model = "nomic-embed-text" api_key_env = "" +# endpoint = "http://localhost:11434" # optional: Ollama endpoint for local backend +# +# Examples: +# OpenAI: backend="openai" model="text-embedding-3-small" api_key_env="OPENAI_API_KEY" +# Voyage: backend="voyage" model="voyage-3" api_key_env="VOYAGE_API_KEY" +# Gemini: backend="gemini" model="text-embedding-004" api_key_env="GEMINI_API_KEY" +# Ollama: backend="local" model="nomic-embed-text" endpoint="http://localhost:11434" [slow_path.vectordb] backend = "qdrant" # qdrant | usearch diff --git a/vectorguard/src/adapter/auditd.rs b/vectorguard/src/adapter/auditd.rs index 3922c8f..28ee227 100644 --- a/vectorguard/src/adapter/auditd.rs +++ b/vectorguard/src/adapter/auditd.rs @@ -135,6 +135,7 @@ fn parse_auditd_syscall(line: &str) -> Result { event_type, severity, action: Action::Allowed, + rule_name: None, k8s: None, raw: serde_json::Value::String(line.to_string()), }) diff --git a/vectorguard/src/adapter/falco.rs b/vectorguard/src/adapter/falco.rs index a86d3fb..9b94bd4 100644 --- a/vectorguard/src/adapter/falco.rs +++ b/vectorguard/src/adapter/falco.rs @@ -139,6 +139,7 @@ fn parse_falco_json(line: &str) -> Result { event_type, severity, action: Action::Allowed, + rule_name: None, k8s: None, raw: v, }) diff --git a/vectorguard/src/adapter/tetragon.rs b/vectorguard/src/adapter/tetragon.rs index 6454024..9b3db14 100644 --- a/vectorguard/src/adapter/tetragon.rs +++ b/vectorguard/src/adapter/tetragon.rs @@ -126,6 +126,7 @@ fn convert_exec(e: pb::ProcessExec) -> Option { event_type: event::EventType::Exec, severity: Severity::Info, action: Action::Allowed, + rule_name: None, k8s: k8s_from(&proc), raw: serde_json::Value::Null, }) @@ -143,6 +144,7 @@ fn convert_exit(e: pb::ProcessExit) -> Option { event_type: event::EventType::Signal { signum: 0, target_pid }, severity: Severity::Info, action: Action::Allowed, + rule_name: None, k8s: k8s_from(&proc), raw: serde_json::Value::Null, }) @@ -165,6 +167,7 @@ fn convert_kprobe(e: pb::ProcessKprobe) -> Option { event_type, severity, action: Action::Allowed, + rule_name: None, k8s: k8s_from(&proc), raw: serde_json::Value::Null, }) @@ -184,6 +187,7 @@ fn convert_tracepoint(e: pb::ProcessTracepoint) -> Option { }, severity: Severity::Medium, action: Action::Allowed, + rule_name: None, k8s: k8s_from(&proc), raw: serde_json::Value::Null, }) diff --git a/vectorguard/src/collector.rs b/vectorguard/src/collector.rs index fd199e5..44c0f52 100644 --- a/vectorguard/src/collector.rs +++ b/vectorguard/src/collector.rs @@ -146,6 +146,7 @@ fn parse_raw_event(data: &[u8]) -> Result { event_type, severity, action, + rule_name: None, k8s: None, raw: serde_json::Value::Null, }) diff --git a/vectorguard/src/config.rs b/vectorguard/src/config.rs index 6412550..d09df69 100644 --- a/vectorguard/src/config.rs +++ b/vectorguard/src/config.rs @@ -108,7 +108,11 @@ pub struct SlowPathConfig { pub struct EmbedderConfig { pub backend: EmbedderBackend, pub model: String, + #[serde(default)] pub api_key_env: String, + /// Optional custom endpoint (e.g. Ollama: "http://localhost:11434") + #[serde(default)] + pub endpoint: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] @@ -116,7 +120,8 @@ pub struct EmbedderConfig { pub enum EmbedderBackend { Local, Openai, - Claude, + Voyage, + Gemini, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/vectorguard/src/event/mod.rs b/vectorguard/src/event/mod.rs index 65c2bed..d4f587b 100644 --- a/vectorguard/src/event/mod.rs +++ b/vectorguard/src/event/mod.rs @@ -11,6 +11,7 @@ pub struct NormalizedEvent { pub event_type: EventType, pub severity: Severity, pub action: Action, + pub rule_name: Option, pub k8s: Option, pub raw: serde_json::Value, } diff --git a/vectorguard/src/fast_path/mod.rs b/vectorguard/src/fast_path/mod.rs index bc9a68b..673eb63 100644 --- a/vectorguard/src/fast_path/mod.rs +++ b/vectorguard/src/fast_path/mod.rs @@ -38,14 +38,18 @@ impl FastPath { return; } - let action = self.ruleset.evaluate(event).unwrap_or(self.default_action.clone()); + let (action, rule_name) = self.ruleset.evaluate(event) + .map(|(a, name)| (a, Some(name))) + .unwrap_or((self.default_action.clone(), None)); debug!( pid = event.process.pid, binary = %event.process.binary, action = ?action, + rule = ?rule_name, "fast_path evaluation complete" ); event.action = action; + event.rule_name = rule_name; if matches!(event.action, Action::Blocked | Action::Alerted) { event.severity = Severity::High; } diff --git a/vectorguard/src/fast_path/rules.rs b/vectorguard/src/fast_path/rules.rs index 685f9ca..538d9aa 100644 --- a/vectorguard/src/fast_path/rules.rs +++ b/vectorguard/src/fast_path/rules.rs @@ -83,12 +83,12 @@ impl RuleSet { RuleSet { rules: all_rules } } - /// Return the Action of the first matching rule, or None if no rule matches - pub fn evaluate(&self, event: &NormalizedEvent) -> Option { + /// Return the (Action, rule name) of the first matching rule, or None if no rule matches + pub fn evaluate(&self, event: &NormalizedEvent) -> Option<(Action, String)> { self.rules .iter() .find(|r| r.matches(event)) - .map(|r| r.action.to_action()) + .map(|r| (r.action.to_action(), r.name.clone())) } fn builtin_rules() -> Vec { @@ -228,6 +228,7 @@ mod tests { event_type, severity: Severity::Info, action: Action::Allowed, + rule_name: None, k8s: None, raw: serde_json::Value::Null, } @@ -259,7 +260,8 @@ mod tests { fn builtin_blocks_shadow_access() { let rs = RuleSet { rules: RuleSet::builtin_rules() }; let ev = file_event("cat", "/etc/shadow"); - assert_eq!(rs.evaluate(&ev), Some(Action::Blocked)); + let result = rs.evaluate(&ev); + assert_eq!(result.as_ref().map(|(a, _)| a), Some(&Action::Blocked)); } #[test] @@ -272,27 +274,17 @@ mod tests { #[test] fn builtin_alerts_shell_from_nginx() { let rs = RuleSet { rules: RuleSet::builtin_rules() }; - let mut ev = exec_event("/bin/bash"); - ev.process.binary = "/bin/bash".to_string(); - // match_process = ["nginx", ...], match_exec_path = ["/bin/sh", "/bin/bash", ...] - // The shell-exec rule requires process name to be nginx etc AND exec path to be a shell - // exec_event sets binary = "/bin/bash" as process too, so it would match exec_path - // but NOT match_process = ["nginx"]. So no match from shell rule. - // Let's create a proper event: process binary = "nginx", event = Exec of /bin/bash - let mut ev2 = base_event("nginx", 33, EventType::Exec); - ev2.process.binary = "/bin/bash".to_string(); // exec path IS the binary in our model - // Actually in our model, for Exec events match_exec_path checks process.binary - // So we need process.binary = "/bin/bash" AND match_process = ["nginx"] - // but those are the SAME field... let's just verify the logic works for path prefix let ev3 = file_event("any", "/etc/sudoers"); - assert_eq!(rs.evaluate(&ev3), Some(Action::Blocked)); + let result = rs.evaluate(&ev3); + assert_eq!(result.as_ref().map(|(a, _)| a), Some(&Action::Blocked)); } #[test] fn builtin_alerts_suspicious_port() { let rs = RuleSet { rules: RuleSet::builtin_rules() }; let ev = net_event("curl", 4444); - assert_eq!(rs.evaluate(&ev), Some(Action::Alerted)); + let result = rs.evaluate(&ev); + assert_eq!(result.as_ref().map(|(a, _)| a), Some(&Action::Alerted)); } #[test] @@ -319,7 +311,7 @@ mod tests { }; let ev_root = base_event("bash", 0, EventType::Exec); let ev_user = base_event("bash", 1000, EventType::Exec); - assert_eq!(rs.evaluate(&ev_root), Some(Action::Alerted)); + assert_eq!(rs.evaluate(&ev_root).map(|(a, _)| a), Some(Action::Alerted)); assert_eq!(rs.evaluate(&ev_user), None); } @@ -336,8 +328,8 @@ mod tests { match_uid: None, }], }; - assert_eq!(rs.evaluate(&exec_event("python3")), Some(Action::Blocked)); - assert_eq!(rs.evaluate(&exec_event("pypy")), Some(Action::Blocked)); + assert_eq!(rs.evaluate(&exec_event("python3")).map(|(a, _)| a), Some(Action::Blocked)); + assert_eq!(rs.evaluate(&exec_event("pypy")).map(|(a, _)| a), Some(Action::Blocked)); assert_eq!(rs.evaluate(&exec_event("ruby")), None); } @@ -359,6 +351,23 @@ mod tests { }, ], }; - assert_eq!(rs.evaluate(&exec_event("nginx")), Some(Action::Alerted)); + assert_eq!(rs.evaluate(&exec_event("nginx")).map(|(a, _)| a), Some(Action::Alerted)); + } + + #[test] + fn evaluate_returns_rule_name() { + let rs = RuleSet { + rules: vec![Rule { + name: "my-custom-rule".into(), + action: RuleAction::Block, + match_process: vec!["bash".into()], + match_path_prefix: vec![], + match_exec_path: vec![], + match_port: vec![], + match_uid: None, + }], + }; + let result = rs.evaluate(&exec_event("bash")); + assert_eq!(result, Some((Action::Blocked, "my-custom-rule".into()))); } } diff --git a/vectorguard/src/incident.rs b/vectorguard/src/incident.rs new file mode 100644 index 0000000..45ff71a --- /dev/null +++ b/vectorguard/src/incident.rs @@ -0,0 +1,91 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; + +use serde::Serialize; +use tracing::{info, warn}; + +use crate::event::{Action, NormalizedEvent}; + +const DEFAULT_INCIDENT_PATH: &str = "/var/log/vectorguard/incidents.jsonl"; + +#[derive(Debug, Serialize)] +struct IncidentRecord { + timestamp: u64, + pid: u32, + ppid: u32, + uid: u32, + binary: String, + event_type: String, + rule: Option, + action: String, +} + +pub struct IncidentLogger { + path: PathBuf, +} + +impl IncidentLogger { + pub fn new(path: Option<&str>) -> Self { + let path = PathBuf::from(path.unwrap_or(DEFAULT_INCIDENT_PATH)); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + warn!("Could not create incident log directory {:?}: {}", parent, e); + } + } + + info!("Incident logger initialized: {}", path.display()); + Self { path } + } + + /// Record an incident if the event is Blocked or Alerted. + /// Returns true if an incident was recorded. + pub fn record(&self, event: &NormalizedEvent) -> bool { + if !matches!(event.action, Action::Blocked | Action::Alerted | Action::Killed) { + return false; + } + + let event_type = match &event.event_type { + crate::event::EventType::Exec => "Exec".to_string(), + crate::event::EventType::FileAccess { path, .. } => format!("FileAccess:{}", path), + crate::event::EventType::Network { port, proto, .. } => format!("Network:{:?}:{}", proto, port), + crate::event::EventType::Privilege { syscall, .. } => format!("Privilege:{}", syscall), + crate::event::EventType::Signal { signum, .. } => format!("Signal:{}", signum), + }; + + let record = IncidentRecord { + timestamp: event.timestamp, + pid: event.process.pid, + ppid: event.process.ppid, + uid: event.process.uid, + binary: event.process.binary.clone(), + event_type, + rule: event.rule_name.clone(), + action: format!("{:?}", event.action), + }; + + match serde_json::to_string(&record) { + Ok(json) => { + match OpenOptions::new().create(true).append(true).open(&self.path) { + Ok(mut file) => { + if let Err(e) = writeln!(file, "{}", json) { + warn!("Failed to write incident: {}", e); + return false; + } + true + } + Err(e) => { + warn!("Failed to open incident file {}: {}", self.path.display(), e); + false + } + } + } + Err(e) => { + warn!("Failed to serialize incident: {}", e); + false + } + } + } +} diff --git a/vectorguard/src/main.rs b/vectorguard/src/main.rs index bcaf6e1..f861164 100644 --- a/vectorguard/src/main.rs +++ b/vectorguard/src/main.rs @@ -7,6 +7,7 @@ mod event; mod adapter; mod fast_path; mod hotreload; +mod incident; mod scope; mod slow_path; mod tui; @@ -130,21 +131,22 @@ async fn main() -> Result<()> { drop(raw_tx); // ── Pipeline Component Initialization ───────────────────────── - let scope_filter = scope::ScopeFilter::new(&cfg.scope); - let fast_path = fast_path::FastPath::new(&cfg.fast_path); - let slow_path = slow_path::SlowPath::new(&cfg.slow_path).await; + let scope_filter = scope::ScopeFilter::new(&cfg.scope); + let fast_path = fast_path::FastPath::new(&cfg.fast_path); + let slow_path = slow_path::SlowPath::new(&cfg.slow_path).await; + let incident_logger = Arc::new(incident::IncidentLogger::new(None)); // ── Event Processing Pipeline Task ──────────────────────────── #[cfg(target_os = "linux")] tokio::spawn(run_pipeline( raw_rx, proc_tx, scope_filter, fast_path, slow_path, - reload_rx, Some(enf_shared), + incident_logger.clone(), reload_rx, Some(enf_shared), )); #[cfg(not(target_os = "linux"))] tokio::spawn(run_pipeline( raw_rx, proc_tx, scope_filter, fast_path, slow_path, - reload_rx, None, + incident_logger.clone(), reload_rx, None, )); // ── Signal ready (for K8s liveness probe) ──────────────────── @@ -172,6 +174,7 @@ async fn run_pipeline( mut scope_filter: scope::ScopeFilter, mut fast_path: fast_path::FastPath, mut slow_path: slow_path::SlowPath, + incident_logger: Arc, mut reload_rx: watch::Receiver, #[cfg(target_os = "linux")] enf_opt: Option>>>, @@ -205,6 +208,7 @@ async fn run_pipeline( fast_path.evaluate(&mut ev); slow_path.analyze(&mut ev).await; + incident_logger.record(&ev); if proc_tx.send(ev).await.is_err() { break; diff --git a/vectorguard/src/scope.rs b/vectorguard/src/scope.rs index 9c809c3..806bd06 100644 --- a/vectorguard/src/scope.rs +++ b/vectorguard/src/scope.rs @@ -127,6 +127,7 @@ mod tests { event_type: EventType::Exec, severity: Severity::Info, action: Action::Allowed, + rule_name: None, k8s, raw: serde_json::Value::Null, } diff --git a/vectorguard/src/slow_path/context.rs b/vectorguard/src/slow_path/context.rs index b5ed0be..b688a0a 100644 --- a/vectorguard/src/slow_path/context.rs +++ b/vectorguard/src/slow_path/context.rs @@ -42,6 +42,11 @@ impl ContextWindow { } } + /// Remove PIDs with empty history (all events expired from time window) + pub fn prune_stale_pids(&mut self) { + self.store.retain(|_, ring| !ring.is_empty()); + } + /// Return a recency-weighted average of past vectors for this PID, or `None` /// if the PID has no history yet (first event → no context available). pub fn context_vector(&self, pid: u32) -> Option> { diff --git a/vectorguard/src/slow_path/embedder.rs b/vectorguard/src/slow_path/embedder.rs deleted file mode 100644 index 0e6af30..0000000 --- a/vectorguard/src/slow_path/embedder.rs +++ /dev/null @@ -1,230 +0,0 @@ -use anyhow::{Context, Result}; -use tracing::warn; - -use crate::config::{EmbedderBackend, EmbedderConfig}; -use crate::event::{EventType, NormalizedEvent, Severity}; - -/// Number of vector dimensions stored in Qdrant (local: 64, OpenAI text-embedding-ada-002: 1536) -pub const VECTOR_DIM: usize = 64; - -pub struct Embedder { - backend: EmbedderBackend, - model: String, - api_key: Option, - client: reqwest::Client, -} - -impl Embedder { - pub fn new(cfg: EmbedderConfig) -> Self { - let api_key = if cfg.api_key_env.is_empty() { - None - } else { - std::env::var(&cfg.api_key_env).ok() - }; - - Self { - backend: cfg.backend, - model: cfg.model, - api_key, - client: reqwest::Client::new(), - } - } - - pub async fn embed(&self, event: &NormalizedEvent) -> Result> { - match self.backend { - EmbedderBackend::Local => Ok(local_embed(event)), - EmbedderBackend::Openai => self.openai_embed(event).await, - EmbedderBackend::Claude => { - // Anthropic does not provide a dedicated embedding API → fall back to local - warn!("Claude embedding backend is not supported — falling back to local"); - Ok(local_embed(event)) - } - } - } - - async fn openai_embed(&self, event: &NormalizedEvent) -> Result> { - let key = self.api_key.as_deref().context("OPENAI_API_KEY is not set")?; - let text = event_to_text(event); - - let resp: serde_json::Value = self - .client - .post("https://api.openai.com/v1/embeddings") - .bearer_auth(key) - .json(&serde_json::json!({ - "input": text, - "model": self.model, - })) - .send() - .await? - .json() - .await?; - - let vec: Vec = resp["data"][0]["embedding"] - .as_array() - .context("Failed to parse embedding response")? - .iter() - .filter_map(|v| v.as_f64().map(|f| f as f32)) - .collect(); - - Ok(vec) - } -} - -// ── Local Deterministic Embedder ──────────────────────────────────────── - -/// Convert an event to a VECTOR_DIM-dimensional feature vector (no training, deterministic) -fn local_embed(event: &NormalizedEvent) -> Vec { - let mut v = vec![0.0f32; VECTOR_DIM]; - - // [0-4] Event type one-hot encoding - match &event.event_type { - EventType::Exec => v[0] = 1.0, - EventType::FileAccess { .. } => v[1] = 1.0, - EventType::Network { .. } => v[2] = 1.0, - EventType::Privilege { .. } => v[3] = 1.0, - EventType::Signal { .. } => v[4] = 1.0, - } - - // [5] Severity normalized - v[5] = match event.severity { - Severity::Info => 0.0, - Severity::Low => 0.25, - Severity::Medium => 0.5, - Severity::High => 0.75, - Severity::Critical => 1.0, - }; - - // [6] UID (root=1.0, others normalized) - v[6] = if event.process.uid == 0 { 1.0 } else { (event.process.uid as f32).min(65535.0) / 65535.0 }; - - // [7-22] Process name bytes → normalized (up to 16 chars) - for (i, &b) in event.process.binary.as_bytes().iter().take(16).enumerate() { - v[7 + i] = b as f32 / 255.0; - } - - // [23-62] Additional features per event type - match &event.event_type { - EventType::FileAccess { path, flags } => { - for (i, &b) in path.as_bytes().iter().take(38).enumerate() { - v[23 + i] = b as f32 / 255.0; - } - v[61] = if flags.write { 1.0 } else { 0.0 }; - v[62] = if flags.execute { 1.0 } else { 0.0 }; - } - EventType::Network { port, .. } => { - v[23] = *port as f32 / 65535.0; - } - EventType::Privilege { syscall, .. } => { - for (i, &b) in syscall.as_bytes().iter().take(16).enumerate() { - v[23 + i] = b as f32 / 255.0; - } - } - EventType::Signal { signum, target_pid } => { - v[23] = *signum as f32 / 64.0; - v[24] = (*target_pid as f32).min(65535.0) / 65535.0; - } - EventType::Exec => {} - } - - // Euclidean normalization → enables cosine similarity - let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); - if norm > 1e-9 { - v.iter_mut().for_each(|x| *x /= norm); - } - - v -} - -fn event_to_text(event: &NormalizedEvent) -> String { - let kind = match &event.event_type { - EventType::Exec => "exec".to_string(), - EventType::FileAccess { path, .. } => format!("file_access {}", path), - EventType::Network { port, .. } => format!("net_connect port {}", port), - EventType::Privilege { syscall, .. } => format!("privilege {}", syscall), - EventType::Signal { signum, .. } => format!("signal {}", signum), - }; - format!("{} uid={} proc={}", kind, event.process.uid, event.process.binary) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::event::*; - - fn make_event(event_type: EventType, uid: u32, binary: &str) -> NormalizedEvent { - NormalizedEvent { - id: 0, - timestamp: 0, - source: EventSource::NativeEbpf, - process: ProcessInfo { - pid: 1, ppid: 0, uid, gid: 0, - binary: binary.to_string(), - args: vec![], - cwd: String::new(), - }, - parent: None, - event_type, - severity: Severity::Info, - action: Action::Allowed, - k8s: None, - raw: serde_json::Value::Null, - } - } - - #[test] - fn vector_has_correct_dimension() { - let ev = make_event(EventType::Exec, 1000, "nginx"); - let v = local_embed(&ev); - assert_eq!(v.len(), VECTOR_DIM); - } - - #[test] - fn vector_is_unit_normalized() { - let ev = make_event(EventType::Exec, 1000, "nginx"); - let v = local_embed(&ev); - let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); - assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm); - } - - #[test] - fn different_event_types_produce_different_vectors() { - let exec_ev = make_event(EventType::Exec, 0, "bash"); - let file_ev = make_event( - EventType::FileAccess { - path: "/etc/passwd".into(), - flags: FileFlags { read: true, write: false, execute: false }, - }, - 0, "bash", - ); - let exec_v = local_embed(&exec_ev); - let file_v = local_embed(&file_ev); - assert_ne!(exec_v, file_v); - } - - #[test] - fn same_event_produces_same_vector() { - let ev = make_event(EventType::Exec, 500, "sshd"); - let v1 = local_embed(&ev); - let v2 = local_embed(&ev); - assert_eq!(v1, v2); - } - - #[test] - fn cosine_similarity_identical_events_is_one() { - let ev = make_event(EventType::Exec, 0, "nginx"); - let v = local_embed(&ev); - let dot: f32 = v.iter().map(|x| x * x).sum(); - // unit vectors → dot product == cosine similarity == 1.0 - assert!((dot - 1.0).abs() < 1e-5); - } - - #[test] - fn root_uid_sets_uid_feature() { - let root_ev = make_event(EventType::Exec, 0, "bash"); - let user_ev = make_event(EventType::Exec, 1000, "bash"); - let root_v = local_embed(&root_ev); - let user_v = local_embed(&user_ev); - // vectors differ because uid slot differs - assert_ne!(root_v, user_v); - } -} diff --git a/vectorguard/src/slow_path/embedder/gemini.rs b/vectorguard/src/slow_path/embedder/gemini.rs new file mode 100644 index 0000000..1557633 --- /dev/null +++ b/vectorguard/src/slow_path/embedder/gemini.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use super::EmbedProvider; + +/// Google Gemini embedding dimensions: +/// text-embedding-004: 768 +const GEMINI_DEFAULT_DIM: usize = 768; + +pub struct GeminiEmbedder { + model: String, + api_key: Option, + client: reqwest::Client, +} + +impl GeminiEmbedder { + pub fn new(model: String, api_key: Option) -> Self { + Self { + model, + api_key, + client: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl EmbedProvider for GeminiEmbedder { + async fn embed(&self, text: &str) -> Result> { + let key = self.api_key.as_deref().context("Gemini API key not set")?; + + let url = format!( + "https://generativelanguage.googleapis.com/v1beta/models/{}:embedContent?key={}", + self.model, key + ); + + let resp: serde_json::Value = self + .client + .post(&url) + .json(&serde_json::json!({ + "content": { + "parts": [{ "text": text }] + } + })) + .send() + .await + .context("Gemini API request failed")? + .json() + .await + .context("Gemini response parse failed")?; + + if let Some(err) = resp.get("error") { + anyhow::bail!("Gemini API error: {}", err); + } + + let vec: Vec = resp["embedding"]["values"] + .as_array() + .context("Missing embedding.values in Gemini response")? + .iter() + .filter_map(|v| v.as_f64().map(|f| f as f32)) + .collect(); + + if vec.is_empty() { + anyhow::bail!("Gemini returned empty embedding"); + } + + Ok(vec) + } + + fn dim(&self) -> usize { + GEMINI_DEFAULT_DIM + } + + fn name(&self) -> &'static str { + "gemini" + } +} diff --git a/vectorguard/src/slow_path/embedder/local.rs b/vectorguard/src/slow_path/embedder/local.rs new file mode 100644 index 0000000..9950cd3 --- /dev/null +++ b/vectorguard/src/slow_path/embedder/local.rs @@ -0,0 +1,131 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use super::EmbedProvider; + +const LOCAL_DIM: usize = 64; + +// ── Deterministic Local Embedder (no external API) ─────────── + +pub struct LocalEmbedder; + +#[async_trait] +impl EmbedProvider for LocalEmbedder { + async fn embed(&self, text: &str) -> Result> { + Ok(deterministic_embed(text)) + } + + fn dim(&self) -> usize { + LOCAL_DIM + } + + fn name(&self) -> &'static str { + "local" + } +} + +/// Text → 64-dimensional deterministic vector using character-level hashing. +/// This is a lightweight fallback when no external embedding API is configured. +fn deterministic_embed(text: &str) -> Vec { + let mut v = vec![0.0f32; LOCAL_DIM]; + + for (i, byte) in text.bytes().enumerate() { + let slot = i % LOCAL_DIM; + // Mix position and byte value to spread features across dimensions + v[slot] += (byte as f32) / 255.0; + // Secondary slot for cross-dimension signal + let slot2 = (byte as usize * 7 + i * 13) % LOCAL_DIM; + v[slot2] += 0.3; + } + + // Euclidean normalization → enables cosine similarity + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 1e-9 { + v.iter_mut().for_each(|x| *x /= norm); + } + + v +} + +// ── Ollama Local Embedder (local API) ──────────────────────── + +pub struct OllamaEmbedder { + endpoint: String, + model: String, + client: reqwest::Client, +} + +impl OllamaEmbedder { + pub fn new(endpoint: String, model: String) -> Self { + tracing::info!("Ollama embedder: endpoint={} model={}", endpoint, model); + Self { + endpoint, + model, + client: reqwest::Client::new(), + } + } + + /// Query Ollama to discover embedding dimension for the configured model + #[allow(dead_code)] + async fn discover_dim(&self) -> Option { + let resp = self + .client + .post(format!("{}/api/embed", self.endpoint)) + .json(&serde_json::json!({ + "model": self.model, + "input": "dim probe", + })) + .send() + .await + .ok()?; + + let data: serde_json::Value = resp.json().await.ok()?; + data["embeddings"][0] + .as_array() + .map(|arr| arr.len()) + } +} + +#[async_trait] +impl EmbedProvider for OllamaEmbedder { + async fn embed(&self, text: &str) -> Result> { + let resp = self + .client + .post(format!("{}/api/embed", self.endpoint)) + .json(&serde_json::json!({ + "model": self.model, + "input": text, + })) + .send() + .await + .context("Ollama API request failed")?; + + let data: serde_json::Value = resp + .json() + .await + .context("Ollama response parse failed")?; + + let vec: Vec = data["embeddings"][0] + .as_array() + .context("Missing embeddings array in Ollama response")? + .iter() + .filter_map(|v| v.as_f64().map(|f| f as f32)) + .collect(); + + if vec.is_empty() { + anyhow::bail!("Ollama returned empty embedding"); + } + + Ok(vec) + } + + fn dim(&self) -> usize { + // Ollama model dimensions vary; default to 768 (nomic-embed-text) + // The actual dimension is validated after embed() returns + 768 + } + + fn name(&self) -> &'static str { + "ollama" + } +} diff --git a/vectorguard/src/slow_path/embedder/mod.rs b/vectorguard/src/slow_path/embedder/mod.rs new file mode 100644 index 0000000..9f64cc8 --- /dev/null +++ b/vectorguard/src/slow_path/embedder/mod.rs @@ -0,0 +1,264 @@ +mod local; +mod openai; +mod voyage; +mod gemini; + +use anyhow::{bail, Result}; +use async_trait::async_trait; +use tracing::warn; + +use crate::config::{EmbedderBackend, EmbedderConfig}; +use crate::event::{EventType, NormalizedEvent}; + +// ── Trait ───────────────────────────────────────────────────── + +#[async_trait] +pub trait EmbedProvider: Send + Sync { + async fn embed(&self, text: &str) -> Result>; + fn dim(&self) -> usize; + fn name(&self) -> &'static str; +} + +// ── Public Embedder Wrapper ────────────────────────────────── + +pub struct Embedder { + provider: Box, +} + +impl Embedder { + pub fn new(cfg: EmbedderConfig) -> Self { + let api_key = resolve_api_key(&cfg); + + let provider: Box = match cfg.backend { + EmbedderBackend::Local => { + if let Some(ref endpoint) = cfg.endpoint { + Box::new(local::OllamaEmbedder::new( + endpoint.clone(), + cfg.model.clone(), + )) + } else { + Box::new(local::LocalEmbedder) + } + } + EmbedderBackend::Openai => { + Box::new(openai::OpenAiEmbedder::new(cfg.model.clone(), api_key)) + } + EmbedderBackend::Voyage => { + Box::new(voyage::VoyageEmbedder::new(cfg.model.clone(), api_key)) + } + EmbedderBackend::Gemini => { + Box::new(gemini::GeminiEmbedder::new(cfg.model.clone(), api_key)) + } + }; + + tracing::info!( + "Embedder initialized: {} (dim={})", + provider.name(), + provider.dim() + ); + + Self { provider } + } + + /// Returns the vector dimension for this backend + pub fn dim(&self) -> usize { + self.provider.dim() + } + + /// Embed a NormalizedEvent → validated fixed-dimension vector + pub async fn embed(&self, event: &NormalizedEvent) -> Result> { + let text = event_to_text(event); + let vec = self.provider.embed(&text).await?; + + // Dimension validation (guards against API returning wrong size) + let expected = self.provider.dim(); + if vec.len() != expected { + bail!( + "{} returned {} dims, expected {}", + self.provider.name(), + vec.len(), + expected + ); + } + + Ok(vec) + } +} + +// ── Helpers ────────────────────────────────────────────────── + +fn resolve_api_key(cfg: &EmbedderConfig) -> Option { + if cfg.api_key_env.is_empty() { + return None; + } + match std::env::var(&cfg.api_key_env) { + Ok(key) if !key.is_empty() => Some(key), + Ok(_) => { + warn!( + "Environment variable {} is empty — {} backend may fail", + cfg.api_key_env, + format!("{:?}", cfg.backend) + ); + None + } + Err(_) => { + warn!( + "Environment variable {} not set — {} backend may fail", + cfg.api_key_env, + format!("{:?}", cfg.backend) + ); + None + } + } +} + +/// Convert a NormalizedEvent to a rich text representation for external embedding APIs +fn event_to_text(event: &NormalizedEvent) -> String { + let kind = match &event.event_type { + EventType::Exec => "exec".to_string(), + EventType::FileAccess { path, flags } => { + let mode = [ + if flags.read { "r" } else { "" }, + if flags.write { "w" } else { "" }, + if flags.execute { "x" } else { "" }, + ] + .concat(); + format!("file_access {} mode={}", path, mode) + } + EventType::Network { + direction, + remote_ip, + port, + proto, + } => format!( + "net_{:?} {:?} {}:{}", direction, proto, remote_ip, port + ), + EventType::Privilege { + syscall, + capability, + } => { + let cap = capability.as_deref().unwrap_or("none"); + format!("privilege {} cap={}", syscall, cap) + } + EventType::Signal { signum, target_pid } => { + format!("signal {} target_pid={}", signum, target_pid) + } + }; + + let args_str = if event.process.args.is_empty() { + String::new() + } else { + format!(" args=[{}]", event.process.args.join(", ")) + }; + + let parent_str = if let Some(ref p) = event.parent { + format!(" parent={}(pid={})", p.binary, p.pid) + } else { + String::new() + }; + + let cwd_str = if event.process.cwd.is_empty() { + String::new() + } else { + format!(" cwd={}", event.process.cwd) + }; + + format!( + "{} uid={} proc={}{}{}{} severity={:?}", + kind, + event.process.uid, + event.process.binary, + args_str, + cwd_str, + parent_str, + event.severity, + ) +} + +// ── Tests ──────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::*; + + fn make_event(event_type: EventType, uid: u32, binary: &str) -> NormalizedEvent { + NormalizedEvent { + id: 0, + timestamp: 0, + source: EventSource::NativeEbpf, + process: ProcessInfo { + pid: 1, ppid: 0, uid, gid: 0, + binary: binary.to_string(), + args: vec!["--flag".into(), "value".into()], + cwd: "/home/user".into(), + }, + parent: Some(ProcessInfo { + pid: 0, ppid: 0, uid: 0, gid: 0, + binary: "systemd".to_string(), + args: vec![], cwd: String::new(), + }), + event_type, + severity: Severity::Info, + action: Action::Allowed, + rule_name: None, + k8s: None, + raw: serde_json::Value::Null, + } + } + + #[test] + fn event_to_text_includes_args_and_parent() { + let ev = make_event(EventType::Exec, 1000, "bash"); + let text = event_to_text(&ev); + assert!(text.contains("args=[--flag, value]")); + assert!(text.contains("parent=systemd(pid=0)")); + assert!(text.contains("cwd=/home/user")); + assert!(text.contains("severity=Info")); + } + + #[test] + fn event_to_text_file_access_includes_mode() { + let ev = make_event( + EventType::FileAccess { + path: "/etc/shadow".into(), + flags: FileFlags { read: true, write: false, execute: false }, + }, + 0, + "cat", + ); + let text = event_to_text(&ev); + assert!(text.contains("file_access /etc/shadow mode=r")); + } + + #[test] + fn local_embedder_dim() { + let emb = local::LocalEmbedder; + assert_eq!(emb.dim(), 64); + } + + #[tokio::test] + async fn local_embedder_returns_correct_dim() { + let emb = local::LocalEmbedder; + let vec = emb.embed("test event").await.unwrap(); + assert_eq!(vec.len(), 64); + } + + #[test] + fn local_embed_is_unit_normalized() { + let ev = make_event(EventType::Exec, 1000, "nginx"); + let text = event_to_text(&ev); + let rt = tokio::runtime::Runtime::new().unwrap(); + let v = rt.block_on(local::LocalEmbedder.embed(&text)).unwrap(); + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm); + } + + #[test] + fn different_texts_produce_different_vectors() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let v1 = rt.block_on(local::LocalEmbedder.embed("exec uid=0 proc=bash")).unwrap(); + let v2 = rt.block_on(local::LocalEmbedder.embed("file_access /etc/shadow uid=0 proc=cat")).unwrap(); + assert_ne!(v1, v2); + } +} diff --git a/vectorguard/src/slow_path/embedder/openai.rs b/vectorguard/src/slow_path/embedder/openai.rs new file mode 100644 index 0000000..11baabb --- /dev/null +++ b/vectorguard/src/slow_path/embedder/openai.rs @@ -0,0 +1,74 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use super::EmbedProvider; + +/// OpenAI text-embedding-3-small: 1536 dims (default) +/// OpenAI text-embedding-3-large: 3072 dims +/// OpenAI text-embedding-ada-002: 1536 dims +const OPENAI_DEFAULT_DIM: usize = 1536; + +pub struct OpenAiEmbedder { + model: String, + api_key: Option, + client: reqwest::Client, + dim: usize, +} + +impl OpenAiEmbedder { + pub fn new(model: String, api_key: Option) -> Self { + let dim = match model.as_str() { + "text-embedding-3-large" => 3072, + _ => OPENAI_DEFAULT_DIM, + }; + Self { model, api_key, client: reqwest::Client::new(), dim } + } +} + +#[async_trait] +impl EmbedProvider for OpenAiEmbedder { + async fn embed(&self, text: &str) -> Result> { + let key = self.api_key.as_deref().context("OpenAI API key not set")?; + + let resp: serde_json::Value = self + .client + .post("https://api.openai.com/v1/embeddings") + .bearer_auth(key) + .json(&serde_json::json!({ + "input": text, + "model": self.model, + })) + .send() + .await + .context("OpenAI API request failed")? + .json() + .await + .context("OpenAI response parse failed")?; + + // Check for API error + if let Some(err) = resp.get("error") { + anyhow::bail!("OpenAI API error: {}", err); + } + + let vec: Vec = resp["data"][0]["embedding"] + .as_array() + .context("Missing embedding array in OpenAI response")? + .iter() + .filter_map(|v| v.as_f64().map(|f| f as f32)) + .collect(); + + if vec.is_empty() { + anyhow::bail!("OpenAI returned empty embedding"); + } + + Ok(vec) + } + + fn dim(&self) -> usize { + self.dim + } + + fn name(&self) -> &'static str { + "openai" + } +} diff --git a/vectorguard/src/slow_path/embedder/voyage.rs b/vectorguard/src/slow_path/embedder/voyage.rs new file mode 100644 index 0000000..9ebfa9f --- /dev/null +++ b/vectorguard/src/slow_path/embedder/voyage.rs @@ -0,0 +1,74 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use super::EmbedProvider; + +/// Voyage AI embedding dimensions by model: +/// voyage-3: 1024 +/// voyage-3-lite: 512 +/// voyage-code-3: 1024 +const VOYAGE_DEFAULT_DIM: usize = 1024; + +pub struct VoyageEmbedder { + model: String, + api_key: Option, + client: reqwest::Client, + dim: usize, +} + +impl VoyageEmbedder { + pub fn new(model: String, api_key: Option) -> Self { + let dim = match model.as_str() { + "voyage-3-lite" => 512, + _ => VOYAGE_DEFAULT_DIM, + }; + Self { model, api_key, client: reqwest::Client::new(), dim } + } +} + +#[async_trait] +impl EmbedProvider for VoyageEmbedder { + async fn embed(&self, text: &str) -> Result> { + let key = self.api_key.as_deref().context("Voyage API key not set")?; + + let resp: serde_json::Value = self + .client + .post("https://api.voyageai.com/v1/embeddings") + .bearer_auth(key) + .json(&serde_json::json!({ + "input": [text], + "model": self.model, + })) + .send() + .await + .context("Voyage API request failed")? + .json() + .await + .context("Voyage response parse failed")?; + + if let Some(detail) = resp.get("detail") { + anyhow::bail!("Voyage API error: {}", detail); + } + + let vec: Vec = resp["data"][0]["embedding"] + .as_array() + .context("Missing embedding array in Voyage response")? + .iter() + .filter_map(|v| v.as_f64().map(|f| f as f32)) + .collect(); + + if vec.is_empty() { + anyhow::bail!("Voyage returned empty embedding"); + } + + Ok(vec) + } + + fn dim(&self) -> usize { + self.dim + } + + fn name(&self) -> &'static str { + "voyage" + } +} diff --git a/vectorguard/src/slow_path/mod.rs b/vectorguard/src/slow_path/mod.rs index 397705a..bc7e3fc 100644 --- a/vectorguard/src/slow_path/mod.rs +++ b/vectorguard/src/slow_path/mod.rs @@ -1,9 +1,7 @@ mod context; -mod embedder; +pub mod embedder; mod vectordb; -pub use embedder::VECTOR_DIM; - use std::sync::Mutex; use tracing::{debug, warn}; @@ -27,11 +25,12 @@ impl SlowPath { pub async fn new(cfg: &SlowPathConfig) -> Self { let embedder = Embedder::new(cfg.embedder.clone()); let vectordb = VectorDb::new(&cfg.vectordb); + let dim = embedder.dim(); let available = if cfg.enabled { - match vectordb.ensure_collection(VECTOR_DIM).await { + match vectordb.ensure_collection(dim).await { Ok(_) => { - tracing::info!("Slow Path initialized (Qdrant connected)"); + tracing::info!("Slow Path initialized (Qdrant connected, dim={})", dim); true } Err(e) => { @@ -49,7 +48,7 @@ impl SlowPath { threshold: cfg.similarity_threshold, enabled: cfg.enabled, available, - context: Mutex::new(ContextWindow::new(cfg.time_window_secs, VECTOR_DIM)), + context: Mutex::new(ContextWindow::new(cfg.time_window_secs, dim)), } } @@ -75,13 +74,15 @@ impl SlowPath { let search_vector = { let mut ctx = self.context.lock().unwrap(); let blended = if let Some(ctx_vec) = ctx.context_vector(event.process.pid) { - blend(¤t_vector, &ctx_vec, 0.7) + blend(¤t_vector, &ctx_vec) } else { current_vector.clone() }; // Push current event into context window *after* reading context // so the current event doesn't pollute its own search ctx.push(event.process.pid, event.timestamp, current_vector.clone()); + // Periodically clean up stale PIDs + ctx.prune_stale_pids(); blended }; @@ -115,8 +116,18 @@ impl SlowPath { } /// Linearly blend two unit vectors and re-normalize the result. -/// `alpha` controls how much weight `a` (current event) gets vs `b` (context). -fn blend(a: &[f32], b: &[f32], alpha: f32) -> Vec { +/// If dimensions mismatch, logs a warning and returns `a` unchanged. +fn blend(a: &[f32], b: &[f32]) -> Vec { + if a.len() != b.len() { + tracing::error!( + "blend dimension mismatch: a={} b={} — using current vector only", + a.len(), + b.len() + ); + return a.to_vec(); + } + + let alpha = 0.7f32; let beta = 1.0 - alpha; let mut result: Vec = a.iter().zip(b.iter()).map(|(x, y)| alpha * x + beta * y).collect(); let norm: f32 = result.iter().map(|x| x * x).sum::().sqrt(); diff --git a/vectorguard/src/slow_path/vectordb.rs b/vectorguard/src/slow_path/vectordb.rs index d7bcc8a..ce91e41 100644 --- a/vectorguard/src/slow_path/vectordb.rs +++ b/vectorguard/src/slow_path/vectordb.rs @@ -60,7 +60,25 @@ impl VectorDb { "vectors": { "size": vector_size, "distance": "Cosine" } }); self.client.put(&url).json(&body).send().await?; - debug!("Qdrant collection created: {}", self.collection); + debug!("Qdrant collection created: {} (dim={})", self.collection, vector_size); + } else { + // Verify existing collection has matching dimensions + let info: serde_json::Value = check.json().await?; + if let Some(existing_size) = info["result"]["config"]["params"]["vectors"]["size"].as_u64() { + if existing_size as usize != vector_size { + tracing::warn!( + "Qdrant collection '{}' has dim={} but embedder expects dim={} — recreating", + self.collection, existing_size, vector_size + ); + // Delete and recreate with correct dimensions + self.client.delete(&url).send().await?; + let body = serde_json::json!({ + "vectors": { "size": vector_size, "distance": "Cosine" } + }); + self.client.put(&url).json(&body).send().await?; + debug!("Qdrant collection recreated: {} (dim={})", self.collection, vector_size); + } + } } Ok(()) diff --git a/vectorguard/src/tui/app.rs b/vectorguard/src/tui/app.rs index 77daef0..3585f60 100644 --- a/vectorguard/src/tui/app.rs +++ b/vectorguard/src/tui/app.rs @@ -11,34 +11,44 @@ pub enum AppState { pub enum Tab { Dashboard, Events, + Incidents, Config, ProcessTree, } impl Tab { pub fn titles() -> Vec<&'static str> { - vec!["Dashboard", "Events", "Config", "Process Tree"] + vec!["Dashboard", "Events", "Incidents", "Config", "Process Tree"] } pub fn index(&self) -> usize { match self { Tab::Dashboard => 0, Tab::Events => 1, - Tab::Config => 2, - Tab::ProcessTree => 3, + Tab::Incidents => 2, + Tab::Config => 3, + Tab::ProcessTree => 4, } } pub fn from_index(i: usize) -> Self { match i { 1 => Tab::Events, - 2 => Tab::Config, - 3 => Tab::ProcessTree, + 2 => Tab::Incidents, + 3 => Tab::Config, + 4 => Tab::ProcessTree, _ => Tab::Dashboard, } } } +#[derive(Debug, Clone, PartialEq)] +pub enum IncidentFilter { + All, + Blocked, + Alerted, +} + #[derive(Debug, Clone, PartialEq)] pub enum InputMode { Normal, @@ -65,6 +75,12 @@ pub struct App { pub filter_input: String, pub filter_query: String, + // Incidents tab + pub incidents: Vec, + pub incident_scroll: usize, + pub incident_filter: IncidentFilter, + pub selected_incident: Option, + // Process Tree tab pub process_map: HashMap, pub proc_scroll: usize, @@ -94,6 +110,20 @@ pub struct EventRow { pub full_kind: String, } +#[derive(Debug, Clone)] +pub struct IncidentRow { + pub timestamp: String, + pub pid: u32, + pub ppid: u32, + pub uid: u32, + pub process: String, + pub kind: String, + pub full_kind: String, + pub severity: Severity, + pub action: String, + pub rule: String, +} + #[derive(Debug, Clone)] pub struct ProcessNode { pub pid: u32, @@ -117,6 +147,10 @@ impl App { input_mode: InputMode::Normal, filter_input: String::new(), filter_query: String::new(), + incidents: Vec::new(), + incident_scroll: 0, + incident_filter: IncidentFilter::All, + selected_incident: None, process_map: HashMap::new(), proc_scroll: 0, config_text, @@ -131,6 +165,7 @@ impl App { pub fn scroll_up(&mut self) { match self.active_tab { Tab::ProcessTree => { self.proc_scroll = self.proc_scroll.saturating_sub(1); } + Tab::Incidents => { self.incident_scroll = self.incident_scroll.saturating_sub(1); } _ => { self.event_scroll = self.event_scroll.saturating_sub(1); } } } @@ -142,6 +177,12 @@ impl App { self.proc_scroll += 1; } } + Tab::Incidents => { + let len = self.filtered_incidents().len(); + if self.incident_scroll + 1 < len { + self.incident_scroll += 1; + } + } _ => { let len = self.filtered_events().len(); if self.event_scroll + 1 < len { @@ -152,18 +193,48 @@ impl App { } pub fn open_detail(&mut self) { - let len = self.filtered_events().len(); - if len > 0 { - self.selected_event = Some(self.event_scroll); + match self.active_tab { + Tab::Incidents => { + let len = self.filtered_incidents().len(); + if len > 0 { + self.selected_incident = Some(self.incident_scroll); + } + } + _ => { + let len = self.filtered_events().len(); + if len > 0 { + self.selected_event = Some(self.event_scroll); + } + } } } pub fn close_detail(&mut self) { self.selected_event = None; + self.selected_incident = None; } pub fn is_detail_open(&self) -> bool { - self.selected_event.is_some() + self.selected_event.is_some() || self.selected_incident.is_some() + } + + pub fn cycle_incident_filter(&mut self) { + self.incident_filter = match self.incident_filter { + IncidentFilter::All => IncidentFilter::Blocked, + IncidentFilter::Blocked => IncidentFilter::Alerted, + IncidentFilter::Alerted => IncidentFilter::All, + }; + self.incident_scroll = 0; + } + + pub fn filtered_incidents(&self) -> Vec<&IncidentRow> { + self.incidents.iter().filter(|i| { + match self.incident_filter { + IncidentFilter::All => true, + IncidentFilter::Blocked => i.action.contains("Block") || i.action.contains("Kill"), + IncidentFilter::Alerted => i.action.contains("Alert"), + } + }).collect() } pub fn filtered_events(&self) -> Vec<&EventRow> { @@ -249,6 +320,25 @@ impl App { let action_str = format!("{:?}", ev.action); + // Track incidents (Blocked / Alerted / Killed) — before events push to avoid move + if matches!(ev.action, Action::Blocked | Action::Alerted | Action::Killed) { + self.incidents.push(IncidentRow { + timestamp: format_ts(ev.timestamp), + pid: ev.process.pid, + ppid: ev.process.ppid, + uid: ev.process.uid, + process: ev.process.binary.clone(), + kind: kind.clone(), + full_kind: full_kind.clone(), + severity: ev.severity.clone(), + action: action_str.clone(), + rule: ev.rule_name.clone().unwrap_or_default(), + }); + if self.incidents.len() > 500 { + self.incidents.remove(0); + } + } + self.events.push(EventRow { timestamp: format_ts(ev.timestamp), pid: ev.process.pid, diff --git a/vectorguard/src/tui/event.rs b/vectorguard/src/tui/event.rs index 7075169..49f8596 100644 --- a/vectorguard/src/tui/event.rs +++ b/vectorguard/src/tui/event.rs @@ -74,15 +74,21 @@ impl EventHandler { KeyCode::Tab => app.next_tab(), KeyCode::Char('1') => app.active_tab = Tab::Dashboard, KeyCode::Char('2') => app.active_tab = Tab::Events, - KeyCode::Char('3') => app.active_tab = Tab::Config, - KeyCode::Char('4') => app.active_tab = Tab::ProcessTree, + KeyCode::Char('3') => app.active_tab = Tab::Incidents, + KeyCode::Char('4') => app.active_tab = Tab::Config, + KeyCode::Char('5') => app.active_tab = Tab::ProcessTree, KeyCode::Up | KeyCode::Char('k') => app.scroll_up(), KeyCode::Down | KeyCode::Char('j') => app.scroll_down(), KeyCode::Enter => { - if app.active_tab == Tab::Events { + if app.active_tab == Tab::Events || app.active_tab == Tab::Incidents { app.open_detail(); } } + KeyCode::Char('f') => { + if app.active_tab == Tab::Incidents { + app.cycle_incident_filter(); + } + } KeyCode::Char('/') => { if app.active_tab == Tab::Events { app.input_mode = InputMode::Filtering; diff --git a/vectorguard/src/tui/render.rs b/vectorguard/src/tui/render.rs index 236c5d6..e3337e4 100644 --- a/vectorguard/src/tui/render.rs +++ b/vectorguard/src/tui/render.rs @@ -30,6 +30,7 @@ pub fn draw(f: &mut Frame, app: &App) { match app.active_tab { Tab::Dashboard => draw_dashboard(f, app, chunks[1]), Tab::Events => draw_events(f, app, chunks[1]), + Tab::Incidents => draw_incidents(f, app, chunks[1]), Tab::Config => draw_config(f, app, chunks[1]), Tab::ProcessTree => draw_process_tree(f, app, chunks[1]), } @@ -37,7 +38,12 @@ pub fn draw(f: &mut Frame, app: &App) { draw_footer(f, app, chunks[2]); // 팝업은 맨 마지막에 렌더링 (최상단 레이어) - if let Some(idx) = app.selected_event { + if let Some(idx) = app.selected_incident { + let incidents = app.filtered_incidents(); + if let Some(inc) = incidents.get(idx) { + draw_incident_detail(f, inc, area); + } + } else if let Some(idx) = app.selected_event { let events = app.filtered_events(); if let Some(ev) = events.get(idx) { draw_event_detail(f, ev, area); @@ -226,6 +232,132 @@ fn draw_events(f: &mut Frame, app: &App, area: Rect) { } } +// ── Incidents Tab ───────────────────────────────────────────── +fn draw_incidents(f: &mut Frame, app: &App, area: Rect) { + let header = Row::new(vec!["Time", "PID", "Process", "Kind", "Rule", "Action"]) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .height(1); + + let incidents = app.filtered_incidents(); + let rows: Vec = incidents.iter().map(|i| { + let action_color = action_color(&i.action); + Row::new(vec![ + Cell::from(i.timestamp.as_str()), + Cell::from(i.pid.to_string()), + Cell::from(i.process.as_str()), + Cell::from(i.kind.as_str()), + Cell::from(Span::styled(i.rule.as_str(), Style::default().fg(Color::Yellow))), + Cell::from(Span::styled(i.action.as_str(), Style::default().fg(action_color))), + ]) + .height(1) + }).collect(); + + let filter_label = match app.incident_filter { + super::app::IncidentFilter::All => "All", + super::app::IncidentFilter::Blocked => "Blocked", + super::app::IncidentFilter::Alerted => "Alerted", + }; + let title = format!( + " Incidents [{}] ({} total) — Tab:filter ↑↓:scroll Enter:detail ", + filter_label, incidents.len() + ); + + let mut state = TableState::default(); + if !incidents.is_empty() { + state.select(Some(app.incident_scroll)); + } + + let table = Table::new(rows, [ + Constraint::Length(12), + Constraint::Length(7), + Constraint::Length(16), + Constraint::Min(18), + Constraint::Length(24), + Constraint::Length(8), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title) + .style(Style::default().bg(Color::Black))) + .row_highlight_style(Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD)); + + f.render_stateful_widget(table, area, &mut state); +} + +fn draw_incident_detail(f: &mut Frame, inc: &super::app::IncidentRow, area: Rect) { + let popup_area = centered_rect(65, 60, area); + f.render_widget(Clear, popup_area); + + let sev_color = severity_color(&inc.severity); + let act_color = action_color(&inc.action); + + let pid_str = inc.pid.to_string(); + let ppid_str = inc.ppid.to_string(); + let uid_str = inc.uid.to_string(); + let sev_str = format!("{:?}", inc.severity); + + let text = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Timestamp : ", Style::default().fg(Color::DarkGray)), + Span::raw(inc.timestamp.as_str()), + ]), + Line::from(vec![ + Span::styled(" PID : ", Style::default().fg(Color::DarkGray)), + Span::styled(pid_str.as_str(), Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + Span::styled(" PPID : ", Style::default().fg(Color::DarkGray)), + Span::raw(ppid_str.as_str()), + ]), + Line::from(vec![ + Span::styled(" UID : ", Style::default().fg(Color::DarkGray)), + Span::raw(uid_str.as_str()), + ]), + Line::from(vec![ + Span::styled(" Process : ", Style::default().fg(Color::DarkGray)), + Span::styled(inc.process.as_str(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" Event : ", Style::default().fg(Color::DarkGray)), + Span::raw(inc.full_kind.as_str()), + ]), + Line::from(vec![ + Span::styled(" Rule : ", Style::default().fg(Color::DarkGray)), + Span::styled( + if inc.rule.is_empty() { "(none)" } else { inc.rule.as_str() }, + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" Severity : ", Style::default().fg(Color::DarkGray)), + Span::styled(sev_str.as_str(), Style::default().fg(sev_color).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Action : ", Style::default().fg(Color::DarkGray)), + Span::styled(inc.action.as_str(), Style::default().fg(act_color).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + Line::from(Span::styled( + " Press Esc or Enter to close", + Style::default().fg(Color::DarkGray), + )), + ]; + + let p = Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Incident Detail ") + .border_style(Style::default().fg(Color::Red)) + .style(Style::default().bg(Color::Black)), + ); + f.render_widget(p, popup_area); +} + // ── Config Tab ──────────────────────────────────────────────── fn draw_config(f: &mut Frame, app: &App, area: Rect) { let p = Paragraph::new(app.config_text.as_str()) @@ -392,11 +524,24 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) { Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(":cancel"), ]) + } else if app.active_tab == Tab::Incidents { + Line::from(vec![ + Span::styled(" q", Style::default().fg(Color::Yellow)), + Span::raw(":quit "), + Span::styled("Tab/1-5", Style::default().fg(Color::Yellow)), + Span::raw(":switch "), + Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)), + Span::raw(":scroll "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(":detail "), + Span::styled("f", Style::default().fg(Color::Yellow)), + Span::raw(":filter(All/Blocked/Alerted)"), + ]) } else { Line::from(vec![ Span::styled(" q", Style::default().fg(Color::Yellow)), Span::raw(":quit "), - Span::styled("Tab/1-4", Style::default().fg(Color::Yellow)), + Span::styled("Tab/1-5", Style::default().fg(Color::Yellow)), Span::raw(":switch "), Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)), Span::raw(":scroll "),