TH2.13 Reusable Functions and Saved Queries

4-5 hours · Module 2 · Free
Operational Objective
You will write the same baseline comparison, the same entity enrichment, and the same VPN exclusion logic across dozens of hunts. Copy-pasting queries between campaigns introduces errors and makes updates painful. KQL user-defined functions and Sentinel saved queries let you write the logic once and reference it everywhere — keeping your hunt queries clean and your exclusion lists maintainable.
Deliverable: The ability to create and use KQL functions for hunting — including per-user IP baselines, VPN exclusion lists, and role-based entity lookups — and to manage them through Sentinel saved queries or Advanced Hunting custom functions.
⏱ Estimated completion: 25 minutes

Write once, hunt many times

Three patterns appear in nearly every campaign module:

  1. The per-user IP baseline (TH4, TH6, TH7, TH10, TH13)
  2. The VPN/proxy exclusion filter (TH4, TH5, TH8, TH13)
  3. The admin account identification (TH6, TH7, TH8)

Writing these from scratch in each hunt wastes time and introduces inconsistency. A function encapsulates the logic. The hunt query calls the function.

Inline functions with let

KQL supports inline function definitions using let. These exist only for the duration of the query — they are not persisted.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Inline function: per-user IP baseline for the last 30 days
let UserIPBaseline = (baselineStart:timespan, baselineEnd:timespan) {
    SigninLogs
    | where TimeGenerated between (ago(baselineStart) .. ago(baselineEnd))
    | where ResultType == 0
    | summarize
        KnownIPs = make_set(IPAddress, 30),
        KnownCountries = make_set(
            tostring(LocationDetails.countryOrRegion), 10)
        by UserPrincipalName
};
// Use it in the hunt query:
let baseline = UserIPBaseline(37d, 7d);
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| join kind=inner baseline on UserPrincipalName
| where not(IPAddress in (KnownIPs))
// The function encapsulates the baseline logic
// Change the baseline window by changing the parameters
// The hunt query stays clean and focused on the detection logic

Persistent functions in Sentinel

For functions you use across multiple hunts over weeks or months, create them as saved functions in Sentinel’s Log Analytics workspace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Create this as a saved function named "HuntUserIPBaseline"
// In Log Analytics: Save  Save as function
// Function name: HuntUserIPBaseline
// Parameters: BaselineDays:int = 30, GapDays:int = 7
SigninLogs
| where TimeGenerated between (
    ago(totimespan(strcat(BaselineDays + GapDays, "d")))
    .. ago(totimespan(strcat(GapDays, "d"))))
| where ResultType == 0
| summarize
    KnownIPs = make_set(IPAddress, 30),
    KnownCountries = make_set(
        tostring(LocationDetails.countryOrRegion), 10),
    AvgDailySignIns = count() / BaselineDays
    by UserPrincipalName

Once saved, any hunt query can call it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Call the saved function  clean, readable hunt query
let baseline = HuntUserIPBaseline(30, 7);
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| join kind=inner baseline on UserPrincipalName
| where not(IPAddress in (KnownIPs))
| summarize NewIPCount = dcount(IPAddress),
    NewIPs = make_set(IPAddress, 5)
    by UserPrincipalName
| where NewIPCount > 0

Additional hunting functions to build

Download volume baseline: Reused in TH8 (exfiltration) and TH13 (insider threats).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Save as function: HuntUserDownloadBaseline
// Parameters: BaselineDays:int = 30, GapDays:int = 7
CloudAppEvents
| where TimeGenerated between (
    ago(totimespan(strcat(BaselineDays + GapDays, "d")))
    .. ago(totimespan(strcat(GapDays, "d"))))
| where ActionType == "FileDownloaded"
| summarize
    BaselineTotal = count(),
    BaselineDaily = count() / BaselineDays,
    BaselineSites = make_set(Application, 20)
    by AccountId
// Per-user download volume and site access baseline
// Call in TH8: HuntUserDownloadBaseline(30, 7)
// Compare current week against baseline to find exfiltration spikes

Admin account identification: Reused across TH6, TH7, TH8, TH13 — some hunts need to include admins, others need to exclude them.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Save as function: HuntGetAdminUsers
// No parameters  returns current admin role holders
IdentityInfo
| where TimeGenerated > ago(1d)
| where AssignedRoles has_any (
    "Global Administrator", "Exchange Administrator",
    "Security Administrator", "Conditional Access Administrator",
    "Privileged Role Administrator", "User Administrator")
| distinct AccountUPN, AssignedRoles
// If IdentityInfo is not ingested, alternative:
// AuditLogs  extract users with active role assignments
// Use in hunts:
//   Exclude admins: | where UserPrincipalName !in (HuntGetAdminUsers())
//   Target admins only: | where UserPrincipalName in (HuntGetAdminUsers())

Function maintenance discipline

Functions rot the same way detection rules do. The IP baseline function built against a 30-day window when your organization had 500 users may need adjustment when you grow to 2,000 — the make_set limits and performance characteristics change.

Review saved functions quarterly alongside your detection rule maintenance cycle:

  • Are the functions still called by active hunts? (Orphaned functions = dead code)
  • Are the parameters still appropriate? (Baseline windows, thresholds)
  • Are the underlying table schemas unchanged? (Microsoft may rename columns)
  • Are the watchlists referenced by functions still current? (VPN IPs, admin accounts)

Watchlists for exclusion data

VPN IP ranges, known admin accounts, and approved application lists change over time. Hardcoding them in queries means updating every query when the list changes. Sentinel watchlists store the lists centrally.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Create a watchlist "HuntExclusions_VPNIPs" with columns:
//   IPRange (string), Description (string), AddedDate (datetime)
// Query it in any hunt:
let vpnIPs = _GetWatchlist('HuntExclusions_VPNIPs')
| project IPRange;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| where not(IPAddress has_any (vpnIPs))
// All hunt queries reference the same watchlist
// Update the watchlist once  all queries use the updated list
// No query editing required when VPN infrastructure changes
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Watchlist for approved OAuth applications (TH5/TH6/TH11 exclusion)
// Applications on this list are pre-approved and should not
//   trigger OAuth consent hunting alerts
let approvedApps = _GetWatchlist('HuntExclusions_ApprovedApps')
| project AppName;
AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName == "Consent to application"
| extend AppName = tostring(TargetResources[0].displayName)
| where AppName !in (approvedApps)
// Only consents for UNAPPROVED applications  the hunting targets
// Review and update the approved apps watchlist quarterly
//   New legitimate apps get added; deprecated apps get removed
//   Each addition should be documented with business justification
1
2
3
4
5
6
7
8
// Watchlist for known admin accounts (exclude from non-admin hunts)
let adminAccounts = _GetWatchlist('HuntExclusions_Admins')
| project UPN;
AuditLogs
| where TimeGenerated > ago(7d)
| where InitiatedBy.user.userPrincipalName !in (adminAccounts)
// Hunts for non-admin behavior automatically exclude admin accounts
// Admin-specific hunts (TH7) use the same list as the inclusion filter
REUSABLE COMPONENTS — WRITE ONCE, HUNT MANYINLINE FUNCTIONS (let)Scope: single queryBest for: one-off hunt logicNo persistence — recreate each sessionSAVED FUNCTIONSScope: Log Analytics workspaceBest for: reused baseline logicPersistent — available in all queriesWATCHLISTSScope: Sentinel workspaceBest for: exclusion lists, entity listsUpdate list once → all queries updatedFunctions encapsulate logic. Watchlists centralize data. Both eliminate copy-paste across hunts.

Figure TH2.13 — Three mechanisms for reusable hunting components. Inline for single queries, saved functions for persistent logic, watchlists for updatable data.

Try it yourself

Exercise: Create a VPN exclusion watchlist

Identify your organization's VPN egress IP ranges. Create a Sentinel watchlist named "HuntExclusions_VPNIPs" with columns IPRange and Description. Add 3–5 IP ranges.

Then run one of the authentication baseline queries from TH2.1 with the watchlist exclusion. Compare the result count with and without the exclusion — the reduction shows how many false positives the watchlist eliminates. This watchlist will be used in TH4, TH5, and every authentication-based campaign.

⚠ Compliance Myth: "Saved functions are a security risk — they could be modified to hide threats"

The myth: A malicious insider could modify a saved function or watchlist to exclude attacker IPs from all hunt queries — creating a blind spot.

The reality: This is a legitimate concern, not a myth. Saved functions and watchlists should be managed with the same change control as detection rules. Restrict write access to the Log Analytics workspace. Audit changes to functions and watchlists through Azure Activity Logs. Review exclusion lists quarterly to confirm each entry is still valid. The risk is real but manageable — and the alternative (hardcoded exclusions in every query) is harder to audit because the exclusions are scattered across dozens of queries rather than centralized in one reviewable list.

Extend this approach

Advanced Hunting in Defender XDR supports custom functions through the "Custom functions" feature. These persist across sessions and are available to all analysts with access to the portal. If your primary hunting platform is Defender XDR rather than Sentinel, use custom functions there instead of Sentinel saved functions. The KQL syntax is identical. TH16 covers function management across both platforms.


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