Building Reusable Hunting Queries in KQL.
Every investigation started the same way.
Same time filter. Same suspicious indicators. Same tables. Same joins.
Agent Foskett realised the answer was not to write the same query again.
The answer was to build hunting queries that could be reused, adapted and trusted.
Lesson overview
Learn how to turn individual KQL techniques into reusable Microsoft Defender XDR hunting queries using variables, let statements, modular query blocks, joins, summaries and investigation workflows.
Why reusable hunting queries matter
The reusable hunting workflow
Step 1 — Start with reusable variables
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
let TimeFrame = 7d;
DeviceProcessEvents
| where Timestamp > ago(TimeFrame)
| where FileName in~ ("powershell.exe", "cmd.exe", "rundll32.exe")
| project Timestamp,
DeviceName,
AccountName,
FileName,
ProcessCommandLine
| order by Timestamp descStep 2 — Store indicators in a dynamic list
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
let TimeFrame = 7d;
let SuspiciousDomains = dynamic(["example-bad-domain.com", "login-check.example", "update-service.example"]);
DeviceNetworkEvents
| where Timestamp > ago(TimeFrame)
| where RemoteUrl has_any (SuspiciousDomains)
| project Timestamp,
DeviceName,
InitiatingProcessFileName,
RemoteUrl,
RemoteIP,
ActionType
| order by Timestamp descStep 3 — Build a reusable email evidence block
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
let TimeFrame = 7d;
let SuspiciousSenderDomains = dynamic(["contoso-security.example", "account-alert.example"]);
let EmailEvidence =
EmailEvents
| where Timestamp > ago(TimeFrame)
| where SenderFromDomain has_any (SuspiciousSenderDomains)
| project EmailTime = Timestamp,
NetworkMessageId,
SenderFromAddress,
RecipientEmailAddress,
Subject,
DeliveryAction,
DeliveryLocation;
EmailEvidence
| order by EmailTime descStep 4 — Build a reusable endpoint evidence block
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
let TimeFrame = 7d;
let SuspiciousProcessNames = dynamic(["powershell.exe", "mshta.exe", "rundll32.exe", "wscript.exe"]);
let EndpointEvidence =
DeviceProcessEvents
| where Timestamp > ago(TimeFrame)
| where FileName in~ (SuspiciousProcessNames)
| project ProcessTime = Timestamp,
DeviceId,
DeviceName,
AccountName,
FileName,
ProcessCommandLine;
EndpointEvidence
| order by ProcessTime descStep 5 — Summarise reusable evidence
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
let TimeFrame = 7d;
DeviceNetworkEvents
| where Timestamp > ago(TimeFrame)
| where isnotempty(RemoteUrl) or isnotempty(RemoteIP)
| summarize ConnectionCount = count(),
Devices = make_set(DeviceName, 20),
Processes = make_set(InitiatingProcessFileName, 20),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp)
by RemoteUrl,
RemoteIP
| order by ConnectionCount descStep 6 — Reuse arg_max() for latest activity
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
let TimeFrame = 30d;
IdentityLogonEvents
| where Timestamp > ago(TimeFrame)
| summarize arg_max(Timestamp, *) by AccountUpn
| project Timestamp,
AccountUpn,
ActionType,
IPAddress,
DeviceName,
FailureReason
| order by Timestamp descStep 7 — Combine reusable modules into a phishing hunt
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
let TimeFrame = 7d;
let SuspiciousTerms = dynamic(["invoice", "payment", "password", "verify", "urgent"]);
let SuspiciousExtensions = dynamic([".html", ".htm", ".js", ".vbs", ".lnk"]);
let EmailEvidence =
EmailEvents
| where Timestamp > ago(TimeFrame)
| where Subject has_any (SuspiciousTerms)
| project EmailTime = Timestamp,
NetworkMessageId,
SenderFromAddress,
RecipientEmailAddress,
Subject,
DeliveryAction,
DeliveryLocation;
let AttachmentEvidence =
EmailAttachmentInfo
| where Timestamp > ago(TimeFrame)
| where FileName has_any (SuspiciousExtensions)
| project NetworkMessageId,
FileName,
SHA256;
EmailEvidence
| join kind=leftouter AttachmentEvidence on NetworkMessageId
| summarize Recipients = make_set(RecipientEmailAddress, 50),
AttachmentNames = make_set(FileName, 20),
MessageCount = count(),
FirstSeen = min(EmailTime),
LastSeen = max(EmailTime)
by SenderFromAddress,
Subject,
DeliveryAction,
DeliveryLocation
| order by MessageCount descReusable query patterns to remember
Real-world investigation
Investigation checklist
Related Agent Foskett Academy lessons
Coming next
Final thought
Building Reusable Hunting Queries in KQL
Agent Foskett Academy Lesson 100 teaches defenders how to build reusable KQL hunting queries using let statements, variables, modular query blocks, joins and Microsoft Defender XDR investigation patterns.
Learn reusable KQL hunting queries for Microsoft Defender XDR
Reusable KQL queries help Microsoft security analysts investigate phishing, endpoint activity, identity events, URLs, attachments and suspicious indicators with consistent and repeatable threat hunting workflows.
KQL reusable hunting query tutorial for Microsoft security analysts
This lesson explains how to combine let statements, dynamic lists, joins, arg_max(), make_set(), mv-expand, mv-apply and regex into practical Microsoft Defender XDR hunting queries.
