A silent rule does not stand out in operation

A detection rule in Microsoft Sentinel is a KQL query that runs on a schedule and reports an incident as soon as it returns rows. If it returns none, operations read that as calm. A rule that can never return a row for logical reasons therefore cannot be told apart in operation from a rule that has nothing to report right now.

The following three queries are valid KQL. Microsoft’s own parser library Kusto.Language accepts all three, and each stays silent in operation. On 10 October 2026 we ran them through the check of our Sentinel validation layer. The queries appear as they ran, including the comment line at the top, from which the line numbers count. The findings below quote the output verbatim, minus the hint line the check appends to each finding.

1. The empty inclusion list

KQL
// Detection: sign-ins by privileged accounts from outside the allowed countries
let Admins = dynamic([]);
SigninLogs
| where TimeGenerated > ago(1d)
| where UserPrincipalName in (Admins)
| where Location !in ("DE", "AT", "CH")
| project TimeGenerated, UserPrincipalName, IPAddress, Location
finding
[ERROR] line 5: 'Admins' is empty (line 2) and is used as a positive set test —
        this query cannot return a single row. (empty_filter_list)

The list of privileged accounts is empty, presumably meant as a placeholder someone fills in later. A positive set test against an empty list never matches. As an exclusion list with !in the same empty list would be correct, which is why the case can be decided from the structure of the query.

2. Ticks instead of seconds

KQL
// Detection: more than one failed sign-in every two seconds from one address
let lookback = 1d;
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType != "0"
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), Attempts = count() by UserPrincipalName, IPAddress
| extend DurationSeconds = todouble(LastSeen - FirstSeen)
| where Attempts > 10 and Attempts / DurationSeconds > 0.5
| project UserPrincipalName, IPAddress, Attempts, FirstSeen, LastSeen
finding
[ERROR] line 7: 'todouble()' converts 'LastSeen - FirstSeen' to a number —
        the result is in ticks, not seconds. (timespan_without_unit)

The rule is meant to report when one address produces more than one failed sign-in every two seconds. Subtracting two timestamps yields a timespan, and todouble() converts it into a number in ticks, ten million per second. The computed rate therefore comes out seven orders of magnitude too small and in practice never exceeds the threshold of 0.5. A language model wrote this rule and named the variable “seconds”.

3. The swapped difference

KQL
// Detection: more than one failed sign-in every two seconds from one address
let lookback = 1d;
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType != "0"
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), Attempts = count() by UserPrincipalName, IPAddress
| extend DurationSeconds = datetime_diff("second", FirstSeen, LastSeen)
| where Attempts > 10 and Attempts / DurationSeconds > 0.5
| project UserPrincipalName, IPAddress, Attempts, FirstSeen, LastSeen
finding
[ERROR] line 7: 'datetime_diff(..., FirstSeen, LastSeen)' is never positive —
        'FirstSeen' is the minimum and 'LastSeen' the maximum of 'timegenerated',
        so the subtraction runs backwards. (negative_duration)

datetime_diff computes the second argument minus the third. With the minimum on the left and the maximum on the right, the duration never turns positive and the rate never exceeds zero. This error arose when the same model repaired the second one. It corrected the unit and swapped the arguments in the process.

Even with the arguments in the right order one fault would remain. datetime_diff returns an integer, the number of attempts is an integer as well, and Kusto truncates the result when it divides two integers. Fifteen attempts in twenty seconds then come out as zero instead of 0.75, and the rule only fires from one attempt per second upwards instead of from half an attempt. todouble(Attempts) / DurationSeconds computes the rate correctly.

Which check finds what

CheckWhat it checksFinds the three cases
Kusto.Language (Microsoft’s parser)syntax, known tables and columns, function argumentsnone; all three are valid
kql-guard (Microsoft, 2026)syntax, unknown names when given a schema, twelve cost rules from a missing time filter to unbounded mv-expandnone according to its documentation; not run by us
Check catalogue of the validation layerpatterns in which a valid query cannot return a rowall three as errors
Test run against attack datawhether the rule detects a real attackyes, provided suitable data exists

Since April 2026 Microsoft’s GitHub account has hosted kql-guard, a fast, offline analyser for “detection as code”. It catches syntax errors, reports unknown tables and columns when given a schema, and scores expensive query shapes. It thereby covers a different class of error, because kql-guard asks whether a query runs and what it costs. The three cases above run and cost little; they just cannot find anything.

The tools therefore complement each other. Anyone who maintains detection rules in a repository should hook kql-guard into the pipeline, because it finds cost traps our catalogue does not know. Anyone who has a language model write rules also needs a check for silent failures, because models write such failures fluently.

Limits

The catalogue knows the patterns we have found, and more will follow. It finds rules that cannot fire. It does not prove that a rule without a finding computes correctly or describes the right threat. The third case shows that limit, because the check did not notice the integer division. The version with the arguments in the right order and without todouble() passes with “No findings.”, just like the corrected version with todouble(Attempts) / DurationSeconds. The statement about kql-guard rests on its README as of October 2026; we did not run the tool against the three queries ourselves, and its rule set may grow. The industry figure that around 13% of rules in production SIEM environments can never fire comes from the 2025 CardinalOps report; we did not measure it.

Sources

  • microsoft/kql-guard, README, github.com
  • Microsoft Learn, datetime_diff(), learn.microsoft.com
  • Microsoft Learn, Numerical operators, type rules for division, learn.microsoft.com
  • CardinalOps figures as reported by Help Net Security, 9 June 2025, helpnetsecurity.com
  • Our own runs of 10 October 2026 against the SigninLogs table from the bundled schema catalogue