Detection rules › Kusto

Kusto rules: 0f

AV detections related to SpringShell Vulnerability

#
Status
available
Severity
high
Time window
1d
Group by
$left.DeviceName, $right.CompromisedEntity, AlertSeverity, CompromisedEntity, Description, DeviceId, DisplayName, Entities, ProductName, PublicIP, TenantId, ThreatFamilyName, ThreatName
Source
github.com/Azure/Azure-Sentinel

This query looks for Microsoft Defender AV detections related to the SpringShell vulnerability. In Microsoft Sentinel, the SecurityAlerts table includes only the Device Name of the affected device. This query joins the DeviceInfo table to clearly connect other information such as device group, IP, logged-on users, etc. This would allow the Microsoft Sentinel analyst to have more context related to the alert, if available. Reference: https://www.microsoft.com/security/blog/2022/04/04/springshell-rce-vulnerability-guidance-for-protecting-against-and-detecting-cve-2022-22965/

MITRE ATT&CK Coverage

TacticTechniques
Initial Access

Telemetry Coverage

Rule Body

id: 3bd33158-3f0b-47e3-a50f-7c20a1b88038
name: AV detections related to SpringShell Vulnerability
description: |
  'This query looks for Microsoft Defender AV detections related to the SpringShell vulnerability. In Microsoft Sentinel, the SecurityAlerts table includes only the Device Name of the affected device.
    This query joins the DeviceInfo table to clearly connect other information such as device group, IP, logged-on users, etc. This would allow the Microsoft Sentinel analyst to have more context related to the alert, if available.
    Reference: https://www.microsoft.com/security/blog/2022/04/04/springshell-rce-vulnerability-guidance-for-protecting-against-and-detecting-cve-2022-22965/'
severity: High
status: Available
requiredDataConnectors:
  - connectorId: MicrosoftThreatProtection
    dataTypes:
      - SecurityAlert
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
relevantTechniques:
  - T1190
tags:
  - CVE-2022-22965
  - SpringShell 
  - Spring4Shell
query: |
  let SpringShell_threats = dynamic(["Trojan:Python/SpringShellExpl", "Exploit:Python/SpringShell", "Backdoor:PHP/Remoteshell.V", "SpringShell"]);
  DeviceInfo
  | extend DeviceName = tolower(DeviceName)
  | join kind=inner ( SecurityAlert
  | where ProviderName =~ "MDATP"
  | extend ThreatName = tostring(parse_json(ExtendedProperties).ThreatName)
  | extend ThreatFamilyName = tostring(parse_json(ExtendedProperties).ThreatFamilyName)
  | where ThreatName in~ (SpringShell_threats) or ThreatFamilyName in~ (SpringShell_threats)
  | extend CompromisedEntity = tolower(CompromisedEntity)
  ) on $left.DeviceName == $right.CompromisedEntity
  | summarize by DisplayName, ThreatName, ThreatFamilyName, PublicIP, AlertSeverity, Description, tostring(LoggedOnUsers), DeviceId, TenantId , bin(TimeGenerated, 1d), CompromisedEntity, tostring(LoggedOnUsers), ProductName, Entities
  | extend HostName = tostring(split(CompromisedEntity, ".")[0]), DomainIndex = toint(indexof(CompromisedEntity, '.'))
  | extend HostNameDomain = iff(DomainIndex != -1, substring(CompromisedEntity, DomainIndex + 1), CompromisedEntity)
entityMappings:
  - entityType: Host
    fieldMappings:
      - identifier: FullName 
        columnName: CompromisedEntity
      - identifier: HostName
        columnName: HostName
      - identifier: DnsDomain
        columnName: HostNameDomain
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: PublicIP
version: 1.0.3
kind: Scheduled

Stages and Predicates

Parameters

let SpringShell_threats = dynamic(["Trojan:Python/SpringShellExpl", "Exploit:Python/SpringShell", "Backdoor:PHP/Remoteshell.V", "SpringShell"]);

Stage 1: source

DeviceInfo

Stage 2: extend

| extend DeviceName = tolower(DeviceName)

Stage 3: join

| join kind=inner ( SecurityAlert
| where ProviderName =~ "MDATP"
| extend ThreatName = tostring(parse_json(ExtendedProperties).ThreatName)
| extend ThreatFamilyName = tostring(parse_json(ExtendedProperties).ThreatFamilyName)
| where ThreatName in~ (SpringShell_threats) or ThreatFamilyName in~ (SpringShell_threats)
| extend CompromisedEntity = tolower(CompromisedEntity)
) on $left.DeviceName == $right.CompromisedEntity

Stage 4: summarize

summarize by DisplayName, ThreatName, ThreatFamilyName, PublicIP, AlertSeverity, Description, DeviceId, TenantId, CompromisedEntity, ProductName, Entities

Stage 5: extend

extend DomainIndex, HostName

Stage 6: extend

extend HostNameDomain
HostNameDomain =
ifDomainIndex != -1substring(CompromisedEntity, (DomainIndex + 1))
elseCompromisedEntity

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
ProviderNameeq
  • MDATP
field:"ProviderName" kind:eq value:"MDATP"
ThreatFamilyNamein
  • Backdoor:PHP/Remoteshell.V
  • Exploit:Python/SpringShell
  • SpringShell
  • Trojan:Python/SpringShellExpl
field:"ThreatFamilyName" kind:in
ThreatNamein
  • Backdoor:PHP/Remoteshell.V
  • Exploit:Python/SpringShell
  • SpringShell
  • Trojan:Python/SpringShellExpl
field:"ThreatName" kind:in

Output Fields

These fields are emitted when the rule matches.

FieldSource
AlertSeveritysummarize
CompromisedEntitysummarize
Descriptionsummarize
DeviceIdsummarize
DisplayNamesummarize
Entitiessummarize
ProductNamesummarize
PublicIPsummarize
TenantIdsummarize
ThreatFamilyNamesummarize
ThreatNamesummarize
DomainIndexextend
HostNameextend
HostNameDomainextend

Dev-0270 Malicious Powershell usage

#
Status
available
Severity
high
Time window
6h
Source
github.com/Azure/Azure-Sentinel

DEV-0270 heavily uses powershell to achieve their objective at various stages of their attack. To locate powershell related activity tied to the actor, Microsoft Sentinel customers can run the following query.

MITRE ATT&CK Coverage

Telemetry Coverage

Rule Body

id: 422ca2bf-598b-4872-82bb-5f7e8fa731e7
name: Dev-0270 Malicious Powershell usage
description: | 
  'DEV-0270 heavily uses powershell to achieve their objective at various stages of their attack. To locate powershell related activity tied to the actor, Microsoft Sentinel customers can run the following query.'
severity: High 
requiredDataConnectors:
  - connectorId: SecurityEvents
    dataTypes:
      - SecurityEvent
  - connectorId: WindowsSecurityEvents
    dataTypes:
      - SecurityEvent
  - connectorId: MicrosoftThreatProtection
    dataTypes:
      - DeviceProcessEvents
queryFrequency: 6h 
queryPeriod: 6h 
triggerOperator: gt 
triggerThreshold: 0 
status: Available
tactics: 
  - Exfiltration
  - DefenseEvasion
relevantTechniques:
  -  T1048
  -  T1562
tags:
  - Dev-0270
query: |
  (union isfuzzy=true
  (SecurityEvent
  | where EventID==4688
  | extend FileName=tostring(split(NewProcessName, @'\')[(-1)]),  ProcessCommandLine = CommandLine, InitiatingProcessFileName=ParentProcessName
  | where (FileName =~ "powershell.exe" and ProcessCommandLine has_all("try", "Add-MpPreference", "-ExclusionPath", "ProgramData", "catch")) or (FileName =~ 'powershell.exe' and ProcessCommandLine has_all('Add-PSSnapin', 'Get-Recipient', '-ExpandProperty', 'EmailAddresses', 'SmtpAddress', '-hidetableheaders') )
  | project TimeGenerated, Computer, Account, AccountDomain, ProcessName, ProcessNameFullPath = NewProcessName, InitiatingProcessFileName, EventID, Activity, CommandLine, EventSourceName, Type
  ),
  (DeviceProcessEvents 
  | where (FileName =~ "powershell.exe" and ((ProcessCommandLine has_all("try", "Add-MpPreference", "-ExclusionPath", "ProgramData", "catch"))  or (ProcessCommandLine has_all('Add-PSSnapin', 'Get-Recipient', '-ExpandProperty', 'EmailAddresses', 'SmtpAddress', '-hidetableheaders'))))
  or ( InitiatingProcessFileName =~ 'powershell.exe' and (((InitiatingProcessCommandLine has_all('$file=', 'dllhost.exe', 'Invoke-WebRequest', '-OutFile')) or ((InitiatingProcessCommandLine has_all('$admins=', 'System.Security.Principal.SecurityIdentifier', 'Translate', '-split', 'localgroup', '/add', '$rdp='))))))
  | extend Account = strcat(InitiatingProcessAccountDomain, @'\', InitiatingProcessAccountName),  Computer = DeviceName
  )
  )
  | extend HostName = tostring(split(Computer, ".")[0]), DomainIndex = toint(indexof(Computer, '.'))
  | extend HostNameDomain = iff(DomainIndex != -1, substring(Computer, DomainIndex + 1), Computer)
  | extend AccountName = tostring(split(Account, @'\')[1]), AccountNTDomain = tostring(split(Account, @'\')[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: Account
      - identifier: Name
        columnName: AccountName
      - identifier: NTDomain
        columnName: AccountNTDomain
  - entityType: Host
    fieldMappings:
      - identifier: FullName
        columnName: Computer
      - identifier: HostName
        columnName: HostName
      - identifier: DnsDomain
        columnName: HostNameDomain
version: 1.0.4
kind: Scheduled

Stages and Predicates

Union isfuzzy=true (2 Sources)

Each leg below queries one source; the rule matches if any leg does. Sources: SecurityEvent, DeviceProcessEvents

Leg 1: SecurityEvent

SecurityEvent
| where EventID==4688
| extend FileName=tostring(split(NewProcessName, @'\')[(-1)]),  ProcessCommandLine = CommandLine, InitiatingProcessFileName=ParentProcessName
| where (FileName =~ "powershell.exe" and ProcessCommandLine has_all("try", "Add-MpPreference", "-ExclusionPath", "ProgramData", "catch")) or (FileName =~ 'powershell.exe' and ProcessCommandLine has_all('Add-PSSnapin', 'Get-Recipient', '-ExpandProperty', 'EmailAddresses', 'SmtpAddress', '-hidetableheaders') )
| project TimeGenerated, Computer, Account, AccountDomain, ProcessName, ProcessNameFullPath = NewProcessName, InitiatingProcessFileName, EventID, Activity, CommandLine, EventSourceName, Type

Leg 2: DeviceProcessEvents

DeviceProcessEvents 
| where (FileName =~ "powershell.exe" and ((ProcessCommandLine has_all("try", "Add-MpPreference", "-ExclusionPath", "ProgramData", "catch"))  or (ProcessCommandLine has_all('Add-PSSnapin', 'Get-Recipient', '-ExpandProperty', 'EmailAddresses', 'SmtpAddress', '-hidetableheaders'))))
or ( InitiatingProcessFileName =~ 'powershell.exe' and (((InitiatingProcessCommandLine has_all('$file=', 'dllhost.exe', 'Invoke-WebRequest', '-OutFile')) or ((InitiatingProcessCommandLine has_all('$admins=', 'System.Security.Principal.SecurityIdentifier', 'Translate', '-split', 'localgroup', '/add', '$rdp='))))))
| extend Account = strcat(InitiatingProcessAccountDomain, @'\', InitiatingProcessAccountName),  Computer = DeviceName

Applied to the Combined Result

| extend HostName = tostring(split(Computer, ".")[0]), DomainIndex = toint(indexof(Computer, '.'))
| extend HostNameDomain = iff(DomainIndex != -1, substring(Computer, DomainIndex + 1), Computer)
| extend AccountName = tostring(split(Account, @'\')[1]), AccountNTDomain = tostring(split(Account, @'\')[0])

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
EventIDeq
  • 4688 corpus 23 (kusto 23)
field:"EventID" kind:eq value:"4688"
FileNameeq
  • powershell.exe
  • powershell.exe corpus 187 (elastic 140, splunk 44, kusto 3)
field:"FileName" kind:eq
InitiatingProcessCommandLinematch
  • $admins= transforms: term
  • $file= transforms: term
  • $rdp= transforms: term
  • -OutFile transforms: term
  • -split transforms: term
  • /add transforms: term corpus 2 (kusto 2)
  • Invoke-WebRequest transforms: term
  • System.Security.Principal.SecurityIdentifier transforms: term
  • Translate transforms: term
  • dllhost.exe transforms: term corpus 2 (kusto 2)
  • localgroup transforms: term
field:"ParentCommandLine" kind:match
InitiatingProcessFileNameeq
  • powershell.exe corpus 37 (elastic 35, splunk 1, kusto 1)
field:"parent_process_basename" kind:eq value:"powershell.exe"
ProcessCommandLinematch
  • -ExclusionPath transforms: term
  • -ExpandProperty transforms: term
  • -hidetableheaders transforms: term
  • Add-MpPreference transforms: term
  • Add-PSSnapin transforms: term
  • EmailAddresses transforms: term
  • Get-Recipient transforms: term
  • ProgramData transforms: term
  • SmtpAddress transforms: term
  • catch transforms: term
  • try transforms: term
field:"CommandLine" kind:match

Output Fields

These fields are emitted when the rule matches.

FieldSource
Accountextend
AccountDomainproject
Activityproject
CommandLineproject
Computerextend
EventIDproject
EventSourceNameproject
InitiatingProcessFileNameproject
ProcessNameproject
ProcessNameFullPathproject
TimeGeneratedproject
Typeproject
DomainIndexextend
HostNameextend
HostNameDomainextend
AccountNTDomainextend
AccountNameextend

Hunt for ADWS requests from unknown devices

#
Group by
$left.RemoteIP, $right.IPAddress, DeviceName, RemoteIP
Author
Robbe Van den Daele
Source
github.com/HybridBrothers/Hunting-Queries-Detection-Rules

This hunting rule searches for incomming ADWS connections on Domain Controllers (DC's need to be onboarded in Defender for Endpoint) from IP Addresses that cannot be linked to MDE onboarded devices.

MITRE ATT&CK Coverage

References

Telemetry Coverage

Rule Body

let device_info = (
    // Get device network info from last 7 days
    DeviceNetworkInfo
    | where Timestamp > ago(7d)
    // Expand the IP Addresses of the devices
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    // Distinct IP address for each device
    | distinct DeviceName, DeviceId, IPAddress
    // Search for each device if it is onboarded or not
    | join kind=inner (
        DeviceInfo 
        | where Timestamp > ago(7d)
        | distinct DeviceName, DeviceId, OnboardingStatus
        // Get the first timestamp the device was seen
        | join kind=inner (
            DeviceInfo
            | where Timestamp > ago(30d)
            | summarize FirstSeen = arg_min(Timestamp, DeviceId) by DeviceId
        ) on DeviceId
        | project-away DeviceId1, DeviceId2
    ) on DeviceId, DeviceName
    | project-away DeviceName1, DeviceId1
);
// Get incomming traffic on ADWS port and save unique remote IP addresses
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where ActionType != "ListeningConnectionCreated"
| where InitiatingProcessFolderPath == @"c:\windows\adws\microsoft.activedirectory.webservices.exe"
| where LocalPort == "9389"
| summarize ConnectionTimes=make_list(Timestamp) by RemoteIP, DeviceName
// Get device information of remote IP addresses, results for IP we do not find information for are allowed
| join kind=leftouter device_info on $left.RemoteIP == $right.IPAddress
| project-away IPAddress
// Check if the remote IPs are onboarded devices or not
| where OnboardingStatus != "Onboarded"
// Make output better
| project DeviceName, ConnectionTimes, RemoteIP, RemoteDeviceName = DeviceName1, RemoteDeviceId = DeviceId, RemoteOnboardingStatus = OnboardingStatus, RemoteDeviceFirstSeen = FirstSeen

let device_info = (
    // Get device network info from last 7 days
    DeviceNetworkInfo
    | where TimeGenerated > ago(7d)
    // Expand the IP Addresses of the devices
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    // Distinct IP address for each device
    | distinct DeviceName, DeviceId, IPAddress
    // Search for each device if it is onboarded or not
    | join kind=inner (
        DeviceInfo 
        | where TimeGenerated > ago(7d)
        | distinct DeviceName, DeviceId, OnboardingStatus
        // Get the first timestamp the device was seen
        | join kind=inner (
            DeviceInfo
            | where TimeGenerated > ago(30d)
            | summarize FirstSeen = arg_min(TimeGenerated, DeviceId) by DeviceId
        ) on DeviceId
        | project-away DeviceId1, DeviceId2
    ) on DeviceId, DeviceName
    | project-away DeviceName1, DeviceId1
);
// Get incomming traffic on ADWS port and save unique remote IP addresses
DeviceNetworkEvents
| where TimeGenerated > ago(30d)
| where ActionType != "ListeningConnectionCreated"
| where InitiatingProcessFolderPath == @"c:\windows\adws\microsoft.activedirectory.webservices.exe"
| where LocalPort == "9389"
| summarize ConnectionTimes=make_list(TimeGenerated) by RemoteIP, DeviceName
// Get device information of remote IP addresses, results for IP we do not find information for are allowed
| join kind=leftouter device_info on $left.RemoteIP == $right.IPAddress
| project-away IPAddress
// Check if the remote IPs are onboarded devices or not
| where OnboardingStatus != "Onboarded"
// Make output better
| project DeviceName, ConnectionTimes, RemoteIP, RemoteDeviceName = DeviceName1, RemoteDeviceId = DeviceId, RemoteOnboardingStatus = OnboardingStatus, RemoteDeviceFirstSeen = FirstSeen

Stages and Predicates

Let Binding: device_info used in Stages 1, 8

let device_info = (
    DeviceNetworkInfo
    | where Timestamp > ago(7d)
    | mv-expand todynamic(IPAddresses)
    | extend IPAddress = tostring(IPAddresses.IPAddress)
    | distinct DeviceName, DeviceId, IPAddress
    | join kind=inner (
        DeviceInfo 
        | where Timestamp > ago(7d)
        | distinct DeviceName, DeviceId, OnboardingStatus
        | join kind=inner (
            DeviceInfo
            | where Timestamp > ago(30d)
            | summarize FirstSeen = arg_min(Timestamp, DeviceId) by DeviceId
        ) on DeviceId
        | project-away DeviceId1, DeviceId2
    ) on DeviceId, DeviceName
    | project-away DeviceName1, DeviceId1
);

Stage 1: source

let device_info

Stage 2: source

DeviceNetworkEvents

Stage 3: where

where Timestamp > ago(2592000s)

Stage 4: where

where ActionType != "ListeningConnectionCreated"

Stage 5: where

where InitiatingProcessFolderPath == @"c:\windows\adws\microsoft.activedirectory.webservices.exe"

Stage 6: where

where LocalPort == 9389

Stage 7: summarize

summarize ConnectionTimes by RemoteIP, DeviceName

Stage 8: join

join kind=leftouter (device_info) on $left.RemoteIP, $right.IPAddress

Stage 9: project-away

project-away IPAddress

Stage 10: where

where OnboardingStatus != "Onboarded"

Stage 11: project

project ConnectionTimes, DeviceName, RemoteDeviceFirstSeen, RemoteDeviceId, RemoteDeviceName, RemoteIP, RemoteOnboardingStatus

Indicators

These rows show field, operator, and value matches.

Output Fields

These fields are emitted when the rule matches.

FieldSource
ConnectionTimesproject
DeviceNameproject
RemoteDeviceFirstSeenproject
RemoteDeviceIdproject
RemoteDeviceNameproject
RemoteIPproject
RemoteOnboardingStatusproject

Ingress Tool Transfer - Certutil

#
Status
available
Severity
low
Time window
14d
Source
github.com/Azure/Azure-Sentinel

This detection addresses most of the known ways to utilize this binary for malicious/unintended purposes. It attempts to accommodate for most detection evasion techniques, like commandline obfuscation and binary renaming.

MITRE ATT&CK Coverage

Telemetry Coverage

Rule Body

id: f0be11a9-ec48-4df6-801d-479556044d4e
name: Ingress Tool Transfer - Certutil
description: |
  This detection addresses most of the known ways to utilize this binary for malicious/unintended purposes. 
  It attempts to accommodate for most detection evasion techniques, like commandline obfuscation and binary renaming.
severity: Low
status: Available
requiredDataConnectors:
  - connectorId: MicrosoftThreatProtection
    dataTypes:
      - DeviceProcessEvents
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CommandAndControl
  - DefenseEvasion
relevantTechniques:
  - T1105
  - T1564.004
  - T1027
  - T1140
query: |
  // Set the time span for the query.
  let Timeframe = 1h;
  // Set the HashTimeframe for the hash lookup; longer makes it more accurate, but obviously also more resource-intensive.
  let HashTimeframe = 14d;
  // Get all known SHA1 hashes for certutil executions or renamed files formerly named certutil.
  let CertUtilPESha1=materialize(DeviceProcessEvents | where Timestamp > ago(HashTimeframe)| where FileName has "certutil"  | where isnotempty(SHA1) | summarize sha1=make_set(SHA1));
  let CertUtilFESha1=materialize(DeviceFileEvents | where Timestamp > ago(HashTimeframe)| where PreviousFileName contains "certutil" or FileName contains "certutil"  | where isnotempty(SHA1) | summarize sha1=make_set(SHA1));
  DeviceProcessEvents
  | where Timestamp >= ago(Timeframe)
  // Get all executions by processes with a SHA1 hash that is or was named certutil.
  | where SHA1 in (CertUtilPESha1) or SHA1 in (CertUtilFESha1) or FileName =~ "certutil.exe" or ProcessCommandLine has "certutil"
  // Create a new field called CleanProcessCommandLine which gets populated with the value of ProcessCommandLine as Windows parses it for execution, 
  // removing any potential command line obfuscation. 
  | extend CleanProcessCommandLine=parse_command_line(ProcessCommandLine, "windows")
  // Search for de-obfuscated commands used. 
  // Urlcache is the documented attribute. However, url is also accepted.
  // Verifyctl is the documented attribute. However, verify is also accepted.
  | where CleanProcessCommandLine has_any ("decode", "encode", "verify","url") 
  | order by Timestamp
entityMappings:
  - entityType: Host
    fieldMappings:
      - identifier: FullName
        columnName: DeviceName
  - entityType: Account
    fieldMappings:
      - identifier: Sid
        columnName: AccountSid 
      - identifier: Name
        columnName: AccountName
      - identifier: NTDomain
        columnName: AccountDomain
  - entityType: Process
    fieldMappings:
      - identifier: CommandLine
        columnName: ProcessCommandLine
version: 1.0.0
kind: Scheduled

Stages and Predicates

Parameters

let Timeframe = 1h;
let HashTimeframe = 14d;

Let Binding: CertUtilPESha1 used in Stages 1, 5

let CertUtilPESha1 = materialize(DeviceProcessEvents | where Timestamp > ago(HashTimeframe)| where FileName has "certutil"  | where isnotempty(SHA1) | summarize sha1=make_set(SHA1));

Let Binding: CertUtilFESha1 used in Stages 2, 5

let CertUtilFESha1 = materialize(DeviceFileEvents | where Timestamp > ago(HashTimeframe)| where PreviousFileName contains "certutil" or FileName contains "certutil"  | where isnotempty(SHA1) | summarize sha1=make_set(SHA1));

Stage 1: source

let CertUtilPESha1

Stage 2: source

let CertUtilFESha1

Stage 3: source

DeviceProcessEvents

Stage 4: where

where Timestamp >= ago(3600s)

Stage 5: where

where (FileName =~ "certutil.exe" or ProcessCommandLine has "certutil" or SHA1 == "CertUtilFESha1" or SHA1 == "CertUtilPESha1")

Stage 6: extend

extend CleanProcessCommandLine

Stage 7: where

where (CleanProcessCommandLine has "decode" or CleanProcessCommandLine has "encode" or CleanProcessCommandLine has "verify" or CleanProcessCommandLine has "url")

Stage 8: sort

sort by Timestamp

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
CleanProcessCommandLinematch
  • decode transforms: term
  • encode transforms: term
  • url transforms: term
  • verify transforms: term
field:"CleanProcessCommandLine" kind:match
FileNameeq
  • certutil.exe
field:"FileName" kind:eq value:"certutil.exe"
ProcessCommandLinematch
  • certutil transforms: term
field:"ProcessCommandLine" kind:match value:"certutil"
SHA1in
  • CertUtilFESha1
  • CertUtilPESha1
field:"SHA1" kind:in

Output Fields

These fields are emitted when the rule matches.

FieldSource
CleanProcessCommandLineextend

OCI - Multiple instances terminated

#
Status
available
Severity
high
Time window
10m
Group by
SrcIpAddr
Source
github.com/Azure/Azure-Sentinel

'Detects when multiple instances were terminated.'

MITRE ATT&CK Coverage

TacticTechniques
Impact

Rule Body

id: 252e651d-d825-480c-bdeb-8b239354577d
name: OCI - Multiple instances terminated
description: |
  'Detects when multiple instances were terminated.'
severity: High
status: Available
requiredDataConnectors:
  - connectorId: OracleCloudInfrastructureLogsConnector
    dataTypes:
      - OCILogs
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Impact
relevantTechniques:
  - T1529
query: |
  let threshold = 5;
  OCILogs
  | where data_eventName_s =~ 'TerminateInstance'
  | summarize count() by SrcIpAddr, bin(TimeGenerated, 10m)
  | where count_ >= threshold
  | extend IPCustomEntity = SrcIpAddr
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPCustomEntity
version: 1.0.1
kind: Scheduled

Stages and Predicates

Parameters

let threshold = 5;

Stage 1: source

OCILogs

Stage 2: where

| where data_eventName_s =~ 'TerminateInstance'

Stage 3: summarize

| summarize count() by SrcIpAddr, bin(TimeGenerated, 10m)

Stage 4: where

| where count_ >= threshold

Stage 5: extend

| extend IPCustomEntity = SrcIpAddr

Indicators

These rows show field, operator, and value matches.

Output Fields

These fields are emitted when the rule matches.

FieldSource
SrcIpAddrsummarize
IPCustomEntityextend

Office Policy Tampering

#
Status
available
Severity
medium
Time window
1d
Group by
ClientIP, Operation, Parameters, Port, ResultStatus, UserId, UserType
Source
github.com/Azure/Azure-Sentinel

Identifies if any tampering is done to either auditlog, ATP Safelink, SafeAttachment, AntiPhish or Dlp policy. An adversary may use this technique to evade detection or avoid other policy based defenses. References: https://docs.microsoft.com/powershell/module/exchange/advanced-threat-protection/remove-antiphishrule?view=exchange-ps.

MITRE ATT&CK Coverage

TacticTechniques
Persistence
Defense Impairment

Telemetry Coverage

Rule Body

id: fbd72eb8-087e-466b-bd54-1ca6ea08c6d3
name: Office Policy Tampering
description: |
  'Identifies if any tampering is done to either auditlog, ATP Safelink, SafeAttachment, AntiPhish or Dlp policy. 
  An adversary may use this technique to evade detection or avoid other policy based defenses.
  References: https://docs.microsoft.com/powershell/module/exchange/advanced-threat-protection/remove-antiphishrule?view=exchange-ps.'
severity: Medium
status: Available 
requiredDataConnectors:
  - connectorId: Office365
    dataTypes:
      - OfficeActivity (Exchange)
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Persistence
  - DefenseEvasion
relevantTechniques:
  - T1098
  - T1562
query: |
  let opList = OfficeActivity 
  | summarize by Operation
  //| where Operation startswith "Remove-" or Operation startswith "Disable-"
  | where Operation has_any ("Remove", "Disable")
  | where Operation contains "AntiPhish" or Operation contains "SafeAttachment" or Operation contains "SafeLinks" or Operation contains "Dlp" or Operation contains "Audit"
  | summarize make_set(Operation, 500);
  OfficeActivity
  // Only admin or global-admin can disable/remove policy
  | where RecordType =~ "ExchangeAdmin"
  | where UserType in~ ("Admin","DcAdmin")
  // Pass in interesting Operation list
  | where Operation in~ (opList)
  | extend ClientIPOnly = case( 
  ClientIP has ".", tostring(split(ClientIP,":")[0]), 
  ClientIP has "[", tostring(trim_start(@'[[]',tostring(split(ClientIP,"]")[0]))),
  ClientIP
  )  
  | extend Port = case(
  ClientIP has ".", (split(ClientIP,":")[1]),
  ClientIP has "[", tostring(split(ClientIP,"]:")[1]),
  ClientIP
  )
  | summarize StartTimeUtc = min(TimeGenerated), EndTimeUtc = max(TimeGenerated), OperationCount = count() by Operation, UserType, UserId, ClientIP = ClientIPOnly, Port, ResultStatus, Parameters
  | extend AccountName = tostring(split(UserId, "@")[0]), AccountUPNSuffix = tostring(split(UserId, "@")[1])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserId
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ClientIP
version: 2.0.3
kind: Scheduled

Stages and Predicates

Let Binding: opList used in Stage 4

let opList = OfficeActivity 
| summarize by Operation
| where Operation has_any ("Remove", "Disable")
| where Operation contains "AntiPhish" or Operation contains "SafeAttachment" or Operation contains "SafeLinks" or Operation contains "Dlp" or Operation contains "Audit"
| summarize make_set(Operation, 500);

Stage 1: source

OfficeActivity

Stage 2: where

| where RecordType =~ "ExchangeAdmin"

Stage 3: where

| where UserType in~ ("Admin","DcAdmin")

Stage 4: where

| where Operation in~ (opList)

Stage 5: extend

| extend ClientIPOnly = case( 
ClientIP has ".", tostring(split(ClientIP,":")[0]), 
ClientIP has "[", tostring(trim_start(@'[[]',tostring(split(ClientIP,"]")[0]))),
ClientIP
)
ClientIPOnly =
ifClientIP has "."tostring(split(ClientIP, ":")[0])
elifClientIP has "["tostring(trim_start(@'[[]', tostring(split(ClientIP, "]")[0])))
elseClientIP

Stage 6: extend

| extend Port = case(
ClientIP has ".", (split(ClientIP,":")[1]),
ClientIP has "[", tostring(split(ClientIP,"]:")[1]),
ClientIP
)
Port =
ifClientIP has "."split(ClientIP, ":")[1]
elifClientIP has "["tostring(split(ClientIP, "]:")[1])
elseClientIP

Stage 7: summarize

| summarize StartTimeUtc = min(TimeGenerated), EndTimeUtc = max(TimeGenerated), OperationCount = count() by Operation, UserType, UserId, ClientIP = ClientIPOnly, Port, ResultStatus, Parameters

Stage 8: extend

| extend AccountName = tostring(split(UserId, "@")[0]), AccountUPNSuffix = tostring(split(UserId, "@")[1])

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Operationin
  • opList
field:"Operation" kind:in value:"opList"
RecordTypeeq
  • ExchangeAdmin
field:"RecordType" kind:eq value:"ExchangeAdmin"
UserTypein
  • Admin corpus 2 (kusto 2)
  • DcAdmin corpus 2 (kusto 2)
field:"UserType" kind:in

Output Fields

These fields are emitted when the rule matches.

FieldSource
ClientIPsummarize
EndTimeUtcsummarize
Operationsummarize
OperationCountsummarize
Parameterssummarize
Portsummarize
ResultStatussummarize
StartTimeUtcsummarize
UserIdsummarize
UserTypesummarize
AccountNameextend
AccountUPNSuffixextend

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, UrlDomain
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 Access

Rule Body

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 used in Stage 10

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;

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 NewDomainCount, NewUrlDomains, NewUrls, RepresentativeUrlDomain, UrlCount by TimeGenerated, EmailFrom, EmailSubject, EmailReturnPath, EmailMessageUid, SrcIp, DstHostname, Severity, Message, CorrelationUid

Stage 12: extend

extend NewUrlDomainList, NewUrlList

Stage 13: project

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

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
EmailUrlsis_not_null
  • (no value, null check)
field:"EmailUrls" kind:is_not_null
UrlDomainis_not_null
  • (no value, null check)
field:"UrlDomain" kind:is_not_null
UrlStringis_not_null
  • (no value, null check)
field:"UrlString" kind:is_not_null

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
EmailUrlsgt0excludes:EmailUrls field:"EmailUrls" value:"0"
EmailUrlsis_not_null(no value, null check)excludes:EmailUrls
UrlDomainis_not_null(no value, null check)excludes:UrlDomain
UrlStringis_not_null(no value, null check)excludes:UrlString

Output Fields

These fields are emitted when the rule matches.

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

SAP ETD - No new data received

#

This is a third-party alert feed, not a detection over modeled telemetry. The vendor product raised the finding; this rule forwards it into the SIEM. It is searchable for reference but is excluded from the detection-rule browse and the ATT&CK coverage matrix.

Status
available
Severity
high
Time window
1h
Source
github.com/Azure/Azure-Sentinel

Identifies a complete gap in the SAP Enterprise Threat Detection (ETD) feed when no records have been ingested into the SAPETDAlerts_CL table within the configured time window (default 1 hour). A full-feed blackout may indicate that an adversary is tampering with the security telemetry pipeline (for example by stopping the SAP ETD collector, disabling the data connector, or blocking network egress to Microsoft Sentinel) to hide follow-on activity in the SAP landscape. Benign causes such as a service outage, connector failure, or maintenance window are also possible and should be ruled out during triage. This rule is complementary to the per-SAP-system rule "SAP ETD - SAP system stopped reporting data", which can help distinguish a targeted silencing of a single system from a full-feed blackout. To change the freshness threshold, update the LookbackPeriod variable in the query and align queryFrequency / queryPeriod accordingly.

MITRE ATT&CK Coverage

TacticTechniques
Defense Impairment

Rule Body

id: a9206c5a-3e72-4c10-807f-313a56075b20
kind: Scheduled
name: SAP ETD - No new data received
description: |
  Identifies a complete gap in the SAP Enterprise Threat Detection (ETD) feed when no records have been ingested into the SAPETDAlerts_CL table within the configured time window (default 1 hour). A full-feed blackout may indicate that an adversary is tampering with the security telemetry pipeline (for example by stopping the SAP ETD collector, disabling the data connector, or blocking network egress to Microsoft Sentinel) to hide follow-on activity in the SAP landscape. Benign causes such as a service outage, connector failure, or maintenance window are also possible and should be ruled out during triage. This rule is complementary to the per-SAP-system rule "SAP ETD - SAP system stopped reporting data", which can help distinguish a targeted silencing of a single system from a full-feed blackout. To change the freshness threshold, update the `LookbackPeriod` variable in the query and align `queryFrequency` / `queryPeriod` accordingly.
severity: High
status: Available
requiredDataConnectors:
  - connectorId: SAPETDAlerts
    dataTypes:
      - SAPETDAlerts_CL
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - DefenseEvasion
relevantTechniques:
  - T1562
  - T1562.006
query: |
  // Configurable freshness threshold for the entire SAP ETD data feed.
  // When changing this value also update queryFrequency and queryPeriod accordingly.
  let LookbackPeriod = 1h;
  SAPETDAlerts_CL
  | summarize
      LastIngestionTime = max(TimeGenerated),
      RecordsInWindow   = countif(TimeGenerated > ago(LookbackPeriod))
  | where RecordsInWindow == 0 or isnull(LastIngestionTime)
  | extend
      LookbackPeriod = LookbackPeriod,
      TimeSinceLastIngestion = now() - coalesce(LastIngestionTime, datetime(null)),
      FeedName = "SAPETD",
      Reason = iff(isnull(LastIngestionTime),
          "No SAPETDAlerts_CL records have ever been ingested.",
          strcat("No SAPETDAlerts_CL records ingested in the last ", tostring(LookbackPeriod), " (last ingestion: ", tostring(LastIngestionTime), ")."))
eventGroupingSettings:
  aggregationKind: SingleAlert
entityMappings:
  - entityType: CloudApplication
    fieldMappings:
      - identifier: Name
        columnName: FeedName
alertDetailsOverride:
  alertDisplayNameFormat: 'SAP ETD - No new data received in the last {{LookbackPeriod}}'
  alertDescriptionFormat: |
    {{Reason}}

    A complete gap in the SAP ETD feed may indicate that an adversary is tampering with the security telemetry pipeline (for example by stopping the SAP ETD collector, disabling the data connector, or blocking network egress to Microsoft Sentinel) in order to hide malicious activity in the SAP landscape. Treat the silence as suspicious until proven otherwise: validate the integrity and runtime state of the SAP ETD data connector, the SAP ETD service, and the network path between them, and review recent change / admin activity on those components before concluding the cause is a benign outage.
customDetails:
  LookbackPeriod: LookbackPeriod
  LastIngestion: LastIngestionTime
  LastIngestionGap: TimeSinceLastIngestion
version: 1.0.0

Stages and Predicates

Parameters

let LookbackPeriod = 1h;

Stage 1: source

SAPETDAlerts_CL

Stage 2: summarize

| summarize
    LastIngestionTime = max(TimeGenerated),
    RecordsInWindow   = countif(TimeGenerated > ago(LookbackPeriod))

Stage 3: where

| where RecordsInWindow == 0 or isnull(LastIngestionTime)

Stage 4: extend

| extend
    LookbackPeriod = LookbackPeriod,
    TimeSinceLastIngestion = now() - coalesce(LastIngestionTime, datetime(null)),
    FeedName = "SAPETD",
    Reason = iff(isnull(LastIngestionTime),
        "No SAPETDAlerts_CL records have ever been ingested.",
        strcat("No SAPETDAlerts_CL records ingested in the last ", tostring(LookbackPeriod), " (last ingestion: ", tostring(LastIngestionTime), ")."))
Reason =
ifisnull(LastIngestionTime)"No SAPETDAlerts_CL records have ever been ingested."
elsestrcat("No SAPETDAlerts_CL records ingested in the last ", tostring(LookbackPeriod), " (last ingestion: ", tostring(LastIngestionTime), ").")

Indicators

These rows show field, operator, and value matches.

Output Fields

These fields are emitted when the rule matches.

FieldSource
LastIngestionTimesummarize
RecordsInWindowsummarize
FeedNameextend
LookbackPeriodextend
Reasonextend
TimeSinceLastIngestionextend

Speculus - Network traffic to or from high-risk IP indicator

#
Status
available
Severity
high
Time window
14d
Group by
$left.MatchIP, $right.IndicatorValue, IndicatorValue
Source
github.com/Azure/Azure-Sentinel

'Matches network events in CommonSecurityLog against high-risk Speculus IP indicators (risk score >= 75) received in the last 14 days. These IPs are associated with malicious activity such as command and control, botnets, ransomware infrastructure, or brute-force campaigns.'

MITRE ATT&CK Coverage

TacticTechniques
Command & Control

Rule Body

id: a7c3f9e2-4d18-4b6a-9f0c-2e5d8b1a6c43
name: Speculus - Network traffic to or from high-risk IP indicator
description: |
  'Matches network events in CommonSecurityLog against high-risk Speculus IP indicators (risk score >= 75)
  received in the last 14 days. These IPs are associated with malicious activity such as command and control,
  botnets, ransomware infrastructure, or brute-force campaigns.'
severity: High
status: Available
requiredDataConnectors:
  - connectorId: SpeculusThreatIntel
    dataTypes:
      - Speculus_Indicators_CL
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CommandAndControl
  - InitialAccess
relevantTechniques:
  - T1071
  - T1090
query: |
  let indicators = Speculus_Indicators_CL
      | where TimeGenerated > ago(14d)
      | where isnotempty(IndicatorValue)
      | where RiskScore >= 75
      | summarize arg_max(Modified, *) by IndicatorValue
      | project
          IndicatorValue,
          RiskScore,
          SpeculusActivity = Activity,
          Attribution,
          Labels,
          IndicatorDescription = Description;
  CommonSecurityLog
  | where TimeGenerated > ago(1h)
  | where isnotempty(SourceIP) or isnotempty(DestinationIP)
  | extend MatchIP = iff(isnotempty(DestinationIP), DestinationIP, SourceIP)
  | join kind=inner indicators on $left.MatchIP == $right.IndicatorValue
  | project
      TimeGenerated,
      MatchIP,
      SourceIP,
      DestinationIP,
      DeviceVendor,
      DeviceProduct,
      RiskScore,
      SpeculusActivity,
      Attribution,
      Labels,
      IndicatorDescription
suppressionDuration: 1h
suppressionEnabled: false
incidentConfiguration:
  createIncident: true
  groupingConfiguration:
    enabled: true
    reopenClosedIncident: false
    lookbackDuration: 1d
    matchingMethod: Selected
    groupByEntities:
      - IP
eventGroupingSettings:
  aggregationKind: AlertPerResult
customDetails:
  RiskScore: RiskScore
  Attribution: Attribution
  Labels: Labels
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: MatchIP
version: 1.0.0
kind: Scheduled

Stages and Predicates

Let Binding: indicators used in Stage 5

let indicators = Speculus_Indicators_CL
    | where TimeGenerated > ago(14d)
    | where isnotempty(IndicatorValue)
    | where RiskScore >= 75
    | summarize arg_max(Modified, *) by IndicatorValue
    | project
        IndicatorValue,
        RiskScore,
        SpeculusActivity = Activity,
        Attribution,
        Labels,
        IndicatorDescription = Description;

Stage 1: source

CommonSecurityLog

Stage 2: where

| where TimeGenerated > ago(1h)

Stage 3: where

| where isnotempty(SourceIP) or isnotempty(DestinationIP)

Stage 4: extend

| extend MatchIP = iff(isnotempty(DestinationIP), DestinationIP, SourceIP)
MatchIP =
ifisnotempty(DestinationIP)DestinationIP
elseSourceIP

Stage 5: join

| join kind=inner indicators on $left.MatchIP == $right.IndicatorValue

Stage 6: project

project Attribution, DestinationIP, DeviceProduct, DeviceVendor, IndicatorDescription, Labels, MatchIP, RiskScore, SourceIP, SpeculusActivity, TimeGenerated

Stage 7: summarize aggregation inside the join branch

summarize by IndicatorValue

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
DestinationIPis_not_null
  • (no value, null check)
field:"DestinationIP" kind:is_not_null
IndicatorValueis_not_null
  • (no value, null check)
field:"IndicatorValue" kind:is_not_null
RiskScorege
  • 75 transforms: number
field:"RiskScore" kind:ge value:"75"
SourceIPis_not_null
  • (no value, null check)
field:"SourceIP" kind:is_not_null

Output Fields

These fields are emitted when the rule matches.

FieldSource
IndicatorValuesummarize

Tailscale: OAuth client or API key created with write scopes

#
Status
available
Severity
high
Time window
15m
Source
github.com/Azure/Azure-Sentinel

Identifies creation of a Tailscale OAuth client or API access key whose granted scopes include WRITE permissions (anything matching :write). Tokens with write scopes are high-value adversary targets.

MITRE ATT&CK Coverage

Rule Body

id: 7237a848-30f2-499b-9ad5-024aea1288bd
name: "Tailscale: OAuth client or API key created with write scopes"
description: Identifies creation of a Tailscale OAuth client or API access key whose granted scopes include WRITE permissions (anything matching :write). Tokens with write scopes are high-value adversary targets.
description-detailed: |
  Detects creation of a Tailscale OAuth client or API access key whose granted scopes include WRITE permissions (anything matching ":write"). Tokens with write scopes can modify tailnet configuration, manage devices, write ACLs, and revoke keys - high-value adversary targets. Compare against the recent actor history; revoke immediately if unexpected.
severity: High
status: Available
requiredDataConnectors:
  - connectorId: TailscaleCCF
    dataTypes:
      - Tailscale_Audit_CL
  - connectorId: TailscalePremiumCCF
    dataTypes:
      - Tailscale_Audit_CL
queryFrequency: 15m
queryPeriod: 15m
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098
  - T1136
query: |
  Tailscale_Audit_CL
  | where EventType == "CONFIG"
  | where Action == "CREATE"
  | where tostring(Target.type) in ("API_KEY", "OAUTH_CLIENT")
  | extend Scopes = extract(@"scopes\s*-\s*(.+)$", 1, ActionDetails)
  | where Scopes contains ":write"
  | extend WriteScopes = extract_all(@"([a-zA-Z_]+:write)", Scopes)
  | extend ActorLogin = tostring(Actor.loginName)
  | extend ActorType = tostring(Actor.type)
  | extend TargetName = tostring(Target.name)
  | extend TargetId = tostring(Target.id)
  | extend TargetType = tostring(Target.type)
  | project TimeGenerated, ActorLogin, ActorType, Action, TargetType, TargetName, TargetId, WriteScopes, Scopes, Origin, ActionDetails
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: ActorLogin
incidentConfiguration:
  createIncident: true
  groupingConfiguration:
    enabled: true
    reopenClosedIncident: false
    lookbackDuration: PT5H
    matchingMethod: AllEntities
kind: Scheduled
version: 1.0.0

Stages and Predicates

Stage 1: source

Tailscale_Audit_CL

Stage 2: where

| where EventType == "CONFIG"

Stage 3: where

| where Action == "CREATE"

Stage 4: where

| where tostring(Target.type) in ("API_KEY", "OAUTH_CLIENT")

Stage 5: extend

| extend Scopes = extract(@"scopes\s*-\s*(.+)$", 1, ActionDetails)

Stage 6: where

| where Scopes contains ":write"

Stage 7: extend (6 consecutive steps)

| extend WriteScopes = extract_all(@"([a-zA-Z_]+:write)", Scopes)
| extend ActorLogin = tostring(Actor.loginName)
| extend ActorType = tostring(Actor.type)
| extend TargetName = tostring(Target.name)
| extend TargetId = tostring(Target.id)
| extend TargetType = tostring(Target.type)

Stage 8: project

| project TimeGenerated, ActorLogin, ActorType, Action, TargetType, TargetName, TargetId, WriteScopes, Scopes, Origin, ActionDetails

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Actioneq
  • CREATE
field:"Action" kind:eq value:"CREATE"
EventTypeeq
  • CONFIG
field:"EventType" kind:eq value:"CONFIG"
Scopescontains
  • :write
field:"Scopes" kind:contains value:":write"
Target.typein
  • API_KEY transforms: tostring, kusto_dynamic_path
  • OAUTH_CLIENT transforms: tostring, kusto_dynamic_path
field:"Target.type" kind:in

Output Fields

These fields are emitted when the rule matches.

FieldSource
Actionproject
ActionDetailsproject
ActorLoginproject
ActorTypeproject
Originproject
Scopesproject
TargetIdproject
TargetNameproject
TargetTypeproject
TimeGeneratedproject
WriteScopesproject

Tenable.ad Password Spraying

#
Severity
high
Time window
2h
Source
github.com/Azure/Azure-Sentinel

'Searches for Password spraying attacks.'

MITRE ATT&CK Coverage

TacticTechniques
Credential Access

Rule Body

id: 29d350db-0ac0-4f4c-92ff-dac0f6335612
name: Tenable.ad Password Spraying
description: |
    'Searches for Password spraying attacks.'
severity: High
requiredDataConnectors:
  - connectorId: Tenable.ad
    dataTypes:
      - Tenable_ad_CL
queryFrequency: 2h
queryPeriod: 2h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110.003
query: |
  // For the query to work properly, make sure you have imported the afad_parser.kql parser into the workspace
  // Retrieve the parser here: https://raw.githubusercontent.com/tenable/Azure-Sentinel/Tenable.ad-connector/Solutions/TenableAD/Parsers/afad_parser.kql
  // Then, create the Kusto Function with alias afad_parser
  afad_parser
    | where MessageType == 2 and Codename == "Password Spraying"
    | extend HostName = tostring(split(Host, '.', 0)[0]), DnsDomain = tostring(strcat_array(array_slice(split(Host, '.'), 1, -1), '.'))
entityMappings:
  - entityType: Host
    fieldMappings:
      - identifier: HostName
        columnName: HostName
      - identifier: DnsDomain
        columnName: DnsDomain
version: 1.0.1
kind: Scheduled

Stages and Predicates

Stage 1: source

afad_parser

Stage 2: where

| where MessageType == 2 and Codename == "Password Spraying"

Stage 3: extend

| extend HostName = tostring(split(Host, '.', 0)[0]), DnsDomain = tostring(strcat_array(array_slice(split(Host, '.'), 1, -1), '.'))

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Codenameeq
  • Password Spraying corpus 3 (kusto 3)
field:"Codename" kind:eq value:"Password Spraying"
MessageTypeeq
  • 2 corpus 21 (kusto 21)
field:"MessageType" kind:eq value:"2"

Output Fields

These fields are emitted when the rule matches.

FieldSource
DnsDomainextend
HostNameextend

Third party integrated apps

#
Status
available
Severity
high
Time window
6h
Source
github.com/Azure/Azure-Sentinel

'This query searches for your services by regulating the access of third-party integrated apps. Only allow access to necessary apps that support robust security controls. Third-party applications are not created by Microsoft, so there is a possibility they could be used for malicious purposes like exfiltrating data from your tenancy. Attackers can maintain persistent access to your services through these integrated apps, without relying on compromised accounts.'

MITRE ATT&CK Coverage

TacticTechniques
Exfiltration

Rule Body

id: BFA7EE22-B5A9-42C8-BD50-2E95885640BB
name: Third party integrated apps
description: |
  'This query searches for your services by regulating the access of third-party integrated apps. 
   Only allow access to necessary apps that support robust security controls. 
   Third-party applications are not created by Microsoft, so there is a possibility they could be used for malicious purposes like exfiltrating data from your tenancy.
   Attackers can maintain persistent access to your services through these integrated apps, without relying on compromised accounts.'
severity: High
status: Available
requiredDataConnectors:
  - connectorId: SenservaPro
    dataTypes:
      - SenservaPro_CL
queryFrequency: 6h
queryPeriod: 6h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Exfiltration
relevantTechniques:
  - T1020
query: |
  SenservaPro_CL
  | where ControlName_s == 'AzureSecureScoreIntegratedApps'
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: ControlName_s
      - identifier: AadTenantId
        columnName: TenantId
      - identifier: DisplayName
        columnName: TenantDisplayName_s 
  - entityType: SecurityGroup
    fieldMappings:
      - identifier: DistinguishedName
        columnName: Group_s
  - entityType: AzureResource
    fieldMappings:
      - identifier: ResourceId
        columnName: SourceSystem 
version: 1.0.1
kind: Scheduled

Stages and Predicates

Stage 1: source

SenservaPro_CL

Stage 2: where

| where ControlName_s == 'AzureSecureScoreIntegratedApps'

Indicators

These rows show field, operator, and value matches.

TI map Domain entity to Web Session Events (ASIM Web Session schema)

#
Severity
medium
Time window
14d
Group by
$left.DomainName, $right.domain, IndicatorId, domain
Source
github.com/Azure/Azure-Sentinel

'This rule identifies Web Sessions for which the target URL hostname is a known IoC. This rule uses the Advanced Security Information Model (ASIM) and supports any web session source that complies with ASIM.'

MITRE ATT&CK Coverage

TacticTechniques
Command & Control

Rule Body

id: b1832f60-6c3d-4722-a0a5-3d564ee61a63
name: TI map Domain entity to Web Session Events (ASIM Web Session schema)
description: |
  'This rule identifies Web Sessions for which the target URL hostname is a known IoC. This rule uses the [Advanced Security Information Model (ASIM)](https:/aka.ms/AboutASIM) and supports any web session source that complies with ASIM.'
severity: Medium
requiredDataConnectors:
  - connectorId: SquidProxy
    dataTypes:
      - SquidProxy_CL
  - connectorId: Zscaler
    dataTypes:
      - CommonSecurityLog
  - connectorId: ThreatIntelligence
    dataTypes:
      - ThreatIntelligenceIndicator
  - connectorId: ThreatIntelligenceTaxii
    dataTypes:
      - ThreatIntelligenceIndicator
  - connectorId: MicrosoftDefenderThreatIntelligence
    dataTypes:
      - ThreatIntelligenceIndicator
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CommandAndControl
relevantTechniques:
  - T1071
query: |
    let HAS_ANY_MAX = 10000;
    let dt_lookBack = 1h;
    let ioc_lookBack = 14d;
    //Create a list of TLDs in our threat feed for later validation
    let DOMAIN_TI=ThreatIntelligenceIndicator
    // Picking up only IOC's that contain the entities we want
    | where isnotempty(DomainName)
    | where TimeGenerated >= ago(ioc_lookBack)
    | summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by IndicatorId
    | where Active == true and ExpirationDateTime > now();
    let DOMAIN_TI_list= todynamic(toscalar(DOMAIN_TI | summarize NIoCs = dcount(DomainName), Domains = make_set(DomainName) 
      | project Domains=iff(NIoCs > HAS_ANY_MAX, dynamic([]), Domains) ));
    DOMAIN_TI
    // using innerunique to keep perf fast and result set low, we only need one match to indicate potential malicious activity that needs to be investigated
    | join kind=innerunique (
        _Im_WebSession(starttime=ago(dt_lookBack), url_has_any= DOMAIN_TI_list )
        //Extract domain patterns from syslog message
        | extend domain = tostring(parse_url(Url)["Host"])
        | where isnotempty(domain)
        | extend tld = tostring(split(domain, '.')[-1])
        | extend Event_TimeGenerated = TimeGenerated
    ) on $left.DomainName==$right.domain
    | where Event_TimeGenerated < ExpirationDateTime
    | summarize Event_TimeGenerated  = arg_max(Event_TimeGenerated , *) by IndicatorId, domain
    | project Event_TimeGenerated, Description, ActivityGroupNames, IndicatorId, ThreatType, ExpirationDateTime, ConfidenceScore, domain, SrcIpAddr, Url   
entityMappings:
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: SrcIpAddr
  - entityType: URL
    fieldMappings:
      - identifier: Url
        columnName: Url
customDetails:
  EventTime: Event_TimeGenerated
  IoCDescription: Description
  ActivityGroupNames: ActivityGroupNames
  IndicatorId: IndicatorId
  ThreatType: ThreatType
  IoCExpirationTime: ExpirationDateTime
  IoCConfidenceScore: ConfidenceScore
alertDetailsOverride:
  alertDisplayNameFormat: A web request from {{SrcIpAddr}} to hostname  {{domain}} matched an IoC
  alertDescriptionFormat: A client with address {{SrcIpAddr}} requested the URL {{Url}}, whose hostname is a known indicator of compromise of {{ThreatType}}. Consult the threat intelligence blade for more information on the indicator.
version: 1.0.6
kind: Scheduled

Stages and Predicates

Parameters

let HAS_ANY_MAX = 10000;
let dt_lookBack = 1h;
let ioc_lookBack = 14d;

let DOMAIN_TI is inlined into the numbered stages below.

Let Binding: DOMAIN_TI_list used in Stage 6

let DOMAIN_TI_list = todynamic(toscalar(DOMAIN_TI | summarize NIoCs = dcount(DomainName), Domains = make_set(DomainName) 
  | project Domains=iff(NIoCs > HAS_ANY_MAX, dynamic([]), Domains) ));

Stages 1 to 5 define let DOMAIN_TI as the rule's main pipeline source. Stages 6 to 9 use it.

Stage 1: source

ThreatIntelligenceIndicator

Stage 2: where

| where isnotempty(DomainName)

Stage 3: where

| where TimeGenerated >= ago(ioc_lookBack)

Stage 4: summarize

| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by IndicatorId

Stage 5: where

| where Active == true and ExpirationDateTime > now()

Stage 6: join

DOMAIN_TI
| join kind=innerunique (
    _Im_WebSession(starttime=ago(dt_lookBack), url_has_any= DOMAIN_TI_list )
    | extend domain = tostring(parse_url(Url)["Host"])
    | where isnotempty(domain)
    | extend tld = tostring(split(domain, '.')[-1])
    | extend Event_TimeGenerated = TimeGenerated
) on $left.DomainName==$right.domain

Stage 7: where

where Event_TimeGenerated < ExpirationDateTime

Stage 8: summarize

summarize Event_TimeGenerated by IndicatorId, domain

Stage 9: project

project ActivityGroupNames, ConfidenceScore, Description, Event_TimeGenerated, ExpirationDateTime, IndicatorId, SrcIpAddr, ThreatType, Url, domain

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Activeeq
  • true
field:"Active" kind:eq value:"true"
DomainNameis_not_null
  • (no value, null check)
field:"DomainName" kind:is_not_null
domainis_not_null
  • (no value, null check)
field:"domain" kind:is_not_null

Output Fields

These fields are emitted when the rule matches.

FieldSource
ActivityGroupNamesproject
ConfidenceScoreproject
Descriptionproject
Event_TimeGeneratedproject
ExpirationDateTimeproject
IndicatorIdproject
SrcIpAddrproject
ThreatTypeproject
Urlproject
domainproject

TIE Password Guessing

#
Severity
high
Time window
2h
Source
github.com/Azure/Azure-Sentinel

'Searches for bruteforce Password Guessing attacks.'

MITRE ATT&CK Coverage

TacticTechniques
Credential Access

Rule Body

id: d1416c25-5a56-4a88-8d7c-568e6551a307
name: TIE Password Guessing
description: |
  'Searches for bruteforce Password Guessing attacks.'
severity: High
requiredDataConnectors:
  - connectorId: TenableIE
    dataTypes:
      - Tenable_IE_CL
queryFrequency: 2h
queryPeriod: 2h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110
query: |
  // For the query to work properly, make sure you have imported the afad_parser.yaml parser into the workspace
  // Retrieve the parser here: https://aka.ms/sentinel-TenableApp-afad-parser
  // Then, create the Kusto Function with alias afad_parser
  afad_parser
    | where MessageType == 2 and Codename == "Password Guessing"
    | extend HostName = tostring(split(Host, '.', 0)[0]), DnsDomain = tostring(strcat_array(array_slice(split(Host, '.'), 1, -1), '.'))
entityMappings:
  - entityType: Host
    fieldMappings:
      - identifier: HostName
        columnName: HostName
      - identifier: DnsDomain
        columnName: DnsDomain
version: 1.0.1
kind: Scheduled

Stages and Predicates

Stage 1: source

afad_parser

Stage 2: where

| where MessageType == 2 and Codename == "Password Guessing"

Stage 3: extend

| extend HostName = tostring(split(Host, '.', 0)[0]), DnsDomain = tostring(strcat_array(array_slice(split(Host, '.'), 1, -1), '.'))

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
Codenameeq
  • Password Guessing corpus 3 (kusto 3)
field:"Codename" kind:eq value:"Password Guessing"
MessageTypeeq
  • 2 corpus 21 (kusto 21)
field:"MessageType" kind:eq value:"2"

Output Fields

These fields are emitted when the rule matches.

FieldSource
DnsDomainextend
HostNameextend

Votiro - File Blocked from Connector

#
Severity
low
Time window
10m
Group by
FileWithConnectorDetails, LogSeverity, SrcFileSHA256, fileName, policyName, sanitizationResult
Source
github.com/Azure/Azure-Sentinel

'The analytic rule is intended to detect when a file is blocked by Votiro Sanitization Engine due to a specific policy, and notify the appropriate parties so that they can take appropriate action. The alert message will state that a file with a specific name and hash value was blocked by Votiro Sanatization Engine due to a specific policy name, and that more details can be found at a specific incident URL.'

MITRE ATT&CK Coverage

Rule Body

id: 17bf3780-ae0d-4cd9-a884-5df8b687f3f5
name: Votiro - File Blocked from Connector
description: |
 'The analytic rule is intended to detect when a file is blocked by Votiro Sanitization Engine due to a specific policy, and notify the appropriate parties so that they can take appropriate action. The alert message will state that a file with a specific name and hash value was blocked by Votiro Sanatization Engine due to a specific policy name, and that more details can be found at a specific incident URL.'
severity: Low
requiredDataConnectors:
  - connectorId: Votiro
    dataTypes:
      - CommonSecurityLog 
  - connectorId: CefAma
    dataTypes:
      - CommonSecurityLog
queryFrequency: 10m
queryPeriod: 10m
triggerOperator: gt
triggerThreshold: 0
tactics:
  - DefenseEvasion
  - Discovery
  - Impact
relevantTechniques:
  - T1036
  - T1083
  - T1057
  - T1082
  - T1565
  - T1498
  - T0837
query: let Votiro_view  = view () {
  VotiroEvents
  | where sanitizationResult has "Blocked" and passwordProtected == "false" and from =~ "null"
  | extend FileWithConnectorDetails = strcat_delim(' ', fileName, 'with the hash', SrcFileSHA256, 'that was sent from connector', connectorName)
  | summarize count() by fileName, SrcFileSHA256, FileWithConnectorDetails, policyName, tostring(incidentURL), sanitizationResult, LogSeverity
  | extend FileHashAlgo = "SHA256", FileHashValue = SrcFileSHA256};Votiro_view
entityMappings:
- entityType: FileHash
  fieldMappings:
    - identifier: Algorithm
      columnName: FileHashAlgo
    - identifier: Value
      columnName: FileHashValue
incidentConfiguration:
  createIncident: true
eventGroupingSettings:
  aggregationKind: AlertPerResult
alertDetailsOverride: 
   alertDisplayNameFormat: File with hash {{SrcFileSHA256}} was blocked
   alertDescriptionFormat: The {{FileWithConnectorDetails}} was blocked by Votiro due to Policy rules, see more detail in the following link {{incidentURL}}
   alertTacticsColumnName: sanitizationResult
   alertSeverityColumnName: LogSeverity
version: 1.0.1
kind: Scheduled

Stages and Predicates

Let Binding: Votiro_view

let Votiro_view = view () { VotiroEvents | where sanitizationResult has "Blocked" and passwordProtected == "false" and from =~ "null" | extend FileWithConnectorDetails = strcat_delim(' ', fileName, 'with the hash', SrcFileSHA256, 'that was sent from connector', connectorName) | summarize count() by fileName, SrcFileSHA256, FileWithConnectorDetails, policyName, tostring(incidentURL), sanitizationResult, LogSeverity | extend FileHashAlgo = "SHA256", FileHashValue = SrcFileSHA256};

Stage 1: source

VotiroEvents

Stage 2: where

where from =~ "null" and passwordProtected == false and sanitizationResult has "Blocked"

Stage 3: extend

extend FileWithConnectorDetails

Stage 4: summarize

summarize by fileName, SrcFileSHA256, FileWithConnectorDetails, policyName, sanitizationResult, LogSeverity

Stage 5: extend

extend FileHashAlgo, FileHashValue

Indicators

These rows show field, operator, and value matches.

Output Fields

These fields are emitted when the rule matches.

FieldSource
FileWithConnectorDetailssummarize
LogSeveritysummarize
SrcFileSHA256summarize
fileNamesummarize
policyNamesummarize
sanitizationResultsummarize
FileHashAlgoextend
FileHashValueextend

Zscaler - Unexpected ZPA session duration

#
Status
available
Severity
medium
Time window
1d
Group by
ConnectionID, DstUserName, SrcIpAddr
Source
github.com/Azure/Azure-Sentinel

'Detects Unexpected ZPA session duration.'

MITRE ATT&CK Coverage

Rule Body

id: e07846e0-43ad-11ec-81d3-0242ac130003
name: Zscaler - Unexpected ZPA session duration
description: |
  'Detects Unexpected ZPA session duration.'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: CustomLogsAma
    datatypes:
      - ZPA_CL
queryFrequency: 1h
queryPeriod: 24h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
relevantTechniques:
  - T1078
  - T1133
query: |
  let dt_lookBack = 24h;
  let time_treshhold = timespan(08:00:00);
  let open_sessions =
  ZPAEvent
  | where TimeGenerated > ago(dt_lookBack)
  | where DvcAction == 'open'
  | summarize timestampstart = min(TimeGenerated) by DstUserName, SrcIpAddr, ConnectionID
  | sort by timestampstart asc;
  let closed_sessions =
  ZPAEvent
  | where TimeGenerated > ago(dt_lookBack)
  | where DvcAction == 'close'
  | summarize timestampend = max(TimeGenerated) by DstUserName, SrcIpAddr, ConnectionID
  | sort by timestampend asc;
  open_sessions
  | join (closed_sessions) on DstUserName, SrcIpAddr, ConnectionID
  | extend duration = timestampend - timestampstart
  | where duration > time_treshhold
  | where DstUserName !has "ZPA LSS"
  | sort by timestampstart asc
  | extend IPCustomEntity = SrcIpAddr, AccountCustomEntity = DstUserName
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountCustomEntity
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPCustomEntity
version: 1.0.3
kind: Scheduled

Stages and Predicates

Parameters

let dt_lookBack = 24h;
let time_treshhold = timespan(08:00:00);

let open_sessions is inlined into the numbered stages below.

Let Binding: closed_sessions used in Stage 6

let closed_sessions = ZPAEvent
| where TimeGenerated > ago(dt_lookBack)
| where DvcAction == 'close'
| summarize timestampend = max(TimeGenerated) by DstUserName, SrcIpAddr, ConnectionID
| sort by timestampend asc;

Stages 1 to 5 define let open_sessions as the rule's main pipeline source. Stages 6 to 11 use it.

Stage 1: source

ZPAEvent

Stage 2: where

| where TimeGenerated > ago(dt_lookBack)

Stage 3: where

| where DvcAction == 'open'

Stage 4: summarize

| summarize timestampstart = min(TimeGenerated) by DstUserName, SrcIpAddr, ConnectionID

Stage 5: sort

| sort by timestampstart asc

Stage 6: join

open_sessions
| join (closed_sessions) on DstUserName, SrcIpAddr, ConnectionID

Stage 7: extend

extend duration

Stage 8: where

where duration > time_treshhold

Stage 9: where

where not (DstUserName has "ZPA LSS")

Stage 10: sort

sort by timestampstart

Stage 11: extend

extend AccountCustomEntity, IPCustomEntity

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
DvcActioneq
  • close
  • open
field:"DvcAction" kind:eq

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
DstUserNamematchZPA LSSexcludes:DstUserName field:"DstUserName" value:"ZPA LSS"

Output Fields

These fields are emitted when the rule matches.

FieldSource
ConnectionIDsummarize
DstUserNamesummarize
SrcIpAddrsummarize
timestampstartsummarize
durationextend
AccountCustomEntityextend
IPCustomEntityextend

Back to all Kusto rules