Fixing the "Mass Cloud resource deletions Time Series Anomaly" Analytic Rule
Alright class.
Today we are looking at Mass Cloud resource deletions Time Series Anomaly, a content hub rule that promises to catch an adversary tearing down your Azure estate. The recipe reads well enough: build fourteen days of per caller deletion counts, run series_decompose_anomalies over daily bins, and alert when yesterday's total spikes past 25 events. Time series decomposition is a good tool. The problems sit in what this rule feeds it and what it does with the answer.
It counts rows, not deletions
Here is the entire event selection:
| where OperationNameValue endswith "delete"
| project TimeGenerated, Caller
There is no status filter anywhere in this rule. AzureActivity writes a separate record for each stage of an operation: one at start, another on accept, another on completion. Delete ten virtual machines and you produce twenty to thirty rows. The threshold of 25 reads like twenty-five deletions. In practice it is closer to ten.

Failed deletions count as well: someone is denied by RBAC fifteen times crosses the threshold without removing a single resource, and the join at the bottom happily lists _ResourceId values for resources that still exist. Ends with delete also pulls in Microsoft.Resources/deployments/delete, which removes deployment history objects. Metadata. The headline number mixes started, succeeded, failed, duplicated and irrelevant records; you cannot read it. The rebuild filters to successful operations and counts distinct correlation IDs, so one deletion is one deletion.
One event can be the whole estate
The threshold measures API calls, not blast radius.
let TotalEventsThreshold = 25;
Microsoft.Resources/subscriptions/resourceGroups/delete is one operation. It takes a resource group with three hundred resources and removes all of them, writing one Success row on the way out. Deleting four resource groups, an entire environment, produces eight rows. A third of the threshold. Silent. And Microsoft.Subscription/cancel/action does not even end with the word delete, so cancelling the whole subscription never enters the count at all.
Then the calendar:
| make-series Total = count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by Caller
The series ends at midnight, so today does not exist in it. A deletion spree at 09:00 gets its anomaly flag somewhere between fifteen and thirty-nine hours later. For data destruction that is a post-mortem. The rule does not detect the takedown. It confirms one happened.
What destruction actually looks like
A per caller daily count is an audit trend wearing a detection costume; it can say that habits changed, and the details it throws away are where intent lives.
Destruction has a shape. The recovery path goes first: Recovery Services vaults, backup items, backup policies, restore point collections. An attacker who wants the damage to stick removes the way back before touching production. The guards go second, and Microsoft.Authorization/locks/delete followed by resource deletions is as clear a statement of intent as Azure telemetry gets. Then scope, because resource group deletions and subscription cancellation destroy in units of environments. Underneath it all sits identity: either the volume is far above anything this caller has ever done, or the caller has never deleted anything before. Each is measurable against the caller's own history, and each becomes a weighted signal.
The benign twins
Every signal above has an operational twin, and the weights only mean something once the twins are named.
CI teardown pipelines delete in bulk, whole resource groups included, and their protection is their own history. The baseline keeps each caller's maximum hourly deletion and scope deletion rates over fourteen days, taken from before the detection window so scored events are never part of their own baseline. A pipeline that removes four resource groups every night has a scope precedent of four, and tonight's four does not clear three times that.
Snapshot rotation churns deletes constantly and is deliberately not excluded by resource type; the steady rate is absorbed by the baseline, and a blacklist of resource types is a published shopping list for the attacker.
A new IaC identity running its first big teardown fires once, because no history plus scope deletions clears the gate. That is the price: one review per identity on its first mass deletion, after which its own history vouches for it. Known identities go in ExcludedCallers up front.
Planned decommissions fire, and they should; a decommission and an attack are the same telemetry, and the change record is the difference. Lock governance stays silent, because the lock signal requires deletions alongside the removal.
The Rebuild
The gate is the series standard:
| where RiskScore >= FireThreshold
The weights: backup and recovery deletions carry 4, no deletion history 3, lock removal with deletions 3, mass scope deletion above the caller's own precedent 3, volume spike 2, any scope level deletion 2, spread across three or more resource groups 1. The threshold is 5.
Only one shape clears the gate on its own: mass scope deletion, because it always brings the plain scope signal with it, 3 plus 2. Several resource groups or a subscription going down in one hour, above everything the caller has done in a fortnight, is exactly the alert this rule exists to raise. Everything else needs a pair. A vault decommission by the backup administrator stays silent at 4. The same vault deletions plus lock removal, a volume spike, or an identity with no history pages someone, and it should.
Degradation is silence, not noise. Without UEBA the IdentityInfo join returns empty enrichment columns and the query survives the missing table through union isfuzzy. If the workspace holds less than a fortnight of AzureActivity, a guard disables the no history signal; no history from a table that started filling last Tuesday is not evidence of anything.
The RiskIndicators Field
One string per alert, readable before the first click:
Backup and recovery deletions: 6 | Resource locks removed alongside deletions: 2 | Volume spike: 41 deletions vs hourly maximum of 4
The combination to chase is recovery deletions together with lock removal. That pairing is ransomware staging until proven otherwise: containment first, change ticket search second.
The Blind Spots You Should Know About
AzureActivity is control plane only. Dropping every table in a SQL database, purging storage blobs, emptying a Key Vault: none of it produces a single row here. Data plane destruction is a different rule.
A single resource group deletion by an established caller stays silent; at the event level it is indistinguishable from Tuesday's dev cleanup. That risk belongs to CanNotDelete locks and change control, and if the lock is removed first, this rule sees it.
Deletion drip stays under the floors, and a patient attacker can pre-seed the baseline by deleting trivia daily for two weeks so the spike compares against a manufactured maximum. The recovery signal still stands in the way, but any personal baseline can be gamed with fourteen days of patience.
A lone subscription cancellation scores 2 and stays silent; subscription lifecycle deserves its own rule, and it is on the series list.
MITRE Mappings for the Updated Rule
T1485, Data Destruction. The core axis: deletion of cloud resources, with intent measured through scoring rather than a raw count.
T1490, Inhibit System Recovery. Backup, vault and restore point deletions inhibit recovery directly, which is why they carry the heaviest weight.
Tactic: Impact (yeah just this)
Rule Settings
- Frequency: 1 hour
- Query period: 14 days, the lookups only resolve if the rule looks back that far
- Severity: High
- Alert grouping: alert per result (one per caller)
- Incident grouping: enabled, by Account entity, 6 hour window
- Entity mappings: Account (Name, UPNSuffix, AadUserId), IP (Address from SourceIp)
- Custom details: RiskScore, RiskIndicators, DeleteCount, BaselineMaxHr, RecoveryDeletes, LockDeletes, ScopeDeletes, RgCount, SubCount, DeletedOps, Department, JobTitle
KQL
// =====================================================================
// Mass Cloud Resource Deletion Activity
// =====================================================================
// Description : Detects mass deletion of Azure resources by scoring the
// shapes destruction takes (backup and recovery deletions,
// lock removal alongside deletions, scope level deletions,
// volume and scope spikes measured against the caller's
// own 14 day history, callers with no deletion history),
// while steady automation and routine churn are absorbed
// by per caller baselines and stay below the threshold.
// Type : Detection
//
// Tables : AzureActivity, IdentityInfo
// Connectors : Azure Activity (stream the activity log from every
// monitored subscription); UEBA for IdentityInfo
// License : Microsoft Sentinel; UEBA adds IdentityInfo enrichment,
// without it the enrichment columns return empty
//
// Tuning : - Set the rule query period to P14D; the 14d lookups
// only resolve if the rule itself looks back that far
// - DetectionWindow (1h) - keep equal to the rule
// frequency so every deletion is scored exactly once
// - MinDeleteFloor (10) / NewDeleterFloor (5) - volume
// gates, raise in large estates, lower in quiet tenants
// - SpikeMultiplier (3) - raise to 5 if steady pipelines
// still trip after their first baseline forming alert
// - ExcludedCallers - known teardown automation, add
// sparingly and review quarterly
//
// Known FPs : - New automation identity's first large teardown -
// fires once, afterwards its own history vouches for it
// - Planned decommissions - same telemetry as an attack
// by design, close against the change record
// - Snapshot rotation and pipeline churn - absorbed by
// the per caller hourly baseline, deliberately not
// excluded by resource type
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-10
// =====================================================================
// ===== Tuning variables =====
let DetectionWindow = 1h; // scoring window, keep equal to the rule frequency so events are scored once
let BaselineWindow = 14d; // per caller deletion history, equals the rule query period
let MinDeleteFloor = 10; // deletions in the window before the volume signal can score
let NewDeleterFloor = 5; // deletions before a caller with no history can score
let SpikeMultiplier = 3; // window count must exceed the caller's own hourly maximum by this factor
let ScopeSpikeFloor = 2; // scope level deletions before the mass scope signal can score
let RgSpreadFloor = 3; // distinct resource groups before the spread signal scores
let IdentityLookback = 14d; // IdentityInfo enrichment window
let ExcludedCallers = dynamic([]); // known teardown automation, add sparingly and review quarterly
// ===== Scoring weights, FireThreshold is the alert gate =====
let W_RecoveryOps = 4; // backup and recovery deletions
let W_NoHistory = 3; // first deletion activity seen for this caller in 14 days
let W_LockRemoval = 3; // resource locks removed alongside other deletions
let W_ScopeSpike = 3; // scope level deletions above the caller's own precedent
let W_VolumeSpike = 2; // deletion volume far above the caller's own history
let W_ScopeLevel = 2; // any resource group or subscription scope deletion
let W_RgSpread = 1; // deletions across several resource groups
let FireThreshold = 5;
// ===== Operation classes =====
let RecoveryOps = dynamic([
"microsoft.recoveryservices/vaults/delete",
"microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems/delete",
"microsoft.recoveryservices/vaults/backuppolicies/delete",
"microsoft.dataprotection/backupvaults/delete",
"microsoft.dataprotection/backupvaults/backupinstances/delete",
"microsoft.compute/restorepointcollections/delete"
]);
let NoiseOps = dynamic([
"microsoft.resources/deployments/delete" // deployment history objects, metadata rather than resources
]);
let RgScopeOp = "microsoft.resources/subscriptions/resourcegroups/delete";
let SubCancelPrefix = "microsoft.subscription/cancel";
let LockOp = "microsoft.authorization/locks/delete";
// The no history signal only scores when the table holds a full baseline, a thin table degrades to silence
let HistoryOk = toscalar(
AzureActivity
| where TimeGenerated > ago(BaselineWindow)
| summarize OldestRecord = min(TimeGenerated)
| project tobool(OldestRecord < ago(BaselineWindow - 1d))
);
// Successful deletions only, one row per operation, shared by the baseline and the detection window
let Deletes = materialize(
AzureActivity
| where TimeGenerated > ago(BaselineWindow)
| where ActivityStatusValue in~ ("Success", "Succeeded")
| extend Operation = tolower(OperationNameValue)
| where Operation endswith "/delete" or Operation startswith SubCancelPrefix
| where Operation !in (NoiseOps)
| where isnotempty(Caller) and Caller !in~ (ExcludedCallers)
| extend IsRecoveryOp = Operation in (RecoveryOps)
| extend IsScopeOp = Operation == RgScopeOp or Operation startswith SubCancelPrefix
| extend IsLockOp = Operation == LockOp
| extend ResourceGroup = tostring(extract(@"/resourcegroups/([^/]+)", 1, tolower(_ResourceId)))
| project TimeGenerated, Caller, CallerIpAddress, Operation, IsRecoveryOp, IsScopeOp, IsLockOp,
ResourceGroup, SubscriptionId, CorrelationId, _ResourceId
);
// Per caller history taken before the detection window, the events being scored are never in their own baseline
let Baseline = Deletes
| where TimeGenerated <= ago(DetectionWindow)
| summarize
HourlyCount = dcount(CorrelationId),
HourlyScope = dcountif(CorrelationId, IsScopeOp)
by Caller, bin(TimeGenerated, 1h)
| summarize
BaselineMaxHourly = max(HourlyCount),
BaselineMaxScope = max(HourlyScope),
BaselineTotal = sum(HourlyCount)
by Caller;
Deletes
| where TimeGenerated > ago(DetectionWindow)
| summarize
DeleteCount = dcount(CorrelationId),
RecoveryDeletes = dcountif(CorrelationId, IsRecoveryOp),
LockDeletes = dcountif(CorrelationId, IsLockOp),
ScopeDeletes = dcountif(CorrelationId, IsScopeOp),
RgCount = dcountif(ResourceGroup, isnotempty(ResourceGroup)),
SubCount = dcount(SubscriptionId),
DeletedOperations = make_set(Operation, 50),
DeletedResources = make_set(_ResourceId, 50),
SourceIps = make_set(CallerIpAddress, 10),
StartTime = min(TimeGenerated),
EndTime = max(TimeGenerated)
by Caller
| join kind=leftouter (Baseline) on Caller
| extend
BaselineMaxHourly = coalesce(BaselineMaxHourly, 0),
BaselineMaxScope = coalesce(BaselineMaxScope, 0),
BaselineTotal = coalesce(BaselineTotal, 0)
| extend
VolumeSpike = DeleteCount >= MinDeleteFloor and BaselineMaxHourly > 0 and DeleteCount > SpikeMultiplier * BaselineMaxHourly,
NoHistory = HistoryOk and BaselineTotal == 0 and DeleteCount >= NewDeleterFloor,
RecoveryHit = RecoveryDeletes > 0,
LockHit = LockDeletes > 0 and DeleteCount > LockDeletes,
ScopeSpike = ScopeDeletes >= ScopeSpikeFloor and ScopeDeletes > SpikeMultiplier * BaselineMaxScope,
ScopeHit = ScopeDeletes > 0,
RgSpreadHit = RgCount >= RgSpreadFloor
| extend RiskScore =
iff(RecoveryHit, W_RecoveryOps, 0)
+ iff(NoHistory, W_NoHistory, 0)
+ iff(LockHit, W_LockRemoval, 0)
+ iff(ScopeSpike, W_ScopeSpike, 0)
+ iff(VolumeSpike, W_VolumeSpike, 0)
+ iff(ScopeHit, W_ScopeLevel, 0)
+ iff(RgSpreadHit, W_RgSpread, 0)
| where RiskScore >= FireThreshold
| extend
Risk_1 = iff(RecoveryHit, strcat("Backup and recovery deletions: ", RecoveryDeletes), ""),
Risk_2 = iff(NoHistory, "First deletion activity for this caller in 14 days", ""),
Risk_3 = iff(LockHit, strcat("Resource locks removed alongside deletions: ", LockDeletes), ""),
Risk_4 = iff(ScopeSpike, strcat("Mass scope deletion: ", ScopeDeletes, " resource group or subscription level deletions vs hourly maximum of ", BaselineMaxScope), iff(ScopeHit, strcat("Scope level deletions: ", ScopeDeletes), "")),
Risk_5 = iff(VolumeSpike, strcat("Volume spike: ", DeleteCount, " deletions vs hourly maximum of ", BaselineMaxHourly), ""),
Risk_6 = iff(RgSpreadHit, strcat("Spread across ", RgCount, " resource groups"), "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
iff(isnotempty(Risk_6), strcat(Risk_6, " | "), "")
))
// IdentityInfo enrichment, the isfuzzy union keeps the rule alive when UEBA is not enabled
| join kind=leftouter (
union isfuzzy=true
(datatable(AccountUPN: string, AccountDisplayName: string, Department: string, JobTitle: string, AssignedRoles: dynamic)[]),
(IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| summarize arg_max(TimeGenerated, AccountDisplayName, Department, JobTitle, AssignedRoles) by AccountUPN
| project AccountUPN, AccountDisplayName, Department, JobTitle, AssignedRoles)
) on $left.Caller == $right.AccountUPN
| extend
Name = iff(Caller contains "@", tostring(split(Caller, "@", 0)[0]), ""),
UPNSuffix = iff(Caller contains "@", tostring(split(Caller, "@", 1)[0]), ""),
AadUserId = iff(Caller !contains "@", Caller, ""),
SourceIp = tostring(SourceIps[0]),
AssignedRoles = tostring(AssignedRoles)
| project
StartTime, EndTime, Caller, Name, UPNSuffix, AadUserId, SourceIp, SourceIps,
AccountDisplayName, Department, JobTitle, AssignedRoles,
DeleteCount, BaselineMaxHourly, BaselineMaxScope, RecoveryDeletes, LockDeletes,
ScopeDeletes, RgCount, SubCount, DeletedOperations, DeletedResources,
RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc
You can also download this as an analytic rule and import it directly to Sentinel.
Follow my repo - GitHub
What You Should Do Next
- Confirm the Azure Activity connector streams from every subscription and holds fourteen days of data. The baselines and the no history signal depend on it; on a fresh workspace the rule runs but scores less on purpose.
- Run the detection window summarize over seven days in Logs and meet your steady deleters before they meet you. Populate
ExcludedCallerssparingly and review it quarterly. - Put CanNotDelete locks on recovery vaults and anything you cannot rebuild. Removing a lock is a weight 3 signal here, which turns the attacker's preparation into your telemetry.
- Check the floors against your estate. Ten deletions an hour is mass activity in a quiet tenant and background noise in a large one; the variables sit at the top of the query for a reason.
Class dismissed.