Detection rules › Kusto

Unusual Volume of file deletion by users

Status
available
Severity
high
Time window
5m
Group by
AccountDisplayName, ActionType, ApplicationId, Time, UserId
Source
github.com/Azure/Azure-Sentinel

This query looks for users performing file deletion activities. Spikes in file deletion observed from risky sign-in sessions are flagged here. This applies to SharePoint and OneDrive users. Audit event and Cloud application identifier references. Reference - https://learn.microsoft.com/microsoft-365/compliance/audit-log-activities?view=o365-worldwide Reference - https://learn.microsoft.com/azure/sentinel/entities-reference#cloud-application-identifiers

MITRE ATT&CK coverage

TacticTechniques
ImpactT1485 Data Destruction

Rule body kusto

id: e5f8e196-3544-4a8b-96a9-17c1b6a49710
name: Unusual Volume of file deletion by users
description: |
  This query looks for users performing file deletion activities. Spikes in file deletion observed from risky sign-in sessions are flagged here.
  This applies to SharePoint and OneDrive users.
  Audit event and Cloud application identifier references.
  Reference - https://learn.microsoft.com/microsoft-365/compliance/audit-log-activities?view=o365-worldwide
  Reference - https://learn.microsoft.com/azure/sentinel/entities-reference#cloud-application-identifiers
severity: High
status: Available
requiredDataConnectors:
  - connectorId: MicrosoftThreatProtection
    dataTypes:
      - CloudAppEvents
      - AADSignInEventsBeta
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Impact
relevantTechniques:
  - T1485
query: |
  let relevantOperations = pack_array("FileDeleted", "FileRecycled", "FileDeletedFirstStageRecycleBin", "FileDeletedSecondStageRecycleBin", "FileVersionsAllMinorsRecycled", "FileVersionRecycled", "FileVersionsAllRecycled");
  let relevantAppIds = pack_array(int(20892), int(15600)); // App Ids for SharePoint and OneDrive
  let timeWindow = 7d;
  let timeNow = now();
  //
  let riskyUsers= // Look for users with risky sign-ins
    SigninLogs    
    | where CreatedDateTime between ((timeNow - timeWindow) .. (timeNow))
    | where isnotempty(UserId) and isnotempty(OriginalRequestId)
    | where ResultType == '0'
    | where RiskLevelDuringSignIn == 'high'
    | project RiskLevelDuringSignIn, UserId, CreatedDateTime, SessionId=OriginalRequestId
    ;
  let hasUsers = isnotempty(toscalar(riskyUsers));
  //
  let deleteEvents = // look for file deletion activity and scope it to risky users
    CloudAppEvents
    | where hasUsers
    | where TimeGenerated between ((timeNow - timeWindow) .. (timeNow))
    | where ApplicationId in (relevantAppIds)
    | where isnotempty(AccountObjectId)
    | where AccountObjectId in (riskyUsers)
    | where ActionType in (relevantOperations)
    | extend SessionId= tostring(RawEventData.AppAccessContext.AADSessionId)
    | where isnotempty(SessionId)
    | project UserId=AccountObjectId, AccountDisplayName, ApplicationId, SessionId, ActionType, TimeGenerated, ReportId
    ;   
   //
  deleteEvents  
  | join kind=leftsemi riskyUsers on UserId, SessionId
  | summarize Count=count() , (Timestamp, ReportId)=arg_min(TimeGenerated, ReportId) by UserId, AccountDisplayName, ApplicationId, ActionType, Time=bin(TimeGenerated, 5m)
  // look for only those scoped users who have generated an increase in file deletion activity.
  | summarize TotalCount= countif(Count > 50), (Timestamp, ReportId)=arg_min(Timestamp, ReportId) by UserId, AccountDisplayName, ApplicationId 
  | where TotalCount >= 3
  | project UserId, AccountDisplayName, ApplicationId, TotalCount, ReportId, Timestamp
  | extend NTDomain = tostring(split(AccountDisplayName,'\\',0)[0]), Name = tostring(split(AccountDisplayName,'\\',1)[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: UserId
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: Name
      - identifier: NTDomain
        columnName: NTDomain
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: ApplicationId
customDetails:
  Count: TotalCount
version: 1.0.1
kind: Scheduled

Stages and Predicates

Parameters

let relevantAppIds = pack_array(int(20892), int(15600));
let timeWindow = 7d;
let timeNow = now();
let hasUsers = isnotempty(toscalar(riskyUsers));

Let binding: relevantOperations

let relevantOperations = pack_array("FileDeleted", "FileRecycled", "FileDeletedFirstStageRecycleBin", "FileDeletedSecondStageRecycleBin", "FileVersionsAllMinorsRecycled", "FileVersionRecycled", "FileVersionsAllRecycled");

Let binding: riskyUsers

let riskyUsers = SigninLogs    
  | where CreatedDateTime between ((timeNow - timeWindow) .. (timeNow))
  | where isnotempty(UserId) and isnotempty(OriginalRequestId)
  | where ResultType == '0'
  | where RiskLevelDuringSignIn == 'high'
  | project RiskLevelDuringSignIn, UserId, CreatedDateTime, SessionId=OriginalRequestId;

Derived from timeWindow, timeNow.

The stages below define let deleteEvents (the rule's main pipeline source).

Stage 1: source

CloudAppEvents

Stage 2: where

| where hasUsers

Stage 3: where

| where TimeGenerated between ((timeNow - timeWindow) .. (timeNow))

Stage 4: where

| where ApplicationId in (relevantAppIds)

Stage 5: where

| where isnotempty(AccountObjectId)

Stage 6: where

| where AccountObjectId in (riskyUsers)

References riskyUsers (defined above).

Stage 7: where

| where ActionType in (relevantOperations)

References relevantOperations (defined above).

Stage 8: extend

| extend SessionId= tostring(RawEventData.AppAccessContext.AADSessionId)

Stage 9: where

| where isnotempty(SessionId)

Stage 10: project

| project UserId=AccountObjectId, AccountDisplayName, ApplicationId, SessionId, ActionType, TimeGenerated, ReportId

The stages below run on deleteEvents (the outer pipeline).

Stage 11: join

deleteEvents
| join kind=leftsemi riskyUsers on UserId, SessionId

Stage 12: summarize

| summarize Count=count() , (Timestamp, ReportId)=arg_min(TimeGenerated, ReportId) by UserId, AccountDisplayName, ApplicationId, ActionType, Time=bin(TimeGenerated, 5m)

Stage 13: summarize

| summarize TotalCount= countif(Count > 50), (Timestamp, ReportId)=arg_min(Timestamp, ReportId) by UserId, AccountDisplayName, ApplicationId
Threshold
ge 3

Stage 14: where

| where TotalCount >= 3

Stage 15: project

| project UserId, AccountDisplayName, ApplicationId, TotalCount, ReportId, Timestamp

Stage 16: extend

| extend NTDomain = tostring(split(AccountDisplayName,'\\',0)[0]), Name = tostring(split(AccountDisplayName,'\\',1)[0])

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
AccountObjectIdin
  • riskyUsers transforms: cased
AccountObjectIdis_not_null
  • (no value, null check)
ActionTypein
  • relevantOperations transforms: cased
ApplicationIdin
  • relevantAppIds transforms: cased
OriginalRequestIdis_not_null
  • (no value, null check)
ResultTypeeq
  • 0 transforms: cased corpus 19 (kusto 17, sigma 2)
RiskLevelDuringSignIneq
  • high transforms: cased
SessionIdis_not_null
  • (no value, null check)
TotalCountge
  • 3 transforms: cased
UserIdis_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
AccountDisplayNameproject
ApplicationIdproject
ReportIdproject
Timestampproject
TotalCountproject
UserIdproject
NTDomainextend
Nameextend