Enforcing Microsoft Defender for Cloud Across 30+ Subscriptions With Bicep
Security posture management is one of those things that starts manually and stays manual for too long. Someone enables Defender for Cloud on a subscription in the portal, picks a few plans, saves. Six months later a new subscription appears, nobody remembers exactly which plans were enabled on the others, and the configuration drifts. An audit comes along and suddenly you’re comparing screenshots.
In a recent engagement I managed Defender for Cloud across dozens of Azure subscriptions — dev/test sandboxes, team playgrounds, and production workloads — under a common management group hierarchy. The goal was to make the desired security posture declarative, version-controlled, and consistently applied with a single deployment command. This post walks through the Bicep module that does that. What is discusses here is in real live part of a bigger initiative used to deploy new Landing Zones for new teams, but the Defender configuration is reusable in any management group structure.
The Problem
Microsoft Defender for Cloud is configured at the subscription level. There’s no built-in way to define a policy at the management group level that says “enable these plans on all subscriptions below this node.” Azure Policy can remediate some settings, but full Defender plan configuration — including sub-plans and per-plan extensions — isn’t fully coverable that way.
The portal approach doesn’t scale. Clicking through twelve plan toggles per subscription, times thirty subscriptions, is both slow and unreliable. And the settings you want aren’t identical across all subscriptions: you might want agentless VM scanning in production (expensive, thorough) but not in dev/test (cost conscious), and you want those differences to be explicit and reviewable rather than implicit and accidental.
The Architecture
Two Bicep files, two different scopes:
modules/defender/main.bicep— the reusable module. Scoped to a single subscription. ConfiguresMicrosoft.Security/pricings(one resource per Defender plan) and optionallyMicrosoft.Security/securityContacts.main/defender/main.bicep— the management group orchestrator. Scoped to the management group. Accepts a list of subscriptions, each tagged as production or non-production, and loops over them calling the subscription-scoped module for each one.
targetScope = 'managementGroup'
module defender 'modules/defender/main.bicep' = [for sub in subscriptions: {
name: 'defender-${sub.subscriptionId}'
scope: subscription(sub.subscriptionId)
params: {
defenderPlans: sub.environmentType == 'production'
? productionConfig.defenderPlans
: nonProductionConfig.defenderPlans
securityContact: sub.environmentType == 'production'
? productionConfig.?securityContact
: nonProductionConfig.?securityContact
}
}]
The ternary on environmentType is the entire production/non-production switching logic. Two config objects, one condition. Each subscription gets the right plan set without per-subscription overrides.
The Module
The inner module (modules/defender/main.bicep) is deliberately thin. Its only job is to turn a list of plan definitions into Microsoft.Security/pricings resources:
@batchSize(1)
resource pricings 'Microsoft.Security/pricings@2024-01-01' = [for plan in defenderPlans: {
name: plan.name
properties: {
pricingTier: plan.pricingTier
subPlan: plan.?subPlan
extensions: plan.?extensions
}
}]
The @batchSize(1) decorator is important. The API does not handle concurrent pricing updates well, deploying all plans in parallel causes intermittent failures. Forcing sequential deployment is slower but reliable.
The type system gives callers Bicep-level validation on the config:
@export()
type defenderPlanType = {
name: string
pricingTier: pricingTierType // 'Standard' | 'Free'
subPlan: string?
extensions: planExtensionType[]?
}
@export()
type planExtensionType = {
name: string
isEnabled: 'True' | 'False' // string, not bool — API quirk
}
Note the isEnabled field: the Defender API expects the string literals "True" and "False", not JSON booleans. The type declaration makes this explicit so callers don’t have to discover it from a failed deployment.
Production vs. Non-Production Plans
The two configs share most plans but differ in a few deliberate places.
VirtualMachines Sub-Plan
| Environment | Sub-plan | Agentless scanning | File integrity monitoring |
|---|---|---|---|
| Production | P2 | Enabled | Enabled |
| Non-production | P1 | Not configured | Not configured |
P1 does not support agentless VM scanning — the comment in the param file calls this out explicitly. Using P2 in non-production would add cost without a proportional security benefit.
StorageAccounts — SensitiveDataDiscovery
Production enables sensitive data discovery on storage accounts (detects secrets, PII, credentials in blobs). Non-production has it disabled — dev/test storage is less likely to hold real sensitive data, and the noise-to-signal ratio of flagging test fixtures isn’t worth it.
Everything else is identical between the two environments.
Production config
param productionConfig = {
defenderPlans: [
{
name: 'CloudPosture'
pricingTier: 'Standard'
subPlan: null
extensions: [
{ name: 'AgentlessVmScanning', isEnabled: 'True' }
{ name: 'AgentlessDiscoveryForKubernetes', isEnabled: 'True' }
{ name: 'SensitiveDataDiscovery', isEnabled: 'True' }
{ name: 'ContainerRegistriesVulnerabilityAssessments', isEnabled: 'True' }
{ name: 'EntraPermissionsManagement', isEnabled: 'False' }
]
}
{
name: 'VirtualMachines'
pricingTier: 'Standard'
subPlan: 'P2'
extensions: [
{ name: 'MdeDesignatedSubscription', isEnabled: 'False' }
{ name: 'AgentlessVmScanning', isEnabled: 'True' }
{ name: 'FileIntegrityMonitoring', isEnabled: 'True' }
]
}
{ name: 'SqlServers', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'SqlServerVirtualMachines', pricingTier: 'Standard', subPlan: null, extensions: null }
{
name: 'StorageAccounts'
pricingTier: 'Standard'
subPlan: 'DefenderForStorageV2'
extensions: [
{ name: 'OnUploadMalwareScanning', isEnabled: 'True' }
{ name: 'SensitiveDataDiscovery', isEnabled: 'True' }
]
}
{ name: 'AppServices', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'KeyVaults', pricingTier: 'Standard', subPlan: 'PerKeyVault', extensions: null }
{ name: 'Arm', pricingTier: 'Standard', subPlan: 'PerSubscription', extensions: null }
{
name: 'Containers'
pricingTier: 'Standard'
subPlan: null
extensions: [
{ name: 'AgentlessDiscoveryForKubernetes', isEnabled: 'True' }
{ name: 'ContainerRegistriesVulnerabilityAssessments', isEnabled: 'True' }
]
}
{ name: 'OpenSourceRelationalDatabases', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'Api', pricingTier: 'Free', subPlan: null, extensions: null }
{ name: 'Ai', pricingTier: 'Free', subPlan: null, extensions: null }
]
}
Non-production config
param nonProductionConfig = {
defenderPlans: [
{
name: 'CloudPosture'
pricingTier: 'Standard'
subPlan: null
extensions: [
{ name: 'AgentlessVmScanning', isEnabled: 'True' }
{ name: 'AgentlessDiscoveryForKubernetes', isEnabled: 'True' }
{ name: 'SensitiveDataDiscovery', isEnabled: 'True' }
{ name: 'ContainerRegistriesVulnerabilityAssessments', isEnabled: 'True' }
{ name: 'EntraPermissionsManagement', isEnabled: 'False' }
]
}
{
name: 'VirtualMachines'
pricingTier: 'Standard'
subPlan: 'P1'
extensions: null // P1 does not support agentless VM scanning
}
{ name: 'SqlServers', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'SqlServerVirtualMachines', pricingTier: 'Standard', subPlan: null, extensions: null }
{
name: 'StorageAccounts'
pricingTier: 'Standard'
subPlan: 'DefenderForStorageV2'
extensions: [
{ name: 'OnUploadMalwareScanning', isEnabled: 'True' }
{ name: 'SensitiveDataDiscovery', isEnabled: 'False' }
]
}
{ name: 'AppServices', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'KeyVaults', pricingTier: 'Standard', subPlan: 'PerKeyVault', extensions: null }
{ name: 'Arm', pricingTier: 'Standard', subPlan: 'PerSubscription', extensions: null }
{
name: 'Containers'
pricingTier: 'Standard'
subPlan: null
extensions: [
{ name: 'AgentlessDiscoveryForKubernetes', isEnabled: 'True' }
{ name: 'ContainerRegistriesVulnerabilityAssessments', isEnabled: 'True' }
]
}
{ name: 'OpenSourceRelationalDatabases', pricingTier: 'Standard', subPlan: null, extensions: null }
{ name: 'Api', pricingTier: 'Free', subPlan: null, extensions: null }
{ name: 'Ai', pricingTier: 'Free', subPlan: null, extensions: null }
]
}
The Subscription List
The param file holds the full list of subscriptions to manage. Each entry is two fields:
param subscriptions = [
{ subscriptionId: '<non-production-subscription-id>', environmentType: 'non-production' } // Enterprise Dev/Test
{ subscriptionId: '<production-subscription-id>', environmentType: 'production' } // Production workloads
...
]
The comment on each line names the subscription, this is the human-readable audit trail. When someone asks “which subscriptions have Defender enabled and with which configuration,” the answer is in version control.
Running It
The orchestrator is a management group deployment. You need a service principal or managed identity with the proper role at the management group level.
What-if
Run a what-if first to see exactly what will be created or changed before committing:
az deployment mg what-if \
--management-group-id <mg-id> \
--location westeurope \
--template-file main/defender/main.bicep \
--parameters main/defender/main.bicepparam
The what-if output will show one set of pricing resources per subscription — useful for confirming the plan list before committing.
Deploy
az deployment mg create \
--management-group-id <mg-id> \
--location westeurope \
--template-file main/defender/main.bicep \
--parameters main/defender/main.bicepparam
Because the pricing resources are deployed @batchSize(1), expect the deployment to take a few minutes per subscription. With thirty subscriptions and twelve plans each, a full run takes a while.