Référence Architecture¶
Document de référence complet — ne nécessite pas de relire le code source.
1. Vue d'ensemble¶
Outil headless qui capture des événements Windows réels via Windows Event Log API (winevt), les matche contre les règles SigmaHQ, et sort des données de régression structurées.
Cycle complet (séquentiel) : 1. Acquérir règles SigmaHQ (grit-lib clone/pull) 2. Charger moteur Sigma (rsigma-eval) avec filtre logsource 3. Collecter événements Event Log (winevt, channels configurés) 4. Évaluer events contre toutes les règles chargées 5. Générer sortie regression (JSON + EVTX template + info.yml)
Boucle : toutes les 30s en continu.
Plateforme : Windows (winevt + Sysmon requis). Linux/macOS : stub no-op.
2. Arborescence¶
src/
├── git.rs # wrapper grit-lib : clone/fetch/push/branch/commit/checkout (Rust pur, pas de git CLI)
├── main.rs # Pipeline + Stats + AggregatedRule
├── config.rs # Config YAML (serde, Default) + LogConfig
├── contrib/ # Workflow de contribution SigmaHQ
│ ├── mod.rs # pub mod branch, commit, fork
│ ├── branch.rs # create_branch_name, create_branch, push_branch (fetch + push normal)
│ ├── commit.rs # commit_all_rules avec author env + fallback
│ └── fork.rs # ForkConfig, check_fork_exists, detect_fork
├── logger.rs # Two-layer tracing (stderr info + rolling file debug)
├── sigma/
│ ├── loader.rs # SigmaRepo (grit-lib) + remote URL update + find_rules_dirs()
│ └── engine.rs # SigmaEngine + évaluation des règles (resolve_logsource depuis mapping)
├── collector/
│ ├── mod.rs # pub mod winevt
│ └── winevt.rs # WinevtCollector (EvtQueryW, EvtNext, EvtRender)
├── evtx/
│ └── writer.rs # write_evtx() via EvtExportLog API + .xml fallback
├── parser/
│ └── mod.rs # XmlParser (Winevt XML → JSON plat)
└── regression/
├── mod.rs # SkipSet, build_skip_set(), validate_rule_id(), triplet validation
├── generator.rs # RegressionData, MatchEvent
└── info_yml.rs # InfoYml, RuleMetadata, RegressionTestInfo
3. Configuration¶
config.yaml (auto-créé au premier run, complété automatiquement si la section sigma manque) :
author: "sigmacatch" # nom GitHub pour contrib workflow (doit être changé)
email: "you@example.com" # requis pour les commits git
github_token: "" # token GitHub (ou var env GITHUB_TOKEN) — requis pour le push du fork
log:
level_file: "debug" # niveau fichier tracing
sigma:
min_status: "stable" # seuil de statut minimal (inclus) : unsupported < deprecated < experimental < test < stable
min_level: "critical" # seuil de niveau minimal (inclus) : informational < low < medium < high < critical
Filtrage des règles : min_status et min_level sont appliqués au chargement. Les règles dont status/level est inférieur au seuil sont exclues du moteur. Les règles sans champ status ou level sont toujours acceptées.
CLI flags : --author <name>
4. Pipeline détaillé¶
Stage 0 — Init¶
config.yaml → Config struct
↓
create_dir_all("sigma/", "regression_data/", "regression_data/rules/", "logs/")
↓
logger::init() → tracing subscriber (stderr info + file debug)
Stage 1 — Acquisition SigmaHQ¶
SigmaRepo::new("sigma/")
↓
with_remote_url(URL du fork)
↓
init() [async]
├── NO .git → grit-lib clone <remote_url> (fork ou SigmaHQ)
└── .git EXISTS → mise à jour remote origin (si fork) → grit-lib fetch
└── échec → WARN, continue avec règles existantes
↓
create_branch("sigmacatch-contrib/YYYYMMDD_<author>")
└── create_branch() → grit-lib create ref + switch HEAD vers la branche (ou switch si existe déjà)
Stage 2 — Skip Set (règles existantes)¶
build_skip_set(dirs, max_depth=64)
├── scan regression_data/rules/*/info.yml
├── scan sigma/regression_data/**/info.yml
│ (exclut rules-compliance/ et rules_compliance/)
├── pour chaque info.yml :
│ ├── parse_info_yml() → rule_id (flexible: rule_metadata[0].id ou root id)
│ ├── validate_rule_id() → UUID v4 ou [a-z0-9_-]+
│ ├── validate_parent_folder() → dossier parent == rule_id
│ └── validate_triplet() → info.yml + .json + .evtx
│ ├── complet → SkipSet::rules
│ └── incomplet → SkipSet::incomplete (listé, pas bloquant)
└── SkipSet { rules, incomplete, duplicates }
Règles avec régression existante (complète ou incomplète) → exclu du moteur Sigma (seule optimisation autorisée).
Stage 3 — Chargement des règles¶
find_rules_dirs("sigma/")
→ Vec<PathBuf> (rules, rules-*, exclut rules-compliance)
↓
Pour chaque .yml / .yaml :
├── parse_sigma_yaml() → règles Sigma
├── post-parse filter: rule.logsource.product == "windows" (ou absent)
├── filter status/level: rule.status >= min_status ET rule.level >= min_level (config.sigma)
├── skip si rule_id dans skip set
└── dédoublonnage cross-fichier (1re occurrence, ordre du walk, gagne)
↓
Merge séquentiel : accumule les règles survivantes dans UNE SigmaCollection,
puis UN SEUL engine.add_collection() → rsigma-eval (un seul rebuild d'index)
↓
SigmaEngine in-memory (règles chargées + rule_paths)
Note perf :
add_collection()dersigma-evalrebuild l'index complet des règles à chaque appel. L'ancienadd_collectionpar fichier était en O(N²) (N rebuilds d'un index de N règles). Regrouper toutes les règles survivantes dans une seule collection fait passer le chargement de ~33s à ~0,2s pour tout SigmaHQ (~2800 règles). Le parse tourne en parallèle viarayon.Affichage d'une table de règles au démarrage (nombre chargé, nombre skipé, services/catégories actifs).
Filtrage status/level : les règles dont status < min_status ou level < min_level sont exclues (seulement si le champ est présent). Par défaut min_status=stable, min_level=critical — très restrictif, ne charge que les règles stables/critiques.
Cycle — Collecte¶
WinevtCollector (channels résolus depuis les règles via resolve_channels_from_rules)
├── [Windows] EvtQueryW(channel="*") → EvtNext() → EvtRender() → XML
│ ├── Un task par channel (tokio::spawn)
│ ├── XML → parse_event_xml() → WinevtEvent (porte event_json pré-parsé)
│ └── mpsc::channel → main loop
└── [non-Windows] Stub → Ok(vec![])
↓
Vec<WinevtEvent> { channel, event_id, raw_xml, event_json }
Cycle — Évaluation¶
Pour chaque SensorEvent :
├── channel → LogSource { product: "windows", service, category }
│ (mapping::resolve_logsource + priorité channel/service)
├── event.event_json → serde_json::Value plat (pré-parsé par collector, fallback XmlParser si None)
├── engine.evaluate_event_with_logsource(event_value, logsource)
│ → Vec<EvaluationResult> (rsigma-eval)
└── Pour chaque match :
├── rule_id = match.header.rule_id
├── skip si rule_id dans retired (déjà généré ce cycle)
├── stats.matches_found++
└── aggregated[rule_id].events.push((event_value, raw_xml, provider))
Cycle — Génération¶
Pour chaque AggregatedRule dans aggregated :
├── RegressionData::new(header, output_path, rule_rel_path, author)
├── exists() → skip si info.yml existe déjà
├── Pour chaque event : reg.add_event(event_json, raw_xml)
├── reg.generate()
│ ├── Write <rule_id>.json (premier event, JSON pretty-printed)
│ ├── Write <rule_id>.evtx (EvtExportLog API, ou .xml fallback)
│ └── Write info.yml (InfoYml::new + save)
├── Append "regression_tests_path: ..." au YAML source de la règle
└── retired.insert(rule_id)
Sortie :
<output_base>/<rule_rel_path>/
├── <rule_id>.json # premier event correspondant (JSON plat)
├── <rule_id>.evtx # EVTX valide via EvtExportLog (ou .xml fallback)
└── info.yml # métadonnées compatible SigmaHQ
output_base = regression_data/ (racine du projet)
- Contrib : output_base = sigma/regression_data/ (dans le repo sigma, commité sur le fork)
Post-cycle¶
commit_all_rules() → batch grit-lib commit dans le repo sigma
sleep 30s → loop
Ctrl+C → running.store(false) → break
push_branch() → fetch + comparaison + push normal vers le fork (skip si divergé)
Stats : { events_processed, matches_found, regression_data_generated }
5. Structures de données clés¶
WinevtEvent¶
WinevtEvent {
channel: String, // nom du channel Event Log
event_id: u32, // EventID
raw_xml: String, // XML complet de l'événement (Winevt format)
event_json: Option<serde_json::Value>, // JSON pré-parsé par le collector (XmlParser)
}
InfoYml¶
type: evtx
id: <uuid v4>
description: "N/A"
date: YYYY-MM-DD
author: <config.author>
rule_metadata:
- id: <rule_id>
title: <rule_title>
regression_tests_info:
name: "Positive Detection Test"
test_type: evtx
channel: "Microsoft-Windows-Sysmon/Operational"
match_count: 1
path: <rule_rel_path>/<rule_id>.evtx
RegressionData¶
RegressionData {
header: RuleHeader, // rule_id, title, etc.
events: Vec<MatchEvent>, // (event_json, raw_xml, channel, record_id, provider)
output_path: PathBuf,
rule_rel_path: Option<PathBuf>,
author: Option<String>,
description: Option<String>,
}
MatchEvent {
event: Value, // JSON plat de l'événement
raw_xml: String, // XML Winevt complet (pour EVTX)
channel: String, // nom du channel Event Log
record_id: Option<u64>, // EventRecordID
provider: String, // ProviderName extrait de l'event (ex: Microsoft-Windows-Sysmon)
}
6. Modules clés¶
SigmaEngine (sigma/engine.rs)¶
- Charge règles depuis
rules*dirs - Walk séquentiel collecte les chemins ; parse + post-parse filter en parallèle (
rayon::par_iter) - Post-parse filter:
rule.logsource.productfiltre les règles non-Windows aprèsparse_sigma_yaml - Filtre status/level appliqué par fichier pendant le parse parallèle
- Skip-at-load = seule optimisation (règles avec
info.ymlexistant) - Toutes les règles survivantes mergées dans une seule
SigmaCollection, compilées en un seuladd_collection()(évite les rebuilds d'index O(N²)) LogSourcedérivé du channel Event Log + provider (resolve_logsource)evaluate_event_with_logsource()→Vec<EvaluationResult>via rsigma-eval
EVTX Writer (evtx/writer.rs)¶
- Windows :
EvtExportLogAPI (winevt) — re-queries l'event par RecordID et exporte en.evtxbinaire valide EvtExportLog(None, channel, query, path, EvtExportLogChannelPath | EvtExportLogOverwrite)- Produit un EVTX binaire valide lisible par hayabusa/chainsaw
- Known limitation : race condition avec la rétention du log — si l'event a été purgé entre la collecte et l'export, l'appel échoue silencieusement (
ERROR_EVT_QUERY_RESULT_STALE) - Fallback : écriture XML brut en
.xml(pas.evtx— évite de produire un binaire invalide qui casserait les outils downstream) - Non-Windows : fallback écriture XML brut en
.xml - Le
.jsoncompagnon porte les données réelles pour le matching Sigma
Logger (logger.rs)¶
- couche stderr : niveau
info, couleurs ANSI, filterable viaRUST_LOG - couche fichier : niveau
debug(configurable), rotation journalière logs/sigmacatch.YYYY-MM-DD.log
7. Invariants architecturaux¶
| Invariant | Détail |
|---|---|
| Pipeline 100% séquentiel | rules → engine → collect → match → generate |
| Tout en RAM | agrégation mémoire avant écriture, pas de DB |
| Un run = cycle complet | pas de mode "juste collect" ou "juste generate" |
| Collecte via Winevt | EvtQueryW → EvtNext → EvtRender, pas ETW, pas ferrisetw |
| LogSource depuis channel | channel Event Log via resolve_logsource (channel > provider > default) |
| Skip-at-load unique optimisation | règles avec info.yml exclu du moteur |
| Un event par test | match_count: 1, premier event seulement |
| Output miroir source | regression_tests_path ajouté au YAML source |
| EVTX via EvtExportLog | Re-queries event par RecordID → EVTX binaire valide. Fallback .xml si échec. |
8. Dépendances¶
| Dépendance | Usage |
|---|---|---|
| grit-lib | toutes les ops git (clone, fetch, push, branch, commit, checkout) via HTTP, Rust pur |
| reqwest (blocking) | client HTTP pour transport git + détection de fork (API GitHub) |
| rsigma-eval + rsigma-parser | Sigma rule loading/evaluation |
| tokio | async runtime |
| tracing + tracing-subscriber | logging |
| serde / serde_json / serde_yaml | config + event + regression serialization |
| anyhow | error handling |
| chrono | dates |
| uuid | UUID v4 pour info.yml |
| windows | Winevt API (cfg-gated: windows only, features: Foundation, Com, Console, EventLog, Threading, Security) |
Retirés : ratatui, crossterm, quick-xml, winevt-writer, tdh, ntapi
9. Build & Lint¶
cargo build --release
cargo clippy -- -W warnings
cargo xwin build --release --target x86_64-pc-windows-msvc # cross-compile Windows
10. CLI¶
La config est auto-créée au premier run avec les valeurs par défaut. Éditez config.yaml avant de lancer.
11. Diagramme du pipeline¶
┌─────────────────────────────────────────────────────────────────────────┐
│ config.yaml │
│ author, email, log.level_file │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ STAGE 0 — INIT │
│ create_dir_all("sigma/", "regression_data/", │
│ "regression_data/rules/", "logs/") │
│ logger::init() → tracing (stderr info + file debug) │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ STAGE 1 — ACQUISITION SIGMAHQ │
│ SigmaRepo::new("sigma/") │
│ ├── [contrib] définir l'URL du remote fork │
│ ├── NO .git → grit-lib clone (fork ou SigmaHQ) │
│ └── .git EXISTS → mise à jour remote origin → grit-lib fetch │
│ ↓ │
│ [contrib] create_branch("sigmacatch-contrib/...") + switch HEAD │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ STAGE 2 — SKIP SET │
│ build_skip_set(regression_data/rules/, sigma/regression_data/) │
│ → validate triplet (info.yml + .json + .evtx) │
│ → validate rule_id format + parent folder match │
│ → SkipSet { rules, incomplete, duplicates } │
│ → HashSet<rule_id> (règles avec régression existante) │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ STAGE 3 — CHARGEMENT DES RÈGLES │
│ find_rules_dirs("sigma/") → rules, rules-* (excl. rules-compliance) │
│ Pour chaque .yml : │
│ ├── parse_sigma_yaml() → règles Sigma │
│ ├── post-parse filter: logsource.product == "windows" (ou absent) │
│ ├── skip si rule_id dans skip set │
│ └── engine.add_collection() → rsigma-eval │
│ → SigmaEngine in-memory + rule_paths HashMap │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — COLLECTE (winevt) │
│ WinevtCollector (channels résolus depuis les règles) │
│ ├── Windows: EvtQueryW → EvtNext → EvtRender → XML │
│ │ → parse_event_xml() → WinevtEvent │
│ └── non-Windows: Stub → Ok(vec![]) │
│ → Vec<WinevtEvent> { channel, event_id, raw_xml } │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — ÉVALUATION │
│ Pour chaque WinevtEvent : │
│ ├── event.event_json → JSON plat (pré-parsé, fallback XmlParser si None)
│ ├── channel → LogSource via resolve_logsource() │
│ └── engine.evaluate_event_with_logsource() │
│ → Vec<EvaluationResult> │
│ Pour chaque match : │
│ └── aggregated[rule_id].events.push((json, raw_xml, provider)) │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — GÉNÉRATION │
│ Pour chaque AggregatedRule : │
│ ├── skip si rule_id dans retired ou info.yml existant │
│ ├── RegressionData::new(output_base, ...) │
│ │ output_base = regression_data/ ou sigma/regression_data/ │
│ ├── reg.generate() → triplet : │
│ │ ├── <rule_id>.json (premier event, JSON plat) │
│ │ ├── <rule_id>.evtx (EvtExportLog, ou .xml fallback) │
│ │ └── info.yml (UUID v4, metadata SigmaHQ) │
│ └── append "regression_tests_path" au YAML source │
│ ↓ │
│ commit_all_rules() → batch grit-lib commit dans sigma │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ POST-CYCLE │
│ sleep 30s → loop │
│ Ctrl+C → running.store(false) → break │
│ push_branch() → fetch + comparaison + push normal vers le fork │
└─────────────────────────────────────────────────────────────────────────┘
Sortie finale :
<output_base>/<rule_rel_path>/
├── <rule_id>.json # premier event correspondant (JSON plat, clés Sigma)
├── <rule_id>.evtx # EVTX valide via EvtExportLog (ou .xml fallback)
└── info.yml # type: evtx, rule_metadata, regression_tests_info
output_base = regression_data/ (racine du projet)
- Contrib : output_base = sigma/regression_data/ (dans le repo sigma, commité sur le fork)