Private/ConvertTo-AACPolicyRemediation.ps1

function ConvertTo-AACPolicyRemediation {
    <#
    .SYNOPSIS
        Builds Get-AACPolicyRemediation's list (AAC.PolicyRemediation): each
        DeployIfNotExists or Modify policy with non-compliant resources it
        could fix, and what its remediation tasks did.
    .DESCRIPTION
        No Azure calls.
          -Read Invoke-AACGraphBatch's result (fixable, assignments)
          -Definition definition ID (lower case) -> its row (displayName),
                        for the policies' names
          -Task the remediation tasks (Microsoft.PolicyInsights/
                        remediations items) of the subscriptions and
                        management groups in scope
        Each row: the assignment, the policy, its effect, the non-compliant
        resources, the latest remediation task (state, when, deployments
        succeeded and failed) and a finding:
          High the latest task failed, or failed deployments; or the
                  assignment has no managed identity (nothing can remediate)
          Medium never remediated
          Low remediated, yet resources are non-compliant again (new
                  resources, or the existence condition never matches)
          Info a task is running; enforcement off (DoNotEnforce: new
                  resources aren't remediated, existing ones can be)
        Returns @{ Remediations; Notices; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Read,

        [hashtable] $Definition = @{},

        [AllowEmptyCollection()]
        [object[]] $Task = @(),

        [string[]] $Unread = @(),

        [datetime] $Now = [datetime]::UtcNow
    )

    $get = ${function:Get-AACMember}
    $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } }
    $number = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v -or '' -eq $v) { 0 } else { [int64]$v } }
    $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $date = {
        param([string] $Value)
        $d = [datetime]::MinValue
        if ($Value -and [datetime]::TryParse($Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null }
    }
    $notices = [System.Collections.Generic.List[string]]::new()
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(@{ fixable = 'non-compliant resources'; assignments = 'policy assignments' }[$key]) couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") }
    foreach ($u in @($Unread | Where-Object { $_ })) { $notices.Add("The remediation tasks couldn't be read for $u - its policies may show as never remediated.") }

    $assignments = @{}
    foreach ($row in (& $rowsOf 'assignments')) { $assignments[(& $text $row 'id')] = $row }
    $scopeName = { param([string] $Scope) if ($Scope -match '/managementgroups/([^/]+)$') { "management group $($Matches[1])" } elseif ($Scope -match '^/subscriptions/([^/]+)$') { "subscription $($Matches[1])" } elseif ($Scope -match '/resourcegroups/([^/]+)$') { "resource group $($Matches[1])" } else { $Scope } }

    # The latest task per assignment and policy reference.
    $latest = @{}
    foreach ($t in @($Task | Where-Object { $_ })) {
        $p = & $get $t 'properties'
        $key = "$((& $text $p 'policyAssignmentId').ToLowerInvariant())|$(& $text $p 'policyDefinitionReferenceId')"
        $when = & $date (& $text $p 'createdOn')
        if (-not $latest.Contains($key) -or ($when -and $latest[$key].When -lt $when)) { $latest[$key] = @{ When = $when; P = $p } }
    }

    $rank = (Get-AACSeverityRank).Rank
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($row in (& $rowsOf 'fixable')) {
        $assignmentId = & $text $row 'assignmentId'
        $reference = & $text $row 'referenceId'
        $definitionId = & $text $row 'definitionId'
        $a = if ($assignments.Contains($assignmentId)) { $assignments[$assignmentId] } else { $null }
        $policy = if ($Definition.Contains($definitionId)) { & $text $Definition[$definitionId] 'displayName' } else { ($definitionId -split '/')[-1] }
        $count = & $number $row 'nonCompliant'
        $latestTask = if ($latest.Contains("$assignmentId|$reference")) { $latest["$assignmentId|$reference"] } elseif ($latest.Contains("$assignmentId|")) { $latest["$assignmentId|"] } else { $null }
        $state = if ($latestTask) { & $text $latestTask.P 'provisioningState' } else { '' }
        $total = if ($latestTask) { & $number $latestTask.P 'deploymentStatus.totalDeployments' } else { 0 }
        $ok = if ($latestTask) { & $number $latestTask.P 'deploymentStatus.successfulDeployments' } else { 0 }
        $failed = if ($latestTask) { & $number $latestTask.P 'deploymentStatus.failedDeployments' } else { 0 }
        $identity = if ($a) { & $text $a 'identityType' } else { '' }
        $findings = [System.Collections.Generic.List[object]]::new()
        if ($a -and (-not $identity -or $identity -eq 'None')) { $findings.Add(@{ S = 'High'; T = 'The assignment has no managed identity: nothing can deploy or modify for it. Assign it a system- or user-assigned identity with the roles its policies list.' }) }
        if ($latestTask -and ($state -eq 'Failed' -or $failed -gt 0)) { $findings.Add(@{ S = 'High'; T = "The latest remediation task $(if ($state -eq 'Failed') { 'failed' } else { "had $failed failed deployment(s)" }) - check the identity's roles (Invoke-AACPolicyAssessment) and the deployment errors in the task." }) }
        elseif (-not $latestTask) { $findings.Add(@{ S = 'Medium'; T = "Never remediated: $count resource(s) could be fixed by a remediation task." }) }
        elseif ($state -in 'Accepted', 'Running', 'Evaluating') { $findings.Add(@{ S = 'Info'; T = 'A remediation task is running.' }) }
        elseif ($latestTask.When -and ($Now - $latestTask.When).TotalDays -ge 1) { $findings.Add(@{ S = 'Low'; T = "Remediated on $($latestTask.When.ToString('yyyy-MM-dd')), yet $count resource(s) are non-compliant again: run it again, or check the policy's existence condition can be met." }) }
        if ($a -and (& $text $a 'enforcement') -eq 'DoNotEnforce') { $findings.Add(@{ S = 'Info'; T = 'Enforcement is off (DoNotEnforce): new resources aren''t remediated automatically - existing ones still can be with a task.' }) }
        $worst = @($findings | Sort-Object { $rank[$_.S] } | Select-Object -First 1)
        $item = [pscustomobject][ordered]@{
            Severity     = $(if ($worst.Count) { $worst[0].S } else { 'Info' })
            Assignment   = $(if ($a -and (& $text $a 'displayName')) { & $text $a 'displayName' } else { ($assignmentId -split '/')[-1] })
            Policy       = $policy
            Effect       = $(if ((& $text $row 'effect') -eq 'modify') { 'Modify' } else { 'DeployIfNotExists' })
            AssignedAt   = $(if ($a) { & $scopeName (& $text $a 'scope') } else { '' })
            NonCompliant = $count
            Subscriptions = & $number $row 'subscriptions'
            LastTask     = $(if ($latestTask) { $state } else { 'Never' })
            LastTaskOn   = $(if ($latestTask) { $latestTask.When } else { $null })
            Deployments  = $(if ($latestTask) { "$ok of $total succeeded" } else { '' })
            Failed       = $failed
            Findings     = (@($findings | ForEach-Object { $_.T }) -join ' ')
            Identity     = $(if ($identity) { $identity } else { 'None' })
            AssignmentId = $assignmentId
            DefinitionId = $definitionId
            ReferenceId  = $reference
        }
        $item.PSObject.TypeNames.Insert(0, 'AAC.PolicyRemediation')
        $rows.Add($item)
    }
    $sorted = @($rows | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, @{ Expression = 'NonCompliant'; Descending = $true }, Assignment, Policy)
    $fixable = 0; foreach ($r in $sorted) { $fixable += $r.NonCompliant }
    @{
        Remediations = $sorted
        Notices      = @($notices)
        Stats        = @{
            Policies = $sorted.Count
            Fixable  = $fixable
            Never    = @($sorted | Where-Object LastTask -EQ 'Never').Count
            Failed   = @($sorted | Where-Object { $_.Failed -gt 0 -or $_.LastTask -eq 'Failed' }).Count
            High     = @($sorted | Where-Object Severity -EQ 'High').Count
        }
    }
}