Src/Private/Helpers.ps1
|
function Test-UserPrincipalName { <# .SYNOPSIS Validates that a string is a properly formatted User Principal Name. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [string]$UserPrincipalName ) # RFC-compliant UPN: localpart@domain.tld return ($UserPrincipalName -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') } function Write-TranscriptLog { <# .SYNOPSIS Writes a structured log entry to the host and optionally to a transcript file. .DESCRIPTION Provides consistent log output across the module. Level values: DEBUG | INFO | SUCCESS | WARNING | ERROR Category is a short tag e.g. AUTH, MFA, USERS, CA etc. #> [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0)] [string]$Message, [Parameter(Position = 1)] [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')] [string]$Level = 'INFO', [Parameter(Position = 2)] [string]$Category = 'GENERAL' ) $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $Entry = "[$Timestamp] [$Level] [$Category] $Message" $null = switch ($Level) { 'DEBUG' { Write-PScriboMessage -Message $Entry } 'INFO' { Write-Host " $Entry" } 'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green } 'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry } 'ERROR' { Write-Host " $Entry" -ForegroundColor Red } } # Append to transcript log file if path is set in script scope if ($script:TranscriptLogPath) { try { Add-Content -Path $script:TranscriptLogPath -Value $Entry -ErrorAction SilentlyContinue | Out-Null } catch { } } } function Invoke-WithRetry { <# .SYNOPSIS Executes a ScriptBlock with configurable retry logic and exponential back-off. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [scriptblock]$ScriptBlock, [Parameter(Mandatory)] [string]$OperationName, [int]$MaxAttempts = 3, [int]$DelaySeconds = 5 ) $Attempt = 0 $CurrentDelay = $DelaySeconds while ($Attempt -lt $MaxAttempts) { $Attempt++ try { Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts}: $OperationName" 'DEBUG' 'RETRY' $null = (& $ScriptBlock) Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} succeeded: $OperationName" 'DEBUG' 'RETRY' return } catch { if ($Attempt -lt $MaxAttempts) { Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} failed for '$OperationName'. Retrying in ${CurrentDelay}s... Error: $($_.Exception.Message)" 'WARNING' 'RETRY' Start-Sleep -Seconds $CurrentDelay $CurrentDelay = $CurrentDelay * 2 } else { Write-TranscriptLog "All ${MaxAttempts} attempts failed for '$OperationName'. Error: $($_.Exception.Message)" 'ERROR' 'RETRY' throw } } } } function Show-AbrDebugExecutionTime { <# .SYNOPSIS Tracks and displays execution time for report sections. Compatible shim for AsBuiltReport.Core's Show-AbrDebugExecutionTime. #> [CmdletBinding()] param ( [switch]$Start, [switch]$End, [string]$TitleMessage ) if ($Start) { $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew() Write-TranscriptLog "Starting section: $TitleMessage" 'DEBUG' 'TIMER' } if ($End) { if ($script:SectionTimers -and $script:SectionTimers.ContainsKey($TitleMessage)) { $script:SectionTimers[$TitleMessage].Stop() $Elapsed = $script:SectionTimers[$TitleMessage].Elapsed.TotalSeconds Write-TranscriptLog "Completed section: $TitleMessage (${Elapsed}s)" 'DEBUG' 'TIMER' $null = $script:SectionTimers.Remove($TitleMessage) } } } function ConvertTo-HashToYN { <# .SYNOPSIS Converts boolean values in a hashtable/ordered-dictionary to Yes/No strings. Compatible shim for AsBuiltReport.Core's ConvertTo-HashToYN. .NOTES Param typed as [System.Collections.IDictionary] so it accepts BOTH [hashtable] (System.Collections.Hashtable) and [ordered]@{} (System.Collections.Specialized.OrderedDictionary) under PowerShell 5.1. Using [hashtable] alone rejects [ordered]@{} in PS5.1, causing booleans to pass through unconverted and trigger PScribo "Unexpected System.Boolean" warnings. #> [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [System.Collections.IDictionary]$Hash ) process { $Result = [ordered]@{} foreach ($Key in $Hash.Keys) { $Value = $Hash[$Key] if ($Value -is [bool]) { $Result[$Key] = if ($Value) { 'Yes' } else { 'No' } } elseif ($null -eq $Value -or $Value -eq '') { $Result[$Key] = '--' } else { $Result[$Key] = $Value } } return $Result } } function Export-EntraIDToExcel { <# .SYNOPSIS Exports one or more arrays of PSCustomObjects to a multi-sheet Excel workbook. .DESCRIPTION Uses the ImportExcel module to generate a formatted .xlsx report. Each key in the Sheets hashtable becomes a worksheet tab. .PARAMETER Sheets Ordered hashtable of SheetName => array of PSCustomObjects. .PARAMETER Path Full file path for the output .xlsx file (e.g. C:\Reports\EntraID.xlsx). .PARAMETER TenantId Tenant name used in the title row of each sheet. .EXAMPLE $Sheets = [ordered]@{ 'MFA Status' = $MfaData 'Auth Methods' = $AuthMethodData } Export-EntraIDToExcel -Sheets $Sheets -Path 'C:\Reports\EntraID_MFA.xlsx' -TenantId 'contoso.com' #> [CmdletBinding()] param ( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary]$Sheets, [Parameter(Mandatory)] [string]$Path, [string]$TenantId = 'Unknown Tenant' ) # Verify ImportExcel is available if (-not (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue)) { Write-Warning "ImportExcel module is not installed. Run 'Install-Module -Name ImportExcel -Force' to enable Excel exports." Write-Warning "Falling back to CSV export..." foreach ($SheetName in $Sheets.Keys) { $CsvPath = $Path -replace '\.xlsx$', "_$($SheetName -replace '\s','_').csv" $Sheets[$SheetName] | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 Write-Host " - CSV exported: $CsvPath" -ForegroundColor Cyan } return } Import-Module ImportExcel -ErrorAction Stop # Remove existing file to avoid sheet conflicts if (Test-Path $Path) { Remove-Item $Path -Force } $SheetIndex = 0 foreach ($SheetName in $Sheets.Keys) { $Data = $Sheets[$SheetName] if (-not $Data -or @($Data).Count -eq 0) { Write-TranscriptLog "Sheet '$SheetName' has no data -- skipping." 'WARNING' 'EXPORT' continue } $ExcelParams = @{ Path = $Path WorksheetName = $SheetName AutoSize = $true AutoFilter = $true FreezeTopRow = $true BoldTopRow = $true TableStyle = 'Medium9' Append = ($SheetIndex -gt 0) PassThru = $false } $Data | Export-Excel @ExcelParams $SheetIndex++ Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)." -ForegroundColor Cyan } Write-Host " - Excel report saved to: $Path" -ForegroundColor Green Write-TranscriptLog "Excel export complete: $Path" 'SUCCESS' 'EXPORT' } function Write-AbrSectionError { <# .SYNOPSIS Logs a section-level error and writes a placeholder Paragraph to the document. The Paragraph ensures the parent Section is never left empty, which would cause PScribo's Word exporter to crash with 'Index was outside the bounds of the array'. Enhanced: also captures full exception detail, inner exception, stack trace, and PowerShell call stack to a dedicated diagnostic log for faster debugging. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Section, [Parameter(Mandatory)] [string]$Message, # Pass $_ (the ErrorRecord) from a catch block for full diagnostic capture [System.Management.Automation.ErrorRecord]$ErrorRecord = $null ) # Log to PScribo warning stream Write-PScriboMessage -IsWarning -Message "${Section}: ${Message}" # Write a visible placeholder to the document so the section is never empty. Paragraph "Unable to retrieve data for this section. Error: $Message" # Log to transcript Write-TranscriptLog "${Section}: ${Message}" 'ERROR' $Section # ---- Deep diagnostic capture ---- $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff' $DiagLines = [System.Collections.Generic.List[string]]::new() $null = $DiagLines.Add("[$Timestamp] [SECTION-ERROR] [$Section]") $null = $DiagLines.Add(" Message : $Message") if ($ErrorRecord) { $null = $DiagLines.Add(" Exception : $($ErrorRecord.Exception.GetType().FullName)") $null = $DiagLines.Add(" ExMsg : $($ErrorRecord.Exception.Message)") if ($ErrorRecord.Exception.InnerException) { $null = $DiagLines.Add(" InnerExc : $($ErrorRecord.Exception.InnerException.Message)") } $null = $DiagLines.Add(" Category : $($ErrorRecord.CategoryInfo.Category)") $null = $DiagLines.Add(" TargetObj : $($ErrorRecord.TargetObject)") $null = $DiagLines.Add(" ScriptPos : $($ErrorRecord.InvocationInfo.PositionMessage -replace '\r?\n',' | ')") $null = $DiagLines.Add(" StackTrace:") foreach ($line in ($ErrorRecord.ScriptStackTrace -split "`n")) { $null = $DiagLines.Add(" $($line.Trim())") } } # PS call stack (always useful even without ErrorRecord) $null = $DiagLines.Add(" PSCallStack:") foreach ($frame in (Get-PSCallStack | Select-Object -Skip 1 -First 8)) { $null = $DiagLines.Add(" $($frame.Location) | $($frame.Command)") } $null = $DiagLines.Add("") # Write to structured error log try { $Entry = "[$Timestamp] [ERROR] [$Section] $Message" if ($script:ErrorLogPath) { Add-Content -Path $script:ErrorLogPath -Value ($DiagLines -join "`n") -ErrorAction SilentlyContinue } if ($script:ErrorLog) { $null = $script:ErrorLog.Add($Entry) } Write-Host " [ERR] $Entry" -ForegroundColor Red foreach ($dline in $DiagLines) { Write-Host " $dline" -ForegroundColor DarkRed } } catch { } } function Invoke-PScriboDocumentAudit { <# .SYNOPSIS Walks the live PScribo document object tree and reports every structural problem that can cause 'Index was outside the bounds of the array' during Word export. Call this AFTER all Section{} blocks are built but BEFORE New-AsBuiltReport hands off to PScribo's exporter. Problems detected: - Section with zero child elements (empty Section) - Section whose only child is another Section (no direct Paragraph/Table) - Table with zero rows - Table with zero columns - Paragraph with null/empty text - Any PScribo object with a null Id Output goes to the error log file AND to the host in bright yellow so it is impossible to miss in the transcript. #> [CmdletBinding()] param( # The PScribo Document object (accessible as $Document inside PScribo scriptblocks, # or pass the variable that New-AsBuiltReport returns / the $script:Document ref) [Parameter(Mandatory)] [object]$Document, [string]$LogPath = $script:ErrorLogPath ) $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $Problems = [System.Collections.Generic.List[string]]::new() $Checked = 0 function Audit-Node { param([object]$Node, [string]$Path) if ($null -eq $Node) { return } $script:Checked++ $TypeName = $Node.GetType().Name $NodePath = if ($Path) { "$Path > $TypeName" } else { $TypeName } # Add name/title if available $Label = '' foreach ($prop in @('Id','Name','Number','Text')) { try { $val = $Node.$prop if ($val -and "$val".Trim()) { $Label = "[$prop=$val]"; break } } catch {} } $FullPath = "$NodePath $Label" # Check: null Id try { if ($null -eq $Node.Id -or "$($Node.Id)".Trim() -eq '') { $null = $Problems.Add(" [NULL-ID] $FullPath -- Object has null/empty Id (PScribo will crash on array lookup)") } } catch {} # Check: Table specifics if ($TypeName -like '*Table*') { try { $rows = $Node.Rows if ($null -eq $rows -or @($rows).Count -eq 0) { $null = $Problems.Add(" [EMPTY-TABLE] $FullPath -- Table has ZERO rows") } } catch {} try { $cols = $Node.Columns if ($null -eq $cols -or @($cols).Count -eq 0) { $null = $Problems.Add(" [NO-COLUMNS] $FullPath -- Table has ZERO columns") } } catch {} } # Check: Paragraph specifics if ($TypeName -like '*Paragraph*') { try { $runs = $Node.Runs $hasText = $false if ($runs) { foreach ($r in $runs) { if ($r.Text -and "$($r.Text)".Trim()) { $hasText = $true; break } } } if (-not $hasText) { try { $txt = $Node.Text if (-not ($txt -and "$txt".Trim())) { $null = $Problems.Add(" [EMPTY-PARA] $FullPath -- Paragraph has no text content") } } catch {} } } catch {} } # Check: Section specifics if ($TypeName -like '*Section*') { try { $children = $Node.Elements if ($null -eq $children -or @($children).Count -eq 0) { $null = $Problems.Add(" [EMPTY-SECT] $FullPath -- Section has ZERO child elements (PScribo Word exporter will crash)") } else { # Recurse into children foreach ($child in $children) { Audit-Node -Node $child -Path $FullPath } } } catch { # Try alternative property names PScribo uses try { $children = $Node.Sections if ($null -eq $children -or @($children).Count -eq 0) { $null = $Problems.Add(" [EMPTY-SECT] $FullPath -- Section has ZERO child elements (PScribo Word exporter will crash)") } else { foreach ($child in $children) { Audit-Node -Node $child -Path $FullPath } } } catch {} } return # already recursed above } # Recurse into any child collection foreach ($prop in @('Elements','Sections','Rows','Columns','Runs')) { try { $children = $Node.$prop if ($children) { foreach ($child in $children) { Audit-Node -Node $child -Path $FullPath } } } catch {} } } Write-Host "" -ForegroundColor Yellow Write-Host " [AUDIT] === PScribo Document Structure Audit ===" -ForegroundColor Yellow Write-Host " [AUDIT] Walking document tree..." -ForegroundColor Yellow # PScribo stores sections differently depending on version -- try common entry points $rootElements = $null foreach ($prop in @('Elements','Sections','Document','Structure')) { try { $val = $Document.$prop if ($val) { $rootElements = $val; break } } catch {} } if ($rootElements) { foreach ($elem in $rootElements) { Audit-Node -Node $elem -Path 'ROOT' } } else { # Try walking the document itself as the root Audit-Node -Node $Document -Path 'ROOT' } $summary = " [AUDIT] Checked $($script:Checked) nodes. Found $($Problems.Count) problem(s)." Write-Host $summary -ForegroundColor $(if ($Problems.Count -gt 0) { 'Red' } else { 'Green' }) if ($Problems.Count -gt 0) { Write-Host " [AUDIT] PROBLEMS FOUND -- these will crash PScribo Word export:" -ForegroundColor Red foreach ($p in $Problems) { Write-Host $p -ForegroundColor Red } } else { Write-Host " [AUDIT] Document structure looks clean." -ForegroundColor Green } # Write to error log if ($LogPath) { $lines = @( "", "[$Timestamp] [AUDIT] === PScribo Document Structure Audit ===", $summary ) + $Problems + @("") try { Add-Content -Path $LogPath -Value ($lines -join "`n") -ErrorAction SilentlyContinue } catch {} } return $Problems } function Write-EntraDebugDiag { <# .SYNOPSIS Writes a structured diagnostic checkpoint to the debug/error log. Call at the start of each section to log Graph module load state, available cmdlets, and PowerShell version -- useful for diagnosing 'command found in module but module could not be loaded' errors. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Section ) $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff' $Lines = [System.Collections.Generic.List[string]]::new() $null = $Lines.Add("[$Timestamp] [DIAG] [$Section] === Section Diagnostic Checkpoint ===") # PowerShell version $null = $Lines.Add(" PSVersion : $($PSVersionTable.PSVersion) | Edition: $($PSVersionTable.PSEdition) | OS: $($PSVersionTable.OS)") # Loaded Graph modules $LoadedGraph = Get-Module -Name 'Microsoft.Graph*' | Select-Object Name, Version if ($LoadedGraph) { $null = $Lines.Add(" Loaded Graph modules ($(@($LoadedGraph).Count)):") foreach ($m in $LoadedGraph) { $null = $Lines.Add(" [LOADED] $($m.Name) v$($m.Version)") } } else { $null = $Lines.Add(" [WARN] No Microsoft.Graph* modules are currently loaded in session!") } # Available (not yet loaded) Graph modules $AvailGraph = Get-Module -ListAvailable -Name 'Microsoft.Graph*' | Group-Object Name | ForEach-Object { $_.Group | Sort-Object Version -Descending | Select-Object -First 1 } | Select-Object Name, Version $null = $Lines.Add(" Available Graph modules ($(@($AvailGraph).Count) unique names installed):") foreach ($m in ($AvailGraph | Sort-Object Name)) { $loaded = if (Get-Module -Name $m.Name) { '[LOADED]' } else { '[NOT LOADED]' } $null = $Lines.Add(" $loaded $($m.Name) v$($m.Version)") } # Graph context try { $ctx = Get-MgContext -ErrorAction SilentlyContinue if ($ctx) { $null = $Lines.Add(" Graph Context: TenantId=$($ctx.TenantId) | Account=$($ctx.Account) | Scopes=$($ctx.Scopes -join ', ')") } else { $null = $Lines.Add(" [WARN] No active Microsoft Graph context (not connected)") } } catch { $null = $Lines.Add(" [WARN] Could not retrieve Graph context: $($_.Exception.Message)") } $null = $Lines.Add("") # Write to error log file and host try { if ($script:ErrorLogPath) { Add-Content -Path $script:ErrorLogPath -Value ($Lines -join "`n") -ErrorAction SilentlyContinue } foreach ($line in $Lines) { Write-Host " $line" -ForegroundColor DarkCyan } } catch { } } # Note: New-AbrE8AssessmentTable and New-AbrCISAssessmentTable # have been moved to Compliance-Helpers.ps1 |