Private/ConvertTo-AACComplianceGap.ps1

function ConvertTo-AACComplianceGap {
    <#
    .SYNOPSIS
        Maps the estate to compliance frameworks - from Defender for Cloud's
        regulatory compliance and Azure Policy's regulatory initiatives - and
        lists the gaps: failing controls, controls left to manual
        attestation, frameworks not assessed at all, and resources nothing
        evaluates - each with a priority, the effort to fix it, a roadmap
        phase and, against a previous run, whether it's new, still open or
        closed.
    .DESCRIPTION
        Frameworks: CIS, PCI-DSS, HIPAA (HITRUST), SOC 2, GDPR, ISO 27001,
        NIST and the Microsoft cloud security benchmark (MCSB), recognised by
        the Defender standard's or Policy initiative's name.
 
        A control (aggregated over subscriptions: its worst state, its
        failing resources summed):
          Failed High with 10 or more failing resources, Medium otherwise;
                   effort Low when its policies can be remediated
                   (DeployIfNotExists, Modify), High for manual ones, Medium
                   otherwise
          Manual (Defender 'Skipped') Low: it needs an attestation
        A framework asked for (-Framework) that nothing assesses is a High
        gap: assign its initiative, or add the standard in Defender for
        Cloud.
 
        Roadmap phase: Quick win (High or Medium, Low effort), Plan (High,
        more effort), Backlog (the rest).
        With -Baseline (a previous run's gaps): New, Open, or Closed (in the
        baseline, gone now).
        Returns @{ Gaps (AAC.ComplianceGap); Frameworks
        (AAC.ComplianceFramework); Unscanned; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyCollection()] [object[]] $Standard = @(),
        [AllowEmptyCollection()] [object[]] $Control = @(),
        [AllowEmptyCollection()] [object[]] $Assessment = @(),
        [AllowEmptyCollection()] [object[]] $PolicyControl = @(),
        [AllowEmptyCollection()] [object[]] $Unscanned = @(),
        # Framework keys asked for: CIS, PCI-DSS, HIPAA, SOC2, GDPR, ISO27001, NIST, MCSB; none - every one found.
        [string[]] $Framework = @(),
        # A previous run's gaps (Source, Standard, ControlId, Status).
        [AllowEmptyCollection()] [object[]] $Baseline = @(),
        [System.Collections.IDictionary] $SubscriptionName = @{}
    )

    $get = { param($Row, [string] $Name) if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] } elseif ($null -ne $Row) { $p = $Row.PSObject.Properties[$Name]; if ($p) { $p.Value } } }
    $frameworks = [ordered]@{
        'CIS'      = @{ Label = 'CIS'; Pattern = '(?i)\bCIS\b' }
        'PCI-DSS'  = @{ Label = 'PCI-DSS'; Pattern = '(?i)PCI' }
        'HIPAA'    = @{ Label = 'HIPAA'; Pattern = '(?i)HIPAA|HITRUST' }
        'SOC2'     = @{ Label = 'SOC 2'; Pattern = '(?i)SOC[ -]?2' }
        'GDPR'     = @{ Label = 'GDPR'; Pattern = '(?i)GDPR' }
        'ISO27001' = @{ Label = 'ISO 27001'; Pattern = '(?i)ISO[ -]?(IEC[ -]?)?27001' }
        'NIST'     = @{ Label = 'NIST'; Pattern = '(?i)NIST' }
        'MCSB'     = @{ Label = 'Microsoft cloud security benchmark'; Pattern = '(?i)Microsoft[ -]cloud[ -]security[ -]benchmark|Azure[ -]Security[ -]Benchmark|\bMCSB\b' }
    }
    $frameworkOf = { param([string] $Name) foreach ($k in $frameworks.Keys) { if ($Name -match $frameworks[$k].Pattern) { return $k } }; 'Other' }
    $wanted = @($Framework | Where-Object { $_ })
    $keep = { param([string] $Key) -not $wanted.Count -or $wanted -contains $Key }
    $pretty = { param([string] $Name) $Name -replace '-', ' ' }

    $gaps = [System.Collections.Generic.List[object]]::new()
    $summary = [ordered]@{}
    $addSummary = {
        param([string] $Key, [string] $StandardName, [string] $Source, [int] $Passed, [int] $Failed, [int] $Manual)
        $id = "$Source|$StandardName"
        $summary[$id] = [pscustomobject][ordered]@{
            PSTypeName = 'AAC.ComplianceFramework'; Framework = $(if ($frameworks.Contains($Key)) { $frameworks[$Key].Label } else { 'Other' }); Standard = $StandardName; Source = $Source
            Controls = $Passed + $Failed + $Manual; Passed = $Passed; Failed = $Failed; Manual = $Manual; Compliance = $(if ($Passed + $Failed) { [Math]::Round($Passed / ($Passed + $Failed) * 100, 1) } else { $null })
            Status = $(if ($Failed) { 'Failed' } elseif ($Passed) { 'Passed' } else { 'Manual review' })
        }
    }

    # --- Defender for Cloud: controls, worst state over the subscriptions -----------------------------------------
    $failing = @{}
    foreach ($a in $Assessment) {
        $k = "$([string](& $get $a 'standard'))|$([string](& $get $a 'control'))".ToLowerInvariant()
        if (-not $failing.Contains($k)) { $failing[$k] = [System.Collections.Generic.List[object]]::new() }
        $failing[$k].Add($a)
    }
    $rank = @{ Failed = 0; Skipped = 1; Passed = 2; Unsupported = 3 }
    $controls = [ordered]@{}
    foreach ($c in $Control) {
        $standardName = [string](& $get $c 'standard')
        $key = "$standardName|$([string](& $get $c 'control'))".ToLowerInvariant()
        $state = [string](& $get $c 'state')
        if (-not $controls.Contains($key)) { $controls[$key] = @{ Standard = $standardName; Control = [string](& $get $c 'control'); Description = [string](& $get $c 'description'); State = $state; Subscriptions = [System.Collections.Generic.List[string]]::new() } }
        $entry = $controls[$key]
        if ($rank[$state] -lt $rank[$entry.State]) { $entry.State = $state }
        if ($state -eq 'Failed') { $sub = ([string](& $get $c 'subscriptionId')).ToLowerInvariant(); $entry.Subscriptions.Add($(if ($SubscriptionName.Contains($sub)) { [string]$SubscriptionName[$sub] } else { $sub })) }
    }
    foreach ($standardName in @($controls.Values | ForEach-Object { $_.Standard } | Select-Object -Unique)) {
        $key = & $frameworkOf (& $pretty $standardName)
        if (-not (& $keep $key)) { continue }
        $mine = @($controls.Values | Where-Object Standard -EQ $standardName)
        & $addSummary $key (& $pretty $standardName) 'Defender for Cloud' @($mine | Where-Object State -EQ 'Passed').Count @($mine | Where-Object State -EQ 'Failed').Count @($mine | Where-Object State -EQ 'Skipped').Count
        foreach ($c in @($mine | Where-Object { $_.State -in 'Failed', 'Skipped' })) {
            $checks = @(if ($failing.Contains("$($c.Standard)|$($c.Control)".ToLowerInvariant())) { $failing["$($c.Standard)|$($c.Control)".ToLowerInvariant()] })
            $resources = [int](@($checks | ForEach-Object { [int](& $get $_ 'failedResources') }) | Measure-Object -Sum).Sum
            $manual = $c.State -eq 'Skipped'
            $severity = if ($manual) { 'Low' } elseif ($resources -ge 10) { 'High' } else { 'Medium' }
            $gaps.Add(@{
                    Severity = $severity; Framework = $(if ($frameworks.Contains($key)) { $frameworks[$key].Label } else { 'Other' }); Standard = (& $pretty $c.Standard); Source = 'Defender for Cloud'; ControlId = $c.Control
                    Control = $(if ($c.Description) { "$($c.Control) $($c.Description)" } else { $c.Control }); State = $(if ($manual) { 'Manual' } else { 'Failed' })
                    FailingChecks = (@($checks | Select-Object -First 4 | ForEach-Object { [string](& $get $_ 'assessment') }) -join '; ') + $(if ($checks.Count -gt 4) { " (+$($checks.Count - 4) more)" })
                    FailingResources = $resources; Effort = $(if ($manual) { 'Low' } else { 'Medium' }); Subscriptions = (@($c.Subscriptions | Select-Object -Unique) -join ', ')
                    Remediation = $(if ($manual) { 'Attest the control manually in Defender for Cloud > Regulatory compliance, with the evidence.' } else { 'Fix the failing recommendations under this control (Defender for Cloud > Regulatory compliance), most resources first.' })
                    Link = [string](@($checks | ForEach-Object { [string](& $get $_ 'link') } | Where-Object { $_ -like 'https://*' }) | Select-Object -First 1)
                })
        }
    }

    # --- Azure Policy: regulatory initiatives, by policy definition group (control) ----------------------------------
    foreach ($group in @($PolicyControl | Group-Object -Property { [string](& $get $_ 'initiative') })) {
        $initiative = $group.Name
        $key = & $frameworkOf $initiative
        if (-not (& $keep $key)) { continue }
        $rows = @($group.Group)
        & $addSummary $key $initiative 'Azure Policy' @($rows | Where-Object { [int](& $get $_ 'nonCompliant') -eq 0 -and [int](& $get $_ 'compliant') -gt 0 }).Count @($rows | Where-Object { [int](& $get $_ 'nonCompliant') -gt 0 }).Count @($rows | Where-Object { [int](& $get $_ 'nonCompliant') -eq 0 -and [int](& $get $_ 'compliant') -eq 0 }).Count
        foreach ($r in @($rows | Where-Object { [int](& $get $_ 'nonCompliant') -gt 0 })) {
            $resources = [int](& $get $r 'nonCompliant')
            $effects = @(& $get $r 'effects' | ForEach-Object { ([string]$_).ToLowerInvariant() })
            $effort = if ($effects -contains 'manual') { 'High' } elseif (@($effects | Where-Object { $_ -in 'deployifnotexists', 'modify' }).Count -and -not @($effects | Where-Object { $_ -in 'audit', 'auditifnotexists', 'deny' }).Count) { 'Low' } else { 'Medium' }
            $gaps.Add(@{
                    Severity = $(if ($resources -ge 10) { 'High' } else { 'Medium' }); Framework = $(if ($frameworks.Contains($key)) { $frameworks[$key].Label } else { 'Other' }); Standard = $initiative; Source = 'Azure Policy'
                    ControlId = [string](& $get $r 'groupName'); Control = [string](& $get $r 'groupName'); State = 'Failed'; FailingChecks = "$([int](& $get $r 'policies')) polic(ies): $($effects -join ', ')"
                    FailingResources = $resources; Effort = $effort; Subscriptions = ''
                    Remediation = $(if ($effort -eq 'Low') { 'Create remediation tasks for its policies (Azure Policy > Remediation): they fix the existing resources.' } else { 'Fix the non-compliant resources under this control (Azure Policy > Compliance, filtered to the initiative and control), or exempt them with a reason.' })
                    Link = 'https://learn.microsoft.com/azure/governance/policy/samples/#regulatory-compliance'
                })
        }
    }

    # --- Frameworks asked for that nothing assesses --------------------------------------------------------------------
    foreach ($k in $wanted) {
        $label = $frameworks[$k].Label
        if (@($summary.Values | Where-Object Framework -EQ $label).Count) { continue }
        $gaps.Add(@{
                Severity = 'High'; Framework = $label; Standard = '(none)'; Source = 'Not assessed'; ControlId = ''; Control = "No $label assessment"; State = 'Not assessed'
                FailingChecks = ''; FailingResources = 0; Effort = 'Low'; Subscriptions = ''
                Remediation = $(if ($k -eq 'GDPR') { 'Azure has no built-in GDPR initiative or standard: map GDPR to the controls of ISO 27001 or the Microsoft cloud security benchmark, which are, and assess those.' } else { "Add the $label standard in Defender for Cloud (Environment settings > Security policies), or assign its built-in regulatory compliance initiative in Azure Policy." })
                Link = 'https://learn.microsoft.com/azure/defender-for-cloud/concept-regulatory-compliance-standards'
            })
    }

    # --- Priority, phase, progress -------------------------------------------------------------------------------------------
    $sevRank = (Get-AACSeverityRank).Rank
    $effortRank = @{ Low = 0; Medium = 1; High = 2 }
    $keyOf = { param($G) "$($G.Source)|$($G.Standard)|$($G.ControlId)".ToLowerInvariant() }
    $before = @{}
    foreach ($b in $Baseline) { if ([string](& $get $b 'Status') -ne 'Closed') { $before["$(& $get $b 'Source')|$(& $get $b 'Standard')|$(& $get $b 'ControlId')".ToLowerInvariant()] = $b } }
    $now = @{}
    $out = foreach ($g in $gaps) {
        $k = & $keyOf $g; $now[$k] = $true
        $phase = if ($g.Severity -in 'Critical', 'High', 'Medium' -and $g.Effort -eq 'Low') { 'Quick win' } elseif ($g.Severity -in 'Critical', 'High') { 'Plan' } else { 'Backlog' }
        New-AACFinding -TypeName 'AAC.ComplianceGap' -Severity $g.Severity -Category $g.Framework -Finding "$($g.Standard): $($g.Control)" -Resource $g.ControlId -ResourceType 'control' -Detail $g.FailingChecks `
            -Impact $(if ($g.FailingResources) { "$($g.FailingResources) resource(s) fail it" } else { '' }) -Remediation $g.Remediation -Effort $g.Effort -Link $g.Link -Property ([ordered]@{
                Framework = $g.Framework; Standard = $g.Standard; Source = $g.Source; ControlId = $g.ControlId; Control = $g.Control; State = $g.State; FailingResources = $g.FailingResources
                Phase = $phase; Progress = $(if (-not $Baseline.Count) { '' } elseif ($before.Contains($k)) { 'Open' } else { 'New' }); FailingSubscriptions = $g.Subscriptions
            })
    }
    $closed = foreach ($k in $before.Keys) {
        if ($now.Contains($k)) { continue }
        $b = $before[$k]
        New-AACFinding -TypeName 'AAC.ComplianceGap' -Severity 'Info' -Category ([string](& $get $b 'Framework')) -Finding "$(& $get $b 'Standard'): $(& $get $b 'Control')" -Resource ([string](& $get $b 'ControlId')) -ResourceType 'control' `
            -Detail 'Closed since the baseline.' -Remediation '' -Effort '' -Property ([ordered]@{
                Framework = [string](& $get $b 'Framework'); Standard = [string](& $get $b 'Standard'); Source = [string](& $get $b 'Source'); ControlId = [string](& $get $b 'ControlId'); Control = [string](& $get $b 'Control'); State = 'Closed'
                FailingResources = 0; Phase = 'Done'; Progress = 'Closed'; FailingSubscriptions = ''
            })
    }
    $sorted = @(@($out) + @($closed) | Where-Object { $_ } | Sort-Object -Property @{ Expression = { if ($_.Progress -eq 'Closed') { 9 } else { $sevRank[$_.Severity] } } }, @{ Expression = { if ($effortRank.Contains($_.Effort)) { $effortRank[$_.Effort] } else { 3 } } }, @{ Expression = 'FailingResources'; Descending = $true }, Framework, ControlId)
    $unscannedRows = @(foreach ($u in $Unscanned) { [pscustomobject]@{ PSTypeName = 'AAC.ComplianceUnscanned'; ResourceType = [string](& $get $u 'type'); Resources = [int](& $get $u 'resources') } })
    $open = @($sorted | Where-Object Progress -NE 'Closed')
    @{
        Gaps       = $sorted
        Frameworks = @($summary.Values | Sort-Object Framework, Source, Standard)
        Unscanned  = $unscannedRows
        Stats      = @{
            Gaps           = $open.Count
            High           = @($open | Where-Object Severity -In 'Critical', 'High').Count
            QuickWins      = @($open | Where-Object Phase -EQ 'Quick win').Count
            Manual         = @($open | Where-Object State -EQ 'Manual').Count
            NotAssessed    = @($open | Where-Object State -EQ 'Not assessed').Count
            Frameworks     = @($summary.Values | ForEach-Object Framework | Select-Object -Unique).Count
            Closed         = @($sorted | Where-Object Progress -EQ 'Closed').Count
            New            = @($sorted | Where-Object Progress -EQ 'New').Count
            UnscannedTotal = [int](@($unscannedRows | ForEach-Object { $_.Resources }) | Measure-Object -Sum).Sum
        }
    }
}