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 ) 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, COMPLIANCE, DEVICES 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 } } 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 { & $ScriptBlock return } catch { if ($Attempt -eq $MaxAttempts) { Write-Warning "[$OperationName] Failed after $MaxAttempts attempts: $($_.Exception.Message)" throw } Write-Host " [RETRY] $OperationName attempt $Attempt/$MaxAttempts failed. Retrying in ${CurrentDelay}s..." -ForegroundColor Yellow Start-Sleep -Seconds $CurrentDelay $CurrentDelay = [math]::Min($CurrentDelay * 2, 60) } } } function ConvertTo-HashToYN { <# .SYNOPSIS Converts boolean values in a hashtable to Yes/No strings for report display. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [hashtable]$InputHash ) $Result = [ordered]@{} foreach ($Key in $InputHash.Keys) { $Val = $InputHash[$Key] if ($Val -is [bool]) { $Result[$Key] = if ($Val) { 'Yes' } else { 'No' } } elseif ($null -eq $Val) { $Result[$Key] = '--' } else { $Result[$Key] = $Val } } return $Result } function Show-AbrDebugExecutionTime { <# .SYNOPSIS Records and optionally displays section execution timing. #> [CmdletBinding()] param ( [switch]$Start, [switch]$End, [string]$TitleMessage ) if ($Start) { $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew() Write-AbrDebugLog "Section [$TitleMessage] started" 'DEBUG' 'TIMING' } if ($End -and $script:SectionTimers.ContainsKey($TitleMessage)) { $script:SectionTimers[$TitleMessage].Stop() $elapsed = $script:SectionTimers[$TitleMessage].Elapsed.TotalSeconds Write-AbrDebugLog "Section [$TitleMessage] completed in $([math]::Round($elapsed,2))s" 'INFO' 'TIMING' } } function Write-AbrSectionError { <# .SYNOPSIS Writes a standardised error paragraph inside a PScribo section. Detects 403 Forbidden and surfaces either a licensing or permissions note depending on whether Graph scopes were fully confirmed at connection time. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Section, [Parameter(Mandatory)] [string]$Message ) if ($Message -like '*Forbidden*' -or $Message -like '*403*') { if ($script:GraphScopesFullyConfirmed -eq $true) { # Scopes OK + 403 = feature not available or not licensed on this tenant Paragraph " [!] '$Section' returned 403 Forbidden. This feature may not be licensed or provisioned on this tenant (e.g. Proactive Remediations requires Intune Plan 2 / Intune Suite). Skipping section." Write-TranscriptLog "'$Section' not available on this tenant (403 with confirmed scopes -- likely licensing)" 'WARNING' $Section } else { # Missing scopes + 403 = genuine permissions issue Paragraph " [!] Insufficient permissions for '$Section': the authenticated account requires 'Intune Service Administrator' or 'Global Administrator' role." Write-TranscriptLog "Insufficient permissions for '$Section': $Message" 'WARNING' $Section } } else { Paragraph " [!] Error collecting data for '$Section': $Message" Write-TranscriptLog "Error in section '$Section': $Message" 'ERROR' $Section } } function Export-IntuneToExcel { <# .SYNOPSIS Exports collected Intune data sheets to an Excel workbook using ImportExcel. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary]$Sheets, [Parameter(Mandatory)] [string]$Path, [string]$TenantId = '' ) $ImportExcelAvailable = Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue if (-not $ImportExcelAvailable) { Write-Host ' - ImportExcel module not found. Skipping Excel export.' -ForegroundColor Yellow Write-TranscriptLog 'ImportExcel module not found. Excel export skipped.' 'WARNING' 'EXPORT' return } Import-Module ImportExcel -ErrorAction SilentlyContinue Write-Host " - Writing $($Sheets.Count) sheet(s) to: $Path" -ForegroundColor Cyan foreach ($SheetName in $Sheets.Keys) { $Data = $Sheets[$SheetName] if (-not $Data -or @($Data).Count -eq 0) { continue } try { $ExcelParams = @{ Path = $Path WorksheetName = $SheetName AutoSize = $true AutoFilter = $true BoldTopRow = $true FreezeTopRow = $true PassThru = $false } if (Test-Path $Path) { $ExcelParams['Append'] = $true } $Data | Export-Excel @ExcelParams Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)" -ForegroundColor DarkGray } catch { Write-Warning " - Failed to write sheet '$SheetName': $($_.Exception.Message)" } } Write-Host " - Excel workbook saved: $Path" -ForegroundColor Green Write-TranscriptLog "Excel workbook saved: $Path" 'SUCCESS' 'EXPORT' } function Write-AbrPermissionError { <# .SYNOPSIS Writes a standardised 403-error paragraph inside a PScribo section. When Graph scopes are fully confirmed, interprets 403 as a licensing/feature availability issue rather than an RBAC permissions problem. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Section, [Parameter(Mandatory)] [string]$RequiredRole, [string]$Message = '' ) if ($script:GraphScopesFullyConfirmed -eq $true) { # All scopes present + Global Admin + 403 = feature not licensed/provisioned on tenant Paragraph " [!] '$Section' is not available on this tenant (403 with all scopes confirmed). This feature may require a specific Intune licence (e.g. Intune Plan 2 / Intune Suite for Proactive Remediations, or Intune Plan 1 for scripts). Skipping section." Write-TranscriptLog "'$Section' not available on this tenant -- likely a licensing or feature provisioning issue" 'WARNING' $Section } else { $detail = if ($Message) { " ($Message)" } else { '' } Paragraph " [!] Insufficient permissions for '$Section'$detail. The authenticated account requires '$RequiredRole' role in Entra ID. Grant the role and re-run the report." Write-TranscriptLog "Insufficient permissions for '$Section' -- requires: $RequiredRole" 'WARNING' $Section } } function Test-AbrGraphForbidden { <# .SYNOPSIS Returns $true if an exception is a 403 Forbidden Graph API response. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) return ($ErrorRecord.Exception.Message -like '*Forbidden*' -or $ErrorRecord.Exception.Message -like '*403*') } function Get-IntuneExcelSheetEnabled { <# .SYNOPSIS Returns $true if a given Excel sheet is enabled in Options.ExcelExport.Sheets. Returns $false if ExcelExport.Enabled is globally false or the sheet key is set to false. Defaults to $true if the key is absent (backwards compatible with old config files). #> [CmdletBinding()] [OutputType([bool])] param([string]$SheetKey) if (-not $script:ExcelEnabled) { return $false } $sheetsConfig = $script:Options.ExcelExport.Sheets if ($null -eq $sheetsConfig) { return $true } # entire Sheets block absent = all enabled $val = $sheetsConfig.$SheetKey if ($null -eq $val) { return $true } # specific key absent = enabled return ($val -eq $true) } function Get-IntuneBackupSectionEnabled { <# .SYNOPSIS Returns $true if a given backup section is enabled in Options.JsonBackup.IncludeSections. Returns $false if JsonBackup.Enabled is globally false or the section key is set to false. Defaults to $true if the key is absent (backwards compatible with old config files). #> [CmdletBinding()] [OutputType([bool])] param([string]$SectionKey) if (-not $script:JsonBackupEnabled) { return $false } $sectionsConfig = $script:Options.JsonBackup.IncludeSections if ($null -eq $sectionsConfig) { return $true } $val = $sectionsConfig.$SectionKey if ($null -eq $val) { return $true } # key absent = enabled return ($val -eq $true) } |