Agent Foskett Academy • KQL Academy • Module 14 • Lesson 170 • Detection Engineering & Proactive Threat Hunting

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.

A reusable hunt is more than saved KQL. It carries its purpose, parameters, assumptions, evidence and expected output with it.
Agent Foskett KQL Academy reusable threat hunting
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.

✓ Separate parameters from logic
✓ Preserve investigation fields
✓ Document assumptions
✓ Standardise the output

Hunting briefing

ONE-OFF HUNT ↓ IT WORKED ↓ BUT WHY? Hard-coded values + hidden assumptions ↓ EXTRACT PARAMETERS ↓ SEPARATE CORE LOGIC ↓ PRESERVE INVESTIGATION CONTEXT ↓ STANDARDISE OUTPUT ↓ DOCUMENT PURPOSE + LIMITATIONS ↓ ANOTHER ANALYST CAN RUN IT ↓ REUSABLE HUNTING CAPABILITY

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.

01-expose-hunt-parameters.kql
1234567891011121314151617181920212223242526
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,
          SignalCount

Parameters 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.

02-centralise-signal-definitions.kql
123456789101112131415161718192021222324252627
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,
          ProcessCommandLine

Readable 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.

03-preserve-investigation-context.kql
1234567891011121314151617181920212223242526272829
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, SignalCount

Output 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.

04-make-correlation-repeatable.kql
123456789101112131415161718192021222324252627282930313233
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, RemotePort

Hidden 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.

05-standardise-hunt-output.kql
1234567891011121314151617181920212223242526272829303132
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 desc

Consistency 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.

06-reusable-threat-hunt.kql
123456789101112131415161718192021222324252627282930313233343536373839404142
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 desc

Documentation 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

SUCCESSFUL INVESTIGATION ↓ IDENTIFY THE SECURITY HYPOTHESIS ↓ EXTRACT TUNABLE PARAMETERS ↓ CENTRALISE SIGNAL DEFINITIONS ↓ PRESERVE ENTITY + PIVOT FIELDS ↓ MAKE CORRELATION BOUNDARIES EXPLICIT ↓ STANDARDISE RESULT COLUMNS ↓ ADD HUMAN-READABLE REASONS ↓ DOCUMENT PURPOSE + ASSUMPTIONS ↓ PEER TEST ↓ SAVE + VERSION + REVIEW ↓ REUSABLE HUNT
The best reusable hunt transfers the analyst's reasoning, not just the analyst's query.

From one-off query to reusable hunt

One-off huntReusable huntWhy it matters
Hard-coded time rangeNamed HuntWindow parameterAnalysts can safely adjust scope without searching through the logic.
Signal terms scattered through KQLCentralised definitionsTuning becomes easier to review and maintain.
Minimal projected columnsEntity and pivot fields preservedResults can move directly into investigation.
Implicit correlation assumptionsDocumented keys and time boundariesAnalysts understand why events are considered related.
Only the author understands itPurpose, assumptions and limitations travel with the queryKnowledge 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.

Module 14 complete. The hunt no longer belongs to one analyst — the reasoning can now be repeated, tested and improved by the team.

Continue your KQL investigation training

Module 14 turns investigation knowledge into proactive hunts, reusable analytics and higher-confidence detections.

🔎 KQL Academy — Module 14: Advanced Detection Engineering & Proactive Threat Hunting

Turn investigation knowledge into proactive hunts, reusable analytics and higher-confidence detections.

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.