Private/Get-AACStorageRuleFix.ps1

function Get-AACStorageRuleFix {
    <#
    .SYNOPSIS
        For each PSRule rule a planned storage account fails, the change to
        its configuration that fixes it - in the AVM parameter names
        Deploy-AACStorageAccount reads - or what to do when no setting can.
    .DESCRIPTION
        -Result is PSRule results (Outcome Fail or Error); -Configuration the
        configuration they were run on; -AccountExists whether the account
        is already there (a redundancy fix must then be one Azure can make in
        place).
 
        Returns AAC.StorageRuleFix rows: Rule, Resource, Kind, Setting,
        Value, Advice, Link:
          Auto a setting fixes it: Setting is its path in the
                   configuration (networkAcls.defaultAction,
                   blobServices.containers[name=raw].publicAccess), Value
                   the value - -UseSuggestedFix applies it
          Manual it can't be fixed by a setting here (the name, Defender
                   for Storage on the subscription, a rule this command
                   doesn't know): Advice says what to do
 
        Built from PSRule for Azure's storage rules as they report (1.47):
        LocalAuth, BlobPublicAccess, MinTLS, SecureTransfer, Firewall,
        UseReplication, SoftDelete, ContainerSoftDelete, FileShareSoftDelete,
        BlobAccessType, Name, Naming, DefenderCloud, Defender.MalwareScan,
        Azure.Resource.UseTags - and the module's own naming and tag rules.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Result,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Configuration,

        [switch] $AccountExists
    )

    $fixes = [System.Collections.Generic.List[object]]::new()
    $add = {
        param($Item, [string] $Kind, [string] $Setting, $Value, [string] $Advice)
        $fixes.Add([pscustomobject]@{ PSTypeName = 'AAC.StorageRuleFix'; Rule = (& $field $Item 'RuleName'); Resource = (& $field $Item 'ResourceName'); Kind = $Kind; Setting = $Setting; Value = $Value; Advice = $Advice; Link = (& $field $Item 'Link') })
    }
    $sku = [string]$(if ($Configuration['skuName']) { $Configuration['skuName'] } else { 'Standard_GRS' })
    # A result's property, or '' - results from elsewhere may not have them all.
    $field = { param($Item, [string] $Name) $property = $Item.PSObject.Properties[$Name]; if ($property) { [string]$property.Value } else { '' } }

    foreach ($item in @($Result | Where-Object { $_.Outcome -in 'Fail', 'Error' })) {
        $rule = [string]$item.RuleName
        switch -Regex ($rule) {
            '^Azure\.Storage\.LocalAuth$' { & $add $item 'Auto' 'allowSharedKeyAccess' $false 'Disable shared key (account key and SAS) access: use Microsoft Entra ID. Apps and tools that use the account key stop working - move them to Entra ID first.' }
            '^Azure\.Storage\.BlobPublicAccess$' { & $add $item 'Auto' 'allowBlobPublicAccess' $false 'Disallow anonymous (public) access to blobs.' }
            '^Azure\.Storage\.MinTLS$' { & $add $item 'Auto' 'minimumTlsVersion' 'TLS1_2' 'Accept TLS 1.2 or later only.' }
            '^Azure\.Storage\.SecureTransfer$' { & $add $item 'Auto' 'supportsHttpsTrafficOnly' $true 'Accept HTTPS only.' }
            '^Azure\.Storage\.Firewall$' { & $add $item 'Auto' 'networkAcls.defaultAction' 'Deny' 'Deny network access by default; allow the clients that need it with networkAcls.ipRules or networkAcls.virtualNetworkRules, or use private endpoints.' }
            '^Azure\.Storage\.UseReplication$' {
                # In place from LRS is GRS (zonal needs a conversion); a new account can be zone-redundant.
                $better = if ($sku -like 'Premium_*') { 'Premium_ZRS' } elseif ($AccountExists) { 'Standard_GRS' } else { 'Standard_GZRS' }
                if ($sku -like 'Premium_*' -and $AccountExists) { & $add $item 'Manual' 'skuName' $null "A Premium account can't change to zone-redundant in place: create a new Premium_ZRS account and move the data." }
                else { & $add $item 'Auto' 'skuName' $better $(if ($AccountExists) { 'Replicate to a second region (GRS) - a change Azure makes in place. For zone redundancy (GZRS), Azure needs a conversion request.' } else { 'Replicate across zones and a second region.' }) }
            }
            '^Azure\.Storage\.SoftDelete$' {
                & $add $item 'Auto' 'blobServices.deleteRetentionPolicyEnabled' $true 'Keep deleted blobs recoverable.'
                if (-not $Configuration['blobServices'] -or -not $Configuration['blobServices']['deleteRetentionPolicyDays']) { & $add $item 'Auto' 'blobServices.deleteRetentionPolicyDays' 7 'For 7 days.' }
            }
            '^Azure\.Storage\.ContainerSoftDelete$' {
                & $add $item 'Auto' 'blobServices.containerDeleteRetentionPolicyEnabled' $true 'Keep deleted containers recoverable.'
                if (-not $Configuration['blobServices'] -or -not $Configuration['blobServices']['containerDeleteRetentionPolicyDays']) { & $add $item 'Auto' 'blobServices.containerDeleteRetentionPolicyDays' 7 'For 7 days.' }
            }
            '^Azure\.Storage\.FileShareSoftDelete$' {
                & $add $item 'Auto' 'fileServices.shareDeleteRetentionPolicy.enabled' $true 'Keep deleted file shares recoverable.'
                & $add $item 'Auto' 'fileServices.shareDeleteRetentionPolicy.days' 7 'For 7 days.'
            }
            '^Azure\.Storage\.BlobAccessType$' {
                $containers = @([regex]::Matches((& $field $item 'Reason'), "container '([^']+)'") | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique)
                if (-not $containers.Count) { & $add $item 'Manual' 'blobServices.containers' $null 'Set publicAccess to None on every container.' }
                foreach ($container in $containers) { & $add $item 'Auto' "blobServices.containers[name=$container].publicAccess" 'None' "Make container $container private." }
            }
            '^(Azure\.Storage\.(Name|Naming)|AAC\.Resource\.Naming)$' { & $add $item 'Manual' 'name' $null "The name doesn't follow the naming rules ($(& $field $item 'Reason')). A storage account can't be renamed: choose a name that does (Cloud Adoption Framework: st<workload><env>, e.g. stcontosoprod) for a new account - or exclude the rule (-ExcludeRule $rule) if this name is intended." }
            '^Azure\.Storage\.(DefenderCloud|Defender\.MalwareScan)$' { & $add $item 'Manual' '' $null "Turn on Microsoft Defender for Storage$(if ($rule -like '*MalwareScan') { ' with malware scanning' }) - for the subscription (Defender for Cloud > Environment settings) or this account. It isn't a storage account setting, so it can't be fixed here; once it's on, run again." }
            '^Azure\.Resource\.UseTags$' { & $add $item 'Manual' 'tags' $null "Tag the account - tags are your values, so they can't be suggested: tags in the configuration, or -Tag @{ env = 'prod'; owner = 'team' }." }
            '^AAC\.(Resource|ResourceGroup)\.(RequiredTags|AllowedTagValues)$' { & $add $item 'Manual' 'tags' $null "Fix the tags: $(& $field $item 'Reason'). Add them to tags in the configuration (-Tag)." }
            default { & $add $item 'Manual' '' $null "$(if (& $field $item 'Recommendation') { & $field $item 'Recommendation' } else { & $field $item 'Title' }) - see the rule's documentation, or exclude it with -ExcludeRule $rule if it doesn't apply." }
        }
    }
    $fixes.ToArray()
}