public/Get-MsecSecureScoreRecommendation.ps1

function Get-MsecSecureScoreRecommendation {
    <#
    .SYNOPSIS
        Microsoft Secure Score recommended actions as flat rows - what is not done, how many
        points it is worth, and the remediation text - joined from the live control scores and
        the control profiles.

    .DESCRIPTION
        This is the list the Defender portal shows under Secure Score > Recommended actions.
        Get-MsecSecureScore answers "what is the score and how has it moved"; this answers
        "what would move it, and what does each one cost".

        IT IS A JOIN OF TWO ENDPOINTS AND THEY DO NOT COVER THE SAME SET. The current state
        lives in the newest /security/secureScores snapshot's controlScores collection; the
        title, remediation, rank and impact live in /security/secureScoreControlProfiles.
        Measured on one tenant: 244 control scores against 462 profiles. The mismatch is not
        an error - a profile exists for products the tenant does not license - but it means
        the join has to be explicit about which side a row came from:

          Matched both sides present. The normal case.
          ScoreOnly a control is being scored with no profile to describe it. Title and
                         Remediation are $null rather than invented, and the row is still
                         emitted - a scored control that nothing can explain is worth seeing.
          ProfileOnly only with -IncludeNotApplicable. Microsoft publishes the action but
                         this tenant is not being scored on it, usually a licensing gap.
                         CurrentScore is $null, NOT zero: nought points earned and not being
                         measured at all are different facts.

        'on' IS THE STRING "false", NOT A BOOLEAN. Graph returns it as text, so
        `if ($control.on)` is true for both states and silently reports every control as
        enabled. It is converted here, and left $null when absent.

        implementationStatus IS FREE TEXT AND SOMETIMES HTML. Real values from one tenant
        include '0/128 exposed devices' and a paragraph of markup listing preset policy
        coverage. It is passed through untouched because it is genuinely useful to read, but
        nothing should parse it - the numbers in it move without notice.

        AN IGNORED CONTROL IS NOT A COMPLETED ONE. controlStateUpdates.state carries Default,
        Ignored, ThirdParty or Reviewed. A control someone marked Ignored still shows zero
        points earned, so a naive "points available" list presents a deliberate decision as
        outstanding work. State is a column, and -State filters on it.

    .PARAMETER Category
        Identity, Device, Apps, Data or Infrastructure. Tab completes but accepts anything, so
        a category Microsoft adds later is not silently unreachable.

    .PARAMETER State
        Only controls in these review states. Tab completes against the known values but
        accepts anything - this tenant returns AlternateMitigation, which is not in the
        documented list.

    .PARAMETER IncludeCompleted
        Also return controls with no points left. Off by default - this command is the
        recommended-actions list, and a finished control is not a recommended action.

    .PARAMETER IncludeDeprecated
        Also return controls whose profile is marked deprecated. Off by default: Microsoft
        retires actions and they linger in the profile list.

    .PARAMETER IncludeNotApplicable
        Also return ProfileOnly rows - published actions this tenant is not scored on.

    .EXAMPLE
        Get-MsecSecureScoreRecommendation | Sort-Object PointsAvailable -Descending |
            Select-Object -First 10 Title, Category, PointsAvailable, UserImpact

        The ten actions worth the most points.

    .EXAMPLE
        Get-MsecSecureScoreRecommendation -Category Identity |
            Where-Object { $_.UserImpact -eq 'Low' } | Sort-Object Rank

        Identity actions that do not inconvenience anyone, in Microsoft's own priority order.

    .EXAMPLE
        Get-MsecSecureScoreRecommendation -State Ignored -IncludeCompleted |
            Select-Object Title, PointsAvailable, StateUpdatedBy, StateComment

        What has been dismissed, by whom, and what it is costing. Worth re-reading periodically
        - an Ignored control is a risk acceptance that nothing expires.

    .OUTPUTS
        One PSCustomObject per control, PSTypeName 'MsecSecureScoreRecommendation'.

    .NOTES
        Needs Connect-Msec. Reads with SecurityEvents.Read.All, which New-MsecApp grants.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete)
            'Identity', 'Device', 'Apps', 'Data', 'Infrastructure' |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object {
                    [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
                }
        })]
        [string[]] $Category,

        # ArgumentCompleter, NOT ValidateSet. The documented states are Default, Ignored,
        # ThirdParty and Reviewed - but this tenant also returns AlternateMitigation, which a
        # ValidateSet would have made unreachable. Microsoft adds these without notice.
        [Parameter()]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete)
            'Default', 'Ignored', 'ThirdParty', 'Reviewed', 'AlternateMitigation' |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object {
                    [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
                }
        })]
        [string[]] $State,

        [Parameter()] [switch] $IncludeCompleted,
        [Parameter()] [switch] $IncludeDeprecated,
        [Parameter()] [switch] $IncludeNotApplicable
    )

    Assert-MsecSession

    # Newest snapshot only. The history is Get-MsecSecureScore's job; mixing the two here
    # would mean a "recommended action" list that is partly out of date depending on row.
    $snapshots = @(Invoke-MsecGraphRequest -Path '/v1.0/security/secureScores' -All)
    if (-not $snapshots.Count) {
        Write-Warning 'No Secure Score snapshots returned. That is not the same as a score of zero - check the app has SecurityEvents.Read.All and that Secure Score is enabled for this tenant.'
        return
    }
    $snapshot = $snapshots | Sort-Object { [datetime]$_.createdDateTime } | Select-Object -Last 1
    Write-Verbose "Using snapshot $($snapshot.createdDateTime): $($snapshot.currentScore)/$($snapshot.maxScore) across $(@($snapshot.controlScores).Count) controls."

    $profiles = @(Invoke-MsecGraphRequest -Path '/v1.0/security/secureScoreControlProfiles' -All)
    $byId = @{}
    foreach ($p in $profiles) { if ($p.id) { $byId[[string]$p.id] = $p } }

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $emit = {
        param($control, $profile, $source)

        $name = if ($control) { [string]$control.controlName } else { [string]$profile.id }
        [void]$seen.Add($name)

        # maxScore lives on the profile. Without one there is no denominator, so points
        # available is unknowable - $null, not the current score subtracted from nothing.
        $max  = if ($profile -and $null -ne $profile.maxScore) { [double]$profile.maxScore } else { $null }
        $cur  = if ($control -and $null -ne $control.score)    { [double]$control.score }    else { $null }
        $left = if ($null -ne $max -and $null -ne $cur) { [math]::Round($max - $cur, 2) } else { $null }

        $stateUpdate = $profile.controlStateUpdates
        if ($stateUpdate -is [System.Array]) { $stateUpdate = $stateUpdate | Select-Object -Last 1 }

        [PSCustomObject]@{
            PSTypeName           = 'MsecSecureScoreRecommendation'
            ControlName          = $name
            Title                = if ($profile) { [string]$profile.title } else { $null }
            Category             = if ($control) { [string]$control.controlCategory } elseif ($profile) { [string]$profile.controlCategory } else { $null }
            Service              = if ($profile) { [string]$profile.service } else { $null }
            Source               = $source
            CurrentScore         = $cur
            MaxScore             = $max
            PointsAvailable      = $left
            PercentComplete      = if ($control -and $null -ne $control.scoreInPercentage) { [math]::Round([double]$control.scoreInPercentage, 1) } else { $null }
            # Graph sends "true"/"false" as TEXT. Converted once, here, so no caller has to
            # remember that a non-empty string is always truthy.
            Enabled              = if ($control -and $control.on -ne $null -and "$($control.on)" -ne '') { "$($control.on)" -eq 'true' } else { $null }
            State                = if ($stateUpdate) { [string]$stateUpdate.state } else { $null }
            StateUpdatedBy       = if ($stateUpdate) { [string]$stateUpdate.updatedBy } else { $null }
            StateUpdatedUtc      = if ($stateUpdate -and $stateUpdate.updatedDateTime) { [datetime]$stateUpdate.updatedDateTime } else { $null }
            StateComment         = if ($stateUpdate) { [string]$stateUpdate.comment } else { $null }
            Rank                 = if ($profile -and $null -ne $profile.rank) { [int]$profile.rank } else { $null }
            Tier                 = if ($profile) { [string]$profile.tier } else { $null }
            ActionType           = if ($profile) { [string]$profile.actionType } else { $null }
            UserImpact           = if ($profile) { [string]$profile.userImpact } else { $null }
            ImplementationCost   = if ($profile) { [string]$profile.implementationCost } else { $null }
            Threats              = @(if ($profile) { $profile.threats })
            Deprecated           = if ($profile -and $null -ne $profile.deprecated) { [bool]$profile.deprecated } else { $null }
            # Free text, sometimes HTML. Useful to read, unsafe to parse - see the help.
            ImplementationStatus = if ($control) { [string]$control.implementationStatus } else { $null }
            Description          = if ($control) { [string]$control.description } else { $null }
            Remediation          = if ($profile) { [string]$profile.remediation } else { $null }
            RemediationImpact    = if ($profile) { [string]$profile.remediationImpact } else { $null }
            ActionUrl            = if ($profile) { [string]$profile.actionUrl } else { $null }
            LastSyncedUtc        = if ($control -and $control.lastSynced) { [datetime]$control.lastSynced } else { $null }
            SnapshotUtc          = [datetime]$snapshot.createdDateTime
            Raw                  = [PSCustomObject]@{ Control = $control; Profile = $profile }
        }
    }

    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($control in @($snapshot.controlScores)) {
        $id = [string]$control.controlName
        $profile = if ($id -and $byId.ContainsKey($id)) { $byId[$id] } else { $null }
        $source = if ($profile) { 'Matched' } else { 'ScoreOnly' }
        $rows.Add((& $emit $control $profile $source))
    }
    if ($IncludeNotApplicable) {
        foreach ($p in $profiles) {
            if ($p.id -and -not $seen.Contains([string]$p.id)) { $rows.Add((& $emit $null $p 'ProfileOnly')) }
        }
    }

    $unmatched = @($rows | Where-Object Source -eq 'ScoreOnly').Count
    if ($unmatched) {
        Write-Verbose "$unmatched scored control(s) have no published profile, so their Title and Remediation are null. They are still returned."
    }

    $out = $rows
    if ($Category)           { $out = $out | Where-Object { $_.Category -in $Category } }
    if ($State)              { $out = $out | Where-Object { $_.State -in $State } }
    if (-not $IncludeDeprecated) { $out = $out | Where-Object { $_.Deprecated -ne $true } }
    # $null PointsAvailable is kept: unknown is not the same as nothing left to do.
    if (-not $IncludeCompleted)  { $out = $out | Where-Object { $_.PointsAvailable -ne 0 } }

    $out | Sort-Object @{ e = 'PointsAvailable'; Descending = $true }, Rank
}