Detection rules › Kusto

Red Sift - Email with URL to previously unseen domain

Status
available
Severity
medium
Time window
14d
Group by
CorrelationUid, DstHostname, EmailFrom, EmailMessageUid, EmailReturnPath, EmailSubject, Message, Severity, SrcIp, TimeGenerated
Source
github.com/Azure/Azure-Sentinel

'Detects email forensics events containing one or more URLs whose domain has not been seen in the previous 14 days, which may indicate newly observed phishing infrastructure or suspicious delivery patterns.'

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1566 Phishing

Rule body kusto

id: 8972b513-12a2-4b46-8263-3f091d88a8bc
name: Red Sift - Email with URL to previously unseen domain
description: |
  'Detects email forensics events containing one or more URLs whose domain has not been seen in the previous 14 days, which may indicate newly observed phishing infrastructure or suspicious delivery patterns.'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: RedSiftPush
    dataTypes:
      - RedSiftEmailForensics_CL
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
relevantTechniques:
  - T1566
query: |
  let lookback = 14d;
  let recentWindow = 1h;
  let historicalDomains = RedSiftEmailForensics_CL
  | extend EmailUrls = todynamic(EmailUrls)
  | where TimeGenerated between (ago(lookback) .. ago(recentWindow))
  | where isnotempty(EmailUrls) and array_length(EmailUrls) > 0
  | mv-expand Url = EmailUrls
  | extend UrlString = tostring(Url.url_string)
  | where isnotempty(UrlString)
  | extend UrlDomain = tostring(parse_url(UrlString).Host)
  | where isnotempty(UrlDomain)
  | summarize by UrlDomain;
  RedSiftEmailForensics_CL
  | extend
      EmailFrom = tostring(column_ifexists("EmailFrom", "")),
      EmailSubject = tostring(column_ifexists("EmailSubject", "")),
      EmailReturnPath = tostring(column_ifexists("EmailReturnPath", "")),
      EmailMessageUid = tostring(column_ifexists("EmailMessageUid", "")),
      SrcIp = tostring(column_ifexists("SrcIp", "")),
      DstHostname = tostring(column_ifexists("DstHostname", "")),
      Severity = tostring(column_ifexists("Severity", "")),
      Message = tostring(column_ifexists("Message", "")),
      CorrelationUid = tostring(column_ifexists("CorrelationUid", "")),
      EmailUrls = todynamic(EmailUrls)
  | where TimeGenerated >= ago(recentWindow)
  | where isnotempty(EmailUrls) and array_length(EmailUrls) > 0
  | mv-expand Url = EmailUrls
  | extend UrlString = tostring(Url.url_string)
  | where isnotempty(UrlString)
  | extend UrlDomain = tostring(parse_url(UrlString).Host)
  | where isnotempty(UrlDomain)
  | join kind=leftanti (historicalDomains) on UrlDomain
  | summarize
      NewUrlDomains = make_set(UrlDomain, 50),
      NewUrls = make_set(UrlString, 50),
      NewDomainCount = dcount(UrlDomain),
      UrlCount = dcount(UrlString),
      RepresentativeUrlDomain = take_any(UrlDomain)
      by TimeGenerated,
      EmailFrom,
      EmailSubject,
      EmailReturnPath,
      EmailMessageUid,
      SrcIp,
      DstHostname,
      Severity,
      Message,
      CorrelationUid
  | extend
      NewUrlDomainList = strcat_array(NewUrlDomains, ", "),
      NewUrlList = strcat_array(NewUrls, ", ")
  | project
      TimeGenerated,
      EmailFrom,
      EmailSubject,
      EmailReturnPath,
      EmailMessageUid,
      SrcIp,
      DstHostname,
      RepresentativeUrlDomain,
      NewDomainCount,
      UrlCount,
      NewUrlDomainList,
      NewUrlList,
      Severity,
      Message,
      CorrelationUid
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: EmailFrom
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: SrcIp
  - entityType: DNS
    fieldMappings:
      - identifier: DomainName
        columnName: RepresentativeUrlDomain
customDetails:
  EmailSubject: EmailSubject
  ReturnPath: EmailReturnPath
  NewDomainCount: NewDomainCount
  NewUrlDomainList: NewUrlDomainList
  NewUrlList: NewUrlList
  CorrelationUid: CorrelationUid
alertDetailsOverride:
  alertDisplayNameFormat: "RedSift - URL to new domain in email from {{EmailFrom}}"
  alertDescriptionFormat: "Email from {{EmailFrom}} contains {{NewDomainCount}} previously unseen URL domain(s): {{NewUrlDomainList}}."
incidentConfiguration:
  createIncident: true
  groupingConfiguration:
    enabled: true
    reopenClosedIncident: false
    lookbackDuration: P1D
    matchingMethod: Selected
    groupByEntities:
      - Account
      - DNS
    groupByCustomDetails:
      - EmailSubject
eventGroupingSettings:
  aggregationKind: AlertPerResult
suppressionEnabled: false
suppressionDuration: PT1H
version: 1.0.0
kind: Scheduled

Stages and Predicates

Parameters

let lookback = 14d;
let recentWindow = 1h;

Let binding: historicalDomains

let historicalDomains = RedSiftEmailForensics_CL
| extend EmailUrls = todynamic(EmailUrls)
| where TimeGenerated between (ago(lookback) .. ago(recentWindow))
| where isnotempty(EmailUrls) and array_length(EmailUrls) > 0
| mv-expand Url = EmailUrls
| extend UrlString = tostring(Url.url_string)
| where isnotempty(UrlString)
| extend UrlDomain = tostring(parse_url(UrlString).Host)
| where isnotempty(UrlDomain)
| summarize by UrlDomain;

Derived from lookback, recentWindow.

Stage 1: source

RedSiftEmailForensics_CL

Stage 2: extend

| extend
    EmailFrom = tostring(column_ifexists("EmailFrom", "")),
    EmailSubject = tostring(column_ifexists("EmailSubject", "")),
    EmailReturnPath = tostring(column_ifexists("EmailReturnPath", "")),
    EmailMessageUid = tostring(column_ifexists("EmailMessageUid", "")),
    SrcIp = tostring(column_ifexists("SrcIp", "")),
    DstHostname = tostring(column_ifexists("DstHostname", "")),
    Severity = tostring(column_ifexists("Severity", "")),
    Message = tostring(column_ifexists("Message", "")),
    CorrelationUid = tostring(column_ifexists("CorrelationUid", "")),
    EmailUrls = todynamic(EmailUrls)

Stage 3: where

| where TimeGenerated >= ago(recentWindow)

Stage 4: where

| where isnotempty(EmailUrls) and array_length(EmailUrls) > 0

Stage 5: mv-expand

| mv-expand Url = EmailUrls

Stage 6: extend

| extend UrlString = tostring(Url.url_string)

Stage 7: where

| where isnotempty(UrlString)

Stage 8: extend

| extend UrlDomain = tostring(parse_url(UrlString).Host)

Stage 9: where

| where isnotempty(UrlDomain)

Stage 10: join (negated)

| join kind=leftanti (historicalDomains) on UrlDomain

Stage 11: summarize

| summarize
    NewUrlDomains = make_set(UrlDomain, 50),
    NewUrls = make_set(UrlString, 50),
    NewDomainCount = dcount(UrlDomain),
    UrlCount = dcount(UrlString),
    RepresentativeUrlDomain = take_any(UrlDomain)
    by TimeGenerated,
    EmailFrom,
    EmailSubject,
    EmailReturnPath,
    EmailMessageUid,
    SrcIp,
    DstHostname,
    Severity,
    Message,
    CorrelationUid

Stage 12: extend

| extend
    NewUrlDomainList = strcat_array(NewUrlDomains, ", "),
    NewUrlList = strcat_array(NewUrls, ", ")

Stage 13: project

| project
    TimeGenerated,
    EmailFrom,
    EmailSubject,
    EmailReturnPath,
    EmailMessageUid,
    SrcIp,
    DstHostname,
    RepresentativeUrlDomain,
    NewDomainCount,
    UrlCount,
    NewUrlDomainList,
    NewUrlList,
    Severity,
    Message,
    CorrelationUid

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
EmailUrlsgt0
EmailUrlsis_not_null(no value, null check)
UrlDomainis_not_null(no value, null check)
UrlStringis_not_null(no value, null check)

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
EmailUrlsgt
  • 0 transforms: array_length
EmailUrlsis_not_null
  • (no value, null check)
UrlDomainis_not_null
  • (no value, null check)
UrlStringis_not_null
  • (no value, null check)

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
CorrelationUidproject
DstHostnameproject
EmailFromproject
EmailMessageUidproject
EmailReturnPathproject
EmailSubjectproject
Messageproject
NewDomainCountproject
NewUrlDomainListproject
NewUrlListproject
RepresentativeUrlDomainproject
Severityproject
SrcIpproject
TimeGeneratedproject
UrlCountproject