Malicious Email Campaigns by Recipient URL Clicks
Description
This query ranks malicious inbound email campaigns by the number of URL clicks they attracted and by how many distinct users clicked, rather than by campaign size.
Query · kql
//This query ranks malicious inbound email campaigns from the last 30 days by recipient URL clicks, not by size.
//A small campaign that reached mailboxes and got clicked is realised risk; a large one blocked at delivery is not,
//so ranking by engagement inverts the triage order compared with ranking by message volume.
//Campaigns carrying QR codes are flagged, since the URL is not visible to the user or to link inspection.
//Phishing simulation and SecOps mailbox traffic are excluded so internal exercises do not inflate the ranking.
let ClicksByCluster = UrlClickEvents
| where Timestamp > ago(30d)
| join kind=inner (EmailEvents | where Timestamp > ago(30d) | where EmailClusterId > 0 | distinct NetworkMessageId, EmailClusterId) on NetworkMessageId
| summarize UrlClicks = count(), ClickingUsers = dcount(AccountUpn) by EmailClusterId;
let QrClusters = EmailUrlInfo
| where Timestamp > ago(30d)
| where UrlLocation == "QRCode"
| join kind=inner (EmailEvents | where Timestamp > ago(30d) | where EmailClusterId > 0 | distinct NetworkMessageId, EmailClusterId) on NetworkMessageId
| summarize QrMessages = dcount(NetworkMessageId) by EmailClusterId;
EmailEvents
| where Timestamp > ago(30d)
| where EmailDirection == "Inbound" and isnotempty(ThreatTypes) and EmailClusterId > 0
| where OrgLevelPolicy !in ("Phishing simulation", "SecOps Mailbox")
| extend Key = strcat(NetworkMessageId, "-", RecipientEmailAddress)
| summarize arg_max(Timestamp, *) by Key
| summarize Messages = count(),
Recipients = dcount(RecipientEmailAddress),
SenderDomains = dcount(SenderFromDomain),
SampleSenderDomains = make_set(SenderFromDomain, 5),
ThreatTypesRaw = make_set(ThreatTypes, 50),
SampleSubjects = make_set(Subject, 5),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp)
by EmailClusterId
| join kind=inner (ClicksByCluster) on EmailClusterId
| where UrlClicks > 0
| join kind=leftouter (QrClusters) on EmailClusterId
| extend CarriedQrCode = iif(coalesce(QrMessages, 0) > 0, "QR code", "")
| extend ThreatMix = array_sort_asc(set_union(split(strcat_array(ThreatTypesRaw, ", "), ", "), dynamic([])))
| top 20 by UrlClicks
| project ['Email Cluster ID']=EmailClusterId, ['URL Clicks']=UrlClicks, ['Clicking Users']=ClickingUsers,
['Messages']=Messages,
['Recipients']=Recipients, ['Sender Domains']=SenderDomains,
['Sample Sender Domains']=SampleSenderDomains, ['Threat Mix']=ThreatMix,
['QR Code']=CarriedQrCode, ['Sample Subjects']=SampleSubjects,
['First Seen']=FirstSeen, ['Last Seen']=LastSeen