Public/Get-MsecSecureScore.ps1

function Get-MsecSecureScore {
    <#
    .SYNOPSIS
        Returns Microsoft Secure Score snapshots as flat rows: ScoreType / Date /
        ScorePercent. Covers both the overall score and every category surfaced by
        Microsoft Graph (Identity, Device, Apps, Data, Infrastructure, plus anything
        Microsoft adds later).

    .DESCRIPTION
        Calls Microsoft Graph /v1.0/security/secureScores. For each snapshot, emits
        one 'Overall' row (currentScore / maxScore) plus one row per category in the
        snapshot's controlScores collection. Per-category percentages are computed by
        summing achieved 'score' values and dividing by the matching sum of maxScore
        values from /v1.0/security/secureScoreControlProfiles. Profiles are stable
        across snapshots, so they're fetched once per call.

        Use this as the single source for Secure Score history. Trend math (diff vs
        previous snapshot, month-over-month, etc.) lives in the consumer - msec's
        job is to expose the raw shape.

    .PARAMETER Category
        Restrict the output to one ScoreType: 'Overall' for the overall row, or a
        category name (Identity, Device, Apps, Data, Infrastructure). When omitted,
        every category present in the snapshot is emitted alongside Overall. Tab
        completes against the known categories but accepts any string in case
        Microsoft adds a new one.

    .PARAMETER Top
        Only process the N most recent snapshots. When omitted, every snapshot in
        the API's window (~90 days) is returned. Pass -Top 1 for today's snapshot.

    .EXAMPLE
        # Today's overall + categories, one row each.
        Get-MsecSecureScore -Top 1

    .EXAMPLE
        # Just Identity, full history (~90 days). Suitable for trend charts.
        Get-MsecSecureScore -Category Identity

    .EXAMPLE
        # Bootstrap an archive with the full 90-day window across every category.
        Get-MsecSecureScore | ConvertTo-Json -Depth 4 | Set-Content ./archive/seed.json

    .OUTPUTS
        PSCustomObject per (snapshot, ScoreType) combination, with:
          - ScoreType : 'Overall' or a category name.
          - Date : DateTime of the snapshot.
          - ScorePercent : 0-100 percentage (null if maxScore was 0/missing).
    #>

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

        [Parameter()]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $Top
    )

    Assert-MsecSession

    # Skip the (expensive) profile fetch when the caller only wants Overall - the
    # control-id -> maxScore map is only needed for per-category math.
    $needCategories = (-not $Category) -or ($Category -ne 'Overall')

    $maxByControl = @{}
    if ($needCategories) {
        Write-Verbose 'Loading secureScoreControlProfiles for per-category maxima...'
        foreach ($p in (Invoke-MsecGraphRequest -Path '/v1.0/security/secureScoreControlProfiles' -All)) {
            if ($null -ne $p.maxScore -and $p.id) {
                $maxByControl[$p.id] = [double]$p.maxScore
            }
        }
    }

    # Pull score snapshots. The Graph API returns up to ~90 days; with -Top we trim.
    $scorePath = '/v1.0/security/secureScores'
    $snapshots = if ($PSBoundParameters.ContainsKey('Top')) {
        (Invoke-MsecGraphRequest -Path ($scorePath + "?`$top=$Top")).value
    }
    else {
        Invoke-MsecGraphRequest -Path $scorePath -All
    }

    foreach ($snapshot in $snapshots) {
        $date = [datetime]$snapshot.createdDateTime

        # Overall row for this snapshot.
        if (-not $Category -or $Category -eq 'Overall') {
            $overallPct = if ($snapshot.maxScore) {
                [math]::Round(($snapshot.currentScore / $snapshot.maxScore) * 100, 2)
            }
            else { $null }

            [PSCustomObject]@{
                ScoreType    = 'Overall'
                Date         = $date
                ScorePercent = $overallPct
            }
        }

        if ($needCategories) {
            # Group this snapshot's controls by category and accumulate (achieved, max).
            $byCategory = @{}
            foreach ($control in $snapshot.controlScores) {
                $cat = $control.controlCategory
                if (-not $cat) { continue }

                if (-not $byCategory.ContainsKey($cat)) {
                    $byCategory[$cat] = [PSCustomObject]@{ Achieved = 0.0; Max = 0.0 }
                }

                $byCategory[$cat].Achieved +=
                    if ($null -ne $control.score) { [double]$control.score } else { 0.0 }

                $controlId = $control.controlName
                if ($controlId -and $maxByControl.ContainsKey($controlId)) {
                    $byCategory[$cat].Max += $maxByControl[$controlId]
                }
            }

            foreach ($cat in $byCategory.Keys) {
                if ($Category -and $cat -ne $Category) { continue }

                $totals = $byCategory[$cat]
                $percent = if ($totals.Max -gt 0) {
                    [math]::Round(($totals.Achieved / $totals.Max) * 100, 2)
                }
                else { $null }

                [PSCustomObject]@{
                    ScoreType    = $cat
                    Date         = $date
                    ScorePercent = $percent
                }
            }
        }
    }
}