Public/Get-TntM365SecureScoreReport.ps1

function Get-TntM365SecureScoreReport {
    <#
    .SYNOPSIS
        Generates a Microsoft 365 Tenant Secure Score report with security recommendations and trends.
 
    .DESCRIPTION
        This function connects to Microsoft Graph using an app registration and generates detailed reports about
        the tenant's secure score, security controls implementation status, and provides actionable security
        recommendations. It includes historical trending data and risk-based prioritization.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID (GUID) to connect to.
 
    .PARAMETER ClientId
        The Application (Client) ID of the app registration created for security reporting.
 
    .PARAMETER ClientSecret
        The client secret for the app registration. Accepts SecureString or plain String.
 
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use for authentication instead of client secret.
 
    .PARAMETER IncludeHistoricalData
        Switch to include historical secure score data for trend analysis (last 90 days).
 
    .PARAMETER FilterByCategory
        Filter results by security control category. Valid values are Identity, Data, Device, Apps, Infrastructure.
 
    .PARAMETER ShowOnlyRecommendations
        Switch to show only actionable recommendations rather than all controls.
 
    .PARAMETER MaxHistoryDays
        Maximum number of days of historical data to retrieve. Defaults to 90 days.
 
    .EXAMPLE
        Get-TntM365SecureScoreReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
    .EXAMPLE
        Get-TntM365SecureScoreReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret |
            ConvertTo-Json -Depth 10 | Out-File -Path 'SecureScore.json'
 
        Exports the report to JSON format.
 
    .EXAMPLE
        $Report = Get-TntM365SecureScoreReport @params -IncludeHistoricalData
        $Report.RecommendationsByImpact.High | Format-Table Title, MaxScore, ScoreGap
 
        Retrieves report with historical data and displays high-impact recommendations.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Returns a structured report object containing:
        - Summary: Current score, percentage, control counts by category
        - TrendAnalysis: Historical score changes (if -IncludeHistoricalData specified)
        - RecommendationsByImpact: High/Medium/Low impact recommendations
        - ImplementedControls: Controls already implemented
        - AllControls: Complete list of security controls
        - ControlsByCategory: Controls grouped by category
        - HistoricalScores: Raw historical data (if -IncludeHistoricalData specified)
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Azure AD Application Permissions:
        - SecurityEvents.Read.All (Application)
        - SecurityActions.Read.All (Application)
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(ParameterSetName = 'Interactive')]
        [Alias('ApplicationId')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Alias('ApplicationSecret')]
        [ValidateNotNullOrEmpty()]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidateNotNullOrEmpty()]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        [Parameter()]
        [switch]$IncludeHistoricalData,

        [Parameter()]
        [ValidateSet('Identity', 'Data', 'Device', 'Apps', 'Infrastructure')]
        [string]$FilterByCategory,

        [Parameter()]
        [switch]$ShowOnlyRecommendations,

        [Parameter()]
        [ValidateRange(1, 365)]
        [int]$MaxHistoryDays = 90
    )

    begin {
        Write-Information 'STARTED : Secure Score report generation...' -InformationAction Continue
    }

    process {
        try {
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo   = Connect-TntGraphSession @ConnectionParams

            Write-Verbose 'Retrieving current secure scores...'
            try {
                # Three fallback methods: SDK with params, REST API, SDK with local sort
                $CurrentSecureScores = [System.Collections.Generic.List[PSObject]]::new()
                try {
                    $CurrentSecureScores = Get-MgSecuritySecureScore -Top 1 -OrderBy 'createdDateTime desc' -ErrorAction Stop
                } catch {
                    Write-Verbose "Direct method failed, trying alternative approach: $($_.Exception.Message)"

                    # Method 2: Alternative approach using Invoke-MgGraphRequest
                    try {
                        $GraphUri = "https://graph.microsoft.com/v1.0/security/secureScores?`$top = 1&`$orderby = createdDateTime desc"
                        $Response = Invoke-MgGraphRequest -Uri $GraphUri -Method GET
                        if ($Response.value -and $Response.value.Count -gt 0) {
                            $CurrentSecureScores.Clear()
                            foreach ($item in $Response.value) {
                                $CurrentSecureScores.Add($item)
                            }
                        }
                    } catch {
                        Write-Verbose "Alternative method also failed: $($_.Exception.Message)"
                    }
                }

                # Method 3: If both fail, try getting all and sorting locally
                if ($CurrentSecureScores.Count -eq 0) {
                    Write-Verbose 'Trying to get all secure scores and sort locally...'
                    try {
                        $AllScores = Get-MgSecuritySecureScore -All -ErrorAction Stop
                        if ($AllScores -and $AllScores.Count -gt 0) {
                            $LatestScoreFromAll = $AllScores | Sort-Object CreatedDateTime -Descending | Select-Object -First 1
                            $CurrentSecureScores.Add($LatestScoreFromAll)
                        }
                    } catch {
                        Write-Warning "Failed to retrieve secure scores with all methods: $($_.Exception.Message)"
                    }
                }
            } catch {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Get-TntM365SecureScoreReport failed retrieving secure score data: $($_.Exception.Message)", $_.Exception),
                    'GetM365SecureScoreReportDataError',
                    [System.Management.Automation.ErrorCategory]::OperationStopped,
                    $TenantId
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            if ($CurrentSecureScores.Count -eq 0) {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new('Get-TntM365SecureScoreReport failed: No secure score data available. Verify that the app registration has SecurityEvents.Read.All permissions.'),
                    'GetM365SecureScoreReportNoDataError',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $TenantId
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            # Handle both array and single object cases
            $LatestScore = if ($CurrentSecureScores -is [array]) {
                $CurrentSecureScores[0]
            } else {
                $CurrentSecureScores
            }

            # Safely extract score values with null checks
            $CurrentScoreValue = if ($null -ne $LatestScore.CurrentScore) {
                [int]$LatestScore.CurrentScore
            } else {
                0
            }
            $MaxScoreValue = if ($null -ne $LatestScore.MaxScore) {
                [int]$LatestScore.MaxScore
            } else {
                0
            }

            $CurrentScorePercentage = if ($MaxScoreValue -gt 0) {
                [math]::Round(($CurrentScoreValue / $MaxScoreValue) * 100, 1)
            } else {
                0
            }

            Write-Verbose "Current secure score: $CurrentScoreValue / $MaxScoreValue ($CurrentScorePercentage%)"

            # Get historical data if requested
            $HistoricalScores = [System.Collections.Generic.List[PSObject]]::new()
            if ($IncludeHistoricalData) {
                Write-Verbose "Retrieving historical secure score data (last $MaxHistoryDays days)..."
                try {
                    $HistoryStartDate = [DateTime]::Now.AddDays(-$MaxHistoryDays).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')

                    # Try multiple methods for historical data
                    try {
                        $HistoricalScores = Get-MgSecuritySecureScore -Filter "createdDateTime ge $HistoryStartDate" -OrderBy 'createdDateTime desc' -All -ErrorAction Stop
                    } catch {
                        Write-Verbose 'Primary historical method failed, trying alternative...'
                        $GraphUri = "https://graph.microsoft.com/v1.0/security/secureScores?`$filter=createdDateTime ge $HistoryStartDate&`$orderby=createdDateTime desc"
                        $Response = Invoke-MgGraphRequest -Uri $GraphUri -Method GET
                        if ($Response.value) {
                            foreach ($item in $Response.value) {
                                $HistoricalScores.Add($item)
                            }
                        }
                    }
                    Write-Verbose "Retrieved $($HistoricalScores.Count) historical score entries"
                } catch {
                    Write-Warning "Failed to retrieve historical data: $($_.Exception.Message)"
                }
            }

            Write-Verbose 'Retrieving secure score control profiles...'
            try {
                $ControlProfiles = [System.Collections.Generic.List[PSObject]]::new()

                # Method 1: REST API (primary)
                try {
                    $GraphUri = 'https://graph.microsoft.com/v1.0/security/secureScoreControlProfiles'
                    $Response = Invoke-MgGraphRequest -Uri $GraphUri -Method GET
                    if ($Response.value) {
                        foreach ($controlProfile in $Response.value) {
                            $ControlProfiles.Add($controlProfile)
                        }

                        # Handle pagination if needed
                        while ($Response.'@odata.nextLink') {
                            $Response = Invoke-MgGraphRequest -Uri $Response.'@odata.nextLink' -Method GET
                            if ($Response.value) {
                                foreach ($controlProfile in $Response.value) {
                                    $ControlProfiles.Add($controlProfile)
                                }
                            }
                        }
                    }
                } catch {
                    # Method 2: SDK fallback
                    Write-Verbose 'REST API method failed, trying SDK fallback...'
                    try {
                        $DirectProfiles = Get-MgSecuritySecureScoreControlProfile -All -ErrorAction Stop
                        foreach ($controlProfile in $DirectProfiles) {
                            $ControlProfiles.Add($controlProfile)
                        }
                    } catch {
                        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                            [System.Exception]::new("Get-TntM365SecureScoreReport failed retrieving control profiles (SDK fallback): $($_.Exception.Message)", $_.Exception),
                            'GetM365SecureScoreReportControlProfilesError',
                            [System.Management.Automation.ErrorCategory]::OperationStopped,
                            $TenantId
                        )
                        $PSCmdlet.ThrowTerminatingError($errorRecord)
                    }
                }

                Write-Verbose "Retrieved $($ControlProfiles.Count) security control profiles"
            } catch {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new("Get-TntM365SecureScoreReport failed retrieving control profiles. Verify SecurityEvents.Read.All permissions: $($_.Exception.Message)", $_.Exception),
                    'GetM365SecureScoreReportControlProfilesError',
                    [System.Management.Automation.ErrorCategory]::OperationStopped,
                    $TenantId
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            $SecurityControls = [System.Collections.Generic.List[PSObject]]::new()
            foreach ($Control in $ControlProfiles) {
                # Get tenant-specific score and calculate implementation status from score comparison
                # Note: The Microsoft Graph API does NOT return 'implementationStatus' in controlScores
                $CurrentControlScore = 0
                $ControlImplementationStatus = 'Unknown'

                # Get MaxScore from control profile
                $MaxControlScore = if ($null -ne $Control.MaxScore) { [double]$Control.MaxScore } else { 0 }

                # Get control scores array
                $controlScoresArray = $LatestScore.ControlScores

                if ($controlScoresArray -and $controlScoresArray.Count -gt 0) {
                    # Find matching control by ID or Title
                    $ControlId = $Control.Id
                    $ControlTitle = $Control.Title

                    $MatchingControl = $null
                    foreach ($ScoreEntry in $controlScoresArray) {
                        if ($ScoreEntry.ControlName -and ($ScoreEntry.ControlName -eq $ControlId -or $ScoreEntry.ControlName -eq $ControlTitle)) {
                            $MatchingControl = $ScoreEntry
                            break
                        }
                    }

                    if ($MatchingControl) {
                        $CurrentControlScore = if ($null -ne $MatchingControl.Score) { [double]$MatchingControl.Score } else { 0 }

                        # Calculate implementation status from score comparison
                        if ($MaxControlScore -gt 0) {
                            if ($CurrentControlScore -ge $MaxControlScore) {
                                $ControlImplementationStatus = 'Implemented'
                            } elseif ($CurrentControlScore -gt 0) {
                                $ControlImplementationStatus = 'InProgress'
                            } else {
                                $ControlImplementationStatus = 'NotImplemented'
                            }
                        }
                    }
                }

                $ControlCategory = $Control.ControlCategory ?? $Control.Category ?? 'Unknown'

                # Skip if filtering by category
                if ($FilterByCategory -and $ControlCategory -ne $FilterByCategory) {
                    Write-Verbose 'Skipping control because of category filter.'
                    continue
                }

                # Skip implemented controls if only showing recommendations
                if ($ShowOnlyRecommendations -and $ControlImplementationStatus -eq 'Implemented') {
                    Write-Verbose 'Skipping control because of recommendation filter.'
                    continue
                }

                $ControlEntry = [PSCustomObject]@{
                    ControlId            = $Control.Id ?? 'Unknown'
                    Rank                 = $Control.Rank
                    Title                = $Control.Title ?? 'Unknown Control'
                    Category             = $ControlCategory
                    Description          = $Control.Description ?? 'No description available'
                    ImplementationStatus = $ControlImplementationStatus
                    ImplementationCost   = $Control.ImplementationCost ?? 'Unknown'
                    UserImpact           = $Control.UserImpact ?? 'Unknown'
                    MaxScore             = $MaxControlScore
                    CurrentScore         = $CurrentControlScore
                    ScoreGap             = $MaxControlScore - $CurrentControlScore
                    Tier                 = $Control.Tier ?? 'Unknown'
                    Threats              = if ($Control.Threats -and $Control.Threats.Count -gt 0) {
                        ($Control.Threats -join '; ')
                    } else {
                        'Not specified'
                    }
                    ActionType           = $Control.ActionType ?? 'Unknown'
                    ActionUrl            = $Control.ActionUrl ?? ''
                    LastModifiedDateTime = $Control.LastModifiedDateTime
                    IsRecommendation     = ($ControlImplementationStatus -eq 'NotImplemented' -or $ControlImplementationStatus -eq 'InProgress')
                    RiskReduction        = switch ($MaxControlScore) {
                        { $_ -ge 10 } { 'High' }
                        { $_ -ge 5 } { 'Medium' }
                        default { 'Low' }
                    }
                }
                $SecurityControls.Add($ControlEntry)
            }

            # Sort controls by priority (category priority, then by score gap)
            $SortedControls = $SecurityControls | Sort-Object ScoreGap -Descending

            # Calculate trend analysis if historical data is available
            $TrendAnalysis = $null
            if ($HistoricalScores.Count -gt 1) {
                $OldestScore = $HistoricalScores[-1]
                $OldestScoreValue = if ($null -ne $OldestScore.CurrentScore) {
                    [int]$OldestScore.CurrentScore
                } else {
                    0
                }
                $ScoreChange = $CurrentScoreValue - $OldestScoreValue
                $PercentageChange = if ($OldestScoreValue -gt 0) {
                    [math]::Round((($CurrentScoreValue - $OldestScoreValue) / $OldestScoreValue) * 100, 2)
                } else {
                    0
                }

                $ScoreTotal = 0
                foreach ($HistoricalScore in $HistoricalScores) {
                    if ($null -ne $HistoricalScore.CurrentScore) {
                        $ScoreTotal += [int]$HistoricalScore.CurrentScore
                    }
                }
                $AverageHistoricalScore = if ($HistoricalScores.Count -gt 0) {
                    [math]::Round(($ScoreTotal / $HistoricalScores.Count), 1)
                } else {
                    0
                }

                $TrendAnalysis = [PSCustomObject]@{
                    PeriodDays           = $MaxHistoryDays
                    ScoreChange          = $ScoreChange
                    PercentageChange     = $PercentageChange
                    Trend                = if ($ScoreChange -gt 0) {
                        'Improving'
                    } elseif ($ScoreChange -lt 0) {
                        'Declining'
                    } else {
                        'Stable'
                    }
                    OldestScoreDate      = $OldestScore.CreatedDateTime
                    OldestScore          = $OldestScoreValue
                    LatestScoreDate      = $LatestScore.CreatedDateTime
                    LatestScore          = $CurrentScoreValue
                    AverageScore         = $AverageHistoricalScore
                    HistoricalDataPoints = $HistoricalScores.Count
                }
            }
            $ControlStats = @{
                ImplementedControls         = 0
                NotImplementedControls      = 0
                InProgressControls          = 0
                PlannedControls             = 0
                TotalScoreGap               = 0
                HighImpactRecommendations   = 0
                MediumImpactRecommendations = 0
                LowImpactRecommendations    = 0
                IdentityControls            = 0
                DataControls                = 0
                DeviceControls              = 0
                AppControls                 = 0
                InfrastructureControls      = 0
            }

            foreach ($Ctrl in $SecurityControls) {
                # Implementation status counts
                switch ($Ctrl.ImplementationStatus) {
                    'Implemented' { $ControlStats.ImplementedControls++ }
                    'NotImplemented' { $ControlStats.NotImplementedControls++ }
                    'InProgress' { $ControlStats.InProgressControls++ }
                    'Planned' { $ControlStats.PlannedControls++ }
                }
                # Recommendation impact counts
                if ($Ctrl.IsRecommendation) {
                    $ControlStats.TotalScoreGap += $Ctrl.ScoreGap
                    if ($Ctrl.MaxScore -ge 10) {
                        $ControlStats.HighImpactRecommendations++
                    } elseif ($Ctrl.MaxScore -ge 5) {
                        $ControlStats.MediumImpactRecommendations++
                    } else {
                        $ControlStats.LowImpactRecommendations++
                    }
                }
                # Category counts
                switch ($Ctrl.Category) {
                    'Identity' { $ControlStats.IdentityControls++ }
                    'Data' { $ControlStats.DataControls++ }
                    'Device' { $ControlStats.DeviceControls++ }
                    'Apps' { $ControlStats.AppControls++ }
                    'Infrastructure' { $ControlStats.InfrastructureControls++ }
                }
            }

            $Summary = [PSCustomObject]@{
                TenantId                    = $TenantId
                ReportGeneratedDate         = Get-Date
                CurrentScore                = $CurrentScoreValue
                MaxPossibleScore            = $MaxScoreValue
                ScorePercentage             = $CurrentScorePercentage
                TotalControls               = $SecurityControls.Count
                ImplementedControls         = $ControlStats.ImplementedControls
                NotImplementedControls      = $ControlStats.NotImplementedControls
                InProgressControls          = $ControlStats.InProgressControls
                PlannedControls             = $ControlStats.PlannedControls
                TotalScoreGap               = $ControlStats.TotalScoreGap
                HighImpactRecommendations   = $ControlStats.HighImpactRecommendations
                MediumImpactRecommendations = $ControlStats.MediumImpactRecommendations
                LowImpactRecommendations    = $ControlStats.LowImpactRecommendations
                IdentityControls            = $ControlStats.IdentityControls
                DataControls                = $ControlStats.DataControls
                DeviceControls              = $ControlStats.DeviceControls
                AppControls                 = $ControlStats.AppControls
                InfrastructureControls      = $ControlStats.InfrastructureControls
                LastUpdated                 = $LatestScore.CreatedDateTime
            }

            Write-Information "FINISHED : Secure Score report - Score: $($CurrentScoreValue)/$($MaxScoreValue) ($($CurrentScorePercentage)%)" -InformationAction Continue

            [PSCustomObject]@{
                Summary                 = $Summary
                TrendAnalysis           = $TrendAnalysis
                RecommendationsByImpact = @{
                    High   = @($SortedControls.Where({ $_.IsRecommendation -and $_.MaxScore -ge 10 }))
                    Medium = @($SortedControls.Where({ $_.IsRecommendation -and $_.MaxScore -ge 5 -and $_.MaxScore -lt 10 }))
                    Low    = @($SortedControls.Where({ $_.IsRecommendation -and $_.MaxScore -lt 5 }))
                }
                ImplementedControls     = @($SortedControls.Where({ $_.ImplementationStatus -eq 'Implemented' }))
                AllControls             = $SortedControls
                ControlsByCategory      = @{
                    Identity       = @($SortedControls.Where({ $_.Category -eq 'Identity' }))
                    Data           = @($SortedControls.Where({ $_.Category -eq 'Data' }))
                    Device         = @($SortedControls.Where({ $_.Category -eq 'Device' }))
                    Apps           = @($SortedControls.Where({ $_.Category -eq 'Apps' }))
                    Infrastructure = @($SortedControls.Where({ $_.Category -eq 'Infrastructure' }))
                }
                HistoricalScores        = if ($IncludeHistoricalData) { $HistoricalScores } else { @() }
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntM365SecureScoreReport failed: $($_.Exception.Message)", $_.Exception),
                'GetM365SecureScoreReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            if ($ConnectionInfo.ShouldDisconnect) {
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
            }
        }
    }
}