Skip to content

v0.25.0

Latest

Choose a tag to compare

@mostafa mostafa released this 19 Oct 08:59
· 9 commits to main since this release
v0.25.0
457aaf9

TL;DR
RSigma v0.25.0 is the "secure defaults and trustworthy results" release: three security advisories are fixed, the daemon and MCP server refuse unsafe exposure unless told otherwise, rules match exactly what they say, and correlation state, alert pipelines, and sinks hold up under reloads, restarts, caps, and delivery failures.

  • Security: OTLP/HTTP gzip bodies are capped at 4 MiB after decompression (GHSA-vwg6-4q3h-8pvq, #597), the MCP server refuses unconfined writes and unconfined HTTP serving (GHSA-33jj-q567-34gj, #598), and event values are escaped in enricher and webhook templates (GHSA-3fh4-8r8j-8xw3, #599). Every build now refuses to serve an unauthenticated daemon API on a non-loopback address (#601), rustls picks up four RUSTSEC fixes (#577), and OTLP log decoding is fuzzed (#617).
  • Matching correctness: integer literals above 2^53 match exactly (#610), boolean keywords match the word (#609), partly case-insensitive regex pairs match the original text again (#608), time-part modifiers accept space-separated fractional timestamps (#611), match detail reports only the selections that decide the match (#612), 0 of selectors are rejected (#562), and value_percentile thresholds apply (#621).
  • State and delivery: hot-reload no longer rolls back correlation state (#604), state saves are one consistent, atomic cut (#606), restore and alert-pipeline reload enforce the dedup and incident caps (#605), entity-graph grouping no longer leaks join keys or drops live incidents (#603), the inhibit store is capped (#616), alerts dropped without a DLQ sink are logged (#615), OTLP sink exports time out (#600), and file and Unix socket sink retries no longer repeat lines (#621).
  • Diagnostics, API, and performance: parse warnings are reported apart from parse errors (#566), lint and the parser agree on modifiers and conditions (#565), pipeline errors get their own variant (#561), embedders can plug in custom logsource extractors (#578, based on work by John Sonnenschein in #500), engine eval -o writes results to a file (#576), match detail costs one evaluation pass (#613), percentile and median checks no longer sort the window (#619), and engines no longer retain rule HIR by default (#620).
  • Breaking: a non-loopback daemon API needs authentication or --allow-unauthenticated; mcp serve --http needs --rules-dir or --allow-unconfined-paths; event values in enricher and webhook URLs are encoded as one component each; HIR_SCHEMA_VERSION is 4 and save_hir requires HIR retention; SigmaCollection::diagnostics replaces errors; EvalError gains Pipeline and is #[non_exhaustive]; EventFilter takes the parsed line by value; numeric matchers and backend methods use NumericLiteral; incompatible_modifiers is a lint error; and cel 0.15 changes how some extract expressions evaluate. See the migration notes in the entries below.
  • Platform and docs: --no-default-features builds compile again (#560), the docs are checked against the code on every pull request (#558, #559), the PostgreSQL backend's JSONB and correlation code moves into dedicated modules with byte-identical SQL (#567, #568), the test backend is compared against pySigma 2.0.1 (#564), and dependencies are updated, including the docs site's sharp security fix (#556, #563, #577).

value_percentile thresholds apply, sink retries stop repeating lines, and smaller eval and runtime fixes (#621)

value_percentile correlations ignored their condition: the percentile was computed and the correlation fired whenever the window held a finite value, so a rule with lte: 50 fired on a window whose percentile was 50.5. The predicates now apply to the percentile as they do for the other numeric aggregates.

When a delivery to the file or unix:// sink was retried, the whole result was written again, duplicating lines that had already gone out, and a file line of 8 KiB or more cut off by a failed write was continued by the retry's first line with no newline in between. Each result's lines now go out in one write, a retry of the same delivery resumes after the lines already written, and a line a failed file write cut off is ended before anything else is appended. A Unix socket sink that loses its connection reconnects and resends from the first line that did not fully reach the old connection.

The remaining fixes:

  • Library users on a current-thread Tokio runtime no longer panic in block_in_place. The stdout and file sinks write inline there, and RuntimeEngine::load_rules returns an error instead of resolving dynamic pipeline sources, which it can only do on a multi-threaded runtime.
  • In the correlation engine, an event timestamp before 1970, a non-finite number, or a date past the representable range no longer counts as a timestamp. These used to clamp to 1970 or saturate to a far-future time; now the next timestamp field and then timestamp_fallback apply.
  • temporal_ordered checks the order with one pass over each referenced rule's hits instead of a recursive search whose cost grew as the product of the hit counts. Results are unchanged, including for rules referenced more than once.
  • A boolean rule value now matches the numbers 1 and 0, as it already matched the strings "1" and "0".
  • When correlation state reaches max_state_entries, eviction selects the stalest 10% without cloning and sorting every group key, and clones only the keys it removes.

Engines no longer retain rule HIR by default, and source references share one grammar (#620)

Engine kept every loaded rule's lowered HIR beside its compiled form, only so save_hir could write a restart cache, which nothing in the CLI, daemon, MCP server, or LSP calls. Retention is now opt-in through Engine::set_retain_hir(true), which must be on before rules are loaded; loading the 3,132 SigmaHQ rules with engine eval peaks about 12 to 16 MB (roughly 6%) lower without it. This changes library behavior: save_hir now returns EvalError::HirCache unless retention was on for every rule loaded from parsed Sigma, rather than writing a cache that silently misses rules.

The ${source.*} reference grammar existed twice, and the copies disagreed. Pipeline parsing ignored ids starting with a digit, so a pipeline whose only reference was ${source.1st_feed} was treated as static and kept the literal template, and ids containing -, such as threat-intel, matched neither copy. Both sides now use rsigma_eval::pipeline::sources::scan_source_templates, and a reference id is one or more ASCII letters, digits, _, or -. A source declaration whose id uses any other character, such as a dot or a space, now fails to load with an error naming the id, since no template could reference it.

Percentile and median checks no longer sort the window (#619)

value_percentile and value_median correlations sorted every finite value in the group's window on each event fed into the group. They now select the needed ranks with select_nth_unstable_by, which is O(n) on average and returns exactly the same values, including interpolated percentiles and even-count medians. In the new correlation_numeric_window benchmark, a group checked against a 5,000-value window processes events about 4x faster. Chained correlations also stop cloning every fired result, including its retained events in full event mode, just to read which correlation fired and for which group.

OTLP log decoding is fuzzed (#617)

OTLP log ingestion decodes untrusted protobuf and flattens each record to JSON, but it was the only untrusted input surface without a cargo-fuzz harness. The new fuzz_otlp_logs_decode target decodes arbitrary bytes as ExportLogsServiceRequest, converts whatever decodes with logs_request_to_raw_events, and asserts each event is valid JSON. It runs in the weekly fuzz workflow from a committed seed corpus. Conversion recurses once per nested AnyValue, so its depth depends on prost's decode recursion limit; a new unit test fails if a dependency turns that limit off through prost's no-recursion-limit feature.

The inhibit store is capped, and long unbounded correlation windows warn at load (#616)

InhibitStore was the only alert-pipeline store without a count cap: sources were pruned by duration only, so equal selectors over attacker-influenced values (source IPs, usernames) could grow it without bound. A new top-level max_inhibit_sources key (default 100000) caps tracked sources across all inhibit_rules. At the cap, a result that would open a new source is still checked as a target but is not tracked, and the refusal is counted on the new rsigma_inhibit_sources_refused_total{rule} counter; already-tracked sources keep refreshing. Restore and hot-reload trim the store to the cap, evicting the least recently seen sources, and CapTrim reports them as inhibit_sources. For library users, AlertPipelineFile gains max_inhibit_sources, and MetricsHook gains on_inhibit_source_refused, which defaults to a no-op.

max_group_entries stays unbounded by default, because a finite default would silently stop event_count rules whose threshold exceeds it. Instead, a correlation whose timespan is longer than one hour and that has no per-group cap, from --max-group-entries or rsigma.max_group_entries, now logs a warning at load, since a busy group's window grows with timespan x event rate.

Delivery failures without a DLQ sink are logged (#615)

When a sink exhausted its retries and no --dlq was set, the daemon counted the dropped result on rsigma_dlq_events_total and logged it once, at debug, so every later dropped alert left no trace in the logs. Each drop now produces a warn log, rate-limited to one every 10 seconds; each warning carries the number of results dropped since the previous one and the last delivery error. A failed write to a configured DLQ sink is now counted on the new rsigma_dlq_write_failures_total counter, and the delivery layer and the daemon's DLQ bridge log a failure they could not queue instead of discarding it silently. The docs now say that a sink commits a failed result by handing it to the DLQ writer, so the source ack does not wait for the DLQ sink's write.

Match detail costs one evaluation pass (#613)

With MatchDetailLevel above Off, a rule that fired was evaluated again to collect its selection names and then walked a third time to collect its field matches, re-running every matcher, including regexes. Field matches are now recorded while the selections are evaluated, so a firing rule costs one detail pass, and array members are matched once per quantifier instead of again for the report. The output is unchanged. On the eval_matched_detail benchmark (200 rules that all fire), Full drops from 314 to 307 µs and Off from 252 to 244 µs.

The daemon and engine eval also stop copying each parsed JSON line before the event filter: with no filter or a --jq filter the line moves through, and a --jsonpath filter copies only the nodes it matches. This changes public API: rsigma_runtime::EventFilter is now dyn Fn(serde_json::Value) -> Vec<serde_json::Value>, taking the parsed line by value, so a custom filter returns vec![v] instead of vec![v.clone()]. JsonEvent::into_value is new and unwraps the JSON value, cloning only when the event borrows it.

Match detail reports only the selections that decide the match (#612)

matched_selections and matched_fields were built from every selection the detail pass saw match, even inside a sub-expression that ended up false, so (s1 and s2) or s3 firing on s3 also reported s1 when it matched, not sel_b or sel_a reported sel_b, a and not all of filter_* reported the matching filters, and a and (a or b) reported a and its fields twice. Enrichment templates, OCSF findings, and engine eval output attributed alerts to selections that did not make them fire. Now a false and keeps only its failing child's selections, a true or keeps only its true child's, a selector reports its members only when its quantifier holds, and each name appears once. eval_condition follows the same rules.

Time-part modifiers accept space-separated fractional timestamps (#611)

rsigma-eval had two string timestamp parsers, and the one behind |minute, |hour, |day, |week, |month, and |year lacked the %Y-%m-%d %H:%M:%S%.f form that the correlation engine accepted, so "2024-07-10 12:30:00.500" worked as a correlation timestamp but made ts|hour: 12 never match. Both now share one parser: RFC 3339, or a T- or space-separated naive timestamp with an optional fractional second, plus epoch seconds and milliseconds.

Integer literals above 2^53 match exactly (#610)

Rule integers were stored as f64 from lowering onward, so any literal above 2^53 rounded to a neighbor: pid: 9007199254740993 matched 9007199254740992 and not itself, pid|gt: 9007199254740992 missed 9007199254740993, an integer keyword searched for the rounded digits, and backend convert and backend reverse emitted the rounded number. Integers now stay exact end to end: IrNumber::Literal holds a new rsigma_ir::NumericLiteral (Integer(i64) or Float(f64)), equality and the gt, gte, lt, and lte comparisons are exact against integer, string, and float event values, and conversion renders the exact digits. Fractional values still compare as f64.

This changes public API. CompiledMatcher::NumericEq, NumericGt, NumericGte, NumericLt, and NumericLte hold a NumericLiteral instead of an f64, and the Backend methods convert_field_eq_num, convert_field_compare_op, and convert_keyword_num take a NumericLiteral; a custom backend renders it with to_string(). HIR_SCHEMA_VERSION is now 4, so Engine::load_hir and decode_rules reject a HIR cache written by an earlier version; rebuild it from the rules.

Boolean keywords match the word instead of nothing (#609)

A keywords: detection with a YAML boolean, such as keywords: [true], parsed, compiled, and passed lint, but never fired, because keyword evaluation runs only string matchers and the value lowered to a boolean equality check; backend convert rejected the same rule with UnsupportedKeyword. Boolean keywords now lower to a case-insensitive substring search for the word true or false, like any other keyword, so keywords: [true] matches "true" and flag is TRUE now in eval and converts as the term true. Keyword search still does not visit JSON booleans; use a field match such as a: true for those. Fixes #587.

Partly case-insensitive regex pairs match the original text again (#608)

When a field had exactly two regex values that both looked case-insensitive, the optimizer grouped them to lowercase the event value once and match each regex against the lowercased text. The check stopped at the first i after (?, so a scoped flag such as (?i:foo)BAR, or a pattern that clears the flag later such as the (?i)foo(?-i)BAR that |re|i builds from foo(?-i)BAR, was treated as fully case-insensitive: such rules missed events they should match and matched events they should not. The optimizer now parses the pattern and groups a regex only when a leading global flag enables i and no later flag group, global or scoped, clears it. Fixes #584.

Daemon state saves are one consistent, atomic cut (#606)

With --state-db, the periodic and shutdown saves exported the correlation snapshot before reading the NATS source position, then wrote the correlation, alert-pipeline, risk, and disposition snapshots as four separate statements with awaits in between. An event acked between the export and the position read was claimed by the position but missing from the snapshot, so a replay from that position skipped its correlation state, and a crash between the writes paired snapshots from different moments. The daemon now reads the position first, captures every snapshot back to back, and writes them in one SQLite transaction, so a failed save leaves the previous one intact. The ack task publishes the position with release ordering to match. Each save also serializes the correlation snapshot once instead of twice. Fixes #580.

Restore and alert-pipeline reload enforce the dedup and incident caps (#605)

Restoring from --state-db kept every dedup alert and incident still inside its resolve_timeout, and an alert-pipeline hot-reload swapped in new caps without touching the carried-over stores, so after an operator lowered dedup.max_active_alerts or group.caps.max_open_incidents the stores stayed above the bound until entries aged out. Both now evict the least recently seen alerts and incidents down to the cap, without a resolved record, and cut each incident to max_results_per_incident and max_entities_per_incident, logging the eviction counts. The daemon trims and swaps the reloaded pipeline under the alert-state lock, and the sink task re-reads the pipeline after taking that lock, so no batch refills the stores under the old caps. In rsigma-runtime, AlertPipeline::enforce_caps applies the trim and returns a CapTrim. Fixes #590.

Hot-reload no longer rolls back correlation state (#604)

A rule reload (SIGHUP, the file watcher, or POST /api/v1/reload) snapshotted correlation state before loading the new rules and imported that snapshot after, so every window update made by events processed during the load was lost and counts silently rewound on each reload under traffic. Rules still load without pausing processing, but the state is now exported from the live engine, imported, and swapped behind the batch-order lock. An ordered batch that was waiting on that lock during the swap now evaluates against the new engine instead of advancing the retired one. Fixes #579.

Entity-graph grouping no longer leaks join keys or drops live incidents (#603)

In entity_graph grouping, a value that arrived after its incident's selector was full (max_entities_per_incident) was not stored on the incident but was still indexed as a join key. Resolve and merge only evict stored values, so those keys outlived the incident: the index grew without bound on a long-running daemon, and a later result carrying such a value could be merged into the dead incident id, which silently discarded the live incident it should have joined. Only stored values are now indexed, a stale index entry is dropped instead of being merged into, and max_value_cardinality counters move only for values an accepted result actually records, so a result refused by max_open_incidents or an over-cap value no longer inflates them. Fixes #583.

Refuse public plaintext and unauthenticated daemon APIs in every build (#601)

Breaking: engine daemon now refuses to start on a non-loopback TCP --api-addr that serves an unauthenticated API, unless bearer tokens (--api-token-env or daemon.api.auth) or client certificates (--tls-client-ca) authenticate callers, or the operator passes the new --allow-unauthenticated flag (config daemon.api.allow_unauthenticated). The plaintext refusal and --allow-plaintext used to exist only in daemon-tls builds, so a default cargo install rsigma daemon served an anonymous control API on every interface; both checks now apply to every build, and daemon.api.tls.allow_plaintext is honored without daemon-tls. Opting out of authentication on a non-loopback address logs a startup warning. Loopback and unix:// addresses are unaffected. To upgrade a public deployment, configure a token, or pass --allow-unauthenticated when an authenticating proxy fronts the daemon. The deployment guides now configure a token. Fixes #582.

OTLP sink export timeouts (#600)

OTLP output sinks no longer wait forever on a collector that accepts the connection and never answers. Every export attempt now has a deadline, 10 seconds by default and tunable per sink with ?timeout=<duration> (for example otlp://otel-collector:4317?timeout=3s). A timed-out attempt is retried and then dead-lettered like any other transient failure. Before this, under the default on_full=block, one hung collector stalled every sink behind the shared sink task. Connection setup is capped at 10 seconds on both transports, and gRPC channels send HTTP/2 keepalive pings. In rsigma-runtime, OtlpSink::with_timeout overrides the deadline and DEFAULT_OTLP_TIMEOUT exposes the default. Fixes #585.

Event values are escaped in enricher and webhook URLs and enricher bodies (security, #599)

The http enricher substituted ${detection.*} and ${correlation.*} values into its request URL and body as raw text, and webhook sinks did the same for their URLs. A crafted event field could add keys to a JSON enricher body or reach other paths and query parameters on the configured host (GHSA-3fh4-8r8j-8xw3).

  • Enricher bodies are JSON-string-escaped, as webhook bodies already were.
  • In enricher and webhook URLs, each event or correlation value is percent-encoded as one URL component, so it cannot add path segments or start a query or fragment. A value that is exactly . or .. has its dots encoded twice so it cannot act as a dot segment.
  • ${ENV_VAR} values in URLs stay verbatim, so an environment variable can still hold a whole path such as a Slack webhook path.
  • New rsigma_runtime::enrichment::render_template_url for embedders that render URL templates.

Breaking changes: an enricher URL template that relied on an event value containing /, ?, &, or # to build several path segments or query parameters now receives that value as a single encoded component. An enricher body template that placed a value outside a JSON string literal still gets raw text, but a value inside a string literal is now escaped.

MCP server refuses unconfined writes and unconfined HTTP serving (security, #598)

Without --rules-dir, rsigma mcp serve accepted any file path in its tools, so a prompt-injected agent or any client reaching the HTTP endpoint could read files outside a rules tree through fix_rules, and fix_rules with write: true could rewrite Sigma YAML anywhere the server user could write (GHSA-33jj-q567-34gj).

  • fix_rules with write: true is refused when the server has no rules root.
  • mcp serve --http refuses to start without --rules-dir unless the new --allow-unconfined-paths flag (config key mcp.allow_unconfined_paths) is set, and exits with code 3.
  • mcp serve on stdio without --rules-dir prints a warning on stderr at startup. --allow-unconfined-paths silences it.

Breaking changes: mcp serve --http without --rules-dir no longer starts. Pass --rules-dir, or --allow-unconfined-paths to keep the old behavior. MCP clients that called fix_rules with write: true against a server without a root now get an error.

OTLP/HTTP gzip bodies are capped at 4 MiB after decompression (security, #597)

POST /v1/logs inflated a Content-Encoding: gzip body into an unbounded buffer, so one small request could expand to gigabytes and get the daemon killed for running out of memory (GHSA-vwg6-4q3h-8pvq). The decompressed body is now limited to 4 MiB, matching the decoded-message limit on the OTLP/gRPC transport. A body that inflates past the limit is rejected with 413 Payload Too Large before decoding and counted as rsigma_otlp_errors_total{transport="http",reason="body_too_large"}. Affects builds with daemon-otlp, which includes the prebuilt binaries and the Docker image.

Custom logsource extractors (#578)

rsigma-eval adds the ExtractLogSource<E> trait so an embedder can prune rules by a logsource its event type carries outside the matchable fields, such as metadata beside the payload. Engine, CorrelationEngine, and SchemaRouter are generic over the extractor type, which defaults to LogSourceExtractor, and call it once per event with static dispatch. Build one with Engine::with_logsource_extractor, CorrelationEngine::with_logsource_extractor, or SchemaRouter::build_with_logsource_extractor. Correlation through process_event, process_event_at, and process_batch keeps each compiled rule's identity, which precomputed detections lose for rules without an id that share a title. Existing code is unaffected: LogSourceExtractor keeps its name and inherent methods, and SchemaRouter::build still accepts None. Based on work by John Sonnenschein in #500.

Dependency updates and rustls security fix (#577)

Rolls up the open Dependabot PRs and the updates queued behind them, with both Cargo lockfiles regenerated against current main. Security: rustls 0.23.45 to 0.23.46 in Cargo.lock and fuzz/Cargo.lock for RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348, and RUSTSEC-2026-0349, and quinn-proto 0.11.16 to 0.11.18 in fuzz/Cargo.lock for RUSTSEC-2026-0338 through RUSTSEC-2026-0342, matching the root lockfile. Rust: the patch group with tokio 1.53.2, zeroize 1.9.1, rmcp 3.5.1, and toml 1.1.8 (#573), insta 1.49.0 (#574), arbitrary 1.5.0 in the fuzz crate (#571), cel 0.14 to 0.15, jsonschema 0.56 to 0.58, hyper 1.12.0, uuid 1.27.0, and pest 2.9.3. CI: taiki-e/install-action v2.87.26, actions/upload-artifact v7.0.2, actions/download-artifact v8.0.2, github/codeql-action/upload-sarif v4.38.3, and actions/setup-node v7.1.0, repinned by commit SHA (#575). VS Code: @types/node 26.6.4 (#572). Backend engine tests: new debian:stable-slim (#570) and python:3.13-slim digests, and markupsafe 3.0.4 in the pySigma image. Held back: yamlpath and yamlpatch 1.30.1 (#495, #494) require rustc 1.97 through tree-sitter-iter, above the 1.96.0 MSRV; tikv-jemallocator 0.7.0 (#425) remains excluded after the measured musl daemon throughput regression; testcontainers 0.28.0 waits for a testcontainers-modules release that supports it.

cel 0.15 follows the CEL specification more closely, which changes how some type: cel extract expressions in dynamic sources and enrichers evaluate: size() of a string counts code points instead of bytes, a map literal that repeats a key is an error, string(bytes) errors on invalid UTF-8, timestamps and durations format as the specification requires and values outside its range are errors, and all() and exists() no longer fail on an element's error when a later element decides the result.

engine eval writes results to a file with -o, --output (#576)

engine eval -o <PATH> writes detection and correlation results to a file instead of stdout, matching the -o, --output flag on backend convert, hunt run, rule reverse, and engine tap. The file is created or truncated at start and flushed on exit, including when --fail-on-detection exits with code 1. Results use --output-format when it is set and NDJSON otherwise, and --pretty still applies. The processing summary and diagnostics stay on stderr, so callers that run engine eval without a shell, such as from the FROM scratch Docker image, get the results and the summary as separate streams. The eval.output config key sets the same path, and the flag takes precedence. A path that names the --event input file is rejected with exit code 3 instead of truncating the input.

PostgreSQL JSONB rendering lives in its own module (#568)

The PostgreSQL backend renders JSONB field paths and array object-scope matches (field[any], field[all], field[all_or_empty], field[none]) in a private jsonb module, with its tests beside it and new unit tests for negative and leading indexes and for malformed bracket paths. Generated SQL is byte-identical for the SigmaHQ ruleset and for array rules, with and without a JSONB column.

PostgreSQL correlation conversion lives in one module (#567)

The PostgreSQL backend builds correlation queries in its correlation module, which now also holds the window-mode resolution and the per-type query selection that used to sit in the Backend impl. The aggregate and temporal query builders are private to that module. Generated SQL is byte-identical for every output format, window mode, and correlation_method.

Parse warnings are reported apart from parse errors (#566)

The parser keeps a rule whose status or level is outside the allowed set, or whose related entries or sigma-version are malformed, and ignores the invalid value. It reported those problems in the same list as documents it dropped, so rule validate exited 2 and the MCP validate_rules and parse_rule tools returned ok: false for a rule that parsed and compiled. These are now parse warnings, reported next to parse errors but not failing validation by default.

  • rule validate prints a Parse warnings: count, lists them under their own heading with --verbose, adds parse_warnings and summary.parse_warnings to the JSON envelope, and emits parse_warning rows in NDJSON and table output. The new --fail-level warning flag also exits 2 on parse warnings; the default, error, matches rule lint --fail-level.
  • MCP validate_rules takes an optional fail_level input (error or warning) and returns parse_warnings next to parse_errors, which now holds only errors. parse_rule also returns parse_warnings and sets ok from errors only. test_exemplars and tune_rules reject a rule collection only on errors.
  • engine eval, engine daemon, and the other commands that load rules report the count of parse warnings separately from parse errors.

In rsigma-parser, SigmaCollection::diagnostics replaces SigmaCollection::errors. Each CollectionDiagnostic carries a kind (DiagnosticKind), a severity (DiagnosticSeverity), the message, the zero-based index of the YAML document, the file path when the collection was read from disk, and the line and column for YAML syntax and condition errors. Its Display prints the same text as the old string. SigmaCollection gains warning_count(), errors(), and warnings(), and SigmaParserError::kind() returns the variant as a SigmaParserErrorKind.

Breaking changes:

  • SigmaCollection::errors is now SigmaCollection::diagnostics: Vec<CollectionDiagnostic>. Call .to_string() or read .message where code used the string, or match on kind and severity. into_result() returns Vec<CollectionDiagnostic> on Err.
  • has_errors(), error_count(), and into_result() no longer count warnings. Check diagnostics.is_empty() to keep treating a warning as a failure.
  • rule validate exits 0, and MCP validate_rules and parse_rule return ok: true, for rules that only have parse warnings. Pass --fail-level warning or fail_level: warning to keep failing on them in CI. rule lint still reports an invalid status or level as an invalid_status or invalid_level error.
  • Consumers of rule validate JSON and MCP parse_errors read warnings from parse_warnings, and consumers of NDJSON or table rows see them with the parse_warning status.

Lint and the parser agree on modifiers and conditions (#565)

incompatible_modifiers and condition_references_unknown now run the parser's own validators instead of separate copies, so rule lint, the LSP, and the MCP lint_rules tool flag exactly the modifier chains and condition references that rule validate rejects. Lint used to accept chains the parser rejects, such as wide|utf16be, base64|base64offset, base64|re, i|re, and exists|cased, and it missed a selector such as 1 of filter_* that matches no detection. It also split a condition that does not parse into words and reported each word as an unknown identifier. Such a condition is now reported once by the new invalid_condition lint, an error that carries the parser's message and location. Each unresolved identifier or selector gets its own condition_references_unknown finding with the parser's wording. On the SigmaHQ corpus, the only change is in four files under unsupported/, whose 39 word-by-word findings become one invalid_condition each.

Breaking changes: incompatible_modifiers is an error instead of a warning, because every chain it flags fails to parse. A ruleset that passed rule lint at the default --fail-level error while carrying such a chain now fails. Fix the chain, or set incompatible_modifiers: warning under severity_overrides in .rsigma-lint.yml while migrating. condition_references_unknown messages now read condition references unknown detection identifier 'x' and selector '1 of x*' matches no detection identifier, so a CI filter that matches the old no such detection identifier exists text needs updating. The lint catalogue, including the MCP rsigma://lint/catalogue resource, has 92 entries.

pySigma 2.0.1 as the test backend reference (#564)

The engine test that compares the test backend with pySigma's TextQueryTestBackend now runs against pySigma 2.0.1 instead of 1.5.1. All 69 shared cases convert the same way under both releases, so no case or known difference changes. pySigma 2.0 adds google-re2 and jq to the pinned, hash-checked requirements of the test image.

Docs site sharp security update (#563)

The docs site plugin that renders the favicon and touch icons now requires sharp 0.35.5, which bundles libvips 1.3.4 with the patched librsvg for CVE-2026-96889 (GHSA-wq5f-xc86-pv6w). The change only affects building the docs. No published crate, binary, or image depends on sharp.

0 of selectors are rejected (#562)

A condition such as 0 of selection_* parsed, compiled, and matched every event, because at least zero of anything is always true. The parser now rejects it with a located condition error that suggests not 1 of, which is what such a condition almost always means. No rule in the SigmaHQ corpus uses it. A condition error raised inside a selector or a parenthesized group also no longer repeats Condition parse error at L:C: once per nesting level, and rule condition no longer prints that prefix twice.

Breaking changes for library users: Quantifier::Count holds a NonZeroU64 instead of a u64, so a zero count cannot be built or deserialized. The serialized form of a valid count is unchanged, so HIR_SCHEMA_VERSION stays at 3.

Pipeline errors get their own variant (#561)

Processing pipeline failures were reported as invalid modifier combination: ..., so a YAML syntax error in a pipeline file, an unknown transformation type, or a deliberate rule_failure read like a problem with a rule's field modifiers. They now return the new EvalError::Pipeline variant and print as pipeline error: ..., and rule migrate-sources reports pipeline YAML errors the same way. The rejection message for an inline pipeline sources: block, and the remaining docs pages that mention it, now date the removal to 0.19.0 instead of v1.0.

Breaking changes for library users: EvalError has a new Pipeline(String) variant, and errors from pipeline parsing and application move to it from InvalidModifiers. EvalError is now #[non_exhaustive], so an exhaustive match on it needs a wildcard arm, and later variants are not breaking changes.

--no-default-features builds compile again (#560)

cargo build -p rsigma --no-default-features, the minimal build the CLI README documents, had failed to compile since 0.22.0 because the disposition-spool reader behind rule tune --from-dispositions needs rsigma-runtime, which only the daemon feature pulls in. The reader is now gated on daemon, and in a build without it --from-dispositions exits 3 with a rebuild hint. The check CI job compiles the CLI and its tests without default features so the build stays green.

Docs checked against the code (#559)

The CLI README lists all four native backends, and its backend targets and backend formats samples, like the LynxDB and Fibratus samples on the docs site, match the binary output. The daemon's --stix-store-allow-custom flag and its daemon.stix_store_allow_custom key, added in 0.23.0, are documented. rule parse and rule stdin report YAML syntax errors as warnings and exit 0, and the exit-code pages now say so. The rule parse jq example selects through rules[0], and the CLI README no longer claims JSON is always pretty-printed. The condition reference documents any of and counted selectors such as 2 of selection_*, which the evaluator reads as "at least two" and conversion rejects. Inline pipeline sources: blocks are dated to their removal in 0.19.0 instead of a v1.0 release. Two scripts under scripts/docs-audit/ now run on every pull request: one compares the docs catalogues with the source tree, and one checks the CLI pages, config schema, MCP tool list, backend samples, and YAML examples against an all-features binary.

Documentation corrections (#558)

The docs site publishes the config JSON Schema at /rsigma.schema.json, the URL the config template and deployment units already reference. The fuzzing guide lists all 27 harnesses, including the 21 the weekly workflow runs. RAYON_NUM_THREADS is documented with the other environment variables. The Prometheus catalogue size is 95 metric names, 90 of them registered without a feature gate. Prose that used an em dash, including older changelog entries and crate descriptions, now uses a comma, a colon, or a separate sentence. Characters that belong to the windash set are unchanged.

Dependency follow-up (#556)

Rolls up the compatible Dependabot PRs that opened after #550, with Cargo.lock regenerated against current main. Rust: rmcp 3.3.0 to 3.5.0 (#555), which renames the deprecated ServerInfo alias to ServerConfig in the MCP server handler, and tokio-rustls 0.26.5 to 0.26.6 (#554). CI: taiki-e/install-action v2.87.21 to v2.87.22, repinned by commit SHA (#553). VS Code: @types/vscode 1.138.0 to 1.140.0 (#552). Held back: yamlpath 1.30.1 (#495) and yamlpatch 1.30.1 (#494) require rustc 1.97 through tree-sitter-iter, above the 1.96.0 MSRV; tikv-jemallocator 0.7.0 (#425) remains excluded after the measured musl daemon throughput regression.

v0.24.0...v0.25.0