Common/Export-AssessmentReport.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Generates an HTML assessment report from M365 assessment output. .DESCRIPTION Reads CSV data from an M365 assessment output folder and produces a self-contained HTML report powered by a React single-page application. The report bundles all JavaScript, CSS, and data inline — no external files or CDN calls required. A companion XLSX compliance matrix is also generated in the same folder. .PARAMETER AssessmentFolder Path to the assessment output folder (e.g., .\M365-Assessment\Assessment_20260306_195618). Must contain _Assessment-Summary.csv. .PARAMETER OutputPath Path for the generated HTML report. Defaults to _Assessment-Report_<domain>.html in the assessment folder. .PARAMETER TenantName Tenant display name for the report title. Read from Tenant Information CSV if omitted. .PARAMETER ReportTheme Default visual theme baked into the report. Users can change the theme via the report UI. Valid values: Neon (default), Console, Saas, HighContrast. Neon and Console default to dark mode; Saas defaults to light mode; HighContrast defaults to dark mode. .PARAMETER WhiteLabel Hides M365-Assess GitHub link and Galvnyz attribution from the report footer. .PARAMETER OpenReport Automatically opens the generated HTML report in the default browser. .PARAMETER QuickScan Passed through for context; has no effect on the React HTML report. .PARAMETER DriftReport Drift comparison rows from Compare-AssessmentBaseline. Passed to the XLSX export. .PARAMETER DriftBaselineLabel Baseline label string — retained for downstream compatibility. .PARAMETER DriftBaselineTimestamp Baseline timestamp string — retained for downstream compatibility. .EXAMPLE PS> .\Common\Export-AssessmentReport.ps1 -AssessmentFolder '.\M365-Assessment\Assessment_20260306_195618' .NOTES Author: Daren9m #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$AssessmentFolder, [Parameter()] [string]$OutputPath, [Parameter()] [string]$TenantName, [Parameter()] [ValidateSet('Neon', 'Console', 'Light', 'HighContrast')] [string]$ReportTheme = 'Neon', [Parameter()] [ValidateSet('Compact', 'Comfort')] [string]$ReportDensity = 'Compact', [Parameter()] [switch]$WhiteLabel, [Parameter()] [switch]$CompactReport, [Parameter()] [switch]$OpenReport, [Parameter()] [switch]$QuickScan, [Parameter()] [switch]$IncludeTrend, [Parameter()] [AllowEmptyCollection()] [PSCustomObject[]]$DriftReport = @(), [Parameter()] [string]$DriftBaselineLabel = '', [Parameter()] [string]$DriftBaselineTimestamp = '' ) $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) # ------------------------------------------------------------------ # Load control registry and framework definitions # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'Import-ControlRegistry.ps1') $controlsPath = Join-Path -Path $projectRoot -ChildPath 'controls' $cisFrameworkId = 'cis-m365-v6' $controlRegistry = Import-ControlRegistry -ControlsPath $controlsPath -CisFrameworkId $cisFrameworkId . (Join-Path -Path $PSScriptRoot -ChildPath 'Import-FrameworkDefinitions.ps1') $allFrameworks = Import-FrameworkDefinitions -FrameworksPath (Join-Path -Path $projectRoot -ChildPath 'controls/frameworks') . (Join-Path -Path $PSScriptRoot -ChildPath 'Import-CmmcHandoff.ps1') $cmmcHandoff = Import-CmmcHandoff -ControlsPath $controlsPath # ------------------------------------------------------------------ # Validate input # ------------------------------------------------------------------ if (-not (Test-Path -Path $AssessmentFolder -PathType Container)) { Write-Error "Assessment folder not found: $AssessmentFolder" return } $summaryFile = Get-ChildItem -Path $AssessmentFolder -Filter '_Assessment-Summary*.csv' -ErrorAction SilentlyContinue | Select-Object -First 1 $summaryPath = if ($summaryFile) { $summaryFile.FullName } else { Join-Path -Path $AssessmentFolder -ChildPath '_Assessment-Summary.csv' } if (-not (Test-Path -Path $summaryPath)) { Write-Error "Summary CSV not found: $summaryPath" return } # ------------------------------------------------------------------ # Load assessment metadata # ------------------------------------------------------------------ $summary = Import-Csv -Path $summaryPath $tenantCsv = Join-Path -Path $AssessmentFolder -ChildPath '01-Tenant-Info.csv' $tenantData = if (Test-Path -Path $tenantCsv) { Import-Csv -Path $tenantCsv } else { $null } if (-not $TenantName) { if ($tenantData -and @($tenantData).Count -gt 0 -and $tenantData[0].PSObject.Properties.Name -contains 'OrgDisplayName') { $TenantName = $tenantData[0].OrgDisplayName } elseif ($tenantData -and @($tenantData).Count -gt 0 -and $tenantData[0].PSObject.Properties.Name -contains 'DefaultDomain') { $TenantName = $tenantData[0].DefaultDomain } else { $TenantName = 'M365 Tenant' } } # Read domain prefix and version from the assessment log $reportDomainPrefix = '' $assessedAt = [datetime]::UtcNow.ToString('o') $assessmentVersion = (Import-PowerShellDataFile -Path "$PSScriptRoot/../M365-Assess.psd1").ModuleVersion $logFile = Get-ChildItem -Path $AssessmentFolder -Filter '_Assessment-Log*.txt' -ErrorAction SilentlyContinue | Select-Object -First 1 $logPath = if ($logFile) { $logFile.FullName } else { Join-Path -Path $AssessmentFolder -ChildPath '_Assessment-Log.txt' } if (Test-Path -Path $logPath) { $logHead = Get-Content -Path $logPath -TotalCount 10 $versionLine = $logHead | Where-Object { $_ -match 'Version:\s+v(.+)' } if ($versionLine) { $assessmentVersion = $Matches[1] } $domainLine = $logHead | Where-Object { $_ -match 'Domain:\s+(\S+)' } if ($domainLine -and $Matches[1]) { $reportDomainPrefix = $Matches[1].Trim() } $startedLine = $logHead | Where-Object { $_ -match 'Started:\s+(.+)' } if ($startedLine -and $Matches[1]) { $assessedAt = $Matches[1].Trim() } } # Determine output path if (-not $OutputPath) { $suffix = if ($reportDomainPrefix) { "_$reportDomainPrefix" } else { '' } $OutputPath = Join-Path -Path $AssessmentFolder -ChildPath "_Assessment-Report$suffix.html" } # ------------------------------------------------------------------ # Load section data, build findings list, and export XLSX # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'Get-RemediationLane.ps1') . (Join-Path -Path $PSScriptRoot -ChildPath 'Build-ReportData.ps1') . (Join-Path -Path $PSScriptRoot -ChildPath 'Build-SectionHtml.ps1') # $allCisFindings and $sectionData are now set in scope # ------------------------------------------------------------------ # Build REPORT_DATA JSON # ------------------------------------------------------------------ $xlsxName = if ($reportDomainPrefix) { "_Compliance-Matrix_$reportDomainPrefix.xlsx" } else { '_Compliance-Matrix.xlsx' } $reportTitle = if ($TenantName -ne 'M365 Tenant') { "$TenantName — M365 Security Assessment" } else { 'M365 Security Assessment' } # #812: load the deficit map written by Test-GraphPermissions / Test-GraphAppRolePermissions. # Hashtable[]/PSCustomObject is fine -- Build-ReportDataJson just passes it through. $permissionDeficits = $null $deficitPath = Join-Path -Path $AssessmentFolder -ChildPath '_PermissionDeficits.json' if (Test-Path -Path $deficitPath) { try { $permissionDeficits = Get-Content -Path $deficitPath -Raw | ConvertFrom-Json } catch { Write-Warning "Could not parse $deficitPath -- skipping Permissions panel data: $($_.Exception.Message)" } } $reportJson = Build-ReportDataJson ` -AllFindings $allCisFindings ` -SectionData $sectionData ` -RegistryData $controlRegistry ` -WhiteLabel: $WhiteLabel ` -XlsxFileName $xlsxName ` -FrameworkDefs $allFrameworks ` -CmmcHandoff $cmmcHandoff ` -IncludeTrend: $IncludeTrend ` -PermissionDeficits $permissionDeficits # ------------------------------------------------------------------ # Assemble HTML and write output # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'Get-ReportTemplate.ps1') $themeDefaults = @{ 'Neon' = @{ Theme = 'neon'; Mode = 'dark' } 'Console' = @{ Theme = 'console'; Mode = 'dark' } 'Light' = @{ Theme = 'saas'; Mode = 'light' } 'HighContrast' = @{ Theme = 'high-contrast'; Mode = 'dark' } } $htmlTheme = $themeDefaults[$ReportTheme] $html = Get-ReportTemplate ` -ReportDataJson $reportJson ` -ReportTitle $reportTitle ` -DefaultTheme $htmlTheme.Theme ` -DefaultMode $htmlTheme.Mode ` -DefaultDensity ($ReportDensity.ToLower()) Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Output "HTML report generated: $OutputPath" # ------------------------------------------------------------------ # Write bridge JSON for M365-Remediate integration # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'Export-AssessmentBridgeJson.ps1') $tenantIdValue = if ($tenantData -and @($tenantData).Count -gt 0 -and $tenantData[0].PSObject.Properties.Name -contains 'TenantId') { $tenantData[0].TenantId } else { '' } $registryVersionValue = '' $registryJsonPath = Join-Path -Path $projectRoot -ChildPath 'controls/registry.json' if (Test-Path -Path $registryJsonPath) { try { $regMeta = Get-Content -Path $registryJsonPath -Raw | ConvertFrom-Json $registryVersionValue = if ($regMeta.dataVersion) { $regMeta.dataVersion } else { $regMeta.schemaVersion } } catch { Write-Verbose "Could not read registry version: $_" } } $bridgeSuffix = if ($reportDomainPrefix) { "_$reportDomainPrefix" } else { '' } $bridgePath = Join-Path -Path $AssessmentFolder -ChildPath "_Assessment$bridgeSuffix.json" try { $written = Export-AssessmentBridgeJson ` -AllFindings $allCisFindings ` -RegistryData $controlRegistry ` -TenantId $tenantIdValue ` -TenantName $TenantName ` -AssessedAt $assessedAt ` -AssessmentVersion $assessmentVersion ` -RegistryVersion $registryVersionValue ` -OutputPath $bridgePath Write-Output "Bridge JSON written: $written" } catch { Write-Warning "Bridge JSON export failed: $($_.Exception.Message)" } if ($OpenReport) { Start-Process -FilePath $OutputPath } |