Analyze/tests/02-Governance.ps1
|
#Governance: security baseline assignment, exemptions, locks and asset hygiene $mcsbInitiatives = @{ 'e3ec7e09-768c-4b64-882c-fcada3772047' = 'Microsoft cloud security benchmark v2' '1f3afdf9-d0c9-4c3d-847f-89da613e70a8' = 'Microsoft cloud security benchmark' } function Get-McsbAssignments { Get-PolicyAssignments | Where-Object { (($_.properties.policyDefinitionId -split '/')[-1]).ToLowerInvariant() -in $mcsbInitiatives.Keys } } Add-AzTest @{ Id = 'AZ-GOV-001' Title = 'The Microsoft cloud security benchmark initiative is assigned' Category = 'Posture and vulnerability management' Service = 'Azure Policy' Severity = 'Medium' Description = 'Checks that the Microsoft cloud security benchmark (v2 or v1) policy initiative is assigned to the subscription or an ancestor management group.' Rationale = 'The benchmark initiative is the security baseline that Defender for Cloud uses for recommendations and secure score. Without it, misconfigurations are not measured continuously.' Remediation = 'Assign the Microsoft cloud security benchmark v2 initiative (e3ec7e09-768c-4b64-882c-fcada3772047) at the management group or subscription, or enable it as a standard in Defender for Cloud > Environment settings > Security policies.' References = @('https://learn.microsoft.com/azure/defender-for-cloud/concept-regulatory-compliance-standards') Requires = @('policy/policyAssignments') Run = { $assignments = @(Get-McsbAssignments) $evidence = [ordered]@{ assignments = @($assignments | ForEach-Object { "$($mcsbInitiatives[(($_.properties.policyDefinitionId -split '/')[-1]).ToLowerInvariant()]) @ $($_.properties.scope)" } | Sort-Object) } if (-not $assignments) { return New-SubscriptionFinding (New-Fail 'The Microsoft cloud security benchmark initiative is not assigned' $evidence) } New-SubscriptionFinding (New-Pass "Assigned: $($evidence.assignments -join '; ')" $evidence) } } Add-AzTest @{ Id = 'AZ-GOV-002' Title = 'Microsoft cloud security benchmark policies are not disabled' Category = 'Posture and vulnerability management' Service = 'Azure Policy' Severity = 'Medium' Description = "Lists effect parameters set to 'Disabled' in the Microsoft cloud security benchmark assignments." Rationale = 'Disabling benchmark policies removes the corresponding recommendations from Defender for Cloud and the secure score, hiding misconfigurations instead of handling them.' Remediation = "Set the effect parameters back to their default (Audit/AuditIfNotExists). Handle justified deviations with policy exemptions that have an owner, reason and expiry date." References = @('https://learn.microsoft.com/azure/defender-for-cloud/tutorial-security-policy') Requires = @('policy/policyAssignments') Run = { $assignments = @(Get-McsbAssignments) if (-not $assignments) { return New-SubscriptionFinding (New-NotApplicable 'The benchmark initiative is not assigned (see AZ-GOV-001)') } foreach ($assignment in $assignments) { $disabled = @($assignment.properties.parameters.PSObject.Properties | Where-Object { $_.Value.value -eq 'Disabled' } | ForEach-Object Name | Sort-Object) $evidence = [ordered]@{ scope = $assignment.properties.scope; enforcementMode = $assignment.properties.enforcementMode; disabledParameters = $disabled } $result = if ($disabled) { New-Fail "$($disabled.Count) benchmark policy effect(s) set to Disabled" $evidence } else { New-Pass 'No benchmark policies disabled' $evidence } New-Finding -ResourceId $assignment.id -ResourceType $assignment.type -ResourceName $assignment.properties.displayName -Result $result } } } Add-AzTest @{ Id = 'AZ-GOV-003' Title = 'Policy waivers have an expiry date' Category = 'Posture and vulnerability management' Service = 'Azure Policy' Severity = 'Low' Description = "Checks policy exemptions of category 'Waiver' for an expiry date." Rationale = 'A waiver accepts a risk. Without an expiry date the accepted risk is never re-evaluated and exemptions accumulate silently.' Remediation = 'Set expiresOn on every waiver and review it before it expires; use the Mitigated category for exemptions that are covered by another control.' References = @('https://learn.microsoft.com/azure/governance/policy/concepts/exemption-structure') Requires = @('policy/policyExemptions') Run = { $waivers = @(Get-IngestData 'policy/policyExemptions' | Where-Object { $_ -and $_.properties.exemptionCategory -eq 'Waiver' }) if (-not $waivers) { return New-SubscriptionFinding (New-Pass 'No policy waivers') } foreach ($waiver in $waivers) { $evidence = [ordered]@{ displayName = $waiver.properties.displayName; policyAssignmentId = $waiver.properties.policyAssignmentId; expiresOn = Format-UtcDate $waiver.properties.expiresOn } $result = if ($waiver.properties.expiresOn) { New-Pass "Waiver expires $($evidence.expiresOn)" $evidence } else { New-Fail 'Waiver without expiry date' $evidence } New-Finding -ResourceId $waiver.id -ResourceType $waiver.type -ResourceName $waiver.properties.displayName -Result $result } } } #CIS 9.3.9 and 9.3.10 are storage specific, so storage accounts have their own lock tests (AZ-STG-025, AZ-STG-026) #and this one covers the remaining recovery critical types under the generic CIS 6.2 Add-AzTest @{ Id = 'AZ-GOV-004' Version = 2 Title = 'Critical data and recovery resources have a delete lock' Category = 'Backup and recovery' Service = 'Azure Resource Manager' Severity = 'Medium' Description = 'Checks key vaults, Recovery Services vaults and Backup vaults for a CanNotDelete or ReadOnly lock on the resource, its resource group or the subscription. Storage accounts are covered by AZ-STG-025.' Rationale = 'Locks prevent accidental or malicious deletion of resources whose loss destroys data, keys or backups. Deleting a lock needs Microsoft.Authorization/locks/delete, which most operators do not hold.' Remediation = 'Add a CanNotDelete lock (az lock create --lock-type CanNotDelete --name DoNotDelete --resource <id>) and restrict lock administration to a dedicated role.' References = @('https://learn.microsoft.com/azure/azure-resource-manager/management/lock-resources') Requires = @('subscription/locks') ResourceTypes = @('Microsoft.KeyVault/vaults', 'Microsoft.RecoveryServices/vaults', 'Microsoft.DataProtection/backupVaults') Evaluate = { param($Record) $locks = @(Get-EffectiveLocks $Record.id) $evidence = [ordered]@{ locks = @($locks | ForEach-Object { "$($_.properties.level) @ $($_.id -replace '(?i)/providers/Microsoft\.Authorization/locks/.*$', '')" } | Sort-Object) } if ($locks) { return New-Pass "Locked ($($locks[0].properties.level))" $evidence } New-Fail 'No delete lock on the resource, resource group or subscription' $evidence } } Add-AzTest @{ Id = 'AZ-GOV-005' Title = 'A custom role for administering resource locks exists' Category = 'Privileged access' Service = 'Azure RBAC' Severity = 'Low' Description = 'Checks for a custom role that grants Microsoft.Authorization/locks permissions, so lock administration can be delegated without Owner or User Access Administrator.' Rationale = 'Only Owner and User Access Administrator can manage locks by default. A dedicated role lets a small group manage locks while keeping them out of reach of everyone else.' Remediation = "Create a custom role with Microsoft.Authorization/locks/* and assign it (PIM eligible) to the team responsible for locks." References = @('https://learn.microsoft.com/azure/azure-resource-manager/management/lock-resources') Requires = @('rbac/roleDefinitions') Run = { $roles = @(Get-IngestData 'rbac/roleDefinitions' | Where-Object { $_ -and $_.properties.type -eq 'CustomRole' -and (@($_.properties.permissions | ForEach-Object { $_.actions }) | Where-Object { $_ -like 'Microsoft.Authorization/locks/*' }) }) $evidence = [ordered]@{ lockRoles = @($roles | ForEach-Object { $_.properties.roleName } | Sort-Object) } if ($roles) { return New-SubscriptionFinding (New-Pass "Lock administrator role(s): $($evidence.lockRoles -join ', ')" $evidence) } New-SubscriptionFinding (New-Fail 'No custom role for administering resource locks' $evidence) } } Add-AzTest @{ Id = 'AZ-GOV-006' Title = 'No unattached managed disks' Category = 'Asset management' Service = 'Compute' Severity = 'Low' Description = 'Finds managed disks that are not attached to any virtual machine.' Rationale = 'Orphaned disks keep copies of data (often including credentials and system state) outside of any lifecycle, monitoring or backup process.' Remediation = 'Delete disks that are no longer needed, after checking whether they must be retained; snapshot them to a governed location if retention is required.' ResourceTypes = @('Microsoft.Compute/disks') Evaluate = { param($Record) $evidence = [ordered]@{ diskState = $Record.resource.properties.diskState; managedBy = $Record.resource.managedBy } if ($Record.resource.properties.diskState -eq 'Unattached') { return New-Fail 'Disk is not attached to a virtual machine' $evidence } New-Pass "Disk state $($Record.resource.properties.diskState)" $evidence } } Add-AzTest @{ Id = 'AZ-GOV-007' Title = 'No unassociated public IP addresses' Category = 'Asset management' Service = 'Networking' Severity = 'Low' Description = 'Finds public IP addresses that are not associated with a network interface, load balancer, gateway or NAT gateway.' Rationale = 'Unused public IP addresses are forgotten attack surface: they are easily re-associated with a resource, and DNS records pointing to them can be abused.' Remediation = 'Delete public IP addresses that are not in use and remove DNS records that point to them.' ResourceTypes = @('Microsoft.Network/publicIPAddresses') Evaluate = { param($Record) $p = $Record.resource.properties $evidence = [ordered]@{ ipAddress = $p.ipAddress; associatedWith = if ($p.ipConfiguration) { $p.ipConfiguration.id } elseif ($p.natGateway) { $p.natGateway.id } else { $null } } if ($evidence.associatedWith) { return New-Pass 'Associated' $evidence } New-Fail 'Public IP address is not associated with any resource' $evidence } } Add-AzTest @{ Id = 'AZ-GOV-008' Version = 3 Title = 'No retired or classic services are in use' Category = 'Asset management' Service = 'Azure Resource Manager' Severity = 'Medium' Description = 'Finds classic (ASM) resources and resources whose service is retired or has a published retirement date: Azure Database for PostgreSQL single server, Azure Database for MySQL single server, Azure Database for MariaDB, general-purpose v1 and legacy Blob Storage accounts (retire 13 October 2026) and Azure Blueprints (retires 31 January 2027).' Rationale = 'Retired services no longer receive security updates or support, and classic resources lack Azure Resource Manager RBAC, policy and logging controls. A service with a published retirement date needs a migration plan before the deadline, not after.' Remediation = 'Migrate to the supported successor (Azure Resource Manager resources, PostgreSQL or MySQL flexible server, a general-purpose v2 storage account, deployment stacks and template specs for blueprints) and delete the retired resources.' References = @('https://learn.microsoft.com/azure/postgresql/migrate/whats-happening-to-postgresql-single-server', 'https://learn.microsoft.com/azure/storage/common/general-purpose-version-1-account-migration-overview', 'https://learn.microsoft.com/azure/storage/common/legacy-blob-storage-account-migration-overview', 'https://learn.microsoft.com/azure/governance/blueprints/blueprint-retirement') Requires = @('subscription/resources') Run = { #resource type -> why it is on the list, so the finding says what is actually wrong $retiredTypes = [ordered]@{ 'Microsoft.DBforPostgreSQL/servers' = 'Azure Database for PostgreSQL single server is retired' 'Microsoft.DBforMySQL/servers' = 'Azure Database for MySQL single server is retired' 'Microsoft.DBforMariaDB/servers' = 'Azure Database for MariaDB is retired' 'Microsoft.Blueprint/blueprintAssignments' = 'Azure Blueprints is deprecated and retires on 31 January 2027' } #storage account kind -> why it is on the list $retiredStorageKinds = [ordered]@{ 'Storage' = 'General-purpose v1 storage accounts retire on 13 October 2026' 'BlobStorage' = 'Legacy Blob Storage accounts retire on 13 October 2026' } $resources = @(Get-IngestData 'subscription/resources' | Where-Object { $_ }) $isRetiredStorage = { param($Resource) [string]$Resource.type -eq 'Microsoft.Storage/storageAccounts' -and $retiredStorageKinds.Contains([string]$Resource.kind) } $retired = @($resources | Where-Object { $_.type -match '^Microsoft\.Classic' -or $retiredTypes.Contains([string]$_.type) -or (& $isRetiredStorage $_) }) foreach ($resource in $retired) { $reason = if ($retiredTypes.Contains([string]$resource.type)) { $retiredTypes[[string]$resource.type] } elseif (& $isRetiredStorage $resource) { $retiredStorageKinds[[string]$resource.kind] } else { "$($resource.type) is a classic (ASM) resource type" } $evidence = [ordered]@{ type = $resource.type; location = $resource.location } if ($resource.kind) { $evidence.kind = $resource.kind } New-Finding -ResourceId $resource.id -ResourceType $resource.type -Result (New-Fail $reason $evidence) } #blueprint assignments are not in the resource list; the call 404s when the provider is not registered, #which is a legitimate "none" rather than a collection failure, so it is reported separately $blueprintScope = "$(Get-SubscriptionScope)/providers/Microsoft.Blueprint/blueprintAssignments" if (-not (Test-IngestSection 'subscription/blueprintAssignments')) { New-Finding -ResourceId $blueprintScope -ResourceType 'Microsoft.Blueprint/blueprintAssignments' -ResourceName 'blueprintAssignments' -Result (New-Unknown 'Blueprint assignments could not be read, so their retirement could not be checked') } else { foreach ($blueprint in @(Get-IngestData 'subscription/blueprintAssignments' | Where-Object { $_ })) { New-Finding -ResourceId ([string]$blueprint.id) -ResourceType 'Microsoft.Blueprint/blueprintAssignments' -Result (New-Fail $retiredTypes['Microsoft.Blueprint/blueprintAssignments'] ([ordered]@{ blueprintId = $blueprint.properties.blueprintId })) } } if (-not $retired) { New-SubscriptionFinding (New-Pass 'No retired or classic resource types') } } } Add-AzTest @{ Id = 'AZ-GOV-009' Title = 'Resources comply with the Azure Policy definitions assigned to them' Category = 'Posture and vulnerability management' Service = 'Azure Policy' Severity = 'Medium' Description = 'Reads the per resource policy compliance states from Azure Resource Graph and reports one finding per policy definition that has non-compliant resources. This measures the outcome of the assigned policies, where AZ-GOV-001 and AZ-GOV-002 only check that the benchmark initiative is assigned and not disabled.' Rationale = 'An assigned policy only improves security once resources actually comply with it. Non-compliant resources are the concrete deviations from the baseline the organization committed to.' Remediation = 'Work through the non-compliant resources per definition in Policy > Compliance, remediate them (deployIfNotExists policies can be remediated in bulk with a remediation task), and record accepted deviations as policy exemptions with an owner and expiry date.' References = @('https://learn.microsoft.com/azure/governance/policy/how-to/get-compliance-data') Requires = @('resourceGraph/policyresources') Run = { $states = @(Get-IngestData 'resourceGraph/policyresources' | Where-Object { $_ -and $_.type -eq 'microsoft.policyinsights/policystates' }) if (-not $states) { return New-SubscriptionFinding (New-NotApplicable 'Azure Resource Graph returned no policy compliance states') } #policy states carry the definition id only, so resolve the display name to keep findings readable $definitionNames = @{} foreach ($definition in @(Get-IngestData 'policy/policyDefinitions' | Where-Object { $_ })) { if ($definition.id -and $definition.properties.displayName) { $definitionNames[([string]$definition.id).ToLowerInvariant()] = [string]$definition.properties.displayName } } $byDefinition = @{} foreach ($state in $states) { $key = [string]$state.properties.policyDefinitionId if (-not $key) { continue } if (-not $byDefinition.ContainsKey($key)) { $byDefinition[$key] = [pscustomobject]@{ Compliant = 0; NonCompliant = 0; Assignment = $state.properties.policyAssignmentName; Resources = [System.Collections.Generic.List[string]]::new() } } if ($state.properties.complianceState -eq 'NonCompliant') { $byDefinition[$key].NonCompliant++ if ($byDefinition[$key].Resources.Count -lt 20) { $byDefinition[$key].Resources.Add([string]$state.properties.resourceId) } } elseif ($state.properties.complianceState -eq 'Compliant') { $byDefinition[$key].Compliant++ } } foreach ($key in ($byDefinition.Keys | Sort-Object)) { $item = $byDefinition[$key] if ($item.NonCompliant -eq 0 -and $item.Compliant -eq 0) { continue } $name = $definitionNames[$key.ToLowerInvariant()] if (-not $name) { $name = Get-ResourceName $key } $evidence = [ordered]@{ policyDefinitionId = $key; policyAssignment = $item.Assignment; nonCompliantResources = $item.NonCompliant; compliantResources = $item.Compliant; examples = @($item.Resources | Sort-Object) } $result = if ($item.NonCompliant) { New-Fail "$($item.NonCompliant) of $($item.NonCompliant + $item.Compliant) resources do not comply with '$name'" $evidence } else { New-Pass "All $($item.Compliant) evaluated resources comply with '$name'" $evidence } New-Finding -ResourceId $key -ResourceType 'Microsoft.Authorization/policyDefinitions' -ResourceName $name -Result $result } } } Add-AzTest @{ Id = 'AZ-GOV-010' Title = 'Azure Advisor security recommendations are resolved' Category = 'Posture and vulnerability management' Service = 'Azure Advisor' Severity = 'Medium' Description = 'Reads the Azure Advisor recommendations of category Security from Azure Resource Graph and reports one finding per open recommendation. Advisor surfaces Defender for Cloud recommendations plus platform advice that the other tests here do not cover.' Rationale = 'Advisor security recommendations are the platform telling you about concrete, already detected weaknesses in this subscription. Leaving them open means known issues stay unfixed.' Remediation = 'Work through the recommendations in Advisor > Security, remediate or dismiss each one with a reason, and treat high impact recommendations first.' References = @('https://learn.microsoft.com/azure/advisor/advisor-security-recommendations') Requires = @('resourceGraph/advisorresources') Run = { $recommendations = @(Get-IngestData 'resourceGraph/advisorresources' | Where-Object { $_ -and $_.type -eq 'microsoft.advisor/recommendations' -and $_.properties.category -eq 'Security' }) if (-not $recommendations) { return New-SubscriptionFinding (New-Pass 'Azure Advisor reports no open security recommendations') } foreach ($recommendation in $recommendations) { $p = $recommendation.properties $evidence = [ordered]@{ impact = $p.impact; impactedField = $p.impactedField; impactedValue = $p.impactedValue; problem = $p.shortDescription.problem; solution = $p.shortDescription.solution } New-Finding -ResourceId ([string]$recommendation.id) -ResourceType 'Microsoft.Advisor/recommendations' -ResourceName ([string]$p.shortDescription.problem) -Result (New-Fail "$($p.impact) impact: $($p.shortDescription.problem) ($($p.impactedValue))" $evidence) } } } Add-AzTest @{ Id = 'AZ-GOV-011' Title = 'Subscriptions cannot be moved into or out of the tenant' Category = 'Asset management' Service = 'Azure subscriptions' Severity = 'Medium' Description = "Checks the tenant subscription policy for 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' set to 'Permit no one', and lists the principals exempted from it." Rationale = 'A subscription moved to another tenant takes its resources and data out of reach of this tenant''s identities, policies and monitoring. A subscription moved in brings resources the organization does not govern. Blocking both makes a move a deliberate decision of a Global Administrator.' Remediation = "In the Azure portal open Subscriptions > Manage policies and set both 'Subscription leaving Microsoft Entra tenant' and 'Subscription entering Microsoft Entra tenant' to 'Permit no one'. Exempt only the principals that must move subscriptions." References = @('https://learn.microsoft.com/azure/cost-management-billing/manage/manage-azure-subscription-policy') Requires = @('subscription/subscriptionPolicies') Run = { $p = (Get-IngestData 'subscription/subscriptionPolicies').properties $evidence = [ordered]@{ blockSubscriptionsLeavingTenant = [bool]$p.blockSubscriptionsLeavingTenant; blockSubscriptionsIntoTenant = [bool]$p.blockSubscriptionsIntoTenant; exemptedPrincipals = @($p.exemptedPrincipals | Where-Object { $_ } | ForEach-Object { Get-PrincipalLabel $_ } | Sort-Object) } $open = @() if (-not $p.blockSubscriptionsLeavingTenant) { $open += 'leaving' } if (-not $p.blockSubscriptionsIntoTenant) { $open += 'entering' } $result = if ($open) { New-Fail "Subscriptions can be moved $($open -join ' and ') the tenant" $evidence } else { New-Pass 'Subscriptions cannot be moved into or out of the tenant' $evidence } New-TenantFinding -Result $result -Suffix '/subscriptionPolicies' } } function Get-AutoscaleTargets { #target resource id (lowercase) -> name of the enabled autoscale setting that scales it if (-not $script:Ingest.Cache.ContainsKey('#autoscale')) { $targets = @{} foreach ($setting in (Get-AzResourceRecords -Type 'Microsoft.Insights/autoscalesettings')) { $p = $setting.resource.properties if ($p.enabled -eq $false -or -not $p.targetResourceUri) { continue } $targets[([string]$p.targetResourceUri).ToLowerInvariant()] = $setting.resource.name } $script:Ingest.Cache['#autoscale'] = $targets } return $script:Ingest.Cache['#autoscale'] } #App Service plan tiers that scale out on their own $elasticPlanTiers = @('Dynamic', 'ElasticPremium', 'FlexConsumption', 'WorkflowStandard') Add-AzTest @{ Id = 'AZ-GOV-012' Title = 'Capacity scales automatically with demand' Category = 'Backup and recovery' Service = 'Multiple' Severity = 'Low' Description = 'Checks App Service plans with apps, virtual machine scale sets and AKS clusters for automatic scaling: an enabled autoscale setting, automatic scaling or an elastic tier for App Service plans, and the cluster autoscaler or node auto provisioning for AKS node pools. Scale sets managed by AKS are covered through their cluster.' Rationale = 'Fixed capacity is sized for the load someone expected. A peak, a failed zone or a denial of service then exhausts it and takes the service down, while scaling out would have kept it available.' Remediation = 'Create an autoscale setting (Azure Monitor autoscale) for App Service plans and scale sets with rules or a predictive profile and sensible minimum and maximum counts, or enable automatic scaling on Premium v2 and v3 plans; enable the cluster autoscaler on every AKS node pool or use node auto provisioning.' References = @('https://learn.microsoft.com/azure/azure-monitor/autoscale/autoscale-overview', 'https://learn.microsoft.com/azure/aks/cluster-autoscaler') ResourceTypes = @('Microsoft.Web/serverfarms', 'Microsoft.Compute/virtualMachineScaleSets', 'Microsoft.ContainerService/managedClusters') Filter = { param($Record) -not ($Record.type -eq 'Microsoft.Compute/virtualMachineScaleSets' -and @($Record.resource.tags.PSObject.Properties.Name | Where-Object { $_ -like 'aks-managed-*' }).Count) } Evaluate = { param($Record) $r = $Record.resource $p = $r.properties if ($Record.type -eq 'Microsoft.ContainerService/managedClusters') { $pools = @($p.agentPoolProfiles | Where-Object { $_ }) $evidence = [ordered]@{ nodeProvisioning = $p.nodeProvisioningProfile.mode; nodePools = @($pools | ForEach-Object { "$($_.name): $(if ($_.enableAutoScaling) { "autoscale $($_.minCount)-$($_.maxCount)" } else { "fixed $($_.count)" })" } | Sort-Object) } if ($p.nodeProvisioningProfile.mode -eq 'Auto') { return New-Pass 'Node auto provisioning adds nodes on demand' $evidence } if (-not $pools) { return New-Unknown 'The node pools could not be read' $evidence } $fixed = @($pools | Where-Object { -not $_.enableAutoScaling } | ForEach-Object { $_.name } | Sort-Object) if ($fixed) { return New-Fail "Node pool(s) $($fixed -join ', ') have a fixed node count" $evidence } return New-Pass 'The cluster autoscaler scales every node pool' $evidence } $autoscale = (Get-AutoscaleTargets)[$Record.id.ToLowerInvariant()] if ($Record.type -eq 'Microsoft.Web/serverfarms') { $tier = [string]$r.sku.tier $evidence = [ordered]@{ tier = $tier; apps = $p.numberOfSites; elasticScaleEnabled = $p.elasticScaleEnabled; autoscaleSetting = $autoscale } if ($tier -in 'Free', 'Shared') { return New-NotApplicable "$tier plans are for development and testing" $evidence } if ($null -ne $p.numberOfSites -and [int]$p.numberOfSites -eq 0) { return New-NotApplicable 'No apps run on this plan' $evidence } if ($tier -in $elasticPlanTiers) { return New-Pass "The $tier tier scales out automatically" $evidence } if ($p.elasticScaleEnabled) { return New-Pass "Automatic scaling up to $($p.maximumElasticWorkerCount) instances" $evidence } if ($autoscale) { return New-Pass "Autoscale setting '$autoscale'" $evidence } if ($tier -eq 'Basic') { return New-Fail 'The Basic tier cannot scale automatically' $evidence } } else { $evidence = [ordered]@{ capacity = $r.sku.capacity; autoscaleSetting = $autoscale } if ($autoscale) { return New-Pass "Autoscale setting '$autoscale'" $evidence } } if (Get-FailedResourceIds -Type 'Microsoft.Insights/autoscalesettings') { return New-Unknown 'No autoscale setting found, and some autoscale settings could not be read' $evidence } New-Fail "No enabled autoscale setting; capacity is fixed at $($r.sku.capacity) instance(s)" $evidence } } Add-AzTest @{ Id = 'AZ-GOV-013' Title = 'Resources are in the region of their resource group' Category = 'Asset management' Service = 'Azure Resource Manager' Severity = 'Low' Description = 'Finds resources whose region differs from the region of their resource group. Global resources are left out, and so are network watchers, which Azure itself creates for every region in one NetworkWatcherRG resource group.' Rationale = 'A resource group keeps the metadata of its resources in its own region. When that region is unavailable, resources in other regions can no longer be changed or redeployed through it, which slows down recovery exactly when it is needed.' Remediation = 'Place resources in a resource group in the same region, and move or redeploy the ones that are not. Enforce it with the built-in policy that audits matching resource and resource group locations.' References = @('https://learn.microsoft.com/azure/azure-resource-manager/management/overview#resource-group-location-alignment') Requires = @('subscription/resources', 'subscription/resourceGroups') Run = { $groupLocations = @{} foreach ($group in @(Get-IngestData 'subscription/resourceGroups' | Where-Object { $_ })) { $groupLocations[$group.name.ToLowerInvariant()] = ([string]$group.location).ToLowerInvariant() -replace ' ', '' } $checked = 0 $findings = foreach ($resource in @(Get-IngestData 'subscription/resources' | Where-Object { $_ -and $_.location -and $_.type -notlike 'Microsoft.Network/networkWatchers*' } | Sort-Object id)) { $location = ([string]$resource.location).ToLowerInvariant() -replace ' ', '' if ($location -eq 'global' -or -not ($resource.id -match '(?i)/resourceGroups/(?<group>[^/]+)/')) { continue } $groupLocation = $groupLocations[$Matches.group.ToLowerInvariant()] if (-not $groupLocation) { continue } $checked++ if ($location -eq $groupLocation) { continue } New-Finding -ResourceId $resource.id -ResourceType $resource.type -ResourceName $resource.name -Result (New-Fail "In $location, its resource group is in $groupLocation" ([ordered]@{ location = $location; resourceGroupLocation = $groupLocation })) } if (-not $findings) { return New-SubscriptionFinding (New-Pass "All $checked regional resources are in the region of their resource group") } $findings } } Add-AzTest @{ Id = 'AZ-GOV-014' Title = 'A cost budget alerts on the spending of the subscription' Category = 'Logging and threat detection' Service = 'Cost Management' Severity = 'Low' Description = 'Checks for a cost budget on the subscription with at least one enabled notification to an email address, a role or an action group.' Rationale = 'Attackers who take over a subscription often run crypto miners or other expensive workloads at the owner''s expense, and a sudden rise in cost is regularly the first visible sign of the breach. A budget with notifications makes that sign reach someone within a day instead of with the invoice.' Remediation = 'Create a budget for the subscription (Cost Management > Budgets) with notifications on actual and forecasted cost that reach the owners of the subscription and the security team.' References = @('https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets') Requires = @('subscription/budgets') Run = { $budgets = @(Get-IngestData 'subscription/budgets' | Where-Object { $_ } | Sort-Object name) $notifying = @(foreach ($budget in $budgets) { $notifications = @($budget.properties.notifications.PSObject.Properties | ForEach-Object Value | Where-Object { $_ -and $_.enabled -and (@($_.contactEmails) + @($_.contactRoles) + @($_.contactGroups) | Where-Object { $_ }).Count }) if ($notifications) { $budget.name } }) $evidence = [ordered]@{ budgets = @($budgets | ForEach-Object name); withNotifications = $notifying } if ($notifying) { return New-SubscriptionFinding (New-Pass "Budget(s) with notifications: $($notifying -join ', ')" $evidence) } if ($budgets) { return New-SubscriptionFinding (New-Fail 'Budgets exist, but none notifies anyone' $evidence) } New-SubscriptionFinding (New-Fail 'No cost budget on the subscription' $evidence) } } |