Invoke-M365Assessment.ps1
|
<#
.SYNOPSIS Runs a comprehensive read-only Microsoft 365 environment assessment. .DESCRIPTION Orchestrates all M365 assessment collector scripts to produce a folder of CSV reports covering identity, email, security, devices, collaboration, and hybrid sync. Each section runs independently — failures in one section do not block others. All operations are strictly read-only (Get-* cmdlets only). Designed for IT consultants assessing SMB clients (10-500 users) with Microsoft-based cloud environments. .NOTES Author: Daren9m .PARAMETER Section One or more assessment sections to run. Valid values: Tenant, Identity, Licensing, Email, Intune, Security, Collaboration, Hybrid, PowerBI, Inventory, ActiveDirectory, SOC2. Defaults to all standard sections. Inventory, ActiveDirectory, and SOC2 are opt-in only. .PARAMETER TenantId Tenant ID or domain (e.g., 'contoso.onmicrosoft.com'). .PARAMETER OutputFolder Root folder for assessment output. A timestamped subfolder is created automatically. Defaults to '.\M365-Assessment'. .PARAMETER SkipConnection Use pre-existing service connections instead of connecting automatically. .PARAMETER ClientId Application (client) ID for app-only authentication. .PARAMETER CertificateThumbprint Certificate thumbprint for app-only authentication. .PARAMETER ClientSecret Client secret for app-only authentication. Less secure than certificate auth -- prefer -CertificateThumbprint for production use. .PARAMETER UserPrincipalName User principal name (e.g., 'admin@contoso.onmicrosoft.com') for interactive authentication to Exchange Online and Purview. Specifying this can bypass Windows Authentication Manager (WAM) broker errors on some systems. .PARAMETER ManagedIdentity Use Azure managed identity authentication. Requires the script to be running on an Azure resource with a system-assigned or user-assigned managed identity (e.g., Azure VM, Azure Functions, Azure Automation). Purview and Power BI do not support managed identity and will fall back with a warning. .PARAMETER UseDeviceCode Use device code authentication flow instead of browser-based interactive auth. Displays a code and URL that you can open in any browser profile, which is useful on machines with multiple Edge profiles (e.g., corporate + GCC). Note: Purview (Security & Compliance) does not support device code and will fall back to browser-based or UPN-hint authentication. .PARAMETER M365Environment Target cloud environment for all service connections. Commercial and GCC use standard endpoints. GCCHigh and DoD use sovereign cloud endpoints. Auto-detected from tenant metadata when not explicitly specified. .PARAMETER SkipDLP Skips the DLP Policies collector and its Purview (Security & Compliance) connection. Purview connection adds ~46 seconds of latency, so use this switch when DLP policy assessment is not needed. .PARAMETER SkipComplianceOverview Omit the Compliance Overview section from the HTML report. Useful when running a single section assessment where framework coverage cards are not relevant. .PARAMETER SkipCoverPage Omit the branded cover page from the HTML report. .PARAMETER SkipExecutiveSummary Omit the executive summary hero panel from the HTML report. .PARAMETER SkipPdf Skip PDF generation even when wkhtmltopdf is available. .PARAMETER FrameworkFilter Limit the compliance overview to specific framework families. .PARAMETER CustomBranding Hashtable for white-label reports. Keys: CompanyName, LogoPath, AccentColor. .PARAMETER FrameworkExport Generate standalone per-framework HTML catalog exports. Specify framework families or 'All'. Output files are named _<Framework>-Catalog_<tenant>.html. .PARAMETER CisBenchmarkVersion CIS benchmark version to use for framework rendering. Defaults to 'v6' (CIS Microsoft 365 v6.0.1). Set to 'v7' when CIS v7.0 data is available. .PARAMETER NonInteractive Suppresses all interactive prompts for module installation, EXO downgrade, and script unblocking. When a required module is missing or incompatible, the exact install/fix command is logged and the script exits with an error. When an optional module is missing (e.g., MicrosoftPowerBIMgmt), the dependent section is skipped with a warning and the assessment continues. Use this switch for CI/CD pipelines, scheduled tasks, and headless environments. Also triggered automatically when the session is not user-interactive ([Environment]::UserInteractive is false). .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -TenantId 'contoso.onmicrosoft.com' Runs a full assessment with interactive authentication and exports CSVs. .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -Section Identity,Email -TenantId 'contoso.onmicrosoft.com' Runs only the Identity and Email sections. .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -SkipConnection Runs all sections using pre-existing service connections. .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -CertificateThumbprint 'ABC123' Runs a full assessment using certificate-based app-only auth. .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -TenantId 'contoso.onmicrosoft.com' -UserPrincipalName 'admin@contoso.onmicrosoft.com' Runs a full assessment using UPN-based auth for EXO/Purview (avoids WAM broker errors). .EXAMPLE PS> .\Invoke-M365Assessment.ps1 -TenantId 'contoso.onmicrosoft.us' -UseDeviceCode Runs a full assessment using device code auth. You choose which browser profile to authenticate in (useful for multi-profile machines). #> #Requires -Version 7.0 function Invoke-M365Assessment { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'connectedServices', Justification = 'Used by Connect-RequiredService in Orchestrator/ via parent scope')] param( [Parameter()] [ValidateSet('Tenant', 'Identity', 'Licensing', 'Email', 'Intune', 'Security', 'Collaboration', 'PowerBI', 'Hybrid', 'Inventory', 'ActiveDirectory', 'SOC2')] [string[]]$Section = @('Tenant', 'Identity', 'Licensing', 'Email', 'Intune', 'Security', 'Collaboration', 'PowerBI', 'Hybrid'), [Parameter()] [string]$TenantId, [Parameter()] [ValidateNotNullOrEmpty()] [string]$OutputFolder = '.\M365-Assessment', [Parameter()] [switch]$SkipConnection, [Parameter()] [string]$ClientId, [Parameter()] [string]$CertificateThumbprint, [Parameter()] [SecureString]$ClientSecret, [Parameter()] [string]$UserPrincipalName, [Parameter()] [switch]$ManagedIdentity, [Parameter()] [switch]$UseDeviceCode, [Parameter()] [ValidateSet('commercial', 'gcc', 'gcchigh', 'dod')] [string]$M365Environment = 'commercial', [Parameter()] [switch]$NoBranding, [Parameter()] [switch]$SkipDLP, [Parameter()] [switch]$SkipComplianceOverview, [Parameter()] [switch]$SkipCoverPage, [Parameter()] [switch]$SkipExecutiveSummary, [Parameter()] [switch]$SkipPdf, [Parameter()] [ValidateSet('CIS','NIST','ISO','STIG','PCI','CMMC','HIPAA','CISA','SOC2','FedRAMP','Essential8','MITRE','CISv8')] [string[]]$FrameworkFilter, [Parameter()] [hashtable]$CustomBranding, [Parameter()] [ValidateSet('CIS','NIST','ISO','STIG','PCI','CMMC','HIPAA','CISA','SOC2','FedRAMP','Essential8','MITRE','All')] [string[]]$FrameworkExport, [Parameter()] [ValidatePattern('^v\d+$')] [string]$CisBenchmarkVersion = 'v6', [Parameter()] [switch]$NonInteractive ) $ErrorActionPreference = 'Stop' # ------------------------------------------------------------------ # Version — read from module manifest (single source of truth) # ------------------------------------------------------------------ $projectRoot = if ($PSCommandPath) { Split-Path -Parent $PSCommandPath } else { $PSScriptRoot } $script:AssessmentVersion = (Import-PowerShellDataFile -Path "$projectRoot/M365-Assess.psd1").ModuleVersion # When invoked directly (not via module), load internal dependencies if (-not (Get-Command -Name Show-InteractiveWizard -ErrorAction SilentlyContinue)) { Get-ChildItem -Path (Join-Path $projectRoot 'Orchestrator') -Filter '*.ps1' | ForEach-Object { . $_.FullName } } # Show-InteractiveWizard -- extracted to Orchestrator/Show-InteractiveWizard.ps1 # Resolve-M365Environment -- extracted to Orchestrator/Resolve-M365Environment.ps1 # ------------------------------------------------------------------ # Detect interactive mode: no connection parameters supplied # The wizard should launch whenever the user hasn't told us HOW to # connect (TenantId, SkipConnection, or app-only auth). Passing # -Section alone should still trigger the wizard for tenant input. # ------------------------------------------------------------------ $launchWizard = -not $PSBoundParameters.ContainsKey('TenantId') -and -not $PSBoundParameters.ContainsKey('SkipConnection') -and -not $PSBoundParameters.ContainsKey('ClientId') -and -not $PSBoundParameters.ContainsKey('ManagedIdentity') if ($launchWizard -and [Environment]::UserInteractive) { try { $wizSplat = @{} if ($PSBoundParameters.ContainsKey('Section')) { $wizSplat['PreSelectedSections'] = $Section } if ($PSBoundParameters.ContainsKey('OutputFolder')) { $wizSplat['PreSelectedOutputFolder'] = $OutputFolder } $wizardParams = Show-InteractiveWizard @wizSplat } catch { Write-Warning "Interactive wizard failed: $($_.Exception.Message)" Write-Host '' Write-Host ' Run with parameters instead:' -ForegroundColor Yellow Write-Host ' ./Invoke-M365Assessment.ps1 -TenantId "contoso.onmicrosoft.com"' -ForegroundColor Cyan Write-Host '' Write-Host ' For full usage: Get-Help ./Invoke-M365Assessment.ps1 -Full' -ForegroundColor Gray return } if ($null -eq $wizardParams) { return } # Override script parameters with wizard selections, but preserve # any values the user already provided on the command line if (-not $PSBoundParameters.ContainsKey('Section')) { $Section = $wizardParams['Section'] } if (-not $PSBoundParameters.ContainsKey('OutputFolder')) { $OutputFolder = $wizardParams['OutputFolder'] } if ($wizardParams.ContainsKey('TenantId')) { $TenantId = $wizardParams['TenantId'] } if ($wizardParams.ContainsKey('SkipConnection')) { $SkipConnection = [switch]$true } if ($wizardParams.ContainsKey('ClientId')) { $ClientId = $wizardParams['ClientId'] } if ($wizardParams.ContainsKey('CertificateThumbprint')) { $CertificateThumbprint = $wizardParams['CertificateThumbprint'] } if ($wizardParams.ContainsKey('UserPrincipalName')) { $UserPrincipalName = $wizardParams['UserPrincipalName'] } # Report options from wizard if ($wizardParams.ContainsKey('SkipComplianceOverview')) { $SkipComplianceOverview = [switch]$true } if ($wizardParams.ContainsKey('SkipCoverPage')) { $SkipCoverPage = [switch]$true } if ($wizardParams.ContainsKey('SkipExecutiveSummary')) { $SkipExecutiveSummary = [switch]$true } if ($wizardParams.ContainsKey('NoBranding')) { $NoBranding = [switch]$true } if ($wizardParams.ContainsKey('FrameworkFilter') -and -not $PSBoundParameters.ContainsKey('FrameworkFilter')) { $FrameworkFilter = $wizardParams['FrameworkFilter'] } } # ------------------------------------------------------------------ # Auto-detect saved credentials from .m365assess.json or cert store # When TenantId is known but no auth params provided, check for saved # credentials from a previous Setup run. This enables zero-config # repeat runs: just provide -TenantId and the rest is automatic. # ------------------------------------------------------------------ if ($TenantId -and -not $ClientId -and -not $CertificateThumbprint -and -not $ManagedIdentity -and -not $UseDeviceCode -and -not $SkipConnection -and -not $ClientSecret) { $autoDetected = $false # Strategy 1: Check .m365assess.json config file $configPath = Join-Path $projectRoot '.m365assess.json' if (Test-Path $configPath) { try { $savedConfig = Get-Content -Path $configPath -Raw | ConvertFrom-Json -AsHashtable if ($savedConfig.ContainsKey($TenantId)) { $entry = $savedConfig[$TenantId] $savedThumbprint = $entry['thumbprint'] # Verify the certificate still exists in the user's cert store $savedCert = Get-Item "Cert:\CurrentUser\My\$savedThumbprint" -ErrorAction SilentlyContinue if ($savedCert) { $ClientId = $entry['clientId'] $CertificateThumbprint = $savedThumbprint $autoDetected = $true $appLabel = if ($entry['appName']) { " ($($entry['appName']))" } else { '' } Write-Verbose "Auto-detected saved credentials for $TenantId$appLabel" } else { Write-Verbose "Saved cert $savedThumbprint for $TenantId not found in cert store -- skipping auto-detect" } } } catch { Write-Verbose "Could not read .m365assess.json: $_" } } # Strategy 2: Cert store auto-detect (CN=M365-Assess-{TenantId}) if (-not $autoDetected) { $certSubject = "CN=M365-Assess-$TenantId" $matchingCerts = @(Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue | Where-Object { $_.Subject -eq $certSubject -and $_.NotAfter -gt (Get-Date) } | Sort-Object -Property NotAfter -Descending) if ($matchingCerts.Count -gt 0) { $detectedCert = $matchingCerts[0] $CertificateThumbprint = $detectedCert.Thumbprint # Try to find the ClientId from the config file or leave it for manual entry if ($savedConfig -and $savedConfig.ContainsKey($TenantId)) { $ClientId = $savedConfig[$TenantId]['clientId'] $autoDetected = $true Write-Verbose "Auto-detected cert $certSubject (thumbprint: $CertificateThumbprint) with saved ClientId" } else { Write-Verbose "Found cert $certSubject but no saved ClientId -- certificate auth requires -ClientId" $CertificateThumbprint = $null # Reset -- can't use without ClientId } } } } # Assessment helpers (8 functions) -- extracted to Orchestrator/AssessmentHelpers.ps1 # Section maps -- extracted to Orchestrator/AssessmentMaps.ps1 $maps = Get-AssessmentMaps $sectionServiceMap = $maps.SectionServiceMap $sectionScopeMap = $maps.SectionScopeMap $sectionModuleMap = $maps.SectionModuleMap $collectorMap = $maps.CollectorMap $dnsCollector = $maps.DnsCollector # ------------------------------------------------------------------ # Auto-detect cloud environment (when not explicitly specified) # ------------------------------------------------------------------ if ($TenantId -and -not $PSBoundParameters.ContainsKey('M365Environment')) { $detectedEnv = Resolve-M365Environment -TenantId $TenantId if ($detectedEnv -and $detectedEnv -ne $M365Environment) { $envDisplayNames = @{ 'commercial' = 'Commercial' 'gcc' = 'GCC' 'gcchigh' = 'GCC High' 'dod' = 'DoD' } $M365Environment = $detectedEnv Write-Host '' Write-Host " Cloud environment detected: $($envDisplayNames[$detectedEnv])" -ForegroundColor Cyan if ($detectedEnv -eq 'gcchigh') { Write-Host ' (If this is a DoD tenant, re-run with -M365Environment dod)' -ForegroundColor DarkGray } } } # ------------------------------------------------------------------ # Create timestamped output folder # ------------------------------------------------------------------ $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' # Extract domain prefix for folder/file naming (Phase A: from TenantId) # Handles onmicrosoft domains (extract prefix) and custom domains (extract label before first dot). # GUIDs are left empty — Phase B resolves them after Graph connects. $script:domainPrefix = '' if ($TenantId -match '^([^.]+)\.onmicrosoft\.(com|us)$') { $script:domainPrefix = $Matches[1] } elseif ($TenantId -match '^([^.]+)\.' -and $TenantId -notmatch '^[0-9a-f]{8}-') { $script:domainPrefix = $Matches[1] } $folderSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' } $assessmentFolder = Join-Path -Path $OutputFolder -ChildPath "Assessment_${timestamp}${folderSuffix}" try { $null = New-Item -Path $assessmentFolder -ItemType Directory -Force } catch { Write-Error "Failed to create output folder '$assessmentFolder': $_" return } # ------------------------------------------------------------------ # Initialize log file # ------------------------------------------------------------------ $logFileSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' } $script:logFileName = "_Assessment-Log${logFileSuffix}.txt" $script:logFilePath = Join-Path -Path $assessmentFolder -ChildPath $script:logFileName $logHeaderLines = @( ('=' * 80) ' M365 Environment Assessment Log' " Version: v$script:AssessmentVersion" " Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" " Tenant: $TenantId" " Cloud: $M365Environment" " Domain: $($script:domainPrefix)" ) $logHeaderLines += @( " Sections: $($Section -join ', ')" ('=' * 80) '' ) $logHeader = $logHeaderLines Set-Content -Path $script:logFilePath -Value ($logHeader -join "`n") -Encoding UTF8 Write-AssessmentLog -Level INFO -Message "Assessment started. Output folder: $assessmentFolder" # ------------------------------------------------------------------ # Show assessment header # ------------------------------------------------------------------ Show-AssessmentHeader -TenantName $TenantId -OutputPath $assessmentFolder -LogPath $script:logFilePath -Version $script:AssessmentVersion # ------------------------------------------------------------------ # Prepare service connections (lazy — connected per-section as needed) # ------------------------------------------------------------------ $connectedServices = [System.Collections.Generic.HashSet[string]]::new() # used by Connect-RequiredService via scope $failedServices = [System.Collections.Generic.HashSet[string]]::new() # ------------------------------------------------------------------ # Module compatibility check — Graph SDK and EXO ship conflicting # versions of Microsoft.Identity.Client (MSAL). Incompatible combos # cause silent auth failures with no useful error message. # ------------------------------------------------------------------ # Module compatibility check -- extracted to Orchestrator/Test-ModuleCompatibility.ps1 if (-not $SkipConnection) { $modResult = Test-ModuleCompatibility -Section $Section -SectionServiceMap $sectionServiceMap -NonInteractive:$NonInteractive -SkipDLP:$SkipDLP if (-not $modResult.Passed) { return } $Section = $modResult.Section # Pre-compute combined Graph scopes across all selected sections # (Graph scopes must be requested at initial connection time) $graphScopes = @() foreach ($s in $Section) { if ($sectionScopeMap.ContainsKey($s)) { $graphScopes += $sectionScopeMap[$s] } } $graphScopes = $graphScopes | Select-Object -Unique # Resolve Connect-Service script path $connectServicePath = Join-Path -Path $projectRoot -ChildPath 'Common\Connect-Service.ps1' if (-not (Test-Path -Path $connectServicePath)) { Write-Error "Connect-Service.ps1 not found at '$connectServicePath'." return } } # Connect-RequiredService -- extracted to Orchestrator/Connect-RequiredService.ps1 # ------------------------------------------------------------------ # Run collectors # ------------------------------------------------------------------ $summaryResults = [System.Collections.Generic.List[PSCustomObject]]::new() $issues = [System.Collections.Generic.List[PSCustomObject]]::new() $overallStart = Get-Date # Blocked scripts check -- extracted to Orchestrator/Test-BlockedScripts.ps1 if (-not (Test-BlockedScripts -ProjectRoot $projectRoot -NonInteractive:$NonInteractive)) { return } # Initialize real-time security check progress display $progressHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Show-CheckProgress.ps1' if (Test-Path -Path $progressHelper) { . $progressHelper $registryHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Import-ControlRegistry.ps1' if (Test-Path -Path $registryHelper) { . $registryHelper $controlsDir = Join-Path -Path $projectRoot -ChildPath 'controls' $progressRegistry = Import-ControlRegistry -ControlsPath $controlsDir if ($progressRegistry.Count -gt 1) { Initialize-CheckProgress -ControlRegistry $progressRegistry -ActiveSections $Section } } else { Write-Warning "Import-ControlRegistry.ps1 not found - progress tracking disabled." } } else { Write-Warning "Show-CheckProgress.ps1 not found - progress display disabled." } # Load cross-platform DNS resolver (Resolve-DnsName on Windows, dig on macOS/Linux) $dnsHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Resolve-DnsRecord.ps1' if (Test-Path -Path $dnsHelper) { . $dnsHelper } # Optimize section execution order to minimize service reconnections. # Group all EXO-dependent sections before Purview-dependent sections so # that running both Inventory and Security avoids EXO→Purview→EXO thrashing. $sectionOrder = @( 'Tenant', 'Identity', 'Licensing', 'Email', 'Intune', 'Inventory', # EXO-dependent — run before Security's Purview collectors 'Security', # Graph → EXO (Defender) → Purview (DLP/Compliance) 'Collaboration', 'PowerBI', 'Hybrid', 'ActiveDirectory', 'SOC2' ) $Section = $sectionOrder | Where-Object { $_ -in $Section } foreach ($sectionName in $Section) { if (-not $collectorMap.Contains($sectionName)) { Write-AssessmentLog -Level WARN -Message "Unknown section '$sectionName' — skipping." continue } $collectors = $collectorMap[$sectionName] # Skip DLP collector (and its Purview connection overhead) when -SkipDLP is set if ($SkipDLP) { $dlpCollectors = @($collectors | Where-Object { $_.ContainsKey('RequiredServices') -and $_.RequiredServices -contains 'Purview' }) if ($dlpCollectors.Count -gt 0) { $collectors = @($collectors | Where-Object { -not ($_.ContainsKey('RequiredServices') -and $_.RequiredServices -contains 'Purview') }) foreach ($skipped in $dlpCollectors) { Write-AssessmentLog -Level INFO -Message "Skipped: $($skipped.Label) (-SkipDLP)" -Section $sectionName -Collector $skipped.Label } } } Show-SectionHeader -Name $sectionName # Connect to services: use per-collector RequiredServices if defined, # otherwise connect all section-level services up front. # This ensures only one non-Graph service is active at a time. $hasPerCollectorRequirements = ($collectors | Where-Object { $_.ContainsKey('RequiredServices') }).Count -gt 0 if (-not $SkipConnection -and -not $hasPerCollectorRequirements) { $sectionServices = $sectionServiceMap[$sectionName] Connect-RequiredService -Services $sectionServices -SectionName $sectionName } # Check if ALL section services failed — skip entire section if so $sectionServices = $sectionServiceMap[$sectionName] $unavailableServices = @($sectionServices | Where-Object { $failedServices.Contains($_) }) $allSectionServicesFailed = ($unavailableServices.Count -eq $sectionServices.Count -and $sectionServices.Count -gt 0 -and -not $SkipConnection) if ($allSectionServicesFailed) { $skipReason = "$($unavailableServices -join ', ') not connected" foreach ($collector in $collectors) { $summaryResults.Add([PSCustomObject]@{ Section = $sectionName Collector = $collector.Label FileName = "$($collector.Name).csv" Status = 'Skipped' Items = 0 Duration = '00:00' Error = $skipReason }) Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason Write-AssessmentLog -Level WARN -Message "Skipped: $($collector.Label) — $skipReason" -Section $sectionName -Collector $collector.Label } # Also skip DNS collector if Email section services are unavailable if ($sectionName -eq 'Email') { $summaryResults.Add([PSCustomObject]@{ Section = 'Email' Collector = $dnsCollector.Label FileName = "$($dnsCollector.Name).csv" Status = 'Skipped' Items = 0 Duration = '00:00' Error = $skipReason }) Show-CollectorResult -Label $dnsCollector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason Write-AssessmentLog -Level WARN -Message "Skipped: $($dnsCollector.Label) — $skipReason" -Section 'Email' -Collector $dnsCollector.Label } continue } # Import Graph submodules required by this section's collectors if ($sectionModuleMap.ContainsKey($sectionName)) { foreach ($mod in $sectionModuleMap[$sectionName]) { Import-Module -Name $mod -ErrorAction SilentlyContinue } } foreach ($collector in $collectors) { # Per-collector service requirement: connect just-in-time, then check if ($collector.ContainsKey('RequiredServices') -and -not $SkipConnection) { Connect-RequiredService -Services $collector.RequiredServices -SectionName $sectionName $collectorUnavailable = @($collector.RequiredServices | Where-Object { $failedServices.Contains($_) }) if ($collectorUnavailable.Count -gt 0) { $skipReason = "$($collectorUnavailable -join ', ') not connected" $summaryResults.Add([PSCustomObject]@{ Section = $sectionName Collector = $collector.Label FileName = "$($collector.Name).csv" Status = 'Skipped' Items = 0 Duration = '00:00' Error = $skipReason }) Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason Write-AssessmentLog -Level WARN -Message "Skipped: $($collector.Label) — $skipReason" -Section $sectionName -Collector $collector.Label continue } } $collectorStart = Get-Date $scriptPath = Join-Path -Path $projectRoot -ChildPath $collector.Script $csvPath = Join-Path -Path $assessmentFolder -ChildPath "$($collector.Name).csv" $status = 'Failed' $itemCount = 0 $errorMessage = '' Write-AssessmentLog -Level INFO -Message "Running: $($collector.Label)" -Section $sectionName -Collector $collector.Label if (Get-Command -Name Update-ProgressStatus -ErrorAction SilentlyContinue) { Update-ProgressStatus -Message "Running $($collector.Label)..." } try { if (-not (Test-Path -Path $scriptPath)) { throw "Script not found: $scriptPath" } # Build parameters for the collector $collectorParams = @{} if ($collector.ContainsKey('Params')) { $collectorParams = $collector.Params.Clone() } # Special handling for Secure Score (two outputs) if ($collector.ContainsKey('HasSecondary') -and $collector.HasSecondary) { $secondaryCsvPath = Join-Path -Path $assessmentFolder -ChildPath "$($collector.SecondaryName).csv" $collectorParams['ImprovementActionsPath'] = $secondaryCsvPath } # Child-process collectors (e.g., PowerBI) run in an isolated pwsh # process to avoid .NET assembly version conflicts. The PowerBI module # ships Microsoft.Identity.Client 4.64 while Microsoft.Graph loads 4.78; # a child process gets its own AppDomain and avoids the clash. if ($collector.ContainsKey('IsChildProcess') -and $collector.IsChildProcess) { Write-Host " Running in isolated process (assembly compatibility)..." -ForegroundColor Gray Write-AssessmentLog -Level INFO -Message "Running $($collector.Label) in child process to avoid MSAL assembly conflict" -Section $sectionName -Collector $collector.Label $childCsvPath = $csvPath # Build a self-contained script that connects + runs the collector $scriptLines = [System.Collections.Generic.List[string]]::new() $scriptLines.Add('$ErrorActionPreference = "Stop"') # Call Connect-Service.ps1 directly (do NOT dot-source -- it has a # Mandatory param block that would prompt for input). $scriptLines.Add("`$connectParams = @{ Service = 'PowerBI' }") if ($TenantId) { $scriptLines.Add("`$connectParams['TenantId'] = '$TenantId'") } if ($ClientId -and $CertificateThumbprint) { $scriptLines.Add("`$connectParams['ClientId'] = '$ClientId'") $scriptLines.Add("`$connectParams['CertificateThumbprint'] = '$CertificateThumbprint'") } elseif ($ClientId -and $ClientSecret) { # Convert SecureString to plain text for child process serialization $plainSecret = [System.Net.NetworkCredential]::new('', $ClientSecret).Password $scriptLines.Add("`$connectParams['ClientId'] = '$ClientId'") $scriptLines.Add("`$connectParams['ClientSecret'] = (ConvertTo-SecureString '$plainSecret' -AsPlainText -Force)") } # On macOS/Linux, interactive browser auth hangs silently for Power BI. # Force device code flow unless a service principal is configured. if ($UseDeviceCode) { $scriptLines.Add('$connectParams["UseDeviceCode"] = $true') } elseif (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret))) { $scriptLines.Add('$connectParams["UseDeviceCode"] = $true') Write-Host " Using device code auth (interactive browser not supported on this platform)" -ForegroundColor Yellow } $scriptLines.Add("try { & '$connectServicePath' @connectParams } catch { Write-Error `$_; exit 1 }") $scriptLines.Add("& '$scriptPath' -OutputPath '$childCsvPath'") $childScriptFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "m365assess_pbi_$([System.IO.Path]::GetRandomFileName()).ps1" $childOutputFile = [System.IO.Path]::ChangeExtension($childScriptFile, '.log') $childErrFile = [System.IO.Path]::ChangeExtension($childScriptFile, '.err') Set-Content -Path $childScriptFile -Value ($scriptLines -join "`n") -Encoding UTF8 $childTimeoutSec = if ($UseDeviceCode -or (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret)))) { 120 } else { 30 } $childNeedsConsole = $UseDeviceCode -or (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret))) try { if ($childNeedsConsole) { # Device code auth: don't redirect output so the user sees the # login prompt. Use a background job with timeout instead. $childProc = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile', '-File', $childScriptFile ` -NoNewWindow -PassThru } else { # Service principal / Windows interactive: redirect output for # clean console and capture errors. $childProc = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile', '-File', $childScriptFile ` -RedirectStandardOutput $childOutputFile -RedirectStandardError $childErrFile ` -NoNewWindow -PassThru } # Poll with countdown so the user sees progress instead of a frozen screen $exited = $false for ($waited = 0; $waited -lt $childTimeoutSec; $waited += 5) { $exited = $childProc.WaitForExit(5000) if ($exited) { break } $remaining = $childTimeoutSec - $waited - 5 if ($remaining -gt 0 -and -not $childNeedsConsole) { Write-Host " Waiting for Power BI response... (${remaining}s until timeout)" -ForegroundColor Gray } } if (-not $exited) { $childProc.Kill() $childProc.WaitForExit(5000) throw "Child process timed out after ${childTimeoutSec}s — Power BI connection or API is unresponsive. Verify the account has Power BI Service Administrator role. The assessment will continue without Power BI data." } # Read captured output for warnings/errors (only when redirected) if (-not $childNeedsConsole) { $childStderrContent = if (Test-Path $childErrFile) { Get-Content -Path $childErrFile -Raw } else { '' } if ($childStderrContent) { Write-AssessmentLog -Level WARN -Message "Child process stderr: $($childStderrContent.Trim())" -Section $sectionName -Collector $collector.Label } } if ($childProc.ExitCode -ne 0) { $errDetail = if (-not $childNeedsConsole -and (Test-Path $childErrFile)) { (Get-Content -Path $childErrFile -Raw).Trim() } else { "Exit code $($childProc.ExitCode)" } throw "Child process failed: $errDetail" } if (Test-Path -Path $childCsvPath) { $results = @(Import-Csv -Path $childCsvPath) $itemCount = $results.Count $status = 'Complete' } else { throw "Child process completed but CSV output not found at $childCsvPath" } } finally { Remove-Item -Path $childScriptFile -ErrorAction SilentlyContinue Remove-Item -Path $childOutputFile -ErrorAction SilentlyContinue Remove-Item -Path $childErrFile -ErrorAction SilentlyContinue } # Skip normal in-process execution $collectorDuration = ((Get-Date) - $collectorStart).TotalSeconds Show-CollectorResult -Label $collector.Label -Status $status -Items $itemCount -DurationSeconds $collectorDuration -ErrorMessage $errorMessage $summaryResults.Add([PSCustomObject]@{ Section = $sectionName Collector = $collector.Label FileName = "$($collector.Name).csv" Status = $status Items = $itemCount Duration = '{0:mm\:ss}' -f [timespan]::FromSeconds($collectorDuration) Error = $errorMessage }) Write-AssessmentLog -Level INFO -Message "Completed: $($collector.Label) -- $status, $itemCount items, $([math]::Round($collectorDuration, 1))s" -Section $sectionName -Collector $collector.Label continue } # Capture warnings (3>&1) so they go to log instead of console. # Suppress error stream (2>$null) to prevent Graph SDK cmdlets from # dumping raw API errors to console; terminating errors still propagate # to the catch block below via the exception mechanism. $rawOutput = & $scriptPath @collectorParams 3>&1 2>$null $capturedWarnings = @($rawOutput | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }) $results = @($rawOutput | Where-Object { $null -ne $_ -and $_ -isnot [System.Management.Automation.WarningRecord] }) # Log captured warnings; track permission-related ones as issues $hasPermissionWarning = $false foreach ($w in $capturedWarnings) { Write-AssessmentLog -Level WARN -Message $w.Message -Section $sectionName -Collector $collector.Label if ($w.Message -match '401|403|Unauthorized|Forbidden|permission|consent') { $hasPermissionWarning = $true $issues.Add([PSCustomObject]@{ Severity = 'WARNING' Section = $sectionName Collector = $collector.Label Description = $w.Message ErrorMessage = $w.Message Action = Get-RecommendedAction -ErrorMessage $w.Message }) } } if ($null -ne $results -and @($results).Count -gt 0) { $itemCount = Export-AssessmentCsv -Path $csvPath -Data @($results) -Label $collector.Label $status = 'Complete' } else { $itemCount = 0 if ($hasPermissionWarning) { $status = 'Failed' $errorMessage = ($capturedWarnings | Where-Object { $_.Message -match '401|403|Unauthorized|Forbidden|permission|consent' } | Select-Object -First 1).Message Write-AssessmentLog -Level ERROR -Message "Collector returned no data due to permission error" ` -Section $sectionName -Collector $collector.Label -Detail $errorMessage } else { $status = 'Complete' Write-AssessmentLog -Level INFO -Message "No data returned" -Section $sectionName -Collector $collector.Label } } } catch { $errorMessage = $_.Exception.Message if (-not $errorMessage) { $errorMessage = $_.Exception.ToString() } if ($errorMessage -match '403|Forbidden|Insufficient privileges') { $status = 'Skipped' Write-AssessmentLog -Level WARN -Message "Insufficient permissions" -Section $sectionName -Collector $collector.Label -Detail $errorMessage $issues.Add([PSCustomObject]@{ Severity = 'WARNING' Section = $sectionName Collector = $collector.Label Description = 'Insufficient permissions' ErrorMessage = $errorMessage Action = Get-RecommendedAction -ErrorMessage $errorMessage }) } elseif ($errorMessage -match 'not found|not installed|not connected') { $status = 'Skipped' Write-AssessmentLog -Level WARN -Message "Prerequisite not met" -Section $sectionName -Collector $collector.Label -Detail $errorMessage $issues.Add([PSCustomObject]@{ Severity = 'WARNING' Section = $sectionName Collector = $collector.Label Description = 'Prerequisite not met' ErrorMessage = $errorMessage Action = Get-RecommendedAction -ErrorMessage $errorMessage }) } else { $status = 'Failed' Write-AssessmentLog -Level ERROR -Message "Collector failed" -Section $sectionName -Collector $collector.Label -Detail $_.Exception.ToString() $issues.Add([PSCustomObject]@{ Severity = 'ERROR' Section = $sectionName Collector = $collector.Label Description = 'Collector error' ErrorMessage = $errorMessage Action = Get-RecommendedAction -ErrorMessage $errorMessage }) } } $collectorEnd = Get-Date $duration = $collectorEnd - $collectorStart $summaryResults.Add([PSCustomObject]@{ Section = $sectionName Collector = $collector.Label FileName = "$($collector.Name).csv" Status = $status Items = $itemCount Duration = '{0:mm\:ss}' -f $duration Error = $errorMessage }) Show-CollectorResult -Label $collector.Label -Status $status -Items $itemCount -DurationSeconds $duration.TotalSeconds -ErrorMessage $errorMessage Write-AssessmentLog -Level INFO -Message "Completed: $($collector.Label) — $status, $itemCount items, $($duration.TotalSeconds.ToString('F1'))s" -Section $sectionName -Collector $collector.Label } # DNS Authentication: deferred until after all sections complete if ($sectionName -eq 'Email') { $script:runDnsAuthentication = $true # Cache accepted domains and DKIM data for deferred DNS checks (avoids EXO session timeout) if (-not $SkipConnection) { try { $script:cachedAcceptedDomains = @(Get-AcceptedDomain -ErrorAction Stop) Write-AssessmentLog -Level INFO -Message "Cached $($script:cachedAcceptedDomains.Count) accepted domain(s) for deferred DNS" -Section 'Email' } catch { Write-AssessmentLog -Level WARN -Message "Could not cache accepted domains: $($_.Exception.Message)" -Section 'Email' } try { $script:cachedDkimConfigs = @(Get-DkimSigningConfig -ErrorAction Stop) Write-AssessmentLog -Level INFO -Message "Cached $($script:cachedDkimConfigs.Count) DKIM config(s) for deferred DNS" -Section 'Email' } catch { Write-Verbose "Could not cache DKIM configs: $($_.Exception.Message)" } } } } # ------------------------------------------------------------------ # Deferred DNS checks (runs after all sections, uses prefetch cache) # ------------------------------------------------------------------ # Deferred DNS checks -- extracted to Orchestrator/Invoke-DnsAuthentication.ps1 if ($script:runDnsAuthentication) { Invoke-DnsAuthentication -AssessmentFolder $assessmentFolder -ProjectRoot $projectRoot -SummaryResults $summaryResults -Issues $issues -DnsCollector $dnsCollector } # ------------------------------------------------------------------ # Export assessment summary # ------------------------------------------------------------------ $overallEnd = Get-Date $overallDuration = $overallEnd - $overallStart $summarySuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' } $summaryCsvPath = Join-Path -Path $assessmentFolder -ChildPath "_Assessment-Summary${summarySuffix}.csv" $summaryResults | Export-Csv -Path $summaryCsvPath -NoTypeInformation -Encoding UTF8 # ------------------------------------------------------------------ # Export issue report (if any issues exist) # ------------------------------------------------------------------ if ($issues.Count -gt 0) { $issueFileSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' } $script:issueFileName = "_Assessment-Issues${issueFileSuffix}.log" $issueReportPath = Join-Path -Path $assessmentFolder -ChildPath $script:issueFileName Export-IssueReport -Path $issueReportPath -Issues @($issues) -TenantName $TenantId -OutputPath $assessmentFolder -Version $script:AssessmentVersion Write-AssessmentLog -Level INFO -Message "Issue report exported: $issueReportPath ($($issues.Count) issues)" } Write-AssessmentLog -Level INFO -Message "Assessment complete. Duration: $($overallDuration.ToString('mm\:ss')). Summary CSV: $summaryCsvPath" # ------------------------------------------------------------------ # Generate HTML report # ------------------------------------------------------------------ $reportScriptPath = Join-Path -Path $projectRoot -ChildPath 'Common\Export-AssessmentReport.ps1' if (Test-Path -Path $reportScriptPath) { try { $reportParams = @{ AssessmentFolder = $assessmentFolder } if ($script:domainPrefix) { $reportParams['TenantName'] = $script:domainPrefix } elseif ($TenantId) { $reportParams['TenantName'] = $TenantId } if ($NoBranding) { $reportParams['NoBranding'] = $true } if ($SkipComplianceOverview) { $reportParams['SkipComplianceOverview'] = $true } if ($SkipCoverPage) { $reportParams['SkipCoverPage'] = $true } if ($SkipExecutiveSummary) { $reportParams['SkipExecutiveSummary'] = $true } if ($SkipPdf) { $reportParams['SkipPdf'] = $true } if ($FrameworkFilter) { $reportParams['FrameworkFilter'] = $FrameworkFilter } if ($CustomBranding) { $reportParams['CustomBranding'] = $CustomBranding } if ($FrameworkExport) { $reportParams['FrameworkExport'] = $FrameworkExport } $reportParams['CisFrameworkId'] = "cis-m365-$CisBenchmarkVersion" $reportOutput = & $reportScriptPath @reportParams foreach ($line in $reportOutput) { Write-AssessmentLog -Level INFO -Message $line } } catch { Write-AssessmentLog -Level WARN -Message "HTML report generation failed: $($_.Exception.Message)" } } # ------------------------------------------------------------------ # Console summary # ------------------------------------------------------------------ Show-AssessmentSummary -SummaryResults @($summaryResults) -Issues @($issues) -Duration $overallDuration -AssessmentFolder $assessmentFolder -SectionCount $Section.Count -Version $script:AssessmentVersion # Summary is exported to _Assessment-Summary.csv for programmatic access } # end function Invoke-M365Assessment # ------------------------------------------------------------------ # Backward-compatible direct invocation: when this script is called # directly (not dot-sourced from the module .psm1), invoke the # function so '.\Invoke-M365Assessment.ps1 -Section Tenant ...' works. # ------------------------------------------------------------------ if ($MyInvocation.InvocationName -ne '.') { Invoke-M365Assessment @args } |