optimisation
This commit is contained in:
parent
646c88511a
commit
c64ccf61fc
37 changed files with 68183 additions and 178 deletions
920
Cargo.lock
generated
920
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
31
Cargo.toml
31
Cargo.toml
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
11
common-lib/Cargo.toml
Normal 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
66
common-lib/src/lib.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
1
edr-common/.gitignore
vendored
1
edr-common/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/target
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
[package]
|
||||
name = "edr-common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -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
1
edr-ebpf/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/target
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
1
edr/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/target
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
16
kernel-land/Cargo.toml
Normal 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
6
kernel-land/src/lib.rs
Normal 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
14
kernel-land/src/main.rs
Normal 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
7
kernel-land/src/maps.rs
Normal 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);
|
||||
62
kernel-land/src/sensors/execve/mod.rs
Normal file
62
kernel-land/src/sensors/execve/mod.rs
Normal 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(())
|
||||
}
|
||||
1
kernel-land/src/sensors/mod.rs
Normal file
1
kernel-land/src/sensors/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod execve;
|
||||
65513
kernel-land/src/vmlinux.rs
Normal file
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
34
rules/detect_ls.yaml
Normal 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
51
rules/nmap.yaml
Normal 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
39
rules/reverse_shell.yaml
Normal 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
3
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rust-src"]
|
||||
26
user-land/Cargo.toml
Normal file
26
user-land/Cargo.toml
Normal 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
30
user-land/build.rs
Normal 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
75
user-land/src/alert.rs
Normal 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
230
user-land/src/engine/mod.rs
Normal 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();
|
||||
}
|
||||
})
|
||||
}
|
||||
10
user-land/src/engine/rules/execve_suspicious.yml
Normal file
10
user-land/src/engine/rules/execve_suspicious.yml
Normal 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
|
||||
658
user-land/src/engine/sigma.rs
Normal file
658
user-land/src/engine/sigma.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
0
user-land/src/exclusions/mod.rs
Normal file
0
user-land/src/exclusions/mod.rs
Normal file
27
user-land/src/loader/mod.rs
Normal file
27
user-land/src/loader/mod.rs
Normal 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
53
user-land/src/main.rs
Normal 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
142
user-land/src/sinks.rs
Normal 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
181
user-land/src/ui/mod.rs
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue