Architecture¶
Workspace cargo¶
Le projet est un package cargo unique (sigmacatch), plus un crate eBPF nested nightly-only (sigmacatch/ebpf) exclu du workspace :
sigmacatch/
├── Cargo.toml # Racine workspace
├── sigmacatch/ # Package principal (library + deux binaires)
│ ├── Cargo.toml # features : winevt (défaut), evtx, auditd, builtin, sysmon, ebpf
│ ├── build.rs # Build de l'objet eBPF (cible Linux + feature `ebpf` uniquement)
│ ├── src/
│ ├── main.rs # Dispatch : --evtx → input evtx ; winevt sur Windows ; inputs Linux sur Linux
│ ├── lib.rs # Déclarations de modules + re-exports (CollectorKind, run, bootstrap_repo_regression, DataFormat)
│ ├── cli.rs # Dispatch + sous-commandes de diagnostic : check-filter, list-rules
│ ├── runner.rs # run<C: CollectorKind> pipeline partagé + trait CollectorKind + bootstrap_repo_regression
│ ├── logging.rs # Init tracing à deux couches (stderr error/info, fichier rolling)
│ ├── config.rs # Config, GitConfig, SigmaFilterConfig, LogConfig, CliArgs, parse_args, custom_channels.yaml
│ ├── types.rs # Event, Alert, RegressionHeader, Product, EventProducer, parsing XML, tables logsource phf
│ ├── evtx_reader.rs # Parse fichiers EVTX → Event (cross-platform, utilisé par les deux binaires)
│ ├── ebpf_common.rs # Types wire du ring buffer eBPF (partagés avec le crate probe via #[path])
│ ├── detection/ # DetectionEngine + pipelines par plateforme + channel_resolver
│ ├── rule/ # SigmahqRules : load/filter/dedupe/remove_id + thresholds, attack, discover
│ ├── repo/ # Wrapper grit-lib : SigmaRepo, plumbing/, porcelain, branch, signing, transport
│ ├── regression/ # SigmahqRegression, InfoYml, DataFormat (evtx/log), logtype, format, evtx_writer
│ ├── inputs/ # Modules d'input adapters (matrice gate feature × plateforme dans mod.rs) :
│ │ ├── mod.rs # matrice gate feature × target_os
│ │ ├── winevt.rs # WinevtCollector (Event Log live, feature `winevt`)
│ │ ├── evtx.rs # EvtxCollector (fichiers EVTX one-shot, feature `evtx`)
│ │ ├── channels.rs # Collecteur Winevt (EvtQueryW/EvtNext/EvtRender, multi-channel)
│ │ ├── linux.rs # LinuxCollector + run() (toute feature Linux)
│ │ ├── auditd.rs # Collecteur auditd (LineHandler, groupement par identifiant d'événement, via tail)
│ │ ├── syslog.rs # Collecteur syslog builtin (LineHandler par fichier, via tail)
│ │ ├── sysmon.rs # Collecteur Sysmon-for-Linux (LineHandler, via tail, feature `sysmon`)
│ │ ├── tail.rs # Driver tail partagé (trait LineHandler + détection rotation)
│ │ ├── sysmon_parse.rs # Parsing Sysmon XML (feature `builtin`)
│ │ ├── ebpf.rs # Loader eBPF + dispatch (feature `ebpf`, privileges requis)
│ │ └── ebpf_event.rs # Synthèse XML eBPF → format Sysmon + tests
│ └── bin/
│ └── regressiondata-check.rs # Binaire standalone cross-platform : validation régression (--json, --ignore, --fix, --path)
│ └── tests/ # 16 tests d'intégration (runner, cli, collecteurs, e2e)
│ └── fixtures/ # sample.xml, sample.evtx, valid-single.evtx + fixtures sigma{,_malformed,_malformed_reference,_negative}/
│ └── ebpf/ # Crate eBPF probe nested nightly (exclu via [workspace], bpfel-unknown-none)
│ ├── .cargo/config.toml # cible bpfel, bpf-linker, build-std=core
│ └── src/main.rs # 8 tracepoints : execve, exec, exit, connect, openat (enter+exit), sendto, sendmsg
Collecteurs¶
Un seul binaire sigmacatch est produit par le package sigmacatch. Les features cargo
sélectionnent quels inputs sont compilés, et main.rs choisit l'input au runtime : le
collecteur one-shot EVTX quand --evtx est présent, le collecteur Winevt live sur Windows,
ou l'ensemble des inputs Linux compilés et disponibles. S'y ajoute le binaire standalone
cross-platform regressiondata-check (deuxième binaire du package sigmacatch) :
| Input | Module | Features | Description |
|---|---|---|---|
| winevt | sigmacatch/src/inputs/channels.rs |
winevt |
API Winevt native (EvtQueryW/EvtNext/EvtRender), multi-channel, rejouable |
| evtx | sigmacatch/src/inputs/evtx.rs |
evtx |
Collecteur one-shot .evtx (live_capture() = false) : parse → détection → génération régression (writer EVTX pur Rust) → commit/push, puis sortie |
| auditd | sigmacatch/src/inputs/auditd.rs |
auditd |
tail auditd (pas de root requis) |
| syslog builtin | sigmacatch/src/inputs/syslog.rs |
builtin |
tails syslog central/authpriv/cron (pas de root requis) |
| sysmon (tail) | sigmacatch/src/inputs/sysmon.rs |
sysmon (implique builtin) |
tail XML Sysmon-for-Linux (Legacy) |
| sysmon (ebpf) | sigmacatch/src/inputs/ebpf.rs |
ebpf |
probes eBPF natifs (root ou CAP_BPF+CAP_PERFMON requis) |
Logsource Windows et catégories PowerShell¶
Les règles Windows sont contraintes par la pipeline 1_win_logsource.yml
(add_condition sur les EventID + change_logsource vers le service) : les catégories
PowerShell sont bornées à leurs EventID — ps_module (4103), ps_script (4104) vers
service: powershell ; ps_classic_start (400), ps_classic_provider_start (600) et
ps_classic_script (800) vers service: powershell-classic. Sans champ category injecté
sur l'événement, le LogSourceExtractor d'rsigma évalue chaque événement fail-open contre toutes
les règles.
Les événements PowerShell classique (400/600/800 …) émettent des <Data> sans attribut
Name : le parseur les expose sous des clés positionnelles (Data0, Data1, …), et
inject_logsource_fields_for surface le contenu EventData sous le champ Sigma générique
Data pour que Data|contains fonctionne (rsigma n'a pas de mapping de champ dédié
powershell_classic).
Les collecteurs Linux¶
Chacun gardé par sa source ; aucune source disponible → bail :
- auditd — si
/var/log/audit/audit.logexiste : tail, parsing linux-audit-parser, groupement par identifiant d'événementtimestamp:sequence, logsourceproduct:linux, service:auditd. - syslog builtin — tail de chaque fichier existant parmi central (
/var/log/messages,/var/log/syslog), authpriv (/var/log/secure,/var/log/auth.log) et cron (/var/log/cron,/var/log/cron.log) : lignes RFC3164, service dérivé du program tag (fallback par groupe de fichier : authpriv →auth, cron →cron). Les lignes taggéessysmonsont exclues (prises en charge par le collecteur dédié).
Les features sysmon et ebpf ajoutent un collecteur dédié :
- Sysmon eBPF (feature
ebpf) — probes Aya embarquées (sigmacatch/ebpf, nightly+bpf-linker, exclue du workspace) couvrant EID 1 process_create, EID 3 network_connect, EID 5 process_terminate, EID 11 file_create et l'extension DNS (EID 22) : événements rendus en XML Sysmon identique au chemin syslog puis injectés via le même pipeline (inject_logsource_fields_for). Prérequis runtime : root ou CAP_BPF+CAP_PERFMON (refus de démarrer sinon —linux.rsbail) + kernel avec BTF. Le hachage SHA256 des images est calculé userspace avec cache (chemin,mtime). Un échec de chargement des probes au runtime avertit (warn!) et continue sans source sysmon ; seul un build avec les featuresebpfetsysmonretombe sur le tail Sysmon-for-Linux. - Sysmon-for-Linux tail (feature
sysmon) — lignes du syslog central taggéessysmondont le corps est XML winevt (parse_winevt_xml/_raw) → logsourceproduct:linux, service:sysmonvia le channelLinux-Sysmon/Operational. Lecture seule, pas de dépendance Aya.
Format de régression : DataFormat::Log.
Chaque input définit son propre CollectorKind
(name()/mode()/channels()/build()/regression_format()/live_capture()) ; les
inputs Linux partagent un unique LinuxCollector défini dans linux.rs. Le format de
régression est choisi par regression_format() : DataFormat::Evtx pour les inputs
winevt/evtx, DataFormat::Log pour les inputs Linux. name() vaut sigmacatch pour
chaque input.
live_capture() est une propriété intrinsèque du collecteur, pas un flag CLI : elle vaut
true par défaut et n'est redéfinie que par l'input evtx (false). Les collecteurs
continus tournent en boucle infinie bornée par le stop-file ; un collecteur one-shot laisse
EventProducer::run() se terminer, le sender mpsc tombe, et run() sort quand rx.recv()
renvoie None.
tail.rs est le driver tail partagé des collecteurs fichier Linux (auditd, syslog builtin,
sysmon) : il possède le handle fichier, la boucle de poll 100 ms, la détection de rotation
(changement dev/ino → réouverture depuis offset 0) et le channel, et pilote un LineHandler
pur par collecteur. Chaque LineHandler transforme des lignes complètes en événements (auditd
groupe les records par identifiant d'événement et flush au changement de séquence ou sur poll idle ; syslog
émet un événement par ligne RFC3164 en excluant les lignes sysmon ; sysmon parse les corps XML
en sautant les tronqués). Gate par les features qui font du tail.
L'input evtx (feature evtx) est un CollectorKind avec live_capture() = false : il
passe par le même pipeline partagé run() que les collecteurs continus, en mode one-shot —
énumérer les fichiers .evtx, parser chaque événement (evtx_reader), alimenter la
DetectionEngine, puis réutiliser la machinerie partagée SigmahqRegression + SigmaRepo
pour écrire les données de régression DataFormat::Evtx (toujours via le writer EVTX pur
Rust, jamais EvtExportLog) et les commit/push vers le fork. Comme EventProducer::run() se
termine quand tous les fichiers sont épuisés, le processus s'arrête de lui-même. Pas de
channels(), pas d'interval, pas de poller stop-file. Le champ EventRecordID est retiré par
événement (l'événement 4688 exige son absence pour déclencher la règle d'imagerie). Comme il est
100 % pur Rust, il se compile et tourne aussi sous Linux.
Graphe de dépendances¶
sigmacatch (package)
├── src/runner.rs (run<C: CollectorKind>, pipeline partagé + init tracing + module cli)
├── src/config.rs (Config, CliArgs)
├── src/rule/ (SigmahqRules : load/filter/remove_id)
├── src/detection/ (DetectionEngine : pipelines + bloom + LogSourceExtractor + resolve_channels)
├── src/regression/ (SigmahqRegression : skip set + génération données)
├── src/types.rs (Event, Alert, RegressionHeader, Product, EventProducer, parsing XML)
├── src/repo/ (SigmaRepo, wrapper grit-lib)
├── src/evtx_reader.rs (parse EVTX → Event)
└── serde (sérialisation JSON des sorties diagnostics)
regressiondata-check (deuxième binaire du package sigmacatch)
├── réutilise src/detection/ (DetectionEngine)
├── réutilise src/rule/ (SigmahqRules : load/filter)
├── réutilise src/regression/ (SigmahqRegression)
├── réutilise src/types.rs (Event)
├── réutilise src/evtx_reader.rs (parse EVTX → Event)
└── linux-audit-parser (parse records auditd → Event)
Le module evtx_reader dépend de types + la crate evtx. regressiondata-check
(validation de régression, cross-platform) réutilise les mêmes modules (detection,
rule, regression, types, evtx_reader) avec linux-audit-parser (auditd) selon le
LogType de chaque entrée. Les sous-commandes de diagnostic (cli.rs) font un parsing
manuel des arguments et utilisent serde pour leurs sorties JSON (toujours compilées).
Pipeline (runner partagé)¶
1. parse_args() + Config::load_with_cli("config.yaml", cli)
└── -n/--dry-run : chargement allégé (pas de validation git), aucun état sur disque
(ni config.yaml ni logs/), sortie après validation des règles + du moteur
2. setup_console() (Windows) ; logging::init du runner (&config, verbose) → tracing (stderr `error` par défaut, `info` avec `-v`, fichier debug)
3. ensure_dirs() → dossier repo sigma + logs/
4. SigmaRepo init : set_info_user/set_info_http|ssh (+ ensure_ssh_host_config si ssh+réseau),
set_signing_key (si ssh_key_path), set_git_operations(offline, contrib),
set_remote_url(fork) → set_working_branch(<branche>) → check_remote_working_branch()
— no-op complet en offline (pas de `.git` requis, fichiers locaux tels quels)
5. SigmahqRegression::new() → set_author/max_failed_cycles/format(kind)/add_json_output
└── existing_rules = regression.get_sigma_id() ∪ sigma_repo.pending_regression_rule_ids()
(branches remote sigmacatch/* en attente ; scan sauté en offline) → HashSet<Uuid> (vide avec --all-rules)
6. SigmahqRules::new() → chargement + dédupe ; remove_id(existing_rules)
└── filter(SigmaFilterConfig { product, min_status, min_level, author, max_rule_size }) ; 0 règles → bail
7. custom_map = load_custom_channel_mapping("custom_channels.yaml")
8. DetectionEngine::new(&rules) (pipelines + bloom + LogSourceExtractor)
└── cycle_channels = kind.channels(&engine, &custom_map)
├── Some(vide) (winevt sans channel résolu) → warn + return
└── None (linux, evtx) → pas de résolution de channels
9. Handlers d'arrêt (watch channel) : Ctrl+C, plus poller stop-file (500 ms) si live_capture()
; output_base = <sigma_repo_path>/regression_data ; clean_partial_artifacts()
10. collector = kind.build(&cycle_channels) → tokio::spawn(collector.run(tx, stop))
├── sigmacatch --evtx (one-shot) → EvtxCollector.run(tx, stop) : énumère les fichiers, envoie chaque événement parsé,
│ se termine à épuisement → sender tombé
├── sigmacatch (winevt, Windows) → EventCollector::new(cycle_channels).run(tx, stop)
└── sigmacatch (Linux) → MultiCollector (tous les tails compilés et disponibles en parallèle, rotation détectée)
11. Boucle : tokio::select!
├── shutdown_rx (Ctrl+C, ou stop file / --max-runs atteint si live_capture()) → break
├── événement depuis rx → engine.put_events(vec![event])
├── generate_interval (30s, live_capture seulement) → spawn_blocking(process_and_generate) → upload_regression() si fichiers
└── [one-shot seulement] rx.recv() → None (sender tombé, collecteur terminé) → break
12. Flush final : arrêt collector (timeout 10s, abort sinon) → drain des événements restants (timeout 5s)
→ process_and_generate() → upload_regression() (commit par règle) → push unique si contrib
— one-shot propage l'erreur de l'upload final (code de sortie non nul) ; live mode log et continue
process_and_generate() :
engine.process_events() → get_alerts()
├── alerts vides → return (pas de log "evaluation complete")
├── regression.begin_cycle() ; log stats (events_processed, matches_found, alerts_count)
└── pour chaque alert : regression.add(&alert) → Option<Vec<String>>
├── None si règle déjà retirée / pas d'id valide / info.yml existant
└── Some(files) → écrit les fichiers + regression_tests_path + retire la règle
└── retired_ids += regression.take_blocked() (règles bloquées après N cycles d'échec)
└── règles retirées → rules.remove_id() → engine.reload_rules() (un seul reload batch)
↓
retourne (Pipeline restitué, batches: Vec<(Uuid, Vec<String>)>)
↓
upload_regression() → upload_rule_batches() (dans sigmacatch::repo)
├── un commit par règle : "🧪 test: add regression data for rule {rule_id}"
├── échec commit/push → rollback de la branche locale vers le tip pré-batch
└── UN SEUL push si git.contrib: true (sinon commits locaux) → message PR
Toute la génération tourne en spawn_blocking (état Pipeline déplacé puis restitué) —
les retries EvtExportLog ne gèlent jamais la collecte (les événements continuent à
s'accumuler dans le canal mpsc).
Variante one-shot : --evtx¶
L'input evtx (live_capture() = false) réutilise le pipeline partagé run() : l'énumération
des fichiers .evtx s'épuise d'elle-même (sender mpsc tombé → rx.recv() renvoie None), sans
channels(), sans generate_interval ni poller stop-file, et --max-runs est ignoré
(sémantique -r 0). Ctrl+C avorte toujours la passe : tout cycle en vol est abandonné et le
push de ce qui n'a pas encore été commité est sauté.
Notes de conception¶
- Stop file :
config.stop_file(défaut.sigmacatch.stop) est pollé toutes les 500 ms ; si le fichier existe, la collecte s'arrête proprement (drain + flush + commit du cycle en cours) — c'est le signal pour terminer un run continu (-r 0) sans kill dur qui perdrait les données de régression du cycle en cours. - Skip set =
HashSet<Uuid>depuisSigmahqRegression::get_sigma_id()(info.yml existants + données valides) ∪SigmaRepo::pending_regression_rule_ids()(arbres des branches remotesigmacatch/*: PR en attente non mergés — une VM fraîche ne recapture pas leurs données), construit une seule fois au démarrage.--all-rulesle désactive. Après génération, une règle est retirée et le moteur est rechargé en un seul batch (engine.reload_rules). Les règles dont les données commitées sont invalides (EVTX cassé / texte vide) sont exclues du skip set → régénérées. - Output toujours dans le repo sigma :
<sigma_repo_path>/regression_data/<rule_rel_path>/(info.yml+ fichier de données.evtx/.log,.jsonoptionnel), commité sur le fork sicontrib(commits locaux sinon). Le chemin de la règle est reproduit sousregression_data/(par rapport ausigma_repo_pathconfiguré, relatif ou absolu) — le commit par règle embarque aussi la règle mise à jour avecregression_tests_path: regression_data/<rule_rel_path>/info.yml. - Collecteur observable : le collecteur exclut une fois pour toutes les channels
inexistants dès
ERROR_EVT_CHANNEL_NOT_FOUND(un seulerror!) ; chaque channel vivant journalise « initial query OK » puis un heartbeat « still alive » (60s) ;warn!quand des événements sont récupérés mais perdus au rendu/parsing. Les collecteurs Linux détectent la rotation du fichier tailé (changement d'inode) et rouvrent le fichier ; le collecteur syslog exclut les lignes taggéessysmonpour éviter les doubles événements (pris en charge par le collecteur sysmon dédié).