↑↓ move ↵ open esc close Searches the full text of every issue.

SOC Weekly Brief The week in the Microsoft security stack, distilled

← Home

KQL library

79 queries featured across the weekly issues. Each one links to the original in its author's repo — run them read-only first and tune the thresholds to your estate.

Successful php2Shell Indicator Scan

Published on 1 November and about as timely as a query gets, this is the follow-up question to the PHP2Shell advisories from last week: not which hosts are vulnerable, but which ones accepted a connection from the addresses reported as exploiting it. It declares five indicator IP addresses, reads DeviceNetworkEvents over the last thirty days, filters to ActionType InboundConnectionAccepted, and matches RemoteIP against the list. Two notes before you run it. InboundConnectionAccepted is the point of the query — an accepted inbound connection from one of these addresses on an internet-facing PHP host is an investigation, not a tuning exercise — and the thirty-day lookback deliberately reaches back before the disclosure, because the interesting answer is whether anything connected before you patched.

let IOC = dynamic([
"2.59.133.151",
"185.65.134.250",
"185.184.222.160",
"206.245.153.56",
"23.234.81.67"
]);
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where ActionType == "InboundConnectionAccepted"
| where RemoteIP has_any(IOC)

MDE Onboarding and Offboarding Package Downloads

From 31 October, an audit query for an action that almost nobody watches and that quietly ends endpoint telemetry for a device. It reads CloudAppEvents over thirty days for the DownloadOnboardingPkg and DownloadOffboardingPkg operations, matching either ActionType or the raw event data, then normalises the operation to Onboarding or Offboarding, pulls the deployment method and operating system family out of RawEventData, and projects the account display name and object ID, the IP address and the raw event. The author is careful about what it proves and so should you be: it detects download events only, and does not establish that a package was run or that a device was offboarded. The reason to schedule it anyway is that running an offboarding package stops a device sending new detection, vulnerability and security data to Defender for Endpoint — which makes an unexpected offboarding package download a credible defence evasion lead and an expected one a change request you can tie it to.

let Lookback = 30d;
let PackageOperations = dynamic([
    "DownloadOnboardingPkg",
    "DownloadOffboardingPkg"
]);
CloudAppEvents
| where Timestamp > ago(Lookback)
| where ActionType in (PackageOperations)
    or RawEventData has_any (PackageOperations)
| extend Info = parse_json(RawEventData)
| extend
    Operation = case(
        ActionType == "DownloadOnboardingPkg", "Onboarding",
        ActionType == "DownloadOffboardingPkg", "Offboarding",
        tostring(RawEventData) contains "DownloadOnboardingPkg", "Onboarding",
        tostring(RawEventData) contains "DownloadOffboardingPkg", "Offboarding",
        "Unknown"
    ),
    DeploymentMethod = tostring(Info.DeploymentMethod),
    OsFamily = tostring(Info.OsFamily)
| project
    Timestamp,
    Operation,
    AccountDisplayName,
    AccountObjectId,
    IPAddress,
    DeploymentMethod,
    OsFamily,
    ActionType,
    RawEventData
| order by Timestamp desc

Azure VM MDE Onboarding Status

Published on 23 October and timely in a week with two Defender for Cloud server-onboarding items in it, this is a coverage audit rather than a hunt, which makes it the kind of query that finds the gap your detections are silently missing. It reads Azure virtual machines from the Defender XDR exposure graph, filters to the subscriptions you list in the configuration block at the top, pulls out resource group, VM name, operating system platform, device group, first and last seen, and both the onboarding status and the sensor health state, then orders by VM name. Two notes before you run it: you must replace the placeholder subscription GUID with your own, and the value of the output is the rows where OnboardingStatus or SensorHealthState is not what you assumed — an unonboarded or unhealthy Azure VM is a machine your EDR telemetry does not cover, however green the subscription looks in a plan-enablement view.

// Configuration: Azure subscriptions to include
let vSubscriptionIds = dynamic([
    "00000000-0000-0000-0000-000000000000"
]);
// Azure VM inventory from Exposure Graph
ExposureGraphNodes
| where NodeLabel =~ "microsoft.compute/virtualmachines"
| where Categories has "virtual_machine"
// Extract Azure resource information
| extend AzureResourceId = tostring(EntityIds[0].id)
| extend
    SubscriptionId = extract(@"(?i)/subscriptions/([^/]+)", 1, AzureResourceId),
    ResourceGroup = extract(@"(?i)/resourcegroups/([^/]+)", 1, AzureResourceId)
// Filter subscriptions
| where SubscriptionId in~ (vSubscriptionIds)
// Extract VM attributes
| extend
    VMName = tostring(split(AzureResourceId, "/")[-1]),
    DeviceName = tostring(NodeProperties.rawData.deviceName),
    LastSeen = todatetime(NodeProperties.rawData.lastSeen),
    OnboardingStatus = tostring(NodeProperties.rawData.onboardingStatus),
    OSPlatform = tostring(NodeProperties.rawData.osPlatformFriendlyName),
    FirstSeenByInventory = todatetime(NodeProperties.rawData.firstSeenByInventory),
    DeviceGroup = tostring(NodeProperties.rawData.machineGroup),
    SensorHealthState = tostring(NodeProperties.rawData.sensorHealthState)
// Normalize hostname (remove DNS suffix)
| extend
    DeviceHostName = tolower(tostring(split(DeviceName, ".")[0]))
// Final inventory
| project
    SubscriptionId,
    ResourceGroup,
    VMName,
    DeviceName,
    DeviceHostName,
    OSPlatform,
    OnboardingStatus,
    SensorHealthState,
    DeviceGroup,
    FirstSeenByInventory,
    LastSeen,
    AzureResourceId 
| order by VMName asc

Uncommon process connection to suspicious domain

From 22 October, a hunt for uncommon processes reaching out to the kind of infrastructure that exfiltration and out-of-band testing actually uses, which is a useful complement to a week of supply chain compromises that ended with a POST to a lookalike domain. It matches DeviceNetworkEvents RemoteUrl against a regex list covering randomised CloudFront, KeyCDN, Azure Front Door and Azure cloudapp hostnames, AWS API Gateway execute-api endpoints, generic CDN patterns, Burp Collaborator domains, and the what-is-my-IP sites that both malware and curious staff use. The prevalence logic is what makes it liveable: it aggregates by the initiating process's version metadata rather than by file hash, invokes FileProfile, then drops anything with a global prevalence above 10,000, anything above 1,000 that is validly signed and was first seen before this run, and anything above 500 matching company and original-filename allow lists you populate yourself. Set query_frequency and query_period to match how you schedule it, and fill in those two exclusion arrays on the first few runs — the design assumes you will, and the signal gets sharper as you do.

let query_frequency = 1h;
let query_period = 14d;
let suspicious_domains = dynamic([
    @"d\d[a-z0-9]{12}\.cloudfront\.net",
    @"[\-\w]+\-[a-f0-9]{3,5}\.kxcdn\.com",
    @"[\-\w]+\-[a-z0-9]{16}\.\w\d\d\.azurefd\.net",
    @"[\-\w]+\.[a-z0-9]+\.cloudapp\.azure\.com",
    @"[a-z0-9]{10}\.execute\-api\.[a-z]{2}\-[a-z]+\-\d\.amazonaws\.com",
    @"[a-z0-9]+\.[a-z]\-cdn\.net",
    @"portswigger\.net",
    @"oastify\.com",
    @"whatismyip\.com",
    @"whatismyip\.net",
    @"whatismyipaddress\.com"
]);
let excluded_company_names = dynamic([]);
let excluded_original_names = dynamic([]);
DeviceNetworkEvents
| where Timestamp > ago(query_period)
| where RemoteUrl matches regex strcat_array(suspicious_domains, "|") // and not(InitiatingProcessAccountSid in ("S-1-5-18", "S-1-5-20"))
| where isnotempty(InitiatingProcessFileName)
| summarize
    StartTime = arg_min(Timestamp, *),
    EndTime = max(Timestamp),
    DeviceNamesSample = array_sort_asc(make_set(DeviceName, 100)),
    RemoteUrlsSample = array_sort_asc(make_set(RemoteUrl, 100))
    by InitiatingProcessVersionInfoCompanyName, InitiatingProcessVersionInfoProductName, InitiatingProcessVersionInfoOriginalFileName, InitiatingProcessVersionInfoInternalFileName, InitiatingProcessVersionInfoFileDescription
| where StartTime > ago(query_frequency)
| invoke FileProfile("InitiatingProcessSHA1", 1000)
| where not(GlobalPrevalence > 10000)
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_frequency) and SignatureState == "SignedValid")
| where not(GlobalPrevalence > 500 and InitiatingProcessVersionInfoCompanyName in (excluded_company_names) and InitiatingProcessVersionInfoOriginalFileName in (excluded_original_names))
| project
    StartTime,
    EndTime,
    DeviceNamesSample,
    RemoteUrlsSample,
    Timestamp = StartTime,
    DeviceId,
    DeviceName,
    LocalIP,
    ActionType,
    RemoteIP,
    RemotePort,
    RemoteUrl,
    Protocol,
    InitiatingProcessAccountName,
    InitiatingProcessAccountSid,
    InitiatingProcessAccountUpn,
    InitiatingProcessAccountObjectId,
    InitiatingProcessSHA1,
    InitiatingProcessSHA256,
    InitiatingProcessMD5,
    InitiatingProcessFileName,
    InitiatingProcessFolderPath,
    InitiatingProcessCommandLine,
    InitiatingProcessCreationTime,
    IsInitiatingProcessRemoteSession,
    InitiatingProcessParentFileName,
    InitiatingProcessVersionInfoCompanyName,
    InitiatingProcessVersionInfoProductName,
    InitiatingProcessVersionInfoOriginalFileName,
    InitiatingProcessVersionInfoInternalFileName,
    InitiatingProcessVersionInfoFileDescription,
    InitiatingProcessVersionInfoProductVersion,
    GlobalPrevalence,
    GlobalFirstSeen,
    GlobalLastSeen,
    SignatureState,
    ReportId

Risky Sign-in Followed by Key Vault Activity

Published on 13 October and squarely on theme for a week with two Key Vault items in it, this one chains an identity signal to a secrets-store action, which is the correlation most teams write by hand and get slightly wrong. It takes Entra sign-ins with a RiskLevelDuringSignIn of 10, 50 or 100 — low, medium or high — and joins them to Key Vault data plane activity on the sign-in's unique token identifier, so the Key Vault events it returns are provably from that session rather than merely from the same user around the same time. The author's triage advice is to review the account, source IP, user agent, sign-in error code, accessed vault and activity together, and to look at failed sign-ins as well as successful ones. Note that it scopes Key Vault events to AccountType "user", so service principal and managed identity secret access is deliberately out of frame here.

EntraIdSignInEvents
| where RiskLevelDuringSignIn in (10, 50, 100)
| project SignInTime = Timestamp, IPAddress, UserAgent, AccountUpn, UniqueTokenId, ErrorCode
| join kind=inner (
    CloudKeyVaultEvents
    | where AccountType == "user"
    | extend UniqueTokenIdentifier = tostring(AdditionalFields.UniqueTokenIdentifier)
    | project KeyVaultEventTime = Timestamp, UniqueTokenIdentifier, AzureResourceId, ActivityName
) on $left.UniqueTokenId == $right.UniqueTokenIdentifier
| where KeyVaultEventTime >= SignInTime

UEBA High Priority Activity from MCP Client User Agents

From 15 October, five lines that answer a question most SOCs cannot currently answer at all: which of our UEBA anomalies came from an AI agent. It reads the Sentinel BehaviorAnalytics table, keeps events with an InvestigationPriority of 5 or higher, expands DevicesInsights to pull out the device user agent, and returns the ones whose user agent contains "mcp". The reasoning is that a Model Context Protocol client acting with a user's identity tends to look anomalous to UEBA by construction — new applications, unusual volumes, uncommon resources, atypical access patterns — and an attacker holding valid credentials can hide automated reconnaissance inside that same traffic shape. The author is clear about the limits and you should be too: the substring match will catch unrelated user agents, MCP clients that do not identify themselves are invisible to it, and the priority threshold wants tuning up or down depending on whether you are hunting or triaging. Treat the output as a list of agent-identity pairs to review, not as alerts.

BehaviorAnalytics
| where InvestigationPriority >= 5
| mv-expand DevicesInsights
| extend UserAgent = (DevicesInsights.UserAgent)
| where isnotempty( UserAgent)
| where tolower( UserAgent) contains "mcp"

Uncover every prompt-injection detection and its disposition

Published on 8 October and almost embarrassingly short for how useful it is, this one answers a question every SOC running Defender for Office 365 should be able to answer this month: how much prompt-injection email is being caught, and is any of it still landing in mailboxes rather than quarantine. It filters EmailEvents on the Prompt injection detection method over 30 days and summarises message counts by DeliveryAction and DeliveryLocation in daily bins, which gives you both a volume trend and an immediate read on whether anything is being delivered. Given the two Purview grounding controls elsewhere in this issue, run it before you write the business case for either.

// Prompt-injection detections over the last 30 days, by disposition
let lookback = 30d;
EmailEvents
| where Timestamp > ago(lookback)
| where DetectionMethods has "Prompt injection"
| summarize Messages = count() by DeliveryAction, DeliveryLocation, bin(Timestamp, 1d)
| sort by Timestamp desc

Delivered mail carrying exfiltration-style URLs

From the same author on the same day, a heuristic hunt for the shape of a URL an AI assistant would be instructed to call. It joins delivered messages to their URLs through EmailUrlInfo on NetworkMessageId and flags long base64-looking values in data, q, payload or d query parameters, or collector-style paths such as /collect, /exfil and /log. Two caveats the author states and you should respect: the exact DetectionMethods string varies by tenant and changes over time, so confirm the literal in your own EmailEvents before building an alert on it, and the regex is a starting heuristic that needs tuning against your own legitimate URL patterns before the false positive rate is tolerable. Treat it as a hunt, not a detection, until you have done that work.

let lookback = 30d;
EmailEvents
| where Timestamp > ago(lookback)
| where DeliveryAction == "Delivered"
| join kind=inner (
    EmailUrlInfo
    | where Timestamp > ago(lookback)
) on NetworkMessageId
| where Url matches regex @"(?i)([?&](data|q|payload|d)=[A-Za-z0-9+/]{24,}={0,2}|/(collect|exfil|log)\b)"
| project Timestamp, SenderFromAddress, RecipientEmailAddress, Subject, Url, DeliveryLocation
| sort by Timestamp desc

Mail Bombing Followed by External Teams Contact and Remote Access Execution

A chain hunt published on 30 September that lines up almost exactly with the RMM campaign above, and the sequencing is what makes it useful: a mail bomb, then an external Teams message, then remote-access tooling on the endpoint. It flags a 15-minute bin as a mail bomb at 100 or more inbound messages from 20 or more distinct senders, looks in MessageEvents for a Teams message from an external domain arriving between the start of the flood and two hours after it ends, then searches DeviceProcessEvents in the following four hours for either a known remote-support tool or a script interpreter launched from explorer.exe using encoded or hidden execution or fetching remote content. Organisation domains are derived from inbound recipient domains rather than hardcoded, and the thresholds sit in let parameters at the top, which you will want to tune to your own mail volume before trusting the ChainConfidence column. Approved external helpdesk partners are the obvious false positive to allowlist.

let HuntLookback = 30d;
let MailBurstWindow = 15m;
let TeamsFollowupWindow = 2h;
let EndpointFollowupWindow = 4h;
let MinimumBurstMessages = 100;
let MinimumDistinctSenders = 20;
let OrgDomains = EmailEvents
 | where EmailDirection == "Inbound"
 | distinct RecipientDomain;
let MailBombs =
 EmailEvents
 | where Timestamp >= ago(HuntLookback)
 | where EmailDirection =~ "Inbound"
 | extend UserEmail = tolower(RecipientEmailAddress)
 | summarize
  FloodStart = min(Timestamp),
  FloodEnd = max(Timestamp),
  BurstMessageCount = count(),
  DistinctSenders = dcount(SenderFromAddress),
  DistinctSubjects = dcount(Subject)
  by UserEmail, FloodBin = bin(Timestamp, MailBurstWindow)
 | where BurstMessageCount >= MinimumBurstMessages
  and DistinctSenders >= MinimumDistinctSenders;
let ExternalTeamsMessages =
 MessageEvents
 | where Timestamp >= ago(HuntLookback)
 | mv-expand RecipientDetails
 | extend
  SenderEmail = tolower(SenderEmailAddress),
  RecipientEmail = tolower(tostring(RecipientDetails.RecipientSmtpAddress))
 | extend
  SenderDomain = tolower(extract(@"@([^@]+)$", 1, SenderEmail)),
  RecipientDomain = tolower(extract(@"@([^@]+)$", 1, RecipientEmail))
 | where isnotempty(SenderDomain) and isnotempty(RecipientDomain)
 | where SenderDomain !in (OrgDomains) and RecipientDomain in (OrgDomains)
 | project
  UserEmail = RecipientEmail,
  TeamsTime = Timestamp,
  ExternalSender = SenderEmail,
  ExternalSenderDomain = SenderDomain,
  TeamsMessageId;
let MailThenTeams =
 MailBombs
 | join kind=inner ExternalTeamsMessages on UserEmail
 | where TeamsTime >= FloodStart and TeamsTime <= FloodEnd + TeamsFollowupWindow
 | summarize
  TeamsTime = min(TeamsTime),
  ExternalSender = take_any(ExternalSender),
  ExternalSenderDomain = take_any(ExternalSenderDomain),
  TeamsMessageId = take_any(TeamsMessageId)
  by UserEmail, FloodBin, FloodStart, FloodEnd, BurstMessageCount, DistinctSenders, DistinctSubjects;
let SuspiciousEndpointActivity =
 DeviceProcessEvents
 | where Timestamp >= ago(HuntLookback)
 | where isnotempty(AccountUpn)
 | extend
  UserEmail = tolower(AccountUpn),
  CommandLine = tolower(ProcessCommandLine),
  ProcessName = tolower(FileName),
  ParentName = tolower(InitiatingProcessFileName),
  GrandparentName = tolower(InitiatingProcessParentFileName)
 | extend
  LaunchedFromExplorer = ParentName == "explorer.exe" or GrandparentName == "explorer.exe",
  HasEncodedOrHiddenExecution = CommandLine matches regex @"(?i)(\s-enc(odedcommand)?\b|\s-w(indowstyle)?\s+hidden\b|frombase64string)",
  HasRemoteFetch = CommandLine matches regex @"(?i)(https?://|downloadstring|invoke-webrequest|\biwr\b|\b(curl|wget|bitsadmin|certutil)\b)",
  IsScriptOrShell = ProcessName in~ ("powershell.exe", "pwsh.exe", "cmd.exe", "mshta.exe", "wscript.exe", "cscript.exe", "curl.exe"),
  IsRemoteSupportTool = ProcessName in~ (
   "quickassist.exe", "anydesk.exe", "teamviewer.exe", "screenconnect.client.exe",
   "rustdesk.exe", "ateraagent.exe", "splashtop.exe", "bomgar-scc.exe", "logmein.exe"
  )
 | where IsRemoteSupportTool
  or (LaunchedFromExplorer and IsScriptOrShell and (HasEncodedOrHiddenExecution or HasRemoteFetch))
 | project
  UserEmail,
  EndpointTime = Timestamp,
  DeviceName,
  DeviceId,
  ProcessFileName = FileName,
  ProcessCommandLine,
  InitiatingProcessFileName,
  InitiatingProcessParentFileName,
  IsRemoteSupportTool,
  HasEncodedOrHiddenExecution,
  HasRemoteFetch;
MailThenTeams
| join kind=inner SuspiciousEndpointActivity on UserEmail
| where EndpointTime >= TeamsTime and EndpointTime <= TeamsTime + EndpointFollowupWindow
| extend ChainConfidence = case(
 IsRemoteSupportTool and (HasEncodedOrHiddenExecution or HasRemoteFetch), "High",
 IsRemoteSupportTool or (HasEncodedOrHiddenExecution and HasRemoteFetch), "High",
 "Elevated")
| project
 ChainConfidence,
 UserEmail,
 FloodStart,
 FloodEnd,
 BurstMessageCount,
 DistinctSenders,
 DistinctSubjects,
 TeamsTime,
 ExternalSender,
 ExternalSenderDomain,
 TeamsMessageId,
 EndpointTime,
 DeviceName,
 DeviceId,
 ProcessFileName,
 ProcessCommandLine,
 InitiatingProcessFileName,
 InitiatingProcessParentFileName,
 IsRemoteSupportTool,
 HasEncodedOrHiddenExecution,
 HasRemoteFetch
| order by iff(ChainConfidence == "High", 0, 1) asc, FloodStart desc, EndpointTime desc

Entra app instance property lock tampering (ServicePrincipalLockConfiguration)

Published on 2 October, and a good companion to the Azure DevOps case above: it watches for app instance property locks being weakened in Entra. An app instance property lock stops a tenant from modifying sensitive properties on a multi-tenant application's service principal — credentials, identifier URIs, token encryption key. Turning one off is a prerequisite step for an attacker who wants to add their own credential to an existing service principal, and it is a configuration change nobody normally makes twice, so the signal is clean. The query walks modifiedProperties in ApplicationManagement audit logs for the ServicePrincipalLockConfiguration property and alerts only on a true-to-false transition across any of the lock's individual flags, which keeps the enable direction and the no-op writes out of the results.

//https://learn.microsoft.com/en-us/entra/identity-platform/howto-configure-app-instance-property-locks
AuditLogs
| where Category == "ApplicationManagement"
| mv-expand TargetResource = TargetResources
| mv-expand Property = TargetResource.modifiedProperties
| where tostring(Property.displayName) == "ServicePrincipalLockConfiguration" //| where TargetResources[0].modifiedProperties[0].displayName == "ServicePrincipalLockConfiguration"
| extend Old = parse_json(tostring(Property.oldValue))
| extend New = parse_json(tostring(Property.newValue))
| extend
Old_IsEnabled = tobool(Old[0].IsEnabled),
New_IsEnabled = tobool(New[0].IsEnabled),
Old_AllProperties = tobool(Old[0].AllProperties),
New_AllProperties = tobool(New[0].AllProperties),
Old_CredentialsWithUsageVerify = tobool(Old[0].CredentialsWithUsageVerify),
New_CredentialsWithUsageVerify = tobool(New[0].CredentialsWithUsageVerify),
Old_CredentialsWithUsageSign = tobool(Old[0].CredentialsWithUsageSign),
New_CredentialsWithUsageSign = tobool(New[0].CredentialsWithUsageSign),
Old_IdentifierUris = tobool(Old[0].IdentifierUris),
New_IdentifierUris = tobool(New[0].IdentifierUris),
Old_TokenEncryptionKeyId = tobool(Old[0].TokenEncryptionKeyId),
New_TokenEncryptionKeyId = tobool(New[0].TokenEncryptionKeyId)
| where
(Old_IsEnabled == true and New_IsEnabled == false) or
(Old_AllProperties == true and New_AllProperties == false) or
(Old_CredentialsWithUsageVerify == true and New_CredentialsWithUsageVerify == false) or
(Old_CredentialsWithUsageSign == true and New_CredentialsWithUsageSign == false) or
(Old_IdentifierUris == true and New_IdentifierUris == false) or
(Old_TokenEncryptionKeyId == true and New_TokenEncryptionKeyId == false)
| project TimeGenerated,OperationName,AppName = tostring(TargetResource.displayName),InitiatedBy,Old,New

Hunting Invisible Unicode in Emails

A hunt published this week for invisible Unicode in inbound email, built on a premise that is going to age well: an email now has two audiences, the human reader and whatever agent or parser processes the text, and they do not necessarily see the same thing. Microsoft and Micro-zero-width-space-soft render identically to a person but differ to a keyword detection, a filter or an agent, which makes zero-width and bidirectional control characters a cheap obfuscation channel. The query extracts characters in the U+200B to U+200F, U+202A to U+202E, U+2060 to U+206F and U+FEFF ranges from both Subject and SenderDisplayName, adds a geolocation check across sender IPs, and scores each message out of 100 with the reasons spelled out in a RiskReasons column so triage does not start from scratch. Note the emoji check carries the heaviest weight at 40 points and marketing mail will trip it, so expect to tune that term down before you trust the ranking.

EmailEvents
| where Timestamp > ago(1d)
| where isnotempty(Subject)
| extend SenderIP = iff(isnotempty(SenderIPv4), SenderIPv4, SenderIPv6)
| extend Country = tostring(geo_info_from_ip_address(SenderIP).country)
| extend SubjectSuspiciousChars = extract_all(@"([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])", Subject)
| extend SenderDisplaySuspiciousChars = extract_all(@"([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])", SenderDisplayName)
| extend HasEmoji = Subject matches regex @"[\u2600-\u27BF]"
| extend SubjectUnicode = iff(array_length(SubjectSuspiciousChars) > 0, 1, 0), SenderDisplayUnicode = iff(array_length(SenderDisplaySuspiciousChars) > 0, 1, 0)
| summarize Timestamp=max(Timestamp), HasEmoji=max(toint(HasEmoji)), SubjectUnicode=max(SubjectUnicode), SenderDisplayUnicode=max(SenderDisplayUnicode), SubjectSuspiciousChars=make_set(SubjectSuspiciousChars), SenderDisplaySuspiciousChars=make_set(SenderDisplaySuspiciousChars), Countries=make_set(Country), SenderIPs=make_set(SenderIP), SenderDisplayNames=make_set(SenderDisplayName), SenderAddresses=make_set(SenderFromAddress), SenderDomains=make_set(SenderFromDomain), DeliveryLocations=make_set(LatestDeliveryLocation), ThreatTypes=make_set(ThreatTypes), ThreatNames=make_set(ThreatNames), EmailCount=count() by Subject, NetworkMessageId, RecipientEmailAddress
| extend CountryCount = array_length(Countries)
| extend MultiCountry = iff(CountryCount > 1, 1, 0)
| extend RiskScore = (HasEmoji * 40) + (SubjectUnicode * 20) + (SenderDisplayUnicode * 20) + (MultiCountry * 20)
| extend RiskReasons = strcat(iff(HasEmoji == 1, "Unicode symbol in Subject [+40]; ", ""), iff(SubjectUnicode == 1, "Hidden Unicode in Subject [+20]; ", ""), iff(SenderDisplayUnicode == 1, "Hidden Unicode in SenderDisplayName [+20]; ", ""), iff(MultiCountry == 1, strcat("Multiple Countries [+20] (", CountryCount, "); "), ""))
| where RiskScore > 0
| project Timestamp, NetworkMessageId, RecipientEmailAddress, RiskScore, RiskReasons, Subject, SubjectSuspiciousChars, SenderDisplayNames, SenderDisplaySuspiciousChars, Countries, CountryCount, SenderIPs, SenderAddresses, SenderDomains, DeliveryLocations, EmailCount, ThreatTypes, ThreatNames
| order by RiskScore desc, Timestamp desc

MDO Hunting - Teams Safe Links Click-Through

Also from this week, a short hunt over UrlClickEvents for Safe Links clicks inside Microsoft Teams rather than email, filtered to Phish and Malware verdicts, and it exists because UrlClickEvents records the Teams workload too and most hunting libraries only ever point it at mail. The column to sort on is IsClickedThrough: a user who saw the warning page and continued anyway is a compromise candidate, not a click statistic, so the follow-up is their subsequent sign-ins and cloud activity. Given how much of the phishing described elsewhere in this issue arrives through Teams rather than a mailbox, this is a cheap gap to close.

UrlClickEvents
| where Timestamp > ago(7d)
| where Workload == "Microsoft Teams" or Workload has "Teams"
| where ThreatTypes has_any ("Phish", "Malware")
| project Timestamp, AccountUpn, Url, UrlChain, ThreatTypes,
        DetectionMethods, ActionType, IsClickedThrough, Workload
| order by Timestamp desc

Microsoft 365 Copilot - System prompt disclosure

A scheduled analytics rule published on 17 September that looks for Microsoft 365 Copilot interactions where a user asks for the system or developer instructions and the response comes back carrying instruction, guardrail, persona or tool-architecture markers. It matches on both sides of the exchange — phrases like repeat the words above or everything above this line in the prompt, and phrases like you are an or you have access to the following tools in the response — and deliberately does not return the prompt or response text in the alert, so the detection does not become its own disclosure channel. The author is careful about what it proves: this is possible prompt-disclosure evidence, not proof that every returned phrase was confidential, so treat a hit as a lead rather than a finding. It needs the CopilotActivity table through the Microsoft Copilot connector.

CopilotActivity
| where RecordType == "CopilotInteraction"
| extend Prompt=tolower(tostring(LLMEventData.Prompt)), Response=tolower(tostring(LLMEventData.Response)), ConversationId=tostring(LLMEventData.ConversationId)
| where isnotempty(Prompt) and isnotempty(Response)
| extend AskedForPrompt=Prompt has_any ("system prompt", "developer message", "hidden instructions", "initial instructions", "reveal your prompt", "repeat the words above", "show me your prompt", "print your system", "everything above this line", "verbatim instructions")
| extend LeakMarker=Response has_any ("you are an", "your role is", "# system", "system prompt", "you must never", "do not reveal", "your instructions are", "you have access to the following tools", "your available tools", "you should always", "never disclose")
| where AskedForPrompt and LeakMarker
| extend Signal="POSSIBLE SYSTEM PROMPT DISCLOSURE"
| project TimeGenerated, Signal, AgentId, AgentName, ActorName, ActorUserId, ConversationId, SrcIpAddr, TenantId
| order by TimeGenerated desc

Foundry - Unapproved MCP server executed

From the same 17 September batch, a rule that flags a Microsoft Foundry agent invoking an MCP tool whose server URL, label or dependency target is not on an approved list — unauthorised server drift, a compromised project connection, or an agent reaching a server from the wrong environment. It reads AppDependencies from Application Insights and coalesces several property names for the server identity, because the preview SDKs emit it under gen_ai.tool fields, mcp.server fields, generic server fields or the dependency Target depending on version. Two things to do before you deploy it: populate a watchlist named FoundryMcpApprovedServers with at least one row where Approved is true and ServerIdentifier holds the exact normalised value your telemetry emits, and then enable the rule, because it ships disabled on purpose and no-ops while the watchlist is empty. Rows with no server metadata at all are excluded rather than alerted on, so pair it with an inventory hunt over the same data.

let approved =
    _GetWatchlist('FoundryMcpApprovedServers')
    | where tolower(tostring(column_ifexists('Approved', 'false'))) in ('true', '1', 'yes')
    | extend ApprovedServer = tolower(trim(' ', tostring(column_ifexists('ServerIdentifier', ''))))
    | where isnotempty(ApprovedServer)
    | distinct ApprovedServer;
let ApprovedCount = toscalar(approved | count);
AppDependencies
| where isnotempty(tostring(Properties["gen_ai.tool.name"]))
| extend
    Agent = tostring(Properties["gen_ai.agent.name"]),
    Model = tostring(Properties["gen_ai.request.model"]),
    ConvId = tostring(Properties["gen_ai.conversation.id"]),
    ToolName = tostring(Properties["gen_ai.tool.name"]),
    ToolType = tostring(Properties["gen_ai.tool.type"]),
    ToolDescription = tostring(Properties["gen_ai.tool.description"]),
    ServerLabel = coalesce(tostring(Properties["gen_ai.tool.server_label"]), tostring(Properties["mcp.server.label"]), tostring(Properties["server_label"])),
    ServerUrl = coalesce(tostring(Properties["gen_ai.tool.server_url"]), tostring(Properties["mcp.server.url"]), tostring(Properties["server_url"])),
    Operation = tostring(Properties["gen_ai.operation.name"])
| extend McpServer = coalesce(ServerUrl, ServerLabel, tostring(Target))
| where ToolType has "mcp" or Operation has "mcp" or ToolName has "mcp" or ToolDescription has "mcp" or McpServer has "mcp"
| where ApprovedCount > 0 and isnotempty(McpServer)
| extend NormalizedServer = tolower(trim(' ', McpServer))
| join kind=leftanti approved on $left.NormalizedServer == $right.ApprovedServer
| summarize
    Calls = count(),
    Failures = countif(Success == false),
    Tools = make_set(ToolName, 20),
    Conversations = dcountif(ConvId, isnotempty(ConvId)),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    Model = take_any(Model)
    by Agent, McpServer
| extend
    AccountName = iff(isempty(Agent), "unknown-agent", Agent),
    McpServerUrl = iff(McpServer startswith "http://" or McpServer startswith "https://", McpServer, "")
| project LastSeen, AccountName, Agent, Model, McpServer, McpServerUrl,
    Calls, Failures, Conversations, Tools, FirstSeen
| order by Calls desc

Unexpected Attachment Types in Emails

An attachment-type hunt published on 10 September that pulls a curated extension blocklist into advanced hunting with externaldata rather than hard-coding one. The list is built from the CIS baseline, the Microsoft common attachment filter and a set of additional extensions, and the query joins EmailAttachmentInfo to EmailEvents so you can see delivery outcome alongside the attachment. The author's point is that you run this first to find out what your users actually receive, then feed the extensions that show up into the anti-malware policy's common attachment filter to quarantine or reject them — a commented-out line lets you exclude anything already blocked so you are only looking at what got through.

//After Hunting consider adding attachment types in AntiMalware policy to quarantine/reject emails containing unexpected attachment types - the supplied list includes CIS baseline, the common attachment filter and some additional extension types.
let ExtensionList = externaldata(extension: string)[@"https://raw.githubusercontent.com/jkerai1/TLD-TABL-Block/refs/heads/main/FileExtensionsForAntiMalwarePolicy.csv"] with (format="csv", ignoreFirstRecord=True)
| extend extension = strcat(".",extension);
EmailAttachmentInfo
| where TimeGenerated > ago(90d)
| where FileExtension in(ExtensionList)
| join EmailEvents on NetworkMessageId
//| where DeliveryAction != @"Delivered" //if you wish to filter out what was already blocked

Multiple-Unexpected AD FS authentication flow

A Sentinel hunt from 9 September that looks for AD FS sign-ins where more than one IP address appears in the authentication flow, which is the signature of a proxy sitting between the user and the federation service. It reads the IP Addresses Involved in Auth Flow field out of AuthenticationProcessingDetails in ADFSSignInLogs, drops flows with a single address and any sourced from your known AD FS egress, then pivots by correlation ID into SigninLogs and AADNonInteractiveUserSignInLogs to establish what the resulting session went on to touch — apps accessed, resources reached, user agent, device detail. Trusted named locations are filtered out at the network-details step. It expects a watchlist named Activity-ExpectedSignificantActivity holding your expected AD FS addresses and trusted named locations, so populate that before the first run or everything looks anomalous. Given the adversary-in-the-middle activity in the passkey campaign above, this one is timely.

let query_frequency = 5m;
let query_period = 2d;
let query_wait = 30m;
let _ExpectedADFSIPAddress = toscalar(
    _GetWatchlist("Activity-ExpectedSignificantActivity")
    | where Activity == "ADFSAuthenticationAddress"
    | summarize make_list(SourceAddress)
);
let _TrustedNamedLocations = toscalar(
    _GetWatchlist("Activity-ExpectedSignificantActivity")
    | where Activity == "TrustedNamedLocation"
    | summarize make_list(SourceResource)
);
ADFSSignInLogs
| where TimeGenerated between (ago(query_frequency + query_wait) .. ago(query_wait))
| where ResultType == 0 and isnotempty(IPAddress)
| mv-apply AuthenticationProcessingDetail = todynamic(AuthenticationProcessingDetails) on (
    where AuthenticationProcessingDetail has "IP Addresses Involved in Auth Flow"
    | extend IPAddressesInvolved = split(tostring(AuthenticationProcessingDetail["value"]), ",")
    )
| project-away AuthenticationProcessingDetail
| where not(array_length(IPAddressesInvolved) == 1) and not(IPAddress in (_ExpectedADFSIPAddress))
| summarize Auxiliar = arg_max(array_length(IPAddressesInvolved), IPAddressesInvolved) by UserId, CorrelationId
| project-away Auxiliar
| join kind = inner (
    union SigninLogs, AADNonInteractiveUserSignInLogs
    | where TimeGenerated > ago(query_period + query_wait)
    | where isnotempty(CorrelationId) and isnotempty(SessionId)
    ) on UserId, CorrelationId
| project-away *1
| summarize arg_min(CreatedDateTime, *) by UserId, HomeTenantId, ResourceTenantId, IPAddress, CorrelationId
| mv-apply NetworkLocationDetail = todynamic(NetworkLocationDetails) on (
    summarize
        //NetworkTypes = make_set(tostring(NetworkLocationDetail["networkType"])),
        NetworkNames = make_set(tostring(NetworkLocationDetail["networkNames"][0]))
    )
| where not(NetworkNames has_any (_TrustedNamedLocations))
| project IPAddressesInvolved, UserId, CorrelationId, SessionId
| join kind = inner (
    union SigninLogs, AADNonInteractiveUserSignInLogs
    | where TimeGenerated > ago(query_period + query_wait)
    | where isnotempty(SessionId)
    ) on UserId, SessionId
| project-away *1
| summarize
    arg_min(CreatedDateTime, *),
    AccesedApps = array_sort_asc(make_set_if(AppDisplayName, ResultType == 0, 50)),
    AccessedResources = array_sort_asc(make_set_if(ResourceDisplayName, ResultType == 0, 50)),
    SessionIds = make_set(SessionId),
    CorrelationIds = make_set(CorrelationId)
    by UserId, IPAddress, HomeTenantId, ResourceTenantId
| project
    TimeGenerated,
    CreatedDateTime,
    UserPrincipalName,
    UserDisplayName,
    SessionIds,
    IPAddressesInvolved,
    IPAddress,
    Location,
    NetworkLocationDetails,
    AutonomousSystemNumber,
    AuthenticationDetails,
    ClientAppUsed,
    DeviceDetail = coalesce(tostring(DeviceDetail_dynamic), DeviceDetail_string),
    UserAgent,
    Category,
    AccesedApps,
    AccessedResources,
    HomeTenantId,
    ResourceTenantId,
    CorrelationIds,
    UserId

eol_products.kql

An end-of-life hunt for Defender XDR, published 4 September, that pulls the endoflife.date dataset into advanced hunting with externaldata and joins it to your own estate. Query A lists devices whose operating system reaches end of life within the next 180 days; query B lists installed applications and databases already past end of life. The matching logic is the valuable part and the reason not to write this yourself — it handles the ways Defender actually populates these fields, including CPE-style lower-case vendor and name tokens, qualifiers such as firefox_esr and the (x64) and (user) suffixes, and the fact that Microsoft products report an internal build rather than the marketing year, so SQL Server major version 13 maps to 2016 and Office 12 to 2007. Office 16.x is deliberately left unmapped because 2016, 2019, 2021 and 365 cannot be told apart from the build.

// =====================================================================================
// End-of-Life (EOL) hunting — endoflife.date  ->  Microsoft Defender XDR
// Data: EOL_Products/eol_products.csv  (built from https://endoflife.date/ v1 API)
// Two self-contained queries: (A) Operating systems, (B) Applications & databases.
// Matching is tuned to how Defender actually populates the fields:
//   DeviceInfo:  OSPlatform / OSVersion / OSVersionInfo
//   DeviceTvmSoftwareInventory: SoftwareVendor / SoftwareName / SoftwareVersion
//     - vendor & name are lower-case CPE-style tokens (microsoft, mozilla, oracle...)
//     - name carries qualifiers: firefox_esr, firefox_for_mac, acrobat_reader_dc_for_mac,
//       office_16_click-to-run_..., *_(x64), *_(x64_de), *_(user)
//     - version is often an internal build, not the marketing version
// =====================================================================================
// ============================ QUERY A — OPERATING SYSTEMS ============================
// Devices whose OS reaches end-of-life within the next 180 days.
let StartDate = now();
let EndDate = datetime_add('day', 180, StartDate);
let eol = externaldata(
    category:string,
    product_id:string,
    product:string,
    release:string,
    release_date:string,
    eol_date:string,
    is_eol:string,
    is_maintained:string,
    latest_version:string,
    cpe:string,
    defender_vendor:string,
    defender_softwarename:string,
    aliases:string
)
[@"https://raw.githubusercontent.com/Sergio-Albea-Git/Threat-Hunting-KQL-Queries/main/EOL_Products/eol_products.csv"]
with (format="csv", ignoreFirstRecord=true);
let eol_os =
    eol
    | where category == "os" and isnotempty(eol_date)
    | extend eolDate = todatetime(eol_date)
    | extend os_key = case(
        product_id == "windows"
            and release !contains "(E)"
            and release !contains "IoT"
            and release !contains "LTS",
            strcat("win|", extract(@"^(1[01]\s+\w+)", 1, release)),
        product_id == "windows-server"
            and release contains "LTSC",
            strcat("winsrv|", extract(@"Windows Server (\d{4})", 1, release)),
        product_id == "macos",
            strcat("mac|", extract(@"(?:macOS|OS X|Mac OS X)\s+(\d+)", 1, release)),
        product_id == "ios",
            strcat("ios|", extract(@"^(\d+)", 1, release)),
        product_id == "android",
            strcat("android|", extract(@"^(\d+)", 1, release)),
        ""
    )
    | where isnotempty(os_key) and not(os_key endswith "|")
    | where eolDate between (StartDate .. EndDate);
DeviceInfo
| where isnotempty(OSPlatform)
| summarize arg_max(Timestamp, OSVersion, OSVersionInfo)
    by DeviceId, DeviceName, OSPlatform
| extend os_family = case(
    OSPlatform startswith "WindowsServer", "winsrv",
    OSPlatform startswith "Windows", "win",
    OSPlatform =~ "macOS", "mac",
    OSPlatform =~ "iOS", "ios",
    OSPlatform =~ "Android", "android",
    "other"
)
| extend os_key = case(
    os_family == "winsrv",
        strcat("winsrv|", extract(@"(\d{4})", 1, OSPlatform)),
    os_family == "win",
        strcat("win|", extract(@"(\d+)$", 1, OSPlatform), " ", OSVersionInfo),
    os_family == "mac",
        strcat("mac|", tostring(split(OSVersion, ".")[0])),
    os_family == "ios",
        strcat("ios|", tostring(split(OSVersion, ".")[0])),
    os_family == "android",
        strcat("android|", tostring(split(OSVersion, ".")[0])),
    ""
)
| where os_family != "other" and isnotempty(os_key)
| join kind=inner eol_os on os_key
| extend DaysToEOL = datetime_diff('day', eolDate, now())
| project
    DeviceName,
    OSPlatform,
    OSVersion,
    OSVersionInfo,
    Matched = product,
    EOL_Release = release,
    EOL_Date = eol_date,
    DaysToEOL
| sort by DaysToEOL asc
// ======================= QUERY B — APPLICATIONS & DATABASES (past EOL) ================
// DeviceTvmSoftwareInventory software whose INSTALLED VERSION is already end-of-life.
// Name is normalized (strip _for_mac / _(x64) / _(user)) and matched to the vendor token;
// the version is mapped to the correct endoflife release so only the EOL cycles are flagged.
let eol = externaldata(
    category:string,
    product_id:string,
    product:string,
    release:string,
    release_date:string,
    eol_date:string,
    is_eol:string,
    is_maintained:string,
    latest_version:string,
    cpe:string,
    defender_vendor:string,
    defender_softwarename:string,
    aliases:string
)
[@"https://raw.githubusercontent.com/Sergio-Albea-Git/Threat-Hunting-KQL-Queries/main/EOL_Products/eol_products.csv"]
with (format="csv", ignoreFirstRecord=true);
let eol_apps =
    eol
    | where category in ("app", "database")
        and isnotempty(defender_vendor) and isnotempty(defender_softwarename) and isnotempty(eol_date)
    | extend eolDate = todatetime(eol_date)
    // normalize the release label to a numeric key: "8.0 (LTS)"->"8.0", "115 (ESR)"->"115",
    // "2013 SP1"->"2013", "2005 'Yukon' SP4"->"2005", "24.8"->"24.8"
    | extend rel_clean = replace_regex(tolower(release), @"'[^']*'", "")
    | extend rel_clean = replace_regex(rel_clean, @"\(esr\)|\(lts\)|for mac|sp[0-9]+|\(w\)|\(e\)|\(ltsc\)|\(ac\)|\(sac\)", "")
    | extend rel_key = extract(@"([0-9]+(?:\.[0-9]+)?)", 1, rel_clean)
    | where isnotempty(rel_key);
DeviceTvmSoftwareInventory
| where isnotempty(SoftwareVendor) and isnotempty(SoftwareName) and isnotempty(SoftwareVersion)
| extend v = tolower(SoftwareVendor)
| extend nname = replace_regex(tolower(SoftwareName), @"_for_mac$|_\(x64[^)]*\)$|_\(x86[^)]*\)$|_\(user\)$", "")
| extend vmajor = tostring(split(SoftwareVersion, ".")[0])
| extend vminor = tostring(split(SoftwareVersion, ".")[1])
| extend vmm = iff(isnotempty(vminor), strcat(vmajor, ".", vminor), vmajor)
| join kind=inner eol_apps on $left.v == $right.defender_vendor
// match the Defender name to the endoflife token (exact, or token_ prefix for sub-components)
| where nname == defender_softwarename or nname startswith strcat(defender_softwarename, "_")
// Microsoft products report an internal build, not the marketing year -> map build major to year.
// Office 16.x = 2016/2019/2021/365 and cannot be told apart from the build, so it is left blank.
| extend ms_year = case(
    v == "microsoft" and defender_softwarename == "sql_server",
        case(vmajor == "9", "2005", vmajor == "10", "2008", vmajor == "11", "2012",
             vmajor == "12", "2014", vmajor == "13", "2016", vmajor == "14", "2017",
             vmajor == "15", "2019", vmajor == "16", "2022", ""),
    v == "microsoft" and defender_softwarename == "office",
        case(vmajor == "12", "2007", vmajor == "14", "2010", vmajor == "15", "2013", ""),
    "")
| where rel_key == vmajor or rel_key == vmm or (isnotempty(ms_year) and rel_key == ms_year)
| where eolDate < now()
| summarize arg_min(eolDate, product, release, eol_date)
    by DeviceName, SoftwareVendor, SoftwareName, SoftwareVersion
| project
    DeviceName,
    SoftwareVendor,
    SoftwareName,
    SoftwareVersion,
    Matched = product,
    EOL_Release = release,
    EOL_Date = eol_date,
    DaysPastEOL = datetime_diff('day', now(), todatetime(eol_date))
| sort by DaysPastEOL desc

Multiple-Uncommon loaded image connection to suspicious domain

Two related Defender for Endpoint hunts from the same author, both keyed on a shared list of domain patterns that show up in tooling and exfiltration rather than in normal traffic: Burp Collaborator (oastify.com, portswigger.net), the whatismyip family, and generated-looking hostnames under CloudFront, KeyCDN, Azure Front Door, Azure cloudapp and AWS API Gateway. The first correlates network events with image load events on the same process, so it surfaces the loaded module behind the connection and filters on FileProfile prevalence and signature state to drop the common and the signed.

let query_frequency = 1h;
let query_period = 14d;
let suspicious_domains = dynamic([
    @"d\d[a-z0-9]{12}\.cloudfront\.net",
    @"[\-\w]+\-[a-f0-9]{3,5}\.kxcdn\.com",
    @"[\-\w]+\-[a-z0-9]{16}\.\w\d\d\.azurefd\.net",
    @"[\-\w]+\.[a-z0-9]+\.cloudapp\.azure\.com",
    @"[a-z0-9]{10}\.execute\-api\.[a-z]{2}\-[a-z]+\-\d\.amazonaws\.com",
    @"portswigger\.net",
    @"oastify\.com",
    @"whatismyip\.com",
    @"whatismyip\.net",
    @"whatismyipaddress\.com"
]);
let excluded_urls = dynamic([
    "uhf-exp-fd-gbcrdgggfbggh0g3.b02.azurefd.net",
    "vs-exp-afd-prod-endpoint-e3b9a0c0h0e8d6fd.b02.azurefd.net"
]);
let excluded_company_names = dynamic([]);
let excluded_original_names = dynamic([]);
DeviceNetworkEvents
| where Timestamp > ago(query_period)
| where RemoteUrl matches regex strcat_array(suspicious_domains, "|") // and not(InitiatingProcessAccountSid in ("S-1-5-18", "S-1-5-20"))
//| where not(RemoteUrl has_any (excluded_urls))
| where not(InitiatingProcessUniqueId == 0)
| project DeviceId, DeviceName, LocalIP, ActionType, RemoteIP, RemotePort, RemoteUrl, Protocol, InitiatingProcessUniqueId
| as _AuxiliarEvents
| join kind=inner (
    DeviceImageLoadEvents
    | where Timestamp > ago(query_period)
    | where not(InitiatingProcessUniqueId == 0) and (isnotempty(SHA1) or isnotempty(SHA256) or isnotempty(MD5)) and DeviceId in (toscalar(_AuxiliarEvents | summarize make_set(DeviceId)))
    | project-away DeviceName, ActionType
    ) on DeviceId, InitiatingProcessUniqueId
| project-away DeviceId1, InitiatingProcessUniqueId1
| summarize
    StartTime = arg_min(Timestamp, *),
    EndTime = max(Timestamp),
    DeviceNamesSample = array_sort_asc(make_set(DeviceName, 100)),
    RemoteUrlsSample = array_sort_asc(make_set(RemoteUrl, 100))
    by SHA1, SHA256, MD5
| where StartTime > ago(query_frequency)
| invoke FileProfile("SHA1", 1000)
| where not(GlobalPrevalence > 10000)
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_period))
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_frequency) and SignatureState == "SignedValid")
| where not(GlobalPrevalence > 500 and InitiatingProcessVersionInfoCompanyName in (excluded_company_names) and InitiatingProcessVersionInfoOriginalFileName in (excluded_original_names))
| where not(GlobalFirstSeen < ago(5 * 365d))
| project
    StartTime,
    EndTime,
    DeviceNamesSample,
    RemoteUrlsSample,
    Timestamp = StartTime,
    DeviceId,
    DeviceName,
    LocalIP,
    ActionType,
    RemoteIP,
    RemotePort,
    RemoteUrl,
    Protocol,
    FileName,
    FolderPath,
    SHA1,
    SHA256,
    MD5,
    FileSize,
    GlobalPrevalence,
    GlobalFirstSeen,
    GlobalLastSeen,
    SignatureState,
    InitiatingProcessAccountName,
    InitiatingProcessAccountSid,
    InitiatingProcessAccountUpn,
    InitiatingProcessAccountObjectId,
    InitiatingProcessFileName,
    InitiatingProcessFolderPath,
    InitiatingProcessCommandLine,
    InitiatingProcessCreationTime,
    IsInitiatingProcessRemoteSession,
    InitiatingProcessParentFileName,
    InitiatingProcessVersionInfoCompanyName,
    InitiatingProcessVersionInfoProductName,
    InitiatingProcessVersionInfoOriginalFileName,
    InitiatingProcessVersionInfoInternalFileName,
    InitiatingProcessVersionInfoFileDescription,
    InitiatingProcessVersionInfoProductVersion,
    InitiatingProcessUniqueId,
    ReportId

DeviceNetworkEvents-Uncommon process connection to suspicious domain

The companion query drops the image load join and groups by the initiating process's version metadata instead, which makes it the lighter of the two and the better starting point if you want to see which uncommon binaries in your estate are reaching those domains at all. It was published on 24 August, a few days ahead of the one above.

let query_frequency = 1h;
let query_period = 14d;
let suspicious_domains = dynamic([
    @"d\d[a-z0-9]{12}\.cloudfront\.net",
    @"[\-\w]+\-[a-f0-9]{3,5}\.kxcdn\.com",
    @"[\-\w]+\-[a-z0-9]{16}\.\w\d\d\.azurefd\.net",
    @"[\-\w]+\.[a-z0-9]+\.cloudapp\.azure\.com",
    @"[a-z0-9]{10}\.execute\-api\.[a-z]{2}\-[a-z]+\-\d\.amazonaws\.com",
    @"portswigger\.net",
    @"oastify\.com",
    @"whatismyip\.com",
    @"whatismyip\.net",
    @"whatismyipaddress\.com"
]);
let excluded_company_names = dynamic([]);
let excluded_original_names = dynamic([]);
DeviceNetworkEvents
| where Timestamp > ago(query_period)
| where RemoteUrl matches regex strcat_array(suspicious_domains, "|") // and not(InitiatingProcessAccountSid in ("S-1-5-18", "S-1-5-20"))
| where isnotempty(InitiatingProcessFileName)
| summarize
    StartTime = arg_min(Timestamp, *),
    EndTime = max(Timestamp),
    DeviceNamesSample = array_sort_asc(make_set(DeviceName, 100)),
    RemoteUrlsSample = array_sort_asc(make_set(RemoteUrl, 100))
    by InitiatingProcessVersionInfoCompanyName, InitiatingProcessVersionInfoProductName, InitiatingProcessVersionInfoOriginalFileName, InitiatingProcessVersionInfoInternalFileName, InitiatingProcessVersionInfoFileDescription
| where StartTime > ago(query_frequency)
| invoke FileProfile("InitiatingProcessSHA1", 1000)
| where not(GlobalPrevalence > 10000)
| where not(GlobalPrevalence > 1000 and GlobalFirstSeen < ago(query_frequency) and SignatureState == "SignedValid")
| where not(GlobalPrevalence > 500 and InitiatingProcessVersionInfoCompanyName in (excluded_company_names) and InitiatingProcessVersionInfoOriginalFileName in (excluded_original_names))
| project
    StartTime,
    EndTime,
    DeviceNamesSample,
    RemoteUrlsSample,
    Timestamp = StartTime,
    DeviceId,
    DeviceName,
    LocalIP,
    ActionType,
    RemoteIP,
    RemotePort,
    RemoteUrl,
    Protocol,
    InitiatingProcessAccountName,
    InitiatingProcessAccountSid,
    InitiatingProcessAccountUpn,
    InitiatingProcessAccountObjectId,
    InitiatingProcessSHA1,
    InitiatingProcessSHA256,
    InitiatingProcessMD5,
    InitiatingProcessFileName,
    InitiatingProcessFolderPath,
    InitiatingProcessCommandLine,
    InitiatingProcessCreationTime,
    IsInitiatingProcessRemoteSession,
    InitiatingProcessParentFileName,
    InitiatingProcessVersionInfoCompanyName,
    InitiatingProcessVersionInfoProductName,
    InitiatingProcessVersionInfoOriginalFileName,
    InitiatingProcessVersionInfoInternalFileName,
    InitiatingProcessVersionInfoFileDescription,
    InitiatingProcessVersionInfoProductVersion,
    GlobalPrevalence,
    GlobalFirstSeen,
    GlobalLastSeen,
    SignatureState,
    ReportId

Hunting CVE-2026-65400 Exploitation on macOS

Hunts Defender XDR telemetry for the macOS Screen Sharing attack path behind CVE-2026-65400, covering privileged Screen Sharing file transfer helpers, SSH session persistence, LaunchDaemon writes, process masquerading, Packet Filter changes and XMRig artefacts — useful for answering whether any of it was already happening before you patched.

let Days = 30d;
let KnownSHA256 = "84006055916e267f7c2f9324f1848563e589e4526a296d4e9e9ce8e2112d357c";
union
( // 1. Initial access / execution: Root Screen Sharing activity, SSH persistence, masquerading and PF modification.
DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceProcessEvents on  DeviceId
    | where Timestamp > ago(Days)
    | where
        FileName =~ "SSFileCopyReceiver" or ProcessCommandLine has "sshd-session -i -R" or ProcessCommandLine has "exec -a com.apple.airportd"
        or ProcessCommandLine has "pfctl" or ProcessCommandLine has "com.xmr.miner.plist" or ProcessCommandLine has "sysmond"
    | extend HuntingSignal = case(
        FileName =~ "SSFileCopyReceiver", "Screen Sharing file transfer", ProcessCommandLine has "sshd-session -i -R",
            "SSH remote session activity", ProcessCommandLine has "exec -a com.apple.airportd", "Process masquerading",
        ProcessCommandLine has "pfctl", "Packet Filter modification", ProcessCommandLine has "com.xmr.miner.plist",
            "LaunchDaemon persistence", ProcessCommandLine has "sysmond", "Hidden miner execution", "Suspicious process activity"
    )
    | project Timestamp,DeviceName, ActionType, HuntingSignal, FileName, FolderPath, SHA256, ProcessCommandLine, InitiatingProcessFileName
),(// 2. File / persistence activity: SSH keys, hidden files, privileged paths and LaunchDaemon persistence
DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceFileEvents on DeviceId
    | where Timestamp > ago(Days)
    | where SHA256 == KnownSHA256
        or FolderPath has "/private/var/root/.config" or FolderPath has "/Library/LaunchDaemons" or FolderPath has "/private/var/root/.ssh"
    | extend HuntingSignal = case(
        SHA256 == KnownSHA256, "Known XMRig SHA256",
        FolderPath has "/private/var/root/.config", "Hidden root file activity",
        FolderPath has "/Library/LaunchDaemons", "LaunchDaemon activity",
        FolderPath has "/private/var/root/.ssh", "Root SSH activity", "Suspicious file activity"
    )
    | project Timestamp, DeviceName, ActionType, HuntingSignal, FileName, FolderPath, SHA256,
        ProcessCommandLine = InitiatingProcessCommandLine,InitiatingProcessFileName
),
(// 3. Network activity: Observed mining pool plus SSH and Screen Sharing traffic.Ports 22/5900 are context only, not malicious by themselves.
 DeviceInfo| where OSPlatform has 'MacOs' | join kind=inner DeviceNetworkEvents on DeviceId
    | where Timestamp > ago(Days)
    | where RemoteUrl =~ "auto.c3pool.org" or RemotePort in (22, 5900)
    | extend HuntingSignal = case(
        RemoteUrl =~ "auto.c3pool.org", "Observed mining pool",
        RemotePort == 5900, "Screen Sharing / VNC traffic",
        RemotePort == 22, "SSH traffic","Suspicious network activity"
    )
    | project Timestamp, DeviceName, ActionType, HuntingSignal, FileName = InitiatingProcessFileName, FolderPath = "", SHA256 = InitiatingProcessSHA256,
        ProcessCommandLine = InitiatingProcessCommandLine, InitiatingProcessFileName
)

Suspicious SSH Tunneling and Config Exposure

Scores SSH port forwarding and tunnelling against a 30-day per-device baseline, weighting listener scope and persistent-shell flags, and pairs it with unauthorised sshd_config writes and GatewayPorts or PermitTunnel appearing on a command line.

let Lookback    = 7d;
let BaselineWin = 30d;
// Set to true to filter out noisy local development tunnels on loopback and focus on higher-risk activity
let HighFidelityOnly = true;
let FwdRegex = @"(^|\s)-[LDR] ?(\d{1,5}(:|\s|$)|\*:|0\.0\.0\.0:|localhost:|\[)";
let SshTunnelEvents = (StartTime:datetime, EndTime:datetime) {
    DeviceProcessEvents
    | where Timestamp between (StartTime .. EndTime)
    // Ignore sshd here, as -D and -R are standard daemon flags there
    | where FileName in~ ("ssh", "ssh.exe", "plink.exe", "putty.exe")
    | where ProcessCommandLine matches regex FwdRegex
    | extend TunnelFlag   = extract(@"(^|\s)-([LDR]) ?(\d|\*:|0\.0\.0\.0:|localhost:|\[)", 2, ProcessCommandLine)
    | extend ForwardSpecs = extract_all(@"(?:^|\s)-[LDR] ?(\S+)", ProcessCommandLine)
    | extend AllSpecs     = strcat_array(ForwardSpecs, " ")
    | extend SshTarget    = extract(@"\s([\w\.\-]+@[\w\.\-]+)", 1, ProcessCommandLine)
    | extend CmdPattern   = replace_regex(ProcessCommandLine, @"\b\d{4,5}\b", "<port>")
};
let TunnelBaseline =
    SshTunnelEvents(ago(BaselineWin + Lookback), ago(Lookback))
    | distinct DeviceName, AccountName, CmdPattern;
let TunnelFindings =
    SshTunnelEvents(ago(Lookback), now())
    | extend TunnelType = case(
        TunnelFlag == "L", "Local Port Forwarding (-L)",
        TunnelFlag == "D", "Dynamic Port Forwarding / SOCKS (-D)",
        TunnelFlag == "R", "Remote Port Forwarding (-R)",
        "SSH Tunneling / Other")
    // Explicit binding requires all four fields (bind:port:host:hostport)
    | extend ListenerScope = case(
        AllSpecs matches regex @"(^|\s)(\*|0\.0\.0\.0|::):",           "Bind to all interfaces",
        AllSpecs matches regex @"[\w\.\-]+:\d{1,5}:[\w\.\-]+:\d{1,5}", "Bind to explicit address",
        "Loopback (Default)")
    | extend PersistentTunnel = ProcessCommandLine matches regex @"(^|\s)-(fN|Nf|N|f)(\s|$)"
    // Regular local dev tunnels on loopback are excluded in High-Fidelity mode
    | where HighFidelityOnly == false
        or ListenerScope != "Loopback (Default)"
        or TunnelType startswith "Remote"
    | summarize
        EventCount     = count(),
        FirstSeen      = min(Timestamp),
        LastSeen       = max(Timestamp),
        SampleCmd      = any(ProcessCommandLine),
        Parents        = make_set(InitiatingProcessFileName, 8),
        SshTargets     = make_set_if(SshTarget, isnotempty(SshTarget), 8),
        ForwardSpecSet = make_set(AllSpecs, 8),
        ReportId       = any(ReportId),
        DeviceId       = any(DeviceId)
        by DeviceName, AccountName, InitiatingProcessAccountName, FileName,
           TunnelType, ListenerScope, PersistentTunnel, CmdPattern
    | join kind=leftanti TunnelBaseline on DeviceName, AccountName, CmdPattern
    | extend RiskScore =
          iff(ListenerScope == "Bind to all interfaces",   40, 0)
        + iff(ListenerScope == "Bind to explicit address", 20, 0)
        + iff(TunnelType startswith "Remote",                30, 0)
        + iff(PersistentTunnel,                               15, 0)
        + iff(TunnelType startswith "Dynamic",               10, 0)
    | project
        Category  = "SSH Port Forwarding",
        RiskScore,
        Signal    = TunnelType,
        Evidence  = strcat(TunnelType, " | ", ListenerScope,
                        iff(PersistentTunnel, " | persistent tunnel without shell (-N/-f)", "")),
        DeviceName,
        Account   = coalesce(AccountName, InitiatingProcessAccountName),
        EventCount,
        FirstSeen,
        LastSeen,
        CLI       = SampleCmd,
        SampleCmd,
        Details   = strcat("Specs: ", strcat_array(ForwardSpecSet, ", "),
                        iff(array_length(SshTargets) > 0, strcat(" | Targets: ", strcat_array(SshTargets, ", ")), ""),
                        " | Parent: ", strcat_array(Parents, ", ")),
        ReportId,
        DeviceId;
let ConfigWrites =
    DeviceFileEvents
    | where Timestamp > ago(Lookback)
    | where FileName =~ "sshd_config" or FolderPath has "/etc/ssh/sshd_config.d"
    | where ActionType in ("FileCreated", "FileModified", "FileRenamed")
    | project Timestamp, DeviceName, Account = InitiatingProcessAccountName,
              Cmd = InitiatingProcessCommandLine, Path = FolderPath,
              Signal = "sshd_config modified", Score = 35, ReportId, DeviceId;
let GatewayPortsCmd =
    DeviceProcessEvents
    | where Timestamp > ago(Lookback)
    | where ProcessCommandLine has_cs "GatewayPorts" or ProcessCommandLine has_cs "PermitTunnel"
    | where ProcessCommandLine !contains "GatewayPorts=no" and ProcessCommandLine !contains "GatewayPorts no"
    | project Timestamp, DeviceName, Account = InitiatingProcessAccountName,
              Cmd = ProcessCommandLine, Path = FolderPath,
              Signal = "GatewayPorts/PermitTunnel in command line", Score = 45, ReportId, DeviceId;
let ConfigFindings =
    union ConfigWrites, GatewayPortsCmd
    // Adjust admin and deployment accounts based on your environment
    | where Account !in~ ("puppet", "ansible", "salt")
        and Cmd !has "cloud-init" and Cmd !has "unattended-upgrade"
    | summarize
        EventCount = count(),
        FirstSeen  = min(Timestamp),
        LastSeen   = max(Timestamp),
        SampleCmd  = any(Cmd),
        Paths      = make_set(Path, 4),
        RiskScore  = max(Score),
        ReportId   = any(ReportId),
        DeviceId   = any(DeviceId)
        by DeviceName, Account, Signal
    | project
        Category  = "sshd_config Exposure",
        RiskScore,
        Signal,
        Evidence  = strcat(Signal, " by ", Account),
        DeviceName,
        Account,
        EventCount,
        FirstSeen,
        LastSeen,
        CLI       = SampleCmd,
        SampleCmd,
        Details   = strcat("Paths: ", strcat_array(Paths, ", ")),
        ReportId,
        DeviceId;
union TunnelFindings, ConfigFindings
| extend Severity = case(RiskScore >= 40, "High", RiskScore >= 20, "Medium", "Low")
| project-reorder Severity, RiskScore, Category, Evidence, DeviceName, Account,
              EventCount, FirstSeen, LastSeen, CLI, SampleCmd, Details
| sort by RiskScore desc, LastSeen desc

Suspicious LDAP Reconnaissance from Non-Compliant Devices

Correlates non-compliant or health-flagged Intune devices with LDAP reconnaissance from the same host, pairing device posture with directory enumeration.

let Lookback		= 1d;
let BinSize		= 15m;
let RiskyDevices = materialize(
	IntuneDeviceComplianceOrg
	| where TimeGenerated > ago(7d)
	| summarize arg_max(TimeGenerated, DeviceHealthThreatLevel, ComplianceState) by DeviceName
	| where DeviceHealthThreatLevel in~ ("Low", "Medium", "High")
		or ComplianceState =~ "Noncompliant"
	| extend DeviceKey = tolower(tostring(split(DeviceName, ".")[0]))
	| distinct DeviceKey, DeviceHealthThreatLevel, ComplianceState
);
// Attributes split by signal strength instead of a flat has_any
let RxHigh	= @"ms-mcs-admpwd|mslaps-(encrypted)?password|ntsecuritydescriptor|msds-allowedtodelegateto|msds-keycredentiallink|sidhistory|unixuserpassword";
let RxMed	= @"serviceprincipalname|admincount|msds-managedpassword|gplink|scriptpath";
let RxLow	= @"useraccountcontrol|memberof|member|primarygroupid|grouptype";
// LDAP matching rules and bitmask filters, almost exclusively seen from offensive tooling
let RxTool	= @"1\.2\.840\.113556\.1\.4\.1941|1\.2\.840\.113556\.1\.4\.803:=(4194304|524288|16777216|8192)";
IdentityQueryEvents
| where TimeGenerated > ago(Lookback)
| where ActionType == "LDAP query"
| where isnotempty(Query) and isnotempty(DeviceName)
| where AccountName !endswith "$"				// Computer accounts need their own baseline
| extend DeviceKey = tolower(tostring(split(DeviceName, ".")[0]))
| lookup kind=inner RiskyDevices on DeviceKey
| extend q = tolower(Query)
| extend
	ToolHit	= extract(RxTool, 0, q),
	Score	= iff(q matches regex RxHigh, 5, 0)
		+ iff(q matches regex RxMed,  2, 0)
		+ iff(q matches regex RxLow,  1, 0)
		+ iff(q matches regex RxTool, 5, 0)
		+ iff(q contains "objectcategory=person" and q contains "(&", 1, 0)
| where Score > 0
| summarize
	Events		= count(),
	DistinctQueries	= dcount(Query),
	Targets		= dcount(QueryTarget),
	MaxScore	= max(Score),
	TotalScore	= sum(Score),
	ToolIndicators	= make_set_if(ToolHit, isnotempty(ToolHit), 10),
	SampleQueries	= make_set(substring(Query, 0, 200), 8),
	IPs		= make_set(IPAddress, 5),
	TargetDCs	= make_set(DestinationDeviceName, 5),
	FirstSeen	= min(TimeGenerated),
	LastSeen	= max(TimeGenerated)
	by bin(TimeGenerated, BinSize), DeviceKey, AccountUpn, AccountDisplayName, DeviceHealthThreatLevel, ComplianceState
// Two separate triggers: single high confidence hit OR bulk enumeration
| where MaxScore >= 5 or (TotalScore >= 15 and DistinctQueries >= 20)
| extend Verdict = case(
	array_length(ToolIndicators) > 0,	"High: LDAP matching rule or bitmask filter, typical for offensive tooling",
	MaxScore >= 5,				        "High: access to highly sensitive AD attributes (LAPS/ACL/delegation)",
	DistinctQueries >= 50,			    "Medium: broad AD enumeration in a short time window",
						                "Low: elevated LDAP activity, review context")
| order by MaxScore desc, DistinctQueries desc

Azure Log Analytic Table Operation Audit

Audits write and delete operations against Log Analytics workspace tables, covering the tamper path that removes the evidence rather than the alert.

//For auditing Write/Delete actions upon LAW Tables
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue contains "MICROSOFT.OPERATIONALINSIGHTS/WORKSPACES/TABLES/"
| extend TableName = Properties_d.resource
| project-reorder TimeGenerated, TableName, Caller

CloudAppEvents-No threats found by AIR investigation in phishing submission

Lists user-reported phishing submissions where automated investigation returned no threats found, the queue where genuine phishing quietly ends up.

CloudAppEvents
| where ActionType == "AirInvestigationData"
| extend
    Status = tostring(RawEventData["Status"]),
    InvestigationId = tostring(RawEventData["InvestigationId"]),
    InvestigationType = tostring(RawEventData["InvestigationType"]),
    InvestigationName = tostring(RawEventData["InvestigationName"]),
    Data = todynamic(tostring(RawEventData["Data"]))
| where InvestigationType == "SubmissionInvestigation" and InvestigationName has "User reported message as malicious" and Status == "No threats found"
| extend
    AlertType = tostring(Data["AlertType"]),
    AlertId = strcat("fa", tostring(Data["ProviderAlertId"])),
    InternetMessageId = tostring(Data["Entities"][0]["InternetMessageId"]),
    NetworkMessageId = tostring(Data["Entities"][0]["NetworkMessageId"]),
    SenderFromAddress = tostring(Data["Entities"][0]["Sender"]),
    RecipientEmailAddress = tostring(Data["Entities"][0]["Recipient"]),
    Subject = tostring(Data["Entities"][0]["Subject"])
| where AlertType == "b26a5770-0c38-434a-9380-3a3c2c27bbb3" and  isnotempty(NetworkMessageId) and isnotempty(SenderFromAddress) and isnotempty(RecipientEmailAddress) and isnotempty(Subject)

Catching emojis on email Subjects

Flags emoji in email subjects joined to URL click events, a filter-evasion trick that shows up in phishing waves.

// Sergio Albea 17-03-2026 ©️
EmailEvents
| where Timestamp > ago(7d)
| where isnotempty(Subject)
| extend Icons = extract_all(@"([\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}])", Subject)
| where isnotempty(Icons)
| join kind=inner UrlClickEvents on NetworkMessageId
| where UserLevelPolicy !has 'Allow' 
| where OrgLevelPolicy !has 'Allow'
| extend SenderIP = iff(isnotempty( SenderIPv4),SenderIPv4,SenderIPv6)
| extend geo_ip = tostring(geo_info_from_ip_address(SenderIP).country)
//| where Subject contains "⚠️" 
| summarize Distinct_Recipients=dcount(RecipientEmailAddress),make_set(RecipientEmailAddress),Emails=count() by Subject,SenderIP,geo_ip,ActionType, Workload, Url, ThreatTypes, LatestDeliveryLocation
| order by Emails, Distinct_Recipients

HUNT-09_New-SP-KeyVault-Access

Surfaces service principals that accessed a Key Vault for the first time in seven days, the probing step after a service principal is compromised.

// Hunt     : Hunt - Service Principals Accessing Key Vault for the First Time Within 7 Days
// Tactics  : CredentialAccess
// MITRE    : T1552.001
// Purpose  : Surfaces identities that accessed a Key Vault for the first time in the last 7 days. Compromised service principals and attacker-created SPs often probe Key Vaults for secrets/certs after initial access. Investigate any SP not expected to require KV access.
//==========================================================================================

let AllKVAccess = AzureActivity
    | where TimeGenerated > ago(90d)
    | where OperationNameValue startswith "MICROSOFT.KEYVAULT/VAULTS/"
    | where ActivityStatusValue =~ "Success"
    | extend VaultPath = tostring(strcat(SubscriptionId, "/", ResourceGroup, "/", tostring(split(ResourceId, "/")[8])))
    | summarize FirstAccessEver = min(TimeGenerated) by Caller, VaultPath;
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue startswith "MICROSOFT.KEYVAULT/VAULTS/"
| where ActivityStatusValue =~ "Success"
| extend VaultPath = tostring(strcat(SubscriptionId, "/", ResourceGroup, "/", tostring(split(ResourceId, "/")[8])))
// FIX: join on BOTH Caller and VaultPath. The previous version joined on Caller only, so a
// caller newly accessing vault A would also surface all of their long-standing vault B access.
| join kind=inner AllKVAccess on Caller, VaultPath
| where FirstAccessEver > ago(7d)
| project TimeGenerated, Caller, Operation = OperationNameValue, ResourceId, CallerIpAddress, SubscriptionId, ResourceGroup, VaultPath, FirstAccessEver
| order by FirstAccessEver desc

Multiple-Ingestion delays

Measures the gap between ingestion times per table across the whole workspace, which is how you spot a connector that has silently gone quiet.

let query_period = 7d;
union *
| where TimeGenerated > ago(query_period)
| distinct Type, bin(IngestionTime = ingestion_time(), 30m)
| sort by Type asc, IngestionTime asc
| extend Difference = iff(Type == prev(Type), IngestionTime - prev(IngestionTime), 0s)
| summarize Frequency = max(Difference) by Type
| extend Frequency = iff(Frequency == 0s, query_period, Frequency)
| lookup kind=leftouter (
    union *
    | where TimeGenerated > ago(query_period)
    | summarize percentiles(IngestionDelay = ingestion_time() - TimeGenerated, 50, 80, 95, 99) by Type
    ) on Type

DeviceNetworkEvents-Suspicious process connection to cloudfront domain

Matches the CloudFront domain pattern used by several loader families where msiexec or an installer process is the parent.

DeviceNetworkEvents
| where RemoteUrl matches regex @"d\d[a-z0-9]{12}\.cloudfront\.net" and (InitiatingProcessParentFileName has "msiexec.exe" or InitiatingProcessCommandLine has "/Install")
| project
    TimeGenerated,
    DeviceName,
    LocalIP,
    ActionType,
    InitiatingProcessParentCreationTime,
    InitiatingProcessParentFileName,
    InitiatingProcessCreationTime,
    InitiatingProcessAccountUpn,
    InitiatingProcessFolderPath,
    InitiatingProcessCommandLine,
    Protocol,
    RemoteUrl,
    RemoteIP,
    RemotePort

Microsoft Entra Conditional Access Policy Exclusion Modification

Extracts the exclusion changes from Conditional Access policy create and update events, so a quietly added exempt user or group does not go unnoticed.

// CAP Exludes
AuditLogs
| where OperationName in ("Update conditional access policy", "Create conditional access policy")
| where Result == "success"
| mv-expand TargetResource = TargetResources
| mv-expand ModifiedProperty = TargetResource.modifiedProperties
| where ModifiedProperty.displayName == "PolicyDetail"
| extend OldPolicy = todynamic(tostring(ModifiedProperty.oldValue))
| extend NewPolicy = todynamic(tostring(ModifiedProperty.newValue))
// Extract Excludes
| extend OldExclusions = OldPolicy.conditions.users.excludeGroups
| extend NewExclusions = NewPolicy.conditions.users.excludeGroups
| extend OldExcludedUsers = OldPolicy.conditions.users.excludeUsers
| extend NewExcludedUsers = NewPolicy.conditions.users.excludeUsers
// check for added Excludes
| where array_length(NewExclusions) > array_length(OldExclusions) 
     or array_length(NewExcludedUsers) > array_length(OldExcludedUsers)
| project TimeGenerated, InitiatedBy = Identity, PolicyName = TargetResource.displayName, OldExclusions, NewExclusions, OldExcludedUsers, NewExcludedUsers

adminsignsinfronnewnetwork

Builds a list of admin accounts, learns the ASNs they normally sign in from, and flags admin sign-ins arriving from a new network.

let admins=(IdentityInfo
| where AssignedRoles contains "admin" or GroupMembership has "Admin"
| summarize by tolower(AccountUPN));
//admins
let known_asns = (
SigninLogs
| where TimeGenerated between(ago(14d)..ago(1d))
| where ResultType == 0
| summarize by AutonomousSystemNumber);
//known_asns
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType == 0
| where tolower(UserPrincipalName) in (admins)
| where AutonomousSystemNumber !in (known_asns)
| project-reorder TimeGenerated, UserPrincipalName, UserAgent, IPAddress, AutonomousSystemNumber
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]), AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1])

Potential Entra Admin Synced back On-premise

Finds identities holding Entra admin roles that are synchronised from on-premises Active Directory, the hybrid dependency Microsoft has been advising teams to break.

IdentityInfo //Advanced Hunting table but can be ingested in sentinel
| where TimeGenerated > ago(30d) //Will capture user if any change occured in last 30 days to user
| where (isnotempty(AccountDomain))
| where (isnotempty(tostring(AssignedRoles)))
| where tostring(AssignedRoles) contains "admin"
| where IdentityEnvironment == @"Hybrid"
| sort by TimeGenerated desc
| summarize by AccountUpn, OnPremObjectId, tostring(AssignedRoles), AccountDomain

MDI Service Accounts without Service Principals and MSAs

Lists accounts Defender for Identity classifies as service accounts but that are neither service principals nor managed service accounts, the ones running on a password somebody set years ago.

IdentityInfo
| where Timestamp > ago(30d)
| where Type == @"ServiceAccount"
| extend ["Service On-Prem Sid"] = OnPremObjectId
| extend ["Service Principal Name"] = iff(IdentityEnvironment == "OnPremises",replace_string(strcat(AccountName, "@",AccountDomain),"$",""), AccountUpn )
| where parse_json(UserAccountControl)[0] != 'WorkstationTrustAccount' //Exclude gMSA/dMSA
| where not (ChangeSource == @"System-UserPersistence" and isempty(CloudSid)) //Exclude Service Principals
| summarize arg_max(Timestamp,*) by AccountObjectId,CloudSid,['Service On-Prem Sid']

Microsoft Dynamics 365 Privilege Escalation via Role or Team Modification

Correlates Dynamics 365 role and team modifications with off-network access inside a short window, aimed at privilege escalation in the business application layer.

let CorporateIPRange = "147.86.0.0/16";
let ThreatWindow = 10m;
let SuspiciousInquiries = 
    CloudAppEvents
    | where TimeGenerated > ago(1d)
    | where Application == "Microsoft Dynamics 365"
    | where not(ipv4_is_in_range(IPAddress, CorporateIPRange ))
    | where IsAdminOperation == 0
    | where ActionType in ("RetrieveUserPrivileges", "RetrieveUserPrivilegeByPrivilegeName", "RetrievePrivilegeMaxDepthFromTeamRoles")
    | project TargetTime = TimeGenerated, AccountId, IPAddress, CorrelationId = tostring(parse_json(RawEventData).CorrelationId);
CloudAppEvents
| where TimeGenerated > ago(1d)
| where Application == "Microsoft Dynamics 365"
| where not(ipv4_is_in_range(IPAddress, CorporateIPRange ))
| where ActionType has_any ("Update", "Create") and (ObjectName has "role" or ObjectName has "team" or parse_json(RawEventData).EntityName has_any ("role", "systemuserroles", "teamroles"))
| project ModificationTime = TimeGenerated, AccountId, ActionType, ObjectName, RawEventData
| join kind=inner SuspiciousInquiries on AccountId
| where ModificationTime between (TargetTime .. (TargetTime + ThreatWindow))
| project ModificationTime, AccountId, ActionType, ObjectName, IPAddress, TargetTime

ExecutableFilesPublicFolder

Watches for executable and script file types written into C:\Users\Public, a staging directory that almost nothing legitimate writes binaries to.

// The start of the folderpath in the Public directory.
let PublicFolder = @'C:\Users\Public';
// List with Executable File Extensions, can be adjusted or changed.
let ExecutableFileExtensions = dynamic(['bat', 'cmd', 'com', 'cpl', 'ex', 'exe', 'jse', 'msc','ps1', 'reg', 'vb', 'vbe', 'ws', 'wsf', 'hta', 'js']);
// Prevalence Threshold, if the file exceeds this threshold it is likely to be benign.
let FilePrevalenceThreshold = 250;
DeviceFileEvents
| where FolderPath contains PublicFolder
// Extract File Extension from the filename.
| extend FileExtension = tostring(extract(@'.*\.(.*)', 1, FileName))
// Only list Files that are executable
| where FileExtension in~ (ExecutableFileExtensions)
| invoke FileProfile('SHA256', 10000)
// Filter based on FilePrevalenceThreshold
| where GlobalPrevalence <= FilePrevalenceThreshold
| project Timestamp, DeviceName, FileExtension, FolderPath, GlobalPrevalence, Signer, Publisher, ReportId, DeviceId

MDE-LocalAIAgents

Lists local AI agents and their MCP servers from the AgentsInfo table, an inventory most estates do not have yet.

AgentsInfo
| where Platform == @"LocalAgents"
| extend AgentInfo = parse_json(RawAgentInfo).localAgentMetadata
| where isnotempty( AgentInfo)
| extend DeviceName = tostring(AgentInfo.deviceName)
| where isnotempty( column_ifexists("McpServers",""))
| mv-expand McpServers
| extend MCP_Name = tostring( McpServers.name)
| extend MCP_Type = tostring(McpServers.type)
| extend MCP_Endpoint = tostring(McpServers.endpoint)
| project MCP_Name, MCP_Type, MCP_Endpoint, Name, DeviceName
| summarize Devices = make_set(DeviceName), TotalDevices = dcount(DeviceName,4) by MCP_Name, MCP_Type, MCP_Endpoint

Potential Azure VM Admin Password reset using VMAccess extension

Catches the VMAccess extension resetting a local administrator password on an Azure VM, a supported feature that is also a clean path onto a machine you do not have credentials for.

//JsonVMAccessExtension.exe refers to the VMAccess Extension that can reset the Built-in administrator account/add new accounts. This applies to any Azure VM/AVD
//Ref: https://learn.microsoft.com/en-us/troubleshoot/azure/virtual-machines/windows/reset-rdp#reset-the-local-admin-account-password
DeviceEvents
| where TimeGenerated >ago(90d)
//| where AdditionalFields.PipeName == "\\Device\\NamedPipe\\wkssvc" or AdditionalFields.PipeName == "\\Device\\NamedPipe\\srvsvc"
| where AdditionalFields.RemoteClientsAccess == "AcceptRemote" 
| where InitiatingProcessCommandLine == "JsonVMAccessExtension.exe  \"enable\"" //Azure VM account Extension
| where InitiatingProcessAccountName == "system" //Extension runs as system

Identify Windows Devices Missing Defender for Endpoint WSL Plugin

Finds Windows devices running WSL without the Defender for Endpoint WSL plugin installed, a coverage gap that hides Linux activity on Windows endpoints.

let ActiveWindowsDevices = 
    DeviceInfo
    | where Timestamp > ago(30d)
    | where OSPlatform startswith "Windows"
    | summarize arg_max(Timestamp, *) by DeviceId
    | where isnotempty(DeviceName)
    | where OnboardingStatus == "Onboarded"
    | project DeviceId, DeviceName, OSPlatform, OSVersion;
// Identify all devices that have the WSL plugin installed
let DevicesWithWslPlugin = 
    DeviceTvmSoftwareInventory
    | where SoftwareName has "Defender for Endpoint plug-in for WSL" or SoftwareName has "DefenderPluginForWSL"
    | summarize by DeviceId;
// Combine the tables and display "yes" or "no"
ActiveWindowsDevices
| extend WslPluginInstalled = iif(DeviceId in (DevicesWithWslPlugin), "yes", "no")
| sort by DeviceName asc
// Optional Filter for Devices with missing WSL Plugin
//| where WslPluginInstalled == "no"
// Microsoft Defender for Endpoint plug-in for Windows Subsystem for Linux (WSL) https://learn.microsoft.com/en-us/defender-endpoint/mde-plugin-wsl

GraphActivityFromFirstPartyApps

Enriches Microsoft Graph activity from first-party apps with a sensitive-permission classification pulled in at query time.

// Get a list of first party apps with activity to Microsoft Graph and enriched classification

let SensitiveMsGraphPermissions = externaldata(AppId: guid, AppRoleId: guid, AppRoleDisplayName: string, Category: string, EAMTierLevelName: string, EAMTierLevelTagValue: string)["https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_MsGraphAppRoles.json"] with (format='multijson')
    | where isnotempty(EAMTierLevelName)
    // Optional: Filtering for Control Plane only

    //| where EAMTierLevelName == "ControlPlane"    

    | distinct AppRoleDisplayName;
_GetWatchlist('WorkloadIdentityInfo')
| where IsFirstPartyApp == "true"
| extend Identity = tostring(ServicePrincipalObjectId)
| join kind=inner (
    MicrosoftGraphActivityLogs
    | where TimeGenerated >ago(365d)
    | where Roles has_any (SensitiveMsGraphPermissions)
    | extend Roles = split(Roles, ' ')
    | extend Identity = ServicePrincipalId
) on Identity
| summarize AppRoleScope = make_set( Roles ), TotalResponseSizeBytes = sum(ResponseSizeBytes) by AppDisplayName, ServicePrincipalObjectId, AppId, AppOwnerTenantId, ServicePrincipalType, SignInAudience

VM Creation using Azure Activity

Lists VM and deployment write operations across 90 days of AzureActivity, the starting point for rogue or unbudgeted VM hunting.

//Could be useful as part of rogue VM creation hunting, could add queries to check the tags and ensure is compliant with Org Tagging
let operationList = dynamic(["microsoft.compute/virtualmachines/write", "microsoft.resources/deployments/write"]);
//
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue in~ (operationList)
| where ActivitySubstatusValue == "Created"
| extend ProvisioningState = parse_json(tostring(parse_json(tostring(parse_json(Properties).responseBody)).properties)).provisioningState,
VM_ID = parse_json(tostring(parse_json(tostring(parse_json(Properties).responseBody)).properties)).vmId,
ImageReference_Offer = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).offer,
ImageReference_Exact_version = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).exactVersion,
ImageReference_SKU = parse_json(tostring(parse_json(tostring(parse_json(tostring(parse_json(tostring(Properties_d.responseBody)).properties)).storageProfile)).imageReference)).sku
//Only search for Windows Server
| where ImageReference_Offer == "WindowsServer"

Teams-AllowedDomains

Tracks changes to the Teams external access allowed-domains list, a federation setting that quietly widens who can message your users.

CloudAppEvents
| where ActionType == "TeamsAdminAction"
| where RawEventData has "AllowedDomains"
| extend ModifiedProperties = parse_json(RawEventData).ModifiedProperties
| mv-apply ModifiedProperties on
(
    where ModifiedProperties.Name == "AllowedDomains"
    | project 
        PropertyName = tostring(ModifiedProperties.Name),
        NewValue = tostring(ModifiedProperties.NewValue),
        OldValue = tostring(ModifiedProperties.OldValue)
)
| project 
    TimeGenerated,
    ActionType,
    AccountDisplayName,
    PropertyName,
    OldValue,
    NewValue,
    RawEventData

NTLM Network Logon to Critical Device

Filters NTLM network logons down to devices the exposure graph marks as critical, which is where an NTLM relay actually hurts.

let NetworkLogons = DeviceLogonEvents
	| where Timestamp > ago(4h)
	| where LogonType == "Network"
	| where Protocol == "NTLM"
	| extend ShortDeviceName = toupper(split(DeviceName, ".")[0]);
NetworkLogons
| join kind=inner (	ExposureGraphNodes
	| where Categories has "device"
	| where isnotnull(NodeProperties.rawData.criticalityLevel)
	| extend ShortNodeName = toupper(split(NodeName, ".")[0])
	| extend TargetCriticalityScore = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
	| extend TargetCriticalityRule = tostring(NodeProperties.rawData.criticalityLevel.ruleName)
	| project ShortNodeName, TargetCriticalityScore, TargetCriticalityRule
) on $left.ShortDeviceName == $right.ShortNodeName
| where TargetCriticalityScore >= 3

XDR-4_Behavior_To_Event_DrillThrough

Joins anomalous Insider Risk behaviours to the concrete file events behind them for the same user and window, turning a behaviour score into evidence.

// XDR-4 - Behavior -> Event drill-through

// Run in: security.microsoft.com -> Hunting -> Advanced hunting

// Joins anomalous Insider Risk behaviors with concrete file events on same user, same window

DataSecurityBehaviors
| where Timestamp > ago(7d) and IsAnomalous == 1
| project BehaviorId, AccountUpn, ActionTypeBehavior=ActionType, Categories, StartTime, EndTime
| join kind=inner (
    DataSecurityEvents
    | where Timestamp > ago(7d)
    | project Timestamp, AccountUpn, ActionType, ObjectId, ApplicationNames, Workload, ActivityId
) on AccountUpn
| where Timestamp between (StartTime .. EndTime)
| project ActionTypeBehavior, Categories, AccountUpn, Timestamp, ActionType, ObjectId, ApplicationNames, Workload
| order by AccountUpn, Timestamp asc

PIMAlerts

Parses triggered PIM alerts out of AuditLogs and assigns severity, covering standing role assignments made outside PIM.

AuditLogs
| where OperationName =~ "Triggered PIM alert"
| where Category =~ "RoleManagement"
| extend AlertDescription = TargetResources[0].displayName
| extend Severity = case(
	AlertDescription =~ "Roles are being assigned outside of Privileged Identity Management" or AlertDescription =~ "Role assigned outside of PIM", "High",
	AlertDescription =~ "Potential stale accounts in a privileged role", "Medium",
	AlertDescription =~ "Administrators aren't using their privileged roles", "Low",
	AlertDescription =~ "Roles don't require multifactor authentication for activation", "Low",
	AlertDescription =~ "The organization doesn't have Microsoft Entra ID P2 or Microsoft Entra ID Governance", "Low",
	AlertDescription =~ "There are too many Global Administrators", "Low",
	AlertDescription =~ "Roles are being activated too frequently", "Low",
	"Unknown"
)
| project-reorder TimeGenerated, OperationName, Category, AlertDescription, Severity

estimate-potential-savings-by-moving-tables-to-data-lake

Estimates what each table would cost in the data lake tier versus analytics, with the per-GB prices exposed as variables you set to your own contract.

let AnalyticsPricePerGB = 2.48; // Example price per GB for Analytics
let DataLakePricePerGB = 0.05; // Example price per GB for Data Lake
Usage
| where TimeGenerated >= startofday(ago(30d))
| where IsBillable == true
| summarize IngestedGB = round(sum(Quantity) / 1000.0, 2) by DataType, Plan
| where Plan == "Analytics"
| extend CurrentCost = round(IngestedGB * AnalyticsPricePerGB, 2)
| extend IfDataLakeCost = round(IngestedGB * DataLakePricePerGB, 2)
| extend PotentialSavings = round(CurrentCost - IfDataLakeCost, 2)
| where PotentialSavings > 0
| project DataType, IngestedGB, CurrentCost, IfDataLakeCost, PotentialSavings
| order by PotentialSavings desc

Copilot-DefenderThreatProtection

Expands Security Copilot activity into the individual resources each session touched, which is the audit trail question that comes up as soon as Copilot is rolled out.

CopilotActivity
| extend Parsed = parse_json(LLMEventData)
| mv-expand Resource = Parsed.AccessedResources
| extend Action = tostring(Resource.Action)
| extend Id = tostring(Resource.id)
| extend Name = tostring(Resource.Name)
| extend Type = tostring(Resource.Type)
| where Name == "Block"
| extend DetectionName = extract(@"blocked by ['""]([^'""]+)['""] detection", 1, Action)
| project TimeGenerated, DetectionName, Action, Id, Name, Type, SrcIpAddr, Workload, AppHost, AppIdentity, LLMEventData
| sort by TimeGenerated

OutboundMSHTA

Four lines for mshta.exe talking to a public IP, which on most estates is either a red team or a real intrusion.

DeviceNetworkEvents
| where InitiatingProcessFileName =~ "mshta.exe"
| where RemoteIPType == "Public" or not(ipv4_is_private(RemoteIP))
| project-reorder TimeGenerated, InitiatingProcessCommandLine, RemoteUrl, RemoteIP, DeviceName, InitiatingProcessAccountUpn

Audit B2B Guest Devices Trust Type

Reports the device trust type behind inbound B2B guest sign-ins, showing how much of your guest access arrives from unmanaged devices.

//Shouthout johannesblog.com for the idea
SigninLogs
//| where AppDisplayName =~ "Microsoft Teams"
| extend TrustType = tostring(DeviceDetail.trustType)
| where CrossTenantAccessType == @"b2bCollaboration"
| where AADTenantId != HomeTenantId //exclude B2b outbound
| where UserType == "Guest"
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress, TrustType,
          DeviceId = tostring(DeviceDetail.deviceId),
          DeviceName = tostring(DeviceDetail.displayName),
          OperatingSystem = tostring(DeviceDetail.operatingSystem),
          Browser = tostring(DeviceDetail.browser),
          ConditionalAccessStatus, ResultType, ResultDescription
| order by TimeGenerated desc

APT28 Kill Chain - LNK to UNC to SMB for Credential Exfiltration

Chains LNK execution to UNC path access to outbound SMB in one query, modelling the NTLM credential leak path used against CVE-2026-32202.

// APT28 Kill Chain for CVE-2026-32202
// https://thehackernews.com/2026/04/microsoft-confirms-active-exploitation.html
// LNK → CPL/UNC → SMB → NTLM Exfil
let timeframe = 7d;
let InitialAccess_LNK = DeviceProcessEvents
| where TimeGenerated > ago(timeframe)
| where InitiatingProcessCommandLine has ".lnk"
| project DeviceId, LNK_TriggerTime = TimeGenerated, LNK_CommandLine = InitiatingProcessCommandLine;
let Execution_UNC = DeviceProcessEvents
| where TimeGenerated > ago(timeframe)
| where ProcessCommandLine matches regex @"\\\\[a-zA-Z0-9\-\.]{4,}\\"
| where ProcessCommandLine has_any (".cpl", ".dll", ".exe")
| project DeviceId, UNC_LoadTime = TimeGenerated, UNC_CommandLine = ProcessCommandLine;
let Exfiltration_SMB = DeviceNetworkEvents
| where TimeGenerated > ago(timeframe)
| where RemotePort == 445
| where not(ipv4_is_private(RemoteIP))
| project DeviceId, SMB_ConnectTime = TimeGenerated, RemoteIP;
InitialAccess_LNK
| join kind=inner Execution_UNC on DeviceId
| where UNC_LoadTime between (LNK_TriggerTime .. (LNK_TriggerTime + 2m))
| join kind=inner Exfiltration_SMB on DeviceId
| where SMB_ConnectTime between (UNC_LoadTime .. (UNC_LoadTime + 2m))
| project
    DeviceId,
    LNK_Time = LNK_TriggerTime,
    UNC_Time = UNC_LoadTime,
    SMB_Time = SMB_ConnectTime,
    LNK_CommandLine,
    UNC_CommandLine,
    RemoteIP
| extend
    AlertTitle = "APT28 Kill Chain: LNK→UNC→SMB (CVE-2026-32202)",
    Severity = "Critical",
    MITRE = "T1566.001 → T1187 → T1557.001"

MDI-AD-GroupPolicy-PasswordPolicy

Surfaces Group Policy password policy changes from Defender for Identity directory events, including which policy and which domain was touched.

IdentityDirectoryEvents
| where ActionType == @"Group Policy settings were changed"
| extend Info = parse_json(AdditionalFields)
| extend MachinePolicies = tostring(Info.MachinePolicies),
         GroupPolicyName = tostring(Info.GroupPolicyName),
         GroupPolicyId   = tostring(Info.GroupPolicyId),
         DomainName      = tostring(Info.DomainName),
         Category        = tostring(Info.Category),
         AttackTechniques = tostring(Info.AttackTechniques)
| project TimeGenerated, DomainName, GroupPolicyName, GroupPolicyId, MachinePolicies, Category, AttackTechniques
| mv-expand PolicyEntry = split(MachinePolicies, ",") to typeof(string)
| extend FullPath    = tostring(split(PolicyEntry, "=")[0]),
         PolicyValue = tostring(split(PolicyEntry, "=")[1])
| extend PathParts   = split(FullPath, @"\"),
         PolicyName  = tostring(split(FullPath, @"\")[-1])
| extend PolicyPath  = strcat_array(array_slice(PathParts, 0, array_length(PathParts) - 2), @"\")
| where PolicyPath == @"Account Policies\PasswordPolicy"
| summarize Settings = make_bag(pack(PolicyName, PolicyValue)) 
    by TimeGenerated, GroupPolicyId, GroupPolicyName, DomainName, AttackTechniques, Category, PolicyPath

New TenantAllowBlockList (TABL) entry

Extracts new Tenant Allow Block List entries with their notes, expiry and defanged URL, so allow-list additions get reviewed rather than forgotten.

CloudAppEvents 
| where ActionType == "New-TenantAllowBlockListItems"
| extend Notes = extract(@'"Notes","Value":"(.*?)"', 1, tostring(ActivityObjects))
| extend Url = replace_string(extract(@'Name":"Entries","Value":"(.*?)"', 1, tostring(ActivityObjects)), ".", "[.]")
| extend Expiration = replace_string(extract(@'"Name":"ExpirationDate","Value":"(.*?)"', 1, tostring(ActivityObjects)), ".", "[.]")
//| project-reorder Notes, Url, Expiration
//| project TimeGenerated, ObjectName, Notes, Url, Expiration, IPAddress

AutomationAccount-RunbookStatus

Turns Automation Account job logs into per-runbook start, end and status rows, so a runbook that suddenly starts failing or running at odd hours is visible.

AzureDiagnostics
| where Category == 'JobLogs'
| extend RunbookName = RunbookName_s
| project TimeGenerated,RunbookName,ResultType,CorrelationId,JobId_g
| summarize StartTime = minif(TimeGenerated,ResultType == 'Started'),EndTime = minif(TimeGenerated,ResultType in ('Completed','Failed','Failed')),
Status = tostring(parse_json(make_list_if(ResultType,ResultType in ('Completed','Failed','Stopped')))[0]) by JobId_g,RunbookName
| extend DurationSec = datetime_diff('second', EndTime,StartTime)
| join kind=leftouter (AzureDiagnostics
| where Category == "JobStreams"
| where StreamType_s == "Error"
| summarize TotalErrors = dcount(StreamType_s) by JobId_g, StreamType_s)
on $left. JobId_g == $right. JobId_g
| extend HasErrors = iff(StreamType_s == 'Error',true,false)
| project StartTime, EndTime, DurationSec,RunbookName,Status,HasErrors,TotalErrors,JobId_g

Defender -RedSun Detection - NamedPipe Detection

Hunts the named pipe pattern behind the RedSun technique, which abuses a Defender detection flow to escalate to SYSTEM.

//https://github.com/Nightmare-Eclipse/RedSun
//Uses a Defender detection to escalate to system. Note I couldn't get this exploit in MDE. It will fail to request a batch oplock on the update file.
// The binary/code supplied in repo uses an EICAR signature but a threatactor could subs this for anything so long as defender is triggered.
//Code will back out if real time monitoring is not enabled.
DeviceEvents
| where ActionType contains "NamedPipeEvent"
| where parse_json(AdditionalFields)["RemoteClientsAccess"] == 'AcceptRemote'
//| where parse_json(AdditionalFields)["PipeName"] == @'\Device\NamedPipe\REDSUN' //Low Fidelity Named Pipe (Line 518 in Code) Name can be changed but included here
| where parse_json(AdditionalFields)["DesiredAccess"] == '1704351' //Technically Access Mask can be modified
| where InitiatingProcessAccountName != @"system" and InitiatingProcessAccountName != "network service" and AccountSid != @"S-1-5-18"//PoC is from unprivileged User
| where parse_json(AdditionalFields)["FileOperation"] == 'File created'
| where InitiatingProcessVersionInfoCompanyName != @"Microsoft Corporation" //File created that created the namedpipe is the RedSun.exe binary (filename can be renamed)

per-solution-breakdown

Breaks 30 days of workspace ingestion down per solution and separates auxiliary tables, the query to run before any Sentinel cost conversation.

let _AuxiliaryTables = dynamic([
    "OfficeActivity", "AzureActivity", "Heartbeat",
    "SentinelHealth", "SecurityAlert", "SecurityIncident", "Operation"
]);
Usage
| where TimeGenerated > ago(30d)
| where IsBillable == true
| where DataType !in (_AuxiliaryTables)
| summarize 
    TotalGB = round(sum(Quantity) / 1024.0, 2),
    TableCount = dcount(DataType),
    DailyAvgGB = round(avg(Quantity) / 1024.0, 4)
    by Solution
| extend EstMonthlyCostUSD = round(DailyAvgGB * 30 * 2.76, 2)
| order by TotalGB desc

Overview of all MFA methods in use

Charts which MFA methods your tenant actually uses, which is how you find out how much of the estate is still on SMS before you start enforcing.

// Bar chart showing all MFA types used
SigninLogs
| where AuthenticationRequirement == "multiFactorAuthentication"
| where ResultType == 0
| project AuthenticationDetails
| extend ['MFA Method'] = tostring(parse_json(AuthenticationDetails)[1].authenticationMethod)
| summarize Count=count() by ['MFA Method']
| where ['MFA Method'] != "Previously satisfied" and isnotempty(['MFA Method'])
| sort by Count desc
| render barchart with (title="Types of MFA Methods used")

Correlation_Git_And_VSCode_Task_Abuse

Correlates history-rewriting git commands with suspicious node.exe activity on the same device, aimed at developer workstation supply chain tampering.

let GitAbuse = DeviceProcessEvents
| where ProcessCommandLine has_any ("git commit --amend", "--no-verify", "git push -f", "git push --force", "git config --local")
| project DeviceId, GitTime=Timestamp, DeviceName, AccountName, GitCmd=ProcessCommandLine;
let SuspiciousNode = DeviceProcessEvents
| where FileName in~ ("node.exe", "node")
| where ProcessCommandLine has_any (".woff2", ".woff", ".ttf", ".otf", ".eot")
| where InitiatingProcessFileName in~ ("Code.exe", "code", "cmd.exe", "powershell.exe", "bash", "sh", "zsh")
   or InitiatingProcessCommandLine has_any ("Code.exe", "code", ".vscode", "tasks.json", "folderOpen")
| project DeviceId, NodeTime=Timestamp, NodeCmd=ProcessCommandLine, InitiatingProcessFileName, InitiatingProcessCommandLine;
GitAbuse
| join kind=inner SuspiciousNode on DeviceId
| where NodeTime between (GitTime - 7d .. GitTime + 7d)
| project DeviceName, AccountName, GitTime, GitCmd, NodeTime, NodeCmd, InitiatingProcessFileName, InitiatingProcessCommandLine
| order by NodeTime desc

NodeC2Polinder

Three lines that flag node.exe reaching out to blockchain infrastructure domains, a command and control channel that hides inside ordinary developer traffic.

DeviceNetworkEvents
| where InitiatingProcessFileName in~ ("node.exe", "node")
| where RemoteUrl has_any ("trongrid.io", "aptoslabs.com")

Device TVM Secure Configuration Assessment Summary

Summarises the TVM secure configuration assessment per device against the configuration knowledge base, giving a readable posture gap list rather than raw IDs.

DeviceTvmSecureConfigurationAssessment
| join kind=leftouter (
    DeviceTvmSecureConfigurationAssessmentKB
    | project ConfigurationId, ConfigurationName
) on ConfigurationId
| summarize 
    Total = count(),
    Compliant = countif(IsCompliant == 1),
    NonCompliant = countif(IsCompliant == 0),
    NotApplicable = countif(IsApplicable == 0)
  by ConfigurationId, ConfigurationName
| order by NonCompliant desc

SecurityEvent-Unusual user account authentication

Compares each account's authentication over a 14-day period against a watchlist of domain controllers to surface first-time or unusual authentication paths.

let query_frequency = 1h;
let query_period = 14d;
let _DomainControllers = toscalar(
    _GetWatchlist("Service-PrivateCorporateServices")
    | where Service == "DomainController"
    | summarize make_list(HostName)
);
SecurityEvent
| where TimeGenerated > ago (query_period)
| where EventID == 4624 and AccountType == "User" and Computer has_any (_DomainControllers)
| summarize arg_min(TimeGenerated, *) by
    LogonTypeName,
    AuthenticationPackageName,
    LmPackageName,
    EmptyIpAddress = IpAddress in ("-", ""),
    EmptyWorkstationName = WorkstationName in ("-", ""),
    ElevatedToken,
    IsAnonymousLogon = TargetAccount ==  @"NT AUTHORITY\ANONYMOUS LOGON"
| where TimeGenerated > ago(query_frequency)
| project
    TimeGenerated,
    Computer,
    Account,
    AccountType,
    Activity,
    LogonTypeName,
    AuthenticationPackageName,
    LmPackageName,
    KeyLength,
    EmptyIpAddress,
    EmptyWorkstationName,
    ElevatedToken,
    IsAnonymousLogon

HUNT-02_M365-Exchange-InboxTransportRule-Audit-30d

Audits 30 days of Exchange inbox and transport rule changes with forwarding, redirect and hiding indicators pre-calculated, the standard opening move on a suspected BEC.

// Hunt    : M365 - Inbox Rule and Transport Rule Audit (30d)

// Purpose : Full audit of all Exchange inbox and transport rules created or modified

//           in the past 30 days, pre-calculated for forwarding, redirect and hiding

//           indicators to support BEC and exfiltration investigations.

// Tables  : OfficeActivity

// Period  : P30D

//==========================================================================================


let LookbackDays = 30d;

OfficeActivity
| where TimeGenerated > ago(LookbackDays)
| where RecordType == "ExchangeAdmin"
| where Operation in (
    "New-InboxRule", "Set-InboxRule", "Remove-InboxRule",
    "New-TransportRule", "Set-TransportRule",
    "Enable-TransportRule", "Disable-TransportRule",
    "Remove-TransportRule", "New-JournalRule")
| extend Params = tostring(Parameters)
| extend
    HasForward      = Params has_any ("ForwardTo", "ForwardAsAttachmentTo", "RedirectTo", "RedirectMessageTo"),
    HasHide         = Params has_any ("DeleteMessage", "MoveToFolder", "MarkAsRead"),
    HasBypass       = Params has_any ("ExceptIfSenderDomainIs", "SetHeaderName"),
    ExternalAddress = extract(@"(ForwardTo|RedirectTo|RedirectMessageTo).*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})", 2, Params),
    FolderTarget    = extract(@"(MoveToFolder).*?:([^\s,]+)", 2, Params)
| project
    TimeGenerated,
    UserId,
    ClientIP,
    Operation,
    HasForward,
    HasHide,
    HasBypass,
    ExternalAddress,
    FolderTarget,
    Params
| sort by TimeGenerated desc

HUNT-01_GPU-VM-Deployment-History

Inventories 90 days of successful GPU and high-compute VM deployments from AzureActivity, the baseline you need before cryptojacking detections mean anything.

// Hunt     : Hunt - GPU and High-Compute VM Deployment History (90d)
// Tactics  : Impact
// MITRE    : T1496
// Purpose  : Inventory all successful GPU/high-compute VM deployments over the past 90 days. Use to baseline legitimate deployers before tuning Rule-01, and to investigate potential cryptojacking campaigns.
//==========================================================================================

let GPUSKUs = dynamic(["Standard_NC", "Standard_NV", "Standard_ND", "Standard_NP", "Standard_HB", "Standard_HC"]);
AzureActivity
| where TimeGenerated > ago(90d)
| where OperationNameValue has_any ("VIRTUALMACHINES/WRITE", "VIRTUALMACHINESCALESETS/WRITE")
| where ActivityStatusValue =~ "Success"
| where Properties has_any (GPUSKUs)
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, ResourceGroup, ResourceId, Properties
| extend SKUHint = extract(@'Standard_(?:NC|NV|ND|NP|HB|HC)[^"]*', 0, Properties)
| order by TimeGenerated desc

MDO-AutoForwardingMode

Tracks changes to the outbound spam policy AutoForwardingMode setting, the tenant-level switch that decides whether external auto-forwarding is possible at all.

CloudAppEvents
| where ObjectName == "Set-HostedOutboundSpamFilterPolicy"
| mv-expand parse_json(ActivityObjects)
| where ActivityObjects.Name == 'AutoForwardingMode'
| extend Setting = tostring(ActivityObjects.Name)
| extend Configuration = tostring(ActivityObjects.Value)
| extend Description = case(
        Configuration == "Automatic", "System-controlled: Default value. Same as Off — forwarding is disabled.",
        Configuration == "On", "Forwarding is enabled: Automatic external forwarding is allowed and not restricted.",
        Configuration == "Off", "Forwarding is disabled: Automatic external forwarding is blocked and results in an NDR to the sender.",
        "Unknown"
    )
| project TimeGenerated, Setting,Configuration,Description

PotentialBeaconingActivity

Scores outbound connections for beaconing using device count, connection count and global prevalence thresholds you can tune to your own noise floor.

let DeviceThreshold = 5;
let ConnectionThreshold = 25;
let GlobalPrevalanceThreshold = 250;
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where not(ipv4_is_private(RemoteIP))
| where ActionType == 'ConnectionSuccessAggregatedReport'
| extend Connections = toint(parse_json(AdditionalFields).uniqueEventsAggregated)
| summarize Total = count(), Devices = dcount(DeviceId), Domains = make_set(RemoteUrl), AvgConnections = avg(Connections) by RemoteIP, bin(TimeGenerated, 1d)
| where AvgConnections >= ConnectionThreshold and Devices <= DeviceThreshold
| join kind=inner (DeviceNetworkEvents
    | where ActionType == 'ConnectionSuccess'
    | distinct RemoteIP, InitiatingProcessSHA256) on RemoteIP
    | invoke FileProfile(InitiatingProcessSHA256)
    | where GlobalPrevalence <= GlobalPrevalanceThreshold

SignInsByOS

Parses the sign-in user agent into an operating system breakdown, useful for finding stale or unmanaged client platforms before you tighten Conditional Access.

EntraIdSignInEvents
| where isnotempty(UserAgent)
// Filter for successful sign ins only
| where ErrorCode == 0
| extend ParsedAgent = parse_json(parse_user_agent(UserAgent, "os"))
| extend OperatingSystem = strcat(tostring(ParsedAgent.OperatingSystem.Family), " ", tostring(ParsedAgent.OperatingSystem.MajorVersion))
| summarize Total = count() by OperatingSystem
| sort by Total

Attempt to Disable Syslog Service

Detects systemctl and service commands aimed at syslog and rsyslog on Linux endpoints, an anti-forensics step that usually precedes something worse.

// Attempt to Disable Syslog Service
DeviceProcessEvents
| where ProcessCommandLine has_any ("syslog", "rsyslog", "syslog-ng", "syslog.service", "rsyslog.service", "syslog-ng.service")
| where FileName in~ ("systemctl", "service", "chkconfig", "update-rc.d")
| where (
    (FileName =~ "systemctl" and ProcessCommandLine has_any ("disable", "stop", "kill", "mask")) or
    (FileName =~ "service" and ProcessCommandLine has "stop") or
    (FileName =~ "chkconfig" and ProcessCommandLine has "off") or
    (FileName =~ "update-rc.d" and ProcessCommandLine has_any ("remove", "disable"))
)
// Exclude known log rotation or HUP signals
| where InitiatingProcessFileName !~ "rsyslog-rotate"
| where ProcessCommandLine !has "HUP"

MDE-AsrVulnerableSignedDriverBlocked

Pulls the LOLDrivers catalogue at query time and checks it against ASR vulnerable-driver block events, showing which known-bad drivers actually got blocked in your estate.

let LOLDrivers = externaldata (Category:string, KnownVulnerableSamples:dynamic, Verified:string ) [h@"https://www.loldrivers.io/api/drivers.json"]
    with (format=multijson, ingestionMapping='[{"Column":"Category","Properties":{"Path":"$.Category"}},{"Column":"KnownVulnerableSamples","Properties":{"Path":"$.KnownVulnerableSamples"}},{"Column":"Verified","Properties":{"Path":"$.Verified"}}]')
| mv-expand KnownVulnerableSamples
| extend SHA1 = tostring(KnownVulnerableSamples.SHA1), SHA256 = tostring(KnownVulnerableSamples.SHA256)
;
DeviceEvents
| where ActionType == @"AsrVulnerableSignedDriverBlocked"
| project Timestamp, DeviceName, FileName, SHA256,SHA1, FolderPath
| join kind=inner   (LOLDrivers | where isnotempty(SHA256)) on SHA256
| union (
  DeviceEvents
| where ActionType == @"AsrVulnerableSignedDriverBlocked"
  | join kind=inner (LOLDrivers | where isnotempty(SHA1)) on SHA1
)

ClickFix LoLBin Abuse

Targets the ClickFix living-off-the-land chain, wscript and cscript running SyncAppvPublishingServer.vbs and its siblings, from process telemetry.

//THX to Maurice Fielenbach https://www.linkedin.com/posts/mauricefielenbach_threathunting-dfir-cybersecurity-activity-7429953431588659200-c0zE/
DeviceProcessEvents
| where Timestamp >= ago(7d)
| where (
    (FileName in~ ("wscript.exe", "cscript.exe") and ProcessCommandLine has "SyncAppvPublishingServer.vbs")
    or (FileName =~ "wmic.exe" and ProcessCommandLine has_any ("process", "call", "create"))
    or (FileName =~ "ssh.exe" and ProcessCommandLine has "ProxyCommand")
)
| where ProcessCommandLine has_any (
    "gal", "i*x",         
    "gcm", "*stM*",       
    "jsdelivr.net",       
    "github",             
    "powershell"          
)

DeviceIPHistory

Reconstructs the IP history of a single device from network telemetry, the lookup you want when an alert gives you an address and a timestamp but no hostname.

let QueryDevice = "devicename";
DeviceNetworkEvents
| where DeviceName startswith QueryDevice
| where LocalIP !in ("", "::", "::1", "127.0.0.1", "0.0.0.0")
| where LocalIP !startswith "::ffff:"
| where LocalIP != RemoteIP
| where ActionType !in ("ListeningConnectionCreated", "ConnectionFailed")  // only successful peer connections are evidence

| where ActionType !endswith "Inspected"  // bad data -- Zeek flips RemoteIP and LocalIP sometimes

| extend AccurateDirection = tostring(parse_json(AdditionalFields).direction)
| extend EstimatedDirection = iff(LocalPort > RemotePort, "Out", "In")  // rarely wrong; needed when sensor isn't logging directionality

| extend Direction = iff(AccurateDirection != "", AccurateDirection, EstimatedDirection)
| summarize
    Start=min(Timestamp),
    End=max(Timestamp),
    ConnectionCount=count(),
    InboundConnections=countif(Direction=="In"),
    OutboundConnections=countif(Direction!="In"),
    Peers=make_set(RemoteIP)
    by LocalIP
| where OutboundConnections > 0  // wrong directionality captured by EstimatedDirection

| sort by Start desc

Windows - Recent Devices Missing Full Scan

Lists recently onboarded devices that never ran the initial full antivirus scan Microsoft recommends after MDE onboarding.

// All credit goes to Felix Brand - Taken from his post (https://www.linkedin.com/posts/felix-brand_defenderxdr-kql-microsoft-activity-7228749401849040896-0sNk)
// Microsoft now recommends (https://lnkd.in/ewBryvhF) to have one initial full scan when you onboard your system into MDE. But MDE does not trigger a full scan after onboarding, this KQL query combined with a custom detection rule can trigger a scan. 
// Once a scan is completed on an endpoint, it will not appear in this query again.

let AvModeDescription = dynamic({"0":"Normal", "1":"Passive", "4":"EDR Block"});
let TimeRange = ago(1d);
DeviceTvmInfoGathering
| where Timestamp > TimeRange
| extend AdditionalFields = parse_json(AdditionalFields)
| extend AvMode =  tostring(AvModeDescription[tostring(AdditionalFields.["AvMode"])])
| extend FullScanStatus = coalesce(extractjson("$.Full.ScanStatus", tostring(AdditionalFields.AvScanResults)),"Not available")
| where isnotempty( AvMode ) and AvMode has "Normal"
| where FullScanStatus !has "Completed"
| join DeviceEvents on DeviceName
| summarize arg_max(Timestamp, *) by DeviceName, DeviceId
| project DeviceName, DeviceId, FullScanStatus, ReportId, Timestamp
| take 50

Malicious Browser Extension Downloads using DeviceFileEvents

Matches browser extension files written to disk against a public list of malicious extension IDs, covering an install path that rarely gets monitored.

//Credit https://github.com/toborrm9/malicious_extension_sentry
let MaliciousExtensions = externaldata (ExtensionID: string) [@'https://raw.githubusercontent.com/toborrm9/malicious_extension_sentry/refs/heads/main/Malicious-Extensions.csv'] with (format=txt, ignoreFirstRecord = true)
| extend ExtensionID = split(ExtensionID,",")
| mv-expand ExtensionID
| extend ExtensionID = tostring(ExtensionID);
DeviceFileEvents
| where TimeGenerated > ago(90d)
| where ActionType == "FileCreated"
| where FileName endswith ".crx"
//| where InitiatingProcessFileName == "chrome.exe" //if you need to filter down to chrome vs edge
| where FolderPath contains "Webstore Downloads"
| extend ExtensionID = trim_end(@"_\d{2,6}.crx", FileName)
| extend ExtensionURL = strcat("https://chrome.google.com/webstore/detail/",ExtensionID)
| extend EdgeExtensionURL = strcat("https://microsoftedge.microsoft.com/addons/detail/",ExtensionID)
| summarize count() by ExtensionID,ExtensionURL, EdgeExtensionURL
| join kind=leftouter MaliciousExtensions on ExtensionID //if name is present in the risky list present it

Image File Execution Options (IFEO) or SilentProcessExit Registry Modification

Watches Image File Execution Options and SilentProcessExit registry keys, a debugger-hijack persistence technique that also doubles as a way to kill security tooling.

let ExludedInitiatingProcessFileNames = ("ExludedProcessFilesName.exe");
DeviceRegistryEvents
| where InitiatingProcessFileName !in (ExludedInitiatingProcessFileNames)
| where RegistryKey has_any (
    @"Microsoft\Windows NT\CurrentVersion\Image File Execution Options", 
    @"Microsoft\Windows NT\CurrentVersion\SilentProcessExit"
)
| where RegistryValueName has_any ("Debugger", "MonitorProcess", "ReportingMode", "GlobalFlag")
| extend TargetProcess = tostring(split(RegistryKey, @"\")[8])

Azure RBAC Elevation via User Access Admin toggle

Catches the Azure RBAC elevate-access toggle, the one-click path from Global Administrator to User Access Administrator over every subscription in the tenant.

AuditLogs
| where Category == "AzureRBACRoleManagementElevateAccess"
| where ActivityDisplayName == "User has elevated their access to User Access Administrator for their Azure Resources"
| extend Actor = tostring(InitiatedBy.user.displayName)
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorId = tostring(InitiatedBy.user.id)
| extend Operation = tostring(OperationName)
| extend IPAddress = tostring(InitiatedBy.user.ipAddress)
| extend AppId = tostring(InitiatedBy.app.appId)
| project TimeGenerated, ActivityDisplayName, Category, Operation, Actor, ActorUPN, ActorId, IPAddress, AppId, Result, ResultReason, CorrelationId
| order by TimeGenerated desc

HuntAccountsWithLeakedCredentials

Joins IdentityInfo against the exposure graph to list accounts flagged with leaked credentials, sorted so critical identities surface first.

IdentityInfo
| summarize arg_max(TimeGenerated, AccountUpn, AccountDisplayName, AccountDomain, CriticalityLevel, DistinguishedName) by AccountObjectId
| join kind=inner (
    ExposureGraphNodes
    // Get accounts with Leaked Credentials
    | where NodeProperties.rawData.hasAdLeakedCredentials == "true" or NodeProperties.rawData.hasLeakedCredentials == "true"
    // Get the AAD Object ID
    | mv-expand EntityIds
    | where EntityIds.type == "AadObjectId"
    | extend AccountObjectId = extract('objectid=(.*)', 1, tostring(EntityIds.id))
    | extend HasAdLeakedCredentials = tostring(NodeProperties.rawData.hasAdLeakedCredentials),
        HasLeakedCredentials = tostring(NodeProperties.rawData.hasLeakedCredentials)
    | distinct NodeLabel, AccountObjectId, HasAdLeakedCredentials, HasLeakedCredentials
) on AccountObjectId

authenticator_device_enrollment_country_risk_baseline

Baselines the countries each user normally signs in from over 90 days, then surfaces authenticator enrollments that happen outside that baseline.

let Lookback=90d;
let CountryBaseline=EntraIdSignInEvents
| where Timestamp > ago(Lookback)
| where isnotempty(AccountUpn)
| where isnotempty(IPAddress)
| extend Geo=geo_info_from_ip_address(IPAddress)
| extend CountryName=tostring(Geo["country"])
| where isnotempty(CountryName)
| summarize BaselineCountries=make_set(CountryName,256),BaselineCountryCount=dcount(CountryName) by AccountUpn;
AuditLogs
| where TimeGenerated > ago(Lookback)
| extend Details=tostring(AdditionalDetails)
| where Details has_any ("Microsoft Authenticator","PhoneAppNotification","PhoneAppOTP","Authenticator")
| extend Timestamp=TimeGenerated,
         AccountUpn=tostring(TargetResources[0].userPrincipalName),
         InitiatorUpn=coalesce(tostring(InitiatedBy.user.userPrincipalName),tostring(InitiatedBy.app.displayName)),
         InitiatorIP=coalesce(tostring(InitiatedBy.user.ipAddress),tostring(InitiatedBy.app.ipAddress))
| extend Geo=iff(isempty(InitiatorIP),dynamic(null),geo_info_from_ip_address(InitiatorIP))
| extend Country=tostring(Geo["country"]),
         Latitude=todouble(Geo["latitude"]),
         Longitude=todouble(Geo["longitude"])
| join kind=leftouter (CountryBaseline) on $left.AccountUpn==$right.AccountUpn
| extend BaselineCountries=coalesce(BaselineCountries,dynamic([]))
| extend HasBaseline=array_length(BaselineCountries)>0
| extend HasGeo=isnotempty(Country)
| extend IsNewCountry=iff(HasGeo==false,bool(null),not(set_has_element(BaselineCountries,Country)))
| extend RiskLevelAggregated=case(Result !~ "success",100,HasGeo==false,50,HasBaseline==false,50,IsNewCountry==true,50,0)
| extend RiskLevel=case(RiskLevelAggregated==100,"High",RiskLevelAggregated==50,"Medium",RiskLevelAggregated==10,"Low","None/Unknown")
| extend RiskReason=case(Result !~ "success","Audit operation not success",HasGeo==false,"No geo data for initiator IP",HasBaseline==false,"No 90d country baseline for user",IsNewCountry==true,"New country vs 90d sign-in baseline","Within baseline")
| project Timestamp,AccountUpn,InitiatorUpn,IPAddress=InitiatorIP,Country,Latitude,Longitude,BaselineCountryCount,BaselineCountries,IsNewCountry,RiskLevelAggregated,RiskLevel,RiskReason,OperationName,Result,CorrelationId,AdditionalDetails
| sort by Timestamp desc

Monitor DLLs by Signer

Summarises loaded DLLs by certificate signer so unsigned or oddly signed modules stand out against the normal signer set in your estate.

DeviceImageLoadEvents
| where TimeGenerated > ago(90d) //change to 30d if using advanced hunting with no sentinel
| where FileName contains ".dll"
| join kind=leftouter DeviceFileCertificateInfo on $left.SHA1 == $right.SHA1
| where FileName contains ".dll"
| summarize make_set(FileName) by Signer

TI Feed - C2URLFeed

Pulls an external C2 domain feed with externaldata and matches it against DeviceNetworkEvents, a cheap way to add threat intel without a connector.

// Collect Remote data
let C2IntelFeeds = externaldata(Domain: string, ioc:string, path:string, IP:string)[@"https://raw.githubusercontent.com/drb-ra/C2IntelFeeds/master/feeds/domainC2swithURLwithIP.csv"] with (format="csv", ignoreFirstRecord=True);
// Generate list that can be used to filter DeviceNetworkEvents
let DomainList = C2IntelFeeds
| distinct Domain;
DeviceNetworkEvents
// Filter only on C2 Domains
| extend ToLowerUrl = tolower(RemoteUrl)
| where RemoteUrl has_any (DomainList)
// Join the C2IntelFeed information for enrichment
| join kind=leftouter C2IntelFeeds on $left.RemoteIP == $right.IP
| extend GeoIPInfo = geo_info_from_ip_address(RemoteIP)
| extend country = tostring(parse_json(GeoIPInfo).country), state = tostring(parse_json(GeoIPInfo).state), city = tostring(parse_json(GeoIPInfo).city), latitude = tostring(parse_json(GeoIPInfo).latitude), longitude = tostring(parse_json(GeoIPInfo).longitude)
| project-reorder TimeGenerated, DeviceName, RemoteIP, RemotePort, RemoteUrl

DetectMsiexecExecutingDllNetworkConnections

Joins network connections back to their initiating process to catch msiexec spawning a child that then calls out, the classic MSI-delivered loader pattern.

DeviceNetworkEvents
| where TimeGenerated > ago(1h)
| where InitiatingProcessParentFileName =~ "msiexec.exe"
| join kind=inner (
    DeviceProcessEvents
    | where Timestamp > ago(3d)
    | where InitiatingProcessFileName =~ "msiexec.exe"
) on DeviceId, 
    $left.InitiatingProcessParentId == $right.InitiatingProcessId,
    $left.InitiatingProcessParentCreationTime == $right.InitiatingProcessCreationTime
| where InitiatingProcessCommandLine1 has_any ("/y", "-y", "/z", "-z")

SuspiciousRUNMRUEntry

Reads the RunMRU registry key for encoded PowerShell, curl and mshta invocations, which is where ClickFix-style paste-and-run lures leave their fingerprint.

let Parameters = dynamic(['http', 'https', 'Encoded', 'EncodedCommand', '-e', '-eC', '-enc', "-w", '-i', '/i','/e', '/eC', '/enc', "/w", 'wind', 'nop', 'DownloadString', 'FromBase64String', 'iwr', '$env']);
let Executables = dynamic(["cmd", "powershell", "curl", "mshta", "msiexec", 'SyncAppvPublishingServer']);
DeviceRegistryEvents
| where ActionType == "RegistryValueSet"
| where RegistryKey has "RunMRU"
| where RegistryValueData has_any (Parameters) and RegistryValueData has_any (Executables)
| project-reorder TimeGenerated, DeviceId, DeviceName, RegistryValueData, RegistryKey

macOS LoginWindow Hooks & Authorization Plugins

Watches DeviceFileEvents for writes into the macOS SecurityAgentPlugins and LoginHook paths, a persistence spot that survives reboots and rarely changes on a managed Mac fleet.

let ExcludedBinaries = dynamic(["acrappyAPP"]);
DeviceFileEvents
| where Timestamp > ago(24h)
| where FolderPath has_any (
    "/Library/Security/SecurityAgentPlugins/",
    "/System/Library/CoreServices/SecurityAgentPlugins/",
    "/Library/Security/SecurityAgentPlugins.bundle"
    )
    or (FolderPath contains "/loginwindow" and ActionType in ("FileCreated", "FileModified"))
| where InitiatingProcessFileName !in ("installer", "softwareupdated", "installd")
| invoke FileProfile(SHA1)
| where GlobalPrevalence < 10000

Mass Wipe or Retire Device Action

Flags an operator wiping or retiring more than five Intune devices in an hour, the shape a compromised endpoint admin account leaves behind.

let WipeThreashold = 5; // A normal engineer is not wiping more than 5 devices per hour. Can be adjusted for the environment.
IntuneAuditLogs 
| where OperationName in('wipe ManagedDevice','retire ManagedDevice')
| where  ResultType == 'Success'
| where isnotempty(Properties)
| extend Targets = extract_json("$.Targets", Properties, typeof(dynamic))
| extend TargetCount= array_length(Targets)
| extend Actor = extract_json("$.Actor", Properties, typeof(string))
| extend AccountObjectId = extract_json("$.ObjectId", Actor, typeof(guid))
| summarize TotalWipeOrRetireTargetCount=sum(TargetCount), TimeGenerated=min(TimeGenerated), Identity=min(Identity) by AccountObjectId
| where TotalWipeOrRetireTargetCount > WipeThreashold