Public/Get-TntAzureSecureScoreReport.ps1

function Get-TntAzureSecureScoreReport {
    <#
    .SYNOPSIS
        Retrieves Azure Secure Score data from all subscriptions within the tenant.
 
    .DESCRIPTION
        This function connects to Azure Resource Manager using an app registration and retrieves
        Azure Security Center secure scores from all accessible subscriptions. It provides comprehensive
        reporting on security posture, recommendations, and compliance across the Azure environment.
 
    .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 IncludeRecommendations
        Switch to include detailed security recommendations for each subscription.
 
    .PARAMETER FilterBySubscription
        Filter results to specific subscription IDs. Accepts array of subscription IDs.
 
    .PARAMETER MaxConcurrentRequests
        Maximum number of concurrent API requests. Defaults to 5 for rate limiting.
 
    .PARAMETER IncludeComplianceScore
        Switch to include regulatory compliance scores where available.
 
    .PARAMETER IncludeHistoricalData
        Switch to include historical secure score data for trend analysis (last 90 days).
 
    .PARAMETER MaxHistoryDays
        Maximum number of days of historical data to retrieve. Defaults to 90 days.
 
    .EXAMPLE
        Get-TntAzureSecureScoreReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
        Retrieves Azure Secure Score data from all accessible subscriptions.
 
    .EXAMPLE
        Get-TntAzureSecureScoreReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret |
            ConvertTo-Json -Depth 10 | Out-File -Path 'AzureSecureScore.json'
 
    .EXAMPLE
        $Report = Get-TntAzureSecureScoreReport @params -IncludeRecommendations -IncludeHistoricalData
        $Report.SubscriptionScores | Sort-Object ScorePercentage | Format-Table
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Returns a structured report object containing:
        - Summary: Aggregated statistics across all subscriptions
        - SubscriptionScores: Per-subscription secure score data
        - ComplianceData: Regulatory compliance scores (if -IncludeComplianceScore specified)
        - TrendAnalysis: Historical trend analysis (if -IncludeHistoricalData specified)
        - HistoricalScores: Raw historical data (if -IncludeHistoricalData specified)
        - Recommendations: Security recommendations (if -IncludeRecommendations specified)
        - ProcessingErrors: Any errors encountered during processing
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Permissions:
        - Microsoft Graph: Directory.Read.All (Application)
        - Azure Service Management: user_impersonation (Delegated)
 
        Additional Requirements:
        - Security Reader role on each subscription (or Contributor/Reader role)
 
    .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]$IncludeRecommendations,

        [Parameter()]
        [string[]]$FilterBySubscription,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$MaxConcurrentRequests = 5,

        [Parameter()]
        [switch]$IncludeComplianceScore,

        [Parameter()]
        [switch]$IncludeHistoricalData,

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

    begin {
        # Azure Resource Manager endpoint
        $Script:ArmBaseUri = 'https://management.azure.com'

        Write-Information 'STARTED : Azure Secure Score collection...' -InformationAction Continue
    }

    process {
        try {
            # Establish connection
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionParams['Scope'] = 'Azure'
            $ConnectionParams['ConnectionType'] = 'RestApi'
            $ConnectionInfo = Connect-TntGraphSession @ConnectionParams
            $Script:ArmHeaders = $ConnectionInfo.Headers

            $SubscriptionsUri      = "$($Script:ArmBaseUri)/subscriptions?api-version=2020-01-01"
            $SubscriptionsResponse = Invoke-RestMethod -Uri $SubscriptionsUri -Headers $Script:ArmHeaders -Method GET -ErrorAction Stop
            $AllSubscriptions      = $SubscriptionsResponse.value.Where({ $_.state -eq 'Enabled' })

            # Filter subscriptions if specified
            if ($FilterBySubscription) {
                $AllSubscriptions = $AllSubscriptions.Where({ $_.subscriptionId -in $FilterBySubscription })
            }

            Write-Verbose "Found $($AllSubscriptions.Count) enabled subscriptions to process"

            if ($AllSubscriptions.Count -eq 0) {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new('Get-TntAzureSecureScoreReport failed: No enabled subscriptions found or insufficient permissions.'),
                    'GetAzureSecureScoreReportNoSubscriptionsError',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $TenantId
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            # Process subscriptions for secure score data (parallel across subscriptions)
            $SubscriptionSecureScores = [System.Collections.Generic.List[PSObject]]::new()
            $OverallRecommendations   = [System.Collections.Generic.List[PSObject]]::new()
            $ComplianceData           = [System.Collections.Generic.List[PSObject]]::new()
            $ProcessingErrors         = [System.Collections.Generic.List[PSObject]]::new()

            Write-Verbose "Processing $($AllSubscriptions.Count) subscriptions with throttle limit: $MaxConcurrentRequests"

            # Capture variables for parallel runspaces
            $ArmBaseUri              = $Script:ArmBaseUri
            $ArmHeaders              = $Script:ArmHeaders
            $WantRecommendations     = $IncludeRecommendations.IsPresent
            $WantCompliance          = $IncludeComplianceScore.IsPresent

            $ParallelResults = $AllSubscriptions | ForEach-Object -ThrottleLimit $MaxConcurrentRequests -Parallel {
                $Subscription        = $_
                $BaseUri             = $using:ArmBaseUri
                $Headers             = $using:ArmHeaders
                $GetRecommendations  = $using:WantRecommendations
                $GetCompliance       = $using:WantCompliance

                try {
                    $SecureScoreUri      = "$BaseUri/subscriptions/$($Subscription.subscriptionId)/providers/Microsoft.Security/secureScores/ascScore?api-version=2020-01-01"

                    try {
                        $SecureScoreResponse = Invoke-RestMethod -Uri $SecureScoreUri -Headers $Headers -Method GET -ErrorAction Stop

                        # Get secure score controls for detailed breakdown
                        $ControlsUri      = "$BaseUri/subscriptions/$($Subscription.subscriptionId)/providers/Microsoft.Security/secureScoreControls?api-version=2020-01-01"
                        $ControlsResponse = Invoke-RestMethod -Uri $ControlsUri -Headers $Headers -Method GET -ErrorAction SilentlyContinue

                        # Calculate control statistics using single-pass
                        $Controls              = $ControlsResponse.value ?? @()
                        $HealthyControls       = 0
                        $UnhealthyControls     = 0
                        $NotApplicableControls = 0
                        foreach ($CtrlItem in $Controls) {
                            if ($CtrlItem.properties.score.max -eq 0) {
                                $NotApplicableControls++
                            } elseif ($CtrlItem.properties.score.current -eq $CtrlItem.properties.score.max) {
                                $HealthyControls++
                            } else {
                                $UnhealthyControls++
                            }
                        }

                        # Collect recommendations for this subscription if requested
                        $SubscriptionRecommendations = [System.Collections.Generic.List[PSObject]]::new()
                        if ($GetRecommendations) {
                            $RecommendationsUri      = "$BaseUri/subscriptions/$($Subscription.subscriptionId)/providers/Microsoft.Security/assessments?api-version=2020-01-01"
                            $RecommendationsResponse = Invoke-RestMethod -Uri $RecommendationsUri -Headers $Headers -Method GET -ErrorAction SilentlyContinue

                            foreach ($Assessment in ($RecommendationsResponse.value ?? @())) {
                                if ($Assessment.properties.status.code -eq 'Unhealthy') {
                                    $Recommendation = [PSCustomObject]@{
                                        SubscriptionId         = $Subscription.subscriptionId
                                        SubscriptionName       = $Subscription.displayName
                                        AssessmentId           = $Assessment.id
                                        DisplayName            = $Assessment.properties.displayName
                                        Description            = $Assessment.properties.description
                                        RemediationDescription = $Assessment.properties.remediationDescription
                                        Severity               = $Assessment.properties.status.severity
                                        Category               = $Assessment.properties.categories -join '; '
                                        ResourceType           = $Assessment.properties.resourceDetails.source
                                        LastStatusChangeDate   = $Assessment.properties.timeGenerated
                                    }
                                    $SubscriptionRecommendations.Add($Recommendation)

                                    # Output recommendation for aggregation
                                    [PSCustomObject]@{
                                        ResultType = 'Recommendation'
                                        Data       = $Recommendation
                                    }
                                }
                            }
                        }

                        # Output subscription score
                        [PSCustomObject]@{
                            ResultType = 'Score'
                            Data       = [PSCustomObject]@{
                                SubscriptionId          = $Subscription.subscriptionId
                                SubscriptionName        = $Subscription.displayName
                                TenantId                = $Subscription.tenantId
                                State                   = $Subscription.state
                                CurrentScore            = [math]::Round($SecureScoreResponse.properties.score.current, 2)
                                MaxScore                = [math]::Round($SecureScoreResponse.properties.score.max, 2)
                                ScorePercentage         = if ($SecureScoreResponse.properties.score.max -gt 0) {
                                    [math]::Round(($SecureScoreResponse.properties.score.current / $SecureScoreResponse.properties.score.max) * 100, 1)
                                } else { 0 }
                                Weight                  = $SecureScoreResponse.properties.weight ?? 0
                                LastCalculatedDate      = $SecureScoreResponse.properties.lastAssessedDate ?? $SecureScoreResponse.properties.createdDate
                                Controls                = $Controls.properties
                                TotalControls           = $Controls.Count
                                HealthyControls         = $HealthyControls
                                UnhealthyControls       = $UnhealthyControls
                                NotApplicableControls   = $NotApplicableControls
                                SecurityCenterEnabled   = $true
                                SecurityRecommendations = $SubscriptionRecommendations
                            }
                        }

                        # Get regulatory compliance if requested
                        if ($GetCompliance) {
                            $ComplianceUri      = "$BaseUri/subscriptions/$($Subscription.subscriptionId)/providers/Microsoft.Security/regulatoryComplianceStandards?api-version=2019-01-01-preview"
                            $ComplianceResponse = Invoke-RestMethod -Uri $ComplianceUri -Headers $Headers -Method GET -ErrorAction SilentlyContinue

                            foreach ($Standard in ($ComplianceResponse.value ?? @())) {
                                [PSCustomObject]@{
                                    ResultType = 'Compliance'
                                    Data       = [PSCustomObject]@{
                                        SubscriptionId      = $Subscription.subscriptionId
                                        SubscriptionName    = $Subscription.displayName
                                        StandardName        = $Standard.properties.displayName
                                        StandardId          = $Standard.name
                                        State               = $Standard.properties.state
                                        PassedControls      = $Standard.properties.passedControls
                                        FailedControls      = $Standard.properties.failedControls
                                        SkippedControls     = $Standard.properties.skippedControls
                                        UnsupportedControls = $Standard.properties.unsupportedControls
                                    }
                                }
                            }
                        }

                    } catch {
                        # Handle subscriptions without Security Center or insufficient permissions
                        [PSCustomObject]@{
                            ResultType = 'Error'
                            Data       = [PSCustomObject]@{
                                SubscriptionId   = $Subscription.subscriptionId
                                SubscriptionName = $Subscription.displayName
                                Error            = $_.Exception.Message
                                ErrorType        = if ($_.Exception.Message -match '403|Forbidden') { 'Insufficient Permissions' }
                                                   elseif ($_.Exception.Message -match '404|Not Found') { 'Security Center Not Enabled' }
                                                   else { 'API Error' }
                            }
                        }

                        # Add placeholder entry for failed subscriptions
                        [PSCustomObject]@{
                            ResultType = 'Score'
                            Data       = [PSCustomObject]@{
                                SubscriptionId        = $Subscription.subscriptionId
                                SubscriptionName      = $Subscription.displayName
                                TenantId              = $Subscription.tenantId
                                State                 = $Subscription.state
                                CurrentScore          = 0
                                MaxScore              = 0
                                ScorePercentage       = 0
                                Weight                = 0
                                LastCalculatedDate    = $null
                                TotalControls         = 0
                                HealthyControls       = 0
                                UnhealthyControls     = 0
                                NotApplicableControls = 0
                                SecurityCenterEnabled = $false
                                Error                 = $_.Exception.Message
                            }
                        }
                    }
                } catch {
                    [PSCustomObject]@{
                        ResultType = 'Warning'
                        Data       = "Failed to process subscription $($Subscription.displayName): $($_.Exception.Message)"
                    }
                }
            }

            # Aggregate parallel results by type
            foreach ($Result in $ParallelResults) {
                switch ($Result.ResultType) {
                    'Score'          { $SubscriptionSecureScores.Add($Result.Data) }
                    'Recommendation' { $OverallRecommendations.Add($Result.Data) }
                    'Compliance'     { $ComplianceData.Add($Result.Data) }
                    'Error'          { $ProcessingErrors.Add($Result.Data) }
                    'Warning'        { Write-Warning $Result.Data }
                }
            }

            # Calculate aggregated statistics
            $ValidScores = $SubscriptionSecureScores.Where({ $_.SecurityCenterEnabled -eq $true })

            if ($ValidScores.Count -eq 0) {
                Write-Warning 'No subscriptions with valid secure score data found. Check Security Center enablement and RBAC permissions.'
                return
            }

            # Calculate aggregated stats using single-pass accumulation
            $ScoreStats = @{
                TotalCurrentScore = 0
                TotalMaxScore     = 0
                HighestScore      = 0
                LowestScore       = [double]::MaxValue
                ScoreSum          = 0
                PercentageSum     = 0
            }
            foreach ($VS in $ValidScores) {
                $ScoreStats.TotalCurrentScore += $VS.CurrentScore
                $ScoreStats.TotalMaxScore     += $VS.MaxScore
                $ScoreStats.ScoreSum          += $VS.CurrentScore
                $ScoreStats.PercentageSum     += $VS.ScorePercentage
                if ($VS.CurrentScore -gt $ScoreStats.HighestScore) { $ScoreStats.HighestScore = $VS.CurrentScore }
                if ($VS.CurrentScore -lt $ScoreStats.LowestScore) { $ScoreStats.LowestScore = $VS.CurrentScore }
            }
            if ($ScoreStats.LowestScore -eq [double]::MaxValue) { $ScoreStats.LowestScore = 0 }

            $RecommendationStats = @{ Critical = 0; High = 0; Medium = 0 }
            foreach ($Rec in $OverallRecommendations) {
                switch ($Rec.Severity) {
                    'High' { $RecommendationStats.Critical++ }
                    'Medium' { $RecommendationStats.High++ }
                    'Low' { $RecommendationStats.Medium++ }
                }
            }

            $ValidScoreCount = $ValidScores.Count
            $AggregatedStats = [PSCustomObject]@{
                TotalSubscriptions              = $AllSubscriptions.Count
                SubscriptionsWithSecurityCenter = $ValidScoreCount
                SubscriptionsWithErrors         = $ProcessingErrors.Count
                AverageScore                    = if ($ValidScoreCount -gt 0) { [math]::Round($ScoreStats.ScoreSum / $ValidScoreCount, 2) } else { 0 }
                AveragePercentage               = if ($ValidScoreCount -gt 0) { [math]::Round($ScoreStats.PercentageSum / $ValidScoreCount, 1) } else { 0 }
                HighestScore                    = $ScoreStats.HighestScore
                LowestScore                     = $ScoreStats.LowestScore
                TotalCurrentScore               = $ScoreStats.TotalCurrentScore
                TotalMaxScore                   = $ScoreStats.TotalMaxScore
                OverallPercentage               = if ($ScoreStats.TotalMaxScore -gt 0) {
                    [math]::Round(($ScoreStats.TotalCurrentScore / $ScoreStats.TotalMaxScore) * 100, 1)
                } else { 0 }
                TotalRecommendations            = $OverallRecommendations.Count
                CriticalRecommendations         = $RecommendationStats.Critical
                HighRecommendations             = $RecommendationStats.High
                MediumRecommendations           = $RecommendationStats.Medium
            }

            # Get historical data if requested
            $HistoricalScores = [System.Collections.Generic.List[PSObject]]::new()
            $TrendAnalysis = $null
            if ($IncludeHistoricalData -and $ValidScores.Count -gt 0) {
                Write-Verbose "Retrieving historical secure score data (last $MaxHistoryDays days) for subscriptions..."

                # Cache date calculation outside the loop
                $StartDate = [DateTime]::Now.AddDays(-$MaxHistoryDays).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')

                foreach ($Subscription in $AllSubscriptions.Where({ $_.subscriptionId -in $ValidScores.SubscriptionId })) {
                    try {
                        Write-Verbose "Retrieving historical data for subscription: $($Subscription.displayName)"

                        # Try to get historical secure score data
                        try {
                            $HistoryScoreUri = "$($Script:ArmBaseUri)/subscriptions/$($Subscription.subscriptionId)/providers/Microsoft.Security/secureScores?api-version=2020-01-01&`$filter=createdDateTime ge $StartDate&`$orderby=createdDateTime desc"
                            $HistoryResponse = Invoke-RestMethod -Uri $HistoryScoreUri -Headers $Script:ArmHeaders -Method GET -ErrorAction SilentlyContinue

                            if ($HistoryResponse.value) {
                                foreach ($HistoryEntry in $HistoryResponse.value) {
                                    $HistoricalScores.Add([PSCustomObject]@{
                                            SubscriptionId   = $Subscription.subscriptionId
                                            SubscriptionName = $Subscription.displayName
                                            Date             = $HistoryEntry.properties.createdDateTime
                                            CurrentScore     = [math]::Round($HistoryEntry.properties.score.current, 2)
                                            MaxScore         = [math]::Round($HistoryEntry.properties.score.max, 2)
                                            ScorePercentage  = if ($HistoryEntry.properties.score.max -gt 0) {
                                                [math]::Round(($HistoryEntry.properties.score.current / $HistoryEntry.properties.score.max) * 100, 1)
                                            } else { 0 }
                                        })
                                }
                            }
                        } catch {
                            Write-Verbose "Could not retrieve historical data for subscription $($Subscription.displayName): $($_.Exception.Message)"
                        }
                    } catch {
                        Write-Verbose "Failed to process historical data for subscription $($Subscription.displayName): $($_.Exception.Message)"
                        continue
                    }
                }

                # Calculate trend analysis if we have historical data
                if ($HistoricalScores.Count -gt 1) {
                    Write-Verbose "Calculating trend analysis from $($HistoricalScores.Count) historical data points"

                    # Group by subscription and calculate trends
                    $SubscriptionTrends = [System.Collections.Generic.List[PSObject]]::new()
                    foreach ($HistoryGroup in ($HistoricalScores | Group-Object SubscriptionId)) {
                        $SubHistory = $HistoryGroup.Group | Sort-Object Date
                        if ($SubHistory.Count -gt 1) {
                            $OldestScore = $SubHistory[0]
                            $LatestScore = $SubHistory[-1]
                            $ScoreChange = $LatestScore.CurrentScore - $OldestScore.CurrentScore
                            $PercentageChange = if ($OldestScore.CurrentScore -gt 0) {
                                [math]::Round((($LatestScore.CurrentScore - $OldestScore.CurrentScore) / $OldestScore.CurrentScore) * 100, 2)
                            } else { 0 }

                            $SubscriptionTrends.Add([PSCustomObject]@{
                                    SubscriptionId   = $HistoryGroup.Name
                                    SubscriptionName = $OldestScore.SubscriptionName
                                    PeriodDays       = $MaxHistoryDays
                                    ScoreChange      = [math]::Round($ScoreChange, 2)
                                    PercentageChange = $PercentageChange
                                    Trend            = if ($ScoreChange -gt 0) { 'Improving' } elseif ($ScoreChange -lt 0) { 'Declining' } else { 'Stable' }
                                    OldestScore      = $OldestScore.CurrentScore
                                    LatestScore      = $LatestScore.CurrentScore
                                    OldestDate       = $OldestScore.Date
                                    LatestDate       = $LatestScore.Date
                                    DataPoints       = $SubHistory.Count
                                })
                        }
                    }

                    # Calculate overall trend analysis using single-pass accumulation
                    if ($SubscriptionTrends.Count -gt 0) {
                        $TrendStats = @{
                            ScoreChangeSum       = 0
                            PercentageChangeSum  = 0
                            ImprovingCount       = 0
                            DecliningCount       = 0
                            StableCount          = 0
                        }
                        foreach ($SubTrend in $SubscriptionTrends) {
                            $TrendStats.ScoreChangeSum += $SubTrend.ScoreChange
                            $TrendStats.PercentageChangeSum += $SubTrend.PercentageChange
                            switch ($SubTrend.Trend) {
                                'Improving' { $TrendStats.ImprovingCount++ }
                                'Declining' { $TrendStats.DecliningCount++ }
                                'Stable' { $TrendStats.StableCount++ }
                            }
                        }
                        $TrendCount              = $SubscriptionTrends.Count
                        $OverallScoreChange      = $TrendStats.ScoreChangeSum
                        $AveragePercentageChange = if ($TrendCount -gt 0) { $TrendStats.PercentageChangeSum / $TrendCount } else { 0 }

                        $TrendAnalysis = [PSCustomObject]@{
                            PeriodDays                  = $MaxHistoryDays
                            TotalSubscriptionsWithTrend = $TrendCount
                            OverallScoreChange          = [math]::Round($OverallScoreChange, 2)
                            AveragePercentageChange     = [math]::Round($AveragePercentageChange, 2)
                            OverallTrend                = if ($OverallScoreChange -gt 0) { 'Improving' } elseif ($OverallScoreChange -lt 0) { 'Declining' } else { 'Stable' }
                            ImprovingSubscriptions      = $TrendStats.ImprovingCount
                            DecliningSubscriptions      = $TrendStats.DecliningCount
                            StableSubscriptions         = $TrendStats.StableCount
                            SubscriptionTrends          = $SubscriptionTrends
                            TotalHistoricalDataPoints   = $HistoricalScores.Count
                        }
                    }
                }

                Write-Verbose "Historical data processing completed. Found $($HistoricalScores.Count) historical entries"
            }

            Write-Information "FINISHED : Azure Secure Score collection - $($ValidScores.Count) subscriptions processed" -InformationAction Continue

            [PSCustomObject][Ordered]@{
                Summary            = $AggregatedStats
                SubscriptionScores = $SubscriptionSecureScores | Sort-Object ScorePercentage -Descending
                ComplianceData     = if ($IncludeComplianceScore) {
                    $ComplianceData | Sort-Object SubscriptionName, StandardName
                } else {
                    @()
                }
                TrendAnalysis      = $TrendAnalysis
                HistoricalScores   = if ($IncludeHistoricalData) {
                    $HistoricalScores | Sort-Object Date -Descending
                } else {
                    @()
                }
                Recommendations    = $OverallRecommendations | Sort-Object Severity -Descending
                ProcessingErrors   = $ProcessingErrors
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntAzureSecureScoreReport failed: $($_.Exception.Message)", $_.Exception),
                'GetAzureSecureScoreReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            # Cleanup connections
            if ($ConnectionInfo.ShouldDisconnect) {
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
            }
        }
    }
}