modules/shared/Viewer.ps1
|
#Requires -Version 7.4 [CmdletBinding()] param () Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Dot-source the merged ReportManifest module so the viewer uses the canonical # Select-ReportArchitecture / Test-ReportArchitectureConfig contract from #456 # instead of the previous local stub (Phase 0 contract drift fix). $script:ReportManifestPath = Join-Path $PSScriptRoot 'ReportManifest.ps1' if (Test-Path -LiteralPath $script:ReportManifestPath) { . $script:ReportManifestPath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param ([string]$Text) return $Text } } if (-not (Get-Variable -Scope Script -Name AzureAnalyzerViewerState -ErrorAction SilentlyContinue)) { $script:AzureAnalyzerViewerState = [ordered]@{ IsRunning = $false Port = $null Address = '127.0.0.1' Token = $null Tier = $null Job = $null } } $script:MaxViewerEntityIdLength = 512 $script:ViewerTriageResponseMaxLength = 1000 $script:ViewerStartupTimeoutSeconds = 10 $script:ViewerHealthPollIntervalMs = 200 function Get-ViewerCollectionCount { [CmdletBinding()] param ( [AllowNull()] [object] $Collection ) if ($null -eq $Collection) { return 0 } if ($Collection -is [string]) { return 1 } if ($Collection -is [System.Collections.IEnumerable]) { return @($Collection).Count } return 1 } function Get-ViewerJsonCount { [CmdletBinding()] param ( [string] $Path, [string[]] $PropertyPreference, # When the JSON root is a bare array (e.g. v3.0 entities.json which is just an # array of entities), should the array length count for this axis? True for the # Entities/Findings axis, false for the Edges axis (bare array has no edges). [bool] $ArrayIsAxis = $true ) if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) { return 0 } try { $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($raw)) { return 0 } $json = $raw | ConvertFrom-Json -ErrorAction Stop } catch { return 0 } # ConvertFrom-Json yields Object[] for bare arrays and PSCustomObject for objects. if ($json -is [array]) { if ($ArrayIsAxis) { return (Get-ViewerCollectionCount -Collection $json) } return 0 } foreach ($prop in @($PropertyPreference)) { if ($json -and $json.PSObject.Properties[$prop]) { return (Get-ViewerCollectionCount -Collection $json.$prop) } } if ($ArrayIsAxis) { return (Get-ViewerCollectionCount -Collection $json) } return 0 } function Resolve-ViewerArchitecture { [CmdletBinding()] param ( [string] $FindingsPath, [string] $EntitiesPath, [double] $HeadroomFactor = 1.25, [int] $FindingCount = -1, [int] $EntityCount = -1, [int] $EdgeCount = -1, [object] $ArchitectureConfig ) $resolvedFindings = if ($FindingCount -ge 0) { $FindingCount } else { Get-ViewerJsonCount -Path $FindingsPath -PropertyPreference @('Findings', 'findings') -ArrayIsAxis:$true } $resolvedEntities = if ($EntityCount -ge 0) { $EntityCount } else { Get-ViewerJsonCount -Path $EntitiesPath -PropertyPreference @('Entities', 'entities') -ArrayIsAxis:$true } $resolvedEdges = if ($EdgeCount -ge 0) { $EdgeCount } else { # v3.0 bare-array entities.json has no edges; only v3.1 envelope objects with # an explicit Edges property report nonzero edges. Get-ViewerJsonCount -Path $EntitiesPath -PropertyPreference @('Edges', 'edges') -ArrayIsAxis:$false } $params = @{ FindingCount = [int]$resolvedFindings EntityCount = [int]$resolvedEntities EdgeCount = [int]$resolvedEdges HeadroomFactor = $HeadroomFactor } if ($ArchitectureConfig) { $params['ArchitectureConfig'] = $ArchitectureConfig } return Select-ReportArchitecture @params } function Test-LoopbackBind { [CmdletBinding()] param ([string] $Address) if ([string]::IsNullOrWhiteSpace($Address)) { return $false } $value = $Address.Trim().ToLowerInvariant() return $value -eq '127.0.0.1' -or $value -eq 'localhost' } function Test-HostHeader { [CmdletBinding()] param ( [string] $HostHeader, [ValidateRange(1, 65535)] [int] $Port ) if ([string]::IsNullOrWhiteSpace($HostHeader)) { return $false } $value = $HostHeader.Trim().ToLowerInvariant() return $value -eq "127.0.0.1:$Port" -or $value -eq "localhost:$Port" -or $value -eq '127.0.0.1' -or $value -eq 'localhost' } function Test-OriginHeader { [CmdletBinding()] param ( [AllowNull()] [string] $Origin, [ValidateRange(1, 65535)] [int] $Port ) if ([string]::IsNullOrWhiteSpace($Origin)) { return $true } try { $originUri = [uri]$Origin } catch { return $false } if ($originUri.Scheme -ne 'http') { return $false } if ($originUri.Port -ne $Port) { return $false } return $originUri.Host -eq '127.0.0.1' -or $originUri.Host -eq 'localhost' } function Test-CsrfToken { [CmdletBinding()] param ( [AllowNull()] [string] $ProvidedToken, [AllowNull()] [string] $ExpectedToken ) if ([string]::IsNullOrWhiteSpace($ProvidedToken) -or [string]::IsNullOrWhiteSpace($ExpectedToken)) { return $false } $providedBytes = [System.Text.Encoding]::UTF8.GetBytes($ProvidedToken) $expectedBytes = [System.Text.Encoding]::UTF8.GetBytes($ExpectedToken) $max = [Math]::Max($providedBytes.Length, $expectedBytes.Length) $diff = $providedBytes.Length -bxor $expectedBytes.Length for ($i = 0; $i -lt $max; $i++) { $p = if ($i -lt $providedBytes.Length) { $providedBytes[$i] } else { 0 } $e = if ($i -lt $expectedBytes.Length) { $expectedBytes[$i] } else { 0 } $diff = $diff -bor ($p -bxor $e) } return $diff -eq 0 } function Get-ViewerCookieValue { [CmdletBinding()] param ( [AllowNull()] [string] $CookieHeader, [Parameter(Mandatory)] [string] $Name ) if ([string]::IsNullOrWhiteSpace($CookieHeader)) { return $null } foreach ($pair in ($CookieHeader -split ';')) { $kv = $pair.Trim() if ($kv -match "^$([regex]::Escape($Name))=(.*)$") { return $matches[1] } } return $null } function Test-ViewerSessionAuth { [CmdletBinding()] param ( [AllowNull()] [string] $CookieHeader, [AllowNull()] [string] $TokenHeader, [Parameter(Mandatory)] [string] $ExpectedToken ) $cookieVal = Get-ViewerCookieValue -CookieHeader $CookieHeader -Name 'aa_session' if ($cookieVal -and (Test-CsrfToken -ProvidedToken $cookieVal -ExpectedToken $ExpectedToken)) { return $true } if ($TokenHeader -and (Test-CsrfToken -ProvidedToken $TokenHeader -ExpectedToken $ExpectedToken)) { return $true } return $false } function Test-EntityIdSafe { [CmdletBinding()] param ( [AllowNull()] [string] $EntityId ) if ([string]::IsNullOrWhiteSpace($EntityId)) { return $false } if ($EntityId.Length -gt $script:MaxViewerEntityIdLength) { return $false } if ($EntityId -match '(\.\.|[\r\n])') { return $false } return $EntityId -match '^[a-zA-Z0-9:/._\-]+$' } function Test-ViewerPortAvailable { [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $Address, [Parameter(Mandatory)] [ValidateRange(1, 65535)] [int] $Port ) try { $ip = [System.Net.IPAddress]::Loopback if ($Address -ne '127.0.0.1' -and $Address -ne 'localhost') { $ip = [System.Net.IPAddress]::Parse($Address) } $listener = [System.Net.Sockets.TcpListener]::new($ip, $Port) $listener.Start() $listener.Stop() return $true } catch { return $false } } function Wait-ViewerHealthReady { [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $HealthUrl, [int] $TimeoutSeconds = 10 ) $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) while ([datetime]::UtcNow -lt $deadline) { try { $resp = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop if ($resp.StatusCode -eq 200) { return $true } } catch { Start-Sleep -Milliseconds $script:ViewerHealthPollIntervalMs } } return $false } function Set-ViewerTokenFileAcl { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [string] $Path ) if (-not (Test-Path -LiteralPath $Path)) { return $false } if ($IsWindows) { if (-not $PSCmdlet.ShouldProcess($Path, 'Restrict ACL to current user only')) { return $false } try { $acl = New-Object System.Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow ) $acl.AddAccessRule($rule) Set-Acl -LiteralPath $Path -AclObject $acl return $true } catch { return $false } } if (-not $PSCmdlet.ShouldProcess($Path, 'chmod 600')) { return $false } try { & chmod 600 $Path 2>$null return ($LASTEXITCODE -eq 0) } catch { return $false } } function Start-AzureAnalyzerViewer { [CmdletBinding(SupportsShouldProcess)] param ( [string] $OutputPath = (Join-Path $PWD 'output'), [ValidateRange(1, 65535)] [int] $Port = 4280, [string] $BindAddress = '127.0.0.1' ) if (-not (Test-LoopbackBind -Address $BindAddress)) { throw "Viewer bind address must be loopback-only (127.0.0.1)." } if (-not $PSCmdlet.ShouldProcess("http://${BindAddress}:$Port/", 'Start azure-analyzer viewer')) { return $null } if ($script:AzureAnalyzerViewerState.Job) { $existing = $script:AzureAnalyzerViewerState.Job if ($existing.PSObject.Properties['State'] -and $existing.State -eq 'Running') { $existingUrl = "http://$($script:AzureAnalyzerViewerState.Address):$($script:AzureAnalyzerViewerState.Port)/" $existingToken = [string]$script:AzureAnalyzerViewerState.Token return [pscustomobject]@{ Url = $existingUrl AuthUrl = "${existingUrl}auth?t=$existingToken" HealthUrl = "http://$($script:AzureAnalyzerViewerState.Address):$($script:AzureAnalyzerViewerState.Port)/api/health" Token = $existingToken Tier = $script:AzureAnalyzerViewerState.Tier JobId = $existing.Id } } Remove-Job -Job $existing -Force -ErrorAction SilentlyContinue $script:AzureAnalyzerViewerState.Job = $null } if (-not (Test-ViewerPortAvailable -Address $BindAddress -Port $Port)) { throw "Viewer port $Port on $BindAddress is already in use. Choose a different -ViewerPort." } if (-not (Get-Command Start-PodeServer -ErrorAction SilentlyContinue)) { try { Import-Module Pode -ErrorAction Stop } catch { throw "Pode module is required but not found. In interactive environments, run: Install-Module Pode -Scope CurrentUser. In CI or restricted environments, pre-install Pode in the runner image." } } $findingsPath = Join-Path $OutputPath 'results.json' $entitiesPath = Join-Path $OutputPath 'entities.json' $triagePath = Join-Path $OutputPath 'triage.json' $arch = Resolve-ViewerArchitecture -FindingsPath $findingsPath -EntitiesPath $entitiesPath $token = [Guid]::NewGuid().ToString('N') $modulePath = $PSCommandPath $archJson = $arch | ConvertTo-Json -Depth 8 -Compress $viewerJob = Start-Job -Name "azure-analyzer-viewer-$Port" -ScriptBlock { param ($ModulePath, $BindAddress, $Port, $Token, $ArchitectureJson, $TriagePath) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . $ModulePath Import-Module Pode -ErrorAction Stop $architecture = $ArchitectureJson | ConvertFrom-Json -ErrorAction Stop Start-PodeServer -Threads 1 -ScriptBlock { Add-PodeEndpoint -Address $using:BindAddress -Port $using:Port -Protocol Http Add-PodeRoute -Method Get -Path '/api/health' -ScriptBlock { $hostHeader = [string]$WebEvent.Request.Headers['Host'] if (-not (Test-HostHeader -HostHeader $hostHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 400 Write-PodeJsonResponse -Value @{ error = 'invalid_host' } return } Write-PodeJsonResponse -StatusCode 200 -Value @{ status = 'ok' tier = $using:architecture.Tier } } Add-PodeRoute -Method Get -Path '/auth' -ScriptBlock { $hostHeader = [string]$WebEvent.Request.Headers['Host'] if (-not (Test-HostHeader -HostHeader $hostHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 400 Write-PodeJsonResponse -Value @{ error = 'invalid_host' } return } $providedToken = [string]$WebEvent.Query['t'] if (-not (Test-CsrfToken -ProvidedToken $providedToken -ExpectedToken $using:Token)) { Set-PodeResponseStatus -Code 403 Write-PodeJsonResponse -Value @{ error = 'invalid_token' } return } # Set HttpOnly, SameSite=Strict, Path=/ session cookie. Browsers attach it # automatically on subsequent same-origin navigations and fetches, removing # the need for the user to set X-Session-Token by hand. $cookieValue = "aa_session=$using:Token; HttpOnly; SameSite=Strict; Path=/" Add-PodeHeader -Name 'Set-Cookie' -Value $cookieValue Move-PodeResponseUrl -Url '/' } Add-PodeRoute -Method Get -Path '/api/triage' -ScriptBlock { $hostHeader = [string]$WebEvent.Request.Headers['Host'] if (-not (Test-HostHeader -HostHeader $hostHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 400 Write-PodeJsonResponse -Value @{ error = 'invalid_host' } return } $originHeader = [string]$WebEvent.Request.Headers['Origin'] if (-not (Test-OriginHeader -Origin $originHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 403 Write-PodeJsonResponse -Value @{ error = 'invalid_origin' } return } $cookieHeader = [string]$WebEvent.Request.Headers['Cookie'] $tokenHeader = [string]$WebEvent.Request.Headers['X-Session-Token'] if (-not (Test-ViewerSessionAuth -CookieHeader $cookieHeader -TokenHeader $tokenHeader -ExpectedToken $using:Token)) { Set-PodeResponseStatus -Code 403 Write-PodeJsonResponse -Value @{ error = 'invalid_token' } return } $payload = $null if (Test-Path -LiteralPath $using:TriagePath) { try { $payload = Get-Content -LiteralPath $using:TriagePath -Raw -Encoding utf8 | ConvertFrom-Json -Depth 20 } catch { $payload = [pscustomobject]@{ error = 'triage_parse_failed' } } } Write-PodeJsonResponse -StatusCode 200 -Value @{ hasTriage = ($null -ne $payload) triage = $payload } } Add-PodeRoute -Method Get -Path '/' -ScriptBlock { $hostHeader = [string]$WebEvent.Request.Headers['Host'] if (-not (Test-HostHeader -HostHeader $hostHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 400 Write-PodeJsonResponse -Value @{ error = 'invalid_host' } return } $originHeader = [string]$WebEvent.Request.Headers['Origin'] if (-not (Test-OriginHeader -Origin $originHeader -Port $using:Port)) { Set-PodeResponseStatus -Code 403 Write-PodeJsonResponse -Value @{ error = 'invalid_origin' } return } $cookieHeader = [string]$WebEvent.Request.Headers['Cookie'] $tokenHeader = [string]$WebEvent.Request.Headers['X-Session-Token'] if (-not (Test-ViewerSessionAuth -CookieHeader $cookieHeader -TokenHeader $tokenHeader -ExpectedToken $using:Token)) { Set-PodeResponseStatus -Code 403 Write-PodeJsonResponse -Value @{ error = 'invalid_token' } return } $triageHtml = "<p>No triage data for this run.</p>" if (Test-Path -LiteralPath $using:TriagePath) { try { $triage = Get-Content -LiteralPath $using:TriagePath -Raw -Encoding utf8 | ConvertFrom-Json -Depth 20 $mode = if ($triage.PSObject.Properties['Mode']) { [string]$triage.Mode } else { 'Unknown' } $models = if ($triage.PSObject.Properties['SelectedModels']) { (@($triage.SelectedModels) -join ', ') } else { '' } $response = if ($triage.PSObject.Properties['Response']) { [string]$triage.Response } else { '' } if ($response.Length -gt $script:ViewerTriageResponseMaxLength) { $response = $response.Substring(0, $script:ViewerTriageResponseMaxLength) + '...[TRUNCATED]' } $safeMode = (Remove-Credentials $mode).Replace('&', '&').Replace('<', '<').Replace('>', '>') $safeModels = (Remove-Credentials $models).Replace('&', '&').Replace('<', '<').Replace('>', '>') $safeResponse = (Remove-Credentials $response).Replace('&', '&').Replace('<', '<').Replace('>', '>') $triageHtml = "<p><strong>Mode:</strong> $safeMode<br><strong>Models:</strong> $safeModels</p><pre>$safeResponse</pre>" } catch { $triageHtml = '<p>Triage output present but could not be parsed.</p>' } } $html = @" <!doctype html> <html lang="en"> <head><meta charset="utf-8"><title>azure-analyzer viewer</title></head> <body><h1>azure-analyzer findings viewer</h1><p>Tier: $($using:architecture.Tier)</p><section id='triage-panel'><h2>Triage</h2>$triageHtml</section></body> </html> "@ Write-PodeHtmlResponse -Value $html } } } -ArgumentList $modulePath, $BindAddress, $Port, $token, $archJson, $triagePath if ($viewerJob -is [System.Management.Automation.Job]) { $deadline = [datetime]::UtcNow.AddSeconds($script:ViewerStartupTimeoutSeconds) while ($viewerJob.State -eq 'NotStarted') { if ([datetime]::UtcNow -ge $deadline) { break } Start-Sleep -Milliseconds 200 $viewerJob = Get-Job -Id $viewerJob.Id -ErrorAction SilentlyContinue if (-not $viewerJob) { break } } } if ($null -ne $viewerJob -and $viewerJob.PSObject.Properties['State'] -and $viewerJob.State -eq 'Failed') { $jobError = (Receive-Job -Job $viewerJob -Keep -ErrorAction SilentlyContinue | Out-String).Trim() Remove-Job -Job $viewerJob -Force -ErrorAction SilentlyContinue throw (Remove-Credentials "Viewer failed to start: $jobError") } $healthUrl = "http://${BindAddress}:$Port/api/health" if ($viewerJob -is [System.Management.Automation.Job]) { if (-not (Wait-ViewerHealthReady -HealthUrl $healthUrl -TimeoutSeconds $script:ViewerStartupTimeoutSeconds)) { $jobError = (Receive-Job -Job $viewerJob -Keep -ErrorAction SilentlyContinue | Out-String).Trim() Stop-Job -Job $viewerJob -ErrorAction SilentlyContinue Remove-Job -Job $viewerJob -Force -ErrorAction SilentlyContinue throw (Remove-Credentials "Viewer did not become ready within $($script:ViewerStartupTimeoutSeconds)s: $jobError") } } $script:AzureAnalyzerViewerState.IsRunning = $true $script:AzureAnalyzerViewerState.Port = $Port $script:AzureAnalyzerViewerState.Address = $BindAddress $script:AzureAnalyzerViewerState.Token = $token $script:AzureAnalyzerViewerState.Tier = $arch.Tier $script:AzureAnalyzerViewerState.Job = $viewerJob $rootUrl = "http://${BindAddress}:$Port/" return [pscustomobject]@{ Url = $rootUrl AuthUrl = "http://${BindAddress}:$Port/auth?t=$token" HealthUrl = $healthUrl Token = $token Tier = $arch.Tier JobId = if ($viewerJob -and $viewerJob.PSObject.Properties['Id']) { $viewerJob.Id } else { $null } } } function Stop-AzureAnalyzerViewer { [CmdletBinding(SupportsShouldProcess)] param () if (-not $script:AzureAnalyzerViewerState.Job) { $script:AzureAnalyzerViewerState.IsRunning = $false return $false } if (-not $PSCmdlet.ShouldProcess('azure-analyzer viewer', 'Stop background job and clear state')) { return $false } $job = $script:AzureAnalyzerViewerState.Job $jobId = if ($job.PSObject.Properties['Id']) { [int]$job.Id } else { $null } $isTypedJob = $job -is [System.Management.Automation.Job] if ($job.PSObject.Properties['State'] -and $job.State -eq 'Running') { if ($isTypedJob) { Stop-Job -Job $job -ErrorAction SilentlyContinue } elseif ($null -ne $jobId) { Stop-Job -Id $jobId -ErrorAction SilentlyContinue } } if ($isTypedJob) { Receive-Job -Job $job -ErrorAction SilentlyContinue | Out-Null Remove-Job -Job $job -Force -ErrorAction SilentlyContinue } elseif ($null -ne $jobId) { Receive-Job -Id $jobId -ErrorAction SilentlyContinue | Out-Null Remove-Job -Id $jobId -Force -ErrorAction SilentlyContinue } $script:AzureAnalyzerViewerState.IsRunning = $false $script:AzureAnalyzerViewerState.Port = $null $script:AzureAnalyzerViewerState.Token = $null $script:AzureAnalyzerViewerState.Tier = $null $script:AzureAnalyzerViewerState.Job = $null return $true } |