Fixing the "Rare Subscription-level Operations in Azure" Analytic Rule
Alright class.
Back on the Azure control plane, and a rule with a good nose for targets. It watches AzureActivity for sensitive subscription operations, and the description picks the right example: a disk snapshot, which is how backups work and also how an attacker copies a domain controller's disk to dump its hashes offline. The operations it names are the ones that matter. How it decides which are worth an alert is the problem, and it is the Key Vault lesson again in a different hat.
Five of a Thing That Happens Once
The whole rule pivots on one number:
let alertOperationThreshold = 5;
It gates both halves of the query: a caller and IP must perform the same sensitive operation five or more times in a day to register. Hold that against the attack the description advertises. Copying a domain controller's disk is one snapshot. Exporting it is one beginGetAccess. Listing the keys to the account that holds your backups is one listKeys. The point of these operations is that one is enough, and a rule that demands five waits for a fifth a careful attacker never provides.
There is a tell in the code, too. The comment above the threshold:
// The number of operations above which an IP address is considered an unusual source of role assignment operations
This rule contains no role assignment operations. The comment is a fossil from whatever rule this was copied from, a fair clue that nobody rethought the threshold either.
What These Operations Have in Common
The operations are not really about volume at all. They are the cloud equivalents of specific physical acts, and each is suspicious the first time the wrong hands do it.
Copying the disk. A snapshot write, then a beginGetAccess that mints a SAS download link for the snapshot or disk. That pair is exfiltration: take a copy, generate a link, pull it from anywhere. The export is the loudest operation in the set, because a snapshot that never leaves is a backup, and a snapshot with a download link is a theft in progress.
Taking the keys. A listKeys against a storage account hands over standing credentials to everything in it, which is why the original was right to catch any provider's listKeys, and wrong to need five.
Opening the fence. An NSG write is the network path being changed, which is how an attacker reaches something that was not previously reachable.
So the question is not how many times, it is whether this caller does this at all. Judge the caller against their own history, not a tenant-wide number. A caller with no sensitive-operation history, or a known caller doing one of these for the first time, or from an address they have never used, is worth one look. The attacker cannot cheaply join that baseline, because the only way in is a fortnight of quietly running the same operations from the same place.
The Backup Job Is Not an Incident
The benign owner of this telemetry is automation. Azure Backup snapshots disks on a schedule, IaC pipelines rewrite NSGs on every deployment, and monitoring tools list keys. Under a flat threshold these are either noise or the reason the threshold gets raised until the rule is deaf. Under a self-baseline they carry their own alibi: operation and address both in their history, and they score zero. No service principal allowlist to maintain, because history maintains itself.
One honest trap avoided. A caller that legitimately operates from many addresses would trip the new-address signal forever, so callers with more historical addresses than the churn cap are exempted from that one signal. It is a bonus for stable callers, not a tax on mobile ones.
The Rebuild
The gate is the score, with the two things that need no second opinion clearing it alone:
| where RiskScore >= FireThreshold
A disk or snapshot export fires alone. A caller with no sensitive-operation history fires alone. A first-time operation for a known caller, a new source address, several operation classes in one window, a burst and off-hours timing stack from there. Degradation is graceful: UEBA and the sign-in tables only put names and roles on the row, so without them the rule still fires on the same control-plane evidence, and a missing lookup silences a signal rather than inventing one.
What the Analyst Sees
The row is the triage. ResourceIdsSample names the disks, accounts and NSGs touched, the blast radius. NewOpsForCaller and NewIps state what is new about this caller, next to HistIpCount so the churn exemption is visible. App callers arrive named through the sign-in logs rather than as a bare GUID, humans arrive with roles and department, and CorrelationIds hands over the operation chains to pivot on.
The RiskIndicators Field
Same accumulating string as the rest of the series, mapped as a custom detail:
DiskExportAccess | NewCallerIdentity | FirstTimeOperation | NewSourceIp | MultiClassActivity | OperationBurst | OffHoursActivity
The combination to chase is DiskExportAccess with NewSourceIp: a SAS export of a disk or snapshot by a caller from an address they have never used is a hash-dump or data theft with stolen credentials, caught at the moment the download link is minted.
The Blind Spots You Should Know About
The caller's Azure RBAC is not in these tables, so the roles on the row are Entra directory roles, not the subscription permissions that authorised the operation; confirm those in the portal.
The operation list is a starting set among thousands of provider operations, so treat it as a living variable. Data-plane theft that never touches the control plane, reading a secret straight from Key Vault, belongs to that lesson. And an insider whose normal job already includes these operations from their usual address stays inside their own baseline, which behavioural detection cannot help.
MITRE Mappings for the Updated Rule
Tactics: Credential Access, Defense Evasion.
T1003.003 OS Credential Dumping, NTDS. The snapshot-and-export of a domain controller disk exists to lift the NTDS database and dump hashes offline.
T1552 Unsecured Credentials. The listKeys operation, lifting standing storage account keys from the control plane.
T1562.007 Impair Defenses, Disable or Modify Cloud Firewall. The NSG write, opening a network path that was previously closed.
Rule Settings
Run every 60 minutes, with the query period at 14 days so the per-caller baselines resolve. High severity: a firing row is a sensitive control-plane operation by a caller outside their own pattern. Alert per result; the query collapses to one row per caller and address. Group by the Account entity over 8 hours so a spree across runs becomes one incident.
Entity mapping:
Name to Account (Name), UPNSuffix to Account (UPNSuffix)
PrimaryIp to IP (Address)
TargetResourceId to AzureResource (ResourceId)
Custom details: RiskScore, RiskIndicators, CallerName, OpCount, SnapshotCount, ExportCount, ListKeysCount, NsgWriteCount, DistinctClasses, Operations, NewOpsForCaller, NewIps, HistIpCount, Subscriptions, ResourceGroups, ResourceIdsSample, CorrelationIds, CallerRoles, Department, UserType.
KQL
// =====================================================================
// Sensitive Subscription Operations - Behavioural
// =====================================================================
// Description : Detects sensitive Azure control-plane operations (disk snapshots, disk and
// snapshot SAS exports, storage key listing, NSG writes) performed by a caller
// outside their own pattern: a caller with no sensitive-op history, a first-time
// operation for a known caller, a new source address, several operation classes
// in one window, or a burst. A single event qualifies, because the attacks these
// operations enable need exactly one. Routine automation, backup snapshots and
// IaC pipelines carry their own history and score zero.
// Type : Detection
//
// Tables : AzureActivity, IdentityInfo, AADServicePrincipalSignInLogs, AADManagedIdentitySignInLogs
// Connectors : Azure Activity (AzureActivity), Microsoft Entra ID (ServicePrincipal and
// ManagedIdentity sign-in logs), Microsoft Sentinel UEBA (IdentityInfo)
// License : Microsoft Sentinel; Azure Activity is free ingestion. UEBA and the sign-in
// tables only enrich (names, roles, department); every signal reads AzureActivity
//
// Tuning : - Set the rule query period to P14D; the 14d baselines only resolve if the rule looks back that far
// - SensitiveOps - the operation list; resource provider operations run to thousands,
// extend from https://learn.microsoft.com/azure/role-based-access-control/resource-provider-operations
// - BurstFloor - operations in one window that count as a burst
// - IpChurnCap - callers with more historical addresses than this are IP churners and
// the new-address signal is disabled for them
// - BusinessStart / BusinessEnd - local working hours; TimeGenerated is UTC, shift to your tenant
//
// Known FPs : - Azure Backup and snapshot tooling: nightly bursts with history score two and stay silent
// - IaC pipelines writing NSGs on every deploy: history and fixed runners, score zero
// - A new operator's first sensitive operation fires once on NewCallerIdentity;
// one look per new pair of hands on sensitive controls is the point
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 1.0 | 2026-07-05
// =====================================================================
let DetectionWindow = 1h;
let BaselineLookback = 14d; // per-caller history: known operations and known addresses
let IdentityLookback = 14d; // UEBA context for human callers
let SpLookback = 14d; // service principal and managed identity sign-ins, to name app callers
let BurstFloor = 5; // operations in one window that count as a burst
let IpChurnCap = 10; // more historical addresses than this disables the new-address signal
let BusinessStart = 7;
let BusinessEnd = 19;
let FireThreshold = 3;
// Scoring weights - a disk export or an unknown caller fires alone; everything else stacks
let W_DiskExport = 3; // a SAS export of a disk or snapshot; the download link for the data
let W_NewCaller = 3; // no sensitive-op history at all in the lookback
let W_FirstTimeOp = 2; // known caller, first time performing this operation
let W_NewSourceIp = 2; // known caller, an address they have never used; disabled for IP churners
let W_MultiClass = 2; // two or more sensitive operation classes in one window
let W_Burst = 2; // operation volume past BurstFloor
let W_OffHours = 1; // activity outside working hours
let SensitiveOps = dynamic([
"microsoft.compute/snapshots/write",
"microsoft.compute/disks/begingetaccess/action",
"microsoft.compute/snapshots/begingetaccess/action",
"microsoft.network/networksecuritygroups/write",
"microsoft.storage/storageaccounts/listkeys/action"
]);
// Sensitive control-plane events across the lookback, caller and class resolved once.
// Materialised because both the detection window and the history read from it
let SensitiveActivity = materialize(AzureActivity
| where TimeGenerated > ago(BaselineLookback)
| extend OpName = tolower(OperationNameValue)
// keep the original rule's one good idea: any provider's listkeys counts
| where OpName in (SensitiveOps) or OpName endswith "listkeys/action"
| where ActivityStatusValue in~ ("Success", "Succeeded") // both spellings appear across API versions
| extend Caller = tolower(Caller)
| where isnotempty(Caller)
| extend CallerType = iff(Caller has "@", "User", "App")
| extend OpClass = case(
OpName has "begingetaccess", "DiskExport",
OpName has "snapshots", "Snapshot",
OpName endswith "listkeys/action", "KeyList",
OpName has "networksecuritygroups", "NsgWrite",
"Other")
| extend HourOfDay = datetime_part("Hour", TimeGenerated)
| extend OffHoursEvent = HourOfDay < BusinessStart or HourOfDay >= BusinessEnd or dayofweek(TimeGenerated) in (0d, 6d)
);
let WindowEvents = SensitiveActivity | where TimeGenerated > ago(DetectionWindow);
let History = SensitiveActivity | where TimeGenerated <= ago(DetectionWindow);
// The caller's known ground: operations they have performed before and addresses they have used
let CallerHistory = History
| summarize HistOps = make_set(OpName, 50),
HistIpSet = make_set(CallerIpAddress, 200),
HistIpCount = dcount(CallerIpAddress)
by Caller;
// Human caller context
let IdentityContext = IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| summarize arg_max(TimeGenerated, AssignedRoles, Department, UserType) by AccountUPN = tolower(AccountUPN);
// Display names for app callers: service principals by AppId, managed identities by object id
let SpNames = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(SpLookback)
| summarize SpName = take_any(ServicePrincipalName) by AppId = tolower(AppId);
let MiNames = AADManagedIdentitySignInLogs
| where TimeGenerated > ago(SpLookback)
| where isnotempty(ServicePrincipalName) and isnotempty(ServicePrincipalId)
| summarize MiName = take_any(ServicePrincipalName) by ServicePrincipalId = tolower(ServicePrincipalId);
// One row per caller and address, scored against the caller's own pattern
WindowEvents
| summarize
StartTime = min(TimeGenerated),
EndTime = max(TimeGenerated),
OpCount = count(),
SnapshotCount = countif(OpClass == "Snapshot"),
ExportCount = countif(OpClass == "DiskExport"),
ListKeysCount = countif(OpClass == "KeyList"),
NsgWriteCount = countif(OpClass == "NsgWrite"),
DistinctClasses = dcount(OpClass),
Operations = make_set(OpName, 15),
WindowOps = make_set(OpName, 50),
Subscriptions = make_set(SubscriptionId, 5),
ResourceGroups = make_set(ResourceGroup, 10),
ResourceIdsSample = make_set(_ResourceId, 10),
CorrelationIds = make_set(CorrelationId, 5),
IpSet = make_set(CallerIpAddress, 20),
PrimaryIp = take_any(CallerIpAddress),
TargetResourceId = take_any(_ResourceId),
OffHoursCount = countif(OffHoursEvent)
by Caller, CallerType, CallerIpAddress
| join kind=leftouter CallerHistory on Caller
| join kind=leftouter IdentityContext on $left.Caller == $right.AccountUPN
| join kind=leftouter SpNames on $left.Caller == $right.AppId
| join kind=leftouter MiNames on $left.Caller == $right.ServicePrincipalId
| extend HasHistory = isnotnull(HistIpCount)
| extend HistOps = iff(isnull(HistOps), dynamic([]), HistOps)
| extend HistIpSet = iff(isnull(HistIpSet), dynamic([]), HistIpSet)
| extend NewOpsForCaller = set_difference(WindowOps, HistOps)
| extend NewIps = set_difference(IpSet, HistIpSet)
| extend CallerRoles = tostring(AssignedRoles)
| extend CallerName = iff(CallerType == "User", Caller, coalesce(SpName, MiName, Caller))
| extend sDiskExport = ExportCount > 0
| extend sNewCaller = not(HasHistory)
| extend sFirstTimeOp = HasHistory and array_length(NewOpsForCaller) > 0
| extend sNewSourceIp = HasHistory and HistIpCount <= IpChurnCap and array_length(NewIps) > 0
| extend sMultiClass = DistinctClasses >= 2
| extend sBurst = OpCount >= BurstFloor
| extend sOffHours = OffHoursCount > 0
| extend RiskScore =
iff(sDiskExport, W_DiskExport, 0)
+ iff(sNewCaller, W_NewCaller, 0)
+ iff(sFirstTimeOp, W_FirstTimeOp, 0)
+ iff(sNewSourceIp, W_NewSourceIp, 0)
+ iff(sMultiClass, W_MultiClass, 0)
+ iff(sBurst, W_Burst, 0)
+ iff(sOffHours, W_OffHours, 0)
| where RiskScore >= FireThreshold
| extend Name = tostring(split(Caller, "@", 0)[0]), UPNSuffix = tostring(split(Caller, "@", 1)[0])
| extend Risk_1 = iff(sDiskExport, "DiskExportAccess", "")
| extend Risk_2 = iff(sNewCaller, "NewCallerIdentity", "")
| extend Risk_3 = iff(sFirstTimeOp, "FirstTimeOperation", "")
| extend Risk_4 = iff(sNewSourceIp, "NewSourceIp", "")
| extend Risk_5 = iff(sMultiClass, "MultiClassActivity", "")
| extend Risk_6 = iff(sBurst, "OperationBurst", "")
| extend Risk_7 = iff(sOffHours, "OffHoursActivity", "")
| 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, " | "), ""),
iff(isnotempty(Risk_7), strcat(Risk_7, " | "), "")
))
| project
StartTime, EndTime, RiskScore, RiskIndicators,
Caller, CallerName, Name, UPNSuffix, CallerType, PrimaryIp, IpSet, NewIps, HistIpCount,
OpCount, SnapshotCount, ExportCount, ListKeysCount, NsgWriteCount, DistinctClasses,
Operations, NewOpsForCaller, Subscriptions, ResourceGroups, ResourceIdsSample,
TargetResourceId, CorrelationIds, CallerRoles, Department, UserType, OffHoursCount
| 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
Fit the operation list to your estate first, because it decides what the rule can see. The defaults are a starting point; add the disk export actions if your copy lacks them, and anything else you consider sensitive.
Run it over the last day. Expect close to nothing on a mature tenant; the rows that appear should be new operators, genuine exports, or a caller reaching outside their pattern, each carrying its resources and history.
Set the churn cap to your automation. If a legitimate pipeline runs from a wide address range, raise the cap so its mobility does not read as new, and let the operation and export signals carry those callers.
Pair it with the companions. This rule, the Key Vault mass-retrieval rule and the suspicious resource deployment rule are the same idea on three surfaces, and a caller lighting up more than one in an hour is the incident worth waking someone for.
Class dismissed.