Azure Data Protection Hardening

Overview

This page covers Microsoft Azure data-at-rest hardening across the surfaces that decide whether an attacker who reaches a tenant — through a stolen SAS token, a leaked service-principal secret, a compromised workstation that holds an active az login session, or a confused-deputy chain through a poorly-scoped Storage Account firewall — can actually read, modify, or destroy regulated data. Scope is the Azure commercial regions; Azure Government and Azure operated by 21Vianet (China) inherit the same controls but expose a different regional Key Vault endpoint suffix, a different Microsoft Entra ID (formerly Azure Active Directory) tenant topology, and FIPS-validated HSM SKUs that differ from the commercial Premium tier — re-verify region-table caveats, the Key Vault DNS suffix, and CMK key-source URIs before applying any of the IaC below to a sovereign cloud. CIS sub-IDs and NIST / ISO mappings throughout this page reference the commercial Microsoft Azure Foundations Benchmark v3.0.0 (Feb 2025) unless explicitly annotated as a post-v3.0.0 feature or a best-practice recommendation that the current benchmark has not yet codified. The crosswalk page at compliance frameworks describes how the seven pinned framework columns relate to each other.

The Azure data plane is the product of Storage Accounts (the blob/file/queue/table container with its own firewall, identity model, and four independent public-access toggles — covered in detail at azure-data-01), Managed Disks (encrypted at rest by platform-managed keys by default; CMK-at-host via a Disk Encryption Set is the upgrade path for regulated workloads), Azure SQL Database (TDE enabled by default with a service-managed key; BYOK via Key Vault is the upgrade path), Cosmos DB (TDE always-on with platform-managed keys; CMK via Key Vault optional), and Microsoft Purview (the Data Map + Data Catalog scanning service that classifies sensitive content). The cryptographic root for everything regulated is Azure Key Vault — the single PaaS service whose compromise unwinds the encryption guarantees of every CMK-backed resource downstream, and therefore the single PaaS service whose access model must be hardest. The cross-cutting principles — encryption at rest, key management, data classification, data loss prevention, and retention, backup & recovery — are owned by the General Data Protection page; this page maps them to Azure primitives. Encryption in transit lives canonically at General Network — encryption in transit and is not re-authored here (Phase 4 canonical-content rule); the Azure network specifics — Storage Account min_tls_version = "TLS1_2", SQL minimum_tls_version = "1.2", Front Door TLS termination policy — are covered alongside the Azure network surface at Azure Network Hardening.

Three anti-conflation callouts up front. First: Storage Account public access is not one toggle — it is four independent toggles and all four must close. public_network_access_enabled (account-level firewall on or off), allow_blob_public_access (anonymous container/blob access permitted at all), allow_nested_items_to_be_public (containers may be set to anonymous-read even if the account allows it), and network_rules.default_action (default-Allow vs default-Deny when the firewall is on). Setting only the first leaves anonymous blob access on for containers that already have it; setting only the second leaves the public Storage endpoint reachable from any internet host that can present a SAS or account key. The canonical hardened posture closes all four — and that is what azure-data-01 enforces. Second: Key Vault has two access-control modes — RBAC and legacy access policies — and the entire industry has converged on RBAC. Access policies pre-date Azure RBAC and have known security gaps: no Microsoft Entra Privileged Identity Management integration, no fine-grained data-plane role separation (the access-policy permissions enum is coarser than the RBAC built-in roles), and harder lifecycle management at scale. Microsoft now recommends RBAC; the Key Vault ARM API version 2026-02-01+ makes RBAC the default for new vaults (enable_rbac_authorization = true). The control at azure-data-05 codifies this posture and frames access policies only as legacy. Third: The CMK chain across Storage, Managed Disks, and Azure SQL is one cryptographic relationship, not three. Storage encryption (CMK via Key Vault key URI), Managed Disk CMK-at-host (CMK via Disk Encryption Set referencing the same Key Vault key), and SQL TDE BYOK (Server Key Type = AzureKeyVault, referencing the same Key Vault key) all root in the same Key Vault. If the vault is compromised, all three are compromised together. This is why azure-data-05 (RBAC) and azure-data-06 (rotation) are not "Key Vault controls" — they are the gating controls for every CMK-backed resource on the page.

Order and scope matter. Control 01 is the foundational public-access invariant enforced subscription-wide via Azure Policy assigned at the root management group: every Storage Account closes all four toggles. Controls 02–04 build the CMK chain across the three highest-value regulated data resources (Storage, Managed Disks, SQL). Control 05 is the Key Vault hardening that makes the CMK chain trustworthy; control 06 bounds compromise windows via auto-rotation; control 07 surfaces classification of sensitive data via Microsoft Purview so the controls above can be prioritised; control 08 closes the immutability loop with locked retention policies on regulated containers. Subscription and management-group scope: Azure Policy at the root management group enforces tenant-wide invariants (deny Storage Account creation with public network access on, deny Key Vault creation without RBAC, deny Managed Disk creation without a Disk Encryption Set, require Defender for SQL on every SQL Server) and is the single most important lever for keeping the controls below from drifting out of compliance once dozens of subscriptions and hundreds of resource groups exist.

azure-data-01-storage-public-disabled ! CRITICAL PREVENTIVE

Every Storage Account in every subscription closes all four independent public-access toggles: public_network_access_enabled = false (account firewall on), allow_blob_public_access = false (anonymous container/blob access prohibited account-wide), allow_nested_items_to_be_public = false (no container may be configured for anonymous read even if the account allowed it), and network_rules { default_action = "Deny" } (the firewall denies by default and only the listed VNets / IP ranges / Private Endpoints punch through). Enforce subscription-wide via Azure Policy assigned at the root management group; do not rely on per-account diligence (Microsoft Learn — Storage Account network security (accessed 2026-05)). The principle is reinforced in General Data — encryption at rest and General Network — private connectivity: a Storage Account reachable from the public internet is a Storage Account whose access depends entirely on whoever holds a valid SAS or account key. CRITICAL because misconfiguration is a single toggle away from anonymous blob enumeration — the canonical Azure data-leak pattern, mirrored on AWS as the open-S3-bucket story and on GCP as the world-readable bucket story. This is the highest-leverage Storage Account hardening Azure exposes; the IaC block below is the canonical pattern to copy across every regulated account.

Remediation — Azure CLI

# Azure CLI 2.x
# Close all four public-access toggles on an existing Storage Account.
az storage account update \
  --resource-group rg-data-prod-westeu \
  --name stappprodwesteu001 \
  --public-network-access Disabled \
  --allow-blob-public-access false \
  --default-action Deny \
  --bypass AzureServices \
  --min-tls-version TLS1_2

# allow_nested_items_to_be_public is the ARM-level alias for allowBlobPublicAccess
# at account scope; the per-container public-access setting is set on the container
# (it cannot be set true if the account-level toggle is false — defense in depth).
az storage container set-permission \
  --account-name stappprodwesteu001 \
  --name regulated-data \
  --public-access off \
  --auth-mode login

# Add a Private Endpoint subnet to the network ACL allow-list.
az storage account network-rule add \
  --resource-group rg-data-prod-westeu \
  --account-name stappprodwesteu001 \
  --vnet-name vnet-app-prod-westeu \
  --subnet snet-pe-data

# Audit: list every Storage Account in the tenant that still allows public access
# (any of the four toggles open).
for sub in $(az account list --query '[].id' -o tsv); do
  az storage account list --subscription "$sub" \
    --query "[?publicNetworkAccess=='Enabled' || allowBlobPublicAccess==\`true\`].{sub:'$sub', name:name, rg:resourceGroup, pna:publicNetworkAccess, abpa:allowBlobPublicAccess, default:networkRuleSet.defaultAction}" \
    -o tsv
done

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_storage_account" "app" {
  name                     = "stappprodwesteu001"
  resource_group_name      = azurerm_resource_group.data.name
  location                 = azurerm_resource_group.data.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"
  min_tls_version          = "TLS1_2"

  # The four independent public-access toggles — all must close.
  public_network_access_enabled   = false
  allow_nested_items_to_be_public = false
  shared_access_key_enabled       = false   # force Entra ID auth where the SDK supports it

  network_rules {
    default_action             = "Deny"        # toggle #4 — firewall default-Deny
    bypass                     = ["AzureServices"]
    virtual_network_subnet_ids = [azurerm_subnet.pe_data.id]
    ip_rules                   = []            # no internet IP allow-list on regulated accounts
  }

  # CMK encryption block — paired with azure-data-02 below.
  identity {
    type = "SystemAssigned"
  }

  tags = { tier = "regulated", "data-class" = "confidential" }
}

# Root-management-group initiative: deny Storage Account creation with public network access on.
resource "azurerm_management_group_policy_assignment" "deny_storage_public" {
  name                 = "deny-storage-public-network-access"
  management_group_id  = "/providers/Microsoft.Management/managementGroups/tenant-root"
  policy_definition_id = var.deny_storage_public_initiative_id
  description          = "Storage Accounts must have public_network_access_enabled=false AND allow_blob_public_access=false AND default_action=Deny"
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Hardened-by-default storage account (3-24 lowercase alphanumeric).')
@minLength(3)
@maxLength(24)
param storageName string

param location string = resourceGroup().location

resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
  name: storageName
  location: location
  kind: 'StorageV2'
  sku: { name: 'Standard_GRS' }
  properties: {
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    publicNetworkAccess: 'Disabled'
    allowSharedKeyAccess: false
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
  }
}

output storageId string = storage.id

Remediation — Pulumi (TypeScript)

import * as pulumi from "@pulumi/pulumi";
import * as storage from "@pulumi/azure-native/storage";
import * as resources from "@pulumi/azure-native/resources";

const rg = new resources.ResourceGroup("data-rg");

new storage.StorageAccount("hardened", {
  resourceGroupName: rg.name,
  kind: storage.Kind.StorageV2,
  sku: { name: storage.SkuName.Standard_GRS },
  enableHttpsTrafficOnly: true,
  minimumTlsVersion: storage.MinimumTlsVersion.TLS1_2,
  allowBlobPublicAccess: false,
  publicNetworkAccess: "Disabled",
  allowSharedKeyAccess: false,
  networkRuleSet: {
    defaultAction: storage.DefaultAction.Deny,
    bypass: "AzureServices",
  },
});

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a3.7n/an/a AC-3; AC-6; SC-7A.5.10; A.8.3CLD.9.5.1

Log signals

  • AzureActivity Microsoft.Storage/storageAccounts/write where the request body sets allowBlobPublicAccess = true on an account whose tag set marks it production.
  • AzureActivity Microsoft.Storage/storageAccounts/blobServices/containers/write setting publicAccess = "Container" or "Blob" on a container of an account whose baseline is private-only.
  • StorageBlobLogs anonymous-read events (AuthenticationType = "Anonymous") immediately after either of the above — downstream confirmation the regression took effect.

Query

AzureActivity
          | where OperationNameValue in ("Microsoft.Storage/storageAccounts/write", "Microsoft.Storage/storageAccounts/blobServices/containers/write")
          | extend body = tostring(parse_json(Properties).requestbody)
          | where body has "\"allowBlobPublicAccess\":true" or body has "\"publicAccess\":\"Container\"" or body has "\"publicAccess\":\"Blob\""
          | project TimeGenerated, Caller, ResourceId, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics; persist as a Sentinel analytics rule with severity High. The S3-bucket-public regression equivalent in Azure is one of the highest-impact data-plane control failures and warrants automated rollback.

Alert threshold

  • Account-level allowBlobPublicAccess flip to true on a production-tagged account — page on first occurrence.
  • Container-level publicAccess flip on any container in an account whose baseline is private — page; the resource provider permits both account-level lockdown and per-container override.

Initial response

  1. Reapply allowBlobPublicAccess=false at the account level via az storage account update; flip the container publicAccess back to None; capture the AzureActivity Caller and timestamp as the rollback ledger.
  2. Walk StorageBlobLogs for the exposure window — every anonymous GetBlob against the affected container is a candidate exfiltration event and warrants object-level inventory diffing against the catalog.
  3. Escalate per general/ir.html — confirm the Azure Policy Storage account public access should be disallowed initiative is assigned in deny mode at the management-group root.

References

Equivalent on: AWS · GCP · OCI

azure-data-02-cmk-keyvault ! HIGH PREVENTIVE

Regulated Storage Accounts, Managed Disks, and Azure SQL databases use a customer-managed key (CMK) hosted in Azure Key Vault, with auto-rotation enabled on the key, soft-delete enabled on the vault, and purge-protection enabled so a malicious or accidental delete cannot vapourise the keying material (Microsoft Learn — Storage encryption with customer-managed keys (accessed 2026-05)). For high-sensitivity workloads, use an HSM-backed key (kty = "RSA-HSM") in the Premium SKU vault, which holds the key material in an HSM that is FIPS 140-3 Level 3 validated and from which the private key cannot be exported in cleartext. The principle is reinforced in General Data — key management. Anti-conflation: Storage encryption is always on at the platform level with Microsoft-managed keys; CMK is the upgrade that puts the organisation's Key Vault on the cryptographic-erase path (revoke the key → Storage can no longer read the data, even though Microsoft still operates the storage hardware). HIGH PREVENTIVE because CMK reduces the trust boundary from "Microsoft's key management" to "the organisation's Key Vault posture" — which is only an improvement if the controls in azure-data-05 and azure-data-06 hold.

Remediation — Azure CLI

# Azure CLI 2.x
# Create an HSM-backed CMK in a Premium Key Vault (azure-data-05 covers vault setup).
az keyvault key create \
  --vault-name kv-app-prod-westeu \
  --name cmk-storage-app-prod \
  --kty RSA-HSM \
  --size 4096 \
  --ops decrypt encrypt sign verify wrapKey unwrapKey

# Wire the Storage Account to the CMK via the Storage system-assigned managed identity.
STORAGE_MI_PRINCIPAL=$(az storage account show \
  --resource-group rg-data-prod-westeu \
  --name stappprodwesteu001 --query identity.principalId -o tsv)

# Grant the Storage MI the data-plane RBAC role on the vault key.
az role assignment create \
  --role "Key Vault Crypto Service Encryption User" \
  --assignee "$STORAGE_MI_PRINCIPAL" \
  --scope $(az keyvault show --name kv-app-prod-westeu --query id -o tsv)

# Point the Storage Account at the Key Vault key URI.
KEY_URI=$(az keyvault key show --vault-name kv-app-prod-westeu --name cmk-storage-app-prod --query 'key.kid' -o tsv)
az storage account update \
  --resource-group rg-data-prod-westeu \
  --name stappprodwesteu001 \
  --encryption-key-source Microsoft.Keyvault \
  --encryption-key-vault "$KEY_URI"

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_key_vault_key" "cmk_storage" {
  name         = "cmk-storage-app-prod"
  key_vault_id = azurerm_key_vault.app.id
  key_type     = "RSA-HSM"
  key_size     = 4096
  key_opts     = ["decrypt", "encrypt", "sign", "verify", "wrapKey", "unwrapKey"]

  rotation_policy {
    automatic { time_before_expiry = "P30D" }
    expire_after         = "P365D"
    notify_before_expiry = "P30D"
  }
}

# Storage Account MI gets Key Vault Crypto Service Encryption User on the vault key.
resource "azurerm_role_assignment" "storage_mi_kv" {
  scope                = azurerm_key_vault.app.id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = azurerm_storage_account.app.identity[0].principal_id
}

# Wire the Storage Account encryption to the CMK.
resource "azurerm_storage_account_customer_managed_key" "app" {
  storage_account_id = azurerm_storage_account.app.id
  key_vault_id       = azurerm_key_vault.app.id
  key_name           = azurerm_key_vault_key.cmk_storage.name
  # key_version omitted = auto-rotate to latest version
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Storage account using CMK from Key Vault.')
param storageName string
@description('Key Vault URI hosting the CMK.')
param keyVaultUri string
@description('Key name in the vault.')
param keyName string
@description('User-assigned identity with Key Vault Crypto Service Encryption User on the vault.')
param identityResourceId string

param location string = resourceGroup().location

resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {
  name: storageName
  location: location
  kind: 'StorageV2'
  sku: { name: 'Standard_GRS' }
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${identityResourceId}': {}
    }
  }
  properties: {
    encryption: {
      keySource: 'Microsoft.Keyvault'
      keyvaultproperties: {
        keyvaulturi: keyVaultUri
        keyname: keyName
      }
      identity: {
        userAssignedIdentity: identityResourceId
      }
    }
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a3.x; 4.x; 8.xn/an/a SC-13; SC-28A.8.24; A.5.34n/a

Log signals

  • AzureActivity Microsoft.Storage/storageAccounts/write where encryption.keySource flips from Microsoft.Keyvault back to Microsoft.Storage — disarms the CMK envelope.
  • AzureActivity Microsoft.KeyVault/vaults/write where enableSoftDelete or enablePurgeProtection is removed on a vault holding a CMK referenced by storage accounts.
  • AzureDiagnostics Category AuditEvent on the Key Vault showing operationName = "KeyDelete" on a key referenced as the CMK — downstream coverage failure even if the storage account flag did not change.

Query

AzureActivity
          | where OperationNameValue in ("Microsoft.Storage/storageAccounts/write", "Microsoft.KeyVault/vaults/write")
          | extend body = tostring(parse_json(Properties).requestbody)
          | where body has "\"keySource\":\"Microsoft.Storage\"" or body has "\"enableSoftDelete\":false" or body has "\"enablePurgeProtection\":false"
          | project TimeGenerated, Caller, ResourceId, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. CMK regressions are rare and intentional; persist as a Sentinel analytics rule with severity High and require an attached governance ticket reference.

Alert threshold

  • Storage keySource flip back to Microsoft.Storage on a production account — page immediately.
  • Key Vault enablePurgeProtection removal on a vault holding a CMK — page; restoration after a malicious key delete is no longer guaranteed.

Initial response

  1. Reapply CMK encryption via az storage account update --encryption-key-source Microsoft.Keyvault; verify the next AzureDiagnostics AuditEvent batch on the Key Vault shows the storage identity issuing a successful wrap operation.
  2. If a key was deleted, attempt soft-delete recovery via az keyvault key recover; reissue all affected data-plane operations only after the recovery is confirmed.
  3. Escalate per general/ir.html — confirm Azure Policy Storage accounts should use customer-managed key for encryption remains assigned in deny mode and that the Key Vault firewall still admits the storage account identity.

References

Equivalent on: AWS · GCP · OCI

azure-data-03-disk-encryption-set ! HIGH PREVENTIVE

Managed Disks attached to VMs and VM scale sets that host regulated data are encrypted at host via a Disk Encryption Set (DES) that references a Key Vault CMK; for the highest-sensitivity tier, use EncryptionAtRestWithPlatformAndCustomerKeys (double-encryption: platform key plus customer key) (Microsoft Learn — Managed Disk encryption overview (accessed 2026-05)). The DES has its own system-assigned managed identity which must be granted the Key Vault Crypto Service Encryption User RBAC role on the vault key — this is the binding between the disk encryption pipeline and the customer's key custody. Anti-conflation: encryption at host (the topic of this control) encrypts the data on the Azure compute hypervisor before it is written to the storage cluster; Azure Disk Encryption (ADE) is the legacy in-guest BitLocker/DM-Crypt pattern that runs inside the VM — ADE is still supported but encryption-at-host via DES is the current recommended architecture (lower operational cost, no guest-side key handling, compatible with all VM SKUs and OSes). HIGH PREVENTIVE because the disk layer is where stolen-VHD or stolen-snapshot attacks land — and a snapshot exported from a CMK-encrypted disk is unreadable without the vault key, while a snapshot from a platform-managed-key disk is readable by anyone with sufficient Azure RBAC.

Remediation — Azure CLI

# Azure CLI 2.x
# Create the Disk Encryption Set bound to a Key Vault CMK.
KEY_URI=$(az keyvault key show \
  --vault-name kv-app-prod-westeu \
  --name cmk-disks-app-prod \
  --query 'key.kid' -o tsv)

az disk-encryption-set create \
  --resource-group rg-compute-prod-westeu \
  --name des-app-prod \
  --key-url "$KEY_URI" \
  --source-vault $(az keyvault show --name kv-app-prod-westeu --query id -o tsv) \
  --encryption-type EncryptionAtRestWithCustomerKey \
  --location westeurope

# Grant the DES MI the data-plane role on the vault.
DES_MI=$(az disk-encryption-set show \
  --resource-group rg-compute-prod-westeu \
  --name des-app-prod --query identity.principalId -o tsv)
az role assignment create \
  --role "Key Vault Crypto Service Encryption User" \
  --assignee "$DES_MI" \
  --scope $(az keyvault show --name kv-app-prod-westeu --query id -o tsv)

# Audit: list every managed disk in the tenant that is NOT attached to a DES.
for sub in $(az account list --query '[].id' -o tsv); do
  az disk list --subscription "$sub" \
    --query "[?encryption.diskEncryptionSetId==null].{sub:'$sub', name:name, rg:resourceGroup, size:diskSizeGB}" \
    -o tsv
done

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_disk_encryption_set" "app" {
  name                = "des-app-prod"
  resource_group_name = azurerm_resource_group.compute.name
  location            = azurerm_resource_group.compute.location
  key_vault_key_id    = azurerm_key_vault_key.cmk_disks.versionless_id
  encryption_type     = "EncryptionAtRestWithCustomerKey"

  identity {
    type = "SystemAssigned"
  }
}

# DES managed identity gets the data-plane role on the vault key.
resource "azurerm_role_assignment" "des_mi_kv" {
  scope                = azurerm_key_vault.app.id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = azurerm_disk_encryption_set.app.identity[0].principal_id
}

# VM data disk attached via the DES.
resource "azurerm_managed_disk" "data" {
  name                   = "disk-app-data-prod-001"
  resource_group_name    = azurerm_resource_group.compute.name
  location               = azurerm_resource_group.compute.location
  storage_account_type   = "Premium_LRS"
  create_option          = "Empty"
  disk_size_gb           = 1024
  disk_encryption_set_id = azurerm_disk_encryption_set.app.id
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Disk Encryption Set name.')
param desName string
@description('Key Vault key URI (versionless).')
param keyUrl string
@description('Key Vault resource ID.')
param vaultResourceId string

param location string = resourceGroup().location

resource des 'Microsoft.Compute/diskEncryptionSets@2024-03-02' = {
  name: desName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    encryptionType: 'EncryptionAtRestWithCustomerKey'
    rotationToLatestKeyVersionEnabled: true
    activeKey: {
      sourceVault: { id: vaultResourceId }
      keyUrl: keyUrl
    }
  }
}

output desId string = des.id

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a7.x (verify)n/an/a SC-28; SC-13A.8.24n/a

Log signals

  • AzureActivity Microsoft.Compute/disks/write creating a managed disk without an encryption.diskEncryptionSetId reference — defaults to platform-managed key path on a workload that should be CMK-enveloped.
  • AzureActivity Microsoft.Compute/diskEncryptionSets/delete on a DES referenced by production disks — downstream re-wrap operations on those disks will fail.
  • AzureActivity Microsoft.Compute/diskEncryptionSets/write editing the activeKey to point at a Key Vault key outside the documented per-environment vault.

Query

AzureActivity
          | where OperationNameValue in ("Microsoft.Compute/disks/write", "Microsoft.Compute/diskEncryptionSets/write", "Microsoft.Compute/diskEncryptionSets/delete")
          | extend body = tostring(parse_json(Properties).requestbody)
          | where OperationNameValue endswith "diskEncryptionSets/delete" or (OperationNameValue endswith "/disks/write" and not(body has "diskEncryptionSetId"))
          | project TimeGenerated, Caller, ResourceId, OperationNameValue, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. Disk creation without a DES reference is the most common silent CMK-bypass; pair with an Azure Resource Graph daily reconciliation that lists all disks without a encryption.diskEncryptionSet property.

Alert threshold

  • Production disk created without a DES reference — page on first occurrence.
  • DES delete on a set referenced by any disk — page; existing disks survive the delete event but rewrap operations will fail.

Initial response

  1. Rewrap the disk via az disk update --disk-encryption-set {desId}; confirm the disk's encryption.type property updates to EncryptionAtRestWithCustomerKey.
  2. If a DES was deleted, reinstantiate it from the IaC source with the original Key Vault key reference; re-attach to all affected disks within the same maintenance window.
  3. Escalate per general/ir.html — confirm the Azure Policy Managed disks should be double encrypted with both platform-managed and customer-managed keys remains assigned at the management-group root.

References

Equivalent on: AWS · GCP · OCI

azure-data-04-sql-tde-cmk ! HIGH PREVENTIVE

Azure SQL Database, Azure SQL Managed Instance, and Azure Synapse SQL pools use Transparent Data Encryption (TDE) with a customer-managed key (BYOK from Key Vault) rather than the service-managed TDE certificate; SQL auditing is routed to the centralised Log Analytics workspace (LAW) referenced from Azure Logging; the Microsoft Defender for SQL plan is enabled on the server (advanced threat protection, SQL vulnerability assessment, anomalous-access detection) (Microsoft Learn — SQL TDE with BYOK overview (accessed 2026-05)). The server's system-assigned managed identity is granted the Key Vault Crypto Service Encryption User RBAC role on the vault key — the same binding pattern as Storage and Disk Encryption Set. Anti-conflation: TDE encrypts the database files on disk; it is not the same as Always Encrypted (which encrypts specific columns end-to-end with a key the database engine never sees), and it is not the same as TLS in transit (covered canonically at General Network — encryption in transit and enforced on Azure SQL via minimum_tls_version = "1.2"). HIGH PREVENTIVE because TDE BYOK gives the organisation the cryptographic-erase lever for an entire database, and Defender for SQL surfaces the most common SQL data-exfiltration patterns (anomalous query exfiltration volumes, login from unfamiliar geographies, vulnerability scanner output of weak DB-level permissions).

Remediation — Azure CLI

# Azure CLI 2.x
# Enable system-assigned managed identity on the SQL server.
az sql server update \
  --resource-group rg-data-prod-westeu \
  --name sql-app-prod-westeu \
  --identity-type SystemAssigned

# Grant the SQL server MI the data-plane role on the vault key.
SQL_MI=$(az sql server show \
  --resource-group rg-data-prod-westeu \
  --name sql-app-prod-westeu --query identity.principalId -o tsv)
az role assignment create \
  --role "Key Vault Crypto Service Encryption User" \
  --assignee "$SQL_MI" \
  --scope $(az keyvault show --name kv-app-prod-westeu --query id -o tsv)

# Set the TDE key to a Key Vault CMK.
KEY_URI=$(az keyvault key show \
  --vault-name kv-app-prod-westeu \
  --name cmk-sql-app-prod --query 'key.kid' -o tsv)
az sql server key create \
  --resource-group rg-data-prod-westeu \
  --server sql-app-prod-westeu \
  --kid "$KEY_URI"

az sql server tde-key set \
  --resource-group rg-data-prod-westeu \
  --server sql-app-prod-westeu \
  --server-key-type AzureKeyVault \
  --kid "$KEY_URI"

# Enable Microsoft Defender for SQL on the server (Standard tier).
az security pricing create --name SqlServers --tier Standard

# Route SQL audit to the central Log Analytics workspace.
az sql server audit-policy update \
  --resource-group rg-data-prod-westeu \
  --name sql-app-prod-westeu \
  --state Enabled \
  --log-analytics-target-state Enabled \
  --log-analytics-workspace-resource-id $(az monitor log-analytics workspace show --resource-group rg-sec-prod --workspace-name law-sec-prod --query id -o tsv)

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_mssql_server" "app" {
  name                         = "sql-app-prod-westeu"
  resource_group_name          = azurerm_resource_group.data.name
  location                     = azurerm_resource_group.data.location
  version                      = "12.0"
  administrator_login          = "sqladmin"   # rotated to Entra-only admin; password vaulted
  administrator_login_password = var.sql_admin_password
  minimum_tls_version          = "1.2"
  public_network_access_enabled = false

  identity {
    type = "SystemAssigned"
  }

  azuread_administrator {
    login_username = "sql-admins-prod"
    object_id      = azuread_group.sql_admins.object_id
  }
}

resource "azurerm_role_assignment" "sql_mi_kv" {
  scope                = azurerm_key_vault.app.id
  role_definition_name = "Key Vault Crypto Service Encryption User"
  principal_id         = azurerm_mssql_server.app.identity[0].principal_id
}

resource "azurerm_mssql_server_transparent_data_encryption" "app" {
  server_id        = azurerm_mssql_server.app.id
  key_vault_key_id = azurerm_key_vault_key.cmk_sql.id
}

# Defender for SQL — subscription plan.
resource "azurerm_security_center_subscription_pricing" "sql" {
  tier          = "Standard"
  resource_type = "SqlServers"
}

# Auditing to the central Log Analytics workspace.
resource "azurerm_mssql_server_extended_auditing_policy" "app" {
  server_id                               = azurerm_mssql_server.app.id
  log_monitoring_enabled                  = true
  retention_in_days                       = 365
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('SQL logical server name.')
param serverName string
@description('Versionless Key Vault key URI for TDE protector.')
param keyUri string
@description('User-assigned managed identity granted Get/WrapKey/UnwrapKey on the vault.')
param identityResourceId string

param location string = resourceGroup().location

resource sql 'Microsoft.Sql/servers@2023-08-01-preview' = {
  name: serverName
  location: location
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${identityResourceId}': {}
    }
  }
  properties: {
    minimalTlsVersion: '1.2'
    publicNetworkAccess: 'Disabled'
    primaryUserAssignedIdentityId: identityResourceId
    keyId: keyUri
  }
}

resource tdeProtector 'Microsoft.Sql/servers/encryptionProtector@2023-08-01-preview' = {
  parent: sql
  name: 'current'
  properties: {
    serverKeyType: 'AzureKeyVault'
    serverKeyName: replace(replace(keyUri, 'https://', ''), '.vault.azure.net/keys/', '_')
    autoRotationEnabled: true
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a4.1n/an/a SC-28; SC-13A.8.24n/a

Log signals

  • AzureActivity Microsoft.Sql/servers/databases/transparentDataEncryption/write where state = "Disabled" — turns off TDE entirely.
  • AzureActivity Microsoft.Sql/servers/keys/delete targeting a CMK that the server uses as TDE protector — downstream rewrap operations will fail.
  • AzureActivity Microsoft.Sql/servers/encryptionProtector/write where serverKeyType flips from AzureKeyVault back to ServiceManaged — silent revert to platform-managed key path.

Query

AzureActivity
          | where OperationNameValue in ("Microsoft.Sql/servers/databases/transparentDataEncryption/write", "Microsoft.Sql/servers/encryptionProtector/write", "Microsoft.Sql/servers/keys/delete")
          | extend body = tostring(parse_json(Properties).requestbody)
          | where body has "\"state\":\"Disabled\"" or body has "\"serverKeyType\":\"ServiceManaged\"" or OperationNameValue endswith "keys/delete"
          | project TimeGenerated, Caller, ResourceId, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. TDE disablement and CMK-to-ServiceManaged regression are rare and intentional; persist as a Sentinel analytics rule with severity High and require an attached governance ticket reference.

Alert threshold

  • TDE disable on any production SQL database — page immediately.
  • Encryption protector flip from AzureKeyVault to ServiceManaged on a production server — page; the bring-your-own-key posture has regressed.

Initial response

  1. Re-enable TDE via az sql db tde set --status Enabled; restore the CMK protector via az sql server tde-key set --server-key-type AzureKeyVault.
  2. If the key reference was lost, restore from soft-delete on the Key Vault and resync via az sql server tde-key set; capture the AzureActivity record as the recovery ledger.
  3. Escalate per general/ir.html — confirm the Azure Policy SQL servers should use customer-managed keys to encrypt data at rest remains assigned in deny mode at the management-group root.

References

Equivalent on: AWS · GCP · OCI

azure-data-05-keyvault-rbac ! CRITICAL PREVENTIVE

Every Key Vault in every subscription is created in RBAC mode (enable_rbac_authorization = true), not legacy access policies. RBAC role assignments are scoped to specific Microsoft Entra identities at vault or per-key scope using built-in data-plane roles — Key Vault Crypto User, Key Vault Crypto Service Encryption User, Key Vault Secrets User, Key Vault Certificates Officer. Soft-delete retention is set to 90 days; purge-protection is enabled (irreversibly — once on, never off); public network access is disabled and the vault is reached only via Private Endpoints from the workload VNets; eligible (just-in-time) elevations to administrative roles are routed through Microsoft Entra Privileged Identity Management (Microsoft Learn — Azure Key Vault RBAC guide (accessed 2026-05)).

Why RBAC over access policies — the three-paragraph explanation. First, access policies pre-date Azure RBAC by years and were designed before Microsoft Entra Privileged Identity Management existed. There is no first-class PIM integration with access policies: you cannot make a user eligible for a Key Vault permission with an approval workflow and a time-bound activation; the policy is either granted or not. RBAC role assignments are first-class PIM citizens — every built-in Key Vault data-plane role can be made eligible, time-bound, MFA-gated, and approval-gated. Second, access policies carry known security gaps. Permission grouping is coarser than RBAC roles (a single get + list + decrypt grant on a key cannot be cleanly decomposed into "read metadata only" vs "decrypt with key"); permissions are bypassable by the vault owner via portal operations that the access-policy enum does not enumerate; the policy is applied at vault scope only, not at per-key scope (RBAC role assignments can scope down to /keys/<keyname>). Third, the Key Vault ARM API version 2026-02-01 and later defaults new vaults to RBAC mode — Microsoft has converged the product on RBAC as the recommended posture, and the only reason to keep access policies on a vault today is to avoid migrating a legacy deployment whose pipeline still assumes access-policy semantics. New vaults must be RBAC; existing access-policy vaults should migrate (the vault setting can be flipped in-place; existing data-plane permissions need to be re-issued as RBAC role assignments before the flip).

Remediation — Azure CLI

# Azure CLI 2.x
# Create a Premium Key Vault in RBAC mode with the hardened defaults.
az keyvault create \
  --resource-group rg-data-prod-westeu \
  --name kv-app-prod-westeu \
  --location westeurope \
  --sku premium \
  --enable-rbac-authorization true \
  --enable-soft-delete true \
  --retention-days 90 \
  --enable-purge-protection true \
  --public-network-access Disabled \
  --default-action Deny

# Assign the application MI the data-plane role at per-key scope.
KEY_ID=$(az keyvault key show --vault-name kv-app-prod-westeu --name cmk-app-prod --query 'key.kid' -o tsv)
KEY_SCOPE="$(az keyvault show --name kv-app-prod-westeu --query id -o tsv)/keys/cmk-app-prod"
az role assignment create \
  --role "Key Vault Crypto User" \
  --assignee-object-id "$APP_MI_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --scope "$KEY_SCOPE"

# Make the human-admin role PIM-eligible only (not active).
# (az rest to Microsoft Graph — az CLI lacks first-class PIM verbs for resource-scope roles.)
# See: https://learn.microsoft.com/azure/role-based-access-control/pim-azure-resource
az rest --method PUT \
  --uri "https://management.azure.com/${KEY_SCOPE}/providers/Microsoft.Authorization/roleEligibilityScheduleRequests/$(uuidgen)?api-version=2020-10-01" \
  --body @pim-eligibility-kv-crypto-officer.json

# Audit: list every vault in the tenant that is still in access-policy mode.
for sub in $(az account list --query '[].id' -o tsv); do
  az keyvault list --subscription "$sub" \
    --query "[?properties.enableRbacAuthorization==\`false\` || properties.enableRbacAuthorization==null].{sub:'$sub', name:name, rg:resourceGroup}" \
    -o tsv
done

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_key_vault" "app" {
  name                          = "kv-app-prod-westeu"
  resource_group_name           = azurerm_resource_group.data.name
  location                      = azurerm_resource_group.data.location
  tenant_id                     = data.azurerm_client_config.current.tenant_id
  sku_name                      = "premium"

  # The locked-in posture: RBAC, soft-delete 90d, purge-protect, public off.
  enable_rbac_authorization     = true
  soft_delete_retention_days    = 90
  purge_protection_enabled      = true
  public_network_access_enabled = false

  network_acls {
    default_action = "Deny"
    bypass         = "AzureServices"
  }

  tags = { tier = "regulated", "crypto-root" = "true" }
}

# RBAC role assignment at per-key scope (not vault scope).
resource "azurerm_role_assignment" "app_mi_crypto_user" {
  scope                = "${azurerm_key_vault.app.id}/keys/${azurerm_key_vault_key.cmk_storage.name}"
  role_definition_name = "Key Vault Crypto User"
  principal_id         = azurerm_user_assigned_identity.app.principal_id
}

# Root-management-group initiative: deny Key Vault creation without RBAC mode.
resource "azurerm_management_group_policy_assignment" "deny_kv_access_policies" {
  name                 = "deny-kv-access-policies"
  management_group_id  = "/providers/Microsoft.Management/managementGroups/tenant-root"
  policy_definition_id = var.deny_kv_access_policies_initiative_id
  description          = "Key Vaults must have enable_rbac_authorization=true; legacy access policies prohibited on new vaults"
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Key Vault name (3-24 chars).')
@minLength(3)
@maxLength(24)
param vaultName string

@description('Entra ID tenant ID.')
param tenantId string = subscription().tenantId

param location string = resourceGroup().location

resource vault 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {
  name: vaultName
  location: location
  properties: {
    tenantId: tenantId
    sku: { family: 'A', name: 'standard' }
    enableRbacAuthorization: true
    enablePurgeProtection: true
    enableSoftDelete: true
    softDeleteRetentionInDays: 90
    publicNetworkAccess: 'Disabled'
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
  }
}

output vaultId string = vault.id

Remediation — Pulumi (TypeScript)

import * as pulumi from "@pulumi/pulumi";
import * as keyvault from "@pulumi/azure-native/keyvault";
import * as resources from "@pulumi/azure-native/resources";

const rg = new resources.ResourceGroup("kv-rg");
const tenantId = "<entra-tenant-id>";

new keyvault.Vault("hardened-vault", {
  resourceGroupName: rg.name,
  properties: {
    tenantId,
    sku: { family: "A", name: keyvault.SkuName.Standard },
    enableRbacAuthorization: true,
    enablePurgeProtection: true,
    enableSoftDelete: true,
    softDeleteRetentionInDays: 90,
    publicNetworkAccess: "Disabled",
    networkAcls: { defaultAction: "Deny", bypass: "AzureServices" },
  },
});

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a8.x (verify)n/an/a AC-6; SC-12A.5.15; A.8.24n/a

Log signals

  • AzureActivity Microsoft.KeyVault/vaults/write where enableRbacAuthorization flips from true back to false — re-enables the legacy access-policy path that lacks the Azure RBAC unified audit surface.
  • AzureActivity Microsoft.Authorization/roleAssignments/write granting Key Vault Administrator or Key Vault Crypto Officer to a principal outside the documented operator group — privilege creep on the crypto plane.
  • AzureDiagnostics Category AuditEvent on the Key Vault showing operationName = "VaultAccessPolicyChange" on a vault that should be RBAC-managed only.

Query

AzureActivity
          | where OperationNameValue in ("Microsoft.KeyVault/vaults/write", "Microsoft.Authorization/roleAssignments/write")
          | extend body = tostring(parse_json(Properties).requestbody)
          | where body has "\"enableRbacAuthorization\":false" or body has "Key Vault Administrator" or body has "Key Vault Crypto Officer"
          | project TimeGenerated, Caller, ResourceId, OperationNameValue, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. Persist as a Sentinel analytics rule with severity High; the Key Vault RBAC model is the only consistent way to enforce least-privilege across the crypto plane.

Alert threshold

  • Any flip of enableRbacAuthorization to false on a production vault — page on first occurrence.
  • Privileged crypto-role assignment to a principal outside the documented operator group — page; the holder can read every secret the vault contains.

Initial response

  1. Re-enable RBAC mode via az keyvault update --enable-rbac-authorization true; capture the AzureActivity Caller and the prior-state access policy snapshot for the ledger.
  2. Walk the Key Vault AuditEvent stream for the exposure window — any SecretGet, KeyGet, or CertificateGet issued by the privilege-creep principal should be reconciled with documented workload calls.
  3. Escalate per general/ir.html — rotate every secret read during the exposure window; confirm the Azure Policy Azure Key Vault should use RBAC permission model remains in deny mode.

References

Equivalent on: AWS · GCP · OCI

azure-data-06-keyvault-rotation ! MEDIUM DETECTIVE

Every Key Vault key used as a CMK has an attached auto-rotation policy that creates a new key version on a defined cadence (commonly 365 days, with a 30-day notification window before expiry); HSM-backed keys are used for high-sensitivity workloads (kty = "RSA-HSM" on a Premium-tier vault) (Microsoft Learn — Azure Key Vault keys, rotation policies and lifecycle (accessed 2026-05)). Rotation is implemented natively in Key Vault — no Azure Function, Logic App, or external orchestrator needed — via az keyvault key rotation-policy update or the Terraform rotation_policy block inside azurerm_key_vault_key. After rotation, downstream resources that reference the key by versionless URI (https://<vault>.vault.azure.net/keys/<name>) automatically pick up the new version on next DEK refresh; resources that reference a specific version need explicit re-pinning.

Why MEDIUM DETECTIVE and not PREVENTIVE — explicit reading of PITFALL B-14 (mirrors the Phase 6 decision on aws-data-06 KMS rotation and the Phase 5 decision on aws-iam-05 access-key rotation). Auto-rotation does not prevent compromise of the key — once an attacker has decrypted any data with key version N, they hold the plaintext of that data forever, regardless of how many subsequent rotations occur. What rotation does is bound the window in which a newly compromised key remains usable: post-rotation, the attacker holds key version N which still decrypts ciphertext encrypted under N, but cannot decrypt anything encrypted under N+1 (which is what the resources are now using after auto-rotation propagated). It also surfaces audit anomalies — a rotation that does not propagate to a downstream resource generates a missing-version Defender for Cloud recommendation that points at the un-converged resource. The control is therefore DETECTIVE: it surfaces unsafe state (stale key, un-propagated rotation) after the rotation has run, and its protective value is bounding the forward-compromise window rather than preventing the compromise itself.

Remediation — Azure CLI

# Azure CLI 2.x
# Define a rotation policy JSON (auto-rotate 30 days before expiry, expiry 1 year).
cat > rotation-policy.json <<'EOF'
{
  "lifetimeActions": [
    { "trigger": { "timeBeforeExpiry": "P30D" }, "action": { "type": "Rotate" } },
    { "trigger": { "timeBeforeExpiry": "P30D" }, "action": { "type": "Notify" } }
  ],
  "attributes": { "expiryTime": "P365D" }
}
EOF

# Attach the policy to a specific key.
az keyvault key rotation-policy update \
  --vault-name kv-app-prod-westeu \
  --name cmk-storage-app-prod \
  --value @rotation-policy.json

# Inspect the policy.
az keyvault key rotation-policy show \
  --vault-name kv-app-prod-westeu \
  --name cmk-storage-app-prod

# Audit: list every CMK key in the tenant that has NO rotation policy attached.
for sub in $(az account list --query '[].id' -o tsv); do
  for vault in $(az keyvault list --subscription "$sub" --query '[].name' -o tsv); do
    for key in $(az keyvault key list --vault-name "$vault" --query '[].name' -o tsv 2>/dev/null); do
      policy=$(az keyvault key rotation-policy show --vault-name "$vault" --name "$key" 2>/dev/null)
      [ -z "$policy" ] && echo "MISSING POLICY: sub=$sub vault=$vault key=$key"
    done
  done
done

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_key_vault_key" "cmk_with_rotation" {
  name         = "cmk-storage-app-prod"
  key_vault_id = azurerm_key_vault.app.id
  key_type     = "RSA-HSM"
  key_size     = 4096
  key_opts     = ["decrypt", "encrypt", "sign", "verify", "wrapKey", "unwrapKey"]

  rotation_policy {
    automatic {
      time_before_expiry = "P30D"     # rotate 30 days before expiry
    }
    expire_after         = "P365D"    # 1-year key lifetime
    notify_before_expiry = "P30D"     # notify 30 days before expiry
  }
}

# Activity Log alert on key rotation events (paired with azure-log-07 alerts).
resource "azurerm_monitor_activity_log_alert" "kv_key_rotated" {
  name                = "alert-kv-key-rotation"
  resource_group_name = azurerm_resource_group.data.name
  scopes              = [azurerm_key_vault.app.id]
  description         = "Notify on every Key Vault key rotation"

  criteria {
    category       = "Administrative"
    operation_name = "Microsoft.KeyVault/vaults/keys/rotate/action"
  }

  action {
    action_group_id = azurerm_monitor_action_group.security_oncall.id
  }
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Key Vault hosting the CMK.')
param vaultName string
@description('Key name (created with RBAC permissions on the vault).')
param keyName string

resource vault 'Microsoft.KeyVault/vaults@2024-04-01-preview' existing = {
  name: vaultName
}

resource key 'Microsoft.KeyVault/vaults/keys@2024-04-01-preview' = {
  parent: vault
  name: keyName
  properties: {
    kty: 'RSA'
    keySize: 4096
    rotationPolicy: {
      lifetimeActions: [
        {
          trigger: { timeAfterCreate: 'P365D' }
          action: { type: 'Rotate' }
        }
        {
          trigger: { timeBeforeExpiry: 'P30D' }
          action: { type: 'Notify' }
        }
      ]
      attributes: { expiryTime: 'P730D' }
    }
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a8.x (verify)n/an/a SC-12; SC-12(1)A.8.24n/a

Log signals

  • AzureDiagnostics Category AuditEvent on the Key Vault showing absence of KeyRotationPolicySet or KeyRotate events for more than 90 days on a key whose policy specifies rotation — coverage erosion on the rotation pipeline.
  • AzureActivity Microsoft.KeyVault/vaults/keys/rotationPolicy/write where the request body changes autoRotation.enabled from true to false.
  • AzureActivity edits removing key versions or shortening retentionInDays below the 90-day policy floor — silent rotation-history truncation.

Query

AzureDiagnostics
          | where ResourceProvider == "MICROSOFT.KEYVAULT"
          | where Category == "AuditEvent"
          | where OperationName in ("KeyRotationPolicySet", "KeyRotate", "KeyCreate")
          | summarize lastRotation = max(TimeGenerated) by Resource, ResourceId, key=id_s
          | where lastRotation < ago(90d)
          | order by lastRotation asc

Run as a KQL query in Log Analytics. The rotation-gap query is more reliable than the rotation-policy-disable event because it surfaces rotation pipelines that broke silently — automation failures, expired identities, removed permissions.

Alert threshold

  • Any key with rotation policy active and a last-rotation gap exceeding 90 days — page on first occurrence; investigate the automation that should have rotated it.
  • Rotation policy disable on a key that previously had it enabled — page; the policy is the load-bearing control.

Initial response

  1. Trigger a manual rotation via az keyvault key rotate --vault-name {v} --name {k}; confirm the next AuditEvent batch records the new version.
  2. If the automation identity has rotated permissions or expired credentials, restore the role assignment via the IaC baseline and rerun the rotation pipeline.
  3. Escalate per general/ir.html — confirm the Azure Policy Keys should have a rotation policy ensuring that their rotation is scheduled remains in deny mode and that the workload identities that pull the key support the new version transparently.

References

Equivalent on: AWS · GCP · OCI

azure-data-07-purview ! MEDIUM DETECTIVE

Deploy Microsoft Purview as the tenant-wide Data Map + Data Catalog and configure scans across Storage Accounts, Azure SQL, Cosmos DB, Azure Data Lake, and (where applicable) Power BI for sensitive classifications — PII (full names, government IDs, addresses), PHI (HIPAA-covered protected health information), PCI (cardholder data and primary account numbers), and any organisation-specific sensitivity labels (Microsoft Learn — Microsoft Purview documentation (accessed 2026-05)). Sensitivity labels are propagated downstream so applications, reports, and exports respect the classification; Purview integrates with Microsoft Defender for Cloud regulatory-compliance scoring so the data classification surfaces are visible in the same posture dashboard as the rest of the controls on this page. The principle is reinforced in General Data — data classification and General Data — data loss prevention.

Implementation note: as of the corpus-pinned AzureRM 3.x provider, only the azurerm_purview_account resource is first-class; scan policies, classification rules, and credential bindings on the Purview side are managed via the Purview governance portal or via direct az rest calls to the Microsoft Purview REST surface (and to the Microsoft Graph data-governance endpoints for sensitivity-label policy). The IaC block below codifies the account and identity wiring; the scan resources are documented as REST calls. AzureRM 4.x ships richer Purview resources; corpus convergence on 4.x will simplify this block. MEDIUM DETECTIVE because Purview surfaces unsafe state (PII landed in an unclassified Storage container, PHI moved into a development workspace, credit-card numbers in a chat-log Data Lake feed) after the fact — it does not prevent the upstream pipeline from writing the data; remediation is workflow-driven (Defender for Cloud recommendation → owner action) rather than block-at-write.

Remediation — Azure CLI

# Azure CLI 2.x
# Create the Purview account.
az purview account create \
  --resource-group rg-governance-prod \
  --name pv-tenant-prod \
  --location westeurope \
  --managed-resource-group-name rg-pv-tenant-prod-managed \
  --identity-type SystemAssigned

# Register a Storage data source and create a scan via the Purview REST API.
# (az purview scan resource group exposes account scope only as of writing; deeper scan
#  configuration is REST-driven on AzureRM 3.x.)
PV_FQDN=$(az purview account show --resource-group rg-governance-prod --name pv-tenant-prod --query 'properties.endpoints.scan' -o tsv)
az rest --method PUT \
  --uri "${PV_FQDN}/datasources/storage-app-prod?api-version=2022-07-01-preview" \
  --body '{
    "kind": "AzureStorage",
    "properties": {
      "endpoint": "https://stappprodwesteu001.blob.core.windows.net",
      "location": "westeurope",
      "resourceGroup": "rg-data-prod-westeu",
      "subscriptionId": "<sub-id>"
    }
  }'

# Grant the Purview MI Reader on the data subscriptions so it can enumerate sources.
az role assignment create \
  --role "Reader" \
  --assignee $(az purview account show --resource-group rg-governance-prod --name pv-tenant-prod --query identity.principalId -o tsv) \
  --scope /subscriptions/<data-sub-id>

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_purview_account" "tenant" {
  name                = "pv-tenant-prod"
  resource_group_name = azurerm_resource_group.governance.name
  location            = azurerm_resource_group.governance.location

  identity {
    type = "SystemAssigned"
  }

  managed_resource_group_name = "rg-pv-tenant-prod-managed"
  public_network_enabled      = false  # Private Endpoints only
}

# Purview MI reads metadata across the data subscriptions.
resource "azurerm_role_assignment" "purview_mi_reader" {
  for_each             = toset(var.data_subscription_ids)
  scope                = "/subscriptions/${each.value}"
  role_definition_name = "Reader"
  principal_id         = azurerm_purview_account.tenant.identity[0].principal_id
}

# Scan resources and classification rules are managed via az rest / Purview API
# until corpus convergence on AzureRM 4.x. Reference managed-policy resource:
# azurerm_purview_account exposes data_catalog_endpoint + scan_endpoint outputs.
output "purview_scan_endpoint" {
  value = azurerm_purview_account.tenant.scan_endpoint
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Microsoft Purview account name.')
param purviewName string

param location string = resourceGroup().location

resource purview 'Microsoft.Purview/accounts@2023-05-01-preview' = {
  name: purviewName
  location: location
  identity: { type: 'SystemAssigned' }
  sku: { name: 'Standard', capacity: 1 }
  properties: {
    publicNetworkAccess: 'Disabled'
    managedResourceGroupName: '${purviewName}-managed-rg'
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a(best-practices)n/an/a RA-5; SI-4A.5.12; A.5.13CLD.12.4.5

Log signals

  • AzureActivity Microsoft.Purview/accounts/write where publicNetworkAccess flips to Enabled or where managed VNet integration is removed — weakens the data-discovery plane's network posture.
  • AzureActivity Microsoft.Purview scan-rule changes that exclude data classifications (PII, PCI, GDPR) the org has committed to discovering — coverage erosion on the data-catalog signal.
  • AzureDiagnostics Category ScanStatusLogEvent showing repeated failures on a previously-healthy scan source — likely identity rotation broke the scan pipeline.

Query

AzureActivity
          | where OperationNameValue startswith "Microsoft.Purview/"
          | extend body = tostring(parse_json(Properties).requestbody)
          | where OperationNameValue endswith "/write" or OperationNameValue endswith "/delete"
          | project TimeGenerated, Caller, ResourceId, OperationNameValue, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. The Purview classification surface is downstream of every data-plane resource; persist as a Sentinel analytics rule and pair with a daily check that the row count in ScanStatusLogEvent grouped by source has not collapsed.

Alert threshold

  • Purview public-network-access flip to Enabled — page on first occurrence.
  • Three or more consecutive scan failures on the same source — page; treat as identity-or-network coverage failure rather than transient.

Initial response

  1. Reapply the Purview account network baseline via the IaC pipeline; confirm the next ScanStatusLogEvent batch shows successful runs for the affected sources.
  2. Walk classification deltas in the Purview catalog between the prior healthy scan and the current state — any data-store flagged as containing PII/PCI that subsequently lost the classification is candidate exfiltration / coverage failure.
  3. Escalate per general/ir.html — confirm the Purview managed-identity has the requisite Reader/Storage Blob Data Reader role on every documented data source.

References

Equivalent on: AWS · GCP · OCI

azure-data-08-immutable-blob ! MEDIUM PREVENTIVE

Regulated Blob Storage containers — audit log archives, financial-record archives, evidence-preservation containers for the IR workflow at Azure IR — have immutable blob storage enabled with time-based retention policies in Locked mode (no deletion or overwrite of any blob until the retention period elapses); for the highest-assurance posture, version-level immutability is enabled at the container level so each individual blob version can carry its own immutability policy independent of the container default (Microsoft Learn — Immutable Blob Storage overview (accessed 2026-05)). The Locked mode is the irreversibility lever: once a policy is locked (versus Unlocked, which can be shortened or removed by a tenant admin), even a subscription owner cannot delete or shorten the retention before expiry — the cryptographic guarantee against malicious-insider or compromised-admin destruction. This is the Azure analog of S3 Object Lock in Compliance mode (versus Governance mode, which preserves a BypassGovernanceRetention root override that a sufficiently-privileged attacker would simply acquire); the parallel is intentional and cross-linked in the AWS sibling at aws/data.html.

Operational notes. Immutable Blob Storage requires the Storage Account to have the blob-versioning feature enabled and change-feed enabled for audit-trail purposes. Retention periods are minimum 1 day, maximum 146000 days (~400 years); the most common posting for regulatory archives is 7 years (PCI DSS) or 10 years (HIPAA in some interpretations); for ransomware-resilient backup tiers, 30-90 days is typical. Legal hold is a separate but related primitive — tag-based, indefinite, attached and removed by users with the legal-hold permission — which composes with time-based retention (the longer of the two governs). MEDIUM PREVENTIVE because the value-add is in preventing tamper of records that must be preserved for compliance or forensic integrity, but the day-to-day operational risk for most workloads is low (only specific containers need immutability — turning it on tenant-wide would break normal data-lifecycle automation).

Remediation — Azure CLI

# Azure CLI 2.x
# Enable blob versioning + change feed on the Storage Account (prerequisites).
az storage account blob-service-properties update \
  --resource-group rg-data-prod-westeu \
  --account-name stauditprodwesteu001 \
  --enable-versioning true \
  --enable-change-feed true

# Create an immutable-by-default container with version-level immutability enabled.
az storage container create \
  --account-name stauditprodwesteu001 \
  --name audit-archive \
  --auth-mode login \
  --enable-vlw true

# Create a 7-year time-based retention policy in Locked mode.
az storage container immutability-policy create \
  --account-name stauditprodwesteu001 \
  --container-name audit-archive \
  --period 2557 \
  --allow-protected-append-writes true

# Lock the policy (irreversible: even Owner cannot shorten or remove).
ETAG=$(az storage container immutability-policy show \
  --account-name stauditprodwesteu001 --container-name audit-archive \
  --query 'etag' -o tsv)
az storage container immutability-policy lock \
  --account-name stauditprodwesteu001 \
  --container-name audit-archive \
  --if-match "$ETAG"

# Optional: legal hold for ad-hoc preservation requests.
az storage container legal-hold set \
  --account-name stauditprodwesteu001 \
  --container-name audit-archive \
  --tags "case-2026-001" "litigation-2025"

Remediation — Terraform

# Terraform AzureRM provider ~> 3.0
# Source: Microsoft Learn (accessed 2026-05)
resource "azurerm_storage_account" "audit" {
  name                     = "stauditprodwesteu001"
  resource_group_name      = azurerm_resource_group.data.name
  location                 = azurerm_resource_group.data.location
  account_tier             = "Standard"
  account_replication_type = "GZRS"
  min_tls_version          = "TLS1_2"

  public_network_access_enabled   = false
  allow_nested_items_to_be_public = false

  blob_properties {
    versioning_enabled            = true
    change_feed_enabled           = true
    change_feed_retention_in_days = 2557   # 7 years
  }

  network_rules {
    default_action = "Deny"
    bypass         = ["AzureServices", "Logging"]
  }
}

resource "azurerm_storage_container" "audit_archive" {
  name                   = "audit-archive"
  storage_account_name   = azurerm_storage_account.audit.name
  container_access_type  = "private"
}

# Locked time-based retention via azurerm_storage_blob_inventory_policy + the
# immutability_policy block on the container (AzureRM 3.x exposes the latter via
# the management-plane Storage management-policy + the data-plane container ops;
# the canonical Locked-mode retention is applied via az CLI block above and
# tracked here as an azapi_resource in pure-IaC pipelines until 4.x).
resource "azapi_resource" "audit_immutability" {
  type      = "Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies@2023-01-01"
  name      = "default"
  parent_id = azurerm_storage_container.audit_archive.resource_manager_id

  body = jsonencode({
    properties = {
      immutabilityPeriodSinceCreationInDays = 2557
      allowProtectedAppendWrites            = true
    }
  })
}

Remediation — Bicep

targetScope = 'resourceGroup'

@description('Storage account hosting WORM-locked container.')
param storageName string
@description('Container name (must be created beforehand or in same template).')
param containerName string

resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = {
  name: storageName
}

resource blobSvc 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' existing = {
  parent: storage
  name: 'default'
}

resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = {
  parent: blobSvc
  name: containerName
  properties: {
    publicAccess: 'None'
    immutableStorageWithVersioning: { enabled: true }
  }
}

resource policy 'Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies@2024-01-01' = {
  parent: container
  name: 'default'
  properties: {
    immutabilityPeriodSinceCreationInDays: 2557 // 7y retention
    allowProtectedAppendWrites: false
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a3.x (verify)n/an/a CP-9; SI-7A.8.13n/a

Log signals

  • AzureActivity Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies/delete on a container holding compliance archive blobs — silently removes the WORM enforcement.
  • AzureActivity Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies/extend where the retention interval is reduced below the policy floor (e.g. 2557 days for SEC 17a-4).
  • AzureActivity Microsoft.Storage/storageAccounts/write where the request body sets immutableStorageWithVersioning.enabled to false at the account scope.

Query

AzureActivity
          | where OperationNameValue has "immutabilityPolicies" or (OperationNameValue == "Microsoft.Storage/storageAccounts/write" and tostring(parse_json(Properties).requestbody) has "immutableStorageWithVersioning")
          | extend body = tostring(parse_json(Properties).requestbody)
          | project TimeGenerated, Caller, ResourceId, OperationNameValue, body
          | order by TimeGenerated desc
          | take 200

Run as a KQL query in Log Analytics. Immutability policies are by design write-once on the locked-policy path; persist as a Sentinel analytics rule with severity High and pair with a four-eyes change-ticket check on every policy mutation.

Alert threshold

  • Immutability policy delete on any container holding production archives — page immediately.
  • Retention reduction below the documented compliance floor — page; the policy is no longer SEC/FINRA defensible.

Initial response

  1. Restore the immutability policy via the IaC baseline; confirm the policy state is Locked rather than Unlocked via az storage container immutability-policy show.
  2. Inventory blob deltas via blob inventory rules between the prior immutable state and the current state — any object that has been overwritten or deleted during the exposure window may need restoration from the soft-delete archive.
  3. Escalate per general/ir.html — confirm the Azure Policy Storage accounts should have immutable storage enabled remains in deny mode and that the storage-account write RBAC is bound to the change-management service principal only.

References

Equivalent on: AWS · GCP · OCI (AWS sibling aws-data-08 deferred in Phase 6; page-level link retained until re-introduction.)

Sources