Product Updates

What Is Rule Validation in HuntRule?

HuntRule Team · · 8 min read

A steel go/no-go gauge closing on a machined metal part resting on a granite inspection table
On this page

Two selections, AND-ed together. One asks for Image|endswith: '\rundll32.exe', the other for Image|endswith: '\regsvr32.exe'. The YAML is valid. Every linter you own will pass it. It will also never fire, because a single process creation event has exactly one Image, and no string ends in both.

That gap is the reason HuntRule runs a validator at all. Parsing tells you a document is well formed. It tells you nothing about whether the detection inside it is internally consistent. Broken rules live in that gap: they load, they index, they sit in a catalog looking like coverage, and they return zero results forever.

Two gates, not one

Validation happens in two stages, and they fail differently.

The first is parseSigmaDocument in server/services/sigma-parse.ts. It is a Zod schema over the yaml package with hard limits: 256 KB, 5000 nodes, 24 levels of nesting. It refuses YAML anchors and aliases outright, refuses anything that is not a YAML map, and refuses correlation and filter rules as outside the V1 safe subset. Its error codes are the whole vocabulary of stage one:

too_large | too_deep | too_many_nodes | alias_forbidden
invalid_yaml | schema_invalid | normalization_incomplete

That safe subset is a real restriction, not a formality. If you write a Sigma correlation rule, HuntRule will not store it. Same for a rule that uses &anchor and *alias to share a selection block. Both are legal Sigma. Both are rejected here, and normalization_incomplete is the code you get back for correlation and filter. If you are new to the format, what a Sigma rule is covers the parts we are talking about.

The second stage is validateRule(yaml), which runs twenty rule-level checks from server/ai/validation/checks/. Nothing auto-fixes. Nothing throws. Every check returns a list of findings, and every finding carries a code, a severity, a message and a path into the document.

Error or warning

Severity is the only decision the validator actually makes, and it is the part worth understanding.

A finding is an error when the check can prove the rule is wrong from the document alone. Not suspicious. Wrong. The impossible AND above is provably wrong: the check only flags mutual exclusion it can demonstrate by comparing value pairs, and when the condition contains any OR or any N of expression it bails out entirely rather than guess. That conservatism is deliberate. A validator that flags legitimate rules gets switched off.

A finding is a warning when the check is a heuristic, or when the right answer depends on your estate rather than on the document. SIG_DRIVE_LETTER_IN_CONTAINS fires on a |contains value pinned to C:\. That might be exactly right in a fleet where SystemDrive is always C:, and it might be why the rule misses in a fleet where it is not. The validator cannot tell which, so it says so and moves on.

Errors, by code:

  • SIG_YAML_PARSE and SIG_MISSING_FIELD (no id, status, description, level, no selection, or no condition)

  • SIG_UNUSED_SELECTION and SIG_UNDEFINED_SELECTION, the two halves of a condition that does not line up with its selections

  • SIG_DOUBLE_BACKSLASH, a non-regex value carrying \\ where Sigma paths use one

  • SIG_DEFANGED_IOC, a value still holding [.], [:] or hxxp, copied out of a report and never refanged

  • SIG_BAD_HASH, an md5, sha1, sha256 or imphash value that is not hex of the right length

  • SIG_REDACTION_ARTEFACT, a <REDACTED> or <C2_ADDRESS> placeholder left in a selection

  • SIG_CONFUSABLE_VALUE, a Unicode dash where an ASCII hyphen belongs, which is how README-GENTLEMEN.txt quietly stops matching

  • SIG_LOGSOURCE_KEY_IN_SELECTION, a category, product or service key inside a selection instead of the logsource block

  • SIG_IMPOSSIBLE_AND, mutually exclusive constraints on one single-valued field

  • SIG_UNKNOWN_FIELD, SIG_MIXED_LOGSOURCE_FIELDS and SIG_PLATFORM_FIELD_MISMATCH, fields checked against a per-category allowlist

  • SIG_DUPLICATE_TOP_KEY, the same top-level key twice

Warnings, by code:

  • SIG_EMPTY_FALSEPOSITIVES, no documented benign activity (a single "None known" entry satisfies it)

  • SIG_UNANCHORED_EXE, Image|endswith: 'rundll32.exe' with no leading backslash, which also matches evilrundll32.exe

  • SIG_DRIVE_LETTER_IN_CONTAINS

  • SIG_MULTI_EVENT_AND, three or more AND-ed selections where at least two pin a cross-event identity anchor

  • SIG_EVENTID_FIELDS_UNVERIFIED, on the Windows security channel, where only EventID is verifiable and everything else depends on which event you picked

  • SIG_UNKNOWN_TOP_KEY, a non-standard top-level key

The field checks earn a note. They only run when the logsource resolves to a known category (process_creation, network_connection, dns_query, file_event, registry_set, image_load, pipe_created, process_access and a few more) or to the powershell or security services. Anything else is unknown and the field checks are skipped rather than guessed at. IntegrityLevel, OriginalFileName, LogonId and imphash are Windows-only, so one of those under product: linux is an error.

A rule that parses and fails anyway

Here is the opening example in full. It is a reconstruction, not a rule from the catalog.

title: Rundll32 and Regsvr32 proxy execution
id: 8f2b1c40-6d5a-4b71-9c33-0a6e2d7f1a55
status: experimental
description: Detects the LOLBin proxy execution chain described in the report.
logsource:
  category: process_creation
  product: windows
detection:
  selection_rundll:
    Image|endswith: '\rundll32.exe'
  selection_regsvr:
    Image|endswith: '\regsvr32.exe'
  condition: selection_rundll and selection_regsvr
falsepositives:
  - Administrative scripting
level: high
tags:
  - attack.t1218.011
  - attack.t1218.010

Stage one passes. It is a valid map, no aliases, no correlation block, Image is a known process_creation field, both selections are referenced, level is in range. Stage two returns one finding:

SIG_IMPOSSIBLE_AND (error) at detection.condition
Selections 'selection_rundll' and 'selection_regsvr' are AND-ed but place
mutually-exclusive constraints on 'Image'; a single event has one Image.
Split into separate rules.

The intent behind the rule was fine. The report really did describe rundll32 proxying a DLL (T1218.011) and regsvr32 registering a scriptlet (T1218.010). Those are two stages of one intrusion, which means two events, which means two rules. Written as one AND it is a rule that reports zero hits and reads as clean coverage on a dashboard. That failure mode is why the check exists, and it is also why the AI generation prompt carries a worked example showing a multi-event report becoming independent rules.

Where the verdict lands

Errors stop things. Warnings do not.

On the SigmaHQ sync path, validateRule runs against the derived YAML and any error-severity finding sets lintStatus: "failed", writes the joined codes into lintError, and drops the rule into the pending queue as a draft instead of publishing it. applyPublishVersion then refuses anything whose lintStatus is not passed, so a failed rule cannot be published later by a different route. In the admin console the manual publish action re-runs the validator and blocks on the same errors. There is also a CLI, pnpm validate:rules <path>, wrapping the same module and exiting 1 on any error.

Warnings ride along. A rule with three warnings and no errors publishes through the sync path exactly like a clean one, and the findings show up in the admin findings panel for a human to read.

Payload-level checks are a separate set and run only over a whole AI generation response, not over a rule you paste in. They cover coverage-ledger integrity, verbatim quote checks against retained evidence, near-duplicate rules within one batch, and a small number of technique mismatches: attack.t1059.001 on a rule that observes no PowerShell, parent-only attack.t1176 on a rule watching the Chromium load-extension switch, parent-only attack.t1059 on a rule selecting deno.exe.

What it cannot do

The validator is deterministic and offline. That is the whole design, and it is also the ceiling.

It cannot tell you a rule will fire in your estate. It has no telemetry, no field mappings, no idea whether you collect Sysmon Event ID 1 or which EDR normalises your paths. A rule can be internally perfect and still match nothing you log.

It does not run pySigma or sigma-cli. Neither is a dependency and neither is in the pipeline. The only hook is an opt-in shell-out in the local CLI script, whose findings are recorded as warnings and which nothing in production or CI ever runs. So the field allowlists are ours, they are conservative, and they are narrower than a full Sigma taxonomy.

It does not check the catalog for duplicates. The near-duplicate check compares rules inside one AI generation payload only. Content-hash comparison happens later, at ingest, and it only compares a new body against earlier versions of the same Sigma id.

And it cannot tell you a rule is a good idea. SIG_EMPTY_FALSEPOSITIVES will notice you documented nothing. It will not notice that the one thing you documented is wrong.

Summary

HuntRule parses Sigma into a deliberately narrow safe subset, then runs twenty deterministic rule-level checks over the result. Errors mean provably broken and they block publication on every path. Warnings mean heuristic or estate-dependent, and they travel with the rule for a human to weigh. Correlation rules, filter rules, YAML anchors and aliases are rejected at the parser and are not stored. Nothing here predicts whether a rule fires against your logs, only whether it is well formed and internally consistent.

Every published rule in the catalog has been through both stages, and the Windows rules are at /rules?domain=windows.

Blocking codes (error)
SIG_YAML_PARSE, SIG_MISSING_FIELD, SIG_BAD_LEVEL, SIG_UNUSED_SELECTION,
SIG_UNDEFINED_SELECTION, SIG_DOUBLE_BACKSLASH, SIG_DEFANGED_IOC, SIG_BAD_HASH,
SIG_REDACTION_ARTEFACT, SIG_CONFUSABLE_VALUE, SIG_LOGSOURCE_KEY_IN_SELECTION,
SIG_IMPOSSIBLE_AND, SIG_UNKNOWN_FIELD, SIG_MIXED_LOGSOURCE_FIELDS,
SIG_PLATFORM_FIELD_MISMATCH, SIG_DUPLICATE_TOP_KEY

Non-blocking codes (warn)
SIG_EMPTY_FALSEPOSITIVES, SIG_UNANCHORED_EXE, SIG_DRIVE_LETTER_IN_CONTAINS,
SIG_MULTI_EVENT_AND, SIG_EVENTID_FIELDS_UNVERIFIED, SIG_UNKNOWN_TOP_KEY

Parser rejection codes
too_large, too_deep, too_many_nodes, alias_forbidden, invalid_yaml,
schema_invalid, normalization_incomplete

Related articles