CloudSecurity/Test-M365SecurityPosture.ps1

function Add-M365Finding {
    <#
    .SYNOPSIS
        Records a single M365 posture finding and updates summary counters.
        'NotEvaluated' findings are tracked separately and never enter the score.
    #>

    param(
        [Parameter(Mandatory)]
        $Results,

        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter(Mandatory)]
        [string]$Category,

        [Parameter(Mandatory)]
        [string]$Severity,

        [Parameter(Mandatory)]
        [ValidateSet('Pass', 'Fail', 'Warning', 'NotEvaluated')]
        [string]$Status,

        [string]$Actual = "",
        [string]$Expected = ""
    )

    $Results.SecurityFindings += [PSCustomObject]@{
        Name     = $Name
        Category = $Category
        Status   = $Status
        Severity = $Severity
        Actual   = $Actual
        Expected = $Expected
    }

    if ($Status -eq 'NotEvaluated') {
        $Results.Summary.NotEvaluated++
        return
    }

    $Results.Summary.TotalChecks++
    switch ($Status) {
        'Pass' { $Results.Summary.Passed++ }
        'Fail' {
            $Results.Summary.Failed++
            if ($Severity -eq 'Critical') {
                $Results.Summary.CriticalIssues++
            }
        }
        'Warning' { $Results.Summary.Warnings++ }
    }
}

function Test-M365SecurityPosture {
    <#
    .SYNOPSIS
        Assesses Microsoft 365 security configuration
     
    .DESCRIPTION
        Performs a security assessment of Microsoft 365 against live tenant data
        retrieved via Microsoft Graph (Conditional Access policies, security
        defaults, and org-wide MFA registration). MFA percentages use
        isMfaRegistered only; isMfaCapable is reported separately and neither
        field proves that MFA is enforced on every sign-in.
 
        Checks whose configuration is not exposed through a reliable Microsoft
        Graph query (Exchange Online, SharePoint, Teams workload settings, and
        effective guest/external access) are reported as NotEvaluated with the
        API required to assess them - results are never fabricated. The
        compliance score is computed over evaluated checks only; if no check
        could be evaluated, OverallScore is $null and OverallStatus is
        'NotEvaluated'.
     
    .PARAMETER CheckExchangeOnline
        Assess Exchange Online security
     
    .PARAMETER CheckSharePoint
        Assess SharePoint/OneDrive security
     
    .PARAMETER CheckTeams
        Assess Microsoft Teams security
     
    .PARAMETER CheckConditionalAccess
        Review Conditional Access, Security Defaults, MFA registration, and
        guest/external access posture
     
    .PARAMETER ExportPath
        Path to export report
     
    .PARAMETER Format
        Report format: HTML, JSON, CSV, or All (Default: HTML)
     
    .EXAMPLE
        Test-M365SecurityPosture -CheckConditionalAccess
    #>

    
    [CmdletBinding()]
    param(
        [switch]$CheckExchangeOnline,
        [switch]$CheckSharePoint,
        [switch]$CheckTeams,
        [switch]$CheckConditionalAccess,
        [string]$ExportPath,
        [ValidateSet('HTML', 'JSON', 'CSV', 'All')]
        [string]$Format = 'HTML'
    )
    
    begin {
        Write-Host "========================================" -ForegroundColor Cyan
        Write-Host " M365 SECURITY POSTURE ASSESSMENT" -ForegroundColor Cyan
        Write-Host "========================================" -ForegroundColor Cyan
        
        # Live tenant data requires a Microsoft Graph connection. Without one the
        # function must not invent results: every check degrades to NotEvaluated.
        $graphConnected = $false
        $tenantId = 'NotConnected'
        $graphConnectInstruction = "Connect-MgGraph -Scopes 'Directory.Read.All','Policy.Read.All','AuditLog.Read.All'"
        $graphContextCommand = Get-Command -Name 'Get-MgContext' -ErrorAction SilentlyContinue
        if ($null -ne $graphContextCommand) {
            try {
                $context = Get-MgContext -ErrorAction Stop
                if ($null -ne $context) {
                    $graphConnected = $true
                    $tenantIdProperty = Get-CloudDataProperty -InputObject $context -Name 'TenantId'
                    if ($tenantIdProperty.Exists -and $tenantIdProperty.Value) {
                        $tenantId = [string]$tenantIdProperty.Value
                    }
                    Write-Host "Connected to tenant: $tenantId" -ForegroundColor Green
                }
            }
            catch {
                Write-Verbose "Get-MgContext failed: $($_.Exception.Message)"
            }
        }

        if (-not $graphConnected) {
            Write-Warning "Not connected to Microsoft Graph. No tenant data will be queried and every check will be reported as NotEvaluated. Connect first: $graphConnectInstruction"
        }

        # If no specific checks selected, run all
        if (-not ($CheckExchangeOnline -or $CheckSharePoint -or $CheckTeams -or $CheckConditionalAccess)) {
            $CheckExchangeOnline = $true
            $CheckSharePoint = $true
            $CheckTeams = $true
            $CheckConditionalAccess = $true
        }

        # Initialize results. Null MFA fields mean unknown, never zero percent.
        $results = [PSCustomObject]@{
            AssessmentDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            TenantInfo = @{
                TenantId = $tenantId
                TenantName = "N/A"
            }
            SecurityFindings = @()
            ComplianceScore = $null
            OverallScore = $null
            OverallStatus = 'NotEvaluated'
            EvaluatedCount = 0
            NotEvaluatedCount = 0
            MfaRegistrationPercentage = $null
            MfaRegisteredUsers = $null
            MfaCapableUsers = $null
            MfaUsersReported = $null
            MfaStatus = 'NotEvaluated'
            MfaNote = 'Not evaluated.'
            Summary = @{
                TotalChecks = 0
                Passed = 0
                Failed = 0
                Warnings = 0
                CriticalIssues = 0
                NotEvaluated = 0
            }
        }
    }
    
    process {
        try {
            $notConnectedReason = "Not evaluated - not connected to Microsoft Graph (run $graphConnectInstruction)"
            
            # Get organization info (live query, non-fatal)
            if ($graphConnected) {
                try {
                    $org = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
                    if ($org) {
                        $results.TenantInfo.TenantName = $org.DisplayName
                    }
                }
                catch {
                    Write-Warning "Could not retrieve organization details: $($_.Exception.Message)"
                }
            }
            
            # Exchange Online checks. Exchange/Defender policy configuration is not
            # exposed through the Microsoft Graph cmdlets used by this module, so
            # these checks are honestly reported as NotEvaluated with the API that
            # would be required — never fabricated.
            if ($CheckExchangeOnline) {
                Write-Host "`nAssessing Exchange Online Security..." -ForegroundColor Cyan
                
                if ($graphConnected) {
                    Add-M365Finding -Results $results -Name "Anti-Malware Policy" -Category "Exchange Online" -Severity "Medium" -Status "NotEvaluated" -Actual "Not evaluated — requires Exchange Online PowerShell (Get-MalwareFilterPolicy); not exposed via Microsoft Graph" -Expected "Default anti-malware policy enabled"
                    Add-M365Finding -Results $results -Name "Safe Attachments" -Category "Exchange Online" -Severity "Critical" -Status "NotEvaluated" -Actual "Not evaluated — requires Exchange Online PowerShell (Get-SafeAttachmentPolicy / Get-SafeAttachmentRule); not exposed via Microsoft Graph" -Expected "Safe Attachments policy covering all users"
                }
                else {
                    Add-M365Finding -Results $results -Name "Anti-Malware Policy" -Category "Exchange Online" -Severity "Medium" -Status "NotEvaluated" -Actual $notConnectedReason
                    Add-M365Finding -Results $results -Name "Safe Attachments" -Category "Exchange Online" -Severity "Critical" -Status "NotEvaluated" -Actual $notConnectedReason
                }
            }
            
            # SharePoint checks
            if ($CheckSharePoint) {
                Write-Host "`nAssessing SharePoint/OneDrive Security..." -ForegroundColor Cyan
                
                if ($graphConnected) {
                    Add-M365Finding -Results $results -Name "External Sharing" -Category "SharePoint/OneDrive" -Severity "High" -Status "NotEvaluated" -Actual "Not evaluated — requires SharePoint Online Management Shell (Get-SPOTenant) or SharePoint admin API; not exposed via Microsoft Graph" -Expected "External sharing restricted to existing guests or disabled"
                    Add-M365Finding -Results $results -Name "DLP Policies" -Category "SharePoint/OneDrive" -Severity "Critical" -Status "NotEvaluated" -Actual "Not evaluated — requires Purview compliance PowerShell (Get-DlpCompliancePolicy); not exposed via Microsoft Graph" -Expected "At least one DLP policy covering SharePoint/OneDrive locations"
                }
                else {
                    Add-M365Finding -Results $results -Name "External Sharing" -Category "SharePoint/OneDrive" -Severity "High" -Status "NotEvaluated" -Actual $notConnectedReason
                    Add-M365Finding -Results $results -Name "DLP Policies" -Category "SharePoint/OneDrive" -Severity "Critical" -Status "NotEvaluated" -Actual $notConnectedReason
                }
            }
            
            # Teams checks
            if ($CheckTeams) {
                Write-Host "`nAssessing Microsoft Teams Security..." -ForegroundColor Cyan
                
                if ($graphConnected) {
                    Add-M365Finding -Results $results -Name "Guest Access" -Category "Microsoft Teams" -Severity "Medium" -Status "NotEvaluated" -Actual "Not evaluated — requires Microsoft Teams PowerShell (Get-CsTeamsClientConfiguration); not exposed via Microsoft Graph" -Expected "Guest access disabled or restricted per policy"
                }
                else {
                    Add-M365Finding -Results $results -Name "Guest Access" -Category "Microsoft Teams" -Severity "Medium" -Status "NotEvaluated" -Actual $notConnectedReason
                }
            }
            
            # Conditional Access and identity checks (live Microsoft Graph queries)
            if ($CheckConditionalAccess) {
                Write-Host "`nAssessing Conditional Access and identity security..." -ForegroundColor Cyan

                if ($graphConnected) {
                    try {
                        $caPolicies = @(Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop)

                        $enabledPolicies = @($caPolicies | Where-Object { $_.State -eq 'enabled' }).Count

                        if ($enabledPolicies -gt 0) {
                            Add-M365Finding -Results $results -Name "Conditional Access Policies" -Category "Conditional Access" -Severity "High" -Status "Pass" -Actual "$enabledPolicies of $(@($caPolicies).Count) Conditional Access policies enabled" -Expected "At least one enabled Conditional Access policy"
                        }
                        else {
                            Add-M365Finding -Results $results -Name "Conditional Access Policies" -Category "Conditional Access" -Severity "Critical" -Status "Fail" -Actual "No enabled Conditional Access policies found ($( @($caPolicies).Count) total, none enabled)" -Expected "At least one enabled Conditional Access policy"
                        }
                    }
                    catch {
                        Write-Warning "Could not retrieve Conditional Access policies: $($_.Exception.Message)"
                        Add-M365Finding -Results $results -Name "Conditional Access Policies" -Category "Conditional Access" -Severity "Critical" -Status "NotEvaluated" -Actual "Not evaluated — Conditional Access query failed: $($_.Exception.Message)"
                    }

                    try {
                        $secDefaults = Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy -ErrorAction Stop

                        if ($secDefaults -and $secDefaults.IsEnabled) {
                            Add-M365Finding -Results $results -Name "Security Defaults" -Category "Conditional Access" -Severity "High" -Status "Pass" -Actual "Security Defaults are enabled" -Expected "Enabled, unless Conditional Access policies are in use"
                        }
                        else {
                            Add-M365Finding -Results $results -Name "Security Defaults" -Category "Conditional Access" -Severity "High" -Status "Warning" -Actual "Security Defaults are disabled" -Expected "Enabled, unless Conditional Access policies are in use"
                        }
                    }
                    catch {
                        Write-Warning "Could not retrieve Security Defaults policy: $($_.Exception.Message)"
                        Add-M365Finding -Results $results -Name "Security Defaults" -Category "Conditional Access" -Severity "High" -Status "NotEvaluated" -Actual "Not evaluated — Security Defaults query failed: $($_.Exception.Message)"
                    }

                    Write-Host "`nAssessing org-wide MFA registration..." -ForegroundColor Cyan
                    try {
                        $graphRequestCommand = Get-Command -Name 'Invoke-MgGraphRequest' -ErrorAction SilentlyContinue
                        if ($null -eq $graphRequestCommand) {
                            throw 'Invoke-MgGraphRequest is unavailable. Install Microsoft.Graph.Authentication.'
                        }

                        $registrationDetails = @()
                        $registrationUri = '/beta/reports/authenticationMethods/userRegistrationDetails'
                        $seenRegistrationPages = @{}
                        while ($true) {
                            if ($seenRegistrationPages.ContainsKey($registrationUri)) {
                                throw 'The MFA registration report returned a repeated pagination link.'
                            }
                            $seenRegistrationPages[$registrationUri] = $true

                            $registrationPage = Invoke-MgGraphRequest -Method GET -Uri $registrationUri -ErrorAction Stop
                            if ($registrationPage -is [string]) {
                                if ([string]::IsNullOrWhiteSpace($registrationPage)) {
                                    throw 'The MFA registration report returned an empty response body.'
                                }
                                $registrationPage = $registrationPage | ConvertFrom-Json -ErrorAction Stop
                            }

                            $registrationValueProperty = Get-CloudDataProperty -InputObject $registrationPage -Name 'value'
                            if (-not $registrationValueProperty.Exists -or $null -eq $registrationValueProperty.Value) {
                                throw "The MFA registration report did not contain a 'value' array."
                            }
                            $registrationDetails += @($registrationValueProperty.Value)

                            $nextRegistrationProperty = Get-CloudDataProperty -InputObject $registrationPage -Name '@odata.nextLink'
                            $nextRegistrationUri = [string]$nextRegistrationProperty.Value
                            if ([string]::IsNullOrWhiteSpace($nextRegistrationUri)) {
                                break
                            }
                            $registrationUri = $nextRegistrationUri
                        }

                        $registrationDetails = @($registrationDetails | Where-Object { $null -ne $_ })
                        if ($registrationDetails.Count -eq 0) {
                            throw 'The MFA registration report returned no user records, so a percentage cannot be calculated.'
                        }

                        # UNCERTAIN field handling: isMfaRegistered measures registration,
                        # not enforcement. isMfaCapable additionally requires a method
                        # allowed by policy and is never a substitute for registration.
                        # Missing/non-Boolean fields are unknown, never implicit failures.
                        $mfaRegisteredUsers = 0
                        $mfaCapableUsers = 0
                        $missingRegistrationFields = 0
                        $missingCapableFields = 0
                        foreach ($registration in $registrationDetails) {
                            $registeredProperty = Get-CloudDataProperty -InputObject $registration -Name 'isMfaRegistered'
                            if (-not $registeredProperty.Exists -or $registeredProperty.Value -isnot [bool]) {
                                $missingRegistrationFields++
                            }
                            elseif ($registeredProperty.Value) {
                                $mfaRegisteredUsers++
                            }

                            $capableProperty = Get-CloudDataProperty -InputObject $registration -Name 'isMfaCapable'
                            if (-not $capableProperty.Exists -or $capableProperty.Value -isnot [bool]) {
                                $missingCapableFields++
                            }
                            elseif ($capableProperty.Value) {
                                $mfaCapableUsers++
                            }
                        }

                        if ($missingRegistrationFields -gt 0) {
                            throw "$missingRegistrationFields user record(s) lacked a Boolean isMfaRegistered field. The org-wide percentage was not calculated; isMfaCapable cannot be used as a substitute."
                        }

                        $reportedUsers = $registrationDetails.Count
                        $mfaPercentage = [Math]::Round(($mfaRegisteredUsers / $reportedUsers) * 100, 1)
                        $results.MfaRegistrationPercentage = $mfaPercentage
                        $results.MfaRegisteredUsers = $mfaRegisteredUsers
                        $results.MfaUsersReported = $reportedUsers
                        if ($missingCapableFields -eq 0) {
                            $results.MfaCapableUsers = $mfaCapableUsers
                            $capableDisplay = "$mfaCapableUsers user(s) are MFA-capable"
                        }
                        else {
                            $results.MfaCapableUsers = $null
                            $capableDisplay = "MFA-capable count is unknown because $missingCapableFields record(s) lacked a Boolean isMfaCapable field"
                        }
                        $results.MfaStatus = 'Evaluated'

                        $mfaActual = "$mfaRegisteredUsers of $reportedUsers returned user record(s) ($mfaPercentage%) are registered for MFA; $capableDisplay. Registration does not prove MFA is enforced on every sign-in; the report does not work for disabled users."
                        $results.MfaNote = $mfaActual
                        if ($mfaPercentage -eq 100) {
                            Add-M365Finding -Results $results -Name "Org-wide MFA Registration" -Category "Identity" -Severity "Critical" -Status "Pass" -Actual $mfaActual -Expected "100% of returned users registered for MFA; verify enforcement separately with Conditional Access or Security Defaults"
                        }
                        elseif ($mfaPercentage -ge 80) {
                            Add-M365Finding -Results $results -Name "Org-wide MFA Registration" -Category "Identity" -Severity "High" -Status "Warning" -Actual $mfaActual -Expected "100% of returned users registered for MFA; verify enforcement separately with Conditional Access or Security Defaults"
                        }
                        else {
                            Add-M365Finding -Results $results -Name "Org-wide MFA Registration" -Category "Identity" -Severity "Critical" -Status "Fail" -Actual $mfaActual -Expected "100% of returned users registered for MFA; verify enforcement separately with Conditional Access or Security Defaults"
                        }
                    }
                    catch {
                        $results.MfaRegistrationPercentage = $null
                        $results.MfaRegisteredUsers = $null
                        $results.MfaCapableUsers = $null
                        $results.MfaUsersReported = $null
                        $results.MfaStatus = 'NotEvaluated'
                        $results.MfaNote = "Not evaluated - GET /beta/reports/authenticationMethods/userRegistrationDetails failed: $($_.Exception.Message). Requires Microsoft Graph scope AuditLog.Read.All; run $graphConnectInstruction."
                        Write-Warning $results.MfaNote
                        Add-M365Finding -Results $results -Name "Org-wide MFA Registration" -Category "Identity" -Severity "Critical" -Status "NotEvaluated" -Actual $results.MfaNote -Expected "100% of returned users registered for MFA; verify enforcement separately"
                    }

                    Add-M365Finding -Results $results -Name "Guest/External Access" -Category "Identity" -Severity "High" -Status "NotEvaluated" -Actual "Not evaluated - /beta/invitations reports invitation redemption only, not effective guest access. Reliable evaluation requires correlation of /beta/users (userType), /beta/policies/externalIdentitiesPolicy, /beta/policies/crossTenantAccessPolicy/default, and workload-specific settings." -Expected "Guest and external access explicitly restricted by policy"
                }
                else {
                    Add-M365Finding -Results $results -Name "Conditional Access Policies" -Category "Conditional Access" -Severity "Critical" -Status "NotEvaluated" -Actual $notConnectedReason
                    Add-M365Finding -Results $results -Name "Security Defaults" -Category "Conditional Access" -Severity "High" -Status "NotEvaluated" -Actual $notConnectedReason
                    Add-M365Finding -Results $results -Name "Org-wide MFA Registration" -Category "Identity" -Severity "Critical" -Status "NotEvaluated" -Actual $notConnectedReason -Expected "100% of returned users registered for MFA; verify enforcement separately"
                    Add-M365Finding -Results $results -Name "Guest/External Access" -Category "Identity" -Severity "High" -Status "NotEvaluated" -Actual $notConnectedReason -Expected "Guest and external access explicitly restricted by policy"
                    $results.MfaNote = $notConnectedReason
                }
            }
            
            # Calculate compliance score over evaluated checks only
            $results.EvaluatedCount = $results.Summary.TotalChecks
            $results.NotEvaluatedCount = $results.Summary.NotEvaluated
            
            if ($results.Summary.TotalChecks -gt 0) {
                $results.ComplianceScore = [Math]::Round(($results.Summary.Passed / $results.Summary.TotalChecks) * 100, 1)
                $results.OverallScore = $results.ComplianceScore
                $results.OverallStatus = if ($results.Summary.Failed -gt 0) { 'AttentionRequired' }
                                         elseif ($results.Summary.Warnings -gt 0) { 'Warning' }
                                         else { 'Pass' }
                
                if ($results.Summary.NotEvaluated -gt 0) {
                    Write-Warning "$($results.Summary.NotEvaluated) check(s) could not be evaluated and were excluded from the score. The score covers only the $($results.Summary.TotalChecks) evaluated check(s)."
                }
            }
            else {
                $results.ComplianceScore = $null
                $results.OverallScore = $null
                $results.OverallStatus = 'NotEvaluated'
                
                if (-not $graphConnected) {
                    Write-Warning "No security checks could be evaluated because there is no Microsoft Graph connection. OverallScore is null — no compliance score was computed."
                }
                else {
                    Write-Warning "No security checks could be evaluated (all queries failed). OverallScore is null — no compliance score was computed."
                }
            }
            
            # Export if requested
            if ($ExportPath) {
                Write-Host "`nExporting reports..." -ForegroundColor Cyan
                
                if (-not (Test-Path $ExportPath)) {
                    New-Item -Path $ExportPath -ItemType Directory -Force | Out-Null
                }
                
                $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
                
                if ($Format -in @('JSON', 'All')) {
                    $jsonPath = Join-Path $ExportPath "M365Security_$timestamp.json"
                    $results | ConvertTo-Json -Depth 10 | Out-File $jsonPath -Encoding UTF8
                    Write-Host "Exported JSON: $jsonPath" -ForegroundColor Green
                }
                
                if ($Format -in @('CSV', 'All')) {
                    $csvPath = Join-Path $ExportPath "M365Security_$timestamp.csv"
                    $results.SecurityFindings | ConvertTo-CsvSafeObject | Export-Csv $csvPath -NoTypeInformation
                    Write-Host "Exported CSV: $csvPath" -ForegroundColor Green
                }
                
                if ($Format -in @('HTML', 'All')) {
                    $htmlPath = Join-Path $ExportPath "M365Security_$timestamp.html"
                    $htmlContent = Generate-M365SecurityHTML -Results $results
                    $htmlContent | Out-File $htmlPath -Encoding UTF8
                    Write-Host "Exported HTML: $htmlPath" -ForegroundColor Green
                }
            }
        }
        catch {
            Write-Error "Error during assessment: $_"
            throw
        }
    }
    
    end {
        Write-Host "`n========================================" -ForegroundColor Green
        Write-Host " M365 ASSESSMENT COMPLETE" -ForegroundColor Green
        Write-Host "========================================" -ForegroundColor Green
        
        if ($null -ne $results.ComplianceScore) {
            $scoreColor = if ($results.ComplianceScore -ge 80) { 'Green' }
                          elseif ($results.ComplianceScore -ge 60) { 'Yellow' }
                          else { 'Red' }
            
            Write-Host "`nCompliance Score: $($results.ComplianceScore)% (over $($results.EvaluatedCount) evaluated check(s))" -ForegroundColor $scoreColor
        }
        else {
            Write-Host "`nCompliance Score: N/A — no checks could be evaluated" -ForegroundColor Yellow
        }
        
        Write-Host "Overall Status: $($results.OverallStatus)" -ForegroundColor White
        Write-Host "Total Checks: $($results.Summary.TotalChecks)" -ForegroundColor White
        Write-Host "Passed: $($results.Summary.Passed)" -ForegroundColor Green
        Write-Host "Failed: $($results.Summary.Failed)" -ForegroundColor Red
        Write-Host "Critical Issues: $($results.Summary.CriticalIssues)" -ForegroundColor Red
        Write-Host "Not Evaluated: $($results.Summary.NotEvaluated)" -ForegroundColor Yellow
        
        return $results
    }
}

function Generate-M365SecurityHTML {
    param($Results)
    
    $scoreColor = if ($null -eq $Results.ComplianceScore) { '#605e5c' }
                  elseif ($Results.ComplianceScore -ge 80) { '#107c10' } 
                  elseif ($Results.ComplianceScore -ge 60) { '#f7630c' } 
                  else { '#d13438' }
    
    $scoreDisplay = if ($null -eq $Results.ComplianceScore) { 'N/A' } else { "$($Results.ComplianceScore)%" }
    
    $html = @'
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>M365 Security Posture Assessment</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
        .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        h1 { color: #0078d4; border-bottom: 3px solid #0078d4; padding-bottom: 10px; }
        h2 { color: #106ebe; margin-top: 30px; }
        table { width: 100%; border-collapse: collapse; margin: 20px 0; }
        th { background: #0078d4; color: white; padding: 12px; text-align: left; }
        td { padding: 10px; border-bottom: 1px solid #ddd; }
        tr:hover { background: #f8f9fa; }
        .pass { color: #107c10; font-weight: bold; }
        .fail { color: #d13438; font-weight: bold; }
        .warning { color: #f7630c; font-weight: bold; }
        .noteval { color: #605e5c; font-weight: bold; }
        .score-box { text-align: center; padding: 30px; margin: 20px 0; border-radius: 10px; color: white; }
        .score-value { font-size: 48px; font-weight: bold; }
    </style>
</head>
<body>
    <div class="container">
        <h1>M365 Security Posture Assessment</h1>
'@

    
    $html += @"
        <div class="score-box" style="background: $scoreColor;">
            <div class="score-value">$scoreDisplay</div>
            <div>Security Compliance Score (evaluated checks only)</div>
        </div>
         
        <h2>Assessment Summary</h2>
        <p><strong>Overall Status:</strong> $($Results.OverallStatus)</p>
        <p><strong>Total Checks (evaluated):</strong> $($Results.Summary.TotalChecks)</p>
        <p><strong>Passed:</strong> $($Results.Summary.Passed)</p>
        <p><strong>Failed:</strong> $($Results.Summary.Failed)</p>
        <p><strong>Critical Issues:</strong> $($Results.Summary.CriticalIssues)</p>
        <p><strong>Not Evaluated:</strong> $($Results.Summary.NotEvaluated)</p>
         
        <h2>Security Findings</h2>
        <table>
            <thead>
                <tr>
                    <th>Category</th>
                    <th>Check Name</th>
                    <th>Severity</th>
                    <th>Status</th>
                    <th>Details</th>
                </tr>
            </thead>
            <tbody>
"@

    
    foreach ($finding in $Results.SecurityFindings) {
        $statusClass = switch ($finding.Status) {
            'Pass' { 'pass' }
            'Fail' { 'fail' }
            'Warning' { 'warning' }
            default { 'noteval' }
        }
        
        $categoryEncoded = ConvertTo-HtmlEncoded $finding.Category
        $nameEncoded = ConvertTo-HtmlEncoded $finding.Name
        $severityEncoded = ConvertTo-HtmlEncoded $finding.Severity
        $statusEncoded = ConvertTo-HtmlEncoded $finding.Status
        $actualEncoded = ConvertTo-HtmlEncoded $finding.Actual

        $html += @"
                <tr>
                    <td>$categoryEncoded</td>
                    <td>$nameEncoded</td>
                    <td>$severityEncoded</td>
                    <td class="$statusClass">$statusEncoded</td>
                    <td>$actualEncoded</td>
                </tr>
"@

    }
    
    $html += @'
            </tbody>
        </table>
         
        <div style="margin-top: 40px; padding-top: 20px; border-top: 2px solid #ddd; text-align: center; color: #605e5c; font-size: 12px;">
            <p>Generated by WindowsSecurityAudit Module</p>
        </div>
    </div>
</body>
</html>
'@

    
    return $html
}