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 } |