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). Checks whose configuration is not exposed through Microsoft
        Graph (Exchange Online, SharePoint, Teams workload settings) 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 policies
     
    .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'
        try {
            $context = Get-MgContext -ErrorAction Stop
            if ($context) {
                $graphConnected = $true
                if ($context.TenantId) { $tenantId = $context.TenantId }
                Write-Host "Connected to tenant: $tenantId" -ForegroundColor Green
            }
        }
        catch {
            Write-Verbose "Get-MgContext failed: $_"
        }
        
        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: Connect-MgGraph -Scopes 'Directory.Read.All','Policy.Read.All'"
        }
        
        # 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
        $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
            Summary = @{
                TotalChecks = 0
                Passed = 0
                Failed = 0
                Warnings = 0
                CriticalIssues = 0
                NotEvaluated = 0
            }
        }
    }
    
    process {
        try {
            $notConnectedReason = "Not evaluated — not connected to Microsoft Graph (run Connect-MgGraph -Scopes 'Directory.Read.All','Policy.Read.All')"
            
            # 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 checks (live Microsoft Graph queries)
            if ($CheckConditionalAccess) {
                Write-Host "`nAssessing Conditional Access..." -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)"
                    }
                }
                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
                }
            }
            
            # 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 | 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' }
        }
        
        $html += @"
                <tr>
                    <td>$($finding.Category)</td>
                    <td>$($finding.Name)</td>
                    <td>$($finding.Severity)</td>
                    <td class="$statusClass">$($finding.Status)</td>
                    <td>$($finding.Actual)</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
}