Public/Export-M365SecurityScorecard.ps1
|
function Export-M365SecurityScorecard { <# .SYNOPSIS Generates a Security Scorecard (grade A-F) as HTML - the executive one-pager. .DESCRIPTION Bundles several signals (MFA coverage, unprotected admins, Conditional Access baseline, legacy-auth block, stale accounts, expired app secrets) into an overall grade and shows each check with status and recommendation. Read-only. .PARAMETER Path Target path of the HTML file. Default: .\Security-Scorecard.html .PARAMETER BrandName Branding. For CloudNest365: -BrandName 'CloudNest365' .PARAMETER PassThru Return the check results as objects. .PARAMETER NoOpen Do not open the report automatically. .EXAMPLE Export-M365SecurityScorecard -BrandName 'CloudNest365' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$Path = (Join-Path (Get-Location) 'Security-Scorecard.html'), [string]$BrandName = 'TenantToolbox', [string]$BrandTagline = 'M365 Tenant Administration', [switch]$Csv, [switch]$Excel, [string]$DataPath, [switch]$NoHtml, [switch]$PassThru, [switch]$NoOpen ) Assert-TTGraph Write-TTLog -Level INFO -Message "Collecting signals for the Security Scorecard ..." $checks = @() function New-Check { param($Name, $Status, $Value, $Rec) $score = switch ($Status) { 'pass' { 1 } 'warn' { 0.5 } default { 0 } } [pscustomobject]@{ Name = $Name; Status = $Status; Value = $Value; Rec = $Rec; Score = $score } } # 1) MFA coverage try { $mfa = @(Get-M365MfaStatus) $mfaTotal = $mfa.Count $mfaReg = @($mfa | Where-Object MfaRegistered).Count $pct = if ($mfaTotal) { [math]::Round(100 * $mfaReg / $mfaTotal) } else { 0 } $st = if ($pct -ge 90) { 'pass' } elseif ($pct -ge 70) { 'warn' } else { 'fail' } $checks += New-Check 'MFA coverage' $st "$pct%" "$mfaReg of $mfaTotal users have MFA registered. Target: >= 90%." # 2) Admins protected $adminBad = @($mfa | Where-Object { $_.IsAdmin -and -not $_.MfaRegistered }).Count $st = if ($adminBad -eq 0) { 'pass' } else { 'fail' } $checks += New-Check 'Admins with MFA' $st "$adminBad without" $(if ($adminBad) { "$adminBad privileged accounts without MFA - secure immediately." } else { 'All admin accounts have MFA. Great.' }) } catch { Write-TTLog -Level WARN -Message "MFA signal skipped: $_" } # 3+4) Conditional Access try { $pol = @(Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop) $enabled = @($pol | Where-Object { $_.State -eq 'enabled' }) $mfaBaseline = @($enabled | Where-Object { $_.GrantControls.BuiltInControls -contains 'mfa' }).Count -gt 0 $st = if ($mfaBaseline) { 'pass' } else { 'fail' } $checks += New-Check 'CA: MFA baseline' $st $(if ($mfaBaseline) { 'present' } else { 'missing' }) $(if ($mfaBaseline) { 'At least one enabled policy enforces MFA.' } else { 'No enabled CA policy enforces MFA.' }) $legacy = @($enabled | Where-Object { ($_.Conditions.ClientAppTypes | Where-Object { $_ -in 'exchangeActiveSync', 'other' }) -and ($_.GrantControls.BuiltInControls -contains 'block') }).Count -gt 0 $st = if ($legacy) { 'pass' } else { 'fail' } $checks += New-Check 'CA: Legacy auth blocked' $st $(if ($legacy) { 'yes' } else { 'no' }) $(if ($legacy) { 'Legacy authentication is blocked.' } else { 'Legacy auth is not blocked - major attack surface.' }) } catch { Write-TTLog -Level WARN -Message "CA signal skipped: $_" } # 5) Stale accounts try { $stale = @(Get-M365StaleUsers -InactiveDays 90) $sc = $stale.Count $st = if ($sc -eq 0) { 'pass' } elseif ($sc -le 5) { 'warn' } else { 'fail' } $checks += New-Check 'Stale accounts (90 days)' $st "$sc" $(if ($sc) { "$sc enabled accounts without sign-in for 90 days - review/disable." } else { 'No stale enabled accounts.' }) } catch { Write-TTLog -Level WARN -Message "Stale signal skipped: $_" } # 6) App secret hygiene try { $now = Get-Date $apps = Get-MgApplication -All -Property 'displayName,passwordCredentials,keyCredentials' -ErrorAction Stop $expired = 0 foreach ($a in $apps) { foreach ($c in @($a.PasswordCredentials) + @($a.KeyCredentials)) { if ($c.EndDateTime -and ([datetime]$c.EndDateTime -lt $now)) { $expired++ } } } $st = if ($expired -eq 0) { 'pass' } else { 'fail' } $checks += New-Check 'App secret hygiene' $st "$expired expired" $(if ($expired) { "$expired expired secrets/certificates - rotate or remove." } else { 'No expired app secrets.' }) } catch { Write-TTLog -Level WARN -Message "App secret signal skipped: $_" } # 7) Permanent Global Admins (PIM) try { $base = 'https://graph.microsoft.com/v1.0/roleManagement/directory' $active = Get-TTGraphCollection "$base/roleAssignmentScheduleInstances?`$expand=roleDefinition&`$top=100" $permGa = @($active | Where-Object { $_.assignmentType -eq 'Assigned' -and $_.roleDefinition.displayName -eq 'Global Administrator' }).Count $st = if ($permGa -le 2) { 'pass' } elseif ($permGa -le 5) { 'warn' } else { 'fail' } $checks += New-Check 'Permanent Global Admins' $st "$permGa" $(if ($permGa -le 2) { 'Few standing Global Admins (break-glass). Good.' } else { "$permGa permanent Global Admins - prefer eligible (PIM/JIT) assignments." }) } catch { Write-TTLog -Level WARN -Message "PIM signal skipped: $_" } # --- Overall grade ------------------------------------------------------- $overall = if ($checks.Count) { [math]::Round(100 * (($checks | Measure-Object Score -Sum).Sum) / $checks.Count) } else { 0 } $grade = if ($overall -ge 90) { 'A' } elseif ($overall -ge 80) { 'B' } elseif ($overall -ge 70) { 'C' } elseif ($overall -ge 60) { 'D' } else { 'F' } $gradeColor = if ($overall -ge 80) { 'var(--on)' } elseif ($overall -ge 60) { 'var(--warn)' } else { 'var(--bad)' } $genAt = Get-Date -Format 'yyyy-MM-dd HH:mm' $tenant = (Get-MgContext).TenantId $statusBadge = @{ pass = "<span class='b b-ok'>pass</span>"; warn = "<span class='b b-warn'>warning</span>"; fail = "<span class='b b-bad'>critical</span>" } $checkCards = foreach ($c in ($checks | Sort-Object Score, Name)) { @" <div class="check $($c.Status)"> <div class="ct"><h4>$(TTEnc $c.Name)</h4>$($statusBadge[$c.Status])</div> <div class="val">$(TTEnc $c.Value)</div> <div class="rec">$(TTEnc $c.Rec)</div> </div> "@ } $body = @" <div class="score-hero"> <div class="ring" style="--p:$overall;--c:$gradeColor"> <div class="inner"><div class="grade" style="color:$gradeColor">$grade</div><div class="pct">$overall / 100</div></div> </div> <div class="score-meta"> <h2>Security score: $overall %</h2> <p>$($checks.Count) checks · Tenant $tenant · $genAt</p> </div> </div> <div class="checks"> $($checkCards -join "`n") </div> "@ $sub = "Tenant $tenant · generated $genAt" $html = New-TTHtmlPage -Title 'Security Scorecard' -Heading 'Security Scorecard' -Sub $sub ` -BrandName $BrandName -BrandTagline $BrandTagline -BodyHtml $body Write-TTLog -Level INFO -Message "Security Scorecard created: $Path (grade $grade, $overall %)." Write-Host "Overall grade: $grade ($overall %)" -ForegroundColor Green $flat = @([pscustomobject]@{ Name = 'OVERALL'; Status = "Grade $grade"; Value = "$overall %"; Rec = "$($checks.Count) checks"; Score = '' }) + ($checks | Select-Object Name, Status, Value, Rec, Score) Complete-TTReport -Html $html -Path $Path -Data $flat -Csv:$Csv -Excel:$Excel -DataPath $DataPath -NoHtml:$NoHtml -NoOpen:$NoOpen -Kind 'Security-Scorecard' if ($PassThru) { $checks } } |