Export customers’ Microsoft Secure Scores to CSV and HTML reports

Microsoft 365 logo

Updated: April 2026

This script has been completely rewritten to use the Microsoft Graph PowerShell SDK, replacing the deprecated AzureAD module. It no longer requires creating a temporary app registration. The new version generates modern branded HTML reports with score gauges, trend charts, category breakdowns, and control change tracking.

Microsoft Secure Score is one of the best tools available for measuring how well a Microsoft 365 tenant has adopted security controls. It provides a numerical score based on system configurations, user behaviour, and security-related settings across Identity, Device, Apps, and Data categories.

If you manage multiple customer tenants as a Microsoft CSP partner, reviewing Secure Score for each one is a manual and time-consuming process. You need to switch between tenants in the Microsoft 365 Defender portal and there is no built-in way to export or compare scores across your entire customer base.

This PowerShell script automates the entire process. It connects to Microsoft Graph, iterates through all your customer tenants via GDAP, and exports each tenant’s Secure Score data to a branded HTML report and a combined CSV overview file.

Microsoft Secure Score report showing GCIT branded header with percentage gauge, comparison bars, and category breakdown

What the reports include

  • Percentage-based secure score with visual gauge
  • Comparison against similar-sized companies and global averages
  • Score history over the last 90 days with trend line
  • Category breakdown (Identity, Device, Apps, Data) with comparison bars
  • Improved and regressed controls over the period
  • Detailed control cards with assessment, remediation steps, and launch links
  • CSV overview of all customer tenants

Secure Score report showing category comparison bars, score history timeline chart, and improved and regressed controls

Secure Score report showing control change details and individual identity control cards with remediation steps

Prerequisites

  • PowerShell 7+ (recommended) or Windows PowerShell 5.1
  • Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication
  • Global Admin or Security Reader role in your partner tenant
  • GDAP relationships with customer tenants (Security Reader role or higher)
  • Admin Agent group membership (standard for CSP partners)

How to run this script

  1. Copy the script below into a .ps1 file (for example, Export-SecureScoreReports.ps1).
  2. Modify the configuration variables at the top of the script ($yourLogo, $companyName, $tableHeaderColour, $outputPath) to match your organisation.
  3. Run the script in PowerShell.
  4. Sign in with your partner tenant admin credentials when the browser authentication window opens.
  5. Wait for the script to process each customer tenant. Progress is displayed in the console.
  6. Find the exported reports at C: emp\SecureScoreReports\ (or your configured output path). Each tenant gets an individual HTML report, and a combined AllTenantOverview.csv is created for comparison.

The script

#Requires -Modules Microsoft.Graph.Authentication

<#
.SYNOPSIS
    Export Microsoft Secure Score reports for all customer tenants to CSV and branded HTML.

.DESCRIPTION
    This script connects to Microsoft Graph using interactive authentication,
    retrieves customer tenant contracts, then exports each tenant's Microsoft
    Secure Score data to individual HTML reports and a combined CSV overview.

    Reports include a percentage gauge, category comparison bars, 90-day score
    history timeline, improved/regressed control tracking, and detailed control
    cards with remediation steps.

    Designed for Microsoft 365 CSP partners with GDAP relationships.

.PREREQUISITES
    - PowerShell 7+ (recommended) or Windows PowerShell 5.1
    - Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication
    - Global Admin or Security Reader role in your partner tenant
    - GDAP relationships with customer tenants (Security Reader role or higher)
    - Admin Agent group membership (standard for CSP partners)

.NOTES
    Author: Elliot Munro - GCIT (https://gcit.com.au)
    Updated: April 2026
    Article: https://gcit.com.au/knowledge-base/export-customers-microsoft-secure-scores-to-csv-and-html-reports/
#>

# ============================================================
# CONFIGURATION - Modify these values for your organisation
# ============================================================

$yourLogo = "https://gcit.com.au/wp-content/uploads/FULL-LOGO-BLACK-TEXT.svg"
$companyName = "GCIT"
$tableHeaderColour = "#00a1f1"
$outputPath = "C:\temp\SecureScoreReports"

# ============================================================
# HELPER FUNCTIONS
# ============================================================

function New-HtmlTable { param([array]$Array, [string]$HeaderColour)
    $table = $Array | ConvertTo-Html -Fragment
    if ($table[2] -match "<tr>") { $table[2] = $table[2] -replace "<tr>", "<tr style=`"background-color:$HeaderColour;color:white`">" }
    return $table
}

function Format-ChangeBars($items, $color) {
    $maxChange = ($items | ForEach-Object { [math]::Abs($_.Change) } | Measure-Object -Maximum).Maximum
    if (-not $maxChange -or $maxChange -eq 0) { $maxChange = 1 }
    $html = ""
    foreach ($item in ($items | Select-Object -First 8)) {
        $barWidth = [math]::Round(([math]::Abs($item.Change) / $maxChange) * 100, 0)
        $sign = if ($item.Change -gt 0) { "+" } else { "" }
        $anchorId = $item.Control -replace '[^a-zA-Z0-9]', '_'
        $html += "<div style='display:flex;align-items:center;gap:12px;margin-bottom:10px'>"
        $html += "<div style='flex:2;font-size:0.85em;min-width:200px'><a href='#ctrl_$anchorId' style='color:#334155;text-decoration:none;font-weight:500'>$($item.DisplayName)</a><br><span style='color:#94a3b8;font-size:0.85em'>$($item.Category)</span></div>"
        $html += "<div style='flex:1'><div style='height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden'><div style='height:100%;width:${barWidth}%;background:$color;border-radius:3px'></div></div></div>"
        $html += "<div style='font-weight:700;color:$color;font-size:0.9em;min-width:50px;text-align:right'>$sign$($item.Change)</div></div>"
    }
    return $html
}

$css = @"
<style>
:root{--primary:#0f172a;--accent:$tableHeaderColour;--bg:#f8fafc;--card-bg:#fff;--text:#334155;--text-light:#64748b;--border:#e2e8f0;--radius:12px}*{box-sizing:border-box;margin:0;padding:0}body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;padding:0}.container{max-width:960px;margin:0 auto;padding:40px 32px}.header{background:linear-gradient(135deg,var(--primary) 0%,#1e293b 100%);color:#fff;padding:48px 40px;border-radius:0 0 var(--radius) var(--radius);margin-bottom:32px}.header .logo{height:44px;margin-bottom:24px;filter:brightness(0) invert(1)}.header h1{font-size:2em;font-weight:700;margin-bottom:8px}.header .byline{opacity:.7;font-size:.9em}.score-hero{background:var(--card-bg);border-radius:var(--radius);padding:32px;margin-bottom:24px;box-shadow:0 1px 3px rgba(0,0,0,.08);border:1px solid var(--border)}.score-hero h2{font-size:1.6em;color:var(--primary);margin-bottom:16px;font-weight:700;border:none;display:block}.gauge-container{text-align:center;margin:8px 0 24px}.gauge-svg{width:220px;height:130px}.gauge-bg{fill:none;stroke:var(--border);stroke-width:14;stroke-linecap:round}.gauge-fill{fill:none;stroke-width:14;stroke-linecap:round}.gauge-percent{font-size:2.2em;font-weight:800;fill:var(--primary)}.gauge-points{font-size:.75em;fill:var(--text-light)}.comparison-bars{display:flex;gap:20px;margin-top:20px;flex-wrap:wrap}.comparison-item{flex:1;min-width:200px}.comparison-label{display:flex;justify-content:space-between;font-size:.85em;margin-bottom:6px}.comparison-label .label-name{color:var(--text-light);font-weight:500}.comparison-label .label-value{font-weight:700;color:var(--primary)}.bar-track{height:8px;background:var(--border);border-radius:4px;overflow:hidden}.bar-fill{height:100%;border-radius:4px}.card{background:var(--card-bg);border-radius:var(--radius);padding:28px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.08);border:1px solid var(--border)}.card h2{border:none;display:block;margin:0 0 12px}.section-title{color:var(--primary);font-size:1.3em;margin:32px 0 16px;font-weight:700;padding-bottom:8px;border-bottom:2px solid var(--accent);display:inline-block}h3{color:var(--primary);font-size:1.1em;margin-top:0;font-weight:600}h4{color:var(--accent);font-weight:600;margin:4px 0 12px;font-size:.95em}table{border-collapse:collapse;width:100%;margin:16px 0}th{padding:12px 16px;text-align:left;font-weight:600;font-size:.85em;text-transform:uppercase}td{padding:10px 16px;text-align:left;font-size:.92em}tr{border-bottom:1px solid var(--border)}tr:nth-child(even){background-color:var(--bg)}.button{display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:6px;font-size:.85em;font-weight:600}.control-item{background:var(--card-bg);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:16px}.footer{margin-top:48px;padding:32px;background:var(--primary);color:#fff;border-radius:var(--radius);text-align:center}.footer .logo{height:36px;margin-bottom:12px;filter:brightness(0) invert(1)}.footer div{opacity:.7;font-size:.85em}strong{color:var(--primary)}
</style>
"@

# ============================================================
# MAIN SCRIPT
# ============================================================

if (-not (Test-Path $outputPath)) { New-Item -Path $outputPath -ItemType Directory -Force | Out-Null; Write-Host "Created output directory: $outputPath" -ForegroundColor Green }
$csvPath = Join-Path $outputPath "AllTenantOverview.csv"
if (Test-Path $csvPath) { Remove-Item $csvPath -Force }

# Connect to partner tenant (interactive auth)
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
Write-Host "A browser window will open for authentication. Sign in with your partner tenant admin account.`n" -ForegroundColor Yellow
Connect-MgGraph -Scopes "SecurityEvents.Read.All", "Directory.Read.All" -NoWelcome

$context = Get-MgContext
$partnerTenantId = $context.TenantId
Write-Host "Connected to partner tenant: $partnerTenantId" -ForegroundColor Green

$orgDetails = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/organization"
$partnerTenantName = $orgDetails.value[0].displayName
$partnerDomain = ($orgDetails.value[0].verifiedDomains | Where-Object { $_.isInitial }).name
Write-Host "Partner tenant: $partnerTenantName ($partnerDomain)" -ForegroundColor Green

# Build tenant list
[array]$tenants = @{ displayName = $partnerTenantName; customerId = $partnerTenantId; defaultDomainName = $partnerDomain }

Write-Host "`nRetrieving customer tenant list..." -ForegroundColor Cyan
try {
    $contractsResult = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/contracts?`$top=999"
    if ($contractsResult.value) {
        foreach ($contract in $contractsResult.value) {
            $tenants += @{ displayName = $contract.displayName; customerId = $contract.customerId; defaultDomainName = $contract.defaultDomainName }
        }
        Write-Host "Found $($contractsResult.value.Count) customer tenants" -ForegroundColor Green
    }
} catch { Write-Host "Could not retrieve customer tenants. Generating report for partner tenant only." -ForegroundColor Yellow }

Write-Host "`nProcessing $($tenants.Count) tenant(s)...`n" -ForegroundColor Cyan

$successCount = 0; $failCount = 0; $noDataCount = 0
$startTime = Get-Date

foreach ($tenant in $tenants) {
    $tenantName = $tenant.displayName; $tenantId = $tenant.customerId; $tenantDomain = $tenant.defaultDomainName
    $idx = $successCount + $failCount + $noDataCount + 1
    Write-Host "[$idx/$($tenants.Count)] $tenantName " -NoNewline

    $retryCount = 0; $maxRetries = 3; $success = $false
    while (-not $success -and $retryCount -lt $maxRetries) {
        try {
            # Connect to customer tenant
            try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
            Connect-MgGraph -TenantId $tenantId -Scopes "SecurityEvents.Read.All" -NoWelcome -ErrorAction Stop

            # Get all historical scores (up to 100 days)
            $allScoresResult = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/security/secureScores?`$top=100"
            $allScores = $allScoresResult.value
            if (-not $allScores -or $allScores.Count -eq 0) {
                Write-Host "- No data" -ForegroundColor DarkYellow; $noDataCount++; $success = $true; continue
            }

            $allScores = $allScores | Sort-Object createdDateTime
            $latestScore = $allScores[-1]

            # Get control profiles (beta has Defender for Endpoint controls with proper titles)
            $profilesResult = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/security/secureScoreControlProfiles?`$top=999"
            $profiles = $profilesResult.value

            # Process controls
            $htmlCollection = @()
            foreach ($control in $latestScore.controlScores) {
                $cp = $profiles | Where-Object { $_.id -eq $control.controlName }
                if (-not $cp) { continue }
                [int]$cs = $control.score; [int]$ms = $cp.maxScore
                $elements = @("<strong>Assessment</strong><br>$($control.description)<br>", "<strong>Remediation</strong><br>$($cp.remediation)<br>")
                if ($cp.remediationImpact) { $elements += "<strong>Remediation Impact</strong><br>$($cp.remediationImpact)<br>" }
                if ($cp.actionUrl) { $elements += "<a class=`"button`" href=`"$($cp.actionUrl)`" target=`"_blank`">Launch</a>" }
                $anchorId = $control.controlName -replace '[^a-zA-Z0-9]', '_'
                $report = "<h3 id='ctrl_$anchorId'>$($cp.title)</h3><h4>Score: $cs / $ms</h4><div style='margin-top:8px'>$($elements -join "</div><div style='margin-top:12px'>")</div><div style='margin-top:12px;color:#64748b'><strong>User Impact:</strong> $($cp.userImpact) | <strong>Tier:</strong> $($cp.tier)</div>"
                $htmlCollection += [pscustomobject]@{ category = $cp.controlCategory; controlReport = $report; controlName = $control.controlName; rank = if($cp.rank){$cp.rank}else{999}; deprecated = $cp.deprecated; score = $control.score }
            }
            $htmlCollection = $htmlCollection | Where-Object { -not $_.deprecated } | Sort-Object rank

            $identityControls = $htmlCollection | Where-Object { $_.category -eq "Identity" }
            $deviceControls = $htmlCollection | Where-Object { $_.category -eq "Device" }
            $appsControls = $htmlCollection | Where-Object { $_.category -eq "Apps" }
            $dataControls = $htmlCollection | Where-Object { $_.category -eq "Data" }
            $infraControls = $htmlCollection | Where-Object { $_.category -eq "Infrastructure" }

            $allTenantScores = $latestScore.averageComparativeScores | Where-Object { $_.basis -eq "AllTenants" }
            $similarScores = $latestScore.averageComparativeScores | Where-Object { $_.basis -eq "TotalSeats" }
            [int]$maxScore = $latestScore.maxScore; [int]$currentScore = $latestScore.currentScore
            $scorePercent = [math]::Round(($currentScore / $maxScore) * 100, 1)
            $globalPercent = [math]::Round($allTenantScores.averageScore, 1)
            $similarPercent = [math]::Round($similarScores.averageScore, 1)
            $reportDate = (Get-Date).ToLongDateString()

            # Gauge
            $gaugeColor = if ($scorePercent -ge 80) { "#10b981" } elseif ($scorePercent -ge 60) { "#f59e0b" } else { "#ef4444" }
            $arcLength = 251.3; $filledArc = [math]::Round($arcLength * ($scorePercent / 100), 1)

            # Category percentage bars
            $catDefs = @(
                @{ Name = "Identity"; Controls = $identityControls; Color = "#6366f1" }
                @{ Name = "Device"; Controls = $deviceControls; Color = "#0ea5e9" }
                @{ Name = "Apps"; Controls = $appsControls; Color = "#10b981" }
                @{ Name = "Data"; Controls = $dataControls; Color = "#f59e0b" }
            )
            $categoryBarsHtml = ""
            foreach ($catDef in $catDefs) {
                $catName = $catDef.Name; $catLower = $catName.ToLower()
                $tScore = ($catDef.Controls | Measure-Object -Property score -Sum).Sum
                $tMax = 0; foreach ($c in ($latestScore.controlScores | Where-Object { $_.controlCategory -eq $catName })) { $p = $profiles | Where-Object { $_.id -eq $c.controlName }; if ($p -and -not $p.deprecated) { $tMax += $p.maxScore } }
                if (-not $tMax -or $tMax -eq 0) { continue }
                $tPct = [math]::Round(($tScore / $tMax) * 100, 0)
                $gScore = $allTenantScores."${catLower}Score"; $gMax = $allTenantScores."${catLower}ScoreMax"
                $sScore = $similarScores."${catLower}Score"; $sMax = $similarScores."${catLower}ScoreMax"
                $gPct = if ($gMax -gt 0) { [math]::Round(($gScore / $gMax) * 100, 0) } else { 0 }
                $sPct = if ($sMax -gt 0) { [math]::Round(($sScore / $sMax) * 100, 0) } else { 0 }
                $categoryBarsHtml += "<div style='margin-bottom:20px'><div style='display:flex;justify-content:space-between;margin-bottom:4px'><span style='font-weight:600;color:var(--primary)'>$catName</span><span style='font-weight:700;color:$($catDef.Color)'>$tPct%</span></div><div style='position:relative;height:10px;background:var(--border);border-radius:5px;overflow:visible;margin-bottom:4px'><div style='height:100%;width:$tPct%;background:$($catDef.Color);border-radius:5px'></div><div style='position:absolute;top:-2px;left:$gPct%;width:2px;height:14px;background:#64748b;border-radius:1px' title='Global avg: $gPct%'></div><div style='position:absolute;top:-2px;left:$sPct%;width:2px;height:14px;background:#8b5cf6;border-radius:1px' title='Similar avg: $sPct%'></div></div><div style='display:flex;gap:16px;font-size:0.75em;color:var(--text-light)'><span>You: $([math]::Round($tScore,0))/$([math]::Round($tMax,0)) pts</span><span style='color:#64748b'>&#9644; Global: $gPct%</span><span style='color:#8b5cf6'>&#9644; Similar: $sPct%</span></div></div>"
            }

            # Timeline chart
            $chartW = 860; $chartH = 200; $padL = 50; $padR = 20; $padT = 20; $padB = 40
            $plotW = $chartW - $padL - $padR; $plotH = $chartH - $padT - $padB
            $percentages = $allScores | ForEach-Object { [math]::Round(($_.currentScore / $_.maxScore) * 100, 1) }
            $yMin = [math]::Floor(($percentages | Measure-Object -Minimum).Minimum - 2)
            $yMax = [math]::Ceiling(($percentages | Measure-Object -Maximum).Maximum + 2)
            $yRange = if (($yMax - $yMin) -gt 0) { $yMax - $yMin } else { 1 }
            $points = @(); for ($i = 0; $i -lt $allScores.Count; $i++) {
                $x = $padL + ($i / [math]::Max(1, $allScores.Count - 1)) * $plotW
                $pct = [math]::Round(($allScores[$i].currentScore / $allScores[$i].maxScore) * 100, 1)
                $y = $padT + $plotH - (($pct - $yMin) / $yRange) * $plotH
                $points += "$([math]::Round($x,1)),$([math]::Round($y,1))"
            }
            $polylinePoints = $points -join " "
            $areaPoints = "$padL,$($padT + $plotH) $polylinePoints $($padL + $plotW),$($padT + $plotH)"
            $yLabels = ""; for ($v = $yMin; $v -le $yMax; $v += [math]::Max(1, [math]::Ceiling($yRange / 4))) {
                $yPos = $padT + $plotH - (($v - $yMin) / $yRange) * $plotH
                $yLabels += "<text x='$($padL-8)' y='$([math]::Round($yPos+4,1))' text-anchor='end' font-size='11' fill='#94a3b8'>$v%</text><line x1='$padL' y1='$([math]::Round($yPos,1))' x2='$($padL+$plotW)' y2='$([math]::Round($yPos,1))' stroke='#e2e8f0' stroke-dasharray='4,4'/>"
            }
            $xLabels = ""; $dateStep = [math]::Max(1, [math]::Floor($allScores.Count / 5))
            for ($i = 0; $i -lt $allScores.Count; $i += $dateStep) {
                $x = $padL + ($i / [math]::Max(1, $allScores.Count - 1)) * $plotW
                $xLabels += "<text x='$([math]::Round($x,1))' y='$($padT+$plotH+20)' text-anchor='middle' font-size='11' fill='#94a3b8'>$(([datetime]$allScores[$i].createdDateTime).ToString('dd MMM'))</text>"
            }
            $timelineChart = "<svg width='100%' viewBox='0 0 $chartW $chartH' style='max-width:${chartW}px'>$yLabels$xLabels<polygon points='$areaPoints' fill='url(#areaGrad)' opacity='0.15'/><polyline points='$polylinePoints' fill='none' stroke='$gaugeColor' stroke-width='2.5' stroke-linejoin='round'/><defs><linearGradient id='areaGrad' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='$gaugeColor'/><stop offset='100%' stop-color='$gaugeColor' stop-opacity='0'/></linearGradient></defs></svg>"
            $dateRangeStr = "$(([datetime]$allScores[0].createdDateTime).ToString('dd MMM yyyy')) to $(([datetime]$allScores[-1].createdDateTime).ToString('dd MMM yyyy'))"

            # Control changes
            $oldestScore = $allScores[0]; $newestScore = $allScores[-1]; $controlChanges = @()
            foreach ($ctrl in $newestScore.controlScores) {
                $old = $oldestScore.controlScores | Where-Object { $_.controlName -eq $ctrl.controlName }
                if ($old -and [math]::Abs($ctrl.score - $old.score) -gt 0.1) {
                    $prof = $profiles | Where-Object { $_.id -eq $ctrl.controlName }
                    $dn = if ($prof -and $prof.title) { $prof.title } else { $ctrl.controlName }
                    $controlChanges += [pscustomobject]@{ Control = $ctrl.controlName; DisplayName = $dn; Category = $ctrl.controlCategory; Change = [math]::Round($ctrl.score - $old.score, 1) }
                }
            }
            $improved = $controlChanges | Where-Object { $_.Change -gt 0 } | Sort-Object Change -Descending
            $regressed = $controlChanges | Where-Object { $_.Change -lt 0 } | Sort-Object Change
            $improvedHtml = Format-ChangeBars $improved "#10b981"
            $regressedHtml = Format-ChangeBars $regressed "#ef4444"

            # Build HTML
            function Format-Section($title, $controls) { if (-not $controls) { return "" }; $cards = ($controls | ForEach-Object { "<div class='control-item'>$($_.controlReport)</div>" }) -join ""; return "<div class='section-title'>$title ($($controls.Count))</div>$cards" }

            $headerHtml = "<div class='header'><img class='logo' src='$yourLogo' alt='$companyName'><h1>$tenantName</h1><div class='byline'>Secure Score report compiled by $companyName on $reportDate</div></div>"
            $gaugeHtml = "<div class='score-hero'><h2>Microsoft Secure Score</h2><div class='gauge-container'><svg class='gauge-svg' viewBox='0 0 200 120'><path class='gauge-bg' d='M 20 100 A 80 80 0 0 1 180 100'/><path class='gauge-fill' d='M 20 100 A 80 80 0 0 1 180 100' stroke='$gaugeColor' stroke-dasharray='$filledArc $arcLength'/><text class='gauge-percent' x='100' y='92' text-anchor='middle'>$scorePercent%</text><text class='gauge-points' x='100' y='112' text-anchor='middle'>$currentScore / $maxScore points</text></svg></div><div class='comparison-bars'><div class='comparison-item'><div class='comparison-label'><span class='label-name'>Your Score</span><span class='label-value'>$scorePercent%</span></div><div class='bar-track'><div class='bar-fill' style='width:$scorePercent%;background:$gaugeColor'></div></div></div><div class='comparison-item'><div class='comparison-label'><span class='label-name'>Similar Companies</span><span class='label-value'>$similarPercent%</span></div><div class='bar-track'><div class='bar-fill' style='width:$similarPercent%;background:#8b5cf6'></div></div></div><div class='comparison-item'><div class='comparison-label'><span class='label-name'>Global Average</span><span class='label-value'>$globalPercent%</span></div><div class='bar-track'><div class='bar-fill' style='width:$globalPercent%;background:#64748b'></div></div></div></div></div>"
            $categoryCard = "<div class='card'><h2>Score by Category</h2><div style='margin-top:16px'>$categoryBarsHtml</div></div>"
            $timelineCard = "<div class='card'><h2>Score History</h2><div style='color:#64748b;font-size:0.85em;margin-bottom:12px'>$dateRangeStr ($($allScores.Count) days)</div>$timelineChart</div>"
            $changesHtml = "<div style='display:flex;gap:20px;flex-wrap:wrap'><div class='card' style='flex:1;min-width:300px'><h2 style='color:#10b981'>Improved Controls</h2><div style='margin-top:16px'>$improvedHtml</div></div><div class='card' style='flex:1;min-width:300px'><h2 style='color:#ef4444'>Regressed Controls</h2><div style='margin-top:16px'>$regressedHtml</div></div></div>"
            $footerHtml = "<div class='footer'><img class='logo' src='$yourLogo' alt='$companyName'><div>Report compiled by $companyName on $reportDate</div></div>"

            $sections = @($headerHtml, $gaugeHtml, $categoryCard, $timelineCard, $changesHtml)
            $sections += Format-Section "Identity Controls" $identityControls
            $sections += Format-Section "Device Controls" $deviceControls
            $sections += Format-Section "Apps Controls" $appsControls
            $sections += Format-Section "Data Controls" $dataControls
            $sections += Format-Section "Infrastructure Controls" $infraControls
            $sections += $footerHtml

            $htmlPath = Join-Path $outputPath "$tenantDomain.html"
            "<!DOCTYPE html><html><head><meta charset='utf-8'><title>Secure Score - $tenantName</title>$css</head><body><div class='container'>$($sections -join '')</div></body></html>" | Out-File $htmlPath -Encoding utf8

            [pscustomobject][ordered]@{
                CustomerName = $tenantName; TenantId = $tenantId; DefaultDomain = $tenantDomain
                SecureScore = $currentScore; MaxScore = $maxScore; ScorePercentage = "$scorePercent%"
                SimilarAvg = "$similarPercent%"; GlobalAvg = "$globalPercent%"
                IdentityScore = [int]($identityControls | Measure-Object -Property score -Sum).Sum
                DeviceScore = [int]($deviceControls | Measure-Object -Property score -Sum).Sum
                AppsScore = [int]($appsControls | Measure-Object -Property score -Sum).Sum
                DataScore = [int]($dataControls | Measure-Object -Property score -Sum).Sum
                HistoryDays = $allScores.Count; ControlsImproved = $improved.Count; ControlsRegressed = $regressed.Count
            } | Export-Csv $csvPath -NoTypeInformation -Append -Force

            Write-Host "- $scorePercent% ($($allScores.Count) days history)" -ForegroundColor Green
            $successCount++; $success = $true
        }
        catch {
            $retryCount++
            if ($retryCount -ge $maxRetries) { Write-Host "- Failed after $maxRetries attempts: $($_.Exception.Message)" -ForegroundColor Red; $failCount++; $success = $true }
            else { Write-Host "." -NoNewline -ForegroundColor DarkYellow; Start-Sleep -Seconds 3 }
        }
    }
}

# Reconnect to partner tenant
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
Connect-MgGraph -TenantId $partnerTenantId -NoWelcome -ErrorAction SilentlyContinue

$elapsed = (Get-Date) - $startTime
Write-Host "`n============================================" -ForegroundColor Cyan
Write-Host "  Secure Score Export Complete ($([math]::Round($elapsed.TotalMinutes, 1)) minutes)" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host "  Successful: $successCount tenant(s)" -ForegroundColor Green
Write-Host "  No data:    $noDataCount tenant(s)" -ForegroundColor DarkYellow
if ($failCount -gt 0) { Write-Host "  Failed:     $failCount tenant(s)" -ForegroundColor Red }
Write-Host "  Reports:    $outputPath" -ForegroundColor White
Write-Host "  CSV:        $csvPath" -ForegroundColor White
Write-Host "============================================`n" -ForegroundColor Cyan

Need help with Microsoft Secure Score or security reporting across your customer tenants? Get in touch with our team.

Was this article helpful?
Ready to secure and simplify your IT? Talk to a GCIT expert today.