Checks/Section09-StorageServices.ps1
|
# ============================================================================= # Section 9: Storage Services - Custom Check Functions # CIS Microsoft Azure Foundations Benchmark v5.0.0 # ============================================================================= # Custom functions for storage account key management, private endpoints, # trusted services, and redundancy controls. # Dispatched via 'Custom' CheckPattern. # Each function receives -ControlDef (hashtable) and -ResourceCache (hashtable). # ============================================================================= function Test-CIS9311-KeyRotationReminders { <# .SYNOPSIS CIS 9.3.1.1 - Ensure 'Enable key rotation reminders' is enabled for each Storage Account. .DESCRIPTION Checks each storage account for key rotation reminder policy (KeyPolicy with KeyExpirationPeriodInDays configured). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter(Mandatory)] [hashtable]$ResourceCache ) try { $storageAccounts = @($ResourceCache.StorageAccounts) if ($storageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } $totalCount = $storageAccounts.Count $failedList = [System.Collections.Generic.List[string]]::new() $passedCount = 0 foreach ($sa in $storageAccounts) { $hasKeyPolicy = $false # Check KeyPolicy for expiration period if ($sa.KeyPolicy -and $sa.KeyPolicy.KeyExpirationPeriodInDays) { if ($sa.KeyPolicy.KeyExpirationPeriodInDays -gt 0) { $hasKeyPolicy = $true } } if ($hasKeyPolicy) { $passedCount++ } else { $failedList.Add("$($sa.StorageAccountName) (RG: $($sa.ResourceGroupName))") } } $failedCount = $failedList.Count if ($failedCount -gt 0) { $details = "Found $failedCount of $totalCount storage account(s) without key rotation reminders: $($failedList -join '; ')" return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details $details ` -AffectedResources $failedList.ToArray() ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources $failedCount } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalCount storage account(s) have key rotation reminders configured." ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources 0 } catch { $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status $status ` -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking key rotation reminders: $(Format-CISErrorMessage $_.Exception.Message)" } } function Test-CIS9312-KeyRegeneration { <# .SYNOPSIS CIS 9.3.1.2 - Ensure Storage Account access keys are periodically regenerated. .DESCRIPTION Checks storage account key creation time to verify keys have been regenerated within the configured period (default 90 days, configurable via CISConfig.KeyRotationMaxDays). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter(Mandatory)] [hashtable]$ResourceCache ) try { $storageAccounts = @($ResourceCache.StorageAccounts) if ($storageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } $maxAgeDays = if ($script:CISConfig.KeyRotationMaxDays) { $script:CISConfig.KeyRotationMaxDays } else { 90 } $now = [DateTime]::UtcNow $totalCount = $storageAccounts.Count $failedList = [System.Collections.Generic.List[string]]::new() $passedCount = 0 foreach ($sa in $storageAccounts) { if ($sa.KeyCreationTime) { # Check both key1 and key2 creation times $key1Time = $sa.KeyCreationTime.Key1 $key2Time = $sa.KeyCreationTime.Key2 # Only evaluate keys that have valid timestamps $key1AgeDays = if ($key1Time) { ($now - $key1Time).TotalDays } else { $null } $key2AgeDays = if ($key2Time) { ($now - $key2Time).TotalDays } else { $null } $key1Old = ($null -ne $key1AgeDays) -and ($key1AgeDays -gt $maxAgeDays) $key2Old = ($null -ne $key2AgeDays) -and ($key2AgeDays -gt $maxAgeDays) $bothUnknown = ($null -eq $key1AgeDays) -and ($null -eq $key2AgeDays) if ($bothUnknown) { $failedList.Add("$($sa.StorageAccountName) (key creation times unavailable)") } elseif ($key1Old -or $key2Old) { $ages = @() if ($null -ne $key1AgeDays) { $ages += $key1AgeDays } if ($null -ne $key2AgeDays) { $ages += $key2AgeDays } $oldestDays = ($ages | Measure-Object -Maximum).Maximum $failedList.Add("$($sa.StorageAccountName) (oldest key: $([math]::Floor($oldestDays)) days)") } else { $passedCount++ } } else { # KeyCreationTime not available - cannot determine key age $failedList.Add("$($sa.StorageAccountName) (key creation time unavailable)") } } $failedCount = $failedList.Count if ($failedCount -gt 0) { $details = "Found $failedCount of $totalCount storage account(s) with keys older than $maxAgeDays days: $($failedList -join '; ')" return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details $details ` -AffectedResources $failedList.ToArray() ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources $failedCount } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalCount storage account(s) have keys regenerated within the last $maxAgeDays days." ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources 0 } catch { $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status $status ` -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking key regeneration: $(Format-CISErrorMessage $_.Exception.Message)" } } function Test-CIS9321-StoragePrivateEndpoints { <# .SYNOPSIS CIS 9.3.2.1 - Ensure Private Endpoints are used to access Storage Accounts. .DESCRIPTION Checks each storage account for the existence of private endpoint connections. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter(Mandatory)] [hashtable]$ResourceCache ) try { $storageAccounts = @($ResourceCache.StorageAccounts) if ($storageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } $totalCount = $storageAccounts.Count $failedList = [System.Collections.Generic.List[string]]::new() $passedCount = 0 foreach ($sa in $storageAccounts) { $hasPrivateEndpoints = $false # Check PrivateEndpointConnections property if ($sa.PrivateEndpointConnections -and $sa.PrivateEndpointConnections.Count -gt 0) { $hasPrivateEndpoints = $true } # Alternative: check via NetworkRuleSet for virtual network rules (not the same as PE but indicates private access) if (-not $hasPrivateEndpoints) { try { $saResource = Get-AzResource -ResourceId $sa.Id -ExpandProperties -ErrorAction Stop if ($saResource.Properties.privateEndpointConnections -and $saResource.Properties.privateEndpointConnections.Count -gt 0) { $hasPrivateEndpoints = $true } } catch { Write-Verbose "Could not expand properties for $($sa.StorageAccountName): $($_.Exception.Message)" } } if ($hasPrivateEndpoints) { $passedCount++ } else { $failedList.Add("$($sa.StorageAccountName) (RG: $($sa.ResourceGroupName))") } } $failedCount = $failedList.Count if ($failedCount -gt 0) { $details = "Found $failedCount of $totalCount storage account(s) without private endpoints: $($failedList -join '; ')" return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details $details ` -AffectedResources $failedList.ToArray() ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources $failedCount } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalCount storage account(s) have private endpoints configured." ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources 0 } catch { $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status $status ` -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking storage private endpoints: $(Format-CISErrorMessage $_.Exception.Message)" } } function Test-CIS935-TrustedServices { <# .SYNOPSIS CIS 9.3.5 - Ensure 'Allow Azure services on the trusted services list' is Enabled. .DESCRIPTION Checks each storage account's network rules to verify the bypass includes 'AzureServices'. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter(Mandatory)] [hashtable]$ResourceCache ) try { $storageAccounts = @($ResourceCache.StorageAccounts) if ($storageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } $totalCount = $storageAccounts.Count $failedList = [System.Collections.Generic.List[string]]::new() $passedCount = 0 foreach ($sa in $storageAccounts) { $bypassTrusted = $false if ($sa.NetworkRuleSet -and $sa.NetworkRuleSet.Bypass) { $bypass = $sa.NetworkRuleSet.Bypass.ToString() if ($bypass -match 'AzureServices') { $bypassTrusted = $true } } # Note: DefaultAction='Allow' permits ALL traffic, not just trusted services. # CIS 9.3.5 requires explicit Bypass='AzureServices' regardless of DefaultAction. if ($bypassTrusted) { $passedCount++ } else { $currentBypass = if ($sa.NetworkRuleSet -and $sa.NetworkRuleSet.Bypass) { $sa.NetworkRuleSet.Bypass.ToString() } else { 'None' } $failedList.Add("$($sa.StorageAccountName) (Bypass: $currentBypass)") } } $failedCount = $failedList.Count if ($failedCount -gt 0) { $details = "Found $failedCount of $totalCount storage account(s) not allowing trusted Azure services: $($failedList -join '; ')" return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details $details ` -AffectedResources $failedList.ToArray() ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources $failedCount } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalCount storage account(s) allow trusted Azure services." ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources 0 } catch { $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status $status ` -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking trusted services: $(Format-CISErrorMessage $_.Exception.Message)" } } function Test-CIS9311-StorageRedundancy { <# .SYNOPSIS CIS 9.3.11 - Ensure Redundancy is set to GRS on critical Azure Storage Accounts. .DESCRIPTION Checks each storage account SKU for geo-redundant storage (GRS, RA-GRS, GZRS, or RA-GZRS). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter(Mandatory)] [hashtable]$ResourceCache ) try { $storageAccounts = @($ResourceCache.StorageAccounts) if ($storageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } # Geo-redundant SKU names $geoRedundantSkus = @( 'Standard_GRS', 'Standard_RAGRS', 'Standard_GZRS', 'Standard_RAGZRS' ) $totalCount = $storageAccounts.Count $failedList = [System.Collections.Generic.List[string]]::new() $passedCount = 0 foreach ($sa in $storageAccounts) { $skuName = $null if ($sa.Sku -and $sa.Sku.Name) { $skuName = $sa.Sku.Name } if ($skuName -and $skuName -in $geoRedundantSkus) { $passedCount++ } else { $currentSku = if ($skuName) { $skuName } else { 'Unknown' } $failedList.Add("$($sa.StorageAccountName) (SKU: $currentSku)") } } $failedCount = $failedList.Count if ($failedCount -gt 0) { $details = "Found $failedCount of $totalCount storage account(s) without geo-redundant storage: $($failedList -join '; ')" return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details $details ` -AffectedResources $failedList.ToArray() ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources $failedCount } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalCount storage account(s) use geo-redundant storage." ` -TotalResources $totalCount ` -PassedResources $passedCount ` -FailedResources 0 } catch { $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status $status ` -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking storage redundancy: $(Format-CISErrorMessage $_.Exception.Message)" } } |