Checks/Section9.ps1
|
# Section 9 — Storage Services # CIS Microsoft Azure Foundations Benchmark v6.0.0 function Invoke-Section9Checks { [CmdletBinding()] param( [string]$SubscriptionId, [string]$SubscriptionName, [hashtable]$PrefetchData ) $results = [System.Collections.Generic.List[object]]::new() $sid = $SubscriptionId $sname = $SubscriptionName $accounts = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "storage" -SubscriptionId $sid) $storErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "storage" # Fallback: if the "storage" key was never prefetched, or the prefetch query # failed, try reading storage accounts directly before giving up. if ($accounts.Count -eq 0 -and ($storErr -or -not $PrefetchData.ContainsKey("storage"))) { $rawAccts = @(Get-AzStorageAccount -ErrorAction SilentlyContinue) if ($rawAccts.Count -gt 0) { $storErr = $null # direct read succeeded — prefetch failure no longer masks data $accounts = @($rawAccts | Select-Object ` @{N='id';E={$_.Id}}, @{N='name';E={$_.StorageAccountName}}, @{N='resourceGroup';E={$_.ResourceGroupName}}, @{N='isHns';E={[string]$_.EnableHierarchicalNamespace}}, @{N='sku';E={$_.Sku.Name}}) } } if ($accounts.Count -eq 0) { $storageControlIds = @( "9.1.1","9.1.2","9.1.3", "9.2.1","9.2.2","9.2.3", "9.3.1.1","9.3.1.2","9.3.1.3","9.3.2.1","9.3.2.2","9.3.2.3","9.3.3.1", "9.3.4","9.3.5","9.3.6","9.3.7","9.3.8","9.3.9","9.3.10","9.3.11" ) foreach ($cid in $storageControlIds) { if ($storErr) { $results.Add((New-ErrorResult $cid "Storage prefetch failed and direct read returned no data: $storErr" $sid $sname)) } else { $results.Add((New-InfoResult $cid "No storage accounts found." $sid $sname)) } } return $results.ToArray() } foreach ($acct in $accounts) { $acctName = [string]($acct.PSObject.Properties['name']?.Value) $acctId = [string]($acct.PSObject.Properties['id']?.Value) $acctRg = [string]($acct.PSObject.Properties['resourceGroup']?.Value) # ── Group 1: Static checks from Resource Graph ──────────────────────── # 9.3.4 — Secure transfer required (HTTPS only) $httpsOnly = [string]($acct.PSObject.Properties['httpsOnly']?.Value) $httpsOk = $httpsOnly -eq "true" -or $httpsOnly -eq "True" $results.Add((New-CISResult ` -ControlId "9.3.4" ` -Status $(if ($httpsOk) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($httpsOk) { "Secure transfer (HTTPS only): Required." } else { "Secure transfer (HTTPS only): Not required — HTTP connections are allowed." }) ` -Remediation $(if (-not $httpsOk) { "Storage account > $acctName > Configuration > Require secure transfer > Enabled" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.8 — No anonymous blob public access $blobAnon = [string]($acct.PSObject.Properties['blobAnon']?.Value) $anonDisabled = $blobAnon -eq "false" -or $blobAnon -eq "False" $results.Add((New-CISResult ` -ControlId "9.3.8" ` -Status $(if ($anonDisabled) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($anonDisabled) { "Blob public access: Disabled." } else { "Blob public access: Enabled — anonymous access to blob containers is allowed." }) ` -Remediation $(if (-not $anonDisabled) { "Storage account > $acctName > Configuration > Allow Blob public access > Disabled" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.7 — Cross-tenant replication disabled $crossTenant = [string]($acct.PSObject.Properties['crossTenant']?.Value) # Null/empty means disabled (Azure default); only explicit "true" means enabled $crossTenantOff = $crossTenant -ne "true" -and $crossTenant -ne "True" $results.Add((New-CISResult ` -ControlId "9.3.7" ` -Status $(if ($crossTenantOff) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($crossTenantOff) { "Cross-tenant replication: Disabled." } else { "Cross-tenant replication: Enabled — data can be replicated to storage accounts in other tenants." }) ` -Remediation $(if (-not $crossTenantOff) { "Storage account > $acctName > Object replication > Disable cross-tenant replication" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.1.3 — Shared Key access disabled $keyAccess = [string]($acct.PSObject.Properties['keyAccess']?.Value) $keyAccessOff = $keyAccess -eq "false" -or $keyAccess -eq "False" $results.Add((New-CISResult ` -ControlId "9.3.1.3" ` -Status $(if ($keyAccessOff) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($keyAccessOff) { "Shared key (storage account key) access: Disabled." } else { "Shared key (storage account key) access: Enabled — disable to enforce Entra ID authentication." }) ` -Remediation $(if (-not $keyAccessOff) { "Storage account > $acctName > Configuration > Allow storage account key access > Disabled" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.3.1 — Default to Entra ID (OAuth) authorization $oauthDefault = [string]($acct.PSObject.Properties['oauthDefault']?.Value) $oauthDefaultOn = $oauthDefault -eq "true" -or $oauthDefault -eq "True" $results.Add((New-CISResult ` -ControlId "9.3.3.1" ` -Status $(if ($oauthDefaultOn) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($oauthDefaultOn) { "Default to Microsoft Entra ID authorization: Enabled." } else { "Default to Microsoft Entra ID authorization: Disabled — storage data requests are not automatically authorized with Entra ID." }) ` -Remediation $(if (-not $oauthDefaultOn) { "Storage account > $acctName > Configuration > Default to Entra ID authorization in Azure portal > Enabled" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # Variable setup for 9.3.2.2, 9.3.2.3, and downstream checks $pubAccess = [string]($acct.PSObject.Properties['publicAccess']?.Value) $defaultAction = [string]($acct.PSObject.Properties['defaultAction']?.Value) $bypass = ([string]($acct.PSObject.Properties['bypass']?.Value)).ToLower() $acctSku = ([string]($acct.PSObject.Properties['sku']?.Value)).ToUpper() $isGrs = @("GRS","GZRS","RAGRS","RAGZRS") | Where-Object { $acctSku -match $_ } # 9.3.2.2 — Public network access must be "Disabled" $pubDisabled = $pubAccess -eq "Disabled" $results.Add((New-CISResult ` -ControlId "9.3.2.2" ` -Status $(if ($pubDisabled) { $script:PASS } else { $script:FAIL }) ` -Details "publicNetworkAccess = $(if ($pubAccess) { $pubAccess } else { 'null (not explicitly disabled — effective: Enabled)' })" ` -Remediation $(if (-not $pubDisabled) { "Storage account > $acctName > Networking > Public network access > Disabled" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.2.3 — Default network ACL action must be "Deny" $denyDefault = $defaultAction -eq "Deny" $results.Add((New-CISResult ` -ControlId "9.3.2.3" ` -Status $(if ($denyDefault) { $script:PASS } else { $script:FAIL }) ` -Details "networkAcls.defaultAction = $defaultAction" ` -Remediation $(if (-not $denyDefault) { "Storage account > $acctName > Networking > Firewall and virtual networks > Default action: Deny" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.6 — TLS 1.2+ $minTls = [string]($acct.PSObject.Properties['minTls']?.Value) $tlsOk = $minTls -match "TLS1_2|TLS1_3" $results.Add((New-CISResult ` -ControlId "9.3.6" ` -Status $(if ($tlsOk) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($tlsOk) { "Minimum TLS version: $minTls." } else { "Minimum TLS version: $minTls — must be TLS 1.2 or higher." }) ` -Remediation $(if (-not $tlsOk) { "Storage account > $acctName > Configuration > Minimum TLS version > TLS 1.2" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.5 — Trusted Azure services bypass $bypassOk = $bypass -match "azureservices" $results.Add((New-CISResult ` -ControlId "9.3.5" ` -Status $(if ($bypassOk) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($bypassOk) { "networkAcls.bypass includes AzureServices." } else { "networkAcls.bypass: $([string]($acct.PSObject.Properties['bypass']?.Value)) — AzureServices not listed." }) ` -Remediation $(if (-not $bypassOk) { "Storage account > $acctName > Networking > Exceptions > Allow Azure services on the trusted services list." } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.11 — Geo-redundant storage (GRS) $results.Add((New-CISResult ` -ControlId "9.3.11" ` -Status $(if ($isGrs) { $script:PASS } else { $script:FAIL }) ` -Details "Storage SKU: $acctSku$(if (-not $isGrs) { ' — not geo-redundant' } else { '' })" ` -Remediation $(if (-not $isGrs) { "Storage account > $acctName > Data Management > Redundancy > Select GRS, GZRS, RA-GRS, or RA-GZRS." } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.2.1 — Private endpoints $privateEps = [int]($acct.PSObject.Properties['privateEps']?.Value) $results.Add((New-CISResult ` -ControlId "9.3.2.1" ` -Status $(if ($privateEps -gt 0) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($privateEps -gt 0) { "Private endpoints: $privateEps configured." } else { "Private endpoints: $privateEps — at least one private endpoint is required." }) ` -Remediation $(if ($privateEps -eq 0) { "Storage account > $acctName > Networking > Private endpoint connections > Add" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # ── Group 2: Blob service properties (dynamic call per account) ─────── # Skip blob/file checks for Azure Data Lake Storage Gen2 (HNS-enabled accounts # do not expose a separate blob service properties API). # Use the isHns property from Resource Graph to detect ADLS Gen2 accounts. $isHnsProp = [string]($acct.PSObject.Properties['isHns']?.Value) $isAdls = $isHnsProp -eq 'true' -or $isHnsProp -eq 'True' if (-not $isAdls) { try { $bp = Invoke-WithRetry -OperationName "blob service properties ($acctName)" -ScriptBlock { Get-AzStorageBlobServiceProperty -ResourceGroupName $acctRg -StorageAccountName $acctName -ErrorAction Stop } # 9.2.1 — Blob soft delete enabled $softDelOn = [bool]($bp.DeleteRetentionPolicy?.Enabled) $retDays = [int]($bp.DeleteRetentionPolicy?.Days ?? 0) $blobSdOk = $softDelOn -and $retDays -ge 7 $results.Add((New-CISResult ` -ControlId "9.2.1" ` -Status $(if ($blobSdOk) { $script:PASS } else { $script:FAIL }) ` -Details "Blob soft delete: enabled=$softDelOn, days=$retDays" ` -Remediation $(if (-not $blobSdOk) { "Storage account > $acctName > Data management > Data protection > Enable soft delete for blobs (>= 7 days)" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.2.2 — Container soft delete enabled $conDelOn = [bool]($bp.ContainerDeleteRetentionPolicy?.Enabled) $conDays = [int]($bp.ContainerDeleteRetentionPolicy?.Days ?? 0) $conOk = $conDelOn -and $conDays -ge 7 $results.Add((New-CISResult ` -ControlId "9.2.2" ` -Status $(if ($conOk) { $script:PASS } else { $script:FAIL }) ` -Details "Container soft delete: enabled=$conDelOn, days=$conDays" ` -Remediation $(if (-not $conOk) { "Storage account > $acctName > Data management > Data protection > Enable soft delete for containers (>= 7 days)" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.2.3 — Blob versioning enabled $versionOn = [bool]$bp.IsVersioningEnabled $results.Add((New-CISResult ` -ControlId "9.2.3" ` -Status $(if ($versionOn) { $script:PASS } else { $script:FAIL }) ` -Details "Blob versioning: $(if($versionOn){'Enabled'}else{'Disabled'})" ` -Remediation $(if (-not $versionOn) { "Storage account > $acctName > Data management > Data protection > Enable blob versioning" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) } catch { Add-ClassifiedErrorSet -Results $results -ControlIds @("9.2.1","9.2.2","9.2.3") -Message $_.Exception.Message ` -NotApplicableMessage "Blob service not supported for this account type." ` -AuthzMessage "Insufficient permissions to read blob service properties." ` -FirewallMessage "Storage account firewall or network configuration is blocking access. Verify that the storage account is accessible from the audit machine." ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName } } else { foreach ($cid in @("9.2.1","9.2.2","9.2.3")) { $results.Add((New-InfoResult $cid "ADLS Gen2 — blob service properties API not applicable." $sid $sname $acctName)) } } # ── Group 3: File service soft delete ───────────────────────────────── # 9.1.1 — File share soft delete try { $fp = Invoke-WithRetry -OperationName "file service properties ($acctName)" -ScriptBlock { Get-AzStorageFileServiceProperty -ResourceGroupName $acctRg -StorageAccountName $acctName -ErrorAction Stop } $fsDelOn = $fp.ShareDeleteRetentionPolicy?.Enabled $fsDays = [int]($fp.ShareDeleteRetentionPolicy?.Days) $fsOk = $fsDelOn -and $fsDays -ge 7 $results.Add((New-CISResult ` -ControlId "9.1.1" ` -Status $(if ($fsOk) { $script:PASS } else { $script:FAIL }) ` -Details "File share soft delete: enabled=$fsDelOn, days=$fsDays" ` -Remediation $(if (-not $fsOk) { "Storage account > $acctName > Data management > Data protection > Enable soft delete for file shares (>= 7 days)" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.1.2 — SMB protocol version >= 3.1.1 # 9.1.3 — SMB channel encryption AES-256-GCM or higher $smb = $fp.ProtocolSettings?.Smb $smbVers = if ($smb) { [string]$smb.Versions } else { "" } $smbEnc = if ($smb) { [string]$smb.ChannelEncryption } else { "" } $goodVers = @("SMB3.1.1","SMB3.11") $goodEnc = @("AES-256-GCM","AES256GCM") if ($smb) { $hasGoodVer = @($smbVers -split '[;, ]' | Where-Object { $goodVers -contains $_.Trim() }).Count -gt 0 $results.Add((New-CISResult ` -ControlId "9.1.2" ` -Status $(if ($hasGoodVer) { $script:PASS } else { $script:FAIL }) ` -Details "SMB protocol versions: $(if($smbVers){$smbVers}else{'Not configured'})" ` -Remediation $(if (-not $hasGoodVer) { "Storage account > $acctName > File shares > SMB settings > Protocol version: SMB 3.1.1" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) $hasGoodEnc = @($smbEnc -split '[;, ]' | Where-Object { $goodEnc -contains $_.Trim() }).Count -gt 0 $results.Add((New-CISResult ` -ControlId "9.1.3" ` -Status $(if ($hasGoodEnc) { $script:PASS } else { $script:FAIL }) ` -Details "SMB channel encryption: $(if($smbEnc){$smbEnc}else{'Not configured'})" ` -Remediation $(if (-not $hasGoodEnc) { "Storage account > $acctName > File shares > SMB settings > Channel encryption: AES-256-GCM" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) } else { $results.Add((New-InfoResult "9.1.2" "No SMB protocolSettings found for this account; SMB protocol may not be applicable." $sid $sname $acctName)) $results.Add((New-InfoResult "9.1.3" "No SMB protocolSettings found for this account; SMB encryption may not be applicable." $sid $sname $acctName)) } } catch { Add-ClassifiedErrorSet -Results $results -ControlIds @("9.1.1","9.1.2","9.1.3") -Message $_.Exception.Message ` -NotApplicableMessage "File service not supported for this account type." ` -AuthzMessage "Insufficient permissions to read file service properties." ` -FirewallMessage "Storage account firewall or network configuration is blocking access." ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName } # ── Group 4: Key rotation ───────────────────────────────────────────── # 9.3.1.1 — Key rotation reminder + 9.3.1.2 — Key rotation < 90 days try { $saAcct = Get-AzStorageAccount -Name $acctName -ResourceGroupName $acctRg -ErrorAction Stop # 9.3.1.1 — Key expiration / rotation reminder $reminderDays = $saAcct.KeyPolicy?.KeyExpirationPeriodInDays $results.Add((New-CISResult ` -ControlId "9.3.1.1" ` -Status $(if ($reminderDays) { $script:PASS } else { $script:FAIL }) ` -Details "Account '$acctName': keyExpirationPeriodInDays = $(if ($null -ne $reminderDays -and $reminderDays -ne '') { $reminderDays } else { '(not configured)' })" ` -Remediation $(if (-not $reminderDays) { "Storage Account > Access keys > Set rotation reminder" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $(if (-not $reminderDays) { $acctName } else { "" }))) # 9.3.1.2 — Key rotation within 90 days $keyTimes = $saAcct.KeyCreationTime if ($keyTimes) { $now = [datetime]::UtcNow $oldKeys = [System.Collections.Generic.List[string]]::new() foreach ($prop in @("Key1","Key2")) { $keyTime = $keyTimes.PSObject.Properties[$prop]?.Value if ($keyTime) { $created = [datetime]$keyTime $days = ($now - $created).TotalDays if ($days -gt 90) { $oldKeys.Add("$prop ($([int]$days) days old)") } } } $pass = $oldKeys.Count -eq 0 $results.Add((New-CISResult ` -ControlId "9.3.1.2" ` -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) ` -Details $(if ($pass) { "Both access keys rotated within 90 days." } else { "Key(s) not rotated in 90 days: $($oldKeys -join '; ')" }) ` -Remediation $(if (-not $pass) { "Storage account > $acctName > Security > Access keys > Rotate key" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) } else { $results.Add((New-ErrorResult "9.3.1.2" "Could not retrieve key creation times." $sid $sname $acctName)) } } catch { $results.Add((New-ErrorResult "9.3.1.1" $_.Exception.Message $sid $sname $acctName)) $results.Add((New-ErrorResult "9.3.1.2" $_.Exception.Message $sid $sname $acctName)) } } # ── 9.3.9 / 9.3.10 — Resource locks on storage accounts (subscription-wide) # 9.3.9: account is covered by a CanNotDelete OR ReadOnly lock (Level 1) # 9.3.10: account is covered by a ReadOnly lock specifically (Level 2) try { # -ErrorAction Stop: a failed lock read must ERROR 9.3.9/9.3.10 in the catch, # not FAIL every account with 'no locks found'. $locks = @(Get-AzResourceLock -ErrorAction Stop) if (-not $locks) { $locks = @() } foreach ($acct in $accounts) { $acctName = [string]($acct.PSObject.Properties['name']?.Value) $acctId = [string]($acct.PSObject.Properties['id']?.Value) $acctRg = [string]($acct.PSObject.Properties['resourceGroup']?.Value) $rgScope = ($acctId -split '/providers/')[0].ToLower() $subScope = "/subscriptions/$sid".ToLower() $covering = @() $acctScope = $acctId.ToLower() foreach ($lk in $locks) { $lkId = [string]$lk.LockId $lkIdLower = $lkId.ToLower() if ($lkIdLower.StartsWith($acctScope + "/providers/microsoft.authorization/locks/") -or $lkIdLower.StartsWith($rgScope + "/providers/microsoft.authorization/locks/") -or $lkIdLower.StartsWith($subScope + "/providers/microsoft.authorization/locks/")) { $covering += [string]$lk.Level } } $coveringLower = @($covering | ForEach-Object { $_.ToLower() }) $hasDeleteLock = ($coveringLower -contains "cannotdelete" -or $coveringLower -contains "readonly") $hasReadOnly = ($coveringLower -contains "readonly") $summary = if ($covering.Count -gt 0) { "Lock(s) found: $($covering -join ', ')" } else { "No resource locks found at account, RG, or subscription scope." } # 9.3.9 — CanNotDelete or ReadOnly lock (protects against accidental deletion) $results.Add((New-CISResult ` -ControlId "9.3.9" ` -Status $(if ($hasDeleteLock) { $script:PASS } else { $script:FAIL }) ` -Details "$($acctName): $summary" ` -Remediation $(if (-not $hasDeleteLock) { "Storage account > $acctName > Locks > Add lock > Lock type: Delete" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) # 9.3.10 — ReadOnly lock (full freeze, highest protection) $results.Add((New-CISResult ` -ControlId "9.3.10" ` -Status $(if ($hasReadOnly) { $script:PASS } else { $script:FAIL }) ` -Details "$($acctName): $summary" ` -Remediation $(if (-not $hasReadOnly) { "Storage account > $acctName > Locks > Add lock > Lock type: Read-only" } else { "" }) ` -SubscriptionId $sid -SubscriptionName $sname -Resource $acctName)) } } catch { $results.Add((New-ErrorResult "9.3.9" $_.Exception.Message $sid $sname)) $results.Add((New-ErrorResult "9.3.10" $_.Exception.Message $sid $sname)) } return $results.ToArray() } |