optimisation

This commit is contained in:
debrunbaix 2026-03-30 14:19:13 +02:00
parent 646c88511a
commit c64ccf61fc
37 changed files with 68183 additions and 178 deletions

920
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,30 @@
[workspace]
resolver = "2"
members = [
"edr",
"edr-common",
"edr-ebpf",
"user-land",
"kernel-land",
"common-lib",
]
resolver = "2"
[workspace.dependencies]
# User-land
aya = { version = "0.13" }
aya-log = { version = "0.2" }
libc = { version = "0.2" }
tokio = { version = "1", features = ["full"] }
ratatui = { version = "0.29" }
crossterm = { version = "0.28" }
serde = { version = "1", features = ["derive"] }
serde_norway = { version = "0.9" }
serde_json = { version = "1" }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
uuid = { version = "1", features = ["v4"] }
regex = { version = "1" }
# Kernel-land (eBPF)
aya-ebpf = { version = "0.1" }
aya-log-ebpf = { version = "0.1" }
# Shared
common-lib = { path = "common-lib" }

View file

@ -1,5 +0,0 @@
# edr-research-rust
Research about how to exploit ebpf to create an edr with the Aya library in Rust.
Repport here : `report/repport.pdf`

11
common-lib/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "common-lib"
version = "0.1.0"
edition = "2021"
[features]
default = []
user = ["aya"]
[dependencies]
aya = { workspace = true, optional = true }

66
common-lib/src/lib.rs Normal file
View file

@ -0,0 +1,66 @@
// Pas de std en kernel-land (eBPF) ; std disponible en user-land via la feature "user".
#![cfg_attr(not(feature = "user"), no_std)]
pub const FILENAME_MAX_LEN: usize = 256;
pub const ARGS_MAX_LEN: usize = 1024;
/// Événement brut capturé par le sensor execve en eBPF.
/// Partagé kernel-land ↔ user-land via le ring buffer.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct EventExecve
{
pub pid: u32,
pub ppid: u32,
pub filename: [u8; FILENAME_MAX_LEN],
pub args: [u8; ARGS_MAX_LEN],
}
#[cfg(feature = "user")]
unsafe impl aya::Pod for EventExecve {}
// ─── Types user-land uniquement ──────────────────────────────────────────────
/// Représentation normalisée d'un événement, indépendante du moteur source.
///
/// Tous les moteurs de détection produisent un `NormalizedEvent` avant de pousser
/// dans le channel. Le moteur Sigma évalue les règles sur cette structure via
/// `fields`, ce qui rend le matching générique et extensible sans couplage fort.
///
/// Conventions de nommage des champs (`fields`) :
/// - `"Image"` — chemin complet de l'exécutable
/// - `"CommandLine"` — ligne de commande complète
/// - `"ProcessId"` — PID du processus
/// - `"ParentProcessId"` — PPID
/// - `"DestinationIp"` — (futur moteur réseau)
/// - `"DestinationPort"` — (futur moteur réseau)
#[cfg(feature = "user")]
pub mod event
{
use std::collections::HashMap;
/// Identifie le moteur de détection à l'origine de l'événement.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineSource
{
Execve,
}
#[derive(Debug, Clone)]
pub struct NormalizedEvent
{
pub source: EngineSource,
/// Valeur de `logsource.category` Sigma : `"process_creation"`, `"network_connection"`, …
pub category: String,
pub fields: HashMap<String, String>,
}
impl NormalizedEvent
{
/// Accès à un champ par son nom canonique.
pub fn get(&self, field: &str) -> Option<&str>
{
self.fields.get(field).map(|s| s.as_str())
}
}
}

View file

@ -1 +0,0 @@
/target

View file

@ -1,6 +0,0 @@
[package]
name = "edr-common"
version = "0.1.0"
edition = "2024"
[dependencies]

View file

@ -1,9 +0,0 @@
#![no_std]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ExecveEvent {
pub pid: u32,
pub filename: [u8; 256],
pub args: [u8; 256],
}

1
edr-ebpf/.gitignore vendored
View file

@ -1 +0,0 @@
/target

View file

@ -1,11 +0,0 @@
[package]
name = "edr-ebpf"
version = "0.1.0"
edition = "2024"
[dependencies]
aya-ebpf = "0.1"
edr-common = { path = "../edr-common" }
[lib]
crate-type = ["cdylib"]

View file

@ -1,67 +0,0 @@
#![no_std]
#![no_main]
#![feature(asm_experimental_arch)]
use aya_ebpf::{
EbpfContext,
macros::tracepoint,
macros::map,
maps::RingBuf,
programs::TracePointContext,
helpers::bpf_probe_read_user_str_bytes,
};
use edr_common::ExecveEvent;
#[map]
static EVENTS: RingBuf = RingBuf::with_byte_size(256*1024, 0);
#[tracepoint]
pub fn edr_execve(ctx: TracePointContext) -> u32
{
match try_edr_execve(&ctx)
{
Ok(_) => 0,
Err(_) => 1,
}
}
fn try_edr_execve(ctx: &TracePointContext) -> Result<(), i64>
{
let mut entry = EVENTS.reserve::<ExecveEvent>(0).ok_or(1i64)?;
let event_ptr = entry.as_mut_ptr();
let pid = ctx.pid();
let filename_ptr = unsafe
{
ctx.read_at::<u64>(16)
};
let filename_ptr = match filename_ptr {
Ok(ptr) => ptr as *const u8,
Err(e) => {
entry.discard(0);
return Err(e);
}
};
unsafe
{
(*event_ptr).pid = pid;
if bpf_probe_read_user_str_bytes(filename_ptr, &mut (*event_ptr).filename).is_err()
{
entry.discard(0);
return Err(1);
}
}
entry.submit(0);
Ok(())
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> !
{
unsafe
{
core::arch::asm!("exit", options(noreturn))
}
}

1
edr/.gitignore vendored
View file

@ -1 +0,0 @@
/target

View file

@ -1,11 +0,0 @@
[package]
name = "edr"
version = "0.1.0"
edition = "2024"
[dependencies]
aya = { version = "0.13", features = ["async_tokio"] }
aya-log = "0.2"
tokio = { version = "1", features = ["full"] }
edr-common = { path = "../edr-common" }
anyhow = "1.0"

View file

@ -1,43 +0,0 @@
use aya::{
Ebpf,
programs::TracePoint,
maps::RingBuf,
};
use tokio::{
signal,
io::unix::AsyncFd,
};
use edr_common::ExecveEvent;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error>
{
let mut bpf = Ebpf::load(include_bytes!("../../target/bpfel-unknown-none/debug/libedr_ebpf.so"))?;
let program: &mut TracePoint = bpf.program_mut("edr_execve").unwrap().try_into()?;
program.load()?;
program.attach("syscalls", "sys_enter_execve")?;
let ring = RingBuf::try_from(bpf.map_mut("EVENTS").unwrap())?;
let mut async_fd = AsyncFd::new(ring)?;
loop {
tokio::select! {
_ = signal::ctrl_c() => break,
result = async_fd.readable_mut() => {
let mut guard = result?;
let rb = guard.get_inner_mut();
while let Some(item) = rb.next() {
let event = unsafe { &*(item.as_ptr() as *const ExecveEvent) };
let filename = std::str::from_utf8(&event.filename)
.unwrap_or("?")
.trim_end_matches('\0');
println!("execve: pid={} filename={}", event.pid, filename);
}
guard.clear_ready();
}
}
}
Ok(())
}

16
kernel-land/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "kernel-land"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "kernel-land"
path = "src/main.rs"
[dependencies]
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
common-lib = { workspace = true }
[profile.release]
debug = 2

6
kernel-land/src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
#![no_std]
#![no_main]
pub mod maps;
pub mod sensors;
pub mod vmlinux;

14
kernel-land/src/main.rs Normal file
View file

@ -0,0 +1,14 @@
#![no_std]
#![no_main]
mod maps;
mod sensors;
mod vmlinux;
/// Requis par le compilateur Rust pour les cibles no_std.
/// En eBPF, une panique est unreachable par construction (le vérificateur kernel le garantit).
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> !
{
unsafe { core::hint::unreachable_unchecked() }
}

7
kernel-land/src/maps.rs Normal file
View file

@ -0,0 +1,7 @@
use aya_ebpf::{macros::map, maps::RingBuf};
/// Ring buffer partagé entre tous les sensors.
/// Taille : 256 KB (doit être une puissance de 2).
/// Le user-land consomme les événements depuis ce buffer.
#[map]
pub static EXECVE_EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);

View file

@ -0,0 +1,62 @@
use aya_ebpf::{
helpers::{bpf_get_current_pid_tgid, bpf_probe_read_kernel_str_bytes},
macros::btf_tracepoint,
programs::BtfTracePointContext,
};
use common_lib::EventExecve;
use crate::maps::EXECVE_EVENTS;
use crate::vmlinux::linux_binprm;
/// Hook sur sched_process_exec : se déclenche uniquement après une exécution réussie.
///
/// Signature kernel : sched_process_exec(task_struct *p, pid_t old_pid, linux_binprm *bprm)
/// - arg(0) : task_struct du nouveau process (ignoré — on utilise le helper BPF)
/// - arg(1) : ancien PID (ignoré)
/// - arg(2) : linux_binprm contenant filename et métadonnées d'exécution
#[btf_tracepoint(function = "sched_process_exec")]
pub fn enter_execve(ctx: BtfTracePointContext) -> i32
{
match unsafe { try_enter_execve(&ctx) }
{
Ok(()) => 0,
Err(_) => 1,
}
}
unsafe fn try_enter_execve(ctx: &BtfTracePointContext) -> Result<(), i64>
{
let bprm: *const linux_binprm = ctx.arg(2);
// bpf_get_current_pid_tgid() retourne (tgid << 32 | pid).
// Le thread-group ID (tgid) est ce que l'espace utilisateur appelle "PID".
let pid = (bpf_get_current_pid_tgid() >> 32) as u32;
// Réservation d'un slot dans le ring buffer.
// Si le buffer est plein, on abandonne l'événement plutôt que de bloquer.
let Some(mut entry) = EXECVE_EVENTS.reserve::<EventExecve>(0)
else
{
return Err(1);
};
let event = entry.as_mut_ptr();
(*event).pid = pid;
(*event).ppid = 0; // Rempli en espace utilisateur via /proc/<pid>/status
// Filename depuis linux_binprm — pointeur kernel validé par le kernel.
// L'offset de `filename` provient de la génération aya-tool depuis /sys/kernel/btf/vmlinux.
// On doit explicit submit ou discard avant tout exit — le vérificateur eBPF le contraint.
if bpf_probe_read_kernel_str_bytes(
(*bprm).filename as *const u8,
&mut (*event).filename,
)
.is_err()
{
entry.discard(0);
return Err(1);
}
entry.submit(0);
Ok(())
}

View file

@ -0,0 +1 @@
pub mod execve;

65513
kernel-land/src/vmlinux.rs Normal file

File diff suppressed because it is too large Load diff

34
rules/detect_ls.yaml Normal file
View file

@ -0,0 +1,34 @@
title: Enumération de /etc/passwd via ls
id: b3f1c2a4-9e7d-4b2a-8f0e-1a2b3c4d5e6f
status: experimental
description: Détecte l'exécution exacte de la commande "ls /etc/passwd"
references:
- https://attack.mitre.org/techniques/T1087/
author: Detection Rule
date: 2024-01-01
tags:
- attack.discovery
- attack.t1087.001
logsource:
category: process_creation
product: linux
detection:
selection:
Image|endswith: '/ls'
CommandLine|contains: '/etc/passwd'
filter_legit_users:
User|contains:
- 'monitoring'
- 'backup'
condition: selection and not 1 of filter_*
falsepositives:
- Aucun (la commande exacte est très spécifique)
level: high

51
rules/nmap.yaml Normal file
View file

@ -0,0 +1,51 @@
title: Nmap Execution on Linux
id: 4a6b2e5c-3f81-4d9a-bc07-e2f1a9d83c14
status: experimental
description: |
Detects the execution of the nmap binary on Linux systems.
Nmap is a network scanner commonly used during reconnaissance
and lateral movement phases by attackers.
references:
- https://nmap.org/
- https://attack.mitre.org/techniques/T1046/
- https://attack.mitre.org/techniques/T1595/
author: Detection Engineer
date: 2024-01-15
tags:
- attack.discovery
- attack.T1046
- attack.reconnaissance
- attack.T1595
logsource:
category: process_creation
product: linux
detection:
selection_img:
Image|endswith:
- '/nmap'
- '/nmap7'
selection_cmd:
CommandLine|contains:
- 'nmap '
- '/usr/bin/nmap'
- '/usr/local/bin/nmap'
- '/snap/bin/nmap'
filter_legit_users:
User|contains:
- 'songbird'
condition: 1 of selection_* and not 1 of filter_*
falsepositives:
- Legitimate network audits by system administrators
- Authorized penetration testing activities
- Security team scheduled scans
- Monitoring or CMDB discovery tools
level: medium
fields:
- Image
- CommandLine
- User
- ParentImage
- ParentCommandLine
- ProcessId

39
rules/reverse_shell.yaml Normal file
View file

@ -0,0 +1,39 @@
title: Python Reverse Shell Execution Via PTY And Socket Modules
id: 32e62bc7-3de0-4bb1-90af-532978fe42c0
related:
- id: c4042d54-110d-45dd-a0e1-05c47822c937
type: similar
status: test
description: |
Detects the execution of python with calls to the socket and pty module in order to connect and spawn a potential reverse shell.
references:
- https://www.revshells.com/
author: '@d4ns4n_, Nasreddine Bencherchali (Nextron Systems)'
date: 2023-04-24
modified: 2024-11-04
tags:
- attack.execution
logsource:
category: process_creation
product: linux
detection:
selection:
Image|contains: 'python'
CommandLine|contains|all:
- ' -c '
- 'import'
- 'pty'
- 'socket'
- 'spawn'
- '.connect'
filter_legit_users:
User|contains:
- 'songbird'
condition: selection and not 1 of filter_*
falsepositives:
- Unknown
level: high

3
rust-toolchain.toml Normal file
View file

@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rust-src"]

26
user-land/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "user-land"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "vauban"
path = "src/main.rs"
[build-dependencies]
# Pas de dépendances externes : build.rs invoque cargo directement
[dependencies]
aya = { workspace = true }
aya-log = { workspace = true }
libc = { workspace = true }
tokio = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
serde = { workspace = true }
serde_norway = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
regex = { workspace = true }
common-lib = { workspace = true, features = ["user"] }

30
user-land/build.rs Normal file
View file

@ -0,0 +1,30 @@
use std::{env, path::PathBuf, process::Command};
fn main()
{
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.parent().unwrap();
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
// Recompile si les sources kernel-land changent
println!("cargo:rerun-if-changed=../kernel-land/src");
// Lance la compilation depuis kernel-land/ pour utiliser son .cargo/config.toml
// (target = bpfel-unknown-none, build-std = core)
// CARGO_TARGET_DIR séparé pour éviter le deadlock sur le file lock du workspace parent
let ebpf_target_dir = workspace_root.join("target/ebpf");
let status = Command::new("cargo")
.current_dir(workspace_root.join("kernel-land"))
.env("CARGO_TARGET_DIR", &ebpf_target_dir)
.args(["build", "--release"])
.status()
.expect("Échec du lancement de cargo pour kernel-land");
assert!(status.success(), "Compilation de kernel-land eBPF échouée");
// Copie l'ELF produit dans OUT_DIR pour que include_bytes! puisse l'embarquer
let ebpf_elf = ebpf_target_dir.join("bpfel-unknown-none/release/kernel-land");
std::fs::copy(&ebpf_elf, out_dir.join("kernel-land.bpf"))
.expect("Impossible de copier l'ELF eBPF dans OUT_DIR");
}

75
user-land/src/alert.rs Normal file
View file

@ -0,0 +1,75 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::Serialize;
/// Alerte produite quand un événement matche une règle Sigma.
/// Sérialisable en JSON pour l'export SIEM.
#[derive(Debug, Clone, Serialize)]
pub struct Alert
{
/// Horodatage de détection au format RFC 3339 (ex: `2026-03-29T08:00:00Z`).
pub timestamp: DateTime<Utc>,
/// Titre de la règle Sigma qui a déclenché l'alerte.
pub rule: String,
/// Niveau de sévérité issu du champ `level:` de la règle Sigma.
pub severity: String,
/// Référence vers l'entrée d'audit correspondante dans `vauban-audit.jsonl`.
/// Permet de retrouver le contexte complet de l'événement déclencheur.
pub event_id: String,
/// Champs de l'événement normalisé (Image, CommandLine, ProcessId, …).
pub event: HashMap<String, String>,
}
impl Alert
{
pub fn new(
rule: String,
severity: String,
event_id: String,
fields: HashMap<String, String>,
) -> Self
{
Alert
{
timestamp: Utc::now(),
rule,
severity,
event_id,
event: fields,
}
}
}
// ─── Bus d'alertes ────────────────────────────────────────────────────────────
/// Contrat minimal d'un consommateur d'alertes.
/// Implémentations : `TuiSink`, `JsonFileSink`, futures `UnixSocketSink`, …
pub trait AlertSink: Send + Sync
{
fn emit(&self, alert: &Alert);
}
/// Dispatche chaque alerte vers tous les sinks enregistrés.
pub struct AlertBus
{
sinks: Vec<Box<dyn AlertSink>>,
}
impl AlertBus
{
pub fn new(sinks: Vec<Box<dyn AlertSink>>) -> Self
{
AlertBus { sinks }
}
/// Distribue l'alerte à tous les sinks de façon synchrone.
/// Chaque sink doit être non-bloquant (canal avec `try_send`, append local, …).
pub fn emit(&self, alert: Alert)
{
for sink in &self.sinks
{
sink.emit(&alert);
}
}
}

230
user-land/src/engine/mod.rs Normal file
View file

@ -0,0 +1,230 @@
mod sigma;
use std::{
collections::HashMap,
fs, mem, path::PathBuf, ptr,
sync::Arc,
time::Duration,
};
use aya::{maps::RingBuf, Ebpf};
use common_lib::{
EventExecve,
event::{EngineSource, NormalizedEvent},
};
use tokio::{io::unix::AsyncFd, sync::RwLock, time};
use uuid::Uuid;
use crate::alert::{Alert, AlertBus};
use crate::sinks::AuditLog;
/// Lit les arguments depuis /proc/<pid>/cmdline (bytes séparés par \0) dans le buffer fourni.
/// Le buffer est laissé tel quel si le fichier est absent (process déjà terminé).
fn read_cmdline(pid: u32, buf: &mut [u8])
{
let path = format!("/proc/{}/cmdline", pid);
if let Ok(bytes) = fs::read(&path)
{
let len = bytes.len().min(buf.len());
buf[..len].copy_from_slice(&bytes[..len]);
}
}
/// Lit le PPID depuis /proc/<pid>/status.
/// Retourne 0 si le fichier est absent ou le champ introuvable (process déjà terminé).
fn read_ppid(pid: u32) -> u32
{
let path = format!("/proc/{}/status", pid);
let content = match fs::read_to_string(&path)
{
Ok(s) => s,
Err(_) => return 0,
};
for line in content.lines()
{
if let Some(rest) = line.strip_prefix("PPid:")
{
return rest.trim().parse().unwrap_or(0);
}
}
0
}
/// Lit le UID réel du processus depuis `/proc/<pid>/status` (ligne `Uid:`).
/// Retourne 0 si le fichier est absent (processus déjà terminé).
fn read_uid(pid: u32) -> u32
{
let path = format!("/proc/{}/status", pid);
let content = match fs::read_to_string(&path)
{
Ok(s) => s,
Err(_) => return 0,
};
for line in content.lines()
{
if let Some(rest) = line.strip_prefix("Uid:")
{
// Format : "Uid:\treal\teffective\tsaved\tfilesystem"
if let Some(real_uid) = rest.split_whitespace().next()
{
return real_uid.parse().unwrap_or(0);
}
}
}
0
}
/// Résout un UID en nom d'utilisateur via `/etc/passwd`.
/// Retourne la représentation décimale du UID si la résolution échoue.
fn uid_to_username(uid: u32) -> String
{
if let Ok(content) = fs::read_to_string("/etc/passwd")
{
for line in content.lines()
{
let mut fields = line.splitn(4, ':');
let username = fields.next().unwrap_or("");
fields.next(); // mot de passe (x)
let line_uid: u32 = fields.next().unwrap_or("").parse().unwrap_or(u32::MAX);
if line_uid == uid
{
return username.to_string();
}
}
}
uid.to_string()
}
fn bytes_to_string(buf: &[u8]) -> String
{
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).into_owned()
}
fn args_to_string(buf: &[u8]) -> String
{
let end = buf.iter().rposition(|&b| b != 0).map(|i| i + 1).unwrap_or(0);
buf[..end]
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s))
.collect::<Vec<_>>()
.join(" ")
}
/// Convertit un `EventExecve` brut (issu du ring buffer) en `NormalizedEvent`.
/// C'est ici que le couplage fort avec le type kernel s'arrête.
fn normalize_execve(event: &EventExecve) -> NormalizedEvent
{
let mut fields = HashMap::new();
let uid = read_uid(event.pid);
let user = uid_to_username(uid);
fields.insert("Image".to_string(), bytes_to_string(&event.filename));
fields.insert("CommandLine".to_string(), args_to_string(&event.args));
fields.insert("ProcessId".to_string(), event.pid.to_string());
fields.insert("ParentProcessId".to_string(), event.ppid.to_string());
fields.insert("User".to_string(), user);
fields.insert("UserId".to_string(), uid.to_string());
NormalizedEvent
{
source: EngineSource::Execve,
category: "process_creation".to_string(),
fields,
}
}
/// Démarre deux tâches tokio :
///
/// 1. **Ingestion** — lit le ring buffer via `AsyncFd` et évalue les règles Sigma.
/// N'acquiert qu'un read lock sur le `RuleStore` : jamais bloquée par un reload.
/// Dispatche les alertes vers tous les sinks via `AlertBus`.
///
/// 2. **Reload** — toutes les 5 secondes, recharge les règles depuis le disque
/// dans une tâche dédiée. Une I/O lente (NFS, disque sous charge) n'impacte
/// pas le pipeline d'ingestion.
pub fn start(
mut ebpf: Ebpf,
bus: AlertBus,
audit_log: AuditLog,
rules_dir: PathBuf,
) -> tokio::task::JoinHandle<()>
{
let rule_store = Arc::new(RwLock::new(
sigma::RuleStore::load_from_dir(&rules_dir)
));
// Tâche de reload : write lock exclusif, toutes les 5 secondes.
// Isolée du pipeline d'ingestion — une I/O lente ne bloque que cette tâche.
let store_for_reload = Arc::clone(&rule_store);
tokio::spawn(async move
{
let mut ticker = time::interval(Duration::from_secs(5));
ticker.tick().await; // consomme le tick immédiat initial
loop
{
ticker.tick().await;
store_for_reload.write().await.reload();
}
});
// Tâche d'ingestion : read lock partagé, jamais bloquée par le reload.
tokio::spawn(async move
{
let map = ebpf
.take_map("EXECVE_EVENTS")
.expect("Map EXECVE_EVENTS introuvable");
let ring_buf = RingBuf::try_from(map)
.expect("Impossible de créer le RingBuf");
let mut async_fd = AsyncFd::new(ring_buf)
.expect("Impossible de créer l'AsyncFd pour le ring buffer");
loop
{
let mut guard = async_fd.readable_mut().await
.expect("Erreur AsyncFd readable");
while let Some(item) = guard.get_inner_mut().next()
{
if item.len() >= mem::size_of::<EventExecve>()
{
let mut raw: EventExecve = unsafe {
ptr::read_unaligned(item.as_ptr() as *const EventExecve)
};
raw.ppid = read_ppid(raw.pid);
read_cmdline(raw.pid, &mut raw.args);
let event = normalize_execve(&raw);
let event_id = Uuid::new_v4().to_string();
// Audit log : tous les events, avant évaluation des règles.
audit_log.write(&event, &event_id);
if let Some((rule, severity)) = rule_store.read().await.first_match(&event)
{
bus.emit(Alert::new(
rule.to_string(),
severity.to_string(),
event_id,
event.fields,
));
}
}
}
guard.clear_ready();
}
})
}

View file

@ -0,0 +1,10 @@
title: Suspicious execve
status: experimental
description: Detects suspicious process execution
logsource:
category: process_creation
product: linux
detection:
selection:
Image|endswith: []
condition: selection

View file

@ -0,0 +1,658 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use regex::Regex;
use serde::Deserialize;
use serde_norway::{Mapping, Value};
use common_lib::event::NormalizedEvent;
// ─── Désérialisation brute du YAML ───────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct RawLogsource
{
category: Option<String>,
}
/// `condition` est extrait séparément ; `#[serde(flatten)]` envoie le reste
/// (les sélections nommées) dans la map.
#[derive(Debug, Deserialize)]
struct RawDetection
{
condition: String,
#[serde(flatten)]
selections: HashMap<String, Value>,
}
#[derive(Debug, Deserialize)]
struct RawRule
{
title: String,
#[serde(default)]
level: Option<String>,
logsource: RawLogsource,
detection: RawDetection,
}
// ─── AST de condition ────────────────────────────────────────────────────────
/// Arbre d'expression issu de la chaîne `condition:` d'une règle Sigma.
///
/// Grammaire supportée :
/// ```text
/// expr := or_expr
/// or_expr := and_expr ('or' and_expr)*
/// and_expr := not_expr ('and' not_expr)*
/// not_expr := 'not' not_expr | atom
/// atom := '(' expr ')' | quantifier | name
/// quantifier := ('1' | 'all') 'of' pattern
/// pattern := 'them' | GLOB
/// name := IDENT (nom de sélection)
/// ```
#[derive(Debug, Clone)]
enum ConditionExpr
{
/// Référence directe à une sélection nommée.
Selection(String),
/// `1 of <pattern>` — au moins une sélection matchant le glob doit être vraie.
AnyOf(String),
/// `all of <pattern>` — toutes les sélections matchant le glob doivent être vraies.
AllOf(String),
Not(Box<ConditionExpr>),
And(Box<ConditionExpr>, Box<ConditionExpr>),
Or(Box<ConditionExpr>, Box<ConditionExpr>),
}
// ─── Prédicats compilés ──────────────────────────────────────────────────────
/// Logique de matching pour un champ, issue des modificateurs Sigma.
///
/// Modificateurs supportés :
/// - (aucun) / `exact` → égalité
/// - `contains` / `contains|all` → sous-chaîne (any/all)
/// - `startswith` / `startswith|all` → préfixe (any/all)
/// - `endswith` / `endswith|all` → suffixe (any/all)
/// - `re` → regex (tout pattern de la liste)
///
/// Le suffixe `|all` signifie que **toutes** les valeurs de la liste doivent
/// correspondre (AND implicite), au lieu de n'importe laquelle (OR par défaut).
#[derive(Debug, Clone)]
enum FieldMatch
{
Exact { values: Vec<String> },
Contains { values: Vec<String>, all: bool },
StartsWith { values: Vec<String>, all: bool },
EndsWith { values: Vec<String>, all: bool },
Regex { patterns: Vec<Regex> },
}
#[derive(Debug, Clone)]
struct Predicate
{
/// Nom canonique du champ dans `NormalizedEvent.fields`.
field: String,
matcher: FieldMatch,
}
/// Une sélection est un AND implicite de tous ses prédicats.
#[derive(Debug, Clone)]
struct Selection
{
predicates: Vec<Predicate>,
}
// ─── Règle compilée ──────────────────────────────────────────────────────────
#[derive(Debug)]
struct SigmaRule
{
title: String,
level: String,
category: String,
/// Sélections nommées du bloc `detection:`, indexées par leur identifiant.
selections: HashMap<String, Selection>,
/// Condition compilée en AST, évaluée contre les sélections.
condition: ConditionExpr,
}
// ─── RuleStore ───────────────────────────────────────────────────────────────
pub struct RuleStore
{
rules: Vec<SigmaRule>,
rules_dir: PathBuf,
}
impl RuleStore
{
pub fn load_from_dir(dir: &Path) -> Self
{
let rules = load_rules(dir, true);
RuleStore { rules, rules_dir: dir.to_path_buf() }
}
pub fn reload(&mut self)
{
self.rules = load_rules(&self.rules_dir, false);
}
/// Retourne `(titre, level)` de la première règle qui correspond à l'événement.
/// Filtre d'abord par `logsource.category` pour éviter les faux positifs inter-moteurs.
pub fn first_match<'a>(&'a self, event: &NormalizedEvent) -> Option<(&'a str, &'a str)>
{
self.rules.iter()
.filter(|rule| rule.category == event.category)
.find(|rule| eval_condition(&rule.condition, event, &rule.selections))
.map(|rule| (rule.title.as_str(), rule.level.as_str()))
}
}
// ─── Parseur de condition ─────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
enum Token
{
And,
Or,
Not,
Of,
Them,
One, // littéral "1"
All, // mot-clé "all" (quantificateur ou nom de sélection selon contexte)
LParen,
RParen,
Name(String), // nom de sélection ou pattern glob
}
fn tokenize(condition: &str) -> Vec<Token>
{
let mut tokens = Vec::new();
let mut current = String::new();
for ch in condition.chars()
{
match ch
{
'(' =>
{
if !current.is_empty() { tokens.push(word_to_token(std::mem::take(&mut current))); }
tokens.push(Token::LParen);
}
')' =>
{
if !current.is_empty() { tokens.push(word_to_token(std::mem::take(&mut current))); }
tokens.push(Token::RParen);
}
' ' | '\t' | '\n' =>
{
if !current.is_empty() { tokens.push(word_to_token(std::mem::take(&mut current))); }
}
c => { current.push(c); }
}
}
if !current.is_empty() { tokens.push(word_to_token(current)); }
tokens
}
/// Convertit un mot en token en reconnaissant les mots-clés de façon insensible
/// à la casse. Le nom de sélection conserve sa casse originale.
fn word_to_token(word: String) -> Token
{
match word.to_lowercase().as_str()
{
"and" => Token::And,
"or" => Token::Or,
"not" => Token::Not,
"of" => Token::Of,
"them" => Token::Them,
"1" => Token::One,
"all" => Token::All,
_ => Token::Name(word),
}
}
struct Parser
{
tokens: Vec<Token>,
pos: usize,
}
impl Parser
{
fn new(tokens: Vec<Token>) -> Self
{
Parser { tokens, pos: 0 }
}
fn peek(&self) -> Option<&Token>
{
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<Token>
{
if self.pos < self.tokens.len()
{
let t = self.tokens[self.pos].clone();
self.pos += 1;
Some(t)
}
else { None }
}
// ── Niveaux de précédence : or < and < not < atom ──────────────────────
fn parse_expr(&mut self) -> Result<ConditionExpr, String>
{
self.parse_or()
}
fn parse_or(&mut self) -> Result<ConditionExpr, String>
{
let mut left = self.parse_and()?;
while self.peek() == Some(&Token::Or)
{
self.advance();
let right = self.parse_and()?;
left = ConditionExpr::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_and(&mut self) -> Result<ConditionExpr, String>
{
let mut left = self.parse_not()?;
while self.peek() == Some(&Token::And)
{
self.advance();
let right = self.parse_not()?;
left = ConditionExpr::And(Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_not(&mut self) -> Result<ConditionExpr, String>
{
if self.peek() == Some(&Token::Not)
{
self.advance();
let inner = self.parse_not()?; // récursif : supporte "not not x"
return Ok(ConditionExpr::Not(Box::new(inner)));
}
self.parse_atom()
}
fn parse_atom(&mut self) -> Result<ConditionExpr, String>
{
match self.peek().cloned()
{
Some(Token::LParen) =>
{
self.advance();
let expr = self.parse_expr()?;
match self.advance()
{
Some(Token::RParen) => Ok(expr),
other => Err(format!("')' attendu, trouvé {:?}", other)),
}
}
Some(Token::One) =>
{
self.advance();
self.parse_quantifier(false)
}
Some(Token::All) =>
{
self.advance();
// "all of …" → quantificateur ; sans "of" → nom de sélection "all"
if self.peek() == Some(&Token::Of)
{
self.parse_quantifier(true)
}
else
{
Ok(ConditionExpr::Selection("all".to_string()))
}
}
Some(Token::Name(name)) =>
{
self.advance();
Ok(ConditionExpr::Selection(name))
}
other => Err(format!("token inattendu dans la condition : {:?}", other)),
}
}
/// Consomme `of <pattern>` et retourne l'expression de quantification.
fn parse_quantifier(&mut self, all: bool) -> Result<ConditionExpr, String>
{
match self.advance()
{
Some(Token::Of) => {}
other => return Err(format!("'of' attendu, trouvé {:?}", other)),
}
let pattern = match self.advance()
{
Some(Token::Them) => "*".to_string(),
Some(Token::Name(p)) => p,
Some(Token::All) => "all".to_string(),
other => return Err(format!("pattern attendu après 'of', trouvé {:?}", other)),
};
if all { Ok(ConditionExpr::AllOf(pattern)) }
else { Ok(ConditionExpr::AnyOf(pattern)) }
}
}
fn parse_condition(condition: &str) -> Result<ConditionExpr, String>
{
let tokens = tokenize(condition);
let mut parser = Parser::new(tokens);
let expr = parser.parse_expr()?;
if parser.peek().is_some()
{
return Err(format!(
"tokens inattendus après la condition (pos {}) : {:?}",
parser.pos,
&parser.tokens[parser.pos..],
));
}
Ok(expr)
}
// ─── Évaluation ──────────────────────────────────────────────────────────────
fn eval_condition(
expr: &ConditionExpr,
event: &NormalizedEvent,
selections: &HashMap<String, Selection>,
) -> bool
{
match expr
{
ConditionExpr::Selection(name) =>
selections.get(name)
.map(|sel| selection_matches(sel, event))
.unwrap_or(false),
ConditionExpr::AnyOf(pattern) =>
selections.keys()
.filter(|name| glob_matches(pattern, name))
.any(|name| selection_matches(&selections[name], event)),
ConditionExpr::AllOf(pattern) =>
{
let matching: Vec<_> = selections.keys()
.filter(|name| glob_matches(pattern, name))
.collect();
// Si aucune sélection ne matche le glob, "all of" est faux par convention.
!matching.is_empty()
&& matching.iter().all(|name| selection_matches(&selections[name.as_str()], event))
}
ConditionExpr::Not(inner) =>
!eval_condition(inner, event, selections),
ConditionExpr::And(left, right) =>
eval_condition(left, event, selections)
&& eval_condition(right, event, selections),
ConditionExpr::Or(left, right) =>
eval_condition(left, event, selections)
|| eval_condition(right, event, selections),
}
}
fn selection_matches(sel: &Selection, event: &NormalizedEvent) -> bool
{
sel.predicates.iter().all(|pred| predicate_matches(pred, event))
}
fn predicate_matches(pred: &Predicate, event: &NormalizedEvent) -> bool
{
let haystack = match event.get(&pred.field)
{
Some(v) => v,
None => return false,
};
match &pred.matcher
{
FieldMatch::Exact { values } =>
values.iter().any(|v| haystack == v),
FieldMatch::Contains { values, all: false } =>
values.iter().any(|v| haystack.contains(v.as_str())),
FieldMatch::Contains { values, all: true } =>
values.iter().all(|v| haystack.contains(v.as_str())),
FieldMatch::StartsWith { values, all: false } =>
values.iter().any(|v| haystack.starts_with(v.as_str())),
FieldMatch::StartsWith { values, all: true } =>
values.iter().all(|v| haystack.starts_with(v.as_str())),
FieldMatch::EndsWith { values, all: false } =>
values.iter().any(|v| haystack.ends_with(v.as_str())),
FieldMatch::EndsWith { values, all: true } =>
values.iter().all(|v| haystack.ends_with(v.as_str())),
FieldMatch::Regex { patterns } =>
patterns.iter().any(|p| p.is_match(haystack)),
}
}
// ─── Chargement depuis le disque ─────────────────────────────────────────────
fn load_rules(dir: &Path, log_success: bool) -> Vec<SigmaRule>
{
let entries = match fs::read_dir(dir)
{
Ok(e) => e,
Err(e) =>
{
eprintln!("[sigma] Impossible de lire {:?} : {}", dir, e);
return Vec::new();
}
};
let mut rules = Vec::new();
for entry in entries.flatten()
{
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "yml" && ext != "yaml" { continue; }
let content = match fs::read_to_string(&path)
{
Ok(s) => s,
Err(e) => { eprintln!("[sigma] Lecture échouée {:?} : {}", path, e); continue; }
};
let raw: RawRule = match serde_norway::from_str(&content)
{
Ok(r) => r,
Err(e) => { eprintln!("[sigma] Parsing YAML échoué {:?} : {}", path, e); continue; }
};
match compile(raw)
{
Ok(rule) =>
{
if log_success
{
eprintln!("[sigma] Règle chargée : {} ({})", rule.title, rule.category);
}
rules.push(rule);
}
Err(e) => { eprintln!("[sigma] Compilation échouée {:?} : {}", path, e); }
}
}
rules
}
// ─── Compilation ─────────────────────────────────────────────────────────────
fn compile(raw: RawRule) -> Result<SigmaRule, String>
{
let category = raw.logsource.category.unwrap_or_default();
let level = raw.level.unwrap_or_else(|| "medium".to_string());
let condition = parse_condition(&raw.detection.condition)?;
let mut selections = HashMap::new();
for (name, value) in raw.detection.selections
{
let mapping = match value
{
Value::Mapping(m) => m,
_ => return Err(format!("la sélection {:?} doit être un mapping", name)),
};
selections.insert(name, compile_selection(&mapping)?);
}
Ok(SigmaRule { title: raw.title, level, category, selections, condition })
}
fn compile_selection(mapping: &Mapping) -> Result<Selection, String>
{
let mut predicates = Vec::new();
for (k, v) in mapping.iter()
{
let key = match k
{
Value::String(s) => s.as_str(),
_ => return Err("les clés de sélection doivent être des chaînes".to_string()),
};
predicates.push(compile_predicate(key, v)?);
}
Ok(Selection { predicates })
}
fn compile_predicate(key: &str, value: &Value) -> Result<Predicate, String>
{
let parts: Vec<&str> = key.split('|').collect();
let field = normalize_field_name(parts[0]);
let modifiers = &parts[1..];
let matcher = compile_matcher(modifiers, value)?;
Ok(Predicate { field, matcher })
}
/// Compile les modificateurs Sigma en `FieldMatch`.
///
/// `|all` peut se combiner avec `|contains`, `|startswith`, `|endswith` pour
/// exiger que **toutes** les valeurs de la liste matchent (AND au lieu de OR).
fn compile_matcher(modifiers: &[&str], value: &Value) -> Result<FieldMatch, String>
{
let has_all = modifiers.contains(&"all");
let base = modifiers.iter().find(|&&m| m != "all").copied();
match base
{
None | Some("exact") =>
Ok(FieldMatch::Exact { values: extract_string_values(value)? }),
Some("contains") =>
Ok(FieldMatch::Contains { values: extract_string_values(value)?, all: has_all }),
Some("startswith") =>
Ok(FieldMatch::StartsWith { values: extract_string_values(value)?, all: has_all }),
Some("endswith") =>
Ok(FieldMatch::EndsWith { values: extract_string_values(value)?, all: has_all }),
Some("re") =>
{
let raw_patterns = extract_string_values(value)?;
let patterns = raw_patterns.iter()
.map(|p| Regex::new(p).map_err(|e| format!("regex invalide {:?} : {}", p, e)))
.collect::<Result<Vec<_>, _>>()?;
Ok(FieldMatch::Regex { patterns })
}
Some(other) => Err(format!("modificateur non supporté : {:?}", other)),
}
}
fn normalize_field_name(name: &str) -> String
{
match name.to_lowercase().as_str()
{
"image" | "filename" => "Image".to_string(),
"commandline" | "args" | "cmdline" => "CommandLine".to_string(),
"processid" | "pid" => "ProcessId".to_string(),
"parentprocessid" | "ppid" => "ParentProcessId".to_string(),
other =>
{
// Champs inconnus : capitalize la première lettre et passe en through.
// Permet d'utiliser des champs futurs (User, DestinationIp, …) sans
// modifier le moteur.
let mut chars = other.chars();
match chars.next()
{
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}
}
}
fn extract_string_values(value: &Value) -> Result<Vec<String>, String>
{
match value
{
Value::String(s) => Ok(vec![s.clone()]),
Value::Null => Ok(Vec::new()),
Value::Sequence(seq) =>
{
seq.iter()
.map(|item| match item
{
Value::String(s) => Ok(s.clone()),
Value::Null => Ok(String::new()),
_ => Err("les valeurs d'une séquence doivent être des chaînes ou null".to_string()),
})
.collect()
}
_ => Err("la valeur d'un champ doit être une chaîne, une séquence ou null".to_string()),
}
}
/// Matcher de glob simple — supporte un unique `*` comme joker.
fn glob_matches(pattern: &str, name: &str) -> bool
{
match pattern.find('*')
{
None => pattern == name,
Some(pos) =>
{
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
name.len() >= prefix.len() + suffix.len()
&& name.starts_with(prefix)
&& name.ends_with(suffix)
}
}
}

View file

View file

@ -0,0 +1,27 @@
use aya::{programs::BtfTracePoint, Btf, Ebpf};
/// Bytecode eBPF embarqué dans le binaire à la compilation par build.rs.
static KERNEL_LAND_BPF: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/kernel-land.bpf"));
/// Charge le module eBPF, attache le sensor execve et retourne le handle Ebpf.
/// Le handle doit rester en vie pour que les programmes restent attachés au kernel.
pub fn load() -> Result<Ebpf, Box<dyn std::error::Error>>
{
// Copie dans un Vec pour garantir l'alignement 8 octets requis par le parser ELF.
// include_bytes! n'offre qu'un alignement 1 octet.
let mut ebpf = Ebpf::load(&KERNEL_LAND_BPF.to_vec())?;
// BTF du kernel courant — requis pour les programmes tp_btf
let btf = Btf::from_sys_fs()?;
let program: &mut BtfTracePoint = ebpf
.program_mut("enter_execve")
.expect("Programme enter_execve introuvable dans l'ELF eBPF")
.try_into()?;
program.load("sched_process_exec", &btf)?;
program.attach()?;
Ok(ebpf)
}

53
user-land/src/main.rs Normal file
View file

@ -0,0 +1,53 @@
mod alert;
mod engine;
mod exclusions;
mod loader;
mod sinks;
mod ui;
use std::{
fs::OpenOptions,
os::unix::io::IntoRawFd,
path::PathBuf,
};
use tokio::sync::mpsc;
use alert::AlertBus;
use sinks::{AuditLog, JsonFileSink, TuiSink};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>>
{
// Redirige stderr vers un fichier de log pour ne pas polluer la TUI
let log = OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/vauban.log")?;
unsafe { libc::dup2(log.into_raw_fd(), libc::STDERR_FILENO); }
let rules_dir = PathBuf::from("/home/songbird/project/vauban/rules");
// Charge et attache le module eBPF
let ebpf = loader::load()?;
// Canal dédié au sink TUI — les autres sinks (JSON, …) ne passent pas par ici.
let (tui_tx, tui_rx) = mpsc::channel(1024);
let bus = AlertBus::new(vec![
Box::new(TuiSink::new(tui_tx)),
Box::new(JsonFileSink::new(PathBuf::from("/tmp/vauban-alerts.jsonl"))),
]);
// Démarre la tâche tokio de lecture du ring buffer.
// ebpf est moved dans la tâche — les programmes eBPF restent attachés
// tant que la tâche vit.
let audit_log = AuditLog::new(PathBuf::from("/tmp/vauban-audit.jsonl"));
let _engine = engine::start(ebpf, bus, audit_log, rules_dir);
// Lance la TUI (bloque jusqu'à [q])
ui::run(tui_rx)?;
Ok(())
}

142
user-land/src/sinks.rs Normal file
View file

@ -0,0 +1,142 @@
use std::{
collections::HashMap,
fs::OpenOptions,
io::Write,
path::PathBuf,
};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::mpsc::Sender;
use crate::alert::{Alert, AlertSink};
use common_lib::event::NormalizedEvent;
// ─── TuiSink ─────────────────────────────────────────────────────────────────
/// Achemine les alertes vers la TUI via un canal tokio.
/// Utilise `try_send` (non-bloquant) : si le canal est plein, l'alerte est
/// silencieusement ignorée côté TUI — les autres sinks continuent à recevoir.
pub struct TuiSink
{
tx: Sender<Alert>,
}
impl TuiSink
{
pub fn new(tx: Sender<Alert>) -> Self
{
TuiSink { tx }
}
}
impl AlertSink for TuiSink
{
fn emit(&self, alert: &Alert)
{
let _ = self.tx.try_send(alert.clone());
}
}
// ─── JsonFileSink ─────────────────────────────────────────────────────────────
/// Écrit chaque alerte sous forme d'une ligne JSON (JSONL / ndjson) dans un fichier.
/// Compatible avec Elastic, Splunk, Loki, et tout outil qui consomme du JSONL.
/// Le fichier est ouvert en mode append à chaque emit — pas de handle persistant,
/// ce qui évite les problèmes de rotation de log (logrotate, etc.).
pub struct JsonFileSink
{
path: PathBuf,
}
impl JsonFileSink
{
pub fn new(path: PathBuf) -> Self
{
JsonFileSink { path }
}
}
impl AlertSink for JsonFileSink
{
fn emit(&self, alert: &Alert)
{
let line = match serde_json::to_string(&alert)
{
Ok(s) => s,
Err(e) =>
{
eprintln!("[JsonFileSink] Sérialisation échouée : {}", e);
return;
}
};
match OpenOptions::new().create(true).append(true).open(&self.path)
{
Ok(mut f) => { let _ = writeln!(f, "{}", line); }
Err(e) =>
{
eprintln!("[JsonFileSink] Impossible d'ouvrir {:?} : {}", self.path, e);
}
}
}
}
// ─── AuditLog ─────────────────────────────────────────────────────────────────
/// Entrée d'audit : tous les événements, qu'ils matchent une règle ou non.
/// Utilisé pour la forensique post-incident — permet de reconstituer l'activité
/// complète du système même en l'absence de règle Sigma correspondante.
/// `event_id` est partagé avec l'alerte correspondante le cas échéant.
#[derive(Serialize)]
struct AuditEntry<'a>
{
timestamp: DateTime<Utc>,
event_id: &'a str,
category: &'a str,
fields: &'a HashMap<String, String>,
}
/// Écrit tous les `NormalizedEvent` en JSONL, avant toute évaluation des règles.
pub struct AuditLog
{
path: PathBuf,
}
impl AuditLog
{
pub fn new(path: PathBuf) -> Self
{
AuditLog { path }
}
pub fn write(&self, event: &NormalizedEvent, event_id: &str)
{
let entry = AuditEntry
{
timestamp: Utc::now(),
event_id,
category: &event.category,
fields: &event.fields,
};
let line = match serde_json::to_string(&entry)
{
Ok(s) => s,
Err(e) =>
{
eprintln!("[AuditLog] Sérialisation échouée : {}", e);
return;
}
};
match OpenOptions::new().create(true).append(true).open(&self.path)
{
Ok(mut f) => { let _ = writeln!(f, "{}", line); }
Err(e) =>
{
eprintln!("[AuditLog] Impossible d'ouvrir {:?} : {}", self.path, e);
}
}
}
}

181
user-land/src/ui/mod.rs Normal file
View file

@ -0,0 +1,181 @@
use std::io;
use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, TableState},
Terminal,
};
use tokio::sync::mpsc::Receiver;
use crate::alert::Alert;
const TICK_RATE: Duration = Duration::from_millis(16); // ~60 fps
/// Point d'entrée de la TUI. Bloque jusqu'à ce que l'utilisateur quitte.
pub fn run(mut rx: Receiver<Alert>) -> Result<(), Box<dyn std::error::Error>>
{
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut alerts: Vec<Alert> = Vec::new();
let mut table_state = TableState::default();
let result = run_loop(&mut terminal, &mut rx, &mut alerts, &mut table_state);
// Restauration du terminal même en cas d'erreur
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
rx: &mut Receiver<Alert>,
alerts: &mut Vec<Alert>,
state: &mut TableState,
) -> Result<(), Box<dyn std::error::Error>>
{
loop
{
while let Ok(alert) = rx.try_recv()
{
alerts.push(alert);
state.select(Some(alerts.len().saturating_sub(1)));
}
terminal.draw(|frame| render(frame, alerts, state))?;
if event::poll(TICK_RATE)?
{
if let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Press
{
match key.code
{
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Down => scroll_down(state, alerts.len()),
KeyCode::Up => scroll_up(state),
_ => {}
}
}
}
}
}
}
fn render(
frame: &mut ratatui::Frame,
alerts: &[Alert],
state: &mut TableState,
)
{
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Barre de titre
let title = Line::from(vec![
Span::styled(" Vauban v0.0.1 ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(""),
Span::styled(
format!("{} alertes", alerts.len()),
Style::default().fg(Color::Yellow),
),
Span::raw(" "),
Span::styled(
"[↑↓] scroll [q] quitter",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(
Block::default().borders(Borders::ALL).title(title),
chunks[0],
);
// En-têtes de la table
let header = Row::new(
["Severity", "Rule", "PID", "Image", "CommandLine"].map(|h|
Cell::from(h).style(
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)
)
)
.height(1)
.bottom_margin(1);
// Coloration par sévérité
let severity_color = |s: &str| match s
{
"critical" => Color::Red,
"high" => Color::LightRed,
"medium" => Color::Yellow,
"low" => Color::Green,
_ => Color::White,
};
// Lignes
let rows: Vec<Row> = alerts
.iter()
.map(|a|
{
let color = severity_color(&a.severity);
Row::new([
Cell::from(a.severity.clone())
.style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
Cell::from(a.rule.clone()),
Cell::from(a.event.get("ProcessId").cloned().unwrap_or_default()),
Cell::from(a.event.get("Image").cloned().unwrap_or_default()),
Cell::from(a.event.get("CommandLine").cloned().unwrap_or_default()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Percentage(25),
Constraint::Length(8),
Constraint::Percentage(30),
Constraint::Min(0),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Alertes détectées "))
.row_highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(table, chunks[1], state);
}
fn scroll_down(state: &mut TableState, len: usize)
{
if len == 0 { return; }
let next = state.selected().map(|i| (i + 1).min(len - 1)).unwrap_or(0);
state.select(Some(next));
}
fn scroll_up(state: &mut TableState)
{
let prev = state.selected().map(|i| i.saturating_sub(1)).unwrap_or(0);
state.select(Some(prev));
}