Public/Invoke-PurviewPostureAnalyzer.ps1
|
# Invoke-PurviewPostureAnalyzer.ps1 - the entry point. Orchestrates the pipeline: # run context -> collect (read-only) -> analyze -> assemble -> render HTML + export JSON. # Graceful degradation: each collector/analyzer is wrapped so one failure yields a # Verify-manually placeholder section and the run continues (PLAN.md section 2 & 9). # ASCII-only source (Windows PowerShell 5.1). Set-StrictMode -Off function Invoke-PurviewPostureAnalyzer { [CmdletBinding()] param( [string]$OutputDirectory, [string]$Organization, [datetime]$AsOf = (Get-Date), # Run profile (P5): -IncludeSection means "only these"; -ExcludeSection removes. # Keys are the top-level section ids (e.g. Audit, DSPM_for_AI). -Profile points # to a .psd1/.json file with IncludeSection/ExcludeSection keys expressing the # same; explicit parameters override the file. (This parameter intentionally # shadows the automatic $Profile variable inside this function only.) [string[]]$IncludeSection, [string[]]$ExcludeSection, [string]$Profile, # Redaction (P6): render-time masking of tenant domains/UPNs (-Redact) and, # additionally, policy/label name pseudonymization (-RedactNames, implies # -Redact). The JSON export and in-memory findings are never modified. [switch]$Redact, [switch]$RedactNames, # Snapshot (Wave 4): a versioned UNREDACTED JSON snapshot is written alongside # the HTML by default; -NoSnapshot suppresses it. -IncludeRawCapture also # writes the raw collector outputs to a separate debug file outside the schema. [switch]$NoSnapshot, [switch]$IncludeRawCapture, # Delta mode (Wave 4 Part C): fully offline comparison of two snapshots - # file-in, HTML-out, no tenant session. PS 7+ only (engine gate inside). [string]$DeltaFrom, [string]$DeltaTo, [string]$OutputPath, [switch]$AllowTenantMismatch, # Client logo (UX-2): a .png/.jpg/.jpeg embedded into the HTML header as a # data: URI (the report stays self-contained/offline). Validated and encoded # BEFORE any collection; report chrome only - never the normalized object, # the JSON export, or snapshots. Ignored (with a warning) in delta mode. [string]$LogoPath, # One-go run (UX-1): OPT-IN switches whose defaults change NOTHING (F-007 - # PPA never opens or tears down a session unless explicitly told to). # -Connect opens the two read sessions only when none are live; -Disconnect # closes them in a finally, even on a failed run; -Show opens the finished # HTML report; -UserPrincipalName feeds the -Connect sign-in (ignored, with # a warning, without -Connect). All four are ignored in delta mode. [switch]$Connect, [switch]$Disconnect, [switch]$Show, [string]$UserPrincipalName, # Cross-tenant guest / B2B (pre-publish Part 6, Option B): forwarded to the # -Connect sign-in so a consultant guest account can read a CLIENT tenant. # -DelegatedOrganization names the client tenant (client.onmicrosoft.com); # -AzureADAuthorizationEndpointUri is an optional override (auto-derived from # the organization otherwise). Both follow the -UserPrincipalName rules: # ignored (with a warning) without -Connect, and ignored in delta mode. [string]$DelegatedOrganization, [string]$AzureADAuthorizationEndpointUri ) # ---- DELTA MODE: short-circuits the whole collection pipeline (spec 4.1) ---- if (-not [string]::IsNullOrWhiteSpace($DeltaFrom) -or -not [string]::IsNullOrWhiteSpace($DeltaTo)) { if ([string]::IsNullOrWhiteSpace($DeltaFrom) -or [string]::IsNullOrWhiteSpace($DeltaTo)) { throw 'Delta mode requires BOTH -DeltaFrom and -DeltaTo snapshot paths.' } if (-not [string]::IsNullOrWhiteSpace($LogoPath)) { Write-Warning 'Delta mode ignores -LogoPath (the delta renderer carries no logo slot).' } # UX-1 switches are tenant-run concepts; delta mode is fully offline (one # consolidated warning, same rule as -LogoPath above). $ignoredSwitches = @() if ($Connect) { $ignoredSwitches += '-Connect' } if ($Disconnect) { $ignoredSwitches += '-Disconnect' } if ($Show) { $ignoredSwitches += '-Show' } if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $ignoredSwitches += '-UserPrincipalName' } if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $ignoredSwitches += '-DelegatedOrganization' } if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $ignoredSwitches += '-AzureADAuthorizationEndpointUri' } if ($ignoredSwitches.Count -gt 0) { Write-Warning ("Delta mode ignores {0} - it is a fully offline snapshot comparison (no tenant session, no report auto-open)." -f ($ignoredSwitches -join ', ')) } return Invoke-PpaDelta -FromPath $DeltaFrom -ToPath $DeltaTo -OutputPath $OutputPath -Redact:$Redact -RedactNames:$RedactNames -AllowTenantMismatch:$AllowTenantMismatch } # ---- CLIENT LOGO (UX-2): validate + encode BEFORE any collection (fail fast - a bad # logo path must never cost the operator a full tenant read). The data URI is handed # only to the HTML renderer below; it never enters the normalized object, the JSON # export, or snapshots. ---- $logoDataUri = '' if (-not [string]::IsNullOrWhiteSpace($LogoPath)) { $logoDataUri = ConvertTo-PpaLogoDataUri -Path $LogoPath } # Resolve the output directory to an ABSOLUTE path against the caller's PowerShell location. # A relative path handed to .NET file APIs (WriteAllText) would otherwise resolve against the # process current directory - often the user home - not the PowerShell location, which is why # a relative -OutputDirectory could land somewhere unexpected. if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = 'Outputs' } if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory = Join-Path -Path (Get-Location).Path -ChildPath $OutputDirectory } $OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) # ---- RUN PROFILE (P5): resolve and validate BEFORE any collection (fail fast) ---- $knownSectionIds = @('Sensitivity_Labels', 'Data_Loss_Prevention', 'Retention', 'Insider_Risk', 'Audit', 'eDiscovery', 'Communication_Compliance', 'DSPM_for_AI') if (-not [string]::IsNullOrWhiteSpace($Profile)) { $profilePath = $Profile if (-not [System.IO.Path]::IsPathRooted($profilePath)) { $profilePath = Join-Path -Path (Get-Location).Path -ChildPath $profilePath } if (-not (Test-Path -LiteralPath $profilePath)) { throw "Run profile file not found: '$profilePath'." } $profileData = switch ([System.IO.Path]::GetExtension($profilePath).ToLower()) { '.psd1' { Import-PowerShellDataFile -LiteralPath $profilePath } '.json' { [System.IO.File]::ReadAllText($profilePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json } default { throw "Run profile '$profilePath' must be a .psd1 or .json file." } } # Explicit parameters override the profile file. (Filter before counting: an # unbound [string[]] parameter wraps to @($null), whose Count is 1, not 0.) if (@($IncludeSection | Where-Object { $_ }).Count -eq 0 -and $profileData.IncludeSection) { $IncludeSection = @($profileData.IncludeSection) } if (@($ExcludeSection | Where-Object { $_ }).Count -eq 0 -and $profileData.ExcludeSection) { $ExcludeSection = @($profileData.ExcludeSection) } } $badKeys = @(@($IncludeSection) + @($ExcludeSection) | Where-Object { $_ -and ($knownSectionIds -notcontains $_) }) if ($badKeys.Count -gt 0) { throw ("Unknown section key(s): {0}. Valid keys: {1}" -f (($badKeys | Select-Object -Unique) -join ', '), ($knownSectionIds -join ', ')) } # ---- RUN MANIFEST (F-008): start recording every cmdlet the read-only chokepoint # dispatches this run - metadata only (name, status, count, timestamp), never arguments # or content. Started before the -Connect session probe (UX-1) and the run-context + # session-diagnostics probes so they are captured too; written alongside the report # below. Emitted by default (self-auditability is the point) and never leaves the # machine. ---- Initialize-PpaRunManifest # -UserPrincipalName and the guest (B2B) parameters only feed the -Connect # sign-in; alone they do nothing (UX-1 rule - one consolidated warning). if (-not $Connect) { $connectOnly = @() if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $connectOnly += '-UserPrincipalName' } if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $connectOnly += '-DelegatedOrganization' } if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $connectOnly += '-AzureADAuthorizationEndpointUri' } if ($connectOnly.Count -gt 0) { $verb = if ($connectOnly.Count -gt 1) { 'are' } else { 'is' } Write-Warning (($connectOnly -join ', ') + ' ' + $verb + ' only used with -Connect; ignoring.') } } # ---- ONE-GO RUN (UX-1): the whole tenant-run body sits in try/finally so an explicit # -Disconnect is honored even when the run throws (including the -Connect both-failed # error below). The finally does NOTHING unless -Disconnect was passed - session # teardown stays opt-in (F-007). ---- try { # ---- SESSION SETUP (-Connect, opt-in): probe the live sessions first and never # disturb one that already exists. The probe is a Get-verb read and goes through # the read-only chokepoint like every other read; Connect-PurviewPostureSession # below is session management (not a tenant read) and is called DIRECTLY - never # through Invoke-PpaReadCmdlet. ---- if ($Connect) { $connProbe = Invoke-PpaReadCmdlet -Name 'Get-ConnectionInformation' $liveRows = @() if ($connProbe.Status -eq 'Ok') { $liveRows = @($connProbe.Data | Where-Object { [string]$_.State -eq 'Connected' }) } # Connected rows split by service: a compliance.* ConnectionUri is the # Security & Compliance session; any other Connected row is Exchange Online. $sccLive = @($liveRows | Where-Object { [string]$_.ConnectionUri -like '*compliance*' }) $exoLive = @($liveRows | Where-Object { [string]$_.ConnectionUri -notlike '*compliance*' }) if ($sccLive.Count -gt 0 -and $exoLive.Count -gt 0) { Write-Verbose '-Connect: Security & Compliance and Exchange Online sessions are already live; nothing to connect.' } elseif ($sccLive.Count -gt 0 -or $exoLive.Count -gt 0) { $haveName = if ($sccLive.Count -gt 0) { 'Security & Compliance' } else { 'Exchange Online' } $missName = if ($sccLive.Count -gt 0) { 'Exchange Online' } else { 'Security & Compliance' } Write-Warning ("-Connect: found a live {0} session but no {1} session - skipping connect rather than disturbing the existing session. If {1}-backed sections come back degraded, run Connect-PurviewPostureSession yourself and re-run." -f $haveName, $missName) } else { $connectArgs = @{} if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $connectArgs['UserPrincipalName'] = $UserPrincipalName } # Guest (B2B) forwarding (Part 6): whichever guest parameters were # supplied ride along; Connect-PurviewPostureSession owns the # endpoint derivation and the endpoint-without-org hygiene warning. if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $connectArgs['DelegatedOrganization'] = $DelegatedOrganization } if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $connectArgs['AzureADAuthorizationEndpointUri'] = $AzureADAuthorizationEndpointUri } $connStatus = Connect-PurviewPostureSession @connectArgs $sccOk = ([string]$connStatus.SecurityCompliance -eq 'connected') $exoOk = ([string]$connStatus.ExchangeOnline -eq 'connected') if (-not $sccOk -and -not $exoOk) { # Both services failed: stop BEFORE any collection - a run with zero # readable services produces an all-degraded report nobody wants. # (Guard-scan note: the gallery-install hint below is composed at # runtime so no mutating-verb cmdlet literal appears in source - it # is message text for the operator, never an invocation.) $hint = '' if (([string]$connStatus.SecurityCompliance + ' ' + [string]$connStatus.ExchangeOnline) -match 'module not installed') { $hint = (' Install it first: ' + 'Install' + '-Module ExchangeOnlineManagement -Scope CurrentUser.') } throw ("-Connect could not establish either service. Security & Compliance: {0}. Exchange Online: {1}.{2}" -f [string]$connStatus.SecurityCompliance, [string]$connStatus.ExchangeOnline, $hint) } if (-not ($sccOk -and $exoOk)) { Write-Verbose ("-Connect: one service connected (Security & Compliance: {0}; Exchange Online: {1}); proceeding - unreadable sections degrade honestly." -f [string]$connStatus.SecurityCompliance, [string]$connStatus.ExchangeOnline) } } } $meta = Get-PpaRunContext -Organization $Organization -AsOf $AsOf # ---- SESSION DIAGNOSTICS (Verbose) ---- # Confirm the read sessions the collectors depend on. Run with -Verbose to see this and # every per-cmdlet outcome (Invoke-PpaReadCmdlet logs each Get-* call and its result/error). Write-Verbose 'Checking read sessions (run with -Verbose for full collector diagnostics)...' foreach ($probe in @('Get-Label', 'Get-DlpCompliancePolicy', 'Get-OrganizationConfig')) { $available = [bool](Get-Command -Name $probe -ErrorAction SilentlyContinue) Write-Verbose (" session cmdlet {0}: {1}" -f $probe, ($(if ($available) { 'available' } else { 'NOT available - session not connected?' }))) } $conn = Invoke-PpaReadCmdlet -Name 'Get-ConnectionInformation' if ($conn.Status -eq 'Ok') { foreach ($c in @($conn.Data)) { Write-Verbose (" active connection: {0} ({1})" -f $c.ConnectionUri, $c.UserPrincipalName) } if (@($conn.Data).Count -eq 0) { Write-Verbose ' active connection: NONE (Connect-IPPSSession / Connect-ExchangeOnline not established)' } } # ---- COLLECT (read-only; a failure is captured, logged, and yields $null) ---- $collectorErrors = @{} # Keyed by SECTION ID (the stable identifier) - the analyze stage below keys its # lookup off the same section id, so the real collector exception survives for ALL # eight sections. (Keying by display name silently missed the four sections whose # collect-name and title differ: Retention, Insider_Risk, Communication_Compliance, # DSPM_for_AI - the generic placeholder was shown instead of the real reason. F-002.) $collect = { param([string]$Id, [scriptblock]$Block) try { & $Block } catch { $collectorErrors[$Id] = $_.Exception.Message $display = ($Id -replace '_', ' ') Write-Warning ("Collector '{0}' failed: {1}" -f $display, $_.Exception.Message) Write-Verbose ("Collector '{0}' exception [{1}]:`n{2}`n{3}" -f $display, $_.Exception.GetType().FullName, $_.Exception.Message, $_.ScriptStackTrace) $null } } $rawLabels = & $collect 'Sensitivity_Labels' { Get-PpaSensitivityLabels } $rawDlp = & $collect 'Data_Loss_Prevention' { Get-PpaDlp } $rawRet = & $collect 'Retention' { Get-PpaRetention } $rawIrm = & $collect 'Insider_Risk' { Get-PpaInsiderRisk } $rawAud = & $collect 'Audit' { Get-PpaAudit } $rawEd = & $collect 'eDiscovery' { Get-PpaEdiscovery } $rawCc = & $collect 'Communication_Compliance' { Get-PpaCommsCompliance } $rawDspm = & $collect 'DSPM_for_AI' { Get-PpaDspmAi } # Static map: license annotations (never detection - decision D9). The SIT tier # map went with the DLP-04 retirement (Wave 5 cleanup Part 4). $licMap = Get-PpaLicenseRequirements $hasSiteLabels = $false if ($rawLabels) { $hasSiteLabels = @($rawLabels.labels.items | Where-Object { $_.scopes -contains 'Site' -or $_.scopes -contains 'UnifiedGroup' }).Count -gt 0 } # ---- ANALYZE (null raw or analyzer error -> Verify-manually error section) ---- # The error section carries the REAL reason: the captured collector exception if the collector # threw, otherwise the analyzer's exception message - not a generic placeholder. $analyze = { param([string]$Id, [string]$Title, [string]$Group, [string]$Icon, [string]$Tag, $Raw, [scriptblock]$Block) if ($null -eq $Raw) { # Look up by section id - the SAME key $collect registers under (F-002). $why = if ($collectorErrors.ContainsKey($Id)) { "Collector error: " + $collectorErrors[$Id] } else { 'Collector returned no data (not connected, missing module, or access denied). Re-run with -Verbose for the underlying cmdlet errors.' } return (New-PpaErrorSection -Id $Id -Title $Title -Group $Group -GroupIcon $Icon -GroupTag $Tag -Message $why) } try { & $Block } catch { Write-Warning ("Analyzer '{0}' failed: {1}" -f $Title, $_.Exception.Message) Write-Verbose ("Analyzer '{0}' exception [{1}]:`n{2}`n{3}" -f $Title, $_.Exception.GetType().FullName, $_.Exception.Message, $_.ScriptStackTrace) New-PpaErrorSection -Id $Id -Title $Title -Group $Group -GroupIcon $Icon -GroupTag $Tag -Message ("Analyzer error: " + $_.Exception.Message) } } $sections = @( & $analyze 'Sensitivity_Labels' 'Sensitivity Labels' 'Microsoft Information Protection' 'fas fa-shield-alt' '' $rawLabels { Invoke-PpaLabelAnalyzer -Raw $Raw -AsOf $AsOf -LicenseMap $licMap } & $analyze 'Data_Loss_Prevention' 'Data Loss Prevention' 'Microsoft Information Protection' 'fas fa-shield-alt' '' $rawDlp { Invoke-PpaDlpAnalyzer -Raw $Raw -AsOf $AsOf -LicenseMap $licMap } & $analyze 'Retention' 'Retention & Records' 'Data Lifecycle & Records' 'fas fa-archive' '' $rawRet { Invoke-PpaRetentionAnalyzer -Raw $Raw -LicenseMap $licMap } & $analyze 'Insider_Risk' 'Insider Risk Management' 'Insider Risk' 'fas fa-user-secret' '' $rawIrm { Invoke-PpaInsiderRiskAnalyzer -Raw $Raw -LicenseMap $licMap } & $analyze 'Audit' 'Audit' 'Discovery & Response' 'fas fa-search' '' $rawAud { Invoke-PpaAuditAnalyzer -Raw $Raw -LicenseMap $licMap } & $analyze 'eDiscovery' 'eDiscovery' 'Discovery & Response' 'fas fa-search' '' $rawEd { Invoke-PpaEdiscoveryAnalyzer -Raw $Raw -LicenseMap $licMap } & $analyze 'Communication_Compliance' 'Communication Compliance' 'Insider Risk' 'fas fa-user-secret' '' $rawCc { Invoke-PpaCommsComplianceAnalyzer -Raw $Raw -LicenseMap $licMap } & $analyze 'DSPM_for_AI' 'DSPM for AI - Copilot Data Security' 'AI Security' 'fas fa-robot' 'NEW' $rawDspm { Invoke-PpaDspmAiAnalyzer -Raw $Raw -LicenseMap $licMap -HasSiteLabels:$hasSiteLabels } ) # ---- RUN PROFILE FILTER (P5): applied after analysis, before assembly, so all # collectors/analyzers ran identically and only the report/export thins out ---- $selection = Select-PpaSections -Sections $sections -IncludeSection $IncludeSection -ExcludeSection $ExcludeSection $sections = @($selection.Sections) # ---- COLLECTOR -> SECTION MAP (keyed by section id): built once here and reused by # the degraded summary below, the coverage matrix, and the snapshot. ---- $rawMap = @{ Sensitivity_Labels = $rawLabels Data_Loss_Prevention = $rawDlp Retention = $rawRet Insider_Risk = $rawIrm Audit = $rawAud eDiscovery = $rawEd Communication_Compliance = $rawCc DSPM_for_AI = $rawDspm } # ---- DEGRADED-SECTION SUMMARY (visible without -Verbose) ---- # A section is degraded if it hard-errored to an *-ERR stub OR its collector read did # not fully succeed (access denied / not connected / partial). Warning on the collector # outcome - not just *-ERR - means a not-connected or partial-role run is flagged # instead of silently rendering fabricated "0 / Improvement" gaps (F-001). $degradedOutcomes = @('AccessDenied', 'CmdletUnavailable', 'Failed', 'Partial') $degraded = @($sections | Where-Object { $sid = [string]$_.id (@($_.findings | Where-Object { $_.id -like '*-ERR' }).Count -gt 0) -or ($rawMap.ContainsKey($sid) -and $null -ne $rawMap[$sid] -and ($degradedOutcomes -contains [string]$rawMap[$sid].outcome)) }) if ($degraded.Count -gt 0) { Write-Warning ("{0} section(s) degraded - a read did not fully succeed (access denied / not connected / partial): {1}. Affected findings read 'Verify manually'; re-run with -Verbose for the underlying cmdlet failures." -f $degraded.Count, ((@($degraded).title) -join ', ')) } # ---- LICENSE-CONTEXT NOTE (assume E5, annotate tier - decision D9) ---- $licNote = if ($licMap -and $licMap.contextNote) { [string]$licMap.contextNote } else { 'This report assumes Microsoft 365 E5 (or equivalent) licensing when judging Purview workloads and does not read the tenant subscriptions; findings marked Requires note the tier the feature needs.' } $licBlock = [pscustomobject]@{ note = $licNote } # ---- COVERAGE MATRIX (Wave 4 Part D): pure projection from collected data. # $rawMap was built once above (collector -> section map) and is reused here. ---- $coverage = Get-PpaCoverageModel -RawMap $rawMap # ---- ASSEMBLE -> RENDER (HTML primary) + EXPORT (JSON) ---- $normalized = ConvertTo-PpaNormalized -Meta $meta -Licensing $licBlock -Sections $sections -Coverage $coverage $stamp = $AsOf.ToUniversalTime().ToString('yyyyMMdd-HHmmss') $reportsDir = Join-Path (Join-Path $OutputDirectory "PurviewPosture-$stamp") 'reports' # Create the timestamped reports directory BEFORE any write (-Force is idempotent and creates # every missing parent). The path is absolute, so New-Item and the .NET writes agree on it. New-Item -ItemType Directory -Path $reportsDir -Force | Out-Null # Run manifest (F-008): metadata-only record of the cmdlets this run executed, written # alongside the report. Emitted here - after the reads are done and the dir exists, before # the render write - so a render failure still leaves the manifest (best-effort; only a # crash in the pure assemble stage above this point skips it), and it always lands on # degraded runs, which complete normally. Metadata only, so there is nothing to redact. $manifestPath = Export-PpaRunManifest -Directory $reportsDir -PpaVersion ([string]$meta.version) $htmlPath = Join-Path $reportsDir 'posture-report.html' $jsonPath = Join-Path $reportsDir 'posture-report.json' [System.IO.File]::WriteAllText($htmlPath, (Export-PpaHtmlReport -Normalized $normalized -ExcludedSections $selection.ExcludedTitles -Redact:$Redact -RedactNames:$RedactNames -LogoDataUri $logoDataUri), (New-Object System.Text.UTF8Encoding($false))) [void](Export-PpaJson -Normalized $normalized -Path $jsonPath) # ---- SNAPSHOT (Wave 4 Part B): versioned JSON capture alongside the HTML ---- # Post-selection scope: only the selected sections' objects/findings are captured; # excluded sections' collectors record 'Skipped', a crashed collector 'Failed' # (attempted-and-errored; 'NotRun' is reserved for never-attempted). $snapshotPath = $null if (-not $NoSnapshot) { # $rawMap was built once above for the coverage matrix; the snapshot reuses it. $profileName = if ([string]::IsNullOrWhiteSpace($Profile)) { $null } else { [System.IO.Path]::GetFileNameWithoutExtension($Profile) } $snapModel = New-PpaSnapshotModel ` -RawMap $rawMap -Sections $sections -Meta $meta ` -CapturedAt $AsOf -SnapshotId ([guid]::NewGuid().ToString()) ` -ProfileName $profileName ` -SectionIds @($sections | ForEach-Object { [string]$_.id }) $snapResult = Export-PpaSnapshot -Model $snapModel -Directory $reportsDir -RawMap $rawMap -IncludeRawCapture:$IncludeRawCapture $snapshotPath = $snapResult.SnapshotPath } # Only report success once both files are actually on disk. If a write failed, let the failure # surface instead of returning paths that do not exist. if (-not (Test-Path -LiteralPath $htmlPath) -or -not (Test-Path -LiteralPath $jsonPath)) { throw "Report generation did not write its output to '$reportsDir'." } Write-Host "Report : $htmlPath" Write-Host "JSON : $jsonPath" $result = [pscustomobject]@{ HtmlPath = $htmlPath; JsonPath = $jsonPath; SnapshotPath = $snapshotPath; ManifestPath = $manifestPath; Normalized = $normalized } # ---- SHOW (-Show, opt-in): open the finished report with its default handler. # Best-effort: a headless box (no browser / shell association) must not fail a # run that already produced its artifacts. (Invoke-Item rather than a # process-start cmdlet: the read-only guard bans that verb across Private and # Public, and the guard stays unmodified.) ---- if ($Show -and (Test-Path -LiteralPath $htmlPath)) { try { Invoke-Item -LiteralPath $htmlPath -ErrorAction Stop } catch { Write-Warning ("-Show could not open the report ({0}). Open it manually: {1}" -f $_.Exception.Message, $htmlPath) } } return $result } finally { # Session teardown stays opt-in (F-007): nothing happens here unless the operator # passed -Disconnect - and then it runs even on a failed run. # Disconnect-PurviewPostureSession is best-effort by contract (it never throws), # so this finally can never mask the real error. if ($Disconnect) { Disconnect-PurviewPostureSession } } } |