Lesson 170 — The Hunt Worked — Now Make It Reusable
The hunt found something useful. We understood the behaviour, validated the results and knew which fields mattered during investigation. Then another analyst asked a very reasonable question:
“Can I run it?”
That exposed the next problem. The query contained hard-coded time ranges, unexplained thresholds and assumptions that existed only in the original analyst's head. A successful hunt is valuable once. A reusable hunt can become part of the team's investigative capability.

Your hunting problem
The original query works, but only its author understands how to change the time window, why the threshold exists and which evidence matters. Refactor it into a hunt another analyst can run safely and interpret consistently.
Hunting briefing
Hunting objective
Turn a successful KQL investigation into a reusable hunting query by exposing tunable parameters, separating core detection logic from presentation, preserving fields needed for pivots, documenting assumptions and producing consistent results that another analyst can understand without reverse-engineering the original hunt.
Threat hunter's rule
If another analyst cannot understand why the query works, it is not reusable yet. Saving KQL in a shared folder does not transfer the reasoning behind it.
Stage 1 — move the tunable values to the top
Start by removing magic numbers buried inside the query. Time windows and thresholds should be visible immediately so another analyst knows which values are intended to change.
let HuntWindow = 7d;
let MinSignalCount = 2;
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend BrowserParent =
InitiatingProcessFileName in~ (
"chrome.exe", "msedge.exe", "firefox.exe"
)
| extend DownloadBehaviour =
ProcessCommandLine has_any (
"Invoke-WebRequest", "DownloadString",
"WebClient", "Start-BitsTransfer"
)
| extend EncodedCommand =
ProcessCommandLine has_any ("-enc", "-encodedcommand")
| extend SignalCount =
toint(BrowserParent)
+ toint(DownloadBehaviour)
+ toint(EncodedCommand)
| where SignalCount >= MinSignalCount
| project Timestamp, DeviceId, DeviceName,
AccountName, ProcessId,
ProcessCommandLine,
InitiatingProcessFileName,
SignalCountParameters reveal intent
HuntWindow and MinSignalCount tell the next analyst which parts of the query are operational choices rather than fixed facts.
Do not parameterise everything
Too many knobs can make a hunt harder to use. Expose values analysts genuinely need to adjust and leave stable logic inside the query.
Stage 2 — centralise reusable signal definitions
Lists of process names and command-line terms are easier to review when they are declared together. This also makes future tuning less likely to leave inconsistent copies of the same logic scattered throughout the query.
let HuntWindow = 7d;
let SuspiciousTools = dynamic([
"powershell.exe",
"pwsh.exe"
]);
let BrowserParents = dynamic([
"chrome.exe",
"msedge.exe",
"firefox.exe"
]);
let DownloadTerms = dynamic([
"Invoke-WebRequest",
"DownloadString",
"WebClient",
"Start-BitsTransfer"
]);
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ (SuspiciousTools)
| extend BrowserParent =
InitiatingProcessFileName in~ (BrowserParents)
| extend DownloadBehaviour =
ProcessCommandLine has_any (DownloadTerms)
| where BrowserParent or DownloadBehaviour
| project Timestamp, DeviceName, AccountName,
FileName, InitiatingProcessFileName,
ProcessCommandLineReadable logic is maintainable logic
An analyst reviewing the hunt can now see the tools, parent processes and download terms before reading the event pipeline.
Lists need ownership
Reusable does not mean permanent. Signal definitions should be reviewed as applications, attacker techniques and the environment change.
Stage 3 — preserve the fields needed for investigation
A hunt that returns only a device name and timestamp forces the next analyst to rebuild context. Preserve entity and pivot fields such as DeviceId, ProcessId, command line and hash when they are available and relevant.
let HuntWindow = 7d;
let BaseHunt =
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend BrowserParent =
InitiatingProcessFileName in~ (
"chrome.exe", "msedge.exe", "firefox.exe"
)
| extend DownloadBehaviour =
ProcessCommandLine has_any (
"Invoke-WebRequest", "DownloadString",
"WebClient", "Start-BitsTransfer"
)
| extend EncodedCommand =
ProcessCommandLine has_any (
"-enc", "-encodedcommand"
)
| extend SignalCount =
toint(BrowserParent)
+ toint(DownloadBehaviour)
+ toint(EncodedCommand);
BaseHunt
| where SignalCount >= 2
| project Timestamp, DeviceId, DeviceName,
AccountName, ProcessId,
FileName, ProcessCommandLine,
InitiatingProcessFileName,
SHA1, SignalCountOutput is part of the hunt design
The query should lead naturally into investigation. Think about what the analyst will pivot to next rather than projecting only the fields that make the results look tidy.
Keep stable entity identifiers
Names can change or collide. DeviceId and other stable identifiers make later correlation more reliable than display names alone.
Stage 4 — make correlation assumptions explicit
If the hunt joins multiple tables, document the relationship in the KQL itself. Here network evidence is tied to the same device and process, with a bounded ten-minute window after process creation.
let HuntWindow = 7d;
let ProcessHunt =
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend DownloadBehaviour =
ProcessCommandLine has_any (
"Invoke-WebRequest", "DownloadString",
"WebClient", "Start-BitsTransfer"
)
| where DownloadBehaviour
| project ProcessTime=Timestamp,
DeviceId, DeviceName,
AccountName, ProcessId,
ProcessCommandLine;
ProcessHunt
| join kind=leftouter (
DeviceNetworkEvents
| where Timestamp > ago(HuntWindow)
| project NetworkTime=Timestamp,
DeviceId, InitiatingProcessId,
RemoteIP, RemoteUrl, RemotePort
) on DeviceId
| where isnull(InitiatingProcessId)
or InitiatingProcessId == ProcessId
| where isnull(NetworkTime)
or NetworkTime between
(ProcessTime .. ProcessTime + 10m)
| project ProcessTime, DeviceId, DeviceName,
AccountName, ProcessId,
ProcessCommandLine,
NetworkTime, RemoteIP,
RemoteUrl, RemotePortHidden joins create hidden risk
Another analyst should be able to see exactly why two events are considered related. Device-only joins or unexplained time windows can create convincing but false correlations.
Nulls need deliberate handling
Decide whether missing network evidence should remove the candidate or simply mean that no matching connection was observed. That choice belongs to the security hypothesis.
Stage 5 — standardise an explainable result
Reusable hunts should return a predictable set of columns and explain why each candidate surfaced. A reason field helps another analyst interpret results without mentally replaying every Boolean expression.
let HuntWindow = 7d;
let MinSignalCount = 2;
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend BrowserParent =
InitiatingProcessFileName in~ (
"chrome.exe", "msedge.exe", "firefox.exe"
)
| extend DownloadBehaviour =
ProcessCommandLine has_any (
"Invoke-WebRequest", "DownloadString",
"WebClient", "Start-BitsTransfer"
)
| extend EncodedCommand =
ProcessCommandLine has_any ("-enc", "-encodedcommand")
| extend SignalCount =
toint(BrowserParent)
+ toint(DownloadBehaviour)
+ toint(EncodedCommand)
| where SignalCount >= MinSignalCount
| extend HuntReasons =
strcat(
iff(BrowserParent, "Browser parent; ", ""),
iff(DownloadBehaviour, "Download behaviour; ", ""),
iff(EncodedCommand, "Encoded command; ", "")
)
| project Timestamp, DeviceId, DeviceName,
AccountName, ProcessId, FileName,
ProcessCommandLine, SHA1,
SignalCount, HuntReasons
| order by SignalCount desc, Timestamp descConsistency speeds triage
When hunts expose timestamp, device, account, process evidence, score and reasons consistently, analysts spend less time learning the query and more time investigating the behaviour.
Do not hide the raw evidence
A reason such as “Encoded command” is useful, but the original command line remains the evidence. Keep both.
Stage 6 — package the reasoning with the KQL
The final version includes concise documentation at the top. The comments explain the purpose, expected telemetry and values that require environmental review. The query can now travel with enough context to be used responsibly.
let HuntWindow = 7d;
let MinSignalCount = 2;
// PURPOSE:
// Find PowerShell activity where multiple suspicious
// behavioural signals occur in the same process event.
//
// EXPECTED TABLE:
// Microsoft Defender XDR - DeviceProcessEvents
//
// REVIEW BEFORE USE:
// HuntWindow, signal terms and MinSignalCount should
// be adjusted for the environment being investigated.
DeviceProcessEvents
| where Timestamp > ago(HuntWindow)
| where FileName in~ ("powershell.exe", "pwsh.exe")
| extend BrowserParent =
InitiatingProcessFileName in~ (
"chrome.exe", "msedge.exe", "firefox.exe"
)
| extend DownloadBehaviour =
ProcessCommandLine has_any (
"Invoke-WebRequest", "DownloadString",
"WebClient", "Start-BitsTransfer"
)
| extend EncodedCommand =
ProcessCommandLine has_any ("-enc", "-encodedcommand")
| extend SignalCount =
toint(BrowserParent)
+ toint(DownloadBehaviour)
+ toint(EncodedCommand)
| where SignalCount >= MinSignalCount
| extend HuntReasons =
strcat(
iff(BrowserParent, "Browser parent; ", ""),
iff(DownloadBehaviour, "Download behaviour; ", ""),
iff(EncodedCommand, "Encoded command; ", "")
)
| project Timestamp, DeviceId, DeviceName,
AccountName, ProcessId,
ProcessCommandLine, SHA1,
SignalCount, HuntReasons
| order by Timestamp descDocumentation is part of detection quality
Useful comments explain assumptions and analyst decisions. They should not narrate every obvious KQL operator.
Version and review reusable hunts
When a hunt becomes shared operational content, changes should be reviewable. Record significant tuning decisions and retest the hunt when telemetry schemas or environmental behaviour change.
Agent Foskett's reusable hunting workflow
From one-off query to reusable hunt
| One-off hunt | Reusable hunt | Why it matters |
|---|---|---|
| Hard-coded time range | Named HuntWindow parameter | Analysts can safely adjust scope without searching through the logic. |
| Signal terms scattered through KQL | Centralised definitions | Tuning becomes easier to review and maintain. |
| Minimal projected columns | Entity and pivot fields preserved | Results can move directly into investigation. |
| Implicit correlation assumptions | Documented keys and time boundaries | Analysts understand why events are considered related. |
| Only the author understands it | Purpose, assumptions and limitations travel with the query | Knowledge becomes a team capability instead of personal memory. |
Write the reusable hunt like a detection engineer
Example: The successful hunting query was refactored into a reusable package by moving operational values into named parameters, centralising behavioural signal definitions and preserving stable entity identifiers required for follow-up investigation. Correlation keys and time boundaries were made explicit, and the output was standardised to retain raw evidence alongside human-readable hunting reasons. The hunt's purpose, expected telemetry and environmental assumptions were documented before peer testing so another analyst could run and interpret the query without relying on undocumented knowledge from the original investigation.
Lesson 170 key takeaways
- A successful one-off query is not automatically a reusable hunt.
- Expose time windows, thresholds and other genuine tuning parameters clearly.
- Centralise behavioural signal definitions so they can be reviewed consistently.
- Preserve stable entity identifiers and fields required for investigation pivots.
- Make join keys and correlation time windows explicit.
- Standardise result columns so analysts know what to expect.
- Keep raw evidence alongside scores and human-readable reasons.
- Document the hunt's purpose, expected telemetry, assumptions and limitations.
- Peer-test reusable hunts with someone who did not write the original query.
- Review and retest shared hunts as telemetry and environmental behaviour change.
Module 14 complete — from hunting idea to operational detection engineering
Across Module 14 we moved beyond writing queries that merely find interesting events. We built behavioural hypotheses, established baselines, investigated drift, combined weak signals, backtested detections, controlled operational volume and finally turned successful hunting logic into reusable team capability.
Continue your KQL investigation training
🔎 KQL Academy — Module 14: Advanced Detection Engineering & Proactive Threat Hunting
Build reusable KQL threat hunts
Lesson 170 of the Agent Foskett KQL Academy shows how to turn successful Microsoft Defender XDR threat-hunting logic into a reusable, parameterised and documented hunting query.
Transfer threat-hunting reasoning between analysts
Learn how to expose parameters, centralise signal definitions, preserve investigation context, document correlation assumptions and standardise KQL hunting output for repeatable use.
