Private/ConvertTo-AACSecurityPosture.ps1

function ConvertTo-AACSecurityPosture {
    <#
    .SYNOPSIS
        Builds Get-AACSecurityPosture's result from the Defender for Cloud
        rows (Get-AACDefenderQuery): secure scores, recommendations, alerts,
        Defender plans and regulatory compliance - and one flat list of
        findings across them.
    .DESCRIPTION
        -Rows maps a query name to its rows (Scores, Controls,
        ControlAssessments, Recommendations, Alerts, Plans, Standards,
        ComplianceControls, ComplianceAssessments); a missing name is a
        section not read (ComplianceRecommendations: the recommendations,
        read only for the compliance failures). -SubscriptionName names the subscriptions in scope
        (others are left out). -ResourceId, when given, keeps only the
        recommendations, alerts and compliance failures on those resources
        (a resource group or tag selection); scores, plans and standards are
        per subscription and stay whole.
 
        Findings (AAC.SecurityFinding), one per:
          Recommendation an unhealthy recommendation on a resource
          Alert an active security alert
          Compliance a failed regulatory compliance assessment, on each
                          resource failing the recommendation behind it (or
                          once, when no resource is named)
          Plan a Defender plan that is off
          Policy a resource that doesn't comply with an Azure Policy
                          assignment (no severity: Azure Policy has none);
                          compliance rolled up as the Azure portal does
                          (ConvertTo-AACPolicyState)
        with Severity, Title, Category (the recommendation's category, the
        alert's intent, the standard, 'Defender plan'), Control (secure score
        or compliance control), the resource, State, Detail, Link (the portal
        page), Since and ResourceId - most severe first.
 
        Returns @{ Findings; Subscriptions; Controls; Recommendations; Alerts;
        Plans; Standards; ComplianceControls; PolicyAssignments; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [hashtable] $Rows = @{},

        [hashtable] $SubscriptionName = @{},

        [System.Collections.Generic.HashSet[string]] $ResourceId
    )

    $value = { param($Object, [string] $Key) if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Key)) { $Object[$Key] } } else { Get-AACPropertyValue -InputObject $Object -Name $Key } }
    $text = { param($Object, [string] $Key) $v = & $value $Object $Key; if ($null -eq $v) { '' } else { [string]$v } }
    $rowsOf = { param([string] $Name) @(if ($Rows.Contains($Name)) { $Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $inScope = { param([string] $Subscription) $SubscriptionName.Contains($Subscription.ToLowerInvariant()) }
    $onResource = { param([string] $Id) $null -eq $ResourceId -or ($Id -and $ResourceId.Contains($Id.ToLowerInvariant())) }
    $subscriptionLabel = { param([string] $Id) $key = $Id.ToLowerInvariant(); if ($SubscriptionName.Contains($key)) { $SubscriptionName[$key] } else { $Id } }
    $severityRank = @{ High = 0; Medium = 1; Low = 2; Informational = 3 }
    $findings = [System.Collections.Generic.List[object]]::new()
    $finding = {
        param([string] $Section, [string] $Severity, [string] $Title, [string] $Category, [string] $Control, [string] $Resource, [string] $Type, [string] $Group, [string] $Subscription, [string] $State, [string] $Detail, [string] $Link, $Since, [string] $Id)
        $findings.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.SecurityFinding'
                Section          = $Section
                Severity         = $Severity
                Title            = $Title
                Category         = $Category
                Control          = $Control
                Resource         = $Resource
                Type             = $Type
                ResourceGroup    = $Group
                SubscriptionName = (& $subscriptionLabel $Subscription)
                State            = $State
                Detail           = $Detail
                Link             = $Link
                Since            = $Since
                SubscriptionId   = $Subscription.ToLowerInvariant()
                ResourceId       = $Id
            })
    }

    # --- Secure scores and controls ----------------------------------------------------------------
    $scores = @{}
    foreach ($row in (& $rowsOf 'Scores')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if (& $inScope $id) { $scores[$id] = @{ Current = [double](& $value $row 'current'); Max = [double](& $value $row 'max') } }
    }
    $maxima = @{}
    foreach ($key in $scores.Keys) { $maxima[$key] = $scores[$key].Max }
    $controls = @(ConvertTo-AACSecurityControl -Row (& $rowsOf 'Controls') -ScoreMax $maxima -SubscriptionName $SubscriptionName)
    $controlOf = @{}
    foreach ($row in (& $rowsOf 'ControlAssessments')) { $controlOf[(& $text $row 'key').ToLowerInvariant()] = & $text $row 'control' }

    # --- Recommendations ------------------------------------------------------------------------------
    # ComplianceRecommendations: read only to name a failed compliance
    # control's resources, when the Recommendations section wasn't asked for.
    $source = if ($Rows.Contains('Recommendations')) { 'Recommendations' } else { 'ComplianceRecommendations' }
    $allRecommendations = @(ConvertTo-AACSecurityRecommendation -Row @(& $rowsOf $source | Where-Object { & $inScope (& $text $_ 'subscriptionId') }) -SubscriptionName $SubscriptionName -ControlOf $controlOf)
    $recommendations = @(if ($Rows.Contains('Recommendations')) { $allRecommendations | Where-Object { & $onResource $_.ResourceId } })
    if ($Rows.Contains('Recommendations')) {
        foreach ($item in $recommendations) {
            & $finding 'Recommendation' $item.Severity $item.Recommendation $item.Category $item.Control $item.Resource $item.Type $item.ResourceGroup $item.SubscriptionId 'Unhealthy' $(if ($item.Description) { $item.Description } else { $item.Cause }) $item.RemediationUrl $item.Since $item.ResourceId
        }
    }

    # --- Alerts ----------------------------------------------------------------------------------------
    $alerts = @(foreach ($row in (& $rowsOf 'Alerts')) {
            $subscription = & $text $row 'subscriptionId'
            if (-not (& $inScope $subscription)) { continue }
            # The Azure resource it is about, else the compromised entity.
            $target = @(@(& $value $row 'resources') | ForEach-Object { [string](& $value $_ 'AzureResourceId'); [string](& $value $_ 'azureResourceId') } | Where-Object { $_ }) | Select-Object -First 1
            if (-not (& $onResource $target)) { continue }
            $time = [datetime]::MinValue
            $when = if ([datetime]::TryParse((& $text $row 'time'), [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$time)) { $time } else { $null }
            $resource = if ($target) { ($target -split '/')[-1] } else { & $text $row 'entity' }
            [pscustomobject][ordered]@{
                PSTypeName       = 'AAC.SecurityAlert'
                Alert            = & $text $row 'name'
                Severity         = & $text $row 'severity'
                Status           = & $text $row 'status'
                Intent           = & $text $row 'intent'
                Resource         = $resource
                ResourceGroup    = $(if ($target -match '/resourcegroups/([^/]+)') { $Matches[1] } else { '' })
                SubscriptionName = & $subscriptionLabel $subscription
                TimeGenerated    = $when
                AgeDays          = $(if ($when) { [int][Math]::Floor(([datetime]::UtcNow - $when).TotalDays) } else { $null })
                AlertType        = & $text $row 'alertType'
                Description      = & $text $row 'description'
                AlertUrl         = & $text $row 'link'
                SubscriptionId   = $subscription.ToLowerInvariant()
                ResourceId       = $target
            }
        })
    $alerts = @($alerts | Sort-Object -Property @{ Expression = { if ($severityRank.Contains($_.Severity)) { $severityRank[$_.Severity] } else { 4 } } }, @{ Expression = 'TimeGenerated'; Descending = $true })
    foreach ($alert in $alerts) {
        & $finding 'Alert' $alert.Severity $alert.Alert $alert.Intent '' $alert.Resource '' $alert.ResourceGroup $alert.SubscriptionId $alert.Status $alert.Description $alert.AlertUrl $alert.TimeGenerated $alert.ResourceId
    }

    # --- Defender plans ------------------------------------------------------------------------------------
    $planNames = @{
        VirtualMachines = 'Servers'; SqlServers = 'Azure SQL databases'; AppServices = 'App Service'; StorageAccounts = 'Storage'
        SqlServerVirtualMachines = 'SQL servers on machines'; KeyVaults = 'Key Vault'; Dns = 'DNS'; Arm = 'Resource Manager'
        OpenSourceRelationalDatabases = 'Open-source relational databases'; CosmosDbs = 'Azure Cosmos DB'; Containers = 'Containers'
        CloudPosture = 'Cloud security posture management (CSPM)'; Api = 'APIs'; AI = 'AI services'
    }
    $plans = @(foreach ($row in (& $rowsOf 'Plans')) {
            $subscription = & $text $row 'subscriptionId'
            if (-not (& $inScope $subscription)) { continue }
            $name = & $text $row 'plan'
            [pscustomobject][ordered]@{
                PSTypeName       = 'AAC.DefenderPlan'
                SubscriptionName = & $subscriptionLabel $subscription
                Plan             = $(if ($planNames.Contains($name)) { $planNames[$name] } else { $name })
                Enabled          = (& $text $row 'tier') -eq 'Standard'
                Tier             = & $text $row 'tier'
                SubPlan          = & $text $row 'subPlan'
                Name             = $name
                SubscriptionId   = $subscription.ToLowerInvariant()
            }
        })
    $plans = @($plans | Sort-Object -Property SubscriptionName, @{ Expression = 'Enabled'; Descending = $true }, Plan)
    foreach ($plan in @($plans | Where-Object { -not $_.Enabled })) {
        & $finding 'Plan' 'Medium' "Microsoft Defender for $($plan.Plan) is off" 'Defender plan' '' $plan.SubscriptionName 'microsoft.resources/subscriptions' '' $plan.SubscriptionId 'Off' 'Workloads of this kind get no threat protection or alerts. Turn the plan on in Defender for Cloud > Environment settings.' '' $null "/subscriptions/$($plan.SubscriptionId)"
    }

    # --- Regulatory compliance -------------------------------------------------------------------------------
    $standards = @(foreach ($row in (& $rowsOf 'Standards')) {
            $subscription = & $text $row 'subscriptionId'
            if (-not (& $inScope $subscription)) { continue }
            $passed = [int](& $value $row 'passed'); $failed = [int](& $value $row 'failed')
            [pscustomobject][ordered]@{
                PSTypeName          = 'AAC.ComplianceStandard'
                Standard            = & $text $row 'standard'
                SubscriptionName    = & $subscriptionLabel $subscription
                State               = & $text $row 'state'
                PassedControls      = $passed
                FailedControls      = $failed
                SkippedControls     = [int](& $value $row 'skipped')
                UnsupportedControls = [int](& $value $row 'unsupported')
                PassRate            = $(if ($passed + $failed) { [Math]::Round(100 * $passed / ($passed + $failed)) } else { $null })
                SubscriptionId      = $subscription.ToLowerInvariant()
            }
        })
    $standards = @($standards | Sort-Object -Property @{ Expression = { if ($null -eq $_.PassRate) { 101 } else { $_.PassRate } } }, Standard, SubscriptionName)
    # A failed assessment's failing resources: the unhealthy recommendations with its key.
    $byKey = @{}
    foreach ($item in $allRecommendations) {
        $key = "$($item.SubscriptionId)|$($item.AssessmentKey)"
        if (-not $byKey.Contains($key)) { $byKey[$key] = [System.Collections.Generic.List[object]]::new() }
        $byKey[$key].Add($item)
    }
    $failing = @{}
    foreach ($row in (& $rowsOf 'ComplianceAssessments')) {
        $subscription = (& $text $row 'subscriptionId').ToLowerInvariant()
        if (-not (& $inScope $subscription) -or (& $text $row 'state') -ne 'Failed') { continue }
        $standard = & $text $row 'standard'; $control = & $text $row 'control'
        $controlKey = "$subscription|$standard|$control"
        if (-not $failing.Contains($controlKey)) { $failing[$controlKey] = [System.Collections.Generic.HashSet[string]]::new() }
        $hits = @(if ($byKey.Contains("$subscription|$(& $text $row 'key')")) { $byKey["$subscription|$(& $text $row 'key')"] | Where-Object { & $onResource $_.ResourceId } })
        foreach ($hit in $hits) {
            [void]$failing[$controlKey].Add($hit.Recommendation)
            & $finding 'Compliance' $hit.Severity (& $text $row 'description') $standard $control $hit.Resource $hit.Type $hit.ResourceGroup $subscription 'Failed' $hit.Recommendation $hit.RemediationUrl $hit.Since $hit.ResourceId
        }
        if (-not $hits.Count -and $null -eq $ResourceId) {
            [void]$failing[$controlKey].Add((& $text $row 'description'))
            & $finding 'Compliance' '' (& $text $row 'description') $standard $control (& $subscriptionLabel $subscription) 'microsoft.resources/subscriptions' '' $subscription 'Failed' "$([int](& $value $row 'failedResources')) resource(s) failing" '' $null "/subscriptions/$subscription"
        }
    }
    $complianceControls = @(foreach ($row in (& $rowsOf 'ComplianceControls')) {
            $subscription = (& $text $row 'subscriptionId').ToLowerInvariant()
            if (-not (& $inScope $subscription)) { continue }
            $controlKey = "$subscription|$(& $text $row 'standard')|$(& $text $row 'control')"
            [pscustomobject][ordered]@{
                PSTypeName         = 'AAC.ComplianceControl'
                Standard           = & $text $row 'standard'
                Control            = & $text $row 'control'
                Description        = & $text $row 'description'
                State              = & $text $row 'state'
                PassedAssessments  = [int](& $value $row 'passed')
                FailedAssessments  = [int](& $value $row 'failed')
                SkippedAssessments = [int](& $value $row 'skipped')
                FailingChecks      = $(if ($failing.Contains($controlKey)) { @($failing[$controlKey]) -join '; ' } else { '' })
                SubscriptionName   = & $subscriptionLabel $subscription
                SubscriptionId     = $subscription
            }
        })
    $stateRank = @{ Failed = 0; Passed = 2; Skipped = 3; Unsupported = 4 }
    $complianceControls = @($complianceControls | Sort-Object -Property Standard, @{ Expression = { if ($stateRank.Contains($_.State)) { $stateRank[$_.State] } else { 1 } } }, Control)

    # --- Azure Policy: rolled up as the portal does (ConvertTo-AACPolicyState, shared with Get-AACPolicyState) ----
    $policyStates = @(& $rowsOf 'PolicyStates' | Where-Object { & $inScope (& $text $_ 'subscriptionId') })
    $policy = ConvertTo-AACPolicyState -Row $policyStates -Assignment (& $rowsOf 'PolicyAssignments') -SubscriptionName $SubscriptionName
    $policyAssignments = @($policy.Assignments)
    $policyBySubscription = @{}
    foreach ($item in @($policy.Scopes | Where-Object Level -EQ 'Subscription')) { $policyBySubscription[$item.SubscriptionId] = $item.ComplianceRate }
    foreach ($item in @($policy.States | Where-Object { $_.ComplianceState -eq 'NonCompliant' -and (& $onResource $_.ResourceId) })) {
        & $finding 'Policy' '' $item.Policy $item.Assignment '' $item.Resource $item.ResourceType $item.ResourceGroup $item.SubscriptionId 'NonCompliant' $(if ($item.Effect) { "Effect: $($item.Effect)" } else { '' }) '' $item.EvaluatedAt $item.ResourceId
    }

    # --- Per subscription -----------------------------------------------------------------------------------
    $subscriptions = @(foreach ($key in @($SubscriptionName.Keys | Sort-Object { $SubscriptionName[$_] })) {
            $score = if ($scores.Contains($key)) { $scores[$key] } else { $null }
            $percent = if ($score -and $score.Max -gt 0) { [Math]::Round(100 * $score.Current / $score.Max) } else { $null }
            $mine = @($recommendations | Where-Object SubscriptionId -EQ $key)
            $myPlans = @($plans | Where-Object SubscriptionId -EQ $key)
            [pscustomobject][ordered]@{
                PSTypeName       = 'AAC.SecurityScore'
                SubscriptionName = $SubscriptionName[$key]
                SecureScore      = $percent
                Rating           = $(if ($null -eq $percent) { '' } elseif ($percent -ge 70) { 'Good' } elseif ($percent -ge 40) { 'Fair' } else { 'Poor' })
                Points           = $(if ($score -and $score.Max -gt 0) { '{0:N1} / {1:N1}' -f $score.Current, $score.Max } else { '' })
                High             = @($mine | Where-Object Severity -EQ 'High').Count
                Medium           = @($mine | Where-Object Severity -EQ 'Medium').Count
                Low              = @($mine | Where-Object Severity -EQ 'Low').Count
                Alerts           = @($alerts | Where-Object SubscriptionId -EQ $key).Count
                PlansOn          = @($myPlans | Where-Object Enabled).Count
                PlansOff         = @($myPlans | Where-Object { -not $_.Enabled }).Count
                FailedControls   = $(& { $sum = 0; foreach ($standard in $standards) { if ($standard.SubscriptionId -eq $key) { $sum += $standard.FailedControls } }; $sum })
                PolicyCompliance = $(if ($policyBySubscription.Contains($key)) { $policyBySubscription[$key] } else { $null })
                SubscriptionId   = $key
            }
        })

    $current = 0.0; $max = 0.0
    foreach ($score in $scores.Values) { $current += $score.Current; $max += $score.Max }
    $overall = if ($max -gt 0) { [Math]::Round(100 * $current / $max) } else { $null }
    $sectionRank = @{ Alert = 0; Recommendation = 1; Compliance = 2; Policy = 3; Plan = 4 }

    $all = @($findings | Sort-Object -Property @{ Expression = { if ($severityRank.Contains($_.Severity)) { $severityRank[$_.Severity] } else { 4 } } }, @{ Expression = { $sectionRank[$_.Section] } }, Title, Resource)
    @{
        Findings           = $all
        Subscriptions      = $subscriptions
        Controls           = $controls
        Recommendations    = $recommendations
        Alerts             = $alerts
        Plans              = $plans
        Standards          = $standards
        ComplianceControls = $complianceControls
        PolicyAssignments  = $policyAssignments
        Stats              = @{
            SecureScore     = $overall
            Rating          = $(if ($null -eq $overall) { '' } elseif ($overall -ge 70) { 'Good' } elseif ($overall -ge 40) { 'Fair' } else { 'Poor' })
            Subscriptions   = $subscriptions.Count
            Recommendations = $recommendations.Count
            High            = @($recommendations | Where-Object Severity -EQ 'High').Count
            Medium          = @($recommendations | Where-Object Severity -EQ 'Medium').Count
            Low             = @($recommendations | Where-Object Severity -EQ 'Low').Count
            Resources       = @($recommendations | ForEach-Object { ([string]$_.ResourceId).ToLowerInvariant() } | Select-Object -Unique).Count
            Alerts          = $alerts.Count
            HighAlerts      = @($alerts | Where-Object Severity -EQ 'High').Count
            PlansOn         = @($plans | Where-Object Enabled).Count
            PlansOff        = @($plans | Where-Object { -not $_.Enabled }).Count
            Standards       = @($standards | Select-Object -ExpandProperty Standard -Unique).Count
            FailedControls  = @($complianceControls | Where-Object State -EQ 'Failed').Count
            PolicyCompliance      = $policy.Stats.ComplianceRate
            PolicyAssignments     = $policyAssignments.Count
            NonCompliantResources = @($findings | Where-Object Section -EQ 'Policy' | ForEach-Object { ([string]$_.ResourceId).ToLowerInvariant() } | Select-Object -Unique).Count
            Findings        = $all.Count
        }
    }
}