TH2.15 KQL Anti-Patterns That Produce False Negatives

4-5 hours · Module 2 · Free
Operational Objective
A false negative in hunting is worse than no hunt — it creates documented assurance that a technique was searched for and not found, when the reality is that the query was wrong. This subsection catalogs the KQL mistakes that produce false negatives in hunting queries — queries that look correct, run without errors, and return results that silently miss the threat.
Deliverable: Recognition of the seven most common KQL anti-patterns in hunting and the corrected patterns for each.
⏱ Estimated completion: 25 minutes

The query ran. The results were wrong.

KQL does not throw errors when your logic is flawed. A query with a wrong filter, a mismatched join, or a truncated result set executes successfully and returns data. The data looks plausible. The analyst draws conclusions from it. The conclusions are wrong because the data is incomplete or filtered incorrectly.

These anti-patterns are not beginner mistakes. They catch experienced analysts because they are subtle — the query looks right until you examine exactly what it misses.

Anti-pattern 1: Case-sensitive string comparison

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// WRONG: Case-sensitive comparison misses variations
SigninLogs
| where AppDisplayName == "Microsoft Azure Portal"
// Misses: "microsoft azure portal", "MICROSOFT AZURE PORTAL"
// M365 logs are inconsistent in capitalization across tenants

// CORRECT: Case-insensitive comparison
SigninLogs
| where AppDisplayName =~ "Microsoft Azure Portal"
// =~ is case-insensitive equals
// has and has_any are also case-insensitive by default
// contains is case-insensitive
// == is case-SENSITIVE  use =~ for string matching in hunting

Anti-pattern 2: Truncated results from missing summarize

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// WRONG: Raw results hit the 10,000-row limit silently
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType != 0  // Failed sign-ins
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType
// If there are 50,000 failed sign-ins, you see 10,000
// You do not know you are missing 40,000
// Your analysis of "all failed sign-ins" is based on 20% of the data

// CORRECT: Aggregate before the row limit
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType != 0
| summarize FailedCount = count(),
    UniqueIPs = dcount(IPAddress)
    by UserPrincipalName
// Aggregation produces one row per user  well within 10,000 limit
// If you need raw events, narrow the time window or add filters

Anti-pattern 3: join type mismatch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// WRONG: inner join drops users with no matching activity
let suspects = SigninLogs
| where TimeGenerated > ago(7d)
| where RiskLevelDuringSignIn == "high"
| distinct UserPrincipalName;
suspects
| join kind=inner (
    CloudAppEvents | where TimeGenerated > ago(7d)
) on $left.UserPrincipalName == $right.AccountId
// Users with risky sign-ins but NO CloudAppEvents activity
//   are silently dropped from results
// A compromised account that has not yet performed cloud actions
//   disappears from the hunt  false negative

// CORRECT: leftouter preserves all suspects
suspects
| join kind=leftouter (
    CloudAppEvents | where TimeGenerated > ago(7d)
    | summarize CloudActions = count() by AccountId
) on $left.UserPrincipalName == $right.AccountId
// Users with no CloudAppEvents activity show CloudActions = null
// The ABSENCE of activity may itself be an indicator
//   (account compromised but attacker using a different access path)

Anti-pattern 4: Filtering before join loses context

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// WRONG: Pre-filtering the enrichment table too aggressively
let suspects = ...; // suspect user list
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName == "Consent to application"  // only consent
| join kind=inner suspects on ...
// Misses: MFA registration, role assignment, CA policy change
// The filter restricts to one operation when the hunt needs all

// CORRECT: Join first, then examine all activity
AuditLogs
| where TimeGenerated > ago(7d)
| join kind=inner suspects on ...
| summarize Operations = make_set(OperationName, 20)
    by tostring(InitiatedBy.user.userPrincipalName)
// See ALL audit operations by suspect users
// Then filter to the interesting ones based on what you find

Anti-pattern 5: Baseline contamination

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// WRONG: Baseline overlaps with detection window
let baseline = SigninLogs
| where TimeGenerated > ago(30d)  // Includes the last 7 days!
| summarize KnownIPs = make_set(IPAddress) by UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(7d)
| join baseline on UserPrincipalName
| where not(IPAddress in (KnownIPs))
// The attacker's IP from the last 7 days is IN the baseline
// because the baseline includes the detection window
// The attacker's IP is classified as "known"  false negative

// CORRECT: Gap window separates baseline from detection
let baseline = SigninLogs
| where TimeGenerated between (ago(37d) .. ago(7d))  // Ends 7 days ago
| summarize KnownIPs = make_set(IPAddress) by UserPrincipalName;
// TH1.10 covers this in detail  the gap prevents contamination

Anti-pattern 6: has vs contains vs == for dynamic fields

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// WRONG: Using == on a field that contains a JSON array
SecurityAlert
| where ExtendedProperties == "anomalousToken"
// ExtendedProperties is a JSON string, not "anomalousToken"
// This matches nothing  ever

// ALSO WRONG: Using has on a comma-separated value
| where RiskEventTypes has "anomalousToken"
// has requires a full term boundary  works for whole words
// But if the value is "anomalousToken,unfamiliarFeatures"
//   without spaces, has may not match correctly

// CORRECT: Parse first, then filter
| extend RiskTypes = parse_json(RiskEventTypes)
| where RiskTypes has_any ("anomalousToken", "tokenIssuerAnomaly")
// Or for ExtendedProperties:
| extend Props = parse_json(ExtendedProperties)
| where tostring(Props.["Techniques"]) has "T1557"

Anti-pattern 7: Time zone confusion in off-hours analysis

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// WRONG: Filtering by UTC hour when your users are in GMT+0
SigninLogs
| where hourofday(TimeGenerated) < 6 or hourofday(TimeGenerated) > 22
// TimeGenerated is UTC. For UK users, this is correct in winter (GMT=UTC)
// but wrong in summer (BST = UTC+1): 22:00 UTC = 23:00 BST
// A sign-in at 23:30 BST (22:30 UTC) is correctly flagged in summer
//   but a sign-in at 05:30 BST (04:30 UTC) is incorrectly flagged

// CORRECT: Adjust for the user's time zone
SigninLogs
| extend LocalHour = hourofday(
    datetime_utc_to_local(TimeGenerated, "Europe/London"))
| where LocalHour < 6 or LocalHour > 22
// datetime_utc_to_local handles DST automatically
// For multi-timezone organizations, join with a user→timezone lookup
SEVEN ANTI-PATTERNS — QUERIES THAT SILENTLY MISS THREATS1. CASESENSITIVEFix: use =~or has2. TRUNCATEDRESULTSFix: summarizebefore limit3. JOIN TYPEMISMATCHFix: leftouterpreserves nulls4. PRE-FILTERTOO AGGRESSIVEFix: join first,filter after5. BASELINECONTAMINATEDFix: gap window(TH1.10)6. DYNAMICFIELD MATCHFix: parse_jsonthen filter7. TIMEZONEFix: utc_to_local()Each anti-pattern produces a query that runs without errors and returns plausible results.The results are wrong — silently missing the threat the hunt was designed to find.

Figure TH2.15 — Seven KQL anti-patterns that produce false negatives. Each runs without errors. Each misses threats. The peer review checklist from TH1.15 catches these before they close a hunt incorrectly.

Try it yourself

Exercise: Audit your existing queries for anti-patterns

Review the hunt queries you wrote during the TH1 and TH2 exercises. Check each against the seven anti-patterns:

1. Did you use == for string comparisons? Switch to =~ or has.

2. Could any query hit the 10,000-row limit without summarize? Add aggregation.

3. Did you use inner join where leftouter would preserve more context?

4. Did you pre-filter enrichment tables before joining?

5. Does your baseline overlap with the detection window?

6. Did you filter dynamic columns without parsing first?

7. Did you use UTC hours for off-hours analysis?

Fix any anti-patterns found. This review builds the habit that TH1.15 (QA) formalizes.

⚠ Compliance Myth: "If the query runs without errors, the results are correct"

The myth: A query that executes successfully produces valid results. Syntax correctness implies logical correctness.

The reality: KQL does not validate logic. A case-sensitive comparison on an inconsistently capitalized field returns partial results with no warning. A truncated result set looks identical to a complete one. A contaminated baseline produces plausible but wrong baselines. The query executes. The data looks reasonable. The conclusion is wrong. Anti-pattern awareness is the defense — there is no automated check. The peer review process from TH1.15 is the organizational defense.

Extend this awareness

New anti-patterns emerge as M365 log schemas change. Microsoft periodically modifies column names, data types, and nesting structures across their security log tables. A query that worked correctly against last month's schema may fail silently against this month's schema if a column was renamed or a value format changed. Before running a campaign module's queries in production, verify the column names and value formats against the current Microsoft Learn documentation for each table. TH0.10 (data sources) provides the reference, but the canonical source is always the current Microsoft documentation.


References Used in This Subsection

You're reading the free modules of this course

The full course continues with advanced topics, production detection rules, worked investigation scenarios, and deployable artifacts. Premium subscribers get access to all courses.

View Pricing See Full Syllabus