Public/Export-MsecWordReport.ps1
|
function Export-MsecWordReport { <# .SYNOPSIS Build a Word (.docx) report from any pipeline of objects. Detects the shape of the input and renders with the matching template. .DESCRIPTION Shape-aware: the function inspects the first row's properties and picks the right layout. - VMScript : Invoke-MsecAzureVMScript rows (have VmName + ScriptName + Output). One VM per page, with captured Output/Error rendered in Courier New. The ISO-27001-evidence layout. - Generic : anything else. A single Word table whose columns are the visible properties of the first row. Useful for piping things like Get-MsecSecureScore, Get-MsecIntuneConfigurationProfile, etc. Pure-PowerShell via PSWriteOffice (which wraps OfficeIMO). No pandoc, no Word, no Windows-only Interop. Install once: Install-Module PSWriteOffice -Scope CurrentUser .EXAMPLE # VMScript-shaped input -> one VM per page, monospaced script output. Search-MsecAzureResourceGraph -ResourceType VM | Where-Object Running | Invoke-MsecAzureVMScript -ScriptName ntp-status | Export-MsecWordReport -Path ./ntp-evidence.docx -Title 'NTP / Time sync evidence' .EXAMPLE # Anything else -> single banded-row Word table. Get-MsecSecureScore -History 12 | Export-MsecWordReport -Path ./secure-score-history.docx -Title 'Secure Score Trend' .PARAMETER InputObject Pipeline input. Any shape - the function detects it after the pipeline ends. .PARAMETER Path Output .docx path. Existing files are overwritten. .PARAMETER Title Cover-page title. Defaults to a shape-appropriate label when omitted. .PARAMETER Subtitle Optional subtitle / context line (e.g. 'ISO 27001 A.8.17 Evidence'). .PARAMETER TableStyle Word table style for the Generic shape. Defaults to 'PlainTable3' (banded rows + bold header). Any value of OfficeIMO.Word.WordTableStyle is valid (TableGrid, GridTable1Light, GridTable4Accent1, ...). Ignored for the VMScript shape. .OUTPUTS FileInfo for the produced .docx. #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory, ValueFromPipeline)] [psobject] $InputObject, [Parameter(Mandatory)] [string] $Path, [string] $Title, [string] $Subtitle, [string] $TableStyle = 'PlainTable3' ) begin { if (-not (Get-Module -ListAvailable -Name PSWriteOffice)) { throw 'PSWriteOffice is required for Export-MsecWordReport. Install with: Install-Module PSWriteOffice -Scope CurrentUser' } Import-Module PSWriteOffice -ErrorAction Stop # Collect rows so we know the total for the cover page AND can inspect the # first row's shape before deciding the layout. $rows = [System.Collections.Generic.List[psobject]]::new() } process { $rows.Add($InputObject) } end { if ($rows.Count -eq 0) { Write-Warning 'No input rows; nothing to export.' return } # Shape detection: VMScript rows are identified by the three properties that # together don't occur on any other msec output shape. Everything else falls # to a flat Word table. $names = $rows[0].PSObject.Properties.Name $shape = if (($names -contains 'VmName') -and ($names -contains 'ScriptName') -and ($names -contains 'Output')) { 'VMScript' } else { 'Generic' } if (-not $Title) { $Title = if ($shape -eq 'VMScript') { 'msec VM script report' } else { 'msec report' } } $generatedAt = (Get-Date).ToUniversalTime().ToString('u') # The DSL scriptblock runs in PSWriteOffice's context - GetNewClosure captures # the surrounding state (Title/Subtitle/rows/shape/generatedAt/TableStyle) so # they resolve inside. $content = { # Cover (common to both shapes) Add-OfficeWordParagraph -Text $Title -Style 'Heading1' | Out-Null if ($Subtitle) { Add-OfficeWordParagraph -Text $Subtitle -Style 'Heading2' | Out-Null } Add-OfficeWordParagraph -Text "Generated: $generatedAt" | Out-Null Add-OfficeWordParagraph -Text "Total rows: $($rows.Count)" | Out-Null if ($shape -eq 'VMScript') { # One section per VM - default section break is NextPage, so each # VM is its own page. Property accesses are guarded against optional # fields (ResourceGroupName, Os, Status, ...) so this still works # for hand-built rows that have only VmName + ScriptName + Output. foreach ($r in $rows) { Add-OfficeWordSection { Add-OfficeWordParagraph -Text "VM: $($r.VmName)" -Style 'Heading1' | Out-Null if ($r.PSObject.Properties.Name -contains 'ResourceGroupName') { Add-OfficeWordParagraph -Text "Resource group: $($r.ResourceGroupName)" | Out-Null } if ($r.PSObject.Properties.Name -contains 'Os') { Add-OfficeWordParagraph -Text "OS: $($r.Os)" | Out-Null } Add-OfficeWordParagraph -Text "Script: $($r.ScriptName)" | Out-Null if ($r.PSObject.Properties.Name -contains 'Status') { Add-OfficeWordParagraph -Text "Status: $($r.Status)" | Out-Null } if ($r.PSObject.Properties.Name -contains 'DurationSeconds' -and $null -ne $r.DurationSeconds) { Add-OfficeWordParagraph -Text "Duration: $($r.DurationSeconds) s" | Out-Null } Add-OfficeWordParagraph -Text '' | Out-Null Add-OfficeWordParagraph -Text 'Output' -Style 'Heading2' | Out-Null if ($r.Output) { foreach ($line in ($r.Output -split "`n")) { $p = Add-OfficeWordParagraph -Text ($line.TrimEnd("`r")) -PassThru [void]$p.SetFontFamily('Courier New') [void]$p.SetFontSize(10) } } else { Add-OfficeWordParagraph -Text '(no output)' | Out-Null } if (($r.PSObject.Properties.Name -contains 'Error') -and $r.Error) { Add-OfficeWordParagraph -Text '' | Out-Null Add-OfficeWordParagraph -Text 'Error' -Style 'Heading2' | Out-Null foreach ($line in ($r.Error -split "`n")) { $p = Add-OfficeWordParagraph -Text ($line.TrimEnd("`r")) -PassThru [void]$p.SetFontFamily('Courier New') [void]$p.SetFontSize(10) } } } | Out-Null } } else { # Generic: dump all rows as a single Word table. Columns are # auto-derived from the first row's visible properties by # PSWriteOffice. Add-OfficeWordParagraph -Text '' | Out-Null Add-OfficeWordTable -InputObject $rows -Style $TableStyle | Out-Null } }.GetNewClosure() New-OfficeWord -OutputPath $Path -Content $content | Out-Null Get-Item -LiteralPath $Path } } |