Public/Invoke-AACDefenderAssessment.ps1

function Invoke-AACDefenderAssessment {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Microsoft Defender for Cloud assessed across subscriptions -
        recommendations, attack paths, security alerts, inventory,
        vulnerabilities, secure score, Defender plans, regulatory compliance
        and environment settings - with what to improve, as a Spectre.Console
        view, an object, CSV files and a tabbed, interactive HTML report.
    .DESCRIPTION
        Read-only (Reader or Security Reader is enough), no Az modules: Azure
        Resource Graph (securityresources) for what it holds, and the
        Defender for Cloud REST API for the settings it doesn't (security
        contacts, integrations, connectors, just-in-time policies, alert
        suppression rules) - one call per subscription and setting, in
        parallel.
 
        -Section picks what is read (everything by default):
          Recommendations every recommendation assessed - unhealthy, healthy
                           and not applicable resources, severity, risk level
                           and attack paths (Defender CSPM), secure score
                           control, description and remediation steps - and
                           every unhealthy resource
          AttackPaths attack paths (Defender CSPM): each step from the
                           entry point to the target, risk factors, MITRE
                           tactics and techniques, story and remediation
          Alerts security alerts generated in the last -AlertDays,
                           every status, with MITRE tactics and techniques,
                           compromised entity and remediation steps; alert
                           suppression rules
          Inventory every resource Defender assesses: the plan that
                           covers it (on or off), recommendations by
                           severity, vulnerabilities, active alerts and
                           attack paths
          Vulnerabilities vulnerability assessment findings (machines, SQL,
                           container images): CVEs, patchable, remediation
          Posture secure score per subscription and its controls
                           with the potential increase, Defender plans with
                           their sub-plan and extensions, multicloud and
                           DevOps connectors, just-in-time policies
          Compliance regulatory compliance standards, their controls
                           and the failed assessments
          Settings security contacts and e-mail notifications,
                           Defender for Endpoint and Defender for Cloud Apps
                           integration
 
        Findings, each with its severity and what to do: a plan off for
        resources the subscription has; Defender CSPM or Resource Manager
        off; Servers on Plan 1; no security contact, alert e-mails off or
        only for High alerts, owners not notified; Defender for Endpoint
        integration off; a secure score under -ScoreWarningPercent (70);
        Critical and High attack paths; High alerts still active, Medium
        alerts open over a week; suppression rules with no expiry;
        just-in-time ports open to any source.
 
        What you get depends on where the command runs:
          at the prompt tiles, the subscriptions, the top
                           recommendations, attack paths, active alerts, the
                           plans that are off and the findings, a page at a
                           time
          piped onward the AAC.DefenderAssessment object, with every
                           table as a property
          -PassThru the view and the object
          -NoDisplay the object only
        -CsvPath (a folder) writes a CSV per table. -HtmlPath writes a tabbed
        report in the portal's order - Overview, Findings, Recommendations,
        Attack path analysis, Security alerts, Inventory, Vulnerabilities,
        Security posture, Regulatory compliance, Environment settings -
        where every table is searchable, filterable, groupable and
        downloadable, and a row opens all its details (descriptions,
        remediation steps, the attack path step by step).
    .PARAMETER SubscriptionId
        Only these subscriptions. Defaults to every subscription the account
        can see.
    .PARAMETER Section
        What to read: Recommendations, AttackPaths, Alerts, Inventory,
        Vulnerabilities, Posture, Compliance, Settings. All by default.
    .PARAMETER AlertDays
        Security alerts generated in the last this many days (30 by
        default).
    .PARAMETER ScoreWarningPercent
        A subscription's secure score under this percentage (70 by default)
        is a finding - High under half of it.
    .PARAMETER CsvPath
        A folder to write a CSV per table to.
    .PARAMETER HtmlPath
        Write the tabbed, interactive HTML report to this file.
    .PARAMETER Title
        The HTML report's title.
    .PARAMETER PassThru
        Show the view and also return the object.
    .PARAMETER NoDisplay
        Return the object without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once instead of a page at a time.
    .EXAMPLE
        Connect-AAC
        Invoke-AACDefenderAssessment -HtmlPath .\out\Defender.html
        Defender for Cloud across every subscription you can see, as a tabbed HTML report.
    .EXAMPLE
        Invoke-AACDefenderAssessment -SubscriptionId '00000000-0000-0000-0000-000000000000' -CsvPath .\out\defender
        One subscription, a CSV per table.
    .EXAMPLE
        Invoke-AACDefenderAssessment -Section AttackPaths, Alerts -AlertDays 7
        Only the attack paths and the last week's alerts.
    .EXAMPLE
        (Invoke-AACDefenderAssessment -NoDisplay).Recommendations | Where-Object { $_.Severity -eq 'High' -and $_.UnhealthyResources } | Select-Object Recommendation, UnhealthyResources, Control
        The High recommendations and how many resources each is on.
    .EXAMPLE
        (Invoke-AACDefenderAssessment -NoDisplay -Section Inventory).Inventory | Where-Object PlanState -EQ 'Off'
        The resources no Defender plan protects.
    .OUTPUTS
        AAC.DefenderAssessment (piped onward, or with -PassThru or -NoDisplay)
    #>

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

        [ValidateSet('Recommendations', 'AttackPaths', 'Alerts', 'Inventory', 'Vulnerabilities', 'Posture', 'Compliance', 'Settings')]
        [string[]] $Section = @('Recommendations', 'AttackPaths', 'Alerts', 'Inventory', 'Vulnerabilities', 'Posture', 'Compliance', 'Settings'),

        [ValidateRange(1, 365)]
        [int] $AlertDays = 30,

        [ValidateRange(1, 99)]
        [int] $ScoreWarningPercent = 70,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $Title = 'Microsoft Defender for Cloud assessment',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    # A failure anywhere below ends as a Spectre.Console error panel and this
    # command's own terminating error; a stopped pipeline just returns.
    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
    $showView = $interactive -and -not ($CsvPath -or $HtmlPath)
    $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    # Read here, not inside the progress block (it runs in Invoke-AACProgress's scope).
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() })
        Section = @($Section); AlertDays = $AlertDays; ScoreWarningPercent = $ScoreWarningPercent
        CsvPath = & $resolve $CsvPath; HtmlPath = & $resolve $HtmlPath; Title = $Title
    }

    if ($interactive) {
        Write-AACRule -Title 'Azure Admin Console :: Microsoft Defender for Cloud' -Color 'deepskyblue3_1'
    }
    $state = Invoke-AACProgress -ScriptBlock {
        $null = Get-AACAccessToken
        $plan = Get-AACDefenderAssessmentQuery -Section $request.Section -AlertDays $request.AlertDays
        $notices = [System.Collections.Generic.List[string]]::new()

        # --- Resource Graph ------------------------------------------------------------------------------------
        $labels = @{
            subscriptions = 'subscriptions'; Plans = 'Defender plans'; Scores = 'secure scores'; Controls = 'secure score controls'; ControlAssessments = 'control membership'
            RecommendationSummary = 'recommendations'; Unhealthy = 'unhealthy resources'; InventorySummary = 'inventory'; AttackPaths = 'attack paths'; Alerts = 'security alerts'
            Vulnerabilities = 'vulnerabilities'; Standards = 'compliance standards'; ComplianceControls = 'compliance controls'; ComplianceAssessments = 'compliance assessments'
        }
        Update-AACProgress -Id 'graph' -Total $plan.Graph.Count -Description 'Reading Microsoft Defender for Cloud from Azure Resource Graph'
        $graph = @{ Query = $plan.Graph; AllowFailure = @($plan.Graph.Keys | Where-Object { $_ -ne 'subscriptions' }) }
        if ($request.SubscriptionId.Count) { $graph.SubscriptionId = $request.SubscriptionId }
        $batch = Invoke-AACGraphBatch @graph -OnProgress {
            param($Name, $Done, $Total)
            Update-AACProgress -Id 'graph' -Increment 1 -Description "Read the $($labels[$Name]) ($Done of $Total)"
        }
        foreach ($name in @($batch.Errors.Keys | Sort-Object)) { $notices.Add("The $($labels[$name]) couldn't be read: $($batch.Errors[$name] -replace '\s+', ' ')") }
        $subscriptionNames = @{}
        foreach ($row in @($batch.Rows['subscriptions'])) { if ($row) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } }
        if ($request.SubscriptionId.Count) {
            $missing = @($request.SubscriptionId | Where-Object { -not $subscriptionNames.Contains($_) })
            foreach ($id in $missing) { Write-Warning "Subscription $id wasn't found, or your account can't read it; it's left out." }
            if ($missing.Count -eq $request.SubscriptionId.Count) {
                $problem = [System.InvalidOperationException]::new('None of those subscriptions was found.')
                $problem.Data['AACHint'] = 'Check the subscription IDs, and that your account has Reader (or Security Reader) on them.'
                throw $problem
            }
            foreach ($key in @($subscriptionNames.Keys)) { if ($request.SubscriptionId -notcontains $key) { $subscriptionNames.Remove($key) } }
        }
        $rows = @{}
        foreach ($name in $plan.Graph.Keys) { if ($name -ne 'subscriptions' -and -not $batch.Errors.Contains($name)) { $rows[$name] = @($batch.Rows[$name]) } }
        Update-AACProgress -Id 'graph' -Complete -Description ('Read Defender for Cloud in {0:N0} subscription(s)' -f $subscriptionNames.Count)

        # --- REST: the settings Resource Graph doesn't have --------------------------------------------------------
        $rest = @{}
        if ($plan.Rest.Count -and $subscriptionNames.Count) {
            $uris = [System.Collections.Generic.List[string]]::new()
            foreach ($name in $plan.Rest.Keys) { foreach ($sub in $subscriptionNames.Keys) { $uris.Add(($plan.Rest[$name] -f $sub)) } }
            Update-AACProgress -Id 'rest' -Total $uris.Count -Description 'Reading the environment settings from the Defender for Cloud REST API'
            $answers = Invoke-AACArmParallel -Uri $uris.ToArray() -OnProgress { param($Done, $Total) Update-AACProgress -Id 'rest' -Increment 1 -Description "Read $Done of $Total settings" }
            foreach ($name in $plan.Rest.Keys) {
                $rest[$name] = @{}
                foreach ($sub in $subscriptionNames.Keys) { $rest[$name][$sub] = $answers[($plan.Rest[$name] -f $sub)] }
            }
            Update-AACProgress -Id 'rest' -Complete -Description ('Read {0:N0} setting(s) in {1:N0} subscription(s)' -f $uris.Count, $subscriptionNames.Count)
        }

        # --- The assessment ---------------------------------------------------------------------------------------
        Update-AACProgress -Id 'assess' -Indeterminate -Description 'Assessing recommendations, attack paths, alerts, inventory, posture and compliance'
        $assessment = ConvertTo-AACDefenderAssessment -Rows $rows -Rest $rest -SubscriptionName $subscriptionNames -ScoreWarningPercent $request.ScoreWarningPercent
        foreach ($line in $assessment.Notices) { $notices.Add($line) }
        $stats = $assessment.Stats
        if (-not @($assessment.Plans).Count -and $rows.Contains('Plans')) { $notices.Add('No Defender plans were found: Defender for Cloud may not be set up on these subscriptions.') }
        if ($request.Section -contains 'AttackPaths' -and -not $stats.AttackPaths -and -not @($assessment.Plans | Where-Object { $_.Name -eq 'CloudPosture' -and $_.State -eq 'On' }).Count) { $notices.Add('No attack paths: Defender CSPM is off, and attack path analysis needs it.') }
        if ($request.Section -contains 'Alerts' -and -not $stats.PlansOn) { $notices.Add('No Defender plan is on, so there can be no security alerts - an empty alert list is not a clean bill of health.') }
        $assessment.Notices = $notices.ToArray()
        Update-AACProgress -Id 'assess' -Complete -Description ('Secure score {0}; {1:N0} recommendation(s), {2:N0} attack path(s), {3:N0} active alert(s), {4:N0} vulnerabilit(ies); {5} critical/high, {6} medium finding(s)' -f $(if ($null -ne $stats.SecureScore) { "$($stats.SecureScore)%" } else { 'n/a' }), $stats.Recommendations, $stats.AttackPaths, $stats.ActiveAlerts, $stats.Vulnerabilities, ($stats.Critical + $stats.High), $stats.Medium)

        $detail = [ordered]@{
            Subscriptions = if ($request.SubscriptionId.Count) { ($request.SubscriptionId | ForEach-Object { if ($subscriptionNames.Contains($_)) { $subscriptionNames[$_] } else { $_ } }) -join ', ' } else { "every subscription the account can see ($($subscriptionNames.Count))" }
            Sections      = $request.Section -join ', '
            Alerts        = "generated in the last $($request.AlertDays) day(s)"
        }
        if ($request.CsvPath) {
            Update-AACProgress -Id 'csv' -Indeterminate -Description 'Writing the CSV files'
            $files = @(Write-AACDefenderAssessmentCsv -Assessment $assessment -Path $request.CsvPath)
            Update-AACProgress -Id 'csv' -Complete -Description "CSV: $($files.Count) file(s) in $($request.CsvPath)"
        }
        $null = Invoke-AACExport -HtmlPath $request.HtmlPath -WriteHtml {
            Write-AACDefenderAssessmentHtml -Assessment $assessment -Path $request.HtmlPath -Title $request.Title -Detail $detail
        }
        @{ Assessment = $assessment; Scope = $detail }
    }

    $assessment = $state.Assessment
    if ($showView) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACDefenderAssessmentView -Assessment $assessment -Scope $state.Scope
        }
    }
    elseif ($interactive) {
        foreach ($notice in @($assessment.Notices)) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" }
    }
    if ($returnObjects) {
        $result = [ordered]@{ PSTypeName = 'AAC.DefenderAssessment'; SecureScore = $assessment.Stats.SecureScore; Subscriptions = @($assessment.Subscriptions) }
        foreach ($key in 'Findings', 'Recommendations', 'UnhealthyResources', 'AttackPaths', 'Alerts', 'Inventory', 'Vulnerabilities', 'Controls', 'Plans', 'Connectors', 'Standards', 'ComplianceControls', 'ComplianceAssessments', 'Settings', 'JitPolicies', 'SuppressionRules', 'Notices') { $result[$key] = @($assessment[$key]) }
        $result['Stats'] = [pscustomobject]$assessment.Stats
        [pscustomobject]$result
    }
}