# rsigma-parser

[![CI](https://github.com/timescale/rsigma/actions/workflows/ci.yml/badge.svg)](https://github.com/timescale/rsigma/actions/workflows/ci.yml)

`rsigma-parser` is a parser for [Sigma](https://github.com/SigmaHQ/sigma) detection rules, correlations, and filters. It parses Sigma YAML into a strongly-typed AST covering the full Sigma 2.0 specification, and includes a 74-rule linter derived from the Sigma v2.1.0 spec.

This library is part of [rsigma].

## Public API

### Parsing

| Function | Description |
|----------|-------------|
| `parse_sigma_yaml(yaml: &str)` | Parse a multi-document YAML string into a `SigmaCollection` |
| `parse_sigma_file(path: &Path)` | Parse a single YAML file |
| `parse_sigma_directory(dir: &Path)` | Recursively parse all `.yml`/`.yaml` files in a directory |
| `parse_condition(input: &str)` | Parse a condition expression string into a `ConditionExpr` |
| `parse_field_spec(key: &str)` | Parse a field specification like `"CommandLine\|contains\|all"` into a `FieldSpec` |

### Linting

| Function | Description |
|----------|-------------|
| `lint_yaml_value(value: &Value)` | Lint a single YAML document value (auto-detects document type) |
| `lint_yaml_str(text: &str)` | Lint raw YAML string with source span resolution |
| `lint_yaml_str_with_config(text: &str, config: &LintConfig)` | Lint with config-based suppression |
| `lint_yaml_file(path: &Path)` | Lint all documents in a file |
| `lint_yaml_file_with_config(path: &Path, config: &LintConfig)` | Lint a file with config |
| `lint_yaml_directory(dir: &Path)` | Recursively lint all `.yml`/`.yaml` in a directory |
| `lint_yaml_directory_with_config(dir: &Path, config: &LintConfig)` | Lint a directory with config |
| `parse_inline_suppressions(text: &str)` | Parse `# rsigma-disable` comments from YAML text |
| `apply_suppressions(warnings, config, inline)` | Filter and override warnings using config and inline suppressions |

### Specification Version

A document may declare the Sigma specification major it targets via the top-level `sigma-version` attribute; version-sensitive syntax (array-matching brackets) is gated on it.

| Function / Constant | Description |
|---------------------|-------------|
| `version::resolve_major(declared)` | Resolve a `sigma-version` major, defaulting to the fixed floor when absent |
| `version::array_matching_enabled(declared)` | Whether array-matching bracket selectors are active at the resolved major |
| `version::is_unsupported(declared)` | Whether a declared major exceeds what this build supports |
| `SPEC_VERSION_FLOOR` / `SPEC_VERSION_ARRAY_MATCHING` / `SPEC_VERSION_SUPPORTED` | The floor (2), array-matching (3), and highest supported majors |

### Auto-Fix Types

Each `LintWarning` can carry an optional `Fix` describing how to automatically correct the issue.

| Type | Description |
|------|-------------|
| `Fix` | A suggested fix: title, disposition (`Safe`/`Unsafe`), and ordered patches |
| `FixDisposition` | `Safe` (no semantic change) or `Unsafe` (may change meaning) |
| `FixPatch` | A single edit: `ReplaceValue`, `ReplaceKey`, or `Remove` with a JSON-pointer path |

The `lint::fix` module turns these into concrete edits on a YAML source string while preserving comments and formatting:

| Function / Type | Description |
|-----------------|-------------|
| `lint::fix::apply_fixes_to_source(source: &str, &[&LintWarning]) -> SourceFixOutcome` | Apply every `Safe` fix to a YAML string; returns the rewritten source plus applied/failed counts |
| `lint::fix::json_pointer_to_route` / `apply_single_fix_patch` / `apply_rename_key` | Lower-level helpers over `yamlpath`/`yamlpatch` |
| `SourceFixOutcome` | `{ fixed_source, applied, failed }` |

The CLI (`rule lint --fix`), the LSP, and the MCP server's `fix_rules` tool all share this one implementation.

### Lint Catalogue

`lint::catalogue::catalogue() -> Vec<LintRuleInfo>` returns programmatic metadata for every lint rule (stable id, default severity, fix disposition, one-line description). It is generated from a single list whose exhaustive match makes adding a `LintRule` variant without a catalogue entry a compile error.

### Reference Data

`reference::MODIFIERS` and `reference::MITRE_TACTICS` are `(name, description)` tables for the field modifiers and MITRE ATT&CK tactics, shared with the LSP (hover/completion) and the MCP server (reference resources).

### Value Types

| Type/Function | Description |
|---------------|-------------|
| `SigmaString::new(s: &str)` | Parse a string with wildcard interpretation (`*`, `?`, `\` escape) |
| `SigmaString::from_raw(s: &str)` | Create from raw string (no wildcard parsing; used for `\|re` modifier) |
| `SigmaString::is_plain()` | Returns `true` if no wildcards |
| `SigmaString::contains_wildcards()` | Returns `true` if any wildcard present |
| `SigmaString::as_plain()` | Get plain string; `None` if wildcards present |
| `SigmaValue::from_yaml(v: &Value)` | Create from a YAML value |
| `Timespan::parse(s: &str)` | Parse timespan like `1h`, `15s`, `7d` |

## Parsing

- **Multi-document YAML**: `---` separators, `action: global/reset/repeat` for rule templates
- **Condition expressions**: PEG grammar (pest) with Pratt parsing and correct operator precedence (`NOT` > `AND` > `OR`). Supports `and`, `or`, `not`, `1 of`, `all of`, `any of`, `N of`, parenthesized groups, wildcard patterns — `them` excludes `_`-prefixed identifiers per spec
- **Value types**: strings with wildcards (`*`, `?`), escape sequences (`\*`, `\?`, `\\`), integers, floats, booleans, null
- **Timespan parsing**: `15s`, `30m`, `1h`, `7d`, `1w`, `1M`, `1y`
- **Logsource**: `category`, `product`, `service`, `definition`, custom fields

### Multi-Document Behavior

1. `yaml_serde::Deserializer` yields documents separated by `---`.
2. Non-mapping documents are skipped; errors are accumulated in `collection.errors`.
3. YAML parse errors stop iteration (the deserializer may not recover from malformed input).
4. **Collection actions:**
   - `action: global` — store document as a template; remove `action` key; do not produce a rule.
   - `action: reset` — clear the global template.
   - `action: repeat` — merge current document onto the previous document; apply global template if present; parse the merged result. Error if no previous document exists.
5. **Merge order:** For normal documents: `merged = deep_merge(global, value)`. For repeat: `merged = deep_merge(global, deep_merge(previous, repeat_doc))`.
6. **`deep_merge`:** Recursive. Source mappings override destination keys; non-mapping source replaces destination entirely.
7. **Previous tracking:** Updated after each non-action document and after each repeat. Repeat chains from the *last* document, not the original.

### SigmaString Escape Semantics

| Input | Parsed as |
|-------|-----------|
| `\*` | literal `*` (not a wildcard) |
| `\?` | literal `?` (not a wildcard) |
| `\\` | literal `\` |
| `\W` (non-special) | literal `\W` (both characters kept) |

Backslash only consumes itself when followed by `*`, `?`, or `\`. This preserves Windows paths like `\Windows\System32`.

### Condition Expression Grammar (PEG)

The full PEG grammar is defined in [`src/sigma.pest`](src/sigma.pest). It implements the [Sigma condition expression syntax](https://sigmahq.io/docs/basics/conditions.html) using a Pratt parser with `not` > `and` > `or` precedence.

**Parsing quirks:**

- `!ident_char` lookahead ensures `and_filter` parses as a single identifier, not `and` + `filter`.
- `1 of` and `any of` both map to `Quantifier::Any`.
- Nested same-type binary ops are flattened: `a and b and c` becomes `And([a, b, c])`, not `And(a, And(b, c))`.

### Operator Precedence

| Precedence (highest first) | Operator | Associativity |
|---------------------------|----------|---------------|
| 1 | `not` (prefix) | — |
| 2 | `and` (infix) | Left |
| 3 | `or` (infix) | Left |

`a or not b and c` parses as `a or ((not b) and c)`.

## AST Types

### Core Types

| Type | Description |
|------|-------------|
| `SigmaCollection` | Collection of rules, correlations, filters, and errors |
| `SigmaRule` | A parsed detection rule with metadata, an optional `sigma_version` (the targeted spec major), logsource, and detections |
| `CorrelationRule` | A correlation rule with type, referenced rules, timespan, window mode, and conditions |
| `FilterRule` | A filter rule that injects `AND NOT` conditions into referenced rules |
| `Detections` | Named detections, condition expressions, and optional timeframe |
| `Detection` | `AllOf` (AND-linked items), `AnyOf` (OR-linked), `Keywords` (plain values), `ArrayMatch` (object-scope array block), `And`, or `Conditional` (extended array block) |
| `FieldSpec` | Field name + modifier chain; `name` is `None` for keyword detections |
| `ConditionExpr` | `And`, `Or`, `Not`, `Identifier`, or `Selector` with quantifier and pattern |
| `SigmaValue` | `String`, `Integer`, `Float`, `Bool`, or `Null` |
| `SigmaString` | String with wildcard parts (`Plain` text + `WildcardMulti`/`WildcardSingle`) |

### Enums

| Enum | Variants |
|------|----------|
| `Status` | `Stable`, `Test`, `Experimental`, `Deprecated`, `Unsupported` |
| `Level` | `Informational`, `Low`, `Medium`, `High`, `Critical` |
| `RelationType` | `Derived`, `Obsolete`, `Merged`, `Renamed`, `Similar` |
| `Quantifier` | `Any`, `All`, `Count(u64)` |
| `ArrayQuantifier` | `Any`, `All`, `AllOrEmpty`, `None` (array object-scope quantifiers) |
| `SelectorPattern` | `Them`, `Pattern(String)` |
| `CorrelationType` | `EventCount`, `ValueCount`, `Temporal`, `TemporalOrdered`, `ValueSum`, `ValueAvg`, `ValuePercentile`, `ValueMedian` |

### Detection Parsing

- **YAML mapping** → `Detection::AllOf` (AND-linked items)
- **YAML list of mappings** → `Detection::AnyOf` (OR-linked)
- **YAML list of plain values** → `Detection::Keywords` (keyword search across all fields)
- **Condition as list** (`condition: [s1, s2]`) → multiple `ConditionExpr` parsed independently
- **Empty field name** (`parse_field_spec("")`) → `FieldSpec { name: None, modifiers: [] }` (keyword)
- **Array selectors** (`field[any]`, `field[all]`, `field[N]`) → desugared on the field path only when the document targets `sigma-version: 3` or higher; below that they are literal field-name characters. See the [Array Matching guide](https://timescale.github.io/rsigma/guide/array-matching/)

## Field Modifiers (30)

The parser recognizes 30 modifier variants, some with aliases:

| Category | Modifiers | Aliases |
|----------|-----------|---------|
| String matching | `contains`, `startswith`, `endswith` | — |
| Value linking | `all` | — |
| Encoding | `base64`, `base64offset`, `wide`, `utf16be`, `utf16`, `windash` | `utf16le` → `wide` |
| Pattern | `re`, `cidr` | — |
| Case | `cased` | — |
| Existence | `exists` | — |
| Placeholder | `expand` | — |
| Field reference | `fieldref` | — |
| Numeric comparison | `gt`, `gte`, `lt`, `lte`, `neq` | — |
| Regex flags | `i`, `m`, `s` | `ignorecase` → `i`, `multiline` → `m`, `dotall` → `s` |
| Timestamp parts | `minute`, `hour`, `day`, `week`, `month`, `year` | — |

When the `re` modifier is present, string values are parsed with `SigmaValue::from_raw_string` (no wildcard interpretation).

## Correlation Rules (8 types)

| Type | Description |
|------|-------------|
| `event_count` | Count matching events per group key |
| `value_count` | Count distinct field values per group key |
| `temporal` | Require multiple rules to fire in the same window |
| `temporal_ordered` | Same as temporal, but rules must fire in order |
| `value_sum` | Sum a numeric field across events |
| `value_avg` | Average a numeric field across events |
| `value_percentile` | Compute a percentile of a numeric field |
| `value_median` | Compute the median of a numeric field |

### Correlation Conditions

- **Threshold (mapping):** `{ gte: 100 }` or `{ gt: 10, lte: 100, field: "TargetUser" }`. Operators: `lt`, `lte`, `gt`, `gte`, `eq`, `neq`. Values must be numeric.
- **Extended (string):** `"rule_a and rule_b"` for temporal types — parsed as a boolean expression over rule references.
- **Default (temporal, no condition):** `Threshold { predicates: [(Gte, 1)], field: None }`.
- **Timeframe/timespan:** The parser accepts both `timeframe` and `timespan` keys.
- **Custom attributes:** Both detection and correlation rules expose a unified `custom_attributes` map (`HashMap<String, yaml_serde::Value>`). It merges (a) any arbitrary top-level YAML key that is not part of the Sigma schema, (b) entries of the optional top-level `custom_attributes:` mapping (explicit block wins over arbitrary keys of the same name), and (c) values set by pipeline `SetCustomAttribute` transformations (applied last, last-write-wins). Engines read `rsigma.*` extensions (`rsigma.suppress`, `rsigma.action`, `rsigma.correlation_event_mode`, etc.) from this map.

### Window Modes

Correlation rules accept an optional `window` attribute that controls how `timespan` is anchored to the event stream, plus a `gap` for session windows:

- `sliding` (default): trailing per-event window `(t - timespan, t]`. Omitting `window` is equivalent and preserves existing behavior.
- `tumbling`: fixed, boundary-aligned, non-overlapping buckets of size `timespan`.
- `session`: dynamic window that extends while consecutive in-group events stay within `gap`, capped by `timespan` as the maximum total span. `gap` is required for `session` and must not be set for the other modes.

This is an rsigma extension (a portable-spec version was declined upstream), so the primary spelling is the `rsigma.*` engine-extension namespace, with the bare keys kept as aliases:

- Primary: top-level `rsigma.window` / `rsigma.gap` (alongside `rsigma.suppress`, `rsigma.action`).
- Alias: nested `correlation.window` / `correlation.gap`.

The `rsigma.*` spelling wins when both are present. Either spelling maps to `CorrelationRule::window` (a `WindowMode` of `Sliding`/`Tumbling`/`Session`) and `CorrelationRule::gap` (an `Option<Timespan>`), so consumers read one place regardless of how the rule was written.

## Filter Rules

Filter rules inject exclusion conditions into referenced detection rules — enables centralized tuning without modifying original rule files. See the [Sigma Filters Specification](https://sigmahq.io/sigma-specification/specification/sigma-filters-specification.html) for the full standard.

Per the spec, `selection`, `condition`, and `rules` all live inside the `filter` section:

```yaml
title: Exclude Admin Users
logsource:
    category: process_creation
    product: windows
filter:
    rules:
        - <rule-id>
    selection:
        User|startswith: 'adm_'
    condition: selection
```

## Timespan Parsing

| Unit | Suffix | Multiplier (seconds) |
|------|--------|----------------------|
| Second | `s` | 1 |
| Minute | `m` | 60 |
| Hour | `h` | 3,600 |
| Day | `d` | 86,400 |
| Week | `w` | 604,800 |
| Month | `M` (uppercase) | 2,629,746 (~30.44 days) |
| Year | `y` | 31,556,952 (~365.25 days) |

The string must be at least 2 characters (e.g. `1h`). The last character is the unit; the prefix must be a positive integer.

## Linter (70 rules)

70 built-in lint rules (plus the reserved `empty_filter_rules`, declared but not emitted) derived from the Sigma v2.1.0 specification. Four severity levels: **Error** (spec violation), **Warning** (best-practice issue), **Info** (soft suggestion), **Hint** (stylistic). Info/Hint findings don't cause lint failure.

The linter operates on raw YAML values to catch issues the parser silently ignores.

### Infrastructure (4)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `yaml_parse_error` | Error | | YAML parse failure |
| `not_a_mapping` | Error | | Document is not a YAML mapping |
| `file_read_error` | Error | | Cannot read file |
| `schema_violation` | Error | | JSON schema validation failure (optional) |

### Shared Metadata (16)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `missing_title` | Error | | No `title` field |
| `empty_title` | Error | | `title` is empty or whitespace |
| `title_too_long` | Warning | | `title` exceeds 256 characters |
| `missing_description` | Info | | No `description` |
| `missing_author` | Info | | No `author` |
| `invalid_id` | Warning | | `id` not a valid UUID (8-4-4-4-12 hex) |
| `invalid_status` | Error | Yes | `status` not in `stable`/`test`/`experimental`/`deprecated`/`unsupported` |
| `missing_level` | Warning | | No `level` (detection rules) |
| `invalid_level` | Error | Yes | `level` not in `informational`/`low`/`medium`/`high`/`critical` |
| `invalid_date` | Error | | `date` not `YYYY-MM-DD` with valid day-of-month |
| `invalid_modified` | Error | | `modified` not `YYYY-MM-DD` |
| `modified_before_date` | Warning | | `modified` is earlier than `date` |
| `description_too_long` | Warning | | `description` exceeds 65,535 characters |
| `name_too_long` | Warning | | `name` exceeds 256 characters |
| `taxonomy_too_long` | Warning | | `taxonomy` exceeds 256 characters |
| `non_lowercase_key` | Warning | Yes | Top-level key is not lowercase |

### Detection Rules (18)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `missing_logsource` | Error | | No `logsource` |
| `missing_detection` | Error | | No `detection` |
| `missing_condition` | Error | | No `condition` in detection |
| `empty_detection` | Warning | | No named search identifiers |
| `invalid_related_type` | Error | | `related[].type` not in `derived`/`obsolete`/`merged`/`renamed`/`similar` |
| `invalid_related_id` | Warning | | `related[].id` not a valid UUID |
| `related_missing_required` | Error | | `related[]` missing `id` or `type` |
| `deprecated_without_related` | Warning | | `status: deprecated` but no `related` |
| `invalid_tag` | Warning | | Tag doesn't match `^[a-z0-9_-]+\.[a-z0-9._-]+$` |
| `unknown_tag_namespace` | Warning | | Tag namespace not in `attack`/`car`/`cve`/`d3fend`/`detection`/`stp`/`tlp` |
| `duplicate_tags` | Warning | Yes | Duplicate tag |
| `duplicate_references` | Warning | Yes | Duplicate reference URL |
| `duplicate_fields` | Warning | Yes | Duplicate field name |
| `falsepositive_too_short` | Warning | | `falsepositives` entry under 2 characters |
| `scope_too_short` | Warning | | `scope` entry under 2 characters |
| `logsource_value_not_lowercase` | Warning | Yes | Logsource `category`/`product`/`service` not lowercase |
| `condition_references_unknown` | Error | | Condition references non-existent detection identifier |
| `deprecated_aggregation_syntax` | Warning | | Condition uses deprecated Sigma v1.x pipe-aggregation syntax (`\| count/min/max/avg/sum/near`); use a correlation rule instead |

### Correlation Rules (17)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `missing_correlation` | Error | | No `correlation` or not a mapping |
| `missing_correlation_type` | Error | | No `correlation.type` |
| `invalid_correlation_type` | Error | | Type not a recognized correlation type |
| `missing_correlation_rules` | Error | | No `correlation.rules` |
| `empty_correlation_rules` | Warning | | `correlation.rules` is empty |
| `missing_correlation_timespan` | Error | | No `correlation.timespan` or `correlation.timeframe` |
| `invalid_timespan_format` | Error | | Timespan format invalid |
| `invalid_window_mode` | Error | | `correlation.window` not `sliding`/`tumbling`/`session` |
| `missing_session_gap` | Error | | `window: session` without a `gap` |
| `gap_without_session` | Error | | `gap` set without `window: session` |
| `invalid_gap_format` | Error | | `gap` format invalid |
| `missing_group_by` | Error | | No `correlation.group-by` |
| `missing_correlation_condition` | Error | | Non-temporal type without condition |
| `missing_condition_field` | Error | | `value_count`/`value_sum`/`value_avg`/`value_percentile` without `condition.field` |
| `invalid_condition_operator` | Error | | Operator not in `gt`/`gte`/`lt`/`lte`/`eq`/`neq` |
| `condition_value_not_numeric` | Error | | Condition value not numeric |
| `generate_not_boolean` | Error | | `generate` is not a boolean |

### Filter Rules (8 IDs, 7 emitted)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `missing_filter` | Error | | No `filter` or not a mapping |
| `missing_filter_rules` | Error | | No `filter.rules` |
| `empty_filter_rules` | reserved | | Declared in the enum but not emitted in production today |
| `missing_filter_selection` | Error | | No `filter.selection` |
| `missing_filter_condition` | Error | | No `filter.condition` |
| `filter_has_level` | Warning | Yes | Filter has `level` (not applicable) |
| `filter_has_status` | Warning | Yes | Filter has `status` (not applicable) |
| `missing_filter_logsource` | Warning | | No `logsource` |

### Detection Logic (8)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `null_in_value_list` | Warning | | `null` mixed with other values in a list |
| `single_value_all_modifier` | Warning | Yes | `\|all` with a single value |
| `all_with_re` | Warning | Yes | `\|all` and `\|re` combined |
| `incompatible_modifiers` | Warning | | Incompatible modifier combination (e.g. `contains\|startswith`, `re\|contains`, `gt\|contains`, regex flags without `re`) |
| `empty_value_list` | Warning | | Empty value list |
| `wildcard_only_value` | Warning | Yes | Lone `*` value (suggests `\|exists: true` instead) |
| `flattened_array_correlation` | Warning | | Sibling keys share a quantified array prefix (e.g. `connections[any].protocol` and `connections[any].ip`); they open independent scopes and don't correlate on one element, so an object-scope block is needed |
| `unknown_key` | Info | Yes | Top-level key likely a typo of a known key (edit distance ≤ 2); custom fields are allowed per the Sigma spec |

### Specification Version and References (4)

| Rule | Severity | Fix | Trigger |
|------|----------|-----|---------|
| `unsupported_sigma_version` | Error | | `sigma-version` declares a major newer than this build supports |
| `array_matching_without_version` | Warning | | Array-matching bracket syntax used below `sigma-version: 3` (brackets read literally) |
| `sigma_version_mismatch` | Warning | | A correlation/filter and a rule it references declare different majors |
| `unknown_rule_reference` | Warning | | A `correlation.rules`/`filter.rules` entry resolves to no rule (directory linting only, where the index is complete) |

### Rule Suppression

Three-tier system to disable or override lint rules:

- **CLI**: `--disable rule1,rule2` suppresses specific rules globally
- **CLI**: `--exclude "pattern"` excludes paths matching glob patterns (relative to lint root, repeatable)
- **Config file**: `.rsigma-lint.yml` (or `.rsigma-lint.yaml`) with `disabled_rules`, `severity_overrides`, and `exclude`, auto-discovered by walking ancestor directories from the target path upward
- **Inline comments**: `# rsigma-disable`, `# rsigma-disable rule1, rule2`, `# rsigma-disable-next-line`, `# rsigma-disable-next-line rule1, rule2`

```yaml
# .rsigma-lint.yml
disabled_rules:
  - missing_description
  - missing_author
severity_overrides:
  title_too_long: info
exclude:
  - "config/**"
  - "**/unsupported/**"
```

**Suppression order:** Excluded paths are skipped during directory traversal. Remaining warnings are filtered by `disabled_rules`, then by inline suppressions, then `severity_overrides` are applied.

Inline `#` inside quoted YAML strings is not treated as a comment.

### Optional Schema Validation

The `schema_violation` lint rule optionally validates rules against a JSON schema. The schema can be the official Sigma schema (downloaded and cached for 7 days, with offline fallback to stale cache) or a local file.

## Directory Parsing and Linting

- **`parse_sigma_directory`:** Recursively walks directories; only processes `.yml`/`.yaml` files. File-level parse errors are accumulated in `collection.errors` (not fatal). Sub-collections (rules, correlations, filters) are merged.
- **`lint_yaml_directory`:** Skips hidden directories (`.` prefix). Canonicalizes paths to detect symlink cycles. Sorts entries by path for deterministic output. Runs a two-pass directory-global rule index so cross-document reference checks (`sigma_version_mismatch`, `unknown_rule_reference`) resolve rules (by `id` or `name`) across sibling files.

## Error Types

| Error | When |
|-------|------|
| `Yaml` | yaml_serde parse failure |
| `Condition` | Condition expression parse failure (PEG/Pratt); carries optional `SourceLocation` with line/column |
| `UnknownModifier` | Unknown modifier in field spec |
| `NotIsNotAModifier` | The literal string `\|not` was used as a modifier; Sigma expresses negation at the condition level (`condition: not selection`) or via `\|neq` for inequality. Surfaced with guidance on how to rewrite the rule |
| `InvalidFieldSpec` | Invalid field specification |
| `InvalidRule` | Document not a mapping, or invalid structure |
| `MissingField` | Required field missing (e.g. `title`, `detection`) |
| `InvalidDetection` | Detection section invalid |
| `InvalidCorrelation` | Correlation rule invalid |
| `InvalidTimespan` | Timespan string invalid (wrong format, unknown unit) |
| `InvalidValue` | Invalid value in detection |
| `InvalidAction` | Unknown collection action |
| `Io` | File read error |

## Compatibility

Tested against the [SigmaHQ/sigma](https://github.com/SigmaHQ/sigma) rule repository:

| Corpus | Rules Parsed | Errors |
|--------|-------------|--------|
| `rules/` | 3,110 | 0 |
| `rules-emerging-threats/` | 436 | 0 |
| `rules-threat-hunting/` | 133 | 0 |
| `rules-compliance/` | 3 | 0 |
| `rules-placeholder/` | 14 | 0 |
| `unsupported/` | 31 | 58 (deprecated pipe syntax) |
| `deprecated/` | 165 | 1 (deprecated pipe syntax) |
| **Total** | **3,892** | **0 real errors** |

The deprecated pipe aggregation syntax (`selection | count(field) by field > N`) is intentionally rejected, matching [pySigma](https://github.com/SigmaHQ/pySigma) behavior.

## Usage

```rust
use rsigma_parser::{parse_sigma_yaml, parse_condition};

let collection = parse_sigma_yaml(yaml).unwrap();
assert_eq!(collection.rules[0].title, "Detect Whoami");

// Parse a condition expression directly
let expr = parse_condition("selection1 and not filter").unwrap();
```

## Benchmarks

Criterion.rs benchmarks with synthetic rules (Apple M-series, single-threaded):

| Scenario | Time |
|----------|------|
| 1 rule | 11.7 us |
| 100 rules | 1.1 ms |
| 1,000 rules | 11.1 ms |
| Complex condition (8 selections, nested booleans) | 23.2 us |

```bash
cargo bench --bench parse
```

## License

MIT License.

[rsigma]: https://github.com/timescale/rsigma
