Skip to content

liliyke/kql-telemetry-detection-automation

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

# KQL Telemetry, Detection & Automation 50 KQL recipes for Microsoft Sentinel and Defender XDR. Each one runs in the Sentinel Logs blade or the Defender XDR Advanced Hunting blade. The recipes assume the default ingest tables (`SigninLogs`, `SecurityEvent`, `OfficeActivity`, `DeviceEvents`, `DeviceProcessEvents`, etc.) — adjust table names if you've remapped them. Sections: - **Telemetry** (1–20) — read, parse, project, join, enrich. - **Detection** (21–35) — failed-login burst, impossible travel, LSASS access, encoded PowerShell, new admin, OAuth consent grant, beacon, DGA. - **Automation** (36–50) — analytic rules, watchlists, workbooks, functions, materialized views, cross-workspace, Logic App triggers. --- ## Table of contents | # | Recipe | |---|---| | 1 | [List recent SecurityEvent records](#1-list-recent-securityevent-records) | | 2 | [Filter SigninLogs to failures](#2-filter-signinlogs-to-failures) | | 3 | [Parse a JSON field with parse_json](#3-parse-a-json-field-with-parse_json) | | 4 | [Parse a CSV string in a column](#4-parse-a-csv-string-in-a-column) | | 5 | [Base64-decode an encoded command](#5-base64-decode-an-encoded-command) | | 6 | [Bucket events into 5-minute windows](#6-bucket-events-into-5-minute-windows) | | 7 | [Render a timechart](#7-render-a-timechart) | | 8 | [Top N source IPs by failures](#8-top-n-source-ips-by-failures) | | 9 | [Distinct count of users per host](#9-distinct-count-of-users-per-host) | | 10 | [Join SigninLogs with IdentityInfo](#10-join-signinlogs-with-identityinfo) | | 11 | [externaldata for a lookup table](#11-externaldata-for-a-lookup-table) | | 12 | [Use let for parameterized queries](#12-use-let-for-parameterized-queries) | | 13 | [Sysmon EID 1 process creations](#13-sysmon-eid-1-process-creations) | | 14 | [OfficeActivity mailbox events](#14-officeactivity-mailbox-events) | | 15 | [CloudAppEvents from Defender for Cloud Apps](#15-cloudappevents-from-defender-for-cloud-apps) | | 16 | [DeviceProcessEvents in Defender XDR](#16-deviceprocessevents-in-defender-xdr) | | 17 | [DeviceNetworkEvents](#17-devicenetworkevents) | | 18 | [DeviceLogonEvents](#18-devicelogonevents) | | 19 | [AzureActivity](#19-azureactivity) | | 20 | [Parse XML payloads](#20-parse-xml-payloads) | | 21 | [Failed sign-in burst from one IP](#21-failed-sign-in-burst-from-one-ip) | | 22 | [Impossible-travel via haversine](#22-impossible-travel-via-haversine) | | 23 | [LSASS access (Sysmon EID 10)](#23-lsass-access-sysmon-eid-10) | | 24 | [PowerShell -EncodedCommand](#24-powershell--encodedcommand) | | 25 | [New local admin (4720 + 4732 join)](#25-new-local-admin-4720--4732-join) | | 26 | [Lateral SMB by user + host pair](#26-lateral-smb-by-user--host-pair) | | 27 | [Off-hours admin sign-ins](#27-off-hours-admin-sign-ins) | | 28 | [WMI persistence creation triplet](#28-wmi-persistence-creation-triplet) | | 29 | [Mailbox forwarding rule created](#29-mailbox-forwarding-rule-created) | | 30 | [OAuth high-privilege consent grant](#30-oauth-high-privilege-consent-grant) | | 31 | [Office process spawning PowerShell](#31-office-process-spawning-powershell) | | 32 | [Rundll32 with suspicious parent](#32-rundll32-with-suspicious-parent) | | 33 | [Beacon detection via interval regularity](#33-beacon-detection-via-interval-regularity) | | 34 | [DGA via subdomain entropy](#34-dga-via-subdomain-entropy) | | 35 | [New cloud admin role assignment](#35-new-cloud-admin-role-assignment) | | 36 | [Save query as analytic rule](#36-save-query-as-analytic-rule) | | 37 | [Entity mappings for incident graph](#37-entity-mappings-for-incident-graph) | | 38 | [Create a watchlist via externaldata](#38-create-a-watchlist-via-externaldata) | | 39 | [Schedule a hunting query](#39-schedule-a-hunting-query) | | 40 | [Ingest custom logs via Data Collector](#40-ingest-custom-logs-via-data-collector) | | 41 | [Cross-workspace query](#41-cross-workspace-query) | | 42 | [Define a function (saved KQL helper)](#42-define-a-function-saved-kql-helper) | | 43 | [Materialized view for high-volume table](#43-materialized-view-for-high-volume-table) | | 44 | [ipv4_lookup for ASN enrichment](#44-ipv4_lookup-for-asn-enrichment) | | 45 | [GeoIP enrichment with externaldata](#45-geoip-enrichment-with-externaldata) | | 46 | [Logic App trigger from query result](#46-logic-app-trigger-from-query-result) | | 47 | [Workbook tile from a query](#47-workbook-tile-from-a-query) | | 48 | [Anomaly detection with series_decompose_anomalies](#48-anomaly-detection-with-series_decompose_anomalies) | | 49 | [adx() bridge to Azure Data Explorer](#49-adx-bridge-to-azure-data-explorer) | | 50 | [Compose detection + enrichment in one query](#50-compose-detection--enrichment-in-one-query) | --- ## Telemetry ### 1. List recent SecurityEvent records ```kql SecurityEvent | where TimeGenerated > ago(15m) | take 100 ``` **What it does:** Returns the most recent rows from the Windows Security event table. **Run:** Sentinel Logs blade. Time picker: last 24h is fine, the `where` will narrow further. **Sample output:** rows from `SecurityEvent` with all standard fields. **Tuning:** swap `take` for `top 100 by TimeGenerated desc` if you actually need the *latest* 100 — `take` is non-deterministic order. --- ### 2. Filter SigninLogs to failures ```kql SigninLogs | where TimeGenerated > ago(1h) | where ResultType != 0 | project TimeGenerated, UserPrincipalName, IPAddress, ResultType, ResultDescription ``` **Sample output:** ``` TimeGenerated UserPrincipalName IPAddress ResultType ResultDescription 2026-05-23T09:11Z admin@corp.com 185.220.101.34 50126 Invalid username or password ``` **Tuning:** `AADNonInteractiveUserSignInLogs` has the same shape — query both with `union` when hunting token-based flows. --- ### 3. Parse a JSON field with parse_json ```kql SecurityEvent | where EventID == 4625 | extend Props = parse_json(EventData) | project TimeGenerated, User = tostring(Props.TargetUserName), IP = tostring(Props.IpAddress) ``` **Sample output:** ``` TimeGenerated User IP 2026-05-23T09:11Z admin 185.220.101.34 ``` **Tuning:** `tostring()` after JSON unboxing is cheap and almost always what you want — Sentinel field types default to `dynamic` from JSON. --- ### 4. Parse a CSV string in a column ```kql let raw = datatable(line: string) [ "2026-05-23T09:11Z,jdoe,10.0.4.27,success", "2026-05-23T09:11Z,admin,185.220.101.34,failure" ]; raw | extend parts = split(line, ",") | project ts = tostring(parts[0]), user = tostring(parts[1]), ip = tostring(parts[2]), outcome = tostring(parts[3]) | where outcome == "failure" ``` **Sample output:** ``` ts user ip outcome 2026-05-23T09:11Z admin 185.220.101.34 failure ``` **Tuning:** for quoted CSV (with embedded commas) use `parse_csv` instead of `split`. --- ### 5. Base64-decode an encoded command ```kql DeviceProcessEvents | where ProcessCommandLine matches regex @"(?i)-(?:enc|encodedcommand)\s+([A-Za-z0-9+/=]+)" | extend b64 = extract(@"(?i)-(?:enc|encodedcommand)\s+([A-Za-z0-9+/=]+)", 1, ProcessCommandLine) | extend decoded = base64_decode_tostring(b64) | project TimeGenerated, DeviceName, AccountName, decoded ``` **Sample output:** ``` TimeGenerated DeviceName AccountName decoded ... WIN10-LAB-02 jdoe $s=New-Object Net.WebClient;$s.DownloadString('... ``` **Tuning:** `base64_decode_tostring` in KQL decodes as UTF-8 — for PowerShell which writes UTF-16 LE the output looks "spaced out." Wrap in `replace_string(.., "", "")` to flatten. --- ### 6. Bucket events into 5-minute windows ```kql SigninLogs | where ResultType != 0 | summarize Count = count() by bin(TimeGenerated, 5m), IPAddress | order by Count desc | take 20 ``` **Sample output:** ``` TimeGenerated IPAddress Count 2026-05-23T09:10Z 185.220.101.34 47 ``` **Tuning:** `bin()` is the equivalent of SQL date-truncation. For 1-second buckets use `bin(TimeGenerated, 1s)`. --- ### 7. Render a timechart ```kql SigninLogs | where TimeGenerated > ago(24h) | where ResultType != 0 | summarize FailedCount = count() by bin(TimeGenerated, 15m) | render timechart ``` **Sample output:** an interactive line chart in the Logs blade. **Tuning:** `render columnchart` for bars; `render piechart` for compositions. The render directive is ignored when the query is consumed via API. --- ### 8. Top N source IPs by failures ```kql SigninLogs | where ResultType != 0 | summarize Failures = count(), UniqueUsers = dcount(UserPrincipalName) by IPAddress | top 10 by Failures desc ``` **Sample output:** ``` IPAddress Failures UniqueUsers 185.220.101.34 142 47 ``` **Tuning:** `UniqueUsers` distinguishes spray (high count, many users) from a single user retrying (high count, 1 user). --- ### 9. Distinct count of users per host ```kql SigninLogs | where ResultType == 0 | summarize UserCount = dcount(UserPrincipalName) by AppDisplayName, IPAddress | where UserCount > 10 | order by UserCount desc ``` **Sample output:** ``` AppDisplayName IPAddress UserCount Office 365 Exchange Online 10.0.4.27 45 ``` **Tuning:** `dcount` is an HLL approximation by default. For exact counts on small data use `dcount(..., 4)` (max accuracy). --- ### 10. Join SigninLogs with IdentityInfo ```kql SigninLogs | where TimeGenerated > ago(1h) | where ResultType != 0 | join kind=leftouter ( IdentityInfo | where TimeGenerated > ago(7d) | summarize arg_max(TimeGenerated, Department, JobTitle) by AccountUPN ) on $left.UserPrincipalName == $right.AccountUPN | project TimeGenerated, UserPrincipalName, Department, JobTitle, IPAddress ``` **Sample output:** ``` TimeGenerated UserPrincipalName Department JobTitle IPAddress 2026-05-23T09:11Z admin@corp.com IT Admin 185.220.101.34 ``` **Tuning:** the `arg_max` trick picks the most recent IdentityInfo row per user — `IdentityInfo` is updated every hour with new snapshots. --- ### 11. externaldata for a lookup table ```kql let badIps = externaldata(IP: string) [@"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"] with (format="txt", ignoreFirstRecord=false); SigninLogs | where IPAddress in (badIps) | project TimeGenerated, UserPrincipalName, IPAddress ``` **Sample output:** ``` TimeGenerated UserPrincipalName IPAddress 2026-05-23T09:11Z admin@corp.com 185.220.101.34 ``` **Tuning:** `externaldata` fetches once per query — fast enough for hunts but not for high-frequency analytic rules. For those use Watchlists (see #38). --- ### 12. Use let for parameterized queries ```kql let lookback = 1h; let threshold = 10; SigninLogs | where TimeGenerated > ago(lookback) | where ResultType != 0 | summarize Failures = count() by IPAddress | where Failures >= threshold ``` **Sample output:** ``` IPAddress Failures 185.220.101.34 47 ``` **Tuning:** `let` declarations at the top of a query make it easy to ship — copy-paste the query, change parameters at the top, run. Don't bury them inside subqueries. --- ### 13. Sysmon EID 1 process creations ```kql Event | where Source == "Microsoft-Windows-Sysmon" and EventID == 1 | extend EventData = parse_xml(EventData) | extend Image = tostring(EventData.DataItem.EventData.Data[4]["#text"]) | extend Cmd = tostring(EventData.DataItem.EventData.Data[10]["#text"]) | where Image endswith "\\powershell.exe" | project TimeGenerated, Image, Cmd ``` **Sample output:** ``` TimeGenerated Image Cmd 2026-05-23T09:11Z C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -nop -enc ... ``` **Tuning:** Defender XDR has `DeviceProcessEvents` which is cleaner. Use Sysmon path only when XDR isn't available. --- ### 14. OfficeActivity mailbox events ```kql OfficeActivity | where OfficeWorkload == "Exchange" | where Operation in ("New-InboxRule", "Set-InboxRule") | project TimeGenerated, UserId, ClientIP, Operation, Parameters ``` **Sample output:** ``` TimeGenerated UserId ClientIP Operation Parameters 2026-05-23T11:09Z cfo@yourdomain.com 185.220.101.34 New-InboxRule {...} ``` **Tuning:** `Parameters` is a stringified JSON — `parse_json` it for further field access. --- ### 15. CloudAppEvents from Defender for Cloud Apps ```kql CloudAppEvents | where Application == "Microsoft Exchange Online" | where ActionType in ("New-InboxRule", "Set-Mailbox") | project Timestamp, AccountId, IPAddress, ActionType, RawEventData ``` **Sample output:** structured CloudAppEvents records. **Tuning:** `CloudAppEvents` is the Defender XDR alternative to Sentinel's `OfficeActivity` — same source data, slightly different schema. --- ### 16. DeviceProcessEvents in Defender XDR ```kql DeviceProcessEvents | where Timestamp > ago(1h) | where InitiatingProcessFileName == "winword.exe" | where FileName in ("powershell.exe", "cmd.exe", "wscript.exe", "cscript.exe", "mshta.exe") | project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine ``` **Sample output:** ``` Timestamp DeviceName AccountName FileName ProcessCommandLine ... WIN10-LAB-02 jdoe powershell.exe -enc ... ``` **Tuning:** the classic maldoc signature. Combine with `InitiatingProcessVersionInfoCompanyName == "Microsoft Corporation"` if a real Office is the parent. --- ### 17. DeviceNetworkEvents ```kql DeviceNetworkEvents | where ActionType == "ConnectionSuccess" | where RemotePort !in (80, 443, 53) | where InitiatingProcessFileName !in ("svchost.exe", "msedge.exe", "chrome.exe") | project Timestamp, DeviceName, InitiatingProcessFileName, RemoteIP, RemotePort ``` **Sample output:** ``` Timestamp DeviceName InitiatingProcessFileName RemoteIP RemotePort ... WIN10-LAB-02 update.exe 93.184.216.34 8080 ``` **Tuning:** combine with `DeviceProcessEvents` joined on `InitiatingProcessId` to get full process tree context. --- ### 18. DeviceLogonEvents ```kql DeviceLogonEvents | where LogonType in ("Network", "RemoteInteractive") | where ActionType == "LogonSuccess" | project Timestamp, DeviceName, AccountName, LogonType, RemoteIP, RemoteDeviceName ``` **Sample output:** ``` Timestamp DeviceName AccountName LogonType RemoteIP RemoteDeviceName ... DC01 jdoe RemoteInteractive 10.0.4.27 LAPTOP-12 ``` **Tuning:** lateral movement hunts pair this with `DeviceProcessEvents` for "what did they do after logging in." --- ### 19. AzureActivity ```kql AzureActivity | where TimeGenerated > ago(7d) | where OperationNameValue endswith "Microsoft.Authorization/roleAssignments/write" | project TimeGenerated, Caller, ResourceId, ActivityStatusValue ``` **Sample output:** ``` TimeGenerated Caller ResourceId Status 2026-05-23T09:11Z jdoe@yourdomain.com /subscriptions/.../providers/Microsoft.A... Succeeded ``` **Tuning:** for the role assigned, parse `Properties` JSON — `extend props = parse_json(Properties)`. --- ### 20. Parse XML payloads ```kql let xml_str = "12"; print parsed = parse_xml(xml_str) | extend a = parsed.root.a, b = parsed.root.b ``` **Sample output:** ``` parsed a b {root: {a: "1", b: "2"}} 1 2 ``` **Tuning:** `parse_xml` returns a `dynamic` — index it like JSON. For deeply-nested Sysmon XML, write a small `mv-expand` helper. --- ## Detection ### 21. Failed sign-in burst from one IP ```kql let threshold = 10; let lookback = 1h; SigninLogs | where TimeGenerated > ago(lookback) | where ResultType in (50053, 50056, 50126, 50057, 50064, 50034) | summarize Failures = count(), Users = dcount(UserPrincipalName), SampleUsers = make_set(UserPrincipalName, 10) by IPAddress | where Failures >= threshold and Users >= 5 ``` **Sample output:** ``` IPAddress Failures Users SampleUsers 185.220.101.34 47 12 ["jdoe","admin","root",...] ``` **Tuning:** keep `Users >= 5` — single-user retries (high `Failures`, low `Users`) is forgotten-password noise. --- ### 22. Impossible-travel via haversine ```kql let speed_threshold_kmh = 800; let haversine = (lat1:real, lon1:real, lat2:real, lon2:real) { let R = 6371.0; let dLat = (lat2 - lat1) * pi() / 180; let dLon = (lon2 - lon1) * pi() / 180; let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * pi() / 180) * cos(lat2 * pi() / 180) * sin(dLon/2) * sin(dLon/2); R * 2 * atan2(sqrt(a), sqrt(1 - a)) }; SigninLogs | where ResultType == 0 | where isnotnull(LocationDetails.geoCoordinates) | extend lat = todouble(LocationDetails.geoCoordinates.latitude), lon = todouble(LocationDetails.geoCoordinates.longitude), country = tostring(LocationDetails.countryOrRegion) | order by UserPrincipalName asc, TimeGenerated asc | extend prevTime = prev(TimeGenerated, 1), prevLat = prev(lat, 1), prevLon = prev(lon, 1), prevUser = prev(UserPrincipalName, 1), prevCountry = prev(country, 1) | where UserPrincipalName == prevUser and country != prevCountry | extend hours = (TimeGenerated - prevTime) / 1h | where hours > 0 and hours < 24 | extend dist = haversine(prevLat, prevLon, lat, lon) | extend speed = dist / hours | where speed > speed_threshold_kmh | project TimeGenerated, UserPrincipalName, prevCountry, country, hours, dist, speed ``` **Sample output:** ``` TimeGenerated UserPrincipalName prevCountry country hours dist speed 2026-05-23T09:11Z jdoe@yourdomain.com US Russia 0.27 9100 33700 ``` **Tuning:** for global VPN providers, exclude their egress IPs in a separate `let badIPs = ...` step. --- ### 23. LSASS access (Sysmon EID 10) ```kql DeviceEvents | where ActionType == "OpenProcessApiCall" | extend props = parse_json(AdditionalFields) | where tostring(props.TargetFileName) endswith "lsass.exe" | where tostring(props.GrantedAccess) in ("0x1410", "0x1010", "0x1438", "0x143a") | where InitiatingProcessFileName !in ( "MsMpEng.exe","MsSense.exe","WmiPrvSE.exe","CSFalconService.exe","SentinelAgent.exe" ) | project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, GrantedAccess = tostring(props.GrantedAccess) ``` **Sample output:** ``` Timestamp DeviceName AccountName InitiatingProcessFileName GrantedAccess ... WIN10-LAB-02 jdoe update.exe 0x1410 ``` **Tuning:** for stronger signal, join to `DeviceFileEvents` to require an unsigned source binary. --- ### 24. PowerShell -EncodedCommand ```kql DeviceProcessEvents | where FileName in ("powershell.exe", "pwsh.exe") | where ProcessCommandLine matches regex @"\s-(?:e|ec|enc|encodedcommand)\s+[A-Za-z0-9+/=]{16,}" | extend b64 = extract(@"(?i)\s-(?:enc|encodedcommand)\s+([A-Za-z0-9+/=]+)", 1, ProcessCommandLine) | extend decoded = base64_decode_tostring(b64) | where decoded has_any("IEX", "DownloadString", "FromBase64String") | project Timestamp, DeviceName, decoded ``` **Sample output:** ``` Timestamp DeviceName decoded ... WIN10-LAB-02 $s=New-Object Net.WebClient;$s.DownloadString('http://... ``` **Tuning:** `has_any` is much faster than chained `contains` — Kusto indexes it natively. --- ### 25. New local admin (4720 + 4732 join) ```kql let creates = SecurityEvent | where EventID == 4720 | project CreateTime = TimeGenerated, Computer, TargetUser = TargetUserName; let adds = SecurityEvent | where EventID == 4732 and TargetSid == "S-1-5-32-544" | project AddTime = TimeGenerated, Computer, AddedUser = MemberName; creates | join kind=inner (adds) on Computer | where TargetUser == extract(@"\\(.+)$", 1, AddedUser) | where (AddTime - CreateTime) between (0s .. 5m) | project CreateTime, AddTime, Computer, TargetUser ``` **Sample output:** ``` CreateTime AddTime Computer TargetUser 2026-05-23T09:11Z 2026-05-23T09:11Z FILESERVER-04 aetest_4823 ``` **Tuning:** for domain-level: same pattern with 4728 (added to global group) and SID ending in `-512` (Domain Admins). --- ### 26. Lateral SMB by user + host pair ```kql DeviceLogonEvents | where Timestamp > ago(1d) | where LogonType == "Network" and ActionType == "LogonSuccess" | summarize HostCount = dcount(DeviceName), Hosts = make_set(DeviceName, 10) by AccountName | where HostCount > 10 ``` **Sample output:** ``` AccountName HostCount Hosts admin 23 ["fileserver01","fileserver02","web01",...] ``` **Tuning:** allowlist IT admin accounts via `where AccountName !in (knownAdmins)`. --- ### 27. Off-hours admin sign-ins ```kql let admin_users = IdentityInfo | where AssignedRoles has_any("Global Administrator", "Security Administrator", "Privileged Role Administrator") | summarize by AccountUPN; SigninLogs | where TimeGenerated > ago(7d) | where ResultType == 0 | where UserPrincipalName in (admin_users) | where hourofday(TimeGenerated) !between (7 .. 19) | project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName ``` **Sample output:** ``` TimeGenerated UserPrincipalName IPAddress AppDisplayName 2026-05-23T03:11Z admin@corp.com 185.220.101.34 Office 365 Exchange Online ``` **Tuning:** convert to user's local time using `bag_extract` on `LocationDetails` for accurate "off-hours." --- ### 28. WMI persistence creation triplet ```kql Event | where Source == "Microsoft-Windows-Sysmon" | where EventID in (19, 20, 21) | summarize EventIDs = make_set(EventID), Times = make_set(TimeGenerated) by Computer | where set_has_element(EventIDs, 19) and set_has_element(EventIDs, 20) and set_has_element(EventIDs, 21) ``` **Sample output:** ``` Computer EventIDs Times WIN10-LAB-02 [19, 20, 21] [...] ``` **Tuning:** add time-window: only fire when all three EIDs occur within 5 minutes of each other. --- ### 29. Mailbox forwarding rule created ```kql OfficeActivity | where Operation in ("New-InboxRule", "Set-InboxRule") | extend params = parse_json(Parameters) | extend forwardTo = tostring(params.ForwardTo) | where isnotempty(forwardTo) | where forwardTo matches regex @"@(?!yourdomain\.com|onmicrosoft\.com)[^,]+" | project TimeGenerated, UserId, ClientIP, forwardTo ``` **Sample output:** ``` TimeGenerated UserId ClientIP forwardTo 2026-05-23T11:09Z cfo@corp.com 185.220.101.34 hr-archive@protonmail.com ``` **Tuning:** the regex's negative lookahead is the allow-list — your tenant domains. Adjust per-org. --- ### 30. OAuth high-privilege consent grant ```kql let highPriv = dynamic([ "mail.read","mail.readwrite","mail.send", "files.read.all","files.readwrite.all","offline_access", "directory.read.all","directory.readwrite.all" ]); AuditLogs | where OperationName == "Consent to application" | extend tr = TargetResources[0] | mv-expand mod = todynamic(tr.modifiedProperties) | where tostring(mod.displayName) == "ConsentAction.Permissions" | extend scopes = extract_all(@"Scope:\s*([\w\.]+)", tolower(tostring(mod.newValue))) | extend matched = set_intersect(scopes, highPriv) | where array_length(matched) > 0 | project TimeGenerated, ConsentingUser = tostring(InitiatedBy.user.userPrincipalName), appName = tostring(tr.displayName), matched ``` **Sample output:** ``` TimeGenerated ConsentingUser appName matched 2026-05-23T11:09Z jdoe@corp.com Document Signer Pro ["mail.read","offline_access"] ``` **Tuning:** combine with app age — newly-registered apps (< 7 days) consenting to high-priv scopes is the strongest signal. --- ### 31. Office process spawning PowerShell ```kql DeviceProcessEvents | where Timestamp > ago(1d) | where InitiatingProcessFileName in~ ("winword.exe","excel.exe","powerpnt.exe","outlook.exe","acrord32.exe") | where FileName in~ ("powershell.exe","pwsh.exe","cmd.exe","wscript.exe","cscript.exe","mshta.exe") | project Timestamp, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine ``` **Sample output:** ``` Timestamp DeviceName InitiatingProcessFileName FileName ProcessCommandLine ... WIN10-LAB-02 winword.exe powershell.exe -enc ... ``` **Tuning:** treat every hit as P0. Office parent + scripting child is overwhelmingly malicious. --- ### 32. Rundll32 with suspicious parent ```kql DeviceProcessEvents | where FileName =~ "rundll32.exe" | where InitiatingProcessFileName !in~ ("explorer.exe","services.exe","svchost.exe","msiexec.exe","taskhostw.exe","dllhost.exe","control.exe") | project Timestamp, DeviceName, InitiatingProcessFileName, ProcessCommandLine ``` **Sample output:** ``` Timestamp DeviceName InitiatingProcessFileName ProcessCommandLine ... WIN10-LAB-02 winword.exe rundll32.exe shell32.dll,Control_RunDLL ... ``` **Tuning:** add `ProcessCommandLine has_any` filter for the LOLBAS patterns: `"javascript:"`, `",RunHTMLApplication"`, `",#1"`. --- ### 33. Beacon detection via interval regularity ```kql DeviceNetworkEvents | where Timestamp > ago(24h) | where ActionType == "ConnectionSuccess" | summarize Times = make_list(Timestamp) by DeviceName, RemoteIP, RemotePort | extend Count = array_length(Times) | where Count >= 10 | extend Gaps = array_iff(array_slice(Times, 1, Count-1) > array_slice(Times, 0, Count-2), bin(Times, 1s)) // Coefficient of variation | extend gaps = mv-apply prev = Times to typeof(datetime) on (extend g = Times - prev | project g) // (KQL doesn't have an easy CV — use a Python plug-in or a workbook for this) | project DeviceName, RemoteIP, RemotePort, Count ``` **Tuning:** beacon detection in pure KQL is awkward. Better: export to ADX with a `series_decompose` analysis, or use the Sentinel ML connector. The version above is a starting point for the columns you need. --- ### 34. DGA via subdomain entropy ```kql let shannon = (s:string) { let chars = extract_all(@".", s); let total = array_length(chars); let freqs = chars | mv-expand c = chars | summarize cnt = count() by tostring(c) | extend p = todouble(cnt) / total; -(freqs | extend e = p * log2(p) | summarize sum(e)) }; DnsEvents | where QueryType == "TXT" | extend label = tostring(split(Name, ".")[0]) | where strlen(label) >= 12 // Shannon-entropy implementation above is illustrative; in practice use a workbook function | project TimeGenerated, ClientIP, Name, label ``` **Tuning:** KQL doesn't have a built-in entropy function. The cleanest practical pattern is to compute entropy in the ingestion pipeline and store as a column — then filter via `where shannon_entropy > 3.7`. --- ### 35. New cloud admin role assignment ```kql AuditLogs | where TimeGenerated > ago(7d) | where OperationName == "Add member to role" | extend role = tostring(TargetResources[0].displayName) | where role in ("Global Administrator", "Privileged Role Administrator", "Security Administrator") | project TimeGenerated, role, AssignedTo = tostring(TargetResources[0].userPrincipalName), AssignedBy = tostring(InitiatedBy.user.userPrincipalName) ``` **Sample output:** ``` TimeGenerated role AssignedTo AssignedBy 2026-05-23T11:09Z Global Administrator jdoe@corp.com admin@corp.com ``` **Tuning:** Privileged Identity Management (PIM) eligible-assignments fire a *different* operation — `Add eligible member to role`. Add both to the filter for full coverage. --- ## Automation ### 36. Save query as analytic rule In the Sentinel portal: **Analytics → Create → Scheduled query rule**. Use this query body: ```kql SigninLogs | where ResultType != 0 | summarize Failures = count(), Users = dcount(UserPrincipalName) by IPAddress | where Failures >= 10 and Users >= 5 ``` Settings: - Frequency: 15 min - Period: 1h - Trigger: > 0 - Severity: Medium **Tuning:** see `detection-infra/sentinel/modules/analytic_rule` for the Terraform module that automates this — much easier than the portal for many rules. --- ### 37. Entity mappings for incident graph Inside the analytic-rule creation flow, pin query columns to entity types: | Entity | Identifier | Column | |---|---|---| | Account | Name | `UserPrincipalName` | | Host | HostName | `Computer` | | IP | Address | `IPAddress` | The investigation graph wires these up automatically. Without mappings, the graph shows raw rows; with them, you can pivot. **Tuning:** for `Account.AadUserId` use the `Identity` column from SigninLogs — gives you the Entra GUID, which links to IdentityInfo. --- ### 38. Create a watchlist via externaldata Watchlists are higher-performance than externaldata for analytic rules (they're cached + indexed): 1. Upload a CSV via **Sentinel → Watchlists → New**. 2. Reference as: ```kql let badIPs = _GetWatchlist('BadIPs') | project IPAddress; SigninLogs | where IPAddress in (badIPs) ``` **Tuning:** keep watchlist row count under 10k for snappy lookups. For larger lists (threat-intel feeds with millions of rows), use the `ThreatIntelligenceIndicator` table. --- ### 39. Schedule a hunting query In **Sentinel → Hunting → Queries**, save with a category. Hunts are point-in-time exploratory queries (not analytic rules) — but they can be promoted to rules if a hunt becomes useful. **Tuning:** add `bookmark` capability — when a hunt finds something, click "Add bookmark" to keep the result tied to an investigation. --- ### 40. Ingest custom logs via Data Collector To land custom logs in a `_CL` table: ```python # Python sending to Sentinel import requests, json, base64, hmac, hashlib, datetime workspace_id = "your-guid" shared_key = "your-key" log_type = "PyDetection" body = json.dumps([{"rule_id":"R1","host":"lab"}]) date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") string_to_sign = f"POST\n{len(body)}\napplication/json\nx-ms-date:{date}\n/api/logs" sig = base64.b64encode(hmac.new(base64.b64decode(shared_key), string_to_sign.encode(), hashlib.sha256).digest()).decode() requests.post( f"https://{workspace_id}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01", headers={"Content-Type":"application/json", "Authorization":f"SharedKey {workspace_id}:{sig}", "Log-Type":log_type, "x-ms-date":date}, data=body) ``` Then query: `PyDetection_CL | take 10` **Tuning:** Data Collector API is being deprecated for new workspaces in favor of the DCR-based Logs Ingestion API. New deployments should target the latter. --- ### 41. Cross-workspace query ```kql workspace("law-soc-prod").SigninLogs | union workspace("law-soc-staging").SigninLogs | where ResultType != 0 | summarize count() by Workspace = "soc-prod-vs-staging" ``` **Tuning:** cross-workspace queries are billed against the *querying* workspace, but RBAC is enforced per source workspace. Useful for federated SOC setups. --- ### 42. Define a function (saved KQL helper) Functions are saved query snippets you can call as if they were tables: ```kql .create-or-alter function HighSevSignins() { SigninLogs | where ResultType != 0 | where RiskLevelAggregated in ("high", "medium") } ``` Then in any query: ```kql HighSevSignins() | where TimeGenerated > ago(1h) ``` **Tuning:** functions can take parameters: `.create-or-alter function HighSevSignins(lookback:timespan)`. The function approach is *the* DRY tool in KQL — use it whenever you find yourself copy-pasting query fragments. --- ### 43. Materialized view for high-volume table ```kql .create-or-alter materialized-view DailyFailedSignins on table SigninLogs { SigninLogs | where ResultType != 0 | summarize FailedCount = count() by bin(TimeGenerated, 1d), IPAddress } ``` Then: `DailyFailedSignins | top 10 by FailedCount desc`. **Tuning:** materialized views compute incrementally — they're cheap to query but expensive to define on extremely high-volume tables. Worth it when you query the same aggregation repeatedly. --- ### 44. ipv4_lookup for ASN enrichment ```kql let asnTable = datatable(StartIP:string, EndIP:string, ASN:int, Description:string) [ "185.220.100.0","185.220.103.255",4224,"Tor exit", "8.8.8.0","8.8.8.255",15169,"Google DNS" ]; SigninLogs | where ResultType != 0 | evaluate ipv4_lookup(asnTable, IPAddress, StartIP, EndIP) | project TimeGenerated, IPAddress, ASN, Description ``` **Sample output:** ``` TimeGenerated IPAddress ASN Description 2026-05-23T09:11Z 185.220.101.34 4224 Tor exit ``` **Tuning:** keep the ASN table in a Watchlist for production — externaldata adds latency. --- ### 45. GeoIP enrichment with externaldata ```kql let geo = externaldata(network: string, country: string) [@"https://yourdomain.com/blob/geoip.csv"] with (format="csv", ignoreFirstRecord=true); SigninLogs | evaluate ipv4_lookup(geo, IPAddress, network) ``` **Tuning:** for MaxMind GeoLite2 CSV, use `network` as CIDR; `ipv4_lookup` interprets it natively. --- ### 46. Logic App trigger from query result The query *is* the input to Logic Apps. In a Sentinel playbook step ("Run query and list results"): ```kql SigninLogs | where ResultType != 0 | where TimeGenerated > ago(15m) | project TimeGenerated, UserPrincipalName, IPAddress ``` Logic App's subsequent actions iterate over rows. Send Teams message, create JIRA ticket, run `Set-AzureADUser -AccountEnabled $false`, etc. **Tuning:** keep result rows < 10k per playbook run — Logic Apps' "list results" action caps there. --- ### 47. Workbook tile from a query In **Workbooks → Edit → Add query**, paste the query and pick `chart` or `tile` rendering. The query result becomes a dashboard tile that refreshes on the workbook's interval. ```kql SigninLogs | where TimeGenerated > ago(24h) | summarize FailedCount = countif(ResultType != 0), SuccessCount = countif(ResultType == 0) | extend FailRate = 100.0 * FailedCount / (FailedCount + SuccessCount) ``` **Tuning:** workbook parameters (`{lookback}`) let users adjust the time window without editing the query. --- ### 48. Anomaly detection with series_decompose_anomalies ```kql SigninLogs | where ResultType != 0 | make-series count=count() default=0 on TimeGenerated from ago(7d) to now() step 1h by IPAddress | extend (anomalies, score, baseline) = series_decompose_anomalies(count, 1.5) | mv-expand TimeGenerated, count, anomalies, score, baseline | where todouble(anomalies) != 0 | project IPAddress, TimeGenerated, count, score, baseline ``` **Sample output:** ``` IPAddress TimeGenerated count score baseline 185.220.101.34 2026-05-23T09Z 142 8.4 3.1 ``` **Tuning:** `series_decompose_anomalies(input, sensitivity)` — higher sensitivity = more anomalies. Start at 1.5 and adjust. --- ### 49. adx() bridge to Azure Data Explorer If you have additional data in an ADX cluster (often used for high-volume telemetry that's too expensive for Sentinel): ```kql let adxData = adx('cluster.region.kusto.windows.net/database').LongTermLogs | where Timestamp > ago(30d) and EventID == 4625; SigninLogs | union adxData ``` **Tuning:** ADX is roughly 90% cheaper than Sentinel for high-volume telemetry. Many orgs route raw Defender XDR streams to ADX and federate via this pattern. --- ### 50. Compose detection + enrichment in one query ```kql let lookback = 1h; let geoTable = externaldata(network:string, country:string) [@"https://yourdomain.com/geo.csv"] with (format="csv", ignoreFirstRecord=true); let knownVPNs = dynamic(["10.99.0.0/16","192.0.2.0/24"]); SigninLogs | where TimeGenerated > ago(lookback) | where ResultType != 0 | summarize FailedCount = count(), Users = dcount(UserPrincipalName), SampleUsers = make_set(UserPrincipalName, 8) by IPAddress | where FailedCount >= 10 and Users >= 5 | evaluate ipv4_lookup(geoTable, IPAddress, network) | extend IsCorpVPN = iff(IPAddress in (toscalar(knownVPNs)), "yes", "no") | project IPAddress, country, FailedCount, Users, SampleUsers, IsCorpVPN | order by FailedCount desc ``` **Sample output:** ``` IPAddress country FailedCount Users SampleUsers IsCorpVPN 185.220.101.34 Russia 142 12 ["admin","root",...] no ``` **Tuning:** this is the shape of a production analytic rule — threshold + filter + enrichment + entity context, all in one query. Save it as a rule and pipe entity mappings. --- ## License MIT. See [LICENSE](LICENSE).

About

KQL Telemetry, Detection & Automation

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors