Public/Get-AACComplianceGap.ps1

function Get-AACComplianceGap {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Maps your Azure estate to compliance frameworks - CIS, PCI-DSS,
        HIPAA, SOC 2, GDPR, ISO 27001, NIST and the Microsoft cloud security
        benchmark - and lists the gaps by priority and effort, as a
        remediation roadmap, with what changed since the last run.
    .DESCRIPTION
        Reads, from Azure Resource Graph:
          Defender for Cloud each regulatory compliance standard's controls
                              and the failing assessments under them
          Azure Policy the regulatory compliance initiatives assigned,
                              control by control (their policy definition
                              groups), and the non-compliant resources
          Coverage the resource types no policy evaluates
        Gaps (AAC.ComplianceGap): failing controls (High with 10 or more
        failing resources, else Medium), controls waiting for a manual
        attestation (Low), and frameworks asked for with -Framework that
        nothing assesses (High). Each has an effort - Low when remediation
        tasks fix it - and a roadmap phase: Quick win, Plan or Backlog.
 
        Progress: -BaselinePath reads a previous run's CSV (-CsvPath) and
        marks each gap New, Open or Closed - so a run a month later shows what
        was fixed.
 
        The report: a summary per framework (controls passed, failed,
        manual; compliance %), the gaps, the roadmap and the unscanned
        resource types. Read-only; Reader (and Security Reader for
        Defender) is enough. Certifications themselves (audit reports,
        expiry dates) aren't in Azure: see the Service Trust Portal.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER Framework
        Only these frameworks; a framework asked for that nothing assesses
        is a gap. CIS, PCI-DSS, HIPAA, SOC2, GDPR, ISO27001, NIST or MCSB.
    .PARAMETER BaselinePath
        A previous run's CSV (-CsvPath): each gap is then New, Open or
        Closed.
    .PARAMETER SkipUnscanned
        Don't look for resources no policy evaluates (one heavier query).
    .PARAMETER CsvPath
        Write the gaps to this CSV file - next time's -BaselinePath.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the gaps.
    .PARAMETER NoDisplay
        Return the gaps without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACComplianceGap -Framework PCI-DSS, ISO27001 -CsvPath .\out\gaps-2026-10.csv
        The PCI-DSS and ISO 27001 gaps, saved as next month's baseline.
    .EXAMPLE
        Get-AACComplianceGap -BaselinePath .\out\gaps-2026-10.csv -HtmlPath .\out\Compliance.html
        What's new, still open and closed since last month, as a report.
    .EXAMPLE
        Get-AACComplianceGap -NoDisplay | Where-Object Phase -EQ 'Quick win'
        The gaps to close first.
    .OUTPUTS
        AAC.ComplianceGap
    #>

    [CmdletBinding()]
    [OutputType('AAC.ComplianceGap')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [ValidateSet('CIS', 'PCI-DSS', 'HIPAA', 'SOC2', 'GDPR', 'ISO27001', 'NIST', 'MCSB')]
        [string[]] $Framework,

        [string] $BaselinePath,

        [switch] $SkipUnscanned,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Compliance gaps',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $baselineRows = @()
    if ($BaselinePath) {
        $baselineFile = & $resolve $BaselinePath
        if (-not (Test-Path -LiteralPath $baselineFile)) { throw "The baseline $baselineFile doesn't exist. Write one with -CsvPath first." }
        $baselineRows = @(Import-Csv -LiteralPath $baselineFile)
        if ($baselineRows.Count -and -not $baselineRows[0].PSObject.Properties['ControlId']) { throw "$baselineFile isn't a Get-AACComplianceGap CSV (it has no ControlId column)." }
    }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); SkipUnscanned = [bool]$SkipUnscanned }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Compliance gaps' -Color 'deepskyblue3_1' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $queries = [ordered]@{
            controls    = "securityresources | where type =~ 'microsoft.security/regulatorycompliancestandards/regulatorycompliancecontrols' | extend parts = split(id, '/') | project id, subscriptionId, standard = tostring(parts[6]), control = name, description = tostring(properties.description), state = tostring(properties.state)"
            assessments = "securityresources | where type =~ 'microsoft.security/regulatorycompliancestandards/regulatorycompliancecontrols/regulatorycomplianceassessments' | where tostring(properties.state) =~ 'Failed' | extend parts = split(id, '/') | project id, subscriptionId, standard = tostring(parts[6]), control = tostring(parts[8]), assessment = tostring(properties.description), failedResources = toint(properties.failedResources), link = tostring(properties.assessmentDetailsLink)"
            policy      = "policyresources | where type =~ 'microsoft.policyinsights/policystates' | extend setId = tolower(tostring(properties.policySetDefinitionId)), groups = properties.policyDefinitionGroupNames, state = tostring(properties.complianceState), effect = tostring(properties.policyDefinitionAction), resourceId = tolower(tostring(properties.resourceId)), definitionId = tolower(tostring(properties.policyDefinitionId)) | where isnotempty(setId) and array_length(groups) > 0 | mv-expand groupName = groups to typeof(string) | summarize nonCompliant = dcountif(resourceId, state =~ 'NonCompliant'), compliant = dcountif(resourceId, state =~ 'Compliant'), policies = dcount(definitionId), effects = make_set(effect, 5) by setId, groupName | join kind=inner (policyresources | where type =~ 'microsoft.authorization/policysetdefinitions' | where tostring(properties.metadata.category) =~ 'Regulatory Compliance' | project setId = tolower(id), initiative = tostring(properties.displayName)) on setId | extend id = strcat(setId, '|', groupName) | project-away setId1"
        }
        if (-not $request.SkipUnscanned) {
            $queries['unscanned'] = "resources | project resourceId = tolower(id), type = tolower(type) | join kind=leftanti (policyresources | where type =~ 'microsoft.policyinsights/policystates' | distinct resourceId = tolower(tostring(properties.resourceId))) on resourceId | summarize resources = count() by type | extend id = type | order by resources desc"
        }
        Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading Defender for Cloud''s regulatory compliance and Azure Policy''s initiatives'
        $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @($queries.Keys) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total)" }
        $notices = [System.Collections.Generic.List[string]]::new()
        $labels = @{ controls = 'Defender for Cloud regulatory compliance'; assessments = 'Defender for Cloud regulatory assessments'; policy = 'Azure Policy regulatory initiatives'; unscanned = 'unscanned resources' }
        foreach ($key in $read.Errors.Keys) { if ($read.Errors[$key]) { $notices.Add("The $($labels[$key]) couldn't be read: $($read.Errors[$key])") } }
        if (-not @($read.Rows['controls']).Count -and -not $read.Errors['controls']) { $notices.Add('Defender for Cloud reports no regulatory compliance standards here: they need a Defender plan (Defender CSPM, or any Defender for Cloud plan) on the subscriptions.') }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} Defender control state(s) and {1:N0} Policy control(s)' -f @($read.Rows['controls']).Count, @($read.Rows['policy']).Count)
        @{ Read = $read; Scope = $scope; Notices = $notices.ToArray() }
    }

    $read = $state.Read
    $result = ConvertTo-AACComplianceGap -Control @($read.Rows['controls'] | Where-Object { $_ }) -Assessment @($read.Rows['assessments'] | Where-Object { $_ }) -PolicyControl @($read.Rows['policy'] | Where-Object { $_ }) `
        -Unscanned @($read.Rows['unscanned'] | Where-Object { $_ } | Select-Object -First 50) -Framework @($Framework | Where-Object { $_ }) -Baseline $baselineRows -SubscriptionName $state.Scope.Names
    $gaps = @($result.Gaps)
    $s = $result.Stats
    $rank = Get-AACSeverityRank
    $phaseTones = @{ 'Quick win' = 'good'; Plan = 'warn'; Backlog = 'neutral'; Done = 'good' }
    $report = @{
        Subtitle = 'Compliance gaps: Defender for Cloud regulatory compliance and Azure Policy initiatives'
        Facts    = [ordered]@{ Scope = $state.Scope.Label; Frameworks = $(if ($Framework) { $Framework -join ', ' } else { 'every one found' }); Baseline = $(if ($BaselinePath) { (Split-Path -Leaf $BaselinePath) } else { 'none' }) }
        Status   = $(if ($s.High) { 'Failed' } elseif ($s.Gaps) { 'Warning' } else { 'Success' })
        Headline = $(if ($s.Gaps) { "$($s.Gaps) gap(s) across $($s.Frameworks) framework(s): $($s.High) high - $($s.QuickWins) quick win(s)$(if ($BaselinePath) { "; $($s.New) new, $($s.Closed) closed since the baseline" })" } else { 'No gaps: every control assessed passes.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $s.Gaps; Label = 'open gaps'; Tone = $(if ($s.Gaps) { 'warn' } else { 'good' }); Table = 'gaps' }
            @{ Value = '{0:N0}' -f $s.High; Label = 'high priority'; Tone = $(if ($s.High) { 'bad' } else { 'good' }); Table = 'gaps'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f $s.QuickWins; Label = 'quick wins'; Tone = 'good'; Table = 'gaps'; Filters = @{ Phase = 'Quick win' } }
            @{ Value = '{0:N0}' -f $s.Manual; Label = 'awaiting attestation'; Tone = 'info'; Table = 'gaps'; Filters = @{ State = 'Manual' } }
            @{ Value = '{0:N0}' -f $s.NotAssessed; Label = 'frameworks not assessed'; Tone = $(if ($s.NotAssessed) { 'bad' } else { 'good' }) }
            @{ Value = $(if ($BaselinePath) { '{0:N0}' -f $s.Closed } else { '-' }); Label = 'closed since the baseline'; Tone = 'good'; Table = 'gaps'; Filters = @{ Progress = 'Closed' } }
            @{ Value = '{0:N0}' -f $s.UnscannedTotal; Label = 'resources no policy evaluates'; Tone = $(if ($s.UnscannedTotal) { 'neutral' } else { 'good' }); Table = 'unscanned' }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'Compliance by framework (%)'; Items = @($result.Frameworks | Where-Object { $null -ne $_.Compliance } | ForEach-Object { @{ Label = "$($_.Framework) ($($_.Source -replace 'Defender for Cloud', 'Defender' -replace 'Azure Policy', 'Policy'))"; Value = $_.Compliance; Filter = $_.Framework } }); Table = 'gaps'; Column = 'Framework'; Tone = 'good'; Console = $true; Format = 'N1' }
            @{ Title = 'Gaps by phase'; Kind = 'donut'; CenterLabel = 'gaps'; Items = @($gaps | Group-Object Phase | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $phaseTones[$_.Name]; Filter = $_.Name } }); Table = 'gaps'; Column = 'Phase' }
        )
        Tables   = @(
            @{ Id = 'frameworks'; Title = 'Frameworks'; Section = 'Summary'; Rows = $result.Frameworks; Noun = 'standards'
                Empty = 'No compliance framework is assessed: add standards in Defender for Cloud, or assign regulatory compliance initiatives in Azure Policy.'; EmptyStatus = 'Warning'
                Columns = @(
                    @{ Key = 'Framework'; Label = 'Framework'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Standard'; Label = 'Standard or initiative'; Type = 'wide'; Console = $true; Pdf = $true }
                    @{ Key = 'Source'; Label = 'Source'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Compliance'; Label = 'Compliance (%)'; Type = 'score'; Console = $true; Pdf = $true }
                    @{ Key = 'Passed'; Label = 'Passed'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Failed'; Label = 'Failed'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Manual'; Label = 'Manual'; Type = 'number'; Console = $true; Pdf = $true }
                ) }
            @{ Id = 'gaps'; Title = 'Gaps and roadmap'; Section = 'Gaps'; Rows = $gaps; Noun = 'gaps'; GroupBy = @('Framework', 'Phase', 'Severity', 'Source'); ConsoleLimit = 25
                Empty = 'No gaps: every control assessed passes.'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Priority'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Phase'; Label = 'Phase'; Type = 'badge'; Tones = $phaseTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Framework'; Label = 'Framework'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Control'; Label = 'Control'; Type = 'wide'; Console = $true; Pdf = $true }
                    @{ Key = 'FailingResources'; Label = 'Failing resources'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Effort'; Label = 'Effort'; Type = 'badge'; Tones = @{ Low = 'good'; Medium = 'warn'; High = 'bad' }; Facet = $true; Console = $true }
                    @{ Key = 'Progress'; Label = 'Since the baseline'; Type = 'badge'; Tones = @{ New = 'warn'; Open = 'neutral'; Closed = 'good' }; Facet = $true }
                    @{ Key = 'State'; Label = 'State'; Facet = $true }
                    @{ Key = 'Standard'; Label = 'Standard'; Facet = $true }
                    @{ Key = 'Source'; Label = 'Source'; Facet = $true }
                    @{ Key = 'Detail'; Label = 'Failing checks'; Type = 'wide' }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'FailingSubscriptions'; Label = 'Subscriptions'; Type = 'wide' }
                    @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' }
                ) }
            @{ Id = 'unscanned'; Title = 'Resource types no policy evaluates'; Section = 'Coverage'; Rows = $result.Unscanned; Noun = 'types'; ConsoleLimit = 10
                Note = 'Resources with no policy compliance state at all: nothing assesses them against a framework (some types, such as extensions, can''t be).'
                Columns = @(@{ Key = 'ResourceType'; Label = 'Resource type'; Type = 'mono'; Console = $true }, @{ Key = 'Resources'; Label = 'Resources'; Type = 'number'; Console = $true }) }
        )
        Hint     = '-Framework narrows it; -CsvPath saves the gaps as next run''s -BaselinePath; -NoDisplay returns the gaps.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $gaps -Noun 'gap' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $gaps -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}