Common/Export-AssessmentReport.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Generates branded HTML assessment reports from M365 assessment output. .DESCRIPTION Reads CSV data and metadata from an M365 assessment output folder and produces a self-contained HTML report with M365 Assess branding. The HTML includes embedded CSS, base64-encoded logos, and print-friendly styling that produces clean PDF output when printed from a browser. The report includes: - Branded cover page with tenant name and assessment date - Executive summary with section counts and issue overview - Section-by-section data tables for all collected CSV data - Issue report with severity levels and recommended actions - Footer with version and generation timestamp .PARAMETER AssessmentFolder Path to the assessment output folder (e.g., .\M365-Assessment\Assessment_20260306_195618). Must contain _Assessment-Summary.csv and optionally _Assessment-Issues.log. .PARAMETER OutputPath Path for the generated HTML report. Defaults to _Assessment-Report.html in the assessment folder. .PARAMETER TenantName Tenant display name for the cover page. If not specified, attempts to read from the Tenant Information CSV. .PARAMETER WhiteLabel Strip all M365-Assess and GitHub identity from the report. With CustomBranding keys produces Mode A (your brand). Without produces Mode C (neutral/clean). .PARAMETER FindingsNarrative Path to a .txt or .md file, or an inline string, with consultant-authored commentary. Rendered as a narrative card before the Executive Summary. .PARAMETER CompactReport Omit the cover page, executive summary, and compliance overview sections. Produces a data-only report. Automatically enabled when -QuickScan is used. .PARAMETER CustomBranding Hashtable for white-label reports. Supported keys: CompanyName (string), LogoPath (file path to PNG/JPEG/SVG), AccentColor (hex color like '#1a56db'). .PARAMETER OpenReport Automatically open the generated HTML report in the default browser after generation. Works on Windows, macOS, and Linux. .EXAMPLE PS> .\Common\Export-AssessmentReport.ps1 -AssessmentFolder '.\M365-Assessment\Assessment_20260306_195618' Generates an HTML report in the assessment folder. .EXAMPLE PS> .\Common\Export-AssessmentReport.ps1 -AssessmentFolder '.\M365-Assessment\Assessment_20260306_195618' -TenantName 'Contoso Ltd' Generates a report with the specified tenant name on the cover page. .NOTES Author: Daren9m #> # Variables set here are consumed by dot-sourced companion files # (Build-SectionHtml.ps1, Get-ReportTemplate.ps1) via shared scope. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$AssessmentFolder, [Parameter()] [string]$OutputPath, [Parameter()] [string]$TenantName, [Parameter()] [switch]$WhiteLabel, [Parameter()] [string]$FindingsNarrative, [Parameter()] [switch]$CompactReport, [Parameter()] [hashtable]$CustomBranding, [Parameter()] [ValidateScript({ -not $_ -or (Test-Path -Path $_ -PathType Leaf) })] [string]$CustomerProfile, [Parameter()] [switch]$OpenReport, [Parameter()] [switch]$QuickScan, [Parameter()] [AllowEmptyCollection()] [PSCustomObject[]]$DriftReport = @(), [Parameter()] [string]$DriftBaselineLabel = '', [Parameter()] [string]$DriftBaselineTimestamp = '' ) $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) # ------------------------------------------------------------------ # Load control registry # ------------------------------------------------------------------ . (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 # ------------------------------------------------------------------ # Framework definitions (auto-discovered from JSON) # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'Import-FrameworkDefinitions.ps1') $allFrameworks = Import-FrameworkDefinitions -FrameworksPath (Join-Path -Path $projectRoot -ChildPath 'controls/frameworks') # ------------------------------------------------------------------ # 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 } if (-not $OutputPath) { # Derive domain prefix from tenant data for filename (resolved later, fallback to generic) $reportDomainPrefix = '' $OutputPath = Join-Path -Path $AssessmentFolder -ChildPath '_Assessment-Report.html' } # ------------------------------------------------------------------ # Load assessment data # ------------------------------------------------------------------ $summary = Import-Csv -Path $summaryPath $issueFile = Get-ChildItem -Path $AssessmentFolder -Filter '_Assessment-Issues*.log' -ErrorAction SilentlyContinue | Select-Object -First 1 $issueReportPath = if ($issueFile) { $issueFile.FullName } else { Join-Path -Path $AssessmentFolder -ChildPath '_Assessment-Issues.log' } $issueContent = if (Test-Path -Path $issueReportPath) { Get-Content -Path $issueReportPath -Raw } else { '' } # Load Tenant Info CSV for organization profile card and cover page $tenantCsv = Join-Path -Path $AssessmentFolder -ChildPath '01-Tenant-Info.csv' $tenantData = $null if (Test-Path -Path $tenantCsv) { $tenantData = Import-Csv -Path $tenantCsv } # Load User Summary for enriched organization profile $userSummaryCsv = Join-Path -Path $AssessmentFolder -ChildPath '02-User-Summary.csv' $userSummaryData = $null if (Test-Path -Path $userSummaryCsv) { $userSummaryData = Import-Csv -Path $userSummaryCsv } # Framework mappings are now sourced from the control registry (loaded above). # The $controlRegistry hashtable is keyed by CheckId and contains framework data. 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' } } # Domain prefix is written to the log header by the main script — read it from there # (avoids fragile CSV-scanning; the main script already resolved it from TenantId or Graph) # Read assessment version and cloud environment from log if available $assessmentVersion = (Import-PowerShellDataFile -Path "$PSScriptRoot/../M365-Assess.psd1").ModuleVersion $cloudEnvironment = 'commercial' # Find the log file (may have domain suffix, e.g., _Assessment-Log_contoso.txt) $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] } $cloudLine = $logHead | Where-Object { $_ -match 'Cloud:\s+(.+)' } if ($cloudLine) { $cloudEnvironment = $Matches[1].Trim() } if ($reportDomainPrefix -eq '') { $domainLine = $logHead | Where-Object { $_ -match 'Domain:\s+(\S+)' } if ($domainLine -and $Matches[1]) { $reportDomainPrefix = $Matches[1].Trim() $OutputPath = Join-Path -Path $AssessmentFolder -ChildPath "_Assessment-Report_${reportDomainPrefix}.html" } } } # Map cloud environment to display names and CSS classes $cloudDisplayNames = @{ 'commercial' = 'Commercial' 'gcc' = 'GCC' 'gcchigh' = 'GCC High' 'dod' = 'DoD' } $cloudDisplayName = if ($cloudDisplayNames.ContainsKey($cloudEnvironment)) { $cloudDisplayNames[$cloudEnvironment] } else { $cloudEnvironment } # Get assessment date from folder name $folderName = Split-Path -Leaf $AssessmentFolder $assessmentDate = Get-Date -Format 'MMMM d, yyyy' if ($folderName -match 'Assessment_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})') { $assessmentDate = Get-Date -Year $Matches[1] -Month $Matches[2] -Day $Matches[3] -Format 'MMMM d, yyyy' } # ------------------------------------------------------------------ # Load report helper functions (needed before asset loading below) # ------------------------------------------------------------------ . (Join-Path -Path $PSScriptRoot -ChildPath 'ReportHelpers.ps1') # ------------------------------------------------------------------ # Load and base64-encode logo and background from assets/ # Searches by pattern so any logo-*.png/jpeg or wave/bg-*.png works. # ------------------------------------------------------------------ $assetsDir = Join-Path -Path $projectRoot -ChildPath 'assets' $logoAsset = Get-AssetBase64 -Directory $assetsDir -Patterns @('*logo-cropped*white*', '*logo-cropped*', '*logo-white*', '*logo*') $logoBase64 = if ($logoAsset) { $logoAsset.Base64 } else { '' } $logoMime = if ($logoAsset) { $logoAsset.Mime } else { 'image/png' } $waveAsset = Get-AssetBase64 -Directory $assetsDir -Patterns @('*wave*', '*bg*') $waveBase64 = if ($waveAsset) { $waveAsset.Base64 } else { '' } $waveMime = if ($waveAsset) { $waveAsset.Mime } else { 'image/png' } # ------------------------------------------------------------------ # CustomerProfile: load .psd1 and merge into individual params # ------------------------------------------------------------------ if ($CustomerProfile) { $cpData = Import-PowerShellDataFile -Path $CustomerProfile if ($cpData.CustomBranding -and -not $PSBoundParameters.ContainsKey('CustomBranding')) { $CustomBranding = $cpData.CustomBranding } if ($cpData.FindingsNarrative -and -not $PSBoundParameters.ContainsKey('FindingsNarrative')) { $FindingsNarrative = $cpData.FindingsNarrative } $WhiteLabel = $true } if ($PSBoundParameters.ContainsKey('CustomBranding') -and -not $WhiteLabel) { $WhiteLabel = $true } $brandName = 'M365 Assess' $accentColor = '' $clientLogoBase64 = '' $clientLogoMime = 'image/png' $clientName = '' $reportNote = '' $disclaimer = '' $sidebarSubtitle = '' $footerText = '' $footerUrl = '' $primaryColor = '' $reportTitle = '' if ($CustomBranding) { if ($CustomBranding.ContainsKey('LogoPath') -and (Test-Path -Path $CustomBranding.LogoPath)) { $customLogoBytes = [System.IO.File]::ReadAllBytes($CustomBranding.LogoPath) $logoBase64 = [Convert]::ToBase64String($customLogoBytes) $ext = [System.IO.Path]::GetExtension($CustomBranding.LogoPath).TrimStart('.').ToLower() $logoMime = switch ($ext) { 'jpg' { 'image/jpeg' } 'jpeg' { 'image/jpeg' } 'svg' { 'image/svg+xml' } default { 'image/png' } } } if ($CustomBranding.ContainsKey('ClientLogoPath') -and (Test-Path -Path $CustomBranding.ClientLogoPath)) { $clientLogoBytes = [System.IO.File]::ReadAllBytes($CustomBranding.ClientLogoPath) $clientLogoBase64 = [Convert]::ToBase64String($clientLogoBytes) $ext = [System.IO.Path]::GetExtension($CustomBranding.ClientLogoPath).TrimStart('.').ToLower() $clientLogoMime = switch ($ext) { 'jpg' { 'image/jpeg' } 'jpeg' { 'image/jpeg' } 'svg' { 'image/svg+xml' } default { 'image/png' } } } if ($CustomBranding.ContainsKey('CompanyName')) { $brandName = $CustomBranding.CompanyName } if ($CustomBranding.ContainsKey('ClientName')) { $clientName = $CustomBranding.ClientName } if ($CustomBranding.ContainsKey('AccentColor')) { $accentColor = $CustomBranding.AccentColor } if ($CustomBranding.ContainsKey('PrimaryColor')) { $primaryColor = $CustomBranding.PrimaryColor } if ($CustomBranding.ContainsKey('ReportNote')) { $reportNote = $CustomBranding.ReportNote } if ($CustomBranding.ContainsKey('Disclaimer')) { $disclaimer = $CustomBranding.Disclaimer } if ($CustomBranding.ContainsKey('SidebarSubtitle')){ $sidebarSubtitle = $CustomBranding.SidebarSubtitle } if ($CustomBranding.ContainsKey('FooterText')) { $footerText = $CustomBranding.FooterText } if ($CustomBranding.ContainsKey('FooterUrl')) { $footerUrl = $CustomBranding.FooterUrl } if ($CustomBranding.ContainsKey('ReportTitle')) { $reportTitle = $CustomBranding.ReportTitle } } # Defaults applied by -WhiteLabel when not explicitly set if ($WhiteLabel) { if (-not $disclaimer) { $disclaimer = 'Confidential — Prepared exclusively for the named recipient. Do not distribute.' } if (-not $footerText -and $brandName -ne 'M365 Assess') { $footerText = "Assessment by $brandName" } } # Resolve FindingsNarrative: file path → read content; inline string → use as-is $findingsNarrativeHtml = '' if ($FindingsNarrative) { $narrativeText = if (Test-Path -Path $FindingsNarrative -ErrorAction SilentlyContinue) { Get-Content -Path $FindingsNarrative -Raw } else { $FindingsNarrative } # Render Markdown if ConvertFrom-Markdown is available and file is .md $isMarkdown = ($FindingsNarrative -match '\.md$') -or ($narrativeText -match '^#{1,6}\s|^\*\*|^-\s') if ($isMarkdown -and (Get-Command -Name ConvertFrom-Markdown -ErrorAction SilentlyContinue)) { $findingsNarrativeHtml = (ConvertFrom-Markdown -InputObject $narrativeText).Html } else { $safeText = [System.Web.HttpUtility]::HtmlEncode($narrativeText) $findingsNarrativeHtml = $safeText -replace "`r?`n", '<br>' } } $frameworkDisplayLabels = @() # ------------------------------------------------------------------ # Compute summary statistics # ------------------------------------------------------------------ $completeCount = @($summary | Where-Object { $_.Status -eq 'Complete' }).Count $skippedCount = @($summary | Where-Object { $_.Status -eq 'Skipped' }).Count $failedCount = @($summary | Where-Object { $_.Status -eq 'Failed' }).Count $totalCollectors = $summary.Count $sections = @($summary | Select-Object -ExpandProperty Section -Unique) # Preferred section display order — sections not listed keep their CSV order at the end $sectionDisplayOrder = @('Tenant','Identity','Hybrid','Licensing','Email','Intune','Security','Collaboration','PowerBI','Inventory','ActiveDirectory','SOC2') $sections = @( foreach ($s in $sectionDisplayOrder) { if ($sections -contains $s) { $s } } foreach ($s in $sections) { if ($sectionDisplayOrder -notcontains $s) { $s } } ) # Parse issues from the log file $issues = [System.Collections.Generic.List[PSCustomObject]]::new() if ($issueContent) { $issueBlocks = $issueContent -split '---\s+Issue\s+\d+\s*/\s*\d+\s+-+' foreach ($block in $issueBlocks) { if ($block -match 'Severity:\s+(.+)') { $severity = $Matches[1].Trim() $section = if ($block -match 'Section:\s+(.+)') { $Matches[1].Trim() } else { '' } $collector = if ($block -match 'Collector:\s+(.+)') { $Matches[1].Trim() } else { '' } $description = if ($block -match 'Description:\s+(.+)') { $Matches[1].Trim() } else { '' } $errorMsg = if ($block -match 'Error:\s+(.+)') { $Matches[1].Trim() } else { '' } $action = if ($block -match 'Action:\s+(.+)') { $Matches[1].Trim() } else { '' } $issues.Add([PSCustomObject]@{ Severity = $severity Section = $section Collector = $collector Description = $description Error = $errorMsg Action = $action }) } } } $errorCount = @($issues | Where-Object { $_.Severity -eq 'ERROR' }).Count $warningCount = @($issues | Where-Object { $_.Severity -eq 'WARNING' }).Count # ------------------------------------------------------------------ # Value Opportunity analysis (if data available) # ------------------------------------------------------------------ $valueOpportunityHtml = '' $voLicensePath = Join-Path -Path $AssessmentFolder -ChildPath '40-License-Utilization.csv' $voAdoptionPath = Join-Path -Path $AssessmentFolder -ChildPath '41-Feature-Adoption.csv' $voReadinessPath = Join-Path -Path $AssessmentFolder -ChildPath '42-Feature-Readiness.csv' $featureMapPath = Join-Path -Path $projectRoot -ChildPath 'controls' -AdditionalChildPath 'sku-feature-map.json' if ((Test-Path -Path $voLicensePath) -and (Test-Path -Path $voAdoptionPath) -and (Test-Path -Path $voReadinessPath) -and (Test-Path -Path $featureMapPath)) { try { . (Join-Path -Path $PSScriptRoot -ChildPath 'Build-ValueOpportunityHtml.ps1') . (Join-Path -Path $projectRoot -ChildPath 'ValueOpportunity' -AdditionalChildPath 'Measure-ValueOpportunity.ps1') $featureMap = Get-Content -Path $featureMapPath -Raw | ConvertFrom-Json $voLicense = Import-Csv -Path $voLicensePath $voAdoption = Import-Csv -Path $voAdoptionPath $voReadiness = Import-Csv -Path $voReadinessPath # CSV import converts booleans to strings -- fix IsLicensed foreach ($row in $voLicense) { $row.IsLicensed = $row.IsLicensed -eq 'True' } $voAnalysis = Measure-ValueOpportunity -LicenseUtilization $voLicense -FeatureAdoption $voAdoption -FeatureReadiness $voReadiness -FeatureMap $featureMap $valueOpportunityHtml = Build-ValueOpportunityHtml -Analysis $voAnalysis } catch { Write-Warning "Could not generate Value Opportunity report section: $_" } } # ------------------------------------------------------------------ # Build report content and assemble HTML (dot-sourced for shared scope) # ------------------------------------------------------------------ # Build section HTML: data tables, dashboards, compliance, TOC, issues . (Join-Path -Path $PSScriptRoot -ChildPath 'Build-SectionHtml.ps1') # Build remediation plan page (requires $allCisFindings set by Build-SectionHtml.ps1) $remediationPlanHtml = Build-RemediationPlanHtml -Findings $allCisFindings -IsQuickScan:$QuickScan # Build drift analysis page (if a baseline comparison was run) $driftHtml = '' if ($DriftBaselineLabel) { . (Join-Path -Path $PSScriptRoot -ChildPath 'Build-DriftHtml.ps1') $driftHtml = Build-DriftHtml ` -DriftReport $DriftReport ` -BaselineLabel $DriftBaselineLabel ` -BaselineTimestamp $DriftBaselineTimestamp } # Assemble full HTML template: CSS, cover page, executive summary, JS . (Join-Path -Path $PSScriptRoot -ChildPath 'Get-ReportTemplate.ps1') # ------------------------------------------------------------------ # Write HTML file # ------------------------------------------------------------------ Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Output "HTML report generated: $OutputPath" if ($OpenReport) { Start-Process -FilePath $OutputPath } |