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.

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


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
- Copy the script below into a
.ps1file (for example,Export-SecureScoreReports.ps1). - Modify the configuration variables at the top of the script (
$yourLogo,$companyName,$tableHeaderColour,$outputPath) to match your organisation. - Run the script in PowerShell.
- Sign in with your partner tenant admin credentials when the browser authentication window opens.
- Wait for the script to process each customer tenant. Progress is displayed in the console.
- Find the exported reports at
C: emp\SecureScoreReports\(or your configured output path). Each tenant gets an individual HTML report, and a combinedAllTenantOverview.csvis 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'>▬ Global: $gPct%</span><span style='color:#8b5cf6'>▬ 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.