Private/ConvertTo-AACAssessmentGovernance.ps1

function ConvertTo-AACAssessmentGovernance {
    <#
    .SYNOPSIS
        Builds Invoke-AACAssessment's governance sheets: policy compliance
        (by initiative, by resource group, by standard and control, and each
        non-compliant resource with why and how to fix it), the policy
        inventory, PSRule for Azure's results, and one list of every
        recommendation for the resources.
    .DESCRIPTION
        Sheets, in ConvertTo-AACAssessment's shape (so the CSV, HTML and PDF
        writers take them as they are):
          Policy Compliance by initiative, Compliance by resource
                             group, Compliance by standard (the controls of
                             the initiatives that group their policies, as
                             the regulatory ones do), Non-compliant resources
          Policy inventory Policy assignments, Policies (each policy in
                             force, per assignment: effect, resource types,
                             controls, status - Failed, Passed, Manual
                             review, Exempt, Not evaluated or Disabled)
          PSRule PSRule rules (checked, passed, failed), PSRule
                             results (the failures and errors)
          Recommendations Resource recommendations: Advisor, Defender for
                             Cloud, retirements, unattached and empty
                             resources, policy non-compliance and PSRule's
                             failures, each with a severity, a category
                             (Cost, Security, Reliability, Operational
                             excellence, Performance, Governance) and what
                             to do
        Returns @{ Sheets; Stats; Notices }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        # ConvertTo-AACAssessment's sheets: Advisor, Defender, retirements
        # and the inventory feed the recommendations.
        [object[]] $Sheet = @(),

        # The Estate read's policy rows (per assignment and policy).
        [object[]] $Policy = @(),

        # Policy compliance per resource group (policyByGroup).
        [object[]] $PolicyByGroup = @(),

        # The non-compliant resources (policyResources).
        [object[]] $PolicyResource = @(),

        # ConvertTo-AACAssignedPolicy's result: the assignments and their policies.
        [hashtable] $AssignedPolicy,

        # Invoke-AACPSRuleEngine's results.
        [object[]] $PSRuleResult = @(),

        [System.Collections.IDictionary] $SubscriptionName = @{},

        [switch] $PolicyRead,

        [switch] $PSRuleRead,

        # The cap on the non-compliant resources read, to say when it was reached.
        [int] $PolicyResourceLimit = 5000,

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

    $value = {
        param($Row, [string] $Name)
        if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] }
        elseif ($null -ne $Row -and $Row.PSObject.Properties[$Name]) { $Row.PSObject.Properties[$Name].Value }
    }
    $lower = { param($Text) ([string]$Text).ToLowerInvariant() }
    $leaf = { param($Id) if ($Id) { ([string]$Id).TrimEnd('/') -replace '^.*/', '' } else { '' } }
    $typeOf = { param($Id) if ([string]$Id -match '(?i)/providers/([^/]+/[^/]+)/[^/]+$') { $Matches[1].ToLowerInvariant() } elseif ([string]$Id -match '(?i)/resourceGroups/[^/]+$') { 'microsoft.resources/resourcegroups' } else { '' } }
    $slug = { param([string] $Name) ($Name.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-') }
    $percent = { param([int] $Good, [int] $Bad) if ($Good + $Bad) { [Math]::Round($Good / ($Good + $Bad) * 100, 1) } else { $null } }
    $subscriptionLabel = { param($Id) $key = & $lower $Id; if ($SubscriptionName.Contains($key)) { [string]$SubscriptionName[$key] } else { [string]$Id } }
    $newSheet = {
        param([string] $Category, [string] $Name, [System.Collections.IDictionary] $Types, [object[]] $Rows, [string[]] $Key, [string] $Note)
        @{ Id = & $slug $Name; Kind = 'Governance'; Category = $Category; Sheet = $Name; Columns = @($Types.Keys); Types = $Types; Rows = @($Rows); Key = @($Key); Error = ''; Note = $Note }
    }
    $findSheet = { param([string] $Name) @($Sheet | Where-Object { $_.Sheet -eq $Name }) | Select-Object -First 1 }
    $sheets = [System.Collections.Generic.List[hashtable]]::new()
    $notices = [System.Collections.Generic.List[string]]::new()
    $learn = 'https://learn.microsoft.com/azure/governance/policy/how-to'

    # A policy's status from its states: what the compliance chart counts.
    $statusOf = {
        param([int] $NonCompliant, [int] $Compliant, [int] $Exempt, [int] $Other, [string] $Effect)
        if ($Effect -eq 'disabled') { 'Disabled' }
        elseif ($NonCompliant) { 'Failed' }
        elseif ($Effect -eq 'manual' -or ($Other -and -not $Compliant)) { 'Manual review' }
        elseif ($Compliant) { 'Passed' }
        elseif ($Exempt) { 'Exempt' }
        else { 'Not evaluated' }
    }
    $remedy = {
        param([string] $Effect)
        switch ($Effect.ToLowerInvariant()) {
            { $_ -in 'deployifnotexists', 'modify' } { 'Create a remediation task for the assignment (Azure Policy > Remediation): it deploys or changes what the policy expects on the existing resources.'; break }
            'deny' { 'Change the resource to meet the policy - new and updated resources that don''t are already denied - or exempt it, with a reason.'; break }
            'auditifnotexists' { 'Deploy or configure the related resource the policy looks for (diagnostic settings, an extension, a setting), or exempt the resource, with a reason.'; break }
            'audit' { 'Change the resource''s configuration to meet the policy, or exempt it, with a reason.'; break }
            'manual' { 'Attest its compliance (a policy attestation), with the evidence.'; break }
            default { 'Review what the policy requires and change the resource, or exempt it, with a reason.' }
        }
    }
    # Resource Graph gives the effect in lower case; the definitions, as written.
    $effectNames = @{ deny = 'Deny'; audit = 'Audit'; auditifnotexists = 'AuditIfNotExists'; deployifnotexists = 'DeployIfNotExists'; modify = 'Modify'; append = 'Append'; manual = 'Manual'; disabled = 'Disabled'; denyaction = 'DenyAction' }
    $effectName = { param($Effect) $key = ([string]$Effect).ToLowerInvariant(); if ($effectNames.Contains($key)) { $effectNames[$key] } else { [string]$Effect } }
    $remedyLink = { param([string] $Effect) if ($Effect -in 'deployifnotexists', 'modify') { "$learn/remediate-resources" } else { "$learn/determine-non-compliance" } }

    # --- Policy: the compliance per assignment and policy (the Estate read) -------------------------------------------------
    $stateOf = @{}   # assignmentId|definitionId -> the row
    foreach ($row in $Policy) { $stateOf["$(& $lower (& $value $row 'assignmentId'))|$(& $lower (& $value $row 'definitionId'))"] = $row }
    $policyStatus = @{}   # the same key -> Failed, Passed, ...
    foreach ($key in $stateOf.Keys) {
        $row = $stateOf[$key]
        $policyStatus[$key] = & $statusOf ([int](& $value $row 'nonCompliant')) ([int](& $value $row 'compliant')) ([int](& $value $row 'exempt')) ([int](& $value $row 'other')) (& $lower (& $value $row 'effect'))
    }
    $byAssignment = @{}
    foreach ($row in $Policy) {
        $id = & $lower (& $value $row 'assignmentId')
        if (-not $byAssignment.Contains($id)) { $byAssignment[$id] = [System.Collections.Generic.List[object]]::new() }
        $byAssignment[$id].Add($row)
    }
    $assignmentTotals = @{}
    foreach ($id in $byAssignment.Keys) {
        $rows = @($byAssignment[$id])
        $sum = { param([string] $Name) [int](@($rows | ForEach-Object { [int](& $value $_ $Name) }) | Measure-Object -Sum).Sum }
        $statuses = @($rows | ForEach-Object { $policyStatus["$id|$(& $lower (& $value $_ 'definitionId'))"] })
        $assignmentTotals[$id] = @{
            Policies = $rows.Count; Failed = @($statuses | Where-Object { $_ -eq 'Failed' }).Count
            NonCompliant = & $sum 'nonCompliant'; Compliant = & $sum 'compliant'; Exempt = & $sum 'exempt'; Other = & $sum 'other'
            Status = $(if ($statuses -contains 'Failed') { 'Failed' } elseif ($statuses -contains 'Manual review') { 'Manual review' } elseif ($statuses -contains 'Passed') { 'Passed' } elseif ($statuses -contains 'Exempt') { 'Exempt' } else { 'Not evaluated' })
            First = $rows[0]
        }
    }

    $nonCompliantResources = 0
    if ($PolicyRead) {
        $initiativeRows = foreach ($id in $assignmentTotals.Keys) {
            $t = $assignmentTotals[$id]; $first = $t.First
            $setName = [string](& $value $first 'policySet')
            [pscustomobject][ordered]@{
                'Assignment' = [string]$(@((& $value $first 'assignment'), (& $value $first 'assignmentName')) | Where-Object { $_ } | Select-Object -First 1)
                'Initiative or policy' = $(if ($setName) { $setName } elseif ((& $value $first 'setId')) { & $leaf (& $value $first 'setId') } else { [string]$(@((& $value $first 'policy'), (& $leaf (& $value $first 'definitionId'))) | Where-Object { $_ } | Select-Object -First 1) })
                'Kind' = $(if ((& $value $first 'setId')) { 'Initiative' } else { 'Policy' })
                'Status' = $t.Status; 'Policies' = $t.Policies; 'Failed policies' = $t.Failed
                'Non-compliant' = $t.NonCompliant; 'Compliant' = $t.Compliant; 'Exempt' = $t.Exempt; 'Compliance (%)' = & $percent $t.Compliant $t.NonCompliant
                'Scope' = [string](& $value $first 'assignmentScope'); 'ResourceId' = [string](& $value $first 'assignmentId')
            }
        }
        $sheets.Add((& $newSheet 'Policy' 'Compliance by initiative' ([ordered]@{ 'Assignment' = 'text'; 'Initiative or policy' = 'wide'; 'Kind' = 'badge'; 'Status' = 'badge'; 'Policies' = 'number'; 'Failed policies' = 'number'; 'Non-compliant' = 'number'; 'Compliant' = 'number'; 'Exempt' = 'number'; 'Compliance (%)' = 'score'; 'Scope' = 'mono' }) `
                    @($initiativeRows | Sort-Object -Property @{ Expression = 'Failed policies'; Descending = $true }, @{ Expression = 'Non-compliant'; Descending = $true }, Assignment) @('Assignment', 'Initiative or policy', 'Status', 'Failed policies', 'Compliance (%)') `
                    'Each assignment: its policies by status, and the resource states (one per resource and policy) in each compliance state.'))

        $groupRows = foreach ($row in $PolicyByGroup) {
            $subscriptionId = [string](& $value $row 'subscriptionId'); $group = [string](& $value $row 'resourceGroup')
            $bad = [int](& $value $row 'nonCompliant'); $good = [int](& $value $row 'compliant'); $exempt = [int](& $value $row 'exempt')
            [pscustomobject][ordered]@{
                'Subscription' = & $subscriptionLabel $subscriptionId; 'Resource group' = $(if ($group) { $group } else { '(the subscription)' })
                'Status' = $(if ($bad) { 'Failed' } elseif ($good) { 'Passed' } elseif ($exempt) { 'Exempt' } else { 'Not evaluated' })
                'Resources' = [int](& $value $row 'resources'); 'Non-compliant' = $bad; 'Compliant' = $good; 'Exempt' = $exempt; 'Compliance (%)' = & $percent $good $bad
                'Non-compliant states' = [int](& $value $row 'nonCompliantStates')
                'ResourceId' = $(if ($group) { "/subscriptions/$subscriptionId/resourceGroups/$group" } else { "/subscriptions/$subscriptionId" })
            }
        }
        $sheets.Add((& $newSheet 'Policy' 'Compliance by resource group' ([ordered]@{ 'Subscription' = 'text'; 'Resource group' = 'resource'; 'Status' = 'badge'; 'Resources' = 'number'; 'Non-compliant' = 'number'; 'Compliant' = 'number'; 'Exempt' = 'number'; 'Compliance (%)' = 'score'; 'Non-compliant states' = 'number' }) `
                    @($groupRows | Sort-Object -Property @{ Expression = 'Non-compliant'; Descending = $true }, Subscription, 'Resource group') @('Subscription', 'Resource group', 'Status', 'Non-compliant', 'Compliance (%)') `
                    'Resources counted once: non-compliant with any policy, else compliant (or exempt).'))

        $resourceRows = foreach ($row in $PolicyResource) {
            $resourceId = [string](& $value $row 'resourceId'); $effect = [string](& $value $row 'effect')
            $policyName = [string]$(@((& $value $row 'policy'), (& $leaf (& $value $row 'definitionId'))) | Where-Object { $_ } | Select-Object -First 1)
            $description = [string](& $value $row 'description')
            $code = [string](& $value $row 'reasonCode')
            $why = switch ($effect.ToLowerInvariant()) {
                'auditifnotexists' { 'The related resource or setting the policy checks for is missing, or not configured as it requires.'; break }
                'deployifnotexists' { 'The related resource or setting the policy deploys is missing, or not configured as it requires.'; break }
                'manual' { 'Its compliance is attested by hand, and hasn''t been.'; break }
                default { 'The resource''s configuration doesn''t match what the policy requires.' }
            }
            [pscustomobject][ordered]@{
                'Resource' = & $leaf $resourceId; 'Resource type' = [string](& $value $row 'resourceType'); 'Resource group' = [string](& $value $row 'resourceGroup')
                'Subscription' = & $subscriptionLabel (& $value $row 'subscriptionId'); 'Policy' = $policyName
                'Initiative' = [string](& $value $row 'policySet'); 'Assignment' = [string]$(@((& $value $row 'assignment'), (& $leaf (& $value $row 'assignmentId'))) | Where-Object { $_ } | Select-Object -First 1)
                'Effect' = & $effectName $effect
                'Reason' = (@($why, $(if ($code) { "Reason code: $code." }), $(if ($description) { "The policy: $description" })) | Where-Object { $_ }) -join ' '
                'Remediation' = & $remedy $effect; 'Evaluated' = [string](& $value $row 'evaluated'); 'Link' = & $remedyLink ($effect.ToLowerInvariant())
                'Controls' = [string](& $value $row 'groups'); 'ResourceId' = $resourceId
            }
        }
        $resourceRows = @($resourceRows)
        $nonCompliantResources = @($resourceRows | ForEach-Object { & $lower $_.ResourceId } | Select-Object -Unique).Count
        if ($resourceRows.Count -ge $PolicyResourceLimit) { $notices.Add("The non-compliant resources list stops at $PolicyResourceLimit resource-and-policy pairs; Compliance by resource group counts them all. Narrow the scope (-SubscriptionId, -ResourceGroupName) for the rest.") }
        $sheets.Add((& $newSheet 'Policy' 'Non-compliant resources' ([ordered]@{ 'Resource' = 'resource'; 'Resource type' = 'mono'; 'Resource group' = 'text'; 'Subscription' = 'text'; 'Policy' = 'wide'; 'Initiative' = 'text'; 'Assignment' = 'text'; 'Effect' = 'badge'; 'Reason' = 'wide'; 'Remediation' = 'wide'; 'Evaluated' = 'text'; 'Link' = 'link'; 'Controls' = 'wide' }) `
                    @($resourceRows | Sort-Object Subscription, 'Resource group', Resource, Policy) @('Resource', 'Resource group', 'Policy', 'Effect', 'Reason') `
                    'Each resource and the policy it fails: why, and how to fix it. For the evaluated field and its value, open the resource''s compliance details in the Azure portal (Policy > Compliance).'))
    }

    # --- Policy inventory: the assignments and every policy in force -----------------------------------------------------
    $policyRows = @()
    if ($AssignedPolicy) {
        $assignmentRows = foreach ($a in @($AssignedPolicy.Assignments)) {
            $id = & $lower $a.AssignmentId
            $t = if ($assignmentTotals.Contains($id)) { $assignmentTotals[$id] } else { $null }
            [pscustomobject][ordered]@{
                'Assignment' = [string]$a.AssignmentDisplayName; 'Definition' = [string]$a.DefinitionDisplayName; 'Kind' = $(if ($a.DefinitionType -eq 'PolicySet') { 'Initiative' } else { 'Policy' })
                'Status' = $(if ($t) { $t.Status } else { 'Not evaluated' }); 'Enforcement' = [string]$a.EnforcementMode
                'Scope' = "$($a.ScopeType): $($a.ScopeName)"; 'Inherited' = $(if ($a.Inherited) { 'Yes' } else { 'No' }); 'Policies' = [int]$a.Members; 'Category' = [string]$a.Category
                'Policy type' = [string]$a.PolicyType; 'Resource types' = [string]$a.ResourceType
                'Non-compliant' = $(if ($t) { $t.NonCompliant } else { 0 }); 'Compliance (%)' = $(if ($t) { & $percent $t.Compliant $t.NonCompliant } else { $null })
                'Not scopes' = [string]$a.NotScopes; 'ResourceId' = [string]$a.AssignmentId
            }
        }
        $sheets.Add((& $newSheet 'Policy inventory' 'Policy assignments' ([ordered]@{ 'Assignment' = 'text'; 'Definition' = 'wide'; 'Kind' = 'badge'; 'Status' = 'badge'; 'Enforcement' = 'badge'; 'Scope' = 'text'; 'Inherited' = 'badge'; 'Policies' = 'number'; 'Category' = 'text'; 'Policy type' = 'text'; 'Resource types' = 'wide'; 'Non-compliant' = 'number'; 'Compliance (%)' = 'score'; 'Not scopes' = 'wide' }) `
                    @($assignmentRows) @('Assignment', 'Definition', 'Kind', 'Status', 'Enforcement', 'Scope') 'Every policy assignment in force on the scope - inherited from management groups too.'))

        # Members come one per parameter: one row per assignment and policy.
        $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $policyRows = @(foreach ($m in @($AssignedPolicy.Members)) {
                $key = "$(& $lower $m.AssignmentId)|$(& $lower $m.PolicyId)"
                if (-not $seen.Add("$key|$($m.ReferenceId)")) { continue }
                $state = if ($stateOf.Contains($key)) { $stateOf[$key] } else { $null }
                $effect = [string]$m.Effect
                $status = if ($effect -eq 'Disabled') { 'Disabled' } elseif ($policyStatus.Contains($key)) { $policyStatus[$key] } elseif ($effect -eq 'Manual') { 'Manual review' } else { 'Not evaluated' }
                $bad = if ($state) { [int](& $value $state 'nonCompliant') } else { 0 }
                $good = if ($state) { [int](& $value $state 'compliant') } else { 0 }
                [pscustomobject][ordered]@{
                    'Policy' = [string]$m.PolicyDisplayName; 'Status' = $status; 'Effect' = $effect; 'Assignment' = [string]$m.AssignmentDisplayName
                    'Initiative' = [string]$m.PolicySetDisplayName; 'Category' = [string]$m.Category; 'Policy type' = [string]$m.PolicyType
                    'Resource types' = [string]$m.ResourceType; 'Controls' = [string]$m.Groups
                    'Non-compliant' = $bad; 'Compliant' = $good; 'Compliance (%)' = & $percent $good $bad
                    'Scope' = "$($m.ScopeType): $($m.ScopeName)"; 'ResourceId' = [string]$m.PolicyId; 'AssignmentId' = [string]$m.AssignmentId
                }
            })
        $statusRank = @{ Failed = 0; 'Manual review' = 1; 'Not evaluated' = 2; Exempt = 3; Passed = 4; Disabled = 5 }
        $sheets.Add((& $newSheet 'Policy inventory' 'Policies' ([ordered]@{ 'Policy' = 'wide'; 'Status' = 'badge'; 'Effect' = 'badge'; 'Assignment' = 'text'; 'Initiative' = 'text'; 'Category' = 'text'; 'Policy type' = 'text'; 'Resource types' = 'wide'; 'Controls' = 'wide'; 'Non-compliant' = 'number'; 'Compliant' = 'number'; 'Compliance (%)' = 'score'; 'Scope' = 'text' }) `
                    @($policyRows | Sort-Object -Property @{ Expression = { $statusRank[$_.Status] } }, @{ Expression = 'Non-compliant'; Descending = $true }, Assignment, Policy) @('Policy', 'Status', 'Effect', 'Assignment', 'Resource types') `
                    'Every policy in force, per assignment: its effect (with the assignment''s parameters), the resource types it targets, the controls it maps to, and its status - Failed, Passed, Manual review, Exempt, Not evaluated or Disabled.'))

        # --- Compliance by standard: the initiatives' controls (their policy definition groups) ----------------------------
        $controls = [ordered]@{}
        foreach ($p in $policyRows) {
            if (-not $p.Controls -or -not $p.Initiative) { continue }
            foreach ($control in @(([string]$p.Controls) -split ',\s*' | Where-Object { $_ })) {
                $key = "$($p.AssignmentId)|$control"
                if (-not $controls.Contains($key)) { $controls[$key] = @{ Standard = $p.Initiative; Assignment = $p.Assignment; Control = $control; Policies = [System.Collections.Generic.List[object]]::new(); AssignmentId = $p.AssignmentId } }
                $controls[$key].Policies.Add($p)
            }
        }
        $standardRows = foreach ($c in $controls.Values) {
            $statuses = @($c.Policies.Status)
            [pscustomobject][ordered]@{
                'Standard' = $c.Standard; 'Control' = $c.Control
                'Status' = $(if ($statuses -contains 'Failed') { 'Failed' } elseif ($statuses -contains 'Manual review') { 'Manual review' } elseif ($statuses -contains 'Passed') { 'Passed' } elseif ($statuses -contains 'Exempt') { 'Exempt' } elseif (-not @($statuses | Where-Object { $_ -ne 'Disabled' }).Count) { 'Disabled' } else { 'Not evaluated' })
                'Policies' = $c.Policies.Count; 'Failed policies' = @($statuses | Where-Object { $_ -eq 'Failed' }).Count
                'Non-compliant' = [int](@($c.Policies | ForEach-Object { $_.'Non-compliant' }) | Measure-Object -Sum).Sum
                'Failing policies' = (@($c.Policies | Where-Object Status -EQ 'Failed' | ForEach-Object { $_.Policy }) | Select-Object -Unique) -join '; '
                'Assignment' = $c.Assignment; 'ResourceId' = $c.AssignmentId
            }
        }
        if (@($standardRows).Count) {
            $sheets.Add((& $newSheet 'Policy' 'Compliance by standard' ([ordered]@{ 'Standard' = 'text'; 'Control' = 'mono'; 'Status' = 'badge'; 'Policies' = 'number'; 'Failed policies' = 'number'; 'Non-compliant' = 'number'; 'Failing policies' = 'wide'; 'Assignment' = 'text' }) `
                        @($standardRows | Sort-Object Standard, @{ Expression = { $statusRank[$_.Status] } }, Control) @('Standard', 'Control', 'Status', 'Failed policies') `
                        'The controls of each initiative that maps its policies to a standard (its policy definition groups - CIS, NIST, ISO 27001, the Microsoft cloud security benchmark ...), with the policies behind each.'))
        }
    }
    elseif ($PolicyRead) { $notices.Add('The policy assignments couldn''t be read, so the policy inventory is missing.') }

    # --- PSRule for Azure ----------------------------------------------------------------------------------------------
    $psruleRows = @($PSRuleResult | Where-Object { $_ })
    if ($PSRuleRead) {
        $joinText = { param($Item) (@($Item) | Where-Object { $_ } | ForEach-Object { [string]$_ }) -join ' ' }
        $ruleRows = foreach ($group in @($psruleRows | Group-Object -Property RuleName)) {
            $first = $group.Group[0]
            $passed = @($group.Group | Where-Object Outcome -EQ 'Pass').Count; $failed = @($group.Group | Where-Object Outcome -EQ 'Fail').Count
            [pscustomobject][ordered]@{
                'Rule' = $(if ($first.Title) { [string]$first.Title } else { [string]$first.RuleName }); 'Name' = [string]$first.RuleName; 'Pillar' = [string]$first.Pillar; 'Severity' = [string]$first.Severity
                'Status' = $(if ($failed) { 'Failed' } elseif (@($group.Group | Where-Object Outcome -EQ 'Error').Count) { 'Error' } else { 'Passed' })
                'Checked' = $group.Count; 'Passed' = $passed; 'Failed' = $failed; 'Errors' = @($group.Group | Where-Object Outcome -EQ 'Error').Count
                'Pass (%)' = & $percent $passed $failed; 'Recommendation' = & $joinText $first.Recommendation; 'Link' = [string]$first.Link; 'ResourceId' = ''
            }
        }
        $sheets.Add((& $newSheet 'PSRule' 'PSRule rules' ([ordered]@{ 'Rule' = 'wide'; 'Name' = 'mono'; 'Pillar' = 'text'; 'Severity' = 'badge'; 'Status' = 'badge'; 'Checked' = 'number'; 'Passed' = 'number'; 'Failed' = 'number'; 'Errors' = 'number'; 'Pass (%)' = 'score'; 'Recommendation' = 'wide'; 'Link' = 'link' }) `
                    @($ruleRows | Sort-Object -Property @{ Expression = 'Failed'; Descending = $true }, Rule) @('Rule', 'Pillar', 'Severity', 'Checked', 'Passed', 'Failed') `
                    'Each PSRule for Azure rule run on the resources: how many it checked, passed and failed, and its documentation.'))
        $resultRows = foreach ($r in @($psruleRows | Where-Object { $_.Outcome -in 'Fail', 'Error' })) {
            [pscustomobject][ordered]@{
                'Outcome' = [string]$r.Outcome; 'Severity' = [string]$r.Severity; 'Rule' = $(if ($r.Title) { [string]$r.Title } else { [string]$r.RuleName }); 'Pillar' = [string]$r.Pillar
                'Resource' = [string]$r.ResourceName; 'Resource type' = [string]$r.ResourceType; 'Resource group' = [string]$r.ResourceGroup; 'Subscription' = [string]$r.SubscriptionName
                'Reason' = & $joinText $r.Reason; 'Recommendation' = & $joinText $r.Recommendation; 'Link' = [string]$r.Link; 'Name' = [string]$r.RuleName; 'ResourceId' = [string]$r.ResourceId
            }
        }
        $sheets.Add((& $newSheet 'PSRule' 'PSRule results' ([ordered]@{ 'Outcome' = 'badge'; 'Severity' = 'badge'; 'Rule' = 'wide'; 'Pillar' = 'text'; 'Resource' = 'resource'; 'Resource type' = 'mono'; 'Resource group' = 'text'; 'Subscription' = 'text'; 'Reason' = 'wide'; 'Recommendation' = 'wide'; 'Link' = 'link'; 'Name' = 'mono' }) `
                    @($resultRows) @('Outcome', 'Severity', 'Rule', 'Resource', 'Reason') 'The resources that failed a rule (or that it couldn''t check), with why.'))
    }

    # --- Resource recommendations: everything to act on, in one list --------------------------------------------------------
    $categoryOf = {
        param([string] $Text)
        switch -Regex ($Text) {
            '(?i)^cost' { 'Cost'; break }
            '(?i)secur' { 'Security'; break }
            '(?i)reliab|availab|resilien' { 'Reliability'; break }
            '(?i)perform' { 'Performance'; break }
            '(?i)operation' { 'Operational excellence'; break }
            default { 'Governance' }
        }
    }
    $recommendations = [System.Collections.Generic.List[object]]::new()
    $add = {
        param([string] $Severity, [string] $Category, [string] $Source, [string] $Recommendation, $Row, [string] $Remediation, [string] $Link, [string] $ResourceId)
        if (-not $ResourceId) { $ResourceId = [string](& $value $Row 'ResourceId') }
        $resourceName = [string](& $value $Row 'Resource')
        $recommendations.Add([pscustomobject][ordered]@{
                'Severity' = $Severity; 'Category' = $Category; 'Source' = $Source; 'Recommendation' = $Recommendation
                'Resource' = $(if ($resourceName) { $resourceName } else { & $leaf $ResourceId })
                'Resource type' = $(if ((& $value $Row 'Resource type')) { [string](& $value $Row 'Resource type') } else { & $typeOf $ResourceId })
                'Resource group' = $(if ((& $value $Row 'Resource group')) { [string](& $value $Row 'Resource group') } elseif ($ResourceId -match '(?i)/resourceGroups/([^/]+)') { $Matches[1] } else { '' })
                'Subscription' = $(if ((& $value $Row 'Subscription')) { [string](& $value $Row 'Subscription') } elseif ($ResourceId -match '(?i)^/subscriptions/([^/]+)') { & $subscriptionLabel $Matches[1] } else { '' })
                'Remediation' = $Remediation; 'Link' = $Link; 'ResourceId' = $ResourceId
            })
    }
    $advisorLink = 'https://portal.azure.com/#view/Microsoft_Azure_Expert/AdvisorMenuBlade/~/overview'
    $advisor = & $findSheet 'Advisor recommendations'
    if ($advisor) { foreach ($row in @($advisor.Rows)) { & $add ([string]$row.Impact) (& $categoryOf ([string]$row.Category)) 'Azure Advisor' ([string]$row.Problem) $row ([string]$row.Solution) $advisorLink '' } }
    $security = & $findSheet 'Security recommendations'
    if ($security) { foreach ($row in @($security.Rows)) { & $add ([string]$row.Severity) 'Security' 'Defender for Cloud' ([string]$row.Recommendation) $row ([string]$row.Remediation) 'https://portal.azure.com/#view/Microsoft_Azure_Security/SecurityMenuBlade/~/5' '' } }
    $retirements = & $findSheet 'Retirements'
    if ($retirements) {
        foreach ($row in @($retirements.Rows)) {
            $date = [datetime]::MinValue
            $known = [datetime]::TryParse([string]$row.'Retirement date', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$date)
            $days = if ($known) { ($date.ToUniversalTime() - $Now).TotalDays } else { 365 }
            & $add $(if ($days -le 90) { 'High' } elseif ($days -le 365) { 'Medium' } else { 'Low' }) 'Reliability' 'Retirements' "Retiring$(if ($known) { " on $($date.ToString('yyyy-MM-dd'))" }): $($row.Retiring)" $row ([string]$row.'What to do') 'https://azure.microsoft.com/updates/?updateType=retirements' ''
        }
    }
    foreach ($s in @($Sheet | Where-Object { $_.Kind -eq 'Inventory' -and ($_.Columns -contains 'Orphaned' -or $_.Columns -contains 'Empty') })) {
        foreach ($label in 'Orphaned', 'Empty') {
            if ($s.Columns -notcontains $label) { continue }
            foreach ($row in @($s.Rows | Where-Object { $_.$label -eq 'Yes' })) {
                & $add 'Low' 'Cost' 'Inventory' "$($s.Sheet): $(if ($label -eq 'Orphaned') { 'not attached to anything' } else { 'empty' })" $row 'Confirm it isn''t needed (it may be kept for a reason - a reserved IP, a standby disk), then delete it: it costs, and widens what has to be governed.' '' ''
            }
        }
    }
    $disks = & $findSheet 'Disks'
    if ($disks -and $disks.Columns -notcontains 'Orphaned') {
        foreach ($row in @($disks.Rows | Where-Object { $_.State -eq 'Unattached' })) { & $add 'Low' 'Cost' 'Inventory' 'Disk not attached to any VM' $row 'Snapshot it if it might be needed, then delete it: an unattached disk is billed in full.' '' '' }
    }
    $groups = & $findSheet 'Resource groups'
    if ($groups) { foreach ($row in @($groups.Rows | Where-Object { $_.Empty -eq 'Yes' })) { & $add 'Low' 'Operational excellence' 'Inventory' 'Empty resource group' $row 'Delete it if nothing is due to be deployed in it: empty groups keep their role assignments and policies, and clutter the estate.' '' '' } }
    $nonCompliant = @($sheets | Where-Object { $_.Sheet -eq 'Non-compliant resources' }) | Select-Object -First 1
    if ($nonCompliant) {
        foreach ($row in @($nonCompliant.Rows)) {
            $effect = ([string]$row.Effect).ToLowerInvariant()
            & $add $(if ($effect -in 'deny', 'deployifnotexists', 'modify') { 'Medium' } else { 'Low' }) 'Governance' 'Azure Policy' "Non-compliant: $($row.Policy)" $row ([string]$row.Remediation) ([string]$row.Link) ''
        }
    }
    $psruleSeverity = @{ Critical = 'Critical'; Important = 'High'; Awareness = 'Low' }
    if ($PSRuleRead) {
        foreach ($r in @($psruleRows | Where-Object Outcome -EQ 'Fail')) {
            $row = @{ Resource = [string]$r.ResourceName; 'Resource type' = [string]$r.ResourceType; 'Resource group' = [string]$r.ResourceGroup; Subscription = [string]$r.SubscriptionName }
            $severity = if ($psruleSeverity.Contains([string]$r.Severity)) { $psruleSeverity[[string]$r.Severity] } else { 'Medium' }
            & $add $severity (& $categoryOf ([string]$r.Pillar)) 'PSRule for Azure' $(if ($r.Title) { [string]$r.Title } else { [string]$r.RuleName }) $row ((@($r.Recommendation) | Where-Object { $_ }) -join ' ') ([string]$r.Link) ([string]$r.ResourceId)
        }
    }
    $severityRank = @{ Critical = 0; High = 1; Medium = 2; Low = 3 }
    $recommendationRows = @($recommendations | Sort-Object -Property @{ Expression = { if ($severityRank.Contains($_.Severity)) { $severityRank[$_.Severity] } else { 4 } } }, Category, Recommendation, Resource)
    $sheets.Add((& $newSheet 'Recommendations' 'Resource recommendations' ([ordered]@{ 'Severity' = 'badge'; 'Category' = 'badge'; 'Source' = 'text'; 'Recommendation' = 'wide'; 'Resource' = 'resource'; 'Resource type' = 'mono'; 'Resource group' = 'text'; 'Subscription' = 'text'; 'Remediation' = 'wide'; 'Link' = 'link' }) `
                $recommendationRows @('Severity', 'Category', 'Source', 'Recommendation', 'Resource') `
                'Everything to act on, in one list: Azure Advisor, Defender for Cloud, retirements, unattached and empty resources, policy non-compliance and PSRule for Azure - most severe first.'))

    $countOf = { param($Rows, [string] $Property, [string] $Value) @($Rows | Where-Object { $_.$Property -eq $Value }).Count }
    $stats = [ordered]@{
        PolicyAssignments      = $(if ($AssignedPolicy) { @($AssignedPolicy.Assignments).Count } else { $byAssignment.Count })
        Policies               = $(if ($AssignedPolicy) { $policyRows.Count } else { $stateOf.Count })
        PoliciesFailed         = $(if ($AssignedPolicy) { & $countOf $policyRows 'Status' 'Failed' } else { @($policyStatus.Values | Where-Object { $_ -eq 'Failed' }).Count })
        PoliciesPassed         = $(if ($AssignedPolicy) { & $countOf $policyRows 'Status' 'Passed' } else { @($policyStatus.Values | Where-Object { $_ -eq 'Passed' }).Count })
        PoliciesManual         = $(if ($AssignedPolicy) { & $countOf $policyRows 'Status' 'Manual review' } else { @($policyStatus.Values | Where-Object { $_ -eq 'Manual review' }).Count })
        NonCompliantResources  = $nonCompliantResources
        PSRuleRules            = $(if ($PSRuleRead) { @($psruleRows | ForEach-Object { $_.RuleName } | Select-Object -Unique).Count } else { $null })
        PSRulePassed           = $(if ($PSRuleRead) { & $countOf $psruleRows 'Outcome' 'Pass' } else { $null })
        PSRuleFailed           = $(if ($PSRuleRead) { & $countOf $psruleRows 'Outcome' 'Fail' } else { $null })
        Recommendations        = $recommendationRows.Count
        RecommendationsUrgent  = @($recommendationRows | Where-Object { $_.Severity -in 'Critical', 'High' }).Count
    }
    @{ Sheets = $sheets.ToArray(); Stats = $stats; Notices = $notices.ToArray() }
}