Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion vectorguard/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions vectorguard/src/adapter/auditd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ fn parse_auditd_syscall(line: &str) -> Result<NormalizedEvent> {
event_type,
severity,
action: Action::Allowed,
rule_name: None,
k8s: None,
raw: serde_json::Value::String(line.to_string()),
})
Expand Down
1 change: 1 addition & 0 deletions vectorguard/src/adapter/falco.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ fn parse_falco_json(line: &str) -> Result<NormalizedEvent> {
event_type,
severity,
action: Action::Allowed,
rule_name: None,
k8s: None,
raw: v,
})
Expand Down
4 changes: 4 additions & 0 deletions vectorguard/src/adapter/tetragon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fn convert_exec(e: pb::ProcessExec) -> Option<NormalizedEvent> {
event_type: event::EventType::Exec,
severity: Severity::Info,
action: Action::Allowed,
rule_name: None,
k8s: k8s_from(&proc),
raw: serde_json::Value::Null,
})
Expand All @@ -143,6 +144,7 @@ fn convert_exit(e: pb::ProcessExit) -> Option<NormalizedEvent> {
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,
})
Expand All @@ -165,6 +167,7 @@ fn convert_kprobe(e: pb::ProcessKprobe) -> Option<NormalizedEvent> {
event_type,
severity,
action: Action::Allowed,
rule_name: None,
k8s: k8s_from(&proc),
raw: serde_json::Value::Null,
})
Expand All @@ -184,6 +187,7 @@ fn convert_tracepoint(e: pb::ProcessTracepoint) -> Option<NormalizedEvent> {
},
severity: Severity::Medium,
action: Action::Allowed,
rule_name: None,
k8s: k8s_from(&proc),
raw: serde_json::Value::Null,
})
Expand Down
1 change: 1 addition & 0 deletions vectorguard/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn parse_raw_event(data: &[u8]) -> Result<NormalizedEvent> {
event_type,
severity,
action,
rule_name: None,
k8s: None,
raw: serde_json::Value::Null,
})
Expand Down
7 changes: 6 additions & 1 deletion vectorguard/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,20 @@ 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<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum EmbedderBackend {
Local,
Openai,
Claude,
Voyage,
Gemini,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
Expand Down
1 change: 1 addition & 0 deletions vectorguard/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct NormalizedEvent {
pub event_type: EventType,
pub severity: Severity,
pub action: Action,
pub rule_name: Option<String>,
pub k8s: Option<K8sMeta>,
pub raw: serde_json::Value,
}
Expand Down
6 changes: 5 additions & 1 deletion vectorguard/src/fast_path/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
53 changes: 31 additions & 22 deletions vectorguard/src/fast_path/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action> {
/// 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<Rule> {
Expand Down Expand Up @@ -228,6 +228,7 @@ mod tests {
event_type,
severity: Severity::Info,
action: Action::Allowed,
rule_name: None,
k8s: None,
raw: serde_json::Value::Null,
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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())));
}
}
91 changes: 91 additions & 0 deletions vectorguard/src/incident.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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
}
}
}
}
14 changes: 9 additions & 5 deletions vectorguard/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod event;
mod adapter;
mod fast_path;
mod hotreload;
mod incident;
mod scope;
mod slow_path;
mod tui;
Expand Down Expand Up @@ -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) ────────────────────
Expand Down Expand Up @@ -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<incident::IncidentLogger>,
mut reload_rx: watch::Receiver<config::Config>,
#[cfg(target_os = "linux")]
enf_opt: Option<Arc<Mutex<Option<enforcer::Enforcer>>>>,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions vectorguard/src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
5 changes: 5 additions & 0 deletions vectorguard/src/slow_path/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<f32>> {
Expand Down
Loading
Loading