Rule Hygiene

Detection programs accumulate rules faster than they retire them. Elastic’s Detection Engineering Behavior Maturity Model (DEBMM) puts structured rule management, continuous review, and low-noise maintenance at the center of a mature detection program, and published lifecycles such as the SANS detection engineering lifecycle treat deployment and maintenance (including tuning and retirement) as an ongoing phase rather than a one-time ship. Without a forcing function the catalog fills with unowned, untagged, never-firing, and stale rules. rsigma rule hygiene is that forcing function. It assembles the signals RSigma already produces into one report of retirement and clean-up candidates, then lets CI gate on them.

This guide covers which input feeds which signal, how to read the report, and how to wire --fail-on into CI.

What it flags

The report carries seven signals in one pass:

  • silent: a rule with no matches over the metrics window, or one whose last-fired is older than --silent-threshold (default 365d). A rule that has not fired in a year is a deletion candidate.
  • noisy: a fire-count outlier. By default a robust median-plus-MAD test over peers that have fired; set --noisy-threshold for an absolute per-window ceiling instead. A rule that fires far more than its peers is either too broad or firing only on false positives.
  • untagged: a rule with no attack.* ATT&CK tag. This is the same untagged set rule coverage reports, rolled into the hygiene verdict rather than recomputed.
  • no-owner: a rule with neither an author: field nor a custom-attribute owner key, so no one is accountable for tuning or retiring it.
  • incomplete-ads: a stable detection rule (not ADS-exempt) missing required ADS sections, so it ships to production without a documented strategy.
  • broken-fields: a rule whose referenced fields are all in the field-observability snapshot’s never-seen set, so it cannot fire no matter what.
  • deprecated: a rule already marked deprecated/unsupported, or one whose modified: (falling back to date:) is older than --stale-threshold (default 365d).

Which input feeds which signal

You have Pass You unlock
Just the rules --rules <PATH> untagged, no-owner, incomplete-ads, deprecated
A Prometheus scrape or endpoint --metrics <FILE|URL> silent, noisy
An event corpus (offline) --corpus <PATH> silent, noisy
A field-observability snapshot --fields <FILE> broken-fields

The static signals need only --rules, so the cheapest useful run is one that flags untagged, unowned, undocumented, and deprecated rules with no infrastructure at all. Layering in --metrics (or --corpus) and --fields adds the data-driven signals. For non-NDJSON corpus files, pass --input-format (json, syslog, plain, logfmt, cef, or auto).

Production fire volume

--metrics reads the two per-rule counter families (rsigma_detection_matches_by_rule_total and rsigma_correlation_matches_by_rule_total, joined by rule_title). Point it at a saved /metrics scrape or a live endpoint:

rsigma rule hygiene --rules ./rules --metrics http://localhost:9090/metrics

A point-in-time scrape establishes silence by absence: a rule whose counter has never registered has never fired in that process. For a true last-fired timestamp, point --metrics at a Prometheus query-API base and pass --metrics-window:

rsigma rule hygiene --rules ./rules \
    --metrics http://prometheus:9090 --metrics-window 90d \
    --silent-threshold 90d

When there is no daemon or Prometheus to read, --corpus is the offline alternative: it replays a corpus (a file or a directory walked recursively) through the engine and counts per-rule fires, producing the same silence and noisy signals. Correlation state resets per file. Combined with --metrics, the counts are summed.

rsigma rule hygiene --rules ./rules --corpus ./corpus

Broken field coverage

--fields consumes a field-observability snapshot: the daemon’s /api/v1/fields payload, or the report from rsigma engine eval --observe-fields. Its missing set is the rule-referenced fields that no event ever carried. Hygiene rolls that up per rule: a rule whose every referenced field is unseen is flagged broken-fields. Generate the snapshot from the same rule set so the field names line up.

Reading the report

On a TTY the default table view prints a per-signal summary on stderr and the flagged rules on stdout:

Rules: 7 (7 detection, 0 correlation) | Flagged: 6 | Sources: rules + metrics + fields
  1 silent  1 noisy  1 untagged  1 no-owner  1 incomplete-ads  1 broken-fields  1 deprecated

RULE                   KIND       SIGNALS            FIRES  LAST_FIRED  OWNER  STATUS
---------------------  ---------  -----------------  -----  ----------  -----  ----------
Bravo Noisy            detection  noisy                500  -           Bob    test
Charlie Quiet          detection  silent                 0  -           Carol  test
Delta Untagged Orphan  detection  untagged,no-owner      3  -           -      test
Echo Incomplete ADS    detection  incomplete-ads         2  -           Eve    stable
Foxtrot Deprecated     detection  deprecated             4  -           Frank  deprecated
Golf Broken Fields     detection  broken-fields          6  -           Grace  test

Without --metrics/--corpus or --fields, the sources line reads rules only.

For machine consumption, --output-format json emits the full document (a summary, a rules[] array of flagged verdicts, and a per-signal list for each signal), and ndjson/csv/tsv emit one row per flagged rule. The JSON list keys use snake_case names that differ from the signal labels: never_fired (silent), broken_coverage (broken-fields), and stale_status (deprecated); the other lists match (noisy, untagged, no_owner, incomplete_ads). --report <FILE> always writes the full JSON document regardless of the chosen output format, so a CI job can both print a table and archive the JSON.

Gating CI

--fail-on is repeatable and exits 1 when a selected condition matches at least one rule. Gate on the conditions your program treats as blocking:

# Fail the build if any rule has been silent past the threshold or has no owner.
rsigma rule hygiene --rules ./rules --metrics metrics.txt \
    --silent-threshold 365d \
    --fail-on silent --fail-on no-owner

Use --fail-on any to fail on any finding, or set the policy in the config file under hygiene.fail_on. The exit codes follow the house convention: 0 clean (or report-only), 1 a selected condition matched, 2 the rules could not load, 3 a bad flag or an unreadable metrics/fields input.

Relationship to the scorecard

Hygiene is the static, coverage-structural half of the retirement story: it surfaces candidates from owner, tag, status, silence, noise, and field-coverage signals, and stops at flagging them. The Detection Scorecard is the quantitative keep/tune/retire verdict that fuses a backtest and coverage report (and optionally the same Prometheus volume) into a precision-driven decision. Run hygiene for the cheap, no-backtest sweep; run the scorecard when you have the backtest and coverage reports and want the graded verdict.

See also