Architecture Reference¶
Complete reference document — no need to read the source code.
1. Overview¶
Headless tool that captures real Windows events via Windows Event Log API (winevt), matches them against SigmaHQ rules, and outputs structured regression data.
Complete cycle (sequential): 1. Acquire SigmaHQ rules (grit-lib clone/pull) 2. Load Sigma engine (rsigma-eval) with logsource filter 3. Collect Event Log events (winevt, configured channels) 4. Evaluate events against all loaded rules 5. Generate regression output (JSON + EVTX template + info.yml)
Loop: every 30s continuously.
Platform: Windows (winevt + Sysmon required). Linux/macOS: no-op stub.
2. Source tree¶
src/
├── git.rs # grit-lib wrapper: clone/fetch/push/branch/commit/checkout (pure Rust, no git CLI)
├── main.rs # Pipeline + Stats + AggregatedRule
├── config.rs # YAML config (serde, Default) + LogConfig
├── contrib/ # SigmaHQ contribution workflow
│ ├── mod.rs # pub mod branch, commit, fork
│ ├── branch.rs # create_branch_name, create_branch, push_branch (fetch + safe push)
│ ├── commit.rs # commit_all_rules with 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 + rule evaluation (resolve_logsource from 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 → flat JSON)
└── 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-created on first run, auto-completed if the sigma section is missing):
author: "sigmacatch" # GitHub username for contrib workflow (must be changed)
email: "you@example.com" # required for git commits
github_token: "" # GitHub token (or GITHUB_TOKEN env var) — required for fork push
log:
level_file: "debug" # tracing file level
sigma:
min_status: "stable" # minimum status threshold (inclusive): unsupported < deprecated < experimental < test < stable
min_level: "critical" # minimum level threshold (inclusive): informational < low < medium < high < critical
Rule filtering: min_status and min_level are applied at load time. Rules whose status/level is below the threshold are excluded from the engine. Rules missing a status or level field are always accepted.
CLI flags: --author <name>
4. Pipeline detailed¶
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 — SigmaHQ Acquisition¶
SigmaRepo::new("sigma/")
↓
with_remote_url(fork URL)
↓
init() [async]
├── NO .git → grit-lib clone <remote_url> (fork or SigmaHQ)
└── .git EXISTS → set remote origin URL (if fork) → grit-lib fetch
└── failure → WARN, continue with existing rules
↓
create_branch("sigmacatch-contrib/YYYYMMDD_<author>")
└── create_branch() → grit-lib create ref + switch HEAD to branch (or switch if exists)
Stage 2 — Skip Set (existing rules)¶
build_skip_set(dirs, max_depth=64)
├── scan regression_data/rules/*/info.yml
├── scan sigma/regression_data/**/info.yml
│ (excludes rules-compliance/ and rules_compliance/)
├── for each info.yml:
│ ├── parse_info_yml() → rule_id (flexible: rule_metadata[0].id or root id)
│ ├── validate_rule_id() → UUID v4 or [a-z0-9_-]+
│ ├── validate_parent_folder() → parent folder == rule_id
│ └── validate_triplet() → info.yml + .json + .evtx
│ ├── complete → SkipSet::rules
│ └── incomplete → SkipSet::incomplete (listed, not blocking)
└── SkipSet { rules, incomplete, duplicates }
Rules with existing regression (complete or incomplete) → excluded from Sigma engine (sole allowed optimization).
Stage 3 — Rule loading¶
find_rules_dirs("sigma/")
→ Vec<PathBuf> (rules, rules-*, excludes rules-compliance)
↓
Sequential walk: collect all .yml / .yaml paths (cheap, no parse)
↓
Parallel parse + filter (rayon::par_iter, no shared state):
For each file:
├── parse_sigma_yaml() → Sigma rules
├── post-parse filter: rule.logsource.product == "windows" (or absent)
├── status/level filter: rule.status >= min_status AND rule.level >= min_level (config.sigma)
├── skip if rule_id in skip set
└── cross-file dedupe (first occurrence, walk order, wins)
↓
Sequential merge: accumulate surviving rules into ONE SigmaCollection,
then a SINGLE engine.add_collection() → rsigma-eval (one index rebuild)
↓
SigmaEngine in-memory (loaded rules + rule_paths)
Performance note:
rsigma-eval'sadd_collection()rebuilds the whole rule index on every call. The old per-fileadd_collectionwas O(N²) (N rebuilds of an N-rule index). Batching all surviving rules into one collection drops rule loading from ~33s to ~0.2s for the full SigmaHQ set (~2800 rules). Parsing itself runs in parallel viarayon.A startup rule table is displayed (rules loaded, rules skipped, active services/categories).
Status/level filtering: rules whose status < min_status or level < min_level are excluded (only if the field is present). Default min_status=stable, min_level=critical — very restrictive, loads only stable/critical rules.
Cycle — Collection¶
WinevtCollector (channels resolved from rules via resolve_channels_from_rules)
├── [Windows] EvtQueryW(channel="*") → EvtNext() → EvtRender() → XML
│ ├── One task per channel (tokio::spawn)
│ ├── XML → parse_event_xml() → WinevtEvent (carries pre-parsed event_json)
│ └── mpsc::channel → main loop
└── [non-Windows] Stub → Ok(vec![])
↓
Vec<WinevtEvent> { channel, event_id, raw_xml, event_json }
Cycle — Evaluation¶
For each SensorEvent:
├── channel → LogSource { product: "windows", service, category }
│ (mapping::resolve_logsource + channel/service priority)
├── event.event_json → flat serde_json::Value (pre-parsed by collector, XmlParser fallback if None)
├── engine.evaluate_event_with_logsource(event_value, logsource)
│ → Vec<EvaluationResult> (rsigma-eval)
└── For each match:
├── rule_id = match.header.rule_id
├── skip if rule_id in retired (already generated this cycle)
├── stats.matches_found++
└── aggregated[rule_id].events.push((event_value, raw_xml, provider))
Cycle — Generation¶
For each AggregatedRule in aggregated:
├── RegressionData::new(header, output_path, rule_rel_path, author)
├── exists() → skip if info.yml already exists
├── For each event: reg.add_event(event_json, raw_xml)
├── reg.generate()
│ ├── Write <rule_id>.json (first event, pretty-printed JSON)
│ ├── Write <rule_id>.evtx (EvtExportLog API, or .xml fallback)
│ └── Write info.yml (InfoYml::new + save)
├── Append "regression_tests_path: ..." to source rule YAML
└── retired.insert(rule_id)
Output:
<output_base>/<rule_rel_path>/
├── <rule_id>.json # first matching event (flat JSON)
├── <rule_id>.evtx # valid EVTX via EvtExportLog (or .xml fallback)
└── info.yml # SigmaHQ-compatible metadata
output_base = regression_data/ (project root)
- Contrib: output_base = sigma/regression_data/ (inside sigma repo, committed to fork)
Post-cycle¶
commit_all_rules() → batch grit-lib commit to sigma repo
Sleep 30s → loop
Ctrl+C → running.store(false) → break
push_branch() → fetch + compare + normal push to fork (skip if diverged)
Stats: { events_processed, matches_found, regression_data_generated }
5. Key data structures¶
WinevtEvent¶
WinevtEvent {
channel: String, // Event Log channel name
event_id: u32, // EventID
raw_xml: String, // Full event XML (Winevt format)
event_json: Option<serde_json::Value>, // pre-parsed JSON from 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, // flat JSON of the event
raw_xml: String, // full Winevt XML (for EVTX)
channel: String, // Event Log channel name
record_id: Option<u64>, // EventRecordID
provider: String, // ProviderName extracted from event (e.g., Microsoft-Windows-Sysmon)
}
6. Key modules¶
SigmaEngine (sigma/engine.rs)¶
- Loads rules from
rules*dirs - Sequential walk collects file paths; parse + post-parse filter run in parallel (
rayon::par_iter) - Post-parse filter:
rule.logsource.productfilters non-Windows rules afterparse_sigma_yaml - Status/level filter applied per-file during the parallel parse
- Skip-at-load = sole optimization (rules with existing
info.yml) - All surviving rules merged into one
SigmaCollection, compiled in a singleadd_collection()call (avoids O(N²) index rebuilds) LogSourcederived from Event Log channel + provider (resolve_logsource)evaluate_event_with_logsource()→Vec<EvaluationResult>via rsigma-eval
EVTX Writer (evtx/writer.rs)¶
- Windows:
EvtExportLogAPI (winevt) — re-queries the event by RecordID and exports to valid binary.evtx EvtExportLog(None, channel, query, path, EvtExportLogChannelPath | EvtExportLogOverwrite)- Produces valid binary EVTX readable by hayabusa/chainsaw
- Known limitation: race condition with log retention — if the event has been purged between collection and export, the call fails silently (
ERROR_EVT_QUERY_RESULT_STALE) - Fallback: writes raw XML as
.xml(not.evtx— avoids producing invalid binary that would break downstream tools) - Non-Windows: fallback raw XML write as
.xml - The companion
.jsonfile carries the actual data for Sigma matching
Logger (logger.rs)¶
- stderr layer:
infolevel, ANSI colors, filterable viaRUST_LOG - file layer:
debuglevel (configurable), daily rotation logs/sigmacatch.YYYY-MM-DD.log
7. Architectural invariants¶
| Invariant | Detail |
|---|---|
| 100% sequential pipeline | rules → engine → collect → match → generate |
| All in RAM | in-memory aggregation before writing, no DB |
| One run = complete cycle | no "just collect" or "just generate" mode |
| Collection via Winevt | EvtQueryW → EvtNext → EvtRender, no ETW, no ferrisetw |
| LogSource from channel | Event Log channel via resolve_logsource (channel > provider > default) |
| Skip-at-load sole optimization | rules with info.yml excluded from engine |
| One event per test | match_count: 1, first event only |
| Output mirrors source | regression_tests_path added to source YAML |
| EVTX via EvtExportLog | Re-queries event by RecordID → valid binary EVTX. .xml fallback on failure. |
8. Dependencies¶
| Dependency | Usage |
|---|---|---|
| grit-lib | all git operations (clone, fetch, push, branch, commit, checkout) via HTTP, pure Rust |
| reqwest (blocking) | HTTP client for git transport + fork detection (GitHub API) |
| 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 for info.yml |
| windows | Winevt API (cfg-gated: windows only, features: Foundation, Com, Console, EventLog, Threading, Security) |
Removed: 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¶
Config is auto-created on first run with defaults. Edit config.yaml before running.
11. Pipeline diagram¶
┌─────────────────────────────────────────────────────────────────────────┐
│ 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 — SIGMAHQ ACQUISITION │
│ SigmaRepo::new("sigma/") │
│ ├── [contrib] set fork remote URL │
│ ├── NO .git → grit-lib clone (fork or SigmaHQ) │
│ └── .git EXISTS → set 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> (rules with existing regression) │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ STAGE 3 — RULE LOADING │
│ find_rules_dirs("sigma/") → rules, rules-* (excl. rules-compliance) │
│ For each .yml: │
│ ├── parse_sigma_yaml() → Sigma rules │
│ ├── post-parse filter: logsource.product == "windows" (or absent) │
│ ├── skip if rule_id in skip set │
│ └── engine.add_collection() → rsigma-eval │
│ → SigmaEngine in-memory + rule_paths HashMap │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — COLLECTION (winevt) │
│ WinevtCollector (channels resolved from rules) │
│ ├── Windows: EvtQueryW → EvtNext → EvtRender → XML │
│ │ → parse_event_xml() → WinevtEvent │
│ └── non-Windows: Stub → Ok(vec![]) │
│ → Vec<WinevtEvent> { channel, event_id, raw_xml } │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — EVALUATION │
│ For each WinevtEvent: │
│ ├── event.event_json → flat JSON (pre-parsed, XmlParser fallback if None)
│ ├── channel → LogSource via resolve_logsource() │
│ └── engine.evaluate_event_with_logsource() │
│ → Vec<EvaluationResult> │
│ For each match: │
│ └── aggregated[rule_id].events.push((json, raw_xml, provider)) │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ CYCLE — GENERATION │
│ For each AggregatedRule: │
│ ├── skip if rule_id in retired or existing info.yml │
│ ├── RegressionData::new(output_base, ...) │
│ │ output_base = regression_data/ or sigma/regression_data/ │
│ ├── reg.generate() → triplet: │
│ │ ├── <rule_id>.json (first event, flat JSON) │
│ │ ├── <rule_id>.evtx (EvtExportLog, or .xml fallback) │
│ │ └── info.yml (UUID v4, SigmaHQ metadata) │
│ └── append "regression_tests_path" to source YAML │
│ ↓ │
│ commit_all_rules() → batch grit-lib commit to sigma repo │
└──────────────────────┬──────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────────┐
│ POST-CYCLE │
│ sleep 30s → loop │
│ Ctrl+C → running.store(false) → break │
│ push_branch() → fetch + compare + normal push to fork │
└─────────────────────────────────────────────────────────────────────────┘
Final output:
<output_base>/<rule_rel_path>/
├── <rule_id>.json # first matching event (flat JSON, Sigma keys)
├── <rule_id>.evtx # valid EVTX via EvtExportLog (or .xml fallback)
└── info.yml # type: evtx, rule_metadata, regression_tests_info
output_base = regression_data/ (project root)
- Contrib: output_base = sigma/regression_data/ (inside sigma repo, committed to fork)