// Illustrative; not executed against a live workspace. Microsoft Sentinel / Log Analytics: SigninLogs and // AADNonInteractiveUserSignInLogs. Password spraying at machine pace: one source failing against many accounts // within a minute. Storm-0940's 10-14 October 2026 campaign peaked at 2,900 failures a minute, and nearly 98% of // its responses were lockouts. Thresholds are starting points; tune them to your tenant before alerting. let Window = 14d; let FailuresPerMinute = 60; let AccountsPerMinute = 10; let FailureCodes = dynamic(["50126", "50053"]); // invalid username or password; account locked or source blocked let SignIns = union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs | where TimeGenerated > ago(Window); let Bursts = SignIns | where ResultType in (FailureCodes) | summarize Failures = count(), Accounts = dcount(UserPrincipalName), Lockouts = countif(ResultType == "50053"), UserAgents = make_set(UserAgent, 5), Apps = make_set(AppDisplayName, 5) by IPAddress, AutonomousSystemNumber, Minute = bin(TimeGenerated, 1m) | where Failures >= FailuresPerMinute or Accounts >= AccountsPerMinute; let Sources = Bursts | summarize BurstMinutes = count(), PeakFailuresPerMinute = max(Failures), PeakAccountsPerMinute = max(Accounts), TotalFailures = sum(Failures), TotalLockouts = sum(Lockouts), FirstBurst = min(Minute), LastBurst = max(Minute), SampleUserAgents = take_any(UserAgents), SampleApps = take_any(Apps) by IPAddress, AutonomousSystemNumber | extend LockoutPercent = round(100.0 * TotalLockouts / TotalFailures, 1); let Successes = SignIns | where ResultType == "0" | summarize FirstSuccess = min(TimeGenerated), SuccessfulAccounts = make_set(UserPrincipalName, 20) by IPAddress; Sources | join kind=leftouter Successes on IPAddress | extend SucceededFromSameSource = isnotnull(FirstSuccess) | project-away IPAddress1 | order by SucceededFromSameSource desc, PeakFailuresPerMinute desc