Public/Export-M365HygieneReport.ps1
|
<# .SYNOPSIS Writes an audit result to a self-contained HTML report and CSV exports. .DESCRIPTION Produces three files in the output directory: *-report.html the readable report: severity summary, coverage, findings with evidence and remediation. One file, no external assets. *-findings.csv one row per finding, for a spreadsheet or a ticket queue. *-checks.csv one row per check, including the ones that were skipped or failed and why. The coverage CSV exists so the incompleteness survives the export. A findings CSV on its own cannot tell a reader that three checks never ran, and that is exactly the fact most likely to be lost once the data is pasted into a spreadsheet. .PARAMETER Audit The object returned by Invoke-M365HygieneAudit. Accepts pipeline input. .PARAMETER Path Output directory. Created if it does not exist. .PARAMETER Prefix File name prefix. Defaults to a timestamp, so repeated runs do not overwrite each other and a history builds up on its own. .PARAMETER Format Which artefacts to produce. Defaults to all of them. .PARAMETER PassThru Emit the created file paths. .EXAMPLE Invoke-M365HygieneAudit | Export-M365HygieneReport -Path ./reports .EXAMPLE Export-M365HygieneReport -Audit $audit -Path ./reports -Format Html -PassThru .OUTPUTS System.IO.FileInfo when -PassThru is used. #> function Export-M365HygieneReport { [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] $Audit, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string] $Prefix, [ValidateSet('Html', 'Csv', 'Sarif', 'All')] [string] $Format = 'All', [switch] $PassThru ) process { if (-not $Audit.PSObject.Properties.Name.Contains('Findings')) { throw 'The object supplied to -Audit is not an audit result. Pass the output of Invoke-M365HygieneAudit.' } $directory = New-Item -Path $Path -ItemType Directory -Force -ErrorAction Stop if (-not $Prefix) { $Prefix = 'hygiene-' + $Audit.CompletedAt.ToString('yyyyMMdd-HHmmss') } $created = [System.Collections.Generic.List[System.IO.FileInfo]]::new() # --- HTML ----------------------------------------------------------------- if ($Format -in @('Html', 'All')) { $htmlPath = Join-Path $directory.FullName "$Prefix-report.html" if ($PSCmdlet.ShouldProcess($htmlPath, 'Write HTML report')) { $html = ConvertTo-HygieneHtmlReport -Audit $Audit # UTF-8 without BOM: browsers honour the meta charset, and a BOM # shows up as stray characters in some downstream tooling. $encoding = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($htmlPath, $html, $encoding) $created.Add((Get-Item -LiteralPath $htmlPath)) Write-HygieneLog -Level Information -Message "HTML report written to $htmlPath" } } # --- CSV ------------------------------------------------------------------ if ($Format -in @('Csv', 'All')) { $findingsPath = Join-Path $directory.FullName "$Prefix-findings.csv" if ($PSCmdlet.ShouldProcess($findingsPath, 'Write findings CSV')) { $rows = foreach ($finding in $Audit.Findings) { [pscustomobject]@{ Severity = $finding.Severity Category = $finding.Category Check = $finding.CheckName CheckId = $finding.CheckId ObjectType = $finding.ObjectType ObjectName = $finding.ObjectName ObjectId = $finding.ObjectId Title = $finding.Title Detail = $finding.Detail Recommendation = $finding.Recommendation Reference = $finding.Reference DetectedAtUtc = $finding.DetectedAt.ToString('o') Evidence = (ConvertTo-HygieneEvidenceJson -Evidence $finding.Evidence) -replace '\r?\n', ' ' } } # -NoTypeInformation keeps the header row usable in Excel. @($rows) | Export-Csv -LiteralPath $findingsPath -NoTypeInformation -Encoding utf8 $created.Add((Get-Item -LiteralPath $findingsPath)) Write-HygieneLog -Level Information -Message "Findings CSV written to $findingsPath ($(@($rows).Count) row(s))." } $checksPath = Join-Path $directory.FullName "$Prefix-checks.csv" if ($PSCmdlet.ShouldProcess($checksPath, 'Write coverage CSV')) { $checkRows = foreach ($check in $Audit.Checks) { [pscustomobject]@{ CheckId = $check.CheckId Check = $check.CheckName Category = $check.Category Status = $check.Status ObjectsEvaluated = $check.ObjectsEvaluated FindingCount = $check.FindingCount DurationSeconds = [math]::Round($check.Duration.TotalSeconds, 2) RequiredScopes = ($check.RequiredScopes -join '; ') Reason = $check.Reason Error = $check.Error } } @($checkRows) | Export-Csv -LiteralPath $checksPath -NoTypeInformation -Encoding utf8 $created.Add((Get-Item -LiteralPath $checksPath)) Write-HygieneLog -Level Information -Message "Coverage CSV written to $checksPath" } } # --- SARIF (Pro) ---------------------------------------------------------- # Requested by name only; 'All' does not silently include it. Gated behind a Pro # license -- the free tier already produces the HTML report and both CSVs. if ($Format -eq 'Sarif') { $ent = Get-HygieneEntitlement if (-not ($ent.Pro -and $ent.Features -contains 'sarif')) { Write-Warning ( "The SARIF export needs an M365TenantHygiene Pro license. " + "Activate one with Enable-M365HygienePro, or see " + "https://github.com/earbona23/m365-tenant-hygiene#pro . " + "The HTML report and CSVs are free.") } else { $sarifPath = Join-Path $directory.FullName "$Prefix.sarif" if ($PSCmdlet.ShouldProcess($sarifPath, 'Write SARIF report')) { $sarif = ConvertTo-HygieneSarif -Audit $Audit $encoding = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($sarifPath, $sarif, $encoding) $created.Add((Get-Item -LiteralPath $sarifPath)) Write-HygieneLog -Level Information -Message "SARIF report written to $sarifPath" } } } if ($PassThru) { $created } } } |