root365.ps1
|
param( [string]$SPOAdminUrl ) # =============================== # ROOT365 audit runner (local SHELL scripts) # =============================== $ErrorActionPreference = "Stop" $MaxRetries = 3 $ScriptDir = Join-Path $PSScriptRoot "SHELL" $LogDir = Join-Path $PSScriptRoot "logs" $RunStamp = Get-Date -Format 'yyyyMMdd_HHmmss' $ReportPath = Join-Path $PSScriptRoot "M365_Audit_Report_$RunStamp.json" $CsvReportPath = Join-Path $PSScriptRoot "M365_Audit_Report_$RunStamp.csv" $HtmlReportPath = Join-Path $PSScriptRoot "M365_Audit_Report_$RunStamp.html" $ManifestCandidates = @( (Join-Path $PSScriptRoot "cis_v6_0_1_controls_manifest.json"), (Join-Path $PSScriptRoot "cis_v6_controls_manifest.json") ) $ManifestPath = $null $BenchmarkVersion = "Unknown" function Get-PropertyValue { param( [Parameter(Mandatory = $true)] [object]$InputObject, [Parameter(Mandatory = $true)] [string]$PropertyName ) if ($null -eq $InputObject -or $null -eq $InputObject.PSObject) { return $null } $Property = $InputObject.PSObject.Properties[$PropertyName] if ($null -ne $Property) { return $Property.Value } return $null } function Get-DerivedSeverity { param( [string]$Level ) switch (($Level | ForEach-Object { $_.ToUpperInvariant() })) { "L2" { return "High" } "L1" { return "Medium" } default { return "Unknown" } } } function Convert-ToHtmlEncoded { param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return "" } return [System.Net.WebUtility]::HtmlEncode([string]$Value) } function Convert-ToBoolean { param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $false } if ($Value -is [bool]) { return [bool]$Value } $Text = ([string]$Value).Trim() return ($Text -match '^(?i:true|1|yes|enabled)$') } function Convert-ToJsonSafe { param( [AllowNull()] [object]$InputObject, [int]$Depth = 8, [switch]$Compress ) try { if ($Compress) { return ($InputObject | ConvertTo-Json -Depth $Depth -Compress -ErrorAction Stop) } return ($InputObject | ConvertTo-Json -Depth $Depth -ErrorAction Stop) } catch { $Fallback = [pscustomobject]@{ SerializationError = $_.Exception.Message SerializationFallback = "Applied" InputType = if ($null -eq $InputObject) { "null" } else { $InputObject.GetType().FullName } InputCount = if ($InputObject -is [System.Collections.ICollection]) { $InputObject.Count } else { $null } Note = "JSON serialization failed for this payload. A compact fallback object was written instead." } if ($Compress) { return ($Fallback | ConvertTo-Json -Depth 4 -Compress) } return ($Fallback | ConvertTo-Json -Depth 4) } } if (-not (Test-Path $LogDir)) { New-Item -Path $LogDir -ItemType Directory -Force | Out-Null } $AuditScripts = Get-ChildItem -Path $ScriptDir -File -Filter "*.ps1" | Sort-Object Name | Select-Object -ExpandProperty FullName if (-not $AuditScripts) { throw "No PowerShell scripts found in $ScriptDir" } $ControlMetadataById = @{} foreach ($CandidatePath in $ManifestCandidates) { if (Test-Path $CandidatePath) { $ManifestPath = $CandidatePath break } } if ($ManifestPath) { try { $Manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json if ($Manifest.version) { $BenchmarkVersion = [string]$Manifest.version } foreach ($Entry in @($Manifest.entries)) { if ($Entry.id) { $ControlMetadataById[[string]$Entry.id] = $Entry } } } catch { Write-Host "Control manifest load warning: $($_.Exception.Message)" -ForegroundColor DarkYellow } } else { Write-Host "Control manifest file not found. Metadata enrichment will be limited." -ForegroundColor DarkYellow } $Results = @() try { Write-Host "`nConnecting to Microsoft 365 services..." -ForegroundColor Cyan Connect-MgGraph -Scopes @( "AccessReview.Read.All", "AdministrativeUnit.Read.All", "AdministrativeUnit.ReadWrite.All", "Application.Read.All", "Application.ReadWrite.All", "AuditLog.Read.All", "Directory.Read.All", "Group.Read.All", "IdentityRiskyUser.Read.All", "NetworkAccessPolicy.Read.All", "Policy.Read.All", "Policy.Read.AuthenticationMethod", "Policy.Read.ConditionalAccess", "Policy.Read.DeviceConfiguration", "Policy.ReadWrite.AuthenticationMethod", "Policy.ReadWrite.ConditionalAccess", "Policy.ReadWrite.DeviceConfiguration", "Policy.ReadWrite.IdentityProtection", "Policy.ReadWrite.PermissionGrant", "Policy.ReadWrite.SecurityDefaults", "Reports.Read.All", "RoleManagement.Read.All", "RoleManagement.Read.Directory", "RoleManagementPolicy.Read.Directory", "SecurityActions.Read.All", "SecurityAlert.Read.All", "SecurityIncident.Read.All", "SecurityIncident.ReadWrite.All", "SecurityEvents.Read.All", "TeamworkDevice.Read.All", "User.Read.All", "UserAuthenticationMethod.Read.All", "Sites.Read.All", "Sites.FullControl.All", "Sites.ReadWrite.All", "Sites.Manage.All", "Sites.Selected", "DelegatedPermissionGrant.Read.All", "DeviceManagementServiceConfig.Read.All", "DeviceManagementConfiguration.Read.All", "OnPremDirectorySynchronization.Read.All", "OrgSettings-AppsAndServices.Read.All", "OrgSettings-Forms.Read.All", "Organization.Read.All", "Domain.Read.All" ) Connect-ExchangeOnline -ShowBanner:$false if (Get-Command -Name Connect-IPPSSession -ErrorAction SilentlyContinue) { Connect-IPPSSession | Out-Null } if (Get-Command -Name Connect-MicrosoftTeams -ErrorAction SilentlyContinue) { Connect-MicrosoftTeams } if (Get-Command -Name Connect-SPOService -ErrorAction SilentlyContinue) { if (-not $SPOAdminUrl) { try { $InitialDomain = Get-MgDomain -All | Where-Object { $_.IsInitial -eq $true } | Select-Object -First 1 -ExpandProperty Id if ($InitialDomain -match "^(?<tenant>[^.]+)\.onmicrosoft\.com$") { $SPOAdminUrl = "https://$($Matches.tenant)-admin.sharepoint.com" } } catch { Write-Host "Unable to auto-detect SharePoint admin URL. Set -SPOAdminUrl to connect SPO in root365.ps1." -ForegroundColor DarkYellow } } if ($SPOAdminUrl) { Connect-SPOService -Url $SPOAdminUrl } else { Write-Host "Skipping SharePoint Online connection in root365.ps1 (SPOAdminUrl not set)." -ForegroundColor DarkYellow } } if (Get-Command -Name Connect-AzAccount -ErrorAction SilentlyContinue) { try { $AzContextAvailable = $null if (Get-Command -Name Get-AzContext -ErrorAction SilentlyContinue) { $AzContextAvailable = Get-AzContext -ErrorAction SilentlyContinue } if (-not $AzContextAvailable) { Connect-AzAccount -ErrorAction Stop | Out-Null } } catch { Write-Host "Azure connection warning for Fabric controls: $($_.Exception.Message)" -ForegroundColor DarkYellow } } if (Get-Command -Name Connect-PowerBIServiceAccount -ErrorAction SilentlyContinue) { try { Connect-PowerBIServiceAccount -ErrorAction Stop | Out-Null } catch { Write-Host "Power BI connection warning for Fabric controls: $($_.Exception.Message)" -ForegroundColor DarkYellow } } Write-Host "All services connected successfully." -ForegroundColor Green foreach ($Script in $AuditScripts) { $RetryCount = 0 $Success = $false Start-Sleep 1 while (-not $Success -and $RetryCount -lt $MaxRetries) { $RetryCount++ Write-Host "`nRunning script [$Script] Attempt #$RetryCount" -ForegroundColor Yellow $LogFile = Join-Path $LogDir "$(Split-Path $Script -Leaf)-$(Get-Date -Format 'yyyyMMdd_HHmmss').log" $Result = [pscustomobject]@{ Script = $Script Attempt = $RetryCount StartTime = Get-Date EndTime = $null Output = $null Errors = $null Status = "UNKNOWN" Reason = $null ExecutionStatus = "NotStarted" LogFile = $LogFile CheckId = $null Title = $null Level = $null Severity = $null SeverityBasis = $null ControlType = $null Benchmark = "CIS Microsoft 365 Foundations Benchmark" BenchmarkVersion = $BenchmarkVersion SourceManifest = $ManifestPath ReferencePages = $null ControlStatus = $null Pass = $null DataAudited = $null } try { $Output = & $Script 2>&1 $Result.Output = @($Output) $Result.ExecutionStatus = "Success" $ControlOutput = $Result.Output | Where-Object { $_ -and $_.PSObject -and $_.PSObject.Properties -and ($_.PSObject.Properties.Name -contains "CheckId") } | Select-Object -First 1 if ($ControlOutput) { try { $Result.CheckId = Get-PropertyValue -InputObject $ControlOutput -PropertyName "CheckId" $Result.Title = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Title" $Result.Level = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Level" $Result.ControlType = Get-PropertyValue -InputObject $ControlOutput -PropertyName "BenchmarkType" if ([string]::IsNullOrWhiteSpace([string]$Result.ControlType)) { $Result.ControlType = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Type" } $Result.ControlStatus = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Status" $Result.Pass = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Pass" $Result.DataAudited = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Evidence" $ControlError = Get-PropertyValue -InputObject $ControlOutput -PropertyName "Error" $RawStatus = $null if ($null -ne $Result.ControlStatus) { $RawStatus = $Result.ControlStatus.ToString().Trim().ToUpperInvariant() } if ($Result.Pass -eq $false) { $Result.Status = "FAIL" $Result.Reason = if ($ControlError) { [string]$ControlError } else { "Control result is non-compliant." } } elseif ($Result.Pass -eq $true) { $Result.Status = "PASS" $Result.Reason = $null } elseif ($RawStatus -eq "ERROR") { $Result.Status = "ERROR" $Result.Reason = if ($ControlError) { [string]$ControlError } else { "Control script returned ERROR status." } $Result.Errors = $Result.Reason } elseif ($RawStatus -eq "FAIL") { $Result.Status = "FAIL" $Result.Reason = if ($ControlError) { [string]$ControlError } else { "Control result is non-compliant." } } elseif (-not [string]::IsNullOrWhiteSpace($RawStatus)) { $Result.Status = $RawStatus if ($ControlError) { $Result.Reason = [string]$ControlError } } else { $Result.Status = "INFO" if ($ControlError) { $Result.Reason = [string]$ControlError } } if ([string]::IsNullOrWhiteSpace($Result.CheckId)) { $Result.CheckId = [System.IO.Path]::GetFileNameWithoutExtension($Script) } $ControlMetadata = $null if ($Result.CheckId -and $ControlMetadataById.ContainsKey([string]$Result.CheckId)) { $ControlMetadata = $ControlMetadataById[[string]$Result.CheckId] } if ([string]::IsNullOrWhiteSpace([string]$Result.Title) -and $ControlMetadata -and $ControlMetadata.title) { $Result.Title = [string]$ControlMetadata.title } if ([string]::IsNullOrWhiteSpace([string]$Result.Level) -and $ControlMetadata -and $ControlMetadata.level) { $Result.Level = [string]$ControlMetadata.level } if ([string]::IsNullOrWhiteSpace([string]$Result.ControlType) -and $ControlMetadata -and $ControlMetadata.type) { $Result.ControlType = [string]$ControlMetadata.type } if ($ControlMetadata -and $ControlMetadata.start_page) { if ($ControlMetadata.end_page -and ([int]$ControlMetadata.end_page -ne [int]$ControlMetadata.start_page)) { $Result.ReferencePages = "pp. $($ControlMetadata.start_page)-$($ControlMetadata.end_page)" } else { $Result.ReferencePages = "p. $($ControlMetadata.start_page)" } } if ([string]::IsNullOrWhiteSpace([string]$Result.Severity)) { if ($ControlMetadata -and $ControlMetadata.severity) { $Result.Severity = [string]$ControlMetadata.severity $Result.SeverityBasis = "CIS v$BenchmarkVersion metadata" } else { $Result.Severity = Get-DerivedSeverity -Level $Result.Level $Result.SeverityBasis = "Derived from CIS level" } } if ($Result.Status -eq "INFO") { $Result.Status = "MANUAL_REVIEW" if ([string]::IsNullOrWhiteSpace($Result.Reason)) { $Result.Reason = "Informational evidence collected. Explicit PASS/FAIL threshold is not yet encoded for this control." } } if ($Result.Status -eq "MANUAL_REVIEW" -and [string]::IsNullOrWhiteSpace($Result.Reason)) { $Result.Reason = "Control requires manual validation or additional logic to determine compliance." } } catch { $WarningMessage = "Output mapping warning: $($_.Exception.Message)" $Result.Status = "ERROR" $Result.Reason = "Failed to parse control result output: $($_.Exception.Message)" if ([string]::IsNullOrWhiteSpace($Result.CheckId)) { $Result.CheckId = [System.IO.Path]::GetFileNameWithoutExtension($Script) } if ([string]::IsNullOrWhiteSpace($Result.Errors)) { $Result.Errors = $WarningMessage } else { $Result.Errors = "$($Result.Errors) | $WarningMessage" } Write-Host $WarningMessage -ForegroundColor DarkYellow } } else { $Result.CheckId = [System.IO.Path]::GetFileNameWithoutExtension($Script) $Result.Status = "ERROR" $Result.Reason = "Script executed but returned no control result (missing object with CheckId)." $Result.Errors = $Result.Reason } if ($Result.CheckId -and $ControlMetadataById.ContainsKey([string]$Result.CheckId)) { $ControlMetadata = $ControlMetadataById[[string]$Result.CheckId] if ([string]::IsNullOrWhiteSpace([string]$Result.Title) -and $ControlMetadata.title) { $Result.Title = [string]$ControlMetadata.title } if ([string]::IsNullOrWhiteSpace([string]$Result.Level) -and $ControlMetadata.level) { $Result.Level = [string]$ControlMetadata.level } if ([string]::IsNullOrWhiteSpace([string]$Result.ControlType) -and $ControlMetadata.type) { $Result.ControlType = [string]$ControlMetadata.type } if ([string]::IsNullOrWhiteSpace([string]$Result.ReferencePages) -and $ControlMetadata.start_page) { if ($ControlMetadata.end_page -and ([int]$ControlMetadata.end_page -ne [int]$ControlMetadata.start_page)) { $Result.ReferencePages = "pp. $($ControlMetadata.start_page)-$($ControlMetadata.end_page)" } else { $Result.ReferencePages = "p. $($ControlMetadata.start_page)" } } if ([string]::IsNullOrWhiteSpace([string]$Result.Severity) -and $ControlMetadata.severity) { $Result.Severity = [string]$ControlMetadata.severity $Result.SeverityBasis = "CIS v$BenchmarkVersion metadata" } } if ([string]::IsNullOrWhiteSpace([string]$Result.Severity)) { $Result.Severity = Get-DerivedSeverity -Level $Result.Level if ([string]::IsNullOrWhiteSpace([string]$Result.SeverityBasis)) { $Result.SeverityBasis = "Derived from CIS level" } } if ($Result.Status -eq "MANUAL_REVIEW" -and [string]::IsNullOrWhiteSpace($Result.Reason)) { $Result.Reason = "Control requires manual validation or additional logic to determine compliance." } $Success = $true } catch { $Result.Errors = $_.Exception.Message $Result.Status = "ERROR" $Result.ExecutionStatus = "Failed" $Result.Reason = "Script execution failed: $($_.Exception.Message)" if ([string]::IsNullOrWhiteSpace($Result.CheckId)) { $Result.CheckId = [System.IO.Path]::GetFileNameWithoutExtension($Script) } if ($Result.CheckId -and $ControlMetadataById.ContainsKey([string]$Result.CheckId)) { $ControlMetadata = $ControlMetadataById[[string]$Result.CheckId] if ([string]::IsNullOrWhiteSpace([string]$Result.Title) -and $ControlMetadata.title) { $Result.Title = [string]$ControlMetadata.title } if ([string]::IsNullOrWhiteSpace([string]$Result.Level) -and $ControlMetadata.level) { $Result.Level = [string]$ControlMetadata.level } if ([string]::IsNullOrWhiteSpace([string]$Result.ControlType) -and $ControlMetadata.type) { $Result.ControlType = [string]$ControlMetadata.type } if ([string]::IsNullOrWhiteSpace([string]$Result.ReferencePages) -and $ControlMetadata.start_page) { if ($ControlMetadata.end_page -and ([int]$ControlMetadata.end_page -ne [int]$ControlMetadata.start_page)) { $Result.ReferencePages = "pp. $($ControlMetadata.start_page)-$($ControlMetadata.end_page)" } else { $Result.ReferencePages = "p. $($ControlMetadata.start_page)" } } if ([string]::IsNullOrWhiteSpace([string]$Result.Severity) -and $ControlMetadata.severity) { $Result.Severity = [string]$ControlMetadata.severity $Result.SeverityBasis = "CIS v$BenchmarkVersion metadata" } } if ([string]::IsNullOrWhiteSpace([string]$Result.Severity)) { $Result.Severity = Get-DerivedSeverity -Level $Result.Level if ([string]::IsNullOrWhiteSpace([string]$Result.SeverityBasis)) { $Result.SeverityBasis = "Derived from CIS level" } } Write-Host "Script failed: $($_.Exception.Message)" -ForegroundColor Red if ($RetryCount -lt $MaxRetries) { Write-Host "Retrying in 5 seconds..." -ForegroundColor Cyan Start-Sleep -Seconds 5 } } $Result.EndTime = Get-Date $ResultJson = Convert-ToJsonSafe -InputObject $Result -Depth 8 $ResultJson | Out-File -FilePath $LogFile -Encoding UTF8 $Results += $Result } } } finally { if (Get-Command -Name Disconnect-SPOService -ErrorAction SilentlyContinue) { try { Disconnect-SPOService -ErrorAction Stop } catch { } } if (Get-Command -Name Disconnect-ExchangeOnline -ErrorAction SilentlyContinue) { try { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction Stop } catch { } } if (Get-Command -Name Disconnect-MgGraph -ErrorAction SilentlyContinue) { try { Disconnect-MgGraph -ErrorAction Stop } catch { } } if (Get-Command -Name Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue) { try { Disconnect-MicrosoftTeams -ErrorAction Stop } catch { } } if (Get-Command -Name Disconnect-PowerBIServiceAccount -ErrorAction SilentlyContinue) { try { Disconnect-PowerBIServiceAccount | Out-Null } catch { } } if (Get-Command -Name Disconnect-AzAccount -ErrorAction SilentlyContinue) { try { Disconnect-AzAccount -Scope Process -ErrorAction SilentlyContinue | Out-Null } catch { } } } $MasterReportJson = Convert-ToJsonSafe -InputObject $Results -Depth 8 $MasterReportJson | Out-File -FilePath $ReportPath -Encoding UTF8 $FinalResultsAll = $Results | Group-Object -Property Script | ForEach-Object { $_.Group | Sort-Object -Property Attempt -Descending | Select-Object -First 1 } | Sort-Object -Property CheckId, Script $SupplementalCheckIds = @("MFA.STATUS", "APP.ROLES.STATUS") $MfaSupplementalResult = $FinalResultsAll | Where-Object { [string]$_.CheckId -eq "MFA.STATUS" } | Select-Object -First 1 $AppRolesSupplementalResult = $FinalResultsAll | Where-Object { [string]$_.CheckId -eq "APP.ROLES.STATUS" } | Select-Object -First 1 $FinalResults = @($FinalResultsAll | Where-Object { $SupplementalCheckIds -notcontains [string]$_.CheckId }) $MfaUserRows = @() $MfaEnrichedRows = @() $MfaUserSourceCsvPath = $null $MfaUserReportPath = $null $MfaRiskCounts = @{ Critical = 0; High = 0; Medium = 0; Low = 0 } $MfaRoleCounts = @{ Admin = 0; External = 0; Member = 0 } $MfaAuditCounts = @{ FAIL = 0; MANUAL_REVIEW = 0; PASS = 0 } $MfaFinalScore = $null $MfaScoreFormula = "Score = 100 - average(min(100, RiskPenalty x RoleMultiplier)); RiskPenalty: Critical=100, High=70, Medium=35, Low=0; RoleMultiplier: Admin=1.3, External=1.1, Member=1.0." $MfaSectionMessage = "" $AppRolesRows = @() $AppRolesEnrichedRows = @() $AppRolesSourceCsvPath = $null $AppRolesCriticalCsvPath = $null $AppRolesSectionMessage = "" $AppRolesRiskCounts = @{ Critical = 0; Low = 0 } $AppRolesTypeCounts = @{ Application = 0; Delegated = 0 } $AppRolesSourceCounts = @{ EnterpriseApplicationConsent = 0; AppRegistrationRequested = 0 } $AppRolesAuditCounts = @{ FAIL = 0; PASS = 0 } $AppRolesFinalScore = $null $AppRolesScoreFormula = "Score = 100 - ((CriticalPermissions / TotalPermissions) x 100)." if ($MfaSupplementalResult -and $MfaSupplementalResult.DataAudited) { $MfaUserSourceCsvPath = [string](Get-PropertyValue -InputObject $MfaSupplementalResult.DataAudited -PropertyName "CsvExportPath") if (-not [string]::IsNullOrWhiteSpace($MfaUserSourceCsvPath) -and (Test-Path $MfaUserSourceCsvPath)) { try { $MfaUserRows = @(Import-Csv -Path $MfaUserSourceCsvPath) } catch { $MfaSectionMessage = "Failed to import MFA user source CSV: $($_.Exception.Message)" } } else { $MfaSectionMessage = "MFA user CSV path not available." } } else { $MfaSectionMessage = "MFA supplemental data is not available in this run." } if (@($MfaUserRows).Count -gt 0) { $PenaltyByRisk = @{ Critical = 100 High = 70 Medium = 35 Low = 0 } $RoleMultiplier = @{ Admin = 1.3 External = 1.1 Member = 1.0 } $TotalPenalty = 0.0 foreach ($Row in $MfaUserRows) { $IsAdmin = Convert-ToBoolean -Value $Row.IsAdmin $IsExternal = Convert-ToBoolean -Value $Row.IsExternal $RoleClass = if ($IsAdmin) { "Admin" } elseif ($IsExternal) { "External" } else { "Member" } $Risk = [string]$Row.Risk if ($MfaRiskCounts.ContainsKey($Risk) -eq $false) { $Risk = "Low" } $UserAuditResult = switch ($Risk) { "Critical" { "FAIL" } "High" { "FAIL" } "Medium" { "MANUAL_REVIEW" } default { "PASS" } } $RiskPenalty = [double]$PenaltyByRisk[$Risk] $Multiplier = [double]$RoleMultiplier[$RoleClass] $RowPenalty = [math]::Min(100.0, ($RiskPenalty * $Multiplier)) $TotalPenalty += $RowPenalty $MfaRiskCounts[$Risk]++ $MfaRoleCounts[$RoleClass]++ $MfaAuditCounts[$UserAuditResult]++ $MfaEnrichedRows += [pscustomobject]@{ Name = [string]$Row.Name Id = [string]$Row.Id UserPrincipalName = [string]$Row.UserPrincipalName RoleClass = $RoleClass Risk = $Risk UserAuditResult = $UserAuditResult IsEnabled = [string]$Row.IsEnabled IsExternal = [string]$Row.IsExternal IsAdmin = [string]$Row.IsAdmin IsSSPRCapable = [string]$Row.IsSSPRCapable IsSSPREnabled = [string]$Row.IsSSPREnabled IsSSPRRegistered = [string]$Row.IsSSPRRegistered IsMFACapable = [string]$Row.IsMFACapable IsMFARegistered = [string]$Row.IsMFARegistered RegisteredMFAMethods = [string]$Row.RegisteredMFAMethods DefaultMFAMethod = [string]$Row.DefaultMFAMethod SystemPreferredMethodEnforced = [string]$Row.SystemPreferredMethodEnforced SystemEnforcedMethod = [string]$Row.SystemEnforcedMethod UserPreferredMFAMethod = [string]$Row.UserPreferredMFAMethod IsPasswordlessCapable = [string]$Row.IsPasswordlessCapable MemberOf = [string]$Row.MemberOf AppliedCAPolicies = [string]$Row.AppliedCAPolicies CAPoliciesNotApplied = [string]$Row.CAPoliciesNotApplied PossibleCAGaps = [string]$Row.PossibleCAGaps ScorePenalty = [math]::Round($RowPenalty, 2) } } if (@($MfaEnrichedRows).Count -gt 0) { $AveragePenalty = $TotalPenalty / @($MfaEnrichedRows).Count $MfaFinalScore = [math]::Round([math]::Max(0.0, (100.0 - $AveragePenalty)), 2) $MfaUserReportPath = Join-Path $PSScriptRoot "M365_MFA_User_Audit_$RunStamp.csv" $MfaEnrichedRows | Export-Csv -Path $MfaUserReportPath -NoTypeInformation -Encoding UTF8 } } if ($AppRolesSupplementalResult -and $AppRolesSupplementalResult.DataAudited) { $AppRolesSourceCsvPath = [string](Get-PropertyValue -InputObject $AppRolesSupplementalResult.DataAudited -PropertyName "CsvExportPath") $AppRolesCriticalCsvPath = [string](Get-PropertyValue -InputObject $AppRolesSupplementalResult.DataAudited -PropertyName "CriticalCsvExportPath") if (-not [string]::IsNullOrWhiteSpace($AppRolesSourceCsvPath) -and (Test-Path $AppRolesSourceCsvPath)) { try { $AppRolesRows = @(Import-Csv -Path $AppRolesSourceCsvPath) } catch { $AppRolesSectionMessage = "Failed to import app role source CSV: $($_.Exception.Message)" } } else { $AppRolesSectionMessage = "App role source CSV path not available." } } else { $AppRolesSectionMessage = "App role supplemental data is not available in this run." } if (@($AppRolesRows).Count -gt 0) { $CriticalCount = 0 foreach ($Row in $AppRolesRows) { $PermissionType = [string]$Row.PermissionType if ($AppRolesTypeCounts.ContainsKey($PermissionType) -eq $false) { $PermissionType = "Delegated" } $AssignmentSource = [string]$Row.AssignmentSource if ($AppRolesSourceCounts.ContainsKey($AssignmentSource) -eq $false) { $AssignmentSource = "AppRegistrationRequested" } $Risk = [string]$Row.Risk if ($AppRolesRiskCounts.ContainsKey($Risk) -eq $false) { $Risk = "Low" } $IsCritical = Convert-ToBoolean -Value $Row.IsCritical if ($IsCritical -or $Risk -eq "Critical") { $Risk = "Critical" $IsCritical = $true } $AuditResult = if ($IsCritical -and $PermissionType -eq "Application") { "FAIL" } else { "PASS" } if ($AuditResult -eq "FAIL") { $CriticalCount++ } $AppRolesRiskCounts[$Risk]++ $AppRolesTypeCounts[$PermissionType]++ $AppRolesSourceCounts[$AssignmentSource]++ $AppRolesAuditCounts[$AuditResult]++ $AppRolesEnrichedRows += [pscustomobject]@{ AppName = [string]$Row.AppName AppObjectId = [string]$Row.AppObjectId AppClientId = [string]$Row.AppClientId AssignmentSource = [string]$Row.AssignmentSource PermissionType = [string]$Row.PermissionType ResourceAppName = [string]$Row.ResourceAppName ResourceAppId = [string]$Row.ResourceAppId PermissionId = [string]$Row.PermissionId PermissionName = [string]$Row.PermissionName Owners = [string]$Row.Owners CreatedDateTime = [string]$Row.CreatedDateTime Disabled = [string]$Row.Disabled Risk = $Risk IsCritical = $IsCritical UserAuditResult = $AuditResult Reason = [string]$Row.Reason } } if (@($AppRolesEnrichedRows).Count -gt 0) { $AppRolesFinalScore = [math]::Round([math]::Max(0.0, (100.0 - (($CriticalCount * 100.0) / @($AppRolesEnrichedRows).Count))), 2) } } $CsvRows = $FinalResults | ForEach-Object { [pscustomobject]@{ CheckId = $_.CheckId Title = $_.Title Level = $_.Level Severity = $_.Severity SeverityBasis = $_.SeverityBasis ControlType = $_.ControlType Benchmark = $_.Benchmark BenchmarkVersion = $_.BenchmarkVersion ReferencePages = $_.ReferencePages Script = $_.Script Attempt = $_.Attempt Result = $_.Status ExecutionStatus = $_.ExecutionStatus ControlStatus = $_.ControlStatus Pass = $_.Pass Reason = $_.Reason DataAudited = if ($null -ne $_.DataAudited) { Convert-ToJsonSafe -InputObject $_.DataAudited -Depth 12 -Compress } else { $null } Error = $_.Errors StartTime = $_.StartTime EndTime = $_.EndTime DurationSeconds = if ($_.StartTime -and $_.EndTime) { [math]::Round((New-TimeSpan -Start $_.StartTime -End $_.EndTime).TotalSeconds, 2) } else { $null } LogFile = $_.LogFile } } $CsvRows | Export-Csv -Path $CsvReportPath -NoTypeInformation -Encoding UTF8 $StatusOrder = @("PASS", "FAIL", "MANUAL_REVIEW", "ERROR", "INFO", "UNKNOWN") $StatusColors = @{ PASS = "#1B5E20" FAIL = "#B71C1C" MANUAL_REVIEW = "#E65100" ERROR = "#6A1B9A" INFO = "#01579B" UNKNOWN = "#455A64" } $StatusCounts = @{} foreach ($ResultRow in $FinalResults) { $StatusKey = [string]$ResultRow.Status if ([string]::IsNullOrWhiteSpace($StatusKey)) { $StatusKey = "UNKNOWN" } if (-not $StatusCounts.ContainsKey($StatusKey)) { $StatusCounts[$StatusKey] = 0 } $StatusCounts[$StatusKey]++ } $StatusSummary = @() foreach ($StatusKey in $StatusOrder) { if ($StatusCounts.ContainsKey($StatusKey)) { $StatusSummary += [pscustomobject]@{ Status = $StatusKey Count = [int]$StatusCounts[$StatusKey] Color = if ($StatusColors.ContainsKey($StatusKey)) { $StatusColors[$StatusKey] } else { "#37474F" } } } } foreach ($StatusKey in ($StatusCounts.Keys | Sort-Object)) { if ($StatusOrder -notcontains $StatusKey) { $StatusSummary += [pscustomobject]@{ Status = $StatusKey Count = [int]$StatusCounts[$StatusKey] Color = "#37474F" } } } $TotalControls = @($FinalResults).Count $StackSegments = @() if ($TotalControls -gt 0) { foreach ($Item in $StatusSummary) { if ($Item.Count -gt 0) { $Pct = [math]::Round(($Item.Count * 100.0) / $TotalControls, 2) $Title = Convert-ToHtmlEncoded "$($Item.Status): $($Item.Count)" $StackSegments += "<div class='stack-segment' style='width:$Pct%; background:$($Item.Color)' title='$Title'></div>" } } } $BarRows = foreach ($Item in $StatusSummary) { $Pct = if ($TotalControls -gt 0) { [math]::Round(($Item.Count * 100.0) / $TotalControls, 1) } else { 0 } $StatusLabel = Convert-ToHtmlEncoded $Item.Status "<div class='bar-row'> <div class='bar-label'>$StatusLabel</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($Item.Color)'></div></div> <div class='bar-value'>$($Item.Count) ($Pct`%)</div> </div>" } $ControlRows = foreach ($Control in $FinalResults) { $Status = if ([string]::IsNullOrWhiteSpace([string]$Control.Status)) { "UNKNOWN" } else { [string]$Control.Status } $StatusColor = if ($StatusColors.ContainsKey($Status)) { $StatusColors[$Status] } else { "#37474F" } $StatusBadge = "<span class='status-badge' style='background:$StatusColor'>$([System.Net.WebUtility]::HtmlEncode($Status))</span>" $ReasonText = if ([string]::IsNullOrWhiteSpace([string]$Control.Reason)) { "" } else { [string]$Control.Reason } $ErrorText = if ([string]::IsNullOrWhiteSpace([string]$Control.Errors)) { "" } else { [string]$Control.Errors } $DetailsText = if ($ReasonText -and $ErrorText) { "$ReasonText | $ErrorText" } elseif ($ReasonText) { $ReasonText } else { $ErrorText } $DataAuditedCompact = if ($null -ne $Control.DataAudited) { (Convert-ToJsonSafe -InputObject $Control.DataAudited -Depth 12 -Compress) } else { "" } if ($DataAuditedCompact.Length -gt 600) { $DataAuditedCompact = $DataAuditedCompact.Substring(0, 600) + "...(truncated)" } "<tr> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.CheckId))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.Title))</td> <td>$StatusBadge</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.Severity))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.Level))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.ControlType))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Control.ReferencePages))</td> <td>$([System.Net.WebUtility]::HtmlEncode($DetailsText))</td> <td><details><summary>Data</summary><pre>$([System.Net.WebUtility]::HtmlEncode($DataAuditedCompact))</pre></details></td> </tr>" } $GeneratedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $StackHtml = $StackSegments -join "`n" $BarsHtml = $BarRows -join "`n" $ControlsHtml = $ControlRows -join "`n" $ManifestDisplay = if ($ManifestPath) { "$ManifestPath (v$BenchmarkVersion)" } else { "Not loaded" } $MfaRiskOrder = @("Critical", "High", "Medium", "Low") $MfaAuditOrder = @("FAIL", "MANUAL_REVIEW", "PASS") $MfaRoleOrder = @("Admin", "External", "Member") $MfaRiskColors = @{ Critical = "#B71C1C" High = "#E65100" Medium = "#F9A825" Low = "#1B5E20" } $MfaAuditColors = @{ FAIL = "#B71C1C" MANUAL_REVIEW = "#E65100" PASS = "#1B5E20" } $MfaRoleColors = @{ Admin = "#6A1B9A" External = "#01579B" Member = "#2E7D32" } $MfaRiskBars = @() foreach ($Key in $MfaRiskOrder) { $Count = if ($MfaRiskCounts.ContainsKey($Key)) { [int]$MfaRiskCounts[$Key] } else { 0 } $Pct = if (@($MfaEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($MfaEnrichedRows).Count, 1) } else { 0 } $MfaRiskBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($MfaRiskColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $MfaAuditBars = @() foreach ($Key in $MfaAuditOrder) { $Count = if ($MfaAuditCounts.ContainsKey($Key)) { [int]$MfaAuditCounts[$Key] } else { 0 } $Pct = if (@($MfaEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($MfaEnrichedRows).Count, 1) } else { 0 } $MfaAuditBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($MfaAuditColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $MfaRoleBars = @() foreach ($Key in $MfaRoleOrder) { $Count = if ($MfaRoleCounts.ContainsKey($Key)) { [int]$MfaRoleCounts[$Key] } else { 0 } $Pct = if (@($MfaEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($MfaEnrichedRows).Count, 1) } else { 0 } $MfaRoleBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($MfaRoleColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $MfaUserRowsHtml = @() foreach ($Row in $MfaEnrichedRows) { $MfaUserRowsHtml += "<tr> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.UserPrincipalName))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.Name))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.RoleClass))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.Risk))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.UserAuditResult))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.IsMFARegistered))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.IsMFACapable))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.DefaultMFAMethod))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.AppliedCAPolicies))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.PossibleCAGaps))</td> </tr>" } $MfaScoreDisplay = if ($null -ne $MfaFinalScore) { "$MfaFinalScore / 100" } else { "N/A" } $MfaUserSourceDisplay = if (-not [string]::IsNullOrWhiteSpace($MfaUserSourceCsvPath)) { $MfaUserSourceCsvPath } else { "N/A" } $MfaUserExportDisplay = if (-not [string]::IsNullOrWhiteSpace($MfaUserReportPath)) { $MfaUserReportPath } else { "N/A" } $MfaSectionInfo = if ([string]::IsNullOrWhiteSpace($MfaSectionMessage)) { "MFA user analysis loaded." } else { $MfaSectionMessage } $MfaRiskBarsHtml = $MfaRiskBars -join "`n" $MfaAuditBarsHtml = $MfaAuditBars -join "`n" $MfaRoleBarsHtml = $MfaRoleBars -join "`n" $MfaUsersHtml = $MfaUserRowsHtml -join "`n" $AppRolesRiskOrder = @("Critical", "Low") $AppRolesTypeOrder = @("Application", "Delegated") $AppRolesSourceOrder = @("EnterpriseApplicationConsent", "AppRegistrationRequested") $AppRolesAuditOrder = @("FAIL", "PASS") $AppRolesRiskColors = @{ Critical = "#B71C1C" Low = "#1B5E20" } $AppRolesTypeColors = @{ Application = "#6A1B9A" Delegated = "#01579B" } $AppRolesSourceColors = @{ EnterpriseApplicationConsent = "#E65100" AppRegistrationRequested = "#2E7D32" } $AppRolesAuditColors = @{ FAIL = "#B71C1C" PASS = "#1B5E20" } $AppRolesRiskBars = @() foreach ($Key in $AppRolesRiskOrder) { $Count = if ($AppRolesRiskCounts.ContainsKey($Key)) { [int]$AppRolesRiskCounts[$Key] } else { 0 } $Pct = if (@($AppRolesEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($AppRolesEnrichedRows).Count, 1) } else { 0 } $AppRolesRiskBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($AppRolesRiskColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $AppRolesTypeBars = @() foreach ($Key in $AppRolesTypeOrder) { $Count = if ($AppRolesTypeCounts.ContainsKey($Key)) { [int]$AppRolesTypeCounts[$Key] } else { 0 } $Pct = if (@($AppRolesEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($AppRolesEnrichedRows).Count, 1) } else { 0 } $AppRolesTypeBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($AppRolesTypeColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $AppRolesSourceBars = @() foreach ($Key in $AppRolesSourceOrder) { $Count = if ($AppRolesSourceCounts.ContainsKey($Key)) { [int]$AppRolesSourceCounts[$Key] } else { 0 } $Pct = if (@($AppRolesEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($AppRolesEnrichedRows).Count, 1) } else { 0 } $AppRolesSourceBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($AppRolesSourceColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $AppRolesAuditBars = @() foreach ($Key in $AppRolesAuditOrder) { $Count = if ($AppRolesAuditCounts.ContainsKey($Key)) { [int]$AppRolesAuditCounts[$Key] } else { 0 } $Pct = if (@($AppRolesEnrichedRows).Count -gt 0) { [math]::Round(($Count * 100.0) / @($AppRolesEnrichedRows).Count, 1) } else { 0 } $AppRolesAuditBars += "<div class='bar-row'> <div class='bar-label'>$([System.Net.WebUtility]::HtmlEncode($Key))</div> <div class='bar-track'><div class='bar-fill' style='width:$Pct%; background:$($AppRolesAuditColors[$Key])'></div></div> <div class='bar-value'>$Count ($Pct`%)</div> </div>" } $AppRolesRowsHtml = @() foreach ($Row in $AppRolesEnrichedRows) { $AppRolesRowsHtml += "<tr> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.AppName))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.AppClientId))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.PermissionType))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.PermissionName))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.ResourceAppName))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.AssignmentSource))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.Risk))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.UserAuditResult))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.Owners))</td> <td>$([System.Net.WebUtility]::HtmlEncode([string]$Row.Reason))</td> </tr>" } $AppRolesScoreDisplay = if ($null -ne $AppRolesFinalScore) { "$AppRolesFinalScore / 100" } else { "N/A" } $AppRolesSourceDisplay = if (-not [string]::IsNullOrWhiteSpace($AppRolesSourceCsvPath)) { $AppRolesSourceCsvPath } else { "N/A" } $AppRolesCriticalDisplay = if (-not [string]::IsNullOrWhiteSpace($AppRolesCriticalCsvPath)) { $AppRolesCriticalCsvPath } else { "N/A" } $AppRolesSectionInfo = if ([string]::IsNullOrWhiteSpace($AppRolesSectionMessage)) { "App role analysis loaded." } else { $AppRolesSectionMessage } $AppRolesRiskBarsHtml = $AppRolesRiskBars -join "`n" $AppRolesTypeBarsHtml = $AppRolesTypeBars -join "`n" $AppRolesSourceBarsHtml = $AppRolesSourceBars -join "`n" $AppRolesAuditBarsHtml = $AppRolesAuditBars -join "`n" $AppRolesUsersHtml = $AppRolesRowsHtml -join "`n" $HtmlBody = @" <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>M365 Audit Report - $RunStamp</title> <style> body { font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2937; background: #f8fafc; } h1, h2 { margin: 0 0 12px 0; } .meta { margin-bottom: 16px; color: #475569; } .panel { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; margin-bottom: 18px; } .stack { display: flex; height: 20px; border-radius: 999px; overflow: hidden; background: #e2e8f0; margin-bottom: 12px; } .stack-segment { height: 100%; } .bar-row { display: grid; grid-template-columns: 170px 1fr 130px; gap: 10px; align-items: center; margin: 6px 0; } .bar-label { font-weight: 600; } .bar-track { background: #e2e8f0; border-radius: 6px; height: 14px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 6px; } .bar-value { text-align: right; color: #334155; font-variant-numeric: tabular-nums; } table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; } thead th { background: #e2e8f0; color: #0f172a; font-weight: 600; text-align: left; padding: 10px; border-bottom: 1px solid #cbd5e1; position: sticky; top: 0; } tbody td { border-bottom: 1px solid #e2e8f0; padding: 10px; vertical-align: top; } .status-badge { color: #fff; font-weight: 700; font-size: 12px; padding: 4px 8px; border-radius: 999px; display: inline-block; } pre { white-space: pre-wrap; word-break: break-word; margin: 8px 0 0 0; font-size: 12px; color: #334155; } .table-wrap { max-height: 68vh; overflow: auto; border: 1px solid #e2e8f0; border-radius: 8px; } .score-card { display: inline-block; padding: 10px 14px; border-radius: 10px; background: #0f172a; color: #f8fafc; font-weight: 700; margin: 8px 0 12px 0; } </style> </head> <body> <h1>Microsoft 365 Audit Report</h1> <div class="meta"> <div><strong>Generated:</strong> $GeneratedAt</div> <div><strong>Total Controls:</strong> $TotalControls</div> <div><strong>Benchmark:</strong> CIS Microsoft 365 Foundations Benchmark v$BenchmarkVersion</div> <div><strong>Control Metadata Source:</strong> $([System.Net.WebUtility]::HtmlEncode($ManifestDisplay))</div> </div> <div class="panel"> <h2>Summary Chart</h2> <div class="stack"> $StackHtml </div> $BarsHtml </div> <div class="panel"> <h2>MFA User Risk Analysis</h2> <div><strong>Source CSV:</strong> $([System.Net.WebUtility]::HtmlEncode($MfaUserSourceDisplay))</div> <div><strong>Exported Enriched CSV:</strong> $([System.Net.WebUtility]::HtmlEncode($MfaUserExportDisplay))</div> <div><strong>Users Analyzed:</strong> $(@($MfaEnrichedRows).Count)</div> <div><strong>Info:</strong> $([System.Net.WebUtility]::HtmlEncode($MfaSectionInfo))</div> <div class="score-card">Final MFA Score: $([System.Net.WebUtility]::HtmlEncode($MfaScoreDisplay))</div> <div><small>$([System.Net.WebUtility]::HtmlEncode($MfaScoreFormula))</small></div> <h3>Risk Distribution</h3> $MfaRiskBarsHtml <h3>Role Distribution</h3> $MfaRoleBarsHtml <h3>User Audit Result Distribution</h3> $MfaAuditBarsHtml <h3>User-Level Results</h3> <div class="table-wrap"> <table> <thead> <tr> <th>UserPrincipalName</th> <th>Name</th> <th>Role</th> <th>Risk</th> <th>Audit Result</th> <th>MFA Registered</th> <th>MFA Capable</th> <th>Default MFA Method</th> <th>Applied CA Policies</th> <th>Possible CA Gaps</th> </tr> </thead> <tbody> $MfaUsersHtml </tbody> </table> </div> </div> <div class="panel"> <h2>Application Roles Risk Analysis</h2> <div><strong>Source CSV:</strong> $([System.Net.WebUtility]::HtmlEncode($AppRolesSourceDisplay))</div> <div><strong>Critical CSV:</strong> $([System.Net.WebUtility]::HtmlEncode($AppRolesCriticalDisplay))</div> <div><strong>Permissions Analyzed:</strong> $(@($AppRolesEnrichedRows).Count)</div> <div><strong>Info:</strong> $([System.Net.WebUtility]::HtmlEncode($AppRolesSectionInfo))</div> <div class="score-card">Final App Permission Score: $([System.Net.WebUtility]::HtmlEncode($AppRolesScoreDisplay))</div> <div><small>$([System.Net.WebUtility]::HtmlEncode($AppRolesScoreFormula))</small></div> <h3>Risk Distribution</h3> $AppRolesRiskBarsHtml <h3>Permission Type Distribution</h3> $AppRolesTypeBarsHtml <h3>Assignment Source Distribution</h3> $AppRolesSourceBarsHtml <h3>Audit Result Distribution</h3> $AppRolesAuditBarsHtml <h3>Permission-Level Results</h3> <div class="table-wrap"> <table> <thead> <tr> <th>App Name</th> <th>Client ID</th> <th>Permission Type</th> <th>Permission</th> <th>Resource</th> <th>Assignment Source</th> <th>Risk</th> <th>Audit Result</th> <th>Owners</th> <th>Reason</th> </tr> </thead> <tbody> $AppRolesUsersHtml </tbody> </table> </div> </div> <div class="panel"> <h2>Control Results</h2> <div class="table-wrap"> <table> <thead> <tr> <th>CheckId</th> <th>Title</th> <th>Status</th> <th>Severity</th> <th>Level</th> <th>Type</th> <th>Pages</th> <th>Details</th> <th>Data Audited</th> </tr> </thead> <tbody> $ControlsHtml </tbody> </table> </div> </div> </body> </html> "@ $HtmlBody | Out-File -FilePath $HtmlReportPath -Encoding UTF8 Write-Host "`nAudit completed. Master report saved to:" -ForegroundColor Green Write-Host $ReportPath Write-Host "`nCSV report saved to:" -ForegroundColor Green Write-Host $CsvReportPath Write-Host "`nHTML report saved to:" -ForegroundColor Green Write-Host $HtmlReportPath if (-not [string]::IsNullOrWhiteSpace([string]$MfaUserReportPath)) { Write-Host "`nMFA user report saved to:" -ForegroundColor Green Write-Host $MfaUserReportPath } if (-not [string]::IsNullOrWhiteSpace([string]$AppRolesSourceCsvPath)) { Write-Host "`nApp roles source report saved to:" -ForegroundColor Green Write-Host $AppRolesSourceCsvPath } if (-not [string]::IsNullOrWhiteSpace([string]$AppRolesCriticalCsvPath)) { Write-Host "`nApp roles critical report saved to:" -ForegroundColor Green Write-Host $AppRolesCriticalCsvPath } if ($ManifestPath) { Write-Host "`nControl metadata source:" -ForegroundColor Green Write-Host "$ManifestPath (v$BenchmarkVersion)" } Write-Host "`nIndividual script logs saved to:" -ForegroundColor Green Write-Host $LogDir |